1447 lines
60 KiB
TypeScript
1447 lines
60 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { Search, Plus, MoreHorizontal, Trash2, Edit2, Users, UserPlus, X, Check, Loader2, FolderKanban, Palette } from "lucide-react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Button } from "@/components/ui/button";
|
|
import { useToast } from "@/components/ui/use-toast";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "@/components/ui/dialog";
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
// Types
|
|
interface User {
|
|
id: string;
|
|
username: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
email: string;
|
|
roles: string[];
|
|
enabled: boolean;
|
|
}
|
|
|
|
interface Group {
|
|
id: string;
|
|
name: string;
|
|
path: string;
|
|
membersCount: number;
|
|
calendarColor?: string;
|
|
}
|
|
|
|
type ActiveTab = "users" | "groups";
|
|
type EditMode = null | { type: "user" | "group"; id: string; action: "edit" | "roles" | "members" | "groups" };
|
|
|
|
export default function EquipePage() {
|
|
const { toast } = useToast();
|
|
const [activeTab, setActiveTab] = useState<ActiveTab>("users");
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [loading, setLoading] = useState(true);
|
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
|
|
|
// Data
|
|
const [users, setUsers] = useState<User[]>([]);
|
|
const [groups, setGroups] = useState<Group[]>([]);
|
|
const [roles, setRoles] = useState<{ id: string; name: string }[]>([]);
|
|
|
|
// Inline edit states
|
|
const [editMode, setEditMode] = useState<EditMode>(null);
|
|
const [editData, setEditData] = useState<any>({});
|
|
|
|
// Group members state
|
|
const [groupMembers, setGroupMembers] = useState<User[]>([]);
|
|
const [availableUsers, setAvailableUsers] = useState<User[]>([]);
|
|
|
|
// User groups state
|
|
const [userGroups, setUserGroups] = useState<Group[]>([]);
|
|
const [availableGroups, setAvailableGroups] = useState<Group[]>([]);
|
|
|
|
// Color picker state
|
|
const [colorPickerDialog, setColorPickerDialog] = useState(false);
|
|
const [selectedGroupForColor, setSelectedGroupForColor] = useState<Group | null>(null);
|
|
const [selectedColor, setSelectedColor] = useState("#4f46e5");
|
|
|
|
// New user dialog state
|
|
const [newUserDialogOpen, setNewUserDialogOpen] = useState(false);
|
|
const [newUserData, setNewUserData] = useState({
|
|
username: "",
|
|
firstName: "",
|
|
lastName: "",
|
|
email: "",
|
|
password: "",
|
|
roles: [] as string[],
|
|
});
|
|
|
|
// New group dialog state
|
|
const [newGroupDialogOpen, setNewGroupDialogOpen] = useState(false);
|
|
const [newGroupName, setNewGroupName] = useState("");
|
|
|
|
// Fetch data on mount
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, []);
|
|
|
|
const fetchData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [usersRes, groupsRes, rolesRes] = await Promise.all([
|
|
fetch("/api/users"),
|
|
fetch("/api/groups"),
|
|
fetch("/api/roles")
|
|
]);
|
|
|
|
if (usersRes.ok) {
|
|
const usersData = await usersRes.json();
|
|
setUsers(Array.isArray(usersData) ? usersData : []);
|
|
}
|
|
|
|
if (groupsRes.ok) {
|
|
const groupsData = await groupsRes.json();
|
|
|
|
// Fetch calendar colors for each group
|
|
const groupsWithColors = await Promise.all(
|
|
(Array.isArray(groupsData) ? groupsData : []).map(async (group: Group) => {
|
|
try {
|
|
const calendarResponse = await fetch(`/api/groups/${group.id}/calendar`);
|
|
if (calendarResponse.ok) {
|
|
const calendar = await calendarResponse.json();
|
|
return { ...group, calendarColor: calendar.color || "#4f46e5" };
|
|
}
|
|
} catch (error) {
|
|
console.warn(`Could not fetch calendar for group ${group.id}`);
|
|
}
|
|
return { ...group, calendarColor: "#4f46e5" };
|
|
})
|
|
);
|
|
|
|
setGroups(groupsWithColors);
|
|
}
|
|
|
|
if (rolesRes.ok) {
|
|
const rolesData = await rolesRes.json();
|
|
setRoles(Array.isArray(rolesData) ? rolesData : []);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error fetching data:", error);
|
|
toast({
|
|
title: "Erreur",
|
|
description: "Impossible de charger les données",
|
|
variant: "destructive"
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// Filter functions
|
|
const filteredUsers = users.filter(user =>
|
|
user.username?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
user.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
user.firstName?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
user.lastName?.toLowerCase().includes(searchTerm.toLowerCase())
|
|
);
|
|
|
|
const filteredGroups = groups.filter(group =>
|
|
group.name?.toLowerCase().includes(searchTerm.toLowerCase())
|
|
);
|
|
|
|
// User actions
|
|
const handleEditUser = (user: User) => {
|
|
setEditMode({ type: "user", id: user.id, action: "edit" });
|
|
setEditData({
|
|
firstName: user.firstName || "",
|
|
lastName: user.lastName || "",
|
|
email: user.email || ""
|
|
});
|
|
};
|
|
|
|
const handleEditRoles = (user: User) => {
|
|
setEditMode({ type: "user", id: user.id, action: "roles" });
|
|
setEditData({ roles: user.roles || [] });
|
|
};
|
|
|
|
const handleManageUserGroups = async (user: User) => {
|
|
setActionLoading(user.id);
|
|
try {
|
|
const groupsRes = await fetch(`/api/users/${user.id}/groups`);
|
|
if (groupsRes.ok) {
|
|
const userGroupsData = await groupsRes.json();
|
|
setUserGroups(Array.isArray(userGroupsData) ? userGroupsData : []);
|
|
setAvailableGroups(groups.filter(g => !userGroupsData.some((ug: Group) => ug.id === g.id)));
|
|
}
|
|
setEditMode({ type: "user", id: user.id, action: "groups" });
|
|
} catch (error) {
|
|
toast({ title: "Erreur", description: "Impossible de charger les groupes", variant: "destructive" });
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
const saveUserEdit = async (userId: string) => {
|
|
setActionLoading(userId);
|
|
try {
|
|
const response = await fetch(`/api/users/${userId}`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
firstName: editData.firstName || "",
|
|
lastName: editData.lastName || "",
|
|
email: editData.email || ""
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({}));
|
|
throw new Error(errorData.error || "Failed to update user");
|
|
}
|
|
|
|
// Refresh user data
|
|
await fetchData();
|
|
|
|
toast({ title: "Succès", description: "Utilisateur modifié" });
|
|
setEditMode(null);
|
|
} catch (error) {
|
|
console.error("Error updating user:", error);
|
|
toast({
|
|
title: "Erreur",
|
|
description: error instanceof Error ? error.message : "Échec de la modification",
|
|
variant: "destructive"
|
|
});
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
const saveUserRoles = async (userId: string) => {
|
|
setActionLoading(userId);
|
|
try {
|
|
const response = await fetch(`/api/users/${userId}/roles`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ roles: editData.roles })
|
|
});
|
|
|
|
if (!response.ok) throw new Error("Failed to update roles");
|
|
|
|
setUsers(prev => prev.map(u =>
|
|
u.id === userId ? { ...u, roles: editData.roles } : u
|
|
));
|
|
|
|
toast({ title: "Succès", description: "Rôles mis à jour" });
|
|
setEditMode(null);
|
|
} catch (error) {
|
|
toast({ title: "Erreur", description: "Échec de la mise à jour des rôles", variant: "destructive" });
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
const deleteUser = async (userId: string, email: string) => {
|
|
if (!confirm("Êtes-vous sûr de vouloir supprimer cet utilisateur ?")) return;
|
|
|
|
setActionLoading(userId);
|
|
try {
|
|
const response = await fetch(`/api/users?id=${userId}&email=${encodeURIComponent(email)}`, {
|
|
method: "DELETE"
|
|
});
|
|
|
|
if (!response.ok) throw new Error("Failed to delete user");
|
|
|
|
setUsers(prev => prev.filter(u => u.id !== userId));
|
|
toast({ title: "Succès", description: "Utilisateur supprimé" });
|
|
} catch (error) {
|
|
toast({ title: "Erreur", description: "Échec de la suppression", variant: "destructive" });
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
// Group actions
|
|
const handleEditGroup = (group: Group) => {
|
|
setEditMode({ type: "group", id: group.id, action: "edit" });
|
|
setEditData({ name: group.name });
|
|
};
|
|
|
|
const handleManageMembers = async (group: Group) => {
|
|
setActionLoading(group.id);
|
|
try {
|
|
const membersRes = await fetch(`/api/groups/${group.id}/members`);
|
|
if (membersRes.ok) {
|
|
const members = await membersRes.json();
|
|
setGroupMembers(Array.isArray(members) ? members : []);
|
|
setAvailableUsers(users.filter(u => !members.some((m: User) => m.id === u.id)));
|
|
}
|
|
setEditMode({ type: "group", id: group.id, action: "members" });
|
|
} catch (error) {
|
|
toast({ title: "Erreur", description: "Impossible de charger les membres", variant: "destructive" });
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
const saveGroupEdit = async (groupId: string) => {
|
|
setActionLoading(groupId);
|
|
try {
|
|
const response = await fetch(`/api/groups/${groupId}`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ name: editData.name })
|
|
});
|
|
|
|
if (!response.ok) throw new Error("Failed to update group");
|
|
|
|
setGroups(prev => prev.map(g =>
|
|
g.id === groupId ? { ...g, name: editData.name } : g
|
|
));
|
|
|
|
toast({ title: "Succès", description: "Groupe modifié" });
|
|
setEditMode(null);
|
|
} catch (error) {
|
|
toast({ title: "Erreur", description: "Échec de la modification", variant: "destructive" });
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
const deleteGroup = async (groupId: string) => {
|
|
if (!confirm("Êtes-vous sûr de vouloir supprimer ce groupe ?")) return;
|
|
|
|
setActionLoading(groupId);
|
|
try {
|
|
const response = await fetch(`/api/groups/${groupId}`, { method: "DELETE" });
|
|
|
|
if (!response.ok) throw new Error("Failed to delete group");
|
|
|
|
setGroups(prev => prev.filter(g => g.id !== groupId));
|
|
toast({ title: "Succès", description: "Groupe supprimé" });
|
|
} catch (error) {
|
|
toast({ title: "Erreur", description: "Échec de la suppression", variant: "destructive" });
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
const addMemberToGroup = async (userId: string) => {
|
|
if (!editMode || editMode.action !== "members") return;
|
|
|
|
setActionLoading(userId);
|
|
try {
|
|
const response = await fetch(`/api/groups/${editMode.id}/members`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ userId })
|
|
});
|
|
|
|
if (!response.ok) throw new Error("Failed to add member");
|
|
|
|
const user = availableUsers.find(u => u.id === userId);
|
|
if (user) {
|
|
setGroupMembers(prev => [...prev, user]);
|
|
setAvailableUsers(prev => prev.filter(u => u.id !== userId));
|
|
setGroups(prev => prev.map(g =>
|
|
g.id === editMode.id ? { ...g, membersCount: g.membersCount + 1 } : g
|
|
));
|
|
}
|
|
|
|
toast({ title: "Succès", description: "Membre ajouté" });
|
|
} catch (error) {
|
|
toast({ title: "Erreur", description: "Échec de l'ajout", variant: "destructive" });
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
const removeMemberFromGroup = async (userId: string) => {
|
|
if (!editMode || editMode.action !== "members") return;
|
|
|
|
setActionLoading(userId);
|
|
try {
|
|
const response = await fetch(`/api/groups/${editMode.id}/members/${userId}`, {
|
|
method: "DELETE"
|
|
});
|
|
|
|
if (!response.ok) throw new Error("Failed to remove member");
|
|
|
|
const user = groupMembers.find(u => u.id === userId);
|
|
if (user) {
|
|
setGroupMembers(prev => prev.filter(u => u.id !== userId));
|
|
setAvailableUsers(prev => [...prev, user]);
|
|
setGroups(prev => prev.map(g =>
|
|
g.id === editMode.id ? { ...g, membersCount: Math.max(0, g.membersCount - 1) } : g
|
|
));
|
|
}
|
|
|
|
toast({ title: "Succès", description: "Membre retiré" });
|
|
} catch (error) {
|
|
toast({ title: "Erreur", description: "Échec du retrait", variant: "destructive" });
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
const toggleRole = (roleName: string) => {
|
|
setEditData((prev: any) => ({
|
|
...prev,
|
|
roles: prev.roles.includes(roleName)
|
|
? prev.roles.filter((r: string) => r !== roleName)
|
|
: [...prev.roles, roleName]
|
|
}));
|
|
};
|
|
|
|
const cancelEdit = () => {
|
|
setEditMode(null);
|
|
setEditData({});
|
|
setGroupMembers([]);
|
|
setAvailableUsers([]);
|
|
setUserGroups([]);
|
|
setAvailableGroups([]);
|
|
};
|
|
|
|
const createUser = async () => {
|
|
setActionLoading("new-user");
|
|
try {
|
|
const response = await fetch("/api/users", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
...newUserData,
|
|
roles: newUserData.roles,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
throw new Error(errorData.error || "Failed to create user");
|
|
}
|
|
|
|
const data = await response.json();
|
|
setUsers(prev => [...prev, data.user]);
|
|
setNewUserDialogOpen(false);
|
|
setNewUserData({
|
|
username: "",
|
|
firstName: "",
|
|
lastName: "",
|
|
email: "",
|
|
password: "",
|
|
roles: [],
|
|
});
|
|
toast({ title: "Succès", description: "Utilisateur créé" });
|
|
fetchData(); // Refresh data to get updated counts
|
|
} catch (error) {
|
|
toast({
|
|
title: "Erreur",
|
|
description: error instanceof Error ? error.message : "Échec de la création",
|
|
variant: "destructive"
|
|
});
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
const addUserToGroup = async (groupId: string) => {
|
|
if (!editMode || editMode.action !== "groups") return;
|
|
|
|
setActionLoading(groupId);
|
|
try {
|
|
const response = await fetch(`/api/users/${editMode.id}/groups`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ groupId })
|
|
});
|
|
|
|
if (!response.ok) throw new Error("Failed to add user to group");
|
|
|
|
const group = availableGroups.find(g => g.id === groupId);
|
|
if (group) {
|
|
setUserGroups(prev => [...prev, group]);
|
|
setAvailableGroups(prev => prev.filter(g => g.id !== groupId));
|
|
setGroups(prev => prev.map(g =>
|
|
g.id === groupId ? { ...g, membersCount: g.membersCount + 1 } : g
|
|
));
|
|
}
|
|
|
|
toast({ title: "Succès", description: "Utilisateur ajouté au groupe" });
|
|
} catch (error) {
|
|
toast({ title: "Erreur", description: "Échec de l'ajout", variant: "destructive" });
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
const removeUserFromGroup = async (groupId: string) => {
|
|
if (!editMode || editMode.action !== "groups") return;
|
|
|
|
setActionLoading(groupId);
|
|
try {
|
|
const response = await fetch(`/api/users/${editMode.id}/groups?groupId=${groupId}`, {
|
|
method: "DELETE"
|
|
});
|
|
|
|
if (!response.ok) throw new Error("Failed to remove user from group");
|
|
|
|
const group = userGroups.find(g => g.id === groupId);
|
|
if (group) {
|
|
setUserGroups(prev => prev.filter(g => g.id !== groupId));
|
|
setAvailableGroups(prev => [...prev, group]);
|
|
setGroups(prev => prev.map(g =>
|
|
g.id === groupId ? { ...g, membersCount: Math.max(0, g.membersCount - 1) } : g
|
|
));
|
|
}
|
|
|
|
toast({ title: "Succès", description: "Utilisateur retiré du groupe" });
|
|
} catch (error) {
|
|
toast({ title: "Erreur", description: "Échec du retrait", variant: "destructive" });
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
const handleOpenColorPicker = (group: Group) => {
|
|
setSelectedGroupForColor(group);
|
|
setSelectedColor(group.calendarColor || "#4f46e5");
|
|
setColorPickerDialog(true);
|
|
};
|
|
|
|
const handleSaveColor = async () => {
|
|
if (!selectedGroupForColor) return;
|
|
|
|
setActionLoading(selectedGroupForColor.id);
|
|
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);
|
|
setSelectedGroupForColor(null);
|
|
setSelectedColor("#4f46e5");
|
|
|
|
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",
|
|
});
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
// Predefined color palette
|
|
const colorPalette = [
|
|
"#4f46e5", "#0891b2", "#0e7490", "#16a34a", "#65a30d", "#ca8a04",
|
|
"#d97706", "#dc2626", "#e11d48", "#9333ea", "#7c3aed", "#2563eb",
|
|
"#0284c7", "#059669", "#84cc16", "#eab308"
|
|
];
|
|
|
|
const createGroup = async () => {
|
|
if (!newGroupName.trim()) {
|
|
toast({ title: "Erreur", description: "Le nom du groupe est requis", variant: "destructive" });
|
|
return;
|
|
}
|
|
|
|
setActionLoading("new-group");
|
|
try {
|
|
const response = await fetch("/api/groups", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ name: newGroupName.trim() })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
// Try to parse error message from response
|
|
let errorMessage = "Échec de la création du groupe";
|
|
|
|
// Provide status-specific default messages
|
|
if (response.status === 409) {
|
|
errorMessage = "Un groupe avec ce nom existe déjà";
|
|
} else if (response.status === 400) {
|
|
errorMessage = "Nom de groupe invalide";
|
|
} else if (response.status === 401) {
|
|
errorMessage = "Non autorisé";
|
|
} else if (response.status === 500) {
|
|
errorMessage = "Erreur serveur lors de la création du groupe";
|
|
}
|
|
|
|
try {
|
|
// Check if response has content
|
|
const contentType = response.headers.get('content-type') || '';
|
|
let text: string = '';
|
|
|
|
try {
|
|
text = await response.text();
|
|
} catch (textError) {
|
|
console.error('Error reading response text:', textError);
|
|
// If we can't read the text, use the default error message and skip parsing
|
|
text = '';
|
|
}
|
|
|
|
// Only try to parse if we have text content
|
|
if (text && typeof text === 'string') {
|
|
const trimmedText = text.trim();
|
|
if (trimmedText.length > 0) {
|
|
if (contentType.includes('application/json')) {
|
|
// Additional check: make sure it looks like JSON (starts with { or [)
|
|
if (trimmedText.startsWith('{') || trimmedText.startsWith('[')) {
|
|
try {
|
|
const errorData = JSON.parse(trimmedText);
|
|
errorMessage = errorData.message || errorData.error || errorMessage;
|
|
} catch (parseError) {
|
|
// If JSON parsing fails, use the text (truncated if too long)
|
|
console.warn('Failed to parse error response as JSON:', parseError);
|
|
errorMessage = trimmedText.length > 200 ? trimmedText.substring(0, 200) + '...' : trimmedText;
|
|
}
|
|
} else {
|
|
// Doesn't look like JSON, use as plain text
|
|
errorMessage = trimmedText.length > 200 ? trimmedText.substring(0, 200) + '...' : trimmedText;
|
|
}
|
|
} else {
|
|
// If there's text but not JSON, use it (truncated if too long)
|
|
errorMessage = trimmedText.length > 200 ? trimmedText.substring(0, 200) + '...' : trimmedText;
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// If anything fails reading the response, use the default message
|
|
console.error('Error processing error response:', error);
|
|
// errorMessage already has a default value, so we keep it
|
|
}
|
|
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
// Only parse JSON if response is OK
|
|
let newGroup;
|
|
try {
|
|
const text = await response.text();
|
|
const trimmedText = text?.trim() || '';
|
|
|
|
if (!trimmedText || trimmedText.length === 0) {
|
|
throw new Error("Réponse vide du serveur");
|
|
}
|
|
|
|
// Make sure it looks like JSON before parsing
|
|
if (!trimmedText.startsWith('{') && !trimmedText.startsWith('[')) {
|
|
throw new Error("Réponse invalide du serveur (format non-JSON)");
|
|
}
|
|
|
|
try {
|
|
newGroup = JSON.parse(trimmedText);
|
|
} catch (parseError) {
|
|
console.error("Error parsing JSON:", parseError);
|
|
throw new Error("Erreur lors de la lecture de la réponse du serveur");
|
|
}
|
|
} catch (parseError) {
|
|
console.error("Error parsing success response:", parseError);
|
|
const errorMsg = parseError instanceof Error ? parseError.message : "Erreur lors de la lecture de la réponse du serveur";
|
|
throw new Error(errorMsg);
|
|
}
|
|
|
|
setGroups(prev => [...prev, newGroup]);
|
|
setNewGroupDialogOpen(false);
|
|
setNewGroupName("");
|
|
toast({ title: "Succès", description: "Groupe créé avec succès" });
|
|
fetchData(); // Refresh data
|
|
} catch (error) {
|
|
console.error("Error creating group:", error);
|
|
toast({
|
|
title: "Erreur",
|
|
description: error instanceof Error ? error.message : "Échec de la création du groupe",
|
|
variant: "destructive"
|
|
});
|
|
} finally {
|
|
setActionLoading(null);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col h-full w-full bg-white">
|
|
{/* Header */}
|
|
<div className="bg-white border-b border-gray-100 py-3 px-6">
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-gray-800 text-base font-medium">Gestion des équipes</h1>
|
|
<div className="flex items-center gap-3">
|
|
{activeTab === "users" && (
|
|
<Dialog open={newUserDialogOpen} onOpenChange={setNewUserDialogOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button size="sm" className="h-9 bg-blue-600 hover:bg-blue-700 text-white">
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
Ajouter un utilisateur
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="sm:max-w-[500px] bg-white">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-gray-900">Nouvel utilisateur</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-4">
|
|
<div>
|
|
<Label htmlFor="username" className="text-gray-900">Nom d'utilisateur *</Label>
|
|
<Input
|
|
id="username"
|
|
value={newUserData.username}
|
|
onChange={(e) => setNewUserData({ ...newUserData, username: e.target.value })}
|
|
className="mt-1 bg-white text-gray-900 border-gray-300"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="firstName" className="text-gray-900">Prénom *</Label>
|
|
<Input
|
|
id="firstName"
|
|
value={newUserData.firstName}
|
|
onChange={(e) => setNewUserData({ ...newUserData, firstName: e.target.value })}
|
|
className="mt-1 bg-white text-gray-900 border-gray-300"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="lastName" className="text-gray-900">Nom *</Label>
|
|
<Input
|
|
id="lastName"
|
|
value={newUserData.lastName}
|
|
onChange={(e) => setNewUserData({ ...newUserData, lastName: e.target.value })}
|
|
className="mt-1 bg-white text-gray-900 border-gray-300"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="email" className="text-gray-900">Email *</Label>
|
|
<Input
|
|
id="email"
|
|
type="email"
|
|
value={newUserData.email}
|
|
onChange={(e) => setNewUserData({ ...newUserData, email: e.target.value })}
|
|
className="mt-1 bg-white text-gray-900 border-gray-300"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="password" className="text-gray-900">Mot de passe *</Label>
|
|
<Input
|
|
id="password"
|
|
type="password"
|
|
value={newUserData.password}
|
|
onChange={(e) => setNewUserData({ ...newUserData, password: e.target.value })}
|
|
className="mt-1 bg-white text-gray-900 border-gray-300"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="mb-2 block text-gray-900">Rôles</Label>
|
|
<div className="max-h-40 overflow-y-auto space-y-2 border rounded-md p-3">
|
|
{roles.map(role => (
|
|
<label
|
|
key={role.id}
|
|
className="flex items-center gap-2 cursor-pointer"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={newUserData.roles.includes(role.name)}
|
|
onChange={(e) => {
|
|
if (e.target.checked) {
|
|
setNewUserData({ ...newUserData, roles: [...newUserData.roles, role.name] });
|
|
} else {
|
|
setNewUserData({ ...newUserData, roles: newUserData.roles.filter(r => r !== role.name) });
|
|
}
|
|
}}
|
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
/>
|
|
<span className="text-sm text-gray-700">{role.name}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-2 pt-4">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setNewUserDialogOpen(false)}
|
|
className="border-gray-300 text-gray-700 hover:bg-gray-50"
|
|
>
|
|
Annuler
|
|
</Button>
|
|
<Button
|
|
onClick={createUser}
|
|
disabled={actionLoading === "new-user" || !newUserData.username || !newUserData.email || !newUserData.password}
|
|
className="bg-blue-600 hover:bg-blue-700 text-white"
|
|
>
|
|
{actionLoading === "new-user" ? (
|
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
) : (
|
|
<Check className="h-4 w-4 mr-2" />
|
|
)}
|
|
Créer
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
{activeTab === "groups" && (
|
|
<Dialog open={newGroupDialogOpen} onOpenChange={setNewGroupDialogOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button size="sm" className="h-9 bg-blue-600 hover:bg-blue-700 text-white">
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
Créer un groupe
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="sm:max-w-[400px] bg-white">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-gray-900">Nouveau groupe</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-4">
|
|
<div>
|
|
<Label htmlFor="groupName" className="text-gray-900">Nom du groupe *</Label>
|
|
<Input
|
|
id="groupName"
|
|
value={newGroupName}
|
|
onChange={(e) => setNewGroupName(e.target.value)}
|
|
className="mt-1 bg-white text-gray-900 border-gray-300"
|
|
placeholder="Ex: Développeurs"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end gap-2 pt-4">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setNewGroupDialogOpen(false);
|
|
setNewGroupName("");
|
|
}}
|
|
className="border-gray-300 text-gray-700 hover:bg-gray-50 bg-white"
|
|
>
|
|
Annuler
|
|
</Button>
|
|
<Button
|
|
onClick={createGroup}
|
|
disabled={actionLoading === "new-group" || !newGroupName.trim()}
|
|
className="bg-blue-600 hover:bg-blue-700 text-white"
|
|
>
|
|
{actionLoading === "new-group" ? (
|
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
) : (
|
|
<Check className="h-4 w-4 mr-2" />
|
|
)}
|
|
Créer
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-500" />
|
|
<Input
|
|
placeholder="Rechercher..."
|
|
className="h-9 pl-9 pr-3 py-2 text-sm bg-white text-gray-800 border-gray-200 rounded-md w-60"
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="border-b border-gray-200 px-6">
|
|
<div className="flex gap-4">
|
|
<button
|
|
onClick={() => { setActiveTab("users"); cancelEdit(); }}
|
|
className={`py-3 px-1 text-sm font-medium border-b-2 transition-colors ${
|
|
activeTab === "users"
|
|
? "border-blue-600 text-blue-600"
|
|
: "border-transparent text-gray-500 hover:text-gray-700"
|
|
}`}
|
|
>
|
|
<Users className="inline h-4 w-4 mr-2" />
|
|
Utilisateurs ({users.length})
|
|
</button>
|
|
<button
|
|
onClick={() => { setActiveTab("groups"); cancelEdit(); }}
|
|
className={`py-3 px-1 text-sm font-medium border-b-2 transition-colors ${
|
|
activeTab === "groups"
|
|
? "border-blue-600 text-blue-600"
|
|
: "border-transparent text-gray-500 hover:text-gray-700"
|
|
}`}
|
|
>
|
|
<Users className="inline h-4 w-4 mr-2" />
|
|
Groupes ({groups.length})
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 overflow-auto bg-gray-50 p-6">
|
|
{loading ? (
|
|
<div className="flex justify-center items-center h-40">
|
|
<Loader2 className="h-8 w-8 animate-spin text-blue-600" />
|
|
</div>
|
|
) : (
|
|
<div className="flex gap-6">
|
|
{/* Main List */}
|
|
<div className={`bg-white rounded-lg border border-gray-200 overflow-hidden ${editMode ? "flex-1" : "w-full"}`}>
|
|
{activeTab === "users" ? (
|
|
/* Users Table */
|
|
<table className="w-full">
|
|
<thead className="bg-gray-50 border-b border-gray-200">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Utilisateur</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Email</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Rôles</th>
|
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100">
|
|
{filteredUsers.map(user => (
|
|
<tr key={user.id} className={`hover:bg-gray-50 ${editMode?.id === user.id ? "bg-blue-50" : ""}`}>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="h-8 w-8 rounded-full bg-blue-100 flex items-center justify-center text-blue-700 text-xs font-medium">
|
|
{user.firstName?.[0] || ""}{user.lastName?.[0] || user.username?.[0] || "?"}
|
|
</div>
|
|
<div>
|
|
<div className="text-sm font-medium text-gray-900">
|
|
{user.firstName} {user.lastName}
|
|
</div>
|
|
<div className="text-xs text-gray-500">@{user.username}</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-gray-600">{user.email}</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex flex-wrap gap-1">
|
|
{Array.isArray(user.roles) && user.roles.length > 0 ? (
|
|
user.roles.map(role => (
|
|
<span key={role} className="px-2 py-0.5 text-xs bg-blue-50 text-blue-700 rounded-full">
|
|
{role}
|
|
</span>
|
|
))
|
|
) : (
|
|
<span className="px-2 py-0.5 text-xs bg-gray-100 text-gray-400 rounded-full">
|
|
Aucun rôle
|
|
</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center justify-end gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleEditUser(user)}
|
|
disabled={actionLoading === user.id}
|
|
className="h-8 w-8 p-0"
|
|
>
|
|
<Edit2 className="h-4 w-4 text-gray-500" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleEditRoles(user)}
|
|
disabled={actionLoading === user.id}
|
|
className="h-8 w-8 p-0"
|
|
title="Gérer les rôles"
|
|
>
|
|
<UserPlus className="h-4 w-4 text-gray-500" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleManageUserGroups(user)}
|
|
disabled={actionLoading === user.id}
|
|
className="h-8 w-8 p-0"
|
|
title="Gérer les groupes"
|
|
>
|
|
{actionLoading === user.id ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<FolderKanban className="h-4 w-4 text-gray-500" />
|
|
)}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => deleteUser(user.id, user.email)}
|
|
disabled={actionLoading === user.id}
|
|
className="h-8 w-8 p-0 hover:text-red-600"
|
|
title="Supprimer"
|
|
>
|
|
<Trash2 className="h-4 w-4 text-gray-500" />
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
) : (
|
|
/* Groups Table */
|
|
<table className="w-full">
|
|
<thead className="bg-gray-50 border-b border-gray-200">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Groupe</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Chemin</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Membres</th>
|
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100">
|
|
{filteredGroups.map(group => (
|
|
<tr key={group.id} className={`hover:bg-gray-50 ${editMode?.id === group.id ? "bg-blue-50" : ""}`}>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-3">
|
|
<div
|
|
className="h-8 w-8 rounded-full flex items-center justify-center"
|
|
style={{ backgroundColor: group.calendarColor || "#4f46e5" }}
|
|
>
|
|
<Users className="h-4 w-4 text-white" />
|
|
</div>
|
|
<span className="text-sm font-medium text-gray-900">{group.name}</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-gray-600">{group.path}</td>
|
|
<td className="px-4 py-3">
|
|
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded-full">
|
|
{group.membersCount} membres
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center justify-end gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleEditGroup(group)}
|
|
disabled={actionLoading === group.id}
|
|
className="h-8 w-8 p-0"
|
|
title="Modifier"
|
|
>
|
|
<Edit2 className="h-4 w-4 text-gray-500" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleOpenColorPicker(group)}
|
|
disabled={actionLoading === group.id}
|
|
className="h-8 w-8 p-0"
|
|
title="Couleur du calendrier"
|
|
>
|
|
<Palette className="h-4 w-4 text-gray-500" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleManageMembers(group)}
|
|
disabled={actionLoading === group.id}
|
|
className="h-8 w-8 p-0"
|
|
title="Gérer les membres"
|
|
>
|
|
{actionLoading === group.id ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Users className="h-4 w-4 text-gray-500" />
|
|
)}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => deleteGroup(group.id)}
|
|
disabled={actionLoading === group.id}
|
|
className="h-8 w-8 p-0 hover:text-red-600"
|
|
title="Supprimer"
|
|
>
|
|
<Trash2 className="h-4 w-4 text-gray-500" />
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
|
|
{/* Empty states */}
|
|
{activeTab === "users" && filteredUsers.length === 0 && (
|
|
<div className="p-8 text-center text-gray-500">
|
|
<Users className="h-12 w-12 mx-auto mb-3 text-gray-300" />
|
|
<p>Aucun utilisateur trouvé</p>
|
|
</div>
|
|
)}
|
|
{activeTab === "groups" && filteredGroups.length === 0 && (
|
|
<div className="p-8 text-center text-gray-500">
|
|
<Users className="h-12 w-12 mx-auto mb-3 text-gray-300" />
|
|
<p>Aucun groupe trouvé</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Side Panel for Editing */}
|
|
{editMode && (
|
|
<div className="w-80 bg-white rounded-lg border border-gray-200 p-4 h-fit">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="font-medium text-gray-900">
|
|
{editMode.action === "edit" && "Modifier"}
|
|
{editMode.action === "roles" && "Gérer les rôles"}
|
|
{editMode.action === "members" && "Gérer les membres"}
|
|
{editMode.action === "groups" && "Gérer les groupes"}
|
|
</h3>
|
|
<Button variant="ghost" size="sm" onClick={cancelEdit} className="h-8 w-8 p-0">
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Edit User Form */}
|
|
{editMode.type === "user" && editMode.action === "edit" && (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-900 block mb-1">Prénom</label>
|
|
<Input
|
|
value={editData.firstName || ""}
|
|
onChange={(e) => setEditData({ ...editData, firstName: e.target.value })}
|
|
className="h-9 bg-white text-gray-900 border-gray-300"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-900 block mb-1">Nom</label>
|
|
<Input
|
|
value={editData.lastName || ""}
|
|
onChange={(e) => setEditData({ ...editData, lastName: e.target.value })}
|
|
className="h-9 bg-white text-gray-900 border-gray-300"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-900 block mb-1">Email</label>
|
|
<Input
|
|
type="email"
|
|
value={editData.email || ""}
|
|
onChange={(e) => setEditData({ ...editData, email: e.target.value })}
|
|
className="h-9 bg-white text-gray-900 border-gray-300"
|
|
/>
|
|
</div>
|
|
<Button
|
|
onClick={() => saveUserEdit(editMode.id)}
|
|
disabled={actionLoading === editMode.id}
|
|
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
|
|
>
|
|
{actionLoading === editMode.id ? (
|
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
) : (
|
|
<Check className="h-4 w-4 mr-2" />
|
|
)}
|
|
Sauvegarder
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Edit Roles Form */}
|
|
{editMode.type === "user" && editMode.action === "roles" && (
|
|
<div className="space-y-3">
|
|
<div className="max-h-60 overflow-y-auto space-y-2">
|
|
{roles.map(role => (
|
|
<label
|
|
key={role.id}
|
|
className={`flex items-center gap-2 p-2 rounded-md cursor-pointer transition-colors ${
|
|
editData.roles?.includes(role.name)
|
|
? "bg-blue-50 border border-blue-200"
|
|
: "bg-gray-50 border border-transparent hover:bg-gray-100"
|
|
}`}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={editData.roles?.includes(role.name)}
|
|
onChange={() => toggleRole(role.name)}
|
|
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
|
/>
|
|
<span className="text-sm text-gray-700">{role.name}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
<Button
|
|
onClick={() => saveUserRoles(editMode.id)}
|
|
disabled={actionLoading === editMode.id}
|
|
className="w-full bg-blue-600 hover:bg-blue-700"
|
|
>
|
|
{actionLoading === editMode.id ? (
|
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
) : (
|
|
<Check className="h-4 w-4 mr-2" />
|
|
)}
|
|
Mettre à jour
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Edit Group Form */}
|
|
{editMode.type === "group" && editMode.action === "edit" && (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-600">Nom du groupe</label>
|
|
<Input
|
|
value={editData.name}
|
|
onChange={(e) => setEditData({ ...editData, name: e.target.value })}
|
|
className="mt-1 h-9"
|
|
/>
|
|
</div>
|
|
<Button
|
|
onClick={() => saveGroupEdit(editMode.id)}
|
|
disabled={actionLoading === editMode.id}
|
|
className="w-full bg-blue-600 hover:bg-blue-700"
|
|
>
|
|
{actionLoading === editMode.id ? (
|
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
) : (
|
|
<Check className="h-4 w-4 mr-2" />
|
|
)}
|
|
Sauvegarder
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Manage User Groups Form */}
|
|
{editMode.type === "user" && editMode.action === "groups" && (
|
|
<div className="space-y-4">
|
|
{/* Current Groups */}
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-900 mb-2 block">
|
|
Groupes actuels ({userGroups.length})
|
|
</label>
|
|
<div className="max-h-40 overflow-y-auto space-y-1 border rounded-md p-2 bg-white">
|
|
{userGroups.length === 0 ? (
|
|
<p className="text-xs text-gray-500 py-2 text-center">Aucun groupe</p>
|
|
) : (
|
|
userGroups.map(group => (
|
|
<div key={group.id} className="flex items-center justify-between p-2 bg-gray-50 rounded-md hover:bg-gray-100 transition-colors">
|
|
<span className="text-sm text-gray-900 font-medium truncate flex-1">
|
|
{group.name}
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => removeUserFromGroup(group.id)}
|
|
disabled={actionLoading === group.id}
|
|
className="h-7 w-7 p-0 hover:bg-red-50 hover:text-red-600 text-gray-500"
|
|
title="Retirer du groupe"
|
|
>
|
|
{actionLoading === group.id ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<X className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Available Groups */}
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-900 mb-2 block">
|
|
Ajouter à des groupes
|
|
</label>
|
|
<div className="max-h-40 overflow-y-auto space-y-1 border rounded-md p-2 bg-white">
|
|
{availableGroups.length === 0 ? (
|
|
<p className="text-xs text-gray-500 py-2 text-center">L'utilisateur est dans tous les groupes</p>
|
|
) : (
|
|
availableGroups.map(group => (
|
|
<div key={group.id} className="flex items-center justify-between p-2 bg-gray-50 rounded-md hover:bg-gray-100 transition-colors">
|
|
<span className="text-sm text-gray-900 font-medium truncate flex-1">
|
|
{group.name}
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => addUserToGroup(group.id)}
|
|
disabled={actionLoading === group.id}
|
|
className="h-7 w-7 p-0 hover:bg-green-50 hover:text-green-600 text-gray-500"
|
|
title="Ajouter au groupe"
|
|
>
|
|
{actionLoading === group.id ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Plus className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Manage Members Form */}
|
|
{editMode.type === "group" && editMode.action === "members" && (
|
|
<div className="space-y-4">
|
|
{/* Current Members */}
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-900 mb-2 block">
|
|
Membres actuels ({groupMembers.length})
|
|
</label>
|
|
<div className="max-h-40 overflow-y-auto space-y-1 border rounded-md p-2 bg-white">
|
|
{groupMembers.length === 0 ? (
|
|
<p className="text-xs text-gray-500 py-2 text-center">Aucun membre</p>
|
|
) : (
|
|
groupMembers.map(member => (
|
|
<div key={member.id} className="flex items-center justify-between p-2 bg-gray-50 rounded-md hover:bg-gray-100 transition-colors">
|
|
<span className="text-sm text-gray-900 font-medium truncate flex-1">
|
|
{member.firstName} {member.lastName}
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => removeMemberFromGroup(member.id)}
|
|
disabled={actionLoading === member.id}
|
|
className="h-7 w-7 p-0 hover:bg-red-50 hover:text-red-600 text-gray-500"
|
|
title="Retirer du groupe"
|
|
>
|
|
{actionLoading === member.id ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<X className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Available Users */}
|
|
<div>
|
|
<label className="text-xs font-medium text-gray-900 mb-2 block">
|
|
Ajouter des membres
|
|
</label>
|
|
<div className="max-h-40 overflow-y-auto space-y-1 border rounded-md p-2 bg-white">
|
|
{availableUsers.length === 0 ? (
|
|
<p className="text-xs text-gray-500 py-2 text-center">Tous les utilisateurs sont membres</p>
|
|
) : (
|
|
availableUsers.map(user => (
|
|
<div key={user.id} className="flex items-center justify-between p-2 bg-gray-50 rounded-md hover:bg-gray-100 transition-colors">
|
|
<span className="text-sm text-gray-900 font-medium truncate flex-1">
|
|
{user.firstName} {user.lastName}
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => addMemberToGroup(user.id)}
|
|
disabled={actionLoading === user.id}
|
|
className="h-7 w-7 p-0 hover:bg-green-50 hover:text-green-600 text-gray-500"
|
|
title="Ajouter au groupe"
|
|
>
|
|
{actionLoading === user.id ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Plus className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Color Picker Dialog */}
|
|
{colorPickerDialog && (
|
|
<Dialog open={colorPickerDialog} onOpenChange={setColorPickerDialog}>
|
|
<DialogContent 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);
|
|
setSelectedGroupForColor(null);
|
|
setSelectedColor("#4f46e5");
|
|
}}
|
|
className="border-gray-300 text-gray-700 hover:bg-gray-50 bg-white"
|
|
>
|
|
Annuler
|
|
</Button>
|
|
<Button
|
|
onClick={handleSaveColor}
|
|
disabled={actionLoading === selectedGroupForColor?.id}
|
|
className="bg-blue-600 hover:bg-blue-700 text-white"
|
|
>
|
|
{actionLoading === selectedGroupForColor?.id ? (
|
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
) : null}
|
|
Enregistrer
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|