reafctor: pages for management and split components

This commit is contained in:
Julien Froidefond
2025-08-23 08:16:09 +02:00
parent 97d274190d
commit 2e195ca5cf
29 changed files with 1968 additions and 1607 deletions

View File

@@ -0,0 +1,28 @@
import { AdminHeader } from "@/components/admin/utils/admin-header";
import { ManageNavigation } from "@/components/admin/layout/manage-navigation";
export default function ManageLayout({
children,
}: {
children: React.ReactNode;
}) {
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-6 max-w-7xl space-y-6">
{/* Header */}
<AdminHeader />
{/* Navigation */}
<ManageNavigation />
{/* Main Content */}
{children}
</div>
</div>
);
}

View File

@@ -1,7 +1,5 @@
import { redirect } from "next/navigation";
import { isUserAuthenticated } from "@/lib/server-auth";
import { AdminService } from "@/services/admin-service";
import { ManageAdminClientWrapper } from "@/components/admin";
export default async function ManageAdminPage() {
// Vérifier l'authentification
@@ -12,28 +10,6 @@ export default async function ManageAdminPage() {
redirect("/login");
}
// Charger les données côté serveur
try {
const adminData = await AdminService.getAdminData();
return (
<ManageAdminClientWrapper
teams={adminData.teams}
skillCategories={adminData.skillCategories}
teamStats={adminData.teamStats}
directionStats={adminData.directionStats}
/>
);
} catch (error) {
console.error("Failed to load admin data:", error);
return (
<div className="container mx-auto p-6">
<div className="flex items-center justify-center h-64">
<div className="text-lg text-red-500">
Erreur lors du chargement des données d'administration
</div>
</div>
</div>
);
}
// Rediriger vers la page skills par défaut
redirect("/admin/manage/skills");
}

View File

@@ -0,0 +1,37 @@
import { redirect } from "next/navigation";
import { isUserAuthenticated } from "@/lib/server-auth";
import { AdminService } from "@/services/admin-service";
import { SkillsManagementPage } from "@/components/admin/skills";
export default async function SkillsPage() {
// Vérifier l'authentification
const isAuthenticated = await isUserAuthenticated();
// Si pas de cookie d'authentification, rediriger vers login
if (!isAuthenticated) {
redirect("/login");
}
// Charger les données côté serveur
try {
const adminData = await AdminService.getAdminData();
return (
<SkillsManagementPage
skillCategories={adminData.skillCategories}
teams={adminData.teams}
/>
);
} catch (error) {
console.error("Failed to load admin data:", error);
return (
<div className="container mx-auto p-6">
<div className="flex items-center justify-center h-64">
<div className="text-lg text-red-500">
Erreur lors du chargement des données d'administration
</div>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,38 @@
import { redirect } from "next/navigation";
import { isUserAuthenticated } from "@/lib/server-auth";
import { AdminService } from "@/services/admin-service";
import { TeamsManagementPage } from "@/components/admin/teams";
export default async function TeamsPage() {
// Vérifier l'authentification
const isAuthenticated = await isUserAuthenticated();
// Si pas de cookie d'authentification, rediriger vers login
if (!isAuthenticated) {
redirect("/login");
}
// Charger les données côté serveur
try {
const adminData = await AdminService.getAdminData();
return (
<TeamsManagementPage
teams={adminData.teams}
teamStats={adminData.teamStats}
skillCategories={adminData.skillCategories}
/>
);
} catch (error) {
console.error("Failed to load admin data:", error);
return (
<div className="container mx-auto p-6">
<div className="flex items-center justify-center h-64">
<div className="text-lg text-red-500">
Erreur lors du chargement des données d'administration
</div>
</div>
</div>
);
}
}

View File

@@ -0,0 +1,36 @@
import { redirect } from "next/navigation";
import { isUserAuthenticated } from "@/lib/server-auth";
import { AdminService } from "@/services/admin-service";
import { UsersManagementPage } from "@/components/admin/users";
export default async function UsersPage() {
// Vérifier l'authentification
const isAuthenticated = await isUserAuthenticated();
// Si pas de cookie d'authentification, rediriger vers login
if (!isAuthenticated) {
redirect("/login");
}
// Charger les données côté serveur
try {
const adminData = await AdminService.getAdminData();
return (
<UsersManagementPage
teams={adminData.teams}
/>
);
} catch (error) {
console.error("Failed to load admin data:", error);
return (
<div className="container mx-auto p-6">
<div className="flex items-center justify-center h-64">
<div className="text-lg text-red-500">
Erreur lors du chargement des données d'administration
</div>
</div>
</div>
);
}
}

View File

@@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from "next/server";
import { getPool } from "@/services/database";
import { isUserAuthenticated } from "@/lib/server-auth";
// Configuration pour éviter la génération statique
export const dynamic = "force-dynamic";
// GET - Récupérer toutes les skills
export async function GET() {
try {

View File

@@ -1,3 +1,2 @@
// Composants de layout et navigation
export { ManageAdminClientWrapper } from "./manage-admin-client-wrapper";
export { ManageContentTabs } from "./manage-content-tabs";
export { ManageNavigation } from "./manage-navigation";

View File

@@ -1,43 +0,0 @@
"use client";
import { useState } from "react";
import { Team, SkillCategory } from "@/lib/types";
import { TeamStats, DirectionStats } from "@/services/admin-service";
import { AdminHeader } from "../utils/admin-header";
import { ManageContentTabs } from "./manage-content-tabs";
interface ManageAdminClientWrapperProps {
teams: Team[];
skillCategories: SkillCategory[];
teamStats: TeamStats[];
directionStats: DirectionStats[];
}
export function ManageAdminClientWrapper({
teams,
skillCategories,
teamStats,
directionStats,
}: ManageAdminClientWrapperProps) {
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-6 max-w-7xl space-y-6">
{/* Header */}
<AdminHeader />
{/* Main Content Tabs */}
<ManageContentTabs
teams={teams}
skillCategories={skillCategories}
teamStats={teamStats}
directionStats={directionStats}
/>
</div>
</div>
);
}

View File

@@ -1,69 +0,0 @@
"use client";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Code2, Users, User } from "lucide-react";
import { Team, SkillCategory } from "@/lib/types";
import { TeamStats, DirectionStats } from "@/services/admin-service";
import { SkillsManagement } from "../management/pages/skills-management";
import { TeamsManagement } from "../management/pages/teams-management";
import { UsersManagement } from "../management/pages/users-management";
interface ManageContentTabsProps {
teams: Team[];
skillCategories: SkillCategory[];
teamStats: TeamStats[];
directionStats: DirectionStats[];
}
export function ManageContentTabs({
teams,
skillCategories,
teamStats,
directionStats,
}: ManageContentTabsProps) {
return (
<Tabs defaultValue="skills" className="w-full">
<div className="bg-white/5 backdrop-blur-sm border border-white/10 rounded-2xl p-1 mb-6 w-fit mx-auto">
<TabsList className="grid w-full grid-cols-3 bg-transparent border-0">
<TabsTrigger
value="skills"
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-slate-400 hover:text-white transition-colors"
>
<Code2 className="w-4 h-4 mr-2" />
Gestion des Skills
</TabsTrigger>
<TabsTrigger
value="teams"
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-slate-400 hover:text-white transition-colors"
>
<Users className="w-4 h-4 mr-2" />
Gestion des Teams
</TabsTrigger>
<TabsTrigger
value="users"
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-slate-400 hover:text-white transition-colors"
>
<User className="w-4 h-4 mr-2" />
Gestion des Utilisateurs
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="skills" className="space-y-4">
<SkillsManagement skillCategories={skillCategories} teams={teams} />
</TabsContent>
<TabsContent value="teams" className="space-y-4">
<TeamsManagement
teams={teams}
teamStats={teamStats}
skillCategories={skillCategories}
/>
</TabsContent>
<TabsContent value="users" className="space-y-4">
<UsersManagement />
</TabsContent>
</Tabs>
);
}

View File

@@ -0,0 +1,58 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Code2, Users, User } from "lucide-react";
import { cn } from "@/lib/utils";
export function ManageNavigation() {
const pathname = usePathname();
const navItems = [
{
href: "/admin/manage/skills",
label: "Gestion des Skills",
icon: Code2,
value: "skills",
},
{
href: "/admin/manage/teams",
label: "Gestion des Teams",
icon: Users,
value: "teams",
},
{
href: "/admin/manage/users",
label: "Gestion des Utilisateurs",
icon: User,
value: "users",
},
];
return (
<div className="bg-white/5 backdrop-blur-sm border border-white/10 rounded-2xl p-1 mb-6 w-fit mx-auto">
<div className="grid grid-cols-3 bg-transparent border-0 gap-1">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href;
return (
<Link
key={item.value}
href={item.href}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
isActive
? "bg-white/20 text-white"
: "text-slate-400 hover:text-white hover:bg-white/10"
)}
>
<Icon className="w-4 h-4 mr-2" />
{item.label}
</Link>
);
})}
</div>
</div>
);
}

View File

