NeahStable/components/missions/mission-members-panel.tsx
2026-01-13 23:10:33 +01:00

628 lines
25 KiB
TypeScript

"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 => {
const displayName = (user.firstName && user.lastName)
? `${user.firstName} ${user.lastName}`
: user.firstName || user.lastName || user.username || user.email || 'Utilisateur';
const initials = (user.firstName?.[0] || "") + (user.lastName?.[0] || "") || user.username?.[0]?.toUpperCase() || user.email?.[0]?.toUpperCase() || "?";
return (
<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">
{initials}
</div>
<div>
<div className="font-medium text-gray-900">{displayName}</div>
<div className="text-sm text-gray-500">{user.email || user.username || ''}</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>
);
}