refactor: renombra Subtareas a Tareas en todo el sistema

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 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-10 08:55:58 -05:00
parent 29daff82ed
commit bb6bd6865c
25 changed files with 191 additions and 172 deletions
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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`
+4 -4
View File
@@ -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
+9
View File
@@ -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**
+1 -1
View File
@@ -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)
+5 -5
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
1.7.0
1.8.0
+3 -3
View File
@@ -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 (
@@ -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;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "project-linkedcorp-backend",
"version": "1.7.0",
"version": "1.8.0",
"private": true,
"type": "module",
"main": "src/server.js",
+2 -2
View File
@@ -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);
+2 -2
View File
@@ -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;
}
+5 -5
View File
@@ -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);
@@ -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 });
});
-14
View File
@@ -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;
-59
View File
@@ -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);
}
+23
View File
@@ -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 });
});
@@ -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 };
+14
View File
@@ -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;
+59
View File
@@ -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);
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "project-linkedcorp-frontend",
"private": true,
"version": "1.7.0",
"version": "1.8.0",
"type": "module",
"scripts": {
"dev": "vite",
+6 -6
View File
@@ -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',
@@ -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
<Tabs
tabs={[
{ key: 'detail', label: t('tab_info') },
{ key: 'subtasks', label: t('subtasks_title') },
{ key: 'tasks', label: t('tasks_title') },
{ key: 'dependencies', label: t('activity_dependencies') },
{ key: 'comments', label: t('tab_comments') },
{ key: 'documents', label: t('tab_documents') },
@@ -167,7 +167,7 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
{t('activity_milestone')}
</label>
</Field>
{!hasSubtasks && (
{!hasTasks && (
<Field label={t('activity_progress')}>
<input className="input" type="number" min={0} max={100} value={form.progressPct} onChange={(e) => setForm((f) => ({ ...f, progressPct: e.target.value }))} />
</Field>
@@ -178,19 +178,19 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
</div>
)}
{tab === 'subtasks' && (
{tab === 'tasks' && (
<div>
{(subtasks || []).map((s) => (
<div key={s.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 0' }}>
<input type="checkbox" checked={!!s.is_done} onChange={() => toggleSubtask(s)} />
<span style={{ flex: 1, fontSize: '.8rem', textDecoration: s.is_done ? 'line-through' : 'none', opacity: s.is_done ? .6 : 1 }}>{s.name}</span>
<button className="icon-btn" onClick={() => removeSubtaskItem(s)}><Trash2 size={15} /></button>
{(tasks || []).map((task) => (
<div key={task.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 0' }}>
<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>
<button className="icon-btn" onClick={() => removeTaskItem(task)}><Trash2 size={15} /></button>
</div>
))}
{(!subtasks || subtasks.length === 0) && <div className="text-muted" style={{ fontSize: '.78rem' }}>{t('common_empty')}</div>}
{(!tasks || tasks.length === 0) && <div className="text-muted" style={{ fontSize: '.78rem' }}>{t('common_empty')}</div>}
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<input className="input" value={newSubtask} placeholder={t('subtask_new')} onChange={(e) => setNewSubtask(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addSubtaskItem()} />
<Button variant="standard" icon={Plus} onClick={addSubtaskItem}>{t('common_new')}</Button>
<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>
</div>
</div>
)}
-7
View File
@@ -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}`);
+7
View File
@@ -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}`);