refactor: rule of coverage are in one place

This commit is contained in:
Julien Froidefond
2025-08-27 14:31:05 +02:00
parent a5bcdd34fb
commit a8cad0b2ec
16 changed files with 430 additions and 133 deletions

View File

@@ -2,6 +2,10 @@ import { getPool } from "./database";
import { Team, SkillCategory } from "@/lib/types";
import { TeamMember, TeamStats, DirectionStats } from "@/lib/admin-types";
import { SkillsService } from "./skills-service";
import {
COVERAGE_OBJECTIVES,
generateSkillCoverageSQL,
} from "@/lib/evaluation-utils";
export class AdminService {
/**
@@ -9,8 +13,11 @@ export class AdminService {
*/
static async getTeamsStats(): Promise<TeamStats[]> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
// Récupérer toutes les équipes avec leurs membres et évaluations
const query = `
WITH team_members AS (
@@ -56,14 +63,7 @@ export class AdminService {
s.icon as skill_icon,
s.importance,
AVG(ss.level_numeric) as avg_level,
COALESCE(
SUM(CASE
WHEN ss.level_numeric >= 2 THEN 100.0 -- autonomous ou expert
WHEN ss.level_numeric = 1 THEN 50.0 -- not-autonomous
ELSE 0.0 -- never
END) / NULLIF(COUNT(*), 0),
0
) as coverage
${generateSkillCoverageSQL("ss.level_numeric")} as coverage
FROM skill_stats ss
JOIN skills s ON ss.skill_id = s.id
WHERE ss.skill_name IS NOT NULL
@@ -73,11 +73,17 @@ export class AdminService {
SELECT
team_id,
COALESCE(
AVG(CASE WHEN importance = 'incontournable' THEN coverage ELSE NULL END),
AVG(CASE
WHEN importance = 'incontournable' THEN coverage
ELSE NULL
END),
0
) as incontournable_coverage,
COALESCE(
AVG(CASE WHEN importance = 'majeure' THEN coverage ELSE NULL END),
AVG(CASE
WHEN importance = 'majeure' THEN coverage
ELSE NULL
END),
0
) as majeure_coverage
FROM team_skill_averages
@@ -149,7 +155,8 @@ export class AdminService {
ORDER BY tm.direction, tm.team_name
`;
const result = await pool.query(query);
const result = await client.query(query);
await client.query("COMMIT");
return result.rows.map((row) => ({
teamId: row.team_id,
@@ -168,8 +175,11 @@ export class AdminService {
),
}));
} catch (error) {
await client.query("ROLLBACK");
console.error("Error fetching teams stats:", error);
throw new Error("Failed to fetch teams statistics");
} finally {
client.release();
}
}
@@ -241,10 +251,15 @@ export class AdminService {
skills: any[];
}> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const [categoriesResult, skills] = await Promise.all([
pool.query("SELECT id, name, icon FROM skill_categories ORDER BY name"),
client.query(
"SELECT id, name, icon FROM skill_categories ORDER BY name"
),
SkillsService.getAllSkillsWithUsage(),
]);
@@ -254,13 +269,18 @@ export class AdminService {
skills: [],
}));
await client.query("COMMIT");
return {
skillCategories,
skills,
};
} catch (error) {
await client.query("ROLLBACK");
console.error("Error fetching skills page data:", error);
throw new Error("Failed to fetch skills page data");
} finally {
client.release();
}
}
@@ -308,13 +328,16 @@ export class AdminService {
users: any[];
}> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const [teamsResult, usersResult] = await Promise.all([
pool.query(
client.query(
"SELECT id, name, direction FROM teams ORDER BY direction, name"
),
pool.query(`
client.query(`
SELECT
u.uuid_id as uuid,
u.first_name as "firstName",
@@ -330,13 +353,18 @@ export class AdminService {
`),
]);
await client.query("COMMIT");
return {
teams: teamsResult.rows,
users: usersResult.rows,
};
} catch (error) {
await client.query("ROLLBACK");
console.error("Error fetching users page data:", error);
throw new Error("Failed to fetch users page data");
} finally {
client.release();
}
}
@@ -349,10 +377,13 @@ export class AdminService {
directionStats: DirectionStats[];
}> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const [teamsResult, teamStats] = await Promise.all([
pool.query(
client.query(
"SELECT id, name, direction FROM teams ORDER BY direction, name"
),
AdminService.getTeamsStats(),
@@ -360,14 +391,19 @@ export class AdminService {
const directionStats = AdminService.generateDirectionStats(teamStats);
await client.query("COMMIT");
return {
teams: teamsResult.rows,
teamStats,
directionStats,
};
} catch (error) {
await client.query("ROLLBACK");
console.error("Error fetching teams page data:", error);
throw new Error("Failed to fetch teams page data");
} finally {
client.release();
}
}
@@ -381,13 +417,18 @@ export class AdminService {
directionStats: DirectionStats[];
}> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const [teamsResult, categoriesResult, teamStats] = await Promise.all([
pool.query(
client.query(
"SELECT id, name, direction FROM teams ORDER BY direction, name"
),
pool.query("SELECT id, name, icon FROM skill_categories ORDER BY name"),
client.query(
"SELECT id, name, icon FROM skill_categories ORDER BY name"
),
AdminService.getTeamsStats(),
]);
@@ -400,6 +441,8 @@ export class AdminService {
const directionStats = AdminService.generateDirectionStats(teamStats);
await client.query("COMMIT");
return {
teams,
skillCategories,
@@ -407,8 +450,11 @@ export class AdminService {
directionStats,
};
} catch (error) {
await client.query("ROLLBACK");
console.error("Error fetching overview page data:", error);
throw new Error("Failed to fetch overview page data");
} finally {
client.release();
}
}
}

View File

@@ -7,7 +7,12 @@ import {
TeamMember,
TeamMemberSkill,
} from "@/lib/team-review-types";
import { SkillLevel } from "@/lib/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> {
@@ -100,7 +105,11 @@ export class TeamReviewService {
skillName: row.skill_name,
category: row.category,
importance: row.importance || "standard",
level: row.level as SkillLevel,
level: row.level as
| "never"
| "not-autonomous"
| "autonomous"
| "expert",
canMentor: row.can_mentor || false,
wantsToLearn: row.wants_to_learn || false,
};
@@ -140,8 +149,14 @@ export class TeamReviewService {
const teamMembers = evaluations.filter((e) => e.level).length;
const totalTeamMembers = membersMap.size;
const coverage =
totalTeamMembers > 0 ? (teamMembers / totalTeamMembers) * 100 : 0;
// 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 =
@@ -159,7 +174,7 @@ export class TeamReviewService {
category: skill.category,
icon: skill.icon,
importance: skill.importance || "standard",
team_members: teamMembers,
teamMembers,
experts,
mentors,
learners,
@@ -175,8 +190,8 @@ export class TeamReviewService {
if (!categoriesMap.has(skill.category)) {
categoriesMap.set(skill.category, {
category: skill.category,
total_skills: 0,
covered_skills: 0,
totalSkills: 0,
coveredSkills: 0,
experts: 0,
mentors: 0,
learners: 0,
@@ -189,13 +204,13 @@ export class TeamReviewService {
}
const categoryStats = categoriesMap.get(skill.category)!;
categoryStats.total_skills++;
categoryStats.totalSkills++;
const skillGap = skillGaps.find(
(gap) => gap.skillId === skill.skill_id
);
if (skillGap) {
if (skillGap.team_members > 0) categoryStats.covered_skills++;
if (skillGap.teamMembers > 0) categoryStats.coveredSkills++;
categoryStats.experts += skillGap.experts;
categoryStats.mentors += skillGap.mentors;
categoryStats.learners += skillGap.learners;
@@ -203,11 +218,14 @@ export class TeamReviewService {
// Calculer la couverture des compétences critiques
if (
skillGap.importance === "incontournable" &&
skillGap.coverage > 50
!isCoverageBelowObjective(skillGap.coverage, skillGap.importance)
) {
categoryStats.criticalSkillsCoverage.incontournable++;
}
if (skillGap.importance === "majeure" && skillGap.coverage > 50) {
if (
skillGap.importance === "majeure" &&
!isCoverageBelowObjective(skillGap.coverage, skillGap.importance)
) {
categoryStats.criticalSkillsCoverage.majeure++;
}
}
@@ -219,8 +237,8 @@ export class TeamReviewService {
).map((category) => ({
...category,
coverage:
category.total_skills > 0
? (category.covered_skills / category.total_skills) * 100
category.totalSkills > 0
? (category.coveredSkills / category.totalSkills) * 100
: 0,
}));
@@ -236,7 +254,14 @@ export class TeamReviewService {
incontournable:
(skillGaps
.filter((gap) => gap.importance === "incontournable")
.reduce((acc, gap) => acc + (gap.coverage > 50 ? 1 : 0), 0) /
.reduce(
(acc, gap) =>
acc +
(!isCoverageBelowObjective(gap.coverage, gap.importance)
? 1
: 0),
0
) /
Math.max(
1,
skillGaps.filter((gap) => gap.importance === "incontournable")
@@ -246,7 +271,14 @@ export class TeamReviewService {
majeure:
(skillGaps
.filter((gap) => gap.importance === "majeure")
.reduce((acc, gap) => acc + (gap.coverage > 50 ? 1 : 0), 0) /
.reduce(
(acc, gap) =>
acc +
(!isCoverageBelowObjective(gap.coverage, gap.importance)
? 1
: 0),
0
) /
Math.max(
1,
skillGaps.filter((gap) => gap.importance === "majeure").length
@@ -298,7 +330,9 @@ export class TeamReviewService {
// Analyser les gaps critiques par importance
const uncoveredIncontournables = skillGaps.filter(
(gap) => gap.importance === "incontournable" && gap.coverage < 50
(gap) =>
gap.importance === "incontournable" &&
isCoverageBelowObjective(gap.coverage, gap.importance)
);
if (uncoveredIncontournables.length > 0) {
recommendations.push(
@@ -311,7 +345,9 @@ export class TeamReviewService {
}
const uncoveredMajeures = skillGaps.filter(
(gap) => gap.importance === "majeure" && gap.coverage < 30
(gap) =>
gap.importance === "majeure" &&
isCoverageBelowObjective(gap.coverage, gap.importance)
);
if (uncoveredMajeures.length > 0) {
recommendations.push(
@@ -338,7 +374,7 @@ export class TeamReviewService {
// Analyser la couverture des catégories
const lowCoverageCategories = categoryCoverage
.filter((cat) => cat.coverage < 50)
.filter((cat) => cat.coverage < COVERAGE_OBJECTIVES.majeure)
.map((cat) => cat.category);
if (lowCoverageCategories.length > 0) {
recommendations.push(