chore: estructura inicial del MVP
Backend (Express + MariaDB) y frontend (React + Vite, identidad Fluent/Windows) con el nucleo funcional: seguridad, organizacion, proyectos, actividades, subtareas, dependencias, dashboard, calendario, gantt, comentarios, adjuntos, notificaciones in-app y catalogos. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Pencil, Save, X, Plus, Trash2, Link2 } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||
import { updateActivity, addDependency, removeDependency } from '../../services/activities.js';
|
||||
import { listSubtasks, createSubtask, setSubtaskDone, deleteSubtask } from '../../services/subtasks.js';
|
||||
import { listCatalog } from '../../services/catalogs.js';
|
||||
import { listUsers } from '../../services/users.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';
|
||||
|
||||
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, onClose }) {
|
||||
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: subtasks, reload: reloadSubtasks } = useAsync(() => listSubtasks(activity.id), [activity.id]);
|
||||
|
||||
const [tab, setTab] = useState('detail');
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [form, setForm] = useState(() => toForm(activity));
|
||||
const [newSubtask, setNewSubtask] = useState('');
|
||||
const [addingDep, setAddingDep] = useState('');
|
||||
|
||||
useEffect(() => { setForm(toForm(activity)); setEditing(false); }, [activity]);
|
||||
|
||||
const hasSubtasks = subtasks && subtasks.length > 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 addSubtaskItem() {
|
||||
if (!newSubtask.trim()) return;
|
||||
await runAction(async () => {
|
||||
await createSubtask(activity.id, { name: newSubtask.trim() });
|
||||
setNewSubtask('');
|
||||
await reloadSubtasks();
|
||||
onChanged();
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleSubtask(s) {
|
||||
await runAction(async () => { await setSubtaskDone(s.id, !s.is_done); await reloadSubtasks(); onChanged(); });
|
||||
}
|
||||
|
||||
async function removeSubtaskItem(s) {
|
||||
await runAction(async () => { await deleteSubtask(s.id); await reloadSubtasks(); onChanged(); });
|
||||
}
|
||||
|
||||
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 className="panel-card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<h3 style={{ margin: 0 }}>{activity.code} — {activity.name}</h3>
|
||||
<Button variant="subtle" icon={X} onClick={onClose}>{t('common_close')}</Button>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ key: 'detail', label: t('tab_info') },
|
||||
{ key: 'comments', label: t('tab_comments') },
|
||||
{ key: 'documents', label: t('tab_documents') },
|
||||
]}
|
||||
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)} />
|
||||
{hasSubtasks && <span className="text-muted" style={{ fontSize: '.68rem' }}>{t('subtasks_title')}</span>}
|
||||
</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>
|
||||
{!hasSubtasks && (
|
||||
<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 style={{ marginTop: 14 }}>
|
||||
<h4>{t('subtasks_title')}</h4>
|
||||
{(subtasks || []).map((s) => (
|
||||
<div key={s.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 0' }}>
|
||||
<input type="checkbox" checked={!!s.is_done} onChange={() => toggleSubtask(s)} />
|
||||
<span style={{ flex: 1, fontSize: '.8rem', textDecoration: s.is_done ? 'line-through' : 'none', opacity: s.is_done ? .6 : 1 }}>{s.name}</span>
|
||||
<button className="icon-btn" onClick={() => removeSubtaskItem(s)}><Trash2 size={15} /></button>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<input className="input" value={newSubtask} placeholder={t('subtask_new')} onChange={(e) => setNewSubtask(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addSubtaskItem()} />
|
||||
<Button variant="standard" icon={Plus} onClick={addSubtaskItem}>{t('common_new')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<h4>{t('activity_dependencies')}</h4>
|
||||
{(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>
|
||||
))}
|
||||
<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}>{t('activity_add_dependency')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'comments' && <CommentsPanel entityType="activity" entityId={activity.id} />}
|
||||
{tab === 'documents' && <AttachmentsPanel entityType="activity" entityId={activity.id} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user