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