feat: my team page
This commit is contained in:
298
services/team-review-service.ts
Normal file
298
services/team-review-service.ts
Normal file
@@ -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<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,
|
||||
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<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,
|
||||
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<string, CategoryCoverage>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user