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,144 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Save } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||
import { listActivitiesByProject, createActivity, getActivity } from '../../services/activities.js';
|
||||
import { listCatalog } from '../../services/catalogs.js';
|
||||
import { listUsers } from '../../services/users.js';
|
||||
import { ListView } from '../../components/ListView.jsx';
|
||||
import { SearchInput } from '../../components/SearchInput.jsx';
|
||||
import { Button } from '../../components/Button.jsx';
|
||||
import { Field, FieldRow } from '../../components/Field.jsx';
|
||||
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||
import { StatusBadge, ProgressBar, DueBadge } from '../../components/StatusBadge.jsx';
|
||||
import { dueStatus } from '../../utils/dueStatus.js';
|
||||
import ActivityDetailPanel from './ActivityDetailPanel.jsx';
|
||||
|
||||
const EMPTY_FORM = { name: '', description: '', responsibleUserId: '', priorityId: '', statusId: '', startDate: '', dueDate: '', isMilestone: false };
|
||||
|
||||
export default function ProjectActivitiesTab({ projectId }) {
|
||||
const { t, pushToast } = useUi();
|
||||
const runAction = useAction();
|
||||
const { data: activities, loading, reload } = useAsync(() => listActivitiesByProject(projectId), [projectId]);
|
||||
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
|
||||
const { data: statuses } = useAsync(() => listCatalog('activity-statuses'), []);
|
||||
const { data: users } = useAsync(listUsers, []);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
const [selected, setSelected] = useState(null);
|
||||
|
||||
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 }));
|
||||
|
||||
async function save() {
|
||||
if (!form.name.trim() || !form.priorityId || !form.statusId) return;
|
||||
await runAction(async () => {
|
||||
await createActivity(projectId, { ...form, responsibleUserId: form.responsibleUserId || null });
|
||||
setCreating(false);
|
||||
setForm(EMPTY_FORM);
|
||||
pushToast({ tone: 'success', message: t('common_saved') });
|
||||
await reload();
|
||||
});
|
||||
}
|
||||
|
||||
async function openActivity(row) {
|
||||
const full = await getActivity(row.id);
|
||||
setSelected(full);
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
await reload();
|
||||
if (selected) setSelected(await getActivity(selected.id));
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ key: 'code', label: t('activity_code') },
|
||||
{ key: 'name', label: t('common_name') },
|
||||
{ key: 'responsible_name', label: t('common_responsible'), render: (r) => r.responsible_name || '—' },
|
||||
{ key: 'status_name', label: t('common_status'), render: (r) => <StatusBadge name={r.status_name} color={r.status_color} /> },
|
||||
{ key: 'priority_name', label: t('common_priority'), render: (r) => <StatusBadge name={r.priority_name} color={r.priority_color} /> },
|
||||
{ key: 'due_date', label: t('activity_due_date'), render: (r) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span>{r.due_date || '—'}</span>
|
||||
<DueBadge status={dueStatus(r.due_date, r.status_is_closed)} />
|
||||
</div>
|
||||
) },
|
||||
{ key: 'progress_pct', label: t('activity_progress'), render: (r) => <div style={{ width: 100 }}><ProgressBar value={Number(r.progress_pct)} /></div> },
|
||||
];
|
||||
|
||||
if (loading || !activities) return <div className="text-muted">{t('common_loading')}</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="toolbar">
|
||||
<SearchInput value={search} onChange={setSearch} />
|
||||
<Button variant="accent" icon={Plus} onClick={() => setCreating(true)}>{t('activity_new')}</Button>
|
||||
</div>
|
||||
|
||||
<ListView
|
||||
columns={columns}
|
||||
rows={activities}
|
||||
searchValue={search}
|
||||
getSearchHaystack={(a) => `${a.code} ${a.name} ${a.responsible_name || ''}`}
|
||||
getRowKey={(a) => a.id}
|
||||
onRowClick={openActivity}
|
||||
/>
|
||||
|
||||
{creating && (
|
||||
<div className="panel-card" style={{ marginTop: 16 }}>
|
||||
<h3>{t('activity_new')}</h3>
|
||||
<Field label={t('common_name')}>
|
||||
<input className="input" value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} autoFocus />
|
||||
</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_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>
|
||||
</FieldRow>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button variant="accent" icon={Save} onClick={save}>{t('common_save')}</Button>
|
||||
<Button variant="standard" onClick={() => setCreating(false)}>{t('common_cancel')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<ActivityDetailPanel
|
||||
activity={selected}
|
||||
projectId={projectId}
|
||||
siblingActivities={activities}
|
||||
onChanged={refreshAll}
|
||||
onClose={() => setSelected(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user