Files
MAD-Platform/components/ConfigurationContent.tsx
2026-02-16 14:43:02 +01:00

1376 lines
55 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect, useCallback, memo } from 'react';
import { useRouter } from 'next/navigation';
import { useNotification } from './NotificationProvider';
import { AVAILABLE_PAGES } from '@/lib/pages';
import ConfirmModal from './ConfirmModal';
import { useBodyScrollLock } from '@/lib/body-scroll-lock';
interface AdherentOption {
id: string;
type: 'situation' | 'prescripteur' | 'facturation' | 'forfait';
value: string;
order: number;
}
interface OptionsByType {
situation: AdherentOption[];
prescripteur: AdherentOption[];
facturation: AdherentOption[];
forfait: AdherentOption[];
}
// Composant OptionCard mémorisé pour éviter les re-renders inutiles
const OptionCard = memo(({
type,
label,
icon,
options,
loading,
editingId,
editingValue,
newValue,
onEdit,
onSaveEdit,
onCancelEdit,
onDelete,
onAdd,
onNewValueChange,
onEditingValueChange,
}: {
type: 'situation' | 'prescripteur' | 'facturation' | 'forfait';
label: string;
icon: React.ReactNode;
options: AdherentOption[];
loading: boolean;
editingId: string | null;
editingValue: string;
newValue: string;
onEdit: (option: AdherentOption) => void;
onSaveEdit: (id: string, type: 'situation' | 'prescripteur' | 'facturation' | 'forfait') => void;
onCancelEdit: () => void;
onDelete: (id: string) => void;
onAdd: (type: 'situation' | 'prescripteur' | 'facturation' | 'forfait') => void;
onNewValueChange: (type: 'situation' | 'prescripteur' | 'facturation' | 'forfait', value: string) => void;
onEditingValueChange: (value: string) => void;
}) => {
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-lg bg-lblue/10 flex items-center justify-center">
{icon}
</div>
<h3 className="text-lg font-bold text-gray-900">{label}</h3>
<span className="ml-auto px-3 py-1 text-xs font-semibold rounded-full bg-gray-100 text-gray-600">
{options.length} option{options.length > 1 ? 's' : ''}
</span>
</div>
{/* Liste des options */}
<div className="space-y-2 mb-4">
{loading ? (
<div className="text-center py-4 text-gray-500">Chargement...</div>
) : options.length === 0 ? (
<div className="text-center py-4 text-gray-500 text-sm">
Aucune option configurée
</div>
) : (
options.map((option) => (
<div
key={option.id}
className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border border-gray-200 hover:border-gray-300 transition-colors"
>
{editingId === option.id ? (
<>
<input
type="text"
value={editingValue}
onChange={(e) => {
onEditingValueChange(e.target.value);
}}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-lblue focus:border-transparent"
onKeyDown={(e) => {
if (e.key === 'Enter') {
onSaveEdit(option.id, type);
} else if (e.key === 'Escape') {
onCancelEdit();
}
}}
autoFocus
/>
<button
onClick={() => onSaveEdit(option.id, type)}
className="px-3 py-2 text-sm font-medium text-white bg-lgreen rounded-lg hover:bg-dgreen transition-colors"
>
Enregistrer
</button>
<button
onClick={onCancelEdit}
className="px-3 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 transition-colors"
>
Annuler
</button>
</>
) : (
<>
<span className="flex-1 text-sm font-medium text-gray-900">
{option.value}
</span>
<button
onClick={() => onEdit(option)}
className="px-3 py-1.5 text-xs font-medium text-lblue bg-lblue/10 rounded-lg hover:bg-lblue/20 transition-colors"
>
Modifier
</button>
<button
onClick={() => onDelete(option.id)}
className="px-3 py-1.5 text-xs font-medium text-red-600 bg-red-50 rounded-lg hover:bg-red-100 transition-colors"
>
Supprimer
</button>
</>
)}
</div>
))
)}
</div>
{/* Formulaire d'ajout */}
<div className="flex items-center gap-2 pt-4 border-t border-gray-200">
<input
type="text"
value={newValue || ''}
onChange={(e) => {
onNewValueChange(type, e.target.value);
}}
placeholder={`Ajouter une nouvelle ${label.toLowerCase()}`}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-lblue focus:border-transparent"
onKeyDown={(e) => {
if (e.key === 'Enter') {
onAdd(type);
}
}}
/>
<button
onClick={() => onAdd(type)}
className="px-4 py-2 text-sm font-medium text-white bg-lblue rounded-lg hover:bg-dblue transition-colors flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Ajouter
</button>
</div>
</div>
);
});
OptionCard.displayName = 'OptionCard';
export default function ConfigurationContent() {
const router = useRouter();
const { showNotification } = useNotification();
const [isMobile, setIsMobile] = useState(false);
const [activeConfigSection, setActiveConfigSection] = useState<'adherents' | 'comptes' | 'roles' | null>('adherents');
const [options, setOptions] = useState<OptionsByType>({
situation: [],
prescripteur: [],
facturation: [],
forfait: [],
});
const [loading, setLoading] = useState(true);
const [editingId, setEditingId] = useState<string | null>(null);
const [editingValue, setEditingValue] = useState('');
const [newValue, setNewValue] = useState<Record<string, string>>({
situation: '',
prescripteur: '',
facturation: '',
forfait: '',
});
const [confirmDeleteOptionModal, setConfirmDeleteOptionModal] = useState<{
show: boolean;
id: string | null;
} | null>(null);
const [confirmDeleteUserModal, setConfirmDeleteUserModal] = useState<{
show: boolean;
userId: string | null;
} | null>(null);
const [confirmDeleteRoleModal, setConfirmDeleteRoleModal] = useState<{
show: boolean;
roleId: string | null;
} | null>(null);
useBodyScrollLock(isMobile);
const fetchOptions = useCallback(async () => {
setLoading(true);
try {
const response = await fetch('/api/settings/adherent-options');
if (response.ok) {
const data = await response.json();
setOptions({
situation: data.situation || [],
prescripteur: data.prescripteur || [],
facturation: data.facturation || [],
forfait: data.forfait || [],
});
}
} catch (error) {
console.error('Erreur lors du chargement des options:', error);
showNotification('Erreur lors du chargement des options', 'error');
} finally {
setLoading(false);
}
}, [showNotification]);
useEffect(() => {
fetchOptions();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth < 768);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
const handleAdd = useCallback(async (type: 'situation' | 'prescripteur' | 'facturation' | 'forfait') => {
const currentValue = newValue[type]?.trim() || '';
if (!currentValue) {
showNotification('Veuillez entrer une valeur', 'error');
return;
}
try {
const response = await fetch('/api/settings/adherent-options', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
type,
value: currentValue,
}),
});
if (response.ok) {
setNewValue((prev) => ({ ...prev, [type]: '' }));
await fetchOptions();
showNotification('Option ajoutée avec succès', 'success');
} else {
const error = await response.json();
showNotification(error.error || 'Erreur lors de l\'ajout', 'error');
}
} catch (error) {
console.error('Erreur:', error);
showNotification('Erreur lors de l\'ajout', 'error');
}
}, [newValue, showNotification, fetchOptions]);
const handleEdit = useCallback((option: AdherentOption) => {
setEditingId(option.id);
setEditingValue(option.value);
}, []);
const handleSaveEdit = useCallback(async (id: string, type: 'situation' | 'prescripteur' | 'facturation' | 'forfait') => {
const value = editingValue.trim();
if (!value) {
showNotification('Veuillez entrer une valeur', 'error');
return;
}
try {
const response = await fetch(`/api/settings/adherent-options/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ value }),
});
if (response.ok) {
setEditingId(null);
setEditingValue('');
await fetchOptions();
showNotification('Option modifiée avec succès', 'success');
} else {
const error = await response.json();
showNotification(error.error || 'Erreur lors de la modification', 'error');
}
} catch (error) {
console.error('Erreur:', error);
showNotification('Erreur lors de la modification', 'error');
}
}, [editingValue, showNotification, fetchOptions]);
const handleCancelEdit = useCallback(() => {
setEditingId(null);
setEditingValue('');
}, []);
const handleDelete = useCallback((id: string) => {
setConfirmDeleteOptionModal({
show: true,
id,
});
}, []);
const confirmDeleteOption = useCallback(async () => {
if (!confirmDeleteOptionModal?.id) return;
const idToDelete = confirmDeleteOptionModal.id;
try {
const response = await fetch(`/api/settings/adherent-options/${idToDelete}`, {
method: 'DELETE',
});
if (response.ok) {
await fetchOptions();
showNotification('Option supprimée avec succès', 'success');
} else {
const error = await response.json();
showNotification(error.error || 'Erreur lors de la suppression', 'error');
}
} catch (error) {
console.error('Erreur:', error);
showNotification('Erreur lors de la suppression', 'error');
} finally {
setConfirmDeleteOptionModal(null);
}
}, [confirmDeleteOptionModal, fetchOptions, showNotification]);
const handleNewValueChange = useCallback((type: 'situation' | 'prescripteur' | 'facturation' | 'forfait', value: string) => {
setNewValue((prev) => ({ ...prev, [type]: value }));
}, []);
const handleEditingValueChange = useCallback((value: string) => {
setEditingValue(value);
}, []);
// Composant pour la gestion des comptes
const GestionComptesContent = () => {
const [users, setUsers] = useState<Array<{
id: string;
email: string;
name: string | null;
photoUrl?: string | null;
roleId: string | null;
role: {
id: string;
name: string;
description: string | null;
} | null;
createdAt: string;
}>>([]);
const [roles, setRoles] = useState<Array<{
id: string;
name: string;
description: string | null;
}>>([]);
const [loadingUsers, setLoadingUsers] = useState(true);
const [selectedUser, setSelectedUser] = useState<string | null>(null);
const [showPasswordModal, setShowPasswordModal] = useState(false);
const [newPassword, setNewPassword] = useState<string | null>(null);
useBodyScrollLock(showPasswordModal && !!newPassword);
useEffect(() => {
fetchUsers();
fetchRoles();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const fetchRoles = async () => {
try {
const response = await fetch('/api/roles');
if (response.ok) {
const data = await response.json();
setRoles(data);
}
} catch (error) {
console.error('Erreur lors du chargement des rôles:', error);
}
};
const fetchUsers = async () => {
setLoadingUsers(true);
try {
const response = await fetch('/api/users');
if (response.ok) {
const data = await response.json();
setUsers(data);
}
} catch (error) {
console.error('Erreur lors du chargement des utilisateurs:', error);
showNotification('Erreur lors du chargement des utilisateurs', 'error');
} finally {
setLoadingUsers(false);
}
};
const handleDeleteUser = (userId: string) => {
setConfirmDeleteUserModal({
show: true,
userId,
});
};
const confirmDeleteUser = async () => {
if (!confirmDeleteUserModal?.userId) return;
try {
const response = await fetch(`/api/users/${confirmDeleteUserModal.userId}`, {
method: 'DELETE',
});
if (response.ok) {
await fetchUsers();
showNotification('Utilisateur supprimé avec succès', 'success');
} else {
const error = await response.json();
showNotification(error.error || 'Erreur lors de la suppression', 'error');
}
} catch (error) {
console.error('Erreur:', error);
showNotification('Erreur lors de la suppression', 'error');
}
};
const handleResetPassword = async (userId: string) => {
try {
const response = await fetch(`/api/users/${userId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ action: 'reset-password' }),
});
if (response.ok) {
const data = await response.json();
setSelectedUser(userId);
setNewPassword(data.newPassword);
setShowPasswordModal(true);
showNotification('Mot de passe réinitialisé avec succès', 'success');
} else {
const error = await response.json();
showNotification(error.error || 'Erreur lors de la réinitialisation', 'error');
}
} catch (error) {
console.error('Erreur:', error);
showNotification('Erreur lors de la réinitialisation', 'error');
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: 'long',
year: 'numeric',
});
};
const getUserInitials = (name: string | null, email: string) => {
if (name) {
const names = name.split(' ');
if (names.length >= 2) {
return `${names[0].charAt(0)}${names[1].charAt(0)}`.toUpperCase();
}
return name.charAt(0).toUpperCase();
}
return email.charAt(0).toUpperCase();
};
return (
<>
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Gestion des comptes</h2>
<p className="text-sm text-gray-600 mb-6">
Gérez tous les comptes utilisateurs de la plateforme
</p>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
{loadingUsers ? (
<div className="p-8 text-center text-gray-500">Chargement...</div>
) : users.length === 0 ? (
<div className="p-8 text-center text-gray-500">Aucun utilisateur trouvé</div>
) : (
<div className="divide-y divide-gray-200">
{users.map((user) => (
<div
key={user.id}
className="p-5 hover:bg-gray-50 transition-colors"
>
<div className="flex items-center gap-4">
{/* Avatar */}
{user.photoUrl ? (
<img
src={user.photoUrl}
alt={user.name || user.email}
className="w-12 h-12 rounded-full object-cover flex-shrink-0 border-2 border-gray-200"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-lblue to-dblue flex items-center justify-center text-white font-semibold flex-shrink-0">
{getUserInitials(user.name, user.email)}
</div>
)}
{/* Informations */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-1">
<h3 className="text-sm font-bold text-gray-900">
{user.name || 'Sans nom'}
</h3>
{user.role && (
<span className="px-2 py-0.5 text-xs font-semibold rounded-full bg-lblue/10 text-lblue">
{user.role.name}
</span>
)}
</div>
<p className="text-sm text-gray-600 mb-1">{user.email}</p>
<p className="text-xs text-gray-500">
Inscrit le {formatDate(user.createdAt)}
</p>
</div>
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0">
<select
value={user.roleId || ''}
onChange={async (e) => {
try {
const response = await fetch(`/api/users/${user.id}/role`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
roleId: e.target.value || null,
}),
});
if (response.ok) {
await fetchUsers();
showNotification('Rôle attribué avec succès', 'success');
} else {
const error = await response.json();
showNotification(error.error || 'Erreur', 'error');
}
} catch (error) {
console.error('Erreur:', error);
showNotification('Erreur lors de l\'attribution du rôle', 'error');
}
}}
className="px-3 py-1.5 text-xs font-medium border border-gray-300 rounded-lg bg-white text-gray-700 focus:outline-none focus:ring-2 focus:ring-lblue focus:border-transparent"
title="Attribuer un rôle"
>
<option value="">Aucun rôle</option>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.name}
</option>
))}
</select>
<button
onClick={() => handleResetPassword(user.id)}
className="px-3 py-1.5 text-xs font-medium text-lblue bg-lblue/10 rounded-lg hover:bg-lblue/20 transition-colors flex items-center gap-1.5"
title="Réinitialiser le mot de passe"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
Réinitialiser MDP
</button>
<button
onClick={() => handleDeleteUser(user.id)}
className="px-3 py-1.5 text-xs font-medium text-red-600 bg-red-50 rounded-lg hover:bg-red-100 transition-colors flex items-center gap-1.5"
title="Supprimer l'utilisateur"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Supprimer
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Modal pour afficher le nouveau mot de passe */}
{showPasswordModal && newPassword && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 max-w-md w-full mx-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-gray-900">Nouveau mot de passe généré</h3>
<button
onClick={() => {
setShowPasswordModal(false);
setNewPassword(null);
setSelectedUser(null);
}}
className="text-gray-400 hover:text-gray-600"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="mb-4">
<p className="text-sm text-gray-600 mb-2">
Le nouveau mot de passe pour <strong>{users.find(u => u.id === selectedUser)?.email}</strong> est :
</p>
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<code className="text-lg font-mono font-bold text-gray-900 break-all">{newPassword}</code>
</div>
<p className="text-xs text-red-600 mt-2">
Copiez ce mot de passe maintenant, il ne sera plus affiché après la fermeture de cette fenêtre.
</p>
</div>
<button
onClick={() => {
navigator.clipboard.writeText(newPassword);
showNotification('Mot de passe copié dans le presse-papiers', 'success');
}}
className="w-full px-4 py-2 bg-lblue text-white rounded-lg hover:bg-dblue transition-colors font-medium"
>
Copier le mot de passe
</button>
</div>
</div>
)}
{/* Modal de confirmation de suppression utilisateur */}
{confirmDeleteUserModal && (
<ConfirmModal
isOpen={confirmDeleteUserModal.show}
title="Supprimer l'utilisateur"
message="Êtes-vous sûr de vouloir supprimer cet utilisateur ? Cette action est irréversible."
confirmText="Supprimer"
cancelText="Annuler"
confirmColor="danger"
onConfirm={confirmDeleteUser}
onCancel={() => setConfirmDeleteUserModal(null)}
/>
)}
</>
);
};
// Composant pour la gestion des rôles
const GestionRolesContent = () => {
const [roles, setRoles] = useState<Array<{
id: string;
name: string;
description: string | null;
permissions: Array<{
permission: {
id: string;
name: string;
description: string | null;
};
}>;
_count: {
users: number;
};
}>>([]);
const [loading, setLoading] = useState(true);
const [showRoleForm, setShowRoleForm] = useState(false);
const [editingRole, setEditingRole] = useState<string | null>(null);
const [roleFormData, setRoleFormData] = useState({
name: '',
description: '',
pageRoutes: [] as string[],
});
useBodyScrollLock(showRoleForm);
useEffect(() => {
fetchRoles();
}, []);
const fetchRoles = async () => {
setLoading(true);
try {
const response = await fetch('/api/roles');
if (response.ok) {
const data = await response.json();
setRoles(data);
}
} catch (error) {
console.error('Erreur lors du chargement des rôles:', error);
showNotification('Erreur lors du chargement des rôles', 'error');
} finally {
setLoading(false);
}
};
const handleCreateRole = async () => {
if (!roleFormData.name.trim()) {
showNotification('Le nom du rôle est requis', 'error');
return;
}
try {
// Créer les permissions pour les pages sélectionnées si elles n'existent pas
const permissionIds: string[] = [];
for (const route of roleFormData.pageRoutes) {
const page = AVAILABLE_PAGES.find(p => p.route === route);
if (page) {
// Vérifier si la permission existe, sinon la créer
const permResponse = await fetch('/api/permissions');
if (permResponse.ok) {
const existingPerms = await permResponse.json();
let perm = existingPerms.find((p: any) => p.name === route);
if (!perm) {
// Créer la permission
const createPermResponse = await fetch('/api/permissions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: route,
description: page.description,
}),
});
if (createPermResponse.ok) {
perm = await createPermResponse.json();
}
}
if (perm) {
permissionIds.push(perm.id);
}
}
}
}
const response = await fetch('/api/roles', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: roleFormData.name,
description: roleFormData.description || null,
permissionIds,
}),
});
if (response.ok) {
setShowRoleForm(false);
setRoleFormData({ name: '', description: '', pageRoutes: [] });
await fetchRoles();
showNotification('Rôle créé avec succès', 'success');
} else {
const error = await response.json();
showNotification(error.error || 'Erreur lors de la création', 'error');
}
} catch (error) {
console.error('Erreur:', error);
showNotification('Erreur lors de la création', 'error');
}
};
const handleEditRole = (role: typeof roles[0]) => {
setEditingRole(role.id);
setRoleFormData({
name: role.name,
description: role.description || '',
pageRoutes: role.permissions.map(p => p.permission.name).filter(name =>
AVAILABLE_PAGES.some(page => page.route === name)
),
});
setShowRoleForm(true);
};
const handleUpdateRole = async () => {
if (!editingRole || !roleFormData.name.trim()) {
showNotification('Le nom du rôle est requis', 'error');
return;
}
try {
// Créer les permissions pour les pages sélectionnées si elles n'existent pas
const permissionIds: string[] = [];
for (const route of roleFormData.pageRoutes) {
const page = AVAILABLE_PAGES.find(p => p.route === route);
if (page) {
// Vérifier si la permission existe, sinon la créer
const permResponse = await fetch('/api/permissions');
if (permResponse.ok) {
const existingPerms = await permResponse.json();
let perm = existingPerms.find((p: any) => p.name === route);
if (!perm) {
// Créer la permission
const createPermResponse = await fetch('/api/permissions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: route,
description: page.description,
}),
});
if (createPermResponse.ok) {
perm = await createPermResponse.json();
}
}
if (perm) {
permissionIds.push(perm.id);
}
}
}
}
const response = await fetch(`/api/roles/${editingRole}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: roleFormData.name,
description: roleFormData.description || null,
permissionIds,
}),
});
if (response.ok) {
setShowRoleForm(false);
setEditingRole(null);
setRoleFormData({ name: '', description: '', pageRoutes: [] });
await fetchRoles();
showNotification('Rôle modifié avec succès', 'success');
} else {
const error = await response.json();
showNotification(error.error || 'Erreur lors de la modification', 'error');
}
} catch (error) {
console.error('Erreur:', error);
showNotification('Erreur lors de la modification', 'error');
}
};
const handleDeleteRole = (roleId: string) => {
setConfirmDeleteRoleModal({
show: true,
roleId,
});
};
const confirmDeleteRole = async () => {
if (!confirmDeleteRoleModal?.roleId) return;
try {
const response = await fetch(`/api/roles/${confirmDeleteRoleModal.roleId}`, {
method: 'DELETE',
});
if (response.ok) {
await fetchRoles();
showNotification('Rôle supprimé avec succès', 'success');
} else {
const error = await response.json();
showNotification(error.error || 'Erreur lors de la suppression', 'error');
}
} catch (error) {
console.error('Erreur:', error);
showNotification('Erreur lors de la suppression', 'error');
} finally {
setConfirmDeleteRoleModal(null);
}
};
const handleCancelForm = () => {
setShowRoleForm(false);
setEditingRole(null);
setRoleFormData({ name: '', description: '', pageRoutes: [] });
};
return (
<>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Gestion des rôles</h2>
<p className="text-sm text-gray-600">
Créez et gérez les rôles avec leurs permissions
</p>
</div>
<button
onClick={() => setShowRoleForm(true)}
className="px-4 py-2 bg-lblue text-white rounded-lg hover:bg-dblue transition-colors flex items-center gap-2"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Nouveau rôle
</button>
</div>
{loading ? (
<div className="p-8 text-center text-gray-500">Chargement...</div>
) : roles.length === 0 ? (
<div className="p-8 text-center text-gray-500">
Aucun rôle créé. Cliquez sur "Nouveau rôle" pour commencer.
</div>
) : (
<div className="space-y-4">
{roles.map((role) => (
<div
key={role.id}
className="bg-white rounded-xl shadow-sm border border-gray-100 p-6"
>
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-bold text-gray-900">{role.name}</h3>
<span className="px-2 py-1 text-xs font-semibold rounded-full bg-gray-100 text-gray-600">
{role._count.users} utilisateur{role._count.users > 1 ? 's' : ''}
</span>
</div>
{role.description && (
<p className="text-sm text-gray-600 mb-3">{role.description}</p>
)}
<div className="flex flex-wrap gap-2">
{role.permissions.length === 0 ? (
<span className="text-xs text-gray-400">Aucune page accessible</span>
) : (
role.permissions
.filter(rp => AVAILABLE_PAGES.some(page => page.route === rp.permission.name))
.map((rp) => {
const page = AVAILABLE_PAGES.find(p => p.route === rp.permission.name);
return (
<span
key={rp.permission.id}
className="px-2 py-1 text-xs font-medium rounded bg-lblue/10 text-lblue"
title={page?.description}
>
{page?.label || rp.permission.name}
</span>
);
})
)}
</div>
</div>
<div className="flex items-center gap-2 ml-4">
<button
onClick={() => handleEditRole(role)}
className="px-3 py-1.5 text-xs font-medium text-lblue bg-lblue/10 rounded-lg hover:bg-lblue/20 transition-colors"
>
Modifier
</button>
<button
onClick={() => handleDeleteRole(role.id)}
className="px-3 py-1.5 text-xs font-medium text-red-600 bg-red-50 rounded-lg hover:bg-red-100 transition-colors"
>
Supprimer
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Modal formulaire rôle */}
{showRoleForm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-start mb-6">
<div>
<h3 className="text-xl font-bold text-gray-900 mb-2">
{editingRole ? 'Modifier le rôle' : 'Nouveau rôle'}
</h3>
<p className="text-sm text-gray-600">
{editingRole ? 'Modifiez les informations du rôle' : 'Créez un nouveau rôle et sélectionnez les pages accessibles'}
</p>
</div>
<button
onClick={handleCancelForm}
className="text-gray-400 hover:text-gray-600"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nom du rôle <span className="text-red-500">*</span>
</label>
<input
type="text"
value={roleFormData.name}
onChange={(e) => setRoleFormData({ ...roleFormData, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-gray-900 focus:outline-none focus:ring-2 focus:ring-lblue focus:border-transparent"
placeholder="Ex: Administrateur, Éditeur..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
value={roleFormData.description}
onChange={(e) => setRoleFormData({ ...roleFormData, description: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-gray-900 focus:outline-none focus:ring-2 focus:ring-lblue focus:border-transparent"
rows={3}
placeholder="Description du rôle..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Pages accessibles
</label>
<div className="space-y-2 max-h-60 overflow-y-auto border border-gray-200 rounded-lg p-4">
{AVAILABLE_PAGES.map((page) => (
<label
key={page.route}
className="flex items-start gap-3 p-2 hover:bg-gray-50 rounded-lg cursor-pointer"
>
<input
type="checkbox"
checked={roleFormData.pageRoutes.includes(page.route)}
onChange={(e) => {
if (e.target.checked) {
setRoleFormData({
...roleFormData,
pageRoutes: [...roleFormData.pageRoutes, page.route],
});
} else {
setRoleFormData({
...roleFormData,
pageRoutes: roleFormData.pageRoutes.filter(route => route !== page.route),
});
}
}}
className="mt-1 w-4 h-4 text-lblue border-gray-300 rounded focus:ring-lblue"
/>
<div className="flex-1">
<div className="text-sm font-medium text-gray-900">{page.label}</div>
<div className="text-xs text-gray-500">{page.description}</div>
<div className="text-xs text-gray-400 font-mono mt-0.5">{page.route}</div>
</div>
</label>
))}
</div>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-gray-200">
<button
onClick={handleCancelForm}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
>
Annuler
</button>
<button
onClick={editingRole ? handleUpdateRole : handleCreateRole}
className="px-4 py-2 text-sm font-medium text-white bg-lblue rounded-lg hover:bg-dblue transition-colors"
>
{editingRole ? 'Enregistrer' : 'Créer'}
</button>
</div>
</div>
</div>
</div>
)}
{/* Modal de confirmation de suppression rôle */}
{confirmDeleteRoleModal && (
<ConfirmModal
isOpen={confirmDeleteRoleModal.show}
title="Supprimer le rôle"
message="Êtes-vous sûr de vouloir supprimer ce rôle ?"
confirmText="Supprimer"
cancelText="Annuler"
confirmColor="danger"
onConfirm={confirmDeleteRole}
onCancel={() => setConfirmDeleteRoleModal(null)}
/>
)}
</>
);
};
// Afficher une modale sur mobile
if (isMobile) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6 md:p-8">
<div className="text-center mb-6">
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full bg-lblue/10 flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 md:w-10 md:h-10 text-lblue" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<h2 className="text-xl md:text-2xl font-bold text-gray-900 mb-2">
Configuration sur ordinateur recommandée
</h2>
<p className="text-sm md:text-base text-gray-600">
Pour une meilleure expérience, veuillez utiliser un ordinateur pour accéder à la configuration de la plateforme.
</p>
</div>
<button
onClick={() => router.push('/dashboard/parametres')}
className="w-full px-4 py-3 bg-lblue text-white rounded-lg hover:bg-dblue transition-colors font-medium text-sm md:text-base"
>
Retour aux paramètres
</button>
</div>
</div>
);
}
return (
<div className="p-6">
<div className="mb-6 flex items-center gap-4">
<button
onClick={() => router.push('/dashboard/parametres')}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Configuration</h1>
<p className="text-sm text-gray-600">
Configurez les paramètres de la plateforme
</p>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div className="flex h-full min-h-[600px]">
{/* Sidebar interne pour les rubriques de configuration */}
<div className="w-64 border-r border-gray-200 bg-gray-50">
<div className="p-4 border-b border-gray-200">
<h3 className="text-sm font-semibold text-gray-500 uppercase tracking-wide">Rubriques</h3>
</div>
<nav className="p-2">
<button
onClick={() => setActiveConfigSection('adherents')}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-colors text-left ${
activeConfigSection === 'adherents'
? 'bg-lblue text-white shadow-sm'
: 'text-gray-700 hover:bg-white'
}`}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<span className="font-medium">Adhérents</span>
</button>
<button
onClick={() => setActiveConfigSection('comptes')}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-colors text-left ${
activeConfigSection === 'comptes'
? 'bg-lblue text-white shadow-sm'
: 'text-gray-700 hover:bg-white'
}`}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span className="font-medium">Gestion des comptes</span>
</button>
<button
onClick={() => setActiveConfigSection('roles')}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-colors text-left ${
activeConfigSection === 'roles'
? 'bg-lblue text-white shadow-sm'
: 'text-gray-700 hover:bg-white'
}`}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
<span className="font-medium">Rôles et permissions</span>
</button>
</nav>
</div>
{/* Contenu de la rubrique sélectionnée */}
<div className="flex-1 p-6 overflow-y-auto">
{activeConfigSection === null ? (
<div className="flex items-center justify-center h-full text-center">
<div>
<svg className="w-16 h-16 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<p className="text-gray-500 font-medium">Sélectionnez une rubrique de configuration</p>
</div>
</div>
) : activeConfigSection === 'adherents' ? (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Configuration des adhérents</h2>
<p className="text-sm text-gray-600 mb-6">
Configurez les options disponibles pour les formulaires d'adhérents
</p>
</div>
<OptionCard
type="situation"
label="Situations"
icon={
<svg className="w-5 h-5 text-lblue" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
}
options={options.situation}
loading={loading}
editingId={editingId}
editingValue={editingValue}
newValue={newValue.situation}
onEdit={handleEdit}
onSaveEdit={handleSaveEdit}
onCancelEdit={handleCancelEdit}
onDelete={handleDelete}
onAdd={handleAdd}
onNewValueChange={handleNewValueChange}
onEditingValueChange={handleEditingValueChange}
/>
<OptionCard
type="prescripteur"
label="Prescripteurs"
icon={
<svg className="w-5 h-5 text-lblue" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
}
options={options.prescripteur}
loading={loading}
editingId={editingId}
editingValue={editingValue}
newValue={newValue.prescripteur}
onEdit={handleEdit}
onSaveEdit={handleSaveEdit}
onCancelEdit={handleCancelEdit}
onDelete={handleDelete}
onAdd={handleAdd}
onNewValueChange={handleNewValueChange}
onEditingValueChange={handleEditingValueChange}
/>
<OptionCard
type="facturation"
label="Modes de facturation"
icon={
<svg className="w-5 h-5 text-lblue" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
}
options={options.facturation}
loading={loading}
editingId={editingId}
editingValue={editingValue}
newValue={newValue.facturation}
onEdit={handleEdit}
onSaveEdit={handleSaveEdit}
onCancelEdit={handleCancelEdit}
onDelete={handleDelete}
onAdd={handleAdd}
onNewValueChange={handleNewValueChange}
onEditingValueChange={handleEditingValueChange}
/>
<OptionCard
type="forfait"
label="Forfaits"
icon={
<svg className="w-5 h-5 text-lblue" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
}
options={options.forfait}
loading={loading}
editingId={editingId}
editingValue={editingValue}
newValue={newValue.forfait}
onEdit={handleEdit}
onSaveEdit={handleSaveEdit}
onCancelEdit={handleCancelEdit}
onDelete={handleDelete}
onAdd={handleAdd}
onNewValueChange={handleNewValueChange}
onEditingValueChange={handleEditingValueChange}
/>
</div>
) : activeConfigSection === 'comptes' ? (
<GestionComptesContent />
) : activeConfigSection === 'roles' ? (
<GestionRolesContent />
) : null}
</div>
</div>
</div>
{/* Modales de confirmation */}
{confirmDeleteOptionModal && (
<ConfirmModal
isOpen={confirmDeleteOptionModal.show}
title="Supprimer l'option"
message="Êtes-vous sûr de vouloir supprimer cette option ?"
confirmText="Supprimer"
cancelText="Annuler"
confirmColor="danger"
onConfirm={confirmDeleteOption}
onCancel={() => setConfirmDeleteOptionModal(null)}
/>
)}
{confirmDeleteUserModal && (
<ConfirmModal
isOpen={confirmDeleteUserModal.show}
title="Supprimer l'utilisateur"
message="Êtes-vous sûr de vouloir supprimer cet utilisateur ? Cette action est irréversible."
confirmText="Supprimer"
cancelText="Annuler"
confirmColor="danger"
onConfirm={confirmDeleteUser}
onCancel={() => setConfirmDeleteUserModal(null)}
/>
)}
{confirmDeleteRoleModal && (
<ConfirmModal
isOpen={confirmDeleteRoleModal.show}
title="Supprimer le rôle"
message="Êtes-vous sûr de vouloir supprimer ce rôle ?"
confirmText="Supprimer"
cancelText="Annuler"
confirmColor="danger"
onConfirm={confirmDeleteRole}
onCancel={() => setConfirmDeleteRoleModal(null)}
/>
)}
</div>
);
}