diff --git a/API.md b/API.md index 1db188e..cb550f6 100644 --- a/API.md +++ b/API.md @@ -71,6 +71,9 @@ ### Actividades (`/api/activities`) - `GET /api/projects/:projectId/activities` · `POST /api/projects/:projectId/activities` - `GET /api/activities/:id` · `PUT /api/activities/:id` +- `DELETE /api/activities/:id` — bloqueado con `400` si la actividad tiene tareas (hay que + eliminarlas primero); limpia comentarios/adjuntos de la actividad de paso (no hay FK física + cruzada). Arrastra `activity_dependencies` por `ON DELETE CASCADE`. - `POST /api/activities/:id/dependencies` · `DELETE /api/activities/:id/dependencies/:dependsOnId` ### Tareas (`/api/activities/:activityId/tasks`) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cc7fc5..d574e81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog - project.linkedcorp.net +## [1.13.0] - 2026-09-15 +### Added +- Eliminar actividad: `DELETE /api/activities/:id` (no existía) + botón "Eliminar" en el listado + de actividades, con popup de confirmación. Si la actividad tiene tareas, se bloquea con un + mensaje claro ("Esta actividad tiene N tarea(s). Elimina primero las tareas antes de borrar la + actividad.") en vez de borrar en cascada silenciosamente. +### Changed +- Adjuntos: la lógica de borrar archivo + fila (antes solo inline en el controller) se movió a un + nuevo `attachments/service.js` reutilizable, usado tanto por el borrado individual como por el + borrado en cascada al eliminar una actividad (evita archivos huérfanos en disco). + ## [1.12.5] - 2026-09-14 ### Changed - Listado de Proyectos: columna "Creado" reemplazada por "Actualizado" (fecha y hora), y es ahora diff --git a/VERSION b/VERSION index e0a6b34..feaae22 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.12.5 +1.13.0 diff --git a/backend/package.json b/backend/package.json index 753d8d2..eb35df3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "1.12.5", + "version": "1.13.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/modules/activities/controller.js b/backend/src/modules/activities/controller.js index 6296415..6e73305 100644 --- a/backend/src/modules/activities/controller.js +++ b/backend/src/modules/activities/controller.js @@ -17,6 +17,11 @@ export const putActivity = asyncHandler(async (req, res) => { ok(res, await service.updateActivity(Number(req.params.id), req.body, req.currentUser)); }); +export const deleteActivity = asyncHandler(async (req, res) => { + await service.removeActivity(Number(req.params.id), req.currentUser); + ok(res, { deleted: true }); +}); + export const postDependency = asyncHandler(async (req, res) => { const deps = await service.addDependency(Number(req.params.id), Number(req.body.dependsOnActivityId), req.currentUser); ok(res, deps, 201); diff --git a/backend/src/modules/activities/repository.js b/backend/src/modules/activities/repository.js index 0d9c25c..b1bb267 100644 --- a/backend/src/modules/activities/repository.js +++ b/backend/src/modules/activities/repository.js @@ -59,6 +59,11 @@ export async function updateProgress(id, progressPct) { await pool.query('UPDATE activities SET progress_pct = ? WHERE id = ?', [progressPct, id]); } +// Arrastra activity_dependencies (ambos lados) y tasks por ON DELETE CASCADE. +export async function remove(id) { + await pool.query('DELETE FROM activities WHERE 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/routes.js b/backend/src/modules/activities/routes.js index 05852d7..99e4262 100644 --- a/backend/src/modules/activities/routes.js +++ b/backend/src/modules/activities/routes.js @@ -1,12 +1,13 @@ import { Router } from 'express'; import { requireAuth } from '../../middleware/auth.js'; -import { getActivity, putActivity, postDependency, deleteDependency } from './controller.js'; +import { getActivity, putActivity, deleteActivity, postDependency, deleteDependency } from './controller.js'; const router = Router(); router.use(requireAuth); router.get('/:id', getActivity); router.put('/:id', putActivity); +router.delete('/:id', deleteActivity); router.post('/:id/dependencies', postDependency); router.delete('/:id/dependencies/:dependsOnId', deleteDependency); diff --git a/backend/src/modules/activities/service.js b/backend/src/modules/activities/service.js index 9ab205e..3ddb3e3 100644 --- a/backend/src/modules/activities/service.js +++ b/backend/src/modules/activities/service.js @@ -1,5 +1,7 @@ import * as repo from './repository.js'; import * as projectsRepo from '../projects/repository.js'; +import * as commentsRepo from '../comments/repository.js'; +import * as attachmentsService from '../attachments/service.js'; import { recalcProjectProgress, canWriteProject } from '../projects/service.js'; import { HttpError } from '../../utils/http.js'; import { logAudit } from '../../utils/audit.js'; @@ -95,6 +97,27 @@ export async function updateActivity(id, payload, currentUser) { return repo.findById(id); } +// Bloquea el borrado si hay tareas pendientes (a proposito: evita perder de vista trabajo real +// hecho/por hacer solo porque alguien borro la actividad de un clic). El usuario debe vaciar el +// checklist primero. Comentarios/adjuntos si se limpian solos, no llevan informacion de progreso. +export async function removeActivity(id, currentUser) { + const activity = await repo.findById(id); + if (!activity) throw new HttpError(404, 'Actividad no encontrada'); + const project = await getProjectOr404(activity.project_id); + if (!(await canWriteActivity(currentUser, project, activity))) { + throw new HttpError(403, 'No tienes permisos sobre esta actividad'); + } + const taskCount = await repo.countTasks(id); + if (taskCount > 0) { + throw new HttpError(400, `Esta actividad tiene ${taskCount} tarea(s). Elimina primero las tareas antes de borrar la actividad.`); + } + await commentsRepo.removeByEntity('activity', id); + await attachmentsService.removeAllForEntity('activity', id); + await repo.remove(id); + await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'activity', entityId: id }); + await recalcProjectProgress(activity.project_id); +} + export async function addDependency(activityId, dependsOnActivityId, currentUser) { if (activityId === dependsOnActivityId) throw new HttpError(400, 'Una actividad no puede depender de si misma'); const activity = await repo.findById(activityId); diff --git a/backend/src/modules/attachments/controller.js b/backend/src/modules/attachments/controller.js index d2581f9..3692d7f 100644 --- a/backend/src/modules/attachments/controller.js +++ b/backend/src/modules/attachments/controller.js @@ -5,6 +5,7 @@ import { logAudit } from '../../utils/audit.js'; import { ROLES } from '../../middleware/auth.js'; import { uploadsDir } from '../../middleware/upload.js'; import * as repo from './repository.js'; +import { removeAttachment } from './service.js'; const VALID_ENTITY_TYPES = new Set(['project', 'activity']); @@ -43,9 +44,7 @@ export const deleteAttachment = asyncHandler(async (req, res) => { const canDelete = [ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER].includes(req.currentUser.roleCode) || attachment.uploaded_by === req.currentUser.id; if (!canDelete) throw new HttpError(403, 'No puedes eliminar este adjunto'); - await repo.remove(attachment.id); - const filePath = path.join(uploadsDir, attachment.filename); - fs.promises.unlink(filePath).catch(() => {}); + await removeAttachment(attachment); await logAudit({ userId: req.currentUser.id, action: 'delete', entityType: attachment.entity_type, entityId: attachment.entity_id, detail: { attachmentId: attachment.id } }); ok(res, { deleted: true }); }); diff --git a/backend/src/modules/attachments/service.js b/backend/src/modules/attachments/service.js new file mode 100644 index 0000000..a97a2a1 --- /dev/null +++ b/backend/src/modules/attachments/service.js @@ -0,0 +1,22 @@ +import path from 'node:path'; +import fs from 'node:fs'; +import { uploadsDir } from '../../middleware/upload.js'; +import * as repo from './repository.js'; + +async function unlinkQuiet(filename) { + await fs.promises.unlink(path.join(uploadsDir, filename)).catch(() => {}); +} + +// Borra la fila y el archivo en disco. Unico lugar que hace esto — reutilizado tanto por el borrado +// individual (controller) como por el borrado en cascada al eliminar la entidad duena. +export async function removeAttachment(attachment) { + await repo.remove(attachment.id); + await unlinkQuiet(attachment.filename); +} + +// Usado al borrar la entidad duena (actividad, proyecto): limpia sus adjuntos (fila + archivo) de +// paso, ya que no hay FK fisica cruzada (entity_type/entity_id es polimorfico) que lo haga por cascada. +export async function removeAllForEntity(entityType, entityId) { + const files = await repo.findByEntity(entityType, entityId); + for (const file of files) await removeAttachment(file); +} diff --git a/backend/src/modules/comments/repository.js b/backend/src/modules/comments/repository.js index 8db1a22..e6b6f68 100644 --- a/backend/src/modules/comments/repository.js +++ b/backend/src/modules/comments/repository.js @@ -10,6 +10,12 @@ export async function findByEntity(entityType, entityId) { return rows; } +// Usado al borrar la entidad duena (actividad, proyecto): limpia sus comentarios de paso, ya que +// no hay FK fisica cruzada (entity_type/entity_id es polimorfico) que lo haga por cascada. +export async function removeByEntity(entityType, entityId) { + await pool.query('DELETE FROM comments WHERE entity_type = ? AND entity_id = ?', [entityType, entityId]); +} + export async function create({ entityType, entityId, userId, message }) { const [result] = await pool.query( 'INSERT INTO comments (entity_type, entity_id, user_id, message) VALUES (?, ?, ?, ?)', diff --git a/frontend/package.json b/frontend/package.json index ef34f52..8182d80 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "1.12.5", + "version": "1.13.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/pages/Proyectos/ProjectActivitiesTab.jsx b/frontend/src/pages/Proyectos/ProjectActivitiesTab.jsx index 09ab4f4..23a74cf 100644 --- a/frontend/src/pages/Proyectos/ProjectActivitiesTab.jsx +++ b/frontend/src/pages/Proyectos/ProjectActivitiesTab.jsx @@ -1,8 +1,9 @@ import { useState } from 'react'; -import { Plus, Save } from 'lucide-react'; +import { Plus, Save, Trash2 } from 'lucide-react'; import { useUi } from '../../context/ui-context.jsx'; +import { useAuth } from '../../context/auth-context.jsx'; import { useAsync, useAction } from '../../hooks/useAsync.js'; -import { listActivitiesByProject, createActivity, getActivity } from '../../services/activities.js'; +import { listActivitiesByProject, createActivity, getActivity, deleteActivity } from '../../services/activities.js'; import { listCatalog } from '../../services/catalogs.js'; import { listUsers } from '../../services/users.js'; import { ListView } from '../../components/ListView.jsx'; @@ -18,7 +19,8 @@ import ActivityDetailPanel from './ActivityDetailPanel.jsx'; const EMPTY_FORM = { name: '', description: '', responsibleUserId: '', priorityId: '', statusId: '', startDate: '', dueDate: '', isMilestone: false }; export default function ProjectActivitiesTab({ projectId }) { - const { t, pushToast } = useUi(); + const { t, pushToast, openDialog } = useUi(); + const { user: currentUser } = useAuth(); const runAction = useAction(); const { data: activities, reload } = useAsync(() => listActivitiesByProject(projectId), [projectId]); const { data: priorities } = useAsync(() => listCatalog('priorities'), []); @@ -55,6 +57,25 @@ export default function ProjectActivitiesTab({ projectId }) { if (selected) setSelected(await getActivity(selected.id)); } + function confirmDelete(row) { + openDialog({ + tone: 'warning', + title: t('common_confirm_delete_title'), + message: t('common_confirm_delete_msg'), + actions: [ + { label: t('common_cancel'), variant: 'standard' }, + { + label: t('common_delete'), variant: 'danger', + onClick: () => runAction(async () => { + await deleteActivity(row.id); + await reload(); + pushToast({ tone: 'success', message: t('common_saved') }); + }), + }, + ], + }); + } + const columns = [ { key: 'code', label: t('activity_code') }, { key: 'name', label: t('common_name') }, @@ -86,6 +107,9 @@ export default function ProjectActivitiesTab({ projectId }) { getSearchHaystack={(a) => `${a.code} ${a.name} ${a.responsible_name || ''}`} getRowKey={(a) => a.id} onRowClick={openActivity} + rowActions={currentUser?.roleCode !== 'viewer' ? (a) => [ + { label: t('common_delete'), icon: Trash2, danger: true, onClick: () => confirmDelete(a) }, + ] : undefined} /> setCreating(false)} width={640} height={560}> diff --git a/frontend/src/services/activities.js b/frontend/src/services/activities.js index d237125..f658116 100644 --- a/frontend/src/services/activities.js +++ b/frontend/src/services/activities.js @@ -4,5 +4,6 @@ export const listActivitiesByProject = (projectId) => api.get(`/projects/${proje export const createActivity = (projectId, data) => api.post(`/projects/${projectId}/activities`, data); export const getActivity = (id) => api.get(`/activities/${id}`); export const updateActivity = (id, data) => api.put(`/activities/${id}`, data); +export const deleteActivity = (id) => api.delete(`/activities/${id}`); export const addDependency = (id, dependsOnActivityId) => api.post(`/activities/${id}/dependencies`, { dependsOnActivityId }); export const removeDependency = (id, dependsOnId) => api.delete(`/activities/${id}/dependencies/${dependsOnId}`);