From 80981e9063453bd3825b841d0496a87ce758fe87 Mon Sep 17 00:00:00 2001 From: Milton Gonzalez Date: Tue, 15 Sep 2026 12:41:19 -0500 Subject: [PATCH] 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 --- API.md | 4 ++ BASE_DE_DATOS.md | 4 ++ CHANGELOG.md | 8 +++ VERSION | 2 +- .../database/migrations/012_sort_order.sql | 8 +++ backend/package.json | 2 +- backend/src/modules/activities/controller.js | 4 ++ backend/src/modules/activities/repository.js | 12 ++++- backend/src/modules/activities/service.js | 11 ++++ backend/src/modules/projects/routes.js | 3 +- backend/src/modules/tasks/controller.js | 4 ++ backend/src/modules/tasks/repository.js | 12 ++++- backend/src/modules/tasks/routes.js | 3 +- backend/src/modules/tasks/service.js | 8 +++ frontend/package.json | 2 +- frontend/src/components/ListView.jsx | 29 +++++++++-- frontend/src/hooks/useDragReorder.js | 51 +++++++++++++++++++ .../pages/Proyectos/ActivityDetailPanel.jsx | 21 ++++++-- .../pages/Proyectos/ProjectActivitiesTab.jsx | 8 ++- frontend/src/services/activities.js | 1 + frontend/src/services/tasks.js | 1 + frontend/src/styles/app.css | 5 ++ 22 files changed, 183 insertions(+), 20 deletions(-) create mode 100644 backend/database/migrations/012_sort_order.sql create mode 100644 frontend/src/hooks/useDragReorder.js diff --git a/API.md b/API.md index cb550f6..5954173 100644 --- a/API.md +++ b/API.md @@ -75,9 +75,13 @@ eliminarlas primero); limpia comentarios/adjuntos de la actividad de paso (no hay FK física cruzada). Arrastra `activity_dependencies` por `ON DELETE CASCADE`. - `POST /api/activities/:id/dependencies` · `DELETE /api/activities/:id/dependencies/:dependsOnId` +- `PUT /api/projects/:projectId/activities/reorder` — `{ ids: [...] }` (orden manual, arrastrar + con el mouse); posiciones 1..N, nunca 0 (0 = "nunca reordenado a mano", ver `sort_order`). ### Tareas (`/api/activities/:activityId/tasks`) - `GET` · `POST` · `PUT /api/tasks/:id` · `PATCH /api/tasks/:id/done` · `DELETE /api/tasks/:id` +- `PUT /api/activities/:activityId/tasks/reorder` — `{ ids: [...] }` (mismo mecanismo de orden + manual que las actividades). ### Tareas recurrentes (`/api/recurring-tasks`) — menú propio, independiente de Proyectos - `GET` · `POST` · `PUT /api/recurring-tasks/:id` · `DELETE /api/recurring-tasks/:id` diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md index db33964..59d67ac 100644 --- a/BASE_DE_DATOS.md +++ b/BASE_DE_DATOS.md @@ -80,6 +80,10 @@ proceso del backend (`setInterval` cada 15 min, sin cron del SO ni cola nueva). - `system_settings.value` para `smtp_pass_enc` va **cifrado** (AES-256-GCM, `ENCRYPTION_KEY` del `.env`) — nunca en texto plano, nunca se devuelve por la API (solo `configured: true/false`). +- `activities.sort_order`/`tasks.sort_order`: orden manual (arrastrar con el mouse en la UI). `0` = + nunca reordenado a mano (la migración `012` lo inicializa con el `id` para preservar el orden + previo); el endpoint de reordenar siempre escribe posiciones `1..N`, nunca `0`, para que esa + migración siga siendo idempotente en deploys futuros sin pisar un orden real ya guardado. - Borrado lógico donde aplique (`users.is_active`); nunca DELETE físico de usuarios **activos** con historial (los `pending_approval` rechazados sí se borran, ver arriba). - `projects.deleted_at`/`deleted_by`: eliminación lógica (quien puede editar el proyecto puede diff --git a/CHANGELOG.md b/CHANGELOG.md index d574e81..53acc85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog - project.linkedcorp.net +## [1.14.0] - 2026-09-15 +### Added +- Arrastrar con el mouse para reordenar: actividades (dentro de un proyecto) y tareas (dentro de + una actividad), ambas con el mismo mecanismo — un hook reutilizable (`useDragReorder`, drag & + drop nativo, sin librería nueva) usado tanto en `ListView` (opt-in vía `onReorder`, deshabilitado + mientras haya un ordenamiento por columna activo) como en el listado de tareas. Orden persistido + (`sort_order` en `activities`/`tasks`, migración `012`, posiciones 1..N). + ## [1.13.0] - 2026-09-15 ### Added - Eliminar actividad: `DELETE /api/activities/:id` (no existía) + botón "Eliminar" en el listado diff --git a/VERSION b/VERSION index feaae22..850e742 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.13.0 +1.14.0 diff --git a/backend/database/migrations/012_sort_order.sql b/backend/database/migrations/012_sort_order.sql new file mode 100644 index 0000000..5fcf05c --- /dev/null +++ b/backend/database/migrations/012_sort_order.sql @@ -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; diff --git a/backend/package.json b/backend/package.json index eb35df3..96a71d6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "1.13.0", + "version": "1.14.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/modules/activities/controller.js b/backend/src/modules/activities/controller.js index 6e73305..ae961a3 100644 --- a/backend/src/modules/activities/controller.js +++ b/backend/src/modules/activities/controller.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))); }); diff --git a/backend/src/modules/activities/repository.js b/backend/src/modules/activities/repository.js index b1bb267..4a22520 100644 --- a/backend/src/modules/activities/repository.js +++ b/backend/src/modules/activities/repository.js @@ -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; diff --git a/backend/src/modules/activities/service.js b/backend/src/modules/activities/service.js index 3ddb3e3..6a46404 100644 --- a/backend/src/modules/activities/service.js +++ b/backend/src/modules/activities/service.js @@ -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); diff --git a/backend/src/modules/projects/routes.js b/backend/src/modules/projects/routes.js index 0099873..1df4411 100644 --- a/backend/src/modules/projects/routes.js +++ b/backend/src/modules/projects/routes.js @@ -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; diff --git a/backend/src/modules/tasks/controller.js b/backend/src/modules/tasks/controller.js index ae3a0e0..b3c0c5f 100644 --- a/backend/src/modules/tasks/controller.js +++ b/backend/src/modules/tasks/controller.js @@ -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)); }); diff --git a/backend/src/modules/tasks/repository.js b/backend/src/modules/tasks/repository.js index a067229..72a3e31 100644 --- a/backend/src/modules/tasks/repository.js +++ b/backend/src/modules/tasks/repository.js @@ -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; diff --git a/backend/src/modules/tasks/routes.js b/backend/src/modules/tasks/routes.js index c33df7e..5a4c768 100644 --- a/backend/src/modules/tasks/routes.js +++ b/backend/src/modules/tasks/routes.js @@ -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); diff --git a/backend/src/modules/tasks/service.js b/backend/src/modules/tasks/service.js index 1dc8b60..7c21644 100644 --- a/backend/src/modules/tasks/service.js +++ b/backend/src/modules/tasks/service.js @@ -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'); diff --git a/frontend/package.json b/frontend/package.json index 8182d80..67bf70f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "1.13.0", + "version": "1.14.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/ListView.jsx b/frontend/src/components/ListView.jsx index 5c00a30..c47a296 100644 --- a/frontend/src/components/ListView.jsx +++ b/frontend/src/components/ListView.jsx @@ -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 + {dragEnabled && - {sorted.map((row) => ( - onRowClick?.(row)}> + {displayRows.map((row) => ( + onRowClick?.(row)} + className={dragEnabled && isDragging(getRowKey(row)) ? 'dragging' : ''} + {...(dragEnabled ? getDragHandlers(row) : {})} + > + {dragEnabled && ( + + )} {columns.map((col) => ( ))} @@ -66,7 +85,7 @@ export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowC ))}
} {columns.map((col) => ( 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
e.stopPropagation()}> + + {col.render ? col.render(row) : row[col.key]}
- {sorted.length === 0 &&
{t('common_empty')}
} + {displayRows.length === 0 &&
{t('common_empty')}
} ); } diff --git a/frontend/src/hooks/useDragReorder.js b/frontend/src/hooks/useDragReorder.js new file mode 100644 index 0000000..aa7f7bf --- /dev/null +++ b/frontend/src/hooks/useDragReorder.js @@ -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 }; +} diff --git a/frontend/src/pages/Proyectos/ActivityDetailPanel.jsx b/frontend/src/pages/Proyectos/ActivityDetailPanel.jsx index eb174f4..0f73ac2 100644 --- a/frontend/src/pages/Proyectos/ActivityDetailPanel.jsx +++ b/frontend/src/pages/Proyectos/ActivityDetailPanel.jsx @@ -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' && (
- {(tasks || []).map((task) => ( -
+ {orderedTasks.map((task) => ( +
{editingTaskId === task.id ? ( <> ) : ( <> + toggleTask(task)} /> {task.name} @@ -251,7 +262,7 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi )}
))} - {(!tasks || tasks.length === 0) &&
{t('common_empty')}
} + {orderedTasks.length === 0 &&
{t('common_empty')}
}
setNewTask(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addTaskItem()} /> diff --git a/frontend/src/pages/Proyectos/ProjectActivitiesTab.jsx b/frontend/src/pages/Proyectos/ProjectActivitiesTab.jsx index 23a74cf..a7d0df9 100644 --- a/frontend/src/pages/Proyectos/ProjectActivitiesTab.jsx +++ b/frontend/src/pages/Proyectos/ProjectActivitiesTab.jsx @@ -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} diff --git a/frontend/src/services/activities.js b/frontend/src/services/activities.js index f658116..7cc2467 100644 --- a/frontend/src/services/activities.js +++ b/frontend/src/services/activities.js @@ -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}`); diff --git a/frontend/src/services/tasks.js b/frontend/src/services/tasks.js index 8984aba..6ad0506 100644 --- a/frontend/src/services/tasks.js +++ b/frontend/src/services/tasks.js @@ -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 }); diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index b10454b..fcd2f7f 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -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;