feat: review admin overview and popup details fwith importance

This commit is contained in:
Julien Froidefond
2025-08-27 13:16:39 +02:00
parent 94a18b0ca5
commit e9aecca2a5
5 changed files with 415 additions and 82 deletions

View File

@@ -15,8 +15,17 @@ interface DirectionOverviewProps {
direction: string; direction: string;
totalMembers: number; totalMembers: number;
averageSkillLevel: number; averageSkillLevel: number;
topSkills: Array<{ skillName: string; averageLevel: number }>; topSkills: Array<{
skillName: string;
averageLevel: number;
importance: "incontournable" | "majeure" | "standard";
coverage: number;
}>;
skillCoverage: number; skillCoverage: number;
criticalSkillsCoverage: {
incontournable: number;
majeure: number;
};
}>; }>;
totalMembers: number; totalMembers: number;
averageSkillLevel: number; averageSkillLevel: number;
@@ -81,6 +90,18 @@ export function DirectionOverview({
}: DirectionOverviewProps) { }: DirectionOverviewProps) {
const colors = getDirectionColors(direction); const colors = getDirectionColors(direction);
// Calculer la moyenne des couvertures des compétences critiques
const averageCriticalCoverage = teams.reduce(
(acc, team) => {
acc.incontournable += team.criticalSkillsCoverage.incontournable;
acc.majeure += team.criticalSkillsCoverage.majeure;
return acc;
},
{ incontournable: 0, majeure: 0 }
);
averageCriticalCoverage.incontournable /= teams.length || 1;
averageCriticalCoverage.majeure /= teams.length || 1;
return ( return (
<div className="bg-gradient-to-br from-slate-800/40 to-slate-700/30 backdrop-blur-sm border border-slate-600/30 rounded-2xl overflow-hidden shadow-xl"> <div className="bg-gradient-to-br from-slate-800/40 to-slate-700/30 backdrop-blur-sm border border-slate-600/30 rounded-2xl overflow-hidden shadow-xl">
<div <div
@@ -153,24 +174,76 @@ export function DirectionOverview({
</h4> </h4>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{/* Compétences incontournables */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-sm text-slate-300"> <span className="text-sm text-slate-300">
Maîtrise globale: Incontournables:
</span> </span>
<span className="text-lg font-bold text-white"> <span
{((averageSkillLevel / 3) * 100).toFixed(0)}% className={`text-sm font-bold ${
averageCriticalCoverage.incontournable < 75
? "text-red-400"
: "text-green-400"
}`}
>
{averageCriticalCoverage.incontournable.toFixed(0)}%
</span> </span>
</div> </div>
<div className="w-full bg-slate-700/50 rounded-full h-2"> <div className="w-full bg-slate-700/50 rounded-full h-1.5">
<div <div
className="bg-gradient-to-r from-blue-500 to-blue-400 h-2 rounded-full transition-all shadow-sm" className={`h-1.5 rounded-full transition-all shadow-sm ${
style={{ width: `${(averageSkillLevel / 3) * 100}%` }} averageCriticalCoverage.incontournable < 75
? "bg-gradient-to-r from-red-500 to-red-400"
: "bg-gradient-to-r from-green-500 to-green-400"
}`}
style={{
width: `${averageCriticalCoverage.incontournable}%`,
}}
/> />
</div> </div>
</div> </div>
<div className="pt-2 text-xs text-slate-400">
Basé sur {teams.length} équipes {/* Compétences majeures */}
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-slate-300">Majeures:</span>
<span
className={`text-sm font-bold ${
averageCriticalCoverage.majeure < 60
? "text-orange-400"
: "text-green-400"
}`}
>
{averageCriticalCoverage.majeure.toFixed(0)}%
</span>
</div>
<div className="w-full bg-slate-700/50 rounded-full h-1.5">
<div
className={`h-1.5 rounded-full transition-all shadow-sm ${
averageCriticalCoverage.majeure < 60
? "bg-gradient-to-r from-orange-500 to-orange-400"
: "bg-gradient-to-r from-green-500 to-green-400"
}`}
style={{ width: `${averageCriticalCoverage.majeure}%` }}
/>
</div>
</div>
{/* Niveau global */}
<div className="space-y-2 pt-2 border-t border-slate-600/30">
<div className="flex justify-between">
<span className="text-sm text-slate-300">Niveau global:</span>
<span className="text-sm font-bold text-white">
{((averageSkillLevel / 3) * 100).toFixed(0)}%
</span>
</div>
<div className="w-full bg-slate-700/50 rounded-full h-1.5">
<div
className="bg-gradient-to-r from-blue-500 to-blue-400 h-1.5 rounded-full transition-all shadow-sm"
style={{ width: `${(averageSkillLevel / 3) * 100}%` }}
/>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -229,6 +302,7 @@ export function DirectionOverview({
averageSkillLevel={team.averageSkillLevel} averageSkillLevel={team.averageSkillLevel}
topSkills={team.topSkills} topSkills={team.topSkills}
skillCoverage={team.skillCoverage} skillCoverage={team.skillCoverage}
criticalSkillsCoverage={team.criticalSkillsCoverage}
onViewDetails={() => onViewTeamDetails(team)} onViewDetails={() => onViewTeamDetails(team)}
onViewReport={() => onExportTeamReport(team)} onViewReport={() => onExportTeamReport(team)}
/> />

View File

@@ -28,8 +28,14 @@ interface TeamDetailModalProps {
skillName: string; skillName: string;
averageLevel: number; averageLevel: number;
icon?: string; icon?: string;
importance: "incontournable" | "majeure" | "standard";
coverage: number;
}>; }>;
skillCoverage: number; skillCoverage: number;
criticalSkillsCoverage: {
incontournable: number;
majeure: number;
};
members: TeamMember[]; members: TeamMember[];
} | null; } | null;
} }
@@ -113,27 +119,88 @@ export function TeamDetailModal({
<div className="space-y-6"> <div className="space-y-6">
{/* Stats générales */} {/* Stats générales */}
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="bg-white/5 border border-white/10 rounded-xl p-4 text-center"> <div className="bg-white/5 border border-white/10 rounded-xl p-4">
<Users className="h-6 w-6 text-green-400 mx-auto mb-2" /> <h3 className="text-sm font-medium text-slate-300 mb-3">
<div className="text-2xl font-bold text-white"> Équipe
</h3>
<div className="flex items-center gap-4">
<div className="flex-1 flex items-center gap-3">
<Users className="h-5 w-5 text-green-400" />
<div>
<div className="text-lg font-bold text-white">
{team.totalMembers} {team.totalMembers}
</div> </div>
<div className="text-xs text-slate-400">Membres</div> <div className="text-xs text-slate-400">Membres</div>
</div> </div>
<div className="bg-white/5 border border-white/10 rounded-xl p-4 text-center"> </div>
<Eye className="h-6 w-6 text-blue-400 mx-auto mb-2" /> <div className="flex-1 flex items-center gap-3">
<div className="text-2xl font-bold text-white"> <Eye className="h-5 w-5 text-blue-400" />
<div>
<div className="text-lg font-bold text-white">
{((team.averageSkillLevel / 3) * 100).toFixed(0)}% {((team.averageSkillLevel / 3) * 100).toFixed(0)}%
</div> </div>
<div className="text-xs text-slate-400">Niveau moyen</div> <div className="text-xs text-slate-400">Niveau moyen</div>
</div> </div>
<div className="bg-white/5 border border-white/10 rounded-xl p-4 text-center">
<ExternalLink className="h-6 w-6 text-orange-400 mx-auto mb-2" />
<div className="text-2xl font-bold text-white">
{team.skillCoverage.toFixed(0)}%
</div> </div>
<div className="text-xs text-slate-400">Couverture</div> </div>
</div>
<div className="bg-white/5 border border-white/10 rounded-xl p-4">
<h3 className="text-sm font-medium text-slate-300 mb-3">
Couverture
</h3>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-slate-300">
Incontournables
</span>
<span
className={`text-sm font-bold ${
team.criticalSkillsCoverage.incontournable < 75
? "text-red-400"
: "text-green-400"
}`}
>
{team.criticalSkillsCoverage.incontournable.toFixed(0)}%
</span>
</div>
<div className="w-full bg-slate-700/50 rounded-full h-1.5">
<div
className={`h-1.5 rounded-full transition-all ${
team.criticalSkillsCoverage.incontournable < 75
? "bg-red-500"
: "bg-green-500"
}`}
style={{
width: `${team.criticalSkillsCoverage.incontournable}%`,
}}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-slate-300">Majeures</span>
<span
className={`text-sm font-bold ${
team.criticalSkillsCoverage.majeure < 60
? "text-red-400"
: "text-green-400"
}`}
>
{team.criticalSkillsCoverage.majeure.toFixed(0)}%
</span>
</div>
<div className="w-full bg-slate-700/50 rounded-full h-1.5">
<div
className={`h-1.5 rounded-full transition-all ${
team.criticalSkillsCoverage.majeure < 60
? "bg-red-500"
: "bg-green-500"
}`}
style={{ width: `${team.criticalSkillsCoverage.majeure}%` }}
/>
</div>
</div>
</div> </div>
</div> </div>
@@ -141,16 +208,56 @@ export function TeamDetailModal({
<div className="bg-white/5 border border-white/10 rounded-xl p-4"> <div className="bg-white/5 border border-white/10 rounded-xl p-4">
<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 =
skill.importance === "incontournable"
? 75
: skill.importance === "majeure"
? 60
: 0;
const isUnderTarget = target > 0 && skill.coverage < target;
return (
<div <div
key={idx} key={idx}
className="flex items-center justify-between p-2 bg-white/5 rounded-lg" className="flex items-center justify-between p-2 bg-white/5 rounded-lg"
> >
<span className="text-white text-sm">{skill.skillName}</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-slate-400"> <span className="text-white text-sm">
{((skill.averageLevel / 3) * 100).toFixed(0)}% {skill.skillName}
</span> </span>
<Badge
variant="outline"
className={
skill.importance === "incontournable"
? "border-red-500/30 text-red-400 text-[10px]"
: skill.importance === "majeure"
? "border-blue-500/30 text-blue-400 text-[10px]"
: "border-slate-500/30 text-slate-400 text-[10px]"
}
>
{skill.importance === "incontournable"
? "Incontournable"
: skill.importance === "majeure"
? "Majeure"
: "Standard"}
</Badge>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<span
className={`text-xs ${
isUnderTarget ? "text-red-400" : "text-green-400"
}`}
>
{skill.coverage.toFixed(0)}%
</span>
{target > 0 && (
<span className="text-[10px] text-slate-500">
(obj. {target}%)
</span>
)}
</div>
<div <div
className={`w-2 h-2 rounded-full ${getSkillLevelColor( className={`w-2 h-2 rounded-full ${getSkillLevelColor(
skill.averageLevel skill.averageLevel
@@ -158,7 +265,8 @@ export function TeamDetailModal({
/> />
</div> </div>
</div> </div>
))} );
})}
</div> </div>
</div> </div>

View File

@@ -20,8 +20,10 @@ import {
User, User,
Zap, Zap,
Crown, Crown,
AlertTriangle,
} 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";
interface TeamStatsRowProps { interface TeamStatsRowProps {
teamId: string; teamId: string;
@@ -34,8 +36,14 @@ interface TeamStatsRowProps {
averageLevel: number; averageLevel: number;
color?: string; color?: string;
icon?: string; icon?: string;
importance: "incontournable" | "majeure" | "standard";
coverage: number;
}>; }>;
skillCoverage: number; skillCoverage: number;
criticalSkillsCoverage: {
incontournable: number;
majeure: number;
};
onViewDetails?: () => void; onViewDetails?: () => void;
onViewReport?: () => void; onViewReport?: () => void;
} }
@@ -77,9 +85,14 @@ export function TeamStatsRow({
averageSkillLevel, averageSkillLevel,
topSkills, topSkills,
skillCoverage, skillCoverage,
criticalSkillsCoverage,
onViewDetails, onViewDetails,
onViewReport, onViewReport,
}: TeamStatsRowProps) { }: TeamStatsRowProps) {
// Calculer les alertes sur les compétences critiques
const hasIncontournableAlert = criticalSkillsCoverage.incontournable < 75;
const hasMajeureAlert = criticalSkillsCoverage.majeure < 60;
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">
{/* Layout horizontal compact */} {/* Layout horizontal compact */}
@@ -125,19 +138,57 @@ export function TeamStatsRow({
{/* Indicateurs clés compacts */} {/* Indicateurs clés compacts */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-center"> <div className="text-center">
<div className="text-sm font-bold text-white"> <div
{averageSkillLevel.toFixed(1)} className={`text-sm font-bold ${
criticalSkillsCoverage.incontournable < 75
? "text-red-400"
: "text-green-400"
}`}
>
{criticalSkillsCoverage.incontournable.toFixed(0)}%
</div> </div>
<div className="text-xs text-slate-300">/ 3.0</div> <div className="text-xs text-slate-300">Incont.</div>
</div> </div>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
Couverture des compétences incontournables
<br />
Objectif : 75%
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-center"> <div className="text-center">
<div className="text-sm font-bold text-white"> <div
{skillCoverage.toFixed(0)}% className={`text-sm font-bold ${
criticalSkillsCoverage.majeure < 60
? "text-red-400"
: "text-green-400"
}`}
>
{criticalSkillsCoverage.majeure.toFixed(0)}%
</div> </div>
<div className="text-xs text-slate-300">Couv.</div> <div className="text-xs text-slate-300">Maj.</div>
</div> </div>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">
Couverture des compétences majeures
<br />
Objectif : 60%
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
@@ -169,21 +220,68 @@ export function TeamStatsRow({
{/* Top skills mini */} {/* Top skills mini */}
<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 target =
skill.importance === "incontournable"
? 75
: skill.importance === "majeure"
? 60
: 0;
const isUnderTarget = target > 0 && skill.coverage < target;
return (
<TooltipProvider key={idx}>
<Tooltip>
<TooltipTrigger asChild>
<div <div
key={idx} className={`relative px-2 py-1 border rounded-md text-center min-w-[60px] shadow-sm ${
className={`px-2 py-1 border rounded-md text-center min-w-[60px] shadow-sm ${getSkillLevelBadgeClasses( isUnderTarget
skill.averageLevel ? "bg-red-500/20 border-red-500/30"
)}`} : "bg-green-500/20 border-green-500/30"
}`}
>
<div
className={`text-xs font-medium truncate mb-1 ${
isUnderTarget ? "text-red-400" : "text-green-400"
}`}
> >
<div className="text-xs font-medium text-white truncate mb-1">
{skill.skillName} {skill.skillName}
</div> </div>
<div className="text-xs font-bold"> <div
{((skill.averageLevel / 3) * 100).toFixed(0)}% className={`text-xs font-bold ${
isUnderTarget ? "text-red-400" : "text-green-400"
}`}
>
{skill.coverage.toFixed(0)}%
</div> </div>
</div> </div>
))} </TooltipTrigger>
<TooltipContent>
<div className="text-xs">
<p className="font-medium">{skill.skillName}</p>
<p className="text-slate-400">
{skill.importance === "incontournable"
? "Compétence incontournable"
: skill.importance === "majeure"
? "Compétence majeure"
: "Compétence standard"}
</p>
{target > 0 && (
<p
className={
isUnderTarget ? "text-red-400" : "text-green-400"
}
>
Objectif : {target}%
</p>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
})}
</div> </div>
{/* Actions compactes */} {/* Actions compactes */}

View File

@@ -21,8 +21,18 @@ export interface TeamStats {
direction: string; direction: string;
totalMembers: number; totalMembers: number;
averageSkillLevel: number; averageSkillLevel: number;
topSkills: Array<{ skillName: string; averageLevel: number; icon?: string }>; topSkills: Array<{
skillName: string;
averageLevel: number;
icon?: string;
importance: "incontournable" | "majeure" | "standard";
coverage: number;
}>;
skillCoverage: number; // Percentage of skills evaluated skillCoverage: number; // Percentage of skills evaluated
criticalSkillsCoverage: {
incontournable: number;
majeure: number;
};
members: TeamMember[]; members: TeamMember[];
} }

View File

@@ -31,6 +31,7 @@ export class AdminService {
tm.uuid_id, tm.uuid_id,
s.id as skill_id, s.id as skill_id,
s.name as skill_name, s.name as skill_name,
s.importance,
sc.name as category_name, sc.name as category_name,
CASE CASE
WHEN se.level = 'never' THEN 0 WHEN se.level = 'never' THEN 0
@@ -53,11 +54,34 @@ export class AdminService {
ss.team_id, ss.team_id,
ss.skill_name, ss.skill_name,
s.icon as skill_icon, s.icon as skill_icon,
AVG(ss.level_numeric) as avg_level 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 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
GROUP BY ss.team_id, ss.skill_name, s.icon 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 SELECT
tm.team_id, tm.team_id,
@@ -92,13 +116,26 @@ export class AdminService {
jsonb_build_object( jsonb_build_object(
'skillName', tsa.skill_name, 'skillName', tsa.skill_name,
'averageLevel', tsa.avg_level, 'averageLevel', tsa.avg_level,
'icon', tsa.skill_icon 'icon', tsa.skill_icon,
) ORDER BY tsa.avg_level DESC '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 FROM team_skill_averages tsa
WHERE tsa.team_id = tm.team_id WHERE tsa.team_id = tm.team_id
LIMIT 3 LIMIT 3
) as top_skills, ) as top_skills,
jsonb_build_object(
'incontournable', COALESCE(csc.incontournable_coverage, 0),
'majeure', COALESCE(csc.majeure_coverage, 0)
) as critical_skills_coverage,
CASE CASE
WHEN COUNT(DISTINCT ss.skill_id) > 0 THEN WHEN COUNT(DISTINCT ss.skill_id) > 0 THEN
(COUNT(DISTINCT ss.skill_id) * 100.0 / (SELECT COUNT(*) FROM skills)) (COUNT(DISTINCT ss.skill_id) * 100.0 / (SELECT COUNT(*) FROM skills))
@@ -106,7 +143,8 @@ export class AdminService {
END as skill_coverage END as skill_coverage
FROM team_members tm 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 skill_stats ss ON tm.team_id = ss.team_id AND tm.uuid_id = ss.uuid_id
GROUP BY tm.team_id, tm.team_name, tm.direction 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 ORDER BY tm.direction, tm.team_name
`; `;
@@ -120,6 +158,10 @@ export class AdminService {
averageSkillLevel: parseFloat(row.avg_skill_level) || 0, averageSkillLevel: parseFloat(row.avg_skill_level) || 0,
topSkills: row.top_skills || [], topSkills: row.top_skills || [],
skillCoverage: parseFloat(row.skill_coverage) || 0, skillCoverage: parseFloat(row.skill_coverage) || 0,
criticalSkillsCoverage: row.critical_skills_coverage || {
incontournable: 0,
majeure: 0,
},
members: (row.members || []).filter( members: (row.members || []).filter(
(member: any) => member.uuid !== null (member: any) => member.uuid !== null
), ),
@@ -238,14 +280,15 @@ export class AdminService {
); );
if (parseInt(skillsCheck.rows[0].count) > 0) { if (parseInt(skillsCheck.rows[0].count) > 0) {
throw new Error("Impossible de supprimer une catégorie qui contient des skills"); throw new Error(
"Impossible de supprimer une catégorie qui contient des skills"
);
} }
// Supprimer la catégorie // Supprimer la catégorie
await client.query( await client.query("DELETE FROM skill_categories WHERE id = $1", [
"DELETE FROM skill_categories WHERE id = $1", categoryId,
[categoryId] ]);
);
await client.query("COMMIT"); await client.query("COMMIT");
} catch (error) { } catch (error) {