From bb6bd6865c7b9cfaeed78d9c249d10b67c109172 Mon Sep 17 00:00:00 2001 From: Milton Gonzalez Date: Thu, 10 Sep 2026 08:55:58 -0500 Subject: [PATCH] refactor: renombra Subtareas a Tareas en todo el sistema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Actividad -> Tarea, sin nivel "sub" intermedio (no hay una entidad "Tarea" de la que estas fueran sub-, asi que el prefijo sobraba). Tabla subtasks -> tasks (RENAME TABLE idempotente en la migracion 005; 001_initial.sql tambien actualizado para que una instalacion nueva cree la tabla ya con el nombre final, sin pasar por el nombre viejo). Modulo backend, rutas, servicio frontend, pestaña del detalle de actividad, i18n y documentacion actualizados. Sin cambios de comportamiento, solo nombres. Co-Authored-By: Claude Sonnet 5 --- AGENTS.md | 4 +- API.md | 4 +- BASE_DE_DATOS.md | 8 +-- CHANGELOG.md | 9 +++ README.md | 2 +- SPEC.md | 10 ++-- VERSION | 2 +- backend/database/migrations/001_initial.sql | 6 +- .../005_rename_subtasks_to_tasks.sql | 10 ++++ backend/package.json | 2 +- backend/src/app.js | 4 +- backend/src/modules/activities/repository.js | 4 +- backend/src/modules/activities/service.js | 10 ++-- backend/src/modules/subtasks/controller.js | 23 -------- backend/src/modules/subtasks/routes.js | 14 ----- backend/src/modules/subtasks/service.js | 59 ------------------- backend/src/modules/tasks/controller.js | 23 ++++++++ .../modules/{subtasks => tasks}/repository.js | 20 +++---- backend/src/modules/tasks/routes.js | 14 +++++ backend/src/modules/tasks/service.js | 59 +++++++++++++++++++ frontend/package.json | 2 +- frontend/src/i18n/messages.js | 12 ++-- .../pages/Proyectos/ActivityDetailPanel.jsx | 48 +++++++-------- frontend/src/services/subtasks.js | 7 --- frontend/src/services/tasks.js | 7 +++ 25 files changed, 191 insertions(+), 172 deletions(-) create mode 100644 backend/database/migrations/005_rename_subtasks_to_tasks.sql delete mode 100644 backend/src/modules/subtasks/controller.js delete mode 100644 backend/src/modules/subtasks/routes.js delete mode 100644 backend/src/modules/subtasks/service.js create mode 100644 backend/src/modules/tasks/controller.js rename backend/src/modules/{subtasks => tasks}/repository.js (55%) create mode 100644 backend/src/modules/tasks/routes.js create mode 100644 backend/src/modules/tasks/service.js delete mode 100644 frontend/src/services/subtasks.js create mode 100644 frontend/src/services/tasks.js diff --git a/AGENTS.md b/AGENTS.md index 83fba19..981b05f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,7 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador): - Seguridad (usuarios, roles fijos, sesión, auditoría) - Organización (departamentos con sub-áreas, usuarios) - Proyectos (con miembros) -- Actividades + Subtareas + dependencias simples +- Actividades + Tareas + dependencias simples - Dashboard (KPIs) - Calendario y Gantt de solo lectura - Comentarios y adjuntos @@ -140,7 +140,7 @@ Adaptación web (no es una app empaquetada, corre en pestaña de navegador): ## Flujos criticos 1. Login → Dashboard con KPIs del usuario. -2. Crear proyecto → asignar miembros → crear actividades → crear subtareas → avance se recalcula solo. +2. Crear proyecto → asignar miembros → crear actividades → crear tareas → avance se recalcula solo. 3. Actividad vencida o por vencer → notificación in-app al responsable. ## Entorno operativo diff --git a/API.md b/API.md index a57d19b..2e83825 100644 --- a/API.md +++ b/API.md @@ -73,8 +73,8 @@ - `GET /api/activities/:id` · `PUT /api/activities/:id` - `POST /api/activities/:id/dependencies` · `DELETE /api/activities/:id/dependencies/:dependsOnId` -### Subtareas (`/api/activities/:activityId/subtasks`) -- `GET` · `POST` · `PUT /api/subtasks/:id` · `PATCH /api/subtasks/:id/done` · `DELETE /api/subtasks/:id` +### Tareas (`/api/activities/:activityId/tasks`) +- `GET` · `POST` · `PUT /api/tasks/:id` · `PATCH /api/tasks/:id/done` · `DELETE /api/tasks/:id` ### Comentarios (`/api/comments`) - `GET /api/comments?entityType=project&entityId=1` · `POST /api/comments` diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md index 318fa96..1d60f23 100644 --- a/BASE_DE_DATOS.md +++ b/BASE_DE_DATOS.md @@ -32,7 +32,7 @@ | `project_members` | usuarios asignados a un proyecto (reemplaza "equipos" como módulo aparte) | | `activities` | actividades de un proyecto | | `activity_dependencies` | dependencias simples entre actividades ("depende de") | -| `subtasks` | checklist liviano dentro de una actividad | +| `tasks` | checklist liviano dentro de una actividad ("Tareas" en la UI; renombrada desde `subtasks` en v1.8.0) | | `comments` | comentarios sobre proyecto o actividad (`entity_type` + `entity_id`) | | `attachments` | archivos adjuntos a proyecto o actividad | | `notifications` | notificaciones in-app por usuario | @@ -49,15 +49,15 @@ - `activities.project_id -> projects.id`, `activities.status_id -> activity_statuses.id`, `activities.priority_id -> priorities.id`, `activities.responsible_user_id -> users.id` - `activity_dependencies.activity_id / depends_on_activity_id -> activities.id` -- `subtasks.activity_id -> activities.id`, `subtasks.responsible_user_id -> users.id` (nullable) +- `tasks.activity_id -> activities.id`, `tasks.responsible_user_id -> users.id` (nullable) - `comments.entity_id` / `attachments.entity_id` apuntan a `projects.id` o `activities.id` según `entity_type` (sin FK física cruzada — se valida en el servicio) ## Reglas de datos - `projects.progress_pct` y `activities.progress_pct` son **calculados por el servicio**, no - editables directo por el cliente: actividad con subtareas = % de subtareas hechas; actividad sin - subtareas = manual (0-100); proyecto = promedio del avance de sus actividades. + editables directo por el cliente: actividad con tareas = % de tareas hechas; actividad sin + tareas = manual (0-100); proyecto = promedio del avance de sus actividades. - `users.password_hash` con bcrypt, nunca texto plano ni reversible. - `users.status` (`pending_verification`/`pending_approval`/`active`/`invited`) es el ciclo de vida de la cuenta, independiente de `is_active` (que sigue siendo el interruptor manual del admin diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fb17bf..5a0711d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog - project.linkedcorp.net +## [1.8.0] - 2026-09-10 +### Changed +- Renombrado "Subtareas" a "Tareas" en todo el sistema (Actividad → Tarea, sin nivel "sub" + intermedio): tabla `subtasks` → `tasks` (migración `005`, `RENAME TABLE` idempotente), módulo + backend `subtasks/` → `tasks/`, rutas `/api/activities/:id/subtasks` → `/api/activities/:id/tasks` + y `/api/subtasks/:id` → `/api/tasks/:id`, servicio frontend, pestaña dentro del detalle de + actividad, i18n (es-EC/en-US) y documentación (`API.md`, `BASE_DE_DATOS.md`, `AGENTS.md`, + `README.md`, `SPEC.md`). Sin cambios de comportamiento, solo nombres. + ## [1.7.0] - 2026-09-10 ### Added - Agregar miembro a un proyecto ahora acepta un **correo que todavía no es usuario del sistema** diff --git a/README.md b/README.md index f66f78e..dc0ef4d 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ project.linkedcorp.net/ - Seguridad (usuarios, roles, auditoría) - Organización (departamentos, usuarios) -- Proyectos, Actividades y Subtareas +- Proyectos, Actividades y Tareas - Dashboard, Calendario, Gantt (solo lectura) - Comentarios, adjuntos, notificaciones in-app - Configuración (catálogos) diff --git a/SPEC.md b/SPEC.md index ee3b435..9253a60 100644 --- a/SPEC.md +++ b/SPEC.md @@ -19,7 +19,7 @@ tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa | Administrador | Todo, incluida gestión de usuarios/roles/catálogos y aprobar/rechazar registros | | Jefe de TI | Ve y gestiona todos los proyectos/actividades, aprueba; no administra usuarios | | Coordinador / Líder de proyecto | Crea/edita los proyectos donde es responsable o miembro, gestiona sus actividades | -| Colaborador | Ve y actualiza lo asignado a él (actividades/subtareas propias), comenta | +| Colaborador | Ve y actualiza lo asignado a él (actividades/tareas propias), comenta | | Consultor | Solo lectura en todo | ## Modulos (MVP) @@ -54,9 +54,9 @@ tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa ("depende de" otra actividad del mismo proyecto). - Estados: Pendiente, En progreso, En revisión, Bloqueada, En espera, Completada, Cancelada. -### 5. Subtareas +### 5. Tareas - Checklist liviano dentro de una actividad: nombre, responsable opcional, hecho/no hecho, fecha - límite opcional. Si una actividad tiene subtareas, su avance se calcula solo desde ellas. + límite opcional. Si una actividad tiene tareas, su avance se calcula solo desde ellas. ### 6. Dashboard - KPIs: proyectos activos, proyectos atrasados, actividades pendientes/vencidas/para hoy, avance @@ -90,7 +90,7 @@ tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa 1. Administrador crea departamentos y usuarios (con rol y departamento). 2. Jefe de TI o Coordinador crea un proyecto, asigna miembros, define fechas y prioridad. 3. Se crean actividades dentro del proyecto (con responsable, fechas, dependencias si aplica). -4. Cada responsable actualiza el avance de sus actividades/subtareas; el avance del proyecto se +4. Cada responsable actualiza el avance de sus actividades/tareas; el avance del proyecto se recalcula solo. 5. El Jefe de TI entra al Dashboard y ve de un vistazo activos/atrasados/pendientes/avance. 6. Vencimientos se marcan automáticamente (🟢 en tiempo / 🟡 próximo a vencer / 🔴 vencido) y generan @@ -99,7 +99,7 @@ tiene cada cosa — sin que cargar la información sea tedioso para quien lo usa ## Reglas funcionales importantes - El listado se ve primero; la creación se abre solo con el botón "Nuevo" (regla de UX del grupo). -- El % de avance de una actividad con subtareas es automático (no editable a mano); sin subtareas, +- El % de avance de una actividad con tareas es automático (no editable a mano); sin tareas, es manual (0-100). - El % de avance de un proyecto es el promedio del avance de sus actividades (no editable a mano). - Una actividad no puede marcarse "Completada" si depende de otra que no lo está (aviso, no bloqueo diff --git a/VERSION b/VERSION index bd8bf88..27f9cd3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.7.0 +1.8.0 diff --git a/backend/database/migrations/001_initial.sql b/backend/database/migrations/001_initial.sql index 06cb16a..0d0dec7 100644 --- a/backend/database/migrations/001_initial.sql +++ b/backend/database/migrations/001_initial.sql @@ -132,7 +132,7 @@ CREATE TABLE IF NOT EXISTS activity_dependencies ( CONSTRAINT fk_dep_depends_on FOREIGN KEY (depends_on_activity_id) REFERENCES activities(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -CREATE TABLE IF NOT EXISTS subtasks ( +CREATE TABLE IF NOT EXISTS tasks ( id INT AUTO_INCREMENT PRIMARY KEY, activity_id INT NOT NULL, name VARCHAR(160) NOT NULL, @@ -141,8 +141,8 @@ CREATE TABLE IF NOT EXISTS subtasks ( due_date DATE NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - CONSTRAINT fk_subtasks_activity FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE, - CONSTRAINT fk_subtasks_responsible FOREIGN KEY (responsible_user_id) REFERENCES users(id) ON DELETE SET NULL + CONSTRAINT fk_tasks_activity FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE, + CONSTRAINT fk_tasks_responsible FOREIGN KEY (responsible_user_id) REFERENCES users(id) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE IF NOT EXISTS comments ( diff --git a/backend/database/migrations/005_rename_subtasks_to_tasks.sql b/backend/database/migrations/005_rename_subtasks_to_tasks.sql new file mode 100644 index 0000000..e7f22f5 --- /dev/null +++ b/backend/database/migrations/005_rename_subtasks_to_tasks.sql @@ -0,0 +1,10 @@ +-- Renombra "Subtareas" a "Tareas" (Actividad -> Tarea, sin nivel "sub" intermedio). Idempotente: +-- RENAME TABLE no se puede repetir tal cual, se guarda con un chequeo de information_schema. +SET @exists := ( + SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = 'subtasks' +); +SET @sql := IF(@exists > 0, 'RENAME TABLE subtasks TO tasks', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/backend/package.json b/backend/package.json index 9b83ad7..9bb4b1a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "1.7.0", + "version": "1.8.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/app.js b/backend/src/app.js index 1602286..c6a9110 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -10,7 +10,7 @@ import departmentsRoutes from './modules/departments/routes.js'; import catalogsRoutes from './modules/catalogs/routes.js'; import projectsRoutes from './modules/projects/routes.js'; import activitiesRoutes from './modules/activities/routes.js'; -import subtasksRoutes from './modules/subtasks/routes.js'; +import tasksRoutes from './modules/tasks/routes.js'; import commentsRoutes from './modules/comments/routes.js'; import attachmentsRoutes from './modules/attachments/routes.js'; import notificationsRoutes from './modules/notifications/routes.js'; @@ -41,7 +41,7 @@ app.use('/api/settings', settingsRoutes); // Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no // interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas // especifico registrado despues (ver ESTADO.md, deuda tecnica). -app.use('/api', subtasksRoutes); +app.use('/api', tasksRoutes); app.use(notFoundHandler); app.use(errorHandler); diff --git a/backend/src/modules/activities/repository.js b/backend/src/modules/activities/repository.js index a5675b4..0d9c25c 100644 --- a/backend/src/modules/activities/repository.js +++ b/backend/src/modules/activities/repository.js @@ -59,8 +59,8 @@ export async function updateProgress(id, progressPct) { await pool.query('UPDATE activities SET progress_pct = ? WHERE id = ?', [progressPct, id]); } -export async function countSubtasks(id) { - const [rows] = await pool.query('SELECT COUNT(*) AS c FROM subtasks WHERE activity_id = ?', [id]); +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 96a1124..9ab205e 100644 --- a/backend/src/modules/activities/service.js +++ b/backend/src/modules/activities/service.js @@ -65,15 +65,15 @@ export async function updateActivity(id, payload, currentUser) { throw new HttpError(400, 'Nombre, prioridad y estado son obligatorios'); } - // Colaborador no puede reasignar la actividad a otra persona ni cambiar avance manual si hay subtareas. + // Colaborador no puede reasignar la actividad a otra persona ni cambiar avance manual si hay tareas. const nextResponsible = currentUser.roleCode === ROLES.COLLABORATOR ? activity.responsible_user_id : (payload.responsibleUserId ?? activity.responsible_user_id); - const subtaskCount = await repo.countSubtasks(id); + const taskCount = await repo.countTasks(id); const updated = await repo.update(id, { ...payload, responsibleUserId: nextResponsible }); - if (subtaskCount === 0 && payload.progressPct !== undefined) { + if (taskCount === 0 && payload.progressPct !== undefined) { const pct = Math.max(0, Math.min(100, Number(payload.progressPct))); await repo.updateProgress(id, pct); updated.progress_pct = pct; @@ -120,8 +120,8 @@ export async function removeDependency(activityId, dependsOnActivityId, currentU return deps; } -// Recalcula el avance de una actividad a partir de sus subtareas (si tiene) y propaga al proyecto. -export async function recalcFromSubtasks(activityId, doneCount, totalCount) { +// Recalcula el avance de una actividad a partir de sus tareas (si tiene) y propaga al proyecto. +export async function recalcFromTasks(activityId, doneCount, totalCount) { if (totalCount === 0) return; const pct = Math.round((doneCount / totalCount) * 10000) / 100; await repo.updateProgress(activityId, pct); diff --git a/backend/src/modules/subtasks/controller.js b/backend/src/modules/subtasks/controller.js deleted file mode 100644 index 185e986..0000000 --- a/backend/src/modules/subtasks/controller.js +++ /dev/null @@ -1,23 +0,0 @@ -import { ok, asyncHandler } from '../../utils/http.js'; -import * as service from './service.js'; - -export const getSubtasks = asyncHandler(async (req, res) => { - ok(res, await service.listByActivity(Number(req.params.activityId))); -}); - -export const postSubtask = asyncHandler(async (req, res) => { - ok(res, await service.createSubtask(Number(req.params.activityId), req.body, req.currentUser), 201); -}); - -export const putSubtask = asyncHandler(async (req, res) => { - ok(res, await service.updateSubtask(Number(req.params.id), req.body, req.currentUser)); -}); - -export const patchSubtaskDone = asyncHandler(async (req, res) => { - ok(res, await service.setSubtaskDone(Number(req.params.id), !!req.body.isDone, req.currentUser)); -}); - -export const deleteSubtask = asyncHandler(async (req, res) => { - await service.removeSubtask(Number(req.params.id), req.currentUser); - ok(res, { deleted: true }); -}); diff --git a/backend/src/modules/subtasks/routes.js b/backend/src/modules/subtasks/routes.js deleted file mode 100644 index 8820d26..0000000 --- a/backend/src/modules/subtasks/routes.js +++ /dev/null @@ -1,14 +0,0 @@ -import { Router } from 'express'; -import { requireAuth } from '../../middleware/auth.js'; -import { getSubtasks, postSubtask, putSubtask, patchSubtaskDone, deleteSubtask } from './controller.js'; - -const router = Router(); - -router.use(requireAuth); -router.get('/activities/:activityId/subtasks', getSubtasks); -router.post('/activities/:activityId/subtasks', postSubtask); -router.put('/subtasks/:id', putSubtask); -router.patch('/subtasks/:id/done', patchSubtaskDone); -router.delete('/subtasks/:id', deleteSubtask); - -export default router; diff --git a/backend/src/modules/subtasks/service.js b/backend/src/modules/subtasks/service.js deleted file mode 100644 index 1b49898..0000000 --- a/backend/src/modules/subtasks/service.js +++ /dev/null @@ -1,59 +0,0 @@ -import * as repo from './repository.js'; -import * as activitiesRepo from '../activities/repository.js'; -import * as projectsRepo from '../projects/repository.js'; -import { canWriteActivity, recalcFromSubtasks } from '../activities/service.js'; -import { HttpError } from '../../utils/http.js'; -import { logAudit } from '../../utils/audit.js'; - -async function requireActivityAccess(activityId, currentUser) { - const activity = await activitiesRepo.findById(activityId); - if (!activity) throw new HttpError(404, 'Actividad no encontrada'); - const project = await projectsRepo.findById(activity.project_id); - if (!(await canWriteActivity(currentUser, project, activity))) { - throw new HttpError(403, 'No tienes permisos sobre esta actividad'); - } - return activity; -} - -export const listByActivity = (activityId) => repo.findByActivity(activityId); - -export async function createSubtask(activityId, payload, currentUser) { - await requireActivityAccess(activityId, currentUser); - if (!payload.name) throw new HttpError(400, 'El nombre es obligatorio'); - const subtask = await repo.create(activityId, payload); - await logAudit({ userId: currentUser.id, action: 'create', entityType: 'subtask', entityId: subtask.id }); - const counts = await repo.countsByActivity(activityId); - await recalcFromSubtasks(activityId, counts.done, counts.total); - return subtask; -} - -export async function updateSubtask(id, payload, currentUser) { - const subtask = await repo.findById(id); - if (!subtask) throw new HttpError(404, 'Subtarea no encontrada'); - await requireActivityAccess(subtask.activity_id, currentUser); - if (!payload.name) throw new HttpError(400, 'El nombre es obligatorio'); - const updated = await repo.update(id, payload); - await logAudit({ userId: currentUser.id, action: 'update', entityType: 'subtask', entityId: id }); - return updated; -} - -export async function setSubtaskDone(id, isDone, currentUser) { - const subtask = await repo.findById(id); - if (!subtask) throw new HttpError(404, 'Subtarea no encontrada'); - await requireActivityAccess(subtask.activity_id, currentUser); - const updated = await repo.setDone(id, isDone); - await logAudit({ userId: currentUser.id, action: isDone ? 'complete' : 'reopen', entityType: 'subtask', entityId: id }); - const counts = await repo.countsByActivity(subtask.activity_id); - await recalcFromSubtasks(subtask.activity_id, counts.done, counts.total); - return updated; -} - -export async function removeSubtask(id, currentUser) { - const subtask = await repo.findById(id); - if (!subtask) throw new HttpError(404, 'Subtarea no encontrada'); - await requireActivityAccess(subtask.activity_id, currentUser); - await repo.remove(id); - await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'subtask', entityId: id }); - const counts = await repo.countsByActivity(subtask.activity_id); - await recalcFromSubtasks(subtask.activity_id, counts.done, counts.total); -} diff --git a/backend/src/modules/tasks/controller.js b/backend/src/modules/tasks/controller.js new file mode 100644 index 0000000..ae3a0e0 --- /dev/null +++ b/backend/src/modules/tasks/controller.js @@ -0,0 +1,23 @@ +import { ok, asyncHandler } from '../../utils/http.js'; +import * as service from './service.js'; + +export const getTasks = asyncHandler(async (req, res) => { + ok(res, await service.listByActivity(Number(req.params.activityId))); +}); + +export const postTask = asyncHandler(async (req, res) => { + ok(res, await service.createTask(Number(req.params.activityId), req.body, req.currentUser), 201); +}); + +export const putTask = asyncHandler(async (req, res) => { + ok(res, await service.updateTask(Number(req.params.id), req.body, req.currentUser)); +}); + +export const patchTaskDone = asyncHandler(async (req, res) => { + ok(res, await service.setTaskDone(Number(req.params.id), !!req.body.isDone, req.currentUser)); +}); + +export const deleteTask = asyncHandler(async (req, res) => { + await service.removeTask(Number(req.params.id), req.currentUser); + ok(res, { deleted: true }); +}); diff --git a/backend/src/modules/subtasks/repository.js b/backend/src/modules/tasks/repository.js similarity index 55% rename from backend/src/modules/subtasks/repository.js rename to backend/src/modules/tasks/repository.js index 67e1337..a067229 100644 --- a/backend/src/modules/subtasks/repository.js +++ b/backend/src/modules/tasks/repository.js @@ -2,47 +2,47 @@ import { pool } from '../../config/db.js'; export async function findByActivity(activityId) { const [rows] = await pool.query( - `SELECT s.id, s.activity_id, s.name, s.responsible_user_id, u.name AS responsible_name, - s.is_done, s.due_date, s.created_at - FROM subtasks s LEFT JOIN users u ON u.id = s.responsible_user_id - WHERE s.activity_id = ? ORDER BY s.created_at`, + `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 + FROM tasks t LEFT JOIN users u ON u.id = t.responsible_user_id + WHERE t.activity_id = ? ORDER BY t.created_at`, [activityId] ); return rows; } export async function findById(id) { - const [rows] = await pool.query('SELECT * FROM subtasks WHERE id = ?', [id]); + const [rows] = await pool.query('SELECT * FROM tasks WHERE id = ?', [id]); return rows[0] || null; } export async function create(activityId, { name, responsibleUserId, dueDate }) { const [result] = await pool.query( - 'INSERT INTO subtasks (activity_id, name, responsible_user_id, due_date) VALUES (?, ?, ?, ?)', + 'INSERT INTO tasks (activity_id, name, responsible_user_id, due_date) VALUES (?, ?, ?, ?)', [activityId, name, responsibleUserId || null, dueDate || null] ); return findById(result.insertId); } export async function update(id, { name, responsibleUserId, dueDate }) { - await pool.query('UPDATE subtasks SET name = ?, responsible_user_id = ?, due_date = ? WHERE id = ?', [ + await pool.query('UPDATE tasks SET name = ?, responsible_user_id = ?, due_date = ? WHERE id = ?', [ name, responsibleUserId || null, dueDate || null, id, ]); return findById(id); } export async function setDone(id, isDone) { - await pool.query('UPDATE subtasks SET is_done = ? WHERE id = ?', [isDone ? 1 : 0, id]); + await pool.query('UPDATE tasks SET is_done = ? WHERE id = ?', [isDone ? 1 : 0, id]); return findById(id); } export async function remove(id) { - await pool.query('DELETE FROM subtasks WHERE id = ?', [id]); + await pool.query('DELETE FROM tasks WHERE id = ?', [id]); } export async function countsByActivity(activityId) { const [rows] = await pool.query( - 'SELECT COUNT(*) AS total, SUM(is_done) AS done FROM subtasks WHERE activity_id = ?', + 'SELECT COUNT(*) AS total, SUM(is_done) AS done FROM tasks WHERE activity_id = ?', [activityId] ); return { total: rows[0].total, done: Number(rows[0].done) || 0 }; diff --git a/backend/src/modules/tasks/routes.js b/backend/src/modules/tasks/routes.js new file mode 100644 index 0000000..c33df7e --- /dev/null +++ b/backend/src/modules/tasks/routes.js @@ -0,0 +1,14 @@ +import { Router } from 'express'; +import { requireAuth } from '../../middleware/auth.js'; +import { getTasks, postTask, putTask, 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('/tasks/:id', putTask); +router.patch('/tasks/:id/done', patchTaskDone); +router.delete('/tasks/:id', deleteTask); + +export default router; diff --git a/backend/src/modules/tasks/service.js b/backend/src/modules/tasks/service.js new file mode 100644 index 0000000..1dc8b60 --- /dev/null +++ b/backend/src/modules/tasks/service.js @@ -0,0 +1,59 @@ +import * as repo from './repository.js'; +import * as activitiesRepo from '../activities/repository.js'; +import * as projectsRepo from '../projects/repository.js'; +import { canWriteActivity, recalcFromTasks } from '../activities/service.js'; +import { HttpError } from '../../utils/http.js'; +import { logAudit } from '../../utils/audit.js'; + +async function requireActivityAccess(activityId, currentUser) { + const activity = await activitiesRepo.findById(activityId); + if (!activity) throw new HttpError(404, 'Actividad no encontrada'); + const project = await projectsRepo.findById(activity.project_id); + if (!(await canWriteActivity(currentUser, project, activity))) { + throw new HttpError(403, 'No tienes permisos sobre esta actividad'); + } + return activity; +} + +export const listByActivity = (activityId) => repo.findByActivity(activityId); + +export async function createTask(activityId, payload, currentUser) { + await requireActivityAccess(activityId, currentUser); + if (!payload.name) throw new HttpError(400, 'El nombre es obligatorio'); + const task = await repo.create(activityId, payload); + await logAudit({ userId: currentUser.id, action: 'create', entityType: 'task', entityId: task.id }); + const counts = await repo.countsByActivity(activityId); + await recalcFromTasks(activityId, counts.done, counts.total); + return task; +} + +export async function updateTask(id, payload, currentUser) { + const task = await repo.findById(id); + if (!task) throw new HttpError(404, 'Tarea no encontrada'); + await requireActivityAccess(task.activity_id, currentUser); + if (!payload.name) throw new HttpError(400, 'El nombre es obligatorio'); + const updated = await repo.update(id, payload); + await logAudit({ userId: currentUser.id, action: 'update', entityType: 'task', entityId: id }); + return updated; +} + +export async function setTaskDone(id, isDone, currentUser) { + const task = await repo.findById(id); + if (!task) throw new HttpError(404, 'Tarea no encontrada'); + await requireActivityAccess(task.activity_id, currentUser); + const updated = await repo.setDone(id, isDone); + await logAudit({ userId: currentUser.id, action: isDone ? 'complete' : 'reopen', entityType: 'task', entityId: id }); + const counts = await repo.countsByActivity(task.activity_id); + await recalcFromTasks(task.activity_id, counts.done, counts.total); + return updated; +} + +export async function removeTask(id, currentUser) { + const task = await repo.findById(id); + if (!task) throw new HttpError(404, 'Tarea no encontrada'); + await requireActivityAccess(task.activity_id, currentUser); + await repo.remove(id); + await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'task', entityId: id }); + const counts = await repo.countsByActivity(task.activity_id); + await recalcFromTasks(task.activity_id, counts.done, counts.total); +} diff --git a/frontend/package.json b/frontend/package.json index f36583c..4c8fad6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "1.7.0", + "version": "1.8.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index b3d253e..ec0bda8 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -168,9 +168,9 @@ export const messages = { activity_dependencies: 'Dependencias', activity_add_dependency: 'Agregar dependencia', activity_depends_on: 'Depende de', - subtasks_title: 'Subtareas', - subtask_new: 'Nueva subtarea', - subtask_done: 'Hecha', + tasks_title: 'Tareas', + task_new: 'Nueva tarea', + task_done: 'Hecha', due_on_time: 'En tiempo', due_warning: 'Proximo a vencer', @@ -376,9 +376,9 @@ export const messages = { activity_dependencies: 'Dependencies', activity_add_dependency: 'Add dependency', activity_depends_on: 'Depends on', - subtasks_title: 'Subtasks', - subtask_new: 'New subtask', - subtask_done: 'Done', + tasks_title: 'Tasks', + task_new: 'New task', + task_done: 'Done', due_on_time: 'On time', due_warning: 'Due soon', diff --git a/frontend/src/pages/Proyectos/ActivityDetailPanel.jsx b/frontend/src/pages/Proyectos/ActivityDetailPanel.jsx index 28f78cd..26bde28 100644 --- a/frontend/src/pages/Proyectos/ActivityDetailPanel.jsx +++ b/frontend/src/pages/Proyectos/ActivityDetailPanel.jsx @@ -3,7 +3,7 @@ import { Pencil, Save, X, Plus, Trash2, Link2 } from 'lucide-react'; import { useUi } from '../../context/ui-context.jsx'; import { useAsync, useAction } from '../../hooks/useAsync.js'; import { updateActivity, addDependency, removeDependency } from '../../services/activities.js'; -import { listSubtasks, createSubtask, setSubtaskDone, deleteSubtask } from '../../services/subtasks.js'; +import { listTasks, createTask, setTaskDone, deleteTask } from '../../services/tasks.js'; import { listCatalog } from '../../services/catalogs.js'; import { listUsers } from '../../services/users.js'; import { Button } from '../../components/Button.jsx'; @@ -29,17 +29,17 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi const { data: priorities } = useAsync(() => listCatalog('priorities'), []); const { data: statuses } = useAsync(() => listCatalog('activity-statuses'), []); const { data: users } = useAsync(listUsers, []); - const { data: subtasks, reload: reloadSubtasks } = useAsync(() => listSubtasks(activity.id), [activity.id]); + const { data: tasks, reload: reloadTasks } = useAsync(() => listTasks(activity.id), [activity.id]); const [tab, setTab] = useState('detail'); const [editing, setEditing] = useState(false); const [form, setForm] = useState(() => toForm(activity)); - const [newSubtask, setNewSubtask] = useState(''); + const [newTask, setNewTask] = useState(''); const [addingDep, setAddingDep] = useState(''); useEffect(() => { setForm(toForm(activity)); setEditing(false); }, [activity]); - const hasSubtasks = subtasks && subtasks.length > 0; + const hasTasks = tasks && tasks.length > 0; const priorityOptions = (priorities || []).map((p) => ({ value: p.id, label: p.name })); const statusOptions = (statuses || []).map((s) => ({ value: s.id, label: s.name })); const userOptions = (users || []).map((u) => ({ value: u.id, label: u.name })); @@ -56,22 +56,22 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi }); } - async function addSubtaskItem() { - if (!newSubtask.trim()) return; + async function addTaskItem() { + if (!newTask.trim()) return; await runAction(async () => { - await createSubtask(activity.id, { name: newSubtask.trim() }); - setNewSubtask(''); - await reloadSubtasks(); + await createTask(activity.id, { name: newTask.trim() }); + setNewTask(''); + await reloadTasks(); onChanged(); }); } - async function toggleSubtask(s) { - await runAction(async () => { await setSubtaskDone(s.id, !s.is_done); await reloadSubtasks(); onChanged(); }); + async function toggleTask(task) { + await runAction(async () => { await setTaskDone(task.id, !task.is_done); await reloadTasks(); onChanged(); }); } - async function removeSubtaskItem(s) { - await runAction(async () => { await deleteSubtask(s.id); await reloadSubtasks(); onChanged(); }); + async function removeTaskItem(task) { + await runAction(async () => { await deleteTask(task.id); await reloadTasks(); onChanged(); }); } async function addDep() { @@ -88,7 +88,7 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi - {!hasSubtasks && ( + {!hasTasks && ( setForm((f) => ({ ...f, progressPct: e.target.value }))} /> @@ -178,19 +178,19 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi )} - {tab === 'subtasks' && ( + {tab === 'tasks' && (
- {(subtasks || []).map((s) => ( -
- toggleSubtask(s)} /> - {s.name} - + {(tasks || []).map((task) => ( +
+ toggleTask(task)} /> + {task.name} +
))} - {(!subtasks || subtasks.length === 0) &&
{t('common_empty')}
} + {(!tasks || tasks.length === 0) &&
{t('common_empty')}
}
- setNewSubtask(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addSubtaskItem()} /> - + setNewTask(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addTaskItem()} /> +
)} diff --git a/frontend/src/services/subtasks.js b/frontend/src/services/subtasks.js deleted file mode 100644 index 710bddf..0000000 --- a/frontend/src/services/subtasks.js +++ /dev/null @@ -1,7 +0,0 @@ -import { api } from './api.js'; - -export const listSubtasks = (activityId) => api.get(`/activities/${activityId}/subtasks`); -export const createSubtask = (activityId, data) => api.post(`/activities/${activityId}/subtasks`, data); -export const updateSubtask = (id, data) => api.put(`/subtasks/${id}`, data); -export const setSubtaskDone = (id, isDone) => api.patch(`/subtasks/${id}/done`, { isDone }); -export const deleteSubtask = (id) => api.delete(`/subtasks/${id}`); diff --git a/frontend/src/services/tasks.js b/frontend/src/services/tasks.js new file mode 100644 index 0000000..8984aba --- /dev/null +++ b/frontend/src/services/tasks.js @@ -0,0 +1,7 @@ +import { api } from './api.js'; + +export const listTasks = (activityId) => api.get(`/activities/${activityId}/tasks`); +export const createTask = (activityId, data) => api.post(`/activities/${activityId}/tasks`, data); +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}`);