Files
GTARH/src/pages/SaisieManuelle.tsx
T

639 lines
34 KiB
TypeScript
Raw 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.
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;