@@ -1,4 +1,2 @@
// Composants de pages de gestion
export { SkillsManagement } from "./skills-management";
export { TeamsManagement } from "./teams-management";
export { UsersManagement } from "./users-management";
// Les composants sont maintenant organisés dans leurs dossiers respectifs

View File

@@ -1,518 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import {
Plus,
Edit,
Trash2,
Code2,
Palette,
Database,
Cloud,
Shield,
Smartphone,
Layers,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast";
import { SkillCategory, Team } from "@/lib/types";
import {
AdminManagementService,
Skill,
} from "@/services/admin-management-service";
import { TechIcon } from "@/components/icons/tech-icon";
import {
TreeCategoryHeader,
TreeItemRow,
} from "@/components/admin";
import { TreeViewPage } from "../tree-view-page";
import { useTreeView } from "@/hooks/use-tree-view";
import { useFormDialog } from "@/hooks/use-form-dialog";
interface SkillsManagementProps {
skillCategories: SkillCategory[];
teams: Team[];
}
interface SkillFormData {
name: string;
categoryId: string;
description: string;
icon: string;
}
export function SkillsManagement({
skillCategories,
teams,
}: SkillsManagementProps) {
const [searchTerm, setSearchTerm] = useState("");
const [editingSkill, setEditingSkill] = useState<any>(null);
const [skillFormData, setSkillFormData] = useState<SkillFormData>({
name: "",
categoryId: "",
description: "",
icon: "",
});
const { toast } = useToast();
const { isCreateDialogOpen, isEditDialogOpen, openCreateDialog, closeCreateDialog, openEditDialog, closeEditDialog } = useFormDialog();
// État des skills
const [skills, setSkills] = useState<Skill[]>([]);
const [isLoading, setIsLoading] = useState(true);
// Utilisation du hook factorisé
const {
filteredDataByCategory: filteredSkillsByCategory,
expandedCategories,
toggleCategory,
expandAll,
collapseAll,
} = useTreeView({
data: skills,
searchFields: ['name', 'description'],
groupBy: (skill) => skill.category,
searchTerm,
onSearchChange: setSearchTerm,
});
// Charger les skills depuis l'API
const fetchSkills = async () => {
try {
setIsLoading(true);
const skillsData = await AdminManagementService.getSkills();
setSkills(skillsData);
} catch (error) {
console.error("Error fetching skills:", error);
toast({
title: "Erreur",
description: "Impossible de charger les skills",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
// Charger les skills au montage du composant
useEffect(() => {
fetchSkills();
}, []);
const handleCreateSkill = async () => {
if (!skillFormData.name || !skillFormData.categoryId) {
toast({
title: "Erreur",
description: "Veuillez remplir tous les champs obligatoires",
variant: "destructive",
});
return;
}
try {
const categoryIndex = parseInt(skillFormData.categoryId);
const category = skillCategories[categoryIndex];
const skillData = {
...skillFormData,
category: category.category,
};
const newSkill = await AdminManagementService.createSkill(skillData);
setSkills([...skills, newSkill]);
setSkillFormData({ name: "", categoryId: "", description: "", icon: "" });
closeCreateDialog();
toast({
title: "Succès",
description: "Skill créée avec succès",
});
} catch (error: any) {
toast({
title: "Erreur",
description: error.message || "Erreur lors de la création de la skill",
variant: "destructive",
});
}
};
const handleEditSkill = (skill: any) => {
setEditingSkill(skill);
const categoryIndex = skillCategories.findIndex(
(cat) => cat.category === skill.category
);
setSkillFormData({
name: skill.name,
categoryId: categoryIndex !== -1 ? categoryIndex.toString() : "",
description: skill.description,
icon: skill.icon,
});
openEditDialog();
};
const handleUpdateSkill = async () => {
if (!editingSkill || !skillFormData.name || !skillFormData.categoryId) {
toast({
title: "Erreur",
description: "Veuillez remplir tous les champs obligatoires",
variant: "destructive",
});
return;
}
try {
const categoryIndex = parseInt(skillFormData.categoryId);
const category = skillCategories[categoryIndex];
const skillData = {
id: editingSkill.id,
...skillFormData,
category: category.category,
usageCount: editingSkill.usageCount,
};
const updatedSkill = await AdminManagementService.updateSkill(skillData);
const updatedSkills = skills.map((skill) =>
skill.id === editingSkill.id ? updatedSkill : skill
);
setSkills(updatedSkills);
closeEditDialog();
setEditingSkill(null);
setSkillFormData({ name: "", categoryId: "", description: "", icon: "" });
toast({
title: "Succès",
description: "Skill mise à jour avec succès",
});
} catch (error: any) {
toast({
title: "Erreur",
description:
error.message || "Erreur lors de la mise à jour de la skill",
variant: "destructive",
});
}
};
const handleDeleteSkill = async (skillId: string) => {
if (
confirm(
"Êtes-vous sûr de vouloir supprimer cette skill ? Cette action est irréversible."
)
) {
try {
await AdminManagementService.deleteSkill(skillId);
setSkills(skills.filter((s) => s.id !== skillId));
toast({
title: "Succès",
description: "Skill supprimée avec succès",
});
} catch (error: any) {
toast({
title: "Erreur",
description:
error.message || "Erreur lors de la suppression de la skill",
variant: "destructive",
});
}
}
};
const resetForm = () => {
setSkillFormData({ name: "", categoryId: "", description: "", icon: "" });
};
// Fonction pour obtenir l'icône de la catégorie
const getCategoryIcon = (category: string) => {
const categoryName = category.toLowerCase();
if (categoryName.includes("frontend") || categoryName.includes("front"))
return Code2;
if (categoryName.includes("backend") || categoryName.includes("back"))
return Layers;
if (
categoryName.includes("design") ||
categoryName.includes("ui") ||
categoryName.includes("ux")
)
return Palette;
if (categoryName.includes("data") || categoryName.includes("database"))
return Database;
if (categoryName.includes("cloud") || categoryName.includes("devops"))
return Cloud;
if (categoryName.includes("security") || categoryName.includes("securité"))
return Shield;
if (
categoryName.includes("mobile") ||
categoryName.includes("android") ||
categoryName.includes("ios")
)
return Smartphone;
return Code2; // Par défaut
};
const headerActions = (
<Dialog open={isCreateDialogOpen} onOpenChange={closeCreateDialog}>
<DialogTrigger asChild>
<Button onClick={() => { resetForm(); openCreateDialog(); }}>
<Plus className="w-4 h-4 mr-2" />
Nouvelle Skill
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Créer une nouvelle skill</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="skill-name">Nom de la skill *</Label>
<Input
id="skill-name"
value={skillFormData.name}
onChange={(e) =>
setSkillFormData({ ...skillFormData, name: e.target.value })
}
placeholder="Ex: React, Node.js, PostgreSQL"
/>
</div>
<div>
<Label htmlFor="skill-category">Catégorie *</Label>
<Select
value={skillFormData.categoryId}
onValueChange={(value) =>
setSkillFormData({ ...skillFormData, categoryId: value })
}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionner une catégorie" />
</SelectTrigger>
<SelectContent>
{skillCategories.map((category, index) => (
<SelectItem key={index} value={index.toString()}>
{category.category}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="skill-description">Description</Label>
<Textarea
id="skill-description"
value={skillFormData.description}
onChange={(e) =>
setSkillFormData({
...skillFormData,
description: e.target.value,
})
}
placeholder="Description de la skill..."
rows={3}
/>
</div>
<div>
<Label htmlFor="skill-icon">Icône</Label>
<Input
id="skill-icon"
value={skillFormData.icon}
onChange={(e) =>
setSkillFormData({ ...skillFormData, icon: e.target.value })
}
placeholder="Ex: react, nodejs, postgresql"
/>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button
variant="outline"
onClick={closeCreateDialog}
>
Annuler
</Button>
<Button onClick={handleCreateSkill}>Créer</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
const emptyState = (
<div className="text-center py-8">
<Code2 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 skill trouvée" : "Aucune skill"}
</h3>
<p className="text-slate-500 text-sm">
{searchTerm
? "Essayez de modifier vos critères de recherche"
: "Commencez par créer votre première skill"}
</p>
</div>
);
return (
<>
<TreeViewPage
title="Gestion des Skills"
description="Créez, modifiez et supprimez les skills de votre organisation"
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
onExpandAll={expandAll}
onCollapseAll={collapseAll}
searchPlaceholder="Rechercher une skill..."
hasContent={Object.keys(filteredSkillsByCategory).length > 0}
isLoading={isLoading}
loadingMessage="Chargement des skills..."
emptyState={emptyState}
headerActions={headerActions}
>
{Object.entries(filteredSkillsByCategory).map(
([category, categorySkills], index) => (
<div key={category}>
<TreeCategoryHeader
category={category}
isExpanded={expandedCategories.has(category)}
onToggle={() => toggleCategory(category)}
icon={(() => {
const IconComponent = getCategoryIcon(category);
return <IconComponent className="w-5 h-5 text-blue-400" />;
})()}
itemCount={categorySkills.length}
itemLabel="skill"
showSeparator={index > 0}
/>
{/* Liste des skills de la catégorie */}
{expandedCategories.has(category) && (
<div className="bg-slate-950/30">
{categorySkills.map((skill, skillIndex) => (
<TreeItemRow
key={skill.id}
icon={
<div className="flex items-center gap-3">
<TechIcon
iconName={skill.icon}
className="w-5 h-5 text-green-400"
fallbackText={skill.name}
/>
<div className="p-1 bg-green-500/20 border border-green-500/30 rounded text-xs font-mono text-green-400 min-w-[3rem] text-center shrink-0">
{skill.icon || "?"}
</div>
</div>
}
title={skill.name}
subtitle={skill.description}
badges={[
{
text: `${skill.usageCount} util.`,
variant: "outline",
},
]}
onEdit={() => handleEditSkill(skill)}
onDelete={() => handleDeleteSkill(skill.id)}
canDelete={skill.usageCount === 0}
showSeparator={skillIndex > 0}
/>
))}
</div>
)}
</div>
)
)}
</TreeViewPage>
{/* Dialog d'édition */}
<Dialog open={isEditDialogOpen} onOpenChange={closeEditDialog}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Modifier la skill</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="edit-skill-name">Nom de la skill *</Label>
<Input
id="edit-skill-name"
value={skillFormData.name}
onChange={(e) =>
setSkillFormData({ ...skillFormData, name: e.target.value })
}
placeholder="Ex: React, Node.js, PostgreSQL"
/>
</div>
<div>
<Label htmlFor="edit-skill-category">Catégorie *</Label>
<Select
value={skillFormData.categoryId}
onValueChange={(value) =>
setSkillFormData({ ...skillFormData, categoryId: value })
}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionner une catégorie" />
</SelectTrigger>
<SelectContent>
{skillCategories.map((category, index) => (
<SelectItem key={index} value={index.toString()}>
{category.category}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="edit-skill-description">Description</Label>
<Textarea
id="edit-skill-description"
value={skillFormData.description}
onChange={(e) =>
setSkillFormData({
...skillFormData,
description: e.target.value,
})
}
placeholder="Description de la skill..."
rows={3}
/>
</div>
<div>
<Label htmlFor="edit-skill-icon">Icône</Label>
<Input
id="edit-skill-icon"
value={skillFormData.icon}
onChange={(e) =>
setSkillFormData({ ...skillFormData, icon: e.target.value })
}
placeholder="Ex: react, nodejs, postgresql"
/>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button
variant="outline"
onClick={closeEditDialog}
>
Annuler
</Button>
<Button onClick={handleUpdateSkill}>Mettre à jour</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,565 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { Plus, Edit, Trash2, Users, Building2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
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 {
TreeCategoryHeader,
TreeItemRow,
TeamMetrics,
} from "@/components/admin";
import { TeamMembersModal } from "@/components/admin/management/team-members-modal";
import { TreeViewPage } from "../tree-view-page";
import { useTreeView } from "@/hooks/use-tree-view";
import { useFormDialog } from "@/hooks/use-form-dialog";
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 [isMembersModalOpen, setIsMembersModalOpen] = useState(false);
const [selectedTeam, setSelectedTeam] = useState<any>(null);
const [editingTeam, setEditingTeam] = useState<any>(null);
const [teamFormData, setTeamFormData] = useState<TeamFormData>({
name: "",
direction: "",
});
const { toast } = useToast();
const {
isCreateDialogOpen,
isEditDialogOpen,
openCreateDialog,
closeCreateDialog,
openEditDialog,
closeEditDialog,
} = useFormDialog();
// État local pour les équipes et leurs stats
const [localTeams, setLocalTeams] = useState<TeamType[]>(teams);
const [localTeamStats, setLocalTeamStats] = useState<TeamStats[]>(teamStats);
// Utilisation du hook factorisé
const {
filteredDataByCategory: filteredTeamsByDirection,
expandedCategories: expandedDirections,
toggleCategory: toggleDirection,
expandAll,
collapseAll,
} = useTreeView({
data: localTeams,
searchFields: ["name"],
groupBy: (team) => team.direction,
searchTerm,
onSearchChange: setSearchTerm,
});
const getTeamStats = (teamId: string): TeamStats | undefined => {
return localTeamStats.find((stats) => stats.teamId === teamId);
};
// Fonction pour obtenir une équipe depuis la liste locale
const getLocalTeam = (teamId: string): TeamType | undefined => {
return localTeams.find((team) => team.id === 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();
}, []);
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: "" });
closeCreateDialog();
// Mettre à jour l'état local avec la nouvelle équipe
const newLocalTeam: TeamType = {
id: newTeam.id,
name: newTeam.name,
direction: newTeam.direction,
};
setLocalTeams((prev) => [...prev, newLocalTeam]);
// Ajouter les stats de la nouvelle équipe (avec les propriétés minimales)
const newTeamStats = {
teamId: newTeam.id,
teamName: newTeam.name,
direction: newTeam.direction,
totalMembers: 0,
averageSkillLevel: 0,
skillCoverage: 0,
topSkills: [],
members: [],
} as TeamStats;
setLocalTeamStats((prev) => [...prev, newTeamStats]);
} 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,
});
openEditDialog();
};
const handleViewMembers = (team: any) => {
setSelectedTeam(team);
setIsMembersModalOpen(true);
};
// Fonction pour mettre à jour les stats d'une équipe après suppression d'un membre
const updateTeamStatsAfterMemberRemoval = (teamId: string) => {
setLocalTeamStats((prev) => {
const updated = prev.map((stats) =>
stats.teamId === teamId
? { ...stats, totalMembers: Math.max(0, stats.totalMembers - 1) }
: stats
);
return updated;
});
};
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",
});
closeEditDialog();
setEditingTeam(null);
setTeamFormData({ name: "", direction: "" });
// Mettre à jour l'état local au lieu de recharger la page
// On pourrait mettre à jour les données locales ici si nécessaire
// Pour l'instant, on laisse l'utilisateur voir le changement
} 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 = getLocalTeam(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",
});
// Mettre à jour l'état local au lieu de recharger la page
// Supprimer l'équipe de la liste locale
setLocalTeams((prev) => prev.filter((t) => t.id !== teamId));
// Mettre à jour les stats locales
setLocalTeamStats((prev) =>
prev.filter((stats) => stats.teamId !== teamId)
);
} 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",
});
// Mettre à jour l'état local au lieu de recharger la page
// Supprimer les équipes de la direction de la liste locale
setLocalTeams((prev) =>
prev.filter((team) => team.direction !== direction)
);
// Supprimer les équipes de la direction des stats locales
setLocalTeamStats((prev) =>
prev.filter((stats) => stats.direction !== direction)
);
} 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(localTeams.map((team) => team.direction))
);
const headerActions = (
<Dialog open={isCreateDialogOpen} onOpenChange={closeCreateDialog}>
<DialogTrigger asChild>
<Button
onClick={() => {
resetForm();
openCreateDialog();
}}
>
<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={closeCreateDialog}>
Annuler
</Button>
<Button onClick={handleCreateTeam}>Créer</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
const 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>
);
return (
<>
<TreeViewPage
title="Gestion des Teams"
description="Créez, modifiez et supprimez les équipes de votre organisation"
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
onExpandAll={expandAll}
onCollapseAll={collapseAll}
searchPlaceholder="Rechercher une équipe..."
hasContent={Object.keys(filteredTeamsByDirection).length > 0}
emptyState={emptyState}
headerActions={headerActions}
>
{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)}
onViewMembers={() => handleViewMembers(team)}
canDelete={!stats || stats.totalMembers === 0}
showSeparator={teamIndex > 0}
hasMembers={stats ? stats.totalMembers > 0 : false}
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>
)
)}
</TreeViewPage>
{/* Dialog d'édition */}
<Dialog open={isEditDialogOpen} onOpenChange={closeEditDialog}>
<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={closeEditDialog}>
Annuler
</Button>
<Button onClick={handleUpdateTeam}>Mettre à jour</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Modal des membres d'équipe */}
{selectedTeam && (
<TeamMembersModal
teamId={selectedTeam.id}
teamName={selectedTeam.name}
isOpen={isMembersModalOpen}
onClose={() => {
setIsMembersModalOpen(false);
setSelectedTeam(null);
// Pas besoin de rafraîchir ici, les stats locales sont déjà à jour
}}
onMemberRemoved={() => {
// Mettre à jour les stats localement
if (selectedTeam) {
updateTeamStatsAfterMemberRemoval(selectedTeam.id);
}
}}
/>
)}
</>
);
}

