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

@@ -6,6 +6,10 @@ import {
getSkillLevelLabel, getSkillLevelLabel,
getSkillLevelColor, getSkillLevelColor,
} from "../team-detail/team-stats-row"; } from "../team-detail/team-stats-row";
import {
COVERAGE_OBJECTIVES,
isCoverageBelowObjective,
} from "@/lib/evaluation-utils";
interface DirectionOverviewProps { interface DirectionOverviewProps {
direction: string; direction: string;
@@ -182,7 +186,10 @@ export function DirectionOverview({
</span> </span>
<span <span
className={`text-sm font-bold ${ className={`text-sm font-bold ${
averageCriticalCoverage.incontournable < 75 isCoverageBelowObjective(
averageCriticalCoverage.incontournable,
"incontournable"
)
? "text-red-400" ? "text-red-400"
: "text-green-400" : "text-green-400"
}`} }`}
@@ -193,7 +200,10 @@ export function DirectionOverview({
<div className="w-full bg-slate-700/50 rounded-full h-1.5"> <div className="w-full bg-slate-700/50 rounded-full h-1.5">
<div <div
className={`h-1.5 rounded-full transition-all shadow-sm ${ className={`h-1.5 rounded-full transition-all shadow-sm ${
averageCriticalCoverage.incontournable < 75 isCoverageBelowObjective(
averageCriticalCoverage.incontournable,
"incontournable"
)
? "bg-gradient-to-r from-red-500 to-red-400" ? "bg-gradient-to-r from-red-500 to-red-400"
: "bg-gradient-to-r from-green-500 to-green-400" : "bg-gradient-to-r from-green-500 to-green-400"
}`} }`}
@@ -210,7 +220,10 @@ export function DirectionOverview({
<span className="text-sm text-slate-300">Majeures:</span> <span className="text-sm text-slate-300">Majeures:</span>
<span <span
className={`text-sm font-bold ${ className={`text-sm font-bold ${
averageCriticalCoverage.majeure < 60 isCoverageBelowObjective(
averageCriticalCoverage.majeure,
"majeure"
)
? "text-orange-400" ? "text-orange-400"
: "text-green-400" : "text-green-400"
}`} }`}
@@ -221,7 +234,10 @@ export function DirectionOverview({
<div className="w-full bg-slate-700/50 rounded-full h-1.5"> <div className="w-full bg-slate-700/50 rounded-full h-1.5">
<div <div
className={`h-1.5 rounded-full transition-all shadow-sm ${ className={`h-1.5 rounded-full transition-all shadow-sm ${
averageCriticalCoverage.majeure < 60 isCoverageBelowObjective(
averageCriticalCoverage.majeure,
"majeure"
)
? "bg-gradient-to-r from-orange-500 to-orange-400" ? "bg-gradient-to-r from-orange-500 to-orange-400"
: "bg-gradient-to-r from-green-500 to-green-400" : "bg-gradient-to-r from-green-500 to-green-400"
}`} }`}

View File

@@ -6,6 +6,10 @@ import { TeamDetailHeader } from "./team-detail-header";
import { TeamMetricsCards } from "./team-metrics-cards"; import { TeamMetricsCards } from "./team-metrics-cards";
import { TeamDetailTabs } from "./team-detail-tabs"; import { TeamDetailTabs } from "./team-detail-tabs";
import { TeamMemberModal } from "@/components/admin"; import { TeamMemberModal } from "@/components/admin";
import {
COVERAGE_OBJECTIVES,
isCoverageBelowObjective,
} from "@/lib/evaluation-utils";
interface TeamDetailClientWrapperProps { interface TeamDetailClientWrapperProps {
team: TeamStats; team: TeamStats;
@@ -185,10 +189,14 @@ export function TeamDetailClientWrapper({
), ),
skillGaps: { skillGaps: {
incontournable: skillAnalysis.filter( incontournable: skillAnalysis.filter(
(s) => s.importance === "incontournable" && s.coverage < 75 (s) =>
s.importance === "incontournable" &&
isCoverageBelowObjective(s.coverage, s.importance)
).length, ).length,
majeure: skillAnalysis.filter( majeure: skillAnalysis.filter(
(s) => s.importance === "majeure" && s.coverage < 60 (s) =>
s.importance === "majeure" &&
isCoverageBelowObjective(s.coverage, s.importance)
).length, ).length,
standard: skillAnalysis.filter( standard: skillAnalysis.filter(
(s) => s.importance === "standard" && s.averageLevel < 1.5 (s) => s.importance === "standard" && s.averageLevel < 1.5
@@ -196,10 +204,14 @@ export function TeamDetailClientWrapper({
}, },
strongSkills: { strongSkills: {
incontournable: skillAnalysis.filter( incontournable: skillAnalysis.filter(
(s) => s.importance === "incontournable" && s.coverage >= 75 (s) =>
s.importance === "incontournable" &&
!isCoverageBelowObjective(s.coverage, s.importance)
).length, ).length,
majeure: skillAnalysis.filter( majeure: skillAnalysis.filter(
(s) => s.importance === "majeure" && s.coverage >= 60 (s) =>
s.importance === "majeure" &&
!isCoverageBelowObjective(s.coverage, s.importance)
).length, ).length,
standard: skillAnalysis.filter( standard: skillAnalysis.filter(
(s) => s.importance === "standard" && s.averageLevel >= 2.5 (s) => s.importance === "standard" && s.averageLevel >= 2.5

View File

@@ -14,6 +14,10 @@ import { Badge } from "@/components/ui/badge";
import { Users, ExternalLink, Download, Eye } from "lucide-react"; import { Users, ExternalLink, Download, Eye } from "lucide-react";
import { TeamMember } from "@/lib/admin-types"; import { TeamMember } from "@/lib/admin-types";
import {
COVERAGE_OBJECTIVES,
isCoverageBelowObjective,
} from "@/lib/evaluation-utils";
interface TeamDetailModalProps { interface TeamDetailModalProps {
isOpen: boolean; isOpen: boolean;
@@ -157,7 +161,10 @@ export function TeamDetailModal({
</span> </span>
<span <span
className={`text-sm font-bold ${ className={`text-sm font-bold ${
team.criticalSkillsCoverage.incontournable < 75 isCoverageBelowObjective(
team.criticalSkillsCoverage.incontournable,
"incontournable"
)
? "text-red-400" ? "text-red-400"
: "text-green-400" : "text-green-400"
}`} }`}
@@ -168,7 +175,10 @@ export function TeamDetailModal({
<div className="w-full bg-slate-700/50 rounded-full h-1.5"> <div className="w-full bg-slate-700/50 rounded-full h-1.5">
<div <div
className={`h-1.5 rounded-full transition-all ${ className={`h-1.5 rounded-full transition-all ${
team.criticalSkillsCoverage.incontournable < 75 isCoverageBelowObjective(
team.criticalSkillsCoverage.incontournable,
"incontournable"
)
? "bg-red-500" ? "bg-red-500"
: "bg-green-500" : "bg-green-500"
}`} }`}
@@ -182,7 +192,10 @@ export function TeamDetailModal({
<span className="text-sm text-slate-300">Majeures</span> <span className="text-sm text-slate-300">Majeures</span>
<span <span
className={`text-sm font-bold ${ className={`text-sm font-bold ${
team.criticalSkillsCoverage.majeure < 60 isCoverageBelowObjective(
team.criticalSkillsCoverage.majeure,
"majeure"
)
? "text-red-400" ? "text-red-400"
: "text-green-400" : "text-green-400"
}`} }`}
@@ -193,7 +206,10 @@ export function TeamDetailModal({
<div className="w-full bg-slate-700/50 rounded-full h-1.5"> <div className="w-full bg-slate-700/50 rounded-full h-1.5">
<div <div
className={`h-1.5 rounded-full transition-all ${ className={`h-1.5 rounded-full transition-all ${
team.criticalSkillsCoverage.majeure < 60 isCoverageBelowObjective(
team.criticalSkillsCoverage.majeure,
"majeure"
)
? "bg-red-500" ? "bg-red-500"
: "bg-green-500" : "bg-green-500"
}`} }`}
@@ -209,13 +225,11 @@ export function TeamDetailModal({
<h3 className="font-medium text-white mb-3">Top 3 Compétences</h3> <h3 className="font-medium text-white mb-3">Top 3 Compétences</h3>
<div className="space-y-2"> <div className="space-y-2">
{team.topSkills.slice(0, 3).map((skill, idx) => { {team.topSkills.slice(0, 3).map((skill, idx) => {
const target = const target = COVERAGE_OBJECTIVES[skill.importance];
skill.importance === "incontournable" const isUnderTarget = isCoverageBelowObjective(
? 75 skill.coverage,
: skill.importance === "majeure" skill.importance
? 60 );
: 0;
const isUnderTarget = target > 0 && skill.coverage < target;
return ( return (
<div <div

View File

@@ -1,6 +1,10 @@
"use client"; "use client";
import { TrendingUp, MessageSquare, Lightbulb } from "lucide-react"; import { TrendingUp, MessageSquare, Lightbulb } from "lucide-react";
import {
COVERAGE_OBJECTIVES,
isCoverageBelowObjective,
} from "@/lib/evaluation-utils";
interface SkillAnalysis { interface SkillAnalysis {
skillName: string; skillName: string;
@@ -64,9 +68,7 @@ export function TeamInsightsTab({
<div className="space-y-1"> <div className="space-y-1">
{/* Incontournables */} {/* Incontournables */}
{skillAnalysis {skillAnalysis
.filter( .filter((s) => isCoverageBelowObjective(s.coverage, s.importance))
(s) => s.importance === "incontournable" && s.coverage < 75
)
.map((skill, idx) => ( .map((skill, idx) => (
<div <div
key={idx} key={idx}
@@ -83,7 +85,7 @@ export function TeamInsightsTab({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="text-[10px] text-red-300 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="text-[10px] text-red-300 opacity-0 group-hover:opacity-100 transition-opacity">
Objectif: 75% Objectif: {COVERAGE_OBJECTIVES[skill.importance]}%
</div> </div>
<div className="text-xs text-red-400 font-medium"> <div className="text-xs text-red-400 font-medium">
{skill.coverage.toFixed(0)}% {skill.coverage.toFixed(0)}%
@@ -95,7 +97,7 @@ export function TeamInsightsTab({
{/* Majeures */} {/* Majeures */}
{skillAnalysis {skillAnalysis
.filter((s) => s.importance === "majeure" && s.coverage < 60) .filter((s) => isCoverageBelowObjective(s.coverage, s.importance))
.map((skill, idx) => ( .map((skill, idx) => (
<div <div
key={idx} key={idx}
@@ -112,7 +114,7 @@ export function TeamInsightsTab({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="text-[10px] text-blue-300 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="text-[10px] text-blue-300 opacity-0 group-hover:opacity-100 transition-opacity">
Objectif: 60% Objectif: {COVERAGE_OBJECTIVES[skill.importance]}%
</div> </div>
<div className="text-xs text-blue-400 font-medium"> <div className="text-xs text-blue-400 font-medium">
{skill.coverage.toFixed(0)}% {skill.coverage.toFixed(0)}%
@@ -217,7 +219,7 @@ export function TeamInsightsTab({
{teamInsights.skillGaps.incontournable > 1 ? "s" : ""}{" "} {teamInsights.skillGaps.incontournable > 1 ? "s" : ""}{" "}
incontournable incontournable
{teamInsights.skillGaps.incontournable > 1 ? "s" : ""} sous {teamInsights.skillGaps.incontournable > 1 ? "s" : ""} sous
l'objectif de 75%. l'objectif de {COVERAGE_OBJECTIVES.incontournable}%.
</> </>
) : ( ) : (
<> <>
@@ -239,7 +241,7 @@ export function TeamInsightsTab({
{teamInsights.skillGaps.majeure > 1 ? "s" : ""} majeure {teamInsights.skillGaps.majeure > 1 ? "s" : ""} majeure
{teamInsights.skillGaps.majeure > 1 ? "s" : ""} n'atteigne {teamInsights.skillGaps.majeure > 1 ? "s" : ""} n'atteigne
{teamInsights.skillGaps.majeure > 1 ? "nt" : ""} pas {teamInsights.skillGaps.majeure > 1 ? "nt" : ""} pas
l'objectif de 60%. l'objectif de {COVERAGE_OBJECTIVES.majeure}%.
</> </>
) : ( ) : (
<> <>

View File

@@ -1,6 +1,10 @@
"use client"; "use client";
import { Users, BarChart3, Award, BookOpen, Target } from "lucide-react"; import { Users, BarChart3, Award, BookOpen, Target } from "lucide-react";
import {
COVERAGE_OBJECTIVES,
isCoverageBelowObjective,
} from "@/lib/evaluation-utils";
interface TeamInsights { interface TeamInsights {
averageTeamLevel: number; averageTeamLevel: number;
@@ -81,7 +85,10 @@ export function TeamMetricsCards({
</div> </div>
<div <div
className={`text-2xl font-bold ${ className={`text-2xl font-bold ${
teamInsights.criticalSkillsCoverage.incontournable < 75 isCoverageBelowObjective(
teamInsights.criticalSkillsCoverage.incontournable,
"incontournable"
)
? "text-red-400" ? "text-red-400"
: "text-green-400" : "text-green-400"
}`} }`}
@@ -102,7 +109,10 @@ export function TeamMetricsCards({
</div> </div>
<div <div
className={`text-2xl font-bold ${ className={`text-2xl font-bold ${
teamInsights.criticalSkillsCoverage.majeure < 60 isCoverageBelowObjective(
teamInsights.criticalSkillsCoverage.majeure,
"majeure"
)
? "text-red-400" ? "text-red-400"
: "text-green-400" : "text-green-400"
}`} }`}

View File

@@ -3,11 +3,16 @@
import { BarChart3, Target, Star } from "lucide-react"; import { BarChart3, Target, Star } from "lucide-react";
import { TeamStats } from "@/lib/admin-types"; import { TeamStats } from "@/lib/admin-types";
import { TechIcon } from "@/components/icons/tech-icon"; import { TechIcon } from "@/components/icons/tech-icon";
import {
COVERAGE_OBJECTIVES,
isCoverageBelowObjective,
} from "@/lib/evaluation-utils";
interface SkillAnalysis { interface SkillAnalysis {
skillName: string; skillName: string;
category: string; category: string;
importance: "incontournable" | "majeure" | "standard"; importance: "incontournable" | "majeure" | "standard";
icon?: string;
experts: Array<{ experts: Array<{
name: string; name: string;
level: number; level: number;
@@ -125,15 +130,14 @@ export function TeamOverviewTab({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <div
className={`text-xs ${ className={`text-xs ${
skill.importance === "incontournable" skill.importance === "standard"
? skill.coverage < 75 ? "text-slate-400"
? "text-red-400" : isCoverageBelowObjective(
: "text-green-400" skill.coverage,
: skill.importance === "majeure" skill.importance
? skill.coverage < 60 )
? "text-red-400" ? "text-red-400"
: "text-green-400" : "text-green-400"
: "text-slate-400"
}`} }`}
> >
{skill.coverage.toFixed(0)}% {skill.coverage.toFixed(0)}%
@@ -231,7 +235,10 @@ export function TeamOverviewTab({
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span <span
className={`text-sm font-medium ${ className={`text-sm font-medium ${
skill.coverage < 75 isCoverageBelowObjective(
skill.coverage,
skill.importance
)
? "text-red-400" ? "text-red-400"
: "text-green-400" : "text-green-400"
}`} }`}
@@ -241,7 +248,12 @@ export function TeamOverviewTab({
<div className="w-16 bg-white/10 rounded-full h-1.5"> <div className="w-16 bg-white/10 rounded-full h-1.5">
<div <div
className={`h-1.5 rounded-full ${ className={`h-1.5 rounded-full ${
skill.coverage < 75 ? "bg-red-500" : "bg-green-500" isCoverageBelowObjective(
skill.coverage,
skill.importance
)
? "bg-red-500"
: "bg-green-500"
}`} }`}
style={{ width: `${skill.coverage}%` }} style={{ width: `${skill.coverage}%` }}
/> />
@@ -292,7 +304,10 @@ export function TeamOverviewTab({
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span <span
className={`text-sm font-medium ${ className={`text-sm font-medium ${
skill.coverage < 60 isCoverageBelowObjective(
skill.coverage,
skill.importance
)
? "text-red-400" ? "text-red-400"
: "text-green-400" : "text-green-400"
}`} }`}
@@ -302,7 +317,12 @@ export function TeamOverviewTab({
<div className="w-16 bg-white/10 rounded-full h-1.5"> <div className="w-16 bg-white/10 rounded-full h-1.5">
<div <div
className={`h-1.5 rounded-full ${ className={`h-1.5 rounded-full ${
skill.coverage < 60 ? "bg-red-500" : "bg-green-500" isCoverageBelowObjective(
skill.coverage,
skill.importance
)
? "bg-red-500"
: "bg-green-500"
}`} }`}
style={{ width: `${skill.coverage}%` }} style={{ width: `${skill.coverage}%` }}
/> />
@@ -404,7 +424,10 @@ export function TeamOverviewTab({
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span <span
className={`text-sm font-bold ${ className={`text-sm font-bold ${
teamInsights.criticalSkillsCoverage.incontournable < 75 isCoverageBelowObjective(
teamInsights.criticalSkillsCoverage.incontournable,
"incontournable"
)
? "text-red-400" ? "text-red-400"
: "text-green-400" : "text-green-400"
}`} }`}
@@ -417,7 +440,10 @@ export function TeamOverviewTab({
<div className="w-16 bg-white/10 rounded-full h-2"> <div className="w-16 bg-white/10 rounded-full h-2">
<div <div
className={`h-2 rounded-full ${ className={`h-2 rounded-full ${
teamInsights.criticalSkillsCoverage.incontournable < 75 isCoverageBelowObjective(
teamInsights.criticalSkillsCoverage.incontournable,
"incontournable"
)
? "bg-red-500" ? "bg-red-500"
: "bg-green-500" : "bg-green-500"
}`} }`}
@@ -437,7 +463,10 @@ export function TeamOverviewTab({
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span <span
className={`text-sm font-bold ${ className={`text-sm font-bold ${
teamInsights.criticalSkillsCoverage.majeure < 60 isCoverageBelowObjective(
teamInsights.criticalSkillsCoverage.majeure,
"majeure"
)
? "text-red-400" ? "text-red-400"
: "text-green-400" : "text-green-400"
}`} }`}
@@ -447,7 +476,10 @@ export function TeamOverviewTab({
<div className="w-16 bg-white/10 rounded-full h-2"> <div className="w-16 bg-white/10 rounded-full h-2">
<div <div
className={`h-2 rounded-full ${ className={`h-2 rounded-full ${
teamInsights.criticalSkillsCoverage.majeure < 60 isCoverageBelowObjective(
teamInsights.criticalSkillsCoverage.majeure,
"majeure"
)
? "bg-red-500" ? "bg-red-500"
: "bg-green-500" : "bg-green-500"
}`} }`}

View File

@@ -3,6 +3,10 @@
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import {
COVERAGE_OBJECTIVES,
isCoverageBelowObjective,
} from "@/lib/evaluation-utils";
interface SkillAnalysis { interface SkillAnalysis {
skillName: string; skillName: string;
@@ -124,13 +128,11 @@ export function TeamSkillsTab({ skillAnalysis }: TeamSkillsTabProps) {
</thead> </thead>
<tbody> <tbody>
{filteredSkills.map((skill, idx) => { {filteredSkills.map((skill, idx) => {
const target = const target = COVERAGE_OBJECTIVES[skill.importance];
skill.importance === "incontournable" const isUnderTarget = isCoverageBelowObjective(
? 75 skill.coverage,
: skill.importance === "majeure" skill.importance
? 60 );
: 0;
const isUnderTarget = target > 0 && skill.coverage < target;
return ( return (
<tr <tr

View File

@@ -12,6 +12,10 @@ import {
ChevronRight, ChevronRight,
} from "lucide-react"; } from "lucide-react";
import { TechIcon } from "@/components/icons/tech-icon"; import { TechIcon } from "@/components/icons/tech-icon";
import {
COVERAGE_OBJECTIVES,
isCoverageBelowObjective,
} from "@/lib/evaluation-utils";
interface TeamStatsCardProps { interface TeamStatsCardProps {
teamId: string; teamId: string;
@@ -24,6 +28,8 @@ interface TeamStatsCardProps {
averageLevel: number; averageLevel: number;
color?: string; color?: string;
icon?: string; icon?: string;
importance: "incontournable" | "majeure" | "standard";
coverage: number;
}>; }>;
skillCoverage: number; skillCoverage: number;
onViewDetails?: () => void; onViewDetails?: () => void;
@@ -52,10 +58,11 @@ export function getSkillLevelBadgeClasses(level: number): string {
return "bg-green-500/20 border-green-500/30 text-green-300"; return "bg-green-500/20 border-green-500/30 text-green-300";
} }
export function getProgressColor(percentage: number): string { export function getProgressColor(
if (percentage < 30) return "bg-red-500"; percentage: number,
if (percentage < 60) return "bg-orange-500"; importance: "incontournable" | "majeure" | "standard"
if (percentage < 80) return "bg-blue-500"; ): string {
if (isCoverageBelowObjective(percentage, importance)) return "bg-red-500";
return "bg-green-500"; return "bg-green-500";
} }

View File

@@ -24,6 +24,10 @@ import {
} from "lucide-react"; } from "lucide-react";
import { TechIcon } from "@/components/icons/tech-icon"; import { TechIcon } from "@/components/icons/tech-icon";
import { getImportanceColors } from "@/lib/tech-colors"; import { getImportanceColors } from "@/lib/tech-colors";
import {
COVERAGE_OBJECTIVES,
isCoverageBelowObjective,
} from "@/lib/evaluation-utils";
interface TeamStatsRowProps { interface TeamStatsRowProps {
teamId: string; teamId: string;
@@ -70,10 +74,11 @@ export function getSkillLevelBadgeClasses(level: number): string {
return "bg-green-500/20 border-green-500/30 text-green-300"; return "bg-green-500/20 border-green-500/30 text-green-300";
} }
export function getProgressColor(percentage: number): string { export function getProgressColor(
if (percentage < 30) return "bg-red-500"; percentage: number,
if (percentage < 60) return "bg-orange-500"; importance: "incontournable" | "majeure" | "standard"
if (percentage < 80) return "bg-blue-500"; ): string {
if (isCoverageBelowObjective(percentage, importance)) return "bg-red-500";
return "bg-green-500"; return "bg-green-500";
} }
@@ -90,8 +95,14 @@ export function TeamStatsRow({
onViewReport, onViewReport,
}: TeamStatsRowProps) { }: TeamStatsRowProps) {
// Calculer les alertes sur les compétences critiques // Calculer les alertes sur les compétences critiques
const hasIncontournableAlert = criticalSkillsCoverage.incontournable < 75; const hasIncontournableAlert = isCoverageBelowObjective(
const hasMajeureAlert = criticalSkillsCoverage.majeure < 60; criticalSkillsCoverage.incontournable,
"incontournable"
);
const hasMajeureAlert = isCoverageBelowObjective(
criticalSkillsCoverage.majeure,
"majeure"
);
return ( return (
<div className="group bg-gradient-to-r from-slate-800/50 to-slate-700/40 backdrop-blur-sm border border-slate-600/40 rounded-xl p-4 hover:from-slate-700/60 hover:to-slate-600/50 hover:border-slate-500/50 transition-all duration-300 shadow-lg hover:shadow-xl"> <div className="group bg-gradient-to-r from-slate-800/50 to-slate-700/40 backdrop-blur-sm border border-slate-600/40 rounded-xl p-4 hover:from-slate-700/60 hover:to-slate-600/50 hover:border-slate-500/50 transition-all duration-300 shadow-lg hover:shadow-xl">
@@ -144,7 +155,10 @@ export function TeamStatsRow({
<div className="text-center"> <div className="text-center">
<div <div
className={`text-sm font-bold ${ className={`text-sm font-bold ${
criticalSkillsCoverage.incontournable < 75 isCoverageBelowObjective(
criticalSkillsCoverage.incontournable,
"incontournable"
)
? "text-red-400" ? "text-red-400"
: "text-green-400" : "text-green-400"
}`} }`}
@@ -158,7 +172,7 @@ export function TeamStatsRow({
<p className="text-xs"> <p className="text-xs">
Couverture des compétences incontournables Couverture des compétences incontournables
<br /> <br />
Objectif : 75% Objectif : {COVERAGE_OBJECTIVES.incontournable}%
</p> </p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -170,7 +184,10 @@ export function TeamStatsRow({
<div className="text-center"> <div className="text-center">
<div <div
className={`text-sm font-bold ${ className={`text-sm font-bold ${
criticalSkillsCoverage.majeure < 60 isCoverageBelowObjective(
criticalSkillsCoverage.majeure,
"majeure"
)
? "text-red-400" ? "text-red-400"
: "text-green-400" : "text-green-400"
}`} }`}
@@ -184,7 +201,7 @@ export function TeamStatsRow({
<p className="text-xs"> <p className="text-xs">
Couverture des compétences majeures Couverture des compétences majeures
<br /> <br />
Objectif : 60% Objectif : {COVERAGE_OBJECTIVES.majeure}%
</p> </p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -222,13 +239,11 @@ export function TeamStatsRow({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{topSkills.slice(0, 3).map((skill, idx) => { {topSkills.slice(0, 3).map((skill, idx) => {
const colors = getImportanceColors(skill.importance); const colors = getImportanceColors(skill.importance);
const target = const target = COVERAGE_OBJECTIVES[skill.importance];
skill.importance === "incontournable" const isUnderTarget = isCoverageBelowObjective(
? 75 skill.coverage,
: skill.importance === "majeure" skill.importance
? 60 );
: 0;
const isUnderTarget = target > 0 && skill.coverage < target;
return ( return (
<TooltipProvider key={idx}> <TooltipProvider key={idx}>

View File

@@ -14,6 +14,10 @@ import { TeamMemberProfile, SkillGap } from "@/lib/team-review-types";
import { UserCheck, GraduationCap } from "lucide-react"; import { UserCheck, GraduationCap } from "lucide-react";
import { TechIcon } from "@/components/icons/tech-icon"; import { TechIcon } from "@/components/icons/tech-icon";
import { getImportanceColors } from "@/lib/tech-colors"; import { getImportanceColors } from "@/lib/tech-colors";
import {
isCoverageBelowObjective,
SKILL_LEVEL_VALUES,
} from "@/lib/evaluation-utils";
interface SkillMatrixProps { interface SkillMatrixProps {
members: TeamMemberProfile[]; members: TeamMemberProfile[];
@@ -26,6 +30,21 @@ export function SkillMatrix({ members, skillGaps }: SkillMatrixProps) {
(skill) => skill.skillId && skill.skillName && skill.category (skill) => skill.skillId && skill.skillName && skill.category
); );
// Fonction de tri par importance
const sortByImportance = (a: SkillGap, b: SkillGap) => {
const importanceOrder = {
incontournable: 2,
majeure: 1,
standard: 0,
};
const importanceDiff =
importanceOrder[b.importance] - importanceOrder[a.importance];
if (importanceDiff !== 0) return importanceDiff;
// Si même importance, trier par couverture (décroissant)
return (b.coverage || 0) - (a.coverage || 0);
};
const skillsByCategory = validSkillGaps.reduce((acc, skill) => { const skillsByCategory = validSkillGaps.reduce((acc, skill) => {
if (!acc[skill.category]) { if (!acc[skill.category]) {
acc[skill.category] = []; acc[skill.category] = [];
@@ -34,6 +53,11 @@ export function SkillMatrix({ members, skillGaps }: SkillMatrixProps) {
return acc; return acc;
}, {} as Record<string, SkillGap[]>); }, {} as Record<string, SkillGap[]>);
// Trier les compétences par importance dans chaque catégorie
Object.values(skillsByCategory).forEach((skills) => {
skills.sort(sortByImportance);
});
const getLevelBadge = (level: string | null) => { const getLevelBadge = (level: string | null) => {
const colors = { const colors = {
never: "bg-white/5 text-slate-300", never: "bg-white/5 text-slate-300",
@@ -176,10 +200,14 @@ export function SkillMatrix({ members, skillGaps }: SkillMatrixProps) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-full bg-white/10 rounded-full h-2"> <div className="w-full bg-white/10 rounded-full h-2">
<div <div
className={`h-2 rounded-full ${colors.bg.replace( className={`h-2 rounded-full ${
"/20", isCoverageBelowObjective(
"/50" skill.coverage || 0,
)}`} skill.importance
)
? "bg-red-500/50"
: colors.bg.replace("/20", "/50")
}`}
style={{ style={{
width: `${Math.max( width: `${Math.max(
0, 0,
@@ -189,7 +217,14 @@ export function SkillMatrix({ members, skillGaps }: SkillMatrixProps) {
/> />
</div> </div>
<span <span
className={`text-sm ${colors.text} whitespace-nowrap`} className={`text-sm whitespace-nowrap ${
isCoverageBelowObjective(
skill.coverage || 0,
skill.importance
)
? "text-red-400"
: colors.text
}`}
> >
{Math.round(skill.coverage || 0)}% {Math.round(skill.coverage || 0)}%
</span> </span>

View File

@@ -6,6 +6,10 @@ import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { AlertTriangle } from "lucide-react"; import { AlertTriangle } from "lucide-react";
import { getImportanceColors } from "@/lib/tech-colors"; import { getImportanceColors } from "@/lib/tech-colors";
import {
COVERAGE_OBJECTIVES,
isCoverageBelowObjective,
} from "@/lib/evaluation-utils";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -41,20 +45,20 @@ export function TeamOverview({
const categoriesNeedingAttention = [...categoryCoverage] const categoriesNeedingAttention = [...categoryCoverage]
.map((cat) => { .map((cat) => {
// Pour chaque catégorie, on identifie : // Pour chaque catégorie, on identifie :
// 1. Les compétences incontournables sous-couvertes (< 75%) // 1. Les compétences incontournables sous-couvertes
const uncoveredIncontournables = skillGaps.filter( const uncoveredIncontournables = skillGaps.filter(
(gap) => (gap) =>
gap.category === cat.category && gap.category === cat.category &&
gap.importance === "incontournable" && gap.importance === "incontournable" &&
gap.coverage < 75 isCoverageBelowObjective(gap.coverage, gap.importance)
); );
// 2. Les compétences majeures sous-couvertes (< 60%) // 2. Les compétences majeures sous-couvertes
const uncoveredMajeures = skillGaps.filter( const uncoveredMajeures = skillGaps.filter(
(gap) => (gap) =>
gap.category === cat.category && gap.category === cat.category &&
gap.importance === "majeure" && gap.importance === "majeure" &&
gap.coverage < 60 isCoverageBelowObjective(gap.coverage, gap.importance)
); );
// Une catégorie nécessite de l'attention si : // Une catégorie nécessite de l'attention si :
@@ -81,7 +85,7 @@ export function TeamOverview({
attentionScore, attentionScore,
}; };
}) })
.filter(Boolean) .filter((cat): cat is NonNullable<typeof cat> => cat !== null)
.sort((a, b) => b.attentionScore - a.attentionScore) .sort((a, b) => b.attentionScore - a.attentionScore)
.slice(0, 3); .slice(0, 3);
@@ -190,8 +194,7 @@ export function TeamOverview({
}) })
.map((skill) => { .map((skill) => {
const colors = getImportanceColors(skill.importance); const colors = getImportanceColors(skill.importance);
const target = const target = COVERAGE_OBJECTIVES[skill.importance];
skill.importance === "incontournable" ? 75 : 60;
return ( return (
<Tooltip key={skill.skillId}> <Tooltip key={skill.skillId}>
<TooltipTrigger> <TooltipTrigger>
@@ -203,7 +206,10 @@ export function TeamOverview({
</span> </span>
<span <span
className={ className={
skill.coverage < target isCoverageBelowObjective(
skill.coverage,
skill.importance
)
? "text-red-400" ? "text-red-400"
: "text-slate-400" : "text-slate-400"
} }
@@ -224,7 +230,10 @@ export function TeamOverview({
<br /> <br />
Actuel : {skill.coverage.toFixed(0)}% Actuel : {skill.coverage.toFixed(0)}%
<br /> <br />
{skill.coverage < target {isCoverageBelowObjective(
skill.coverage,
skill.importance
)
? `Manque ${( ? `Manque ${(
target - skill.coverage target - skill.coverage
).toFixed(0)}%` ).toFixed(0)}%`

View File

@@ -15,6 +15,10 @@ import {
Cell, Cell,
Legend, Legend,
} from "recharts"; } from "recharts";
import {
COVERAGE_OBJECTIVES,
isCoverageBelowObjective,
} from "@/lib/evaluation-utils";
import { import {
Tooltip as UITooltip, Tooltip as UITooltip,
TooltipContent, TooltipContent,
@@ -82,9 +86,11 @@ export function TeamStats({
// Gaps critiques par catégorie, séparés par importance // Gaps critiques par catégorie, séparés par importance
const criticalGapsByCategory = skillGaps.reduce((acc, gap) => { const criticalGapsByCategory = skillGaps.reduce((acc, gap) => {
const isIncontournableUndercovered = const isIncontournableUndercovered =
gap.importance === "incontournable" && gap.coverage < 75; gap.importance === "incontournable" &&
isCoverageBelowObjective(gap.coverage, gap.importance);
const isMajeureUndercovered = const isMajeureUndercovered =
gap.importance === "majeure" && gap.coverage < 60; gap.importance === "majeure" &&
isCoverageBelowObjective(gap.coverage, gap.importance);
if (isIncontournableUndercovered || isMajeureUndercovered) { if (isIncontournableUndercovered || isMajeureUndercovered) {
if (!acc[gap.category]) { if (!acc[gap.category]) {
@@ -161,8 +167,13 @@ export function TeamStats({
<div className="text-xs space-y-1"> <div className="text-xs space-y-1">
<p>Compétences critiques sous-couvertes :</p> <p>Compétences critiques sous-couvertes :</p>
<ul className="list-disc list-inside space-y-0.5"> <ul className="list-disc list-inside space-y-0.5">
<li>Incontournables : couverture &lt; 75%</li> <li>
<li>Majeures : couverture &lt; 60%</li> Incontournables : couverture &lt;{" "}
{COVERAGE_OBJECTIVES.incontournable}%
</li>
<li>
Majeures : couverture &lt; {COVERAGE_OBJECTIVES.majeure}%
</li>
</ul> </ul>
</div> </div>
</TooltipContent> </TooltipContent>
@@ -205,7 +216,10 @@ export function TeamStats({
<TooltipContent className="bg-slate-900 text-slate-200 border border-slate-700"> <TooltipContent className="bg-slate-900 text-slate-200 border border-slate-700">
<div className="text-xs"> <div className="text-xs">
<p>Compétences incontournables</p> <p>Compétences incontournables</p>
<p className="text-slate-400">Objectif : 75% de couverture</p> <p className="text-slate-400">
Objectif : {COVERAGE_OBJECTIVES.incontournable}% de
couverture
</p>
</div> </div>
</TooltipContent> </TooltipContent>
</UITooltip> </UITooltip>
@@ -343,16 +357,16 @@ export function TeamStats({
formatter={(value, name) => { formatter={(value, name) => {
const label = const label =
name === "incontournable" name === "incontournable"
? "Incontournables (obj. 75%)" ? `Incontournables (obj. ${COVERAGE_OBJECTIVES.incontournable}%)`
: "Majeures (obj. 60%)"; : `Majeures (obj. ${COVERAGE_OBJECTIVES.majeure}%)`;
return [value, label]; return [value, label];
}} }}
/> />
<Legend <Legend
formatter={(value) => { formatter={(value) => {
return value === "incontournable" return value === "incontournable"
? "Incontournables (obj. 75%)" ? `Incontournables (obj. ${COVERAGE_OBJECTIVES.incontournable}%)`
: "Majeures (obj. 60%)"; : `Majeures (obj. ${COVERAGE_OBJECTIVES.majeure}%)`;
}} }}
wrapperStyle={{ wrapperStyle={{
paddingTop: "20px", paddingTop: "20px",

View File

@@ -3,6 +3,7 @@ import {
CategoryEvaluation, CategoryEvaluation,
RadarChartData, RadarChartData,
SkillCategory, SkillCategory,
SkillImportance,
} from "./types"; } from "./types";
export function calculateCategoryScore( export function calculateCategoryScore(
@@ -51,3 +52,47 @@ export function createEmptyEvaluation(
selectedSkillIds: [], selectedSkillIds: [],
})); }));
} }
export const COVERAGE_OBJECTIVES: Record<SkillImportance, number> = {
incontournable: 75,
majeure: 60,
standard: 0,
};
export function isCoverageBelowObjective(
coverage: number,
importance: SkillImportance
): boolean {
const objective = COVERAGE_OBJECTIVES[importance];
return objective > 0 && coverage < objective;
}
export function getCoverageObjective(importance: SkillImportance): number {
return COVERAGE_OBJECTIVES[importance];
}
export function calculateSkillCoverage(
levels: number[],
totalMembers: number
): number {
if (levels.length === 0 || totalMembers === 0) return 0;
// Compter le nombre de membres autonomes ou experts (niveau >= 2)
const expertCount = levels.filter((level) => level >= 2).length;
// La couverture est le pourcentage de membres autonomes ou experts
return (expertCount / totalMembers) * 100;
}
export function generateSkillCoverageSQL(levelField: string): string {
return `
COALESCE(
(COUNT(*) FILTER (WHERE ${levelField} >= 2) * 100.0) / NULLIF(COUNT(*), 0),
0
)
`;
}
export function isExpertLevel(level: number): boolean {
return level >= 2;
}

View File

@@ -19,13 +19,15 @@ export const SKILL_LEVEL_VALUES: Record<Exclude<SkillLevel, null>, number> = {
expert: 3, expert: 3,
}; };
export type SkillImportance = "incontournable" | "majeure" | "standard";
export interface Skill { export interface Skill {
id: string; id: string;
name: string; name: string;
description: string; description: string;
icon?: string; icon?: string;
links: string[]; links: string[];
importance?: string; importance: SkillImportance;
} }
export interface SkillCategory { export interface SkillCategory {

View File

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

View File

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