feat: mover una actividad (con sus tareas) a otro proyecto

Menu de tres puntos en el listado de Actividades -> elegir proyecto destino -> confirmar
("Se movera la actividad... (junto con sus tareas)... 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
(addDependency ya lo exigia). Nuevo PUT /api/activities/:id/move, exige permiso de escritura en
ambos proyectos (origen y destino).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-16 14:47:57 -05:00
parent 397fed5eb1
commit 53570bd4f8
12 changed files with 122 additions and 6 deletions
+4
View File
@@ -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`
+9
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
1.16.4
1.17.0
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "project-linkedcorp-backend",
"version": "1.16.4",
"version": "1.17.0",
"private": true,
"type": "module",
"main": "src/server.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);
@@ -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) {
+2 -1
View File
@@ -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);
+26
View File
@@ -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);
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "project-linkedcorp-frontend",
"private": true,
"version": "1.16.4",
"version": "1.17.0",
"type": "module",
"scripts": {
"dev": "vite",
+10
View File
@@ -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.',
@@ -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}
/>
<DetailPopup open={!!moving} title={t('activity_move_title')} onClose={() => setMoving(null)} width={480} height={260}>
{moving && (
<>
<Field label={t('activity_move_target')}>
<ComboBox options={projectOptions} value={targetProjectId} onChange={setTargetProjectId} />
</Field>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<Button variant="accent" icon={ArrowRightLeft} onClick={confirmMove} disabled={!targetProjectId}>{t('activity_move')}</Button>
<Button variant="standard" onClick={() => setMoving(null)}>{t('common_cancel')}</Button>
</div>
</>
)}
</DetailPopup>
<DetailPopup open={creating} title={t('activity_new')} onClose={() => setCreating(false)} width={640} height={560}>
<Field label={t('common_name')}>
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
+1
View File
@@ -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}`);