Compare commits

..

2 Commits

Author SHA1 Message Date
oimer 314a98c1ad Ajouter completion des profils nouveaux arrivants et corrections diverses
- 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>
2026-08-17 15:37:03 +02:00
oimer a7a0e51c7a Modif_GestionDoublonsGTARH_Fonctionnel_V1 2025-12-11 16:47:21 +01:00
7 changed files with 989 additions and 336 deletions
+142 -28
View File
@@ -1,4 +1,4 @@

const express = require('express'); const express = require('express');
const cors = require('cors'); const cors = require('cors');
@@ -943,7 +943,23 @@ try {
await transaction.rollback(); await transaction.rollback();
return res.status(403).json({ error: 'Impossible de créer une demande pour un compte désactivé' }); return res.status(403).json({ error: 'Impossible de créer une demande pour un compte désactivé' });
} }
// ── Vérification anti-doublon avant création ──────────────────
const doublonCheck = await transaction.request()
.input('collabId', sql.Int, collaborateurId)
.input('dateDebut', sql.Date, dateDebut)
.input('dateFin', sql.Date, dateFin)
.query(`
SELECT Id FROM DemandeConge
WHERE CollaborateurADId = @collabId
AND Statut IN ('Validée', 'Validé', 'En attente')
AND DateDebut <= @dateFin AND DateFin >= @dateDebut
`);
if (doublonCheck.recordset.length > 0) {
await transaction.rollback();
return res.status(409).json({
error: `Une demande existe déjà pour ${collab[0].prenom} ${collab[0].nom} sur cette période.`
});
}
const totalJours = typesConge.reduce((sum, type) => sum + parseFloat(type.nombreJours), 0); const totalJours = typesConge.reduce((sum, type) => sum + parseFloat(type.nombreJours), 0);
const statut = saisieManuelle ? 'Validée' : 'En attente'; const statut = saisieManuelle ? 'Validée' : 'En attente';
@@ -2457,6 +2473,82 @@ WHERE CollaborateurADId = @userId
} }
}); });
// ================================================
// ROUTE GET /demandes/check-doublon
// Vérifie si un ou plusieurs collaborateurs ont déjà une demande
// (Validée ou En attente) qui chevauche la période demandée.
// Usage: GET /demandes/check-doublon?collaborateurIds=12,45&dateDebut=2026-08-01&dateFin=2026-08-05
// ================================================
app.get('/demandes/check-doublon', authenticateToken, async (req, res) => {
try {
const { collaborateurIds, dateDebut, dateFin } = req.query;
if (!collaborateurIds || !dateDebut || !dateFin) {
return res.json({ success: false, message: 'Paramètres requis: collaborateurIds, dateDebut, dateFin' });
}
const ids = collaborateurIds
.split(',')
.map(id => parseInt(id.trim(), 10))
.filter(id => !isNaN(id));
if (ids.length === 0) {
return res.json({ success: false, message: 'Aucun ID collaborateur valide' });
}
const doublonsParCollab = {};
for (const collabId of ids) {
const result = await pool.request()
.input('collabId', sql.Int, collabId)
.input('dateDebut', sql.Date, dateDebut)
.input('dateFin', sql.Date, dateFin)
.query(`
SELECT
dc.Id,
CONVERT(VARCHAR(10), dc.DateDebut, 23) AS dateDebut,
CONVERT(VARCHAR(10), dc.DateFin, 23) AS dateFin,
dc.Statut,
dc.NombreJours,
STUFF((
SELECT DISTINCT ', ' + tc2.Nom
FROM DemandeCongeType dct2
JOIN TypeConge tc2 ON dct2.TypeCongeId = tc2.Id
WHERE dct2.DemandeCongeId = dc.Id
FOR XML PATH('')
), 1, 2, '') AS types
FROM DemandeConge dc
WHERE dc.CollaborateurADId = @collabId
AND dc.Statut IN ('Validée', 'Validé', 'En attente')
AND dc.DateDebut <= @dateFin
AND dc.DateFin >= @dateDebut
ORDER BY dc.DateDebut
`);
if (result.recordset.length > 0) {
doublonsParCollab[collabId] = result.recordset.map(d => ({
id: d.Id,
dateDebut: d.dateDebut,
dateFin: d.dateFin,
statut: d.Statut,
nombreJours: d.NombreJours,
types: d.types
}));
}
}
res.json({
success: true,
hasDoublon: Object.keys(doublonsParCollab).length > 0,
doublons: doublonsParCollab
});
} catch (error) {
console.error('❌ Erreur check-doublon:', error);
res.status(500).json({ success: false, message: error.message });
}
});
// ================================================ // ================================================
// ROUTE PUT /demandes/:id - AVEC VALIDATION AUTOMATIQUE // ROUTE PUT /demandes/:id - AVEC VALIDATION AUTOMATIQUE
@@ -5516,8 +5608,8 @@ WHERE CollaborateurADId = @userId
const cpTypeForCheck = await pool.request() const cpTypeForCheck = await pool.request()
.query("SELECT TOP 1 Id FROM TypeConge WHERE Nom = 'Congé payé'"); .query("SELECT TOP 1 Id FROM TypeConge WHERE Nom = 'Congé payé'");
let currentYear = currentYearSys; // CP N (2026 ou 2027 après bascule) let currentYear = currentYearSys;
let previousYear = currentYearSys - 1; // CP N-1 let previousYear = currentYearSys - 1;
if (cpTypeForCheck.recordset.length > 0) { if (cpTypeForCheck.recordset.length > 0) {
const cpTypeId = cpTypeForCheck.recordset[0].Id; const cpTypeId = cpTypeForCheck.recordset[0].Id;
@@ -5528,7 +5620,6 @@ WHERE CollaborateurADId = @userId
SELECT TOP 1 Id FROM CompteurConges SELECT TOP 1 Id FROM CompteurConges
WHERE TypeCongeId = @cpTypeId WHERE TypeCongeId = @cpTypeId
AND Annee = @anneeN1 AND Annee = @anneeN1
AND SoldeReporte > 0
`); `);
if (checkBascule.recordset.length > 0) { if (checkBascule.recordset.length > 0) {
@@ -5538,9 +5629,8 @@ WHERE CollaborateurADId = @userId
} }
} }
// RTT et Récup : toujours l'année civile système (jamais basculent) const anneeRTT = currentYearSys;
const anneeRTT = currentYearSys; // 2026 const anneeRecup = currentYearSys;
const anneeRecup = currentYearSys; // 2026
console.log(`📅 CP N=${currentYear}, CP N-1=${previousYear}, RTT=${anneeRTT}, Récup=${anneeRecup}`); console.log(`📅 CP N=${currentYear}, CP N-1=${previousYear}, RTT=${anneeRTT}, Récup=${anneeRecup}`);
@@ -5592,7 +5682,7 @@ WHERE CollaborateurADId = @userId
.input('typeId', sql.Int, typeCongeMap['Congé payé']) .input('typeId', sql.Int, typeCongeMap['Congé payé'])
.input('annee', sql.Int, currentYear) .input('annee', sql.Int, currentYear)
.query(` .query(`
SELECT Total, Solde SELECT Total, Solde, SoldeReporte
FROM CompteurConges FROM CompteurConges
WHERE CollaborateurADId = @collabId WHERE CollaborateurADId = @collabId
AND TypeCongeId = @typeId AND TypeCongeId = @typeId
@@ -5604,11 +5694,12 @@ WHERE CollaborateurADId = @userId
let cpTotalN, cpSoldeN; let cpTotalN, cpSoldeN;
if (cpN.length > 0) { if (cpN.length > 0) {
cpTotalN = parseFloat(cpN[0].Total); cpTotalN = parseFloat(cpN[0].Total);
cpSoldeN = parseFloat(cpN[0].Solde); const soldeReporte = parseFloat(cpN[0].SoldeReporte || 0);
// Solde réel = Solde - SoldeReporte
cpSoldeN = Math.max(0, parseFloat(cpN[0].Solde) - soldeReporte);
} else { } else {
// Après bascule, si pas de ligne en base → 0
// Avant bascule, on calcule l'acquisition
if (currentYear > currentYearSys) { if (currentYear > currentYearSys) {
// Bascule faite mais pas de compteur 2027 pour ce collab → 0
cpTotalN = 0; cpTotalN = 0;
cpSoldeN = 0; cpSoldeN = 0;
} else { } else {
@@ -5631,7 +5722,6 @@ WHERE CollaborateurADId = @userId
} }
} }
// ⭐ PUSH CP N (était manquant — cause du bug d'affichage)
resultats.push({ resultats.push({
collaborateurId: collab.id, collaborateurId: collab.id,
employe: `${collab.prenom} ${collab.nom}`, employe: `${collab.prenom} ${collab.nom}`,
@@ -5644,7 +5734,7 @@ WHERE CollaborateurADId = @userId
annee: currentYear, annee: currentYear,
total: parseFloat(cpTotalN.toFixed(2)), total: parseFloat(cpTotalN.toFixed(2)),
solde: parseFloat(cpSoldeN.toFixed(2)), solde: parseFloat(cpSoldeN.toFixed(2)),
consomme: parseFloat((cpTotalN - cpSoldeN).toFixed(2)), consomme: Math.max(0, parseFloat((cpTotalN - cpSoldeN).toFixed(2))),
role: collab.role, role: collab.role,
typeContrat: collab.TypeContrat, typeContrat: collab.TypeContrat,
estApprenti: collab.role === 'Apprenti' || collab.TypeContrat === 'Apprentissage' estApprenti: collab.role === 'Apprenti' || collab.TypeContrat === 'Apprentissage'
@@ -5656,7 +5746,7 @@ WHERE CollaborateurADId = @userId
.input('typeId', sql.Int, typeCongeMap['Congé payé']) .input('typeId', sql.Int, typeCongeMap['Congé payé'])
.input('annee', sql.Int, previousYear) .input('annee', sql.Int, previousYear)
.query(` .query(`
SELECT Total, Solde SELECT Total, Solde, SoldeReporte
FROM CompteurConges FROM CompteurConges
WHERE CollaborateurADId = @collabId WHERE CollaborateurADId = @collabId
AND TypeCongeId = @typeId AND TypeCongeId = @typeId
@@ -5665,7 +5755,7 @@ WHERE CollaborateurADId = @userId
const cpN1 = cpN1Result.recordset; const cpN1 = cpN1Result.recordset;
if (cpN1.length > 0 && cpN1[0].Solde > 0) { if (cpN1.length > 0 ) {
const total = parseFloat(cpN1[0].Total); const total = parseFloat(cpN1[0].Total);
const solde = parseFloat(cpN1[0].Solde); const solde = parseFloat(cpN1[0].Solde);
resultats.push({ resultats.push({
@@ -5680,7 +5770,7 @@ WHERE CollaborateurADId = @userId
annee: previousYear, annee: previousYear,
total, total,
solde, solde,
consomme: parseFloat((total - solde).toFixed(2)), consomme: Math.max(0, parseFloat((total - solde).toFixed(2))),
role: collab.role, role: collab.role,
typeContrat: collab.TypeContrat, typeContrat: collab.TypeContrat,
estApprenti: collab.role === 'Apprenti' || collab.TypeContrat === 'Apprentissage' estApprenti: collab.role === 'Apprenti' || collab.TypeContrat === 'Apprentissage'
@@ -5740,7 +5830,7 @@ WHERE CollaborateurADId = @userId
annee: anneeRTT, annee: anneeRTT,
total: parseFloat(rttTotalN.toFixed(2)), total: parseFloat(rttTotalN.toFixed(2)),
solde: parseFloat(rttSoldeN.toFixed(2)), solde: parseFloat(rttSoldeN.toFixed(2)),
consomme: parseFloat((rttTotalN - rttSoldeN).toFixed(2)), consomme: Math.max(0, parseFloat((rttTotalN - rttSoldeN).toFixed(2))),
role: collab.role, role: collab.role,
typeContrat: collab.TypeContrat, typeContrat: collab.TypeContrat,
estApprenti: collab.role === 'Apprenti' || collab.TypeContrat === 'Apprentissage' estApprenti: collab.role === 'Apprenti' || collab.TypeContrat === 'Apprentissage'
@@ -5779,7 +5869,7 @@ WHERE CollaborateurADId = @userId
annee: anneeRecup, annee: anneeRecup,
total: recupTotal, total: recupTotal,
solde: recupSolde, solde: recupSolde,
consomme: parseFloat((recupTotal - recupSolde).toFixed(2)), consomme: Math.max(0, parseFloat((recupTotal - recupSolde).toFixed(2))),
role: collab.role, role: collab.role,
typeContrat: collab.TypeContrat, typeContrat: collab.TypeContrat,
estApprenti: collab.role === 'Apprenti' || collab.TypeContrat === 'Apprentissage' estApprenti: collab.role === 'Apprenti' || collab.TypeContrat === 'Apprentissage'
@@ -5797,7 +5887,6 @@ WHERE CollaborateurADId = @userId
}); });
// ================================================ // ================================================
// ROUTES POUR LA GESTION DES COLLABORATEURS - Adaptées à votre structure BDD // ROUTES POUR LA GESTION DES COLLABORATEURS - Adaptées à votre structure BDD
// ================================================ // ================================================
@@ -5821,11 +5910,11 @@ WHERE CollaborateurADId = @userId
ca.nom, ca.nom,
ca.prenom, ca.prenom,
ca.email, ca.email,
ca.fonction as poste, ca.description as poste,
ca.role,
ca.TypeContrat as typeContrat, ca.TypeContrat as typeContrat,
ca.DateEntree as dateEntree, ca.DateEntree as dateEntree,
ca.actif as Actif, ca.actif as Actif,
ca.description,
-- Société -- Société
COALESCE(soc.Nom, 'Sans société') as societe, COALESCE(soc.Nom, 'Sans société') as societe,
@@ -5954,6 +6043,31 @@ WHERE CollaborateurADId = @userId
// PUT /api/collaborateurs/:id - Modifier un collaborateur // PUT /api/collaborateurs/:id - Modifier un collaborateur
// GET /api/collaborateurs/validateurs - Liste des collaborateurs actifs pouvant être validateurs
app.get('/collaborateurs/validateurs', authenticateToken, async (req, res) => {
try {
const result = await pool.request().query(`
SELECT id, CONCAT(prenom, ' ', nom) as nom, email, role
FROM CollaborateurAD
WHERE (Actif = 1 OR Actif IS NULL)
AND (
role LIKE '%Admin%'
OR role LIKE '%RH%'
OR role LIKE '%Validat%'
OR role LIKE '%Directeur%'
OR role LIKE '%Directrice%'
OR role LIKE '%President%'
OR role LIKE '%Président%'
)
ORDER BY nom, prenom
`);
res.json(result.recordset);
} catch (error) {
console.error('❌ Erreur /collaborateurs/validateurs:', error);
res.status(500).json({ error: error.message });
}
});
app.put('/collaborateurs/:id', authenticateToken, async (req, res) => { app.put('/collaborateurs/:id', authenticateToken, async (req, res) => {
const transaction = new sql.Transaction(pool); const transaction = new sql.Transaction(pool);
try { try {
@@ -5973,11 +6087,11 @@ WHERE CollaborateurADId = @userId
campusId, campusId,
serviceId, serviceId,
poste, poste,
role,
typeContrat, typeContrat,
dateEntree, dateEntree,
nPlus1Id, nPlus1Id,
nPlus2Id, nPlus2Id
description
} = req.body; } = req.body;
console.log('Modification collaborateur', id, req.body); console.log('Modification collaborateur', id, req.body);
@@ -6001,10 +6115,10 @@ WHERE CollaborateurADId = @userId
.input('societeId', sql.Int, societeId) .input('societeId', sql.Int, societeId)
.input('campusId', sql.Int, campusId) .input('campusId', sql.Int, campusId)
.input('serviceId', sql.Int, serviceId) .input('serviceId', sql.Int, serviceId)
.input('fonction', sql.NVarChar(255), poste)
.input('typeContrat', sql.NVarChar(50), typeContrat) .input('typeContrat', sql.NVarChar(50), typeContrat)
.input('dateEntree', sql.Date, dateEntree || null) .input('dateEntree', sql.Date, dateEntree || null)
.input('description', sql.NVarChar(500), description || null) .input('description', sql.NVarChar(500), poste || null)
.input('role', sql.NVarChar(100), role || null)
.query(` .query(`
UPDATE CollaborateurAD UPDATE CollaborateurAD
SET nom = @nom, SET nom = @nom,
@@ -6013,10 +6127,10 @@ WHERE CollaborateurADId = @userId
SocieteId = @societeId, SocieteId = @societeId,
CampusId = @campusId, CampusId = @campusId,
ServiceId = @serviceId, ServiceId = @serviceId,
fonction = @fonction,
TypeContrat = @typeContrat, TypeContrat = @typeContrat,
DateEntree = @dateEntree, DateEntree = @dateEntree,
description = @description description = @description,
role = @role
WHERE id = @id WHERE id = @id
`); `);
+79
View File
@@ -0,0 +1,79 @@
import { useState } from "react";
import { Check, ChevronsUpDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
interface Validateur {
id: number;
nom: string;
email: string;
role: string;
}
interface ValidateurComboboxProps {
validateurs: Validateur[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
}
export function ValidateurCombobox({ validateurs, value, onChange, placeholder = "Sélectionner..." }: ValidateurComboboxProps) {
const [open, setOpen] = useState(false);
const selected = validateurs.find((v) => String(v.id) === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between font-normal"
>
{selected ? `${selected.nom} (${selected.role})` : placeholder}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
<Command>
<CommandInput placeholder="Rechercher un validateur..." />
<CommandList>
<CommandEmpty>Aucun validateur trouvé.</CommandEmpty>
<CommandGroup>
{validateurs.map((v) => (
<CommandItem
key={v.id}
value={`${v.nom} ${v.email}`}
onSelect={() => {
onChange(String(v.id) === value ? "" : String(v.id));
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === String(v.id) ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span>{v.nom} <span className="text-muted-foreground">({v.role})</span></span>
<span className="text-xs text-muted-foreground">{v.email}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
-3
View File
@@ -54,9 +54,6 @@ const AuthMicrosoft = () => {
// 🔑 Envoie accessToken au backend // 🔑 Envoie accessToken au backend
const authenticateWithBackend = async (accessToken: string) => { const authenticateWithBackend = async (accessToken: string) => {
try { try {
console.log('🔑 Token complet:', accessToken);
console.log('🔑 Token (50 premiers):', accessToken.substring(0, 50));
console.log('🔑 Longueur:', accessToken.length);
const response = await fetch('/api/login-dev', { const response = await fetch('/api/login-dev', {
method: 'POST', method: 'POST',
headers: { headers: {
+26 -1
View File
@@ -3,7 +3,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import {
Calendar, Users, Clock, CheckCircle, XCircle, AlertCircle, Calendar, Users, Clock, CheckCircle, XCircle, AlertCircle, AlertTriangle,
Plus, LogOut, FileSpreadsheet, RefreshCw, ClipboardList, Plus, LogOut, FileSpreadsheet, RefreshCw, ClipboardList,
FileText, ChevronLeft, ChevronRight FileText, ChevronLeft, ChevronRight
} from "lucide-react"; } from "lucide-react";
@@ -55,6 +55,8 @@ interface Collaborateur {
campusId: number | null; campusId: number | null;
societe: string; societe: string;
societeId: number | null; societeId: number | null;
dateEntree: string | null;
nPlus1Id: number | null;
} }
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -184,6 +186,8 @@ const Dashboard = () => {
campusId: c.campusId ?? null, campusId: c.campusId ?? null,
societe: c.societe || "", societe: c.societe || "",
societeId: c.societeId ?? null, societeId: c.societeId ?? null,
dateEntree: c.dateEntree ?? null,
nPlus1Id: c.nPlus1Id ?? null,
})) }))
); );
} }
@@ -385,6 +389,12 @@ const Dashboard = () => {
); );
const usedTypes = [...new Set(leavesThisMonth.map(l => getTypeKey(l.typesConge)))]; const usedTypes = [...new Set(leavesThisMonth.map(l => getTypeKey(l.typesConge)))];
// ── Profils à compléter (nouveaux arrivants synchronisés depuis Entra) ─────
const profilsIncompletsCount = allCollabs.filter(
c => !c.societeId || !c.campusId || !c.serviceId || !c.dateEntree || !c.nPlus1Id
).length;
const peutGererCollaborateurs = user?.role === "Admin" || user?.role === "RH";
// ── Cartes stats & actions ──────────────────────────────────────────────── // ── Cartes stats & actions ────────────────────────────────────────────────
const statsCards = [ const statsCards = [
{ title: "Demandes en attente", value: stats.enAttente, icon: Clock, color: "text-warning", bg: "bg-warning/10", action: () => navigate("/validation") }, { title: "Demandes en attente", value: stats.enAttente, icon: Clock, color: "text-warning", bg: "bg-warning/10", action: () => navigate("/validation") },
@@ -439,6 +449,21 @@ const Dashboard = () => {
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8"> <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
{/* Alerte profils à compléter */}
{peutGererCollaborateurs && profilsIncompletsCount > 0 && (
<Card
className="shadow-card border-0 border-l-4 border-l-amber-400 bg-amber-50 cursor-pointer hover:shadow-elegant transition-smooth"
onClick={() => navigate("/teams")}
>
<CardContent className="p-4 flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-amber-600 shrink-0" />
<p className="text-sm text-amber-900">
<strong>{profilsIncompletsCount} profil{profilsIncompletsCount > 1 ? "s" : ""}</strong> à compléter (société, campus, service, date d'entrée ou validateur manquant) — cliquez pour ouvrir la gestion des collaborateurs.
</p>
</CardContent>
</Card>
)}
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{statsCards.map((stat, i) => ( {statsCards.map((stat, i) => (
+22 -28
View File
@@ -64,7 +64,6 @@ interface SoldesALaDate {
}>; }>;
} }
// ⭐ Interface prévisionnel (identique à SaisieManuelle)
interface Previsionnel { interface Previsionnel {
mode: string; mode: string;
isAnticipe: boolean; isAnticipe: boolean;
@@ -371,7 +370,7 @@ const HistoriqueSnapshotDialog = ({ compteur, onClose }: HistoriqueDialogProps)
}; };
// ===================================================== // =====================================================
// PANNEAU PRÉVISIONNEL (réutilisé depuis SaisieManuelle) // PANNEAU PRÉVISIONNEL
// ===================================================== // =====================================================
interface PrevisonnnelPanelProps { interface PrevisonnnelPanelProps {
@@ -400,10 +399,6 @@ const PrevisonnnelPanel = ({ collaborateurId, dateDebut, loadingPrevisionnel, pr
if (!previsionnel) return null; if (!previsionnel) return null;
const estOk = previsionnel.suffisantCP && previsionnel.suffisantRTT; const estOk = previsionnel.suffisantCP && previsionnel.suffisantRTT;
const cpInsuffisant = !previsionnel.suffisantCP && previsionnel.joursNecessairesCP > 0;
const rttInsuffisant = !previsionnel.suffisantRTT && previsionnel.joursNecessairesRTT > 0;
// En mode consultation (pas de saisie), on affiche toujours CP et RTT si dispo
const showCP = previsionnel.totalCPDisponible > 0 || previsionnel.cpNSolde > 0; const showCP = previsionnel.totalCPDisponible > 0 || previsionnel.cpNSolde > 0;
const showRTT = previsionnel.rttDispo > 0 || previsionnel.rttNAcquisAujourdHui > 0; const showRTT = previsionnel.rttDispo > 0 || previsionnel.rttNAcquisAujourdHui > 0;
@@ -429,8 +424,6 @@ const PrevisonnnelPanel = ({ collaborateurId, dateDebut, loadingPrevisionnel, pr
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* CP */}
{/* CP */}
{showCP && ( {showCP && (
<div className="rounded-lg p-3 border bg-white border-gray-200"> <div className="rounded-lg p-3 border bg-white border-gray-200">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
@@ -441,7 +434,6 @@ const PrevisonnnelPanel = ({ collaborateurId, dateDebut, loadingPrevisionnel, pr
</div> </div>
<div className="space-y-1 text-xs text-gray-600"> <div className="space-y-1 text-xs text-gray-600">
{previsionnel.apresBasculement ? ( {previsionnel.apresBasculement ? (
// ✅ APRÈS LE 1er JUIN : ancien CP N (devenu N-1) + nouveau CP N depuis juin
<> <>
{previsionnel.cpN1Reporte > 0 && ( {previsionnel.cpN1Reporte > 0 && (
<div className="flex justify-between"> <div className="flex justify-between">
@@ -467,7 +459,6 @@ const PrevisonnnelPanel = ({ collaborateurId, dateDebut, loadingPrevisionnel, pr
)} )}
</> </>
) : ( ) : (
// ✅ AVANT LE 1er JUIN : CP N-1 + CP N en cours + anticipé
<> <>
{previsionnel.cpN1Reporte > 0 && ( {previsionnel.cpN1Reporte > 0 && (
<div className="flex justify-between"> <div className="flex justify-between">
@@ -503,7 +494,6 @@ const PrevisonnnelPanel = ({ collaborateurId, dateDebut, loadingPrevisionnel, pr
</div> </div>
)} )}
{/* RTT */}
{showRTT && ( {showRTT && (
<div className="rounded-lg p-3 border bg-white border-gray-200"> <div className="rounded-lg p-3 border bg-white border-gray-200">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
@@ -565,22 +555,23 @@ const GestionCompteurs = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [showEditDialog, setShowEditDialog] = useState(false); const [showEditDialog, setShowEditDialog] = useState(false);
// Dialog consultation soldes à une date
const [showSoldesDateDialog, setShowSoldesDateDialog] = useState(false); const [showSoldesDateDialog, setShowSoldesDateDialog] = useState(false);
const [selectedCollaborateurId, setSelectedCollaborateurId] = useState<number | null>(null); const [selectedCollaborateurId, setSelectedCollaborateurId] = useState<number | null>(null);
const [selectedEmployeName, setSelectedEmployeName] = useState<string>(""); const [selectedEmployeName, setSelectedEmployeName] = useState<string>("");
const [dateConsultation, setDateConsultation] = useState(new Date().toISOString().split('T')[0]); const [dateConsultation, setDateConsultation] = useState(new Date().toISOString().split('T')[0]);
// ⭐ État prévisionnel
const [previsionnel, setPrevisionnel] = useState<Previsionnel | null>(null); const [previsionnel, setPrevisionnel] = useState<Previsionnel | null>(null);
const [loadingPrevisionnel, setLoadingPrevisionnel] = useState(false); const [loadingPrevisionnel, setLoadingPrevisionnel] = useState(false);
// ✅ CORRECTION : currentYear + 1 = 2027 = année en cours après bascule
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const anneeEnCours = currentYear + 1; // 2027
const anneeN1 = currentYear; // 2026
const [editForm, setEditForm] = useState({ total: 0, solde: 0 }); const [editForm, setEditForm] = useState({ total: 0, solde: 0 });
useEffect(() => { chargerCompteurs(); }, []); useEffect(() => { chargerCompteurs(); }, []);
// ⭐ Recharger le prévisionnel quand la date ou le collaborateur change
useEffect(() => { useEffect(() => {
if (showSoldesDateDialog && selectedCollaborateurId && dateConsultation) { if (showSoldesDateDialog && selectedCollaborateurId && dateConsultation) {
chargerPrevisionnel(); chargerPrevisionnel();
@@ -594,7 +585,6 @@ const GestionCompteurs = () => {
return token ? { Authorization: `Bearer ${token}` } : {}; return token ? { Authorization: `Bearer ${token}` } : {};
}; };
// ⭐ Chargement du prévisionnel
const chargerPrevisionnel = useCallback(async () => { const chargerPrevisionnel = useCallback(async () => {
if (!selectedCollaborateurId || !dateConsultation) return; if (!selectedCollaborateurId || !dateConsultation) return;
@@ -792,7 +782,8 @@ const GestionCompteurs = () => {
<SelectItem value="all">Toutes les années</SelectItem> <SelectItem value="all">Toutes les années</SelectItem>
{annees.map(a => ( {annees.map(a => (
<SelectItem key={a} value={a.toString()}> <SelectItem key={a} value={a.toString()}>
{a} {a === currentYear && '(en cours)'} {/* ✅ CORRECTION : En cours = 2027 */}
{a} {a === anneeEnCours && '(en cours)'}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -871,16 +862,25 @@ const GestionCompteurs = () => {
<TableCell className="text-center"> <TableCell className="text-center">
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<span className="font-medium">{compteur.annee}</span> <span className="font-medium">{compteur.annee}</span>
{compteur.annee === currentYear && ( {/* CP 2027 = En cours */}
{compteur.annee === anneeEnCours && compteur.typeConge === 'Congé payé' && (
<Badge variant="outline" className="bg-blue-100 text-blue-700 border-blue-300 text-xs">En cours</Badge> <Badge variant="outline" className="bg-blue-100 text-blue-700 border-blue-300 text-xs">En cours</Badge>
)} )}
{compteur.annee === currentYear - 1 && ( {/* CP 2026 = N-1 */}
{compteur.annee === anneeN1 && compteur.typeConge === 'Congé payé' && (
<Badge variant="outline" className="bg-gray-100 text-gray-600 border-gray-300 text-xs">N-1</Badge> <Badge variant="outline" className="bg-gray-100 text-gray-600 border-gray-300 text-xs">N-1</Badge>
)} )}
{/* RTT et Récupération 2026 = En cours */}
{compteur.annee === currentYear && compteur.typeConge !== 'Congé payé' && (
<Badge variant="outline" className="bg-blue-100 text-blue-700 border-blue-300 text-xs">En cours</Badge>
)}
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-right font-medium">{compteur.total}</TableCell> <TableCell className="text-right font-medium">{compteur.total}</TableCell>
<TableCell className="text-right text-muted-foreground">{compteur.consomme}</TableCell> {/* ✅ CORRECTION : consommé jamais négatif */}
<TableCell className="text-right text-muted-foreground">
{compteur.consomme < 0 ? 0 : compteur.consomme}
</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<span className={`font-bold text-lg ${compteur.solde <= 0 ? 'text-destructive' : compteur.total > 0 && compteur.solde / compteur.total <= 0.2 ? 'text-orange-600' : 'text-green-600'}`}> <span className={`font-bold text-lg ${compteur.solde <= 0 ? 'text-destructive' : compteur.total > 0 && compteur.solde / compteur.total <= 0.2 ? 'text-orange-600' : 'text-green-600'}`}>
{compteur.solde} {compteur.solde}
@@ -893,7 +893,6 @@ const GestionCompteurs = () => {
title="Modifier le compteur"> title="Modifier le compteur">
<Edit className="w-4 h-4" /> <Edit className="w-4 h-4" />
</Button> </Button>
<Button variant="outline" size="sm" <Button variant="outline" size="sm"
onClick={() => ouvrirConsultationSoldes(compteur)} onClick={() => ouvrirConsultationSoldes(compteur)}
title="Voir les soldes à une date spécifique" title="Voir les soldes à une date spécifique"
@@ -912,9 +911,7 @@ const GestionCompteurs = () => {
</Card> </Card>
</main> </main>
{/* DIALOG CONSULTATION SOLDES */}
{/* ⭐ DIALOG CONSULTATION SOLDES À UNE DATE + PRÉVISIONNEL */}
<Dialog open={showSoldesDateDialog} onOpenChange={fermerConsultationSoldes}> <Dialog open={showSoldesDateDialog} onOpenChange={fermerConsultationSoldes}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto"> <DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
@@ -928,7 +925,6 @@ const GestionCompteurs = () => {
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
{/* Sélecteur de date */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="dateConsultation">Date de consultation</Label> <Label htmlFor="dateConsultation">Date de consultation</Label>
<Input <Input
@@ -942,7 +938,6 @@ const GestionCompteurs = () => {
</p> </p>
</div> </div>
{/* ⭐ Panneau prévisionnel */}
{selectedCollaborateurId && dateConsultation && ( {selectedCollaborateurId && dateConsultation && (
<PrevisonnnelPanel <PrevisonnnelPanel
collaborateurId={selectedCollaborateurId} collaborateurId={selectedCollaborateurId}
@@ -952,7 +947,6 @@ const GestionCompteurs = () => {
/> />
)} )}
{/* Snapshot historique (dates passées uniquement) */}
{selectedCollaborateurId && dateConsultation && {selectedCollaborateurId && dateConsultation &&
dateConsultation <= new Date().toISOString().split('T')[0] && ( dateConsultation <= new Date().toISOString().split('T')[0] && (
<SnapshotDateDisplay <SnapshotDateDisplay
@@ -973,7 +967,7 @@ const GestionCompteurs = () => {
<Dialog open={showEditDialog} onOpenChange={setShowEditDialog}> <Dialog open={showEditDialog} onOpenChange={setShowEditDialog}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Modifier le compteur (V2)</DialogTitle> <DialogTitle>Modifier le compteur </DialogTitle>
<DialogDescription> <DialogDescription>
{selectedCompteur && `${selectedCompteur.employe} - ${selectedCompteur.typeConge} ${selectedCompteur.annee}`} {selectedCompteur && `${selectedCompteur.employe} - ${selectedCompteur.typeConge} ${selectedCompteur.annee}`}
</DialogDescription> </DialogDescription>
@@ -1020,7 +1014,7 @@ const GestionCompteurs = () => {
}; };
// ===================================================== // =====================================================
// SNAPSHOT À UNE DATE (affiché en complément du prévisionnel pour les dates passées) // SNAPSHOT À UNE DATE
// ===================================================== // =====================================================
interface SnapshotDateDisplayProps { interface SnapshotDateDisplayProps {
+130 -10
View File
@@ -33,6 +33,14 @@ interface Previsionnel {
suffisant: boolean; suffisantCP: boolean; suffisantRTT: boolean; suffisant: boolean; suffisantCP: boolean; suffisantRTT: boolean;
deficitCP: number; deficitRTT: number; message: string; deficitCP: number; deficitRTT: number; message: string;
} }
interface DoublonDemande {
id: number;
dateDebut: string;
dateFin: string;
statut: string;
nombreJours: number;
types: string;
}
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
const calcWorkingDays = ( const calcWorkingDays = (
@@ -417,6 +425,8 @@ const ImpactPreview = ({
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
const SaisieManuelle = () => { const SaisieManuelle = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [doublonsInfo, setDoublonsInfo] = useState<Record<string, DoublonDemande[]>>({});
const [isCheckingDoublon, setIsCheckingDoublon] = useState(false);
const [employes, setEmployes] = useState<Employe[]>([]); const [employes, setEmployes] = useState<Employe[]>([]);
const [employesFiltres, setEmployesFiltres] = useState<Employe[]>([]); const [employesFiltres, setEmployesFiltres] = useState<Employe[]>([]);
@@ -456,6 +466,40 @@ const SaisieManuelle = () => {
]).then(([a, b]) => setPublicHolidays({ ...a, ...b })).catch(() => { }); ]).then(([a, b]) => setPublicHolidays({ ...a, ...b })).catch(() => { });
}, []); }, []);
// ── Vérification anti-doublon ──────────────────────────────────────────
useEffect(() => {
let cancelled = false;
const checkDoublon = async () => {
if (!employeIdsSelectionnes.length || !dateDebut || !dateFin) {
setDoublonsInfo({});
return;
}
setIsCheckingDoublon(true);
try {
const token = localStorage.getItem("token");
const p = new URLSearchParams({
collaborateurIds: employeIdsSelectionnes.join(","),
dateDebut,
dateFin,
});
const res = await fetch(`/api/demandes/check-doublon?${p}`, {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
if (cancelled) return;
setDoublonsInfo(data.success && data.hasDoublon ? data.doublons : {});
} catch {
if (!cancelled) setDoublonsInfo({});
} finally {
if (!cancelled) setIsCheckingDoublon(false);
}
};
const debounceTimer = setTimeout(checkDoublon, 250);
return () => { cancelled = true; clearTimeout(debounceTimer); };
}, [employeIdsSelectionnes, dateDebut, dateFin]);
useEffect(() => { useEffect(() => {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
const h = { Authorization: `Bearer ${token}` }; const h = { Authorization: `Bearer ${token}` };
@@ -568,17 +612,30 @@ const SaisieManuelle = () => {
return t?.nom.toLowerCase().includes("récup") && l.nombreJours > 0; return t?.nom.toLowerCase().includes("récup") && l.nombreJours > 0;
}); });
// ── Helper : nom d'un employé à partir de son ID
const getEmployeNom = (id: string) => {
const e = employes.find(emp => emp.id.toString() === id);
return e ? `${e.prenom} ${e.nom}` : `Collaborateur #${id}`;
};
const nbDoublons = Object.keys(doublonsInfo).length;
const handleSubmit = async () => { const handleSubmit = async () => {
setError(""); setError("");
if (!employeIdsSelectionnes.length || !dateDebut || !dateFin) { if (!employeIdsSelectionnes.length || !dateDebut || !dateFin) {
setError("Veuillez sélectionner un collaborateur et remplir les dates."); return; setError("Veuillez sélectionner un collaborateur et remplir les dates."); return;
} }
if (nbDoublons > 0) {
setError("Des doublons ont été détectés. Modifiez les dates ou retirez les collaborateurs concernés avant de continuer.");
return;
}
const lignesValides = lignesConge.filter(l => l.typeId > 0 && l.nombreJours > 0); const lignesValides = lignesConge.filter(l => l.typeId > 0 && l.nombreJours > 0);
if (!lignesValides.length) { if (!lignesValides.length) {
setError("Ajoutez au moins un type de congé avec un nombre de jours."); return; setError("Ajoutez au moins un type de congé avec un nombre de jours."); return;
} }
setLoading(true); setLoading(true);
let dernierMessageErreur = ""; // ⭐ Capture le dernier message d'erreur précis
try { try {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
let ok = 0, ko = 0; let ok = 0, ko = 0;
@@ -606,20 +663,35 @@ const SaisieManuelle = () => {
body: JSON.stringify({ jours: jJPOSF }), body: JSON.stringify({ jours: jJPOSF }),
}); });
} }
} else ko++; } else {
} catch { ko++; } ko++;
try {
const errData = await res.json();
console.error("❌ Erreur POST /demandes:", res.status, errData);
dernierMessageErreur = errData?.error || `Erreur ${res.status}`;
} catch {
dernierMessageErreur = `Erreur serveur (${res.status})`;
}
}
} catch (fetchErr) {
ko++;
console.error("❌ Exception fetch:", fetchErr);
dernierMessageErreur = "Impossible de contacter le serveur";
}
} }
if (ok > 0) { if (ok > 0) {
toast.success(`${ok} demande(s) enregistrée(s) et validée(s)`, { toast.success(`${ok} demande(s) enregistrée(s) et validée(s)`, {
description: ko > 0 ? `${ko} erreur(s)` : undefined, description: ko > 0 ? `${ko} erreur(s)` : undefined,
}); });
navigate("/dashboard"); navigate("/dashboard");
} else throw new Error("Aucune demande créée"); } else {
// ⭐ Utilise le message précis capturé, sinon fallback générique
setError(dernierMessageErreur || "Aucune demande n'a pu être créée.");
}
} catch (e: any) { } catch (e: any) {
setError(e.message || "Erreur lors de l'enregistrement"); setError(e.message || "Une erreur est survenue");
} finally { setLoading(false); } } finally { setLoading(false); }
}; };
// ═════════════════════════════════════════════════════════════════════════ // ═════════════════════════════════════════════════════════════════════════
// RENDU — Layout 2 colonnes SAISIE | SOLDES (côte à côte) // RENDU — Layout 2 colonnes SAISIE | SOLDES (côte à côte)
// ═════════════════════════════════════════════════════════════════════════ // ═════════════════════════════════════════════════════════════════════════
@@ -764,14 +836,21 @@ const SaisieManuelle = () => {
{employesSelectionnes.length > 0 && ( {employesSelectionnes.length > 0 && (
<div className="flex flex-wrap gap-1.5 p-3 bg-gray-50 border border-gray-200 rounded-lg"> <div className="flex flex-wrap gap-1.5 p-3 bg-gray-50 border border-gray-200 rounded-lg">
{employesSelectionnes.map(e => ( {employesSelectionnes.map(e => {
<Badge key={e.id} variant="secondary" className="px-2.5 py-1 text-sm"> const aUnDoublon = !!doublonsInfo[e.id.toString()];
{e.nom} {e.prenom} return (
<Badge
key={e.id}
variant={aUnDoublon ? "destructive" : "secondary"}
className="px-2.5 py-1 text-sm"
>
{aUnDoublon && "⚠️ "}{e.nom} {e.prenom}
<button type="button" onClick={() => toggleEmploye(e.id.toString())} className="ml-1.5 hover:text-red-500"> <button type="button" onClick={() => toggleEmploye(e.id.toString())} className="ml-1.5 hover:text-red-500">
<X className="w-3 h-3" /> <X className="w-3 h-3" />
</button> </button>
</Badge> </Badge>
))} );
})}
</div> </div>
)} )}
@@ -866,6 +945,44 @@ const SaisieManuelle = () => {
)} )}
</div> </div>
)} )}
{/* ── Bandeau anti-doublon ────────────────────────────── */}
{isCheckingDoublon && (
<div className="flex items-center gap-2 bg-gray-50 border border-gray-200 rounded-lg px-4 py-2 text-xs text-gray-500">
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
Vérification des doublons…
</div>
)}
{!isCheckingDoublon && nbDoublons > 0 && (
<div className="bg-red-50 border-2 border-red-300 rounded-lg p-3">
<div className="flex items-start gap-2">
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-semibold text-red-800">
⚠️ Doublon détecté pour {nbDoublons} collaborateur{nbDoublons > 1 ? "s" : ""}
</p>
<div className="mt-2 space-y-2">
{Object.entries(doublonsInfo).map(([empId, demandes]) => (
<div key={empId} className="bg-white border border-red-200 rounded px-2 py-1.5">
<p className="text-xs font-semibold text-gray-800">{getEmployeNom(empId)}</p>
{demandes.map(d => (
<div key={d.id} className="text-xs text-gray-600 mt-0.5">
{d.types || "Congé"} — {fmtDate(d.dateDebut, { day: "2-digit", month: "short" })}
{d.dateDebut !== d.dateFin && ` au ${fmtDate(d.dateFin, { day: "2-digit", month: "short" })}`}
{" "}({d.nombreJours}j) — <span className={d.statut === "En attente" ? "text-orange-600" : "text-green-600"}>{d.statut}</span>
</div>
))}
</div>
))}
</div>
<p className="text-xs text-red-700 mt-2">
Modifiez les dates ou retirez ces collaborateurs de la sélection pour continuer.
</p>
</div>
</div>
</div>
)}
</div> </div>
</div> </div>
@@ -969,10 +1086,13 @@ const SaisieManuelle = () => {
Annuler Annuler
</button> </button>
<button <button
type="button" onClick={handleSubmit} disabled={loading} type="button" onClick={handleSubmit}
disabled={loading || isCheckingDoublon || nbDoublons > 0}
className="flex-1 px-4 py-2.5 rounded-lg font-medium text-sm flex items-center justify-center gap-2 transition-all bg-blue-600 hover:bg-blue-700 text-white shadow-sm disabled:opacity-50" className="flex-1 px-4 py-2.5 rounded-lg font-medium text-sm flex items-center justify-center gap-2 transition-all bg-blue-600 hover:bg-blue-700 text-white shadow-sm disabled:opacity-50"
> >
{loading ? "Enregistrement" {loading ? "Enregistrement"
: nbDoublons > 0
? <><AlertCircle className="w-4 h-4" /> Doublon détecté</>
: previsionnel && !previsionnel.suffisant : previsionnel && !previsionnel.suffisant
? <><AlertCircle className="w-4 h-4" /> Enregistrer malgré le déficit</> ? <><AlertCircle className="w-4 h-4" /> Enregistrer malgré le déficit</>
: <><CheckCircle className="w-4 h-4" /> Enregistrer et valider</>} : <><CheckCircle className="w-4 h-4" /> Enregistrer et valider</>}
+327 -3
View File
@@ -4,11 +4,20 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Calendar, Search, ArrowLeft, Users, ChevronDown, ChevronUp } from "lucide-react"; import { Calendar, Search, ArrowLeft, Users, ChevronDown, ChevronUp, UserCog, AlertTriangle } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "sonner"; import { toast } from "sonner";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ValidateurCombobox } from "@/components/ValidateurCombobox";
interface Collaborateur { interface Collaborateur {
id: number; id: number;
@@ -22,9 +31,9 @@ interface Collaborateur {
service: string; service: string;
serviceId: number | null; serviceId: number | null;
poste: string; poste: string;
role: string;
typeContrat: string; typeContrat: string;
dateEntree: string; dateEntree: string;
description: string;
soldeCPN: number; soldeCPN: number;
soldeCPNMoins1: number; soldeCPNMoins1: number;
soldeRTT: number; soldeRTT: number;
@@ -53,6 +62,41 @@ interface Service {
nom: string; nom: string;
} }
interface Validateur {
id: number;
nom: string;
email: string;
role: string;
}
const ROLES_DISPONIBLES = [
"Collaborateur", "Collaboratrice",
"Validateur", "Validatrice",
"RH", "Admin",
"Directeur de campus", "Directrice de campus",
"President",
"Apprenti", "Stagiaire",
];
const EDITION_INITIALE = {
id: 0,
nom: "",
prenom: "",
email: "",
societeId: "",
campusId: "",
serviceId: "",
poste: "",
role: "",
typeContrat: "",
dateEntree: "",
nPlus1Id: "",
nPlus2Id: "",
};
const estProfilIncomplet = (c: Collaborateur) =>
!c.societeId || !c.campusId || !c.serviceId || !c.dateEntree || !c.nPlus1Id;
const Teams = () => { const Teams = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
@@ -69,10 +113,95 @@ const Teams = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [expandedRows, setExpandedRows] = useState<Set<number>>(new Set()); const [expandedRows, setExpandedRows] = useState<Set<number>>(new Set());
const [validateurs, setValidateurs] = useState<Validateur[]>([]);
const [editionOuverte, setEditionOuverte] = useState(false);
const [edition, setEdition] = useState(EDITION_INITIALE);
const [enregistrement, setEnregistrement] = useState(false);
useEffect(() => { useEffect(() => {
chargerDonnees(); chargerDonnees();
}, []); }, []);
const chargerValidateurs = async () => {
try {
const token = localStorage.getItem('token');
const response = await fetch('/api/collaborateurs/validateurs', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
setValidateurs(await response.json());
}
} catch (error) {
console.error('Erreur chargement validateurs:', error);
}
};
const ouvrirEdition = (collab: Collaborateur) => {
setEdition({
id: collab.id,
nom: collab.nom || "",
prenom: collab.prenom || "",
email: collab.email || "",
societeId: collab.societeId ? String(collab.societeId) : "",
campusId: collab.campusId ? String(collab.campusId) : "",
serviceId: collab.serviceId ? String(collab.serviceId) : "",
poste: collab.poste || "",
role: collab.role || "",
typeContrat: collab.typeContrat || "",
dateEntree: collab.dateEntree ? collab.dateEntree.split("T")[0] : "",
nPlus1Id: collab.nPlus1Id ? String(collab.nPlus1Id) : "",
nPlus2Id: collab.nPlus2Id ? String(collab.nPlus2Id) : "",
});
chargerValidateurs();
setEditionOuverte(true);
};
const enregistrerEdition = async () => {
if (!edition.role) {
toast.error("Le rôle est obligatoire");
return;
}
setEnregistrement(true);
try {
const token = localStorage.getItem('token');
const response = await fetch(`/api/collaborateurs/${edition.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
nom: edition.nom,
prenom: edition.prenom,
email: edition.email,
societeId: edition.societeId ? parseInt(edition.societeId) : null,
campusId: edition.campusId ? parseInt(edition.campusId) : null,
serviceId: edition.serviceId ? parseInt(edition.serviceId) : null,
poste: edition.poste || null,
role: edition.role || null,
typeContrat: edition.typeContrat || null,
dateEntree: edition.dateEntree || null,
nPlus1Id: edition.nPlus1Id ? parseInt(edition.nPlus1Id) : null,
nPlus2Id: edition.nPlus2Id ? parseInt(edition.nPlus2Id) : null,
})
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || "Erreur lors de la mise à jour");
}
toast.success("Collaborateur mis à jour avec succès");
setEditionOuverte(false);
chargerDonnees();
} catch (error) {
toast.error(error instanceof Error ? error.message : "Erreur lors de la mise à jour");
} finally {
setEnregistrement(false);
}
};
const chargerDonnees = async () => { const chargerDonnees = async () => {
setLoading(true); setLoading(true);
try { try {
@@ -135,6 +264,23 @@ const Teams = () => {
} }
}; };
const getRoleBadge = (role: string) => {
switch (role) {
case 'Admin':
return <Badge variant="destructive">{role}</Badge>;
case 'RH':
return <Badge variant="default">{role}</Badge>;
case 'Validateur':
return <Badge variant="secondary">{role}</Badge>;
case 'Directeur de campus':
return <Badge className="bg-purple-600">{role}</Badge>;
case 'Apprenti':
return <Badge variant="outline" className="bg-blue-100 text-blue-700 border-blue-300">{role}</Badge>;
default:
return <Badge variant="outline">{role || 'Non défini'}</Badge>;
}
};
const getTypeContratBadge = (type: string) => { const getTypeContratBadge = (type: string) => {
switch (type) { switch (type) {
case 'forfait_jour': case 'forfait_jour':
@@ -170,6 +316,9 @@ const Teams = () => {
`${a.nom} ${a.prenom}`.localeCompare(`${b.nom} ${b.prenom}`) `${a.nom} ${a.prenom}`.localeCompare(`${b.nom} ${b.prenom}`)
); );
// Profils incomplets (nouveaux arrivants synchronisés depuis Entra sans société/campus/service/date d'entrée/validateur)
const profilsIncomplets = collaborateurs.filter(estProfilIncomplet);
// Listes uniques pour les filtres // Listes uniques pour les filtres
const societesUniques = [...new Set(collaborateurs.map(c => c.societe).filter(Boolean))].sort(); const societesUniques = [...new Set(collaborateurs.map(c => c.societe).filter(Boolean))].sort();
const campusUniques = [...new Set(collaborateurs.map(c => c.campus).filter(Boolean))].sort(); const campusUniques = [...new Set(collaborateurs.map(c => c.campus).filter(Boolean))].sort();
@@ -205,11 +354,182 @@ const Teams = () => {
</div> </div>
</div> </div>
</div> </div>
{profilsIncomplets.length > 0 && (
<Badge variant="outline" className="bg-amber-100 text-amber-800 border-amber-300 gap-1.5 px-3 py-1.5">
<AlertTriangle className="w-4 h-4" />
{profilsIncomplets.length} profil{profilsIncomplets.length > 1 ? 's' : ''} à compléter
</Badge>
)}
</div> </div>
</div> </div>
</header> </header>
<Dialog open={editionOuverte} onOpenChange={setEditionOuverte}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Modifier {edition.prenom} {edition.nom}</DialogTitle>
<DialogDescription>
Renseignez la date d'entrée, le rôle, le validateur, la société, le campus, le service et le type de contrat.
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 py-2">
<div className="space-y-2">
<Label htmlFor="ed-dateEntree">Date d'entrée</Label>
<Input
id="ed-dateEntree"
type="date"
value={edition.dateEntree}
onChange={(e) => setEdition({ ...edition, dateEntree: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Rôle</Label>
<Select
value={edition.role}
onValueChange={(v) => setEdition({ ...edition, role: v })}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionner..." />
</SelectTrigger>
<SelectContent>
{ROLES_DISPONIBLES.map((r) => (
<SelectItem key={r} value={r}>{r}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Type de contrat</Label>
<Select
value={edition.typeContrat}
onValueChange={(v) => setEdition({ ...edition, typeContrat: v })}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionner..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="forfait_jour">Forfait jours</SelectItem>
<SelectItem value="37h">37h</SelectItem>
<SelectItem value="Apprentissage">Alternant</SelectItem>
<SelectItem value="tempspartiel">Temps partiel</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Société</Label>
<Select
value={edition.societeId}
onValueChange={(v) => setEdition({ ...edition, societeId: v })}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionner..." />
</SelectTrigger>
<SelectContent>
{societes.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>{s.nom}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Campus</Label>
<Select
value={edition.campusId}
onValueChange={(v) => setEdition({ ...edition, campusId: v })}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionner..." />
</SelectTrigger>
<SelectContent>
{campusList.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>{c.nom}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Service</Label>
<Select
value={edition.serviceId}
onValueChange={(v) => setEdition({ ...edition, serviceId: v })}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionner..." />
</SelectTrigger>
<SelectContent>
{services.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>{s.nom}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2 md:col-span-2">
<Label>Validateur (N+1)</Label>
<ValidateurCombobox
validateurs={validateurs}
value={edition.nPlus1Id}
onChange={(v) => setEdition({ ...edition, nPlus1Id: v })}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditionOuverte(false)} disabled={enregistrement}>
Annuler
</Button>
<Button onClick={enregistrerEdition} disabled={enregistrement}>
{enregistrement ? "Enregistrement..." : "Enregistrer"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Profils à compléter (nouveaux arrivants synchronisés depuis Entra) */}
{profilsIncomplets.length > 0 && (
<Card className="shadow-card border-0 mb-8 border-l-4 border-l-amber-400">
<CardHeader>
<div className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-amber-600" />
<CardTitle>Profils à compléter</CardTitle>
</div>
<CardDescription>
Ces collaborateurs ont é créés par la synchronisation Entra mais il manque leur société, campus, service, date d'entrée ou validateur.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{profilsIncomplets.map((collab) => (
<div
key={collab.id}
className="flex items-center justify-between p-3 rounded-lg border border-amber-200 bg-amber-50"
>
<div>
<p className="font-medium text-sm">{collab.nom} {collab.prenom}</p>
<p className="text-xs text-muted-foreground">{collab.email}</p>
<div className="flex flex-wrap gap-1 mt-1">
{!collab.societeId && <Badge variant="outline" className="text-xs bg-white">Société manquante</Badge>}
{!collab.campusId && <Badge variant="outline" className="text-xs bg-white">Campus manquant</Badge>}
{!collab.serviceId && <Badge variant="outline" className="text-xs bg-white">Service manquant</Badge>}
{!collab.dateEntree && <Badge variant="outline" className="text-xs bg-white">Date d'entrée manquante</Badge>}
{!collab.nPlus1Id && <Badge variant="outline" className="text-xs bg-white">Validateur manquant</Badge>}
</div>
</div>
<Button size="sm" onClick={() => ouvrirEdition(collab)}>
<UserCog className="w-4 h-4 mr-2" />
Compléter
</Button>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Filtres */} {/* Filtres */}
<Card className="shadow-card border-0 mb-8"> <Card className="shadow-card border-0 mb-8">
<CardContent className="p-6"> <CardContent className="p-6">
@@ -308,6 +628,8 @@ const Teams = () => {
<TableHead className="w-8"></TableHead> <TableHead className="w-8"></TableHead>
<TableHead>Nom / Prénom</TableHead> <TableHead>Nom / Prénom</TableHead>
<TableHead>Email</TableHead> <TableHead>Email</TableHead>
<TableHead>Poste</TableHead>
<TableHead>Rôle</TableHead>
<TableHead>Société</TableHead> <TableHead>Société</TableHead>
<TableHead>Campus</TableHead> <TableHead>Campus</TableHead>
<TableHead>Service</TableHead> <TableHead>Service</TableHead>
@@ -339,6 +661,8 @@ const Teams = () => {
<TableCell className="text-muted-foreground"> <TableCell className="text-muted-foreground">
{collab.email} {collab.email}
</TableCell> </TableCell>
<TableCell className="text-muted-foreground">{collab.poste || '-'}</TableCell>
<TableCell>{getRoleBadge(collab.role)}</TableCell>
<TableCell>{collab.societe || '-'}</TableCell> <TableCell>{collab.societe || '-'}</TableCell>
<TableCell>{collab.campus || '-'}</TableCell> <TableCell>{collab.campus || '-'}</TableCell>
<TableCell>{collab.service || '-'}</TableCell> <TableCell>{collab.service || '-'}</TableCell>
@@ -368,7 +692,7 @@ const Teams = () => {
{/* Ligne de détails expansible */} {/* Ligne de détails expansible */}
{expandedRows.has(collab.id) && ( {expandedRows.has(collab.id) && (
<TableRow className="bg-muted/30"> <TableRow className="bg-muted/30">
<TableCell colSpan={11}> <TableCell colSpan={13}>
<div className="p-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="p-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Infos personnelles */} {/* Infos personnelles */}
<div className="space-y-2"> <div className="space-y-2">