diff --git a/CHANGELOG.md b/CHANGELOG.md index b9b5ce7..45d9ae7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 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 ### Changed - Panel → "Proyectos atrasados": el centro del medidor muestra el porcentaje (atrasados sobre diff --git a/VERSION b/VERSION index 728789a..17bbe1e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.17.6 +2.17.7 diff --git a/backend/package.json b/backend/package.json index 4e8becc..fee5a38 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "2.17.6", + "version": "2.17.7", "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 063008e..9f270c2 100644 --- a/backend/src/modules/dashboard/controller.js +++ b/backend/src/modules/dashboard/controller.js @@ -14,17 +14,19 @@ function memberFilterFor(currentUser) { export const getSummary = asyncHandler(async (req, res) => { 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.myPendingActivities(req.currentUser.id, memberUserId), repo.projectsByStatus(memberUserId), repo.overdueProjectsList(memberUserId), + repo.activeProjectsList(memberUserId), ]); ok(res, { kpis, myActivities: myActivities.map((a) => ({ ...a, dueStatus: dueStatus(a.due_date) })), projectsByStatus: byStatus, overdueProjects, + activeProjects: activeProjects.map((p) => ({ ...p, dueStatus: dueStatus(p.estimated_end_date) })), }); }); diff --git a/backend/src/modules/dashboard/repository.js b/backend/src/modules/dashboard/repository.js index f0b49ad..e1e5197 100644 --- a/backend/src/modules/dashboard/repository.js +++ b/backend/src/modules/dashboard/repository.js @@ -12,6 +12,13 @@ export async function summary(memberUserId) { WHERE ps.is_closed = 0 AND p.deleted_at IS NULL ${memberClause}`, 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( `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 @@ -50,6 +57,7 @@ export async function summary(memberUserId) { return { activeProjects: activeProjects.c, + nonCancelledProjects: nonCancelledProjects.c, overdueProjects: overdueProjects.c, dueSoonProjects: dueSoonProjects.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. 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 = ?)' : ''; diff --git a/frontend/package.json b/frontend/package.json index 9482ea0..edf536b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "2.17.6", + "version": "2.17.7", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index d8e2752..d6d1e81 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -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', diff --git a/frontend/src/pages/Dashboard/DashboardPage.jsx b/frontend/src/pages/Dashboard/DashboardPage.jsx index 792d353..513447b 100644 --- a/frontend/src/pages/Dashboard/DashboardPage.jsx +++ b/frontend/src/pages/Dashboard/DashboardPage.jsx @@ -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 ( +
+
+

{title}

+ +
+ {projects.length === 0 &&
{t('common_empty')}
} + {projects.map((p) => ( +
navigate(`/proyectos/${p.id}`)} style={ROW_STYLE}> +
+
{p.code} — {p.name}
+
+ {p.owner_name || '—'} · {p.estimated_end_date ? String(p.estimated_end_date).slice(0, 10) : '—'}{p.status_name ? ` · ${p.status_name}` : ''} +
+
+
+ +
+ ))} +
+ ); +} + 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
{t('common_loading')}
; - 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 (

{t('dashboard_title')}

- + toggleDetail('active')} + selected={detail === 'active'} + /> toggleDetail('overdue')} selected={detail === 'overdue'} /> @@ -40,39 +81,27 @@ export default function DashboardPage() {
- {detail === 'overdue' ? ( + {detail === 'overdue' && ( + 'overdue'} onBack={() => setDetail(null)} /> + )} + {detail === 'active' && ( + p.dueStatus} onBack={() => setDetail(null)} /> + )} + {!detail && (
-
-

{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' }}> +

{t('dashboard_my_activities')}

+ {myActivities.length === 0 &&
{t('common_empty')}
} + {myActivities.map((a) => ( +
navigate(`/proyectos/${a.project_id}`)} style={ROW_STYLE}>
-
{p.code} — {p.name}
-
{p.owner_name || '—'} · {String(p.estimated_end_date).slice(0, 10)}
+
{a.name}
+
{a.project_name} · {a.due_date || '—'}
-
- +
+
))}
- ) : ( -
-

{t('dashboard_my_activities')}

- {myActivities.length === 0 &&
{t('common_empty')}
} - {myActivities.map((a) => ( -
navigate(`/proyectos/${a.project_id}`)} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '8px 0', borderBottom: '1px solid rgb(var(--line) / .5)', cursor: 'pointer' }}> -
-
{a.name}
-
{a.project_name} · {a.due_date || '—'}
-
-
- -
- ))} -
)}