feat: version 2.17.7 -- Panel: medidor y detalle en "Proyectos activos"

Porcentaje de activos sobre el total sin cancelados, "N de M" y lista de
proyectos activos al hacer clic. La lista de detalle pasa a un componente
compartido con "Proyectos atrasados".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-21 07:52:49 -05:00
parent d15a58cb29
commit 10952beea5
8 changed files with 99 additions and 38 deletions
+2
View File
@@ -156,6 +156,7 @@ export const messages = {
dashboard_average_progress: 'Avance promedio',
dashboard_my_activities: 'Mis actividades pendientes',
dashboard_my_overdue_projects: 'Mis proyectos atrasados',
dashboard_my_active_projects: 'Mis proyectos activos',
common_of: 'de',
dashboard_back_to_my_activities: 'Ver mis actividades',
dashboard_projects_by_status: 'Proyectos por estado',
@@ -644,6 +645,7 @@ export const messages = {
dashboard_average_progress: 'Average progress',
dashboard_my_activities: 'My pending activities',
dashboard_my_overdue_projects: 'My overdue projects',
dashboard_my_active_projects: 'My active projects',
common_of: 'of',
dashboard_back_to_my_activities: 'Show my activities',
dashboard_projects_by_status: 'Projects by status',
+63 -34
View File
@@ -6,30 +6,71 @@ import { getSummary } from '../../services/dashboard.js';
import { KpiCard } from '../../components/KpiCard.jsx';
import { ProgressBar, DueBadge } from '../../components/StatusBadge.jsx';
const ROW_STYLE = { display: 'flex', alignItems: 'center', gap: 12, padding: '8px 0', borderBottom: '1px solid rgb(var(--line) / .5)', cursor: 'pointer' };
function ratioOf(part, total) {
const ratio = total ? Number(part) / total : 0;
return { ratio, pct: Math.round(ratio * 100) };
}
// Lista de proyectos que reemplaza "Mis actividades" al hacer clic en una tarjeta con detalle.
function ProjectsDetail({ title, projects, getDueStatus, onBack }) {
const { t } = useUi();
const navigate = useNavigate();
return (
<div className="panel-card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<h3>{title}</h3>
<button type="button" className="link-button" onClick={onBack}>{t('dashboard_back_to_my_activities')}</button>
</div>
{projects.length === 0 && <div className="text-muted">{t('common_empty')}</div>}
{projects.map((p) => (
<div key={p.id} onClick={() => navigate(`/proyectos/${p.id}`)} style={ROW_STYLE}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '.82rem', fontWeight: 600 }}>{p.code} — {p.name}</div>
<div className="text-muted" style={{ fontSize: '.72rem' }}>
{p.owner_name || '—'} · {p.estimated_end_date ? String(p.estimated_end_date).slice(0, 10) : '—'}{p.status_name ? ` · ${p.status_name}` : ''}
</div>
</div>
<div style={{ width: 120 }}><ProgressBar value={Number(p.progress_pct)} /></div>
<DueBadge status={getDueStatus(p)} />
</div>
))}
</div>
);
}
export default function DashboardPage() {
const { t } = useUi();
const navigate = useNavigate();
const { data } = useAsync(getSummary, []);
// Que muestra el bloque de abajo: null = mis actividades (por defecto), 'overdue' = proyectos atrasados.
// Que muestra el bloque de abajo: null = mis actividades (por defecto), o la clave de la tarjeta.
const [detail, setDetail] = useState(null);
const toggleDetail = (key) => setDetail((d) => (d === key ? null : key));
if (!data) return <div className="text-muted">{t('common_loading')}</div>;
const { kpis, myActivities, projectsByStatus, overdueProjects } = data;
const { kpis, myActivities, projectsByStatus, overdueProjects, activeProjects } = data;
const activeTotal = Number(kpis.activeProjects) || 0;
const overdueRatio = activeTotal ? Number(kpis.overdueProjects) / activeTotal : 0;
const overduePct = Math.round(overdueRatio * 100);
const nonCancelledTotal = Number(kpis.nonCancelledProjects) || 0;
const active = ratioOf(kpis.activeProjects, nonCancelledTotal);
const overdue = ratioOf(kpis.overdueProjects, activeTotal);
return (
<div>
<h2 className="page-title mt-0" style={{ marginBottom: 16 }}>{t('dashboard_title')}</h2>
<div className="kpi-grid">
<KpiCard label={t('dashboard_active_projects')} value={kpis.activeProjects} />
<KpiCard
label={t('dashboard_active_projects')}
value={`${active.pct}%`}
gauge={{ ratio: active.ratio, color: 'accent', caption: `${kpis.activeProjects} ${t('common_of')} ${nonCancelledTotal}` }}
onClick={() => toggleDetail('active')}
selected={detail === 'active'}
/>
<KpiCard
label={t('dashboard_overdue_projects')}
value={`${overduePct}%`}
gauge={{ ratio: overdueRatio, color: 'danger', caption: `${kpis.overdueProjects} ${t('common_of')} ${activeTotal}` }}
value={`${overdue.pct}%`}
gauge={{ ratio: overdue.ratio, color: 'danger', caption: `${kpis.overdueProjects} ${t('common_of')} ${activeTotal}` }}
onClick={() => toggleDetail('overdue')}
selected={detail === 'overdue'}
/>
@@ -40,39 +81,27 @@ export default function DashboardPage() {
<KpiCard label={t('dashboard_average_progress')} value={`${kpis.averageProgress}%`} />
</div>
{detail === 'overdue' ? (
{detail === 'overdue' && (
<ProjectsDetail title={t('dashboard_my_overdue_projects')} projects={overdueProjects} getDueStatus={() => 'overdue'} onBack={() => setDetail(null)} />
)}
{detail === 'active' && (
<ProjectsDetail title={t('dashboard_my_active_projects')} projects={activeProjects} getDueStatus={(p) => p.dueStatus} onBack={() => setDetail(null)} />
)}
{!detail && (
<div className="panel-card">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<h3>{t('dashboard_my_overdue_projects')}</h3>
<button type="button" className="link-button" onClick={() => setDetail(null)}>{t('dashboard_back_to_my_activities')}</button>
</div>
{overdueProjects.length === 0 && <div className="text-muted">{t('common_empty')}</div>}
{overdueProjects.map((p) => (
<div key={p.id} onClick={() => navigate(`/proyectos/${p.id}`)} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 0', borderBottom: '1px solid rgb(var(--line) / .5)', cursor: 'pointer' }}>
<h3>{t('dashboard_my_activities')}</h3>
{myActivities.length === 0 && <div className="text-muted">{t('common_empty')}</div>}
{myActivities.map((a) => (
<div key={a.id} onClick={() => navigate(`/proyectos/${a.project_id}`)} style={ROW_STYLE}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '.82rem', fontWeight: 600 }}>{p.code} — {p.name}</div>
<div className="text-muted" style={{ fontSize: '.72rem' }}>{p.owner_name || '—'} · {String(p.estimated_end_date).slice(0, 10)}</div>
<div style={{ fontSize: '.82rem', fontWeight: 600 }}>{a.name}</div>
<div className="text-muted" style={{ fontSize: '.72rem' }}>{a.project_name} · {a.due_date || '—'}</div>
</div>
<div style={{ width: 120 }}><ProgressBar value={Number(p.progress_pct)} /></div>
<DueBadge status="overdue" />
<div style={{ width: 120 }}><ProgressBar value={Number(a.progress_pct)} /></div>
<DueBadge status={a.dueStatus} />
</div>
))}
</div>
) : (
<div className="panel-card">
<h3>{t('dashboard_my_activities')}</h3>
{myActivities.length === 0 && <div className="text-muted">{t('common_empty')}</div>}
{myActivities.map((a) => (
<div key={a.id} onClick={() => navigate(`/proyectos/${a.project_id}`)} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 0', borderBottom: '1px solid rgb(var(--line) / .5)', cursor: 'pointer' }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '.82rem', fontWeight: 600 }}>{a.name}</div>
<div className="text-muted" style={{ fontSize: '.72rem' }}>{a.project_name} · {a.due_date || '—'}</div>
</div>
<div style={{ width: 120 }}><ProgressBar value={Number(a.progress_pct)} /></div>
<DueBadge status={a.dueStatus} />
</div>
))}
</div>
)}
<div className="panel-card">