mirror of
https://github.com/R0m1k3/LogiFlow.git
synced 2026-10-13 10:18:34 +02:00
Introduce custom CSS for improved table scrolling and integrate it into various data-displaying components. Replit-Commit-Author: Agent Replit-Commit-Session-Id: d43bd811-9372-45a7-8ac9-4a954c0538e1 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/1957c339-2757-4d1f-8e92-e9f71a1ce58e/d43bd811-9372-45a7-8ac9-4a954c0538e1/YCOwJ60
1156 lines
43 KiB
TypeScript
1156 lines
43 KiB
TypeScript
import { useState } from "react";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
|
|
import { useAuthUnified } from "@/hooks/useAuthUnified";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { apiRequest } from "@/lib/queryClient";
|
|
import { getRoleTailwindClasses, getRoleDisplayName } from "@/lib/roleUtils";
|
|
import { usePermissions } from "@/lib/permissions";
|
|
import { isUnauthorizedError } from "@/lib/authUtils";
|
|
import {
|
|
Search,
|
|
UserCog,
|
|
Plus,
|
|
Users,
|
|
Edit,
|
|
Trash2,
|
|
Crown,
|
|
Shield,
|
|
User
|
|
} from "lucide-react";
|
|
import type { UserWithGroups, Group } from "@shared/schema";
|
|
|
|
export default function UsersPage() {
|
|
const { user } = useAuthUnified();
|
|
const { toast } = useToast();
|
|
const queryClient = useQueryClient();
|
|
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [roleFilter, setRoleFilter] = useState("all");
|
|
const [showEditModal, setShowEditModal] = useState(false);
|
|
// Removed showGroupModal - now integrated in edit form
|
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
|
const [selectedUser, setSelectedUser] = useState<UserWithGroups | null>(null);
|
|
const [userGroups, setUserGroups] = useState<number[]>([]);
|
|
const [showRoleModal, setShowRoleModal] = useState(false);
|
|
const [selectedUserForRole, setSelectedUserForRole] = useState<UserWithGroups | null>(null);
|
|
const [showRoleConfirmModal, setShowRoleConfirmModal] = useState(false);
|
|
const [pendingRoleChange, setPendingRoleChange] = useState<{userId: string, newRoleName: string, newRoleDisplay: string} | null>(null);
|
|
|
|
// Edit form state
|
|
const [editForm, setEditForm] = useState({
|
|
username: "",
|
|
firstName: "",
|
|
lastName: "",
|
|
email: "",
|
|
password: "",
|
|
role: "employee" as "admin" | "manager" | "employee" | "directeur",
|
|
});
|
|
|
|
// Form states for creating user
|
|
const [newUser, setNewUser] = useState({
|
|
email: "",
|
|
firstName: "",
|
|
lastName: "",
|
|
username: "",
|
|
password: "",
|
|
role: "employee" as "admin" | "manager" | "employee" | "directeur",
|
|
});
|
|
|
|
const USE_LOCAL_AUTH = import.meta.env.VITE_USE_LOCAL_AUTH === 'true' || import.meta.env.MODE === 'development';
|
|
|
|
const { data: users = [], isLoading: usersLoading } = useQuery<UserWithGroups[]>({
|
|
queryKey: ['/api/users'],
|
|
enabled: user?.role === 'admin',
|
|
});
|
|
|
|
const { data: groups = [] } = useQuery<Group[]>({
|
|
queryKey: ['/api/groups'],
|
|
enabled: user?.role === 'admin',
|
|
});
|
|
|
|
|
|
|
|
// Protection React Error #310 - Vérification Array pour users et groups
|
|
|
|
// Protection: s'assurer que users et groups sont des arrays
|
|
const safeUsers = Array.isArray(users) ? users : [];
|
|
const safeGroups = Array.isArray(groups) ? groups : [];
|
|
|
|
const updateUserRoleMutation = useMutation({
|
|
mutationFn: async (data: { userId: string; role: string }) => {
|
|
await apiRequest(`/api/users/${data.userId}`, "PUT", { role: data.role });
|
|
},
|
|
onSuccess: () => {
|
|
toast({
|
|
title: "Succès",
|
|
description: "Rôle utilisateur mis à jour avec succès",
|
|
});
|
|
queryClient.invalidateQueries({ queryKey: ['/api/users'] });
|
|
|
|
},
|
|
onError: (error) => {
|
|
if (isUnauthorizedError(error)) {
|
|
toast({
|
|
title: "Non autorisé",
|
|
description: "Vous êtes déconnecté. Reconnexion...",
|
|
variant: "destructive",
|
|
});
|
|
setTimeout(() => {
|
|
window.location.href = "/api/login";
|
|
}, 500);
|
|
return;
|
|
}
|
|
toast({
|
|
title: "Erreur",
|
|
description: "Impossible de mettre à jour le rôle",
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
const updateUserMutation = useMutation({
|
|
mutationFn: async (data: { id: string; updates: any }) => {
|
|
// Nettoyer les données côté frontend avant envoi
|
|
const cleanedUpdates: any = {};
|
|
for (const [key, value] of Object.entries(data.updates)) {
|
|
// For name fields (firstName, lastName, email), allow empty strings to clear the fields
|
|
if (key === 'firstName' || key === 'lastName' || key === 'email') {
|
|
cleanedUpdates[key] = value || '';
|
|
} else if (value !== undefined && value !== null && value !== '') {
|
|
cleanedUpdates[key] = value;
|
|
}
|
|
}
|
|
|
|
const response = await apiRequest(`/api/users/${data.id}`, "PUT", cleanedUpdates);
|
|
return response;
|
|
},
|
|
onSuccess: (updatedUser) => {
|
|
toast({
|
|
title: "Succès",
|
|
description: "Utilisateur mis à jour avec succès",
|
|
});
|
|
|
|
// Update the form with the response data before closing
|
|
if (updatedUser) {
|
|
setEditForm({
|
|
firstName: updatedUser.firstName || '',
|
|
lastName: updatedUser.lastName || '',
|
|
username: updatedUser.username || '',
|
|
email: updatedUser.email || '',
|
|
role: updatedUser.role || 'employee',
|
|
password: ''
|
|
});
|
|
|
|
// Update selected user as well
|
|
setSelectedUser(prev => prev ? {...prev, ...updatedUser} : null);
|
|
}
|
|
|
|
queryClient.invalidateQueries({ queryKey: ['/api/users'] });
|
|
|
|
// Delay modal close to show the updated data
|
|
setTimeout(() => {
|
|
setShowEditModal(false);
|
|
setSelectedUser(null);
|
|
}, 1000);
|
|
},
|
|
onError: (error: any) => {
|
|
if (isUnauthorizedError(error)) {
|
|
toast({
|
|
title: "Non autorisé",
|
|
description: "Vous êtes déconnecté. Reconnexion...",
|
|
variant: "destructive",
|
|
});
|
|
setTimeout(() => {
|
|
window.location.href = "/api/login";
|
|
}, 500);
|
|
return;
|
|
}
|
|
|
|
// Show specific error message from API if available
|
|
const errorMessage = error?.response?.data?.message || "Impossible de mettre à jour l'utilisateur";
|
|
toast({
|
|
title: "Erreur",
|
|
description: errorMessage,
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
const createUserMutation = useMutation({
|
|
mutationFn: async (userData: typeof newUser) => {
|
|
const payload = {
|
|
...userData,
|
|
id: userData.email.split('@')[0] + '_' + Date.now(), // Simple ID generation
|
|
};
|
|
|
|
// Only include password for local auth
|
|
if (USE_LOCAL_AUTH && userData.password) {
|
|
payload.password = userData.password;
|
|
}
|
|
|
|
const response = await apiRequest("/api/users", "POST", payload);
|
|
return response;
|
|
},
|
|
onError: (error: any) => {
|
|
if (isUnauthorizedError(error)) {
|
|
toast({
|
|
title: "Non autorisé",
|
|
description: "Vous êtes déconnecté. Reconnexion...",
|
|
variant: "destructive",
|
|
});
|
|
setTimeout(() => {
|
|
window.location.href = "/api/login";
|
|
}, 500);
|
|
return;
|
|
}
|
|
|
|
// Show specific error message from API if available
|
|
const errorMessage = error?.response?.data?.message || "Impossible de créer l'utilisateur";
|
|
toast({
|
|
title: "Erreur",
|
|
description: errorMessage,
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
const assignGroupMutation = useMutation({
|
|
mutationFn: async (data: { userId: string; groupId: number }) => {
|
|
console.log('📤 Assigning group:', data);
|
|
const response = await apiRequest(
|
|
`/api/users/${data.userId}/groups`,
|
|
"POST",
|
|
{ groupId: data.groupId }
|
|
);
|
|
console.log('✅ Group assignment response:', response);
|
|
return response;
|
|
},
|
|
onSuccess: () => {
|
|
console.log('✅ Group assigned successfully');
|
|
toast({
|
|
title: "Succès",
|
|
description: "Utilisateur assigné au groupe avec succès",
|
|
});
|
|
// Invalidation complète du cache
|
|
queryClient.invalidateQueries({ queryKey: ['/api/users'] });
|
|
|
|
queryClient.invalidateQueries({ queryKey: ['/api/groups'] });
|
|
},
|
|
onError: (error: any) => {
|
|
console.error('❌ Error assigning group:', error);
|
|
if (isUnauthorizedError(error)) {
|
|
toast({
|
|
title: "Non autorisé",
|
|
description: "Vous êtes déconnecté. Reconnexion...",
|
|
variant: "destructive",
|
|
});
|
|
setTimeout(() => {
|
|
window.location.href = "/api/login";
|
|
}, 500);
|
|
return;
|
|
}
|
|
|
|
// Show specific error message from API if available
|
|
const errorMessage = error?.response?.data?.message || "Impossible d'assigner l'utilisateur au groupe";
|
|
toast({
|
|
title: "Erreur",
|
|
description: errorMessage,
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
const removeGroupMutation = useMutation({
|
|
mutationFn: async (data: { userId: string; groupId: number }) => {
|
|
console.log('🗑️ Removing group:', data);
|
|
const response = await apiRequest(
|
|
`/api/users/${data.userId}/groups/${data.groupId}`,
|
|
"DELETE"
|
|
);
|
|
console.log('✅ Group removal response:', response);
|
|
return response;
|
|
},
|
|
onSuccess: () => {
|
|
console.log('✅ Group removed successfully');
|
|
toast({
|
|
title: "Succès",
|
|
description: "Utilisateur retiré du groupe avec succès",
|
|
});
|
|
// Invalidation complète du cache
|
|
queryClient.invalidateQueries({ queryKey: ['/api/users'] });
|
|
|
|
queryClient.invalidateQueries({ queryKey: ['/api/groups'] });
|
|
},
|
|
onError: (error: any) => {
|
|
console.error('❌ Error removing group:', error);
|
|
if (isUnauthorizedError(error)) {
|
|
toast({
|
|
title: "Non autorisé",
|
|
description: "Vous êtes déconnecté. Reconnexion...",
|
|
variant: "destructive",
|
|
});
|
|
setTimeout(() => {
|
|
window.location.href = "/api/login";
|
|
}, 500);
|
|
return;
|
|
}
|
|
|
|
// Show specific error message from API if available
|
|
const errorMessage = error?.response?.data?.message || "Impossible de retirer l'utilisateur du groupe";
|
|
toast({
|
|
title: "Erreur",
|
|
description: errorMessage,
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
const deleteUserMutation = useMutation({
|
|
mutationFn: async (userId: string) => {
|
|
await apiRequest(`/api/users/${userId}`, "DELETE");
|
|
},
|
|
onSuccess: () => {
|
|
toast({
|
|
title: "Succès",
|
|
description: "Utilisateur supprimé avec succès",
|
|
});
|
|
queryClient.invalidateQueries({ queryKey: ['/api/users'] });
|
|
},
|
|
onError: (error) => {
|
|
if (isUnauthorizedError(error)) {
|
|
toast({
|
|
title: "Non autorisé",
|
|
description: "Vous êtes déconnecté. Reconnexion...",
|
|
variant: "destructive",
|
|
});
|
|
setTimeout(() => {
|
|
window.location.href = "/api/login";
|
|
}, 500);
|
|
return;
|
|
}
|
|
toast({
|
|
title: "Erreur",
|
|
description: "Impossible de supprimer l'utilisateur",
|
|
variant: "destructive",
|
|
});
|
|
},
|
|
});
|
|
|
|
const filteredUsers = Array.isArray(users) ? users.filter(u => {
|
|
const matchesSearch = u.firstName?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
u.lastName?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
u.email?.toLowerCase().includes(searchTerm.toLowerCase());
|
|
|
|
const matchesRole = roleFilter === "all" || u.role === roleFilter;
|
|
|
|
return matchesSearch && matchesRole;
|
|
}) : [];
|
|
|
|
const getRoleIcon = (role: string) => {
|
|
const { iconClass } = getRoleTailwindClasses(role);
|
|
|
|
switch (role) {
|
|
case 'admin':
|
|
return <Crown className={`w-4 h-4 ${iconClass}`} />;
|
|
case 'manager':
|
|
case 'directeur':
|
|
return <Shield className={`w-4 h-4 ${iconClass}`} />;
|
|
case 'employee':
|
|
return <User className={`w-4 h-4 ${iconClass}`} />;
|
|
default:
|
|
return <User className={`w-4 h-4 ${iconClass}`} />;
|
|
}
|
|
};
|
|
|
|
const getRoleBadge = (role: string) => {
|
|
const { badgeClass } = getRoleTailwindClasses(role);
|
|
const displayName = getRoleDisplayName(role);
|
|
|
|
return <Badge className={badgeClass}>{displayName}</Badge>;
|
|
};
|
|
|
|
const handleCreateUser = () => {
|
|
setShowCreateModal(true);
|
|
setNewUser({ email: "", firstName: "", lastName: "", username: "", password: "", role: "employee" });
|
|
setUserGroups([]);
|
|
};
|
|
|
|
const handleSubmitCreateUser = async () => {
|
|
if (!newUser.username || !newUser.username.trim()) {
|
|
toast({
|
|
title: "Erreur",
|
|
description: "L'identifiant est obligatoire",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!newUser.password) {
|
|
toast({
|
|
title: "Erreur",
|
|
description: "Le mot de passe est obligatoire",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
// First create the user
|
|
try {
|
|
const createdUser = await createUserMutation.mutateAsync(newUser);
|
|
|
|
// Then assign groups if any selected
|
|
if (userGroups.length > 0) {
|
|
await Promise.all(
|
|
userGroups.map(groupId =>
|
|
assignGroupMutation.mutateAsync({ userId: createdUser.id, groupId })
|
|
)
|
|
);
|
|
}
|
|
|
|
toast({
|
|
title: "Succès",
|
|
description: `Utilisateur créé et assigné à ${userGroups.length} magasin(s)`,
|
|
});
|
|
|
|
setShowCreateModal(false);
|
|
setNewUser({ email: "", firstName: "", lastName: "", username: "", password: "", role: "employee" });
|
|
setUserGroups([]);
|
|
queryClient.invalidateQueries({ queryKey: ['/api/users'] });
|
|
} catch (error) {
|
|
// Error handling is already done in the mutations
|
|
}
|
|
};
|
|
|
|
const handleEditUser = (userData: UserWithGroups) => {
|
|
// Priority: use firstName/lastName if available, fallback to splitting 'name' field
|
|
let firstName = userData.firstName || '';
|
|
let lastName = userData.lastName || '';
|
|
|
|
// If no firstName/lastName but we have a 'name' field, try to split it
|
|
if (!firstName && !lastName && userData.name) {
|
|
const [firstPart = '', ...lastNameParts] = userData.name.split(' ');
|
|
firstName = firstPart;
|
|
lastName = lastNameParts.join(' ');
|
|
}
|
|
|
|
const userWithNames = {
|
|
...userData,
|
|
firstName,
|
|
lastName
|
|
};
|
|
|
|
setSelectedUser(userWithNames);
|
|
setEditForm({
|
|
username: userData.username || "",
|
|
firstName: firstName,
|
|
lastName: lastName,
|
|
email: userData.email || "",
|
|
password: "",
|
|
role: userData.role as "admin" | "manager" | "employee" | "directeur",
|
|
});
|
|
setShowEditModal(true);
|
|
};
|
|
|
|
|
|
|
|
// Function to handle group assignment in edit form
|
|
const handleToggleGroupInEdit = async (groupId: number, isCurrentlyAssigned: boolean) => {
|
|
if (!selectedUser) return;
|
|
|
|
try {
|
|
if (isCurrentlyAssigned) {
|
|
await removeGroupMutation.mutateAsync({ userId: selectedUser.id, groupId });
|
|
} else {
|
|
await assignGroupMutation.mutateAsync({ userId: selectedUser.id, groupId });
|
|
}
|
|
} catch (error) {
|
|
// Error handling is done in mutations
|
|
}
|
|
};
|
|
|
|
const handleSubmitEdit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!selectedUser) return;
|
|
|
|
const updates: any = {
|
|
username: editForm.username,
|
|
firstName: editForm.firstName,
|
|
lastName: editForm.lastName,
|
|
email: editForm.email,
|
|
role: editForm.role,
|
|
};
|
|
|
|
// Only include password if it's not empty
|
|
if (editForm.password.trim()) {
|
|
updates.password = editForm.password;
|
|
}
|
|
|
|
updateUserMutation.mutate({
|
|
id: selectedUser.id,
|
|
updates
|
|
});
|
|
};
|
|
|
|
const handleRoleChange = (userId: string, newRoleName: string) => {
|
|
if (userId === user?.id) {
|
|
toast({
|
|
title: "Erreur",
|
|
description: "Vous ne pouvez pas modifier votre propre rôle",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Préparer la confirmation et ouvrir le modal
|
|
setPendingRoleChange({
|
|
userId,
|
|
newRoleName,
|
|
newRoleDisplay: newRoleName
|
|
});
|
|
setShowRoleConfirmModal(true);
|
|
}
|
|
|
|
const confirmRoleChange = () => {
|
|
if (!pendingRoleChange) return;
|
|
|
|
updateUserRoleMutation.mutate({
|
|
userId: pendingRoleChange.userId,
|
|
role: pendingRoleChange.newRoleName
|
|
});
|
|
|
|
setShowRoleConfirmModal(false);
|
|
setPendingRoleChange(null);
|
|
}
|
|
|
|
const cancelRoleChange = () => {
|
|
setShowRoleConfirmModal(false);
|
|
setPendingRoleChange(null);
|
|
}
|
|
|
|
// Gestion des rôles centralisée dans Administration > Gestion des Rôles
|
|
|
|
const handleToggleGroup = (userId: string, groupId: number, isAssigned: boolean) => {
|
|
if (isAssigned) {
|
|
removeGroupMutation.mutate({ userId, groupId });
|
|
} else {
|
|
assignGroupMutation.mutate({ userId, groupId });
|
|
}
|
|
};
|
|
|
|
const handleDeleteUser = (userToDelete: UserWithGroups) => {
|
|
if (userToDelete.id === user?.id) {
|
|
toast({
|
|
title: "Erreur",
|
|
description: "Vous ne pouvez pas supprimer votre propre compte",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (window.confirm(`Êtes-vous sûr de vouloir supprimer l'utilisateur ${userToDelete.firstName} ${userToDelete.lastName} ? Cette action est irréversible.`)) {
|
|
deleteUserMutation.mutate(userToDelete.id);
|
|
}
|
|
};
|
|
|
|
const getInitials = (firstName?: string, lastName?: string) => {
|
|
if (!firstName && !lastName) return "U";
|
|
return `${firstName?.[0] || ""}${lastName?.[0] || ""}`.toUpperCase();
|
|
};
|
|
|
|
const canManage = user?.role === 'admin';
|
|
|
|
if (!canManage) {
|
|
return (
|
|
<div className="flex-1 flex flex-col items-center justify-center">
|
|
<div className="text-center">
|
|
<UserCog className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
|
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
|
Accès restreint
|
|
</h2>
|
|
<p className="text-gray-600">
|
|
Seuls les administrateurs peuvent accéder à la gestion des utilisateurs.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex-1 flex flex-col overflow-hidden">
|
|
{/* Header */}
|
|
<div className="bg-white border-b border-gray-200 p-6 shadow-sm">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-2xl font-semibold text-gray-900 flex items-center">
|
|
<UserCog className="w-6 h-6 mr-3 text-blue-600" />
|
|
Gestion des Utilisateurs
|
|
</h2>
|
|
<p className="text-gray-600 mt-1">
|
|
{Array.isArray(filteredUsers) ? filteredUsers.length : 0} utilisateur{Array.isArray(filteredUsers) && filteredUsers.length !== 1 ? 's' : ''}
|
|
</p>
|
|
</div>
|
|
|
|
<Button
|
|
onClick={handleCreateUser}
|
|
className="bg-blue-600 hover:bg-blue-700 text-white shadow-md"
|
|
>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Créer un utilisateur
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="bg-gray-50 border-b border-gray-200 p-4">
|
|
<div className="flex flex-wrap items-center gap-4">
|
|
<div className="flex-1 min-w-64">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
|
<Input
|
|
placeholder="Rechercher un utilisateur..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="pl-10 border border-gray-300 shadow-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<Select value={roleFilter} onValueChange={setRoleFilter}>
|
|
<SelectTrigger className="w-48 border border-gray-300 shadow-sm">
|
|
<SelectValue placeholder="Filtrer par rôle" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">Tous les rôles</SelectItem>
|
|
<SelectItem value="admin">Administrateur</SelectItem>
|
|
<SelectItem value="manager">Manager</SelectItem>
|
|
<SelectItem value="employee">Employé</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Users List */}
|
|
<div className="flex-1 overflow-auto">
|
|
{usersLoading ? (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
|
</div>
|
|
) : (!Array.isArray(filteredUsers) || filteredUsers.length === 0) ? (
|
|
<div className="flex flex-col items-center justify-center h-64 text-gray-500">
|
|
<Users className="w-16 h-16 mb-4 text-gray-300" />
|
|
<h3 className="text-lg font-medium mb-2">Aucun utilisateur trouvé</h3>
|
|
<p className="text-center max-w-md">
|
|
{searchTerm || roleFilter !== "all"
|
|
? "Aucun utilisateur ne correspond à vos critères de recherche."
|
|
: "Aucun utilisateur n'est encore enregistré dans le système."}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="p-6">
|
|
<div className="bg-white border border-gray-200 shadow-lg overflow-hidden">
|
|
<div>
|
|
<table className="w-full">
|
|
<thead className="bg-gray-50 border-b border-gray-200">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Utilisateur
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Email
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Rôle
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Groupes
|
|
</th>
|
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
Actions
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-gray-200">
|
|
{Array.isArray(filteredUsers) && filteredUsers.map((userData) => (
|
|
<tr key={userData.id} className="hover:bg-gray-50">
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="flex items-center">
|
|
<div className="w-10 h-10 bg-primary rounded-full flex items-center justify-center mr-3">
|
|
{userData.profileImageUrl ? (
|
|
<img
|
|
src={userData.profileImageUrl}
|
|
alt="Profile"
|
|
className="w-full h-full rounded-full object-cover"
|
|
/>
|
|
) : (
|
|
<span className="text-white font-medium">
|
|
{getInitials(
|
|
userData.firstName || userData.name?.split(' ')[0] || '',
|
|
userData.lastName || userData.name?.split(' ').slice(1).join(' ') || ''
|
|
)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<div className="text-sm font-medium text-gray-900 flex items-center">
|
|
{(userData.firstName && userData.lastName)
|
|
? `${userData.firstName} ${userData.lastName}`.trim()
|
|
: (userData.firstName || userData.lastName || userData.name || 'Nom non renseigné')
|
|
}
|
|
{userData.id === user?.id && (
|
|
<Badge variant="outline" className="ml-2 text-xs">Vous</Badge>
|
|
)}
|
|
</div>
|
|
<div className="text-sm text-gray-500">
|
|
ID: {userData.id}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
{userData.email || 'Non renseigné'}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="flex items-center">
|
|
{getRoleIcon(userData.role)}
|
|
<span className="ml-2">{getRoleBadge(userData.role)}</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="flex flex-wrap gap-1">
|
|
{!Array.isArray(userData.userGroups) || userData.userGroups.length === 0 ? (
|
|
<span className="text-sm text-gray-500">Aucun groupe</span>
|
|
) : (
|
|
userData.userGroups.map((userGroup) => (
|
|
<Badge
|
|
key={userGroup.groupId}
|
|
variant="outline"
|
|
className="text-xs"
|
|
>
|
|
<div className="flex items-center">
|
|
<div
|
|
className="w-2 h-2 rounded-full mr-1"
|
|
style={{ backgroundColor: userGroup.group.color }}
|
|
/>
|
|
{userGroup.group.name}
|
|
</div>
|
|
</Badge>
|
|
))
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
|
<div className="flex items-center justify-end space-x-2">
|
|
{/* Sélecteur de rôle rapide */}
|
|
{userData.id !== user?.id && (
|
|
<Select
|
|
value={userData.role || ''}
|
|
onValueChange={(newRole) => handleRoleChange(userData.id, newRole)}
|
|
disabled={updateUserRoleMutation.isPending}
|
|
>
|
|
<SelectTrigger className="w-28 h-8 text-xs border-purple-200 hover:border-purple-300">
|
|
<SelectValue placeholder="Rôle" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="admin">Administrateur</SelectItem>
|
|
<SelectItem value="directeur">Directeur</SelectItem>
|
|
<SelectItem value="manager">Manager</SelectItem>
|
|
<SelectItem value="employee">Employé</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleEditUser(userData)}
|
|
title="Modifier l'utilisateur"
|
|
className="text-blue-600 hover:text-blue-700 border-blue-200 hover:border-blue-300 hover:bg-blue-50"
|
|
>
|
|
<Edit className="w-4 h-4" />
|
|
</Button>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleDeleteUser(userData)}
|
|
disabled={userData.id === user?.id || deleteUserMutation.isPending}
|
|
className="text-red-600 hover:text-red-700 border-red-200 hover:border-red-300 hover:bg-red-50"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Edit User Modal */}
|
|
{showEditModal && selectedUser && (
|
|
<Dialog open={showEditModal} onOpenChange={() => {
|
|
setShowEditModal(false);
|
|
setSelectedUser(null);
|
|
}}>
|
|
<DialogContent className="sm:max-w-lg" aria-describedby="edit-user-modal-description">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-xl font-medium">
|
|
Modifier l'utilisateur
|
|
</DialogTitle>
|
|
<p id="edit-user-modal-description" className="text-sm text-gray-600 mt-1">
|
|
Modifier les informations de l'utilisateur {selectedUser.name || selectedUser.firstName + ' ' + selectedUser.lastName}
|
|
</p>
|
|
</DialogHeader>
|
|
|
|
<form onSubmit={handleSubmitEdit} className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<Label htmlFor="edit-firstName">Prénom</Label>
|
|
<Input
|
|
id="edit-firstName"
|
|
value={editForm.firstName}
|
|
onChange={(e) => setEditForm({...editForm, firstName: e.target.value})}
|
|
placeholder="Prénom (optionnel)"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="edit-lastName">Nom</Label>
|
|
<Input
|
|
id="edit-lastName"
|
|
value={editForm.lastName}
|
|
onChange={(e) => setEditForm({...editForm, lastName: e.target.value})}
|
|
placeholder="Nom (optionnel)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="edit-username">Identifiant *</Label>
|
|
<Input
|
|
id="edit-username"
|
|
value={editForm.username}
|
|
onChange={(e) => setEditForm({...editForm, username: e.target.value})}
|
|
placeholder="Identifiant de connexion"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="edit-email">Email</Label>
|
|
<Input
|
|
id="edit-email"
|
|
type="email"
|
|
value={editForm.email}
|
|
onChange={(e) => setEditForm({...editForm, email: e.target.value})}
|
|
placeholder="email@exemple.com (optionnel)"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="edit-password">Nouveau mot de passe (optionnel)</Label>
|
|
<Input
|
|
id="edit-password"
|
|
type="password"
|
|
value={editForm.password}
|
|
onChange={(e) => setEditForm({...editForm, password: e.target.value})}
|
|
placeholder="Laisser vide pour ne pas changer"
|
|
/>
|
|
<p className="text-sm text-gray-500 mt-1">
|
|
Minimum 6 caractères. Laissez vide pour conserver le mot de passe actuel.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Sélection du rôle */}
|
|
<div>
|
|
<Label>Rôle</Label>
|
|
<Select
|
|
value={editForm.role}
|
|
onValueChange={(role: any) => setEditForm({...editForm, role})}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Sélectionner un rôle" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="admin">Administrateur</SelectItem>
|
|
<SelectItem value="directeur">Directeur</SelectItem>
|
|
<SelectItem value="manager">Manager</SelectItem>
|
|
<SelectItem value="employee">Employé</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Gestion des groupes/magasins */}
|
|
<div>
|
|
<Label>Magasins/Groupes</Label>
|
|
<div className="space-y-2 mt-2 max-h-32 overflow-y-auto">
|
|
{safeGroups.map((group) => {
|
|
const isAssigned = selectedUser?.userGroups?.some(ug => ug.groupId === group.id) || false;
|
|
return (
|
|
<div key={group.id} className="flex items-center justify-between p-2 border rounded">
|
|
<div className="flex items-center space-x-2">
|
|
<div
|
|
className="w-3 h-3 rounded-full"
|
|
style={{ backgroundColor: group.color }}
|
|
/>
|
|
<span className="text-sm">{group.name}</span>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleToggleGroupInEdit(group.id, isAssigned)}
|
|
className={isAssigned ? "bg-green-50 text-green-700 border-green-200" : ""}
|
|
>
|
|
{isAssigned ? "Retirer" : "Assigner"}
|
|
</Button>
|
|
</div>
|
|
);
|
|
})}
|
|
{safeGroups.length === 0 && (
|
|
<p className="text-sm text-gray-500">Aucun groupe disponible</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex justify-end space-x-2 pt-4">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => {
|
|
setShowEditModal(false);
|
|
setSelectedUser(null);
|
|
}}
|
|
>
|
|
Annuler
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
disabled={updateUserMutation.isPending}
|
|
className="bg-blue-600 hover:bg-blue-700 text-white"
|
|
>
|
|
{updateUserMutation.isPending ? "Mise à jour..." : "Mettre à jour"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
|
|
|
|
|
|
{/* Create User Modal */}
|
|
{showCreateModal && (
|
|
<Dialog open={showCreateModal} onOpenChange={setShowCreateModal}>
|
|
<DialogContent className="max-w-md" aria-describedby="create-user-modal-description">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center">
|
|
<Plus className="w-5 h-5 mr-2" />
|
|
Créer un nouvel utilisateur
|
|
</DialogTitle>
|
|
<p id="create-user-modal-description" className="text-sm text-gray-600 mt-1">
|
|
Créer un nouveau compte utilisateur avec ses permissions
|
|
</p>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Label htmlFor="identifier">Identifiant *</Label>
|
|
<Input
|
|
id="identifier"
|
|
value={newUser.username || ''}
|
|
onChange={(e) => setNewUser({...newUser, username: e.target.value})}
|
|
placeholder="Identifiant unique (ex: ff0292)"
|
|
required
|
|
/>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
Identifiant unique pour se connecter à l'application
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<Label htmlFor="firstName">Prénom</Label>
|
|
<Input
|
|
id="firstName"
|
|
value={newUser.firstName || ''}
|
|
onChange={(e) => setNewUser({...newUser, firstName: e.target.value})}
|
|
placeholder="Prénom (optionnel)"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="lastName">Nom</Label>
|
|
<Input
|
|
id="lastName"
|
|
value={newUser.lastName || ''}
|
|
onChange={(e) => setNewUser({...newUser, lastName: e.target.value})}
|
|
placeholder="Nom (optionnel)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="email">Email</Label>
|
|
<Input
|
|
id="email"
|
|
type="email"
|
|
value={newUser.email || ''}
|
|
onChange={(e) => setNewUser({...newUser, email: e.target.value})}
|
|
placeholder="email@exemple.com (optionnel)"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="password">Mot de passe *</Label>
|
|
<Input
|
|
id="password"
|
|
type="password"
|
|
value={newUser.password}
|
|
onChange={(e) => setNewUser({...newUser, password: e.target.value})}
|
|
placeholder="••••••••"
|
|
required
|
|
/>
|
|
<p className="text-sm text-muted-foreground mt-1">
|
|
Mot de passe pour se connecter à l'application
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="role">Rôle</Label>
|
|
<Select
|
|
value={newUser.role}
|
|
onValueChange={(value: "admin" | "manager" | "employee") =>
|
|
setNewUser({...newUser, role: value})
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="employee">Employé</SelectItem>
|
|
<SelectItem value="manager">Manager</SelectItem>
|
|
<SelectItem value="admin">Administrateur</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div>
|
|
<Label>Magasins assignés</Label>
|
|
<div className="space-y-2 mt-2 max-h-40 overflow-y-auto">
|
|
{groups.map((group) => (
|
|
<div key={group.id} className="flex items-center space-x-3">
|
|
<Checkbox
|
|
id={`group-${group.id}`}
|
|
checked={userGroups.includes(group.id)}
|
|
onCheckedChange={(checked) => {
|
|
if (checked) {
|
|
setUserGroups([...userGroups, group.id]);
|
|
} else {
|
|
setUserGroups(userGroups.filter(id => id !== group.id));
|
|
}
|
|
}}
|
|
/>
|
|
<div className="flex items-center space-x-2">
|
|
<div
|
|
className="w-3 h-3 rounded-full"
|
|
style={{ backgroundColor: group.color }}
|
|
/>
|
|
<Label htmlFor={`group-${group.id}`} className="text-sm">
|
|
{group.name}
|
|
</Label>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-3 pt-4">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setShowCreateModal(false);
|
|
setNewUser({ email: "", firstName: "", lastName: "", username: "", password: "", role: "employee" });
|
|
setUserGroups([]);
|
|
}}
|
|
>
|
|
Annuler
|
|
</Button>
|
|
<Button
|
|
onClick={handleSubmitCreateUser}
|
|
disabled={createUserMutation.isPending}
|
|
className="flex-1"
|
|
>
|
|
{createUserMutation.isPending ? "Création..." : "Créer l'utilisateur"}
|
|
</Button>
|
|
</div>
|
|
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
|
|
{/* Role Change Confirmation Modal */}
|
|
{showRoleConfirmModal && pendingRoleChange && (
|
|
<Dialog open={showRoleConfirmModal} onOpenChange={cancelRoleChange}>
|
|
<DialogContent className="sm:max-w-md" aria-describedby="role-change-modal-description">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-xl font-medium flex items-center">
|
|
<Shield className="w-5 h-5 mr-2 text-orange-500" />
|
|
Confirmer le changement de rôle
|
|
</DialogTitle>
|
|
<p id="role-change-modal-description" className="text-sm text-gray-600 mt-2">
|
|
Cette action modifiera les permissions de l'utilisateur dans l'application.
|
|
</p>
|
|
</DialogHeader>
|
|
|
|
<div className="py-4">
|
|
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4 mb-4">
|
|
<div className="flex items-center mb-2">
|
|
<Shield className="w-4 h-4 mr-2 text-orange-600" />
|
|
<span className="text-sm font-medium text-orange-800">Attention</span>
|
|
</div>
|
|
<p className="text-sm text-orange-700">
|
|
Vous êtes sur le point de changer le rôle de cet utilisateur vers{" "}
|
|
<span className="font-semibold">{pendingRoleChange.newRoleDisplay}</span>.
|
|
Cette action prendra effet immédiatement.
|
|
</p>
|
|
</div>
|
|
|
|
<p className="text-sm text-gray-600 mb-4">
|
|
Êtes-vous sûr de vouloir continuer ?
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end space-x-3">
|
|
<Button
|
|
variant="outline"
|
|
onClick={cancelRoleChange}
|
|
disabled={updateUserRoleMutation.isPending}
|
|
>
|
|
Annuler
|
|
</Button>
|
|
<Button
|
|
onClick={confirmRoleChange}
|
|
disabled={updateUserRoleMutation.isPending}
|
|
className="bg-orange-600 hover:bg-orange-700 text-white"
|
|
>
|
|
{updateUserRoleMutation.isPending ? "Modification..." : "Confirmer"}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|