Centrale Refactor Big
This commit is contained in:
parent
0358c87af3
commit
be3f4cf508
503
MISSIONS_CODE_REVIEW.md
Normal file
503
MISSIONS_CODE_REVIEW.md
Normal file
@ -0,0 +1,503 @@
|
||||
# Analyse approfondie du système Missions - Code Review Senior
|
||||
|
||||
## 📋 Vue d'ensemble
|
||||
|
||||
Ce document présente une analyse complète du système de gestion des missions, incluant la page de liste, les détails de mission, et l'architecture backend associée.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture générale
|
||||
|
||||
### Structure des fichiers
|
||||
|
||||
```
|
||||
app/
|
||||
├── missions/
|
||||
│ ├── page.tsx # Page principale de liste des missions
|
||||
│ ├── layout.tsx # Layout avec sidebar CAP
|
||||
│ ├── new/
|
||||
│ │ └── page.tsx # Création de nouvelle mission
|
||||
│ └── [missionId]/
|
||||
│ ├── page.tsx # Page de détails de mission
|
||||
│ └── edit/
|
||||
│ └── page.tsx # Édition de mission
|
||||
│
|
||||
├── api/
|
||||
│ └── missions/
|
||||
│ ├── route.ts # GET/POST missions
|
||||
│ ├── [missionId]/
|
||||
│ │ ├── route.ts # GET/PUT/DELETE mission spécifique
|
||||
│ │ ├── close/route.ts # Clôture de mission
|
||||
│ │ └── generate-plan/ # Génération plan d'action IA
|
||||
│ └── ...
|
||||
│
|
||||
components/
|
||||
└── missions/
|
||||
├── missions-frame.tsx # Iframe wrapper
|
||||
├── missions-admin-panel.tsx # Panel de création/édition
|
||||
└── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📄 Page de liste des missions (`app/missions/page.tsx`)
|
||||
|
||||
### Points forts ✅
|
||||
|
||||
1. **Interface utilisateur claire**
|
||||
- Design en grille responsive (1/2/3 colonnes)
|
||||
- Cartes de mission bien structurées
|
||||
- Indicateurs visuels pour missions clôturées
|
||||
- Recherche en temps réel
|
||||
|
||||
2. **Gestion d'état**
|
||||
- Utilisation appropriée de `useState` et `useEffect`
|
||||
- Gestion des états de chargement
|
||||
- Filtrage côté client efficace
|
||||
|
||||
3. **Affichage des données**
|
||||
- Logos avec fallback gracieux
|
||||
- Badges ODD avec icônes
|
||||
- Affichage conditionnel des services
|
||||
- Formatage des dates en français
|
||||
|
||||
### Points d'amélioration 🔧
|
||||
|
||||
1. **Performance**
|
||||
```typescript
|
||||
// ❌ Problème: Filtrage côté client uniquement
|
||||
const filteredMissions = missions.filter(mission =>
|
||||
mission.name.toLowerCase().includes(searchTerm.toLowerCase()) || ...
|
||||
);
|
||||
|
||||
// ✅ Suggestion: Pagination et recherche côté serveur
|
||||
// Utiliser les query params dans l'API
|
||||
```
|
||||
|
||||
2. **Gestion d'erreurs**
|
||||
```typescript
|
||||
// ⚠️ Actuel: Toast générique
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Impossible de charger les missions",
|
||||
variant: "destructive",
|
||||
});
|
||||
|
||||
// ✅ Suggestion: Messages d'erreur plus spécifiques
|
||||
// + Retry automatique pour erreurs réseau
|
||||
```
|
||||
|
||||
3. **Console.log en production**
|
||||
```typescript
|
||||
// ❌ Lignes 59, 199-203: console.log en production
|
||||
console.log("Mission data with intention:", data.missions);
|
||||
|
||||
// ✅ Suggestion: Utiliser un logger conditionnel
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(...);
|
||||
}
|
||||
```
|
||||
|
||||
4. **Accessibilité**
|
||||
- Manque d'attributs ARIA sur les cartes
|
||||
- Navigation clavier non optimisée
|
||||
- Pas de skip links
|
||||
|
||||
---
|
||||
|
||||
## 📄 Page de détails de mission (`app/missions/[missionId]/page.tsx`)
|
||||
|
||||
### Points forts ✅
|
||||
|
||||
1. **Architecture en onglets**
|
||||
- Organisation claire: Général, Plan d'actions, Équipe, Ressources
|
||||
- Compteurs visuels sur les onglets (équipe, documents)
|
||||
- Navigation intuitive
|
||||
|
||||
2. **Fonctionnalités avancées**
|
||||
- Génération de plan d'action par IA (N8N)
|
||||
- Édition inline du plan avec sauvegarde
|
||||
- Gestion des gardiens de l'intention
|
||||
- Clôture de mission avec intégration N8N
|
||||
|
||||
3. **Gestion d'état complexe**
|
||||
- Suivi des modifications non sauvegardées
|
||||
- États de chargement multiples (generating, saving, deleting, closing)
|
||||
- Synchronisation avec le backend
|
||||
|
||||
### Points d'amélioration critiques 🔴
|
||||
|
||||
1. **Sécurité - Validation côté client uniquement**
|
||||
```typescript
|
||||
// ⚠️ Ligne 192: Confirmation simple avec confirm()
|
||||
if (!confirm("Êtes-vous sûr de vouloir supprimer cette mission ?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ Suggestion: Modal de confirmation avec détails
|
||||
// + Vérification des permissions côté serveur (déjà fait ✅)
|
||||
```
|
||||
|
||||
2. **Gestion des erreurs réseau**
|
||||
```typescript
|
||||
// ⚠️ Pas de retry automatique
|
||||
// Pas de gestion des timeouts
|
||||
// Pas de fallback si l'API est down
|
||||
|
||||
// ✅ Suggestion: Implémenter retry avec exponential backoff
|
||||
// + Cache local pour données critiques
|
||||
```
|
||||
|
||||
3. **Performance - Re-renders inutiles**
|
||||
```typescript
|
||||
// ⚠️ Ligne 112-116: useEffect qui se déclenche à chaque changement
|
||||
useEffect(() => {
|
||||
if (mission) {
|
||||
setIsPlanModified(editedPlan !== (mission.actionPlan || ""));
|
||||
}
|
||||
}, [editedPlan, mission]);
|
||||
|
||||
// ✅ Suggestion: Utiliser useMemo pour éviter recalculs
|
||||
const isPlanModified = useMemo(() => {
|
||||
return mission ? editedPlan !== (mission.actionPlan || "") : false;
|
||||
}, [editedPlan, mission?.actionPlan]);
|
||||
```
|
||||
|
||||
4. **Textarea auto-resize - Code fragile**
|
||||
```typescript
|
||||
// ⚠️ Lignes 676-688: Manipulation directe du DOM
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = e.target.scrollHeight + 'px';
|
||||
|
||||
// ✅ Suggestion: Utiliser une librairie dédiée (react-textarea-autosize)
|
||||
// ou un hook personnalisé réutilisable
|
||||
```
|
||||
|
||||
5. **Duplication de code**
|
||||
```typescript
|
||||
// ⚠️ Fonctions helper dupliquées entre page.tsx et [missionId]/page.tsx
|
||||
// getMissionTypeLabel, getDurationLabel, getNiveauLabel, etc.
|
||||
|
||||
// ✅ Suggestion: Extraire dans lib/mission-helpers.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔌 API Routes - Analyse Backend
|
||||
|
||||
### `app/api/missions/route.ts` (GET/POST)
|
||||
|
||||
#### Points forts ✅
|
||||
|
||||
1. **Sécurité**
|
||||
- Vérification d'authentification systématique
|
||||
- Validation des champs requis
|
||||
- Gestion des permissions
|
||||
|
||||
2. **Gestion des fichiers**
|
||||
- Upload vers Minio/S3 bien structuré
|
||||
- Vérification d'existence des fichiers avant N8N
|
||||
- Cleanup en cas d'erreur (lignes 460-474)
|
||||
|
||||
3. **Intégration N8N**
|
||||
- Workflow asynchrone pour création
|
||||
- Gestion des erreurs non-bloquantes
|
||||
- Logging détaillé
|
||||
|
||||
#### Points d'amélioration 🔧
|
||||
|
||||
1. **Transaction database**
|
||||
```typescript
|
||||
// ⚠️ Pas de transaction Prisma
|
||||
const mission = await prisma.mission.create({...});
|
||||
await prisma.missionUser.createMany({...});
|
||||
|
||||
// ✅ Suggestion: Utiliser $transaction pour atomicité
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const mission = await tx.mission.create({...});
|
||||
await tx.missionUser.createMany({...});
|
||||
return mission;
|
||||
});
|
||||
```
|
||||
|
||||
2. **Validation des données**
|
||||
```typescript
|
||||
// ⚠️ Validation basique (lignes 230-235)
|
||||
if (!body.name || !body.oddScope) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// ✅ Suggestion: Utiliser Zod ou Yup pour validation stricte
|
||||
const MissionSchema = z.object({
|
||||
name: z.string().min(3).max(100),
|
||||
oddScope: z.array(z.string().regex(/^odd-\d+$/)),
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
3. **Gestion des erreurs N8N**
|
||||
```typescript
|
||||
// ⚠️ Ligne 439: Erreur N8N bloque la création
|
||||
if (!workflowResult.success) {
|
||||
throw new Error(workflowResult.error || 'N8N workflow failed');
|
||||
}
|
||||
|
||||
// ✅ Suggestion: Mode "best effort" - créer la mission même si N8N échoue
|
||||
// + Queue de retry pour N8N (BullMQ, etc.)
|
||||
```
|
||||
|
||||
### `app/api/missions/[missionId]/route.ts` (GET/PUT/DELETE)
|
||||
|
||||
#### Points forts ✅
|
||||
|
||||
1. **DELETE bien implémenté**
|
||||
- Cleanup Minio avant suppression DB
|
||||
- Intégration N8N pour rollback
|
||||
- Gestion des erreurs non-bloquantes
|
||||
|
||||
2. **Permissions granulaires**
|
||||
- Vérification créateur/admin pour DELETE
|
||||
- Gardiens peuvent modifier (PUT)
|
||||
|
||||
#### Points d'amélioration 🔧
|
||||
|
||||
1. **GET - Performance**
|
||||
```typescript
|
||||
// ⚠️ Ligne 38: findFirst au lieu de findUnique
|
||||
const mission = await (prisma as any).mission.findFirst({
|
||||
where: {
|
||||
id: missionId,
|
||||
OR: [
|
||||
{ creatorId: userId },
|
||||
{ missionUsers: { some: { userId } } }
|
||||
]
|
||||
},
|
||||
// ...
|
||||
});
|
||||
|
||||
// ✅ Suggestion: findUnique + vérification permissions séparée
|
||||
// Plus performant avec index sur id
|
||||
```
|
||||
|
||||
2. **PUT - Validation partielle**
|
||||
```typescript
|
||||
// ⚠️ Pas de validation des données mises à jour
|
||||
// Pas de vérification de cohérence (ex: oddScope doit être array)
|
||||
|
||||
// ✅ Suggestion: Validation stricte avec schéma
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Composants UI
|
||||
|
||||
### `components/missions/missions-admin-panel.tsx`
|
||||
|
||||
#### Points forts ✅
|
||||
|
||||
1. **Interface complète**
|
||||
- Formulaire multi-onglets bien organisé
|
||||
- Gestion des gardiens et volontaires
|
||||
- Upload de fichiers intégré
|
||||
|
||||
2. **UX soignée**
|
||||
- Validation en temps réel
|
||||
- Indicateurs visuels de progression
|
||||
- Messages d'erreur contextuels
|
||||
|
||||
#### Points d'amélioration critiques 🔴
|
||||
|
||||
1. **Fichier trop volumineux (1570 lignes)**
|
||||
```typescript
|
||||
// ❌ Un seul composant fait tout
|
||||
// Difficile à maintenir, tester, et réutiliser
|
||||
|
||||
// ✅ Suggestion: Découper en sous-composants
|
||||
// - MissionGeneralForm
|
||||
// - MissionDetailsForm
|
||||
// - MissionAttachmentsForm
|
||||
// - MissionMembersForm
|
||||
// - MissionSkillsForm
|
||||
```
|
||||
|
||||
2. **Gestion d'état complexe**
|
||||
```typescript
|
||||
// ⚠️ Trop de useState (15+)
|
||||
const [selectedServices, setSelectedServices] = useState<string[]>([]);
|
||||
const [selectedProfils, setSelectedProfils] = useState<string[]>([]);
|
||||
// ... 13 autres
|
||||
|
||||
// ✅ Suggestion: Utiliser useReducer ou Zustand
|
||||
const [state, dispatch] = useReducer(missionReducer, initialState);
|
||||
```
|
||||
|
||||
3. **Logique métier dans le composant**
|
||||
```typescript
|
||||
// ⚠️ Lignes 400-408: Conversion base64 dans le composant
|
||||
const convertFileToBase64 = (file: File): Promise<string> => {
|
||||
// ...
|
||||
};
|
||||
|
||||
// ✅ Suggestion: Extraire dans lib/file-utils.ts
|
||||
```
|
||||
|
||||
4. **Console.log en production**
|
||||
```typescript
|
||||
// ❌ Lignes 412, 422, 428, 451, 465, 492, 504, 514, 541, 559
|
||||
// Trop de logs de debug
|
||||
|
||||
// ✅ Suggestion: Logger conditionnel ou supprimer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Flux de données
|
||||
|
||||
### Création de mission
|
||||
|
||||
```
|
||||
1. User remplit formulaire (missions-admin-panel.tsx)
|
||||
↓
|
||||
2. POST /api/missions
|
||||
↓
|
||||
3. Création DB (Prisma)
|
||||
↓
|
||||
4. Upload fichiers (Minio)
|
||||
↓
|
||||
5. Vérification fichiers
|
||||
↓
|
||||
6. Trigger N8N workflow
|
||||
↓
|
||||
7. N8N crée intégrations (Gitea, Leantime, etc.)
|
||||
↓
|
||||
8. Callback /api/missions/mission-created
|
||||
↓
|
||||
9. Mise à jour mission avec IDs externes
|
||||
```
|
||||
|
||||
**Problème potentiel**: Si N8N échoue après création DB, la mission existe sans intégrations.
|
||||
|
||||
**Solution**: Queue de retry ou mode "best effort" avec notification.
|
||||
|
||||
### Affichage de mission
|
||||
|
||||
```
|
||||
1. GET /api/missions/[missionId]
|
||||
↓
|
||||
2. Prisma query avec includes
|
||||
↓
|
||||
3. Génération URLs publiques (logo, attachments)
|
||||
↓
|
||||
4. Affichage dans page.tsx
|
||||
```
|
||||
|
||||
**Optimisation possible**: Cache Redis pour missions fréquemment consultées.
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Bugs potentiels identifiés
|
||||
|
||||
1. **Race condition sur plan d'action**
|
||||
```typescript
|
||||
// Si l'utilisateur modifie pendant la génération
|
||||
// Les modifications peuvent être écrasées
|
||||
```
|
||||
|
||||
2. **Memory leak potentiel**
|
||||
```typescript
|
||||
// Textarea auto-resize avec ref callback
|
||||
// Pas de cleanup dans useEffect
|
||||
```
|
||||
|
||||
3. **Type safety**
|
||||
```typescript
|
||||
// Utilisation de (prisma as any) dans plusieurs endroits
|
||||
// Indique que le schema Prisma n'est pas à jour
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Métriques de code
|
||||
|
||||
### Complexité cyclomatique
|
||||
|
||||
- `missions-admin-panel.tsx`: **Très élevée** (>50)
|
||||
- `[missionId]/page.tsx`: **Élevée** (~30)
|
||||
- `page.tsx`: **Moyenne** (~15)
|
||||
|
||||
### Taille des fichiers
|
||||
|
||||
- `missions-admin-panel.tsx`: **1570 lignes** ⚠️
|
||||
- `[missionId]/page.tsx`: **920 lignes** ⚠️
|
||||
- `route.ts` (POST): **480 lignes** ⚠️
|
||||
|
||||
**Recommandation**: Découper les fichiers >500 lignes.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Recommandations prioritaires
|
||||
|
||||
### 🔴 Critique (À faire immédiatement)
|
||||
|
||||
1. **Sécurité**
|
||||
- Ajouter validation stricte avec Zod
|
||||
- Implémenter rate limiting sur API
|
||||
- Ajouter CSRF protection
|
||||
|
||||
2. **Performance**
|
||||
- Implémenter pagination côté serveur
|
||||
- Ajouter cache Redis
|
||||
- Optimiser les requêtes Prisma (select spécifiques)
|
||||
|
||||
3. **Maintenabilité**
|
||||
- Découper `missions-admin-panel.tsx`
|
||||
- Extraire helpers dans lib/
|
||||
- Supprimer console.log de production
|
||||
|
||||
### 🟡 Important (À planifier)
|
||||
|
||||
1. **Tests**
|
||||
- Unit tests pour helpers
|
||||
- Integration tests pour API routes
|
||||
- E2E tests pour flux critiques
|
||||
|
||||
2. **Documentation**
|
||||
- JSDoc pour fonctions complexes
|
||||
- Diagrammes de séquence pour flux N8N
|
||||
- Guide de contribution
|
||||
|
||||
3. **Monitoring**
|
||||
- Sentry pour erreurs frontend
|
||||
- Logging structuré backend
|
||||
- Métriques de performance
|
||||
|
||||
### 🟢 Amélioration (Nice to have)
|
||||
|
||||
1. **UX**
|
||||
- Optimistic updates
|
||||
- Skeleton loaders
|
||||
- Animations de transition
|
||||
|
||||
2. **Accessibilité**
|
||||
- ARIA labels complets
|
||||
- Navigation clavier
|
||||
- Support lecteurs d'écran
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Conclusion
|
||||
|
||||
Le système de missions est **fonctionnel et bien structuré** avec une architecture claire. Les principales améliorations à apporter concernent:
|
||||
|
||||
1. **Maintenabilité**: Découpage des gros composants
|
||||
2. **Performance**: Optimisation des requêtes et pagination
|
||||
3. **Robustesse**: Meilleure gestion d'erreurs et retry logic
|
||||
4. **Sécurité**: Validation stricte et rate limiting
|
||||
|
||||
Le code montre une bonne compréhension de Next.js, Prisma, et des patterns React modernes. Avec les améliorations suggérées, le système sera prêt pour la production à grande échelle.
|
||||
|
||||
---
|
||||
|
||||
**Date de review**: $(date)
|
||||
**Reviewer**: Senior Developer
|
||||
**Version analysée**: Current codebase
|
||||
620
components/missions/mission-members-panel.tsx
Normal file
620
components/missions/mission-members-panel.tsx
Normal file
@ -0,0 +1,620 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { logger } from '@/lib/logger';
|
||||
import { Input } from "../ui/input";
|
||||
import { Button } from "../ui/button";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { X, Search, UserPlus, Users, PlusCircle, AlertCircle, Check } from "lucide-react";
|
||||
import { toast } from "../ui/use-toast";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "../ui/dropdown-menu";
|
||||
|
||||
// Define interfaces for user and group data
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
roles?: string[];
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
membersCount: number;
|
||||
}
|
||||
|
||||
// User role types in mission
|
||||
export type GuardienRole = 'temps' | 'parole' | 'memoire';
|
||||
export type UserRole = GuardienRole | 'volontaire';
|
||||
|
||||
interface MissionMembersPanelProps {
|
||||
gardienDuTemps: string | null;
|
||||
gardienDeLaParole: string | null;
|
||||
gardienDeLaMemoire: string | null;
|
||||
volontaires: string[];
|
||||
onGardienDuTempsChange: (userId: string | null) => void;
|
||||
onGardienDeLaParoleChange: (userId: string | null) => void;
|
||||
onGardienDeLaMemoireChange: (userId: string | null) => void;
|
||||
onVolontairesChange: (userIds: string[]) => void;
|
||||
}
|
||||
|
||||
export function MissionMembersPanel({
|
||||
gardienDuTemps,
|
||||
gardienDeLaParole,
|
||||
gardienDeLaMemoire,
|
||||
volontaires,
|
||||
onGardienDuTempsChange,
|
||||
onGardienDeLaParoleChange,
|
||||
onGardienDeLaMemoireChange,
|
||||
onVolontairesChange,
|
||||
}: MissionMembersPanelProps) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedTab, setSelectedTab] = useState<'users' | 'groups'>('users');
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Check if mission is valid (has all required guardiens)
|
||||
const isMissionValid = gardienDuTemps !== null && gardienDeLaParole !== null && gardienDeLaMemoire !== null;
|
||||
|
||||
// Fetch users and groups on component mount
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await Promise.all([fetchUsers(), fetchGroups()]);
|
||||
} catch (error) {
|
||||
logger.error("Error fetching data", { error: error instanceof Error ? error.message : String(error) });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// Function to fetch users from API
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/users");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch users");
|
||||
}
|
||||
const data = await response.json();
|
||||
setUsers(data);
|
||||
} catch (error) {
|
||||
logger.error("Error fetching users", { error: error instanceof Error ? error.message : String(error) });
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Erreur lors de la récupération des utilisateurs",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Function to fetch groups from API
|
||||
const fetchGroups = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/groups");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch groups");
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
// Fetch member counts for groups
|
||||
const groupsWithCounts = await Promise.all(
|
||||
(Array.isArray(data) ? data : []).map(async (group) => {
|
||||
try {
|
||||
const membersResponse = await fetch(`/api/groups/${group.id}/members`);
|
||||
if (membersResponse.ok) {
|
||||
const members = await membersResponse.json();
|
||||
return {
|
||||
...group,
|
||||
membersCount: Array.isArray(members) ? members.length : 0
|
||||
};
|
||||
}
|
||||
return {...group, membersCount: 0};
|
||||
} catch (error) {
|
||||
logger.error(`Error fetching members for group ${group.id}`, { error: error instanceof Error ? error.message : String(error) });
|
||||
return {...group, membersCount: 0};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
setGroups(groupsWithCounts);
|
||||
} catch (error) {
|
||||
logger.error("Error fetching groups", { error: error instanceof Error ? error.message : String(error) });
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Erreur lors de la récupération des groupes",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Filtered users based on search term
|
||||
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())
|
||||
);
|
||||
|
||||
// Filtered groups based on search term
|
||||
const filteredGroups = groups.filter(group =>
|
||||
(group.name?.toLowerCase() || "").includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
// Function to check if a user is already assigned to any role
|
||||
const isUserAssigned = (userId: string) => {
|
||||
return gardienDuTemps === userId ||
|
||||
gardienDeLaParole === userId ||
|
||||
gardienDeLaMemoire === userId ||
|
||||
volontaires.includes(userId);
|
||||
};
|
||||
|
||||
// Function to get user's roles (can now have multiple)
|
||||
const getUserRoles = (userId: string): UserRole[] => {
|
||||
const roles: UserRole[] = [];
|
||||
if (gardienDuTemps === userId) roles.push('temps');
|
||||
if (gardienDeLaParole === userId) roles.push('parole');
|
||||
if (gardienDeLaMemoire === userId) roles.push('memoire');
|
||||
if (volontaires.includes(userId)) roles.push('volontaire');
|
||||
return roles;
|
||||
};
|
||||
|
||||
// Function to get role display name
|
||||
const getRoleDisplayName = (role: UserRole | null): string => {
|
||||
switch(role) {
|
||||
case 'temps': return "Gardien du Temps";
|
||||
case 'parole': return "Gardien de la Parole";
|
||||
case 'memoire': return "Gardien de la Mémoire";
|
||||
case 'volontaire': return "Volontaire";
|
||||
default: return "";
|
||||
}
|
||||
};
|
||||
|
||||
// Function to assign a user to a specific guardian role
|
||||
const assignGuardienRole = (userId: string, role: GuardienRole) => {
|
||||
// Only remove from volunteers if they're currently a volunteer
|
||||
if (volontaires.includes(userId)) {
|
||||
onVolontairesChange(volontaires.filter(id => id !== userId));
|
||||
}
|
||||
|
||||
// Assign to new role
|
||||
if (role === 'temps') {
|
||||
onGardienDuTempsChange(userId);
|
||||
} else if (role === 'parole') {
|
||||
onGardienDeLaParoleChange(userId);
|
||||
} else if (role === 'memoire') {
|
||||
onGardienDeLaMemoireChange(userId);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Rôle assigné",
|
||||
description: `L'utilisateur a été assigné comme ${getRoleDisplayName(role)}`,
|
||||
});
|
||||
};
|
||||
|
||||
// Function to assign a user as volunteer
|
||||
const assignVolontaire = (userId: string) => {
|
||||
// Remove from any existing role first
|
||||
removeUserFromAllRoles(userId);
|
||||
|
||||
// Add to volunteers
|
||||
onVolontairesChange([...volontaires, userId]);
|
||||
|
||||
toast({
|
||||
title: "Rôle assigné",
|
||||
description: "L'utilisateur a été assigné comme Volontaire",
|
||||
});
|
||||
};
|
||||
|
||||
// Function to remove a user from all roles
|
||||
const removeUserFromAllRoles = (userId: string) => {
|
||||
if (gardienDuTemps === userId) onGardienDuTempsChange(null);
|
||||
if (gardienDeLaParole === userId) onGardienDeLaParoleChange(null);
|
||||
if (gardienDeLaMemoire === userId) onGardienDeLaMemoireChange(null);
|
||||
if (volontaires.includes(userId)) {
|
||||
onVolontairesChange(volontaires.filter(id => id !== userId));
|
||||
}
|
||||
};
|
||||
|
||||
// Function to fetch group members
|
||||
const fetchGroupMembers = async (groupId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/groups/${groupId}/members`);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch group members");
|
||||
}
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error(`Error fetching members for group ${groupId}`, { error: error instanceof Error ? error.message : String(error) });
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Erreur lors de la récupération des membres du groupe",
|
||||
variant: "destructive",
|
||||
});
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Handler for viewing group members
|
||||
const handleViewGroupMembers = async (groupId: string, groupName: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const members = await fetchGroupMembers(groupId);
|
||||
|
||||
// Update the users list with the group members and switch to users tab
|
||||
if (Array.isArray(members) && members.length > 0) {
|
||||
setUsers(members);
|
||||
setSelectedTab('users');
|
||||
setSearchTerm(''); // Clear any existing search
|
||||
|
||||
toast({
|
||||
title: `Membres de ${groupName}`,
|
||||
description: `${members.length} membres trouvés et affichés ci-dessous`,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: `Membres de ${groupName}`,
|
||||
description: "Aucun membre trouvé dans ce groupe",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error handling group members", { error: error instanceof Error ? error.message : String(error) });
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Erreur lors de l'affichage des membres du groupe",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper component for Guardian card
|
||||
const GuardianCard = ({
|
||||
title,
|
||||
userId,
|
||||
onRemove
|
||||
}: {
|
||||
title: string;
|
||||
userId: string | null;
|
||||
onRemove: () => void;
|
||||
}) => {
|
||||
const user = userId ? users.find(u => u.id === userId) : null;
|
||||
|
||||
return (
|
||||
<div className="border rounded-md p-4 bg-white">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h4 className="font-medium text-gray-800">{title}</h4>
|
||||
{userId && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRemove}
|
||||
className="text-red-600 hover:bg-red-50 hover:text-red-700 border-red-200 h-8 bg-white"
|
||||
disabled={loading}
|
||||
>
|
||||
<X size={16} className="mr-1" />
|
||||
Supprimer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex items-center py-2 px-3 bg-gray-50 border border-gray-200 rounded-md">
|
||||
<div className="animate-pulse w-full flex items-center">
|
||||
<div className="h-10 w-10 bg-gray-300 rounded-full mr-3"></div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-3 bg-gray-300 rounded w-1/3"></div>
|
||||
<div className="h-3 bg-gray-300 rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : user ? (
|
||||
<div className="bg-blue-50 border border-blue-100 rounded-md p-3">
|
||||
<div className="flex items-center">
|
||||
<div className="h-10 w-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-medium mr-3">
|
||||
{user.firstName?.[0] || ""}{user.lastName?.[0] || ""}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{user.firstName} {user.lastName}</div>
|
||||
<div className="text-sm text-gray-500">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center text-gray-500 bg-gray-50 border border-gray-200 rounded-md py-2 px-3">
|
||||
<div className="h-8 w-8 rounded-full bg-gray-200 flex items-center justify-center mr-3">
|
||||
<Users size={14} className="text-gray-400" />
|
||||
</div>
|
||||
<span className="text-sm">Aucun utilisateur sélectionné</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-700">Les Gardiens de l'Intention</h3>
|
||||
|
||||
<div className="flex items-center">
|
||||
{!isMissionValid && (
|
||||
<div className="flex items-center text-amber-600 mr-3 text-sm">
|
||||
<AlertCircle size={16} className="mr-1" />
|
||||
Les 3 gardiens doivent être assignés
|
||||
</div>
|
||||
)}
|
||||
{isMissionValid && (
|
||||
<div className="flex items-center text-green-600 mr-3 text-sm">
|
||||
<Check size={16} className="mr-1" />
|
||||
Tous les gardiens sont assignés
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<GuardianCard
|
||||
title="Gardien du Temps"
|
||||
userId={gardienDuTemps}
|
||||
onRemove={() => removeUserFromAllRoles(gardienDuTemps!)}
|
||||
/>
|
||||
|
||||
<GuardianCard
|
||||
title="Gardien de la Parole"
|
||||
userId={gardienDeLaParole}
|
||||
onRemove={() => removeUserFromAllRoles(gardienDeLaParole!)}
|
||||
/>
|
||||
|
||||
<GuardianCard
|
||||
title="Gardien de la Mémoire"
|
||||
userId={gardienDeLaMemoire}
|
||||
onRemove={() => removeUserFromAllRoles(gardienDeLaMemoire!)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-md p-4">
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="font-medium text-gray-800">Sélectionner des membres</h4>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant={selectedTab === 'users' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedTab('users')}
|
||||
className={selectedTab === 'users'
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
: 'text-gray-700 bg-white hover:bg-gray-50 border border-gray-300'}
|
||||
disabled={loading}
|
||||
>
|
||||
<Users size={16} className="mr-1" />
|
||||
Utilisateurs
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedTab === 'groups' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedTab('groups')}
|
||||
className={selectedTab === 'groups'
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
: 'text-gray-700 bg-white hover:bg-gray-50 border border-gray-300'}
|
||||
disabled={loading}
|
||||
>
|
||||
<Users size={16} className="mr-1" />
|
||||
Groupes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-2">Volontaires ({volontaires.length})</h5>
|
||||
{volontaires.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{volontaires.map(userId => {
|
||||
const user = users.find(u => u.id === userId);
|
||||
if (!user) return null;
|
||||
return (
|
||||
<Badge key={userId} className="bg-gray-100 text-gray-800 hover:bg-gray-200 px-2 py-1 flex items-center">
|
||||
{user.firstName} {user.lastName}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeUserFromAllRoles(userId)}
|
||||
className="ml-1 h-5 w-5 p-0 text-gray-500 hover:text-red-600 hover:bg-transparent"
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500 mb-3">Aucun volontaire assigné</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-500" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={`Rechercher ${selectedTab === 'users' ? 'un utilisateur' : 'un groupe'}...`}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9 bg-white text-gray-900 border-gray-300"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="border rounded-md p-6 flex flex-col items-center justify-center text-gray-500">
|
||||
<div className="animate-pulse space-y-4 w-full">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center py-3 px-4">
|
||||
<div className="h-10 w-10 bg-gray-300 rounded-full mr-3"></div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-3 bg-gray-300 rounded w-1/3"></div>
|
||||
<div className="h-3 bg-gray-300 rounded w-1/2"></div>
|
||||
</div>
|
||||
<div className="h-7 w-16 bg-gray-300 rounded"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md max-h-[300px] overflow-y-auto">
|
||||
{selectedTab === 'users' ? (
|
||||
filteredUsers.length > 0 ? (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{filteredUsers.map(user => (
|
||||
<div key={user.id} className="p-3 hover:bg-gray-50 flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className="h-10 w-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 font-medium mr-3">
|
||||
{user.firstName?.[0] || ""}{user.lastName?.[0] || ""}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{user.firstName} {user.lastName}</div>
|
||||
<div className="text-sm text-gray-500">{user.email}</div>
|
||||
{isUserAssigned(user.id) && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{getUserRoles(user.id).map((role) => (
|
||||
<Badge
|
||||
key={role}
|
||||
className={`px-1.5 py-0.5 text-xs ${
|
||||
role === 'volontaire'
|
||||
? 'bg-gray-100 text-gray-800'
|
||||
: 'bg-blue-100 text-blue-800'
|
||||
}`}
|
||||
>
|
||||
{getRoleDisplayName(role)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User role controls always show dropdown */}
|
||||
<div className="flex items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-blue-600 bg-white hover:bg-blue-50 hover:text-blue-700 border-blue-200 h-8 mr-2"
|
||||
disabled={loading}
|
||||
>
|
||||
<UserPlus size={16} className="mr-1" />
|
||||
Ajouter rôle
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="bg-white border border-gray-200">
|
||||
<DropdownMenuItem
|
||||
onClick={() => assignGuardienRole(user.id, 'temps')}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Gardien du Temps
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => assignGuardienRole(user.id, 'parole')}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Gardien de la Parole
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => assignGuardienRole(user.id, 'memoire')}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Gardien de la Mémoire
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => assignVolontaire(user.id)}
|
||||
className="text-gray-700 bg-white hover:bg-gray-50 border-gray-300 h-8"
|
||||
disabled={loading}
|
||||
>
|
||||
<PlusCircle size={16} className="mr-1" />
|
||||
Volontaire
|
||||
</Button>
|
||||
|
||||
{isUserAssigned(user.id) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => removeUserFromAllRoles(user.id)}
|
||||
className="ml-2 text-red-600 hover:bg-red-50 hover:text-red-700 border-red-200 h-8 bg-white"
|
||||
disabled={loading}
|
||||
>
|
||||
<X size={14} className="mr-1" />
|
||||
Supprimer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 text-center text-gray-500">
|
||||
Aucun utilisateur trouvé
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
filteredGroups.length > 0 ? (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{filteredGroups.map(group => (
|
||||
<div key={group.id} className="p-3 hover:bg-gray-50 flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className="h-10 w-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 font-medium mr-3">
|
||||
<Users size={16} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{group.name}</div>
|
||||
<div className="text-sm text-gray-500">{group.membersCount} membres</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleViewGroupMembers(group.id, group.name)}
|
||||
className="text-blue-600 bg-white hover:bg-blue-50 hover:text-blue-700 border-blue-200 h-8"
|
||||
disabled={loading}
|
||||
>
|
||||
<Users size={16} className="mr-1" />
|
||||
Voir membres
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 text-center text-gray-500">
|
||||
Aucun groupe trouvé
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -18,40 +18,13 @@ import {
|
||||
CardContent
|
||||
} from "../ui/card";
|
||||
import { Badge } from "../ui/badge";
|
||||
import { X, Search, UserPlus, Users, PlusCircle, AlertCircle, Check, UploadCloud, File } from "lucide-react";
|
||||
import { X, UploadCloud, File } from "lucide-react";
|
||||
import { toast } from "../ui/use-toast";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "../ui/dropdown-menu";
|
||||
import { FileUpload } from "./file-upload";
|
||||
import { AttachmentsList } from "./attachments-list";
|
||||
import { MissionMembersPanel } from "./mission-members-panel";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
// Define interfaces for user and group data
|
||||
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;
|
||||
}
|
||||
|
||||
// User role types in mission
|
||||
type GuardienRole = 'temps' | 'parole' | 'memoire';
|
||||
type UserRole = GuardienRole | 'volontaire';
|
||||
|
||||
interface MissionData {
|
||||
name?: string;
|
||||
logo?: {
|
||||
@ -74,8 +47,6 @@ export function MissionsAdminPanel() {
|
||||
const router = useRouter();
|
||||
const [selectedServices, setSelectedServices] = useState<string[]>([]);
|
||||
const [selectedProfils, setSelectedProfils] = useState<string[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedTab, setSelectedTab] = useState<'users' | 'groups'>('users');
|
||||
const [gardienDuTemps, setGardienDuTemps] = useState<string | null>(null);
|
||||
const [gardienDeLaParole, setGardienDeLaParole] = useState<string | null>(null);
|
||||
const [gardienDeLaMemoire, setGardienDeLaMemoire] = useState<string | null>(null);
|
||||
@ -87,11 +58,6 @@ export function MissionsAdminPanel() {
|
||||
const [selectedAttachments, setSelectedAttachments] = useState<File[]>([]);
|
||||
const [missionData, setMissionData] = useState<MissionData>({});
|
||||
|
||||
// State for storing fetched data
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Check if mission is valid (has all required guardiens)
|
||||
const isMissionValid = gardienDuTemps !== null && gardienDeLaParole !== null && gardienDeLaMemoire !== null;
|
||||
|
||||
@ -109,234 +75,6 @@ export function MissionsAdminPanel() {
|
||||
}
|
||||
}, [draftMissionId]);
|
||||
|
||||
// Fetch users and groups on component mount
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await Promise.all([fetchUsers(), fetchGroups()]);
|
||||
} catch (error) {
|
||||
logger.error("Error fetching data", { error: error instanceof Error ? error.message : String(error) });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
// Function to fetch users from API
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/users");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch users");
|
||||
}
|
||||
const data = await response.json();
|
||||
setUsers(data);
|
||||
} catch (error) {
|
||||
logger.error("Error fetching users", { error: error instanceof Error ? error.message : String(error) });
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Erreur lors de la récupération des utilisateurs",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Function to fetch groups from API
|
||||
const fetchGroups = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/groups");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch groups");
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
// Fetch member counts for groups
|
||||
const groupsWithCounts = await Promise.all(
|
||||
(Array.isArray(data) ? data : []).map(async (group) => {
|
||||
try {
|
||||
const membersResponse = await fetch(`/api/groups/${group.id}/members`);
|
||||
if (membersResponse.ok) {
|
||||
const members = await membersResponse.json();
|
||||
return {
|
||||
...group,
|
||||
membersCount: Array.isArray(members) ? members.length : 0
|
||||
};
|
||||
}
|
||||
return {...group, membersCount: 0};
|
||||
} catch (error) {
|
||||
logger.error(`Error fetching members for group ${group.id}`, { error: error instanceof Error ? error.message : String(error) });
|
||||
return {...group, membersCount: 0};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
setGroups(groupsWithCounts);
|
||||
} catch (error) {
|
||||
logger.error("Error fetching groups", { error: error instanceof Error ? error.message : String(error) });
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Erreur lors de la récupération des groupes",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Filtered users based on search term
|
||||
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())
|
||||
);
|
||||
|
||||
// Filtered groups based on search term
|
||||
const filteredGroups = groups.filter(group =>
|
||||
(group.name?.toLowerCase() || "").includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
// Function to check if a user is already assigned to any role
|
||||
const isUserAssigned = (userId: string) => {
|
||||
return gardienDuTemps === userId ||
|
||||
gardienDeLaParole === userId ||
|
||||
gardienDeLaMemoire === userId ||
|
||||
volontaires.includes(userId);
|
||||
};
|
||||
|
||||
// Function to get user's roles (can now have multiple)
|
||||
const getUserRoles = (userId: string): UserRole[] => {
|
||||
const roles: UserRole[] = [];
|
||||
if (gardienDuTemps === userId) roles.push('temps');
|
||||
if (gardienDeLaParole === userId) roles.push('parole');
|
||||
if (gardienDeLaMemoire === userId) roles.push('memoire');
|
||||
if (volontaires.includes(userId)) roles.push('volontaire');
|
||||
return roles;
|
||||
};
|
||||
|
||||
// For backwards compatibility with existing code
|
||||
const getUserRole = (userId: string): UserRole | null => {
|
||||
const roles = getUserRoles(userId);
|
||||
return roles.length > 0 ? roles[0] : null;
|
||||
};
|
||||
|
||||
// Function to get role display name
|
||||
const getRoleDisplayName = (role: UserRole | null): string => {
|
||||
switch(role) {
|
||||
case 'temps': return "Gardien du Temps";
|
||||
case 'parole': return "Gardien de la Parole";
|
||||
case 'memoire': return "Gardien de la Mémoire";
|
||||
case 'volontaire': return "Volontaire";
|
||||
default: return "";
|
||||
}
|
||||
};
|
||||
|
||||
// Function to assign a user to a specific guardian role
|
||||
const assignGuardienRole = (userId: string, role: GuardienRole) => {
|
||||
// Only remove from volunteers if they're currently a volunteer
|
||||
if (volontaires.includes(userId)) {
|
||||
setVolontaires(prev => prev.filter(id => id !== userId));
|
||||
}
|
||||
|
||||
// Assign to new role
|
||||
if (role === 'temps') {
|
||||
setGardienDuTemps(userId);
|
||||
} else if (role === 'parole') {
|
||||
setGardienDeLaParole(userId);
|
||||
} else if (role === 'memoire') {
|
||||
setGardienDeLaMemoire(userId);
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Rôle assigné",
|
||||
description: `L'utilisateur a été assigné comme ${getRoleDisplayName(role)}`,
|
||||
});
|
||||
};
|
||||
|
||||
// Function to assign a user as volunteer
|
||||
const assignVolontaire = (userId: string) => {
|
||||
// Remove from any existing role first
|
||||
removeUserFromAllRoles(userId);
|
||||
|
||||
// Add to volunteers
|
||||
setVolontaires(prev => [...prev, userId]);
|
||||
|
||||
toast({
|
||||
title: "Rôle assigné",
|
||||
description: "L'utilisateur a été assigné comme Volontaire",
|
||||
});
|
||||
};
|
||||
|
||||
// Function to remove a user from all roles
|
||||
const removeUserFromAllRoles = (userId: string) => {
|
||||
if (gardienDuTemps === userId) setGardienDuTemps(null);
|
||||
if (gardienDeLaParole === userId) setGardienDeLaParole(null);
|
||||
if (gardienDeLaMemoire === userId) setGardienDeLaMemoire(null);
|
||||
if (volontaires.includes(userId)) {
|
||||
setVolontaires(prev => prev.filter(id => id !== userId));
|
||||
}
|
||||
};
|
||||
|
||||
// Check if all guardian roles are filled
|
||||
const areAllGuardiensFilled = (): boolean => {
|
||||
return gardienDuTemps !== null && gardienDeLaParole !== null && gardienDeLaMemoire !== null;
|
||||
};
|
||||
|
||||
// Function to fetch group members
|
||||
const fetchGroupMembers = async (groupId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/groups/${groupId}/members`);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch group members");
|
||||
}
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error(`Error fetching members for group ${groupId}`, { error: error instanceof Error ? error.message : String(error) });
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Erreur lors de la récupération des membres du groupe",
|
||||
variant: "destructive",
|
||||
});
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// Handler for viewing group members
|
||||
const handleViewGroupMembers = async (groupId: string, groupName: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const members = await fetchGroupMembers(groupId);
|
||||
|
||||
// Update the users list with the group members and switch to users tab
|
||||
if (Array.isArray(members) && members.length > 0) {
|
||||
setUsers(members);
|
||||
setSelectedTab('users');
|
||||
setSearchTerm(''); // Clear any existing search
|
||||
|
||||
toast({
|
||||
title: `Membres de ${groupName}`,
|
||||
description: `${members.length} membres trouvés et affichés ci-dessous`,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: `Membres de ${groupName}`,
|
||||
description: "Aucun membre trouvé dans ce groupe",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error handling group members", { error: error instanceof Error ? error.message : String(error) });
|
||||
toast({
|
||||
title: "Erreur",
|
||||
description: "Erreur lors de l'affichage des membres du groupe",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to navigate to the next tab
|
||||
const goToNextTab = () => {
|
||||
const tabOrder = ["general", "details", "attachments", "skills", "membres"];
|
||||
@ -1094,423 +832,16 @@ export function MissionsAdminPanel() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="membres" className="space-y-6">
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-700">Les Gardiens de l'Intention</h3>
|
||||
|
||||
<div className="flex items-center">
|
||||
{!isMissionValid && (
|
||||
<div className="flex items-center text-amber-600 mr-3 text-sm">
|
||||
<AlertCircle size={16} className="mr-1" />
|
||||
Les 3 gardiens doivent être assignés
|
||||
</div>
|
||||
)}
|
||||
{isMissionValid && (
|
||||
<div className="flex items-center text-green-600 mr-3 text-sm">
|
||||
<Check size={16} className="mr-1" />
|
||||
Tous les gardiens sont assignés
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Gardien du Temps */}
|
||||
<div className="border rounded-md p-4 bg-white">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h4 className="font-medium text-gray-800">Gardien du Temps</h4>
|
||||
{gardienDuTemps && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => removeUserFromAllRoles(gardienDuTemps)}
|
||||
className="text-red-600 hover:bg-red-50 hover:text-red-700 border-red-200 h-8 bg-white"
|
||||
disabled={loading}
|
||||
>
|
||||
<X size={16} className="mr-1" />
|
||||
Supprimer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex items-center py-2 px-3 bg-gray-50 border border-gray-200 rounded-md">
|
||||
<div className="animate-pulse w-full flex items-center">
|
||||
<div className="h-10 w-10 bg-gray-300 rounded-full mr-3"></div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-3 bg-gray-300 rounded w-1/3"></div>
|
||||
<div className="h-3 bg-gray-300 rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
gardienDuTemps ? (
|
||||
<div className="bg-blue-50 border border-blue-100 rounded-md p-3">
|
||||
{(() => {
|
||||
const user = users.find(u => u.id === gardienDuTemps);
|
||||
return user ? (
|
||||
<div className="flex items-center">
|
||||
<div className="h-10 w-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-medium mr-3">
|
||||
{user.firstName?.[0] || ""}{user.lastName?.[0] || ""}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{user.firstName} {user.lastName}</div>
|
||||
<div className="text-sm text-gray-500">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : "Utilisateur non trouvé";
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center text-gray-500 bg-gray-50 border border-gray-200 rounded-md py-2 px-3">
|
||||
<div className="h-8 w-8 rounded-full bg-gray-200 flex items-center justify-center mr-3">
|
||||
<Users size={14} className="text-gray-400" />
|
||||
</div>
|
||||
<span className="text-sm">Aucun utilisateur sélectionné</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gardien de la Parole */}
|
||||
<div className="border rounded-md p-4 bg-white">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h4 className="font-medium text-gray-800">Gardien de la Parole</h4>
|
||||
{gardienDeLaParole && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => removeUserFromAllRoles(gardienDeLaParole)}
|
||||
className="text-red-600 hover:bg-red-50 hover:text-red-700 border-red-200 h-8 bg-white"
|
||||
disabled={loading}
|
||||
>
|
||||
<X size={16} className="mr-1" />
|
||||
Supprimer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex items-center py-2 px-3 bg-gray-50 border border-gray-200 rounded-md">
|
||||
<div className="animate-pulse w-full flex items-center">
|
||||
<div className="h-10 w-10 bg-gray-300 rounded-full mr-3"></div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-3 bg-gray-300 rounded w-1/3"></div>
|
||||
<div className="h-3 bg-gray-300 rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
gardienDeLaParole ? (
|
||||
<div className="bg-blue-50 border border-blue-100 rounded-md p-3">
|
||||
{(() => {
|
||||
const user = users.find(u => u.id === gardienDeLaParole);
|
||||
return user ? (
|
||||
<div className="flex items-center">
|
||||
<div className="h-10 w-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-medium mr-3">
|
||||
{user.firstName?.[0] || ""}{user.lastName?.[0] || ""}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{user.firstName} {user.lastName}</div>
|
||||
<div className="text-sm text-gray-500">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : "Utilisateur non trouvé";
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center text-gray-500 bg-gray-50 border border-gray-200 rounded-md py-2 px-3">
|
||||
<div className="h-8 w-8 rounded-full bg-gray-200 flex items-center justify-center mr-3">
|
||||
<Users size={14} className="text-gray-400" />
|
||||
</div>
|
||||
<span className="text-sm">Aucun utilisateur sélectionné</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Gardien de la Mémoire */}
|
||||
<div className="border rounded-md p-4 bg-white">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h4 className="font-medium text-gray-800">Gardien de la Mémoire</h4>
|
||||
{gardienDeLaMemoire && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => removeUserFromAllRoles(gardienDeLaMemoire)}
|
||||
className="text-red-600 hover:bg-red-50 hover:text-red-700 border-red-200 h-8 bg-white"
|
||||
disabled={loading}
|
||||
>
|
||||
<X size={16} className="mr-1" />
|
||||
Supprimer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex items-center py-2 px-3 bg-gray-50 border border-gray-200 rounded-md">
|
||||
<div className="animate-pulse w-full flex items-center">
|
||||
<div className="h-10 w-10 bg-gray-300 rounded-full mr-3"></div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-3 bg-gray-300 rounded w-1/3"></div>
|
||||
<div className="h-3 bg-gray-300 rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
gardienDeLaMemoire ? (
|
||||
<div className="bg-blue-50 border border-blue-100 rounded-md p-3">
|
||||
{(() => {
|
||||
const user = users.find(u => u.id === gardienDeLaMemoire);
|
||||
return user ? (
|
||||
<div className="flex items-center">
|
||||
<div className="h-10 w-10 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-medium mr-3">
|
||||
{user.firstName?.[0] || ""}{user.lastName?.[0] || ""}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{user.firstName} {user.lastName}</div>
|
||||
<div className="text-sm text-gray-500">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : "Utilisateur non trouvé";
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center text-gray-500 bg-gray-50 border border-gray-200 rounded-md py-2 px-3">
|
||||
<div className="h-8 w-8 rounded-full bg-gray-200 flex items-center justify-center mr-3">
|
||||
<Users size={14} className="text-gray-400" />
|
||||
</div>
|
||||
<span className="text-sm">Aucun utilisateur sélectionné</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border rounded-md p-4">
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="font-medium text-gray-800">Sélectionner des membres</h4>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant={selectedTab === 'users' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedTab('users')}
|
||||
className={selectedTab === 'users'
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
: 'text-gray-700 bg-white hover:bg-gray-50 border border-gray-300'}
|
||||
disabled={loading}
|
||||
>
|
||||
<Users size={16} className="mr-1" />
|
||||
Utilisateurs
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedTab === 'groups' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedTab('groups')}
|
||||
className={selectedTab === 'groups'
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
: 'text-gray-700 bg-white hover:bg-gray-50 border border-gray-300'}
|
||||
disabled={loading}
|
||||
>
|
||||
<Users size={16} className="mr-1" />
|
||||
Groupes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-2">Volontaires ({volontaires.length})</h5>
|
||||
{volontaires.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{volontaires.map(userId => {
|
||||
const user = users.find(u => u.id === userId);
|
||||
if (!user) return null;
|
||||
return (
|
||||
<Badge key={userId} className="bg-gray-100 text-gray-800 hover:bg-gray-200 px-2 py-1 flex items-center">
|
||||
{user.firstName} {user.lastName}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeUserFromAllRoles(userId)}
|
||||
className="ml-1 h-5 w-5 p-0 text-gray-500 hover:text-red-600 hover:bg-transparent"
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500 mb-3">Aucun volontaire assigné</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-500" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={`Rechercher ${selectedTab === 'users' ? 'un utilisateur' : 'un groupe'}...`}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9 bg-white text-gray-900 border-gray-300"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="border rounded-md p-6 flex flex-col items-center justify-center text-gray-500">
|
||||
<div className="animate-pulse space-y-4 w-full">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center py-3 px-4">
|
||||
<div className="h-10 w-10 bg-gray-300 rounded-full mr-3"></div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-3 bg-gray-300 rounded w-1/3"></div>
|
||||
<div className="h-3 bg-gray-300 rounded w-1/2"></div>
|
||||
</div>
|
||||
<div className="h-7 w-16 bg-gray-300 rounded"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md max-h-[300px] overflow-y-auto">
|
||||
{selectedTab === 'users' ? (
|
||||
filteredUsers.length > 0 ? (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{filteredUsers.map(user => (
|
||||
<div key={user.id} className="p-3 hover:bg-gray-50 flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className="h-10 w-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 font-medium mr-3">
|
||||
{user.firstName?.[0] || ""}{user.lastName?.[0] || ""}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{user.firstName} {user.lastName}</div>
|
||||
<div className="text-sm text-gray-500">{user.email}</div>
|
||||
{isUserAssigned(user.id) && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{getUserRoles(user.id).map((role) => (
|
||||
<Badge
|
||||
key={role}
|
||||
className={`px-1.5 py-0.5 text-xs ${
|
||||
role === 'volontaire'
|
||||
? 'bg-gray-100 text-gray-800'
|
||||
: 'bg-blue-100 text-blue-800'
|
||||
}`}
|
||||
>
|
||||
{getRoleDisplayName(role)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User role controls always show dropdown */}
|
||||
<div className="flex items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-blue-600 bg-white hover:bg-blue-50 hover:text-blue-700 border-blue-200 h-8 mr-2"
|
||||
disabled={loading}
|
||||
>
|
||||
<UserPlus size={16} className="mr-1" />
|
||||
Ajouter rôle
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="bg-white border border-gray-200">
|
||||
<DropdownMenuItem
|
||||
onClick={() => assignGuardienRole(user.id, 'temps')}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Gardien du Temps
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => assignGuardienRole(user.id, 'parole')}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Gardien de la Parole
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => assignGuardienRole(user.id, 'memoire')}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Gardien de la Mémoire
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => assignVolontaire(user.id)}
|
||||
className="text-gray-700 bg-white hover:bg-gray-50 border-gray-300 h-8"
|
||||
disabled={loading}
|
||||
>
|
||||
<PlusCircle size={16} className="mr-1" />
|
||||
Volontaire
|
||||
</Button>
|
||||
|
||||
{isUserAssigned(user.id) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => removeUserFromAllRoles(user.id)}
|
||||
className="ml-2 text-red-600 hover:bg-red-50 hover:text-red-700 border-red-200 h-8 bg-white"
|
||||
disabled={loading}
|
||||
>
|
||||
<X size={14} className="mr-1" />
|
||||
Supprimer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 text-center text-gray-500">
|
||||
Aucun utilisateur trouvé
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
filteredGroups.length > 0 ? (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{filteredGroups.map(group => (
|
||||
<div key={group.id} className="p-3 hover:bg-gray-50 flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className="h-10 w-10 rounded-full bg-gray-100 flex items-center justify-center text-gray-600 font-medium mr-3">
|
||||
<Users size={16} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{group.name}</div>
|
||||
<div className="text-sm text-gray-500">{group.membersCount} membres</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleViewGroupMembers(group.id, group.name)}
|
||||
className="text-blue-600 bg-white hover:bg-blue-50 hover:text-blue-700 border-blue-200 h-8"
|
||||
disabled={loading}
|
||||
>
|
||||
<Users size={16} className="mr-1" />
|
||||
Voir membres
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 text-center text-gray-500">
|
||||
Aucun groupe trouvé
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MissionMembersPanel
|
||||
gardienDuTemps={gardienDuTemps}
|
||||
gardienDeLaParole={gardienDeLaParole}
|
||||
gardienDeLaMemoire={gardienDeLaMemoire}
|
||||
volontaires={volontaires}
|
||||
onGardienDuTempsChange={setGardienDuTemps}
|
||||
onGardienDeLaParoleChange={setGardienDeLaParole}
|
||||
onGardienDeLaMemoireChange={setGardienDeLaMemoire}
|
||||
onVolontairesChange={setVolontaires}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user