From c7a5b2550114d76e7a58e7952ffc2cb8ba2f973f Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Wed, 27 Aug 2025 10:53:11 +0200 Subject: [PATCH] feat: my team page --- app/api/teams/review/route.ts | 21 ++ app/team/page.tsx | 100 ++++++ components/layout/navigation.tsx | 6 + components/team-review/learning-section.tsx | 152 +++++++++ components/team-review/mentorship-section.tsx | 126 ++++++++ components/team-review/skill-matrix.tsx | 199 ++++++++++++ components/team-review/team-insights.tsx | 89 ++++++ components/team-review/team-overview.tsx | 166 ++++++++++ components/team-review/team-stats.tsx | 285 +++++++++++++++++ lib/team-review-types.ts | 87 +++++ services/team-review-service.ts | 298 ++++++++++++++++++ 11 files changed, 1529 insertions(+) create mode 100644 app/api/teams/review/route.ts create mode 100644 app/team/page.tsx create mode 100644 components/team-review/learning-section.tsx create mode 100644 components/team-review/mentorship-section.tsx create mode 100644 components/team-review/skill-matrix.tsx create mode 100644 components/team-review/team-insights.tsx create mode 100644 components/team-review/team-overview.tsx create mode 100644 components/team-review/team-stats.tsx create mode 100644 lib/team-review-types.ts create mode 100644 services/team-review-service.ts diff --git a/app/api/teams/review/route.ts b/app/api/teams/review/route.ts new file mode 100644 index 0000000..a1c4ee3 --- /dev/null +++ b/app/api/teams/review/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from "next/server"; +import { TeamReviewService } from "@/services/team-review-service"; +import { AuthService } from "@/services/auth-service"; + +export async function GET(request: NextRequest) { + try { + // Vérifier l'authentification + const { userProfile } = await AuthService.requireAuthenticatedUser(); + + const teamId = userProfile.teamId; + const data = await TeamReviewService.getTeamReviewData(teamId); + + return NextResponse.json(data); + } catch (error) { + console.error("Error in team review API:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/team/page.tsx b/app/team/page.tsx new file mode 100644 index 0000000..cca9403 --- /dev/null +++ b/app/team/page.tsx @@ -0,0 +1,100 @@ +import { TeamReviewService } from "@/services/team-review-service"; +import { AuthService } from "@/services/auth-service"; +import { redirect } from "next/navigation"; +import { TeamOverview } from "@/components/team-review/team-overview"; +import { SkillMatrix } from "@/components/team-review/skill-matrix"; +import { TeamInsights } from "@/components/team-review/team-insights"; +import { MentorshipSection } from "@/components/team-review/mentorship-section"; +import { LearningSection } from "@/components/team-review/learning-section"; +import { TeamStats } from "@/components/team-review/team-stats"; +import { Users } from "lucide-react"; + +export const dynamic = "force-dynamic"; + +async function TeamReviewPage() { + try { + const { userProfile } = await AuthService.requireAuthenticatedUser(); + + const teamData = await TeamReviewService.getTeamReviewData( + userProfile.teamId + ); + + return ( +
+ {/* Background Effects */} +
+
+
+ +
+ {/* Header */} +
+
+ + + Vue d'équipe + +
+ +

+ {teamData.team.name} +

+ +

+ Vue d'ensemble et analyse des compétences de l'équipe{" "} + {teamData.team.direction} +

+
+ + {/* Main Content */} +
+ + +
+ + +
+ + + +
+ + +
+
+
+
+ ); + } catch (error: any) { + if (error.status === 401) { + redirect("/login"); + } + throw error; + } +} + +export default function Page() { + return ; +} diff --git a/components/layout/navigation.tsx b/components/layout/navigation.tsx index 0ad39fd..b4468f7 100644 --- a/components/layout/navigation.tsx +++ b/components/layout/navigation.tsx @@ -10,6 +10,7 @@ import { Settings, Building2, ChevronDown, + Users, } from "lucide-react"; import { DropdownMenu, @@ -63,6 +64,11 @@ export function Navigation({ userInfo }: NavigationProps = {}) { label: "Évaluation", icon: User, }, + { + href: "/team", + label: "Mon équipe", + icon: Users, + }, { href: "/admin", label: "Administration", diff --git a/components/team-review/learning-section.tsx b/components/team-review/learning-section.tsx new file mode 100644 index 0000000..f29e797 --- /dev/null +++ b/components/team-review/learning-section.tsx @@ -0,0 +1,152 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { TeamMemberProfile, SkillGap } from "@/lib/team-review-types"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { GraduationCap } from "lucide-react"; + +interface LearningSectionProps { + members: TeamMemberProfile[]; + skillGaps: SkillGap[]; +} + +export function LearningSection({ members, skillGaps }: LearningSectionProps) { + // Trouver les apprenants + const learners = members.filter((member) => member.learningSkills > 0); + + // Organiser les besoins d'apprentissage par apprenant + const learningNeeds = learners.map((learner) => { + const needs = learner.skills + .filter((skill) => skill.wantsToLearn) + .map((skill) => { + const gap = skillGaps.find((g) => g.skillId === skill.skillId); + if (!gap) return null; + + const mentors = members.filter((member) => + member.skills.some( + (s) => + s.skillId === skill.skillId && + s.canMentor && + ["autonomous", "expert"].includes(s.level) + ) + ); + + return { + skill: skill.skillName, + category: skill.category, + currentLevel: skill.level, + mentors, + isHighRisk: gap.risk === "high", + }; + }) + .filter(Boolean); + + return { + learner, + needs, + }; + }); + + const getLevelColor = (level: string) => { + const colors = { + never: "bg-white/5 text-slate-300 border-white/20", + "not-autonomous": "bg-yellow-500/20 text-yellow-200 border-yellow-500/30", + autonomous: "bg-green-500/20 text-green-200 border-green-500/30", + expert: "bg-blue-500/20 text-blue-200 border-blue-500/30", + }; + return colors[level as keyof typeof colors]; + }; + + return ( + + + Besoins en formation + + + +
+ {learningNeeds.map(({ learner, needs }) => ( +
+
+ + + {learner.member.firstName[0]} + {learner.member.lastName[0]} + + +
+

+ {learner.member.firstName} {learner.member.lastName} +

+

+ {learner.learningSkills} compétences à développer +

+
+
+ +
+ {needs.map( + (need, idx) => + need && ( +
+
+ + {need.category} + + + {need.skill} + + {need.isHighRisk && ( + + Prioritaire + + )} +
+
+ + Niveau actuel : + + + {need.currentLevel} + +
+ {need.mentors.length > 0 && ( +
+

+ Mentors disponibles : +

+
+ {need.mentors.map((mentor) => ( + + {mentor.member.firstName}{" "} + {mentor.member.lastName} + + ))} +
+
+ )} +
+ ) + )} +
+
+ ))} +
+
+
+
+ ); +} diff --git a/components/team-review/mentorship-section.tsx b/components/team-review/mentorship-section.tsx new file mode 100644 index 0000000..d9350d4 --- /dev/null +++ b/components/team-review/mentorship-section.tsx @@ -0,0 +1,126 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { TeamMemberProfile, SkillGap } from "@/lib/team-review-types"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { UserCheck } from "lucide-react"; + +interface MentorshipSectionProps { + members: TeamMemberProfile[]; + skillGaps: SkillGap[]; +} + +export function MentorshipSection({ + members, + skillGaps, +}: MentorshipSectionProps) { + // Trouver les mentors potentiels + const mentors = members.filter((member) => member.mentorSkills > 0); + + // Organiser les opportunités de mentorat par mentor + const mentorshipOpportunities = mentors.map((mentor) => { + const opportunities = mentor.skills + .filter((skill) => skill.canMentor) + .map((skill) => { + const gap = skillGaps.find((g) => g.skillId === skill.skillId); + if (!gap) return null; + + const learners = members.filter((member) => + member.skills.some( + (s) => + s.skillId === skill.skillId && + s.wantsToLearn && + ["never", "not-autonomous"].includes(s.level) + ) + ); + + return { + skill: skill.skillName, + category: skill.category, + learners, + }; + }) + .filter(Boolean); + + return { + mentor, + opportunities, + }; + }); + + return ( + + + + Opportunités de mentorat + + + + +
+ {mentorshipOpportunities.map(({ mentor, opportunities }) => ( +
+
+ + + {mentor.member.firstName[0]} + {mentor.member.lastName[0]} + + +
+

+ {mentor.member.firstName} {mentor.member.lastName} +

+

+ {mentor.mentorSkills} compétences en mentorat +

+
+
+ +
+ {opportunities.map( + (opp, idx) => + opp && ( +
+
+ + {opp.category} + + + {opp.skill} + +
+ {opp.learners.length > 0 && ( +
+

+ Apprenants potentiels : +

+
+ {opp.learners.map((learner) => ( + + {learner.member.firstName}{" "} + {learner.member.lastName} + + ))} +
+
+ )} +
+ ) + )} +
+
+ ))} +
+
+
+
+ ); +} diff --git a/components/team-review/skill-matrix.tsx b/components/team-review/skill-matrix.tsx new file mode 100644 index 0000000..9beb92e --- /dev/null +++ b/components/team-review/skill-matrix.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +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"; + +interface SkillMatrixProps { + members: TeamMemberProfile[]; + skillGaps: SkillGap[]; +} + +export function SkillMatrix({ members, skillGaps }: SkillMatrixProps) { + // Filtrer les skills valides et les organiser par catégorie + const validSkillGaps = skillGaps.filter( + (skill) => skill.skillId && skill.skillName && skill.category + ); + + const skillsByCategory = validSkillGaps.reduce((acc, skill) => { + if (!acc[skill.category]) { + acc[skill.category] = []; + } + acc[skill.category].push(skill); + return acc; + }, {} as Record); + + const getLevelBadge = (level: string | null) => { + const colors = { + never: "bg-white/5 text-slate-300", + "not-autonomous": "bg-yellow-500/20 text-yellow-200", + autonomous: "bg-green-500/20 text-green-200", + expert: "bg-blue-500/20 text-blue-200", + }; + + return level ? ( + + {level} + + ) : ( + - + ); + }; + + // Vérifier si nous avons des données valides + if (validSkillGaps.length === 0 || members.length === 0) { + return ( + + + + Matrice des compétences + + + +

