60347fab72
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>
373 lines
19 KiB
React
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>
|
|
);
|
|
}
|