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:
Milton Gonzalez
2026-09-03 08:37:58 -05:00
commit 1d22adc42e
135 changed files with 10913 additions and 0 deletions
@@ -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>
);
}