diff --git a/CHANGELOG.md b/CHANGELOG.md index d38bfa3..70d814c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog - project.linkedcorp.net +## [2.18.0] - 2026-10-21 +### Changed +- Panel: el detalle de cada tarjeta se abre en un **popup** en vez de reemplazar el bloque de + "Mis actividades". Ahora las 7 tarjetas tienen detalle, cada una con su lista: proyectos + activos, atrasados y próximos a vencer; actividades pendientes, vencidas y para hoy; y en + Avance promedio los proyectos activos ordenados por avance. Clic en una fila abre el proyecto + o la actividad. +### Fixed +- Panel → "Para hoy" contaba actividades eliminadas y de proyectos cancelados; ya no. + ## [2.17.11] - 2026-10-21 ### Changed - Panel: las tarjetas con medidor tienen el medidor y sus textos centrados dentro de la tarjeta. diff --git a/VERSION b/VERSION index ead81cc..cf86907 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.17.11 +2.18.0 diff --git a/backend/package.json b/backend/package.json index 7fc5dd5..173745e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "2.17.11", + "version": "2.18.0", "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 43add00..fca0879 100644 --- a/backend/src/modules/dashboard/controller.js +++ b/backend/src/modules/dashboard/controller.js @@ -14,23 +14,31 @@ function memberFilterFor(currentUser) { export const getSummary = asyncHandler(async (req, res) => { const memberUserId = memberFilterFor(req.currentUser); - const [kpis, myActivities, byStatus, overdueProjects, activeProjects, pendingActivities, overdueActivities] = await Promise.all([ + const [kpis, myActivities, byStatus, overdueProjects, activeProjects, dueSoonProjects, + pendingActivities, overdueActivities, todayActivities] = await Promise.all([ repo.summary(memberUserId), repo.myPendingActivities(req.currentUser.id, memberUserId), repo.projectsByStatus(memberUserId), repo.overdueProjectsList(memberUserId), repo.activeProjectsList(memberUserId), - repo.pendingActivitiesList(memberUserId), - repo.pendingActivitiesList(memberUserId, true), + repo.dueSoonProjectsList(memberUserId), + repo.pendingActivitiesList(memberUserId, 'all'), + repo.pendingActivitiesList(memberUserId, 'overdue'), + repo.pendingActivitiesList(memberUserId, 'today'), ]); + const withProjectDue = (p) => ({ ...p, dueStatus: dueStatus(p.estimated_end_date) }); + const withActivityDue = (a) => ({ ...a, dueStatus: dueStatus(a.due_date) }); ok(res, { kpis, - myActivities: myActivities.map((a) => ({ ...a, dueStatus: dueStatus(a.due_date) })), + myActivities: myActivities.map(withActivityDue), projectsByStatus: byStatus, - overdueProjects, - activeProjects: activeProjects.map((p) => ({ ...p, dueStatus: dueStatus(p.estimated_end_date) })), - pendingActivities: pendingActivities.map((a) => ({ ...a, dueStatus: dueStatus(a.due_date) })), + // Detalle de cada tarjeta del Panel (popup al hacer clic). + overdueProjects: overdueProjects.map((p) => ({ ...p, dueStatus: 'overdue' })), + activeProjects: activeProjects.map(withProjectDue), + dueSoonProjects: dueSoonProjects.map(withProjectDue), + pendingActivities: pendingActivities.map(withActivityDue), overdueActivities: overdueActivities.map((a) => ({ ...a, dueStatus: 'overdue' })), + todayActivities: todayActivities.map(withActivityDue), }); }); diff --git a/backend/src/modules/dashboard/repository.js b/backend/src/modules/dashboard/repository.js index fcc107d..20ab54e 100644 --- a/backend/src/modules/dashboard/repository.js +++ b/backend/src/modules/dashboard/repository.js @@ -62,8 +62,9 @@ export async function summary(memberUserId) { ); const [[todayActivities]] = await pool.query( `SELECT COUNT(*) AS c FROM activities a JOIN activity_statuses st ON st.id = a.status_id - JOIN projects p ON p.id = a.project_id - WHERE st.is_closed = 0 AND p.deleted_at IS NULL AND a.due_date = CURDATE() ${memberClause}`, + JOIN projects p ON p.id = a.project_id JOIN project_statuses ps ON ps.id = p.status_id + WHERE st.is_closed = 0 AND a.deleted_at IS NULL AND p.deleted_at IS NULL AND ps.name NOT LIKE 'cancel%' + AND a.due_date = CURDATE() ${memberClause}`, memberParams ); const [[avgProgress]] = await pool.query( @@ -86,10 +87,16 @@ export async function summary(memberUserId) { }; } -// Mismo criterio que los KPI pendingActivities (overdueOnly=false) / overdueActivities (true). -export async function pendingActivitiesList(memberUserId, overdueOnly = false) { +// Mismo criterio que los KPI pendingActivities ('all') / overdueActivities ('overdue') / +// todayActivities ('today') -- la lista y el numero de cada tarjeta nunca difieren. +const ACTIVITY_DUE_CLAUSES = { + all: '', + overdue: 'AND a.due_date IS NOT NULL AND a.due_date < CURDATE()', + today: 'AND a.due_date = CURDATE()', +}; +export async function pendingActivitiesList(memberUserId, which = 'all') { const memberClause = memberUserId ? 'AND EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = ?)' : ''; - const overdueClause = overdueOnly ? 'AND a.due_date IS NOT NULL AND a.due_date < CURDATE()' : ''; + const overdueClause = ACTIVITY_DUE_CLAUSES[which]; const [rows] = await pool.query( `SELECT a.id, a.code, a.name, a.due_date, a.progress_pct, p.id AS project_id, p.code AS project_code, p.name AS project_name, u.name AS responsible_name @@ -104,6 +111,20 @@ export async function pendingActivitiesList(memberUserId, overdueOnly = false) { return rows; } +// Mismo criterio que el KPI dueSoonProjects de summary(). +export async function dueSoonProjectsList(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 AND p.estimated_end_date IS NOT NULL + AND p.estimated_end_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 7 DAY) ${memberClause} + ORDER BY p.estimated_end_date, p.code`, + memberUserId ? [memberUserId] : [] + ); + return rows; +} + // 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 = ?)' : ''; @@ -121,7 +142,7 @@ export async function activeProjectsList(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 [rows] = await pool.query( - `SELECT p.id, p.code, p.name, p.estimated_end_date, p.progress_pct, u.name AS owner_name + `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 AND p.estimated_end_date IS NOT NULL AND p.estimated_end_date < CURDATE() ${memberClause} diff --git a/frontend/package.json b/frontend/package.json index 6f859b9..8c06435 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "2.17.11", + "version": "2.18.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/i18n/messages.js b/frontend/src/i18n/messages.js index 0bbb42b..e942106 100644 --- a/frontend/src/i18n/messages.js +++ b/frontend/src/i18n/messages.js @@ -155,12 +155,7 @@ 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_my_active_projects: 'Mis proyectos activos', - dashboard_pending_activities_list: 'Actividades pendientes', - dashboard_overdue_activities_list: 'Actividades vencidas', common_of: 'de', - dashboard_back_to_my_activities: 'Ver mis actividades', dashboard_projects_by_status: 'Proyectos por estado', projects_title: 'Proyectos', @@ -646,12 +641,7 @@ 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_my_active_projects: 'My active projects', - dashboard_pending_activities_list: 'Pending activities', - dashboard_overdue_activities_list: 'Overdue activities', common_of: 'of', - 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 413d5d5..61cd331 100644 --- a/frontend/src/pages/Dashboard/DashboardPage.jsx +++ b/frontend/src/pages/Dashboard/DashboardPage.jsx @@ -4,6 +4,7 @@ import { useUi } from '../../context/ui-context.jsx'; import { useAsync } from '../../hooks/useAsync.js'; import { getSummary } from '../../services/dashboard.js'; import { KpiCard } from '../../components/KpiCard.jsx'; +import { DetailPopup } from '../../components/DetailPopup.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' }; @@ -13,78 +14,77 @@ function ratioOf(part, total) { 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 dateOnly = (d) => (d ? String(d).slice(0, 10) : '—'); + +function ProjectRows({ projects, onOpen }) { 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}` : ''} -
-
-
- + if (projects.length === 0) return
{t('common_empty')}
; + return projects.map((p) => ( +
onOpen(`/proyectos/${p.id}`)} style={ROW_STYLE}> +
+
{p.code} — {p.name}
+
+ {p.owner_name || '—'} · {dateOnly(p.estimated_end_date)}{p.status_name ? ` · ${p.status_name}` : ''}
- ))} +
+
+
- ); + )); } -// Igual que ProjectsDetail pero para actividades (clic abre el proyecto en la actividad). -function ActivitiesDetail({ title, activities, onBack }) { +function ActivityRows({ activities, onOpen }) { const { t } = useUi(); - const navigate = useNavigate(); - return ( -
-
-

