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}
+
+ );
+}
+
+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 {