Files
people-randomizr/app/page.tsx

453 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect, 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>
);
}