444 lines
16 KiB
TypeScript
444 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { TeamReviewData } from "@/lib/team-review-types";
|
|
import {
|
|
BarChart,
|
|
Bar,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
ResponsiveContainer,
|
|
PieChart,
|
|
Pie,
|
|
Cell,
|
|
Legend,
|
|
} from "recharts";
|
|
import {
|
|
COVERAGE_OBJECTIVES,
|
|
isCoverageBelowObjective,
|
|
} from "@/lib/evaluation-utils";
|
|
import {
|
|
Tooltip as UITooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
|
|
interface TeamStatsProps {
|
|
stats: TeamReviewData["stats"];
|
|
members: TeamReviewData["members"];
|
|
skillGaps: TeamReviewData["skillGaps"];
|
|
categoryCoverage: TeamReviewData["categoryCoverage"];
|
|
}
|
|
|
|
export function TeamStats({
|
|
stats,
|
|
members,
|
|
skillGaps,
|
|
categoryCoverage,
|
|
}: TeamStatsProps) {
|
|
// Calcul des statistiques avancées
|
|
const totalEvaluations = members.reduce(
|
|
(acc, member) => acc + member.skills.length,
|
|
0
|
|
);
|
|
const avgSkillsPerMember = totalEvaluations / (stats.totalMembers || 1);
|
|
|
|
// Distribution des niveaux
|
|
const levelDistribution = members.reduce((acc, member) => {
|
|
member.skills.forEach((skill) => {
|
|
acc[skill.level] = (acc[skill.level] || 0) + 1;
|
|
});
|
|
return acc;
|
|
}, {} as Record<string, number>);
|
|
|
|
const levelData = [
|
|
{ name: "Expert", value: levelDistribution.expert || 0, color: "#4f46e5" },
|
|
{
|
|
name: "Autonome",
|
|
value: levelDistribution.autonomous || 0,
|
|
color: "#10b981",
|
|
},
|
|
{
|
|
name: "En apprentissage",
|
|
value: levelDistribution["not-autonomous"] || 0,
|
|
color: "#f59e0b",
|
|
},
|
|
{
|
|
name: "Jamais pratiqué",
|
|
value: levelDistribution.never || 0,
|
|
color: "#6b7280",
|
|
},
|
|
];
|
|
|
|
// Top catégories par expertise
|
|
const sortedCategories = [...categoryCoverage].sort(
|
|
(a, b) => b.experts - a.experts
|
|
);
|
|
const topCategories = sortedCategories.slice(0, 3);
|
|
|
|
// Statistiques de mentorat
|
|
const mentorCount = members.filter((m) => m.mentorSkills > 0).length;
|
|
const learnerCount = members.filter((m) => m.learningSkills > 0).length;
|
|
const mentorshipRatio = (mentorCount / (stats.totalMembers || 1)) * 100;
|
|
|
|
// Gaps critiques par catégorie, séparés par importance
|
|
const criticalGapsByCategory = skillGaps.reduce((acc, gap) => {
|
|
const isIncontournableUndercovered =
|
|
gap.importance === "incontournable" &&
|
|
isCoverageBelowObjective(gap.coverage, gap.importance);
|
|
const isMajeureUndercovered =
|
|
gap.importance === "majeure" &&
|
|
isCoverageBelowObjective(gap.coverage, gap.importance);
|
|
|
|
if (isIncontournableUndercovered || isMajeureUndercovered) {
|
|
if (!acc[gap.category]) {
|
|
acc[gap.category] = { incontournable: 0, majeure: 0 };
|
|
}
|
|
if (isIncontournableUndercovered) {
|
|
acc[gap.category].incontournable++;
|
|
}
|
|
if (isMajeureUndercovered) {
|
|
acc[gap.category].majeure++;
|
|
}
|
|
}
|
|
return acc;
|
|
}, {} as Record<string, { incontournable: number; majeure: number }>);
|
|
|
|
const gapData = Object.entries(criticalGapsByCategory)
|
|
.map(([category, counts]) => ({
|
|
name: category,
|
|
incontournable: counts.incontournable,
|
|
majeure: counts.majeure,
|
|
}))
|
|
.sort(
|
|
(a, b) =>
|
|
// Trier d'abord par nombre total de compétences critiques
|
|
b.incontournable + b.majeure - (a.incontournable + a.majeure) ||
|
|
// Puis par nombre de compétences incontournables
|
|
b.incontournable - a.incontournable
|
|
);
|
|
|
|
// Nombre total de compétences critiques sous-couvertes
|
|
const totalCriticalGaps = Object.values(criticalGapsByCategory).reduce(
|
|
(sum, counts) => sum + counts.incontournable + counts.majeure,
|
|
0
|
|
);
|
|
|
|
return (
|
|
<Card className="bg-white/5 border-white/10 backdrop-blur">
|
|
<CardHeader>
|
|
<CardTitle className="text-slate-200">
|
|
Statistiques de l'équipe
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-8">
|
|
{/* Vue d'ensemble */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<div className="space-y-1">
|
|
<p className="text-sm text-slate-400">Membres</p>
|
|
<p className="text-2xl font-bold text-slate-200">
|
|
{stats.totalMembers}
|
|
</p>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<p className="text-sm text-slate-400">Compétences/membre</p>
|
|
<p className="text-2xl font-bold text-slate-200">
|
|
{avgSkillsPerMember.toFixed(1)}
|
|
</p>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<p className="text-sm text-slate-400">Ratio mentors</p>
|
|
<p className="text-2xl font-bold text-slate-200">
|
|
{mentorshipRatio.toFixed(0)}%
|
|
</p>
|
|
</div>
|
|
<UITooltip>
|
|
<TooltipTrigger asChild>
|
|
<div className="space-y-1">
|
|
<p className="text-sm text-slate-400">Compétences critiques</p>
|
|
<p className="text-2xl font-bold text-red-400">
|
|
{totalCriticalGaps}
|
|
</p>
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent className="bg-slate-900 text-slate-200 border border-slate-700">
|
|
<div className="text-xs space-y-1">
|
|
<p>Compétences critiques sous-couvertes :</p>
|
|
<ul className="list-disc list-inside space-y-0.5">
|
|
<li>
|
|
Incontournables : couverture <{" "}
|
|
{COVERAGE_OBJECTIVES.incontournable}%
|
|
</li>
|
|
<li>
|
|
Majeures : couverture < {COVERAGE_OBJECTIVES.majeure}%
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</TooltipContent>
|
|
</UITooltip>
|
|
</div>
|
|
|
|
{/* Couverture des compétences critiques */}
|
|
<div>
|
|
<h3 className="text-sm font-medium text-slate-200 mb-4">
|
|
Couverture des compétences critiques
|
|
</h3>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<UITooltip>
|
|
<TooltipTrigger asChild>
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-sm text-slate-400">
|
|
Incontournables
|
|
</span>
|
|
<span className="text-sm font-medium text-slate-200">
|
|
{stats.criticalSkillsCoverage.incontournable.toFixed(0)}%
|
|
</span>
|
|
</div>
|
|
<div className="h-2 w-full bg-white/10 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-red-500/50 rounded-full transition-all"
|
|
style={{
|
|
width: `${Math.min(
|
|
100,
|
|
Math.max(
|
|
0,
|
|
stats.criticalSkillsCoverage.incontournable
|
|
)
|
|
)}%`,
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent className="bg-slate-900 text-slate-200 border border-slate-700">
|
|
<div className="text-xs">
|
|
<p>Compétences incontournables</p>
|
|
<p className="text-slate-400">
|
|
Objectif : {COVERAGE_OBJECTIVES.incontournable}% de
|
|
couverture
|
|
</p>
|
|
</div>
|
|
</TooltipContent>
|
|
</UITooltip>
|
|
<UITooltip>
|
|
<TooltipTrigger asChild>
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-sm text-slate-400">Majeures</span>
|
|
<span className="text-sm font-medium text-slate-200">
|
|
{stats.criticalSkillsCoverage.majeure.toFixed(0)}%
|
|
</span>
|
|
</div>
|
|
<div className="h-2 w-full bg-white/10 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-orange-500/50 rounded-full transition-all"
|
|
style={{
|
|
width: `${Math.min(
|
|
100,
|
|
Math.max(0, stats.criticalSkillsCoverage.majeure)
|
|
)}%`,
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent className="bg-slate-900 text-slate-200 border border-slate-700">
|
|
<div className="text-xs">
|
|
<p>Compétences majeures</p>
|
|
<p className="text-slate-400">Objectif : 60% de couverture</p>
|
|
</div>
|
|
</TooltipContent>
|
|
</UITooltip>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Distribution des niveaux */}
|
|
<div>
|
|
<h3 className="text-sm font-medium text-slate-200 mb-4">
|
|
Distribution des niveaux
|
|
</h3>
|
|
<div className="h-[200px]">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<PieChart>
|
|
<Pie
|
|
data={levelData}
|
|
dataKey="value"
|
|
nameKey="name"
|
|
cx="50%"
|
|
cy="50%"
|
|
outerRadius={80}
|
|
label={(entry) => `${entry.name} (${entry.value})`}
|
|
labelLine={{ stroke: "#64748b" }}
|
|
>
|
|
{levelData.map((entry, index) => (
|
|
<Cell
|
|
key={`cell-${index}`}
|
|
fill={entry.color}
|
|
className="opacity-80"
|
|
/>
|
|
))}
|
|
</Pie>
|
|
<Tooltip
|
|
contentStyle={{
|
|
backgroundColor: "rgba(30, 41, 59, 0.9)",
|
|
border: "1px solid rgba(255, 255, 255, 0.1)",
|
|
borderRadius: "8px",
|
|
}}
|
|
itemStyle={{ color: "#e2e8f0" }}
|
|
labelStyle={{ color: "#e2e8f0" }}
|
|
/>
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Top catégories */}
|
|
<div>
|
|
<h3 className="text-sm font-medium text-slate-200 mb-4">
|
|
Top catégories par expertise
|
|
</h3>
|
|
<div className="space-y-3">
|
|
{topCategories.map((cat) => (
|
|
<div key={cat.category} className="flex items-center gap-4">
|
|
<div className="flex-1">
|
|
<p className="text-sm font-medium text-slate-300">
|
|
{cat.category}
|
|
</p>
|
|
<div className="flex gap-2 text-sm text-slate-400">
|
|
<span>{cat.experts} experts</span>
|
|
<span>•</span>
|
|
<span>{cat.mentors} mentors</span>
|
|
</div>
|
|
</div>
|
|
<div className="w-24 text-right">
|
|
<p className="text-sm font-medium text-slate-300">
|
|
{cat.coverage.toFixed(0)}%
|
|
</p>
|
|
<p className="text-sm text-slate-400">couverture</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Gaps critiques par catégorie */}
|
|
{gapData.length > 0 && (
|
|
<div>
|
|
<h3 className="text-sm font-medium text-slate-200 mb-4">
|
|
Compétences critiques sous-couvertes par catégorie
|
|
</h3>
|
|
<div className="h-[250px]">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={gapData}>
|
|
<CartesianGrid
|
|
strokeDasharray="3 3"
|
|
stroke="rgba(255, 255, 255, 0.1)"
|
|
/>
|
|
<XAxis
|
|
dataKey="name"
|
|
tick={{ fill: "#94a3b8" }}
|
|
axisLine={{ stroke: "#334155" }}
|
|
/>
|
|
<YAxis
|
|
tick={{ fill: "#94a3b8" }}
|
|
axisLine={{ stroke: "#334155" }}
|
|
/>
|
|
<Tooltip
|
|
contentStyle={{
|
|
backgroundColor: "rgba(30, 41, 59, 0.9)",
|
|
border: "1px solid rgba(255, 255, 255, 0.1)",
|
|
borderRadius: "8px",
|
|
}}
|
|
itemStyle={{ color: "#e2e8f0" }}
|
|
labelStyle={{ color: "#e2e8f0" }}
|
|
formatter={(value, name) => {
|
|
const label =
|
|
name === "incontournable"
|
|
? `Incontournables (obj. ${COVERAGE_OBJECTIVES.incontournable}%)`
|
|
: `Majeures (obj. ${COVERAGE_OBJECTIVES.majeure}%)`;
|
|
return [value, label];
|
|
}}
|
|
/>
|
|
<Legend
|
|
formatter={(value) => {
|
|
return value === "incontournable"
|
|
? `Incontournables (obj. ${COVERAGE_OBJECTIVES.incontournable}%)`
|
|
: `Majeures (obj. ${COVERAGE_OBJECTIVES.majeure}%)`;
|
|
}}
|
|
wrapperStyle={{
|
|
paddingTop: "20px",
|
|
}}
|
|
/>
|
|
<Bar
|
|
dataKey="incontournable"
|
|
fill="#ef4444"
|
|
name="incontournable"
|
|
stackId="stack"
|
|
/>
|
|
<Bar
|
|
dataKey="majeure"
|
|
fill="#f97316"
|
|
name="majeure"
|
|
stackId="stack"
|
|
/>
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Statistiques de mentorat */}
|
|
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-white/10">
|
|
<div>
|
|
<h3 className="text-sm font-medium text-slate-200 mb-2">
|
|
Mentorat
|
|
</h3>
|
|
<div className="space-y-1">
|
|
<div className="flex justify-between">
|
|
<p className="text-sm text-slate-400">Mentors</p>
|
|
<p className="text-sm font-medium text-slate-300">
|
|
{mentorCount}
|
|
</p>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<p className="text-sm text-slate-400">Apprenants</p>
|
|
<p className="text-sm font-medium text-slate-300">
|
|
{learnerCount}
|
|
</p>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<p className="text-sm text-slate-400">Opportunités</p>
|
|
<p className="text-sm font-medium text-slate-300">
|
|
{stats.mentorshipOpportunities}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h3 className="text-sm font-medium text-slate-200 mb-2">
|
|
Couverture globale
|
|
</h3>
|
|
<div className="space-y-1">
|
|
<div className="flex justify-between">
|
|
<p className="text-sm text-slate-400">Compétences</p>
|
|
<p className="text-sm font-medium text-slate-300">
|
|
{stats.totalSkills}
|
|
</p>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<p className="text-sm text-slate-400">Niveau moyen</p>
|
|
<p className="text-sm font-medium text-slate-300">
|
|
{((stats.averageTeamLevel * 100) / 3).toFixed(0)}%
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|