diff --git a/API.md b/API.md index 89db016..5b54dd5 100644 --- a/API.md +++ b/API.md @@ -77,6 +77,10 @@ - `POST /api/activities/:id/dependencies` · `DELETE /api/activities/:id/dependencies/:dependsOnId` - `PUT /api/projects/:projectId/activities/reorder` — `{ ids: [...] }` (orden manual, arrastrar con el mouse); posiciones 1..N, nunca 0 (0 = "nunca reordenado a mano", ver `sort_order`). +- `PUT /api/activities/:id/move` — `{ projectId }`. Mueve la actividad a otro proyecto; exige + 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. ### 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 873edcf..cf25894 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog - project.linkedcorp.net +## [1.17.0] - 2026-09-16 +### Added +- Mover una actividad a otro proyecto (menú de tres puntos en el listado de Actividades): se + eligen el proyecto destino y se confirma con un mensaje ("Se moverá la actividad "X" (junto con + sus tareas) al proyecto "Y". ¿Deseas continuar?", Aceptar/Cancelar) antes de ejecutar. Tareas, + comentarios y adjuntos se mueven solos (referencian la actividad, no el proyecto). Las + dependencias existentes se eliminan al mover (solo tienen sentido dentro del mismo proyecto). + Nuevo `PUT /api/activities/:id/move`, exige permiso de escritura en ambos proyectos. + ## [1.16.4] - 2026-09-16 ### Added - Columna "Actualizado" (fecha y hora) en el listado de Actividades de un proyecto — mismo diff --git a/VERSION b/VERSION index a232073..092afa1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.16.4 +1.17.0 diff --git a/backend/package.json b/backend/package.json index 04f1255..29d30cd 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "1.16.4", + "version": "1.17.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 ae961a3..fefbd59 100644 --- a/backend/src/modules/activities/controller.js +++ b/backend/src/modules/activities/controller.js @@ -26,6 +26,10 @@ export const deleteActivity = asyncHandler(async (req, res) => { ok(res, { deleted: true }); }); +export const putMoveActivity = asyncHandler(async (req, res) => { + ok(res, await service.moveActivity(Number(req.params.id), Number(req.body.projectId), req.currentUser)); +}); + 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 4a22520..784c261 100644 --- a/backend/src/modules/activities/repository.js +++ b/backend/src/modules/activities/repository.js @@ -64,6 +64,17 @@ export async function remove(id) { await pool.query('DELETE FROM activities WHERE id = ?', [id]); } +// Las tareas (tasks.activity_id) y los comentarios/adjuntos (entity_id de la actividad) se +// "mueven" solos, sin tocar nada — no referencian project_id. sort_order vuelve a 0 ("nunca +// reordenado a mano") para que aparezca al final del proyecto destino sin pisar su orden manual. +// Las dependencias solo tienen sentido dentro del mismo proyecto (addDependency ya lo exige), asi +// que las existentes (en cualquiera de los dos sentidos) quedan invalidas al cambiar de proyecto. +export async function moveToProject(id, projectId) { + await pool.query('UPDATE activities SET project_id = ?, sort_order = 0 WHERE id = ?', [projectId, id]); + await pool.query('DELETE FROM activity_dependencies WHERE activity_id = ? OR depends_on_activity_id = ?', [id, id]); + return findById(id); +} + // Posiciones SIEMPRE 1..N (nunca 0 — 0 queda reservado para "nunca reordenado a mano" en la // migracion). Solo reordena las filas de projectId que vengan en ids, ignora el resto. export async function reorder(projectId, ids) { diff --git a/backend/src/modules/activities/routes.js b/backend/src/modules/activities/routes.js index 99e4262..7b79c40 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, postDependency, deleteDependency } from './controller.js'; +import { getActivity, putActivity, deleteActivity, putMoveActivity, postDependency, deleteDependency } from './controller.js'; const router = Router(); @@ -8,6 +8,7 @@ router.use(requireAuth); router.get('/:id', getActivity); router.put('/:id', putActivity); router.delete('/:id', deleteActivity); +router.put('/:id/move', putMoveActivity); 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 6a46404..b841423 100644 --- a/backend/src/modules/activities/service.js +++ b/backend/src/modules/activities/service.js @@ -118,6 +118,32 @@ export async function removeActivity(id, currentUser) { await recalcProjectProgress(activity.project_id); } +// Mueve la actividad (y sus tareas/comentarios/adjuntos, que van solos por referenciar activity_id +// en vez de project_id) a otro proyecto. Exige permiso de escritura en AMBOS proyectos. +export async function moveActivity(id, targetProjectId, currentUser) { + const activity = await repo.findById(id); + 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 (Number(targetProjectId) === activity.project_id) { + throw new HttpError(400, 'La actividad ya pertenece a este proyecto'); + } + const targetProject = await getProjectOr404(targetProjectId); + if (!(await canWriteProject(currentUser, targetProject))) { + throw new HttpError(403, 'No tienes permisos sobre el proyecto de destino'); + } + const moved = await repo.moveToProject(id, targetProjectId); + await logAudit({ + userId: currentUser.id, action: 'move', entityType: 'activity', entityId: id, + detail: { fromProjectId: activity.project_id, toProjectId: Number(targetProjectId) }, + }); + await recalcProjectProgress(activity.project_id); + await recalcProjectProgress(targetProjectId); + return moved; +} + // 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/frontend/package.json b/frontend/package.json index 2edc249..64326ce 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "1.16.4", + "version": "1.17.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index 6973b88..8dffbe6 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -76,6 +76,7 @@ export const messages = { common_edit: 'Editar', common_save: 'Guardar', common_cancel: 'Cancelar', + common_accept: 'Aceptar', common_delete: 'Eliminar', common_close: 'Cerrar', common_search: 'Buscar...', @@ -199,6 +200,10 @@ export const messages = { activity_milestone: 'Es hito', activity_estimated_hours: 'Horas estimadas', activity_dependencies: 'Dependencias', + activity_move: 'Mover a otro proyecto', + 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_add_dependency: 'Agregar dependencia', activity_depends_on: 'Depende de', activity_no_dependency_options: 'No hay otras actividades en este proyecto para agregar como dependencia.', @@ -318,6 +323,7 @@ export const messages = { common_edit: 'Edit', common_save: 'Save', common_cancel: 'Cancel', + common_accept: 'Accept', common_delete: 'Delete', common_close: 'Close', common_search: 'Search...', @@ -441,6 +447,10 @@ export const messages = { activity_milestone: 'Is milestone', activity_estimated_hours: 'Estimated hours', activity_dependencies: 'Dependencies', + activity_move: 'Move to another project', + 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_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 c9f561c..3487273 100644 --- a/frontend/src/pages/Proyectos/ProjectActivitiesTab.jsx +++ b/frontend/src/pages/Proyectos/ProjectActivitiesTab.jsx @@ -1,9 +1,10 @@ import { useState } from 'react'; -import { Plus, Save, Trash2 } from 'lucide-react'; +import { Plus, Save, Trash2, ArrowRightLeft } 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, deleteActivity, reorderActivities } from '../../services/activities.js'; +import { listActivitiesByProject, createActivity, getActivity, deleteActivity, reorderActivities, moveActivity } from '../../services/activities.js'; +import { listProjects } from '../../services/projects.js'; import { listCatalog } from '../../services/catalogs.js'; import { listUsers } from '../../services/users.js'; import { ListView } from '../../components/ListView.jsx'; @@ -26,15 +27,21 @@ export default function ProjectActivitiesTab({ projectId }) { const { data: priorities } = useAsync(() => listCatalog('priorities'), []); const { data: statuses } = useAsync(() => listCatalog('activity-statuses'), []); const { data: users } = useAsync(listUsers, []); + const { data: allProjects } = useAsync(() => listProjects(), []); const [search, setSearch] = useState(''); const [creating, setCreating] = useState(false); const [form, setForm] = useState(EMPTY_FORM); const [selected, setSelected] = useState(null); + const [moving, setMoving] = useState(null); + const [targetProjectId, setTargetProjectId] = useState(''); 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 })); + const projectOptions = (allProjects || []) + .filter((p) => p.id !== Number(projectId)) + .map((p) => ({ value: p.id, label: `${p.code} — ${p.name}` })); async function save() { if (!form.name.trim() || !form.priorityId || !form.statusId) return; @@ -62,6 +69,34 @@ export default function ProjectActivitiesTab({ projectId }) { await reload(); } + function startMove(row) { + setMoving(row); + setTargetProjectId(''); + } + + function confirmMove() { + const target = (allProjects || []).find((p) => p.id === Number(targetProjectId)); + if (!target) return; + const message = t('activity_move_confirm_msg').replace('{activity}', moving.name).replace('{project}', target.name); + openDialog({ + tone: 'warning', + title: t('activity_move_title'), + message, + actions: [ + { label: t('common_cancel'), variant: 'standard' }, + { + label: t('common_accept'), variant: 'accent', + onClick: () => runAction(async () => { + await moveActivity(moving.id, Number(targetProjectId)); + setMoving(null); + await reload(); + pushToast({ tone: 'success', message: t('common_saved') }); + }), + }, + ], + }); + } + function confirmDelete(row) { openDialog({ tone: 'warning', @@ -115,10 +150,25 @@ export default function ProjectActivitiesTab({ projectId }) { onRowClick={openActivity} onReorder={currentUser?.roleCode !== 'viewer' ? handleReorder : undefined} rowActions={currentUser?.roleCode !== 'viewer' ? (a) => [ + { label: t('activity_move'), icon: ArrowRightLeft, onClick: () => startMove(a) }, { label: t('common_delete'), icon: Trash2, danger: true, onClick: () => confirmDelete(a) }, ] : undefined} /> + setMoving(null)} width={480} height={260}> + {moving && ( + <> + + + +
+ + +
+ + )} +
+ 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 7cc2467..cb5c335 100644 --- a/frontend/src/services/activities.js +++ b/frontend/src/services/activities.js @@ -5,6 +5,7 @@ export const createActivity = (projectId, data) => api.post(`/projects/${project 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 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}`);