Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const CustomLegend = ({ payload }) => (
</div>
);

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

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)),
}));
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)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const CustomLegend = ({ payload }) => (
</div>
);

export default function HoursByDeveloperChart({ sprints }) {
export default function HoursByDeveloperChart({ sprints, selectedSprintId, devId }) {
const { project } = useProject();
const [chartData, setChartData] = useState([]);
const [members, setMembers] = useState([]);
Expand All @@ -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) => {
Expand All @@ -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;
Expand All @@ -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 <Skeleton />;
if (error) return <p className="text-xs text-oracle">{error}</p>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -30,7 +30,8 @@ export default function HoursPerMember({ sprintId }) {
if (!data) return <p className="text-sm" style={{ color: 'var(--text-muted)' }}>Select a sprint to view data.</p>;
if (!data.members?.length) return <p className="text-sm" style={{ color: 'var(--text-muted)' }}>No hours logged yet.</p>;

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 (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import React, { useState, useEffect } from 'react';
import { useProject } from '../../context/ProjectContext';
import { dashboardService } from '../../services/dashboardService';

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

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 (
<div className="flex flex-col items-center justify-center py-4 gap-1">
<span className="text-5xl font-display font-extrabold tabular-nums leading-none text-navy">{value}</span>
<span className="text-[10px] font-bold uppercase tracking-widest mt-1" style={{ color: 'var(--text-muted)' }}>{label}</span>
{sub && <span className="text-[11px]" style={{ color: 'var(--text-muted)' }}>{sub}</span>}
</div>
);
}

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 (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[0, 1, 2, 3].map((i) => <Skeleton key={i} />)}
</div>
);
}
if (error) return <p className="text-xs text-red-500">{error}</p>;

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 (
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>
No hay datos para los filtros seleccionados.
</p>
);
}

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 (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<MiniKpi label="Avg Task / Dev" value={avgTasks.toFixed(1)} sub={devLabel} />
<MiniKpi label="Avg Hours / Dev" value={avgHours.toFixed(1)} sub={devLabel} />
<MiniKpi label="Median Task / Dev" value={medTasks.toFixed(1)} sub="mediana tareas" />
<MiniKpi label="Median Hours / Dev" value={medHours.toFixed(1)} sub="mediana horas" />
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const CustomLegend = ({ payload }) => (
</div>
);

export default function TasksByDeveloperChart({ sprints }) {
export default function TasksByDeveloperChart({ sprints, selectedSprintId, devId }) {
const { project } = useProject();
const [chartData, setChartData] = useState([]);
const [members, setMembers] = useState([]);
Expand All @@ -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) => {
Expand All @@ -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];
Expand All @@ -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 <Skeleton />;
if (error) return <p className="text-xs text-oracle">{error}</p>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const STATUSES = ['TODO', 'IN_PROGRESS', 'BLOCKED', 'DONE'];

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

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

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

const getValue = (member, status) =>
mode === 'tasks'
Expand All @@ -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,
Expand Down Expand Up @@ -86,8 +87,8 @@ export default function WorkloadTable({ sprintId }) {
</tr>
</thead>
<tbody>
{data.map((member, i) => (
<tr key={member.userId} style={{ borderBottom: i < data.length - 1 ? '1px solid var(--border)' : 'none' }}>
{visibleData.map((member, i) => (
<tr key={member.userId} style={{ borderBottom: i < visibleData.length - 1 ? '1px solid var(--border)' : 'none' }}>
<td className="py-2.5 pr-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-primary)' }}>{member.fullName}</td>
{STATUSES.map((s) => (
<td
Expand Down
Loading
Loading