diff --git a/MtdrSpring/backend/src/main/frontend/src/components/dashboard/EfficiencyChart.jsx b/MtdrSpring/backend/src/main/frontend/src/components/dashboard/EfficiencyChart.jsx index f64423c73..2ae103276 100644 --- a/MtdrSpring/backend/src/main/frontend/src/components/dashboard/EfficiencyChart.jsx +++ b/MtdrSpring/backend/src/main/frontend/src/components/dashboard/EfficiencyChart.jsx @@ -36,7 +36,7 @@ const CustomLegend = ({ payload }) => ( ); -export default function EfficiencyChart({ sprintId }) { +export default function EfficiencyChart({ sprintId, devId }) { const { project } = useProject(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); @@ -54,10 +54,11 @@ export default function EfficiencyChart({ sprintId }) { if (loading) return ; if (error) return

{error}

; if (!data) return

Select a sprint to view data.

; - if (!data.members?.length) return

No completed tasks in this sprint.

; + const visibleMembers = (data.members || []).filter((m) => !devId || m.userId === devId); + if (!visibleMembers.length) return

No completed tasks in this sprint.

; const handleExport = () => { - const rows = data.members.map((m) => ({ + const rows = visibleMembers.map((m) => ({ Miembro: m.fullName, 'SP Completados': m.spCompleted, 'Horas Reales': parseFloat(m.actualHours.toFixed(1)), @@ -65,7 +66,7 @@ export default function EfficiencyChart({ sprintId }) { exportCsv(rows, `efficiency-sprint-${sprintId}`); }; - const chartData = data.members.map((m) => ({ + const chartData = visibleMembers.map((m) => ({ name: m.fullName.split(' ')[0], 'SP done': m.spCompleted, 'Hours': parseFloat(m.actualHours.toFixed(1)), diff --git a/MtdrSpring/backend/src/main/frontend/src/components/dashboard/HoursByDeveloperChart.jsx b/MtdrSpring/backend/src/main/frontend/src/components/dashboard/HoursByDeveloperChart.jsx index 0fe59f518..8ac70a616 100644 --- a/MtdrSpring/backend/src/main/frontend/src/components/dashboard/HoursByDeveloperChart.jsx +++ b/MtdrSpring/backend/src/main/frontend/src/components/dashboard/HoursByDeveloperChart.jsx @@ -41,7 +41,7 @@ const CustomLegend = ({ payload }) => ( ); -export default function HoursByDeveloperChart({ sprints }) { +export default function HoursByDeveloperChart({ sprints, selectedSprintId, devId }) { const { project } = useProject(); const [chartData, setChartData] = useState([]); const [members, setMembers] = useState([]); @@ -51,19 +51,24 @@ export default function HoursByDeveloperChart({ sprints }) { useEffect(() => { if (!sprints?.length) { setLoading(false); return; } - const active = sprints.find((s) => s.status === 'ACTIVE'); - const closed = sprints - .filter((s) => s.status === 'CLOSED') - .sort((a, b) => new Date(a.endDate) - new Date(b.endDate)) - .slice(-3); - const selected = [...closed, ...(active ? [active] : [])]; + let selected; + if (selectedSprintId) { + const single = sprints.find((s) => s.id === selectedSprintId); + selected = single ? [single] : []; + } else { + const active = sprints.find((s) => s.status === 'ACTIVE'); + const closed = sprints + .filter((s) => s.status === 'CLOSED') + .sort((a, b) => new Date(a.endDate) - new Date(b.endDate)) + .slice(-3); + selected = [...closed, ...(active ? [active] : [])]; + } if (!selected.length) { setLoading(false); return; } setLoading(true); Promise.all(selected.map((s) => dashboardService.efficiency(project.id, s.id))) .then((results) => { - // Aggregate total hours per member across all sprints const totals = {}; const nameMap = {}; results.forEach((res) => { @@ -73,18 +78,17 @@ export default function HoursByDeveloperChart({ sprints }) { }); }); - // Top 8 by total hours - const top8ids = Object.entries(totals) + let candidateIds = Object.entries(totals) .sort((a, b) => b[1] - a[1]) - .slice(0, 8) .map(([id]) => Number(id)); + if (devId) candidateIds = candidateIds.filter((id) => id === devId); + const top8ids = candidateIds.slice(0, 8); - // Build recharts data const data = selected.map((s, i) => { const row = { sprint: s.sprintName }; - const members = results[i]?.members || []; + const sprintMembers = results[i]?.members || []; top8ids.forEach((uid) => { - const m = members.find((x) => x.userId === uid); + const m = sprintMembers.find((x) => x.userId === uid); row[nameMap[uid]] = m ? parseFloat(m.actualHours.toFixed(1)) : 0; }); return row; @@ -95,7 +99,7 @@ export default function HoursByDeveloperChart({ sprints }) { }) .catch((err) => setError(err.message)) .finally(() => setLoading(false)); - }, [project.id, sprints]); + }, [project.id, sprints, selectedSprintId, devId]); if (loading) return ; if (error) return

{error}

; diff --git a/MtdrSpring/backend/src/main/frontend/src/components/dashboard/HoursPerMember.jsx b/MtdrSpring/backend/src/main/frontend/src/components/dashboard/HoursPerMember.jsx index bfd131bca..d24ad7cf1 100644 --- a/MtdrSpring/backend/src/main/frontend/src/components/dashboard/HoursPerMember.jsx +++ b/MtdrSpring/backend/src/main/frontend/src/components/dashboard/HoursPerMember.jsx @@ -10,7 +10,7 @@ function initials(name) { return name?.split(' ').map((n) => n[0]).slice(0, 2).join('') ?? '?'; } -export default function HoursPerMember({ sprintId }) { +export default function HoursPerMember({ sprintId, devId }) { const { project } = useProject(); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); @@ -30,7 +30,8 @@ export default function HoursPerMember({ sprintId }) { if (!data) return

Select a sprint to view data.

; if (!data.members?.length) return

No hours logged yet.

; - const sorted = [...data.members].sort((a, b) => b.actualHours - a.actualHours); + const filtered = devId ? data.members.filter((m) => m.userId === devId) : data.members; + const sorted = [...filtered].sort((a, b) => b.actualHours - a.actualHours); const max = sorted[0]?.actualHours || 1; return ( diff --git a/MtdrSpring/backend/src/main/frontend/src/components/dashboard/KpiDevStats.jsx b/MtdrSpring/backend/src/main/frontend/src/components/dashboard/KpiDevStats.jsx new file mode 100644 index 000000000..d793034c9 --- /dev/null +++ b/MtdrSpring/backend/src/main/frontend/src/components/dashboard/KpiDevStats.jsx @@ -0,0 +1,84 @@ +import React, { useState, useEffect } from 'react'; +import { useProject } from '../../context/ProjectContext'; +import { dashboardService } from '../../services/dashboardService'; + +function Skeleton() { + return
; +} + +function median(arr) { + if (!arr.length) return 0; + const sorted = [...arr].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; +} + +function MiniKpi({ label, value, sub }) { + return ( +
+ {value} + {label} + {sub && {sub}} +
+ ); +} + +export default function KpiDevStats({ sprintId, devId }) { + const { project } = useProject(); + const [effData, setEffData] = useState(null); + const [wkData, setWkData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + setLoading(true); + setError(''); + Promise.all([ + dashboardService.efficiency(project.id, sprintId), + dashboardService.workload(project.id, sprintId), + ]) + .then(([eff, wk]) => { setEffData(eff); setWkData(wk); }) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)); + }, [project.id, sprintId]); + + if (loading) { + return ( +
+ {[0, 1, 2, 3].map((i) => )} +
+ ); + } + if (error) return

{error}

; + + const effMembers = (effData?.members || []).filter((m) => !devId || m.userId === devId); + const wkMembers = (wkData || []).filter((m) => !devId || m.userId === devId); + + const hoursArr = effMembers.map((m) => m.actualHours || 0); + const tasksArr = wkMembers.map((m) => m.taskCounts?.DONE || 0); + + if (!hoursArr.length && !tasksArr.length) { + return ( +

+ No hay datos para los filtros seleccionados. +

+ ); + } + + const n = Math.max(hoursArr.length, tasksArr.length); + const avgTasks = tasksArr.length ? tasksArr.reduce((s, v) => s + v, 0) / tasksArr.length : 0; + const avgHours = hoursArr.length ? hoursArr.reduce((s, v) => s + v, 0) / hoursArr.length : 0; + const medTasks = median(tasksArr); + const medHours = median(hoursArr); + + const devLabel = `${n} dev${n !== 1 ? 's' : ''}`; + + return ( +
+ + + + +
+ ); +} diff --git a/MtdrSpring/backend/src/main/frontend/src/components/dashboard/TasksByDeveloperChart.jsx b/MtdrSpring/backend/src/main/frontend/src/components/dashboard/TasksByDeveloperChart.jsx index f09b85139..b3ba8b79e 100644 --- a/MtdrSpring/backend/src/main/frontend/src/components/dashboard/TasksByDeveloperChart.jsx +++ b/MtdrSpring/backend/src/main/frontend/src/components/dashboard/TasksByDeveloperChart.jsx @@ -41,7 +41,7 @@ const CustomLegend = ({ payload }) => (
); -export default function TasksByDeveloperChart({ sprints }) { +export default function TasksByDeveloperChart({ sprints, selectedSprintId, devId }) { const { project } = useProject(); const [chartData, setChartData] = useState([]); const [members, setMembers] = useState([]); @@ -51,19 +51,24 @@ export default function TasksByDeveloperChart({ sprints }) { useEffect(() => { if (!sprints?.length) { setLoading(false); return; } - const active = sprints.find((s) => s.status === 'ACTIVE'); - const closed = sprints - .filter((s) => s.status === 'CLOSED') - .sort((a, b) => new Date(a.endDate) - new Date(b.endDate)) - .slice(-3); - const selected = [...closed, ...(active ? [active] : [])]; + let selected; + if (selectedSprintId) { + const single = sprints.find((s) => s.id === selectedSprintId); + selected = single ? [single] : []; + } else { + const active = sprints.find((s) => s.status === 'ACTIVE'); + const closed = sprints + .filter((s) => s.status === 'CLOSED') + .sort((a, b) => new Date(a.endDate) - new Date(b.endDate)) + .slice(-3); + selected = [...closed, ...(active ? [active] : [])]; + } if (!selected.length) { setLoading(false); return; } setLoading(true); Promise.all(selected.map((s) => dashboardService.workload(project.id, s.id))) .then((results) => { - // Aggregate total DONE per member across all sprints const totals = {}; const nameMap = {}; results.forEach((sprintData) => { @@ -73,13 +78,12 @@ export default function TasksByDeveloperChart({ sprints }) { }); }); - // Top 8 by total tasks DONE - const top8ids = Object.entries(totals) + let candidateIds = Object.entries(totals) .sort((a, b) => b[1] - a[1]) - .slice(0, 8) .map(([id]) => Number(id)); + if (devId) candidateIds = candidateIds.filter((id) => id === devId); + const top8ids = candidateIds.slice(0, 8); - // Build recharts data const data = selected.map((s, i) => { const row = { sprint: s.sprintName }; const sprintData = results[i]; @@ -95,7 +99,7 @@ export default function TasksByDeveloperChart({ sprints }) { }) .catch((err) => setError(err.message)) .finally(() => setLoading(false)); - }, [project.id, sprints]); + }, [project.id, sprints, selectedSprintId, devId]); if (loading) return ; if (error) return

{error}

; diff --git a/MtdrSpring/backend/src/main/frontend/src/components/dashboard/WorkloadTable.jsx b/MtdrSpring/backend/src/main/frontend/src/components/dashboard/WorkloadTable.jsx index 1957de9ba..4992f9211 100644 --- a/MtdrSpring/backend/src/main/frontend/src/components/dashboard/WorkloadTable.jsx +++ b/MtdrSpring/backend/src/main/frontend/src/components/dashboard/WorkloadTable.jsx @@ -8,7 +8,7 @@ const STATUSES = ['TODO', 'IN_PROGRESS', 'BLOCKED', 'DONE']; function Skeleton() { return
; } -export default function WorkloadTable({ sprintId }) { +export default function WorkloadTable({ sprintId, devId }) { const { project } = useProject(); const [data, setData] = useState([]); const [loading, setLoading] = useState(true); @@ -26,7 +26,8 @@ export default function WorkloadTable({ sprintId }) { if (loading) return ; if (error) return

{error}

; - if (!data.length) return

Select a sprint to view data.

; + const visibleData = data.filter((m) => !devId || m.userId === devId); + if (!visibleData.length) return

Select a sprint to view data.

; const getValue = (member, status) => mode === 'tasks' @@ -37,7 +38,7 @@ export default function WorkloadTable({ sprintId }) { STATUSES.reduce((sum, s) => sum + getValue(member, s), 0); const handleExport = () => { - const rows = data.map((m) => ({ + const rows = visibleData.map((m) => ({ Miembro: m.fullName, 'Tasks TODO': m.taskCounts?.TODO ?? 0, 'Tasks IN_PROGRESS': m.taskCounts?.IN_PROGRESS ?? 0, @@ -86,8 +87,8 @@ export default function WorkloadTable({ sprintId }) { - {data.map((member, i) => ( - + {visibleData.map((member, i) => ( + {member.fullName} {STATUSES.map((s) => ( { sprintService.list(project.id).then((data) => { @@ -54,6 +58,23 @@ export default function DashboardPage() { }).catch(() => {}); }, [project.id]); + // Load developer list from workload (all sprints) + useEffect(() => { + dashboardService.workload(project.id, null) + .then((members) => { + const unique = []; + const seen = new Set(); + (members || []).forEach((m) => { + if (!seen.has(m.userId)) { + seen.add(m.userId); + unique.push({ id: m.userId, name: m.fullName }); + } + }); + setDevelopers(unique.sort((a, b) => a.name.localeCompare(b.name))); + }) + .catch(() => {}); + }, [project.id]); + const sid = selectedSprintId || null; return ( @@ -66,12 +87,20 @@ export default function DashboardPage() {

{project.projectName}

- setSelectedSprintId(v ? Number(v) : null)} - options={sprints.map((s) => ({ value: String(s.id), label: sprintLabel(s) }))} - placeholder="No sprint selected" - /> +
+ setSelectedSprintId(v ? Number(v) : null)} + options={sprints.map((s) => ({ value: String(s.id), label: sprintLabel(s) }))} + placeholder="All Sprints" + /> + setSelectedDevId(v ? Number(v) : null)} + options={developers.map((d) => ({ value: String(d.id), label: d.name }))} + placeholder="All Devs" + /> +
@@ -81,7 +110,7 @@ export default function DashboardPage() { - {/* KPI row */} + {/* KPI row — existing */} @@ -92,20 +121,25 @@ export default function DashboardPage() { + {/* KPI row — new: Avg/Median por dev */} + + + + {/* Charts row */} - + {/* Tables row */} - + - + {/* Backlog */} @@ -113,12 +147,12 @@ export default function DashboardPage() { - {/* Developer charts — last 3 sprints, top 8 by volume */} + {/* Developer charts — filtered by sprint/dev */} - + - + {/* Compare panel */}