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
+7
View File
@@ -1,5 +1,12 @@
# Changelog - project.linkedcorp.net # Changelog - project.linkedcorp.net
## [2.17.7] - 2026-10-21
### Changed
- Panel → "Proyectos activos": mismo modelo que "Proyectos atrasados". Medidor con el
porcentaje de activos sobre el total de proyectos sin contar los cancelados (los completados
sí cuentan), "N de M" debajo, y al hacer clic el bloque de abajo muestra "Mis proyectos
activos".
## [2.17.6] - 2026-10-21 ## [2.17.6] - 2026-10-21
### Changed ### Changed
- Panel → "Proyectos atrasados": el centro del medidor muestra el porcentaje (atrasados sobre - Panel → "Proyectos atrasados": el centro del medidor muestra el porcentaje (atrasados sobre
+1 -1
View File
@@ -1 +1 @@
2.17.6 2.17.7
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "2.17.6", "version": "2.17.7",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/server.js", "main": "src/server.js",
+3 -1
View File
@@ -14,17 +14,19 @@ function memberFilterFor(currentUser) {
export const getSummary = asyncHandler(async (req, res) => { export const getSummary = asyncHandler(async (req, res) => {
const memberUserId = memberFilterFor(req.currentUser); const memberUserId = memberFilterFor(req.currentUser);
const [kpis, myActivities, byStatus, overdueProjects] = await Promise.all([ const [kpis, myActivities, byStatus, overdueProjects, activeProjects] = await Promise.all([
repo.summary(memberUserId), repo.summary(memberUserId),
repo.myPendingActivities(req.currentUser.id, memberUserId), repo.myPendingActivities(req.currentUser.id, memberUserId),
repo.projectsByStatus(memberUserId), repo.projectsByStatus(memberUserId),
repo.overdueProjectsList(memberUserId), repo.overdueProjectsList(memberUserId),
repo.activeProjectsList(memberUserId),
]); ]);
ok(res, { ok(res, {
kpis, kpis,
myActivities: myActivities.map((a) => ({ ...a, dueStatus: dueStatus(a.due_date) })), myActivities: myActivities.map((a) => ({ ...a, dueStatus: dueStatus(a.due_date) })),
projectsByStatus: byStatus, projectsByStatus: byStatus,
overdueProjects, overdueProjects,
activeProjects: activeProjects.map((p) => ({ ...p, dueStatus: dueStatus(p.estimated_end_date) })),
}); });
}); });
@@ -12,6 +12,13 @@ export async function summary(memberUserId) {
WHERE ps.is_closed = 0 AND p.deleted_at IS NULL ${memberClause}`, WHERE ps.is_closed = 0 AND p.deleted_at IS NULL ${memberClause}`,
memberParams memberParams
); );
// Base del medidor de "Proyectos activos": todos menos los cancelados (los completados si
// cuentan). El catalogo no tiene bandera de "cancelado", se identifica por nombre.
const [[nonCancelledProjects]] = await pool.query(
`SELECT COUNT(*) AS c FROM projects p JOIN project_statuses ps ON ps.id = p.status_id
WHERE p.deleted_at IS NULL AND ps.name NOT LIKE 'cancel%' ${memberClause}`,
memberParams
);
const [[overdueProjects]] = await pool.query( const [[overdueProjects]] = await pool.query(
`SELECT COUNT(*) AS c FROM projects p JOIN project_statuses ps ON ps.id = p.status_id `SELECT COUNT(*) AS c FROM projects p JOIN project_statuses ps ON ps.id = p.status_id
WHERE ps.is_closed = 0 AND p.deleted_at IS NULL WHERE ps.is_closed = 0 AND p.deleted_at IS NULL
@@ -50,6 +57,7 @@ export async function summary(memberUserId) {
return { return {
activeProjects: activeProjects.c, activeProjects: activeProjects.c,
nonCancelledProjects: nonCancelledProjects.c,
overdueProjects: overdueProjects.c, overdueProjects: overdueProjects.c,
dueSoonProjects: dueSoonProjects.c, dueSoonProjects: dueSoonProjects.c,
pendingActivities: pendingActivities.c, pendingActivities: pendingActivities.c,
@@ -59,6 +67,19 @@ export async function summary(memberUserId) {
}; };
} }
// Mismo criterio que el KPI activeProjects de summary().
export async function activeProjectsList(memberUserId) {
const memberClause = memberUserId ? 'AND EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = ?)' : '';
const [rows] = await pool.query(
`SELECT p.id, p.code, p.name, p.estimated_end_date, p.progress_pct, u.name AS owner_name, ps.name AS status_name
FROM projects p JOIN project_statuses ps ON ps.id = p.status_id LEFT JOIN users u ON u.id = p.owner_user_id
WHERE ps.is_closed = 0 AND p.deleted_at IS NULL ${memberClause}
ORDER BY (p.estimated_end_date IS NULL), p.estimated_end_date, p.code`,
memberUserId ? [memberUserId] : []
);
return rows;
}
// Mismo criterio que el KPI overdueProjects de summary() -- la lista y el numero nunca difieren. // Mismo criterio que el KPI overdueProjects de summary() -- la lista y el numero nunca difieren.
export async function overdueProjectsList(memberUserId) { export async function overdueProjectsList(memberUserId) {
const memberClause = memberUserId ? 'AND EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = ?)' : ''; const memberClause = memberUserId ? 'AND EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = ?)' : '';
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"private": true, "private": true,
"version": "2.17.6", "version": "2.17.7",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+2
View File
@@ -156,6 +156,7 @@ export const messages = {
dashboard_average_progress: 'Avance promedio', dashboard_average_progress: 'Avance promedio',
dashboard_my_activities: 'Mis actividades pendientes', dashboard_my_activities: 'Mis actividades pendientes',
dashboard_my_overdue_projects: 'Mis proyectos atrasados', dashboard_my_overdue_projects: 'Mis proyectos atrasados',
dashboard_my_active_projects: 'Mis proyectos activos',
common_of: 'de', common_of: 'de',
dashboard_back_to_my_activities: 'Ver mis actividades', dashboard_back_to_my_activities: 'Ver mis actividades',
dashboard_projects_by_status: 'Proyectos por estado', dashboard_projects_by_status: 'Proyectos por estado',
@@ -644,6 +645,7 @@ export const messages = {
dashboard_average_progress: 'Average progress', dashboard_average_progress: 'Average progress',
dashboard_my_activities: 'My pending activities', dashboard_my_activities: 'My pending activities',
dashboard_my_overdue_projects: 'My overdue projects', dashboard_my_overdue_projects: 'My overdue projects',
dashboard_my_active_projects: 'My active projects',
common_of: 'of', common_of: 'of',
dashboard_back_to_my_activities: 'Show my activities', dashboard_back_to_my_activities: 'Show my activities',
dashboard_projects_by_status: 'Projects by status', dashboard_projects_by_status: 'Projects by status',
+56 -27
View File
@@ -6,30 +6,71 @@ import { getSummary } from '../../services/dashboard.js';
import { KpiCard } from '../../components/KpiCard.jsx'; import { KpiCard } from '../../components/KpiCard.jsx';
import { ProgressBar, DueBadge } from '../../components/StatusBadge.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() { export default function DashboardPage() {
const { t } = useUi(); const { t } = useUi();
const navigate = useNavigate(); const navigate = useNavigate();
const { data } = useAsync(getSummary, []); 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 [detail, setDetail] = useState(null);
const toggleDetail = (key) => setDetail((d) => (d === key ? null : key)); const toggleDetail = (key) => setDetail((d) => (d === key ? null : key));
if (!data) return <div className="text-muted">{t('common_loading')}</div>; 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 activeTotal = Number(kpis.activeProjects) || 0;
const overdueRatio = activeTotal ? Number(kpis.overdueProjects) / activeTotal : 0; const nonCancelledTotal = Number(kpis.nonCancelledProjects) || 0;
const overduePct = Math.round(overdueRatio * 100); const active = ratioOf(kpis.activeProjects, nonCancelledTotal);
const overdue = ratioOf(kpis.overdueProjects, activeTotal);
return ( return (
<div> <div>
<h2 className="page-title mt-0" style={{ marginBottom: 16 }}>{t('dashboard_title')}</h2> <h2 className="page-title mt-0" style={{ marginBottom: 16 }}>{t('dashboard_title')}</h2>
<div className="kpi-grid"> <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 <KpiCard
label={t('dashboard_overdue_projects')} label={t('dashboard_overdue_projects')}
value={`${overduePct}%`} value={`${overdue.pct}%`}
gauge={{ ratio: overdueRatio, color: 'danger', caption: `${kpis.overdueProjects} ${t('common_of')} ${activeTotal}` }} gauge={{ ratio: overdue.ratio, color: 'danger', caption: `${kpis.overdueProjects} ${t('common_of')} ${activeTotal}` }}
onClick={() => toggleDetail('overdue')} onClick={() => toggleDetail('overdue')}
selected={detail === 'overdue'} selected={detail === 'overdue'}
/> />
@@ -40,30 +81,18 @@ export default function DashboardPage() {
<KpiCard label={t('dashboard_average_progress')} value={`${kpis.averageProgress}%`} /> <KpiCard label={t('dashboard_average_progress')} value={`${kpis.averageProgress}%`} />
</div> </div>
{detail === 'overdue' ? ( {detail === 'overdue' && (
<div className="panel-card"> <ProjectsDetail title={t('dashboard_my_overdue_projects')} projects={overdueProjects} getDueStatus={() => 'overdue'} onBack={() => setDetail(null)} />
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}> )}
<h3>{t('dashboard_my_overdue_projects')}</h3> {detail === 'active' && (
<button type="button" className="link-button" onClick={() => setDetail(null)}>{t('dashboard_back_to_my_activities')}</button> <ProjectsDetail title={t('dashboard_my_active_projects')} projects={activeProjects} getDueStatus={(p) => p.dueStatus} onBack={() => setDetail(null)} />
</div> )}
{overdueProjects.length === 0 && <div className="text-muted">{t('common_empty')}</div>} {!detail && (
{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' }}>
<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>
<div style={{ width: 120 }}><ProgressBar value={Number(p.progress_pct)} /></div>
<DueBadge status="overdue" />
</div>
))}
</div>
) : (
<div className="panel-card"> <div className="panel-card">
<h3>{t('dashboard_my_activities')}</h3> <h3>{t('dashboard_my_activities')}</h3>
{myActivities.length === 0 && <div className="text-muted">{t('common_empty')}</div>} {myActivities.length === 0 && <div className="text-muted">{t('common_empty')}</div>}
{myActivities.map((a) => ( {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 key={a.id} onClick={() => navigate(`/proyectos/${a.project_id}`)} style={ROW_STYLE}>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ fontSize: '.82rem', fontWeight: 600 }}>{a.name}</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 className="text-muted" style={{ fontSize: '.72rem' }}>{a.project_name} · {a.due_date || '—'}</div>