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,154 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Plus, Save } from 'lucide-react';
|
||||
import { useUi } from '../../context/ui-context.jsx';
|
||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||
import { listProjects, createProject } from '../../services/projects.js';
|
||||
import { listCatalog } from '../../services/catalogs.js';
|
||||
import { listDepartments } from '../../services/departments.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 } from '../../components/StatusBadge.jsx';
|
||||
import { Badge } from '../../components/Badge.jsx';
|
||||
|
||||
function flatten(nodes, acc = []) {
|
||||
for (const n of nodes) { acc.push(n); if (n.children?.length) flatten(n.children, acc); }
|
||||
return acc;
|
||||
}
|
||||
|
||||
const RISK_COLOR = { low: 'success', medium: 'warning', high: 'danger' };
|
||||
const EMPTY_FORM = {
|
||||
name: '', description: '', objective: '', projectTypeId: '', priorityId: '', statusId: '',
|
||||
requestingDepartmentId: '', responsibleDepartmentId: '', startDate: '', estimatedEndDate: '',
|
||||
riskLevel: 'low', notes: '',
|
||||
};
|
||||
|
||||
export default function ProyectosPage() {
|
||||
const { t, pushToast } = useUi();
|
||||
const navigate = useNavigate();
|
||||
const runAction = useAction();
|
||||
|
||||
const { data: projects, loading, reload } = useAsync(listProjects, []);
|
||||
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
|
||||
const { data: statuses } = useAsync(() => listCatalog('project-statuses'), []);
|
||||
const { data: types } = useAsync(() => listCatalog('project-types'), []);
|
||||
const { data: deptTree } = useAsync(listDepartments, []);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [form, setForm] = useState(EMPTY_FORM);
|
||||
|
||||
const deptOptions = deptTree ? flatten(deptTree).map((d) => ({ value: d.id, label: d.name })) : [];
|
||||
const priorityOptions = (priorities || []).map((p) => ({ value: p.id, label: p.name }));
|
||||
const statusOptions = (statuses || []).map((s) => ({ value: s.id, label: s.name }));
|
||||
const typeOptions = (types || []).map((tp) => ({ value: tp.id, label: tp.name }));
|
||||
|
||||
async function save() {
|
||||
if (!form.name.trim() || !form.priorityId || !form.statusId) return;
|
||||
await runAction(async () => {
|
||||
const project = await createProject({
|
||||
...form,
|
||||
projectTypeId: form.projectTypeId || null,
|
||||
requestingDepartmentId: form.requestingDepartmentId || null,
|
||||
responsibleDepartmentId: form.responsibleDepartmentId || null,
|
||||
startDate: form.startDate || null,
|
||||
estimatedEndDate: form.estimatedEndDate || null,
|
||||
});
|
||||
setCreating(false);
|
||||
setForm(EMPTY_FORM);
|
||||
pushToast({ tone: 'success', message: t('common_saved') });
|
||||
navigate(`/proyectos/${project.id}`);
|
||||
});
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ key: 'code', label: t('project_code') },
|
||||
{ key: 'name', label: t('common_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: 'owner_name', label: t('project_owner') },
|
||||
{ key: 'progress_pct', label: t('project_progress'), render: (r) => <div style={{ width: 100 }}><ProgressBar value={Number(r.progress_pct)} /></div> },
|
||||
{ key: 'risk_level', label: t('project_risk'), render: (r) => <Badge color={RISK_COLOR[r.risk_level]}>{t(`risk_${r.risk_level}`)}</Badge> },
|
||||
];
|
||||
|
||||
if (loading || !projects) return <div className="text-muted">{t('common_loading')}</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="toolbar">
|
||||
<div className="toolbar-left">
|
||||
<h2 className="page-title mt-0">{t('projects_title')}</h2>
|
||||
</div>
|
||||
<Button variant="accent" icon={Plus} onClick={() => setCreating(true)}>{t('project_new')}</Button>
|
||||
</div>
|
||||
|
||||
<div className="toolbar">
|
||||
<SearchInput value={search} onChange={setSearch} />
|
||||
</div>
|
||||
|
||||
<ListView
|
||||
columns={columns}
|
||||
rows={projects}
|
||||
searchValue={search}
|
||||
getSearchHaystack={(p) => `${p.code} ${p.name} ${p.owner_name} ${p.status_name}`}
|
||||
getRowKey={(p) => p.id}
|
||||
onRowClick={(p) => navigate(`/proyectos/${p.id}`)}
|
||||
/>
|
||||
|
||||
{creating && (
|
||||
<div className="panel-card" style={{ marginTop: 16 }}>
|
||||
<h3>{t('project_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>
|
||||
<Field label={t('project_objective')}>
|
||||
<textarea className="textarea" value={form.objective} onChange={(e) => setForm((f) => ({ ...f, objective: e.target.value }))} />
|
||||
</Field>
|
||||
<FieldRow>
|
||||
<Field label={t('project_type')}>
|
||||
<ComboBox options={[{ value: '', label: '—' }, ...typeOptions]} value={form.projectTypeId} onChange={(v) => setForm((f) => ({ ...f, projectTypeId: 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('project_requesting_department')}>
|
||||
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.requestingDepartmentId} onChange={(v) => setForm((f) => ({ ...f, requestingDepartmentId: v }))} />
|
||||
</Field>
|
||||
<Field label={t('project_responsible_department')}>
|
||||
<ComboBox options={[{ value: '', label: '—' }, ...deptOptions]} value={form.responsibleDepartmentId} onChange={(v) => setForm((f) => ({ ...f, responsibleDepartmentId: v }))} />
|
||||
</Field>
|
||||
<Field label={t('project_risk')}>
|
||||
<ComboBox options={['low', 'medium', 'high'].map((r) => ({ value: r, label: t(`risk_${r}`) }))} value={form.riskLevel} onChange={(v) => setForm((f) => ({ ...f, riskLevel: v }))} />
|
||||
</Field>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
<Field label={t('project_start_date')}>
|
||||
<input className="input" type="date" value={form.startDate} onChange={(e) => setForm((f) => ({ ...f, startDate: e.target.value }))} />
|
||||
</Field>
|
||||
<Field label={t('project_estimated_end_date')}>
|
||||
<input className="input" type="date" value={form.estimatedEndDate} onChange={(e) => setForm((f) => ({ ...f, estimatedEndDate: e.target.value }))} />
|
||||
</Field>
|
||||
</FieldRow>
|
||||
<Field label={t('project_notes')}>
|
||||
<textarea className="textarea" value={form.notes} onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))} />
|
||||
</Field>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user