{title}

- -
- {activities.length === 0 &&
{t('common_empty')}
} - {activities.map((a) => ( -
navigate(`/proyectos/${a.project_id}?tab=activities&activity=${a.id}`)} style={ROW_STYLE}> -
-
{a.code} — {a.name}
-
- {a.project_code} — {a.project_name} · {a.responsible_name || '—'} · {a.due_date ? String(a.due_date).slice(0, 10) : '—'} -
-
-
- + if (activities.length === 0) return
{t('common_empty')}
; + return activities.map((a) => ( +
onOpen(`/proyectos/${a.project_id}?tab=activities&activity=${a.id}`)} style={ROW_STYLE}> +
+
{a.code} — {a.name}
+
+ {a.project_code} — {a.project_name} · {a.responsible_name || '—'} · {dateOnly(a.due_date)}
- ))} +
+
+
- ); + )); } 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), o la clave de la tarjeta. + // Tarjeta cuyo detalle se muestra en el popup (null = cerrado). 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, activeProjects, pendingActivities, overdueActivities } = data; - const activitiesStrictTotal = Number(kpis.nonCancelledActivitiesStrict) || 0; - const overdueAct = ratioOf(kpis.overdueActivities, activitiesStrictTotal); - const activitiesTotal = Number(kpis.nonCancelledActivities) || 0; - const pending = ratioOf(kpis.pendingActivities, activitiesTotal); + const { kpis, myActivities, projectsByStatus } = data; const activeTotal = Number(kpis.activeProjects) || 0; const nonCancelledTotal = Number(kpis.nonCancelledProjects) || 0; + const activitiesTotal = Number(kpis.nonCancelledActivities) || 0; + const activitiesStrictTotal = Number(kpis.nonCancelledActivitiesStrict) || 0; const active = ratioOf(kpis.activeProjects, nonCancelledTotal); const overdue = ratioOf(kpis.overdueProjects, activeTotal); + const pending = ratioOf(kpis.pendingActivities, activitiesTotal); + const overdueAct = ratioOf(kpis.overdueActivities, activitiesStrictTotal); + const of = (n, total) => `${n} ${t('common_of')} ${total}`; + + // Cada tarjeta: que muestra y que lista abre. kind decide el formato de las filas del popup. + const DETAILS = { + active: { title: t('dashboard_active_projects'), kind: 'projects', rows: data.activeProjects }, + overdue: { title: t('dashboard_overdue_projects'), kind: 'projects', rows: data.overdueProjects }, + dueSoon: { title: t('dashboard_due_soon_projects'), kind: 'projects', rows: data.dueSoonProjects }, + pending: { title: t('dashboard_pending_activities'), kind: 'activities', rows: data.pendingActivities }, + overdueActivities: { title: t('dashboard_overdue_activities'), kind: 'activities', rows: data.overdueActivities }, + today: { title: t('dashboard_today_activities'), kind: 'activities', rows: data.todayActivities }, + average: { + title: t('dashboard_average_progress'), kind: 'projects', + rows: [...data.activeProjects].sort((a, b) => Number(b.progress_pct) - Number(a.progress_pct)), + }, + }; + const current = detail ? DETAILS[detail] : null; + const cardProps = (key) => ({ onClick: () => setDetail(key), selected: detail === key }); + const openFromPopup = (path) => { setDetail(null); navigate(path); }; return (
@@ -92,70 +92,48 @@ export default function DashboardPage() {
toggleDetail('active')} - selected={detail === 'active'} + label={t('dashboard_active_projects')} value={`${active.pct}%`} + gauge={{ ratio: active.ratio, color: 'accent', caption: of(kpis.activeProjects, nonCancelledTotal) }} + {...cardProps('active')} /> toggleDetail('overdue')} - selected={detail === 'overdue'} + label={t('dashboard_overdue_projects')} value={`${overdue.pct}%`} + gauge={{ ratio: overdue.ratio, color: 'danger', caption: of(kpis.overdueProjects, activeTotal) }} + {...cardProps('overdue')} /> - + toggleDetail('pending')} - selected={detail === 'pending'} + label={t('dashboard_pending_activities')} value={`${pending.pct}%`} + gauge={{ ratio: pending.ratio, color: 'warning', caption: of(kpis.pendingActivities, activitiesTotal) }} + {...cardProps('pending')} /> toggleDetail('overdueActivities')} - selected={detail === 'overdueActivities'} + label={t('dashboard_overdue_activities')} value={`${overdueAct.pct}%`} + gauge={{ ratio: overdueAct.ratio, color: 'danger', caption: of(kpis.overdueActivities, activitiesStrictTotal) }} + {...cardProps('overdueActivities')} /> - +
- {detail === 'overdue' && ( - 'overdue'} onBack={() => setDetail(null)} /> - )} - {detail === 'active' && ( - p.dueStatus} onBack={() => setDetail(null)} /> - )} - {detail === 'pending' && ( - setDetail(null)} /> - )} - {detail === 'overdueActivities' && ( - setDetail(null)} /> - )} - {!detail && ( -
-

{t('dashboard_my_activities')}

- {myActivities.length === 0 &&
{t('common_empty')}
} - {myActivities.map((a) => ( -
navigate(`/proyectos/${a.project_id}`)} style={ROW_STYLE}> -
-
{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={ROW_STYLE}> +
+
{a.name}
+
{a.project_name} · {a.due_date || '—'}
- ))} -
- )} +
+ +
+ ))} +

{t('dashboard_projects_by_status')}

@@ -167,6 +145,17 @@ export default function DashboardPage() {
))}
+ + setDetail(null)} + width={820} + height={560} + > + {current?.kind === 'projects' && } + {current?.kind === 'activities' && } +
); }