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:
@@ -77,6 +77,10 @@
|
|||||||
- `POST /api/activities/:id/dependencies` · `DELETE /api/activities/:id/dependencies/:dependsOnId`
|
- `POST /api/activities/:id/dependencies` · `DELETE /api/activities/:id/dependencies/:dependsOnId`
|
||||||
- `PUT /api/projects/:projectId/activities/reorder` — `{ ids: [...] }` (orden manual, arrastrar
|
- `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`).
|
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`)
|
### Tareas (`/api/activities/:activityId/tasks`)
|
||||||
- `GET` · `POST` · `PUT /api/tasks/:id` · `PATCH /api/tasks/:id/done` · `DELETE /api/tasks/:id`
|
- `GET` · `POST` · `PUT /api/tasks/:id` · `PATCH /api/tasks/:id/done` · `DELETE /api/tasks/:id`
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# Changelog - project.linkedcorp.net
|
# 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
|
## [1.16.4] - 2026-09-16
|
||||||
### Added
|
### Added
|
||||||
- Columna "Actualizado" (fecha y hora) en el listado de Actividades de un proyecto — mismo
|
- Columna "Actualizado" (fecha y hora) en el listado de Actividades de un proyecto — mismo
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "1.16.4",
|
"version": "1.17.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ export const deleteActivity = asyncHandler(async (req, res) => {
|
|||||||
ok(res, { deleted: true });
|
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) => {
|
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);
|
||||||
|
|||||||
@@ -64,6 +64,17 @@ export async function remove(id) {
|
|||||||
await pool.query('DELETE FROM activities WHERE id = ?', [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
|
// 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.
|
// migracion). Solo reordena las filas de projectId que vengan en ids, ignora el resto.
|
||||||
export async function reorder(projectId, ids) {
|
export async function reorder(projectId, ids) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { requireAuth } from '../../middleware/auth.js';
|
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();
|
const router = Router();
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ router.use(requireAuth);
|
|||||||
router.get('/:id', getActivity);
|
router.get('/:id', getActivity);
|
||||||
router.put('/:id', putActivity);
|
router.put('/:id', putActivity);
|
||||||
router.delete('/:id', deleteActivity);
|
router.delete('/:id', deleteActivity);
|
||||||
|
router.put('/:id/move', putMoveActivity);
|
||||||
router.post('/:id/dependencies', postDependency);
|
router.post('/:id/dependencies', postDependency);
|
||||||
router.delete('/:id/dependencies/:dependsOnId', deleteDependency);
|
router.delete('/:id/dependencies/:dependsOnId', deleteDependency);
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,32 @@ export async function removeActivity(id, currentUser) {
|
|||||||
await recalcProjectProgress(activity.project_id);
|
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.
|
// Nuevo orden manual (arrastrar con el mouse): mismo permiso que crear actividades en el proyecto.
|
||||||
export async function reorderActivities(projectId, ids, currentUser) {
|
export async function reorderActivities(projectId, ids, currentUser) {
|
||||||
const project = await getProjectOr404(projectId);
|
const project = await getProjectOr404(projectId);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.16.4",
|
"version": "1.17.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export const messages = {
|
|||||||
common_edit: 'Editar',
|
common_edit: 'Editar',
|
||||||
common_save: 'Guardar',
|
common_save: 'Guardar',
|
||||||
common_cancel: 'Cancelar',
|
common_cancel: 'Cancelar',
|
||||||
|
common_accept: 'Aceptar',
|
||||||
common_delete: 'Eliminar',
|
common_delete: 'Eliminar',
|
||||||
common_close: 'Cerrar',
|
common_close: 'Cerrar',
|
||||||
common_search: 'Buscar...',
|
common_search: 'Buscar...',
|
||||||
@@ -199,6 +200,10 @@ export const messages = {
|
|||||||
activity_milestone: 'Es hito',
|
activity_milestone: 'Es hito',
|
||||||
activity_estimated_hours: 'Horas estimadas',
|
activity_estimated_hours: 'Horas estimadas',
|
||||||
activity_dependencies: 'Dependencias',
|
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_add_dependency: 'Agregar dependencia',
|
||||||
activity_depends_on: 'Depende de',
|
activity_depends_on: 'Depende de',
|
||||||
activity_no_dependency_options: 'No hay otras actividades en este proyecto para agregar como dependencia.',
|
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_edit: 'Edit',
|
||||||
common_save: 'Save',
|
common_save: 'Save',
|
||||||
common_cancel: 'Cancel',
|
common_cancel: 'Cancel',
|
||||||
|
common_accept: 'Accept',
|
||||||
common_delete: 'Delete',
|
common_delete: 'Delete',
|
||||||
common_close: 'Close',
|
common_close: 'Close',
|
||||||
common_search: 'Search...',
|
common_search: 'Search...',
|
||||||
@@ -441,6 +447,10 @@ export const messages = {
|
|||||||
activity_milestone: 'Is milestone',
|
activity_milestone: 'Is milestone',
|
||||||
activity_estimated_hours: 'Estimated hours',
|
activity_estimated_hours: 'Estimated hours',
|
||||||
activity_dependencies: 'Dependencies',
|
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_add_dependency: 'Add dependency',
|
||||||
activity_depends_on: 'Depends on',
|
activity_depends_on: 'Depends on',
|
||||||
activity_no_dependency_options: 'There are no other activities in this project to add as a dependency.',
|
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 { 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 { useUi } from '../../context/ui-context.jsx';
|
||||||
import { useAuth } from '../../context/auth-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, 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 { 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';
|
||||||
@@ -26,15 +27,21 @@ export default function ProjectActivitiesTab({ projectId }) {
|
|||||||
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
|
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
|
||||||
const { data: statuses } = useAsync(() => listCatalog('activity-statuses'), []);
|
const { data: statuses } = useAsync(() => listCatalog('activity-statuses'), []);
|
||||||
const { data: users } = useAsync(listUsers, []);
|
const { data: users } = useAsync(listUsers, []);
|
||||||
|
const { data: allProjects } = useAsync(() => listProjects(), []);
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [form, setForm] = useState(EMPTY_FORM);
|
const [form, setForm] = useState(EMPTY_FORM);
|
||||||
const [selected, setSelected] = useState(null);
|
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 priorityOptions = (priorities || []).map((p) => ({ value: p.id, label: p.name }));
|
||||||
const statusOptions = (statuses || []).map((s) => ({ value: s.id, label: s.name }));
|
const statusOptions = (statuses || []).map((s) => ({ value: s.id, label: s.name }));
|
||||||
const userOptions = (users || []).map((u) => ({ value: u.id, label: u.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() {
|
async function save() {
|
||||||
if (!form.name.trim() || !form.priorityId || !form.statusId) return;
|
if (!form.name.trim() || !form.priorityId || !form.statusId) return;
|
||||||
@@ -62,6 +69,34 @@ export default function ProjectActivitiesTab({ projectId }) {
|
|||||||
await reload();
|
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) {
|
function confirmDelete(row) {
|
||||||
openDialog({
|
openDialog({
|
||||||
tone: 'warning',
|
tone: 'warning',
|
||||||
@@ -115,10 +150,25 @@ export default function ProjectActivitiesTab({ projectId }) {
|
|||||||
onRowClick={openActivity}
|
onRowClick={openActivity}
|
||||||
onReorder={currentUser?.roleCode !== 'viewer' ? handleReorder : undefined}
|
onReorder={currentUser?.roleCode !== 'viewer' ? handleReorder : undefined}
|
||||||
rowActions={currentUser?.roleCode !== 'viewer' ? (a) => [
|
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) },
|
{ label: t('common_delete'), icon: Trash2, danger: true, onClick: () => confirmDelete(a) },
|
||||||
] : undefined}
|
] : 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}>
|
<DetailPopup open={creating} title={t('activity_new')} onClose={() => setCreating(false)} width={640} height={560}>
|
||||||
<Field label={t('common_name')}>
|
<Field label={t('common_name')}>
|
||||||
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
|
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export const createActivity = (projectId, data) => api.post(`/projects/${project
|
|||||||
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 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 reorderActivities = (projectId, ids) => api.put(`/projects/${projectId}/activities/reorder`, { ids });
|
||||||
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