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}`);