Skip to content

Commit 8a2fbc7

Browse files
Merge pull request #18 from PatWhite29/feat/dashboard-kpi-avg-median-filters
feat(frontend): dashboard KPI avg/median por dev y filtros por sprint/developer
2 parents f7e6912 + f7588ad commit 8a2fbc7

7 files changed

Lines changed: 181 additions & 52 deletions

File tree

MtdrSpring/backend/src/main/frontend/src/components/dashboard/EfficiencyChart.jsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ const CustomLegend = ({ payload }) => (
3636
</div>
3737
);
3838

39-
export default function EfficiencyChart({ sprintId }) {
39+
export default function EfficiencyChart({ sprintId, devId }) {
4040
const { project } = useProject();
4141
const [data, setData] = useState(null);
4242
const [loading, setLoading] = useState(true);
@@ -54,18 +54,19 @@ export default function EfficiencyChart({ sprintId }) {
5454
if (loading) return <Skeleton />;
5555
if (error) return <p className="text-xs text-oracle">{error}</p>;
5656
if (!data) return <p className="text-sm text-gray-400">Select a sprint to view data.</p>;
57-
if (!data.members?.length) return <p className="text-sm text-gray-400">No completed tasks in this sprint.</p>;
57+
const visibleMembers = (data.members || []).filter((m) => !devId || m.userId === devId);
58+
if (!visibleMembers.length) return <p className="text-sm text-gray-400">No completed tasks in this sprint.</p>;
5859

5960
const handleExport = () => {
60-
const rows = data.members.map((m) => ({
61+
const rows = visibleMembers.map((m) => ({
6162
Miembro: m.fullName,
6263
'SP Completados': m.spCompleted,
6364
'Horas Reales': parseFloat(m.actualHours.toFixed(1)),
6465
}));
6566
exportCsv(rows, `efficiency-sprint-${sprintId}`);
6667
};
6768

68-
const chartData = data.members.map((m) => ({
69+
const chartData = visibleMembers.map((m) => ({
6970
name: m.fullName.split(' ')[0],
7071
'SP done': m.spCompleted,
7172
'Hours': parseFloat(m.actualHours.toFixed(1)),

MtdrSpring/backend/src/main/frontend/src/components/dashboard/HoursByDeveloperChart.jsx

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ const CustomLegend = ({ payload }) => (
4141
</div>
4242
);
4343

44-
export default function HoursByDeveloperChart({ sprints }) {
44+
export default function HoursByDeveloperChart({ sprints, selectedSprintId, devId }) {
4545
const { project } = useProject();
4646
const [chartData, setChartData] = useState([]);
4747
const [members, setMembers] = useState([]);
@@ -51,19 +51,24 @@ export default function HoursByDeveloperChart({ sprints }) {
5151
useEffect(() => {
5252
if (!sprints?.length) { setLoading(false); return; }
5353

54-
const active = sprints.find((s) => s.status === 'ACTIVE');
55-
const closed = sprints
56-
.filter((s) => s.status === 'CLOSED')
57-
.sort((a, b) => new Date(a.endDate) - new Date(b.endDate))
58-
.slice(-3);
59-
const selected = [...closed, ...(active ? [active] : [])];
54+
let selected;
55+
if (selectedSprintId) {
56+
const single = sprints.find((s) => s.id === selectedSprintId);
57+
selected = single ? [single] : [];
58+
} else {
59+
const active = sprints.find((s) => s.status === 'ACTIVE');
60+
const closed = sprints
61+
.filter((s) => s.status === 'CLOSED')
62+
.sort((a, b) => new Date(a.endDate) - new Date(b.endDate))
63+
.slice(-3);
64+
selected = [...closed, ...(active ? [active] : [])];
65+
}
6066

6167
if (!selected.length) { setLoading(false); return; }
6268

6369
setLoading(true);
6470
Promise.all(selected.map((s) => dashboardService.efficiency(project.id, s.id)))
6571
.then((results) => {
66-
// Aggregate total hours per member across all sprints
6772
const totals = {};
6873
const nameMap = {};
6974
results.forEach((res) => {
@@ -73,18 +78,17 @@ export default function HoursByDeveloperChart({ sprints }) {
7378
});
7479
});
7580

76-
// Top 8 by total hours
77-
const top8ids = Object.entries(totals)
81+
let candidateIds = Object.entries(totals)
7882
.sort((a, b) => b[1] - a[1])
79-
.slice(0, 8)
8083
.map(([id]) => Number(id));
84+
if (devId) candidateIds = candidateIds.filter((id) => id === devId);
85+
const top8ids = candidateIds.slice(0, 8);
8186

82-
// Build recharts data
8387
const data = selected.map((s, i) => {
8488
const row = { sprint: s.sprintName };
85-
const members = results[i]?.members || [];
89+
const sprintMembers = results[i]?.members || [];
8690
top8ids.forEach((uid) => {
87-
const m = members.find((x) => x.userId === uid);
91+
const m = sprintMembers.find((x) => x.userId === uid);
8892
row[nameMap[uid]] = m ? parseFloat(m.actualHours.toFixed(1)) : 0;
8993
});
9094
return row;
@@ -95,7 +99,7 @@ export default function HoursByDeveloperChart({ sprints }) {
9599
})
96100
.catch((err) => setError(err.message))
97101
.finally(() => setLoading(false));
98-
}, [project.id, sprints]);
102+
}, [project.id, sprints, selectedSprintId, devId]);
99103

100104
if (loading) return <Skeleton />;
101105
if (error) return <p className="text-xs text-oracle">{error}</p>;

MtdrSpring/backend/src/main/frontend/src/components/dashboard/HoursPerMember.jsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ function initials(name) {
1010
return name?.split(' ').map((n) => n[0]).slice(0, 2).join('') ?? '?';
1111
}
1212

13-
export default function HoursPerMember({ sprintId }) {
13+
export default function HoursPerMember({ sprintId, devId }) {
1414
const { project } = useProject();
1515
const [data, setData] = useState(null);
1616
const [loading, setLoading] = useState(true);
@@ -30,7 +30,8 @@ export default function HoursPerMember({ sprintId }) {
3030
if (!data) return <p className="text-sm" style={{ color: 'var(--text-muted)' }}>Select a sprint to view data.</p>;
3131
if (!data.members?.length) return <p className="text-sm" style={{ color: 'var(--text-muted)' }}>No hours logged yet.</p>;
3232

33-
const sorted = [...data.members].sort((a, b) => b.actualHours - a.actualHours);
33+
const filtered = devId ? data.members.filter((m) => m.userId === devId) : data.members;
34+
const sorted = [...filtered].sort((a, b) => b.actualHours - a.actualHours);
3435
const max = sorted[0]?.actualHours || 1;
3536

3637
return (
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import React, { useState, useEffect } from 'react';
2+
import { useProject } from '../../context/ProjectContext';
3+
import { dashboardService } from '../../services/dashboardService';
4+
5+
function Skeleton() {
6+
return <div className="animate-pulse h-24 rounded-lg" style={{ background: 'var(--bg-card-alt)' }} />;
7+
}
8+
9+
function median(arr) {
10+
if (!arr.length) return 0;
11+
const sorted = [...arr].sort((a, b) => a - b);
12+
const mid = Math.floor(sorted.length / 2);
13+
return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
14+
}
15+
16+
function MiniKpi({ label, value, sub }) {
17+
return (
18+
<div className="flex flex-col items-center justify-center py-4 gap-1">
19+
<span className="text-5xl font-display font-extrabold tabular-nums leading-none text-navy">{value}</span>
20+
<span className="text-[10px] font-bold uppercase tracking-widest mt-1" style={{ color: 'var(--text-muted)' }}>{label}</span>
21+
{sub && <span className="text-[11px]" style={{ color: 'var(--text-muted)' }}>{sub}</span>}
22+
</div>
23+
);
24+
}
25+
26+
export default function KpiDevStats({ sprintId, devId }) {
27+
const { project } = useProject();
28+
const [effData, setEffData] = useState(null);
29+
const [wkData, setWkData] = useState(null);
30+
const [loading, setLoading] = useState(true);
31+
const [error, setError] = useState('');
32+
33+
useEffect(() => {
34+
setLoading(true);
35+
setError('');
36+
Promise.all([
37+
dashboardService.efficiency(project.id, sprintId),
38+
dashboardService.workload(project.id, sprintId),
39+
])
40+
.then(([eff, wk]) => { setEffData(eff); setWkData(wk); })
41+
.catch((err) => setError(err.message))
42+
.finally(() => setLoading(false));
43+
}, [project.id, sprintId]);
44+
45+
if (loading) {
46+
return (
47+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
48+
{[0, 1, 2, 3].map((i) => <Skeleton key={i} />)}
49+
</div>
50+
);
51+
}
52+
if (error) return <p className="text-xs text-red-500">{error}</p>;
53+
54+
const effMembers = (effData?.members || []).filter((m) => !devId || m.userId === devId);
55+
const wkMembers = (wkData || []).filter((m) => !devId || m.userId === devId);
56+
57+
const hoursArr = effMembers.map((m) => m.actualHours || 0);
58+
const tasksArr = wkMembers.map((m) => m.taskCounts?.DONE || 0);
59+
60+
if (!hoursArr.length && !tasksArr.length) {
61+
return (
62+
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>
63+
No hay datos para los filtros seleccionados.
64+
</p>
65+
);
66+
}
67+
68+
const n = Math.max(hoursArr.length, tasksArr.length);
69+
const avgTasks = tasksArr.length ? tasksArr.reduce((s, v) => s + v, 0) / tasksArr.length : 0;
70+
const avgHours = hoursArr.length ? hoursArr.reduce((s, v) => s + v, 0) / hoursArr.length : 0;
71+
const medTasks = median(tasksArr);
72+
const medHours = median(hoursArr);
73+
74+
const devLabel = `${n} dev${n !== 1 ? 's' : ''}`;
75+
76+
return (
77+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
78+
<MiniKpi label="Avg Task / Dev" value={avgTasks.toFixed(1)} sub={devLabel} />
79+
<MiniKpi label="Avg Hours / Dev" value={avgHours.toFixed(1)} sub={devLabel} />
80+
<MiniKpi label="Median Task / Dev" value={medTasks.toFixed(1)} sub="mediana tareas" />
81+
<MiniKpi label="Median Hours / Dev" value={medHours.toFixed(1)} sub="mediana horas" />
82+
</div>
83+
);
84+
}

MtdrSpring/backend/src/main/frontend/src/components/dashboard/TasksByDeveloperChart.jsx

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ const CustomLegend = ({ payload }) => (
4141
</div>
4242
);
4343

44-
export default function TasksByDeveloperChart({ sprints }) {
44+
export default function TasksByDeveloperChart({ sprints, selectedSprintId, devId }) {
4545
const { project } = useProject();
4646
const [chartData, setChartData] = useState([]);
4747
const [members, setMembers] = useState([]);
@@ -51,19 +51,24 @@ export default function TasksByDeveloperChart({ sprints }) {
5151
useEffect(() => {
5252
if (!sprints?.length) { setLoading(false); return; }
5353

54-
const active = sprints.find((s) => s.status === 'ACTIVE');
55-
const closed = sprints
56-
.filter((s) => s.status === 'CLOSED')
57-
.sort((a, b) => new Date(a.endDate) - new Date(b.endDate))
58-
.slice(-3);
59-
const selected = [...closed, ...(active ? [active] : [])];
54+
let selected;
55+
if (selectedSprintId) {
56+
const single = sprints.find((s) => s.id === selectedSprintId);
57+
selected = single ? [single] : [];
58+
} else {
59+
const active = sprints.find((s) => s.status === 'ACTIVE');
60+
const closed = sprints
61+
.filter((s) => s.status === 'CLOSED')
62+
.sort((a, b) => new Date(a.endDate) - new Date(b.endDate))
63+
.slice(-3);
64+
selected = [...closed, ...(active ? [active] : [])];
65+
}
6066

6167
if (!selected.length) { setLoading(false); return; }
6268

6369
setLoading(true);
6470
Promise.all(selected.map((s) => dashboardService.workload(project.id, s.id)))
6571
.then((results) => {
66-
// Aggregate total DONE per member across all sprints
6772
const totals = {};
6873
const nameMap = {};
6974
results.forEach((sprintData) => {
@@ -73,13 +78,12 @@ export default function TasksByDeveloperChart({ sprints }) {
7378
});
7479
});
7580

76-
// Top 8 by total tasks DONE
77-
const top8ids = Object.entries(totals)
81+
let candidateIds = Object.entries(totals)
7882
.sort((a, b) => b[1] - a[1])
79-
.slice(0, 8)
8083
.map(([id]) => Number(id));
84+
if (devId) candidateIds = candidateIds.filter((id) => id === devId);
85+
const top8ids = candidateIds.slice(0, 8);
8186

82-
// Build recharts data
8387
const data = selected.map((s, i) => {
8488
const row = { sprint: s.sprintName };
8589
const sprintData = results[i];
@@ -95,7 +99,7 @@ export default function TasksByDeveloperChart({ sprints }) {
9599
})
96100
.catch((err) => setError(err.message))
97101
.finally(() => setLoading(false));
98-
}, [project.id, sprints]);
102+
}, [project.id, sprints, selectedSprintId, devId]);
99103

100104
if (loading) return <Skeleton />;
101105
if (error) return <p className="text-xs text-oracle">{error}</p>;

MtdrSpring/backend/src/main/frontend/src/components/dashboard/WorkloadTable.jsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const STATUSES = ['TODO', 'IN_PROGRESS', 'BLOCKED', 'DONE'];
88

99
function Skeleton() { return <div className="animate-pulse h-40 rounded-lg" style={{ background: 'var(--bg-card-alt)' }} />; }
1010

11-
export default function WorkloadTable({ sprintId }) {
11+
export default function WorkloadTable({ sprintId, devId }) {
1212
const { project } = useProject();
1313
const [data, setData] = useState([]);
1414
const [loading, setLoading] = useState(true);
@@ -26,7 +26,8 @@ export default function WorkloadTable({ sprintId }) {
2626

2727
if (loading) return <Skeleton />;
2828
if (error) return <p className="text-xs text-red-500">{error}</p>;
29-
if (!data.length) return <p className="text-sm" style={{ color: 'var(--text-muted)' }}>Select a sprint to view data.</p>;
29+
const visibleData = data.filter((m) => !devId || m.userId === devId);
30+
if (!visibleData.length) return <p className="text-sm" style={{ color: 'var(--text-muted)' }}>Select a sprint to view data.</p>;
3031

3132
const getValue = (member, status) =>
3233
mode === 'tasks'
@@ -37,7 +38,7 @@ export default function WorkloadTable({ sprintId }) {
3738
STATUSES.reduce((sum, s) => sum + getValue(member, s), 0);
3839

3940
const handleExport = () => {
40-
const rows = data.map((m) => ({
41+
const rows = visibleData.map((m) => ({
4142
Miembro: m.fullName,
4243
'Tasks TODO': m.taskCounts?.TODO ?? 0,
4344
'Tasks IN_PROGRESS': m.taskCounts?.IN_PROGRESS ?? 0,
@@ -86,8 +87,8 @@ export default function WorkloadTable({ sprintId }) {
8687
</tr>
8788
</thead>
8889
<tbody>
89-
{data.map((member, i) => (
90-
<tr key={member.userId} style={{ borderBottom: i < data.length - 1 ? '1px solid var(--border)' : 'none' }}>
90+
{visibleData.map((member, i) => (
91+
<tr key={member.userId} style={{ borderBottom: i < visibleData.length - 1 ? '1px solid var(--border)' : 'none' }}>
9192
<td className="py-2.5 pr-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-primary)' }}>{member.fullName}</td>
9293
{STATUSES.map((s) => (
9394
<td

0 commit comments

Comments
 (0)