feat(proyectos): Miembros pasa a ser su propia pestaña junto a Info
Antes la lista de miembros del proyecto vivia dentro de la pestaña Info, mezclada con los campos del proyecto. Se extrae a un componente propio (ProjectMembersTab) y se agrega como pestaña "Miembros" justo a la derecha de "Info" en el detalle del proyecto. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -77,6 +77,7 @@ export const messages = {
|
|||||||
risk_high: 'Alto',
|
risk_high: 'Alto',
|
||||||
|
|
||||||
tab_info: 'Info',
|
tab_info: 'Info',
|
||||||
|
tab_members: 'Miembros',
|
||||||
tab_activities: 'Actividades',
|
tab_activities: 'Actividades',
|
||||||
tab_gantt: 'Gantt',
|
tab_gantt: 'Gantt',
|
||||||
tab_calendar: 'Calendario',
|
tab_calendar: 'Calendario',
|
||||||
@@ -211,6 +212,7 @@ export const messages = {
|
|||||||
risk_high: 'High',
|
risk_high: 'High',
|
||||||
|
|
||||||
tab_info: 'Info',
|
tab_info: 'Info',
|
||||||
|
tab_members: 'Members',
|
||||||
tab_activities: 'Activities',
|
tab_activities: 'Activities',
|
||||||
tab_gantt: 'Gantt',
|
tab_gantt: 'Gantt',
|
||||||
tab_calendar: 'Calendar',
|
tab_calendar: 'Calendar',
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Pencil, Save, X, UserPlus, Trash2 } from 'lucide-react';
|
import { Pencil, Save, X } from 'lucide-react';
|
||||||
import { useUi } from '../../context/ui-context.jsx';
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||||
import { updateProject, addProjectMember, removeProjectMember } from '../../services/projects.js';
|
import { updateProject } from '../../services/projects.js';
|
||||||
import { listCatalog } from '../../services/catalogs.js';
|
import { listCatalog } from '../../services/catalogs.js';
|
||||||
import { listDepartments } from '../../services/departments.js';
|
import { listDepartments } from '../../services/departments.js';
|
||||||
import { listUsers } from '../../services/users.js';
|
import { listUsers } from '../../services/users.js';
|
||||||
@@ -11,7 +11,6 @@ import { Field, FieldRow } from '../../components/Field.jsx';
|
|||||||
import { ComboBox } from '../../components/ComboBox.jsx';
|
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||||
import { StatusBadge, ProgressBar } from '../../components/StatusBadge.jsx';
|
import { StatusBadge, ProgressBar } from '../../components/StatusBadge.jsx';
|
||||||
import { Badge } from '../../components/Badge.jsx';
|
import { Badge } from '../../components/Badge.jsx';
|
||||||
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
|
||||||
|
|
||||||
function flatten(nodes, acc = []) {
|
function flatten(nodes, acc = []) {
|
||||||
for (const n of nodes) { acc.push(n); if (n.children?.length) flatten(n.children, acc); }
|
for (const n of nodes) { acc.push(n); if (n.children?.length) flatten(n.children, acc); }
|
||||||
@@ -32,7 +31,7 @@ function toForm(p) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ProjectInfoTab({ project, onChanged }) {
|
export default function ProjectInfoTab({ project, onChanged }) {
|
||||||
const { t, pushToast, openDialog } = useUi();
|
const { t, pushToast } = useUi();
|
||||||
const runAction = useAction();
|
const runAction = useAction();
|
||||||
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
|
const { data: priorities } = useAsync(() => listCatalog('priorities'), []);
|
||||||
const { data: statuses } = useAsync(() => listCatalog('project-statuses'), []);
|
const { data: statuses } = useAsync(() => listCatalog('project-statuses'), []);
|
||||||
@@ -42,8 +41,6 @@ export default function ProjectInfoTab({ project, onChanged }) {
|
|||||||
|
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [form, setForm] = useState(() => toForm(project));
|
const [form, setForm] = useState(() => toForm(project));
|
||||||
const [addingMember, setAddingMember] = useState('');
|
|
||||||
const [addMemberOpen, setAddMemberOpen] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => { setForm(toForm(project)); }, [project]);
|
useEffect(() => { setForm(toForm(project)); }, [project]);
|
||||||
|
|
||||||
@@ -51,7 +48,6 @@ export default function ProjectInfoTab({ project, onChanged }) {
|
|||||||
const priorityOptions = (priorities || []).map((p) => ({ value: p.id, label: p.name }));
|
const priorityOptions = (priorities || []).map((p) => ({ value: p.id, label: p.name }));
|
||||||
const statusOptions = (statuses || []).map((s) => ({ value: s.id, label: s.name }));
|
const statusOptions = (statuses || []).map((s) => ({ value: s.id, label: s.name }));
|
||||||
const typeOptions = (types || []).map((tp) => ({ value: tp.id, label: tp.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() {
|
async function save() {
|
||||||
const changed = JSON.stringify(form) !== JSON.stringify(toForm(project));
|
const changed = JSON.stringify(form) !== JSON.stringify(toForm(project));
|
||||||
@@ -72,28 +68,6 @@ export default function ProjectInfoTab({ project, 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
|
||||||
@@ -187,29 +161,6 @@ export default function ProjectInfoTab({ project, onChanged }) {
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { UserPlus, Trash2 } from 'lucide-react';
|
||||||
|
import { useUi } from '../../context/ui-context.jsx';
|
||||||
|
import { useAsync, useAction } from '../../hooks/useAsync.js';
|
||||||
|
import { addProjectMember, removeProjectMember } from '../../services/projects.js';
|
||||||
|
import { listUsers } from '../../services/users.js';
|
||||||
|
import { Button } from '../../components/Button.jsx';
|
||||||
|
import { Field } from '../../components/Field.jsx';
|
||||||
|
import { ComboBox } from '../../components/ComboBox.jsx';
|
||||||
|
import { DetailPopup } from '../../components/DetailPopup.jsx';
|
||||||
|
|
||||||
|
export default function ProjectMembersTab({ project, onChanged }) {
|
||||||
|
const { t, openDialog } = useUi();
|
||||||
|
const runAction = useAction();
|
||||||
|
const { data: users } = useAsync(listUsers, []);
|
||||||
|
|
||||||
|
const [addingMember, setAddingMember] = useState('');
|
||||||
|
const [addMemberOpen, setAddMemberOpen] = useState(false);
|
||||||
|
|
||||||
|
const userOptions = (users || []).filter((u) => !project.members.some((m) => m.user_id === u.id)).map((u) => ({ value: u.id, label: u.name }));
|
||||||
|
|
||||||
|
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 className="panel-card">
|
||||||
|
<div className="toolbar">
|
||||||
|
<h3 style={{ margin: 0 }}>{t('project_members')}</h3>
|
||||||
|
<Button variant="accent" 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { StatusBadge } from '../../components/StatusBadge.jsx';
|
|||||||
import { CommentsPanel } from '../../components/CommentsPanel.jsx';
|
import { CommentsPanel } from '../../components/CommentsPanel.jsx';
|
||||||
import { AttachmentsPanel } from '../../components/AttachmentsPanel.jsx';
|
import { AttachmentsPanel } from '../../components/AttachmentsPanel.jsx';
|
||||||
import ProjectInfoTab from './ProjectInfoTab.jsx';
|
import ProjectInfoTab from './ProjectInfoTab.jsx';
|
||||||
|
import ProjectMembersTab from './ProjectMembersTab.jsx';
|
||||||
import ProjectActivitiesTab from './ProjectActivitiesTab.jsx';
|
import ProjectActivitiesTab from './ProjectActivitiesTab.jsx';
|
||||||
import ProjectGanttTab from './ProjectGanttTab.jsx';
|
import ProjectGanttTab from './ProjectGanttTab.jsx';
|
||||||
import ProjectCalendarTab from './ProjectCalendarTab.jsx';
|
import ProjectCalendarTab from './ProjectCalendarTab.jsx';
|
||||||
@@ -38,6 +39,7 @@ export default function ProyectoDetallePage() {
|
|||||||
<Tabs
|
<Tabs
|
||||||
tabs={[
|
tabs={[
|
||||||
{ key: 'info', label: t('tab_info') },
|
{ key: 'info', label: t('tab_info') },
|
||||||
|
{ key: 'members', label: t('tab_members') },
|
||||||
{ key: 'activities', label: t('tab_activities') },
|
{ key: 'activities', label: t('tab_activities') },
|
||||||
{ key: 'gantt', label: t('tab_gantt') },
|
{ key: 'gantt', label: t('tab_gantt') },
|
||||||
{ key: 'calendar', label: t('tab_calendar') },
|
{ key: 'calendar', label: t('tab_calendar') },
|
||||||
@@ -49,6 +51,7 @@ export default function ProyectoDetallePage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{tab === 'info' && <ProjectInfoTab project={project} onChanged={reload} />}
|
{tab === 'info' && <ProjectInfoTab project={project} onChanged={reload} />}
|
||||||
|
{tab === 'members' && <ProjectMembersTab project={project} onChanged={reload} />}
|
||||||
{tab === 'activities' && <ProjectActivitiesTab projectId={projectId} />}
|
{tab === 'activities' && <ProjectActivitiesTab projectId={projectId} />}
|
||||||
{tab === 'gantt' && <ProjectGanttTab projectId={projectId} />}
|
{tab === 'gantt' && <ProjectGanttTab projectId={projectId} />}
|
||||||
{tab === 'calendar' && <ProjectCalendarTab projectId={projectId} />}
|
{tab === 'calendar' && <ProjectCalendarTab projectId={projectId} />}
|
||||||
|
|||||||
Reference in New Issue
Block a user