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
@@ -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 />