From 94a18b0ca502ec56001cbfb78f26939569258ebe Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Wed, 27 Aug 2025 12:56:48 +0200 Subject: [PATCH] feat: review my team page with importance --- app/team/page.tsx | 1 + components/team-review/skill-matrix.tsx | 161 +++++++++++--------- components/team-review/team-insights.tsx | 71 ++++++++- components/team-review/team-overview.tsx | 150 ++++++++++++++---- components/team-review/team-stats.tsx | 184 ++++++++++++++++++++--- lib/team-review-types.ts | 11 ++ services/skills-service.ts | 2 +- services/team-review-service.ts | 115 +++++++++++--- 8 files changed, 544 insertions(+), 151 deletions(-) diff --git a/app/team/page.tsx b/app/team/page.tsx index cca9403..c25b8c5 100644 --- a/app/team/page.tsx +++ b/app/team/page.tsx @@ -53,6 +53,7 @@ async function TeamReviewPage() { stats={teamData.stats} members={teamData.members} categoryCoverage={teamData.categoryCoverage} + skillGaps={teamData.skillGaps} />
diff --git a/components/team-review/skill-matrix.tsx b/components/team-review/skill-matrix.tsx index 9beb92e..a771067 100644 --- a/components/team-review/skill-matrix.tsx +++ b/components/team-review/skill-matrix.tsx @@ -13,6 +13,7 @@ import { Badge } from "@/components/ui/badge"; import { TeamMemberProfile, SkillGap } from "@/lib/team-review-types"; import { UserCheck, GraduationCap } from "lucide-react"; import { TechIcon } from "@/components/icons/tech-icon"; +import { getImportanceColors } from "@/lib/tech-colors"; interface SkillMatrixProps { members: TeamMemberProfile[]; @@ -106,87 +107,97 @@ export function SkillMatrix({ members, skillGaps }: SkillMatrixProps) { - {skills.map((skill) => ( - - -
-
- -
-
- - {skill.skillName} - - {skill.risk === "high" && ( - - Risque - - )} -
-
-
- {members.map((member) => { - const memberSkill = member.skills.find( - (s) => s.skillId === skill.skillId - ); - return ( - -
- {getLevelBadge(memberSkill?.level || null)} - {memberSkill?.canMentor && ( + {skills.map((skill) => { + const colors = getImportanceColors(skill.importance); + return ( + + +
+
+ +
+
+ + {skill.skillName} + + {skill.risk === "high" && ( - - Mentor - - )} - {memberSkill?.wantsToLearn && ( - - - Apprenant + Risque )}
- - ); - })} - -
-
-
- - {Math.round(skill.coverage || 0)}% - -
- - - ))} + + {members.map((member) => { + const memberSkill = member.skills.find( + (s) => s.skillId === skill.skillId + ); + return ( + +
+ {getLevelBadge(memberSkill?.level || null)} + {memberSkill?.canMentor && ( + + + Mentor + + )} + {memberSkill?.wantsToLearn && ( + + + Apprenant + + )} +
+
+ ); + })} + +
+
+
+
+ + {Math.round(skill.coverage || 0)}% + +
+ + + ); + })}
diff --git a/components/team-review/team-insights.tsx b/components/team-review/team-insights.tsx index e30ab79..55db7ff 100644 --- a/components/team-review/team-insights.tsx +++ b/components/team-review/team-insights.tsx @@ -2,6 +2,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { CategoryCoverage } from "@/lib/team-review-types"; import { Progress } from "@/components/ui/progress"; +import { AlertTriangle, AlertCircle } from "lucide-react"; interface TeamInsightsProps { recommendations: string[]; @@ -22,9 +23,27 @@ export function TeamInsights({
{recommendations.map((recommendation, index) => ( - - - {recommendation} + + + {recommendation.includes("⚠️") ? ( + + ) : ( + + )} + {recommendation.replace("⚠️ ", "")} ))} @@ -79,6 +98,52 @@ export function TeamInsights({ app.
+ {/* Couverture des compétences critiques */} +
+
+
+ + Incontournables + + + {category.criticalSkillsCoverage.incontournable} + +
+
+
+
+
+
+
+ Majeures + + {category.criticalSkillsCoverage.majeure} + +
+
+
+
+
+
))}
diff --git a/components/team-review/team-overview.tsx b/components/team-review/team-overview.tsx index 8f31c8a..69ef0d0 100644 --- a/components/team-review/team-overview.tsx +++ b/components/team-review/team-overview.tsx @@ -1,14 +1,23 @@ +"use client"; + import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { TeamReviewData } from "@/lib/team-review-types"; import { Progress } from "@/components/ui/progress"; import { Badge } from "@/components/ui/badge"; import { AlertTriangle } from "lucide-react"; +import { getImportanceColors } from "@/lib/tech-colors"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; interface TeamOverviewProps { team: TeamReviewData["team"]; stats: TeamReviewData["stats"]; members: TeamReviewData["members"]; categoryCoverage: TeamReviewData["categoryCoverage"]; + skillGaps: TeamReviewData["skillGaps"]; } export function TeamOverview({ @@ -16,6 +25,7 @@ export function TeamOverview({ stats, members, categoryCoverage, + skillGaps, }: TeamOverviewProps) { // Trouver les top contributeurs const topContributors = [...members] @@ -29,24 +39,51 @@ export function TeamOverview({ // Trouver les catégories qui nécessitent de l'attention const categoriesNeedingAttention = [...categoryCoverage] - .filter((cat) => { - // Une catégorie nécessite de l'attention si : - // - Couverture faible (< 40%) - // - OU pas assez d'experts (< 2) avec une équipe de taille significative (> 5) - // - OU beaucoup d'apprenants (> 30% de l'équipe) avec peu de mentors (< 2) - return ( - cat.coverage < 40 || - (cat.experts < 2 && stats.totalMembers > 5) || - (cat.learners > stats.totalMembers * 0.3 && cat.mentors < 2) + .map((cat) => { + // Pour chaque catégorie, on identifie : + // 1. Les compétences incontournables sous-couvertes (< 75%) + const uncoveredIncontournables = skillGaps.filter( + (gap) => + gap.category === cat.category && + gap.importance === "incontournable" && + gap.coverage < 75 ); + + // 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) => { - // Prioriser les catégories avec le plus de besoins - const aScore = a.learners * 2 - a.mentors - a.experts; - const bScore = b.learners * 2 - b.mentors - b.experts; - return bScore - aScore; - }) - .slice(0, 2); + .filter(Boolean) + .sort((a, b) => b.attentionScore - a.attentionScore) + .slice(0, 3); return ( @@ -130,23 +167,76 @@ export function TeamOverview({ variant="outline" className="text-amber-400 border-amber-400/30" > - {cat.learners} apprenants + {cat.experts} exp • {cat.mentors} ment
-
- {cat.experts} experts - {cat.mentors} mentors -
-
- {cat.coverage < 40 && "Couverture insuffisante • "} - {cat.experts < 2 && - stats.totalMembers > 5 && - "Manque d'experts • "} - {cat.learners > stats.totalMembers * 0.3 && - cat.mentors < 2 && - "Besoin de mentors"} -
+ {/* Compétences sous-couvertes */} + {cat.criticalSkills.length > 0 && ( +
+ {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 ( + + +
+ + {skill.skillName} + + + {skill.coverage.toFixed(0)}% + +
+
+ +
+

+ {skill.importance === "incontournable" + ? "Compétence incontournable" + : "Compétence majeure"} +

+

+ Objectif : {target}% de couverture +
+ Actuel : {skill.coverage.toFixed(0)}% +
+ {skill.coverage < target + ? `Manque ${( + target - skill.coverage + ).toFixed(0)}%` + : "Objectif atteint"} +

+
+
+
+ ); + })} +
+ )}
))} {stats.learningNeeds > 0 && ( diff --git a/components/team-review/team-stats.tsx b/components/team-review/team-stats.tsx index a47b5d7..cda2686 100644 --- a/components/team-review/team-stats.tsx +++ b/components/team-review/team-stats.tsx @@ -13,7 +13,13 @@ import { PieChart, Pie, Cell, + Legend, } from "recharts"; +import { + Tooltip as UITooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; interface TeamStatsProps { stats: TeamReviewData["stats"]; @@ -73,20 +79,45 @@ export function TeamStats({ const learnerCount = members.filter((m) => m.learningSkills > 0).length; const mentorshipRatio = (mentorCount / (stats.totalMembers || 1)) * 100; - // Gaps critiques par catégorie - const criticalGapsByCategory = skillGaps - .filter((gap) => gap.risk === "high") - .reduce((acc, gap) => { - acc[gap.category] = (acc[gap.category] || 0) + 1; - return acc; - }, {} as Record); + // Gaps critiques par catégorie, séparés par importance + const criticalGapsByCategory = skillGaps.reduce((acc, gap) => { + const isIncontournableUndercovered = + gap.importance === "incontournable" && gap.coverage < 75; + const isMajeureUndercovered = + gap.importance === "majeure" && gap.coverage < 60; - const gapData = Object.entries(criticalGapsByCategory).map( - ([category, count]) => ({ + 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); + + const gapData = Object.entries(criticalGapsByCategory) + .map(([category, counts]) => ({ name: category, - value: count, - fill: "#ef4444", - }) + 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 ( @@ -117,11 +148,96 @@ export function TeamStats({ {mentorshipRatio.toFixed(0)}%

-
-

Gaps critiques

-

- {stats.learningNeeds} -

+ + +
+

Compétences critiques

+

+ {totalCriticalGaps} +

+
+
+ +
+

Compétences critiques sous-couvertes :

+
    +
  • Incontournables : couverture < 75%
  • +
  • Majeures : couverture < 60%
  • +
+
+
+
+
+ + {/* Couverture des compétences critiques */} +
+

+ Couverture des compétences critiques +

+
+ + +
+
+ + Incontournables + + + {stats.criticalSkillsCoverage.incontournable.toFixed(0)}% + +
+
+
+
+
+ + +
+

Compétences incontournables

+

Objectif : 75% de couverture

+
+
+ + + +
+
+ Majeures + + {stats.criticalSkillsCoverage.majeure.toFixed(0)}% + +
+
+
+
+
+ + +
+

Compétences majeures

+

Objectif : 60% de couverture

+
+
+
@@ -198,9 +314,9 @@ export function TeamStats({ {gapData.length > 0 && (

- Gaps critiques par catégorie + Compétences critiques sous-couvertes par catégorie

-
+
{ + const label = + name === "incontournable" + ? "Incontournables (obj. 75%)" + : "Majeures (obj. 60%)"; + return [value, label]; + }} + /> + { + return value === "incontournable" + ? "Incontournables (obj. 75%)" + : "Majeures (obj. 60%)"; + }} + wrapperStyle={{ + paddingTop: "20px", + }} + /> + + -
diff --git a/lib/team-review-types.ts b/lib/team-review-types.ts index 6b96677..1e569b5 100644 --- a/lib/team-review-types.ts +++ b/lib/team-review-types.ts @@ -12,6 +12,7 @@ export interface TeamMemberSkill { skillId: string; skillName: string; category: string; + importance: "incontournable" | "majeure" | "standard"; level: "never" | "not-autonomous" | "autonomous" | "expert"; canMentor: boolean; wantsToLearn: boolean; @@ -32,6 +33,7 @@ export interface SkillGap { skillName: string; category: string; icon?: string; + importance: "incontournable" | "majeure" | "standard"; teamMembers: number; experts: number; mentors: number; @@ -48,6 +50,10 @@ export interface CategoryCoverage { experts: number; mentors: number; learners: number; + criticalSkillsCoverage: { + incontournable: number; + majeure: number; + }; } export interface TeamReviewData { @@ -66,6 +72,10 @@ export interface TeamReviewData { averageTeamLevel: number; mentorshipOpportunities: number; learningNeeds: number; + criticalSkillsCoverage: { + incontournable: number; + majeure: number; + }; }; } @@ -74,6 +84,7 @@ export interface MentorOpportunity { mentee: TeamMember; skill: string; category: string; + importance: "incontournable" | "majeure" | "standard"; mentorLevel: "autonomous" | "expert"; menteeLevel: "never" | "not-autonomous"; } diff --git a/services/skills-service.ts b/services/skills-service.ts index 4f948ac..eb64e7a 100644 --- a/services/skills-service.ts +++ b/services/skills-service.ts @@ -522,4 +522,4 @@ export class SkillsService { client.release(); } } -} \ No newline at end of file +} diff --git a/services/team-review-service.ts b/services/team-review-service.ts index 75aff95..a2e0595 100644 --- a/services/team-review-service.ts +++ b/services/team-review-service.ts @@ -32,6 +32,7 @@ export class TeamReviewService { s.id as skill_id, s.name as skill_name, s.icon, + s.importance, sc.name as category FROM skill_categories sc JOIN skills s ON s.category_id = sc.id @@ -54,6 +55,7 @@ export class TeamReviewService { u.email, s.id as skill_id, s.name as skill_name, + s.importance, sc.name as category, se.level, se.can_mentor, @@ -97,6 +99,7 @@ export class TeamReviewService { skillId: row.skill_id, skillName: row.skill_name, category: row.category, + importance: row.importance || "standard", level: row.level as SkillLevel, canMentor: row.can_mentor || false, wantsToLearn: row.wants_to_learn || false, @@ -140,22 +143,28 @@ export class TeamReviewService { const coverage = 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 { skillId: skill.skill_id, skillName: skill.skill_name, category: skill.category, icon: skill.icon, + importance: skill.importance || "standard", team_members: teamMembers, experts, mentors, learners, coverage, - risk: - experts === 0 && mentors === 0 - ? "high" - : experts === 0 || mentors === 0 - ? "medium" - : "low", + risk, }; }); @@ -172,6 +181,10 @@ export class TeamReviewService { mentors: 0, learners: 0, coverage: 0, + criticalSkillsCoverage: { + incontournable: 0, + majeure: 0, + }, }); } @@ -186,6 +199,17 @@ export class TeamReviewService { categoryStats.experts += skillGap.experts; categoryStats.mentors += skillGap.mentors; 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 + 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 = { totalMembers: membersMap.size, totalSkills: skillGaps.length, @@ -221,6 +267,7 @@ export class TeamReviewService { 0 ), learningNeeds: skillGaps.filter((gap) => gap.risk === "high").length, + criticalSkillsCoverage, }; await client.query("COMMIT"); @@ -249,25 +296,43 @@ export class TeamReviewService { ): string[] { const recommendations: string[] = []; - // Analyser les gaps critiques - const criticalGaps = skillGaps.filter((gap) => gap.risk === "high"); - if (criticalGaps.length > 0) { + // Analyser les gaps critiques par importance + const uncoveredIncontournables = skillGaps.filter( + (gap) => gap.importance === "incontournable" && gap.coverage < 50 + ); + if (uncoveredIncontournables.length > 0) { recommendations.push( - `Attention : ${ - criticalGaps.length - } compétences critiques sans expert ni mentor : ${criticalGaps + `⚠️ ${ + uncoveredIncontournables.length + } compétences incontournables faiblement couvertes : ${uncoveredIncontournables .map((gap) => gap.skillName) .join(", ")}` ); } - // Analyser les opportunités de mentorat - const mentorshipNeeds = skillGaps.filter( - (gap) => gap.learners > 0 && gap.mentors > 0 - ).length; - if (mentorshipNeeds > 0) { + const uncoveredMajeures = skillGaps.filter( + (gap) => gap.importance === "majeure" && gap.coverage < 30 + ); + if (uncoveredMajeures.length > 0) { 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 - const isolatedExperts = members.filter( - (member) => member.expertSkills > 0 && member.mentorSkills === 0 + // Identifier les experts isolés sur des compétences importantes + const isolatedExperts = members.filter((member) => + member.skills.some( + (skill) => + skill.level === "expert" && + !skill.canMentor && + (skill.importance === "incontournable" || + skill.importance === "majeure") + ) ); if (isolatedExperts.length > 0) { recommendations.push( - `${isolatedExperts.length} experts pourraient devenir mentors` + `${isolatedExperts.length} experts en compétences critiques pourraient devenir mentors` ); }