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:
@@ -75,9 +75,13 @@
|
|||||||
eliminarlas primero); limpia comentarios/adjuntos de la actividad de paso (no hay FK física
|
eliminarlas primero); limpia comentarios/adjuntos de la actividad de paso (no hay FK física
|
||||||
cruzada). Arrastra `activity_dependencies` por `ON DELETE CASCADE`.
|
cruzada). Arrastra `activity_dependencies` por `ON DELETE CASCADE`.
|
||||||
- `POST /api/activities/:id/dependencies` · `DELETE /api/activities/:id/dependencies/:dependsOnId`
|
- `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`)
|
### Tareas (`/api/activities/:activityId/tasks`)
|
||||||
- `GET` · `POST` · `PUT /api/tasks/:id` · `PATCH /api/tasks/:id/done` · `DELETE /api/tasks/:id`
|
- `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
|
### Tareas recurrentes (`/api/recurring-tasks`) — menú propio, independiente de Proyectos
|
||||||
- `GET` · `POST` · `PUT /api/recurring-tasks/:id` · `DELETE /api/recurring-tasks/:id`
|
- `GET` · `POST` · `PUT /api/recurring-tasks/:id` · `DELETE /api/recurring-tasks/:id`
|
||||||
|
|||||||
@@ -80,6 +80,10 @@
|
|||||||
proceso del backend (`setInterval` cada 15 min, sin cron del SO ni cola nueva).
|
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
|
- `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`).
|
`.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
|
- 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).
|
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
|
- `projects.deleted_at`/`deleted_by`: eliminación lógica (quien puede editar el proyecto puede
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# Changelog - project.linkedcorp.net
|
# 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
|
## [1.13.0] - 2026-09-15
|
||||||
### Added
|
### Added
|
||||||
- Eliminar actividad: `DELETE /api/activities/:id` (no existía) + botón "Eliminar" en el listado
|
- Eliminar actividad: `DELETE /api/activities/:id` (no existía) + botón "Eliminar" en el listado
|
||||||
|
|||||||
@@ -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",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "1.13.0",
|
"version": "1.14.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/server.js",
|
"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);
|
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) => {
|
export const getActivity = asyncHandler(async (req, res) => {
|
||||||
ok(res, await service.getActivity(Number(req.params.id)));
|
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.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.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.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
|
FROM activities a
|
||||||
JOIN priorities pr ON pr.id = a.priority_id
|
JOIN priorities pr ON pr.id = a.priority_id
|
||||||
JOIN activity_statuses st ON st.id = a.status_id
|
JOIN activity_statuses st ON st.id = a.status_id
|
||||||
@@ -13,7 +13,7 @@ const LIST_SELECT = `
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export async function findByProject(projectId) {
|
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;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +64,14 @@ export async function remove(id) {
|
|||||||
await pool.query('DELETE FROM activities WHERE id = ?', [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) {
|
export async function countTasks(id) {
|
||||||
const [rows] = await pool.query('SELECT COUNT(*) AS c FROM tasks WHERE activity_id = ?', [id]);
|
const [rows] = await pool.query('SELECT COUNT(*) AS c FROM tasks WHERE activity_id = ?', [id]);
|
||||||
return rows[0].c;
|
return rows[0].c;
|
||||||
|
|||||||
@@ -118,6 +118,17 @@ export async function removeActivity(id, currentUser) {
|
|||||||
await recalcProjectProgress(activity.project_id);
|
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) {
|
export async function addDependency(activityId, dependsOnActivityId, currentUser) {
|
||||||
if (activityId === dependsOnActivityId) throw new HttpError(400, 'Una actividad no puede depender de si misma');
|
if (activityId === dependsOnActivityId) throw new HttpError(400, 'Una actividad no puede depender de si misma');
|
||||||
const activity = await repo.findById(activityId);
|
const activity = await repo.findById(activityId);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
deleteProjectPermanent, postMember, putMemberRole, deleteMember,
|
deleteProjectPermanent, postMember, putMemberRole, deleteMember,
|
||||||
} from './controller.js';
|
} from './controller.js';
|
||||||
import { getGantt } from '../dashboard/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();
|
const router = Router();
|
||||||
|
|
||||||
@@ -23,5 +23,6 @@ router.put('/:id/members/:userId/role', putMemberRole);
|
|||||||
router.delete('/:id/members/:userId', deleteMember);
|
router.delete('/:id/members/:userId', deleteMember);
|
||||||
router.get('/:projectId/activities', getActivitiesByProject);
|
router.get('/:projectId/activities', getActivitiesByProject);
|
||||||
router.post('/:projectId/activities', postActivity);
|
router.post('/:projectId/activities', postActivity);
|
||||||
|
router.put('/:projectId/activities/reorder', putReorderActivities);
|
||||||
|
|
||||||
export default router;
|
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));
|
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) => {
|
export const patchTaskDone = asyncHandler(async (req, res) => {
|
||||||
ok(res, await service.setTaskDone(Number(req.params.id), !!req.body.isDone, req.currentUser));
|
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) {
|
export async function findByActivity(activityId) {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT t.id, t.activity_id, t.name, t.responsible_user_id, u.name AS responsible_name,
|
`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
|
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]
|
[activityId]
|
||||||
);
|
);
|
||||||
return rows;
|
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) {
|
export async function findById(id) {
|
||||||
const [rows] = await pool.query('SELECT * FROM tasks WHERE id = ?', [id]);
|
const [rows] = await pool.query('SELECT * FROM tasks WHERE id = ?', [id]);
|
||||||
return rows[0] || null;
|
return rows[0] || null;
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { requireAuth } from '../../middleware/auth.js';
|
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();
|
const router = Router();
|
||||||
|
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
router.get('/activities/:activityId/tasks', getTasks);
|
router.get('/activities/:activityId/tasks', getTasks);
|
||||||
router.post('/activities/:activityId/tasks', postTask);
|
router.post('/activities/:activityId/tasks', postTask);
|
||||||
|
router.put('/activities/:activityId/tasks/reorder', putReorderTasks);
|
||||||
router.put('/tasks/:id', putTask);
|
router.put('/tasks/:id', putTask);
|
||||||
router.patch('/tasks/:id/done', patchTaskDone);
|
router.patch('/tasks/:id/done', patchTaskDone);
|
||||||
router.delete('/tasks/:id', deleteTask);
|
router.delete('/tasks/:id', deleteTask);
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ export async function setTaskDone(id, isDone, currentUser) {
|
|||||||
return updated;
|
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) {
|
export async function removeTask(id, currentUser) {
|
||||||
const task = await repo.findById(id);
|
const task = await repo.findById(id);
|
||||||
if (!task) throw new HttpError(404, 'Tarea no encontrada');
|
if (!task) throw new HttpError(404, 'Tarea no encontrada');
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.13.0",
|
"version": "1.14.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { MoreVertical } from 'lucide-react';
|
import { MoreVertical, GripVertical } from 'lucide-react';
|
||||||
import { useUi } from '../context/ui-context.jsx';
|
import { useUi } from '../context/ui-context.jsx';
|
||||||
|
import { useDragReorder } from '../hooks/useDragReorder.js';
|
||||||
import { Flyout, FlyoutItem } from './Flyout.jsx';
|
import { Flyout, FlyoutItem } from './Flyout.jsx';
|
||||||
|
|
||||||
// ListView/DataGrid: columnas ordenables, buscador ligero sobre el haystack completo, clic en
|
// 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).
|
// 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 { t } = useUi();
|
||||||
const [sort, setSort] = useState(initialSort || { key: null, dir: 1 });
|
const [sort, setSort] = useState(initialSort || { key: null, dir: 1 });
|
||||||
|
|
||||||
@@ -26,6 +30,10 @@ export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowC
|
|||||||
});
|
});
|
||||||
}, [filtered, sort, columns]);
|
}, [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) {
|
function toggleSort(key) {
|
||||||
setSort((prev) => (prev.key === key ? { key, dir: prev.dir * -1 } : { key, dir: 1 }));
|
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">
|
<table className="list-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
{dragEnabled && <th style={{ width: 28 }} />}
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<th key={col.key} onClick={() => col.sortable !== false && toggleSort(col.key)}>
|
<th key={col.key} onClick={() => col.sortable !== false && toggleSort(col.key)}>
|
||||||
{col.label}{sort.key === col.key ? (sort.dir === 1 ? ' ▲' : ' ▼') : ''}
|
{col.label}{sort.key === col.key ? (sort.dir === 1 ? ' ▲' : ' ▼') : ''}
|
||||||
@@ -44,8 +53,18 @@ export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowC
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{sorted.map((row) => (
|
{displayRows.map((row) => (
|
||||||
<tr key={getRowKey(row)} onClick={() => onRowClick?.(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) => (
|
{columns.map((col) => (
|
||||||
<td key={col.key}>{col.render ? col.render(row) : row[col.key]}</td>
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{sorted.length === 0 && <div className="list-empty">{t('common_empty')}</div>}
|
{displayRows.length === 0 && <div className="list-empty">{t('common_empty')}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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 { useUi } from '../../context/ui-context.jsx';
|
||||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||||
|
import { useDragReorder } from '../../hooks/useDragReorder.js';
|
||||||
import { updateActivity, addDependency, removeDependency } from '../../services/activities.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 { promoteTaskToRecurring } from '../../services/recurringTasks.js';
|
||||||
import { listCatalog } from '../../services/catalogs.js';
|
import { listCatalog } from '../../services/catalogs.js';
|
||||||
import { listUsers } from '../../services/users.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: statuses } = useAsync(() => listCatalog('activity-statuses'), []);
|
||||||
const { data: users } = useAsync(listUsers, []);
|
const { data: users } = useAsync(listUsers, []);
|
||||||
const { data: tasks, reload: reloadTasks } = useAsync(() => listTasks(activity.id), [activity.id]);
|
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 [tab, setTab] = useState('detail');
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
@@ -228,8 +233,13 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
|
|||||||
|
|
||||||
{tab === 'tasks' && (
|
{tab === 'tasks' && (
|
||||||
<div>
|
<div>
|
||||||
{(tasks || []).map((task) => (
|
{orderedTasks.map((task) => (
|
||||||
<div key={task.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 0' }}>
|
<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 ? (
|
{editingTaskId === task.id ? (
|
||||||
<>
|
<>
|
||||||
<input
|
<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)} />
|
<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>
|
<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>
|
<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>
|
</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 }}>
|
<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()} />
|
<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>
|
<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 { useUi } from '../../context/ui-context.jsx';
|
||||||
import { useAuth } from '../../context/auth-context.jsx';
|
import { useAuth } from '../../context/auth-context.jsx';
|
||||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
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 { listCatalog } from '../../services/catalogs.js';
|
||||||
import { listUsers } from '../../services/users.js';
|
import { listUsers } from '../../services/users.js';
|
||||||
import { ListView } from '../../components/ListView.jsx';
|
import { ListView } from '../../components/ListView.jsx';
|
||||||
@@ -57,6 +57,11 @@ export default function ProjectActivitiesTab({ projectId }) {
|
|||||||
if (selected) setSelected(await getActivity(selected.id));
|
if (selected) setSelected(await getActivity(selected.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleReorder(ids) {
|
||||||
|
await reorderActivities(projectId, ids);
|
||||||
|
await reload();
|
||||||
|
}
|
||||||
|
|
||||||
function confirmDelete(row) {
|
function confirmDelete(row) {
|
||||||
openDialog({
|
openDialog({
|
||||||
tone: 'warning',
|
tone: 'warning',
|
||||||
@@ -107,6 +112,7 @@ export default function ProjectActivitiesTab({ projectId }) {
|
|||||||
getSearchHaystack={(a) => `${a.code} ${a.name} ${a.responsible_name || ''}`}
|
getSearchHaystack={(a) => `${a.code} ${a.name} ${a.responsible_name || ''}`}
|
||||||
getRowKey={(a) => a.id}
|
getRowKey={(a) => a.id}
|
||||||
onRowClick={openActivity}
|
onRowClick={openActivity}
|
||||||
|
onReorder={currentUser?.roleCode !== 'viewer' ? handleReorder : undefined}
|
||||||
rowActions={currentUser?.roleCode !== 'viewer' ? (a) => [
|
rowActions={currentUser?.roleCode !== 'viewer' ? (a) => [
|
||||||
{ label: t('common_delete'), icon: Trash2, danger: true, onClick: () => confirmDelete(a) },
|
{ label: t('common_delete'), icon: Trash2, danger: true, onClick: () => confirmDelete(a) },
|
||||||
] : undefined}
|
] : undefined}
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ export const createActivity = (projectId, data) => api.post(`/projects/${project
|
|||||||
export const getActivity = (id) => api.get(`/activities/${id}`);
|
export const getActivity = (id) => api.get(`/activities/${id}`);
|
||||||
export const updateActivity = (id, data) => api.put(`/activities/${id}`, data);
|
export const updateActivity = (id, data) => api.put(`/activities/${id}`, data);
|
||||||
export const deleteActivity = (id) => api.delete(`/activities/${id}`);
|
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 addDependency = (id, dependsOnActivityId) => api.post(`/activities/${id}/dependencies`, { dependsOnActivityId });
|
||||||
export const removeDependency = (id, dependsOnId) => api.delete(`/activities/${id}/dependencies/${dependsOnId}`);
|
export const removeDependency = (id, dependsOnId) => api.delete(`/activities/${id}/dependencies/${dependsOnId}`);
|
||||||
|
|||||||
@@ -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 updateTask = (id, data) => api.put(`/tasks/${id}`, data);
|
||||||
export const setTaskDone = (id, isDone) => api.patch(`/tasks/${id}/done`, { isDone });
|
export const setTaskDone = (id, isDone) => api.patch(`/tasks/${id}/done`, { isDone });
|
||||||
export const deleteTask = (id) => api.delete(`/tasks/${id}`);
|
export const deleteTask = (id) => api.delete(`/tasks/${id}`);
|
||||||
|
export const reorderTasks = (activityId, ids) => api.put(`/activities/${activityId}/tasks/reorder`, { ids });
|
||||||
|
|||||||
@@ -155,6 +155,11 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
|
|||||||
.row-menu-btn { opacity: .6; }
|
.row-menu-btn { opacity: .6; }
|
||||||
.row-menu-btn:hover { opacity: 1; }
|
.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 --- */
|
/* --- Badges --- */
|
||||||
.badge {
|
.badge {
|
||||||
display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px; border-radius: 999px;
|
display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px; border-radius: 999px;
|
||||||
|
|||||||
Reference in New Issue
Block a user