414 lines
13 KiB
TypeScript
414 lines
13 KiB
TypeScript
import { getPool } from "./database";
|
|
import { Team, SkillCategory } from "@/lib/types";
|
|
import { TeamMember, TeamStats, DirectionStats } from "@/lib/admin-types";
|
|
import { SkillsService } from "./skills-service";
|
|
|
|
export class AdminService {
|
|
/**
|
|
* Récupère toutes les statistiques des équipes depuis la base de données
|
|
*/
|
|
static async getTeamsStats(): Promise<TeamStats[]> {
|
|
const pool = getPool();
|
|
|
|
try {
|
|
// Récupérer toutes les équipes avec leurs membres et évaluations
|
|
const query = `
|
|
WITH team_members AS (
|
|
SELECT
|
|
t.id as team_id,
|
|
t.name as team_name,
|
|
t.direction,
|
|
u.uuid_id,
|
|
u.first_name,
|
|
u.last_name,
|
|
u.created_at as join_date
|
|
FROM teams t
|
|
LEFT JOIN users u ON t.id = u.team_id
|
|
),
|
|
skill_stats AS (
|
|
SELECT
|
|
tm.team_id,
|
|
tm.uuid_id,
|
|
s.id as skill_id,
|
|
s.name as skill_name,
|
|
s.importance,
|
|
sc.name as category_name,
|
|
CASE
|
|
WHEN se.level = 'never' THEN 0
|
|
WHEN se.level = 'not-autonomous' THEN 1
|
|
WHEN se.level = 'autonomous' THEN 2
|
|
WHEN se.level = 'expert' THEN 3
|
|
ELSE 0
|
|
END as level_numeric,
|
|
se.can_mentor,
|
|
se.wants_to_learn
|
|
FROM team_members tm
|
|
LEFT JOIN user_evaluations ue ON tm.uuid_id = ue.user_uuid
|
|
LEFT JOIN skill_evaluations se ON ue.id = se.user_evaluation_id AND se.is_selected = true
|
|
LEFT JOIN skills s ON se.skill_id = s.id
|
|
LEFT JOIN skill_categories sc ON s.category_id = sc.id
|
|
WHERE tm.uuid_id IS NOT NULL
|
|
),
|
|
team_skill_averages AS (
|
|
SELECT
|
|
ss.team_id,
|
|
ss.skill_name,
|
|
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
|
|
FROM skill_stats ss
|
|
JOIN skills s ON ss.skill_id = s.id
|
|
WHERE ss.skill_name IS NOT NULL
|
|
GROUP BY ss.team_id, ss.skill_name, s.icon, s.importance
|
|
),
|
|
critical_skills_coverage AS (
|
|
SELECT
|
|
team_id,
|
|
COALESCE(
|
|
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),
|
|
0
|
|
) as majeure_coverage
|
|
FROM team_skill_averages
|
|
GROUP BY team_id
|
|
)
|
|
SELECT
|
|
tm.team_id,
|
|
tm.team_name,
|
|
tm.direction,
|
|
json_agg(
|
|
DISTINCT jsonb_build_object(
|
|
'uuid', tm.uuid_id,
|
|
'firstName', tm.first_name,
|
|
'lastName', tm.last_name,
|
|
'joinDate', tm.join_date,
|
|
'skills', COALESCE(
|
|
(SELECT json_agg(
|
|
jsonb_build_object(
|
|
'skillId', ss.skill_id,
|
|
'skillName', ss.skill_name,
|
|
'category', ss.category_name,
|
|
'level', ss.level_numeric,
|
|
'canMentor', ss.can_mentor,
|
|
'wantsToLearn', ss.wants_to_learn
|
|
)
|
|
)
|
|
FROM skill_stats ss
|
|
WHERE ss.team_id = tm.team_id AND ss.uuid_id = tm.uuid_id AND ss.skill_name IS NOT NULL
|
|
), '[]'::json
|
|
)
|
|
)
|
|
) FILTER (WHERE tm.uuid_id IS NOT NULL) as members,
|
|
COUNT(DISTINCT tm.uuid_id) FILTER (WHERE tm.uuid_id IS NOT NULL) as total_members,
|
|
COALESCE(AVG(ss.level_numeric) FILTER (WHERE ss.skill_name IS NOT NULL), 0) as avg_skill_level,
|
|
(SELECT json_agg(
|
|
jsonb_build_object(
|
|
'skillName', tsa.skill_name,
|
|
'averageLevel', tsa.avg_level,
|
|
'icon', tsa.skill_icon,
|
|
'importance', tsa.importance,
|
|
'coverage', tsa.coverage
|
|
) ORDER BY
|
|
CASE tsa.importance
|
|
WHEN 'incontournable' THEN 2
|
|
WHEN 'majeure' THEN 1
|
|
ELSE 0
|
|
END DESC,
|
|
tsa.avg_level DESC,
|
|
tsa.coverage DESC
|
|
)
|
|
FROM team_skill_averages tsa
|
|
WHERE tsa.team_id = tm.team_id
|
|
LIMIT 3
|
|
) as top_skills,
|
|
jsonb_build_object(
|
|
'incontournable', COALESCE(csc.incontournable_coverage, 0),
|
|
'majeure', COALESCE(csc.majeure_coverage, 0)
|
|
) as critical_skills_coverage,
|
|
CASE
|
|
WHEN COUNT(DISTINCT ss.skill_id) > 0 THEN
|
|
(COUNT(DISTINCT ss.skill_id) * 100.0 / (SELECT COUNT(*) FROM skills))
|
|
ELSE 0
|
|
END as skill_coverage
|
|
FROM team_members tm
|
|
LEFT JOIN skill_stats ss ON tm.team_id = ss.team_id AND tm.uuid_id = ss.uuid_id
|
|
LEFT JOIN critical_skills_coverage csc ON tm.team_id = csc.team_id
|
|
GROUP BY tm.team_id, tm.team_name, tm.direction, csc.incontournable_coverage, csc.majeure_coverage
|
|
ORDER BY tm.direction, tm.team_name
|
|
`;
|
|
|
|
const result = await pool.query(query);
|
|
|
|
return result.rows.map((row) => ({
|
|
teamId: row.team_id,
|
|
teamName: row.team_name,
|
|
direction: row.direction,
|
|
totalMembers: parseInt(row.total_members) || 0,
|
|
averageSkillLevel: parseFloat(row.avg_skill_level) || 0,
|
|
topSkills: row.top_skills || [],
|
|
skillCoverage: parseFloat(row.skill_coverage) || 0,
|
|
criticalSkillsCoverage: row.critical_skills_coverage || {
|
|
incontournable: 0,
|
|
majeure: 0,
|
|
},
|
|
members: (row.members || []).filter(
|
|
(member: any) => member.uuid !== null
|
|
),
|
|
}));
|
|
} catch (error) {
|
|
console.error("Error fetching teams stats:", error);
|
|
throw new Error("Failed to fetch teams statistics");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Génère les statistiques par direction à partir des stats d'équipes
|
|
*/
|
|
static generateDirectionStats(teamStats: TeamStats[]): DirectionStats[] {
|
|
// Filtrer d'abord les équipes avec 0 membres
|
|
const teamsWithMembers = teamStats.filter((t) => t.totalMembers > 0);
|
|
const directions = Array.from(
|
|
new Set(teamsWithMembers.map((t) => t.direction))
|
|
);
|
|
|
|
return directions.map((direction) => {
|
|
const directionTeams = teamsWithMembers.filter(
|
|
(t) => t.direction === direction
|
|
);
|
|
const totalMembers = directionTeams.reduce(
|
|
(sum, t) => sum + t.totalMembers,
|
|
0
|
|
);
|
|
const averageSkillLevel =
|
|
directionTeams.length > 0
|
|
? directionTeams.reduce((sum, t) => sum + t.averageSkillLevel, 0) /
|
|
directionTeams.length
|
|
: 0;
|
|
|
|
// Calculer les top catégories pour cette direction
|
|
const categoryMap = new Map<string, { total: number; count: number }>();
|
|
|
|
directionTeams.forEach((team) => {
|
|
team.members.forEach((member) => {
|
|
member.skills.forEach((skill) => {
|
|
const current = categoryMap.get(skill.category) || {
|
|
total: 0,
|
|
count: 0,
|
|
};
|
|
categoryMap.set(skill.category, {
|
|
total: current.total + skill.level,
|
|
count: current.count + 1,
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
const topCategories = Array.from(categoryMap.entries())
|
|
.map(([category, stats]) => ({
|
|
category,
|
|
averageLevel: stats.count > 0 ? stats.total / stats.count : 0,
|
|
}))
|
|
.sort((a, b) => b.averageLevel - a.averageLevel)
|
|
.slice(0, 3);
|
|
|
|
return {
|
|
direction,
|
|
teams: directionTeams,
|
|
totalMembers,
|
|
averageSkillLevel,
|
|
topCategories,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Récupère les données nécessaires pour la page de gestion des skills
|
|
*/
|
|
static async getSkillsPageData(): Promise<{
|
|
skillCategories: SkillCategory[];
|
|
skills: any[];
|
|
}> {
|
|
const pool = getPool();
|
|
|
|
try {
|
|
const [categoriesResult, skills] = await Promise.all([
|
|
pool.query("SELECT id, name, icon FROM skill_categories ORDER BY name"),
|
|
SkillsService.getAllSkillsWithUsage(),
|
|
]);
|
|
|
|
const skillCategories = categoriesResult.rows.map((row) => ({
|
|
...row,
|
|
category: row.name,
|
|
skills: [],
|
|
}));
|
|
|
|
return {
|
|
skillCategories,
|
|
skills,
|
|
};
|
|
} catch (error) {
|
|
console.error("Error fetching skills page data:", error);
|
|
throw new Error("Failed to fetch skills page data");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Supprime une catégorie de skills vide
|
|
*/
|
|
static async deleteSkillCategory(categoryId: string): Promise<void> {
|
|
const pool = getPool();
|
|
const client = await pool.connect();
|
|
|
|
try {
|
|
await client.query("BEGIN");
|
|
|
|
// Vérifier que la catégorie n'a pas de skills
|
|
const skillsCheck = await client.query(
|
|
"SELECT COUNT(*) FROM skills WHERE category_id = $1",
|
|
[categoryId]
|
|
);
|
|
|
|
if (parseInt(skillsCheck.rows[0].count) > 0) {
|
|
throw new Error(
|
|
"Impossible de supprimer une catégorie qui contient des skills"
|
|
);
|
|
}
|
|
|
|
// Supprimer la catégorie
|
|
await client.query("DELETE FROM skill_categories WHERE id = $1", [
|
|
categoryId,
|
|
]);
|
|
|
|
await client.query("COMMIT");
|
|
} catch (error) {
|
|
await client.query("ROLLBACK");
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Récupère les données nécessaires pour la page de gestion des utilisateurs
|
|
*/
|
|
static async getUsersPageData(): Promise<{
|
|
teams: Team[];
|
|
users: any[];
|
|
}> {
|
|
const pool = getPool();
|
|
|
|
try {
|
|
const [teamsResult, usersResult] = await Promise.all([
|
|
pool.query(
|
|
"SELECT id, name, direction FROM teams ORDER BY direction, name"
|
|
),
|
|
pool.query(`
|
|
SELECT
|
|
u.uuid_id as uuid,
|
|
u.first_name as "firstName",
|
|
u.last_name as "lastName",
|
|
t.name as "teamName",
|
|
EXISTS (
|
|
SELECT 1 FROM user_evaluations ue
|
|
WHERE ue.user_uuid = u.uuid_id
|
|
) as "hasEvaluations"
|
|
FROM users u
|
|
LEFT JOIN teams t ON u.team_id = t.id
|
|
ORDER BY u.first_name, u.last_name
|
|
`),
|
|
]);
|
|
|
|
return {
|
|
teams: teamsResult.rows,
|
|
users: usersResult.rows,
|
|
};
|
|
} catch (error) {
|
|
console.error("Error fetching users page data:", error);
|
|
throw new Error("Failed to fetch users page data");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Récupère les données nécessaires pour la page de gestion des équipes
|
|
*/
|
|
static async getTeamsPageData(): Promise<{
|
|
teams: Team[];
|
|
teamStats: TeamStats[];
|
|
directionStats: DirectionStats[];
|
|
}> {
|
|
const pool = getPool();
|
|
|
|
try {
|
|
const [teamsResult, teamStats] = await Promise.all([
|
|
pool.query(
|
|
"SELECT id, name, direction FROM teams ORDER BY direction, name"
|
|
),
|
|
AdminService.getTeamsStats(),
|
|
]);
|
|
|
|
const directionStats = AdminService.generateDirectionStats(teamStats);
|
|
|
|
return {
|
|
teams: teamsResult.rows,
|
|
teamStats,
|
|
directionStats,
|
|
};
|
|
} catch (error) {
|
|
console.error("Error fetching teams page data:", error);
|
|
throw new Error("Failed to fetch teams page data");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Récupère les données nécessaires pour la page d'accueil admin
|
|
*/
|
|
static async getOverviewPageData(): Promise<{
|
|
teams: Team[];
|
|
skillCategories: SkillCategory[];
|
|
teamStats: TeamStats[];
|
|
directionStats: DirectionStats[];
|
|
}> {
|
|
const pool = getPool();
|
|
|
|
try {
|
|
const [teamsResult, categoriesResult, teamStats] = await Promise.all([
|
|
pool.query(
|
|
"SELECT id, name, direction FROM teams ORDER BY direction, name"
|
|
),
|
|
pool.query("SELECT id, name, icon FROM skill_categories ORDER BY name"),
|
|
AdminService.getTeamsStats(),
|
|
]);
|
|
|
|
const teams = teamsResult.rows;
|
|
const skillCategories = categoriesResult.rows.map((row) => ({
|
|
...row,
|
|
category: row.name,
|
|
skills: [],
|
|
}));
|
|
|
|
const directionStats = AdminService.generateDirectionStats(teamStats);
|
|
|
|
return {
|
|
teams,
|
|
skillCategories,
|
|
teamStats,
|
|
directionStats,
|
|
};
|
|
} catch (error) {
|
|
console.error("Error fetching overview page data:", error);
|
|
throw new Error("Failed to fetch overview page data");
|
|
}
|
|
}
|
|
}
|