feat: convertir una actividad en proyecto (con sus tareas)

Una tarea siempre necesita una actividad contenedora, asi que convertir crea DOS cosas: el
proyecto nuevo y, dentro, una actividad nueva que recibe las tareas -- y comentarios/adjuntos --
de la actividad original. Se piden ambos nombres (precargados con el nombre actual) antes de
confirmar. La actividad original se borra al final, ya vacia de tareas (no choca con el bloqueo
de removeActivity). Exige el mismo permiso que crear un proyecto, no solo escribir la actividad.

Nuevo POST /api/activities/:id/convert-to-project. Probado de punta a punta en dev: prioridad
heredada, tareas y comentario movidos correctamente, actividad original eliminada, navega al
proyecto nuevo al terminar.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-09-17 08:59:16 -05:00
parent cc7928464b
commit 043433bf9b
15 changed files with 185 additions and 6 deletions
+12
View File
@@ -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.',
@@ -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
)}
</DetailPopup>
<DetailPopup open={!!converting} title={t('activity_convert_title')} onClose={() => setConverting(null)} width={520} height={360}>
{converting && (
<>
<div className="text-muted" style={{ fontSize: '.78rem', marginBottom: 12 }}>{t('activity_convert_hint')}</div>
<Field label={t('activity_convert_project_name')}>
<input
className="input" value={convertForm.projectName} autoFocus
onChange={(e) => setConvertForm((f) => ({ ...f, projectName: e.target.value }))}
/>
</Field>
<Field label={t('activity_convert_activity_name')}>
<input
className="input" value={convertForm.taskActivityName}
onChange={(e) => setConvertForm((f) => ({ ...f, taskActivityName: e.target.value }))}
/>
</Field>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<Button
variant="accent" icon={FolderUp} onClick={confirmConvert}
disabled={!convertForm.projectName.trim() || !convertForm.taskActivityName.trim()}
>
{t('activity_convert')}
</Button>
<Button variant="standard" onClick={() => setConverting(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 />
+2
View File
@@ -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}`);