From 4583e3c4567780024b998be3a6be606cf2c86dad Mon Sep 17 00:00:00 2001 From: Milton Gonzalez Date: Tue, 20 Oct 2026 17:02:45 -0500 Subject: [PATCH] feat: version 2.17.5 -- Panel: medidor y detalle en "Proyectos atrasados" La tarjeta muestra un medidor semicircular (atrasados/activos) con el valor al centro; al hacer clic, el bloque inferior lista los proyectos atrasados en vez de mis actividades. Por ahora solo esta tarjeta. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 ++++ VERSION | 2 +- backend/package.json | 2 +- backend/src/modules/dashboard/controller.js | 4 ++- backend/src/modules/dashboard/repository.js | 14 ++++++++ frontend/package.json | 2 +- frontend/src/components/KpiCard.jsx | 35 +++++++++++++++++-- frontend/src/i18n/messages.js | 4 +++ .../src/pages/Dashboard/DashboardPage.jsx | 35 +++++++++++++++++-- frontend/src/styles/app.css | 6 ++++ 10 files changed, 102 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 514d007..db5253d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog - project.linkedcorp.net +## [2.17.5] - 2026-10-20 +### Added +- Panel: la tarjeta "Proyectos atrasados" muestra un medidor (atrasados sobre activos) con el + número al centro. Al hacer clic, el bloque de abajo cambia de "Mis actividades pendientes" a + "Mis proyectos atrasados" (mismo criterio que el número); otro clic, o "Ver mis + actividades", lo devuelve. + ## [2.17.4] - 2026-10-20 ### Fixed - Avance del proyecto: el promedio incluía actividades eliminadas (papelera), así que un proyecto diff --git a/VERSION b/VERSION index b92cdb1..00db226 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.17.4 +2.17.5 diff --git a/backend/package.json b/backend/package.json index 87cddda..704b910 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "2.17.4", + "version": "2.17.5", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/modules/dashboard/controller.js b/backend/src/modules/dashboard/controller.js index b217caa..063008e 100644 --- a/backend/src/modules/dashboard/controller.js +++ b/backend/src/modules/dashboard/controller.js @@ -14,15 +14,17 @@ function memberFilterFor(currentUser) { export const getSummary = asyncHandler(async (req, res) => { const memberUserId = memberFilterFor(req.currentUser); - const [kpis, myActivities, byStatus] = await Promise.all([ + const [kpis, myActivities, byStatus, overdueProjects] = await Promise.all([ repo.summary(memberUserId), repo.myPendingActivities(req.currentUser.id, memberUserId), repo.projectsByStatus(memberUserId), + repo.overdueProjectsList(memberUserId), ]); ok(res, { kpis, myActivities: myActivities.map((a) => ({ ...a, dueStatus: dueStatus(a.due_date) })), projectsByStatus: byStatus, + overdueProjects, }); }); diff --git a/backend/src/modules/dashboard/repository.js b/backend/src/modules/dashboard/repository.js index 06c44dd..f0b49ad 100644 --- a/backend/src/modules/dashboard/repository.js +++ b/backend/src/modules/dashboard/repository.js @@ -59,6 +59,20 @@ export async function summary(memberUserId) { }; } +// Mismo criterio que el KPI overdueProjects de summary() -- la lista y el numero nunca difieren. +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 [rows] = await pool.query( + `SELECT p.id, p.code, p.name, p.estimated_end_date, p.progress_pct, u.name AS owner_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 + AND p.estimated_end_date IS NOT NULL AND p.estimated_end_date < CURDATE() ${memberClause} + ORDER BY p.estimated_end_date`, + memberUserId ? [memberUserId] : [] + ); + return rows; +} + export async function myPendingActivities(userId, memberUserId) { const memberClause = memberUserId ? 'AND EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = ?)' : ''; const params = memberUserId ? [userId, memberUserId] : [userId]; diff --git a/frontend/package.json b/frontend/package.json index 7a162e6..b92ce3e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "2.17.4", + "version": "2.17.5", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/KpiCard.jsx b/frontend/src/components/KpiCard.jsx index 93619c9..2d2eab6 100644 --- a/frontend/src/components/KpiCard.jsx +++ b/frontend/src/components/KpiCard.jsx @@ -1,7 +1,36 @@ -export function KpiCard({ label, value }) { +// Medidor semicircular (tipo odometro): arco de fondo + arco relleno segun ratio (0..1), con el +// valor del KPI al centro. Sin libreria -- un SVG con stroke-dasharray sobre el semicirculo. +function Gauge({ ratio, color, value }) { + const r = 40; + const length = Math.PI * r; + const clamped = Math.max(0, Math.min(1, ratio || 0)); + const arc = `M ${50 - r} 50 A ${r} ${r} 0 0 1 ${50 + r} 50`; return ( -
-
{value}
+
+ +
{value}
+
+ ); +} + +export function KpiCard({ label, value, gauge, onClick, selected }) { + const clickable = !!onClick; + return ( +
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(); } } : undefined} + > + {gauge ? :
{value}
}
{label}
); diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index 9d83dc3..a515854 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -155,6 +155,8 @@ export const messages = { dashboard_today_activities: 'Para hoy', dashboard_average_progress: 'Avance promedio', dashboard_my_activities: 'Mis actividades pendientes', + dashboard_my_overdue_projects: 'Mis proyectos atrasados', + dashboard_back_to_my_activities: 'Ver mis actividades', dashboard_projects_by_status: 'Proyectos por estado', projects_title: 'Proyectos', @@ -640,6 +642,8 @@ export const messages = { dashboard_today_activities: 'Due today', dashboard_average_progress: 'Average progress', dashboard_my_activities: 'My pending activities', + dashboard_my_overdue_projects: 'My overdue projects', + dashboard_back_to_my_activities: 'Show my activities', dashboard_projects_by_status: 'Projects by status', projects_title: 'Projects', diff --git a/frontend/src/pages/Dashboard/DashboardPage.jsx b/frontend/src/pages/Dashboard/DashboardPage.jsx index 3804e6e..316d0b3 100644 --- a/frontend/src/pages/Dashboard/DashboardPage.jsx +++ b/frontend/src/pages/Dashboard/DashboardPage.jsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useUi } from '../../context/ui-context.jsx'; import { useAsync } from '../../hooks/useAsync.js'; @@ -9,9 +10,13 @@ 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. + const [detail, setDetail] = useState(null); + const toggleDetail = (key) => setDetail((d) => (d === key ? null : key)); if (!data) return
{t('common_loading')}
; - const { kpis, myActivities, projectsByStatus } = data; + const { kpis, myActivities, projectsByStatus, overdueProjects } = data; + const activeTotal = Number(kpis.activeProjects) || 0; return (
@@ -19,7 +24,13 @@ export default function DashboardPage() {
- + toggleDetail('overdue')} + selected={detail === 'overdue'} + /> @@ -27,6 +38,25 @@ export default function DashboardPage() {
+ {detail === 'overdue' ? ( +
+
+

{t('dashboard_my_overdue_projects')}

+ +
+ {overdueProjects.length === 0 &&
{t('common_empty')}
} + {overdueProjects.map((p) => ( +
navigate(`/proyectos/${p.id}`)} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 0', borderBottom: '1px solid rgb(var(--line) / .5)', cursor: 'pointer' }}> +
+
{p.code} — {p.name}
+
{p.owner_name || '—'} · {String(p.estimated_end_date).slice(0, 10)}
+
+
+ +
+ ))} +
+ ) : (

{t('dashboard_my_activities')}

{myActivities.length === 0 &&
{t('common_empty')}
} @@ -41,6 +71,7 @@ export default function DashboardPage() {
))}
+ )}

