From c0e2b9533b41e70d0a4b6cf66488cbd68f814064 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Fri, 22 Aug 2025 09:28:52 +0200 Subject: [PATCH] feat: add team members functionality and modal - Introduced `onViewMembers` prop in `TreeItemRow` to handle viewing team members. - Added `TeamMembersModal` to display members of a selected team. - Implemented state management for team members in `TeamsManagement`, including fetching and updating stats. - Enhanced `AdminManagementService` with methods to fetch and remove team members. --- app/api/admin/teams/[teamId]/members/route.ts | 110 ++++++++++ .../management/pages/teams-management.tsx | 80 ++++++- .../admin/management/team-members-modal.tsx | 207 ++++++++++++++++++ components/admin/management/tree-item-row.tsx | 17 +- services/admin-management-service.ts | 35 +++ 5 files changed, 445 insertions(+), 4 deletions(-) create mode 100644 app/api/admin/teams/[teamId]/members/route.ts create mode 100644 components/admin/management/team-members-modal.tsx diff --git a/app/api/admin/teams/[teamId]/members/route.ts b/app/api/admin/teams/[teamId]/members/route.ts new file mode 100644 index 0000000..9887400 --- /dev/null +++ b/app/api/admin/teams/[teamId]/members/route.ts @@ -0,0 +1,110 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getPool } from "@/services/database"; +import { isUserAuthenticated } from "@/lib/server-auth"; + +// GET - Récupérer les membres d'une équipe +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ teamId: string }> } +) { + try { + // Vérifier l'authentification + const isAuthenticated = await isUserAuthenticated(); + if (!isAuthenticated) { + return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + } + + const { teamId } = await params; + + if (!teamId) { + return NextResponse.json( + { error: "L'ID de l'équipe est requis" }, + { status: 400 } + ); + } + + const pool = getPool(); + const query = ` + SELECT + u.uuid_id, + u.first_name, + u.last_name, + u.created_at + FROM users u + WHERE u.team_id = $1 + ORDER BY u.last_name, u.first_name + `; + + const result = await pool.query(query, [teamId]); + + const members = result.rows.map((row) => ({ + id: row.uuid_id, + firstName: row.first_name, + lastName: row.last_name, + fullName: `${row.first_name} ${row.last_name}`, + joinedAt: row.created_at, + })); + + return NextResponse.json(members); + } catch (error) { + console.error("Error fetching team members:", error); + return NextResponse.json( + { error: "Erreur lors de la récupération des membres" }, + { status: 500 } + ); + } +} + +// DELETE - Supprimer un membre d'une équipe +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ teamId: string }> } +) { + try { + // Vérifier l'authentification + const isAuthenticated = await isUserAuthenticated(); + if (!isAuthenticated) { + return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + } + + const { teamId } = await params; + const { memberId } = await request.json(); + + if (!teamId || !memberId) { + return NextResponse.json( + { error: "L'ID de l'équipe et l'ID du membre sont requis" }, + { status: 400 } + ); + } + + const pool = getPool(); + + // Vérifier que le membre appartient bien à cette équipe + const memberCheck = await pool.query( + "SELECT uuid_id FROM users WHERE uuid_id = $1 AND team_id = $2", + [memberId, teamId] + ); + + if (memberCheck.rows.length === 0) { + return NextResponse.json( + { error: "Membre non trouvé dans cette équipe" }, + { status: 404 } + ); + } + + // Supprimer le membre (mettre team_id à NULL au lieu de supprimer l'utilisateur) + await pool.query("UPDATE users SET team_id = NULL WHERE uuid_id = $1", [ + memberId, + ]); + + return NextResponse.json({ + message: "Membre supprimé de l'équipe avec succès", + }); + } catch (error) { + console.error("Error removing team member:", error); + return NextResponse.json( + { error: "Erreur lors de la suppression du membre" }, + { status: 500 } + ); + } +} diff --git a/components/admin/management/pages/teams-management.tsx b/components/admin/management/pages/teams-management.tsx index 08dcb48..4bc06c9 100644 --- a/components/admin/management/pages/teams-management.tsx +++ b/components/admin/management/pages/teams-management.tsx @@ -35,6 +35,7 @@ import { TreeSearchControls, TeamMetrics, } from "@/components/admin"; +import { TeamMembersModal } from "@/components/admin/management/team-members-modal"; interface TeamsManagementProps { teams: TeamType[]; @@ -55,6 +56,8 @@ export function TeamsManagement({ const [searchTerm, setSearchTerm] = useState(""); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + const [isMembersModalOpen, setIsMembersModalOpen] = useState(false); + const [selectedTeam, setSelectedTeam] = useState(null); const [editingTeam, setEditingTeam] = useState(null); const [teamFormData, setTeamFormData] = useState({ name: "", @@ -67,7 +70,8 @@ export function TeamsManagement({ new Set() ); - // État pour gérer la liste des équipes + // État local pour les stats des équipes + const [localTeamStats, setLocalTeamStats] = useState(teamStats); // Grouper les teams par direction et filtrer en fonction de la recherche const filteredTeamsByDirection = useMemo(() => { @@ -129,8 +133,8 @@ export function TeamsManagement({ [] ); - const getTeamStats = (teamId: string) => { - return teamStats.find((stats) => stats.teamId === teamId); + const getTeamStats = (teamId: string): TeamStats | undefined => { + return localTeamStats.find((stats) => stats.teamId === teamId); }; // Charger les teams depuis l'API @@ -149,6 +153,18 @@ export function TeamsManagement({ } }; + // Rafraîchir les stats des équipes depuis l'API des équipes + const refreshTeamStats = async () => { + try { + const teamsData = await AdminManagementService.getTeams(); + // Fusionner avec les stats existantes pour préserver les métriques + const updatedStats = mergeTeamStats(localTeamStats, teamsData); + setLocalTeamStats(updatedStats); + } catch (error) { + console.error("Error refreshing team stats:", error); + } + }; + // Charger les teams au montage du composant useEffect(() => { fetchTeams(); @@ -205,6 +221,41 @@ export function TeamsManagement({ setIsEditDialogOpen(true); }; + 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) => + prev.map((stats) => + stats.teamId === teamId + ? { ...stats, totalMembers: Math.max(0, stats.totalMembers - 1) } + : stats + ) + ); + }; + + // Fonction pour fusionner les stats existantes avec les nouvelles données + const mergeTeamStats = (existingStats: TeamStats[], newTeams: Team[]) => { + return newTeams.map((team) => { + const existingStat = existingStats.find( + (stat) => stat.teamId === team.id + ); + return { + teamId: team.id, + teamName: team.name, + direction: team.direction, + totalMembers: team.memberCount, + averageSkillLevel: existingStat?.averageSkillLevel || 0, + skillCoverage: existingStat?.skillCoverage || 0, + topSkillsCount: existingStat?.topSkillsCount || 0, + topSkills: existingStat?.topSkills || [], + }; + }); + }; + const handleUpdateTeam = async () => { if (!editingTeam || !teamFormData.name || !teamFormData.direction) { toast({ @@ -466,8 +517,10 @@ export function TeamsManagement({ } 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 ? ( + + {/* Modal des membres d'équipe */} + {selectedTeam && ( + { + setIsMembersModalOpen(false); + setSelectedTeam(null); + // Rafraîchir les stats depuis l'API pour s'assurer de la cohérence + refreshTeamStats(); + }} + onMemberRemoved={() => { + // Mettre à jour les stats localement + if (selectedTeam) { + updateTeamStatsAfterMemberRemoval(selectedTeam.id); + } + }} + /> + )} ); } diff --git a/components/admin/management/team-members-modal.tsx b/components/admin/management/team-members-modal.tsx new file mode 100644 index 0000000..e76442a --- /dev/null +++ b/components/admin/management/team-members-modal.tsx @@ -0,0 +1,207 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Users, User, Calendar, X, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { TeamMember } from "@/services/admin-management-service"; +import { AdminManagementService } from "@/services/admin-management-service"; +import { useToast } from "@/hooks/use-toast"; + +interface TeamMembersModalProps { + teamId: string; + teamName: string; + isOpen: boolean; + onClose: () => void; + onMemberRemoved?: () => void; // Callback pour notifier le parent qu'un membre a été supprimé +} + +export function TeamMembersModal({ + teamId, + teamName, + isOpen, + onClose, + onMemberRemoved, +}: TeamMembersModalProps) { + const [members, setMembers] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [deletingMemberId, setDeletingMemberId] = useState(null); + const { toast } = useToast(); + + useEffect(() => { + if (isOpen && teamId) { + fetchMembers(); + } + }, [isOpen, teamId]); + + const fetchMembers = async () => { + setIsLoading(true); + setError(null); + try { + const membersData = await AdminManagementService.getTeamMembers(teamId); + setMembers(membersData); + } catch (err: any) { + setError(err.message || "Erreur lors du chargement des membres"); + } finally { + setIsLoading(false); + } + }; + + const handleRemoveMember = async (memberId: string, memberName: string) => { + if ( + !confirm(`Êtes-vous sûr de vouloir retirer ${memberName} de l'équipe ?`) + ) { + return; + } + + setDeletingMemberId(memberId); + try { + await AdminManagementService.removeTeamMember(teamId, memberId); + + // Mettre à jour la liste locale + setMembers((prev) => prev.filter((member) => member.id !== memberId)); + + // Notifier le parent qu'un membre a été supprimé + onMemberRemoved?.(); + + toast({ + title: "Succès", + description: `${memberName} a été retiré de l'équipe`, + }); + } catch (err: any) { + toast({ + title: "Erreur", + description: err.message || "Erreur lors de la suppression du membre", + variant: "destructive", + }); + } finally { + setDeletingMemberId(null); + } + }; + + if (!isOpen) return null; + + return ( +
+ + +
+ +
+ Membres de l'équipe +

