314a98c1ad
- Détection des profils incomplets (société/campus/service/date d'entrée/validateur) avec alerte Dashboard et section dédiée dans Équipe - Formulaire d'édition des collaborateurs (date d'entrée, rôle, validateur N+1, société, campus, service, type de contrat) avec combobox de recherche pour le validateur - Correction du mapping poste (colonne description au lieu de fonction) et du filtre des validateurs (variantes de rôle: Validateur/Validatrice, Directeur/Directrice, President) - Fix du proxy Vite vers le backend Docker Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
748 lines
44 KiB
TypeScript
748 lines
44 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
||
import {
|
||
Calendar, Lock, Unlock, Download,
|
||
ChevronLeft, ChevronRight, RefreshCw,
|
||
Search, Users, CheckCircle, XCircle, Clock, ArrowLeft,
|
||
TrendingUp, Building2, MapPin, Briefcase, FileText, Edit3
|
||
} from 'lucide-react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { toast } from 'sonner';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||
|
||
// ─── Composant ligne éditable ───────────────────────────────────────────────
|
||
const JourEditRow = ({ jour, collaborateurId, token, onSaved }: any) => {
|
||
const [travaille, setTravaille] = useState<boolean>(!!jour.JourTravaille);
|
||
const [reposQ, setReposQ] = useState<boolean>(!!jour.ReposQuotidienRespect);
|
||
const [reposH, setReposH] = useState<boolean>(!!jour.ReposHebdomadaireRespect);
|
||
const [commentaire, setCommentaire] = useState<string>(jour.CommentaireRepos || '');
|
||
const [saving, setSaving] = useState(false);
|
||
const [saved, setSaved] = useState(false);
|
||
const [dirty, setDirty] = useState(false);
|
||
|
||
const dateObj = new Date(jour.JourDate);
|
||
const isWeekend = dateObj.getDay() === 0 || dateObj.getDay() === 6;
|
||
const dateStr = dateObj.toLocaleDateString('fr-FR', {
|
||
weekday: 'short', day: '2-digit', month: '2-digit'
|
||
});
|
||
const nonSaisi = !jour.DateSaisie; // jour jamais rempli par le collaborateur
|
||
|
||
const mark = (fn: () => void) => { fn(); setDirty(true); setSaved(false); };
|
||
|
||
const handleSave = async () => {
|
||
setSaving(true);
|
||
try {
|
||
const res = await fetch('/api/compte-rendu/jour', {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${token}`
|
||
},
|
||
// ✅ CORRECTION : noms de champs alignés avec le backend
|
||
body: JSON.stringify({
|
||
collaborateurid: collaborateurId,
|
||
jourdate: jour.JourDate,
|
||
jourtravaille: travaille,
|
||
reposquotidien: reposQ,
|
||
reposhebdo: reposH,
|
||
commentaire
|
||
})
|
||
});
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
setSaved(true);
|
||
setDirty(false);
|
||
setTimeout(() => setSaved(false), 2000);
|
||
onSaved();
|
||
toast.success('Jour mis à jour');
|
||
} else {
|
||
toast.error(data.error || 'Erreur serveur');
|
||
}
|
||
} catch {
|
||
toast.error('Erreur serveur');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<tr className={`
|
||
border-b transition-colors text-sm
|
||
${isWeekend ? 'opacity-50' : ''}
|
||
${dirty ? 'bg-yellow-50 dark:bg-yellow-950' : ''}
|
||
${nonSaisi && !dirty ? 'bg-gray-50 dark:bg-gray-900' : ''}
|
||
`}>
|
||
{/* Date */}
|
||
<td className="py-2 px-3 font-medium whitespace-nowrap text-foreground">
|
||
{dateStr}
|
||
{isWeekend && <span className="ml-1 text-xs text-muted-foreground">(WE)</span>}
|
||
{/* ✅ Badge "Non saisi" pour les jours vides */}
|
||
{nonSaisi && (
|
||
<span className="ml-2 text-xs bg-orange-100 text-orange-600 px-1.5 py-0.5 rounded">
|
||
Non saisi
|
||
</span>
|
||
)}
|
||
</td>
|
||
|
||
{/* Jour travaillé */}
|
||
<td className="py-2 px-3 text-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={travaille}
|
||
onChange={e => mark(() => {
|
||
const v = e.target.checked;
|
||
setTravaille(v);
|
||
if (!v) { setReposQ(false); setReposH(false); }
|
||
})}
|
||
className="w-4 h-4 accent-blue-600 cursor-pointer"
|
||
/>
|
||
</td>
|
||
|
||
{/* Repos quotidien */}
|
||
<td className="py-2 px-3 text-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={reposQ}
|
||
onChange={e => mark(() => setReposQ(e.target.checked))}
|
||
disabled={!travaille}
|
||
className="w-4 h-4 accent-green-600 cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
|
||
/>
|
||
</td>
|
||
|
||
{/* Repos hebdo */}
|
||
<td className="py-2 px-3 text-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={reposH}
|
||
onChange={e => mark(() => setReposH(e.target.checked))}
|
||
disabled={!travaille}
|
||
className="w-4 h-4 accent-green-600 cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
|
||
/>
|
||
</td>
|
||
|
||
{/* Commentaire */}
|
||
<td className="py-2 px-3">
|
||
<input
|
||
type="text"
|
||
value={commentaire}
|
||
onChange={e => mark(() => setCommentaire(e.target.value))}
|
||
placeholder="Commentaire..."
|
||
className="w-full text-xs border border-border rounded px-2 py-1 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-400"
|
||
/>
|
||
</td>
|
||
|
||
{/* Bouton sauvegarde */}
|
||
<td className="py-2 px-3 text-center">
|
||
{saved ? (
|
||
<CheckCircle className="w-5 h-5 text-green-500 mx-auto" />
|
||
) : (
|
||
<button
|
||
onClick={handleSave}
|
||
disabled={saving || !dirty}
|
||
className="px-3 py-1 rounded text-xs font-medium bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||
>
|
||
{saving ? '…' : 'Sauver'}
|
||
</button>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
);
|
||
};
|
||
|
||
// ─── Génère tous les jours d'un mois (y compris non saisis) ─────────────────
|
||
const genererJoursDuMois = (annee: number, mois: number, joursExistants: any[]) => {
|
||
const jours: any[] = [];
|
||
const nbJours = new Date(annee, mois, 0).getDate();
|
||
|
||
for (let d = 1; d <= nbJours; d++) {
|
||
const dateStr = `${annee}-${String(mois).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||
const existing = joursExistants.find(j => j.JourDate?.substring(0, 10) === dateStr);
|
||
if (existing) {
|
||
jours.push(existing);
|
||
} else {
|
||
// Jour non saisi → ligne vide modifiable par les RH
|
||
jours.push({
|
||
JourDate: dateStr,
|
||
JourTravaille: false,
|
||
ReposQuotidienRespect: false,
|
||
ReposHebdomadaireRespect: false,
|
||
CommentaireRepos: '',
|
||
DateSaisie: null, // null = non saisi
|
||
DateModification: null
|
||
});
|
||
}
|
||
}
|
||
return jours;
|
||
};
|
||
|
||
// ─── Composant principal ────────────────────────────────────────────────────
|
||
const CompteRenduRH = () => {
|
||
const navigate = useNavigate();
|
||
const [user, setUser] = useState<any>(null);
|
||
const [token, setToken] = useState<string>('');
|
||
|
||
const [currentDate, setCurrentDate] = useState(new Date());
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [collaborateurs, setCollaborateurs] = useState<any[]>([]);
|
||
const [allCollaborateurs, setAllCollaborateurs] = useState<any[]>([]);
|
||
const [selectedCollab, setSelectedCollab] = useState<any>(null);
|
||
const [compteRenduData, setCompteRenduData] = useState<any[]>([]);
|
||
const [mensuelData, setMensuelData] = useState<any>(null);
|
||
const [statsGlobales, setStatsGlobales] = useState<any>(null);
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
const [showDetailModal, setShowDetailModal] = useState(false);
|
||
const [selectedJour, setSelectedJour] = useState<any>(null);
|
||
const [cumulAnnuel, setCumulAnnuel] = useState<any>(null);
|
||
|
||
// Filtres
|
||
const [filterSociete, setFilterSociete] = useState<string>('all');
|
||
const [filterCampus, setFilterCampus] = useState<string>('all');
|
||
const [filterService, setFilterService] = useState<string>('all');
|
||
|
||
const annee = currentDate.getFullYear();
|
||
const mois = currentDate.getMonth() + 1;
|
||
|
||
// ── Auth
|
||
useEffect(() => {
|
||
const userData = localStorage.getItem('user');
|
||
const userToken = localStorage.getItem('token');
|
||
if (!userData || !userToken) { toast.error('Session expirée'); navigate('/'); return; }
|
||
const parsedUser = JSON.parse(userData);
|
||
if (!['RH', 'Admin'].includes(parsedUser?.role)) {
|
||
toast.error('Accès réservé aux RH et Administrateurs');
|
||
navigate('/dashboard');
|
||
return;
|
||
}
|
||
setUser(parsedUser);
|
||
setToken(userToken);
|
||
}, [navigate]);
|
||
|
||
// ── Chargement collaborateurs
|
||
useEffect(() => {
|
||
if (!token) return;
|
||
const load = async () => {
|
||
setIsLoading(true);
|
||
try {
|
||
const res = await fetch('/api/collaborateurs/details', {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
});
|
||
if (!res.ok) throw new Error(`Erreur ${res.status}`);
|
||
const data = await res.json();
|
||
const forfaitJour = data.filter((emp: any) => {
|
||
const contrat = (emp.typeContrat || emp.TypeContrat || '').toLowerCase();
|
||
const isActif = emp.Actif === 1 || emp.Actif === true || emp.Actif === null;
|
||
return ['forfait_jour', 'forfaitjour', 'forfait jour', 'forfait-jour'].includes(contrat) && isActif;
|
||
}).map((e: any) => ({ ...e, nomComplet: `${e.prenom} ${e.nom}`.trim() }))
|
||
.sort((a: any, b: any) => a.nomComplet.localeCompare(b.nomComplet));
|
||
setAllCollaborateurs(forfaitJour);
|
||
setCollaborateurs(forfaitJour);
|
||
} catch (err: any) {
|
||
toast.error('Erreur chargement collaborateurs: ' + err.message);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
load();
|
||
}, [token]);
|
||
|
||
// ── Listes uniques pour filtres
|
||
const societesUniques = [...new Set(allCollaborateurs.map(e => e.societe).filter(Boolean))].sort();
|
||
const campusUniques = [...new Set(allCollaborateurs.map(e => e.campus).filter(Boolean))].sort();
|
||
const servicesUniques = [...new Set(allCollaborateurs.map(e => e.service).filter(Boolean))].sort();
|
||
|
||
// ── Filtrage
|
||
useEffect(() => {
|
||
let f = [...allCollaborateurs];
|
||
if (searchTerm) f = f.filter(e =>
|
||
e.nomComplet?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
e.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
e.service?.toLowerCase().includes(searchTerm.toLowerCase())
|
||
);
|
||
if (filterSociete !== 'all') f = f.filter(e => e.societe === filterSociete);
|
||
if (filterCampus !== 'all') f = f.filter(e => e.campus === filterCampus);
|
||
if (filterService !== 'all') f = f.filter(e => e.service === filterService);
|
||
setCollaborateurs(f);
|
||
if (selectedCollab && !f.find(c => c.id === selectedCollab.id)) setSelectedCollab(null);
|
||
}, [allCollaborateurs, searchTerm, filterSociete, filterCampus, filterService]);
|
||
|
||
// ── Stats globales
|
||
useEffect(() => {
|
||
if (!token || collaborateurs.length === 0) return;
|
||
fetch(`/api/compte-rendu/stats?annee=${annee}&mois=${mois}`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
}).then(r => r.ok ? r.json() : null)
|
||
.then(data => setStatsGlobales(data || {
|
||
totalCollaborateurs: collaborateurs.length,
|
||
mensuelsValides: 0,
|
||
mensuelsEnAttente: collaborateurs.length
|
||
}));
|
||
}, [token, collaborateurs.length, annee, mois]);
|
||
|
||
// ── Chargement compte-rendu
|
||
const loadCompteRendu = async () => {
|
||
if (!selectedCollab || !token) return;
|
||
setIsLoading(true);
|
||
try {
|
||
// ✅ CORRECTION : paramètre "userid" (sans underscore)
|
||
const res = await fetch(
|
||
`/api/compte-rendu-activites?userid=${selectedCollab.id}&annee=${annee}&mois=${mois}`,
|
||
{ headers: { 'Authorization': `Bearer ${token}` } }
|
||
);
|
||
if (!res.ok) throw new Error(`Erreur ${res.status}`);
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
// ✅ NOUVEAU : on génère TOUS les jours du mois, y compris non saisis
|
||
const tousLesJours = genererJoursDuMois(annee, mois, data.jours || []);
|
||
setCompteRenduData(tousLesJours);
|
||
setMensuelData(data.mensuel);
|
||
}
|
||
} catch (err: any) {
|
||
console.error(err);
|
||
setCompteRenduData(genererJoursDuMois(annee, mois, []));
|
||
setMensuelData(null);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => { loadCompteRendu(); }, [selectedCollab, annee, mois, token]);
|
||
|
||
// ── Cumul annuel
|
||
useEffect(() => {
|
||
if (!selectedCollab || !token) return;
|
||
fetch(`/api/compte-rendu/cumul-annuel?userid=${selectedCollab.id}&annee=${annee}`, {
|
||
headers: { 'Authorization': `Bearer ${token}` }
|
||
}).then(r => r.ok ? r.json() : null)
|
||
.then(data => setCumulAnnuel(data?.success ? data.cumul : null));
|
||
}, [selectedCollab, annee, token]);
|
||
|
||
// ── Verrouiller / Déverrouiller
|
||
const handleToggleLock = async (unlock = false) => {
|
||
if (!selectedCollab || !token) return;
|
||
const monthNames = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
|
||
if (!confirm(`Confirmer ${unlock ? 'déverrouiller' : 'verrouiller'} ${monthNames[mois - 1]} ${annee} pour ${selectedCollab.nomComplet} ?`)) return;
|
||
try {
|
||
const res = await fetch(unlock ? '/api/compte-rendu/deverrouiller' : '/api/compte-rendu/verrouiller', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
||
// ✅ CORRECTION : collaborateur_id → collaborateurid
|
||
body: JSON.stringify({ collaborateurid: selectedCollab.id, annee, mois })
|
||
});
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
toast.success(`Mois ${unlock ? 'déverrouillé' : 'verrouillé'} avec succès`);
|
||
await loadCompteRendu();
|
||
} else {
|
||
toast.error(data.message || 'Erreur');
|
||
}
|
||
} catch { toast.error('Erreur serveur'); }
|
||
};
|
||
|
||
// ── Export PDF
|
||
const handleExportPDF = async () => {
|
||
if (!selectedCollab || !token) { toast.error('Aucun collaborateur sélectionné'); return; }
|
||
const loadingToast = toast.loading('Génération du PDF...');
|
||
try {
|
||
const res = await fetch(
|
||
`/api/export-compte-rendu-pdf?user_id=${selectedCollab.id}&annee=${annee}&mois=${mois}`,
|
||
{ headers: { 'Authorization': `Bearer ${token}` } }
|
||
);
|
||
if (!res.ok) throw new Error(`Erreur ${res.status}`);
|
||
const blob = await res.blob();
|
||
if (blob.size === 0) throw new Error('PDF vide');
|
||
const url = window.URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url; a.download = `compte-rendu-${selectedCollab.nom}-${annee}-${String(mois).padStart(2, '0')}.pdf`;
|
||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||
window.URL.revokeObjectURL(url);
|
||
toast.dismiss(loadingToast);
|
||
toast.success('PDF téléchargé');
|
||
} catch (err: any) {
|
||
toast.dismiss(loadingToast);
|
||
toast.error(err.message || 'Erreur export PDF');
|
||
}
|
||
};
|
||
|
||
const navigateMonth = (dir: string) => setCurrentDate(prev => {
|
||
const d = new Date(prev);
|
||
d.setMonth(prev.getMonth() + (dir === 'prev' ? -1 : 1));
|
||
return d;
|
||
});
|
||
|
||
const isVerrouille = () => mensuelData?.Verrouille === true || mensuelData?.Verrouille === 1;
|
||
const hasActiveFilters = filterSociete !== 'all' || filterCampus !== 'all' || filterService !== 'all';
|
||
const monthNames = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
|
||
const nbNonSaisis = compteRenduData.filter(j => !j.DateSaisie).length;
|
||
|
||
if (isLoading && allCollaborateurs.length === 0) return (
|
||
<div className="min-h-screen bg-gradient-subtle flex items-center justify-center">
|
||
<div className="text-center">
|
||
<RefreshCw className="w-12 h-12 animate-spin text-cyan-600 mx-auto mb-4" />
|
||
<p className="text-gray-600">Chargement...</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gradient-subtle">
|
||
{/* Header */}
|
||
<header className="bg-card border-b border-border shadow-card">
|
||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||
<div className="flex items-center space-x-3">
|
||
<Button variant="ghost" size="sm" onClick={() => navigate('/dashboard')}>
|
||
<ArrowLeft className="w-4 h-4 mr-2" /> Retour
|
||
</Button>
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-foreground">
|
||
Gestion des Comptes-Rendus d'Activités
|
||
</h1>
|
||
<p className="text-sm text-muted-foreground">
|
||
Suivi et validation des forfaits jour · {allCollaborateurs.length} collaborateurs
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||
|
||
{/* Filtres */}
|
||
<Card className="shadow-card border-0 mb-6">
|
||
<CardContent className="p-6">
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||
<div className="relative">
|
||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||
<Input placeholder="Rechercher..." value={searchTerm}
|
||
onChange={e => setSearchTerm(e.target.value)} className="pl-10" />
|
||
</div>
|
||
<Select value={filterSociete} onValueChange={setFilterSociete}>
|
||
<SelectTrigger><SelectValue placeholder="Toutes les sociétés" /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">Toutes les sociétés</SelectItem>
|
||
{societesUniques.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
<Select value={filterCampus} onValueChange={setFilterCampus}>
|
||
<SelectTrigger><SelectValue placeholder="Tous les campus" /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">Tous les campus</SelectItem>
|
||
{campusUniques.map(c => <SelectItem key={c} value={c}>{c}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
<Select value={filterService} onValueChange={setFilterService}>
|
||
<SelectTrigger><SelectValue placeholder="Tous les services" /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">Tous les services</SelectItem>
|
||
{servicesUniques.map(s => <SelectItem key={s} value={s}>{s}</SelectItem>)}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
{hasActiveFilters && (
|
||
<div className="mt-3 flex items-center gap-2">
|
||
<span className="text-xs text-muted-foreground">Filtres actifs :</span>
|
||
{filterSociete !== 'all' && <span className="px-2 py-0.5 bg-blue-100 text-blue-700 text-xs rounded-full flex items-center gap-1"><Building2 className="w-3 h-3" />{filterSociete}<button onClick={() => setFilterSociete('all')} className="ml-1">×</button></span>}
|
||
{filterCampus !== 'all' && <span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs rounded-full flex items-center gap-1"><MapPin className="w-3 h-3" />{filterCampus}<button onClick={() => setFilterCampus('all')} className="ml-1">×</button></span>}
|
||
{filterService !== 'all' && <span className="px-2 py-0.5 bg-purple-100 text-purple-700 text-xs rounded-full flex items-center gap-1"><Briefcase className="w-3 h-3" />{filterService}<button onClick={() => setFilterService('all')} className="ml-1">×</button></span>}
|
||
<button onClick={() => { setFilterSociete('all'); setFilterCampus('all'); setFilterService('all'); }} className="text-xs text-muted-foreground underline">Réinitialiser</button>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Stats globales */}
|
||
{statsGlobales && (
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||
<Card className="shadow-card"><CardContent className="p-6">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-3 bg-blue-100 rounded-lg"><Users className="w-6 h-6 text-blue-600" /></div>
|
||
<div><p className="text-sm text-muted-foreground">Forfaits jour</p><p className="text-2xl font-bold">{collaborateurs.length}</p></div>
|
||
</div>
|
||
</CardContent></Card>
|
||
<Card className="shadow-card"><CardContent className="p-6">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-3 bg-green-100 rounded-lg"><CheckCircle className="w-6 h-6 text-green-600" /></div>
|
||
<div><p className="text-sm text-muted-foreground">Mois validés</p><p className="text-2xl font-bold">{statsGlobales.mensuelsValides}</p></div>
|
||
</div>
|
||
</CardContent></Card>
|
||
<Card className="shadow-card"><CardContent className="p-6">
|
||
<div className="flex items-center gap-3">
|
||
<div className="p-3 bg-orange-100 rounded-lg"><Clock className="w-6 h-6 text-orange-600" /></div>
|
||
<div><p className="text-sm text-muted-foreground">En attente</p><p className="text-2xl font-bold">{statsGlobales.mensuelsEnAttente}</p></div>
|
||
</div>
|
||
</CardContent></Card>
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||
{/* Liste collaborateurs */}
|
||
<div className="lg:col-span-1">
|
||
<Card className="shadow-card">
|
||
<CardHeader className="bg-gradient-to-r from-cyan-500 to-blue-500 text-white rounded-t-lg">
|
||
<h2 className="text-lg font-bold flex items-center gap-2">
|
||
<Users className="w-5 h-5" />
|
||
Collaborateurs ({collaborateurs.length})
|
||
{hasActiveFilters && <span className="text-xs bg-white/20 px-2 py-1 rounded">Filtré</span>}
|
||
</h2>
|
||
</CardHeader>
|
||
<div className="overflow-y-auto max-h-[600px]">
|
||
{collaborateurs.length === 0 ? (
|
||
<p className="p-4 text-center text-muted-foreground text-sm">
|
||
{searchTerm || hasActiveFilters ? 'Aucun résultat' : 'Aucun collaborateur en forfait jour'}
|
||
</p>
|
||
) : collaborateurs.map(collab => (
|
||
<div key={collab.id} onClick={() => setSelectedCollab(collab)}
|
||
className={`p-4 border-b cursor-pointer hover:bg-gray-50 transition-all
|
||
${selectedCollab?.id === collab.id ? 'bg-blue-50 border-l-4 border-blue-500' : ''}`}>
|
||
<p className="font-semibold text-foreground">{collab.nomComplet}</p>
|
||
<div className="flex flex-wrap gap-1 mt-1">
|
||
{collab.societe && <span className="text-xs text-blue-600 bg-blue-50 px-1.5 py-0.5 rounded">{collab.societe}</span>}
|
||
{collab.campus && <span className="text-xs text-green-600 bg-green-50 px-1.5 py-0.5 rounded">{collab.campus}</span>}
|
||
</div>
|
||
<p className="text-xs text-muted-foreground mt-1">{collab.service || 'Service non défini'}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* Détails */}
|
||
<div className="lg:col-span-2">
|
||
{!selectedCollab ? (
|
||
<Card className="shadow-card p-8 text-center">
|
||
<Users className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||
<p className="text-muted-foreground">Sélectionnez un collaborateur pour voir ses comptes-rendus</p>
|
||
</Card>
|
||
) : (
|
||
<div className="space-y-6">
|
||
{/* En-tête + navigation */}
|
||
<Card className="shadow-card">
|
||
<CardContent className="p-6">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<div>
|
||
<h2 className="text-xl font-bold">{selectedCollab.nomComplet}</h2>
|
||
<div className="flex flex-wrap gap-2 mt-1">
|
||
{selectedCollab.societe && <span className="text-xs text-blue-600 bg-blue-50 px-2 py-1 rounded">{selectedCollab.societe}</span>}
|
||
{selectedCollab.campus && <span className="text-xs text-green-600 bg-green-50 px-2 py-1 rounded">{selectedCollab.campus}</span>}
|
||
{selectedCollab.service && <span className="text-xs text-purple-600 bg-purple-50 px-2 py-1 rounded">{selectedCollab.service}</span>}
|
||
</div>
|
||
</div>
|
||
{isVerrouille() ? (
|
||
<span className="flex items-center gap-2 px-3 py-1.5 bg-red-100 text-red-700 rounded-lg text-sm font-medium">
|
||
<Lock className="w-4 h-4" /> Verrouillé
|
||
</span>
|
||
) : (
|
||
<span className="flex items-center gap-2 px-3 py-1.5 bg-green-100 text-green-700 rounded-lg text-sm font-medium">
|
||
<Unlock className="w-4 h-4" /> Ouvert
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Navigation mois */}
|
||
<div className="flex items-center justify-between mb-4">
|
||
<button onClick={() => navigateMonth('prev')} className="p-2 hover:bg-gray-100 rounded">
|
||
<ChevronLeft className="w-5 h-5 text-blue-600" />
|
||
</button>
|
||
<h3 className="text-lg font-semibold">{monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}</h3>
|
||
<button onClick={() => navigateMonth('next')} className="p-2 hover:bg-gray-100 rounded">
|
||
<ChevronRight className="w-5 h-5 text-blue-600" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Actions */}
|
||
<div className="flex gap-3">
|
||
{isVerrouille() ? (
|
||
<Button onClick={() => handleToggleLock(true)} className="flex-1 bg-orange-600 hover:bg-orange-700">
|
||
<Unlock className="w-4 h-4 mr-2" /> Déverrouiller
|
||
</Button>
|
||
) : (
|
||
<Button onClick={() => handleToggleLock(false)} className="flex-1 bg-green-600 hover:bg-green-700">
|
||
<Lock className="w-4 h-4 mr-2" /> Verrouiller
|
||
</Button>
|
||
)}
|
||
<Button onClick={handleExportPDF} className="flex-1 bg-purple-600 hover:bg-purple-700">
|
||
<Download className="w-4 h-4 mr-2" /> Export PDF
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Cumul annuel */}
|
||
{cumulAnnuel && (
|
||
<Card className="shadow-card bg-gradient-to-br from-purple-50 to-blue-50 border-purple-200">
|
||
<CardContent className="p-6">
|
||
<div className="flex items-center gap-3 mb-4">
|
||
<div className="p-3 bg-purple-100 rounded-lg"><TrendingUp className="w-6 h-6 text-purple-600" /></div>
|
||
<div>
|
||
<h3 className="text-lg font-bold text-purple-900">Cumul Annuel {annee}</h3>
|
||
<p className="text-sm text-purple-700">Synthèse des activités sur l'année</p>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="bg-white rounded-lg p-4 border border-purple-200">
|
||
<p className="text-sm text-gray-600 mb-1">Total jours travaillés</p>
|
||
<p className="text-3xl font-bold text-purple-600">{cumulAnnuel.totalJoursTravailles || 0}</p>
|
||
<p className="text-xs text-gray-500 mt-1">jours</p>
|
||
</div>
|
||
<div className="bg-white rounded-lg p-4 border border-purple-200">
|
||
<p className="text-sm text-gray-600 mb-1">Mois validés</p>
|
||
<p className="text-3xl font-bold text-blue-600">{cumulAnnuel.moisValides || 0}</p>
|
||
<p className="text-xs text-gray-500 mt-1">/ 12 mois</p>
|
||
</div>
|
||
<div className="bg-white rounded-lg p-4 border border-orange-200">
|
||
<p className="text-sm text-gray-600 mb-1">Repos quotidien non respect.</p>
|
||
<p className="text-2xl font-bold text-orange-600">{cumulAnnuel.totalReposQuotidien || 0}</p>
|
||
</div>
|
||
<div className="bg-white rounded-lg p-4 border border-red-200">
|
||
<p className="text-sm text-gray-600 mb-1">Repos hebdo non respect.</p>
|
||
<p className="text-2xl font-bold text-red-600">{cumulAnnuel.totalReposHebdo || 0}</p>
|
||
</div>
|
||
</div>
|
||
<div className="mt-4 bg-white rounded-lg p-3 border border-purple-200">
|
||
<div className="flex justify-between mb-2">
|
||
<span className="text-sm font-medium text-gray-700">Progression annuelle</span>
|
||
<span className="text-sm font-bold text-purple-600">{Math.round(((cumulAnnuel.moisValides || 0) / 12) * 100)}%</span>
|
||
</div>
|
||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||
<div className="bg-gradient-to-r from-purple-500 to-blue-500 h-2 rounded-full"
|
||
style={{ width: `${((cumulAnnuel.moisValides || 0) / 12) * 100}%` }} />
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Stats du mois */}
|
||
{mensuelData && (
|
||
<Card className="shadow-card">
|
||
<CardContent className="p-6">
|
||
<h3 className="text-lg font-bold mb-4">Statistiques du mois</h3>
|
||
<div className="grid grid-cols-3 gap-4">
|
||
<div className="text-center">
|
||
<p className="text-2xl font-bold text-blue-600">{mensuelData.NbJoursTravailles || 0}</p>
|
||
<p className="text-sm text-muted-foreground">Jours travaillés</p>
|
||
</div>
|
||
<div className="text-center">
|
||
<p className="text-2xl font-bold text-orange-600">{mensuelData.NbJoursNonRespectsReposQuotidien || 0}</p>
|
||
<p className="text-sm text-muted-foreground">Non-respect repos quotidien</p>
|
||
</div>
|
||
<div className="text-center">
|
||
<p className="text-2xl font-bold text-red-600">{mensuelData.NbJoursNonRespectsReposHebdo || 0}</p>
|
||
<p className="text-sm text-muted-foreground">Non-respect repos hebdo</p>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
{/* Tableau éditable */}
|
||
<Card className="shadow-card">
|
||
<CardContent className="p-6">
|
||
<div className="flex items-center justify-between mb-2">
|
||
<h3 className="text-lg font-bold flex items-center gap-2">
|
||
<Edit3 className="w-5 h-5 text-blue-600" />
|
||
Édition des saisies — accès RH
|
||
</h3>
|
||
{/* ✅ Badge jours non saisis */}
|
||
{nbNonSaisis > 0 && (
|
||
<span className="text-xs bg-orange-100 text-orange-700 px-3 py-1 rounded-full font-medium">
|
||
{nbNonSaisis} jour{nbNonSaisis > 1 ? 's' : ''} non saisi{nbNonSaisis > 1 ? 's' : ''}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<p className="text-xs text-muted-foreground mb-4">
|
||
Les lignes en fond <span className="text-orange-600 font-medium">orange clair</span> sont des jours non saisis par le collaborateur.
|
||
Les lignes en fond <span className="text-yellow-600 font-medium">jaune</span> ont été modifiées.
|
||
</p>
|
||
{isLoading ? (
|
||
<div className="text-center py-8">
|
||
<RefreshCw className="w-8 h-8 animate-spin text-blue-500 mx-auto" />
|
||
</div>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full">
|
||
<thead>
|
||
<tr className="border-b text-xs uppercase text-muted-foreground">
|
||
<th className="text-left py-2 px-3">Date</th>
|
||
<th className="text-center py-2 px-3">Travaillé</th>
|
||
<th className="text-center py-2 px-3">Repos quotidien</th>
|
||
<th className="text-center py-2 px-3">Repos hebdo</th>
|
||
<th className="text-left py-2 px-3">Commentaire</th>
|
||
<th className="text-center py-2 px-3">Action</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{compteRenduData.map((jour: any) => (
|
||
<JourEditRow
|
||
key={jour.JourDate}
|
||
jour={jour}
|
||
collaborateurId={selectedCollab.id}
|
||
token={token}
|
||
onSaved={loadCompteRendu}
|
||
/>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
{/* Modal détail jour */}
|
||
{showDetailModal && selectedJour && (
|
||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||
<Card className="w-full max-w-md">
|
||
<CardHeader className="bg-gradient-to-r from-cyan-500 to-blue-500 text-white rounded-t-lg">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="font-bold">Détail du {new Date(selectedJour.JourDate).toLocaleDateString('fr-FR')}</h3>
|
||
<button onClick={() => setShowDetailModal(false)} className="text-white hover:bg-white/20 p-1 rounded">
|
||
<XCircle className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="p-6 space-y-4">
|
||
<div>
|
||
<p className="text-sm text-muted-foreground">Statut</p>
|
||
<p className="font-semibold">{selectedJour.JourTravaille ? 'Jour travaillé' : 'Jour non travaillé'}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-sm text-muted-foreground">Repos quotidien respecté</p>
|
||
<p className="font-semibold">{selectedJour.ReposQuotidienRespect ? 'Oui' : 'Non'}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-sm text-muted-foreground">Repos hebdomadaire respecté</p>
|
||
<p className="font-semibold">{selectedJour.ReposHebdomadaireRespect ? 'Oui' : 'Non'}</p>
|
||
</div>
|
||
{selectedJour.CommentaireRepos && (
|
||
<div>
|
||
<p className="text-sm text-muted-foreground">Commentaire</p>
|
||
<p className="text-sm bg-gray-50 p-3 rounded">{selectedJour.CommentaireRepos}</p>
|
||
</div>
|
||
)}
|
||
{selectedJour.DateSaisie && (
|
||
<div>
|
||
<p className="text-sm text-muted-foreground">Date de saisie</p>
|
||
<p className="text-sm">{new Date(selectedJour.DateSaisie).toLocaleDateString('fr-FR')}</p>
|
||
</div>
|
||
)}
|
||
{selectedJour.DateModification && (
|
||
<div>
|
||
<p className="text-sm text-muted-foreground">Dernière modification</p>
|
||
<p className="text-sm">{new Date(selectedJour.DateModification).toLocaleDateString('fr-FR')}</p>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default CompteRenduRH; |