Files
project.linkedcorp.net/frontend/src/pages/Proyectos/ActivityDetailPanel.jsx
T
Milton Gonzalez 60347fab72 feat: contador de Tareas muestra pendientes/total (ej. 2/3)
Solo la pestaña Tareas -- Comentarios y Documentos siguen con el total simple. Se recalcula al
agregar, quitar o marcar/desmarcar tareas.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 13:02:34 -05:00

373 lines
19 KiB
React

import { useEffect, useState } from '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, reorderTasks } from '../../services/tasks.js';
import { promoteTaskToRecurring } from '../../services/recurringTasks.js';
import { listCatalog } from '../../services/catalogs.js';
import { listUsers } from '../../services/users.js';
import { listComments } from '../../services/comments.js';
import { listAttachments } from '../../services/attachments.js';
import { Button } from '../../components/Button.jsx';
import { Field, FieldRow } from '../../components/Field.jsx';
import { ComboBox } from '../../components/ComboBox.jsx';
import { StatusBadge, ProgressBar } from '../../components/StatusBadge.jsx';
import { Tabs } from '../../components/Tabs.jsx';
import { CommentsPanel } from '../../components/CommentsPanel.jsx';
import { AttachmentsPanel } from '../../components/AttachmentsPanel.jsx';
import { DetailPopup } from '../../components/DetailPopup.jsx';
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => ({ value: h, label: `${String(h).padStart(2, '0')}:00` }));
function toForm(a) {
return {
name: a.name, description: a.description || '', responsibleUserId: a.responsible_user_id || '',
priorityId: a.priority_id, statusId: a.status_id, startDate: a.start_date || '', dueDate: a.due_date || '',
actualEndDate: a.actual_end_date || '', isMilestone: !!a.is_milestone, estimatedHours: a.estimated_hours || '',
progressPct: a.progress_pct,
};
}
export default function ActivityDetailPanel({ activity, projectId, siblingActivities, onChanged }) {
const { t, pushToast } = useUi();
const runAction = useAction();
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
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, getHandleProps, getRowProps, isDragging } = useDragReorder(tasks || [], async (ids) => {
await reorderTasks(activity.id, ids);
await reloadTasks();
});
const [tab, setTab] = useState('detail');
// Recargadas al cambiar de pestaña (listas livianas) para que los contadores de las pestañas
// se mantengan al dia sin necesidad de que CommentsPanel/AttachmentsPanel avisen al padre.
const { data: comments } = useAsync(() => listComments('activity', activity.id), [activity.id, tab]);
const { data: attachments } = useAsync(() => listAttachments('activity', activity.id), [activity.id, tab]);
const [editing, setEditing] = useState(false);
const [form, setForm] = useState(() => toForm(activity));
const [newTask, setNewTask] = useState('');
const [editingTaskId, setEditingTaskId] = useState(null);
const [editingTaskName, setEditingTaskName] = useState('');
const [addingDep, setAddingDep] = useState('');
const [promoting, setPromoting] = useState(null);
const [promoteForm, setPromoteForm] = useState(null);
useEffect(() => { setForm(toForm(activity)); setEditing(false); }, [activity]);
const hasTasks = tasks && tasks.length > 0;
const totalTasksCount = (tasks || []).length;
const pendingTasksCount = (tasks || []).filter((task) => !task.is_done).length;
// "pendientes/total" (ej. "2/3") en vez de un numero solo — cambia solo cuando se agregan,
// quitan o marcan/desmarcan tareas.
const tasksTabCount = totalTasksCount > 0 ? `${pendingTasksCount}/${totalTasksCount}` : 0;
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 depOptions = (siblingActivities || [])
.filter((a) => a.id !== activity.id && !activity.dependencies?.some((d) => d.id === a.id))
.map((a) => ({ value: a.id, label: `${a.code} — ${a.name}` }));
async function save() {
await runAction(async () => {
await updateActivity(activity.id, { ...form, responsibleUserId: form.responsibleUserId || null, estimatedHours: form.estimatedHours || null });
setEditing(false);
pushToast({ tone: 'success', message: t('common_saved') });
onChanged();
});
}
async function addTaskItem() {
if (!newTask.trim()) return;
await runAction(async () => {
await createTask(activity.id, { name: newTask.trim() });
setNewTask('');
await reloadTasks();
onChanged();
});
}
async function toggleTask(task) {
await runAction(async () => { await setTaskDone(task.id, !task.is_done); await reloadTasks(); onChanged(); });
}
async function removeTaskItem(task) {
await runAction(async () => { await deleteTask(task.id); await reloadTasks(); onChanged(); });
}
function startEditTask(task) {
setEditingTaskId(task.id);
setEditingTaskName(task.name);
}
function cancelEditTask() {
setEditingTaskId(null);
setEditingTaskName('');
}
async function saveEditTask() {
if (!editingTaskName.trim()) return;
await runAction(async () => {
await updateTask(editingTaskId, { name: editingTaskName.trim() });
cancelEditTask();
await reloadTasks();
onChanged();
});
}
function startPromote(task) {
setPromoting(task);
setPromoteForm({
name: task.name, responsibleUserId: task.responsible_user_id || '',
intervalUnit: 'months', intervalValue: 1, nextDueDate: task.due_date || '', endDate: '',
reminderDaysBefore: 7, reminderHour: 9,
});
}
async function confirmPromote() {
if (!promoteForm.nextDueDate) return;
await runAction(async () => {
await promoteTaskToRecurring(promoting.id, {
...promoteForm, responsibleUserId: promoteForm.responsibleUserId || null, endDate: promoteForm.endDate || null,
});
setPromoting(null);
pushToast({ tone: 'success', message: t('common_saved') });
});
}
async function addDep() {
if (!addingDep) return;
await runAction(async () => { await addDependency(activity.id, Number(addingDep)); setAddingDep(''); onChanged(); });
}
async function removeDep(depId) {
await runAction(async () => { await removeDependency(activity.id, depId); onChanged(); });
}
return (
<div>
<Tabs
tabs={[
{ key: 'detail', label: t('tab_info') },
{ key: 'tasks', label: t('tasks_title'), count: tasksTabCount },
{ key: 'dependencies', label: t('activity_dependencies') },
{ key: 'comments', label: t('tab_comments'), count: (comments || []).length },
{ key: 'documents', label: t('tab_documents'), count: (attachments || []).length },
]}
active={tab}
onChange={setTab}
/>
{tab === 'detail' && (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 10 }}>
{editing ? (
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
<Button variant="standard" icon={X} onClick={() => { setForm(toForm(activity)); setEditing(false); }}>{t('common_cancel')}</Button>
</div>
) : (
<Button variant="standard" icon={Pencil} onClick={() => setEditing(true)}>{t('common_edit')}</Button>
)}
</div>
{!editing ? (
<div>
<FieldRow>
<Field label={t('common_status')}><StatusBadge name={activity.status_name} color={activity.status_color} /></Field>
<Field label={t('common_priority')}><StatusBadge name={activity.priority_name} color={activity.priority_color} /></Field>
<Field label={t('common_responsible')}><div>{activity.responsible_name || '—'}</div></Field>
</FieldRow>
<Field label={t('common_description')}><div>{activity.description || '—'}</div></Field>
<FieldRow>
<Field label={t('activity_start_date')}><div>{activity.start_date || '—'}</div></Field>
<Field label={t('activity_due_date')}><div>{activity.due_date || '—'}</div></Field>
<Field label={t('activity_actual_end_date')}><div>{activity.actual_end_date || '—'}</div></Field>
</FieldRow>
<Field label={t('activity_progress')}>
<ProgressBar value={Number(activity.progress_pct)} />
</Field>
</div>
) : (
<div>
<Field label={t('common_name')}>
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} />
</Field>
<Field label={t('common_description')}>
<textarea className="textarea" value={form.description} onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))} />
</Field>
<FieldRow>
<Field label={t('common_responsible')}>
<ComboBox options={[{ value: '', label: '—' }, ...userOptions]} value={form.responsibleUserId} onChange={(v) => setForm((f) => ({ ...f, responsibleUserId: v }))} />
</Field>
<Field label={t('common_priority')}>
<ComboBox options={priorityOptions} value={form.priorityId} onChange={(v) => setForm((f) => ({ ...f, priorityId: v }))} />
</Field>
<Field label={t('common_status')}>
<ComboBox options={statusOptions} value={form.statusId} onChange={(v) => setForm((f) => ({ ...f, statusId: v }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('activity_start_date')}>
<input className="input" type="date" value={form.startDate} onChange={(e) => setForm((f) => ({ ...f, startDate: e.target.value }))} />
</Field>
<Field label={t('activity_due_date')}>
<input className="input" type="date" value={form.dueDate} onChange={(e) => setForm((f) => ({ ...f, dueDate: e.target.value }))} />
</Field>
<Field label={t('activity_actual_end_date')}>
<input className="input" type="date" value={form.actualEndDate} onChange={(e) => setForm((f) => ({ ...f, actualEndDate: e.target.value }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('activity_estimated_hours')}>
<input className="input" type="number" value={form.estimatedHours} onChange={(e) => setForm((f) => ({ ...f, estimatedHours: e.target.value }))} />
</Field>
<Field label={t('activity_milestone')}>
<label className="checkbox-row">
<input type="checkbox" checked={form.isMilestone} onChange={(e) => setForm((f) => ({ ...f, isMilestone: e.target.checked }))} />
{t('activity_milestone')}
</label>
</Field>
{!hasTasks && (
<Field label={t('activity_progress')}>
<input className="input" type="number" min={0} max={100} value={form.progressPct} onChange={(e) => setForm((f) => ({ ...f, progressPct: e.target.value }))} />
</Field>
)}
</FieldRow>
</div>
)}
</div>
)}
{tab === 'tasks' && (
<div>
{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 ? {} : getRowProps(task))}
>
{editingTaskId === task.id ? (
<>
<input
className="input" style={{ flex: 1 }} value={editingTaskName} autoFocus
onChange={(e) => setEditingTaskName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') saveEditTask(); if (e.key === 'Escape') cancelEditTask(); }}
/>
<button className="icon-btn" title={t('common_save')} onClick={saveEditTask}><Save size={15} /></button>
<button className="icon-btn" title={t('common_cancel')} onClick={cancelEditTask}><X size={15} /></button>
</>
) : (
<>
<GripVertical
size={15} className="drag-handle" style={{ opacity: .6, cursor: 'grab', flexShrink: 0 }}
{...getHandleProps(task)}
/>
<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>
<button className="icon-btn" title={t('recurring_task_promote')} onClick={() => startPromote(task)}><Repeat size={15} /></button>
<button className="icon-btn" onClick={() => removeTaskItem(task)}><Trash2 size={15} /></button>
</>
)}
</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>
</div>
</div>
)}
{tab === 'dependencies' && (
<div>
{(activity.dependencies || []).length === 0 && <div className="text-muted" style={{ fontSize: '.78rem' }}>{t('common_empty')}</div>}
{(activity.dependencies || []).map((d) => (
<div key={d.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 0' }}>
<Link2 size={14} style={{ opacity: .6 }} />
<span style={{ flex: 1, fontSize: '.8rem' }}>{d.code} — {d.name}</span>
<button className="icon-btn" onClick={() => removeDep(d.id)}><Trash2 size={15} /></button>
</div>
))}
{depOptions.length === 0 ? (
<div className="text-muted" style={{ fontSize: '.78rem', marginTop: 8 }}>{t('activity_no_dependency_options')}</div>
) : (
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<div style={{ flex: 1 }}>
<ComboBox options={depOptions} value={addingDep} onChange={setAddingDep} placeholder={t('activity_depends_on')} />
</div>
<Button variant="standard" icon={Plus} onClick={addDep} disabled={!addingDep}>{t('activity_add_dependency')}</Button>
</div>
)}
</div>
)}
{tab === 'comments' && <CommentsPanel entityType="activity" entityId={activity.id} />}
{tab === 'documents' && <AttachmentsPanel entityType="activity" entityId={activity.id} />}
<DetailPopup
open={!!promoting}
title={t('recurring_task_promote_title')}
onClose={() => setPromoting(null)}
width={560}
height={600}
>
{promoteForm && (
<>
<div className="text-muted" style={{ fontSize: '.78rem', marginBottom: 10 }}>{t('recurring_task_promote_hint')}</div>
<Field label={t('common_name')}>
<input className="input" value={promoteForm.name} onChange={(e) => setPromoteForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
</Field>
<FieldRow>
<Field label={t('common_responsible')}>
<ComboBox options={[{ value: '', label: '—' }, ...userOptions]} value={promoteForm.responsibleUserId} onChange={(v) => setPromoteForm((f) => ({ ...f, responsibleUserId: v }))} />
</Field>
<Field label={t('recurring_task_next_due')}>
<input className="input" type="date" value={promoteForm.nextDueDate} onChange={(e) => setPromoteForm((f) => ({ ...f, nextDueDate: e.target.value }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('recurring_task_end_date')} hint={t('recurring_task_unlimited_hint')}>
<input className="input" type="date" value={promoteForm.endDate} onChange={(e) => setPromoteForm((f) => ({ ...f, endDate: e.target.value }))} />
</Field>
</FieldRow>
<FieldRow>
<Field label={t('recurring_task_every')}>
<input className="input" type="number" min={1} value={promoteForm.intervalValue} onChange={(e) => setPromoteForm((f) => ({ ...f, intervalValue: Number(e.target.value) }))} />
</Field>
<Field label={t('recurring_task_interval_unit')}>
<ComboBox
options={[
{ value: 'days', label: t('recurring_task_unit_days') },
{ value: 'months', label: t('recurring_task_unit_months') },
{ value: 'years', label: t('recurring_task_unit_years') },
]}
value={promoteForm.intervalUnit}
onChange={(v) => setPromoteForm((f) => ({ ...f, intervalUnit: v }))}
/>
</Field>
</FieldRow>
<FieldRow>
<Field label={t('recurring_task_reminder_days')}>
<input className="input" type="number" min={0} value={promoteForm.reminderDaysBefore} onChange={(e) => setPromoteForm((f) => ({ ...f, reminderDaysBefore: Number(e.target.value) }))} />
</Field>
<Field label={t('recurring_task_reminder_hour')}>
<ComboBox options={HOUR_OPTIONS} value={promoteForm.reminderHour} onChange={(v) => setPromoteForm((f) => ({ ...f, reminderHour: Number(v) }))} />
</Field>
</FieldRow>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<Button variant="accent" icon={Repeat} onClick={confirmPromote}>{t('recurring_task_promote')}</Button>
<Button variant="standard" onClick={() => setPromoting(null)}>{t('common_cancel')}</Button>
</div>
</>
)}
</DetailPopup>
</div>
);
}