feat: arrastrar con el mouse para reordenar actividades y tareas
Hook reutilizable useDragReorder (drag & drop nativo HTML5, sin libreria nueva) usado en dos lugares: ListView (opt-in via onReorder, deshabilitado mientras haya un ordenamiento por columna activo) para el listado de actividades de un proyecto, y el listado de tareas de una actividad. Orden persistido en sort_order (activities y tasks, migracion 012, posiciones siempre 1..N). Fix real encontrado durante las pruebas: sincronizar el estado interno del hook por la REFERENCIA del arreglo de entrada entraba en loop infinito de renders cuando el llamador pasa "algo || []" (un arreglo nuevo en cada render mientras los datos cargan). Corregido para sincronizar por el contenido (ids), no por referencia. Probado en dev con curl (persistencia del orden) y Playwright (arrastrar filas en el navegador, orden visible y guardado correctamente, sin errores de consola tras el fix). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Pencil, Save, X, Plus, Trash2, Link2, Repeat } from 'lucide-react';
|
||||
import { Pencil, Save, X, Plus, Trash2, Link2, Repeat, GripVertical } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||
import { useDragReorder } from '../../hooks/useDragReorder.js';
|
||||
import { updateActivity, addDependency, removeDependency } from '../../services/activities.js';
|
||||
import { listTasks, createTask, updateTask, setTaskDone, deleteTask } from '../../services/tasks.js';
|
||||
import { listTasks, createTask, updateTask, setTaskDone, deleteTask, reorderTasks } from '../../services/tasks.js';
|
||||
import { promoteTaskToRecurring } from '../../services/recurringTasks.js';
|
||||
import { listCatalog } from '../../services/catalogs.js';
|
||||
import { listUsers } from '../../services/users.js';
|
||||
@@ -34,6 +35,10 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
|
||||
const { data: statuses } = useAsync(() => listCatalog('activity-statuses'), []);
|
||||
const { data: users } = useAsync(listUsers, []);
|
||||
const { data: tasks, reload: reloadTasks } = useAsync(() => listTasks(activity.id), [activity.id]);
|
||||
const { order: orderedTasks, getDragHandlers, isDragging } = useDragReorder(tasks || [], async (ids) => {
|
||||
await reorderTasks(activity.id, ids);
|
||||
await reloadTasks();
|
||||
});
|
||||
|
||||
const [tab, setTab] = useState('detail');
|
||||
const [editing, setEditing] = useState(false);
|
||||
@@ -228,8 +233,13 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
|
||||
|
||||
{tab === 'tasks' && (
|
||||
<div>
|
||||
{(tasks || []).map((task) => (
|
||||
<div key={task.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 0' }}>
|
||||
{orderedTasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className={isDragging(task.id) ? 'drag-row dragging' : 'drag-row'}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 0' }}
|
||||
{...(editingTaskId === task.id ? {} : getDragHandlers(task))}
|
||||
>
|
||||
{editingTaskId === task.id ? (
|
||||
<>
|
||||
<input
|
||||
@@ -242,6 +252,7 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GripVertical size={15} className="drag-handle" style={{ opacity: .6, cursor: 'grab', flexShrink: 0 }} />
|
||||
<input type="checkbox" checked={!!task.is_done} onChange={() => toggleTask(task)} />
|
||||
<span style={{ flex: 1, fontSize: '.8rem', textDecoration: task.is_done ? 'line-through' : 'none', opacity: task.is_done ? .6 : 1 }}>{task.name}</span>
|
||||
<button className="icon-btn" title={t('common_edit')} onClick={() => startEditTask(task)}><Pencil size={15} /></button>
|
||||
@@ -251,7 +262,7 @@ export default function ActivityDetailPanel({ activity, projectId, siblingActivi
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{(!tasks || tasks.length === 0) && <div className="text-muted" style={{ fontSize: '.78rem' }}>{t('common_empty')}</div>}
|
||||
{orderedTasks.length === 0 && <div className="text-muted" style={{ fontSize: '.78rem' }}>{t('common_empty')}</div>}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<input className="input" value={newTask} placeholder={t('task_new')} onChange={(e) => setNewTask(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addTaskItem()} />
|
||||
<Button variant="standard" icon={Plus} onClick={addTaskItem}>{t('common_new')}</Button>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Plus, Save, Trash2 } 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 } from '../../services/activities.js';
|
||||
import { listActivitiesByProject, createActivity, getActivity, deleteActivity, reorderActivities } from '../../services/activities.js';
|
||||
import { listCatalog } from '../../services/catalogs.js';
|
||||
import { listUsers } from '../../services/users.js';
|
||||
import { ListView } from '../../components/ListView.jsx';
|
||||
@@ -57,6 +57,11 @@ export default function ProjectActivitiesTab({ projectId }) {
|
||||
if (selected) setSelected(await getActivity(selected.id));
|
||||
}
|
||||
|
||||
async function handleReorder(ids) {
|
||||
await reorderActivities(projectId, ids);
|
||||
await reload();
|
||||
}
|
||||
|
||||
function confirmDelete(row) {
|
||||
openDialog({
|
||||
tone: 'warning',
|
||||
@@ -107,6 +112,7 @@ export default function ProjectActivitiesTab({ projectId }) {
|
||||
getSearchHaystack={(a) => `${a.code} ${a.name} ${a.responsible_name || ''}`}
|
||||
getRowKey={(a) => a.id}
|
||||
onRowClick={openActivity}
|
||||
onReorder={currentUser?.roleCode !== 'viewer' ? handleReorder : undefined}
|
||||
rowActions={currentUser?.roleCode !== 'viewer' ? (a) => [
|
||||
{ label: t('common_delete'), icon: Trash2, danger: true, onClick: () => confirmDelete(a) },
|
||||
] : undefined}
|
||||
|
||||
Reference in New Issue
Block a user