View File

@@ -1,381 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { Users, User, Trash2, Building2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast";
import {
AdminManagementService,
Team,
} from "@/services/admin-management-service";
import { TreeCategoryHeader, TreeItemRow } from "@/components/admin";
import { TreeViewPage } from "../tree-view-page";
import { useTreeView } from "@/hooks/use-tree-view";
import { useFormDialog } from "@/hooks/use-form-dialog";
interface User {
uuid: string;
firstName: string;
lastName: string;
teamName?: string;
hasEvaluations: boolean;
}
interface UserFormData {
firstName: string;
lastName: string;
teamId: string;
}
export function UsersManagement() {
const [users, setUsers] = useState<User[]>([]);
const [teams, setTeams] = useState<Team[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deletingUserId, setDeletingUserId] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const [userFormData, setUserFormData] = useState<UserFormData>({
firstName: "",
lastName: "",
teamId: "",
});
const { toast } = useToast();
const { isCreateDialogOpen, openCreateDialog, closeCreateDialog } =
useFormDialog();
// Utilisation du hook factorisé
const {
filteredDataByCategory: filteredUsersByTeam,
expandedCategories: expandedTeams,
toggleCategory: toggleTeam,
expandAll,
collapseAll,
} = useTreeView({
data: users,
searchFields: ["firstName", "lastName"],
groupBy: (user) => user.teamName || "Sans équipe",
searchTerm,
onSearchChange: setSearchTerm,
});
useEffect(() => {
fetchUsers();
fetchTeams();
}, []);
const fetchUsers = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch("/api/admin/users");
if (!response.ok) {
throw new Error("Erreur lors de la récupération des utilisateurs");
}
const usersData = await response.json();
setUsers(usersData);
} catch (err: any) {
setError(err.message || "Erreur lors du chargement des utilisateurs");
} finally {
setIsLoading(false);
}
};
const fetchTeams = async () => {
try {
const teamsData = await AdminManagementService.getTeams();
setTeams(teamsData);
} catch (error) {
console.error("Error fetching teams:", error);
}
};
const handleCreateUser = async () => {
if (!userFormData.firstName || !userFormData.lastName) {
toast({
title: "Erreur",
description: "Veuillez remplir tous les champs obligatoires",
variant: "destructive",
});
return;
}
try {
// TODO: Implémenter la création d'utilisateur
toast({
title: "Succès",
description: "Utilisateur créé avec succès",
});
setUserFormData({ firstName: "", lastName: "", teamId: "" });
closeCreateDialog();
// Rafraîchir la liste
fetchUsers();
} catch (error: any) {
toast({
title: "Erreur",
description:
error.message || "Erreur lors de la création de l'utilisateur",
variant: "destructive",
});
}
};
const handleDeleteUser = async (user: User) => {
if (user.teamName) {
toast({
title: "Action impossible",
description:
"Retirez d'abord l'utilisateur de son équipe avant de le supprimer",
variant: "destructive",
});
return;
}
if (
!confirm(
`Êtes-vous sûr de vouloir supprimer définitivement ${user.firstName} ${user.lastName} ?\n\nCette action supprimera aussi toutes ses évaluations par skills et est irréversible.`
)
) {
return;
}
setDeletingUserId(user.uuid);
try {
await AdminManagementService.deleteUser(user.uuid);
// Mettre à jour la liste locale
setUsers((prev) => prev.filter((u) => u.uuid !== user.uuid));
toast({
title: "Succès",
description: `${user.firstName} ${user.lastName} a été supprimé avec succès`,
});
} catch (err: any) {
toast({
title: "Erreur",
description:
err.message || "Erreur lors de la suppression de l'utilisateur",
variant: "destructive",
});
} finally {
setDeletingUserId(null);
}
};
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-white">
Gestion des utilisateurs
</h2>
<Button disabled>
<Users className="w-4 h-4 mr-2" />
Créer un utilisateur
</Button>
</div>
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-3 p-3">
<div className="w-8 h-8 rounded-full bg-slate-700 animate-pulse" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-slate-700 rounded animate-pulse w-32" />
<div className="h-3 bg-slate-700 rounded animate-pulse w-24" />
</div>
<div className="w-20 h-8 bg-slate-700 rounded animate-pulse" />
</div>
))}
</div>
</div>
);
}
if (error) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-white">
Gestion des utilisateurs
</h2>
</div>
<div className="text-center py-8">
<div className="w-12 h-12 rounded-full bg-red-500/20 flex items-center justify-center mx-auto mb-4">
<Users className="w-6 h-6 text-red-400" />
</div>
<p className="text-red-400 mb-4">{error}</p>
<Button onClick={fetchUsers} variant="outline">
Réessayer
</Button>
</div>
</div>
);
}
const headerActions = (
<Dialog open={isCreateDialogOpen} onOpenChange={closeCreateDialog}>
<DialogTrigger asChild>
<Button onClick={openCreateDialog}>
<Users className="w-4 h-4 mr-2" />
Créer un utilisateur
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Créer un nouvel utilisateur</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="user-first-name">Prénom *</Label>
<Input
id="user-first-name"
value={userFormData.firstName}
onChange={(e) =>
setUserFormData({
...userFormData,
firstName: e.target.value,
})
}
placeholder="Prénom de l'utilisateur"
/>
</div>
<div>
<Label htmlFor="user-last-name">Nom *</Label>
<Input
id="user-last-name"
value={userFormData.lastName}
onChange={(e) =>
setUserFormData({
...userFormData,
lastName: e.target.value,
})
}
placeholder="Nom de l'utilisateur"
/>
</div>
<div>
<Label htmlFor="user-team">Équipe</Label>
<Select
value={userFormData.teamId}
onValueChange={(value) =>
setUserFormData({ ...userFormData, teamId: value })
}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionner une équipe (optionnel)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Aucune équipe</SelectItem>
{teams.map((team) => (
<SelectItem key={team.id} value={team.id}>
{team.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button variant="outline" onClick={closeCreateDialog}>
Annuler
</Button>
<Button onClick={handleCreateUser}>Créer</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
const emptyState = (
<div className="text-center py-8">
<Users className="w-10 h-10 text-slate-500 mx-auto mb-3" />
<h3 className="text-base font-medium text-slate-400 mb-1">
{searchTerm ? "Aucun utilisateur trouvé" : "Aucun utilisateur"}
</h3>
<p className="text-sm text-slate-500">
{searchTerm
? "Essayez de modifier vos critères de recherche"
: "Commencez par créer votre premier utilisateur"}
</p>
</div>
);
return (
<TreeViewPage
title="Gestion des utilisateurs"
description="Gérez les utilisateurs de votre organisation"
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
onExpandAll={expandAll}
onCollapseAll={collapseAll}
searchPlaceholder="Rechercher un utilisateur..."
hasContent={Object.keys(filteredUsersByTeam).length > 0}
emptyState={emptyState}
headerActions={headerActions}
>
{Object.entries(filteredUsersByTeam).map(
([teamName, teamUsers], index) => (
<div key={teamName}>
<TreeCategoryHeader
category={teamName}
isExpanded={expandedTeams.has(teamName)}
onToggle={() => toggleTeam(teamName)}
icon={<Building2 className="w-5 h-5 text-blue-400" />}
itemCount={teamUsers.length}
itemLabel="utilisateur"
showSeparator={index > 0}
canDelete={false}
isDirection={false}
/>
{/* Liste des utilisateurs de l'équipe */}
{expandedTeams.has(teamName) && (
<div className="bg-slate-950/30">
{teamUsers.map((user, userIndex) => (
<TreeItemRow
key={user.uuid}
icon={<User className="w-5 h-5 text-green-400" />}
title={`${user.firstName} ${user.lastName}`}
badges={[
{
text: user.hasEvaluations
? "A des évaluations"
: "Aucune évaluation",
variant: user.hasEvaluations ? "default" : "outline",
},
]}
onDelete={() => handleDeleteUser(user)}
canDelete={!user.teamName}
showSeparator={userIndex > 0}
additionalInfo={
<p className="text-slate-500 text-xs">
{user.teamName
? `Équipe: ${user.teamName}`
: "Aucune équipe"}
</p>
}
/>
))}
</div>
)}
</div>
)
)}
</TreeViewPage>
);
}

View File

@@ -0,0 +1,3 @@
export { SkillsManagementPage } from "./skills-management-page";
export { SkillFormDialog } from "./skill-form-dialog";
export { SkillsList } from "./skills-list";

View File

@@ -0,0 +1,123 @@
"use client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { SkillCategory } from "@/lib/types";
interface SkillFormData {
name: string;
categoryId: string;
description: string;
icon: string;
}
interface SkillFormDialogProps {
isOpen: boolean;
onClose: () => void;
onSubmit: () => void;
title: string;
formData: SkillFormData;
onFormDataChange: (data: SkillFormData) => void;
skillCategories: SkillCategory[];
isSubmitting?: boolean;
}
export function SkillFormDialog({
isOpen,
onClose,
onSubmit,
title,
formData,
onFormDataChange,
skillCategories,
isSubmitting = false,
}: SkillFormDialogProps) {
const handleInputChange = (field: keyof SkillFormData, value: string) => {
onFormDataChange({ ...formData, [field]: value });
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="skill-name">Nom de la skill *</Label>
<Input
id="skill-name"
value={formData.name}
onChange={(e) => handleInputChange("name", e.target.value)}
placeholder="Ex: React, Node.js, PostgreSQL"
/>
</div>
<div>
<Label htmlFor="skill-category">Catégorie *</Label>
<Select
value={formData.categoryId}
onValueChange={(value) => handleInputChange("categoryId", value)}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionner une catégorie" />
</SelectTrigger>
<SelectContent>
{skillCategories.map((category, index) => (
<SelectItem key={index} value={index.toString()}>
{category.category}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="skill-description">Description</Label>
<Textarea
id="skill-description"
value={formData.description}
onChange={(e) => handleInputChange("description", e.target.value)}
placeholder="Description de la skill..."
rows={3}
/>
</div>
<div>
<Label htmlFor="skill-icon">Icône</Label>
<Input
id="skill-icon"
value={formData.icon}
onChange={(e) => handleInputChange("icon", e.target.value)}
placeholder="Ex: react, nodejs, postgresql"
/>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
Annuler
</Button>
<Button onClick={onSubmit} disabled={isSubmitting}>
{isSubmitting
? "En cours..."
: title.includes("Créer")
? "Créer"
: "Mettre à jour"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,116 @@
"use client";
import {
Code2,
Palette,
Database,
Cloud,
Shield,
Smartphone,
Layers,
} from "lucide-react";
import { TreeCategoryHeader, TreeItemRow } from "@/components/admin";
import { TechIcon } from "@/components/icons/tech-icon";
import { Skill } from "@/services/admin-management-service";
interface SkillsListProps {
filteredSkillsByCategory: Record<string, Skill[]>;
expandedCategories: Set<string>;
onToggleCategory: (category: string) => void;
onEditSkill: (skill: Skill) => void;
onDeleteSkill: (skillId: string) => void;
}
export function SkillsList({
filteredSkillsByCategory,
expandedCategories,
onToggleCategory,
onEditSkill,
onDeleteSkill,
}: SkillsListProps) {
// Fonction pour obtenir l'icône de la catégorie
const getCategoryIcon = (category: string) => {
const categoryName = category.toLowerCase();
if (categoryName.includes("frontend") || categoryName.includes("front"))
return Code2;
if (categoryName.includes("backend") || categoryName.includes("back"))
return Layers;
if (
categoryName.includes("design") ||
categoryName.includes("ui") ||
categoryName.includes("ux")
)
return Palette;
if (categoryName.includes("data") || categoryName.includes("database"))
return Database;
if (categoryName.includes("cloud") || categoryName.includes("devops"))
return Cloud;
if (categoryName.includes("security") || categoryName.includes("securité"))
return Shield;
if (
categoryName.includes("mobile") ||
categoryName.includes("android") ||
categoryName.includes("ios")
)
return Smartphone;
return Code2; // Par défaut
};
return (
<>
{Object.entries(filteredSkillsByCategory).map(
([category, categorySkills], index) => (
<div key={category}>
<TreeCategoryHeader
category={category}
isExpanded={expandedCategories.has(category)}
onToggle={() => onToggleCategory(category)}
icon={(() => {
const IconComponent = getCategoryIcon(category);
return <IconComponent className="w-5 h-5 text-blue-400" />;
})()}
itemCount={categorySkills.length}
itemLabel="skill"
showSeparator={index > 0}
/>
{/* Liste des skills de la catégorie */}
{expandedCategories.has(category) && (
<div className="bg-slate-950/30">
{categorySkills.map((skill, skillIndex) => (
<TreeItemRow
key={skill.id}
icon={
<div className="flex items-center gap-3">
<TechIcon
iconName={skill.icon}
className="w-5 h-5 text-green-400"
fallbackText={skill.name}
/>
<div className="p-1 bg-green-500/20 border border-green-500/30 rounded text-xs font-mono text-green-400 min-w-[3rem] text-center shrink-0">
{skill.icon || "?"}
</div>
</div>
}
title={skill.name}
subtitle={skill.description}
badges={[
{
text: `${skill.usageCount} util.`,
variant: "outline",
},
]}
onEdit={() => onEditSkill(skill)}
onDelete={() => onDeleteSkill(skill.id)}
canDelete={skill.usageCount === 0}
showSeparator={skillIndex > 0}
/>
))}
</div>
)}
</div>
)
)}
</>
);
}

View File

@@ -0,0 +1,158 @@
"use client";
import { useState } from "react";
import { Plus, Code2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { DialogTrigger } from "@/components/ui/dialog";
import { SkillCategory, Team } from "@/lib/types";
import { TreeViewPage } from "../management/tree-view-page";
import { useTreeView } from "@/hooks/use-tree-view";
import { useFormDialog } from "@/hooks/use-form-dialog";
import { useSkillsManagement } from "@/hooks/use-skills-management";
import { SkillFormDialog } from "./skill-form-dialog";
import { SkillsList } from "./skills-list";
interface SkillsManagementPageProps {
skillCategories: SkillCategory[];
teams: Team[];
}
export function SkillsManagementPage({
skillCategories,
teams,
}: SkillsManagementPageProps) {
const [searchTerm, setSearchTerm] = useState("");
const {
isCreateDialogOpen,
isEditDialogOpen,
openCreateDialog,
closeCreateDialog,
openEditDialog,
closeEditDialog,
} = useFormDialog();
const {
skills,
isLoading,
editingSkill,
skillFormData,
isSubmitting,
setSkillFormData,
resetForm,
handleCreateSkill,
handleEditSkill,
handleUpdateSkill,
handleDeleteSkill,
} = useSkillsManagement(skillCategories);
// Utilisation du hook factorisé pour la vue arborescente
const {
filteredDataByCategory: filteredSkillsByCategory,
expandedCategories,
toggleCategory,
expandAll,
collapseAll,
} = useTreeView({
data: skills,
searchFields: ["name", "description"],
groupBy: (skill) => skill.category,
searchTerm,
onSearchChange: setSearchTerm,
});
const handleCreateSubmit = async () => {
const success = await handleCreateSkill();
if (success) {
closeCreateDialog();
}
};
const handleEditSubmit = async () => {
const success = await handleUpdateSkill();
if (success) {
closeEditDialog();
}
};
const handleOpenCreateDialog = () => {
resetForm();
openCreateDialog();
};
const handleOpenEditDialog = (skill: any) => {
handleEditSkill(skill);
openEditDialog();
};
const headerActions = (
<Button onClick={handleOpenCreateDialog}>
<Plus className="w-4 h-4 mr-2" />
Nouvelle Skill
</Button>
);
const emptyState = (
<div className="text-center py-8">
<Code2 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 skill trouvée" : "Aucune skill"}
</h3>
<p className="text-slate-500 text-sm">
{searchTerm
? "Essayez de modifier vos critères de recherche"
: "Commencez par créer votre première skill"}
</p>
</div>
);
return (
<>
<TreeViewPage
title="Gestion des Skills"
description="Créez, modifiez et supprimez les skills de votre organisation"
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
onExpandAll={expandAll}
onCollapseAll={collapseAll}
searchPlaceholder="Rechercher une skill..."
hasContent={Object.keys(filteredSkillsByCategory).length > 0}
isLoading={isLoading}
loadingMessage="Chargement des skills..."
emptyState={emptyState}
headerActions={headerActions}
>
<SkillsList
filteredSkillsByCategory={filteredSkillsByCategory}
expandedCategories={expandedCategories}
onToggleCategory={toggleCategory}
onEditSkill={handleOpenEditDialog}
onDeleteSkill={handleDeleteSkill}
/>
</TreeViewPage>
{/* Dialog de création */}
<SkillFormDialog
isOpen={isCreateDialogOpen}
onClose={closeCreateDialog}
onSubmit={handleCreateSubmit}
title="Créer une nouvelle skill"
formData={skillFormData}
onFormDataChange={setSkillFormData}
skillCategories={skillCategories}
isSubmitting={isSubmitting}
/>
{/* Dialog d'édition */}
<SkillFormDialog
isOpen={isEditDialogOpen}
onClose={closeEditDialog}
onSubmit={handleEditSubmit}
title="Modifier la skill"
formData={skillFormData}
onFormDataChange={setSkillFormData}
skillCategories={skillCategories}
isSubmitting={isSubmitting}
/>
</>
);
}

View File

@@ -0,0 +1,3 @@
export { TeamsManagementPage } from "./teams-management-page";
export { TeamFormDialog } from "./team-form-dialog";
export { TeamsList } from "./teams-list";

View File

@@ -0,0 +1,96 @@
"use client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface TeamFormData {
name: string;
direction: string;
}
interface TeamFormDialogProps {
isOpen: boolean;
onClose: () => void;
onSubmit: () => void;
title: string;
formData: TeamFormData;
onFormDataChange: (data: TeamFormData) => void;
directions: string[];
isSubmitting?: boolean;
}
export function TeamFormDialog({
isOpen,
onClose,
onSubmit,
title,
formData,
onFormDataChange,
directions,
isSubmitting = false,
}: TeamFormDialogProps) {
const handleInputChange = (field: keyof TeamFormData, value: string) => {
onFormDataChange({ ...formData, [field]: value });
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="team-name">Nom de l'équipe *</Label>
<Input
id="team-name"
value={formData.name}
onChange={(e) => handleInputChange("name", e.target.value)}
placeholder="Ex: Équipe Frontend, Équipe Backend"
/>
</div>
<div>
<Label htmlFor="team-direction">Direction *</Label>
<Select
value={formData.direction}
onValueChange={(value) => handleInputChange("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={onClose} disabled={isSubmitting}>
Annuler
</Button>
<Button onClick={onSubmit} disabled={isSubmitting}>
{isSubmitting ? "En cours..." : title.includes("Créer") ? "Créer" : "Mettre à jour"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,97 @@
"use client";
import { Users, Building2 } from "lucide-react";
import { TreeCategoryHeader, TreeItemRow, TeamMetrics } from "@/components/admin";
import { Team as TeamType } from "@/lib/types";
import { TeamStats } from "@/services/admin-service";
interface TeamsListProps {
filteredTeamsByDirection: Record<string, TeamType[]>;
expandedDirections: Set<string>;
onToggleDirection: (direction: string) => void;
onEditTeam: (team: TeamType) => void;
onDeleteTeam: (teamId: string) => void;
onDeleteDirection: (direction: string) => void;
onViewMembers: (team: TeamType) => void;
getTeamStats: (teamId: string) => TeamStats | undefined;
}
export function TeamsList({
filteredTeamsByDirection,
expandedDirections,
onToggleDirection,
onEditTeam,
onDeleteTeam,
onDeleteDirection,
onViewMembers,
getTeamStats,
}: TeamsListProps) {
return (
<>
{Object.entries(filteredTeamsByDirection).map(
([direction, directionTeams], index) => (
<div key={direction}>
<TreeCategoryHeader
category={direction}
isExpanded={expandedDirections.has(direction)}
onToggle={() => onToggleDirection(direction)}
icon={<Building2 className="w-5 h-5 text-blue-400" />}
itemCount={directionTeams.length}
itemLabel="équipe"
showSeparator={index > 0}
onDelete={() => onDeleteDirection(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={() => onEditTeam(team)}
onDelete={() => onDeleteTeam(team.id)}
onViewMembers={() => onViewMembers(team)}
canDelete={!stats || stats.totalMembers === 0}
showSeparator={teamIndex > 0}
hasMembers={stats ? stats.totalMembers > 0 : false}
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>
)
)}
</>
);
}

View File

@@ -0,0 +1,189 @@
"use client";
import { useState } from "react";
import { Plus, Building2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SkillCategory, Team as TeamType } from "@/lib/types";
import { TeamStats } from "@/services/admin-service";
import { TreeViewPage } from "../management/tree-view-page";
import { useTreeView } from "@/hooks/use-tree-view";
import { useFormDialog } from "@/hooks/use-form-dialog";
import { useTeamsManagement } from "@/hooks/use-teams-management";
import { TeamFormDialog } from "./team-form-dialog";
import { TeamsList } from "./teams-list";
import { TeamMembersModal } from "../management/team-members-modal";
interface TeamsManagementPageProps {
teams: TeamType[];
teamStats: TeamStats[];
skillCategories: SkillCategory[];
}
export function TeamsManagementPage({
teams,
teamStats,
skillCategories,
}: TeamsManagementPageProps) {
const [searchTerm, setSearchTerm] = useState("");
const [isMembersModalOpen, setIsMembersModalOpen] = useState(false);
const [selectedTeam, setSelectedTeam] = useState<TeamType | null>(null);
const { isCreateDialogOpen, isEditDialogOpen, openCreateDialog, closeCreateDialog, openEditDialog, closeEditDialog } = useFormDialog();
const {
teams: localTeams,
teamStats: localTeamStats,
editingTeam,
teamFormData,
isSubmitting,
directions,
setTeamFormData,
resetForm,
getTeamStats,
handleCreateTeam,
handleEditTeam,
handleUpdateTeam,
handleDeleteTeam,
handleDeleteDirection,
} = useTeamsManagement(teams, teamStats);
// Utilisation du hook factorisé pour la vue arborescente
const {
filteredDataByCategory: filteredTeamsByDirection,
expandedCategories: expandedDirections,
toggleCategory: toggleDirection,
expandAll,
collapseAll,
} = useTreeView({
data: localTeams,
searchFields: ["name"],
groupBy: (team) => team.direction,
searchTerm,
onSearchChange: setSearchTerm,
});
const handleCreateSubmit = async () => {
const success = await handleCreateTeam();
if (success) {
closeCreateDialog();
}
};
const handleEditSubmit = async () => {
const success = await handleUpdateTeam();
if (success) {
closeEditDialog();
}
};
const handleOpenCreateDialog = () => {
resetForm();
openCreateDialog();
};
const handleOpenEditDialog = (team: TeamType) => {
handleEditTeam(team);
openEditDialog();
};
const handleViewMembers = (team: TeamType) => {
setSelectedTeam(team);
setIsMembersModalOpen(true);
};
// Fonction pour mettre à jour les stats d'une équipe après suppression d'un membre
const updateTeamStatsAfterMemberRemoval = (teamId: string) => {
// Cette logique sera gérée par le hook useTeamsManagement
};
const headerActions = (
<Button onClick={handleOpenCreateDialog}>
<Plus className="w-4 h-4 mr-2" />
Nouvelle Équipe
</Button>
);
const 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>
);
return (
<>
<TreeViewPage
title="Gestion des Teams"
description="Créez, modifiez et supprimez les équipes de votre organisation"
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
onExpandAll={expandAll}
onCollapseAll={collapseAll}
searchPlaceholder="Rechercher une équipe..."
hasContent={Object.keys(filteredTeamsByDirection).length > 0}
emptyState={emptyState}
headerActions={headerActions}
>
<TeamsList
filteredTeamsByDirection={filteredTeamsByDirection}
expandedDirections={expandedDirections}
onToggleDirection={toggleDirection}
onEditTeam={handleOpenEditDialog}
onDeleteTeam={handleDeleteTeam}
onDeleteDirection={handleDeleteDirection}
onViewMembers={handleViewMembers}
getTeamStats={getTeamStats}
/>
</TreeViewPage>
{/* Dialog de création */}
<TeamFormDialog
isOpen={isCreateDialogOpen}
onClose={closeCreateDialog}
onSubmit={handleCreateSubmit}
title="Créer une nouvelle équipe"
formData={teamFormData}
onFormDataChange={setTeamFormData}
directions={directions}
isSubmitting={isSubmitting}
/>
{/* Dialog d'édition */}
<TeamFormDialog
isOpen={isEditDialogOpen}
onClose={closeEditDialog}
onSubmit={handleEditSubmit}
title="Modifier l'équipe"
formData={teamFormData}
onFormDataChange={setTeamFormData}
directions={directions}
isSubmitting={isSubmitting}
/>
{/* Modal des membres d'équipe */}
{selectedTeam && (
<TeamMembersModal
teamId={selectedTeam.id}
teamName={selectedTeam.name}
isOpen={isMembersModalOpen}
onClose={() => {
setIsMembersModalOpen(false);
setSelectedTeam(null);
}}
onMemberRemoved={() => {
if (selectedTeam) {
updateTeamStatsAfterMemberRemoval(selectedTeam.id);
}
}}
/>
)}
</>
);
}

View File

@@ -0,0 +1,3 @@
export { UsersManagementPage } from "./users-management-page";
export { UserFormDialog } from "./user-form-dialog";
export { UsersList } from "./users-list";

View File

@@ -0,0 +1,108 @@
"use client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Team } from "@/services/admin-management-service";
interface UserFormData {
firstName: string;
lastName: string;
teamId: string;
}
interface UserFormDialogProps {
isOpen: boolean;
onClose: () => void;
onSubmit: () => void;
title: string;
formData: UserFormData;
onFormDataChange: (data: UserFormData) => void;
teams: Team[];
isSubmitting?: boolean;
}
export function UserFormDialog({
isOpen,
onClose,
onSubmit,
title,
formData,
onFormDataChange,
teams,
isSubmitting = false,
}: UserFormDialogProps) {
const handleInputChange = (field: keyof UserFormData, value: string) => {
onFormDataChange({ ...formData, [field]: value });
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="user-first-name">Prénom *</Label>
<Input
id="user-first-name"
value={formData.firstName}
onChange={(e) => handleInputChange("firstName", e.target.value)}
placeholder="Prénom de l'utilisateur"
/>
</div>
<div>
<Label htmlFor="user-last-name">Nom *</Label>
<Input
id="user-last-name"
value={formData.lastName}
onChange={(e) => handleInputChange("lastName", e.target.value)}
placeholder="Nom de l'utilisateur"
/>
</div>
<div>
<Label htmlFor="user-team">Équipe</Label>
<Select
value={formData.teamId}
onValueChange={(value) => handleInputChange("teamId", value)}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionner une équipe (optionnel)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Aucune équipe</SelectItem>
{teams.map((team) => (
<SelectItem key={team.id} value={team.id}>
{team.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
Annuler
</Button>
<Button onClick={onSubmit} disabled={isSubmitting}>
{isSubmitting ? "En cours..." : title.includes("Créer") ? "Créer" : "Mettre à jour"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,79 @@
"use client";
import { User, Building2 } from "lucide-react";
import { TreeCategoryHeader, TreeItemRow } from "@/components/admin";
interface User {
uuid: string;
firstName: string;
lastName: string;
teamName?: string;
hasEvaluations: boolean;
}
interface UsersListProps {
filteredUsersByTeam: Record<string, User[]>;
expandedTeams: Set<string>;
onToggleTeam: (teamName: string) => void;
onDeleteUser: (user: User) => void;
}
export function UsersList({
filteredUsersByTeam,
expandedTeams,
onToggleTeam,
onDeleteUser,
}: UsersListProps) {
return (
<>
{Object.entries(filteredUsersByTeam).map(
([teamName, teamUsers], index) => (
<div key={teamName}>
<TreeCategoryHeader
category={teamName}
isExpanded={expandedTeams.has(teamName)}
onToggle={() => onToggleTeam(teamName)}
icon={<Building2 className="w-5 h-5 text-blue-400" />}
itemCount={teamUsers.length}
itemLabel="utilisateur"
showSeparator={index > 0}
canDelete={false}
isDirection={false}
/>
{/* Liste des utilisateurs de l'équipe */}
{expandedTeams.has(teamName) && (
<div className="bg-slate-950/30">
{teamUsers.map((user, userIndex) => (
<TreeItemRow
key={user.uuid}
icon={<User className="w-5 h-5 text-green-400" />}
title={`${user.firstName} ${user.lastName}`}
badges={[
{
text: user.hasEvaluations
? "A des évaluations"
: "Aucune évaluation",
variant: user.hasEvaluations ? "default" : "outline",
},
]}
onDelete={() => onDeleteUser(user)}
canDelete={!user.teamName}
showSeparator={userIndex > 0}
additionalInfo={
<p className="text-slate-500 text-xs">
{user.teamName
? `Équipe: ${user.teamName}`
: "Aucune équipe"}
</p>
}
/>
))}
</div>
)}
</div>
)
)}
</>
);
}

View File

@@ -0,0 +1,167 @@
"use client";
import { useState } from "react";
import { Users, Building2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Team } from "@/services/admin-management-service";
import { TreeViewPage } from "../management/tree-view-page";
import { useTreeView } from "@/hooks/use-tree-view";
import { useFormDialog } from "@/hooks/use-form-dialog";
import { useUsersManagement } from "@/hooks/use-users-management";
import { UserFormDialog } from "./user-form-dialog";
import { UsersList } from "./users-list";
interface UsersManagementPageProps {
teams: Team[];
}
export function UsersManagementPage({ teams }: UsersManagementPageProps) {
const [searchTerm, setSearchTerm] = useState("");
const { isCreateDialogOpen, openCreateDialog, closeCreateDialog } = useFormDialog();
const {
users,
isLoading,
error,
userFormData,
isSubmitting,
setUserFormData,
resetForm,
handleCreateUser,
handleDeleteUser,
} = useUsersManagement(teams);
// Utilisation du hook factorisé pour la vue arborescente
const {
filteredDataByCategory: filteredUsersByTeam,
expandedCategories: expandedTeams,
toggleCategory: toggleTeam,
expandAll,
collapseAll,
} = useTreeView({
data: users,
searchFields: ["firstName", "lastName"],
groupBy: (user) => user.teamName || "Sans équipe",
searchTerm,
onSearchChange: setSearchTerm,
});
const handleCreateSubmit = async () => {
const success = await handleCreateUser();
if (success) {
closeCreateDialog();
}
};
const handleOpenCreateDialog = () => {
resetForm();
openCreateDialog();
};
const headerActions = (
<Button onClick={handleOpenCreateDialog}>
<Users className="w-4 h-4 mr-2" />
Créer un utilisateur
</Button>
);
const emptyState = (
<div className="text-center py-8">
<Users className="w-10 h-10 text-slate-500 mx-auto mb-3" />
<h3 className="text-base font-medium text-slate-400 mb-1">
{searchTerm ? "Aucun utilisateur trouvé" : "Aucun utilisateur"}
</h3>
<p className="text-sm text-slate-500">
{searchTerm
? "Essayez de modifier vos critères de recherche"
: "Commencez par créer votre premier utilisateur"}
</p>
</div>
);
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-white">
Gestion des utilisateurs
</h2>
<Button disabled>
<Users className="w-4 h-4 mr-2" />
Créer un utilisateur
</Button>
</div>
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-3 p-3">
<div className="w-8 h-8 rounded-full bg-slate-700 animate-pulse" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-slate-700 rounded animate-pulse w-32" />
<div className="h-3 bg-slate-700 rounded animate-pulse w-24" />
</div>
<div className="w-20 h-8 bg-slate-700 rounded animate-pulse" />
</div>
))}
</div>
</div>
);
}
if (error) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-white">
Gestion des utilisateurs
</h2>
</div>
<div className="text-center py-8">
<div className="w-12 h-12 rounded-full bg-red-500/20 flex items-center justify-center mx-auto mb-4">
<Users className="w-6 h-6 text-red-400" />
</div>
<p className="text-red-400 mb-4">{error}</p>
<Button onClick={() => window.location.reload()} variant="outline">
Réessayer
</Button>
</div>
</div>
);
}
return (
<>
<TreeViewPage
title="Gestion des utilisateurs"
description="Gérez les utilisateurs de votre organisation"
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
onExpandAll={expandAll}
onCollapseAll={collapseAll}
searchPlaceholder="Rechercher un utilisateur..."
hasContent={Object.keys(filteredUsersByTeam).length > 0}
emptyState={emptyState}
headerActions={headerActions}
>
<UsersList
filteredUsersByTeam={filteredUsersByTeam}
expandedTeams={expandedTeams}
onToggleTeam={toggleTeam}
onDeleteUser={handleDeleteUser}
/>
</TreeViewPage>
{/* Dialog de création */}
<UserFormDialog
isOpen={isCreateDialogOpen}
onClose={closeCreateDialog}
onSubmit={handleCreateSubmit}
title="Créer un nouvel utilisateur"
formData={userFormData}
onFormDataChange={setUserFormData}
teams={teams}
isSubmitting={isSubmitting}
/>
</>
);
}

View File

@@ -0,0 +1,199 @@
import { useState, useEffect } from "react";
import { useToast } from "@/hooks/use-toast";
import { SkillCategory } from "@/lib/types";
import {
AdminManagementService,
Skill,
} from "@/services/admin-management-service";
interface SkillFormData {
name: string;
categoryId: string;
description: string;
icon: string;
}
export function useSkillsManagement(skillCategories: SkillCategory[]) {
const [skills, setSkills] = useState<Skill[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [editingSkill, setEditingSkill] = useState<Skill | null>(null);
const [skillFormData, setSkillFormData] = useState<SkillFormData>({
name: "",
categoryId: "",
description: "",
icon: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const { toast } = useToast();
// Charger les skills depuis l'API
const fetchSkills = async () => {
try {
setIsLoading(true);
const skillsData = await AdminManagementService.getSkills();
setSkills(skillsData);
} catch (error) {
console.error("Error fetching skills:", error);
toast({
title: "Erreur",
description: "Impossible de charger les skills",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
// Charger les skills au montage du composant
useEffect(() => {
fetchSkills();
}, []);
const resetForm = () => {
setSkillFormData({ name: "", categoryId: "", description: "", icon: "" });
setEditingSkill(null);
};
const handleCreateSkill = async () => {
if (!skillFormData.name || !skillFormData.categoryId) {
toast({
title: "Erreur",
description: "Veuillez remplir tous les champs obligatoires",
variant: "destructive",
});
return false;
}
try {
setIsSubmitting(true);
const categoryIndex = parseInt(skillFormData.categoryId);
const category = skillCategories[categoryIndex];
const skillData = {
...skillFormData,
category: category.category,
};
const newSkill = await AdminManagementService.createSkill(skillData);
setSkills([...skills, newSkill]);
resetForm();
toast({
title: "Succès",
description: "Skill créée avec succès",
});
return true;
} catch (error: any) {
toast({
title: "Erreur",
description: error.message || "Erreur lors de la création de la skill",
variant: "destructive",
});
return false;
} finally {
setIsSubmitting(false);
}
};
const handleEditSkill = (skill: Skill) => {
setEditingSkill(skill);
const categoryIndex = skillCategories.findIndex(
(cat) => cat.category === skill.category
);
setSkillFormData({
name: skill.name,
categoryId: categoryIndex !== -1 ? categoryIndex.toString() : "",
description: skill.description,
icon: skill.icon,
});
};
const handleUpdateSkill = async () => {
if (!editingSkill || !skillFormData.name || !skillFormData.categoryId) {
toast({
title: "Erreur",
description: "Veuillez remplir tous les champs obligatoires",
variant: "destructive",
});
return false;
}
try {
setIsSubmitting(true);
const categoryIndex = parseInt(skillFormData.categoryId);
const category = skillCategories[categoryIndex];
const skillData = {
id: editingSkill.id,
...skillFormData,
category: category.category,
usageCount: editingSkill.usageCount,
};
const updatedSkill = await AdminManagementService.updateSkill(skillData);
const updatedSkills = skills.map((skill) =>
skill.id === editingSkill.id ? updatedSkill : skill
);
setSkills(updatedSkills);
resetForm();
toast({
title: "Succès",
description: "Skill mise à jour avec succès",
});
return true;
} catch (error: any) {
toast({
title: "Erreur",
description:
error.message || "Erreur lors de la mise à jour de la skill",
variant: "destructive",
});
return false;
} finally {
setIsSubmitting(false);
}
};
const handleDeleteSkill = async (skillId: string) => {
if (
!confirm(
"Êtes-vous sûr de vouloir supprimer cette skill ? Cette action est irréversible."
)
) {
return;
}
try {
await AdminManagementService.deleteSkill(skillId);
setSkills(skills.filter((s) => s.id !== skillId));
toast({
title: "Succès",
description: "Skill supprimée avec succès",
});
} catch (error: any) {
toast({
title: "Erreur",
description:
error.message || "Erreur lors de la suppression de la skill",
variant: "destructive",
});
}
};
return {
skills,
isLoading,
editingSkill,
skillFormData,
isSubmitting,
setSkillFormData,
resetForm,
handleCreateSkill,
handleEditSkill,
handleUpdateSkill,
handleDeleteSkill,
};
}

View File

@@ -0,0 +1,273 @@
import { useState, useEffect } from "react";
import { useToast } from "@/hooks/use-toast";
import { Team as TeamType } from "@/lib/types";
import { TeamStats } from "@/services/admin-service";
import {
AdminManagementService,
Team,
} from "@/services/admin-management-service";
interface TeamFormData {
name: string;
direction: string;
}
export function useTeamsManagement(
initialTeams: TeamType[],
initialTeamStats: TeamStats[]
) {
const [teams, setTeams] = useState<TeamType[]>(initialTeams);
const [teamStats, setTeamStats] = useState<TeamStats[]>(initialTeamStats);
const [editingTeam, setEditingTeam] = useState<TeamType | null>(null);
const [teamFormData, setTeamFormData] = useState<TeamFormData>({
name: "",
direction: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const { toast } = useToast();
// 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();
}, []);
const resetForm = () => {
setTeamFormData({ name: "", direction: "" });
setEditingTeam(null);
};
const getTeamStats = (teamId: string): TeamStats | undefined => {
return teamStats.find((stats) => stats.teamId === teamId);
};
const handleCreateTeam = async () => {
if (!teamFormData.name || !teamFormData.direction) {
toast({
title: "Erreur",
description: "Veuillez remplir tous les champs obligatoires",
variant: "destructive",
});
return false;
}
try {
setIsSubmitting(true);
const newTeam = await AdminManagementService.createTeam(teamFormData);
toast({
title: "Succès",
description: "Équipe créée avec succès",
});
resetForm();
// Mettre à jour l'état local avec la nouvelle équipe
const newLocalTeam: TeamType = {
id: newTeam.id,
name: newTeam.name,
direction: newTeam.direction,
};
setTeams((prev) => [...prev, newLocalTeam]);
// Ajouter les stats de la nouvelle équipe (avec les propriétés minimales)
const newTeamStats = {
teamId: newTeam.id,
teamName: newTeam.name,
direction: newTeam.direction,
totalMembers: 0,
averageSkillLevel: 0,
skillCoverage: 0,
topSkills: [],
members: [],
} as TeamStats;
setTeamStats((prev) => [...prev, newTeamStats]);
return true;
} catch (error: any) {
toast({
title: "Erreur",
description: error.message || "Erreur lors de la création de l'équipe",
variant: "destructive",
});
return false;
} finally {
setIsSubmitting(false);
}
};
const handleEditTeam = (team: TeamType) => {
setEditingTeam(team);
setTeamFormData({
name: team.name,
direction: team.direction,
});
};
const handleUpdateTeam = async () => {
if (!editingTeam || !teamFormData.name || !teamFormData.direction) {
toast({
title: "Erreur",
description: "Veuillez remplir tous les champs obligatoires",
variant: "destructive",
});
return false;
}
try {
setIsSubmitting(true);
await AdminManagementService.updateTeam({
id: editingTeam.id,
...teamFormData,
memberCount: editingTeam.memberCount || 0,
});
toast({
title: "Succès",
description: "Équipe mise à jour avec succès",
});
resetForm();
return true;
} catch (error: any) {
toast({
title: "Erreur",
description:
error.message || "Erreur lors de la mise à jour de l'équipe",
variant: "destructive",
});
return false;
} finally {
setIsSubmitting(false);
}
};
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",
});
// Mettre à jour l'état local au lieu de recharger la page
setTeams((prev) => prev.filter((t) => t.id !== teamId));
setTeamStats((prev) =>
prev.filter((stats) => stats.teamId !== teamId)
);
} 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",
});
// Mettre à jour l'état local au lieu de recharger la page
setTeams((prev) =>
prev.filter((team) => team.direction !== direction)
);
setTeamStats((prev) =>
prev.filter((stats) => stats.direction !== direction)
);
} catch (error: any) {
toast({
title: "Erreur",
description:
error.message || "Erreur lors de la suppression de la direction",
variant: "destructive",
});
}
}
};
// Extraire les directions uniques pour les formulaires
const directions = Array.from(
new Set(teams.map((team) => team.direction))
);
return {
teams,
teamStats,
editingTeam,
teamFormData,
isSubmitting,
directions,
setTeamFormData,
resetForm,
getTeamStats,
handleCreateTeam,
handleEditTeam,
handleUpdateTeam,
handleDeleteTeam,
handleDeleteDirection,
};
}

View File

@@ -0,0 +1,150 @@
import { useState, useEffect } from "react";
import { useToast } from "@/hooks/use-toast";
import { Team } from "@/services/admin-management-service";
interface User {
uuid: string;
firstName: string;
lastName: string;
teamName?: string;
hasEvaluations: boolean;
}
interface UserFormData {
firstName: string;
lastName: string;
teamId: string;
}
export function useUsersManagement(teams: Team[]) {
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deletingUserId, setDeletingUserId] = useState<string | null>(null);
const [userFormData, setUserFormData] = useState<UserFormData>({
firstName: "",
lastName: "",
teamId: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const { toast } = useToast();
// Charger les utilisateurs depuis l'API
const fetchUsers = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch("/api/admin/users");
if (!response.ok) {
throw new Error("Erreur lors de la récupération des utilisateurs");
}
const usersData = await response.json();
setUsers(usersData);
} catch (err: any) {
setError(err.message || "Erreur lors du chargement des utilisateurs");
} finally {
setIsLoading(false);
}
};
// Charger les utilisateurs au montage du composant
useEffect(() => {
fetchUsers();
}, []);
const resetForm = () => {
setUserFormData({ firstName: "", lastName: "", teamId: "" });
};
const handleCreateUser = async () => {
if (!userFormData.firstName || !userFormData.lastName) {
toast({
title: "Erreur",
description: "Veuillez remplir tous les champs obligatoires",
variant: "destructive",
});
return false;
}
try {
setIsSubmitting(true);
// TODO: Implémenter la création d'utilisateur
toast({
title: "Succès",
description: "Utilisateur créé avec succès",
});
resetForm();
// Rafraîchir la liste
await fetchUsers();
return true;
} catch (error: any) {
toast({
title: "Erreur",
description:
error.message || "Erreur lors de la création de l'utilisateur",
variant: "destructive",
});
return false;
} finally {
setIsSubmitting(false);
}
};
const handleDeleteUser = async (user: User) => {
if (user.teamName) {
toast({
title: "Action impossible",
description:
"Retirez d'abord l'utilisateur de son équipe avant de le supprimer",
variant: "destructive",
});
return;
}
if (
!confirm(
`Êtes-vous sûr de vouloir supprimer définitivement ${user.firstName} ${user.lastName} ?\n\nCette action supprimera aussi toutes ses évaluations par skills et est irréversible.`
)
) {
return;
}
setDeletingUserId(user.uuid);
try {
// TODO: Implémenter la suppression d'utilisateur
// await AdminManagementService.deleteUser(user.uuid);
// Mettre à jour la liste locale
setUsers((prev) => prev.filter((u) => u.uuid !== user.uuid));
toast({
title: "Succès",
description: `${user.firstName} ${user.lastName} a été supprimé avec succès`,
});
} catch (err: any) {
toast({
title: "Erreur",
description:
err.message || "Erreur lors de la suppression de l'utilisateur",
variant: "destructive",
});
} finally {
setDeletingUserId(null);
}
};
return {
users,
isLoading,
error,
deletingUserId,
userFormData,
isSubmitting,
setUserFormData,
resetForm,
handleCreateUser,
handleDeleteUser,
fetchUsers,
};
}