1d22adc42e
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>
205 lines
11 KiB
React
205 lines
11 KiB
React
import { useEffect, useState } from 'react';
|
|
import { Pencil, Save, X, UserPlus, Trash2 } from 'lucide-react';
|
|
import { useUi } from '../../context/ui-context.jsx';
|
|
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
|
import { updateProject, addProjectMember, removeProjectMember } from '../../services/projects.js';
|
|
import { listCatalog } from '../../services/catalogs.js';
|
|
import { listDepartments } from '../../services/departments.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 { 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' };
|
|
|
|
function toForm(p) {
|
|
return {
|
|
name: p.name, description: p.description || '', objective: p.objective || '',
|
|
projectTypeId: p.project_type_id || '', priorityId: p.priority_id, statusId: p.status_id,
|
|
ownerUserId: p.owner_user_id,
|
|
requestingDepartmentId: p.requesting_department_id || '', responsibleDepartmentId: p.responsible_department_id || '',
|
|
startDate: p.start_date || '', estimatedEndDate: p.estimated_end_date || '', actualEndDate: p.actual_end_date || '',
|
|
riskLevel: p.risk_level, notes: p.notes || '',
|
|
};
|
|
}
|
|
|
|
export default function ProjectInfoTab({ project, onChanged }) {
|
|
const { t, pushToast, openDialog } = useUi();
|
|
const runAction = useAction();
|
|
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 { data: users } = useAsync(listUsers, []);
|
|
|
|
const [editing, setEditing] = useState(false);
|
|
const [form, setForm] = useState(() => toForm(project));
|
|
const [addingMember, setAddingMember] = useState('');
|
|
|
|
useEffect(() => { setForm(toForm(project)); }, [project]);
|
|
|
|
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 }));
|
|
const userOptions = (users || []).filter((u) => !project.members.some((m) => m.user_id === u.id)).map((u) => ({ value: u.id, label: u.name }));
|
|
|
|
async function save() {
|
|
const changed = JSON.stringify(form) !== JSON.stringify(toForm(project));
|
|
if (!changed) { setEditing(false); return; }
|
|
await runAction(async () => {
|
|
await updateProject(project.id, {
|
|
...form,
|
|
projectTypeId: form.projectTypeId || null,
|
|
requestingDepartmentId: form.requestingDepartmentId || null,
|
|
responsibleDepartmentId: form.responsibleDepartmentId || null,
|
|
startDate: form.startDate || null,
|
|
estimatedEndDate: form.estimatedEndDate || null,
|
|
actualEndDate: form.actualEndDate || null,
|
|
});
|
|
setEditing(false);
|
|
pushToast({ tone: 'success', message: t('common_saved') });
|
|
onChanged();
|
|
});
|
|
}
|
|
|
|
async function addMember() {
|
|
if (!addingMember) return;
|
|
await runAction(async () => {
|
|
await addProjectMember(project.id, Number(addingMember));
|
|
setAddingMember('');
|
|
onChanged();
|
|
});
|
|
}
|
|
|
|
function confirmRemoveMember(member) {
|
|
openDialog({
|
|
tone: 'warning',
|
|
title: t('common_confirm_delete_title'),
|
|
message: t('common_confirm_delete_msg'),
|
|
actions: [
|
|
{ label: t('common_cancel'), variant: 'standard' },
|
|
{ label: t('common_delete'), variant: 'danger', onClick: () => runAction(async () => { await removeProjectMember(project.id, member.user_id); onChanged(); }) },
|
|
],
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
|
|
{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(project)); setEditing(false); }}>{t('common_cancel')}</Button>
|
|
</div>
|
|
) : (
|
|
<Button variant="standard" icon={Pencil} onClick={() => setEditing(true)}>{t('common_edit')}</Button>
|
|
)}
|
|
</div>
|
|
|
|
{!editing ? (
|
|
<div className="panel-card">
|
|
<FieldRow>
|
|
<Field label={t('common_status')}><StatusBadge name={project.status_name} color={project.status_color} /></Field>
|
|
<Field label={t('common_priority')}><StatusBadge name={project.priority_name} color={project.priority_color} /></Field>
|
|
<Field label={t('project_risk')}><Badge color={RISK_COLOR[project.risk_level]}>{t(`risk_${project.risk_level}`)}</Badge></Field>
|
|
</FieldRow>
|
|
<Field label={t('common_description')}><div>{project.description || '—'}</div></Field>
|
|
<Field label={t('project_objective')}><div>{project.objective || '—'}</div></Field>
|
|
<FieldRow>
|
|
<Field label={t('project_type')}><div>{project.project_type_name || '—'}</div></Field>
|
|
<Field label={t('project_owner')}><div>{project.owner_name}</div></Field>
|
|
</FieldRow>
|
|
<FieldRow>
|
|
<Field label={t('project_requesting_department')}><div>{project.requesting_department_name || '—'}</div></Field>
|
|
<Field label={t('project_responsible_department')}><div>{project.responsible_department_name || '—'}</div></Field>
|
|
</FieldRow>
|
|
<FieldRow>
|
|
<Field label={t('project_start_date')}><div>{project.start_date || '—'}</div></Field>
|
|
<Field label={t('project_estimated_end_date')}><div>{project.estimated_end_date || '—'}</div></Field>
|
|
<Field label={t('project_actual_end_date')}><div>{project.actual_end_date || '—'}</div></Field>
|
|
</FieldRow>
|
|
<Field label={t('project_progress')}><ProgressBar value={Number(project.progress_pct)} /></Field>
|
|
<Field label={t('project_notes')}><div>{project.notes || '—'}</div></Field>
|
|
</div>
|
|
) : (
|
|
<div className="panel-card">
|
|
<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>
|
|
<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_owner')}>
|
|
<ComboBox options={(users || []).map((u) => ({ value: u.id, label: u.name }))} value={form.ownerUserId} onChange={(v) => setForm((f) => ({ ...f, ownerUserId: 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_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>
|
|
</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>
|
|
<Field label={t('project_actual_end_date')}>
|
|
<input className="input" type="date" value={form.actualEndDate} onChange={(e) => setForm((f) => ({ ...f, actualEndDate: 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>
|
|
)}
|
|
|
|
<div className="panel-card">
|
|
<h3>{t('project_members')}</h3>
|
|
{project.members.map((m) => (
|
|
<div key={m.user_id} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '6px 0' }}>
|
|
<span style={{ flex: 1, fontSize: '.82rem' }}>{m.name} <span className="text-muted">· {m.email}</span></span>
|
|
<button className="icon-btn" onClick={() => confirmRemoveMember(m)}><Trash2 size={16} /></button>
|
|
</div>
|
|
))}
|
|
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
|
|
<div style={{ flex: 1 }}>
|
|
<ComboBox options={userOptions} value={addingMember} onChange={setAddingMember} placeholder={t('project_add_member')} />
|
|
</div>
|
|
<Button variant="standard" icon={UserPlus} onClick={addMember}>{t('project_add_member')}</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|