+ Aucune donnée disponible pour la matrice des compétences. +

+
+
+ ); + } + + return ( + + + + Matrice des compétences + + + +
+ {Object.entries(skillsByCategory).map(([category, skills]) => ( +
+

+ {category} +

+
+ + + + + Compétence + + {members.map((member) => ( + + {member.member.firstName} {member.member.lastName} + + ))} + + Couverture + + + + + {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 && ( + + + 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 new file mode 100644 index 0000000..e30ab79 --- /dev/null +++ b/components/team-review/team-insights.tsx @@ -0,0 +1,89 @@ +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"; + +interface TeamInsightsProps { + recommendations: string[]; + categoryCoverage: CategoryCoverage[]; +} + +export function TeamInsights({ + recommendations, + categoryCoverage, +}: TeamInsightsProps) { + return ( + + + + Insights & Recommandations + + + +
+ {recommendations.map((recommendation, index) => ( + + + {recommendation} + + + ))} +
+ +
+

+ Couverture par catégorie +

+
+ {categoryCoverage.map((category) => ( +
+
+ + {category.category} + + + {Math.round(category.coverage)}% + +
+
+
+
+
+
+ + {category.experts} + {" "} + exp. +
+
+ + {category.mentors} + {" "} + ment. +
+
+ + {category.learners} + {" "} + app. +
+
+
+ ))} +
+
+ + + ); +} diff --git a/components/team-review/team-overview.tsx b/components/team-review/team-overview.tsx new file mode 100644 index 0000000..8f31c8a --- /dev/null +++ b/components/team-review/team-overview.tsx @@ -0,0 +1,166 @@ +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"; + +interface TeamOverviewProps { + team: TeamReviewData["team"]; + stats: TeamReviewData["stats"]; + members: TeamReviewData["members"]; + categoryCoverage: TeamReviewData["categoryCoverage"]; +} + +export function TeamOverview({ + team, + stats, + members, + categoryCoverage, +}: TeamOverviewProps) { + // Trouver les top contributeurs + const topContributors = [...members] + .sort((a, b) => b.expertSkills - a.expertSkills) + .slice(0, 3); + + // Trouver les catégories les plus fortes + const strongestCategories = [...categoryCoverage] + .sort((a, b) => b.coverage - a.coverage) + .slice(0, 2); + + // 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) + ); + }) + .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); + + return ( + + + Vue d'ensemble + + +
+ {/* Points forts */} +
+

+ Points forts +

+
+ {strongestCategories.map((cat) => ( +
+
+

+ {cat.category} +

+ + {cat.coverage.toFixed(0)}% + +
+ +

+ {cat.experts} experts • {cat.mentors} mentors +

+
+ ))} +
+
+ + {/* Top contributeurs */} +
+

+ Top contributeurs +

+
+ {topContributors.map((member) => ( +
+

+ {member.member.firstName} {member.member.lastName} +

+
+ + {member.expertSkills} expertises + + {member.mentorSkills > 0 && ( + + {member.mentorSkills} mentorats + + )} +
+
+ ))} +
+
+ + {/* Points d'attention */} +
+

+ + Besoins prioritaires +

+
+ {categoriesNeedingAttention.map((cat) => ( +
+
+

+ {cat.category} +

+
+ + {cat.learners} apprenants + +
+
+
+ {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"} +
+
+ ))} + {stats.learningNeeds > 0 && ( +
+

+ + {stats.learningNeeds} compétences sans expert ni mentor +

+
+ )} +
+
+
+
+
+ ); +} diff --git a/components/team-review/team-stats.tsx b/components/team-review/team-stats.tsx new file mode 100644 index 0000000..a47b5d7 --- /dev/null +++ b/components/team-review/team-stats.tsx @@ -0,0 +1,285 @@ +"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, +} from "recharts"; + +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); + + 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 + const criticalGapsByCategory = skillGaps + .filter((gap) => gap.risk === "high") + .reduce((acc, gap) => { + acc[gap.category] = (acc[gap.category] || 0) + 1; + return acc; + }, {} as Record); + + const gapData = Object.entries(criticalGapsByCategory).map( + ([category, count]) => ({ + name: category, + value: count, + fill: "#ef4444", + }) + ); + + return ( + + + + Statistiques de l'équipe + + + + {/* Vue d'ensemble */} +
+
+

Membres

+

+ {stats.totalMembers} +

+
+
+

Compétences/membre

+

+ {avgSkillsPerMember.toFixed(1)} +

+
+
+

Ratio mentors

+

+ {mentorshipRatio.toFixed(0)}% +

+
+
+

Gaps critiques

+

+ {stats.learningNeeds} +

+
+
+ + {/* Distribution des niveaux */} +
+

+ Distribution des niveaux +

+
+ + + `${entry.name} (${entry.value})`} + labelLine={{ stroke: "#64748b" }} + > + {levelData.map((entry, index) => ( + + ))} + + + + +
+
+ + {/* Top catégories */} +
+

+ Top catégories par expertise +

+
+ {topCategories.map((cat) => ( +
+
+

+ {cat.category} +

+
+ {cat.experts} experts + + {cat.mentors} mentors +
+
+
+

+ {cat.coverage.toFixed(0)}% +

+

couverture

+
+
+ ))} +
+
+ + {/* Gaps critiques par catégorie */} + {gapData.length > 0 && ( +
+

+ Gaps critiques par catégorie +

+
+ + + + + + + + + +
+
+ )} + + {/* Statistiques de mentorat */} +
+
+

+ Mentorat +

+
+
+

Mentors

+

+ {mentorCount} +

+
+
+

Apprenants

+

+ {learnerCount} +

+
+
+

Opportunités

+

+ {stats.mentorshipOpportunities} +

+
+
+
+
+

+ Couverture globale +

+
+
+

Compétences

+

+ {stats.totalSkills} +

+
+
+

Niveau moyen

+

+ {((stats.averageTeamLevel * 100) / 3).toFixed(0)}% +

+
+
+
+
+
+
+ ); +} diff --git a/lib/team-review-types.ts b/lib/team-review-types.ts new file mode 100644 index 0000000..6b96677 --- /dev/null +++ b/lib/team-review-types.ts @@ -0,0 +1,87 @@ +export interface TeamMember { + uuid: string; + firstName: string; + lastName: string; + teamId: string; + email?: string; + role?: string; + joinDate?: string; +} + +export interface TeamMemberSkill { + skillId: string; + skillName: string; + category: string; + level: "never" | "not-autonomous" | "autonomous" | "expert"; + canMentor: boolean; + wantsToLearn: boolean; +} + +export interface TeamMemberProfile { + member: TeamMember; + skills: TeamMemberSkill[]; + totalSkills: number; + expertSkills: number; + mentorSkills: number; + learningSkills: number; + averageLevel: number; +} + +export interface SkillGap { + skillId: string; + skillName: string; + category: string; + icon?: string; + teamMembers: number; + experts: number; + mentors: number; + learners: number; + coverage: number; // Pourcentage de couverture + risk: "low" | "medium" | "high"; +} + +export interface CategoryCoverage { + category: string; + totalSkills: number; + coveredSkills: number; + coverage: number; + experts: number; + mentors: number; + learners: number; +} + +export interface TeamReviewData { + team: { + id: string; + name: string; + direction: string; + }; + members: TeamMemberProfile[]; + skillGaps: SkillGap[]; + categoryCoverage: CategoryCoverage[]; + recommendations: string[]; + stats: { + totalMembers: number; + totalSkills: number; + averageTeamLevel: number; + mentorshipOpportunities: number; + learningNeeds: number; + }; +} + +export interface MentorOpportunity { + mentor: TeamMember; + mentee: TeamMember; + skill: string; + category: string; + mentorLevel: "autonomous" | "expert"; + menteeLevel: "never" | "not-autonomous"; +} + +export interface LearningPath { + member: TeamMember; + currentSkills: TeamMemberSkill[]; + targetSkills: TeamMemberSkill[]; + recommendedMentors: TeamMember[]; + estimatedTimeToAutonomy: number; // en mois +} diff --git a/services/team-review-service.ts b/services/team-review-service.ts new file mode 100644 index 0000000..75aff95 --- /dev/null +++ b/services/team-review-service.ts @@ -0,0 +1,298 @@ +import { getPool } from "./database"; +import { + TeamReviewData, + TeamMemberProfile, + SkillGap, + CategoryCoverage, + TeamMember, + TeamMemberSkill, +} from "@/lib/team-review-types"; +import { SkillLevel } from "@/lib/types"; + +export class TeamReviewService { + static async getTeamReviewData(teamId: string): Promise { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + // 1. Récupérer les infos de l'équipe + const teamQuery = ` + SELECT id, name, direction + FROM teams + WHERE id = $1 + `; + const teamResult = await client.query(teamQuery, [teamId]); + const team = teamResult.rows[0]; + + // 2. Récupérer les compétences qui ont au moins une évaluation dans l'équipe + const skillsQuery = ` + SELECT DISTINCT + s.id as skill_id, + s.name as skill_name, + s.icon, + sc.name as category + FROM skill_categories sc + JOIN skills s ON s.category_id = sc.id + JOIN skill_evaluations se ON s.id = se.skill_id + JOIN user_evaluations ue ON se.user_evaluation_id = ue.id + JOIN users u ON ue.user_uuid = u.uuid_id + WHERE u.team_id = $1 + ORDER BY sc.name, s.name + `; + const skillsResult = await client.query(skillsQuery, [teamId]); + const allSkills = skillsResult.rows; + + // 3. Récupérer les évaluations des membres + const membersQuery = ` + SELECT + u.uuid_id, + u.first_name, + u.last_name, + u.team_id, + u.email, + s.id as skill_id, + s.name as skill_name, + sc.name as category, + se.level, + se.can_mentor, + se.wants_to_learn + FROM users u + LEFT JOIN user_evaluations ue ON u.uuid_id = ue.user_uuid + LEFT JOIN skill_evaluations se ON ue.id = se.user_evaluation_id + LEFT JOIN skills s ON se.skill_id = s.id + LEFT JOIN skill_categories sc ON s.category_id = sc.id + WHERE u.team_id = $1 + ORDER BY u.first_name, u.last_name, sc.name, s.name + `; + const membersResult = await client.query(membersQuery, [teamId]); + + // 4. Organiser les données des membres + const membersMap = new Map(); + + for (const row of membersResult.rows) { + if (!membersMap.has(row.uuid_id)) { + membersMap.set(row.uuid_id, { + member: { + uuid: row.uuid_id, + firstName: row.first_name, + lastName: row.last_name, + teamId: row.team_id, + email: row.email, + }, + skills: [], + totalSkills: 0, + expertSkills: 0, + mentorSkills: 0, + learningSkills: 0, + averageLevel: 0, + }); + } + + const profile = membersMap.get(row.uuid_id)!; + + if (row.skill_id && row.skill_name && row.category) { + const skill: TeamMemberSkill = { + skillId: row.skill_id, + skillName: row.skill_name, + category: row.category, + level: row.level as SkillLevel, + canMentor: row.can_mentor || false, + wantsToLearn: row.wants_to_learn || false, + }; + + profile.skills.push(skill); + profile.totalSkills++; + + if (row.level === "expert") profile.expertSkills++; + if (row.can_mentor) profile.mentorSkills++; + if (row.wants_to_learn) profile.learningSkills++; + } + } + + // Calculer les moyennes + for (const profile of membersMap.values()) { + profile.averageLevel = + profile.skills.reduce((acc, skill) => { + const levelValues = { + never: 0, + "not-autonomous": 1, + autonomous: 2, + expert: 3, + }; + return acc + (levelValues[skill.level] || 0); + }, 0) / (profile.skills.length || 1); + } + + // 5. Analyser les gaps de compétences + const skillGaps: SkillGap[] = allSkills.map((skill) => { + const evaluations = membersResult.rows.filter( + (row) => row.skill_id === skill.skill_id + ); + + const experts = evaluations.filter((e) => e.level === "expert").length; + const mentors = evaluations.filter((e) => e.can_mentor).length; + const learners = evaluations.filter((e) => e.wants_to_learn).length; + const teamMembers = evaluations.filter((e) => e.level).length; + + const totalTeamMembers = membersMap.size; + const coverage = + totalTeamMembers > 0 ? (teamMembers / totalTeamMembers) * 100 : 0; + + return { + skillId: skill.skill_id, + skillName: skill.skill_name, + category: skill.category, + icon: skill.icon, + team_members: teamMembers, + experts, + mentors, + learners, + coverage, + risk: + experts === 0 && mentors === 0 + ? "high" + : experts === 0 || mentors === 0 + ? "medium" + : "low", + }; + }); + + // 6. Analyser la couverture par catégorie + const categoriesMap = new Map(); + + for (const skill of allSkills) { + if (!categoriesMap.has(skill.category)) { + categoriesMap.set(skill.category, { + category: skill.category, + total_skills: 0, + covered_skills: 0, + experts: 0, + mentors: 0, + learners: 0, + coverage: 0, + }); + } + + const categoryStats = categoriesMap.get(skill.category)!; + categoryStats.total_skills++; + + const skillGap = skillGaps.find( + (gap) => gap.skillId === skill.skill_id + ); + if (skillGap) { + if (skillGap.team_members > 0) categoryStats.covered_skills++; + categoryStats.experts += skillGap.experts; + categoryStats.mentors += skillGap.mentors; + categoryStats.learners += skillGap.learners; + } + } + + // Calculer la couverture pour chaque catégorie + const categoryCoverage: CategoryCoverage[] = Array.from( + categoriesMap.values() + ).map((category) => ({ + ...category, + coverage: + category.total_skills > 0 + ? (category.covered_skills / category.total_skills) * 100 + : 0, + })); + + // 7. Générer des recommandations + const recommendations = this.generateRecommendations( + Array.from(membersMap.values()), + skillGaps, + categoryCoverage + ); + + // 8. Calculer les statistiques globales + const stats = { + totalMembers: membersMap.size, + totalSkills: skillGaps.length, + averageTeamLevel: + Array.from(membersMap.values()).reduce( + (acc, profile) => acc + profile.averageLevel, + 0 + ) / (membersMap.size || 1), + mentorshipOpportunities: skillGaps.reduce( + (acc, gap) => acc + (gap.learners || 0), + 0 + ), + learningNeeds: skillGaps.filter((gap) => gap.risk === "high").length, + }; + + await client.query("COMMIT"); + + return { + team, + members: Array.from(membersMap.values()), + skillGaps, + categoryCoverage, + recommendations, + stats, + }; + } catch (error) { + await client.query("ROLLBACK"); + console.error("Error in getTeamReviewData:", error); + throw new Error("Failed to get team review data"); + } finally { + client.release(); + } + } + + private static generateRecommendations( + members: TeamMemberProfile[], + skillGaps: SkillGap[], + categoryCoverage: CategoryCoverage[] + ): string[] { + const recommendations: string[] = []; + + // Analyser les gaps critiques + const criticalGaps = skillGaps.filter((gap) => gap.risk === "high"); + if (criticalGaps.length > 0) { + recommendations.push( + `Attention : ${ + criticalGaps.length + } compétences critiques sans expert ni mentor : ${criticalGaps + .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) { + recommendations.push( + `${mentorshipNeeds} opportunités de mentorat identifiées` + ); + } + + // Analyser la couverture des catégories + const lowCoverageCategories = categoryCoverage + .filter((cat) => cat.coverage < 50) + .map((cat) => cat.category); + if (lowCoverageCategories.length > 0) { + recommendations.push( + `Faible couverture dans les catégories : ${lowCoverageCategories.join( + ", " + )}` + ); + } + + // Identifier les experts isolés + const isolatedExperts = members.filter( + (member) => member.expertSkills > 0 && member.mentorSkills === 0 + ); + if (isolatedExperts.length > 0) { + recommendations.push( + `${isolatedExperts.length} experts pourraient devenir mentors` + ); + } + + return recommendations; + } +}