feat: version 2.18.0 -- Panel: detalle de cada tarjeta en popup
Las 7 tarjetas abren su lista en un DetailPopup (proyectos o actividades segun la tarjeta); el bloque "Mis actividades" queda fijo. Nuevas listas de proximos a vencer y para hoy; "Para hoy" deja de contar eliminadas/cancelados. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,15 @@
|
|||||||
# Changelog - project.linkedcorp.net
|
# 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
|
## [2.17.11] - 2026-10-21
|
||||||
### Changed
|
### Changed
|
||||||
- Panel: las tarjetas con medidor tienen el medidor y sus textos centrados dentro de la tarjeta.
|
- Panel: las tarjetas con medidor tienen el medidor y sus textos centrados dentro de la tarjeta.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-backend",
|
"name": "project-linkedcorp-backend",
|
||||||
"version": "2.17.11",
|
"version": "2.18.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
|
|||||||
@@ -14,23 +14,31 @@ 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, activeProjects, pendingActivities, overdueActivities] = await Promise.all([
|
const [kpis, myActivities, byStatus, overdueProjects, activeProjects, dueSoonProjects,
|
||||||
|
pendingActivities, overdueActivities, todayActivities] = 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),
|
repo.activeProjectsList(memberUserId),
|
||||||
repo.pendingActivitiesList(memberUserId),
|
repo.dueSoonProjectsList(memberUserId),
|
||||||
repo.pendingActivitiesList(memberUserId, true),
|
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, {
|
ok(res, {
|
||||||
kpis,
|
kpis,
|
||||||
myActivities: myActivities.map((a) => ({ ...a, dueStatus: dueStatus(a.due_date) })),
|
myActivities: myActivities.map(withActivityDue),
|
||||||
projectsByStatus: byStatus,
|
projectsByStatus: byStatus,
|
||||||
overdueProjects,
|
// Detalle de cada tarjeta del Panel (popup al hacer clic).
|
||||||
activeProjects: activeProjects.map((p) => ({ ...p, dueStatus: dueStatus(p.estimated_end_date) })),
|
overdueProjects: overdueProjects.map((p) => ({ ...p, dueStatus: 'overdue' })),
|
||||||
pendingActivities: pendingActivities.map((a) => ({ ...a, dueStatus: dueStatus(a.due_date) })),
|
activeProjects: activeProjects.map(withProjectDue),
|
||||||
|
dueSoonProjects: dueSoonProjects.map(withProjectDue),
|
||||||
|
pendingActivities: pendingActivities.map(withActivityDue),
|
||||||
overdueActivities: overdueActivities.map((a) => ({ ...a, dueStatus: 'overdue' })),
|
overdueActivities: overdueActivities.map((a) => ({ ...a, dueStatus: 'overdue' })),
|
||||||
|
todayActivities: todayActivities.map(withActivityDue),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -62,8 +62,9 @@ export async function summary(memberUserId) {
|
|||||||
);
|
);
|
||||||
const [[todayActivities]] = await pool.query(
|
const [[todayActivities]] = await pool.query(
|
||||||
`SELECT COUNT(*) AS c FROM activities a JOIN activity_statuses st ON st.id = a.status_id
|
`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
|
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 p.deleted_at IS NULL AND a.due_date = CURDATE() ${memberClause}`,
|
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
|
memberParams
|
||||||
);
|
);
|
||||||
const [[avgProgress]] = await pool.query(
|
const [[avgProgress]] = await pool.query(
|
||||||
@@ -86,10 +87,16 @@ export async function summary(memberUserId) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mismo criterio que los KPI pendingActivities (overdueOnly=false) / overdueActivities (true).
|
// Mismo criterio que los KPI pendingActivities ('all') / overdueActivities ('overdue') /
|
||||||
export async function pendingActivitiesList(memberUserId, overdueOnly = false) {
|
// 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 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(
|
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,
|
`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
|
p.name AS project_name, u.name AS responsible_name
|
||||||
@@ -104,6 +111,20 @@ export async function pendingActivitiesList(memberUserId, overdueOnly = false) {
|
|||||||
return rows;
|
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().
|
// Mismo criterio que el KPI activeProjects de summary().
|
||||||
export async function activeProjectsList(memberUserId) {
|
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 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) {
|
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 = ?)' : '';
|
||||||
const [rows] = await pool.query(
|
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
|
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
|
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}
|
AND p.estimated_end_date IS NOT NULL AND p.estimated_end_date < CURDATE() ${memberClause}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "project-linkedcorp-frontend",
|
"name": "project-linkedcorp-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.17.11",
|
"version": "2.18.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -155,12 +155,7 @@ export const messages = {
|
|||||||
dashboard_today_activities: 'Para hoy',
|
dashboard_today_activities: 'Para hoy',
|
||||||
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_active_projects: 'Mis proyectos activos',
|
|
||||||
dashboard_pending_activities_list: 'Actividades pendientes',
|
|
||||||
dashboard_overdue_activities_list: 'Actividades vencidas',
|
|
||||||
common_of: 'de',
|
common_of: 'de',
|
||||||
dashboard_back_to_my_activities: 'Ver mis actividades',
|
|
||||||
dashboard_projects_by_status: 'Proyectos por estado',
|
dashboard_projects_by_status: 'Proyectos por estado',
|
||||||
|
|
||||||
projects_title: 'Proyectos',
|
projects_title: 'Proyectos',
|
||||||
@@ -646,12 +641,7 @@ export const messages = {
|
|||||||
dashboard_today_activities: 'Due today',
|
dashboard_today_activities: 'Due today',
|
||||||
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_active_projects: 'My active projects',
|
|
||||||
dashboard_pending_activities_list: 'Pending activities',
|
|
||||||
dashboard_overdue_activities_list: 'Overdue activities',
|
|
||||||
common_of: 'of',
|
common_of: 'of',
|
||||||
dashboard_back_to_my_activities: 'Show my activities',
|
|
||||||
dashboard_projects_by_status: 'Projects by status',
|
dashboard_projects_by_status: 'Projects by status',
|
||||||
|
|
||||||
projects_title: 'Projects',
|
projects_title: 'Projects',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useUi } from '../../context/ui-context.jsx';
|
|||||||
import { useAsync } from '../../hooks/useAsync.js';
|
import { useAsync } from '../../hooks/useAsync.js';
|
||||||
import { getSummary } from '../../services/dashboard.js';
|
import { getSummary } from '../../services/dashboard.js';
|
||||||
import { KpiCard } from '../../components/KpiCard.jsx';
|
import { KpiCard } from '../../components/KpiCard.jsx';
|
||||||
|
import { DetailPopup } from '../../components/DetailPopup.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' };
|
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) };
|
return { ratio, pct: Math.round(ratio * 100) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lista de proyectos que reemplaza "Mis actividades" al hacer clic en una tarjeta con detalle.
|
const dateOnly = (d) => (d ? String(d).slice(0, 10) : '—');
|
||||||
function ProjectsDetail({ title, projects, getDueStatus, onBack }) {
|
|
||||||
|
function ProjectRows({ projects, onOpen }) {
|
||||||
const { t } = useUi();
|
const { t } = useUi();
|
||||||
const navigate = useNavigate();
|
if (projects.length === 0) return <div className="text-muted">{t('common_empty')}</div>;
|
||||||
return (
|
return projects.map((p) => (
|
||||||
<div className="panel-card">
|
<div key={p.id} onClick={() => onOpen(`/proyectos/${p.id}`)} style={ROW_STYLE}>
|
||||||
<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={{ flex: 1 }}>
|
||||||
<div style={{ fontSize: '.82rem', fontWeight: 600 }}>{p.code} — {p.name}</div>
|
<div style={{ fontSize: '.82rem', fontWeight: 600 }}>{p.code} — {p.name}</div>
|
||||||
<div className="text-muted" style={{ fontSize: '.72rem' }}>
|
<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}` : ''}
|
{p.owner_name || '—'} · {dateOnly(p.estimated_end_date)}{p.status_name ? ` · ${p.status_name}` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ width: 120 }}><ProgressBar value={Number(p.progress_pct)} /></div>
|
<div style={{ width: 120 }}><ProgressBar value={Number(p.progress_pct)} /></div>
|
||||||
<DueBadge status={getDueStatus(p)} />
|
<DueBadge status={p.dueStatus} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
));
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Igual que ProjectsDetail pero para actividades (clic abre el proyecto en la actividad).
|
function ActivityRows({ activities, onOpen }) {
|
||||||
function ActivitiesDetail({ title, activities, onBack }) {
|
|
||||||
const { t } = useUi();
|
const { t } = useUi();
|
||||||
const navigate = useNavigate();
|
if (activities.length === 0) return <div className="text-muted">{t('common_empty')}</div>;
|
||||||
return (
|
return activities.map((a) => (
|
||||||
<div className="panel-card">
|
<div key={a.id} onClick={() => onOpen(`/proyectos/${a.project_id}?tab=activities&activity=${a.id}`)} style={ROW_STYLE}>
|
||||||
<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>
|
|
||||||
{activities.length === 0 && <div className="text-muted">{t('common_empty')}</div>}
|
|
||||||
{activities.map((a) => (
|
|
||||||
<div key={a.id} onClick={() => navigate(`/proyectos/${a.project_id}?tab=activities&activity=${a.id}`)} style={ROW_STYLE}>
|
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={{ fontSize: '.82rem', fontWeight: 600 }}>{a.code} — {a.name}</div>
|
<div style={{ fontSize: '.82rem', fontWeight: 600 }}>{a.code} — {a.name}</div>
|
||||||
<div className="text-muted" style={{ fontSize: '.72rem' }}>
|
<div className="text-muted" style={{ fontSize: '.72rem' }}>
|
||||||
{a.project_code} — {a.project_name} · {a.responsible_name || '—'} · {a.due_date ? String(a.due_date).slice(0, 10) : '—'}
|
{a.project_code} — {a.project_name} · {a.responsible_name || '—'} · {dateOnly(a.due_date)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ width: 120 }}><ProgressBar value={Number(a.progress_pct)} /></div>
|
<div style={{ width: 120 }}><ProgressBar value={Number(a.progress_pct)} /></div>
|
||||||
<DueBadge status={a.dueStatus} />
|
<DueBadge status={a.dueStatus} />
|
||||||
</div>
|
</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), o la clave de la tarjeta.
|
// Tarjeta cuyo detalle se muestra en el popup (null = cerrado).
|
||||||
const [detail, setDetail] = useState(null);
|
const [detail, setDetail] = useState(null);
|
||||||
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, activeProjects, pendingActivities, overdueActivities } = data;
|
const { kpis, myActivities, projectsByStatus } = 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 activeTotal = Number(kpis.activeProjects) || 0;
|
const activeTotal = Number(kpis.activeProjects) || 0;
|
||||||
const nonCancelledTotal = Number(kpis.nonCancelledProjects) || 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 active = ratioOf(kpis.activeProjects, nonCancelledTotal);
|
||||||
const overdue = ratioOf(kpis.overdueProjects, activeTotal);
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -92,55 +92,34 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
<div className="kpi-grid">
|
<div className="kpi-grid">
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label={t('dashboard_active_projects')}
|
label={t('dashboard_active_projects')} value={`${active.pct}%`}
|
||||||
value={`${active.pct}%`}
|
gauge={{ ratio: active.ratio, color: 'accent', caption: of(kpis.activeProjects, nonCancelledTotal) }}
|
||||||
gauge={{ ratio: active.ratio, color: 'accent', caption: `${kpis.activeProjects} ${t('common_of')} ${nonCancelledTotal}` }}
|
{...cardProps('active')}
|
||||||
onClick={() => toggleDetail('active')}
|
|
||||||
selected={detail === 'active'}
|
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label={t('dashboard_overdue_projects')}
|
label={t('dashboard_overdue_projects')} value={`${overdue.pct}%`}
|
||||||
value={`${overdue.pct}%`}
|
gauge={{ ratio: overdue.ratio, color: 'danger', caption: of(kpis.overdueProjects, activeTotal) }}
|
||||||
gauge={{ ratio: overdue.ratio, color: 'danger', caption: `${kpis.overdueProjects} ${t('common_of')} ${activeTotal}` }}
|
{...cardProps('overdue')}
|
||||||
onClick={() => toggleDetail('overdue')}
|
|
||||||
selected={detail === 'overdue'}
|
|
||||||
/>
|
/>
|
||||||
<KpiCard label={t('dashboard_due_soon_projects')} value={kpis.dueSoonProjects} />
|
<KpiCard label={t('dashboard_due_soon_projects')} value={kpis.dueSoonProjects} {...cardProps('dueSoon')} />
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label={t('dashboard_pending_activities')}
|
label={t('dashboard_pending_activities')} value={`${pending.pct}%`}
|
||||||
value={`${pending.pct}%`}
|
gauge={{ ratio: pending.ratio, color: 'warning', caption: of(kpis.pendingActivities, activitiesTotal) }}
|
||||||
gauge={{ ratio: pending.ratio, color: 'warning', caption: `${kpis.pendingActivities} ${t('common_of')} ${activitiesTotal}` }}
|
{...cardProps('pending')}
|
||||||
onClick={() => toggleDetail('pending')}
|
|
||||||
selected={detail === 'pending'}
|
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label={t('dashboard_overdue_activities')}
|
label={t('dashboard_overdue_activities')} value={`${overdueAct.pct}%`}
|
||||||
value={`${overdueAct.pct}%`}
|
gauge={{ ratio: overdueAct.ratio, color: 'danger', caption: of(kpis.overdueActivities, activitiesStrictTotal) }}
|
||||||
gauge={{ ratio: overdueAct.ratio, color: 'danger', caption: `${kpis.overdueActivities} ${t('common_of')} ${activitiesStrictTotal}` }}
|
{...cardProps('overdueActivities')}
|
||||||
onClick={() => toggleDetail('overdueActivities')}
|
|
||||||
selected={detail === 'overdueActivities'}
|
|
||||||
/>
|
/>
|
||||||
<KpiCard label={t('dashboard_today_activities')} value={kpis.todayActivities} />
|
<KpiCard label={t('dashboard_today_activities')} value={kpis.todayActivities} {...cardProps('today')} />
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label={t('dashboard_average_progress')}
|
label={t('dashboard_average_progress')} value={`${kpis.averageProgress}%`}
|
||||||
value={`${kpis.averageProgress}%`}
|
|
||||||
gauge={{ ratio: Number(kpis.averageProgress) / 100, color: 'success' }}
|
gauge={{ ratio: Number(kpis.averageProgress) / 100, color: 'success' }}
|
||||||
|
{...cardProps('average')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{detail === 'overdue' && (
|
|
||||||
<ProjectsDetail title={t('dashboard_my_overdue_projects')} projects={overdueProjects} getDueStatus={() => 'overdue'} onBack={() => setDetail(null)} />
|
|
||||||
)}
|
|
||||||
{detail === 'active' && (
|
|
||||||
<ProjectsDetail title={t('dashboard_my_active_projects')} projects={activeProjects} getDueStatus={(p) => p.dueStatus} onBack={() => setDetail(null)} />
|
|
||||||
)}
|
|
||||||
{detail === 'pending' && (
|
|
||||||
<ActivitiesDetail title={t('dashboard_pending_activities_list')} activities={pendingActivities} onBack={() => setDetail(null)} />
|
|
||||||
)}
|
|
||||||
{detail === 'overdueActivities' && (
|
|
||||||
<ActivitiesDetail title={t('dashboard_overdue_activities_list')} activities={overdueActivities} onBack={() => setDetail(null)} />
|
|
||||||
)}
|
|
||||||
{!detail && (
|
|
||||||
<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>}
|
||||||
@@ -155,7 +134,6 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="panel-card">
|
<div className="panel-card">
|
||||||
<h3>{t('dashboard_projects_by_status')}</h3>
|
<h3>{t('dashboard_projects_by_status')}</h3>
|
||||||
@@ -167,6 +145,17 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DetailPopup
|
||||||
|
open={!!current}
|
||||||
|
title={current ? `${current.title} (${current.rows.length})` : ''}
|
||||||
|
onClose={() => setDetail(null)}
|
||||||
|
width={820}
|
||||||
|
height={560}
|
||||||
|
>
|
||||||
|
{current?.kind === 'projects' && <ProjectRows projects={current.rows} onOpen={openFromPopup} />}
|
||||||
|
{current?.kind === 'activities' && <ActivityRows activities={current.rows} onOpen={openFromPopup} />}
|
||||||
|
</DetailPopup>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user