feat: eliminar actividad, bloqueado si tiene tareas pendientes
No existia forma de borrar una actividad. Agregado DELETE /api/activities/:id con boton "Eliminar" en el listado (popup de confirmacion). Si tiene tareas, se bloquea con 400 y mensaje claro en vez de arrastrar todo en cascada sin avisar. De paso: la logica de borrar adjunto (fila + archivo en disco) se extrajo a un attachments/service.js reutilizable, usado tanto por el borrado individual como por el borrado en cascada de una actividad (evita archivos huerfanos). Comentarios de la actividad tambien se limpian (sin FK fisica cruzada, entity_type/entity_id es polimorfico). Probado en dev con curl (bloquea con tarea, permite sin tareas, 404 tras borrar) y Playwright (mensaje de advertencia visible). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -71,6 +71,9 @@
|
|||||||
### Actividades (`/api/activities`)
|
### Actividades (`/api/activities`)
|
||||||
- `GET /api/projects/:projectId/activities` · `POST /api/projects/:projectId/activities`
|
- `GET /api/projects/:projectId/activities` · `POST /api/projects/:projectId/activities`
|
||||||
- `GET /api/activities/:id` · `PUT /api/activities/:id`
|
- `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`
|
- `POST /api/activities/:id/dependencies` · `DELETE /api/activities/:id/dependencies/:dependsOnId`
|
||||||
|
|
||||||
### Tareas (`/api/activities/:activityId/tasks`)
|
### Tareas (`/api/activities/:activityId/tasks`)
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
# Changelog - project.linkedcorp.net
|
# 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
|
## [1.12.5] - 2026-09-14
|
||||||
### Changed
|
### Changed
|
||||||
- Listado de Proyectos: columna "Creado" reemplazada por "Actualizado" (fecha y hora), y es ahora
|
- Listado de Proyectos: columna "Creado" reemplazada por "Actualizado" (fecha y hora), y es ahora
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "1.12.5",
|
"version": "1.13.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/server.js",
|
"main": "src/server.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));
|
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) => {
|
export const postDependency = asyncHandler(async (req, res) => {
|
||||||
const deps = await service.addDependency(Number(req.params.id), Number(req.body.dependsOnActivityId), req.currentUser);
|
const deps = await service.addDependency(Number(req.params.id), Number(req.body.dependsOnActivityId), req.currentUser);
|
||||||
ok(res, deps, 201);
|
ok(res, deps, 201);
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ export async function updateProgress(id, progressPct) {
|
|||||||
await pool.query('UPDATE activities SET progress_pct = ? WHERE id = ?', [progressPct, id]);
|
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) {
|
export async function countTasks(id) {
|
||||||
const [rows] = await pool.query('SELECT COUNT(*) AS c FROM tasks WHERE activity_id = ?', [id]);
|
const [rows] = await pool.query('SELECT COUNT(*) AS c FROM tasks WHERE activity_id = ?', [id]);
|
||||||
return rows[0].c;
|
return rows[0].c;
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { requireAuth } from '../../middleware/auth.js';
|
import { requireAuth } from '../../middleware/auth.js';
|
||||||
import { getActivity, putActivity, postDependency, deleteDependency } from './controller.js';
|
import { getActivity, putActivity, deleteActivity, postDependency, deleteDependency } from './controller.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
router.get('/:id', getActivity);
|
router.get('/:id', getActivity);
|
||||||
router.put('/:id', putActivity);
|
router.put('/:id', putActivity);
|
||||||
|
router.delete('/:id', deleteActivity);
|
||||||
router.post('/:id/dependencies', postDependency);
|
router.post('/:id/dependencies', postDependency);
|
||||||
router.delete('/:id/dependencies/:dependsOnId', deleteDependency);
|
router.delete('/:id/dependencies/:dependsOnId', deleteDependency);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import * as repo from './repository.js';
|
import * as repo from './repository.js';
|
||||||
import * as projectsRepo from '../projects/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 { recalcProjectProgress, canWriteProject } from '../projects/service.js';
|
||||||
import { HttpError } from '../../utils/http.js';
|
import { HttpError } from '../../utils/http.js';
|
||||||
import { logAudit } from '../../utils/audit.js';
|
import { logAudit } from '../../utils/audit.js';
|
||||||
@@ -95,6 +97,27 @@ export async function updateActivity(id, payload, currentUser) {
|
|||||||
return repo.findById(id);
|
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) {
|
export async function addDependency(activityId, dependsOnActivityId, currentUser) {
|
||||||
if (activityId === dependsOnActivityId) throw new HttpError(400, 'Una actividad no puede depender de si misma');
|
if (activityId === dependsOnActivityId) throw new HttpError(400, 'Una actividad no puede depender de si misma');
|
||||||
const activity = await repo.findById(activityId);
|
const activity = await repo.findById(activityId);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { logAudit } from '../../utils/audit.js';
|
|||||||
import { ROLES } from '../../middleware/auth.js';
|
import { ROLES } from '../../middleware/auth.js';
|
||||||
import { uploadsDir } from '../../middleware/upload.js';
|
import { uploadsDir } from '../../middleware/upload.js';
|
||||||
import * as repo from './repository.js';
|
import * as repo from './repository.js';
|
||||||
|
import { removeAttachment } from './service.js';
|
||||||
|
|
||||||
const VALID_ENTITY_TYPES = new Set(['project', 'activity']);
|
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;
|
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');
|
if (!canDelete) throw new HttpError(403, 'No puedes eliminar este adjunto');
|
||||||
|
|
||||||
await repo.remove(attachment.id);
|
await removeAttachment(attachment);
|
||||||
const filePath = path.join(uploadsDir, attachment.filename);
|
|
||||||
fs.promises.unlink(filePath).catch(() => {});
|
|
||||||
await logAudit({ userId: req.currentUser.id, action: 'delete', entityType: attachment.entity_type, entityId: attachment.entity_id, detail: { attachmentId: attachment.id } });
|
await logAudit({ userId: req.currentUser.id, action: 'delete', entityType: attachment.entity_type, entityId: attachment.entity_id, detail: { attachmentId: attachment.id } });
|
||||||
ok(res, { deleted: true });
|
ok(res, { deleted: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -10,6 +10,12 @@ export async function findByEntity(entityType, entityId) {
|
|||||||
return rows;
|
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 }) {
|
export async function create({ entityType, entityId, userId, message }) {
|
||||||
const [result] = await pool.query(
|
const [result] = await pool.query(
|
||||||
'INSERT INTO comments (entity_type, entity_id, user_id, message) VALUES (?, ?, ?, ?)',
|
'INSERT INTO comments (entity_type, entity_id, user_id, message) VALUES (?, ?, ?, ?)',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.12.5",
|
"version": "1.13.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState } from 'react';
|
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 { useUi } from '../../context/ui-context.jsx';
|
||||||
|
import { useAuth } from '../../context/auth-context.jsx';
|
||||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||||
import { listActivitiesByProject, createActivity, getActivity } from '../../services/activities.js';
|
import { listActivitiesByProject, createActivity, getActivity, deleteActivity } from '../../services/activities.js';
|
||||||
import { listCatalog } from '../../services/catalogs.js';
|
import { listCatalog } from '../../services/catalogs.js';
|
||||||
import { listUsers } from '../../services/users.js';
|
import { listUsers } from '../../services/users.js';
|
||||||
import { ListView } from '../../components/ListView.jsx';
|
import { ListView } from '../../components/ListView.jsx';
|
||||||
@@ -18,7 +19,8 @@ import ActivityDetailPanel from './ActivityDetailPanel.jsx';
|
|||||||
const EMPTY_FORM = { name: '', description: '', responsibleUserId: '', priorityId: '', statusId: '', startDate: '', dueDate: '', isMilestone: false };
|
const EMPTY_FORM = { name: '', description: '', responsibleUserId: '', priorityId: '', statusId: '', startDate: '', dueDate: '', isMilestone: false };
|
||||||
|
|
||||||
export default function ProjectActivitiesTab({ projectId }) {
|
export default function ProjectActivitiesTab({ projectId }) {
|
||||||
const { t, pushToast } = useUi();
|
const { t, pushToast, openDialog } = useUi();
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
const runAction = useAction();
|
const runAction = useAction();
|
||||||
const { data: activities, reload } = useAsync(() => listActivitiesByProject(projectId), [projectId]);
|
const { data: activities, reload } = useAsync(() => listActivitiesByProject(projectId), [projectId]);
|
||||||
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
|
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
|
||||||
@@ -55,6 +57,25 @@ export default function ProjectActivitiesTab({ projectId }) {
|
|||||||
if (selected) setSelected(await getActivity(selected.id));
|
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 = [
|
const columns = [
|
||||||
{ key: 'code', label: t('activity_code') },
|
{ key: 'code', label: t('activity_code') },
|
||||||
{ key: 'name', label: t('common_name') },
|
{ key: 'name', label: t('common_name') },
|
||||||
@@ -86,6 +107,9 @@ export default function ProjectActivitiesTab({ projectId }) {
|
|||||||
getSearchHaystack={(a) => `${a.code} ${a.name} ${a.responsible_name || ''}`}
|
getSearchHaystack={(a) => `${a.code} ${a.name} ${a.responsible_name || ''}`}
|
||||||
getRowKey={(a) => a.id}
|
getRowKey={(a) => a.id}
|
||||||
onRowClick={openActivity}
|
onRowClick={openActivity}
|
||||||
|
rowActions={currentUser?.roleCode !== 'viewer' ? (a) => [
|
||||||
|
{ label: t('common_delete'), icon: Trash2, danger: true, onClick: () => confirmDelete(a) },
|
||||||
|
] : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DetailPopup open={creating} title={t('activity_new')} onClose={() => setCreating(false)} width={640} height={560}>
|
<DetailPopup open={creating} title={t('activity_new')} onClose={() => setCreating(false)} width={640} height={560}>
|
||||||
|
|||||||
@@ -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 createActivity = (projectId, data) => api.post(`/projects/${projectId}/activities`, data);
|
||||||
export const getActivity = (id) => api.get(`/activities/${id}`);
|
export const getActivity = (id) => api.get(`/activities/${id}`);
|
||||||
export const updateActivity = (id, data) => api.put(`/activities/${id}`, data);
|
export const updateActivity = (id, data) => api.put(`/activities/${id}`, data);
|
||||||
|
export const deleteActivity = (id) => api.delete(`/activities/${id}`);
|
||||||
export const addDependency = (id, dependsOnActivityId) => api.post(`/activities/${id}/dependencies`, { dependsOnActivityId });
|
export const addDependency = (id, dependsOnActivityId) => api.post(`/activities/${id}/dependencies`, { dependsOnActivityId });
|
||||||
export const removeDependency = (id, dependsOnId) => api.delete(`/activities/${id}/dependencies/${dependsOnId}`);
|
export const removeDependency = (id, dependsOnId) => api.delete(`/activities/${id}/dependencies/${dependsOnId}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user