feat: review my team page with importance
This commit is contained in:
@@ -53,6 +53,7 @@ async function TeamReviewPage() {
|
|||||||
stats={teamData.stats}
|
stats={teamData.stats}
|
||||||
members={teamData.members}
|
members={teamData.members}
|
||||||
categoryCoverage={teamData.categoryCoverage}
|
categoryCoverage={teamData.categoryCoverage}
|
||||||
|
skillGaps={teamData.skillGaps}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { TeamMemberProfile, SkillGap } from "@/lib/team-review-types";
|
import { TeamMemberProfile, SkillGap } from "@/lib/team-review-types";
|
||||||
import { UserCheck, GraduationCap } from "lucide-react";
|
import { UserCheck, GraduationCap } from "lucide-react";
|
||||||
import { TechIcon } from "@/components/icons/tech-icon";
|
import { TechIcon } from "@/components/icons/tech-icon";
|
||||||
|
import { getImportanceColors } from "@/lib/tech-colors";
|
||||||
|
|
||||||
interface SkillMatrixProps {
|
interface SkillMatrixProps {
|
||||||
members: TeamMemberProfile[];
|
members: TeamMemberProfile[];
|
||||||
@@ -106,28 +107,32 @@ export function SkillMatrix({ members, skillGaps }: SkillMatrixProps) {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{skills.map((skill) => (
|
{skills.map((skill) => {
|
||||||
|
const colors = getImportanceColors(skill.importance);
|
||||||
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={`skill-row-${skill.skillId}-${skill.category}`}
|
key={`skill-row-${skill.skillId}-${skill.category}`}
|
||||||
className="border-white/10"
|
className="border-white/10"
|
||||||
>
|
>
|
||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 rounded-lg bg-white/10 border border-white/20 flex items-center justify-center">
|
<div
|
||||||
|
className={`w-8 h-8 rounded-lg ${colors.bg} ${colors.border} border flex items-center justify-center`}
|
||||||
|
>
|
||||||
<TechIcon
|
<TechIcon
|
||||||
iconName={skill.icon || ""}
|
iconName={skill.icon || ""}
|
||||||
className="w-4 h-4 text-blue-400"
|
className={`w-4 h-4 ${colors.text}`}
|
||||||
fallbackText={skill.skillName}
|
fallbackText={skill.skillName}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-slate-200">
|
<span className={`${colors.text} font-medium`}>
|
||||||
{skill.skillName}
|
{skill.skillName}
|
||||||
</span>
|
</span>
|
||||||
{skill.risk === "high" && (
|
{skill.risk === "high" && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="bg-red-500/20 text-red-200 border-red-500/30"
|
className="bg-red-500/20 text-red-200 border-red-500/30 w-fit"
|
||||||
>
|
>
|
||||||
Risque
|
Risque
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -171,7 +176,10 @@ export function SkillMatrix({ members, skillGaps }: SkillMatrixProps) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-full bg-white/10 rounded-full h-2">
|
<div className="w-full bg-white/10 rounded-full h-2">
|
||||||
<div
|
<div
|
||||||
className="bg-blue-500/50 h-2 rounded-full"
|
className={`h-2 rounded-full ${colors.bg.replace(
|
||||||
|
"/20",
|
||||||
|
"/50"
|
||||||
|
)}`}
|
||||||
style={{
|
style={{
|
||||||
width: `${Math.max(
|
width: `${Math.max(
|
||||||
0,
|
0,
|
||||||
@@ -180,13 +188,16 @@ export function SkillMatrix({ members, skillGaps }: SkillMatrixProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-slate-400 whitespace-nowrap">
|
<span
|
||||||
|
className={`text-sm ${colors.text} whitespace-nowrap`}
|
||||||
|
>
|
||||||
{Math.round(skill.coverage || 0)}%
|
{Math.round(skill.coverage || 0)}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { CategoryCoverage } from "@/lib/team-review-types";
|
import { CategoryCoverage } from "@/lib/team-review-types";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { AlertTriangle, AlertCircle } from "lucide-react";
|
||||||
|
|
||||||
interface TeamInsightsProps {
|
interface TeamInsightsProps {
|
||||||
recommendations: string[];
|
recommendations: string[];
|
||||||
@@ -22,9 +23,27 @@ export function TeamInsights({
|
|||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{recommendations.map((recommendation, index) => (
|
{recommendations.map((recommendation, index) => (
|
||||||
<Alert key={index} className="bg-white/5 border-white/10">
|
<Alert
|
||||||
<AlertDescription className="text-slate-300">
|
key={index}
|
||||||
{recommendation}
|
className={`${
|
||||||
|
recommendation.includes("⚠️")
|
||||||
|
? "bg-red-500/10 border-red-500/30"
|
||||||
|
: "bg-white/5 border-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<AlertDescription
|
||||||
|
className={`${
|
||||||
|
recommendation.includes("⚠️")
|
||||||
|
? "text-red-200"
|
||||||
|
: "text-slate-300"
|
||||||
|
} flex items-start gap-2`}
|
||||||
|
>
|
||||||
|
{recommendation.includes("⚠️") ? (
|
||||||
|
<AlertTriangle className="h-5 w-5 text-red-400 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="h-5 w-5 text-blue-400 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span>{recommendation.replace("⚠️ ", "")}</span>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
))}
|
))}
|
||||||
@@ -79,6 +98,52 @@ export function TeamInsights({
|
|||||||
app.
|
app.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Couverture des compétences critiques */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 pt-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-xs text-slate-400">
|
||||||
|
Incontournables
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium text-red-300">
|
||||||
|
{category.criticalSkillsCoverage.incontournable}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1 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,
|
||||||
|
category.criticalSkillsCoverage.incontournable
|
||||||
|
)
|
||||||
|
)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-xs text-slate-400">Majeures</span>
|
||||||
|
<span className="text-xs font-medium text-orange-300">
|
||||||
|
{category.criticalSkillsCoverage.majeure}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1 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, category.criticalSkillsCoverage.majeure)
|
||||||
|
)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { TeamReviewData } from "@/lib/team-review-types";
|
import { TeamReviewData } from "@/lib/team-review-types";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { AlertTriangle } from "lucide-react";
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
import { getImportanceColors } from "@/lib/tech-colors";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
|
||||||
interface TeamOverviewProps {
|
interface TeamOverviewProps {
|
||||||
team: TeamReviewData["team"];
|
team: TeamReviewData["team"];
|
||||||
stats: TeamReviewData["stats"];
|
stats: TeamReviewData["stats"];
|
||||||
members: TeamReviewData["members"];
|
members: TeamReviewData["members"];
|
||||||
categoryCoverage: TeamReviewData["categoryCoverage"];
|
categoryCoverage: TeamReviewData["categoryCoverage"];
|
||||||
|
skillGaps: TeamReviewData["skillGaps"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TeamOverview({
|
export function TeamOverview({
|
||||||
@@ -16,6 +25,7 @@ export function TeamOverview({
|
|||||||
stats,
|
stats,
|
||||||
members,
|
members,
|
||||||
categoryCoverage,
|
categoryCoverage,
|
||||||
|
skillGaps,
|
||||||
}: TeamOverviewProps) {
|
}: TeamOverviewProps) {
|
||||||
// Trouver les top contributeurs
|
// Trouver les top contributeurs
|
||||||
const topContributors = [...members]
|
const topContributors = [...members]
|
||||||
@@ -29,24 +39,51 @@ export function TeamOverview({
|
|||||||
|
|
||||||
// Trouver les catégories qui nécessitent de l'attention
|
// Trouver les catégories qui nécessitent de l'attention
|
||||||
const categoriesNeedingAttention = [...categoryCoverage]
|
const categoriesNeedingAttention = [...categoryCoverage]
|
||||||
.filter((cat) => {
|
.map((cat) => {
|
||||||
// Une catégorie nécessite de l'attention si :
|
// Pour chaque catégorie, on identifie :
|
||||||
// - Couverture faible (< 40%)
|
// 1. Les compétences incontournables sous-couvertes (< 75%)
|
||||||
// - OU pas assez d'experts (< 2) avec une équipe de taille significative (> 5)
|
const uncoveredIncontournables = skillGaps.filter(
|
||||||
// - OU beaucoup d'apprenants (> 30% de l'équipe) avec peu de mentors (< 2)
|
(gap) =>
|
||||||
return (
|
gap.category === cat.category &&
|
||||||
cat.coverage < 40 ||
|
gap.importance === "incontournable" &&
|
||||||
(cat.experts < 2 && stats.totalMembers > 5) ||
|
gap.coverage < 75
|
||||||
(cat.learners > stats.totalMembers * 0.3 && cat.mentors < 2)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 2. Les compétences majeures sous-couvertes (< 60%)
|
||||||
|
const uncoveredMajeures = skillGaps.filter(
|
||||||
|
(gap) =>
|
||||||
|
gap.category === cat.category &&
|
||||||
|
gap.importance === "majeure" &&
|
||||||
|
gap.coverage < 60
|
||||||
|
);
|
||||||
|
|
||||||
|
// Une catégorie nécessite de l'attention si :
|
||||||
|
const needsAttention =
|
||||||
|
uncoveredIncontournables.length > 0 || // Il y a des compétences incontournables sous-couvertes
|
||||||
|
uncoveredMajeures.length > 0 || // OU des compétences majeures sous-couvertes
|
||||||
|
(cat.experts < 2 && stats.totalMembers > 5); // OU pas assez d'experts dans une équipe significative
|
||||||
|
|
||||||
|
if (!needsAttention) return null;
|
||||||
|
|
||||||
|
// Calculer un score d'attention pour trier les catégories
|
||||||
|
const attentionScore =
|
||||||
|
// Les incontournables pèsent plus lourd
|
||||||
|
uncoveredIncontournables.length * 3 +
|
||||||
|
// Les majeures un peu moins
|
||||||
|
uncoveredMajeures.length * 2 +
|
||||||
|
// Manque d'experts est un facteur aggravant
|
||||||
|
(cat.experts < 2 && stats.totalMembers > 5 ? 1 : 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...cat,
|
||||||
|
// On combine les deux types pour l'affichage
|
||||||
|
criticalSkills: [...uncoveredIncontournables, ...uncoveredMajeures],
|
||||||
|
attentionScore,
|
||||||
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.filter(Boolean)
|
||||||
// Prioriser les catégories avec le plus de besoins
|
.sort((a, b) => b.attentionScore - a.attentionScore)
|
||||||
const aScore = a.learners * 2 - a.mentors - a.experts;
|
.slice(0, 3);
|
||||||
const bScore = b.learners * 2 - b.mentors - b.experts;
|
|
||||||
return bScore - aScore;
|
|
||||||
})
|
|
||||||
.slice(0, 2);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-white/5 border-white/10 backdrop-blur">
|
<Card className="bg-white/5 border-white/10 backdrop-blur">
|
||||||
@@ -130,23 +167,76 @@ export function TeamOverview({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-amber-400 border-amber-400/30"
|
className="text-amber-400 border-amber-400/30"
|
||||||
>
|
>
|
||||||
{cat.learners} apprenants
|
{cat.experts} exp • {cat.mentors} ment
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 text-sm text-slate-400">
|
{/* Compétences sous-couvertes */}
|
||||||
<span>{cat.experts} experts</span>
|
{cat.criticalSkills.length > 0 && (
|
||||||
<span>{cat.mentors} mentors</span>
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{cat.criticalSkills
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (
|
||||||
|
a.importance === "incontournable" &&
|
||||||
|
b.importance !== "incontournable"
|
||||||
|
)
|
||||||
|
return -1;
|
||||||
|
if (
|
||||||
|
a.importance !== "incontournable" &&
|
||||||
|
b.importance === "incontournable"
|
||||||
|
)
|
||||||
|
return 1;
|
||||||
|
return a.coverage - b.coverage;
|
||||||
|
})
|
||||||
|
.map((skill) => {
|
||||||
|
const colors = getImportanceColors(skill.importance);
|
||||||
|
const target =
|
||||||
|
skill.importance === "incontournable" ? 75 : 60;
|
||||||
|
return (
|
||||||
|
<Tooltip key={skill.skillId}>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<div
|
||||||
|
className={`text-xs rounded-md px-1.5 py-0.5 ${colors.bg} ${colors.border} border flex items-center gap-1.5`}
|
||||||
|
>
|
||||||
|
<span className={colors.text}>
|
||||||
|
{skill.skillName}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
skill.coverage < target
|
||||||
|
? "text-red-400"
|
||||||
|
: "text-slate-400"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{skill.coverage.toFixed(0)}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-amber-400/80">
|
</TooltipTrigger>
|
||||||
{cat.coverage < 40 && "Couverture insuffisante • "}
|
<TooltipContent className="bg-slate-900 text-slate-200 border border-slate-700">
|
||||||
{cat.experts < 2 &&
|
<div className="text-xs">
|
||||||
stats.totalMembers > 5 &&
|
<p className="font-medium">
|
||||||
"Manque d'experts • "}
|
{skill.importance === "incontournable"
|
||||||
{cat.learners > stats.totalMembers * 0.3 &&
|
? "Compétence incontournable"
|
||||||
cat.mentors < 2 &&
|
: "Compétence majeure"}
|
||||||
"Besoin de mentors"}
|
</p>
|
||||||
|
<p className="text-slate-400">
|
||||||
|
Objectif : {target}% de couverture
|
||||||
|
<br />
|
||||||
|
Actuel : {skill.coverage.toFixed(0)}%
|
||||||
|
<br />
|
||||||
|
{skill.coverage < target
|
||||||
|
? `Manque ${(
|
||||||
|
target - skill.coverage
|
||||||
|
).toFixed(0)}%`
|
||||||
|
: "Objectif atteint"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{stats.learningNeeds > 0 && (
|
{stats.learningNeeds > 0 && (
|
||||||
|
|||||||
@@ -13,7 +13,13 @@ import {
|
|||||||
PieChart,
|
PieChart,
|
||||||
Pie,
|
Pie,
|
||||||
Cell,
|
Cell,
|
||||||
|
Legend,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
import {
|
||||||
|
Tooltip as UITooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
|
||||||
interface TeamStatsProps {
|
interface TeamStatsProps {
|
||||||
stats: TeamReviewData["stats"];
|
stats: TeamReviewData["stats"];
|
||||||
@@ -73,20 +79,45 @@ export function TeamStats({
|
|||||||
const learnerCount = members.filter((m) => m.learningSkills > 0).length;
|
const learnerCount = members.filter((m) => m.learningSkills > 0).length;
|
||||||
const mentorshipRatio = (mentorCount / (stats.totalMembers || 1)) * 100;
|
const mentorshipRatio = (mentorCount / (stats.totalMembers || 1)) * 100;
|
||||||
|
|
||||||
// Gaps critiques par catégorie
|
// Gaps critiques par catégorie, séparés par importance
|
||||||
const criticalGapsByCategory = skillGaps
|
const criticalGapsByCategory = skillGaps.reduce((acc, gap) => {
|
||||||
.filter((gap) => gap.risk === "high")
|
const isIncontournableUndercovered =
|
||||||
.reduce((acc, gap) => {
|
gap.importance === "incontournable" && gap.coverage < 75;
|
||||||
acc[gap.category] = (acc[gap.category] || 0) + 1;
|
const isMajeureUndercovered =
|
||||||
return acc;
|
gap.importance === "majeure" && gap.coverage < 60;
|
||||||
}, {} as Record<string, number>);
|
|
||||||
|
|
||||||
const gapData = Object.entries(criticalGapsByCategory).map(
|
if (isIncontournableUndercovered || isMajeureUndercovered) {
|
||||||
([category, count]) => ({
|
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,
|
name: category,
|
||||||
value: count,
|
incontournable: counts.incontournable,
|
||||||
fill: "#ef4444",
|
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 (
|
return (
|
||||||
@@ -117,12 +148,97 @@ export function TeamStats({
|
|||||||
{mentorshipRatio.toFixed(0)}%
|
{mentorshipRatio.toFixed(0)}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<UITooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm text-slate-400">Gaps critiques</p>
|
<p className="text-sm text-slate-400">Compétences critiques</p>
|
||||||
<p className="text-2xl font-bold text-red-400">
|
<p className="text-2xl font-bold text-red-400">
|
||||||
{stats.learningNeeds}
|
{totalCriticalGaps}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 < 75%</li>
|
||||||
|
<li>Majeures : couverture < 60%</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 : 75% 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>
|
</div>
|
||||||
|
|
||||||
{/* Distribution des niveaux */}
|
{/* Distribution des niveaux */}
|
||||||
@@ -198,9 +314,9 @@ export function TeamStats({
|
|||||||
{gapData.length > 0 && (
|
{gapData.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium text-slate-200 mb-4">
|
<h3 className="text-sm font-medium text-slate-200 mb-4">
|
||||||
Gaps critiques par catégorie
|
Compétences critiques sous-couvertes par catégorie
|
||||||
</h3>
|
</h3>
|
||||||
<div className="h-[200px]">
|
<div className="h-[250px]">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={gapData}>
|
<BarChart data={gapData}>
|
||||||
<CartesianGrid
|
<CartesianGrid
|
||||||
@@ -224,8 +340,36 @@ export function TeamStats({
|
|||||||
}}
|
}}
|
||||||
itemStyle={{ color: "#e2e8f0" }}
|
itemStyle={{ color: "#e2e8f0" }}
|
||||||
labelStyle={{ color: "#e2e8f0" }}
|
labelStyle={{ color: "#e2e8f0" }}
|
||||||
|
formatter={(value, name) => {
|
||||||
|
const label =
|
||||||
|
name === "incontournable"
|
||||||
|
? "Incontournables (obj. 75%)"
|
||||||
|
: "Majeures (obj. 60%)";
|
||||||
|
return [value, label];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
formatter={(value) => {
|
||||||
|
return value === "incontournable"
|
||||||
|
? "Incontournables (obj. 75%)"
|
||||||
|
: "Majeures (obj. 60%)";
|
||||||
|
}}
|
||||||
|
wrapperStyle={{
|
||||||
|
paddingTop: "20px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="incontournable"
|
||||||
|
fill="#ef4444"
|
||||||
|
name="incontournable"
|
||||||
|
stackId="stack"
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="majeure"
|
||||||
|
fill="#f97316"
|
||||||
|
name="majeure"
|
||||||
|
stackId="stack"
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="value" />
|
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface TeamMemberSkill {
|
|||||||
skillId: string;
|
skillId: string;
|
||||||
skillName: string;
|
skillName: string;
|
||||||
category: string;
|
category: string;
|
||||||
|
importance: "incontournable" | "majeure" | "standard";
|
||||||
level: "never" | "not-autonomous" | "autonomous" | "expert";
|
level: "never" | "not-autonomous" | "autonomous" | "expert";
|
||||||
canMentor: boolean;
|
canMentor: boolean;
|
||||||
wantsToLearn: boolean;
|
wantsToLearn: boolean;
|
||||||
@@ -32,6 +33,7 @@ export interface SkillGap {
|
|||||||
skillName: string;
|
skillName: string;
|
||||||
category: string;
|
category: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
|
importance: "incontournable" | "majeure" | "standard";
|
||||||
teamMembers: number;
|
teamMembers: number;
|
||||||
experts: number;
|
experts: number;
|
||||||
mentors: number;
|
mentors: number;
|
||||||
@@ -48,6 +50,10 @@ export interface CategoryCoverage {
|
|||||||
experts: number;
|
experts: number;
|
||||||
mentors: number;
|
mentors: number;
|
||||||
learners: number;
|
learners: number;
|
||||||
|
criticalSkillsCoverage: {
|
||||||
|
incontournable: number;
|
||||||
|
majeure: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamReviewData {
|
export interface TeamReviewData {
|
||||||
@@ -66,6 +72,10 @@ export interface TeamReviewData {
|
|||||||
averageTeamLevel: number;
|
averageTeamLevel: number;
|
||||||
mentorshipOpportunities: number;
|
mentorshipOpportunities: number;
|
||||||
learningNeeds: number;
|
learningNeeds: number;
|
||||||
|
criticalSkillsCoverage: {
|
||||||
|
incontournable: number;
|
||||||
|
majeure: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +84,7 @@ export interface MentorOpportunity {
|
|||||||
mentee: TeamMember;
|
mentee: TeamMember;
|
||||||
skill: string;
|
skill: string;
|
||||||
category: string;
|
category: string;
|
||||||
|
importance: "incontournable" | "majeure" | "standard";
|
||||||
mentorLevel: "autonomous" | "expert";
|
mentorLevel: "autonomous" | "expert";
|
||||||
menteeLevel: "never" | "not-autonomous";
|
menteeLevel: "never" | "not-autonomous";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export class TeamReviewService {
|
|||||||
s.id as skill_id,
|
s.id as skill_id,
|
||||||
s.name as skill_name,
|
s.name as skill_name,
|
||||||
s.icon,
|
s.icon,
|
||||||
|
s.importance,
|
||||||
sc.name as category
|
sc.name as category
|
||||||
FROM skill_categories sc
|
FROM skill_categories sc
|
||||||
JOIN skills s ON s.category_id = sc.id
|
JOIN skills s ON s.category_id = sc.id
|
||||||
@@ -54,6 +55,7 @@ export class TeamReviewService {
|
|||||||
u.email,
|
u.email,
|
||||||
s.id as skill_id,
|
s.id as skill_id,
|
||||||
s.name as skill_name,
|
s.name as skill_name,
|
||||||
|
s.importance,
|
||||||
sc.name as category,
|
sc.name as category,
|
||||||
se.level,
|
se.level,
|
||||||
se.can_mentor,
|
se.can_mentor,
|
||||||
@@ -97,6 +99,7 @@ export class TeamReviewService {
|
|||||||
skillId: row.skill_id,
|
skillId: row.skill_id,
|
||||||
skillName: row.skill_name,
|
skillName: row.skill_name,
|
||||||
category: row.category,
|
category: row.category,
|
||||||
|
importance: row.importance || "standard",
|
||||||
level: row.level as SkillLevel,
|
level: row.level as SkillLevel,
|
||||||
canMentor: row.can_mentor || false,
|
canMentor: row.can_mentor || false,
|
||||||
wantsToLearn: row.wants_to_learn || false,
|
wantsToLearn: row.wants_to_learn || false,
|
||||||
@@ -140,22 +143,28 @@ export class TeamReviewService {
|
|||||||
const coverage =
|
const coverage =
|
||||||
totalTeamMembers > 0 ? (teamMembers / totalTeamMembers) * 100 : 0;
|
totalTeamMembers > 0 ? (teamMembers / totalTeamMembers) * 100 : 0;
|
||||||
|
|
||||||
|
// Déterminer le niveau de risque en fonction de l'importance
|
||||||
|
const risk =
|
||||||
|
skill.importance === "incontournable" && experts === 0
|
||||||
|
? "high"
|
||||||
|
: skill.importance === "majeure" && experts === 0 && mentors === 0
|
||||||
|
? "high"
|
||||||
|
: experts === 0 && mentors === 0
|
||||||
|
? "medium"
|
||||||
|
: "low";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
skillId: skill.skill_id,
|
skillId: skill.skill_id,
|
||||||
skillName: skill.skill_name,
|
skillName: skill.skill_name,
|
||||||
category: skill.category,
|
category: skill.category,
|
||||||
icon: skill.icon,
|
icon: skill.icon,
|
||||||
|
importance: skill.importance || "standard",
|
||||||
team_members: teamMembers,
|
team_members: teamMembers,
|
||||||
experts,
|
experts,
|
||||||
mentors,
|
mentors,
|
||||||
learners,
|
learners,
|
||||||
coverage,
|
coverage,
|
||||||
risk:
|
risk,
|
||||||
experts === 0 && mentors === 0
|
|
||||||
? "high"
|
|
||||||
: experts === 0 || mentors === 0
|
|
||||||
? "medium"
|
|
||||||
: "low",
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -172,6 +181,10 @@ export class TeamReviewService {
|
|||||||
mentors: 0,
|
mentors: 0,
|
||||||
learners: 0,
|
learners: 0,
|
||||||
coverage: 0,
|
coverage: 0,
|
||||||
|
criticalSkillsCoverage: {
|
||||||
|
incontournable: 0,
|
||||||
|
majeure: 0,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,6 +199,17 @@ export class TeamReviewService {
|
|||||||
categoryStats.experts += skillGap.experts;
|
categoryStats.experts += skillGap.experts;
|
||||||
categoryStats.mentors += skillGap.mentors;
|
categoryStats.mentors += skillGap.mentors;
|
||||||
categoryStats.learners += skillGap.learners;
|
categoryStats.learners += skillGap.learners;
|
||||||
|
|
||||||
|
// Calculer la couverture des compétences critiques
|
||||||
|
if (
|
||||||
|
skillGap.importance === "incontournable" &&
|
||||||
|
skillGap.coverage > 50
|
||||||
|
) {
|
||||||
|
categoryStats.criticalSkillsCoverage.incontournable++;
|
||||||
|
}
|
||||||
|
if (skillGap.importance === "majeure" && skillGap.coverage > 50) {
|
||||||
|
categoryStats.criticalSkillsCoverage.majeure++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,6 +232,28 @@ export class TeamReviewService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 8. Calculer les statistiques globales
|
// 8. Calculer les statistiques globales
|
||||||
|
const criticalSkillsCoverage = {
|
||||||
|
incontournable:
|
||||||
|
(skillGaps
|
||||||
|
.filter((gap) => gap.importance === "incontournable")
|
||||||
|
.reduce((acc, gap) => acc + (gap.coverage > 50 ? 1 : 0), 0) /
|
||||||
|
Math.max(
|
||||||
|
1,
|
||||||
|
skillGaps.filter((gap) => gap.importance === "incontournable")
|
||||||
|
.length
|
||||||
|
)) *
|
||||||
|
100,
|
||||||
|
majeure:
|
||||||
|
(skillGaps
|
||||||
|
.filter((gap) => gap.importance === "majeure")
|
||||||
|
.reduce((acc, gap) => acc + (gap.coverage > 50 ? 1 : 0), 0) /
|
||||||
|
Math.max(
|
||||||
|
1,
|
||||||
|
skillGaps.filter((gap) => gap.importance === "majeure").length
|
||||||
|
)) *
|
||||||
|
100,
|
||||||
|
};
|
||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
totalMembers: membersMap.size,
|
totalMembers: membersMap.size,
|
||||||
totalSkills: skillGaps.length,
|
totalSkills: skillGaps.length,
|
||||||
@@ -221,6 +267,7 @@ export class TeamReviewService {
|
|||||||
0
|
0
|
||||||
),
|
),
|
||||||
learningNeeds: skillGaps.filter((gap) => gap.risk === "high").length,
|
learningNeeds: skillGaps.filter((gap) => gap.risk === "high").length,
|
||||||
|
criticalSkillsCoverage,
|
||||||
};
|
};
|
||||||
|
|
||||||
await client.query("COMMIT");
|
await client.query("COMMIT");
|
||||||
@@ -249,25 +296,43 @@ export class TeamReviewService {
|
|||||||
): string[] {
|
): string[] {
|
||||||
const recommendations: string[] = [];
|
const recommendations: string[] = [];
|
||||||
|
|
||||||
// Analyser les gaps critiques
|
// Analyser les gaps critiques par importance
|
||||||
const criticalGaps = skillGaps.filter((gap) => gap.risk === "high");
|
const uncoveredIncontournables = skillGaps.filter(
|
||||||
if (criticalGaps.length > 0) {
|
(gap) => gap.importance === "incontournable" && gap.coverage < 50
|
||||||
|
);
|
||||||
|
if (uncoveredIncontournables.length > 0) {
|
||||||
recommendations.push(
|
recommendations.push(
|
||||||
`Attention : ${
|
`⚠️ ${
|
||||||
criticalGaps.length
|
uncoveredIncontournables.length
|
||||||
} compétences critiques sans expert ni mentor : ${criticalGaps
|
} compétences incontournables faiblement couvertes : ${uncoveredIncontournables
|
||||||
.map((gap) => gap.skillName)
|
.map((gap) => gap.skillName)
|
||||||
.join(", ")}`
|
.join(", ")}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Analyser les opportunités de mentorat
|
const uncoveredMajeures = skillGaps.filter(
|
||||||
const mentorshipNeeds = skillGaps.filter(
|
(gap) => gap.importance === "majeure" && gap.coverage < 30
|
||||||
(gap) => gap.learners > 0 && gap.mentors > 0
|
);
|
||||||
).length;
|
if (uncoveredMajeures.length > 0) {
|
||||||
if (mentorshipNeeds > 0) {
|
|
||||||
recommendations.push(
|
recommendations.push(
|
||||||
`${mentorshipNeeds} opportunités de mentorat identifiées`
|
`⚠️ ${
|
||||||
|
uncoveredMajeures.length
|
||||||
|
} compétences majeures faiblement couvertes : ${uncoveredMajeures
|
||||||
|
.map((gap) => gap.skillName)
|
||||||
|
.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyser les opportunités de mentorat pour les compétences importantes
|
||||||
|
const criticalMentorshipNeeds = skillGaps.filter(
|
||||||
|
(gap) =>
|
||||||
|
gap.learners > 0 &&
|
||||||
|
gap.mentors > 0 &&
|
||||||
|
(gap.importance === "incontournable" || gap.importance === "majeure")
|
||||||
|
).length;
|
||||||
|
if (criticalMentorshipNeeds > 0) {
|
||||||
|
recommendations.push(
|
||||||
|
`${criticalMentorshipNeeds} opportunités de mentorat sur des compétences critiques identifiées`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,13 +348,19 @@ export class TeamReviewService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Identifier les experts isolés
|
// Identifier les experts isolés sur des compétences importantes
|
||||||
const isolatedExperts = members.filter(
|
const isolatedExperts = members.filter((member) =>
|
||||||
(member) => member.expertSkills > 0 && member.mentorSkills === 0
|
member.skills.some(
|
||||||
|
(skill) =>
|
||||||
|
skill.level === "expert" &&
|
||||||
|
!skill.canMentor &&
|
||||||
|
(skill.importance === "incontournable" ||
|
||||||
|
skill.importance === "majeure")
|
||||||
|
)
|
||||||
);
|
);
|
||||||
if (isolatedExperts.length > 0) {
|
if (isolatedExperts.length > 0) {
|
||||||
recommendations.push(
|
recommendations.push(
|
||||||
`${isolatedExperts.length} experts pourraient devenir mentors`
|
`${isolatedExperts.length} experts en compétences critiques pourraient devenir mentors`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user