NeahStable/components/groups/groups-table.tsx
2026-01-20 15:17:22 +01:00

785 lines
26 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, Palette } 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;
calendarColor?: string;
}
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[]>([]);
const [colorPickerDialog, setColorPickerDialog] = useState(false);
const [selectedGroupForColor, setSelectedGroupForColor] = useState<Group | null>(null);
const [selectedColor, setSelectedColor] = useState("#4f46e5");
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 {
// Fetch members count
const membersResponse = await fetch(`/api/groups/${group.id}/members`);
let membersCount = 0;
if (membersResponse.ok) {
const members = await membersResponse.json();
membersCount = Array.isArray(members) ? members.length : 0;
}
// Fetch calendar color
let calendarColor = "#4f46e5"; // Default color
try {
const calendarResponse = await fetch(`/api/groups/${group.id}/calendar`);
if (calendarResponse.ok) {
const calendar = await calendarResponse.json();
calendarColor = calendar.color || calendarColor;
}
} catch (calendarError) {
console.warn(`Could not fetch calendar for group ${group.id}:`, calendarError);
}
return {
...group,
membersCount,
calendarColor,
};
} catch (error) {
console.error(`Error fetching data for group ${group.id}:`, error);
return { ...group, membersCount: 0, calendarColor: "#4f46e5" };
}
})
);
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([]);
}, []);
const resetColorPickerForm = useCallback(() => {
setSelectedGroupForColor(null);
setSelectedColor("#4f46e5");
}, []);
const handleOpenColorPicker = (group: Group) => {
setSelectedGroupForColor(group);
setSelectedColor(group.calendarColor || "#4f46e5");
setColorPickerDialog(true);
};
const handleSaveColor = async () => {
if (!selectedGroupForColor) return;
try {
const response = await fetch(`/api/groups/${selectedGroupForColor.id}/calendar`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ color: selectedColor }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || "Erreur lors de la mise à jour de la couleur");
}
// Update local state
setGroups(prev => prev.map(g =>
g.id === selectedGroupForColor.id
? { ...g, calendarColor: selectedColor }
: g
));
setColorPickerDialog(false);
toast({
title: "Succès",
description: "La couleur du calendrier a été mise à jour",
});
} catch (error) {
toast({
title: "Erreur",
description: error instanceof Error ? error.message : "Une erreur est survenue",
variant: "destructive",
});
}
};
// Predefined color palette
const colorPalette = [
"#4f46e5", // Indigo
"#0891b2", // Cyan
"#0e7490", // Teal
"#16a34a", // Green
"#65a30d", // Lime
"#ca8a04", // Amber
"#d97706", // Orange
"#dc2626", // Red
"#e11d48", // Rose
"#9333ea", // Purple
"#7c3aed", // Violet
"#2563eb", // Blue
"#0284c7", // Sky
"#059669", // Emerald
"#84cc16", // Lime
"#eab308", // Yellow
];
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">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: group.calendarColor || "#4f46e5" }}
/>
{group.name}
</div>
</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={() => handleOpenColorPicker(group)}
className="text-gray-900 hover:bg-gray-100"
>
<Palette className="mr-2 h-4 w-4" />
Couleur du calendrier
</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={colorPickerDialog}
onOpenChange={setColorPickerDialog}
onAfterClose={resetColorPickerForm}
className="sm:max-w-[400px] bg-white text-black border border-gray-300"
>
<DialogHeader>
<DialogTitle className="text-gray-900">
Changer la couleur du calendrier - {selectedGroupForColor?.name}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label className="text-gray-900 mb-3 block">Couleur sélectionnée</Label>
<div className="flex items-center gap-3 mb-4">
<div
className="w-16 h-16 rounded-lg border-2 border-gray-300 shadow-sm"
style={{ backgroundColor: selectedColor }}
/>
<div className="flex-1">
<Input
type="text"
value={selectedColor}
onChange={(e) => setSelectedColor(e.target.value)}
className="bg-white text-gray-900 border-gray-300 font-mono"
placeholder="#4f46e5"
/>
</div>
</div>
</div>
<div>
<Label className="text-gray-900 mb-3 block">Palette de couleurs</Label>
<div className="grid grid-cols-8 gap-2">
{colorPalette.map((color) => (
<button
key={color}
type="button"
onClick={() => setSelectedColor(color)}
className={`w-8 h-8 rounded-md border-2 transition-all hover:scale-110 ${
selectedColor === color ? 'border-gray-900 shadow-md' : 'border-gray-300'
}`}
style={{ backgroundColor: color }}
title={color}
/>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button
variant="outline"
onClick={() => {
setColorPickerDialog(false);
resetColorPickerForm();
}}
className="border-gray-300 text-gray-700 hover:bg-gray-50 bg-white"
>
Annuler
</Button>
<Button
onClick={handleSaveColor}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
Enregistrer
</Button>
</div>
</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>
);
}