feat: my team page
This commit is contained in:
21
app/api/teams/review/route.ts
Normal file
21
app/api/teams/review/route.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { TeamReviewService } from "@/services/team-review-service";
|
||||||
|
import { AuthService } from "@/services/auth-service";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// Vérifier l'authentification
|
||||||
|
const { userProfile } = await AuthService.requireAuthenticatedUser();
|
||||||
|
|
||||||
|
const teamId = userProfile.teamId;
|
||||||
|
const data = await TeamReviewService.getTeamReviewData(teamId);
|
||||||
|
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in team review API:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
100
app/team/page.tsx
Normal file
100
app/team/page.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { TeamReviewService } from "@/services/team-review-service";
|
||||||
|
import { AuthService } from "@/services/auth-service";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { TeamOverview } from "@/components/team-review/team-overview";
|
||||||
|
import { SkillMatrix } from "@/components/team-review/skill-matrix";
|
||||||
|
import { TeamInsights } from "@/components/team-review/team-insights";
|
||||||
|
import { MentorshipSection } from "@/components/team-review/mentorship-section";
|
||||||
|
import { LearningSection } from "@/components/team-review/learning-section";
|
||||||
|
import { TeamStats } from "@/components/team-review/team-stats";
|
||||||
|
import { Users } from "lucide-react";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
async function TeamReviewPage() {
|
||||||
|
try {
|
||||||
|
const { userProfile } = await AuthService.requireAuthenticatedUser();
|
||||||
|
|
||||||
|
const teamData = await TeamReviewService.getTeamReviewData(
|
||||||
|
userProfile.teamId
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 relative overflow-hidden">
|
||||||
|
{/* Background Effects */}
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-blue-900/20 via-slate-900 to-slate-950" />
|
||||||
|
<div className="absolute inset-0 bg-grid-white/5 bg-[size:50px_50px]" />
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-transparent to-transparent" />
|
||||||
|
|
||||||
|
<div className="relative z-10 container mx-auto px-6 py-8 max-w-7xl space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center space-y-4 mb-12">
|
||||||
|
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/5 border border-white/10 backdrop-blur-sm">
|
||||||
|
<Users className="h-4 w-4 text-blue-400" />
|
||||||
|
<span className="text-sm font-medium text-slate-200">
|
||||||
|
Vue d'équipe
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl font-bold text-white">
|
||||||
|
{teamData.team.name}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-slate-400 max-w-2xl mx-auto leading-relaxed">
|
||||||
|
Vue d'ensemble et analyse des compétences de l'équipe{" "}
|
||||||
|
{teamData.team.direction}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="space-y-8">
|
||||||
|
<TeamOverview
|
||||||
|
team={teamData.team}
|
||||||
|
stats={teamData.stats}
|
||||||
|
members={teamData.members}
|
||||||
|
categoryCoverage={teamData.categoryCoverage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
<TeamStats
|
||||||
|
stats={teamData.stats}
|
||||||
|
members={teamData.members}
|
||||||
|
skillGaps={teamData.skillGaps}
|
||||||
|
categoryCoverage={teamData.categoryCoverage}
|
||||||
|
/>
|
||||||
|
<TeamInsights
|
||||||
|
recommendations={teamData.recommendations}
|
||||||
|
categoryCoverage={teamData.categoryCoverage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SkillMatrix
|
||||||
|
members={teamData.members}
|
||||||
|
skillGaps={teamData.skillGaps}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
<MentorshipSection
|
||||||
|
members={teamData.members}
|
||||||
|
skillGaps={teamData.skillGaps}
|
||||||
|
/>
|
||||||
|
<LearningSection
|
||||||
|
members={teamData.members}
|
||||||
|
skillGaps={teamData.skillGaps}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.status === 401) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <TeamReviewPage />;
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
Building2,
|
Building2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -63,6 +64,11 @@ export function Navigation({ userInfo }: NavigationProps = {}) {
|
|||||||
label: "Évaluation",
|
label: "Évaluation",
|
||||||
icon: User,
|
icon: User,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: "/team",
|
||||||
|
label: "Mon équipe",
|
||||||
|
icon: Users,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: "/admin",
|
href: "/admin",
|
||||||
label: "Administration",
|
label: "Administration",
|
||||||
|
|||||||
152
components/team-review/learning-section.tsx
Normal file
152
components/team-review/learning-section.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { TeamMemberProfile, SkillGap } from "@/lib/team-review-types";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { GraduationCap } from "lucide-react";
|
||||||
|
|
||||||
|
interface LearningSectionProps {
|
||||||
|
members: TeamMemberProfile[];
|
||||||
|
skillGaps: SkillGap[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LearningSection({ members, skillGaps }: LearningSectionProps) {
|
||||||
|
// Trouver les apprenants
|
||||||
|
const learners = members.filter((member) => member.learningSkills > 0);
|
||||||
|
|
||||||
|
// Organiser les besoins d'apprentissage par apprenant
|
||||||
|
const learningNeeds = learners.map((learner) => {
|
||||||
|
const needs = learner.skills
|
||||||
|
.filter((skill) => skill.wantsToLearn)
|
||||||
|
.map((skill) => {
|
||||||
|
const gap = skillGaps.find((g) => g.skillId === skill.skillId);
|
||||||
|
if (!gap) return null;
|
||||||
|
|
||||||
|
const mentors = members.filter((member) =>
|
||||||
|
member.skills.some(
|
||||||
|
(s) =>
|
||||||
|
s.skillId === skill.skillId &&
|
||||||
|
s.canMentor &&
|
||||||
|
["autonomous", "expert"].includes(s.level)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
skill: skill.skillName,
|
||||||
|
category: skill.category,
|
||||||
|
currentLevel: skill.level,
|
||||||
|
mentors,
|
||||||
|
isHighRisk: gap.risk === "high",
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return {
|
||||||
|
learner,
|
||||||
|
needs,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const getLevelColor = (level: string) => {
|
||||||
|
const colors = {
|
||||||
|
never: "bg-white/5 text-slate-300 border-white/20",
|
||||||
|
"not-autonomous": "bg-yellow-500/20 text-yellow-200 border-yellow-500/30",
|
||||||
|
autonomous: "bg-green-500/20 text-green-200 border-green-500/30",
|
||||||
|
expert: "bg-blue-500/20 text-blue-200 border-blue-500/30",
|
||||||
|
};
|
||||||
|
return colors[level as keyof typeof colors];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white/5 border-white/10 backdrop-blur">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-slate-200">Besoins en formation</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ScrollArea className="h-[400px]">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{learningNeeds.map(({ learner, needs }) => (
|
||||||
|
<div key={learner.member.uuid} className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar className="bg-white/10 border border-white/20">
|
||||||
|
<AvatarFallback className="text-slate-200 bg-transparent">
|
||||||
|
{learner.member.firstName[0]}
|
||||||
|
{learner.member.lastName[0]}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-slate-200">
|
||||||
|
{learner.member.firstName} {learner.member.lastName}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
{learner.learningSkills} compétences à développer
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pl-12 space-y-3">
|
||||||
|
{needs.map(
|
||||||
|
(need, idx) =>
|
||||||
|
need && (
|
||||||
|
<div key={idx} className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="bg-white/5 text-slate-300 border-white/20"
|
||||||
|
>
|
||||||
|
{need.category}
|
||||||
|
</Badge>
|
||||||
|
<span className="font-medium text-slate-300">
|
||||||
|
{need.skill}
|
||||||
|
</span>
|
||||||
|
{need.isHighRisk && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="bg-red-500/20 text-red-200 border-red-500/30"
|
||||||
|
>
|
||||||
|
Prioritaire
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-slate-400">
|
||||||
|
Niveau actuel :
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={getLevelColor(need.currentLevel)}
|
||||||
|
>
|
||||||
|
{need.currentLevel}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{need.mentors.length > 0 && (
|
||||||
|
<div className="pl-4">
|
||||||
|
<p className="text-sm text-slate-400 mb-2">
|
||||||
|
Mentors disponibles :
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{need.mentors.map((mentor) => (
|
||||||
|
<Badge
|
||||||
|
key={mentor.member.uuid}
|
||||||
|
variant="outline"
|
||||||
|
className="bg-green-500/10 text-green-200 border-green-500/30"
|
||||||
|
>
|
||||||
|
{mentor.member.firstName}{" "}
|
||||||
|
{mentor.member.lastName}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
components/team-review/mentorship-section.tsx
Normal file
126
components/team-review/mentorship-section.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { TeamMemberProfile, SkillGap } from "@/lib/team-review-types";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { UserCheck } from "lucide-react";
|
||||||
|
|
||||||
|
interface MentorshipSectionProps {
|
||||||
|
members: TeamMemberProfile[];
|
||||||
|
skillGaps: SkillGap[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MentorshipSection({
|
||||||
|
members,
|
||||||
|
skillGaps,
|
||||||
|
}: MentorshipSectionProps) {
|
||||||
|
// Trouver les mentors potentiels
|
||||||
|
const mentors = members.filter((member) => member.mentorSkills > 0);
|
||||||
|
|
||||||
|
// Organiser les opportunités de mentorat par mentor
|
||||||
|
const mentorshipOpportunities = mentors.map((mentor) => {
|
||||||
|
const opportunities = mentor.skills
|
||||||
|
.filter((skill) => skill.canMentor)
|
||||||
|
.map((skill) => {
|
||||||
|
const gap = skillGaps.find((g) => g.skillId === skill.skillId);
|
||||||
|
if (!gap) return null;
|
||||||
|
|
||||||
|
const learners = members.filter((member) =>
|
||||||
|
member.skills.some(
|
||||||
|
(s) =>
|
||||||
|
s.skillId === skill.skillId &&
|
||||||
|
s.wantsToLearn &&
|
||||||
|
["never", "not-autonomous"].includes(s.level)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
skill: skill.skillName,
|
||||||
|
category: skill.category,
|
||||||
|
learners,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mentor,
|
||||||
|
opportunities,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white/5 border-white/10 backdrop-blur">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-slate-200">
|
||||||
|
Opportunités de mentorat
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ScrollArea className="h-[400px]">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{mentorshipOpportunities.map(({ mentor, opportunities }) => (
|
||||||
|
<div key={mentor.member.uuid} className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar className="bg-white/10 border border-white/20">
|
||||||
|
<AvatarFallback className="text-slate-200 bg-transparent">
|
||||||
|
{mentor.member.firstName[0]}
|
||||||
|
{mentor.member.lastName[0]}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-slate-200">
|
||||||
|
{mentor.member.firstName} {mentor.member.lastName}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
{mentor.mentorSkills} compétences en mentorat
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pl-12 space-y-3">
|
||||||
|
{opportunities.map(
|
||||||
|
(opp, idx) =>
|
||||||
|
opp && (
|
||||||
|
<div key={idx} className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="bg-white/5 text-slate-300 border-white/20"
|
||||||
|
>
|
||||||
|
{opp.category}
|
||||||
|
</Badge>
|
||||||
|
<span className="font-medium text-slate-300">
|
||||||
|
{opp.skill}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{opp.learners.length > 0 && (
|
||||||
|
<div className="pl-4">
|
||||||
|
<p className="text-sm text-slate-400 mb-2">
|
||||||
|
Apprenants potentiels :
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{opp.learners.map((learner) => (
|
||||||
|
<Badge
|
||||||
|
key={learner.member.uuid}
|
||||||
|
variant="outline"
|
||||||
|
className="bg-blue-500/10 text-blue-200 border-blue-500/30"
|
||||||
|
>
|
||||||
|
{learner.member.firstName}{" "}
|
||||||
|
{learner.member.lastName}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
199
components/team-review/skill-matrix.tsx
Normal file
199
components/team-review/skill-matrix.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { TeamMemberProfile, SkillGap } from "@/lib/team-review-types";
|
||||||
|
import { UserCheck, GraduationCap } from "lucide-react";
|
||||||
|
import { TechIcon } from "@/components/icons/tech-icon";
|
||||||
|
|
||||||
|
interface SkillMatrixProps {
|
||||||
|
members: TeamMemberProfile[];
|
||||||
|
skillGaps: SkillGap[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkillMatrix({ members, skillGaps }: SkillMatrixProps) {
|
||||||
|
// Filtrer les skills valides et les organiser par catégorie
|
||||||
|
const validSkillGaps = skillGaps.filter(
|
||||||
|
(skill) => skill.skillId && skill.skillName && skill.category
|
||||||
|
);
|
||||||
|
|
||||||
|
const skillsByCategory = validSkillGaps.reduce((acc, skill) => {
|
||||||
|
if (!acc[skill.category]) {
|
||||||
|
acc[skill.category] = [];
|
||||||
|
}
|
||||||
|
acc[skill.category].push(skill);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, SkillGap[]>);
|
||||||
|
|
||||||
|
const getLevelBadge = (level: string | null) => {
|
||||||
|
const colors = {
|
||||||
|
never: "bg-white/5 text-slate-300",
|
||||||
|
"not-autonomous": "bg-yellow-500/20 text-yellow-200",
|
||||||
|
autonomous: "bg-green-500/20 text-green-200",
|
||||||
|
expert: "bg-blue-500/20 text-blue-200",
|
||||||
|
};
|
||||||
|
|
||||||
|
return level ? (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={colors[level as keyof typeof colors]}
|
||||||
|
>
|
||||||
|
{level}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-600">-</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Vérifier si nous avons des données valides
|
||||||
|
if (validSkillGaps.length === 0 || members.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white/5 border-white/10 backdrop-blur">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-slate-200">
|
||||||
|
Matrice des compétences
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-slate-400">
|
||||||
|
Aucune donnée disponible pour la matrice des compétences.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white/5 border-white/10 backdrop-blur">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-slate-200">
|
||||||
|
Matrice des compétences
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-8">
|
||||||
|
{Object.entries(skillsByCategory).map(([category, skills]) => (
|
||||||
|
<div key={`category-${category}`}>
|
||||||
|
<h3 className="text-lg font-semibold text-slate-200 mb-4">
|
||||||
|
{category}
|
||||||
|
</h3>
|
||||||
|
<div className="rounded-md border border-white/10">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="border-white/10">
|
||||||
|
<TableHead className="w-[200px] text-slate-300">
|
||||||
|
Compétence
|
||||||
|
</TableHead>
|
||||||
|
{members.map((member) => (
|
||||||
|
<TableHead
|
||||||
|
key={`header-${member.member.uuid}`}
|
||||||
|
className="text-slate-300"
|
||||||
|
>
|
||||||
|
{member.member.firstName} {member.member.lastName}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
<TableHead className="w-[120px] text-slate-300">
|
||||||
|
Couverture
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{skills.map((skill) => (
|
||||||
|
<TableRow
|
||||||
|
key={`skill-row-${skill.skillId}-${skill.category}`}
|
||||||
|
className="border-white/10"
|
||||||
|
>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-white/10 border border-white/20 flex items-center justify-center">
|
||||||
|
<TechIcon
|
||||||
|
iconName={skill.icon || ""}
|
||||||
|
className="w-4 h-4 text-blue-400"
|
||||||
|
fallbackText={skill.skillName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-slate-200">
|
||||||
|
{skill.skillName}
|
||||||
|
</span>
|
||||||
|
{skill.risk === "high" && (
|
||||||
|
<Badge
|
||||||
|
variant="destructive"
|
||||||
|
className="bg-red-500/20 text-red-200 border-red-500/30"
|
||||||
|
>
|
||||||
|
Risque
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
{members.map((member) => {
|
||||||
|
const memberSkill = member.skills.find(
|
||||||
|
(s) => s.skillId === skill.skillId
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<TableCell
|
||||||
|
key={`skill-${skill.skillId}-member-${member.member.uuid}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{getLevelBadge(memberSkill?.level || null)}
|
||||||
|
{memberSkill?.canMentor && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="flex items-center gap-1 bg-green-500/10 text-green-200 border-green-500/30"
|
||||||
|
>
|
||||||
|
<UserCheck className="h-3 w-3" />
|
||||||
|
<span className="text-xs">Mentor</span>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{memberSkill?.wantsToLearn && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="flex items-center gap-1 bg-blue-500/10 text-blue-200 border-blue-500/30"
|
||||||
|
>
|
||||||
|
<GraduationCap className="h-3 w-3" />
|
||||||
|
<span className="text-xs">Apprenant</span>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-full bg-white/10 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-blue-500/50 h-2 rounded-full"
|
||||||
|
style={{
|
||||||
|
width: `${Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(100, skill.coverage || 0)
|
||||||
|
)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-slate-400 whitespace-nowrap">
|
||||||
|
{Math.round(skill.coverage || 0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
components/team-review/team-insights.tsx
Normal file
89
components/team-review/team-insights.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { CategoryCoverage } from "@/lib/team-review-types";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
|
||||||
|
interface TeamInsightsProps {
|
||||||
|
recommendations: string[];
|
||||||
|
categoryCoverage: CategoryCoverage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeamInsights({
|
||||||
|
recommendations,
|
||||||
|
categoryCoverage,
|
||||||
|
}: TeamInsightsProps) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-white/5 border-white/10 backdrop-blur">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-slate-200">
|
||||||
|
Insights & Recommandations
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{recommendations.map((recommendation, index) => (
|
||||||
|
<Alert key={index} className="bg-white/5 border-white/10">
|
||||||
|
<AlertDescription className="text-slate-300">
|
||||||
|
{recommendation}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="font-semibold text-slate-200">
|
||||||
|
Couverture par catégorie
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{categoryCoverage.map((category) => (
|
||||||
|
<div key={category.category} className="space-y-2">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span
|
||||||
|
className="text-sm font-medium truncate text-slate-300"
|
||||||
|
title={category.category}
|
||||||
|
>
|
||||||
|
{category.category}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-slate-400">
|
||||||
|
{Math.round(category.coverage)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 w-full bg-white/10 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500/50 rounded-full transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(
|
||||||
|
100,
|
||||||
|
Math.max(0, category.coverage)
|
||||||
|
)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-xs text-slate-400">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-slate-300">
|
||||||
|
{category.experts}
|
||||||
|
</span>{" "}
|
||||||
|
exp.
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-slate-300">
|
||||||
|
{category.mentors}
|
||||||
|
</span>{" "}
|
||||||
|
ment.
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-slate-300">
|
||||||
|
{category.learners}
|
||||||
|
</span>{" "}
|
||||||
|
app.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
components/team-review/team-overview.tsx
Normal file
166
components/team-review/team-overview.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { TeamReviewData } from "@/lib/team-review-types";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
|
||||||
|
interface TeamOverviewProps {
|
||||||
|
team: TeamReviewData["team"];
|
||||||
|
stats: TeamReviewData["stats"];
|
||||||
|
members: TeamReviewData["members"];
|
||||||
|
categoryCoverage: TeamReviewData["categoryCoverage"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeamOverview({
|
||||||
|
team,
|
||||||
|
stats,
|
||||||
|
members,
|
||||||
|
categoryCoverage,
|
||||||
|
}: TeamOverviewProps) {
|
||||||
|
// Trouver les top contributeurs
|
||||||
|
const topContributors = [...members]
|
||||||
|
.sort((a, b) => b.expertSkills - a.expertSkills)
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
|
// Trouver les catégories les plus fortes
|
||||||
|
const strongestCategories = [...categoryCoverage]
|
||||||
|
.sort((a, b) => b.coverage - a.coverage)
|
||||||
|
.slice(0, 2);
|
||||||
|
|
||||||
|
// Trouver les catégories qui nécessitent de l'attention
|
||||||
|
const categoriesNeedingAttention = [...categoryCoverage]
|
||||||
|
.filter((cat) => {
|
||||||
|
// Une catégorie nécessite de l'attention si :
|
||||||
|
// - Couverture faible (< 40%)
|
||||||
|
// - OU pas assez d'experts (< 2) avec une équipe de taille significative (> 5)
|
||||||
|
// - OU beaucoup d'apprenants (> 30% de l'équipe) avec peu de mentors (< 2)
|
||||||
|
return (
|
||||||
|
cat.coverage < 40 ||
|
||||||
|
(cat.experts < 2 && stats.totalMembers > 5) ||
|
||||||
|
(cat.learners > stats.totalMembers * 0.3 && cat.mentors < 2)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Prioriser les catégories avec le plus de besoins
|
||||||
|
const aScore = a.learners * 2 - a.mentors - a.experts;
|
||||||
|
const bScore = b.learners * 2 - b.mentors - b.experts;
|
||||||
|
return bScore - aScore;
|
||||||
|
})
|
||||||
|
.slice(0, 2);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white/5 border-white/10 backdrop-blur">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-slate-200">Vue d'ensemble</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
{/* Points forts */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-slate-200 mb-4">
|
||||||
|
Points forts
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{strongestCategories.map((cat) => (
|
||||||
|
<div key={cat.category} className="space-y-2">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<p className="text-sm font-medium text-slate-300">
|
||||||
|
{cat.category}
|
||||||
|
</p>
|
||||||
|
<span className="text-sm text-slate-400">
|
||||||
|
{cat.coverage.toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={cat.coverage} className="bg-white/10" />
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
{cat.experts} experts • {cat.mentors} mentors
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top contributeurs */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-slate-200 mb-4">
|
||||||
|
Top contributeurs
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{topContributors.map((member) => (
|
||||||
|
<div key={member.member.uuid} className="space-y-1">
|
||||||
|
<p className="text-sm font-medium text-slate-300">
|
||||||
|
{member.member.firstName} {member.member.lastName}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 text-xs">
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="bg-white/10 text-slate-300 border-white/20"
|
||||||
|
>
|
||||||
|
{member.expertSkills} expertises
|
||||||
|
</Badge>
|
||||||
|
{member.mentorSkills > 0 && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-slate-300 border-white/20"
|
||||||
|
>
|
||||||
|
{member.mentorSkills} mentorats
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Points d'attention */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium flex items-center gap-2 text-amber-400 mb-4">
|
||||||
|
<AlertTriangle size={16} />
|
||||||
|
Besoins prioritaires
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{categoriesNeedingAttention.map((cat) => (
|
||||||
|
<div key={cat.category} className="space-y-2">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<p className="text-sm font-medium text-slate-300">
|
||||||
|
{cat.category}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-amber-400 border-amber-400/30"
|
||||||
|
>
|
||||||
|
{cat.learners} apprenants
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-slate-400">
|
||||||
|
<span>{cat.experts} experts</span>
|
||||||
|
<span>{cat.mentors} mentors</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-amber-400/80">
|
||||||
|
{cat.coverage < 40 && "Couverture insuffisante • "}
|
||||||
|
{cat.experts < 2 &&
|
||||||
|
stats.totalMembers > 5 &&
|
||||||
|
"Manque d'experts • "}
|
||||||
|
{cat.learners > stats.totalMembers * 0.3 &&
|
||||||
|
cat.mentors < 2 &&
|
||||||
|
"Besoin de mentors"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{stats.learningNeeds > 0 && (
|
||||||
|
<div className="pt-2 border-t border-white/10">
|
||||||
|
<p className="text-sm text-amber-400 flex items-center gap-2">
|
||||||
|
<AlertTriangle size={16} />
|
||||||
|
{stats.learningNeeds} compétences sans expert ni mentor
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
285
components/team-review/team-stats.tsx
Normal file
285
components/team-review/team-stats.tsx
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { TeamReviewData } from "@/lib/team-review-types";
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
|
interface TeamStatsProps {
|
||||||
|
stats: TeamReviewData["stats"];
|
||||||
|
members: TeamReviewData["members"];
|
||||||
|
skillGaps: TeamReviewData["skillGaps"];
|
||||||
|
categoryCoverage: TeamReviewData["categoryCoverage"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeamStats({
|
||||||
|
stats,
|
||||||
|
members,
|
||||||
|
skillGaps,
|
||||||
|
categoryCoverage,
|
||||||
|
}: TeamStatsProps) {
|
||||||
|
// Calcul des statistiques avancées
|
||||||
|
const totalEvaluations = members.reduce(
|
||||||
|
(acc, member) => acc + member.skills.length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const avgSkillsPerMember = totalEvaluations / (stats.totalMembers || 1);
|
||||||
|
|
||||||
|
// Distribution des niveaux
|
||||||
|
const levelDistribution = members.reduce((acc, member) => {
|
||||||
|
member.skills.forEach((skill) => {
|
||||||
|
acc[skill.level] = (acc[skill.level] || 0) + 1;
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>);
|
||||||
|
|
||||||
|
const levelData = [
|
||||||
|
{ name: "Expert", value: levelDistribution.expert || 0, color: "#4f46e5" },
|
||||||
|
{
|
||||||
|
name: "Autonome",
|
||||||
|
value: levelDistribution.autonomous || 0,
|
||||||
|
color: "#10b981",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "En apprentissage",
|
||||||
|
value: levelDistribution["not-autonomous"] || 0,
|
||||||
|
color: "#f59e0b",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Jamais pratiqué",
|
||||||
|
value: levelDistribution.never || 0,
|
||||||
|
color: "#6b7280",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Top catégories par expertise
|
||||||
|
const sortedCategories = [...categoryCoverage].sort(
|
||||||
|
(a, b) => b.experts - a.experts
|
||||||
|
);
|
||||||
|
const topCategories = sortedCategories.slice(0, 3);
|
||||||
|
|
||||||
|
// Statistiques de mentorat
|
||||||
|
const mentorCount = members.filter((m) => m.mentorSkills > 0).length;
|
||||||
|
const learnerCount = members.filter((m) => m.learningSkills > 0).length;
|
||||||
|
const mentorshipRatio = (mentorCount / (stats.totalMembers || 1)) * 100;
|
||||||
|
|
||||||
|
// Gaps critiques par catégorie
|
||||||
|
const criticalGapsByCategory = skillGaps
|
||||||
|
.filter((gap) => gap.risk === "high")
|
||||||
|
.reduce((acc, gap) => {
|
||||||
|
acc[gap.category] = (acc[gap.category] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>);
|
||||||
|
|
||||||
|
const gapData = Object.entries(criticalGapsByCategory).map(
|
||||||
|
([category, count]) => ({
|
||||||
|
name: category,
|
||||||
|
value: count,
|
||||||
|
fill: "#ef4444",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-white/5 border-white/10 backdrop-blur">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-slate-200">
|
||||||
|
Statistiques de l'équipe
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-8">
|
||||||
|
{/* Vue d'ensemble */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-slate-400">Membres</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-200">
|
||||||
|
{stats.totalMembers}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-slate-400">Compétences/membre</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-200">
|
||||||
|
{avgSkillsPerMember.toFixed(1)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-slate-400">Ratio mentors</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-200">
|
||||||
|
{mentorshipRatio.toFixed(0)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-slate-400">Gaps critiques</p>
|
||||||
|
<p className="text-2xl font-bold text-red-400">
|
||||||
|
{stats.learningNeeds}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Distribution des niveaux */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-slate-200 mb-4">
|
||||||
|
Distribution des niveaux
|
||||||
|
</h3>
|
||||||
|
<div className="h-[200px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={levelData}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
outerRadius={80}
|
||||||
|
label={(entry) => `${entry.name} (${entry.value})`}
|
||||||
|
labelLine={{ stroke: "#64748b" }}
|
||||||
|
>
|
||||||
|
{levelData.map((entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={`cell-${index}`}
|
||||||
|
fill={entry.color}
|
||||||
|
className="opacity-80"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "rgba(30, 41, 59, 0.9)",
|
||||||
|
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
}}
|
||||||
|
itemStyle={{ color: "#e2e8f0" }}
|
||||||
|
labelStyle={{ color: "#e2e8f0" }}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top catégories */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-slate-200 mb-4">
|
||||||
|
Top catégories par expertise
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{topCategories.map((cat) => (
|
||||||
|
<div key={cat.category} className="flex items-center gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-slate-300">
|
||||||
|
{cat.category}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 text-sm text-slate-400">
|
||||||
|
<span>{cat.experts} experts</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{cat.mentors} mentors</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-24 text-right">
|
||||||
|
<p className="text-sm font-medium text-slate-300">
|
||||||
|
{cat.coverage.toFixed(0)}%
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-400">couverture</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gaps critiques par catégorie */}
|
||||||
|
{gapData.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-slate-200 mb-4">
|
||||||
|
Gaps critiques par catégorie
|
||||||
|
</h3>
|
||||||
|
<div className="h-[200px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={gapData}>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
stroke="rgba(255, 255, 255, 0.1)"
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
tick={{ fill: "#94a3b8" }}
|
||||||
|
axisLine={{ stroke: "#334155" }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fill: "#94a3b8" }}
|
||||||
|
axisLine={{ stroke: "#334155" }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "rgba(30, 41, 59, 0.9)",
|
||||||
|
border: "1px solid rgba(255, 255, 255, 0.1)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
}}
|
||||||
|
itemStyle={{ color: "#e2e8f0" }}
|
||||||
|
labelStyle={{ color: "#e2e8f0" }}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="value" />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Statistiques de mentorat */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-white/10">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-slate-200 mb-2">
|
||||||
|
Mentorat
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<p className="text-sm text-slate-400">Mentors</p>
|
||||||
|
<p className="text-sm font-medium text-slate-300">
|
||||||
|
{mentorCount}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<p className="text-sm text-slate-400">Apprenants</p>
|
||||||
|
<p className="text-sm font-medium text-slate-300">
|
||||||
|
{learnerCount}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<p className="text-sm text-slate-400">Opportunités</p>
|
||||||
|
<p className="text-sm font-medium text-slate-300">
|
||||||
|
{stats.mentorshipOpportunities}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-slate-200 mb-2">
|
||||||
|
Couverture globale
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<p className="text-sm text-slate-400">Compétences</p>
|
||||||
|
<p className="text-sm font-medium text-slate-300">
|
||||||
|
{stats.totalSkills}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<p className="text-sm text-slate-400">Niveau moyen</p>
|
||||||
|
<p className="text-sm font-medium text-slate-300">
|
||||||
|
{((stats.averageTeamLevel * 100) / 3).toFixed(0)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
lib/team-review-types.ts
Normal file
87
lib/team-review-types.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
export interface TeamMember {
|
||||||
|
uuid: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
teamId: string;
|
||||||
|
email?: string;
|
||||||
|
role?: string;
|
||||||
|
joinDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamMemberSkill {
|
||||||
|
skillId: string;
|
||||||
|
skillName: string;
|
||||||
|
category: string;
|
||||||
|
level: "never" | "not-autonomous" | "autonomous" | "expert";
|
||||||
|
canMentor: boolean;
|
||||||
|
wantsToLearn: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamMemberProfile {
|
||||||
|
member: TeamMember;
|
||||||
|
skills: TeamMemberSkill[];
|
||||||
|
totalSkills: number;
|
||||||
|
expertSkills: number;
|
||||||
|
mentorSkills: number;
|
||||||
|
learningSkills: number;
|
||||||
|
averageLevel: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillGap {
|
||||||
|
skillId: string;
|
||||||
|
skillName: string;
|
||||||
|
category: string;
|
||||||
|
icon?: string;
|
||||||
|
teamMembers: number;
|
||||||
|
experts: number;
|
||||||
|
mentors: number;
|
||||||
|
learners: number;
|
||||||
|
coverage: number; // Pourcentage de couverture
|
||||||
|
risk: "low" | "medium" | "high";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryCoverage {
|
||||||
|
category: string;
|
||||||
|
totalSkills: number;
|
||||||
|
coveredSkills: number;
|
||||||
|
coverage: number;
|
||||||
|
experts: number;
|
||||||
|
mentors: number;
|
||||||
|
learners: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TeamReviewData {
|
||||||
|
team: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
direction: string;
|
||||||
|
};
|
||||||
|
members: TeamMemberProfile[];
|
||||||
|
skillGaps: SkillGap[];
|
||||||
|
categoryCoverage: CategoryCoverage[];
|
||||||
|
recommendations: string[];
|
||||||
|
stats: {
|
||||||
|
totalMembers: number;
|
||||||
|
totalSkills: number;
|
||||||
|
averageTeamLevel: number;
|
||||||
|
mentorshipOpportunities: number;
|
||||||
|
learningNeeds: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MentorOpportunity {
|
||||||
|
mentor: TeamMember;
|
||||||
|
mentee: TeamMember;
|
||||||
|
skill: string;
|
||||||
|
category: string;
|
||||||
|
mentorLevel: "autonomous" | "expert";
|
||||||
|
menteeLevel: "never" | "not-autonomous";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LearningPath {
|
||||||
|
member: TeamMember;
|
||||||
|
currentSkills: TeamMemberSkill[];
|
||||||
|
targetSkills: TeamMemberSkill[];
|
||||||
|
recommendedMentors: TeamMember[];
|
||||||
|
estimatedTimeToAutonomy: number; // en mois
|
||||||
|
}
|
||||||
298
services/team-review-service.ts
Normal file
298
services/team-review-service.ts
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
import { getPool } from "./database";
|
||||||
|
import {
|
||||||
|
TeamReviewData,
|
||||||
|
TeamMemberProfile,
|
||||||
|
SkillGap,
|
||||||
|
CategoryCoverage,
|
||||||
|
TeamMember,
|
||||||
|
TeamMemberSkill,
|
||||||
|
} from "@/lib/team-review-types";
|
||||||
|
import { SkillLevel } from "@/lib/types";
|
||||||
|
|
||||||
|
export class TeamReviewService {
|
||||||
|
static async getTeamReviewData(teamId: string): Promise<TeamReviewData> {
|
||||||
|
const pool = getPool();
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
// 1. Récupérer les infos de l'équipe
|
||||||
|
const teamQuery = `
|
||||||
|
SELECT id, name, direction
|
||||||
|
FROM teams
|
||||||
|
WHERE id = $1
|
||||||
|
`;
|
||||||
|
const teamResult = await client.query(teamQuery, [teamId]);
|
||||||
|
const team = teamResult.rows[0];
|
||||||
|
|
||||||
|
// 2. Récupérer les compétences qui ont au moins une évaluation dans l'équipe
|
||||||
|
const skillsQuery = `
|
||||||
|
SELECT DISTINCT
|
||||||
|
s.id as skill_id,
|
||||||
|
s.name as skill_name,
|
||||||
|
s.icon,
|
||||||
|
sc.name as category
|
||||||
|
FROM skill_categories sc
|
||||||
|
JOIN skills s ON s.category_id = sc.id
|
||||||
|
JOIN skill_evaluations se ON s.id = se.skill_id
|
||||||
|
JOIN user_evaluations ue ON se.user_evaluation_id = ue.id
|
||||||
|
JOIN users u ON ue.user_uuid = u.uuid_id
|
||||||
|
WHERE u.team_id = $1
|
||||||
|
ORDER BY sc.name, s.name
|
||||||
|
`;
|
||||||
|
const skillsResult = await client.query(skillsQuery, [teamId]);
|
||||||
|
const allSkills = skillsResult.rows;
|
||||||
|
|
||||||
|
// 3. Récupérer les évaluations des membres
|
||||||
|
const membersQuery = `
|
||||||
|
SELECT
|
||||||
|
u.uuid_id,
|
||||||
|
u.first_name,
|
||||||
|
u.last_name,
|
||||||
|
u.team_id,
|
||||||
|
u.email,
|
||||||
|
s.id as skill_id,
|
||||||
|
s.name as skill_name,
|
||||||
|
sc.name as category,
|
||||||
|
se.level,
|
||||||
|
se.can_mentor,
|
||||||
|
se.wants_to_learn
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN user_evaluations ue ON u.uuid_id = ue.user_uuid
|
||||||
|
LEFT JOIN skill_evaluations se ON ue.id = se.user_evaluation_id
|
||||||
|
LEFT JOIN skills s ON se.skill_id = s.id
|
||||||
|
LEFT JOIN skill_categories sc ON s.category_id = sc.id
|
||||||
|
WHERE u.team_id = $1
|
||||||
|
ORDER BY u.first_name, u.last_name, sc.name, s.name
|
||||||
|
`;
|
||||||
|
const membersResult = await client.query(membersQuery, [teamId]);
|
||||||
|
|
||||||
|
// 4. Organiser les données des membres
|
||||||
|
const membersMap = new Map<string, TeamMemberProfile>();
|
||||||
|
|
||||||
|
for (const row of membersResult.rows) {
|
||||||
|
if (!membersMap.has(row.uuid_id)) {
|
||||||
|
membersMap.set(row.uuid_id, {
|
||||||
|
member: {
|
||||||
|
uuid: row.uuid_id,
|
||||||
|
firstName: row.first_name,
|
||||||
|
lastName: row.last_name,
|
||||||
|
teamId: row.team_id,
|
||||||
|
email: row.email,
|
||||||
|
},
|
||||||
|
skills: [],
|
||||||
|
totalSkills: 0,
|
||||||
|
expertSkills: 0,
|
||||||
|
mentorSkills: 0,
|
||||||
|
learningSkills: 0,
|
||||||
|
averageLevel: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = membersMap.get(row.uuid_id)!;
|
||||||
|
|
||||||
|
if (row.skill_id && row.skill_name && row.category) {
|
||||||
|
const skill: TeamMemberSkill = {
|
||||||
|
skillId: row.skill_id,
|
||||||
|
skillName: row.skill_name,
|
||||||
|
category: row.category,
|
||||||
|
level: row.level as SkillLevel,
|
||||||
|
canMentor: row.can_mentor || false,
|
||||||
|
wantsToLearn: row.wants_to_learn || false,
|
||||||
|
};
|
||||||
|
|
||||||
|
profile.skills.push(skill);
|
||||||
|
profile.totalSkills++;
|
||||||
|
|
||||||
|
if (row.level === "expert") profile.expertSkills++;
|
||||||
|
if (row.can_mentor) profile.mentorSkills++;
|
||||||
|
if (row.wants_to_learn) profile.learningSkills++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculer les moyennes
|
||||||
|
for (const profile of membersMap.values()) {
|
||||||
|
profile.averageLevel =
|
||||||
|
profile.skills.reduce((acc, skill) => {
|
||||||
|
const levelValues = {
|
||||||
|
never: 0,
|
||||||
|
"not-autonomous": 1,
|
||||||
|
autonomous: 2,
|
||||||
|
expert: 3,
|
||||||
|
};
|
||||||
|
return acc + (levelValues[skill.level] || 0);
|
||||||
|
}, 0) / (profile.skills.length || 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Analyser les gaps de compétences
|
||||||
|
const skillGaps: SkillGap[] = allSkills.map((skill) => {
|
||||||
|
const evaluations = membersResult.rows.filter(
|
||||||
|
(row) => row.skill_id === skill.skill_id
|
||||||
|
);
|
||||||
|
|
||||||
|
const experts = evaluations.filter((e) => e.level === "expert").length;
|
||||||
|
const mentors = evaluations.filter((e) => e.can_mentor).length;
|
||||||
|
const learners = evaluations.filter((e) => e.wants_to_learn).length;
|
||||||
|
const teamMembers = evaluations.filter((e) => e.level).length;
|
||||||
|
|
||||||
|
const totalTeamMembers = membersMap.size;
|
||||||
|
const coverage =
|
||||||
|
totalTeamMembers > 0 ? (teamMembers / totalTeamMembers) * 100 : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
skillId: skill.skill_id,
|
||||||
|
skillName: skill.skill_name,
|
||||||
|
category: skill.category,
|
||||||
|
icon: skill.icon,
|
||||||
|
team_members: teamMembers,
|
||||||
|
experts,
|
||||||
|
mentors,
|
||||||
|
learners,
|
||||||
|
coverage,
|
||||||
|
risk:
|
||||||
|
experts === 0 && mentors === 0
|
||||||
|
? "high"
|
||||||
|
: experts === 0 || mentors === 0
|
||||||
|
? "medium"
|
||||||
|
: "low",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Analyser la couverture par catégorie
|
||||||
|
const categoriesMap = new Map<string, CategoryCoverage>();
|
||||||
|
|
||||||
|
for (const skill of allSkills) {
|
||||||
|
if (!categoriesMap.has(skill.category)) {
|
||||||
|
categoriesMap.set(skill.category, {
|
||||||
|
category: skill.category,
|
||||||
|
total_skills: 0,
|
||||||
|
covered_skills: 0,
|
||||||
|
experts: 0,
|
||||||
|
mentors: 0,
|
||||||
|
learners: 0,
|
||||||
|
coverage: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryStats = categoriesMap.get(skill.category)!;
|
||||||
|
categoryStats.total_skills++;
|
||||||
|
|
||||||
|
const skillGap = skillGaps.find(
|
||||||
|
(gap) => gap.skillId === skill.skill_id
|
||||||
|
);
|
||||||
|
if (skillGap) {
|
||||||
|
if (skillGap.team_members > 0) categoryStats.covered_skills++;
|
||||||
|
categoryStats.experts += skillGap.experts;
|
||||||
|
categoryStats.mentors += skillGap.mentors;
|
||||||
|
categoryStats.learners += skillGap.learners;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculer la couverture pour chaque catégorie
|
||||||
|
const categoryCoverage: CategoryCoverage[] = Array.from(
|
||||||
|
categoriesMap.values()
|
||||||
|
).map((category) => ({
|
||||||
|
...category,
|
||||||
|
coverage:
|
||||||
|
category.total_skills > 0
|
||||||
|
? (category.covered_skills / category.total_skills) * 100
|
||||||
|
: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 7. Générer des recommandations
|
||||||
|
const recommendations = this.generateRecommendations(
|
||||||
|
Array.from(membersMap.values()),
|
||||||
|
skillGaps,
|
||||||
|
categoryCoverage
|
||||||
|
);
|
||||||
|
|
||||||
|
// 8. Calculer les statistiques globales
|
||||||
|
const stats = {
|
||||||
|
totalMembers: membersMap.size,
|
||||||
|
totalSkills: skillGaps.length,
|
||||||
|
averageTeamLevel:
|
||||||
|
Array.from(membersMap.values()).reduce(
|
||||||
|
(acc, profile) => acc + profile.averageLevel,
|
||||||
|
0
|
||||||
|
) / (membersMap.size || 1),
|
||||||
|
mentorshipOpportunities: skillGaps.reduce(
|
||||||
|
(acc, gap) => acc + (gap.learners || 0),
|
||||||
|
0
|
||||||
|
),
|
||||||
|
learningNeeds: skillGaps.filter((gap) => gap.risk === "high").length,
|
||||||
|
};
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
|
||||||
|
return {
|
||||||
|
team,
|
||||||
|
members: Array.from(membersMap.values()),
|
||||||
|
skillGaps,
|
||||||
|
categoryCoverage,
|
||||||
|
recommendations,
|
||||||
|
stats,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
console.error("Error in getTeamReviewData:", error);
|
||||||
|
throw new Error("Failed to get team review data");
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static generateRecommendations(
|
||||||
|
members: TeamMemberProfile[],
|
||||||
|
skillGaps: SkillGap[],
|
||||||
|
categoryCoverage: CategoryCoverage[]
|
||||||
|
): string[] {
|
||||||
|
const recommendations: string[] = [];
|
||||||
|
|
||||||
|
// Analyser les gaps critiques
|
||||||
|
const criticalGaps = skillGaps.filter((gap) => gap.risk === "high");
|
||||||
|
if (criticalGaps.length > 0) {
|
||||||
|
recommendations.push(
|
||||||
|
`Attention : ${
|
||||||
|
criticalGaps.length
|
||||||
|
} compétences critiques sans expert ni mentor : ${criticalGaps
|
||||||
|
.map((gap) => gap.skillName)
|
||||||
|
.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyser les opportunités de mentorat
|
||||||
|
const mentorshipNeeds = skillGaps.filter(
|
||||||
|
(gap) => gap.learners > 0 && gap.mentors > 0
|
||||||
|
).length;
|
||||||
|
if (mentorshipNeeds > 0) {
|
||||||
|
recommendations.push(
|
||||||
|
`${mentorshipNeeds} opportunités de mentorat identifiées`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyser la couverture des catégories
|
||||||
|
const lowCoverageCategories = categoryCoverage
|
||||||
|
.filter((cat) => cat.coverage < 50)
|
||||||
|
.map((cat) => cat.category);
|
||||||
|
if (lowCoverageCategories.length > 0) {
|
||||||
|
recommendations.push(
|
||||||
|
`Faible couverture dans les catégories : ${lowCoverageCategories.join(
|
||||||
|
", "
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identifier les experts isolés
|
||||||
|
const isolatedExperts = members.filter(
|
||||||
|
(member) => member.expertSkills > 0 && member.mentorSkills === 0
|
||||||
|
);
|
||||||
|
if (isolatedExperts.length > 0) {
|
||||||
|
recommendations.push(
|
||||||
|
`${isolatedExperts.length} experts pourraient devenir mentors`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return recommendations;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user