import { getPool } from "./database"; import { TeamReviewData, TeamMemberProfile, SkillGap, CategoryCoverage, TeamMember, TeamMemberSkill, } from "@/lib/team-review-types"; import { SKILL_LEVEL_VALUES, SkillImportance } from "@/lib/types"; import { COVERAGE_OBJECTIVES, isCoverageBelowObjective, calculateSkillCoverage, } from "@/lib/evaluation-utils"; 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, s.importance, 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, s.importance, 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, importance: row.importance || "standard", level: row.level as | "never" | "not-autonomous" | "autonomous" | "expert", 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; // Calculer la couverture en fonction des niveaux const levels = evaluations.map((e) => e.level ? SKILL_LEVEL_VALUES[e.level as keyof typeof SKILL_LEVEL_VALUES] : 0 ); const coverage = calculateSkillCoverage(levels, totalTeamMembers); // Déterminer le niveau de risque en fonction de l'importance et de la couverture const coverageObjective = COVERAGE_OBJECTIVES[skill.importance as SkillImportance]; const risk = skill.importance === "incontournable" && coverage < coverageObjective ? "high" : skill.importance === "majeure" && coverage < coverageObjective && experts === 0 ? "high" : coverage < coverageObjective ? "medium" : "low"; return { skillId: skill.skill_id, skillName: skill.skill_name, category: skill.category, icon: skill.icon, importance: skill.importance || "standard", teamMembers, experts, mentors, learners, coverage, risk, }; }); // 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, totalSkills: 0, coveredSkills: 0, experts: 0, mentors: 0, learners: 0, coverage: 0, criticalSkillsCoverage: { incontournable: 0, majeure: 0, }, }); } const categoryStats = categoriesMap.get(skill.category)!; categoryStats.totalSkills++; const skillGap = skillGaps.find( (gap) => gap.skillId === skill.skill_id ); if (skillGap) { if (skillGap.teamMembers > 0) categoryStats.coveredSkills++; categoryStats.experts += skillGap.experts; categoryStats.mentors += skillGap.mentors; categoryStats.learners += skillGap.learners; // Calculer la couverture des compétences critiques if ( skillGap.importance === "incontournable" && !isCoverageBelowObjective(skillGap.coverage, skillGap.importance) ) { categoryStats.criticalSkillsCoverage.incontournable++; } if ( skillGap.importance === "majeure" && !isCoverageBelowObjective(skillGap.coverage, skillGap.importance) ) { categoryStats.criticalSkillsCoverage.majeure++; } } } // Calculer la couverture pour chaque catégorie const categoryCoverage: CategoryCoverage[] = Array.from( categoriesMap.values() ).map((category) => ({ ...category, coverage: category.totalSkills > 0 ? (category.coveredSkills / category.totalSkills) * 100 : 0, })); // 7. Générer des recommandations const recommendations = this.generateRecommendations( Array.from(membersMap.values()), skillGaps, categoryCoverage ); // 8. Calculer les statistiques globales const criticalSkillsCoverage = { incontournable: (skillGaps .filter((gap) => gap.importance === "incontournable") .reduce( (acc, gap) => acc + (!isCoverageBelowObjective(gap.coverage, gap.importance) ? 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 + (!isCoverageBelowObjective(gap.coverage, gap.importance) ? 1 : 0), 0 ) / Math.max( 1, skillGaps.filter((gap) => gap.importance === "majeure").length )) * 100, }; 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, criticalSkillsCoverage, }; 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 par importance const uncoveredIncontournables = skillGaps.filter( (gap) => gap.importance === "incontournable" && isCoverageBelowObjective(gap.coverage, gap.importance) ); if (uncoveredIncontournables.length > 0) { recommendations.push( `⚠️ ${ uncoveredIncontournables.length } compétences incontournables faiblement couvertes : ${uncoveredIncontournables .map((gap) => gap.skillName) .join(", ")}` ); } const uncoveredMajeures = skillGaps.filter( (gap) => gap.importance === "majeure" && isCoverageBelowObjective(gap.coverage, gap.importance) ); if (uncoveredMajeures.length > 0) { recommendations.push( `⚠️ ${ 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` ); } // Analyser la couverture des catégories const lowCoverageCategories = categoryCoverage .filter((cat) => cat.coverage < COVERAGE_OBJECTIVES.majeure) .map((cat) => cat.category); if (lowCoverageCategories.length > 0) { recommendations.push( `Faible couverture dans les catégories : ${lowCoverageCategories.join( ", " )}` ); } // 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 en compétences critiques pourraient devenir mentors` ); } return recommendations; } }