Compare commits

..

1 Commits

Author SHA1 Message Date
oimer 762f9cfd4a GTARH_Modif_anno27 2026-05-28 11:11:52 +02:00
7 changed files with 336 additions and 989 deletions
+28 -142
View File
@@ -1,4 +1,4 @@

const express = require('express'); const express = require('express');
const cors = require('cors'); const cors = require('cors');
@@ -943,23 +943,7 @@ 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';
@@ -2473,82 +2457,6 @@ 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
@@ -5608,8 +5516,8 @@ app.get('/getAllDetailedCounters', async (req, res) => {
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; let currentYear = currentYearSys; // CP N (2026 ou 2027 après bascule)
let previousYear = currentYearSys - 1; let previousYear = currentYearSys - 1; // CP N-1
if (cpTypeForCheck.recordset.length > 0) { if (cpTypeForCheck.recordset.length > 0) {
const cpTypeId = cpTypeForCheck.recordset[0].Id; const cpTypeId = cpTypeForCheck.recordset[0].Id;
@@ -5620,6 +5528,7 @@ app.get('/getAllDetailedCounters', async (req, res) => {
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) {
@@ -5629,8 +5538,9 @@ app.get('/getAllDetailedCounters', async (req, res) => {
} }
} }
const anneeRTT = currentYearSys; // RTT et Récup : toujours l'année civile système (jamais basculent)
const anneeRecup = currentYearSys; const anneeRTT = currentYearSys; // 2026
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}`);
@@ -5682,7 +5592,7 @@ app.get('/getAllDetailedCounters', async (req, res) => {
.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, SoldeReporte SELECT Total, Solde
FROM CompteurConges FROM CompteurConges
WHERE CollaborateurADId = @collabId WHERE CollaborateurADId = @collabId
AND TypeCongeId = @typeId AND TypeCongeId = @typeId
@@ -5694,12 +5604,11 @@ app.get('/getAllDetailedCounters', async (req, res) => {
let cpTotalN, cpSoldeN; let cpTotalN, cpSoldeN;
if (cpN.length > 0) { if (cpN.length > 0) {
cpTotalN = parseFloat(cpN[0].Total); cpTotalN = parseFloat(cpN[0].Total);
const soldeReporte = parseFloat(cpN[0].SoldeReporte || 0); cpSoldeN = parseFloat(cpN[0].Solde);
// 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 {
@@ -5722,6 +5631,7 @@ app.get('/getAllDetailedCounters', async (req, res) => {
} }
} }
// ⭐ 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}`,
@@ -5734,7 +5644,7 @@ app.get('/getAllDetailedCounters', async (req, res) => {
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: Math.max(0, parseFloat((cpTotalN - cpSoldeN).toFixed(2))), consomme: 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'
@@ -5746,7 +5656,7 @@ app.get('/getAllDetailedCounters', async (req, res) => {
.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, SoldeReporte SELECT Total, Solde
FROM CompteurConges FROM CompteurConges
WHERE CollaborateurADId = @collabId WHERE CollaborateurADId = @collabId
AND TypeCongeId = @typeId AND TypeCongeId = @typeId
@@ -5755,7 +5665,7 @@ app.get('/getAllDetailedCounters', async (req, res) => {
const cpN1 = cpN1Result.recordset; const cpN1 = cpN1Result.recordset;
if (cpN1.length > 0 ) { if (cpN1.length > 0 && cpN1[0].Solde > 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({
@@ -5770,7 +5680,7 @@ app.get('/getAllDetailedCounters', async (req, res) => {
annee: previousYear, annee: previousYear,
total, total,
solde, solde,
consomme: Math.max(0, parseFloat((total - solde).toFixed(2))), consomme: 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'
@@ -5830,7 +5740,7 @@ app.get('/getAllDetailedCounters', async (req, res) => {
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: Math.max(0, parseFloat((rttTotalN - rttSoldeN).toFixed(2))), consomme: 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'
@@ -5869,7 +5779,7 @@ app.get('/getAllDetailedCounters', async (req, res) => {
annee: anneeRecup, annee: anneeRecup,
total: recupTotal, total: recupTotal,
solde: recupSolde, solde: recupSolde,
consomme: Math.max(0, parseFloat((recupTotal - recupSolde).toFixed(2))), consomme: 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'
@@ -5887,6 +5797,7 @@ app.get('/getAllDetailedCounters', async (req, res) => {
}); });
// ================================================ // ================================================
// ROUTES POUR LA GESTION DES COLLABORATEURS - Adaptées à votre structure BDD // ROUTES POUR LA GESTION DES COLLABORATEURS - Adaptées à votre structure BDD
// ================================================ // ================================================
@@ -5910,11 +5821,11 @@ app.get('/getAllDetailedCounters', async (req, res) => {
ca.nom, ca.nom,
ca.prenom, ca.prenom,
ca.email, ca.email,
ca.description as poste, ca.fonction 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,
@@ -6043,31 +5954,6 @@ app.get('/getAllDetailedCounters', async (req, res) => {
// 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 {
@@ -6087,11 +5973,11 @@ app.get('/getAllDetailedCounters', async (req, res) => {
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);
@@ -6115,10 +6001,10 @@ app.get('/getAllDetailedCounters', async (req, res) => {
.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), poste || null) .input('description', sql.NVarChar(500), description || null)
.input('role', sql.NVarChar(100), role || null)
.query(` .query(`
UPDATE CollaborateurAD UPDATE CollaborateurAD
SET nom = @nom, SET nom = @nom,
@@ -6127,10 +6013,10 @@ app.get('/getAllDetailedCounters', async (req, res) => {
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
@@ -1,79 +0,0 @@
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,6 +54,9 @@ 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: {
+1 -26
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, AlertTriangle, Calendar, Users, Clock, CheckCircle, XCircle, AlertCircle,
Plus, LogOut, FileSpreadsheet, RefreshCw, ClipboardList, Plus, LogOut, FileSpreadsheet, RefreshCw, ClipboardList,
FileText, ChevronLeft, ChevronRight FileText, ChevronLeft, ChevronRight
} from "lucide-react"; } from "lucide-react";
@@ -55,8 +55,6 @@ 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 ──────────────────────────────────────────────────────────────────
@@ -186,8 +184,6 @@ 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,
})) }))
); );
} }
@@ -389,12 +385,6 @@ 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") },
@@ -449,21 +439,6 @@ 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) => (
+28 -22
View File
@@ -64,6 +64,7 @@ interface SoldesALaDate {
}>; }>;
} }
// ⭐ Interface prévisionnel (identique à SaisieManuelle)
interface Previsionnel { interface Previsionnel {
mode: string; mode: string;
isAnticipe: boolean; isAnticipe: boolean;
@@ -370,7 +371,7 @@ const HistoriqueSnapshotDialog = ({ compteur, onClose }: HistoriqueDialogProps)
}; };
// ===================================================== // =====================================================
// PANNEAU PRÉVISIONNEL // PANNEAU PRÉVISIONNEL (réutilisé depuis SaisieManuelle)
// ===================================================== // =====================================================
interface PrevisonnnelPanelProps { interface PrevisonnnelPanelProps {
@@ -399,6 +400,10 @@ 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;
@@ -424,6 +429,8 @@ 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">
@@ -434,6 +441,7 @@ 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">
@@ -459,6 +467,7 @@ 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">
@@ -494,6 +503,7 @@ 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">
@@ -555,23 +565,22 @@ 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();
@@ -585,6 +594,7 @@ 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;
@@ -782,8 +792,7 @@ 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()}>
{/* ✅ CORRECTION : En cours = 2027 */} {a} {a === currentYear && '(en cours)'}
{a} {a === anneeEnCours && '(en cours)'}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -862,25 +871,16 @@ 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>
{/* CP 2027 = En cours */} {compteur.annee === currentYear && (
{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>
)} )}
{/* CP 2026 = N-1 */} {compteur.annee === currentYear - 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>
{/* ✅ CORRECTION : consommé jamais négatif */} <TableCell className="text-right text-muted-foreground">{compteur.consomme}</TableCell>
<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,6 +893,7 @@ 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"
@@ -911,7 +912,9 @@ 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>
@@ -925,6 +928,7 @@ 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
@@ -938,6 +942,7 @@ const GestionCompteurs = () => {
</p> </p>
</div> </div>
{/* ⭐ Panneau prévisionnel */}
{selectedCollaborateurId && dateConsultation && ( {selectedCollaborateurId && dateConsultation && (
<PrevisonnnelPanel <PrevisonnnelPanel
collaborateurId={selectedCollaborateurId} collaborateurId={selectedCollaborateurId}
@@ -947,6 +952,7 @@ 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
@@ -967,7 +973,7 @@ const GestionCompteurs = () => {
<Dialog open={showEditDialog} onOpenChange={setShowEditDialog}> <Dialog open={showEditDialog} onOpenChange={setShowEditDialog}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Modifier le compteur </DialogTitle> <DialogTitle>Modifier le compteur (V2)</DialogTitle>
<DialogDescription> <DialogDescription>
{selectedCompteur && `${selectedCompteur.employe} - ${selectedCompteur.typeConge} ${selectedCompteur.annee}`} {selectedCompteur && `${selectedCompteur.employe} - ${selectedCompteur.typeConge} ${selectedCompteur.annee}`}
</DialogDescription> </DialogDescription>
@@ -1014,7 +1020,7 @@ const GestionCompteurs = () => {
}; };
// ===================================================== // =====================================================
// SNAPSHOT À UNE DATE // SNAPSHOT À UNE DATE (affiché en complément du prévisionnel pour les dates passées)
// ===================================================== // =====================================================
interface SnapshotDateDisplayProps { interface SnapshotDateDisplayProps {
+10 -130
View File
@@ -33,14 +33,6 @@ 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 = (
@@ -425,8 +417,6 @@ 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[]>([]);
@@ -466,40 +456,6 @@ 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}` };
@@ -612,30 +568,17 @@ 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;
@@ -663,35 +606,20 @@ const SaisieManuelle = () => {
body: JSON.stringify({ jours: jJPOSF }), body: JSON.stringify({ jours: jJPOSF }),
}); });
} }
} else { } else ko++;
ko++; } catch { 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 { } else throw new Error("Aucune demande créée");
// ⭐ 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 || "Une erreur est survenue"); setError(e.message || "Erreur lors de l'enregistrement");
} 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)
// ═════════════════════════════════════════════════════════════════════════ // ═════════════════════════════════════════════════════════════════════════
@@ -836,21 +764,14 @@ 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 => (
const aUnDoublon = !!doublonsInfo[e.id.toString()]; <Badge key={e.id} variant="secondary" className="px-2.5 py-1 text-sm">
return ( {e.nom} {e.prenom}
<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>
)} )}
@@ -945,44 +866,6 @@ 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>
@@ -1086,13 +969,10 @@ const SaisieManuelle = () => {
Annuler Annuler
</button> </button>
<button <button
type="button" onClick={handleSubmit} type="button" onClick={handleSubmit} disabled={loading}
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</>}
+3 -327
View File
@@ -4,20 +4,11 @@ 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, UserCog, AlertTriangle } from "lucide-react"; import { Calendar, Search, ArrowLeft, Users, ChevronDown, ChevronUp } 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;
@@ -31,9 +22,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;
@@ -62,41 +53,6 @@ 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("");
@@ -113,95 +69,10 @@ 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 {
@@ -264,23 +135,6 @@ 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':
@@ -316,9 +170,6 @@ 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();
@@ -354,182 +205,11 @@ 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">
@@ -628,8 +308,6 @@ 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>
@@ -661,8 +339,6 @@ 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>
@@ -692,7 +368,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={13}> <TableCell colSpan={11}>
<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">