Modif_GestionDoublonsGTARH_Fonctionnel_V1

This commit is contained in:
2025-12-02 17:57:33 +01:00
parent 721c02759f
commit a7a0e51c7a
96 changed files with 27161 additions and 0 deletions
+638
View File
@@ -0,0 +1,638 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Calendar, ArrowLeft, Save, Plus, Trash2, AlertCircle } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
interface TypeConge {
id: number;
nom: string;
couleur: string;
}
interface LigneConge {
typeId: number;
nombreJours: number;
}
interface Employe {
id: number;
nom: string;
prenom: string;
email: string;
service: string;
soldeCP?: number;
soldeRTT?: number;
}
const SaisieManuelle = () => {
const navigate = useNavigate();
const [employes, setEmployes] = useState<Employe[]>([]);
const [typesConge, setTypesConge] = useState<TypeConge[]>([]);
const [loading, setLoading] = useState(false);
const [employeSelectionne, setEmployeSelectionne] = useState<Employe | null>(null);
const [formData, setFormData] = useState({
employeId: "",
dateDebut: "",
dateFin: "",
commentaire: ""
});
const [lignesConge, setLignesConge] = useState<LigneConge[]>([
{ typeId: 0, nombreJours: 0 }
]);
useEffect(() => {
chargerDonnees();
}, []);
const chargerDonnees = async () => {
try {
const token = localStorage.getItem('token');
// ✅ Charger les employés
const respEmployes = await fetch('/api/employes', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!respEmployes.ok) {
throw new Error(`Erreur HTTP ${respEmployes.status}`);
}
const dataEmployes = await respEmployes.json();
// ✅ LOG DÉBOGAGE
console.log('📊 Employés reçus:', dataEmployes);
console.log(' Type:', Array.isArray(dataEmployes) ? 'Array' : typeof dataEmployes);
console.log(' Longueur:', dataEmployes?.length);
// ✅ VÉRIFICATION : S'assurer que c'est un tableau
if (Array.isArray(dataEmployes)) {
// ✅ Filtrer les entrées invalides
const employesValides = dataEmployes.filter(emp =>
emp &&
emp.id !== undefined &&
emp.nom &&
emp.prenom
);
console.log(' Employés valides:', employesValides.length);
setEmployes(employesValides);
} else {
console.error('❌ dataEmployes n\'est pas un tableau:', dataEmployes);
toast.error("Format de données invalide");
setEmployes([]);
}
// ✅ Charger les types de congé
const respTypes = await fetch('/api/types-conge', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (!respTypes.ok) {
throw new Error(`Erreur HTTP ${respTypes.status}`);
}
const dataTypes = await respTypes.json();
if (Array.isArray(dataTypes)) {
setTypesConge(dataTypes);
} else {
console.error('❌ dataTypes n\'est pas un tableau:', dataTypes);
setTypesConge([]);
}
} catch (error) {
console.error('❌ Erreur chargement données:', error);
toast.error("Erreur lors du chargement des données");
setEmployes([]);
setTypesConge([]);
}
};
const ajouterLigne = () => {
const joursOuvres = calculerJoursOuvres(formData.dateDebut, formData.dateFin);
setLignesConge([...lignesConge, { typeId: 0, nombreJours: joursOuvres }]);
};
const supprimerLigne = (index: number) => {
if (lignesConge.length > 1) {
setLignesConge(lignesConge.filter((_, i) => i !== index));
}
};
const modifierLigne = (index: number, field: keyof LigneConge, value: any) => {
const nouvelles = [...lignesConge];
nouvelles[index] = { ...nouvelles[index], [field]: value };
setLignesConge(nouvelles);
};
// Mettre à jour automatiquement tous les nombres de jours quand les dates changent
const handleDateChange = (field: 'dateDebut' | 'dateFin', value: string) => {
const nouvellesData = { ...formData, [field]: value };
setFormData(nouvellesData);
// Recalculer les jours pour toutes les lignes
if (nouvellesData.dateDebut && nouvellesData.dateFin) {
const joursOuvres = calculerJoursOuvres(nouvellesData.dateDebut, nouvellesData.dateFin);
const nouvellesLignes = lignesConge.map(ligne => ({
...ligne,
nombreJours: ligne.nombreJours === 0 ? joursOuvres : ligne.nombreJours
}));
setLignesConge(nouvellesLignes);
}
};
const calculerTotalJours = () => {
return lignesConge.reduce((total, ligne) => total + (ligne.nombreJours || 0), 0);
};
const calculerJoursOuvres = (debut: string, fin: string) => {
if (!debut || !fin) return 0;
const dateDebut = new Date(debut);
const dateFin = new Date(fin);
let jours = 0;
for (let d = new Date(dateDebut); d <= dateFin; d.setDate(d.getDate() + 1)) {
const jour = d.getDay();
if (jour !== 0 && jour !== 6) {
jours++;
}
}
return jours;
};
const handleEmployeChange = (employeId: string) => {
setFormData({ ...formData, employeId });
const employe = employes.find(e => e.id.toString() === employeId);
setEmployeSelectionne(employe || null);
};
const calculerJoursParType = (typeId: number) => {
return lignesConge
.filter(ligne => ligne.typeId === typeId)
.reduce((total, ligne) => total + (ligne.nombreJours || 0), 0);
};
const obtenirSoldeParType = (typeId: number): number => {
if (!employeSelectionne) return 0;
if (typeId === 1) return employeSelectionne.soldeCP ?? 0;
if (typeId === 2) return employeSelectionne.soldeRTT ?? 0;
return 0;
};
const obtenirNomType = (typeId: number): string => {
const type = typesConge.find(t => t.id === typeId);
return type?.nom || '';
};
const calculerRestantParType = (typeId: number): number => {
const solde = obtenirSoldeParType(typeId);
const saisis = calculerJoursParType(typeId);
return solde - saisis;
};
const verifierDepassement = () => {
if (!employeSelectionne) return false;
const joursCP = calculerJoursParType(1);
const joursRTT = calculerJoursParType(2);
return (joursCP > (employeSelectionne.soldeCP ?? 0)) ||
(joursRTT > (employeSelectionne.soldeRTT ?? 0));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.employeId || !formData.dateDebut || !formData.dateFin) {
toast.error("Veuillez remplir tous les champs obligatoires");
return;
}
// Vérifier que au moins une ligne a des données valides
const lignesValides = lignesConge.filter(l => l.typeId > 0 && l.nombreJours > 0);
if (lignesValides.length === 0) {
toast.error("Veuillez remplir au moins une ligne avec un type de congé et un nombre de jours");
return;
}
if (verifierDepassement()) {
toast.warning("⚠️ Attention: dépassement des compteurs disponibles");
}
setLoading(true);
try {
const token = localStorage.getItem('token');
const response = await fetch('/api/demandes', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
collaborateurId: formData.employeId,
dateDebut: formData.dateDebut,
dateFin: formData.dateFin,
typesConge: lignesValides.map(l => ({
typeId: l.typeId,
nombreJours: l.nombreJours
})),
commentaire: formData.commentaire
})
});
if (response.ok) {
toast.success("Demande enregistrée avec succès");
navigate("/dashboard");
} else {
throw new Error('Erreur lors de l\'enregistrement');
}
} catch (error) {
toast.error("Erreur lors de l'enregistrement");
} finally {
setLoading(false);
}
};
const joursOuvres = calculerJoursOuvres(formData.dateDebut, formData.dateFin);
const totalJours = calculerTotalJours();
const aDepassement = verifierDepassement();
return (
<div className="min-h-screen bg-gradient-subtle">
<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 justify-between">
<div className="flex items-center space-x-4">
<Button
variant="ghost"
size="sm"
onClick={() => navigate("/dashboard")}
className="transition-smooth"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Retour
</Button>
<div className="flex items-center space-x-3">
<div className="w-10 h-10 bg-gradient-primary rounded-xl flex items-center justify-center">
<Calendar className="w-5 h-5 text-primary-foreground" />
</div>
<h1 className="text-2xl font-bold text-foreground">Saisie manuelle</h1>
</div>
</div>
</div>
</div>
</header>
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<Card className="shadow-elegant border-0">
<CardHeader>
<CardTitle>Nouvelle demande de congé</CardTitle>
<CardDescription>
Enregistrer manuellement une demande d'absence ou de récupération
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-4">
<h3 className="text-lg font-semibold">Informations générales</h3>
{/* ✅ SECTION EMPLOYÉ CORRIGÉE */}
<div className="space-y-2">
<Label htmlFor="employe">
Collaborateur <span className="text-destructive">*</span>
</Label>
<Select
value={formData.employeId}
onValueChange={handleEmployeChange}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionner un collaborateur" />
</SelectTrigger>
<SelectContent>
{employes && employes.length > 0 ? (
employes
.filter(emp => emp && emp.id && emp.prenom && emp.nom)
.map(emp => (
<SelectItem
key={emp.id}
value={emp.id.toString()}
>
{emp.prenom} {emp.nom} - {emp.service || 'Sans service'}
</SelectItem>
))
) : (
<div className="p-4 text-center text-sm text-muted-foreground">
{employes.length === 0
? "Chargement des collaborateurs..."
: "Aucun collaborateur disponible"}
</div>
)}
</SelectContent>
</Select>
{/* ✅ Indicateur de chargement */}
{employes.length === 0 && (
<p className="text-xs text-muted-foreground">
Chargement des collaborateurs...
</p>
)}
{employes.length > 0 && (
<p className="text-xs text-muted-foreground">
{employes.length} collaborateur{employes.length > 1 ? 's' : ''} disponible{employes.length > 1 ? 's' : ''}
</p>
)}
</div>
{employeSelectionne && (
<div className="p-4 bg-primary/5 rounded-lg border border-primary/10">
<p className="text-sm font-medium mb-3">Compteurs disponibles</p>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Congés Payés (CP)</p>
<div className="flex items-baseline gap-2">
<p className="text-2xl font-bold text-primary">
{employeSelectionne.soldeCP ?? 0}
</p>
<span className="text-sm text-muted-foreground">jours</span>
</div>
{calculerJoursParType(1) > 0 && (
<p className="text-xs text-muted-foreground">
Saisie en cours: <span className="font-semibold text-warning">{calculerJoursParType(1)}j</span>
{calculerJoursParType(1) > (employeSelectionne.soldeCP ?? 0) && (
<span className="text-destructive ml-1">⚠️ Dépassement!</span>
)}
</p>
)}
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">RTT</p>
<div className="flex items-baseline gap-2">
<p className="text-2xl font-bold text-primary">
{employeSelectionne.soldeRTT ?? 0}
</p>
<span className="text-sm text-muted-foreground">jours</span>
</div>
{calculerJoursParType(2) > 0 && (
<p className="text-xs text-muted-foreground">
Saisie en cours: <span className="font-semibold text-warning">{calculerJoursParType(2)}j</span>
{calculerJoursParType(2) > (employeSelectionne.soldeRTT ?? 0) && (
<span className="text-destructive ml-1">⚠️ Dépassement!</span>
)}
</p>
)}
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="dateDebut">Date de début <span className="text-destructive">*</span></Label>
<Input
id="dateDebut"
type="date"
value={formData.dateDebut}
onChange={(e) => handleDateChange('dateDebut', e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="dateFin">Date de fin <span className="text-destructive">*</span></Label>
<Input
id="dateFin"
type="date"
value={formData.dateFin}
onChange={(e) => handleDateChange('dateFin', e.target.value)}
required
/>
</div>
</div>
{formData.dateDebut && formData.dateFin && joursOuvres > 0 && (
<div className="p-4 bg-primary/5 rounded-lg space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Jours ouvrés de la période</p>
<p className="text-2xl font-bold">{joursOuvres}</p>
</div>
<div className="text-right">
<p className="text-sm text-muted-foreground">Total saisi</p>
<p className="text-2xl font-bold">{totalJours}</p>
</div>
</div>
{employeSelectionne && totalJours > 0 && (
<div className="pt-3 border-t space-y-2">
<p className="text-xs font-medium text-muted-foreground">Répartition par type</p>
{calculerJoursParType(1) > 0 && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">CP:</span>
<div className="flex items-center gap-2">
<Badge variant="outline" className="bg-blue-50 text-blue-700">
{calculerJoursParType(1)}j
</Badge>
<span className="text-xs">
Restants: <span className={calculerRestantParType(1) < 0 ? "font-bold text-destructive" : "font-bold text-success"}>
{calculerRestantParType(1)}j
</span>
</span>
</div>
</div>
)}
{calculerJoursParType(2) > 0 && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">RTT:</span>
<div className="flex items-center gap-2">
<Badge variant="outline" className="bg-purple-50 text-purple-700">
{calculerJoursParType(2)}j
</Badge>
<span className="text-xs">
Restants: <span className={calculerRestantParType(2) < 0 ? "font-bold text-destructive" : "font-bold text-success"}>
{calculerRestantParType(2)}j
</span>
</span>
</div>
</div>
)}
{lignesConge.filter(l => l.typeId > 2 && l.nombreJours > 0).map((ligne, idx) => {
const typeNom = obtenirNomType(ligne.typeId);
return typeNom ? (
<div key={idx} className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{typeNom}:</span>
<Badge variant="outline" className="bg-green-50 text-green-700">
{calculerJoursParType(ligne.typeId)}j
</Badge>
</div>
) : null;
})}
</div>
)}
</div>
)}
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Types de congé</h3>
<Button
type="button"
variant="outline"
size="sm"
onClick={ajouterLigne}
>
<Plus className="w-4 h-4 mr-1" />
Ajouter un type
</Button>
</div>
<div className="space-y-3">
{lignesConge.map((ligne, index) => {
const soldeDisponible = obtenirSoldeParType(ligne.typeId);
const dejaSaisi = calculerJoursParType(ligne.typeId);
const restant = soldeDisponible - dejaSaisi;
const enDepassement = restant < 0;
return (
<div key={index} className="space-y-2">
<div className="flex gap-3 items-end">
<div className="flex-1 space-y-2">
<Label>Type de congé</Label>
<Select
value={ligne.typeId.toString()}
onValueChange={(value) => modifierLigne(index, 'typeId', parseInt(value))}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionner" />
</SelectTrigger>
<SelectContent>
{typesConge.map(type => (
<SelectItem key={type.id} value={type.id.toString()}>
{type.nom}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="w-40 space-y-2">
<Label>
Nombre de jours
{joursOuvres > 0 && (
<span className="text-xs text-muted-foreground ml-1">
(max: {joursOuvres})
</span>
)}
</Label>
<Input
type="number"
step="0.5"
min="0"
max={joursOuvres}
value={ligne.nombreJours || ''}
onChange={(e) => modifierLigne(index, 'nombreJours', parseFloat(e.target.value) || 0)}
placeholder={joursOuvres > 0 ? joursOuvres.toString() : "0"}
className={enDepassement && ligne.nombreJours > 0 ? "border-destructive" : ""}
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => supprimerLigne(index)}
disabled={lignesConge.length === 1}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
{/* Indicateur de solde pour cette ligne */}
{employeSelectionne && ligne.typeId > 0 && ligne.nombreJours > 0 && (
<div className={`text-xs px-3 py-1.5 rounded-md ${enDepassement ? 'bg-destructive/10 text-destructive' : 'bg-success/10 text-success'}`}>
{obtenirNomType(ligne.typeId)}:
<span className="font-semibold ml-1">
{soldeDisponible}j disponibles
</span>
{dejaSaisi > 0 && (
<>
{' '}→ {dejaSaisi}j saisis =
<span className="font-bold ml-1">
{restant}j restants
{enDepassement && ' '}
</span>
</>
)}
</div>
)}
</div>
);
})}
</div>
</div>
{aDepassement && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<strong>Attention:</strong> La saisie dépasse les compteurs disponibles du collaborateur.
La demande sera tout de même enregistrée mais nécessitera une validation.
</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="commentaire">Commentaire (optionnel)</Label>
<Textarea
id="commentaire"
placeholder="Préciser le motif, les circonstances..."
value={formData.commentaire}
onChange={(e) => setFormData({ ...formData, commentaire: e.target.value })}
rows={4}
className="resize-none"
/>
</div>
<div className="flex gap-4 pt-4">
<Button
type="submit"
disabled={loading}
className="flex-1 bg-gradient-primary hover:opacity-90 transition-smooth shadow-elegant"
>
<Save className="w-4 h-4 mr-2" />
{loading ? 'Enregistrement...' : 'Enregistrer la demande'}
</Button>
<Button
type="button"
variant="outline"
onClick={() => navigate("/dashboard")}
disabled={loading}
className="transition-smooth"
>
Annuler
</Button>
</div>
</form>
</CardContent>
</Card>
</main>
</div>
);
};
export default SaisieManuelle;