feat: CRUD admin for skills and teams

This commit is contained in:
Julien Froidefond
2025-08-22 08:56:02 +02:00
parent 514b33870b
commit e314a96fae
43 changed files with 2516 additions and 179 deletions

View File

@@ -0,0 +1,547 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { Plus, Edit, Trash2, Users, Search, Building2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useToast } from "@/hooks/use-toast";
import { SkillCategory, Team as TeamType } from "@/lib/types";
import { TeamStats } from "@/services/admin-service";
import {
AdminManagementService,
Team,
} from "@/services/admin-management-service";
import {
TreeViewContainer,
TreeCategoryHeader,
TreeItemRow,
TreeSearchControls,
TeamMetrics,
} from "@/components/admin";
interface TeamsManagementProps {
teams: TeamType[];
teamStats: TeamStats[];
skillCategories: SkillCategory[];
}
interface TeamFormData {
name: string;
direction: string;
}
export function TeamsManagement({
teams,
teamStats,
skillCategories,
}: TeamsManagementProps) {
const [searchTerm, setSearchTerm] = useState("");
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [editingTeam, setEditingTeam] = useState<any>(null);
const [teamFormData, setTeamFormData] = useState<TeamFormData>({
name: "",
direction: "",
});
const { toast } = useToast();
// État pour les directions ouvertes/fermées
const [expandedDirections, setExpandedDirections] = useState<Set<string>>(
new Set()
);
// État pour gérer la liste des équipes
// Grouper les teams par direction et filtrer en fonction de la recherche
const filteredTeamsByDirection = useMemo(() => {
// Grouper les teams par direction
const teamsByDirection = teams.reduce((acc, team) => {
if (!acc[team.direction]) {
acc[team.direction] = [];
}
acc[team.direction].push(team);
return acc;
}, {} as Record<string, TeamType[]>);
// Filtrer les teams en fonction de la recherche
return Object.entries(teamsByDirection).reduce(
(acc, [direction, directionTeams]) => {
const filteredTeams = directionTeams.filter((team) => {
const matchesSearch = team.name
.toLowerCase()
.includes(searchTerm.toLowerCase());
return matchesSearch;
});
if (filteredTeams.length > 0) {
acc[direction] = filteredTeams;
}
return acc;
},
{} as Record<string, TeamType[]>
);
}, [teams, searchTerm]);
// Fonctions pour gérer l'expansion des directions
const toggleDirection = useMemo(
() => (direction: string) => {
setExpandedDirections((prev) => {
const newExpanded = new Set(prev);
if (newExpanded.has(direction)) {
newExpanded.delete(direction);
} else {
newExpanded.add(direction);
}
return newExpanded;
});
},
[]
);
const expandAll = useMemo(
() => () => {
setExpandedDirections(new Set(Object.keys(filteredTeamsByDirection)));
},
[filteredTeamsByDirection]
);
const collapseAll = useMemo(
() => () => {
setExpandedDirections(new Set());
},
[]
);
const getTeamStats = (teamId: string) => {
return teamStats.find((stats) => stats.teamId === teamId);
};
// Charger les teams depuis l'API
const fetchTeams = async () => {
try {
const teamsData = await AdminManagementService.getTeams();
// Note: on garde les teams existantes pour la compatibilité
// Les nouvelles teams créées via l'API seront visibles après rafraîchissement
} catch (error) {
console.error("Error fetching teams:", error);
toast({
title: "Erreur",
description: "Impossible de charger les teams",
variant: "destructive",
});
}
};
// Charger les teams au montage du composant
useEffect(() => {
fetchTeams();
}, []);
// Ouvrir automatiquement les directions qui contiennent des résultats lors de la recherche
useEffect(() => {
if (searchTerm.trim()) {
const directionsWithResults = Object.keys(filteredTeamsByDirection);
setExpandedDirections(new Set(directionsWithResults));
} else {
// Si pas de recherche, fermer toutes les directions
setExpandedDirections(new Set());
}
}, [searchTerm, filteredTeamsByDirection]);
const handleCreateTeam = async () => {
if (!teamFormData.name || !teamFormData.direction) {
toast({
title: "Erreur",
description: "Veuillez remplir tous les champs obligatoires",
variant: "destructive",
});
return;
}
try {
const newTeam = await AdminManagementService.createTeam(teamFormData);
toast({
title: "Succès",
description: "Équipe créée avec succès",
});
setTeamFormData({ name: "", direction: "" });
setIsCreateDialogOpen(false);
// Rafraîchir la page pour voir les changements
window.location.reload();
} catch (error: any) {
toast({
title: "Erreur",
description: error.message || "Erreur lors de la création de l'équipe",
variant: "destructive",
});
}
};
const handleEditTeam = (team: any) => {
setEditingTeam(team);
setTeamFormData({
name: team.name,
direction: team.direction,
});
setIsEditDialogOpen(true);
};
const handleUpdateTeam = async () => {
if (!editingTeam || !teamFormData.name || !teamFormData.direction) {
toast({
title: "Erreur",
description: "Veuillez remplir tous les champs obligatoires",
variant: "destructive",
});
return;
}
try {
await AdminManagementService.updateTeam({
id: editingTeam.id,
...teamFormData,
memberCount: editingTeam.memberCount || 0,
});
toast({
title: "Succès",
description: "Équipe mise à jour avec succès",
});
setIsEditDialogOpen(false);
setEditingTeam(null);
setTeamFormData({ name: "", direction: "" });
// Rafraîchir la page pour voir les changements
window.location.reload();
} catch (error: any) {
toast({
title: "Erreur",
description:
error.message || "Erreur lors de la mise à jour de l'équipe",
variant: "destructive",
});
}
};
const handleDeleteTeam = async (teamId: string) => {
const team = teams.find((t) => t.id === teamId);
const stats = getTeamStats(teamId);
if (stats && stats.totalMembers > 0) {
toast({
title: "Erreur",
description:
"Impossible de supprimer une équipe qui contient des membres",
variant: "destructive",
});
return;
}
if (
confirm(
`Êtes-vous sûr de vouloir supprimer l'équipe "${team?.name}" ? Cette action est irréversible.`
)
) {
try {
await AdminManagementService.deleteTeam(teamId);
toast({
title: "Succès",
description: "Équipe supprimée avec succès",
});
// Rafraîchir la page pour voir les changements
window.location.reload();
} catch (error: any) {
toast({
title: "Erreur",
description:
error.message || "Erreur lors de la suppression de l'équipe",
variant: "destructive",
});
}
}
};
const handleDeleteDirection = async (direction: string) => {
// Vérifier si des équipes de cette direction ont des membres
const teamsInDirection = teams.filter(
(team) => team.direction === direction
);
const hasMembers = teamsInDirection.some((team) => {
const stats = getTeamStats(team.id);
return stats && stats.totalMembers > 0;
});
if (hasMembers) {
toast({
title: "Erreur",
description: `Impossible de supprimer la direction "${direction}" car certaines équipes ont des membres`,
variant: "destructive",
});
return;
}
if (
confirm(
`Êtes-vous sûr de vouloir supprimer la direction "${direction}" et TOUTES ses équipes ?\n\n⚠ Cette action est irréversible !\n\nÉquipes qui seront supprimées :\n${teamsInDirection
.map((t) => `${t.name}`)
.join("\n")}`
)
) {
try {
await AdminManagementService.deleteDirection(direction);
toast({
title: "Succès",
description: `Direction "${direction}" et toutes ses équipes supprimées avec succès`,
variant: "default",
});
// Rafraîchir la page pour voir les changements
window.location.reload();
} catch (error: any) {
toast({
title: "Erreur",
description:
error.message || "Erreur lors de la suppression de la direction",
variant: "destructive",
});
}
}
};
const resetForm = () => {
setTeamFormData({ name: "", direction: "" });
};
// Extraire les directions uniques pour les formulaires
const directions = Array.from(new Set(teams.map((team) => team.direction)));
return (
<div className="space-y-4">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3">
<div>
<h2 className="text-2xl font-bold text-white">Gestion des Teams</h2>
<p className="text-slate-400">
Créez, modifiez et supprimez les équipes de votre organisation
</p>
</div>
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogTrigger asChild>
<Button onClick={resetForm}>
<Plus className="w-4 h-4 mr-2" />
Nouvelle Équipe
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Créer une nouvelle équipe</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="team-name">Nom de l'équipe *</Label>
<Input
id="team-name"
value={teamFormData.name}
onChange={(e) =>
setTeamFormData({ ...teamFormData, name: e.target.value })
}
placeholder="Ex: Équipe Frontend, Équipe Backend"
/>
</div>
<div>
<Label htmlFor="team-direction">Direction *</Label>
<Select
value={teamFormData.direction}
onValueChange={(value) =>
setTeamFormData({ ...teamFormData, direction: value })
}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionner une direction" />
</SelectTrigger>
<SelectContent>
{directions.map((direction) => (
<SelectItem key={direction} value={direction}>
{direction}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button
variant="outline"
onClick={() => setIsCreateDialogOpen(false)}
>
Annuler
</Button>
<Button onClick={handleCreateTeam}>Créer</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
{/* Filtres et contrôles */}
<TreeSearchControls
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
onExpandAll={expandAll}
onCollapseAll={collapseAll}
placeholder="Rechercher une équipe..."
/>
{/* Vue arborescente des Teams */}
<TreeViewContainer
hasContent={Object.keys(filteredTeamsByDirection).length > 0}
emptyState={
<div className="text-center py-8">
<Building2 className="w-10 h-10 text-slate-500 mx-auto mb-3" />
<h3 className="text-base font-medium text-slate-400 mb-1">
{searchTerm ? "Aucune équipe trouvée" : "Aucune équipe"}
</h3>
<p className="text-sm text-slate-500">
{searchTerm
? "Essayez de modifier vos critères de recherche"
: "Commencez par créer votre première équipe"}
</p>
</div>
}
>
{Object.entries(filteredTeamsByDirection).map(
([direction, directionTeams], index) => (
<div key={direction}>
<TreeCategoryHeader
category={direction}
isExpanded={expandedDirections.has(direction)}
onToggle={() => toggleDirection(direction)}
icon={<Building2 className="w-5 h-5 text-blue-400" />}
itemCount={directionTeams.length}
itemLabel="équipe"
showSeparator={index > 0}
onDelete={() => handleDeleteDirection(direction)}
canDelete={true}
isDirection={true}
/>
{/* Liste des teams de la direction */}
{expandedDirections.has(direction) && (
<div className="bg-slate-950/30">
{directionTeams.map((team, teamIndex) => {
const stats = getTeamStats(team.id);
return (
<TreeItemRow
key={team.id}
icon={<Users className="w-5 h-5 text-green-400" />}
title={team.name}
badges={
stats
? [
{
text: `${stats.totalMembers} membres`,
variant: "outline",
},
]
: []
}
onEdit={() => handleEditTeam(team)}
onDelete={() => handleDeleteTeam(team.id)}
canDelete={!stats || stats.totalMembers === 0}
showSeparator={teamIndex > 0}
additionalInfo={
stats ? (
<TeamMetrics
averageSkillLevel={stats.averageSkillLevel}
skillCoverage={stats.skillCoverage}
topSkillsCount={stats.topSkills.length}
totalMembers={stats.totalMembers}
/>
) : (
<p className="text-slate-500 text-xs">
Aucune donnée disponible
</p>
)
}
/>
);
})}
</div>
)}
</div>
)
)}
</TreeViewContainer>
{/* Dialog d'édition */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Modifier l'équipe</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="edit-team-name">Nom de l'équipe *</Label>
<Input
id="edit-team-name"
value={teamFormData.name}
onChange={(e) =>
setTeamFormData({ ...teamFormData, name: e.target.value })
}
placeholder="Ex: Équipe Frontend, Équipe Backend"
/>
</div>
<div>
<Label htmlFor="edit-team-direction">Direction *</Label>
<Select
value={teamFormData.direction}
onValueChange={(value) =>
setTeamFormData({ ...teamFormData, direction: value })
}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionner une direction" />
</SelectTrigger>
<SelectContent>
{directions.map((direction) => (
<SelectItem key={direction} value={direction}>
{direction}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button
variant="outline"
onClick={() => setIsEditDialogOpen(false)}
>
Annuler
</Button>
<Button onClick={handleUpdateTeam}>Mettre à jour</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}