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;
+ }
+}