Files
peakskills/services/team-review-service.ts
2025-08-27 14:31:05 +02:00

406 lines
12 KiB
TypeScript

import { getPool } from "./database";
import {
TeamReviewData,
TeamMemberProfile,
SkillGap,
CategoryCoverage,
TeamMember,
TeamMemberSkill,
} from "@/lib/team-review-types";
import { SkillLevel, SKILL_LEVEL_VALUES } from "@/lib/types";
import {
COVERAGE_OBJECTIVES,
isCoverageBelowObjective,
calculateSkillCoverage,
} from "@/lib/evaluation-utils";
export class TeamReviewService {
static async getTeamReviewData(teamId: string): Promise<TeamReviewData> {
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<string, TeamMemberProfile>();
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
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",
teamMembers,
experts,
mentors,
learners,
coverage,
risk,
};
});
// 6. Analyser la couverture par catégorie
const categoriesMap = new Map<string, CategoryCoverage>();
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;
}
}