{t('dashboard_projects_by_status')}

diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index f314835..c64c6d2 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -241,6 +241,12 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; } .kpi-card { background: rgb(var(--surface)); border: 1px solid rgb(var(--line)); border-radius: var(--r-lg); padding: 16px; } .kpi-value { font-size: 1.6rem; font-weight: 700; } .kpi-label { font-size: .72rem; color: rgb(var(--muted)); margin-top: 4px; } +.kpi-card-clickable { cursor: pointer; transition: border-color .15s ease; } +.kpi-card-clickable:hover { border-color: rgb(var(--accent) / .5); } +.kpi-card.selected { border-color: rgb(var(--accent)); box-shadow: 0 0 0 1px rgb(var(--accent)); } +.kpi-gauge { position: relative; width: 120px; max-width: 100%; } +.kpi-gauge svg { display: block; width: 100%; } +.kpi-gauge-value { position: absolute; left: 0; right: 0; bottom: 0; text-align: center; font-size: 1.4rem; font-weight: 700; line-height: 1; } .panel-card { background: rgb(var(--surface)); border: 1px solid rgb(var(--line)); border-radius: var(--r-lg); padding: 16px; margin-bottom: 16px; } .panel-card h3 { margin: 0 0 12px; font-size: .95rem; } .company-logo-preview {