2661d31977
Auditoria completa del frontend: quedaban 2 lugares con el mismo antipatron que ya se habia corregido en Proyectos/Departamentos/Usuarios/Catalogos. - ProjectActivitiesTab: "Nueva actividad" se apilaba debajo del listado de actividades. Ahora abre en su propio DetailPopup, igual que el resto. - ProjectInfoTab (pestaña Info de un proyecto): el combo "agregar miembro" se apilaba debajo de la lista de miembros. Ahora hay un boton "Agregar miembro" en el encabezado de esa tarjeta que abre un DetailPopup chico. Con esto no queda ningun listado del sistema con un formulario apilado debajo: todos usan DetailPopup (ver regla en project-md-templates/UI-UX-WINDOWS.md). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
216 lines
11 KiB
React
216 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';
|
|
import { DetailPopup } from '../../components/DetailPopup.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('');
|
|
const [addMemberOpen, setAddMemberOpen] = useState(false);
|
|
|
|
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('');
|
|
setAddMemberOpen(false);
|
|
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">
|
|
<div className="toolbar">
|
|
<h3 style={{ margin: 0 }}>{t('project_members')}</h3>
|
|
<Button variant="standard" icon={UserPlus} onClick={() => setAddMemberOpen(true)}>{t('project_add_member')}</Button>
|
|
</div>
|
|
{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>
|
|
))}
|
|
{project.members.length === 0 && <div className="text-muted" style={{ fontSize: '.78rem' }}>{t('common_empty')}</div>}
|
|
</div>
|
|
|
|
<DetailPopup open={addMemberOpen} title={t('project_add_member')} onClose={() => setAddMemberOpen(false)} width={420} height={260}>
|
|
<Field label={t('project_add_member')}>
|
|
<ComboBox options={userOptions} value={addingMember} onChange={setAddingMember} placeholder={t('project_add_member')} />
|
|
</Field>
|
|
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
|
<Button variant="accent" icon={UserPlus} onClick={addMember}>{t('common_save')}</Button>
|
|
<Button variant="standard" onClick={() => setAddMemberOpen(false)}>{t('common_cancel')}</Button>
|
|
</div>
|
|
</DetailPopup>
|
|
</div>
|
|
);
|
|
}
|