From 043433bf9b51c51fe59d7a56b9d350a83c95f4a6 Mon Sep 17 00:00:00 2001 From: Milton Gonzalez Date: Thu, 17 Sep 2026 08:59:16 -0500 Subject: [PATCH] feat: convertir una actividad en proyecto (con sus tareas) Una tarea siempre necesita una actividad contenedora, asi que convertir crea DOS cosas: el proyecto nuevo y, dentro, una actividad nueva que recibe las tareas -- y comentarios/adjuntos -- de la actividad original. Se piden ambos nombres (precargados con el nombre actual) antes de confirmar. La actividad original se borra al final, ya vacia de tareas (no choca con el bloqueo de removeActivity). Exige el mismo permiso que crear un proyecto, no solo escribir la actividad. Nuevo POST /api/activities/:id/convert-to-project. Probado de punta a punta en dev: prioridad heredada, tareas y comentario movidos correctamente, actividad original eliminada, navega al proyecto nuevo al terminar. Co-Authored-By: Claude Sonnet 5 --- API.md | 5 ++ CHANGELOG.md | 9 +++ VERSION | 2 +- backend/package.json | 2 +- backend/src/modules/activities/controller.js | 4 ++ backend/src/modules/activities/routes.js | 3 +- backend/src/modules/activities/service.js | 56 +++++++++++++++ backend/src/modules/attachments/repository.js | 6 ++ backend/src/modules/comments/repository.js | 6 ++ backend/src/modules/projects/repository.js | 7 ++ backend/src/modules/tasks/repository.js | 6 ++ frontend/package.json | 2 +- frontend/src/i18n/messages.js | 12 ++++ .../pages/Proyectos/ProjectActivitiesTab.jsx | 69 ++++++++++++++++++- frontend/src/services/activities.js | 2 + 15 files changed, 185 insertions(+), 6 deletions(-) diff --git a/API.md b/API.md index 3232e43..8db2ba4 100644 --- a/API.md +++ b/API.md @@ -81,6 +81,11 @@ permiso de escritura en ambos proyectos. Tareas/comentarios/adjuntos se mueven solos (referencian `activity_id`, no `project_id`). Las dependencias existentes (en cualquiera de los dos sentidos) se eliminan — solo tienen sentido dentro del mismo proyecto. `sort_order` vuelve a 0 en el destino. +- `POST /api/activities/:id/convert-to-project` — `{ projectName, taskActivityName }`. Crea un + proyecto nuevo (`projectName`) y, dentro, una actividad nueva (`taskActivityName`) que recibe + las tareas/comentarios/adjuntos de la actividad original; la original se borra al final (ya sin + tareas). Exige el mismo permiso que crear un proyecto (no solo escribir la actividad). Responde + `{ project, activity }` (201). ### Tareas (`/api/activities/:activityId/tasks`) - `GET` · `POST` · `PUT /api/tasks/:id` · `PATCH /api/tasks/:id/done` · `DELETE /api/tasks/:id` diff --git a/CHANGELOG.md b/CHANGELOG.md index aceb4b8..53ed9fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog - project.linkedcorp.net +## [1.20.0] - 2026-09-17 +### Added +- Convertir una actividad en proyecto (menú de tres puntos en el listado de Actividades). Como + una tarea siempre necesita una actividad contenedora, se crea un proyecto nuevo y, dentro, una + actividad nueva que recibe las tareas — y comentarios/documentos — de la actividad original; se + piden ambos nombres (proyecto y actividad, precargados con el nombre actual) antes de + confirmar. La actividad original se elimina al final, ya vacía de tareas. Nuevo endpoint + `POST /api/activities/:id/convert-to-project`, exige el mismo permiso que crear un proyecto. + ## [1.19.0] - 2026-09-16 ### Added - Panel "Actualizaciones" en Configuración (solo superadmin): compara la versión instalada contra diff --git a/VERSION b/VERSION index 815d5ca..3989355 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.19.0 +1.20.0 diff --git a/backend/package.json b/backend/package.json index b20a7b5..14af306 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "1.19.0", + "version": "1.20.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 fefbd59..357e3b8 100644 --- a/backend/src/modules/activities/controller.js +++ b/backend/src/modules/activities/controller.js @@ -30,6 +30,10 @@ export const putMoveActivity = asyncHandler(async (req, res) => { ok(res, await service.moveActivity(Number(req.params.id), Number(req.body.projectId), req.currentUser)); }); +export const postConvertToProject = asyncHandler(async (req, res) => { + ok(res, await service.convertToProject(Number(req.params.id), req.body, req.currentUser), 201); +}); + 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/routes.js b/backend/src/modules/activities/routes.js index 7b79c40..b050d5d 100644 --- a/backend/src/modules/activities/routes.js +++ b/backend/src/modules/activities/routes.js @@ -1,6 +1,6 @@ import { Router } from 'express'; import { requireAuth } from '../../middleware/auth.js'; -import { getActivity, putActivity, deleteActivity, putMoveActivity, postDependency, deleteDependency } from './controller.js'; +import { getActivity, putActivity, deleteActivity, putMoveActivity, postConvertToProject, postDependency, deleteDependency } from './controller.js'; const router = Router(); @@ -9,6 +9,7 @@ router.get('/:id', getActivity); router.put('/:id', putActivity); router.delete('/:id', deleteActivity); router.put('/:id/move', putMoveActivity); +router.post('/:id/convert-to-project', postConvertToProject); 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 b841423..374caa4 100644 --- a/backend/src/modules/activities/service.js +++ b/backend/src/modules/activities/service.js @@ -1,7 +1,9 @@ import * as repo from './repository.js'; import * as projectsRepo from '../projects/repository.js'; import * as commentsRepo from '../comments/repository.js'; +import * as attachmentsRepo from '../attachments/repository.js'; import * as attachmentsService from '../attachments/service.js'; +import * as tasksRepo from '../tasks/repository.js'; import { recalcProjectProgress, canWriteProject } from '../projects/service.js'; import { HttpError } from '../../utils/http.js'; import { logAudit } from '../../utils/audit.js'; @@ -144,6 +146,60 @@ export async function moveActivity(id, targetProjectId, currentUser) { return moved; } +// Convierte una actividad en un proyecto nuevo. Una tarea no puede vivir directo dentro de un +// proyecto (siempre necesita una actividad contenedora), asi que esto crea DOS cosas: el proyecto +// nuevo, y una actividad nueva adentro que recibe las tareas/comentarios/adjuntos de la original +// (la original se borra al final, ya vacia de tareas). Exige el mismo permiso que crear un +// proyecto (no solo escribir la actividad) — convertir equivale a crear un proyecto de la nada. +export async function convertToProject(activityId, { projectName, taskActivityName }, currentUser) { + const activity = await repo.findById(activityId); + if (!activity) throw new HttpError(404, 'Actividad no encontrada'); + const sourceProject = await getProjectOr404(activity.project_id); + if (!(await canWriteActivity(currentUser, sourceProject, activity))) { + throw new HttpError(403, 'No tienes permisos sobre esta actividad'); + } + if (![ROLES.SUPERADMIN, ROLES.ADMIN, ROLES.IT_MANAGER, ROLES.PROJECT_LEAD].includes(currentUser.roleCode)) { + throw new HttpError(403, 'No tienes permisos para crear proyectos'); + } + + const name = (projectName || '').trim(); + const taskActName = (taskActivityName || '').trim(); + if (!name) throw new HttpError(400, 'El nombre del proyecto es obligatorio'); + if (!taskActName) throw new HttpError(400, 'El nombre de la actividad es obligatorio'); + + const defaultStatusId = await projectsRepo.getDefaultStatusId(); + if (!defaultStatusId) throw new HttpError(500, 'No hay catalogo de estados de proyecto configurado'); + + const newProject = await projectsRepo.create({ + name, priorityId: activity.priority_id, statusId: defaultStatusId, + ownerUserId: currentUser.id, createdBy: currentUser.id, + }); + + const newActivity = await repo.create(newProject.id, { + name: taskActName, priorityId: activity.priority_id, statusId: activity.status_id, + createdBy: currentUser.id, + }); + + await tasksRepo.moveAll(activityId, newActivity.id); + await commentsRepo.reassignEntity('activity', activityId, newActivity.id); + await attachmentsRepo.reassignEntity('activity', activityId, newActivity.id); + + const counts = await tasksRepo.countsByActivity(newActivity.id); + await recalcFromTasks(newActivity.id, counts.done, counts.total); + + await repo.remove(activityId); // ya sin tareas: el bloqueo de removeActivity no aplica aqui. + + await logAudit({ + userId: currentUser.id, action: 'convert_to_project', entityType: 'activity', entityId: activityId, + detail: { newProjectId: newProject.id, newActivityId: newActivity.id }, + }); + + await recalcProjectProgress(activity.project_id); + await recalcProjectProgress(newProject.id); + + return { project: newProject, activity: newActivity }; +} + // Nuevo orden manual (arrastrar con el mouse): mismo permiso que crear actividades en el proyecto. export async function reorderActivities(projectId, ids, currentUser) { const project = await getProjectOr404(projectId); diff --git a/backend/src/modules/attachments/repository.js b/backend/src/modules/attachments/repository.js index 5348d31..fd295be 100644 --- a/backend/src/modules/attachments/repository.js +++ b/backend/src/modules/attachments/repository.js @@ -28,3 +28,9 @@ export async function create({ entityType, entityId, filename, originalName, mim export async function remove(id) { await pool.query('DELETE FROM attachments WHERE id = ?', [id]); } + +// Usado al convertir una actividad en proyecto: sus adjuntos pasan a la actividad nueva que +// hereda sus tareas, en vez de perderse cuando se borra la actividad original. +export async function reassignEntity(entityType, oldEntityId, newEntityId) { + await pool.query('UPDATE attachments SET entity_id = ? WHERE entity_type = ? AND entity_id = ?', [newEntityId, entityType, oldEntityId]); +} diff --git a/backend/src/modules/comments/repository.js b/backend/src/modules/comments/repository.js index e6b6f68..3e2f5cb 100644 --- a/backend/src/modules/comments/repository.js +++ b/backend/src/modules/comments/repository.js @@ -16,6 +16,12 @@ export async function removeByEntity(entityType, entityId) { await pool.query('DELETE FROM comments WHERE entity_type = ? AND entity_id = ?', [entityType, entityId]); } +// Usado al convertir una actividad en proyecto: sus comentarios pasan a la actividad nueva que +// hereda sus tareas, en vez de perderse cuando se borra la actividad original. +export async function reassignEntity(entityType, oldEntityId, newEntityId) { + await pool.query('UPDATE comments SET entity_id = ? WHERE entity_type = ? AND entity_id = ?', [newEntityId, entityType, oldEntityId]); +} + 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/backend/src/modules/projects/repository.js b/backend/src/modules/projects/repository.js index 2b96a83..6bf3c81 100644 --- a/backend/src/modules/projects/repository.js +++ b/backend/src/modules/projects/repository.js @@ -72,6 +72,13 @@ export async function create(data) { return findById(id); } +// Usado al crear un proyecto sin elegir estado explicito (ej. al convertir una actividad en +// proyecto): el primer estado no-cerrado por orden manual, tipicamente "Planificado". +export async function getDefaultStatusId() { + const [rows] = await pool.query('SELECT id FROM project_statuses WHERE is_closed = 0 ORDER BY sort_order LIMIT 1'); + return rows[0]?.id || null; +} + export async function update(id, data) { await pool.query( `UPDATE projects SET diff --git a/backend/src/modules/tasks/repository.js b/backend/src/modules/tasks/repository.js index 72a3e31..ce43614 100644 --- a/backend/src/modules/tasks/repository.js +++ b/backend/src/modules/tasks/repository.js @@ -48,6 +48,12 @@ export async function remove(id) { await pool.query('DELETE FROM tasks WHERE id = ?', [id]); } +// Usado al convertir una actividad en proyecto: sus tareas pasan a la actividad nueva que se crea +// para contenerlas. sort_order vuelve a 0 ("nunca reordenado a mano") en el destino. +export async function moveAll(oldActivityId, newActivityId) { + await pool.query('UPDATE tasks SET activity_id = ?, sort_order = 0 WHERE activity_id = ?', [newActivityId, oldActivityId]); +} + export async function countsByActivity(activityId) { const [rows] = await pool.query( 'SELECT COUNT(*) AS total, SUM(is_done) AS done FROM tasks WHERE activity_id = ?', diff --git a/frontend/package.json b/frontend/package.json index 51339c3..32cc533 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "1.19.0", + "version": "1.20.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index ce633dc..084502e 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -217,6 +217,12 @@ export const messages = { activity_move_title: 'Mover actividad', activity_move_target: 'Proyecto destino', activity_move_confirm_msg: 'Se moverá la actividad "{activity}" (junto con sus tareas) al proyecto "{project}". ¿Deseas continuar?', + activity_convert: 'Convertir en proyecto', + activity_convert_title: 'Convertir actividad en proyecto', + activity_convert_hint: 'Una tarea siempre necesita una actividad contenedora — se crea un proyecto nuevo y, dentro, una actividad que recibirá las tareas (y comentarios/documentos) de esta actividad.', + activity_convert_project_name: 'Nombre del proyecto nuevo', + activity_convert_activity_name: 'Nombre de la actividad (contendrá las tareas)', + activity_convert_confirm_msg: 'Se convertirá la actividad "{activity}" en el proyecto nuevo "{project}", con una actividad "{newActivity}" que tendrá sus tareas. ¿Deseas continuar?', activity_add_dependency: 'Agregar dependencia', activity_depends_on: 'Depende de', activity_no_dependency_options: 'No hay otras actividades en este proyecto para agregar como dependencia.', @@ -477,6 +483,12 @@ export const messages = { activity_move_title: 'Move activity', activity_move_target: 'Target project', activity_move_confirm_msg: 'Activity "{activity}" (along with its tasks) will move to project "{project}". Continue?', + activity_convert: 'Convert to project', + activity_convert_title: 'Convert activity to project', + activity_convert_hint: 'A task always needs a containing activity — a new project is created and, inside it, an activity that will receive this activity\'s tasks (and comments/documents).', + activity_convert_project_name: 'New project name', + activity_convert_activity_name: 'Activity name (will hold the tasks)', + activity_convert_confirm_msg: 'Activity "{activity}" will become the new project "{project}", with an activity "{newActivity}" holding its tasks. Continue?', activity_add_dependency: 'Add dependency', activity_depends_on: 'Depends on', activity_no_dependency_options: 'There are no other activities in this project to add as a dependency.', diff --git a/frontend/src/pages/Proyectos/ProjectActivitiesTab.jsx b/frontend/src/pages/Proyectos/ProjectActivitiesTab.jsx index 852b90c..07fbe5a 100644 --- a/frontend/src/pages/Proyectos/ProjectActivitiesTab.jsx +++ b/frontend/src/pages/Proyectos/ProjectActivitiesTab.jsx @@ -1,10 +1,11 @@ import { useEffect, useRef, useState } from 'react'; -import { Plus, Save, Trash2, ArrowRightLeft } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import { Plus, Save, Trash2, ArrowRightLeft, FolderUp } 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 { useDebouncedValue } from '../../hooks/useDebouncedValue.js'; -import { listActivitiesByProject, createActivity, getActivity, deleteActivity, reorderActivities, moveActivity } from '../../services/activities.js'; +import { listActivitiesByProject, createActivity, getActivity, deleteActivity, reorderActivities, moveActivity, convertActivityToProject } from '../../services/activities.js'; import { listProjects } from '../../services/projects.js'; import { search as searchApi } from '../../services/search.js'; import { listCatalog } from '../../services/catalogs.js'; @@ -25,6 +26,7 @@ const EMPTY_FORM = { name: '', description: '', responsibleUserId: '', priorityI export default function ProjectActivitiesTab({ projectId, openActivityId, openActivityTab }) { const { t, pushToast, openDialog } = useUi(); const { user: currentUser } = useAuth(); + const navigate = useNavigate(); const runAction = useAction(); const { data: activities, reload } = useAsync(() => listActivitiesByProject(projectId), [projectId]); const { data: priorities } = useAsync(() => listCatalog('priorities'), []); @@ -39,6 +41,8 @@ export default function ProjectActivitiesTab({ projectId, openActivityId, openAc const [selectedInitialTab, setSelectedInitialTab] = useState(undefined); const [moving, setMoving] = useState(null); const [targetProjectId, setTargetProjectId] = useState(''); + const [converting, setConverting] = useState(null); + const [convertForm, setConvertForm] = useState({ projectName: '', taskActivityName: '' }); // Busca tambien tareas de esta actividad (no solo actividades, que ya filtra la tabla mas abajo // via getSearchHaystack) — resultados con su ruta "Actividad - Tarea" en un panel flotante. @@ -132,6 +136,37 @@ export default function ProjectActivitiesTab({ projectId, openActivityId, openAc }); } + function startConvert(row) { + setConverting(row); + setConvertForm({ projectName: row.name, taskActivityName: row.name }); + } + + function confirmConvert() { + const { projectName, taskActivityName } = convertForm; + if (!projectName.trim() || !taskActivityName.trim()) return; + const message = t('activity_convert_confirm_msg') + .replace('{activity}', converting.name) + .replace('{project}', projectName.trim()) + .replace('{newActivity}', taskActivityName.trim()); + openDialog({ + tone: 'warning', + title: t('activity_convert_title'), + message, + actions: [ + { label: t('common_cancel'), variant: 'standard' }, + { + label: t('common_accept'), variant: 'accent', + onClick: () => runAction(async () => { + const result = await convertActivityToProject(converting.id, projectName.trim(), taskActivityName.trim()); + setConverting(null); + pushToast({ tone: 'success', message: t('common_saved') }); + navigate(`/proyectos/${result.project.id}`); + }), + }, + ], + }); + } + function confirmDelete(row) { openDialog({ tone: 'warning', @@ -191,6 +226,7 @@ export default function ProjectActivitiesTab({ projectId, openActivityId, openAc onReorder={currentUser?.roleCode !== 'viewer' ? handleReorder : undefined} rowActions={currentUser?.roleCode !== 'viewer' ? (a) => [ { label: t('activity_move'), icon: ArrowRightLeft, onClick: () => startMove(a) }, + { label: t('activity_convert'), icon: FolderUp, onClick: () => startConvert(a) }, { label: t('common_delete'), icon: Trash2, danger: true, onClick: () => confirmDelete(a) }, ] : undefined} /> @@ -209,6 +245,35 @@ export default function ProjectActivitiesTab({ projectId, openActivityId, openAc )} + setConverting(null)} width={520} height={360}> + {converting && ( + <> +
{t('activity_convert_hint')}
+ + setConvertForm((f) => ({ ...f, projectName: e.target.value }))} + /> + + + setConvertForm((f) => ({ ...f, taskActivityName: e.target.value }))} + /> + +
+ + +
+ + )} +
+ setCreating(false)} width={640} height={560}> setForm((f) => ({ ...f, name: e.target.value }))} autoFocus /> diff --git a/frontend/src/services/activities.js b/frontend/src/services/activities.js index cb5c335..c958ee0 100644 --- a/frontend/src/services/activities.js +++ b/frontend/src/services/activities.js @@ -6,6 +6,8 @@ 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 moveActivity = (id, projectId) => api.put(`/activities/${id}/move`, { projectId }); +export const convertActivityToProject = (id, projectName, taskActivityName) => + api.post(`/activities/${id}/convert-to-project`, { projectName, taskActivityName }); export const reorderActivities = (projectId, ids) => api.put(`/projects/${projectId}/activities/reorder`, { ids }); export const addDependency = (id, dependsOnActivityId) => api.post(`/activities/${id}/dependencies`, { dependsOnActivityId }); export const removeDependency = (id, dependsOnId) => api.delete(`/activities/${id}/dependencies/${dependsOnId}`);