611 lines
20 KiB
TypeScript
611 lines
20 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback } from "react";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Plus, MoreHorizontal, Trash, Edit, Users } from "lucide-react";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { Label } from "@/components/ui/label";
|
|
import { toast } from "@/components/ui/use-toast";
|
|
|
|
interface Group {
|
|
id: string;
|
|
name: string;
|
|
path: string;
|
|
membersCount: number;
|
|
}
|
|
|
|
interface User {
|
|
id: string;
|
|
username: string;
|
|
email: string;
|
|
lastName: string;
|
|
firstName: string;
|
|
}
|
|
|
|
interface ApiError {
|
|
message: string;
|
|
}
|
|
|
|
interface GroupsTableProps {
|
|
userRole?: string[];
|
|
}
|
|
|
|
// DialogWrapper component to better handle dialog state
|
|
function DialogWrapper({
|
|
open,
|
|
onOpenChange,
|
|
onAfterClose,
|
|
triggerComponent,
|
|
children,
|
|
className
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onAfterClose?: () => void;
|
|
triggerComponent?: React.ReactNode;
|
|
children: React.ReactNode;
|
|
className?: string;
|
|
}) {
|
|
const handleOpenChange = useCallback((open: boolean) => {
|
|
onOpenChange(open);
|
|
|
|
if (!open && onAfterClose) {
|
|
// Use a longer timeout to ensure all animations are complete
|
|
setTimeout(onAfterClose, 300);
|
|
}
|
|
}, [onOpenChange, onAfterClose]);
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
{triggerComponent && <DialogTrigger asChild>{triggerComponent}</DialogTrigger>}
|
|
<DialogContent className={className}>
|
|
{children}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
export function GroupsTable({ userRole = [] }: GroupsTableProps) {
|
|
const [groups, setGroups] = useState<Group[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [newGroupDialog, setNewGroupDialog] = useState(false);
|
|
const [newGroupName, setNewGroupName] = useState("");
|
|
const [modifyGroupDialog, setModifyGroupDialog] = useState(false);
|
|
const [selectedGroup, setSelectedGroup] = useState<Group | null>(null);
|
|
const [modifiedGroupName, setModifiedGroupName] = useState("");
|
|
const [manageMembersDialog, setManageMembersDialog] = useState(false);
|
|
const [groupMembers, setGroupMembers] = useState<User[]>([]);
|
|
const [availableUsers, setAvailableUsers] = useState<User[]>([]);
|
|
|
|
useEffect(() => {
|
|
fetchGroups();
|
|
}, []);
|
|
|
|
const fetchGroups = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const response = await fetch("/api/groups");
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
throw new Error(data.message || "Erreur lors de la récupération des groupes");
|
|
}
|
|
|
|
const groupsWithCounts = await Promise.all(
|
|
(Array.isArray(data) ? data : []).map(async (group) => {
|
|
try {
|
|
const membersResponse = await fetch(`/api/groups/${group.id}/members`);
|
|
if (membersResponse.ok) {
|
|
const members = await membersResponse.json();
|
|
return {
|
|
...group,
|
|
membersCount: Array.isArray(members) ? members.length : 0
|
|
};
|
|
}
|
|
return group;
|
|
} catch (error) {
|
|
console.error(`Error fetching members for group ${group.id}:`, error);
|
|
return group;
|
|
}
|
|
})
|
|
);
|
|
|
|
setGroups(groupsWithCounts);
|
|
} catch (error) {
|
|
toast({
|
|
title: "Erreur",
|
|
description: error instanceof Error ? error.message : "Une erreur est survenue",
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleCreateGroup = async () => {
|
|
try {
|
|
if (!newGroupName.trim()) {
|
|
throw new Error("Le nom du groupe est requis");
|
|
}
|
|
|
|
const response = await fetch("/api/groups", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ name: newGroupName }),
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
throw new Error(data.message || "Erreur lors de la création du groupe");
|
|
}
|
|
|
|
setGroups(prev => [...prev, data]);
|
|
setNewGroupDialog(false);
|
|
setNewGroupName("");
|
|
toast({
|
|
title: "Succès",
|
|
description: "Le groupe a été créé avec succès",
|
|
});
|
|
} catch (error) {
|
|
toast({
|
|
title: "Erreur",
|
|
description: error instanceof Error ? error.message : "Une erreur est survenue",
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleDeleteGroup = async (groupId: string) => {
|
|
try {
|
|
const response = await fetch(`/api/groups/${groupId}`, {
|
|
method: "DELETE",
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error("Erreur lors de la suppression du groupe");
|
|
}
|
|
|
|
setGroups(prev => prev.filter(group => group.id !== groupId));
|
|
toast({
|
|
title: "Succès",
|
|
description: "Le groupe a été supprimé avec succès",
|
|
});
|
|
} catch (error) {
|
|
toast({
|
|
title: "Erreur",
|
|
description: error instanceof Error ? error.message : "Une erreur est survenue",
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleModifyGroup = async (groupId: string) => {
|
|
try {
|
|
const group = groups.find(g => g.id === groupId);
|
|
if (!group) return;
|
|
|
|
setSelectedGroup(group);
|
|
setModifiedGroupName(group.name);
|
|
setModifyGroupDialog(true);
|
|
} catch (error) {
|
|
toast({
|
|
title: "Erreur",
|
|
description: error instanceof Error ? error.message : "Une erreur est survenue",
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleUpdateGroup = async () => {
|
|
try {
|
|
if (!selectedGroup || !modifiedGroupName.trim()) {
|
|
throw new Error("Le nom du groupe est requis");
|
|
}
|
|
|
|
const response = await fetch(`/api/groups/${selectedGroup.id}`, {
|
|
method: "PUT",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ name: modifiedGroupName }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error("Erreur lors de la modification du groupe");
|
|
}
|
|
|
|
setGroups(prev => prev.map(group =>
|
|
group.id === selectedGroup.id
|
|
? { ...group, name: modifiedGroupName }
|
|
: group
|
|
));
|
|
|
|
setModifyGroupDialog(false);
|
|
setSelectedGroup(null);
|
|
setModifiedGroupName("");
|
|
|
|
toast({
|
|
title: "Succès",
|
|
description: "Le groupe a été modifié avec succès",
|
|
});
|
|
} catch (error) {
|
|
toast({
|
|
title: "Erreur",
|
|
description: error instanceof Error ? error.message : "Une erreur est survenue",
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleManageMembers = async (groupId: string) => {
|
|
const group = groups.find(g => g.id === groupId);
|
|
if (!group) return;
|
|
|
|
setSelectedGroup(group);
|
|
|
|
try {
|
|
const membersResponse = await fetch(`/api/groups/${groupId}/members`);
|
|
if (!membersResponse.ok) throw new Error("Failed to fetch group members");
|
|
const members = await membersResponse.json();
|
|
setGroupMembers(members);
|
|
|
|
setGroups(prev => prev.map(g =>
|
|
g.id === groupId
|
|
? { ...g, membersCount: members.length }
|
|
: g
|
|
));
|
|
|
|
const usersResponse = await fetch("/api/users");
|
|
if (!usersResponse.ok) throw new Error("Failed to fetch users");
|
|
const users = await usersResponse.json();
|
|
setAvailableUsers(users.filter((user: User) => !members.some((m: User) => m.id === user.id)));
|
|
|
|
setManageMembersDialog(true);
|
|
} catch (error) {
|
|
toast({
|
|
title: "Erreur",
|
|
description: "Erreur lors de la récupération des membres",
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleAddMember = async (userId: string) => {
|
|
if (!selectedGroup) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/groups/${selectedGroup.id}/members`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ userId }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error("Failed to add member");
|
|
}
|
|
|
|
const updatedMember = availableUsers.find(u => u.id === userId);
|
|
if (updatedMember) {
|
|
setGroupMembers(prev => [...prev, updatedMember]);
|
|
setAvailableUsers(prev => prev.filter(u => u.id !== userId));
|
|
}
|
|
|
|
setGroups(prev => prev.map(group =>
|
|
group.id === selectedGroup.id
|
|
? { ...group, membersCount: group.membersCount + 1 }
|
|
: group
|
|
));
|
|
|
|
toast({
|
|
title: "Success",
|
|
description: "Member added successfully",
|
|
});
|
|
} catch (error) {
|
|
toast({
|
|
title: "Error",
|
|
description: error instanceof Error ? error.message : "An error occurred",
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleRemoveMember = async (userId: string) => {
|
|
if (!selectedGroup) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/groups/${selectedGroup.id}/members/${userId}`, {
|
|
method: "DELETE",
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error("Failed to remove member");
|
|
}
|
|
|
|
const removedMember = groupMembers.find(u => u.id === userId);
|
|
if (removedMember) {
|
|
setGroupMembers(prev => prev.filter(u => u.id !== userId));
|
|
setAvailableUsers(prev => [...prev, removedMember]);
|
|
}
|
|
|
|
setGroups(prev => prev.map(group =>
|
|
group.id === selectedGroup.id
|
|
? { ...group, membersCount: Math.max(0, group.membersCount - 1) }
|
|
: group
|
|
));
|
|
|
|
toast({
|
|
title: "Success",
|
|
description: "Member removed successfully",
|
|
});
|
|
} catch (error) {
|
|
toast({
|
|
title: "Error",
|
|
description: error instanceof Error ? error.message : "An error occurred",
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
// Reset callbacks
|
|
const resetNewGroupForm = useCallback(() => {
|
|
setNewGroupName("");
|
|
}, []);
|
|
|
|
const resetModifyGroupForm = useCallback(() => {
|
|
setSelectedGroup(null);
|
|
setModifiedGroupName("");
|
|
}, []);
|
|
|
|
const resetMembersForm = useCallback(() => {
|
|
setSelectedGroup(null);
|
|
setGroupMembers([]);
|
|
setAvailableUsers([]);
|
|
}, []);
|
|
|
|
if (loading) return <div className="text-center p-4">Loading...</div>;
|
|
|
|
return (
|
|
<div className="space-y-4 p-6 bg-gray-100 rounded-lg">
|
|
<div className="flex justify-between items-center">
|
|
<Input
|
|
type="text"
|
|
placeholder="Rechercher un groupe..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="max-w-xs bg-white text-gray-900 border-gray-300"
|
|
/>
|
|
<DialogWrapper
|
|
open={newGroupDialog}
|
|
onOpenChange={setNewGroupDialog}
|
|
onAfterClose={resetNewGroupForm}
|
|
triggerComponent={
|
|
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
|
|
<Plus className="mr-2 h-4 w-4" /> Ajouter un groupe
|
|
</Button>
|
|
}
|
|
className="bg-white text-black border border-gray-300"
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle className="text-gray-900">Nouveau Groupe</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="groupName" className="text-gray-900">Nom du groupe</Label>
|
|
<Input
|
|
id="groupName"
|
|
value={newGroupName}
|
|
onChange={(e) => setNewGroupName(e.target.value)}
|
|
placeholder="Entrez le nom du groupe"
|
|
className="bg-white text-gray-900 border-gray-300"
|
|
/>
|
|
</div>
|
|
<Button
|
|
onClick={handleCreateGroup}
|
|
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
|
|
>
|
|
Créer le groupe
|
|
</Button>
|
|
</div>
|
|
</DialogWrapper>
|
|
</div>
|
|
|
|
<Table className="bg-white border border-gray-300 rounded-md">
|
|
<TableHeader className="bg-gray-50">
|
|
<TableRow>
|
|
<TableHead className="text-gray-900">Nom</TableHead>
|
|
<TableHead className="text-gray-900">Chemin</TableHead>
|
|
<TableHead className="text-gray-900">Membres</TableHead>
|
|
<TableHead className="text-right text-gray-900">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{groups
|
|
.filter(group =>
|
|
group.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
group.path.toLowerCase().includes(searchTerm.toLowerCase())
|
|
)
|
|
.map((group) => (
|
|
<TableRow key={group.id} className="hover:bg-gray-50 border-t border-gray-200">
|
|
<TableCell className="text-gray-900 font-medium">{group.name}</TableCell>
|
|
<TableCell className="text-gray-900">{group.path}</TableCell>
|
|
<TableCell className="text-gray-900">{group.membersCount}</TableCell>
|
|
<TableCell className="text-right">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
className="h-8 w-8 p-0"
|
|
>
|
|
<span className="sr-only">Open menu</span>
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="bg-white border border-gray-300">
|
|
<DropdownMenuLabel className="text-gray-900">Actions</DropdownMenuLabel>
|
|
<DropdownMenuItem
|
|
onClick={() => handleModifyGroup(group.id)}
|
|
className="text-gray-900 hover:bg-gray-100"
|
|
>
|
|
<Edit className="mr-2 h-4 w-4" />
|
|
Modifier
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() => handleManageMembers(group.id)}
|
|
className="text-gray-900 hover:bg-gray-100"
|
|
>
|
|
<Users className="mr-2 h-4 w-4" />
|
|
Gérer les membres
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onClick={() => handleDeleteGroup(group.id)}
|
|
className="text-red-600 hover:bg-red-50 hover:text-red-700"
|
|
>
|
|
<Trash className="mr-2 h-4 w-4" />
|
|
Supprimer
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
|
|
<DialogWrapper
|
|
open={modifyGroupDialog}
|
|
onOpenChange={setModifyGroupDialog}
|
|
onAfterClose={resetModifyGroupForm}
|
|
className="bg-white text-black border border-gray-300"
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle className="text-gray-900">Modifier le groupe</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="modifiedGroupName" className="text-gray-900">Nom du groupe</Label>
|
|
<Input
|
|
id="modifiedGroupName"
|
|
value={modifiedGroupName}
|
|
onChange={(e) => setModifiedGroupName(e.target.value)}
|
|
className="bg-white text-gray-900 border-gray-300"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<Button
|
|
onClick={handleUpdateGroup}
|
|
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
|
|
>
|
|
Mettre à jour
|
|
</Button>
|
|
</div>
|
|
</DialogWrapper>
|
|
|
|
<DialogWrapper
|
|
open={manageMembersDialog}
|
|
onOpenChange={setManageMembersDialog}
|
|
onAfterClose={resetMembersForm}
|
|
className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto bg-white text-black border border-gray-300"
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle className="text-gray-900">
|
|
Gérer les membres - {selectedGroup?.name}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<h3 className="text-lg font-medium mb-2 text-gray-900">Membres actuels</h3>
|
|
{groupMembers.length === 0 ? (
|
|
<p className="text-gray-600">Ce groupe n'a pas de membres.</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{groupMembers.map((member) => (
|
|
<div key={member.id} className="flex items-center justify-between p-2 bg-gray-50 rounded-md border border-gray-200">
|
|
<div className="text-gray-900">
|
|
<div className="font-medium">{member.username}</div>
|
|
<div className="text-sm text-gray-600">{member.firstName} {member.lastName} ({member.email})</div>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleRemoveMember(member.id)}
|
|
className="text-red-600 hover:bg-red-50 hover:text-red-700 border-red-200"
|
|
>
|
|
<Trash className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<h3 className="text-lg font-medium mb-2 text-gray-900">Ajouter des membres</h3>
|
|
<Input
|
|
type="text"
|
|
placeholder="Rechercher un utilisateur..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="mb-2 bg-white text-gray-900 border-gray-300"
|
|
/>
|
|
<div className="space-y-2 max-h-[250px] overflow-y-auto">
|
|
{availableUsers
|
|
.filter(user =>
|
|
!groupMembers.some(member => member.id === user.id) &&
|
|
(user.username.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
user.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
user.firstName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
user.lastName.toLowerCase().includes(searchTerm.toLowerCase()))
|
|
)
|
|
.map((user) => (
|
|
<div key={user.id} className="flex items-center justify-between p-2 bg-gray-50 rounded-md border border-gray-200">
|
|
<div className="text-gray-900">
|
|
<div className="font-medium">{user.username}</div>
|
|
<div className="text-sm text-gray-600">{user.firstName} {user.lastName} ({user.email})</div>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleAddMember(user.id)}
|
|
className="text-green-600 hover:bg-green-50 hover:text-green-700 border-green-200"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</DialogWrapper>
|
|
</div>
|
|
);
|
|
}
|