453 lines
20 KiB
TypeScript
453 lines
20 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||
import { parseCSVFromText, Person } from '@/lib/csv-parser-client';
|
||
|
||
const STORAGE_KEY = 'people-randomizr-data';
|
||
|
||
export default function Home() {
|
||
const [people, setPeople] = useState<Person[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [selectedCount, setSelectedCount] = useState<number>(5);
|
||
const [selectedPostes, setSelectedPostes] = useState<Set<string>>(new Set());
|
||
const [randomPeople, setRandomPeople] = useState<Person[]>([]);
|
||
const [showResults, setShowResults] = useState(false);
|
||
const [searchPoste, setSearchPoste] = useState<string>('');
|
||
const [hasData, setHasData] = useState<boolean>(false);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
// Charger les données depuis localStorage au démarrage
|
||
useEffect(() => {
|
||
try {
|
||
const stored = localStorage.getItem(STORAGE_KEY);
|
||
if (stored) {
|
||
const data = JSON.parse(stored);
|
||
setPeople(data);
|
||
setHasData(true);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading from localStorage:', error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
// Sauvegarder dans localStorage quand les données changent
|
||
useEffect(() => {
|
||
if (people.length > 0) {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(people));
|
||
setHasData(true);
|
||
}
|
||
}, [people]);
|
||
|
||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = event.target.files?.[0];
|
||
if (!file) return;
|
||
|
||
if (!file.name.endsWith('.csv')) {
|
||
alert('Veuillez sélectionner un fichier CSV');
|
||
return;
|
||
}
|
||
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
try {
|
||
const content = e.target?.result as string;
|
||
const parsed = parseCSVFromText(content);
|
||
setPeople(parsed);
|
||
setSelectedPostes(new Set());
|
||
setShowResults(false);
|
||
alert(`✅ ${parsed.length} personnes chargées avec succès !`);
|
||
} catch (error) {
|
||
console.error('Error parsing CSV:', error);
|
||
alert('Erreur lors du parsing du CSV. Vérifiez le format du fichier.');
|
||
}
|
||
};
|
||
reader.readAsText(file, 'UTF-8');
|
||
};
|
||
|
||
const handleClearData = () => {
|
||
if (confirm('Êtes-vous sûr de vouloir supprimer toutes les données ?')) {
|
||
localStorage.removeItem(STORAGE_KEY);
|
||
setPeople([]);
|
||
setHasData(false);
|
||
setSelectedPostes(new Set());
|
||
setShowResults(false);
|
||
if (fileInputRef.current) {
|
||
fileInputRef.current.value = '';
|
||
}
|
||
}
|
||
};
|
||
|
||
// Compter le nombre de personnes par poste
|
||
const posteCounts = useMemo(() => {
|
||
const counts: Record<string, number> = {};
|
||
people.forEach(person => {
|
||
if (person.description) {
|
||
counts[person.description] = (counts[person.description] || 0) + 1;
|
||
}
|
||
});
|
||
return counts;
|
||
}, [people]);
|
||
|
||
// Extraire tous les postes uniques triés par nombre décroissant
|
||
const allPostes = useMemo(() => {
|
||
const postes = new Set<string>();
|
||
people.forEach(person => {
|
||
if (person.description) {
|
||
postes.add(person.description);
|
||
}
|
||
});
|
||
return Array.from(postes).sort((a, b) => {
|
||
const countA = posteCounts[a] || 0;
|
||
const countB = posteCounts[b] || 0;
|
||
// Tri décroissant par nombre, puis alphabétique si égal
|
||
if (countA !== countB) {
|
||
return countB - countA;
|
||
}
|
||
return a.localeCompare(b);
|
||
});
|
||
}, [people, posteCounts]);
|
||
|
||
// Filtrer les postes selon la recherche
|
||
const filteredPostes = useMemo(() => {
|
||
if (!searchPoste.trim()) {
|
||
return allPostes;
|
||
}
|
||
const searchLower = searchPoste.toLowerCase();
|
||
return allPostes.filter(poste =>
|
||
poste.toLowerCase().includes(searchLower)
|
||
);
|
||
}, [allPostes, searchPoste]);
|
||
|
||
// Filtrer les personnes selon les postes sélectionnés
|
||
const filteredPeople = useMemo(() => {
|
||
if (selectedPostes.size === 0) {
|
||
return people;
|
||
}
|
||
return people.filter(person =>
|
||
person.description && selectedPostes.has(person.description)
|
||
);
|
||
}, [people, selectedPostes]);
|
||
|
||
const handlePosteToggle = (poste: string) => {
|
||
const newSelected = new Set(selectedPostes);
|
||
if (newSelected.has(poste)) {
|
||
newSelected.delete(poste);
|
||
} else {
|
||
newSelected.add(poste);
|
||
}
|
||
setSelectedPostes(newSelected);
|
||
setShowResults(false);
|
||
};
|
||
|
||
const handleSelectAll = () => {
|
||
if (selectedPostes.size === allPostes.length) {
|
||
setSelectedPostes(new Set());
|
||
} else {
|
||
setSelectedPostes(new Set(allPostes));
|
||
}
|
||
setShowResults(false);
|
||
};
|
||
|
||
const handleRandomize = () => {
|
||
if (selectedCount <= 0 || selectedCount > filteredPeople.length) {
|
||
alert(`Veuillez entrer un nombre entre 1 et ${filteredPeople.length}`);
|
||
return;
|
||
}
|
||
|
||
const shuffled = [...filteredPeople].sort(() => Math.random() - 0.5);
|
||
const selected = shuffled.slice(0, selectedCount);
|
||
setRandomPeople(selected);
|
||
setShowResults(true);
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="min-h-screen flex items-center justify-center">
|
||
<div className="relative">
|
||
<div className="w-16 h-16 border-4 border-cyan-500 border-t-transparent rounded-full animate-spin"></div>
|
||
<div className="absolute inset-0 w-16 h-16 border-4 border-purple-500 border-t-transparent rounded-full animate-spin opacity-50" style={{ animationDirection: 'reverse', animationDuration: '1.5s' }}></div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!hasData) {
|
||
return (
|
||
<div className="min-h-screen py-8 px-4 relative overflow-hidden">
|
||
{/* Effets de fond animés */}
|
||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-cyan-500 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob"></div>
|
||
<div className="absolute top-1/3 right-1/4 w-96 h-96 bg-purple-500 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob animation-delay-2000"></div>
|
||
<div className="absolute bottom-1/4 left-1/3 w-96 h-96 bg-pink-500 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob animation-delay-4000"></div>
|
||
</div>
|
||
|
||
<div className="max-w-2xl mx-auto relative z-10 flex items-center justify-center min-h-screen">
|
||
<div className="glass-strong rounded-2xl p-12 shadow-2xl border border-cyan-500/20 text-center">
|
||
<div className="mb-8">
|
||
<div className="text-6xl mb-4">📊</div>
|
||
<h1 className="text-4xl font-black mb-4 text-gradient tracking-tight">
|
||
People Randomizr
|
||
</h1>
|
||
<p className="text-cyan-300 text-lg font-light mb-8">
|
||
Importez votre fichier CSV pour commencer
|
||
</p>
|
||
</div>
|
||
|
||
<div className="space-y-4">
|
||
<label className="block">
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept=".csv"
|
||
onChange={handleFileUpload}
|
||
className="hidden"
|
||
/>
|
||
<div className="px-8 py-4 bg-gradient-to-r from-cyan-600 to-purple-600 text-white font-bold rounded-xl hover:from-cyan-500 hover:to-purple-500 transition-all duration-300 shadow-lg hover:shadow-cyan-500/50 border border-cyan-400/30 cursor-pointer inline-block">
|
||
📁 Choisir un fichier CSV
|
||
</div>
|
||
</label>
|
||
<p className="text-gray-400 text-sm mt-4">
|
||
Le fichier CSV doit contenir les colonnes : Nom, Description, Type
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen py-8 px-4 relative overflow-hidden">
|
||
{/* Effets de fond animés */}
|
||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-cyan-500 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob"></div>
|
||
<div className="absolute top-1/3 right-1/4 w-96 h-96 bg-purple-500 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob animation-delay-2000"></div>
|
||
<div className="absolute bottom-1/4 left-1/3 w-96 h-96 bg-pink-500 rounded-full mix-blend-multiply filter blur-3xl opacity-20 animate-blob animation-delay-4000"></div>
|
||
</div>
|
||
|
||
<div className="max-w-6xl mx-auto relative z-10">
|
||
<div className="text-center mb-8">
|
||
<h1 className="text-6xl font-black mb-4 text-gradient tracking-tight">
|
||
People Randomizr
|
||
</h1>
|
||
<p className="text-cyan-300 text-lg font-light mb-6">Sélection intelligente • Extraction aléatoire</p>
|
||
|
||
{/* Boutons de gestion de données */}
|
||
<div className="flex items-center justify-center gap-4 mb-4">
|
||
<label className="cursor-pointer">
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept=".csv"
|
||
onChange={handleFileUpload}
|
||
className="hidden"
|
||
/>
|
||
<span className="px-4 py-2 glass rounded-lg text-cyan-300 hover:text-white border border-cyan-500/30 hover:border-cyan-400/50 transition-all duration-300 text-sm font-semibold">
|
||
📁 Changer le CSV
|
||
</span>
|
||
</label>
|
||
<button
|
||
onClick={handleClearData}
|
||
className="px-4 py-2 glass rounded-lg text-red-300 hover:text-red-200 border border-red-500/30 hover:border-red-400/50 transition-all duration-300 text-sm font-semibold"
|
||
>
|
||
🗑️ Supprimer les données
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="glass-strong rounded-2xl p-8 mb-8 shadow-2xl border border-cyan-500/20">
|
||
<div className="flex items-center justify-between mb-6">
|
||
<h2 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-purple-400">
|
||
Filtres par poste
|
||
</h2>
|
||
<div className="flex items-center gap-4">
|
||
<div className="px-4 py-2 glass rounded-lg border border-cyan-500/30">
|
||
<span className="text-sm text-cyan-300">
|
||
<span className="font-bold text-cyan-400 text-lg">{selectedPostes.size}</span>
|
||
<span className="text-purple-300"> / </span>
|
||
<span className="text-white">{allPostes.length}</span>
|
||
<span className="text-gray-400 ml-2">sélectionné{selectedPostes.size > 1 ? 's' : ''}</span>
|
||
</span>
|
||
</div>
|
||
<button
|
||
onClick={handleSelectAll}
|
||
className="px-5 py-2.5 text-sm font-semibold text-white bg-gradient-to-r from-cyan-600 to-purple-600 rounded-lg hover:from-cyan-500 hover:to-purple-500 transition-all duration-300 shadow-lg hover:shadow-cyan-500/50 border border-cyan-400/30"
|
||
>
|
||
{selectedPostes.size === allPostes.length ? 'Tout désélectionner' : 'Tout sélectionner'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Barre de recherche */}
|
||
<div className="mb-6">
|
||
<div className="relative">
|
||
<input
|
||
type="text"
|
||
placeholder="🔍 Rechercher un poste..."
|
||
value={searchPoste}
|
||
onChange={(e) => setSearchPoste(e.target.value)}
|
||
className="w-full px-5 py-3 glass rounded-xl focus:ring-2 focus:ring-cyan-500 focus:border-cyan-400/50 text-white placeholder-gray-400 border border-cyan-500/20 transition-all duration-300"
|
||
/>
|
||
{searchPoste && (
|
||
<button
|
||
onClick={() => setSearchPoste('')}
|
||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-white transition-colors"
|
||
>
|
||
✕
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Badges de filtres */}
|
||
<div className="max-h-64 overflow-y-auto p-3 mb-6 custom-scrollbar">
|
||
<div className="flex flex-wrap gap-3">
|
||
{filteredPostes.length === 0 ? (
|
||
<div className="w-full text-center py-8">
|
||
<p className="text-gray-400 text-sm">Aucun poste trouvé</p>
|
||
</div>
|
||
) : (
|
||
filteredPostes.map((poste) => {
|
||
const isSelected = selectedPostes.has(poste);
|
||
const count = posteCounts[poste] || 0;
|
||
return (
|
||
<button
|
||
key={poste}
|
||
onClick={() => handlePosteToggle(poste)}
|
||
className={`
|
||
px-5 py-2.5 rounded-xl text-sm font-semibold transition-all duration-300
|
||
flex items-center gap-2 border-2 relative overflow-hidden group
|
||
${isSelected
|
||
? 'bg-gradient-to-r from-cyan-600 to-purple-600 text-white border-cyan-400 shadow-lg glow-cyan transform scale-105'
|
||
: 'glass text-gray-300 border-cyan-500/20 hover:border-cyan-400/50 hover:bg-cyan-500/10 hover:text-white'
|
||
}
|
||
`}
|
||
>
|
||
{isSelected && (
|
||
<div className="absolute inset-0 bg-gradient-to-r from-cyan-400/20 to-purple-400/20 animate-pulse"></div>
|
||
)}
|
||
<span className="relative z-10">{poste}</span>
|
||
<span className={`
|
||
px-2.5 py-1 rounded-lg text-xs font-bold relative z-10
|
||
${isSelected
|
||
? 'bg-white/20 text-white backdrop-blur-sm'
|
||
: 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/30'
|
||
}
|
||
`}>
|
||
{count}
|
||
</span>
|
||
</button>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-col sm:flex-row gap-6 items-end">
|
||
<div className="flex-1">
|
||
<label htmlFor="count" className="block text-sm font-semibold text-cyan-300 mb-3">
|
||
Nombre de personnes à extraire
|
||
</label>
|
||
<input
|
||
id="count"
|
||
type="number"
|
||
min="1"
|
||
max={filteredPeople.length}
|
||
value={selectedCount}
|
||
onChange={(e) => setSelectedCount(parseInt(e.target.value) || 1)}
|
||
className="w-full px-5 py-3 glass rounded-xl focus:ring-2 focus:ring-cyan-500 focus:border-cyan-400/50 text-white bg-white/5 border border-cyan-500/20 transition-all duration-300"
|
||
/>
|
||
<p className="text-sm text-gray-400 mt-2 font-medium">
|
||
{selectedPostes.size === 0 ? (
|
||
<>📊 Total disponible: <span className="text-cyan-400 font-bold">{people.length}</span> personnes</>
|
||
) : (
|
||
<>🎯 Total filtré: <span className="text-cyan-400 font-bold">{filteredPeople.length}</span> personne{filteredPeople.length > 1 ? 's' : ''} <span className="text-gray-500">(sur {people.length})</span></>
|
||
)}
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={handleRandomize}
|
||
disabled={filteredPeople.length === 0}
|
||
className="px-8 py-4 bg-gradient-to-r from-cyan-600 via-purple-600 to-pink-600 text-white font-bold rounded-xl hover:from-cyan-500 hover:via-purple-500 hover:to-pink-500 focus:outline-none transition-all duration-300 shadow-lg hover:shadow-cyan-500/50 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:from-cyan-600 disabled:hover:via-purple-600 disabled:hover:to-pink-600 border border-cyan-400/30 relative overflow-hidden group"
|
||
>
|
||
<span className="relative z-10 flex items-center gap-2">
|
||
<span>🎲</span>
|
||
<span>Extraire aléatoirement</span>
|
||
</span>
|
||
<div className="absolute inset-0 bg-gradient-to-r from-cyan-400/20 to-purple-400/20 opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{showResults && randomPeople.length > 0 && (
|
||
<div className="glass-strong rounded-2xl p-8 mb-8 shadow-2xl border border-purple-500/20">
|
||
<div className="flex items-center gap-3 mb-6">
|
||
<div className="w-1 h-8 bg-gradient-to-b from-cyan-400 to-purple-400 rounded-full"></div>
|
||
<h2 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-purple-400">
|
||
Résultats sélectionnés
|
||
</h2>
|
||
<span className="px-3 py-1 glass rounded-lg text-cyan-300 font-semibold border border-cyan-500/30">
|
||
{randomPeople.length} personne{randomPeople.length > 1 ? 's' : ''}
|
||
</span>
|
||
</div>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
{randomPeople.map((person, index) => (
|
||
<div
|
||
key={`${person.nom}-${index}`}
|
||
className="glass rounded-xl p-5 hover:bg-white/10 border border-cyan-500/20 hover:border-cyan-400/50 transition-all duration-300 hover:shadow-lg hover:shadow-cyan-500/20 group"
|
||
style={{ animationDelay: `${index * 50}ms` }}
|
||
>
|
||
<h3 className="font-bold text-lg text-white mb-2 group-hover:text-cyan-300 transition-colors">
|
||
{person.nom}
|
||
</h3>
|
||
{person.description && (
|
||
<p className="text-sm text-gray-300 mb-3">{person.description}</p>
|
||
)}
|
||
<span className="inline-block px-3 py-1 text-xs rounded-lg bg-gradient-to-r from-cyan-600/30 to-purple-600/30 text-cyan-300 border border-cyan-500/30 font-semibold">
|
||
{person.type}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="glass-strong rounded-2xl p-8 shadow-2xl border border-purple-500/20">
|
||
<div className="flex items-center gap-3 mb-6">
|
||
<div className="w-1 h-8 bg-gradient-to-b from-purple-400 to-pink-400 rounded-full"></div>
|
||
<h2 className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-purple-400 to-pink-400">
|
||
{selectedPostes.size === 0
|
||
? `Toutes les personnes`
|
||
: `Personnes filtrées`
|
||
}
|
||
</h2>
|
||
<span className="px-3 py-1 glass rounded-lg text-purple-300 font-semibold border border-purple-500/30">
|
||
{filteredPeople.length} / {people.length}
|
||
</span>
|
||
</div>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 max-h-96 overflow-y-auto custom-scrollbar p-2">
|
||
{filteredPeople.map((person, index) => (
|
||
<div
|
||
key={`${person.nom}-${index}`}
|
||
className={`glass rounded-xl p-4 hover:bg-white/10 border transition-all duration-300 hover:shadow-lg ${
|
||
selectedPostes.size > 0 && selectedPostes.has(person.description)
|
||
? 'border-cyan-400/50 bg-cyan-500/10 hover:border-cyan-400 hover:shadow-cyan-500/20'
|
||
: 'border-purple-500/20 hover:border-purple-400/50'
|
||
}`}
|
||
>
|
||
<h3 className="font-semibold text-white mb-1">{person.nom}</h3>
|
||
{person.description && (
|
||
<p className="text-sm text-gray-400 mt-1">{person.description}</p>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|