{teamName}

+
+
+ +
+ + + {isLoading ? ( +
+ {[...Array(5)].map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+ ) : error ? ( +
+
+ +
+

{error}

+ +
+ ) : members.length === 0 ? ( +
+
+ +
+

+ Aucun membre dans cette équipe +

+

+ Les membres apparaîtront ici une fois qu'ils seront assignés +

+
+ ) : ( + <> +
+ + {members.length} membre{members.length > 1 ? "s" : ""} + + +
+ +
+ {members.map((member) => ( +
+
+ +
+
+

+ {member.fullName} +

+
+ + + Membre depuis{" "} + {new Date(member.joinedAt).toLocaleDateString( + "fr-FR", + { + year: "numeric", + month: "long", + } + )} + +
+
+ +
+ ))} +
+ + )} +
+
+
+ ); +} diff --git a/components/admin/management/tree-item-row.tsx b/components/admin/management/tree-item-row.tsx index 030cfc2..aaa3059 100644 --- a/components/admin/management/tree-item-row.tsx +++ b/components/admin/management/tree-item-row.tsx @@ -2,7 +2,7 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { Edit, Trash2 } from "lucide-react"; +import { Edit, Trash2, Users } from "lucide-react"; interface TreeItemRowProps { icon?: React.ReactNode; @@ -15,9 +15,11 @@ interface TreeItemRowProps { }>; onEdit?: () => void; onDelete?: () => void; + onViewMembers?: () => void; canDelete?: boolean; showSeparator?: boolean; additionalInfo?: React.ReactNode; + hasMembers?: boolean; } export function TreeItemRow({ @@ -27,9 +29,11 @@ export function TreeItemRow({ badges = [], onEdit, onDelete, + onViewMembers, canDelete = true, showSeparator = false, additionalInfo, + hasMembers = false, }: TreeItemRowProps) { return (
@@ -67,6 +71,17 @@ export function TreeItemRow({ {/* Actions */}
+ {onViewMembers && hasMembers && ( + + )} {onEdit && (