Compare commits

..

2 Commits

Author SHA1 Message Date
f22979a44a Reapply "V1_Sans_Congé_Anticipéfemini collaboratrice"
This reverts commit 7f15e380e3.
2025-11-17 10:39:27 +01:00
f3a3746c3e Revert "femini collaboratrice"
This reverts commit 148a02099f.
2025-11-17 10:38:50 +01:00
41 changed files with 26488 additions and 2518 deletions

8087
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"devDependencies": {
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@vitejs/plugin-react": "^5.0.2",
"jest": "^30.1.3",
"jsdom": "^26.1.0",
"nodemon": "^3.1.10",
"vitest": "^3.2.4"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^5.1.0",
"framer-motion": "^12.23.22",
"node-cron": "^4.2.1"
}
}

4145
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/GA.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GTA</title>
</head>

1777
project/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,7 +11,14 @@
"dependencies": {
"@azure/msal-browser": "^4.19.0",
"@azure/msal-react": "^3.0.17",
"axios": "^1.12.2",
"cors": "^2.8.5",
"crypto": "^1.0.1",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"lucide-react": "^0.344.0",
"multer": "^2.0.2",
"mysql2": "^3.15.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.7.1"
@@ -24,6 +31,7 @@
"jest": "^30.1.1",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"vite": "^5.4.2"
"vite": "^5.4.2",
"vitest": "^3.2.4"
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,19 @@
// webhook-config.js
export const WEBHOOKS= {
COLLABORATEURS_URL: process.env.COLLABORATEURS_URL || 'http://localhost:3000',
RH_URL: process.env.RH_URL || 'http://localhost:3001',
SECRET_KEY: process.env.WEBHOOK_SECRET || 'secret-key-securise'
};
// Types d'événements
export const EVENTS= {
DEMANDE_VALIDATED: 'demande.validated',
DEMANDE_CREATED: 'demande.created',
DEMANDE_UPDATED: 'demande.updated',
DEMANDE_DELETED: 'demande.deleted',
COMPTEUR_UPDATED: 'compteur.updated'
};

View File

@@ -0,0 +1,115 @@
// webhook-utils.js (VERSION ES MODULES)
// Pour projets avec "type": "module" dans package.json
import axios from 'axios';
import crypto from 'crypto';
class WebhookManager {
constructor(secretKey) {
this.secretKey = secretKey;
}
/**
* Génère une signature HMAC SHA-256 pour sécuriser le webhook
* @param {Object} payload - Les données à signer
* @returns {string} La signature hexadécimale
*/
generateSignature(payload) {
return crypto
.createHmac('sha256', this.secretKey)
.update(JSON.stringify(payload))
.digest('hex');
}
/**
* Vérifie la signature d'un webhook reçu
* @param {Object} payload - Les données reçues
* @param {string} receivedSignature - La signature reçue dans le header
* @returns {boolean} True si la signature est valide
*/
verifySignature(payload, receivedSignature) {
if (!receivedSignature) {
console.error('❌ Aucune signature fournie');
return false;
}
try {
const expectedSignature = this.generateSignature(payload);
return crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(receivedSignature)
);
} catch (error) {
console.error('❌ Erreur vérification signature:', error);
return false;
}
}
/**
* Envoie un webhook à une URL cible avec retry automatique
* @param {string} targetUrl - URL du serveur cible
* @param {string} eventType - Type d'événement (ex: 'demande.validated')
* @param {Object} data - Données de l'événement
* @param {number} retries - Nombre de tentatives (défaut: 3)
* @returns {Promise<Object>} La réponse du serveur
*/
async sendWebhook(targetUrl, eventType, data, retries = 3) {
const payload = {
event: eventType,
data: data,
timestamp: new Date().toISOString()
};
const signature = this.generateSignature(payload);
for (let attempt = 1; attempt <= retries; attempt++) {
try {
console.log(`📤 Envoi webhook: ${eventType} vers ${targetUrl} (tentative ${attempt}/${retries})`);
const response = await axios.post(
`${targetUrl}/api/webhook/receive`,
payload,
{
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': signature
},
timeout: 5000 // 5 secondes de timeout
}
);
console.log(`✅ Webhook envoyé avec succès: ${eventType}`);
return response.data;
} catch (error) {
console.error(`❌ Erreur envoi webhook (tentative ${attempt}/${retries}):`, error.message);
if (attempt === retries) {
console.error(`❌ Échec définitif du webhook après ${retries} tentatives`);
throw error;
}
// Attendre avant de réessayer (backoff exponentiel)
const waitTime = 1000 * attempt;
console.log(`⏳ Nouvelle tentative dans ${waitTime}ms...`);
await new Promise(resolve => setTimeout(resolve, waitTime));
}
}
}
/**
* Envoie un webhook sans attendre la réponse (fire and forget)
* Utile pour ne pas bloquer l'exécution
* @param {string} targetUrl - URL du serveur cible
* @param {string} eventType - Type d'événement
* @param {Object} data - Données de l'événement
*/
sendWebhookAsync(targetUrl, eventType, data) {
this.sendWebhook(targetUrl, eventType, data)
.catch(error => {
console.error('❌ Webhook async échoué (non bloquant):', error.message);
});
}
}
export default WebhookManager;

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 2.8 MiB

View File

@@ -2,146 +2,162 @@
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') { http_response_code(200); exit(); }
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit();
}
header("Content-Type: application/json");
ini_set('display_errors',1); ini_set('display_startup_errors',1); error_reporting(E_ALL);
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
$host="192.168.0.4"; $dbname="DemandeConge"; $username="wpuser"; $password="-2b/)ru5/Bi8P[7_";
$conn = new mysqli($host,$username,$password,$dbname);
if ($conn->connect_error) { echo json_encode(["success"=>false,"message"=>"Erreur DB: ".$conn->connect_error]); exit(); }
$host = "192.168.0.4";
$username = "wpuser";
$password = "-2b/)ru5/Bi8P[7_";
$dbname = "DemandeConge";
$userId = isset($_GET['user_id']) ? (int)$_GET['user_id'] : null;
if (!$userId) { echo json_encode(["success"=>false,"message"=>"user_id manquant"]); $conn->close(); exit(); }
function getLeaveYear($date=null){ $d=$date?new DateTime($date):new DateTime(); $y=(int)$d->format('Y'); return ((int)$d->format('m')<6)?$y-1:$y;}
function getRTTYear($date=null){ $d=$date?new DateTime($date):new DateTime(); return (int)$d->format('Y');}
function getWorkingDays($start,$end){ $c=new DateTime($start); $e=new DateTime($end); $days=0; while($c<=$e){ $n=(int)$c->format('N'); if($n<6) $days++; $c->modify('+1 day'); } return $days;}
// Récupérer les typeIds utiles
function getTypeId($conn,$name){ $s=$conn->prepare("SELECT Id FROM TypeConge WHERE Nom=?"); $s->bind_param("s",$name); $s->execute(); $res=$s->get_result(); $id=null; if($r=$res->fetch_assoc()) $id=(int)$r['Id']; $s->close(); return $id; }
$cpTypeId = getTypeId($conn,'Congé payé');
$rttTypeId = getTypeId($conn,'RTT');
$absTypeId = getTypeId($conn,'Congé maladie');
$leaveYear = getLeaveYear();
$rttYear = getRTTYear();
$currentDate = date('Y-m-d');
// --- Soldes initiaux (CompteurConges pour CollaborateurAD) ---
$cpSolde = 0; $rttSolde = 0; $absSolde = 0;
if ($cpTypeId !== null) {
$q="SELECT Solde FROM CompteurConges WHERE CollaborateurADId=? AND TypeCongeId=? AND Annee=?";
$s=$conn->prepare($q); $s->bind_param("iii",$userId,$cpTypeId,$leaveYear); $s->execute(); $res=$s->get_result(); if($r=$res->fetch_assoc()) $cpSolde=$r['Solde']; $s->close();
}
if ($rttTypeId !== null) {
$q="SELECT Solde FROM CompteurConges WHERE CollaborateurADId=? AND TypeCongeId=? AND Annee=?";
$s=$conn->prepare($q); $s->bind_param("iii",$userId,$rttTypeId,$rttYear); $s->execute(); $res=$s->get_result(); if($r=$res->fetch_assoc()) $rttSolde=$r['Solde']; $s->close();
}
if ($absTypeId !== null) {
$q="SELECT Solde FROM CompteurConges WHERE CollaborateurADId=? AND TypeCongeId=? AND Annee=?";
$s=$conn->prepare($q); $s->bind_param("iii",$userId,$absTypeId,$rttYear); $s->execute(); $res=$s->get_result(); if($r=$res->fetch_assoc()) $absSolde=$r['Solde']; $s->close();
$conn = new mysqli($host, $username, $password, $dbname);
if ($conn->connect_error) {
error_log("Erreur DB: " . $conn->connect_error);
echo json_encode(['success' => false, 'message' => 'Erreur de connexion à la base de données']);
exit;
}
// --- Calcul CP en cours ---
$cpInProcess = 0;
if ($cpTypeId !== null) {
$sql = "
SELECT dc.Id, dc.DateDebut, dc.DateFin, dct.NombreJours
FROM DemandeConge dc
LEFT JOIN DemandeCongeType dct
ON dct.DemandeCongeId = dc.Id AND dct.TypeCongeId = ?
WHERE dc.CollaborateurADId = ?
AND dc.Statut IN ('En attente','Validée')
AND dc.DateFin >= ?
AND (dct.NombreJours IS NOT NULL OR FIND_IN_SET(?, dc.TypeCongeId))
";
$s = $conn->prepare($sql);
$s->bind_param("iisi", $cpTypeId, $userId, $currentDate, $cpTypeId);
$s->execute();
$res = $s->get_result();
while ($r = $res->fetch_assoc()) {
if ($r['NombreJours'] !== null) {
$cpInProcess += (float)$r['NombreJours'];
} else {
$cpInProcess += getWorkingDays($r['DateDebut'], $r['DateFin']);
$today = new DateTime();
$yearCurrent = (int)$today->format('Y');
$yearNMinus1 = $yearCurrent - 1;
function getTypeId($conn, $nom) {
$stmt = $conn->prepare("SELECT Id FROM TypeConge WHERE Nom=?");
$stmt->bind_param("s", $nom);
$stmt->execute();
$result = $stmt->get_result();
$id = null;
if ($row = $result->fetch_assoc()) {
$id = (int)$row['Id'];
}
}
$s->close();
$stmt->close();
error_log("TypeConge '$nom' => Id $id");
return $id;
}
// --- Calcul RTT en cours ---
$rttInProcess = 0;
if ($rttTypeId !== null) {
$sql = "
SELECT dc.Id, dc.DateDebut, dc.DateFin, dct.NombreJours
FROM DemandeConge dc
LEFT JOIN DemandeCongeType dct
ON dct.DemandeCongeId = dc.Id AND dct.TypeCongeId = ?
WHERE dc.CollaborateurADId = ?
AND dc.Statut IN ('En attente','Validée')
AND dc.DateFin >= ?
AND (dct.NombreJours IS NOT NULL OR FIND_IN_SET(?, dc.TypeCongeId))
";
$s = $conn->prepare($sql);
$s->bind_param("iisi", $rttTypeId, $userId, $currentDate, $rttTypeId);
$s->execute();
$res = $s->get_result();
while ($r = $res->fetch_assoc()) {
if ($r['NombreJours'] !== null) {
$rttInProcess += (float)$r['NombreJours'];
} else {
$rttInProcess += getWorkingDays($r['DateDebut'], $r['DateFin']);
}
}
$s->close();
$cpTypeId = getTypeId($conn, 'Congé payé');
$rttTypeId = getTypeId($conn, 'RTT');
$soldeReportInitial_CP = 0.0;
$soldeReportInitial_RTT = 0.0;
$collaborateursResult = $conn->query("SELECT id FROM CollaborateurAD");
if (!$collaborateursResult) {
error_log("Erreur récupération collaborateurs : ".$conn->error);
echo json_encode(['success' => false, 'message' => 'Erreur récupération collaborateurs']);
exit;
}
// --- Calcul absenteisme validé ---
$absenteism = 0;
if ($absTypeId !== null) {
$sql = "
SELECT dc.DateDebut, dc.DateFin, dct.NombreJours
FROM DemandeConge dc
LEFT JOIN DemandeCongeType dct
ON dct.DemandeCongeId = dc.Id AND dct.TypeCongeId = ?
WHERE dc.CollaborateurADId = ?
AND dc.Statut = 'Validée'
AND (dct.NombreJours IS NOT NULL OR FIND_IN_SET(?, dc.TypeCongeId))
";
$s = $conn->prepare($sql);
$s->bind_param("iii", $absTypeId, $userId, $absTypeId);
$s->execute();
$res = $s->get_result();
while ($r = $res->fetch_assoc()) {
if ($r['NombreJours'] !== null) {
$absenteism += (float)$r['NombreJours'];
} else {
$d1 = new DateTime($r['DateDebut']); $d2 = new DateTime($r['DateFin']);
$absenteism += ($d2->diff($d1)->days + 1);
while ($collab = $collaborateursResult->fetch_assoc()) {
$collabId = (int)$collab['id'];
if ($cpTypeId !== null) {
$existsStmt = $conn->prepare("SELECT Id FROM CompteurConges WHERE CollaborateurADId=? AND TypeCongeId=? AND Annee=?");
$existsStmt->bind_param("iii", $collabId, $cpTypeId, $yearNMinus1);
$existsStmt->execute();
$existsStmt->store_result();
if ($existsStmt->num_rows === 0) {
$insertStmt = $conn->prepare("INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Solde, Total, SoldeReporte) VALUES (?, ?, ?, ?, ?, ?)");
$insertStmt->bind_param("iiiddd", $collabId, $cpTypeId, $yearNMinus1, $soldeReportInitial_CP, $soldeReportInitial_CP, $soldeReportInitial_CP);
if (!$insertStmt->execute()) {
error_log("Erreur insertion CP N-1 collaborateur $collabId : ".$insertStmt->error);
}
$insertStmt->close();
}
$existsStmt->close();
}
if ($rttTypeId !== null) {
$existsStmt = $conn->prepare("SELECT Id FROM CompteurConges WHERE CollaborateurADId=? AND TypeCongeId=? AND Annee=?");
$existsStmt->bind_param("iii", $collabId, $rttTypeId, $yearNMinus1);
$existsStmt->execute();
$existsStmt->store_result();
if ($existsStmt->num_rows === 0) {
$insertStmt = $conn->prepare("INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Solde, Total, SoldeReporte) VALUES (?, ?, ?, ?, ?, ?)");
$insertStmt->bind_param("iiiddd", $collabId, $rttTypeId, $yearNMinus1, $soldeReportInitial_RTT, $soldeReportInitial_RTT, $soldeReportInitial_RTT);
if (!$insertStmt->execute()) {
error_log("Erreur insertion RTT N-1 collaborateur $collabId : ".$insertStmt->error);
}
$insertStmt->close();
}
$existsStmt->close();
}
$s->close();
}
$availableCPCalculated = max(0, $cpSolde - $cpInProcess);
$availableRTTCalculated = max(0, $rttSolde - $rttInProcess);
$cpStart = new DateTime("$yearCurrent-06-01");
$cpEnd = new DateTime(($yearCurrent + 1) . "-05-31");
$rttStart = new DateTime("$yearCurrent-01-01");
$rttEnd = new DateTime("$yearCurrent-12-31");
echo json_encode([
"success" => true,
"message" => "Compteurs récupérés avec succès.",
"counters" => [
"availableCP" => (int)$availableCPCalculated,
"availableRTT" => (int)$availableRTTCalculated,
"availableABS" => (int)$absSolde,
"rttInProcess" => (int)$rttInProcess,
"absenteism" => (int)$absenteism
],
"debug" => [
"cpSolde"=>$cpSolde,"cpInProcess"=>$cpInProcess,
"rttSolde"=>$rttSolde,"rttInProcess"=>$rttInProcess,
"absSolde"=>$absSolde,"absenteism"=>$absenteism
]
]);
$cpAnnualDays = 25;
$rttAnnualDays = 10;
$cpPeriodDays = $cpEnd->diff($cpStart)->days + 1;
$rttPeriodDays = $rttEnd->diff($rttStart)->days + 1;
$cpDailyIncrement = $cpAnnualDays / $cpPeriodDays;
$rttDailyIncrement = $rttAnnualDays / $rttPeriodDays;
error_log("Incrément CP jour : $cpDailyIncrement");
error_log("Incrément RTT jour : $rttDailyIncrement");
if ($today >= $cpStart && $today <= $cpEnd && $cpTypeId !== null) {
$exerciseYear = (int)$cpStart->format('Y');
$stmt = $conn->prepare("UPDATE CompteurConges SET Solde = Solde + ? WHERE TypeCongeId = ? AND Annee = ?");
$stmt->bind_param("dii", $cpDailyIncrement, $cpTypeId, $exerciseYear);
if (!$stmt->execute()) {
error_log("Erreur incrément CP N : ".$stmt->error);
}
$stmt->close();
}
if ($today >= $rttStart && $today <= $rttEnd && $rttTypeId !== null) {
$exerciseYear = $yearCurrent;
$stmt = $conn->prepare("UPDATE CompteurConges SET Solde = Solde + ? WHERE TypeCongeId = ? AND Annee = ?");
$stmt->bind_param("dii", $rttDailyIncrement, $rttTypeId, $exerciseYear);
if (!$stmt->execute()) {
error_log("Erreur incrément RTT N : ".$stmt->error);
}
$stmt->close();
}
// Récupérer les compteurs actuels de l'utilisateur demandé en GET
$userId = isset($_GET['user_id']) ? (int)$_GET['user_id'] : 0;
$data = [];
if ($userId > 0) {
$stmt = $conn->prepare(
"SELECT tc.Nom, cc.Annee, cc.Solde, cc.Total, cc.SoldeReporte
FROM CompteurConges cc
JOIN TypeConge tc ON cc.TypeCongeId = tc.Id
WHERE cc.CollaborateurADId = ?"
);
$stmt->bind_param("i", $userId);
$stmt->execute();
$result = $stmt->get_result();
while ($row = $result->fetch_assoc()) {
$data[$row['Nom']] = [
'Annee' => $row['Annee'],
'Solde' => (float)$row['Solde'],
'Total' => (float)$row['Total'],
'SoldeReporte' => (float)$row['SoldeReporte'],
];
}
$stmt->close();
}
$conn->close();
?>
echo json_encode([
'success' => true,
'message' => 'Compteurs mis à jour',
'counters' => $data,
]);
exit;

View File

@@ -0,0 +1,75 @@
<?php
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
header('Content-Type: application/json; charset=utf-8');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit();
}
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
$host = "192.168.0.4";
$username = "wpuser";
$password = "-2b/)ru5/Bi8P[7_";
$dbname = "DemandeConge";
$conn = new mysqli($host, $username, $password, $dbname);
if ($conn->connect_error) {
http_response_code(500);
echo json_encode(["success" => false, "message" => "Erreur de connexion à la base de données"]);
exit;
}
$user_id = isset($_GET['user_id']) ? intval($_GET['user_id']) : 0;
if ($user_id <= 0) {
http_response_code(400);
echo json_encode(["success" => false, "message" => "Paramètre user_id manquant ou invalide"]);
exit;
}
// Récupérer les notifications non lues ou récentes (ex: dernières 30 j)
$query = "
SELECT Id, Titre, Message, Type, DemandeCongeId, DateCreation, lu
FROM Notifications
WHERE CollaborateurADId = ?
ORDER BY DateCreation DESC
LIMIT 50
";
$stmt = $conn->prepare($query);
if (!$stmt) {
http_response_code(500);
echo json_encode(["success" => false, "message" => "Erreur préparation requête"]);
exit;
}
$stmt->bind_param('i', $user_id); // ✅ correction ici
$stmt->execute();
$result = $stmt->get_result();
$notifications = [];
while ($row = $result->fetch_assoc()) {
$notifications[] = [
"Id" => intval($row['Id']),
"Titre" => $row['Titre'],
"Message" => $row['Message'],
"Type" => $row['Type'],
"DemandeCongeId" => intval($row['DemandeCongeId']),
"DateCreation" => $row['DateCreation'],
"lu" => intval($row['lu']) === 1,
];
}
$stmt->close();
$conn->close();
echo json_encode([
"success" => true,
"notifications" => $notifications
]);

View File

@@ -190,15 +190,9 @@ try {
$stmtEmployees->close();
} elseif ($role === 'directeur de campus') {
$filters['societes'] = [];
$filters['services'] = [];
$result = $conn->query("SELECT DISTINCT Nom as societe_nom FROM Societe ORDER BY societe_nom");
while($row = $result->fetch_assoc()) $filters['societes'][] = $row['societe_nom'];
$result = $conn->query("SELECT DISTINCT Nom as service_nom FROM Services ORDER BY service_nom");
while($row = $result->fetch_assoc()) $filters['services'][] = $row['service_nom'];
// Pour le directeur, les filtres se basent sur les congés de son campus
$filters['societes'] = array_values(array_unique(array_column($leaves, 'societe_nom')));
$filters['services'] = array_values(array_unique(array_column($leaves, 'service_nom')));
} elseif (in_array($role, ['president', 'rh'])) {
// 🔹 Récupérer tous les campus, sociétés, services de manière unique

View File

@@ -75,18 +75,17 @@ foreach ($members as $m) {
$nom = $m["surname"] ?? "";
$email = $m["mail"] ?? "";
$service = $m["department"] ?? "";
$description = $m["jobTitle"] ?? null;
if (!$email) continue;
// Insertion ou mise à jour de lutilisateur
$stmt = $conn->prepare("INSERT INTO CollaborateurAD (entraUserId, prenom, nom, email, service, role)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE prenom=?, nom=?, email=?, service=?");
$stmt = $conn->prepare("INSERT INTO CollaborateurAD (entraUserId, prenom, nom, email, service, description, role)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE prenom=?, nom=?, email=?, service=?, description=?");
if ($stmt) {
$role = "Collaborateur"; // attribué uniquement si nouvel utilisateur
$stmt->bind_param("ssssssssss",
$entraUserId, $prenom, $nom, $email, $service, $role,
$prenom, $nom, $email, $service
$role = "Collaborateur";
$stmt->bind_param("ssssssssssss",
$entraUserId, $prenom, $nom, $email, $service, $description, $role,
$prenom, $nom, $email, $service, $description
);
$stmt->execute();
$usersInserted++;

View File

@@ -0,0 +1,62 @@
<?php
// Autoriser CORS
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
exit(0);
}
header("Content-Type: application/json");
// Affichage erreurs PHP (utile pour debug)
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
// Connexion base de données
$host = "192.168.0.4";
$username = "wpuser";
$password = "-2b/)ru5/Bi8P[7_";
$dbname = "DemandeConge";
$conn = new mysqli($host, $username, $password, $dbname);
if ($conn->connect_error) {
http_response_code(500);
echo json_encode(["success" => false, "message" => "Erreur de connexion à la base de données"]);
exit;
}
// Récupération données JSON POST
$postData = json_decode(file_get_contents("php://input"), true);
if (!isset($postData['notificationId'])) {
http_response_code(400);
echo json_encode(["success" => false, "message" => "Paramètre notificationId manquant"]);
exit;
}
$notificationId = intval($postData['notificationId']);
if ($notificationId <= 0) {
http_response_code(400);
echo json_encode(["success" => false, "message" => "ID notification invalide"]);
exit;
}
// Mettre à jour notification lu = 1
$query = "UPDATE Notifications SET lu = 1 WHERE Id = ?";
$stmt = $conn->prepare($query);
if (!$stmt) {
http_response_code(500);
echo json_encode(["success" => false, "message" => "Erreur préparation requête"]);
exit;
}
$stmt->bind_param("i", $notificationId);
if ($stmt->execute()) {
echo json_encode(["success" => true, "message" => "Notification marquée comme lue"]);
} else {
http_response_code(500);
echo json_encode(["success" => false, "message" => "Erreur lors de la mise à jour"]);
}
$stmt->close();
$conn->close();

View File

@@ -9,6 +9,7 @@ import Manager from './pages/Manager';
import ProtectedRoute from './components/ProtectedRoute';
import EmployeeDetails from './pages/EmployeeDetails';
import Collaborateur from './pages/Collaborateur';
import CompteRenduActivites from './pages/CompteRenduActivite'; // ⭐ Ajout
function App() {
return (
@@ -31,7 +32,7 @@ function App() {
<Route
path="/demandes"
element={
<ProtectedRoute allowedRoles={['Collaborateur', 'RH']}>
<ProtectedRoute allowedRoles={['Collaborateur', 'Collaboratrice', 'Apprenti', 'RH', 'Admin']}>
<Requests />
</ProtectedRoute>
}
@@ -40,7 +41,7 @@ function App() {
<Route
path="/calendrier"
element={
<ProtectedRoute allowedRoles={['Collaborateur', 'Manager', 'RH']}>
<ProtectedRoute allowedRoles={['Collaborateur', 'Collaboratrice', 'Apprenti', 'Manager', 'Validateur', 'Validatrice', 'Directeur de campus', 'Directrice de campus', 'RH', 'Admin', 'President']}>
<Calendar />
</ProtectedRoute>
}
@@ -49,7 +50,7 @@ function App() {
<Route
path="/manager"
element={
<ProtectedRoute allowedRoles={['Manager']}>
<ProtectedRoute allowedRoles={['Manager', 'Validateur', 'Validatrice', 'Directeur de campus', 'Directrice de campus', 'RH', 'Admin', 'President']}>
<Manager />
</ProtectedRoute>
}
@@ -58,7 +59,7 @@ function App() {
<Route
path="/collaborateur"
element={
<ProtectedRoute allowedRoles={['Collaborateur']}>
<ProtectedRoute allowedRoles={['Collaborateur', 'Collaboratrice', 'Apprenti']}>
<Collaborateur />
</ProtectedRoute>
}
@@ -67,14 +68,27 @@ function App() {
<Route
path="/employee/:id"
element={
<ProtectedRoute allowedRoles={['RH', 'Manager']}>
<ProtectedRoute allowedRoles={['RH', 'Manager', 'Validateur', 'Validatrice', 'Directeur de campus', 'Directrice de campus', 'Admin', 'President']}>
<EmployeeDetails />
</ProtectedRoute>
}
/>
{/* ⭐ Nouvelle route pour Compte-Rendu d'Activités */}
<Route
path="/compte-rendu-activites"
element={
<ProtectedRoute allowedRoles={['Validateur', 'Validatrice', 'Directeur de campus', 'Directrice de campus', 'RH', 'Admin', 'President']}>
<CompteRenduActivites />
</ProtectedRoute>
}
/>
{/* Redirection par défaut */}
<Route path="/" element={<Navigate to="/dashboard" replace />} />
{/* Route 404 - Redirection vers dashboard */}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</Router>
</AuthProvider>

View File

@@ -0,0 +1,515 @@
import React, { useState, useEffect } from 'react';
import { X, Calendar, AlertCircle, Upload } from 'lucide-react';
const EditLeaveRequestModal = ({
onClose,
request,
availableLeaveCounters,
accessToken,
userId,
userEmail,
userRole,
userName,
onRequestUpdated
}) => {
const [leaveType, setLeaveType] = useState(request.typeId || '');
const [startDate, setStartDate] = useState(request.startDate || '');
const [endDate, setEndDate] = useState(request.endDate || '');
const [reason, setReason] = useState(request.reason || '');
const [businessDays, setBusinessDays] = useState(request.days || 0);
const [saturdayCount, setSaturdayCount] = useState(0);
const [medicalDocuments, setMedicalDocuments] = useState([]);
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitMessage, setSubmitMessage] = useState({ type: '', text: '' });
// ⭐ Types de congés disponibles selon le rôle
const getLeaveTypes = () => {
const baseTypes = [
{ id: 1, name: 'Congé payé', key: 'CP', counter: availableLeaveCounters.availableCP },
];
// Ajouter RTT sauf pour les apprentis
if (userRole !== 'Apprenti') {
baseTypes.push({
id: 2,
name: 'RTT',
key: 'RTT',
counter: availableLeaveCounters.availableRTT
});
}
// Ajouter les types sans compteur
baseTypes.push(
{ id: 3, name: 'Arrêt maladie', key: 'ABS', counter: null },
{ id: 5, name: 'Récupération (samedi)', key: 'Récup', counter: null }
);
// Ajouter Formation pour les apprentis
if (userRole === 'Apprenti') {
baseTypes.push({ id: 4, name: 'Formation', key: 'Formation', counter: null });
}
return baseTypes;
};
const leaveTypes = getLeaveTypes();
// ⭐ Calcul des jours ouvrés ET des samedis
useEffect(() => {
if (startDate && endDate) {
const result = calculateBusinessDaysAndSaturdays(startDate, endDate);
setBusinessDays(result.workingDays);
setSaturdayCount(result.saturdays);
}
}, [startDate, endDate]);
const calculateBusinessDaysAndSaturdays = (start, end) => {
const startD = new Date(start);
const endD = new Date(end);
let workingDays = 0;
let saturdays = 0;
const current = new Date(startD);
while (current <= endD) {
const dayOfWeek = current.getDay();
if (dayOfWeek === 6) {
saturdays++;
} else if (dayOfWeek !== 0) { // Pas dimanche
workingDays++;
}
current.setDate(current.getDate() + 1);
}
return { workingDays, saturdays };
};
const validateForm = () => {
const newErrors = {};
if (!leaveType) {
newErrors.leaveType = 'Veuillez sélectionner un type de congé';
}
if (!startDate) {
newErrors.startDate = 'La date de début est requise';
}
if (!endDate) {
newErrors.endDate = 'La date de fin est requise';
}
if (startDate && endDate && new Date(startDate) > new Date(endDate)) {
newErrors.endDate = 'La date de fin doit être après la date de début';
}
// ⭐ Validation spécifique pour Récupération
const selectedType = leaveTypes.find(t => t.id === parseInt(leaveType));
if (selectedType?.key === 'Récup') {
if (saturdayCount === 0) {
newErrors.days = 'Une récupération nécessite au moins un samedi dans la période sélectionnée';
}
}
// ⭐ Validation spécifique pour Arrêt maladie
if (selectedType?.key === 'ABS' && medicalDocuments.length === 0) {
newErrors.medical = 'Un justificatif médical est obligatoire pour un arrêt maladie';
}
// Vérification du solde disponible (CP et RTT uniquement)
if (selectedType && selectedType.counter !== null && businessDays > selectedType.counter) {
newErrors.days = `Solde insuffisant. Disponible : ${selectedType.counter} jour(s)`;
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleFileUpload = (e) => {
const files = Array.from(e.target.files);
const validFiles = [];
const maxSize = 5 * 1024 * 1024; // 5MB
for (const file of files) {
const validTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png'];
if (!validTypes.includes(file.type)) {
setSubmitMessage({
type: 'error',
text: `Le fichier "${file.name}" n'est pas un format valide.`
});
continue;
}
if (file.size > maxSize) {
setSubmitMessage({
type: 'error',
text: `Le fichier "${file.name}" est trop volumineux (max 5MB).`
});
continue;
}
validFiles.push(file);
}
setMedicalDocuments(prev => [...prev, ...validFiles]);
e.target.value = '';
};
const removeDocument = (index) => {
setMedicalDocuments(prev => prev.filter((_, i) => i !== index));
};
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateForm()) {
return;
}
setIsSubmitting(true);
setSubmitMessage({ type: '', text: '' });
try {
const formDataToSend = new FormData();
formDataToSend.append('requestId', request.id);
formDataToSend.append('leaveType', parseInt(leaveType));
formDataToSend.append('startDate', startDate);
formDataToSend.append('endDate', endDate);
formDataToSend.append('reason', reason);
formDataToSend.append('userId', userId);
formDataToSend.append('userEmail', userEmail);
formDataToSend.append('userName', userName);
formDataToSend.append('accessToken', accessToken);
// ⭐ Calcul des jours selon le type
const selectedType = leaveTypes.find(t => t.id === parseInt(leaveType));
const daysToSend = selectedType?.key === 'Récup' ? saturdayCount : businessDays;
formDataToSend.append('businessDays', daysToSend);
// ⭐ Documents médicaux
medicalDocuments.forEach((file) => {
formDataToSend.append('medicalDocuments', file);
});
const response = await fetch('http://localhost:3000/updateRequest', {
method: 'POST',
body: formDataToSend
});
const data = await response.json();
if (data.success) {
setSubmitMessage({
type: 'success',
text: '✅ Demande modifiée avec succès ! Le manager a été informé par email.'
});
setTimeout(() => {
onRequestUpdated();
onClose();
}, 2000);
} else {
setSubmitMessage({
type: 'error',
text: `${data.message || 'Erreur lors de la modification'}`
});
}
} catch (error) {
console.error('Erreur:', error);
setSubmitMessage({
type: 'error',
text: '❌ Une erreur est survenue. Veuillez réessayer.'
});
} finally {
setIsSubmitting(false);
}
};
const getMinDate = () => {
const today = new Date();
return today.toISOString().split('T')[0];
};
const selectedType = leaveTypes.find(t => t.id === parseInt(leaveType));
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Overlay */}
<div
className="absolute inset-0 bg-black bg-opacity-50"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 flex justify-between items-center rounded-t-2xl">
<h2 className="text-2xl font-bold text-gray-900">Modifier la demande</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
{/* Body */}
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{/* Message de statut */}
{submitMessage.text && (
<div className={`p-4 rounded-lg ${submitMessage.type === 'success'
? 'bg-green-50 text-green-800 border border-green-200'
: 'bg-red-50 text-red-800 border border-red-200'
}`}>
{submitMessage.text}
</div>
)}
{/* Info - Demande originale */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 className="font-semibold text-blue-900 mb-2 flex items-center gap-2">
<AlertCircle className="w-5 h-5" />
Demande actuelle
</h3>
<div className="text-sm text-blue-800 space-y-1">
<p><strong>Type :</strong> {request.type}</p>
<p><strong>Dates :</strong> {request.dateDisplay}</p>
<p><strong>Jours :</strong> {request.days}</p>
</div>
</div>
{/* Type de congé */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Type de congé *
</label>
<select
value={leaveType}
onChange={(e) => setLeaveType(e.target.value)}
className={`w-full px-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${errors.leaveType ? 'border-red-500' : 'border-gray-300'
}`}
>
<option value="">Sélectionnez un type</option>
{leaveTypes.map(type => (
<option key={type.id} value={type.id}>
{type.name}
{type.counter !== null && ` (${type.counter.toFixed(1)} jours disponibles)`}
</option>
))}
</select>
{errors.leaveType && (
<p className="mt-1 text-sm text-red-600">{errors.leaveType}</p>
)}
</div>
{/* Dates */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Date de début *
</label>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
min={getMinDate()}
className={`w-full pl-10 pr-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${errors.startDate ? 'border-red-500' : 'border-gray-300'
}`}
/>
</div>
{errors.startDate && (
<p className="mt-1 text-sm text-red-600">{errors.startDate}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Date de fin *
</label>
<div className="relative">
<Calendar className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
min={startDate || getMinDate()}
className={`w-full pl-10 pr-4 py-3 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${errors.endDate ? 'border-red-500' : 'border-gray-300'
}`}
/>
</div>
{errors.endDate && (
<p className="mt-1 text-sm text-red-600">{errors.endDate}</p>
)}
</div>
</div>
{/* ⭐ Résumé de la période */}
{startDate && endDate && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<p className="text-sm font-medium text-blue-900 mb-1">Résumé de la période :</p>
<div className="text-xs text-blue-700 space-y-1">
<p> <strong>{businessDays} jour(s) ouvré(s)</strong> (lundi-vendredi)</p>
{saturdayCount > 0 && (
<>
<p className="text-purple-700"> {saturdayCount} samedi(s) détecté(s)</p>
{selectedType?.key !== 'Récup' && (
<p className="text-orange-700 font-medium">
Les samedis seront ignorés (sélectionnez "Récupération" pour les inclure)
</p>
)}
{selectedType?.key === 'Récup' && (
<p className="text-green-700 font-medium">
Récupération : {saturdayCount} samedi(s) seront comptabilisés
</p>
)}
</>
)}
</div>
</div>
)}
{/* Nombre de jours */}
{(businessDays > 0 || saturdayCount > 0) && (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<p className="text-sm text-gray-700">
<strong>Nombre de jours {selectedType?.key === 'Récup' ? '(samedis)' : 'ouvrés'} :</strong>{' '}
{selectedType?.key === 'Récup' ? saturdayCount : businessDays}
</p>
{errors.days && (
<p className="mt-1 text-sm text-red-600">{errors.days}</p>
)}
</div>
)}
{/* ⭐ Upload documents médicaux pour Arrêt maladie */}
{selectedType?.key === 'ABS' && (
<div>
<label className="block text-sm font-medium text-gray-900 mb-3">
Justificatif médical <span className="text-red-600">*</span>
</label>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-gray-400 transition-colors">
<div className="w-12 h-12 mx-auto mb-4 bg-gray-100 rounded-full flex items-center justify-center">
<Upload className="w-6 h-6 text-gray-400" />
</div>
<p className="text-gray-700 text-sm mb-2">
Glissez vos documents ici ou cliquez pour sélectionner
</p>
<p className="text-gray-500 text-xs mb-4">
Formats acceptés : PDF, JPG, PNG (max 5MB par fichier)
</p>
<input
type="file"
multiple
accept=".pdf,.jpg,.jpeg,.png"
onChange={handleFileUpload}
className="hidden"
id="medical-documents"
/>
<label
htmlFor="medical-documents"
className="inline-block px-6 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 cursor-pointer transition-colors text-sm font-medium text-gray-700"
>
Sélectionner des fichiers
</label>
</div>
{medicalDocuments.length > 0 && (
<div className="mt-4 space-y-2">
<p className="text-sm font-medium text-gray-900 mb-2">
Fichiers sélectionnés ({medicalDocuments.length}) :
</p>
{medicalDocuments.map((file, index) => (
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border border-gray-200">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="w-8 h-8 bg-blue-100 rounded flex items-center justify-center flex-shrink-0">
{file.type === 'application/pdf' ? (
<svg className="w-4 h-4 text-red-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-4 h-4 text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z" clipRule="evenodd" />
</svg>
)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900 truncate">{file.name}</p>
<p className="text-xs text-gray-500">{formatFileSize(file.size)}</p>
</div>
</div>
<button
type="button"
onClick={() => removeDocument(index)}
className="text-gray-400 hover:text-red-600 ml-2 flex-shrink-0"
>
<X className="w-5 h-5" />
</button>
</div>
))}
</div>
)}
{errors.medical && (
<p className="mt-2 text-sm text-red-600">{errors.medical}</p>
)}
</div>
)}
{/* Motif */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Motif (optionnel)
</label>
<textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
rows={3}
placeholder="Précisez le motif de votre demande..."
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
</div>
{/* Info importante */}
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<p className="text-sm text-yellow-800">
<strong>Important :</strong> Votre manager sera automatiquement informé par email de cette modification.
</p>
</div>
{/* Actions */}
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="flex-1 px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 font-medium transition-colors"
>
Annuler
</button>
<button
type="submit"
disabled={isSubmitting}
className={`flex-1 px-6 py-3 bg-blue-600 text-white rounded-lg font-medium transition-colors ${isSubmitting
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-blue-700'
}`}
>
{isSubmitting ? 'Modification...' : 'Modifier la demande'}
</button>
</div>
</form>
</div>
</div>
);
};
export default EditLeaveRequestModal;

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,133 @@
import React, { useState, useEffect } from 'react';
import { FileText, Download, Eye, Loader } from 'lucide-react';
const MedicalDocuments = ({ demandeId }) => {
const [documents, setDocuments] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchDocuments = async () => {
if (!demandeId) {
setLoading(false);
return;
}
try {
setLoading(true);
const response = await fetch(`http://localhost:3000/medical-documents/${demandeId}`);
const data = await response.json();
if (data.success) {
setDocuments(data.documents || []);
} else {
setError(data.message);
}
} catch (err) {
console.error('Erreur récupération documents:', err);
setError('Impossible de charger les documents');
} finally {
setLoading(false);
}
};
fetchDocuments();
}, [demandeId]);
const formatFileSize = (bytes) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const getFileIcon = (type) => {
if (type === 'application/pdf') {
return (
<svg className="w-5 h-5 text-red-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clipRule="evenodd" />
</svg>
);
}
return (
<svg className="w-5 h-5 text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4 3a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V5a2 2 0 00-2-2H4zm12 12H4l4-8 3 6 2-4 3 6z" clipRule="evenodd" />
</svg>
);
};
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
if (loading) {
return (
<div className="flex items-center justify-center py-4">
<Loader className="w-5 h-5 animate-spin text-gray-400" />
<span className="ml-2 text-sm text-gray-500">Chargement...</span>
</div>
);
}
if (error) {
return (
<div className="text-sm text-red-600 py-2">
Erreur : {error}
</div>
);
}
if (documents.length === 0) {
return null; // Ne rien afficher s'il n'y a pas de documents
}
return (
<div>
<p className="text-gray-500 mb-2">
Justificatifs médicaux ({documents.length})
</p>
<div className="space-y-2">
{documents.map((doc) => (
<div
key={doc.id}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border border-gray-200 hover:bg-gray-100 transition-colors"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex-shrink-0">
{getFileIcon(doc.type)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900 truncate">
{doc.nom}
</p>
<div className="flex items-center gap-2 text-xs text-gray-500">
<span>{formatFileSize(doc.taille)}</span>
<span></span>
<span>{formatDate(doc.date)}</span>
</div>
</div>
</div>
<a
href={`http://localhost:3000${doc.downloadUrl}`}
download
className="flex-shrink-0 p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="Télécharger"
>
<Download className="w-4 h-4" />
</a>
</div>
))}
</div>
</div>
);
};
export default MedicalDocuments;

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import { LogOut, Calendar, Home, FileText, Building2, X, Users } from 'lucide-react';
import { LogOut, Calendar, Home, FileText, X, Users, Clock } from 'lucide-react';
import { useAuth } from '../context/AuthContext';
const Sidebar = ({ isOpen, onToggle }) => {
@@ -15,13 +15,40 @@ const Sidebar = ({ isOpen, onToggle }) => {
return 'bg-red-100 text-red-800';
case 'Validateur':
return 'bg-green-100 text-green-800';
case 'Validatrice':
return 'bg-green-100 text-green-800';
case 'Directeur de campus':
return 'bg-purple-100 text-purple-800';
case 'Directrice de campus':
return 'bg-purple-100 text-purple-800';
case 'President':
return 'bg-indigo-100 text-indigo-800';
case 'Collaborateur':
return 'bg-cyan-600 text-white';
case 'Collaboratrice':
return 'bg-cyan-600 text-white';
default:
return 'bg-gray-100 text-gray-800';
}
};
// Vérifier si l'utilisateur peut voir le compte-rendu d'activités
const canViewCompteRendu = () => {
const allowedRoles = [
'Validateur',
'Validatrice',
'Directeur de campus',
'Directrice de campus',
'President',
'Admin',
'RH'
];
return allowedRoles.includes(user?.role);
};
// Vérifier si l'utilisateur est en forfait jour
const isForfaitJour = user?.TypeContrat === 'forfait_jour' || user?.typeContrat === 'forfaitjour';
return (
<>
{isOpen && (
@@ -46,14 +73,12 @@ const Sidebar = ({ isOpen, onToggle }) => {
{/* Logo */}
<div className="p-6 border-b border-gray-100">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-cyan-600 rounded-lg flex items-center justify-center">
<Building2 className="w-6 h-6 text-white" />
</div>
<div>
<h2 className="text-xl font-bold text-gray-900">GTA</h2>
<p className="text-sm text-gray-500">Gestion de congés</p>
</div>
<div className="flex flex-col items-center gap-2">
<img
src="/assets/GA.svg"
alt="GTA Logo"
className="h-24 w-auto"
/>
</div>
</div>
@@ -123,28 +148,50 @@ const Sidebar = ({ isOpen, onToggle }) => {
<span className="font-medium">Calendrier</span>
</Link>
{/* Rubrique dynamique Collaborateur / Validateur */}
{/* Lien Compte-Rendu d'Activités - Visible pour validateurs et directeurs */}
{(canViewCompteRendu() || isForfaitJour) && (
<Link
to="/compte-rendu-activites"
onClick={() => window.innerWidth < 1024 && onToggle()}
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${isActive("/compte-rendu-activites")
? "bg-blue-50 text-cyan-700 border-r-2 border-cyan-700"
: "text-gray-700 hover:bg-gray-50"
}`}
>
<Clock className="w-5 h-5" />
<span className="font-medium">Compte-Rendu</span>
</Link>
)}
{(user?.role === "Collaborateur" ||
user?.role === "Collaboratrice" ||
user?.role === "Apprenti" ||
user?.role === "Validateur" ||
user?.role === "Validatrice" ||
user?.role === "Manager" ||
user?.role === "RH" ||
user?.role === "Admin") && (
user?.role === "Directeur de campus" ||
user?.role === "Directrice de campus" ||
user?.role === "President" ||
user?.role === "Admin") && (() => {
const targetPath = (user?.role === "Collaborateur" || user?.role === "Apprenti" || user?.role === "Collaboratrice")
? "/collaborateur"
: "/manager";
return (
<Link
to={user?.role === "Collaborateur" ? "/collaborateur" : "/manager"}
to={targetPath}
onClick={() => window.innerWidth < 1024 && onToggle()}
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${isActive(user?.role === "Collaborateur" ? "/collaborateur" : "/manager")
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${isActive(targetPath)
? "bg-blue-50 text-cyan-700 border-r-2 border-cyan-700"
: "text-gray-700 hover:bg-gray-50"
}`}
>
<Users className="w-5 h-5" />
<span className="font-medium">
{user?.role === "Collaborateur"
? "Mon équipe"
: "Mon équipe"}
</span>
<span className="font-medium">Mon équipe</span>
</Link>
)}
);
})()}
</nav>
{/* Bouton déconnexion */}

View File

@@ -6,9 +6,7 @@ const AuthContext = createContext();
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
if (!context) throw new Error('useAuth must be used within an AuthProvider');
return context;
};
@@ -16,25 +14,17 @@ const msalInstance = new msal.PublicClientApplication(msalConfig);
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [userGroups, setUserGroups] = useState([]);
const [isAuthorized, setIsAuthorized] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isMsalInitialized, setIsMsalInitialized] = useState(false);
// Fonction pour obtenir l'URL de l'API backend
const getApiUrl = (endpoint) => {
const possibleUrls = [
'http://localhost/GTA/project/public/php/',
'http://localhost:80/GTA/project/public/php/',
'http://localhost/GTA/public/php/',
'http://localhost/public/php/'
];
return possibleUrls[0] + endpoint; // Utilisez votre URL préférée
};
const getApiUrl = (endpoint) => `http://localhost:3000/${endpoint}`;
// Vérifier les groupes utilisateur via l'API backend
// --- Vérifie l'autorisation de l'utilisateur via groupes
const checkUserAuthorization = async (userPrincipalName, accessToken) => {
try {
const response = await fetch(getApiUrl('check-user-groups.php'), {
const response = await fetch(getApiUrl('check-user-groups'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -56,30 +46,22 @@ export const AuthProvider = ({ children }) => {
}
};
// Synchroniser l'utilisateur avec la base locale
// --- Synchronisation utilisateur connecté
const syncUserToDatabase = async (entraUser, accessToken) => {
try {
const response = await fetch(getApiUrl('check-user-groups.php'), {
const response = await fetch(getApiUrl('initial-sync'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
},
body: JSON.stringify({
entraUserId: entraUser.id,
userPrincipalName: entraUser.userPrincipalName,
email: entraUser.mail || entraUser.userPrincipalName,
displayName: entraUser.displayName,
givenName: entraUser.givenName,
surname: entraUser.surname,
jobTitle: entraUser.jobTitle,
department: entraUser.department,
officeLocation: entraUser.officeLocation
})
body: JSON.stringify(entraUser)
});
if (response.ok) {
return await response.json();
const data = await response.json();
console.log('Utilisateur synchronisé:', entraUser.userPrincipalName);
return data;
}
} catch (error) {
console.error('Erreur synchronisation utilisateur:', error);
@@ -87,22 +69,52 @@ export const AuthProvider = ({ children }) => {
return null;
};
// Initialisation MSAL
// --- Full sync admin
const fullSyncDatabase = async (accessToken) => {
try {
const response = await fetch(getApiUrl('initial-sync'), {
method: 'POST',
headers: { 'Authorization': `Bearer ${accessToken}` }
});
if (response.ok) {
const data = await response.json();
console.log('Full sync terminée:', data);
return data;
}
} catch (error) {
console.error('Erreur full sync:', error);
}
return null;
};
// --- S'assurer que MSAL est initialisé avant tout appel
const ensureMsalInitialized = async () => {
if (!isMsalInitialized) {
try {
await msalInstance.initialize();
setIsMsalInitialized(true);
console.log('MSAL initialisé');
} catch (error) {
console.error('Erreur initialisation MSAL:', error);
throw error;
}
}
};
// --- Initialisation au chargement
useEffect(() => {
const initializeMsal = async () => {
try {
await msalInstance.initialize();
await ensureMsalInitialized();
// Vérifier si il y a un utilisateur connecté
const accounts = msalInstance.getAllAccounts();
if (accounts.length > 0) {
// Essayer de récupérer un token silencieusement
try {
const response = await msalInstance.acquireTokenSilent({
...loginRequest,
account: accounts[0]
});
await handleSuccessfulAuth(response);
} catch (error) {
console.log('Token silent acquisition failed:', error);
@@ -118,18 +130,12 @@ export const AuthProvider = ({ children }) => {
initializeMsal();
}, []);
// Gérer l'authentification réussie
// Gérer l'authentification réussie
// --- Gestion login réussi
const handleSuccessfulAuth = async (authResponse) => {
try {
const account = authResponse.account;
const accessToken = authResponse.accessToken;
// 🔹 Récupérer profil Microsoft Graph
const graphResponse = await fetch('https://graph.microsoft.com/v1.0/me', {
headers: { 'Authorization': `Bearer ${accessToken}` }
});
let entraUser = {
id: account.homeAccountId,
displayName: account.name,
@@ -137,35 +143,31 @@ export const AuthProvider = ({ children }) => {
mail: account.username
};
const graphResponse = await fetch('https://graph.microsoft.com/v1.0/me', {
headers: { 'Authorization': `Bearer ${accessToken}` }
});
if (graphResponse.ok) {
const graphData = await graphResponse.json();
entraUser = { ...entraUser, ...graphData };
}
// 🔹 Synchroniser lutilisateur dans la DB
// 1 Synchroniser lutilisateur connecté
const syncResult = await syncUserToDatabase(entraUser, accessToken);
console.log("Résultat syncUserToDatabase:", syncResult);
// 🚀 Si admin → lancer full-sync.php
if (syncResult?.role === "Admin") {
try {
const syncResp = await fetch(getApiUrl('full-sync.php'), {
method: "POST",
headers: { "Authorization": `Bearer ${accessToken}` }
});
const syncData = await syncResp.json();
console.log("Résultat Full Sync:", syncData);
} catch (err) {
console.error("Erreur synchronisation groupes:", err);
}
// 2⃣ Full sync si admin
if (syncResult?.role === 'Admin') {
console.log('Admin détecté → lancement full sync...');
await fullSyncDatabase(accessToken);
}
// 🔹 Vérifier autorisation via groupes DB
// 3 Vérifier groupes
const authResult = await checkUserAuthorization(entraUser.userPrincipalName, accessToken);
if (authResult.authorized) {
const userData = {
setUser({
id: syncResult?.localUserId || entraUser.id,
CollaborateurADId: syncResult?.localUserId, // ⭐ AJOUT
entraUserId: entraUser.id,
name: entraUser.displayName,
prenom: entraUser.givenName || entraUser.displayName?.split(' ')[0] || '',
@@ -173,22 +175,15 @@ export const AuthProvider = ({ children }) => {
email: entraUser.mail || entraUser.userPrincipalName,
userPrincipalName: entraUser.userPrincipalName,
role: syncResult?.role || 'Employe',
// ✅ Correction ici
service: syncResult?.service
|| syncResult?.user?.service
|| entraUser.department
|| 'Non défini',
service: syncResult?.service || entraUser.department || 'Non défini',
jobTitle: entraUser.jobTitle,
department: entraUser.department,
officeLocation: entraUser.officeLocation,
typeContrat: syncResult?.typeContrat || '37h', // ⭐ AJOUT
dateEntree: syncResult?.dateEntree || null, // ⭐ AJOUT
groups: authResult.groups
};
setUser(userData);
});
setIsAuthorized(true);
return true;
} else {
throw new Error('Utilisateur non autorisé - pas membre des groupes requis');
}
@@ -198,32 +193,20 @@ export const AuthProvider = ({ children }) => {
}
};
// Connexion classique (email/mot de passe)
// --- Connexion classique
const login = async (email, password) => {
try {
const response = await fetch(getApiUrl('login.php'), {
const response = await fetch(getApiUrl('login'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, mot_de_passe: password }),
body: JSON.stringify({ email, mot_de_passe: password })
});
if (!response.ok) {
throw new Error('Erreur de connexion');
}
const text = await response.text();
let data;
try {
data = JSON.parse(text);
} catch {
console.error("Réponse non-JSON:", text.substring(0, 200));
throw new Error("Le serveur PHP ne répond pas correctement.");
}
if (!response.ok) throw new Error('Erreur de connexion');
const data = await response.json();
if (data.success) {
const userData = {
setUser({
id: data.user.id,
name: `${data.user.prenom} ${data.user.nom}`,
prenom: data.user.prenom,
@@ -231,9 +214,7 @@ export const AuthProvider = ({ children }) => {
email: data.user.email,
role: data.user.role || 'Employe',
service: data.user.service || 'Non défini'
};
setUser(userData);
});
setIsAuthorized(true);
return true;
}
@@ -244,9 +225,10 @@ export const AuthProvider = ({ children }) => {
}
};
// Connexion Office 365
// --- Connexion Office 365
const loginWithO365 = async () => {
try {
await ensureMsalInitialized();
const authResponse = await msalInstance.loginPopup(loginRequest);
await handleSuccessfulAuth(authResponse);
return true;
@@ -259,14 +241,12 @@ export const AuthProvider = ({ children }) => {
}
};
// Déconnexion
// --- Déconnexion
const logout = async () => {
try {
const accounts = msalInstance.getAllAccounts();
if (accounts.length > 0) {
await msalInstance.logoutPopup({
account: accounts[0]
});
await msalInstance.logoutPopup({ account: accounts[0] });
}
} catch (error) {
console.error('Erreur lors de la déconnexion:', error);
@@ -277,13 +257,12 @@ export const AuthProvider = ({ children }) => {
}
};
// Obtenir un token pour l'API
// --- Obtenir token API
const getAccessToken = async () => {
try {
await ensureMsalInitialized();
const accounts = msalInstance.getAllAccounts();
if (accounts.length === 0) {
throw new Error('Aucun compte connecté');
}
if (accounts.length === 0) throw new Error('Aucun compte connecté');
const response = await msalInstance.acquireTokenSilent({
...loginRequest,
@@ -308,11 +287,7 @@ export const AuthProvider = ({ children }) => {
getAccessToken
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export default AuthContext;

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ import { Users, CheckCircle, XCircle, Clock, Calendar, FileText, Menu, Eye, Mess
const Collaborateur = () => {
const { user } = useAuth();
const [sidebarOpen, setSidebarOpen] = useState(false);
const isEmployee = user?.role === 'Collaborateur';
const isEmployee = user?.role === 'Collaborateur'||'Apprenti';
const [teamMembers, setTeamMembers] = useState([]);
const [pendingRequests, setPendingRequests] = useState([]);
const [allRequests, setAllRequests] = useState([]);
@@ -44,7 +44,7 @@ const Collaborateur = () => {
const fetchTeamMembers = async () => {
try {
const response = await fetch(`http://localhost/GTA/project/public/php/getTeamMembers.php?manager_id=${user.id}`);
const response = await fetch(`http://localhost:3000/getTeamMembers?manager_id=${user.id}`);
const text = await response.text();
console.log('Réponse équipe:', text);
@@ -60,7 +60,7 @@ const Collaborateur = () => {
const fetchPendingRequests = async () => {
try {
const response = await fetch(`http://localhost/GTA/project/public/php/getPendingRequests.php?manager_id=${user.id}`);
const response = await fetch(`http://localhost:3000/getPendingRequests?manager_id=${user.id}`);
const text = await response.text();
console.log('Réponse demandes en attente:', text);
@@ -76,7 +76,7 @@ const Collaborateur = () => {
const fetchAllTeamRequests = async () => {
try {
const response = await fetch(`http://localhost/GTA/project/public/php/getAllTeamRequests.php?SuperieurId=${user.id}`);
const response = await fetch(`http://localhost:3000/getAllTeamRequests?SuperieurId=${user.id}`);
const text = await response.text();
console.log('Réponse toutes demandes équipe:', text);
@@ -94,7 +94,7 @@ const Collaborateur = () => {
const handleValidateRequest = async (requestId, action, comment = '') => {
try {
const response = await fetch('http://localhost/GTA/project/public/php/validateRequest.php', {
const response = await fetch('http://localhost:3000/validateRequest', {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View File

@@ -0,0 +1,984 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useAuth } from '../context/AuthContext';
import Sidebar from '../components/Sidebar';
import {
Calendar, Clock, Check, X, Save, Lock, Unlock,
Download, Menu, AlertCircle, ChevronLeft, ChevronRight,
FileText, TrendingUp, RefreshCw, Info
} from 'lucide-react';
const CompteRenduActivites = () => {
const { user } = useAuth();
const userId = user?.id || user?.CollaborateurADId || user?.ID;
const [sidebarOpen, setSidebarOpen] = useState(false);
const [currentDate, setCurrentDate] = useState(new Date());
const [joursActifs, setJoursActifs] = useState([]);
const [statsAnnuelles, setStatsAnnuelles] = useState(null);
const [mensuelData, setMensuelData] = useState(null);
const [congesData, setCongesData] = useState([]);
const [holidays, setHolidays] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [showSaisieModal, setShowSaisieModal] = useState(false);
const [selectedJour, setSelectedJour] = useState(null);
const [showSaisieMasse, setShowSaisieMasse] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [infoMessage, setInfoMessage] = useState(null);
// Vérifier l'accès : forfait jour, directeur campus, ou RH
const hasAccess = () => {
const userRole = user?.role;
const typeContrat = user?.TypeContrat || user?.typeContrat;
return (
typeContrat === 'forfait-jour' ||
typeContrat === 'forfait_jour' ||
userRole === 'Directeur Campus' ||
userRole === 'Directrice Campus' ||
userRole === 'RH' ||
userRole === 'Admin'
);
};
const isRH = user?.role === 'RH' || user?.role === 'Admin';
const isDirecteurCampus = user?.role === 'Directeur Campus' || user?.role === 'Directrice Campus';
const annee = currentDate.getFullYear();
const mois = currentDate.getMonth() + 1;
// Charger les jours fériés français
const fetchFrenchHolidays = async (year) => {
try {
const response = await fetch(`https://calendrier.api.gouv.fr/jours-feries/metropole/${year}.json`);
if (!response.ok) throw new Error(`Erreur API: ${response.status}`);
const data = await response.json();
const holidayDates = Object.keys(data).map(dateStr => {
const [year, month, day] = dateStr.split('-').map(Number);
return {
date: new Date(year, month - 1, day),
name: data[dateStr]
};
});
return holidayDates;
} catch (error) {
console.error('Erreur jours fériés:', error);
return [];
}
};
useEffect(() => {
const year = currentDate.getFullYear();
fetchFrenchHolidays(year).then(setHolidays);
}, [currentDate]);
const isHoliday = (date) => {
if (!date) return false;
return holidays.some(holiday =>
holiday.date.getDate() === date.getDate() &&
holiday.date.getMonth() === date.getMonth() &&
holiday.date.getFullYear() === date.getFullYear()
);
};
const getHolidayName = (date) => {
const holiday = holidays.find(h =>
h.date.getDate() === date.getDate() &&
h.date.getMonth() === date.getMonth() &&
h.date.getFullYear() === date.getFullYear()
);
return holiday?.name;
};
// Charger les données du mois
const loadCompteRendu = useCallback(async () => {
if (!userId || !hasAccess()) return;
setIsLoading(true);
try {
const response = await fetch(`http://localhost:3000/getCompteRenduActivites?user_id=${userId}&annee=${annee}&mois=${mois}`);
const data = await response.json();
if (data.success) {
setJoursActifs(data.jours || []);
setMensuelData(data.mensuel);
console.log('📅 Jours chargés:', data.jours?.length || 0, 'jours');
console.log('📊 Détail des jours:', data.jours);
}
const congesResponse = await fetch(`http://localhost:3000/getTeamLeaves?user_id=${userId}&role=${user.role}`);
const congesData = await congesResponse.json();
if (congesData.success) {
setCongesData(congesData.leaves || []);
}
} catch (error) {
console.error('Erreur chargement compte-rendu:', error);
} finally {
setIsLoading(false);
}
}, [userId, annee, mois, user?.role]);
// Charger les stats annuelles
const loadStatsAnnuelles = useCallback(async () => {
if (!userId || !hasAccess()) return;
try {
const response = await fetch(`http://localhost:3000/getStatsAnnuelles?user_id=${userId}&annee=${annee}`);
const data = await response.json();
if (data.success) {
setStatsAnnuelles(data.stats);
}
} catch (error) {
console.error('Erreur stats annuelles:', error);
}
}, [userId, annee]);
useEffect(() => {
loadCompteRendu();
loadStatsAnnuelles();
}, [loadCompteRendu, loadStatsAnnuelles]);
// Vérifier si le mois est autorisé (mois en cours + mois précédent)
const isMoisAutorise = () => {
const today = new Date();
const currentYear = today.getFullYear();
const currentMonth = today.getMonth() + 1;
const selectedYear = currentDate.getFullYear();
const selectedMonth = currentDate.getMonth() + 1;
// RH peut tout voir
if (isRH) return true;
// Mois en cours autorisé
if (selectedYear === currentYear && selectedMonth === currentMonth) return true;
// Mois précédent autorisé
const previousMonth = currentMonth === 1 ? 12 : currentMonth - 1;
const previousYear = currentMonth === 1 ? currentYear - 1 : currentYear;
return selectedYear === previousYear && selectedMonth === previousMonth;
};
// Générer les jours du mois (lundi-vendredi)
const getDaysInMonth = () => {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const lastDay = new Date(year, month + 1, 0);
const daysInMonth = lastDay.getDate();
const days = [];
for (let day = 1; day <= daysInMonth; day++) {
const currentDay = new Date(year, month, day);
const dayOfWeek = currentDay.getDay();
if (dayOfWeek >= 1 && dayOfWeek <= 5) {
days.push(currentDay);
}
}
return days;
};
const getJourData = (date) => {
const dateStr = formatDateToString(date);
const found = joursActifs.find(j => {
// Normaliser la date de la BDD (peut être un objet Date ou une string)
let jourDateStr = j.JourDate;
if (j.JourDate instanceof Date) {
jourDateStr = formatDateToString(j.JourDate);
} else if (typeof j.JourDate === 'string') {
// Si c'est déjà une string, extraire juste la partie date (YYYY-MM-DD)
jourDateStr = j.JourDate.split('T')[0];
}
const match = jourDateStr === dateStr;
console.log('Comparaison:', jourDateStr, 'vs', dateStr, 'match:', match);
return match;
});
if (found) {
console.log('✅ Jour trouvé:', dateStr, found);
}
return found;
};
const isJourEnConge = (date) => {
return congesData.some(conge => {
const start = new Date(conge.startdate);
const end = new Date(conge.enddate);
return date >= start && date <= end && conge.statut === 'Valide';
});
};
// Vérifier si le jour est STRICTEMENT dans le passé (pas aujourd'hui)
const isPastOnly = (date) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const checkDate = new Date(date);
checkDate.setHours(0, 0, 0, 0);
return checkDate < today;
};
// Vérifier si un jour spécifique est verrouillé
const isJourVerrouille = (date) => {
const jourData = getJourData(date);
return jourData?.Verrouille === true || jourData?.Verrouille === 1;
};
// Afficher message d'info
const showInfo = (message, type = 'info') => {
setInfoMessage({ message, type });
setTimeout(() => setInfoMessage(null), 5000);
};
// Ouvrir le modal de saisie
const handleJourClick = (date) => {
if (!isMoisAutorise() && !isRH) {
showInfo('Vous ne pouvez saisir que pour le mois en cours ou le mois précédent', 'warning');
return;
}
if (!isPastOnly(date)) {
showInfo('Vous ne pouvez pas saisir le jour actuel. Veuillez attendre demain.', 'warning');
return;
}
if (isHoliday(date)) {
showInfo(`Jour férié : ${getHolidayName(date)} - Saisie impossible`, 'info');
return;
}
if (isJourEnConge(date)) {
showInfo('Vous êtes en congé ce jour - Saisie impossible', 'info');
return;
}
if (isJourVerrouille(date) && !isRH) {
showInfo('Ce jour est déjà verrouillé - Contactez les RH pour modification', 'error');
return;
}
const jourData = getJourData(date);
setSelectedJour({
date: date,
dateStr: formatDateToString(date),
jourTravaille: jourData?.JourTravaille !== false,
reposQuotidien: jourData?.ReposQuotidienRespect !== false,
reposHebdo: jourData?.ReposHebdomadaireRespect !== false,
commentaire: jourData?.CommentaireRepos || ''
});
setShowSaisieModal(true);
};
// Sauvegarder un jour
const handleSaveJour = async () => {
if ((!selectedJour.reposQuotidien || !selectedJour.reposHebdo)) {
if (!selectedJour.commentaire || selectedJour.commentaire.trim() === '') {
showInfo('Commentaire obligatoire en cas de non-respect des repos', 'warning');
return;
}
}
setIsSaving(true);
try {
const response = await fetch('http://localhost:3000/saveCompteRenduJour', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: userId,
date: selectedJour.dateStr,
jour_travaille: selectedJour.jourTravaille,
repos_quotidien: selectedJour.reposQuotidien,
repos_hebdo: selectedJour.reposHebdo,
commentaire: selectedJour.commentaire,
rh_override: isRH
})
});
const data = await response.json();
if (data.success) {
await loadCompteRendu();
await loadStatsAnnuelles();
setShowSaisieModal(false);
showInfo('✅ Jour enregistré', 'success');
console.log('Données rechargées après sauvegarde');
} else {
showInfo(data.message || 'Erreur lors de la sauvegarde', 'error');
}
} catch (error) {
console.error('Erreur sauvegarde jour:', error);
showInfo('Erreur serveur', 'error');
} finally {
setIsSaving(false);
}
};
// Saisie en masse
const handleSaisieMasse = async (joursTravailles) => {
if (!isMoisAutorise() && !isRH) {
showInfo('Vous ne pouvez saisir que pour le mois en cours ou le mois précédent', 'warning');
return;
}
setIsSaving(true);
try {
const response = await fetch('http://localhost:3000/saveCompteRenduMasse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: userId,
annee: annee,
mois: mois,
jours: joursTravailles,
rh_override: isRH
})
});
const data = await response.json();
if (data.success) {
await loadCompteRendu();
await loadStatsAnnuelles();
setShowSaisieMasse(false);
showInfo(data.message || `${data.count || 0} jours enregistrés`, 'success');
} else {
showInfo(data.message || 'Erreur', 'error');
}
} catch (error) {
console.error('Erreur saisie masse:', error);
showInfo('Erreur serveur', 'error');
} finally {
setIsSaving(false);
}
};
const formatDateToString = (date) => {
if (!date) return null;
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const navigateMonth = (direction) => {
setCurrentDate(prev => {
const newDate = new Date(prev);
if (direction === 'prev') {
newDate.setMonth(prev.getMonth() - 1);
} else {
newDate.setMonth(prev.getMonth() + 1);
}
return newDate;
});
};
const monthNames = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
const days = getDaysInMonth();
const moisAutorise = isMoisAutorise();
// Vérifier l'accès
if (!hasAccess()) {
return (
<div className="min-h-screen bg-gray-50 flex">
<Sidebar isOpen={sidebarOpen} onToggle={() => setSidebarOpen(!sidebarOpen)} />
<div className="flex-1 lg:ml-60 p-8">
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6 text-center">
<AlertCircle className="w-12 h-12 text-yellow-600 mx-auto mb-4" />
<h2 className="text-xl font-bold text-gray-900 mb-2">Accès restreint</h2>
<p className="text-gray-600 mb-4">
Cette fonctionnalité est réservée aux :
</p>
<ul className="text-gray-700 space-y-2 text-left max-w-md mx-auto">
<li className="flex items-center gap-2">
<Check className="w-4 h-4 text-green-600" />
Collaborateurs en forfait jour
</li>
<li className="flex items-center gap-2">
<Check className="w-4 h-4 text-green-600" />
Directeurs et directrices de campus
</li>
<li className="flex items-center gap-2">
<Check className="w-4 h-4 text-green-600" />
Service RH
</li>
</ul>
</div>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 flex">
<Sidebar isOpen={sidebarOpen} onToggle={() => setSidebarOpen(!sidebarOpen)} />
<div className="flex-1 lg:ml-60 flex items-center justify-center">
<div className="text-center">
<RefreshCw className="w-12 h-12 animate-spin text-cyan-600 mx-auto mb-4" />
<p className="text-gray-600">Chargement...</p>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 flex">
<Sidebar isOpen={sidebarOpen} onToggle={() => setSidebarOpen(!sidebarOpen)} />
<div className="flex-1 lg:ml-60 p-4 lg:p-8">
{/* Message d'information */}
{infoMessage && (
<div className={`mb-4 p-4 rounded-lg border-l-4 flex items-center gap-3 animate-slideIn ${infoMessage.type === 'success' ? 'bg-green-50 border-green-500' :
infoMessage.type === 'error' ? 'bg-red-50 border-red-500' :
infoMessage.type === 'warning' ? 'bg-orange-50 border-orange-500' :
'bg-blue-50 border-blue-500'
}`}>
<Info className={`w-5 h-5 flex-shrink-0 ${infoMessage.type === 'success' ? 'text-green-600' :
infoMessage.type === 'error' ? 'text-red-600' :
infoMessage.type === 'warning' ? 'text-orange-600' :
'text-blue-600'
}`} />
<p className={`text-sm font-medium ${infoMessage.type === 'success' ? 'text-green-800' :
infoMessage.type === 'error' ? 'text-red-800' :
infoMessage.type === 'warning' ? 'text-orange-800' :
'text-blue-800'
}`}>{infoMessage.message}</p>
</div>
)}
{/* Header */}
<div className="mb-6">
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-2xl lg:text-3xl font-bold text-gray-900">
Compte-Rendu d'Activités
</h1>
<p className="text-sm text-gray-600 mt-1">
Forfait jour - Suivi des jours travaillés et repos obligatoires
</p>
</div>
<button
onClick={() => setSidebarOpen(true)}
className="lg:hidden p-2 rounded-lg bg-white border"
>
<Menu className="w-6 h-6" />
</button>
</div>
{/* Stats annuelles */}
{statsAnnuelles && (
<div className="bg-gradient-to-r from-cyan-500 to-blue-500 rounded-xl shadow-md p-6 text-white">
<div className="flex items-center gap-2 mb-3">
<TrendingUp className="w-6 h-6" />
<h3 className="text-lg font-bold">Cumul annuel {annee}</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white bg-opacity-20 rounded-lg p-4">
<p className="text-sm opacity-90">Jours travaillés</p>
<p className="text-3xl font-bold">{statsAnnuelles.totalJoursTravailles || 0}</p>
</div>
<div className="bg-white bg-opacity-20 rounded-lg p-4">
<p className="text-sm opacity-90">Non-respect repos quotidien</p>
<p className="text-3xl font-bold">{statsAnnuelles.totalNonRespectQuotidien || 0}</p>
</div>
<div className="bg-white bg-opacity-20 rounded-lg p-4">
<p className="text-sm opacity-90">Non-respect repos hebdo</p>
<p className="text-3xl font-bold">{statsAnnuelles.totalNonRespectHebdo || 0}</p>
</div>
</div>
</div>
)}
</div>
{/* Bandeau mois non autorisé */}
{!moisAutorise && !isRH && (
<div className="bg-yellow-50 border-l-4 border-yellow-500 p-4 mb-6 rounded">
<div className="flex items-center gap-3">
<AlertCircle className="w-6 h-6 text-yellow-600" />
<div>
<p className="font-semibold text-yellow-900">Mois non accessible</p>
<p className="text-sm text-yellow-700">
Vous pouvez saisir uniquement le mois en cours et le mois précédent (mais pas le jour actuel).
</p>
</div>
</div>
</div>
)}
{/* Navigation + Actions */}
<div className="bg-white rounded-lg border p-4 mb-6">
<div className="flex flex-col lg:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-4">
<button
onClick={() => navigateMonth('prev')}
className="p-2 hover:bg-gray-100 rounded"
>
<ChevronLeft className="w-5 h-5 text-blue-600" />
</button>
<h2 className="text-xl font-semibold min-w-[200px] text-center">
{monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}
</h2>
<button
onClick={() => navigateMonth('next')}
className="p-2 hover:bg-gray-100 rounded"
>
<ChevronRight className="w-5 h-5 text-blue-600" />
</button>
</div>
<div className="flex items-center gap-3 flex-wrap">
{mensuelData && (
<span className="text-sm text-gray-600">
{mensuelData.NbJoursTravailles || 0} jours saisis
</span>
)}
<button
onClick={() => setShowSaisieMasse(true)}
disabled={!moisAutorise && !isRH}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<FileText className="w-4 h-4" />
<span className="hidden sm:inline">Saisie en masse</span>
</button>
</div>
</div>
</div>
{/* Calendrier */}
<div className="bg-white rounded-lg border overflow-hidden shadow-sm">
<div className="grid grid-cols-5 gap-2 p-4 bg-gray-50">
{['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi'].map(day => (
<div key={day} className="text-center font-semibold text-gray-700 text-sm">
{day}
</div>
))}
</div>
<div className="grid grid-cols-5 gap-2 p-4">
{days.map((date, index) => {
const jourData = getJourData(date);
const enConge = isJourEnConge(date);
const ferie = isHoliday(date);
const isPast = isPastOnly(date);
const isToday = date.toDateString() === new Date().toDateString();
const jourVerrouille = isJourVerrouille(date);
// Déterminer la classe de fond
let bgClass = 'bg-white hover:bg-gray-50';
let cursorClass = 'cursor-pointer hover:shadow-md';
if (ferie) {
bgClass = 'bg-gray-700 text-white';
cursorClass = 'cursor-not-allowed';
} else if (enConge) {
bgClass = 'bg-purple-100';
cursorClass = 'cursor-not-allowed';
} else if (jourData) {
bgClass = 'bg-gray-300 hover:bg-gray-400';
cursorClass = 'cursor-pointer hover:shadow-md';
}
return (
<div
key={index}
onClick={() => handleJourClick(date)}
className={`
min-h-[100px] p-3 rounded-lg border-2 transition-all
${isToday ? 'border-cyan-500' : 'border-gray-200'}
${bgClass}
${cursorClass}
${!isPast ? 'opacity-40 cursor-not-allowed' : ''}
${!moisAutorise && !isRH ? 'opacity-60 cursor-not-allowed' : ''}
`}
title={
ferie ? getHolidayName(date) :
enConge ? 'En congé' :
jourData ? 'Jour saisi - Cliquer pour modifier' :
''
}
>
<div className={`text-right text-sm font-semibold mb-2 flex items-center justify-end gap-1 ${ferie ? 'text-white' :
jourData ? 'text-gray-700' :
'text-gray-700'
}`}>
{date.getDate()}
{jourData && !ferie && !enConge && (
<Lock className="w-3 h-3 text-gray-600" />
)}
</div>
{ferie ? (
<div className="text-center">
<div className="text-xs text-white font-bold truncate">{getHolidayName(date)}</div>
</div>
) : enConge ? (
<div className="text-center">
<div className="text-xs text-purple-700 font-semibold">En congé</div>
</div>
) : jourData ? (
<div className="space-y-1">
<div className="flex items-center gap-1 text-xs">
{jourData.JourTravaille ? (
<Check className="w-3 h-3 text-gray-700" />
) : (
<X className="w-3 h-3 text-gray-700" />
)}
<span className="text-gray-700 truncate">
{jourData.JourTravaille ? 'Travaillé' : 'Non travaillé'}
</span>
</div>
{!jourData.ReposQuotidienRespect && (
<div className="flex items-center gap-1 text-xs text-gray-700">
<AlertCircle className="w-3 h-3" />
<span className="truncate">Repos quotidien</span>
</div>
)}
{!jourData.ReposHebdomadaireRespect && (
<div className="flex items-center gap-1 text-xs text-gray-700">
<AlertCircle className="w-3 h-3" />
<span className="truncate">Repos hebdo</span>
</div>
)}
<div className="text-center text-xs text-gray-600 font-semibold mt-2">
Saisi
</div>
</div>
) : (
<div className="text-center text-xs text-gray-400">
{isPast && moisAutorise ? 'Cliquer pour saisir' : 'Non disponible'}
</div>
)}
</div>
);
})}
</div>
</div>
{/* Légende */}
<div className="mt-4 flex flex-wrap items-center gap-4 text-sm text-gray-600">
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-cyan-50 border-2 border-cyan-500 rounded"></div>
<span>Aujourd'hui</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-gray-300 border-2 border-gray-200 rounded"></div>
<Lock className="w-3 h-3 text-gray-600" />
<span>Jour saisi (grisé)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-purple-100 border-2 border-gray-200 rounded"></div>
<span>En congé</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 bg-gray-700 rounded"></div>
<span>Jour férié</span>
</div>
<div className="flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-orange-600" />
<span>Non-respect repos</span>
</div>
</div>
</div>
{/* Modal saisie jour */}
{showSaisieModal && selectedJour && (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-xl shadow-xl max-w-lg w-full p-6 max-h-[90vh] overflow-y-auto">
<h3 className="text-xl font-bold text-gray-900 mb-4">
Saisie du {selectedJour.date.toLocaleDateString('fr-FR', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'
})}
</h3>
<div className="space-y-4">
<div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={selectedJour.jourTravaille}
onChange={(e) => setSelectedJour({ ...selectedJour, jourTravaille: e.target.checked })}
className="w-5 h-5 text-blue-600 rounded"
/>
<span className="text-gray-700 font-medium">Jour travaillé</span>
</label>
</div>
{selectedJour.jourTravaille && (
<>
<div className="border-t pt-4">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={selectedJour.reposQuotidien}
onChange={(e) => setSelectedJour({ ...selectedJour, reposQuotidien: e.target.checked })}
className="w-5 h-5 text-blue-600 rounded mt-0.5"
/>
<div>
<span className="text-gray-700 font-medium block">
Respect du repos quotidien
</span>
<span className="text-xs text-gray-500">
11 heures consécutives minimum
</span>
</div>
</label>
</div>
<div>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={selectedJour.reposHebdo}
onChange={(e) => setSelectedJour({ ...selectedJour, reposHebdo: e.target.checked })}
className="w-5 h-5 text-blue-600 rounded mt-0.5"
/>
<div>
<span className="text-gray-700 font-medium block">
Respect du repos hebdomadaire
</span>
<span className="text-xs text-gray-500">
35 heures consécutives minimum (24h + 11h)
</span>
</div>
</label>
</div>
{(!selectedJour.reposQuotidien || !selectedJour.reposHebdo) && (
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Commentaire obligatoire
</label>
<textarea
value={selectedJour.commentaire}
onChange={(e) => setSelectedJour({ ...selectedJour, commentaire: e.target.value })}
placeholder="Précisez les jours/semaines concernés et les raisons..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
rows={4}
/>
<p className="text-xs text-orange-700 mt-2">
Veuillez préciser les circonstances du non-respect des repos obligatoires
</p>
</div>
)}
</>
)}
</div>
<div className="flex gap-3 mt-6">
<button
onClick={() => setShowSaisieModal(false)}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
disabled={isSaving}
>
Annuler
</button>
<button
onClick={handleSaveJour}
disabled={isSaving}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center justify-center gap-2 disabled:opacity-50"
>
{isSaving ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Enregistrement...
</>
) : (
<>
<Save className="w-4 h-4" />
Enregistrer
</>
)}
</button>
</div>
</div>
</div>
)}
{/* Modal saisie en masse */}
{showSaisieMasse && (
<SaisieMasseModal
mois={mois}
annee={annee}
days={days}
congesData={congesData}
holidays={holidays}
onClose={() => setShowSaisieMasse(false)}
onSave={handleSaisieMasse}
isSaving={isSaving}
/>
)}
</div>
);
};
// Modal de saisie en masse
const SaisieMasseModal = ({ mois, annee, days, congesData, holidays, onClose, onSave, isSaving }) => {
const [selectedDays, setSelectedDays] = useState([]);
const monthNames = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
const isJourEnConge = (date) => {
return congesData.some(conge => {
const start = new Date(conge.startdate);
const end = new Date(conge.enddate);
return date >= start && date <= end && conge.statut === 'Valide';
});
};
const isHoliday = (date) => {
return holidays.some(holiday =>
holiday.date.getDate() === date.getDate() &&
holiday.date.getMonth() === date.getMonth() &&
holiday.date.getFullYear() === date.getFullYear()
);
};
const isPastOnly = (date) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
date = new Date(date);
date.setHours(0, 0, 0, 0);
return date < today;
};
const toggleDay = (date) => {
const dateStr = formatDateToString(date);
if (selectedDays.includes(dateStr)) {
setSelectedDays(selectedDays.filter(d => d !== dateStr));
} else {
setSelectedDays([...selectedDays, dateStr]);
}
};
const selectAllWorkingDays = () => {
const workingDays = days
.filter(date => isPastOnly(date) && !isJourEnConge(date) && !isHoliday(date))
.map(date => formatDateToString(date));
setSelectedDays(workingDays);
};
const formatDateToString = (date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const handleSave = () => {
const joursTravailles = selectedDays.map(dateStr => ({
date: dateStr,
jour_travaille: true,
repos_quotidien: true,
repos_hebdo: true,
commentaire: ''
}));
onSave(joursTravailles);
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-xl shadow-xl max-w-4xl w-full p-6 max-h-[90vh] overflow-y-auto">
<h3 className="text-xl font-bold text-gray-900 mb-4">
Saisie en masse - {monthNames[mois - 1]} {annee}
</h3>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<p className="text-sm text-blue-800">
Sélectionnez tous les jours travaillés du mois. Le jour actuel, les jours fériés et les congés sont automatiquement exclus.
Les repos quotidien et hebdomadaire seront considérés comme respectés.
</p>
</div>
<button
onClick={selectAllWorkingDays}
className="mb-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Sélectionner tous les jours ouvrés disponibles
</button>
<div className="grid grid-cols-5 gap-2 mb-6">
{days.map((date, index) => {
const dateStr = formatDateToString(date);
const enConge = isJourEnConge(date);
const ferie = isHoliday(date);
const isPast = isPastOnly(date);
const isSelected = selectedDays.includes(dateStr);
const isToday = date.toDateString() === new Date().toDateString();
return (
<div
key={index}
onClick={() => !enConge && !ferie && isPast && toggleDay(date)}
className={`
p-3 rounded-lg border-2 text-center cursor-pointer transition-all
${isToday ? 'border-cyan-500 bg-cyan-100' : ''}
${ferie ? 'bg-gray-700 text-white cursor-not-allowed' : ''}
${enConge ? 'bg-purple-100 cursor-not-allowed' : ''}
${!isPast ? 'opacity-30 cursor-not-allowed' : ''}
${isSelected ? 'bg-green-500 border-green-600 text-white' : 'bg-white border-gray-200 hover:bg-gray-50'}
`}
>
<div className="font-semibold">{date.getDate()}</div>
{isToday && <div className="text-xs mt-1">Aujourd'hui</div>}
{ferie && <div className="text-xs mt-1">Férié</div>}
{enConge && <div className="text-xs mt-1 text-purple-700">Congé</div>}
</div>
);
})}
</div>
<div className="text-sm text-gray-600 mb-4">
{selectedDays.length} jour{selectedDays.length > 1 ? 's' : ''} sélectionné{selectedDays.length > 1 ? 's' : ''}
</div>
<div className="flex gap-3">
<button
onClick={onClose}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
disabled={isSaving}
>
Annuler
</button>
<button
onClick={handleSave}
disabled={isSaving || selectedDays.length === 0}
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSaving ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Enregistrement...
</>
) : (
<>
<Save className="w-4 h-4" />
Enregistrer {selectedDays.length} jours
</>
)}
</button>
</div>
</div>
</div>
);
};
export default CompteRenduActivites;

File diff suppressed because it is too large Load Diff

View File

@@ -13,12 +13,13 @@ const EmployeeDetails = () => {
fetchEmployeeData();
}, [id]);
// Dans EmployeeDetails.jsx, modifier fetchEmployeeData:
const fetchEmployeeData = async () => {
try {
setIsLoading(true);
// 1⃣ Données employé
const resEmployee = await fetch(`http://localhost/GTA/project/public/php/getEmploye.php?id=${id}`);
// 1⃣ Données employé (avec compteurs inclus)
const resEmployee = await fetch(`http://localhost:3000/getEmploye?id=${id}`);
const dataEmployee = await resEmployee.json();
console.log("Réponse API employé:", dataEmployee);
@@ -26,23 +27,16 @@ const EmployeeDetails = () => {
setEmployee(null);
return;
}
// ✅ Les compteurs sont déjà dans la réponse
setEmployee(dataEmployee.employee);
// 2⃣ Historique des demandes
const resRequests = await fetch(`http://localhost/GTA/project/public/php/getEmployeRequest.php?id=${id}`);
const resRequests = await fetch(`http://localhost:3000/getEmployeRequest?id=${id}`);
const dataRequests = await resRequests.json();
if (dataRequests.success) {
setRequests(dataRequests.requests || []);
// 3⃣ Compteurs de congés et RTT
const resCounters = await fetch(`http://localhost/GTA/project/public/php/getLeaveCounters.php?user_id=${id}`);
const dataCounters = await resCounters.json();
if (dataCounters.success) {
setEmployee(prev => ({
...prev,
conges_restants: dataCounters.counters.availableCP,
rtt_restants: dataCounters.counters.availableRTT
}));
}
} catch (err) {

View File

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { useAuth } from '../context/AuthContext';
import { useNavigate } from 'react-router-dom';
import { Building2, Mail, Lock, Eye, EyeOff, AlertTriangle } from 'lucide-react';
import { Building2, AlertTriangle } from 'lucide-react';
const Login = () => {
const [email, setEmail] = useState('');
@@ -9,10 +9,10 @@ const Login = () => {
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [authMethod, setAuthMethod] = useState(''); // Pour tracker la méthode d'auth utilisée
const [authMethod, setAuthMethod] = useState('');
const navigate = useNavigate();
const { login, loginWithO365, isAuthorized } = useAuth();
const { login, loginWithO365 } = useAuth();
const handleSubmit = async (e) => {
e.preventDefault();
@@ -40,7 +40,6 @@ const Login = () => {
setAuthMethod('o365');
try {
// Étape 1 : Login O365
const success = await loginWithO365();
if (!success) {
@@ -49,30 +48,7 @@ const Login = () => {
return;
}
// Étape 2 : Récupération du token dauthentification (si ton context le fournit)
const token = localStorage.getItem("o365_token");
// ⚠️ Ici jimagine que tu stockes ton token quelque part (dans ton AuthContext ou localStorage).
// Adapte selon ton implémentation de loginWithO365
// Étape 3 : Appel de ton API PHP
const response = await fetch("http://localhost/GTA/project/public/php/initial-sync.php", {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
},
});
const data = await response.json();
console.log("Résultat syncGroups :", data);
if (!data.success) {
setError("Erreur de synchronisation des groupes : " + data.message);
setIsLoading(false);
return;
}
// Étape 4 : Redirection vers le dashboard
// Redirection vers le dashboard
navigate('/dashboard');
} catch (error) {
@@ -90,8 +66,8 @@ const Login = () => {
setIsLoading(false);
};
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex flex-col lg:flex-row">
{/* Image côté gauche */}
<div className="h-32 lg:h-auto lg:flex lg:w-1/2 bg-cover bg-center"
@@ -105,17 +81,20 @@ const Login = () => {
<div className="max-w-md w-full">
<div className="bg-white rounded-2xl shadow-xl p-6 lg:p-8">
{/* Logo */}
<div className="text-center mb-6 lg:mb-8">
<div className="w-12 h-12 lg:w-16 lg:h-16 bg-cyan-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Building2 className="w-6 h-6 lg:w-8 lg:h-8 text-white" />
<div className="text-center mb-4">
<img
src="/assets/GA.svg"
alt="GTA Logo"
className="h-36 lg:h-40 w-auto mx-auto"
/>
<p className="text-lg lg:text-xl font-semibold mb-6" style={{ color: '#7e5aa2' }}>
GESTION DES TEMPS ET DES ACTIVITÉS
</p>
</div>
<h1 className="text-xl lg:text-2xl font-bold text-gray-900">GTA</h1>
<p className="text-sm lg:text-base text-gray-600">Gestion de congés</p>
</div>
{/* Connexion Office 365 prioritaire */}
<div className="mb-6">
{/* Bouton Office 365 */}
<div>
<button
data-testid="o365-login-btn"
onClick={handleO365Login}
disabled={isLoading}
type="button"
@@ -134,16 +113,9 @@ const Login = () => {
</button>
</div>
{/* Formulaire classique */}
{/* Affichage des erreurs */}
{/* Message d'erreur */}
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
<div className="p-3 bg-red-50 border border-red-200 rounded-lg mt-4">
<div className="flex items-start space-x-2">
<AlertTriangle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
@@ -160,12 +132,6 @@ const Login = () => {
</div>
</div>
)}
{/* Info sur l'authentification */}
</div>
</div>
</div>

View File

@@ -1,44 +1,49 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../context/AuthContext';
import Sidebar from '../components/Sidebar';
import { Users, CheckCircle, XCircle, Clock, Calendar, FileText, Menu, Eye, MessageSquare } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import React, { useState, useEffect } from "react";
import { useAuth } from "../context/AuthContext";
import Sidebar from "../components/Sidebar";
import {
Users,
CheckCircle,
XCircle,
Clock,
FileText,
Eye,
Check,
X,
MessageSquare,
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
const Manager = () => {
const { user } = useAuth();
const [sidebarOpen, setSidebarOpen] = useState(false);
const isEmployee = user?.role === 'validateur';
const isEmployee = user?.role === "Collaborateur" || user?.role === "Apprenti";
const [teamMembers, setTeamMembers] = useState([]);
const [pendingRequests, setPendingRequests] = useState([]);
const [allRequests, setAllRequests] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [selectedRequest, setSelectedRequest] = useState(null);
const [showValidationModal, setShowValidationModal] = useState(false);
const [validationComment, setValidationComment] = useState('');
const [validationAction, setValidationAction] = useState('');
const navigate = useNavigate();
const [toast, setToast] = useState(null);
const [validationModal, setValidationModal] = useState(null);
const [comment, setComment] = useState("");
const [isValidating, setIsValidating] = useState(false);
useEffect(() => {
if (user?.id) {
fetchTeamData();
}
if (user?.id) fetchTeamData();
}, [user]);
const fetchTeamData = async () => {
try {
setIsLoading(true);
// Récupérer les membres de l'équipe
await fetchTeamMembers();
// Récupérer les demandes en attente
await fetchPendingRequests();
// Récupérer toutes les demandes de l'équipe
await fetchAllTeamRequests();
await Promise.all([
fetchTeamMembers(),
fetchPendingRequests(),
fetchAllTeamRequests(),
]);
} catch (error) {
console.error('Erreur lors de la récupération des données équipe:', error);
console.error("Erreur lors du chargement:", error);
} finally {
setIsLoading(false);
}
@@ -46,373 +51,432 @@ const Manager = () => {
const fetchTeamMembers = async () => {
try {
const response = await fetch(`http://localhost/GTA/project/public/php/getTeamMembers.php?manager_id=${user.id}`);
const text = await response.text();
console.log('Réponse équipe:', text);
const data = JSON.parse(text);
if (data.success) {
setTeamMembers(data.team_members || []);
}
} catch (error) {
console.error('Erreur récupération équipe:', error);
const res = await fetch(`http://localhost:3000/getTeamMembers?manager_id=${user.id}`);
const data = await res.json();
if (data.success) setTeamMembers(data.team_members || []);
else setTeamMembers([]);
} catch {
setTeamMembers([]);
}
};
const fetchPendingRequests = async () => {
try {
const response = await fetch(`http://localhost/GTA/project/public/php/getPendingRequests.php?manager_id=${user.id}`);
const text = await response.text();
console.log('Réponse demandes en attente:', text);
const data = JSON.parse(text);
if (data.success) {
setPendingRequests(data.requests || []);
}
} catch (error) {
console.error('Erreur récupération demandes en attente:', error);
const res = await fetch(`http://localhost:3000/getPendingRequests?manager_id=${user.id}`);
const data = await res.json();
if (data.success) setPendingRequests(data.requests || []);
else setPendingRequests([]);
} catch {
setPendingRequests([]);
}
};
const fetchAllTeamRequests = async () => {
try {
const response = await fetch(`http://localhost/GTA/project/public/php/getAllTeamRequests.php?SuperieurId=${user.id}`);
const text = await response.text();
console.log('Réponse toutes demandes équipe:', text);
const data = JSON.parse(text);
if (data.success) {
setAllRequests(data.requests || []);
}
} catch (error) {
console.error('Erreur récupération toutes demandes:', error);
console.log('Réponse brute:', text);
const res = await fetch(`http://localhost:3000/getAllTeamRequests?SuperieurId=${user.id}`);
const data = await res.json();
if (data.success) setAllRequests(data.requests || []);
else setAllRequests([]);
} catch {
setAllRequests([]);
}
};
const openValidationModal = (request, action) => {
setValidationModal({ request, action });
setComment("");
};
const closeValidationModal = () => {
setValidationModal(null);
setComment("");
};
const confirmValidation = async () => {
const { request, action } = validationModal;
if (action === "reject" && !comment.trim()) {
showToast("error", "Un commentaire est obligatoire pour refuser une demande");
return;
}
await handleValidateRequest(request.id, action, comment);
closeValidationModal();
};
const handleValidateRequest = async (requestId, action, comment = '') => {
if (!user || !user.id) {
alert('❌ Utilisateur non identifié');
return;
}
try {
const response = await fetch('http://localhost/GTA/project/public/php/validateRequest.php', {
setIsValidating(true); // ✅ Maintenant défini
const response = await fetch('http://localhost:3000/validateRequest', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
request_id: requestId,
action: action, // 'approve' ou 'reject'
comment: comment,
validator_id: user.id
action: action,
validator_id: user.id,
comment: comment
}),
});
const text = await response.text();
console.log('Réponse validation:', text);
const data = JSON.parse(text);
const data = await response.json();
if (data.success) {
// Rafraîchir les données
await fetchTeamData();
setShowValidationModal(false);
setSelectedRequest(null);
setValidationComment('');
alert(`Demande ${action === 'approve' ? 'approuvée' : 'refusée'} avec succès !`);
// Rafraîchir les données
await Promise.all([
fetchPendingRequests(),
fetchAllTeamRequests()
]);
} else {
alert(`Erreur: ${data.message}`);
alert(`Erreur : ${data.message}`);
}
} catch (error) {
console.error('Erreur validation:', error);
alert('Erreur lors de la validation');
console.error('Erreur lors de la validation:', error);
alert('Erreur lors de la validation de la demande');
} finally {
setIsValidating(false);
}
};
const openValidationModal = (request, action) => {
setSelectedRequest(request);
setValidationAction(action);
setValidationComment('');
setShowValidationModal(true);
const showToast = (type, message) => {
setToast({ type, message });
setTimeout(() => setToast(null), 4000);
};
const getStatusColor = (status) => {
switch (status) {
case 'En attente': return 'bg-yellow-100 text-yellow-800';
case 'Validée':
case 'Approuvé': return 'bg-green-100 text-green-800';
case 'Refusée': return 'bg-red-100 text-red-800';
default: return 'bg-gray-100 text-gray-800';
case "En attente": return "bg-yellow-100 text-yellow-800";
case "Validée":
case "Approuvé": return "bg-green-100 text-green-800";
case "Refusée": return "bg-red-100 text-red-800";
default: return "bg-gray-100 text-gray-800";
}
};
const getTypeColor = (type) => {
switch (type) {
case 'Congés payés':
case 'Congé payé': return 'bg-blue-100 text-blue-800';
case 'RTT': return 'bg-green-100 text-green-800';
case 'Congé maladie': return 'bg-red-100 text-red-800';
default: return 'bg-gray-100 text-gray-800';
case "Congés payés":
case "Congé payé": return "bg-blue-100 text-blue-800";
case "RTT": return "bg-green-100 text-green-800";
case "Congé maladie": return "bg-red-100 text-red-800";
default: return "bg-gray-100 text-gray-800";
}
};
const EmptyBackground = ({ icon: Icon, title, subtitle }) => (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="absolute inset-0 flex flex-col items-center justify-center bg-gradient-to-b from-gray-50 to-gray-100 text-gray-500 pointer-events-none"
>
<motion.div
animate={{ y: [0, -8, 0] }}
transition={{ duration: 3, repeat: Infinity, ease: "easeInOut" }}
className="bg-gray-200 p-5 rounded-full shadow-inner mb-4"
>
<Icon className="w-12 h-12 text-gray-400" />
</motion.div>
<h2 className="text-xl font-semibold mb-1 text-gray-700">{title}</h2>
<p className="text-sm text-gray-500">{subtitle}</p>
</motion.div>
);
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50">
<Sidebar isOpen={sidebarOpen} onToggle={() => setSidebarOpen(!sidebarOpen)} />
<div className="lg:ml-60 flex items-center justify-center min-h-screen">
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Chargement des données équipe...</p>
</div>
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-blue-600 mx-auto mb-3"></div>
<p className="text-gray-600">Chargement des données...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 flex">
<Sidebar isOpen={sidebarOpen} onToggle={() => setSidebarOpen(!sidebarOpen)} />
<div className="flex-1 lg:ml-60">
<div className="p-4 lg:p-8 w-full">
{/* Mobile menu button */}
<div className="lg:hidden mb-4">
<button
onClick={() => setSidebarOpen(true)}
className="p-2 rounded-lg bg-white shadow-sm border border-gray-200"
<div className="relative min-h-screen bg-gray-50 flex overflow-hidden">
{/* Toast Notification */}
<AnimatePresence>
{toast && (
<motion.div
initial={{ opacity: 0, y: -50, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -20, scale: 0.95 }}
className="fixed top-6 left-1/2 transform -translate-x-1/2 z-50 max-w-md w-full mx-4"
>
<Menu className="w-6 h-6" />
<div className={`rounded-xl shadow-2xl p-4 flex items-center gap-3 backdrop-blur-sm border-2 ${toast.type === "success" ? "bg-green-50 border-green-500 text-green-900" : "bg-red-50 border-red-500 text-red-900"
}`}>
<div className={`p-2 rounded-full ${toast.type === "success" ? "bg-green-500" : "bg-red-500"}`}>
{toast.type === "success" ? (
<Check className="w-5 h-5 text-white" />
) : (
<X className="w-5 h-5 text-white" />
)}
</div>
<div className="flex-1">
<p className="font-semibold text-sm">{toast.message}</p>
</div>
<button
onClick={() => setToast(null)}
className={`p-1 rounded-lg transition ${toast.type === "success" ? "hover:bg-green-200" : "hover:bg-red-200"}`}
>
<X className="w-4 h-4" />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Header */}
<div className="mb-8">
<h1 className="text-2xl lg:text-3xl font-bold text-gray-900 mb-2">
{isEmployee ? 'Mon équipe 👥' : 'Gestion d\'équipe 👥'}
</h1>
<p className="text-sm lg:text-base text-gray-600">
{isEmployee ? 'Consultez les congés de votre équipe' : 'Gérez les demandes de congés de votre équipe'}
</p>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-6 mb-8">
<div className="bg-white rounded-xl p-6 shadow-sm border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-xs lg:text-sm font-medium text-gray-600">Équipe</p>
<p className="text-xl lg:text-2xl font-bold text-gray-900">{teamMembers.length}</p>
<p className="text-xs text-gray-500">membres</p>
</div>
<div className="w-8 h-8 lg:w-12 lg:h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<Users className="w-4 h-4 lg:w-6 lg:h-6 text-blue-600" />
</div>
</div>
</div>
<div className="bg-white rounded-xl p-6 shadow-sm border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-xs lg:text-sm font-medium text-gray-600">En attente</p>
<p className="text-xl lg:text-2xl font-bold text-gray-900">{pendingRequests.length}</p>
<p className="text-xs text-gray-500">demandes</p>
</div>
<div className="w-8 h-8 lg:w-12 lg:h-12 bg-yellow-100 rounded-lg flex items-center justify-center">
<Clock className="w-4 h-4 lg:w-6 lg:h-6 text-yellow-600" />
</div>
</div>
</div>
<div className="bg-white rounded-xl p-6 shadow-sm border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-xs lg:text-sm font-medium text-gray-600">Approuvées</p>
<p className="text-xl lg:text-2xl font-bold text-gray-900">
{allRequests.filter(r => r.status === 'Validée' || r.status === 'Approuvé').length}
</p>
<p className="text-xs text-gray-500">demandes</p>
</div>
<div className="w-8 h-8 lg:w-12 lg:h-12 bg-green-100 rounded-lg flex items-center justify-center">
<CheckCircle className="w-4 h-4 lg:w-6 lg:h-6 text-green-600" />
</div>
</div>
</div>
<div className="bg-white rounded-xl p-6 shadow-sm border border-gray-100">
<div className="flex items-center justify-between">
<div>
<p className="text-xs lg:text-sm font-medium text-gray-600">Refusées</p>
<p className="text-xl lg:text-2xl font-bold text-gray-900">
{allRequests.filter(r => r.status === 'Refusée').length}
</p>
<p className="text-xs text-gray-500">demandes</p>
</div>
<div className="w-8 h-8 lg:w-12 lg:h-12 bg-red-100 rounded-lg flex items-center justify-center">
<XCircle className="w-4 h-4 lg:w-6 lg:h-6 text-red-600" />
</div>
</div>
</div>
</div>
{/* Main Content */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Demandes en attente */}
{!isEmployee && (
<div className="bg-white rounded-xl shadow-sm border border-gray-100">
<div className="p-4 lg:p-6 border-b border-gray-100">
<h2 className="text-lg lg:text-xl font-semibold text-gray-900 flex items-center gap-2">
<Clock className="w-5 h-5 text-yellow-600" />
Demandes en attente ({pendingRequests.length})
</h2>
</div>
<div className="p-4 lg:p-6">
{pendingRequests.length === 0 ? (
<div className="text-center py-8">
<Clock className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<p className="text-gray-600">Aucune demande en attente</p>
</div>
{/* Modal de validation */}
<AnimatePresence>
{validationModal && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
onClick={closeValidationModal}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
onClick={(e) => e.stopPropagation()}
className="bg-white rounded-xl shadow-2xl max-w-md w-full"
>
<div className="p-6 border-b border-gray-100">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-full ${validationModal.action === "approve" ? "bg-green-100" : "bg-red-100"
}`}>
{validationModal.action === "approve" ? (
<CheckCircle className="w-6 h-6 text-green-600" />
) : (
<div className="space-y-4">
{pendingRequests.map((request) => (
<div key={request.id} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium text-gray-900">{request.employee_name}</h3>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getTypeColor(request.type)}`}>
{request.type}
<XCircle className="w-6 h-6 text-red-600" />
)}
</div>
<div>
<h3 className="text-lg font-semibold text-gray-900">
{validationModal.action === "approve" ? "Approuver la demande" : "Refuser la demande"}
</h3>
<p className="text-sm text-gray-600">{validationModal.request.employee_name}</p>
</div>
</div>
</div>
<div className="p-6 space-y-4">
<div className="bg-gray-50 rounded-lg p-4 space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Type</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getTypeColor(validationModal.request.type)}`}>
{validationModal.request.type}
</span>
</div>
<p className="text-sm text-gray-600">{request.date_display}</p>
<p className="text-xs text-gray-500">Soumis le {request.submitted_display}</p>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Période</span>
<span className="text-sm font-medium text-gray-900">{validationModal.request.date_display}</span>
</div>
<div className="text-right">
<p className="font-medium text-gray-900">{request.days}j</p>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">Durée</span>
<span className="text-sm font-medium text-gray-900">{validationModal.request.days} jour(s)</span>
</div>
</div>
{request.reason && (
<div className="mb-3 p-2 bg-gray-50 rounded text-sm text-gray-700">
<strong>Motif:</strong> {request.reason}
{validationModal.request.reason && (
<div className="pt-2 border-t border-gray-200">
<p className="text-xs text-gray-500 mb-1">Motif :</p>
<p className="text-sm text-gray-700">{validationModal.request.reason}</p>
</div>
)}
</div>
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-2">
<MessageSquare className="w-4 h-4" />
Commentaire{" "}
{validationModal.action === "reject" && <span className="text-red-600">*</span>}
{validationModal.action === "approve" && <span className="text-gray-400 font-normal">(optionnel)</span>}
</label>
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder={validationModal.action === "approve" ? "Ajouter un commentaire..." : "Expliquer le motif du refus..."}
rows={4}
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:outline-none resize-none ${validationModal.action === "reject" && !comment.trim()
? "border-red-300 focus:ring-red-500 focus:border-red-500"
: "border-gray-300 focus:ring-blue-500 focus:border-blue-500"
}`}
/>
{validationModal.action === "reject" && !comment.trim() && (
<p className="text-xs text-red-600 mt-1">Un commentaire est obligatoire pour un refus</p>
)}
</div>
</div>
<div className="p-6 border-t border-gray-100 flex gap-3">
<button
onClick={closeValidationModal}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition font-medium"
>
Annuler
</button>
<button
onClick={confirmValidation}
disabled={validationModal.action === "reject" && !comment.trim()}
className={`flex-1 px-4 py-2 text-white rounded-lg transition font-medium disabled:opacity-50 disabled:cursor-not-allowed ${validationModal.action === "approve" ? "bg-green-600 hover:bg-green-700" : "bg-red-600 hover:bg-red-700"
}`}
>
{validationModal.action === "approve" ? "Approuver" : "Refuser"}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Fond animé si aucune donnée */}
{!isLoading && teamMembers.length === 0 && pendingRequests.length === 0 && allRequests.length === 0 && (
<EmptyBackground
icon={Users}
title="Bienvenue dans la gestion d'équipe 👋"
subtitle="Les demandes et collaborateurs apparaîtront ici dès qu'ils seront disponibles."
/>
)}
<Sidebar isOpen={sidebarOpen} onToggle={() => setSidebarOpen(!sidebarOpen)} />
<div className="flex-1 lg:ml-60 p-6 space-y-8 relative z-10">
<h1 className="text-2xl font-bold text-gray-900">
{isEmployee ? "Mon équipe 👥" : "Gestion d'équipe 👥"}
</h1>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{!isEmployee && (
<div className="bg-white rounded-xl shadow-sm border border-gray-100">
<div className="p-4 border-b border-gray-100 flex items-center gap-2">
<Clock className="w-5 h-5 text-yellow-600" />
<h2 className="font-semibold text-gray-900">Demandes en attente ({pendingRequests.length})</h2>
</div>
<div className="p-4 space-y-3">
{pendingRequests.length === 0 ? (
<p className="text-center text-gray-500">Aucune demande en attente</p>
) : (
pendingRequests.map((r) => (
<div key={r.id} className="border p-4 rounded-lg bg-gray-50 hover:bg-gray-100 transition">
<div className="flex justify-between mb-2">
<div>
<p className="font-medium text-gray-900">{r.employee_name}</p>
<p className="text-sm text-gray-600">{r.date_display}</p>
</div>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getTypeColor(r.type)}`}>
{r.type}
</span>
</div>
{r.reason && (
<p className="text-sm text-gray-700 mb-2">
<strong>Motif:</strong> {r.reason}
</p>
)}
<div className="flex gap-2">
<button
onClick={() => openValidationModal(request, 'approve')}
className="flex-1 bg-green-600 text-white px-3 py-2 rounded-lg hover:bg-green-700 transition-colors flex items-center justify-center gap-2 text-sm"
onClick={() => openValidationModal(r, "approve")}
className="flex-1 bg-green-600 text-white px-3 py-2 rounded-lg hover:bg-green-700 text-sm"
>
<CheckCircle className="w-4 h-4" />
<CheckCircle className="w-4 h-4 inline mr-1" />
Approuver
</button>
<button
onClick={() => openValidationModal(request, 'reject')}
className="flex-1 bg-red-600 text-white px-3 py-2 rounded-lg hover:bg-red-700 transition-colors flex items-center justify-center gap-2 text-sm"
onClick={() => openValidationModal(r, "reject")}
className="flex-1 bg-red-600 text-white px-3 py-2 rounded-lg hover:bg-red-700 text-sm"
>
<XCircle className="w-4 h-4" />
<XCircle className="w-4 h-4 inline mr-1" />
Refuser
</button>
</div>
</div>
))}
</div>
))
)}
</div>
</div>
)}
{/* Équipe */}
<div className={`bg-white rounded-xl shadow-sm border border-gray-100 ${isEmployee ? 'lg:col-span-2' : ''}`}>
<div className="p-4 lg:p-6 border-b border-gray-100">
<h2 className="text-lg lg:text-xl font-semibold text-gray-900 flex items-center gap-2">
<div className={`bg-white rounded-xl shadow-sm border border-gray-100 ${isEmployee ? "lg:col-span-2" : ""}`}>
<div className="p-4 border-b border-gray-100 flex items-center gap-2">
<Users className="w-5 h-5 text-blue-600" />
Mon équipe ({teamMembers.length})
</h2>
<h2 className="font-semibold text-gray-900">Mon équipe ({teamMembers.length})</h2>
</div>
<div className="p-4 lg:p-6">
<div className="p-4 space-y-2">
{teamMembers.length === 0 ? (
<div className="text-center py-8">
<Users className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<p className="text-gray-600">Aucun membre d'équipe</p>
</div>
<p className="text-center text-gray-500">Aucun membre d'équipe</p>
) : (
<div className="space-y-3">
{teamMembers.map((member) => (
<div key={member.id}
onClick={() => navigate(`/employee/${member.id}`)}
className={`flex items-center justify-between p-3 bg-gray-50 rounded-lg ${isEmployee ? 'lg:p-4' : ''}`}>
teamMembers.map((m) => (
<div
key={m.id}
onClick={() => navigate(`/employee/${m.id}`)}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 cursor-pointer transition"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<span className="text-blue-600 font-medium text-sm">
{member.prenom?.charAt(0)}{member.nom?.charAt(0)}
{m.prenom?.charAt(0)}{m.nom?.charAt(0)}
</span>
</div>
<div>
<p className="font-medium text-gray-900">{member.prenom} {member.nom}</p>
<p className="text-sm text-gray-600">{member.email}</p>
<p className="font-medium text-gray-900">{m.prenom} {m.nom}</p>
<p className="text-sm text-gray-600">{m.email}</p>
</div>
</div>
{!isEmployee && (
<div className="text-right">
<p className="text-sm font-medium text-gray-900">
{allRequests.filter(r => r.employee_id === member.id && r.status === 'En attente').length} en attente
{allRequests.filter((r) => r.employee_id === m.id && r.status === "En attente").length} en attente
</p>
<p className="text-xs text-gray-500">
{allRequests.filter(r => r.employee_id === member.id).length} total
{allRequests.filter((r) => r.employee_id === m.id).length} total
</p>
</div>
)}
</div>
))}
</div>
))
)}
</div>
</div>
</div>
{/* Historique des demandes */}
{!isEmployee && (
<div className="mt-6 bg-white rounded-xl shadow-sm border border-gray-100">
<div className="p-4 lg:p-6 border-b border-gray-100">
<h2 className="text-lg lg:text-xl font-semibold text-gray-900 flex items-center gap-2">
<div className="bg-white rounded-xl shadow-sm border border-gray-100 mt-6">
<div className="p-4 border-b border-gray-100 flex items-center gap-2">
<FileText className="w-5 h-5 text-gray-600" />
Historique des demandes ({allRequests.length})
</h2>
<h2 className="font-semibold text-gray-900">Historique des demandes ({allRequests.length})</h2>
</div>
<div className="p-4 lg:p-6">
<div className="p-4 space-y-3 max-h-80 overflow-y-auto">
{allRequests.length === 0 ? (
<div className="text-center py-8">
<FileText className="w-12 h-12 text-gray-400 mx-auto mb-3" />
<p className="text-gray-600">Aucune demande</p>
</div>
<p className="text-center text-gray-500">Aucune demande</p>
) : (
<div className="space-y-3 max-h-80 overflow-y-auto">
{allRequests.map((request) => (
<div key={request.id} className="p-3 border border-gray-100 rounded-lg hover:bg-gray-50 transition-colors">
allRequests.map((r) => (
<div key={r.id} className="p-3 border border-gray-100 rounded-lg hover:bg-gray-50 transition-colors">
<div className="flex items-center gap-2 mb-2">
<p className="font-medium text-gray-900">{request.employee_name}</p>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getTypeColor(request.type)}`}>
{request.type}
<p className="font-medium text-gray-900">{r.employee_name}</p>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getTypeColor(r.type)}`}>
{r.type}
</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(request.status)}`}>
{request.status}
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(r.status)}`}>
{r.status}
</span>
</div>
<p className="text-sm text-gray-600">{request.date_display}</p>
<p className="text-xs text-gray-500 mb-2">Soumis le {request.submitted_display}</p>
{request.reason && (
<p className="text-sm text-gray-700 mb-1"><strong>Motif :</strong> {request.reason}</p>
<p className="text-sm text-gray-600">{r.date_display}</p>
<p className="text-xs text-gray-500 mb-2">Soumis le {r.submitted_display}</p>
{r.reason && (
<p className="text-sm text-gray-700 mb-1">
<strong>Motif :</strong> {r.reason}
</p>
)}
{request.file && (
{r.file && (
<div className="text-sm mt-1">
<p className="text-gray-500">Document joint</p>
<a
href={`http://localhost/GTA/project/uploads/${request.file}`}
href={`http://localhost:3000/uploads/${r.file}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline flex items-center gap-1 mt-1"
@@ -423,105 +487,14 @@ const Manager = () => {
</div>
)}
<div className="text-right mt-2">
<p className="font-medium text-gray-900">{request.days}j</p>
</div>
</div>
))}
</div>
))
)}
</div>
</div>
)}
</div>
</div>
{/* Modal de validation */}
{showValidationModal && selectedRequest && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl max-w-md w-full">
{/* Header */}
<div className="p-6 border-b border-gray-100">
<h3 className="text-lg font-semibold text-gray-900">
{validationAction === 'approve' ? 'Approuver' : 'Refuser'} la demande
</h3>
</div>
{/* Corps du contenu */}
<div className="p-6">
<div className="mb-4 p-4 bg-gray-50 rounded-lg">
<p className="font-medium text-gray-900">{selectedRequest.employee_name}</p>
<p className="text-sm text-gray-600">
{selectedRequest.type} - {selectedRequest.date_display}
</p>
<p className="text-sm text-gray-600">{selectedRequest.days} jour(s)</p>
{selectedRequest.reason && (
<p className="text-sm text-gray-600 mt-2">
<strong>Motif:</strong> {selectedRequest.reason}
</p>
)}
{selectedRequest.file && (
<div>
<p className="text-gray-500">Document joint</p>
<a
href={`http://localhost/GTA/project/uploads/${selectedRequest.file}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline flex items-center gap-2"
>
<Eye className="w-4 h-4" />
Voir le fichier
</a>
</div>
)}
</div>
{/* Champ commentaire */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Commentaire {validationAction === 'reject' ? '(obligatoire)' : '(optionnel)'}
</label>
<textarea
value={validationComment}
onChange={(e) => setValidationComment(e.target.value)}
placeholder={validationAction === 'approve' ? 'Commentaire optionnel...' : 'Motif du refus...'}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
/>
</div>
{/* Boutons */}
<div className="flex gap-3">
<button
onClick={() => setShowValidationModal(false)}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Annuler
</button>
<button
onClick={() =>
handleValidateRequest(selectedRequest.id, validationAction, validationComment)
}
disabled={validationAction === 'reject' && !validationComment.trim()}
className={`flex-1 px-4 py-2 text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${validationAction === 'approve'
? 'bg-green-600 hover:bg-green-700'
: 'bg-red-600 hover:bg-red-700'
}`}
>
{validationAction === 'approve' ? 'Approuver' : 'Refuser'}
</button>
</div>
</div>
</div>
</div>
)}
</div>
</div >
);
};

View File

@@ -1,24 +1,22 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { useAuth } from '../context/AuthContext';
import Sidebar from '../components/Sidebar';
import { Plus, Search, Filter, Eye, Menu, X } from 'lucide-react';
import { Plus, Search, Filter, Eye, Menu, X, RefreshCw, Wifi, WifiOff, Info, Edit2, Trash2 } from 'lucide-react';
import NewLeaveRequestModal from '../components/NewLeaveRequestModal';
import EditLeaveRequestModal from '../components/EditLeaveRequestModal';
import { useMsal } from "@azure/msal-react";
import MedicalDocuments from '../components/MedicalDocuments';
const Requests = () => {
const { user } = useAuth();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [leaveCounters, setLeaveCounters] = useState({
availableCP: 0,
availableRTT: 0,
availableABS: 0,
rttInProcess: 0,
absenteism: 0
});
const [detailedCounters, setDetailedCounters] = useState(null);
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
const [showEditRequestModal, setShowEditRequestModal] = useState(false);
const [requestToEdit, setRequestToEdit] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [allRequests, setAllRequests] = useState([]);
const [filteredRequests, setFilteredRequests] = useState([]);
@@ -37,6 +35,16 @@ const Requests = () => {
const { instance, accounts } = useMsal();
const userId = user?.id || user?.CollaborateurADId || user?.ID;
const [lastRefresh, setLastRefresh] = useState(new Date());
// States pour SSE
const [sseConnected, setSseConnected] = useState(false);
const [toasts, setToasts] = useState([]);
// ⭐ NOUVEAU : State pour la modal de confirmation de suppression
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [requestToDelete, setRequestToDelete] = useState(null);
useEffect(() => {
if (accounts.length > 0) {
const request = {
@@ -47,7 +55,6 @@ const Requests = () => {
instance.acquireTokenSilent(request)
.then((response) => {
setGraphToken(response.accessToken);
console.log("✅ Token Graph récupéré :", response.accessToken);
})
.catch((err) => {
console.error("❌ Erreur récupération token Graph:", err);
@@ -55,64 +62,24 @@ const Requests = () => {
}
}, [accounts, instance]);
useEffect(() => {
if (userId) {
fetchLeaveCounters();
fetchAllRequests();
}
}, [user]);
useEffect(() => {
let filtered = allRequests;
if (searchTerm) {
filtered = filtered.filter(request =>
request.type.toLowerCase().includes(searchTerm.toLowerCase()) ||
request.reason.toLowerCase().includes(searchTerm.toLowerCase()) ||
request.dateDisplay.includes(searchTerm)
);
}
if (statusFilter !== 'all') {
filtered = filtered.filter(request => request.status === statusFilter);
}
if (typeFilter !== 'all') {
if (typeFilter === 'Autres') {
const otherTypes = ['Récup', 'Congés sans solde', 'Congés pour évènement familial', 'Congé maternité', 'Congé paternité', 'Congé parental', 'Congé parental à temps partiel'];
filtered = filtered.filter(request => otherTypes.includes(request.type));
} else {
filtered = filtered.filter(request => request.type === typeFilter);
}
}
setFilteredRequests(filtered);
setCurrentPage(1);
}, [allRequests, searchTerm, statusFilter, typeFilter]);
const fetchLeaveCounters = async () => {
const fetchDetailedCounters = async () => {
try {
const response = await fetch(`http://localhost/GTA/project/public/php/getLeaveCounters.php?user_id=${userId}`);
const text = await response.text();
let data;
try {
data = JSON.parse(text);
} catch {
throw new Error('Le serveur PHP ne répond pas correctement');
}
const response = await fetch(`http://localhost:3000/getDetailedLeaveCounters?user_id=${userId}`);
const data = await response.json();
if (data.success) {
setLeaveCounters(data.counters);
setDetailedCounters(data.data);
} else {
throw new Error(data.message || 'Erreur lors de la récupération des compteurs');
console.error("Erreur compteurs:", data.message);
}
} catch (error) {
console.error('Erreur compteurs:', error);
console.error('💥 Erreur compteurs:', error);
}
};
const fetchAllRequests = async () => {
try {
const url = `http://localhost/GTA/project/public/php/getRequests.php?user_id=${userId}`;
const url = `http://localhost:3000/getRequests?user_id=${userId}`;
const response = await fetch(url);
const text = await response.text();
let data;
@@ -128,17 +95,199 @@ const Requests = () => {
}
} catch (error) {
console.error('Erreur requêtes:', error);
} finally {
setIsLoading(false);
}
};
// Fonction de rafraîchissement unifiée
const refreshAllData = useCallback(async () => {
if (!userId) return;
console.log('🔄 Rafraîchissement des données...');
if (!isLoading) setIsRefreshing(true);
try {
await Promise.all([
fetchDetailedCounters(),
fetchAllRequests()
]);
setLastRefresh(new Date());
console.log('✅ Données rafraîchies');
} catch (error) {
console.error('❌ Erreur lors du rafraîchissement:', error);
} finally {
setIsLoading(false);
setIsRefreshing(false);
}
}, [userId]);
// Fonction pour afficher des toasts
const showToast = useCallback((message, type = 'info') => {
const id = Date.now();
const newToast = { id, message, type };
setToasts(prev => [...prev, newToast]);
// Auto-remove après 5 secondes
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id));
}, 5000);
}, []);
// ⭐ NOUVELLE FONCTION : Modifier une demande
const handleEditRequest = (request) => {
if (request.status !== 'En attente') {
showToast('⚠️ Vous ne pouvez modifier que les demandes en attente', 'warning');
return;
}
setRequestToEdit(request);
setShowEditRequestModal(true);
};
// ⭐ NOUVELLE FONCTION : Supprimer une demande
const handleDeleteRequest = (request) => {
setRequestToDelete(request);
setShowDeleteConfirm(true);
};
// ⭐ NOUVELLE FONCTION : Confirmer la suppression
const confirmDeleteRequest = async () => {
if (!requestToDelete) return;
try {
const response = await fetch('http://localhost:3000/deleteRequest', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
requestId: requestToDelete.id,
userId: userId,
userEmail: user.email,
userName: `${user.prenom} ${user.nom}`,
accessToken: graphToken
}),
});
const data = await response.json();
if (data.success) {
showToast('✅ Demande supprimée avec succès', 'success');
refreshAllData();
setShowDeleteConfirm(false);
setRequestToDelete(null);
if (selectedRequest?.id === requestToDelete.id) {
setSelectedRequest(null);
}
} else {
showToast(`❌ Erreur : ${data.message}`, 'error');
}
} catch (error) {
console.error('Erreur suppression:', error);
showToast('❌ Erreur lors de la suppression', 'error');
}
};
// Connexion SSE
useEffect(() => {
if (!userId) return;
console.log('🔌 Connexion SSE au serveur collaborateurs...');
const eventSource = new EventSource(`http://localhost:3000/api/events/collaborateur?user_id=${userId}`);
eventSource.onopen = () => {
console.log('✅ SSE connecté');
setSseConnected(true);
};
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('📩 SSE reçu:', data);
switch (data.type) {
case 'connected':
console.log('✅ Connexion SSE établie:', data.message);
break;
case 'heartbeat':
break;
case 'demande-validated-rh':
console.log(`🔔 Validation RH reçue: ${data.statut}`);
if (data.statut === 'Validée') {
showToast('✅ Votre demande a été VALIDÉE !', 'success');
} else if (data.statut === 'Refusée') {
showToast('❌ Votre demande a été REFUSÉE', 'error');
}
refreshAllData();
break;
case 'compteur-updated':
console.log('🔄 Compteurs mis à jour');
fetchDetailedCounters();
break;
default:
console.log('📩 Événement SSE:', data);
}
} catch (error) {
console.error('❌ Erreur parsing SSE:', error);
}
};
eventSource.onerror = (error) => {
console.error('❌ Erreur SSE:', error);
setSseConnected(false);
};
return () => {
console.log('🔌 Fermeture connexion SSE');
eventSource.close();
};
}, [userId, refreshAllData, showToast]);
useEffect(() => {
if (userId) {
refreshAllData();
}
}, [userId]);
useEffect(() => {
let filtered = allRequests;
if (searchTerm) {
filtered = filtered.filter(req =>
req.type.toLowerCase().includes(searchTerm.toLowerCase()) ||
req.status.toLowerCase().includes(searchTerm.toLowerCase())
);
}
if (statusFilter !== 'all') {
filtered = filtered.filter(req => req.status === statusFilter);
}
if (typeFilter !== 'all') {
filtered = filtered.filter(req => req.type === typeFilter);
}
setFilteredRequests(filtered);
setCurrentPage(1);
}, [allRequests, searchTerm, statusFilter, typeFilter]);
const indexOfLastRequest = currentPage * requestsPerPage;
const indexOfFirstRequest = indexOfLastRequest - requestsPerPage;
const currentRequests = filteredRequests.slice(indexOfFirstRequest, indexOfLastRequest);
const totalPages = Math.ceil(filteredRequests.length / requestsPerPage);
const paginate = (pageNumber) => setCurrentPage(pageNumber);
const getStatusColor = (status) => {
switch (status) {
case 'Approuvé':
case 'Validée': return 'bg-green-100 text-green-800';
case 'En attente': return 'bg-yellow-100 text-yellow-800';
case 'Refusé':
case 'Validée': return 'bg-green-100 text-green-800';
case 'Refusée': return 'bg-red-100 text-red-800';
default: return 'bg-gray-100 text-gray-800';
}
@@ -152,193 +301,285 @@ const Requests = () => {
setSelectedRequest(null);
};
const indexOfLastRequest = currentPage * requestsPerPage;
const indexOfFirstRequest = indexOfLastRequest - requestsPerPage;
const currentRequests = filteredRequests.slice(indexOfFirstRequest, indexOfLastRequest);
const totalPages = Math.ceil(filteredRequests.length / requestsPerPage);
const handleRefresh = () => {
refreshAllData();
};
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50">
<Sidebar isOpen={sidebarOpen} onToggle={() => setSidebarOpen(!sidebarOpen)} />
<div className="lg:ml-60 flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Chargement des données...</p>
</div>
</div>
</div>
);
}
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
return (
<div className="min-h-screen bg-gray-50 flex">
<Sidebar isOpen={sidebarOpen} onToggle={() => setSidebarOpen(!sidebarOpen)} />
{/* Toast container */}
<div className="fixed top-4 right-4 z-50 space-y-2">
{toasts.map(toast => (
<div
key={toast.id}
className={`px-4 py-3 rounded-lg shadow-lg ${toast.type === 'success' ? 'bg-green-500 text-white' :
toast.type === 'error' ? 'bg-red-500 text-white' :
toast.type === 'warning' ? 'bg-yellow-500 text-white' :
'bg-blue-500 text-white'
} animate-slideInRight`}
style={{ animation: 'slideInRight 0.3s ease-out' }}
>
{toast.message}
</div>
))}
</div>
<div className="flex-1 lg:ml-60 p-4 lg:p-8">
{/* Mobile top bar */}
<div className="lg:hidden flex justify-between items-center mb-4">
<button onClick={() => setSidebarOpen(true)} className="p-2 rounded-lg bg-white shadow-sm border border-gray-200">
{/* Modal de confirmation de suppression */}
{showDeleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black bg-opacity-50" onClick={() => setShowDeleteConfirm(false)}></div>
<div className="relative bg-white rounded-xl shadow-xl p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-semibold mb-4">Confirmer la suppression</h3>
<p className="text-gray-600 mb-6">
Êtes-vous sûr de vouloir supprimer cette demande ?
<br /><strong>Type :</strong> {requestToDelete?.type}
<br /><strong>Dates :</strong> {requestToDelete?.dateDisplay}
<br /><br />
<span className="text-sm text-gray-500">Un email sera envoyé à votre manager pour l'informer.</span>
</p>
<div className="flex gap-3 justify-end">
<button
onClick={() => setShowDeleteConfirm(false)}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200"
>
Annuler
</button>
<button
onClick={confirmDeleteRequest}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
>
Supprimer
</button>
</div>
</div>
</div>
)}
<div className="flex-1 lg:ml-64">
<div className="p-4 lg:p-8 max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<button onClick={() => setSidebarOpen(true)} className="lg:hidden p-2">
<Menu className="w-6 h-6" />
</button>
<div className="flex items-center gap-2">
<button onClick={() => setShowFilters(!showFilters)} className="p-2 rounded-lg bg-white shadow-sm border border-gray-200 flex items-center gap-2">
<Filter className="w-4 h-4" /> <span className="text-sm">Filtres</span>
</button>
<button onClick={() => setShowNewRequestModal(true)} className="p-2 rounded-lg bg-blue-600 text-white">
<Plus className="w-4 h-4" />
</button>
</div>
</div>
{/* Header */}
<div className="flex justify-between items-center mb-4 lg:mb-8">
<div>
<h1 className="text-2xl lg:text-3xl font-bold text-gray-900 mb-1">Mes Demandes de Congés</h1>
<p className="text-sm text-gray-600">Gérez toutes vos demandes de congés</p>
</div>
<h1 className="text-2xl lg:text-3xl font-bold text-gray-900">Mes demandes</h1>
<div className="hidden lg:flex items-center gap-3">
<button onClick={() => setShowNewRequestModal(true)} className="bg-cyan-600 text-white px-4 py-2 rounded-lg flex items-center gap-2">
<Plus className="w-4 h-4" /> Nouvelle demande
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleRefresh}
disabled={isRefreshing}
className={`p-2 text-gray-600 hover:bg-gray-100 rounded-lg ${isRefreshing ? 'opacity-50 cursor-not-allowed' : ''}`}
title="Rafraîchir"
>
<RefreshCw className={`w-5 h-5 ${isRefreshing ? 'animate-spin' : ''}`} />
</button>
<button
onClick={() => setShowNewRequestModal(true)}
className="bg-blue-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-blue-700 text-sm lg:text-base"
>
<Plus className="w-5 h-5" /> Nouvelle demande
</button>
</div>
</div>
{/* Filters panel (mobile toggle + desktop always visible) */}
<div className={`${showFilters ? 'block' : 'hidden'} lg:block bg-white rounded-xl shadow-sm border border-gray-100 p-4 lg:p-6 mb-6`}>
<div className="flex flex-col lg:flex-row gap-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
{/* Compteurs */}
{detailedCounters && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{/* CP N */}
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100">
<div className="flex justify-between items-start mb-2">
<h3 className="text-sm font-medium text-gray-500">CP Année N</h3>
</div>
<p className="text-2xl font-bold text-gray-900">{detailedCounters.cpN?.solde?.toFixed(1) || '0.0'}</p>
<p className="text-xs text-gray-500 mt-1">Sur {detailedCounters.cpN?.acquis?.toFixed(1) || '0.0'} acquis</p>
</div>
{/* CP N-1 */}
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100">
<div className="flex justify-between items-start mb-2">
<h3 className="text-sm font-medium text-gray-500">CP Année N-1</h3>
</div>
<p className="text-2xl font-bold text-gray-900">{detailedCounters.cpN1?.solde?.toFixed(1) || '0.0'}</p>
<p className="text-xs text-gray-500 mt-1">Sur {detailedCounters.cpN1?.acquis?.toFixed(1) || '0.0'} acquis</p>
</div>
{/* RTT N */}
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100">
<div className="flex justify-between items-start mb-2">
<h3 className="text-sm font-medium text-gray-500">RTT Année N</h3>
</div>
<p className="text-2xl font-bold text-gray-900">{detailedCounters.rttN?.solde?.toFixed(1) || '0.0'}</p>
<p className="text-xs text-gray-500 mt-1">Sur {detailedCounters.rttN?.acquis?.toFixed(1) || '0.0'} acquis</p>
</div>
{/* Total disponible */}
<div className="bg-gradient-to-br from-blue-500 to-blue-600 p-4 rounded-xl shadow-sm text-white">
<h3 className="text-sm font-medium opacity-90 mb-2">Total disponible</h3>
<p className="text-2xl font-bold">
{(
(detailedCounters.cpN?.solde || 0) +
(detailedCounters.cpN1?.solde || 0) +
(detailedCounters.rttN?.solde || 0)
).toFixed(1)}
</p>
<p className="text-xs opacity-75 mt-1">Jours ouvrés</p>
</div>
</div>
)}
{/* Main content */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left: list */}
<div className="lg:col-span-2">
{/* Filters */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 mb-4">
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
type="text"
placeholder="Rechercher par type, motif ou date..."
placeholder="Rechercher..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 text-sm"
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<button
onClick={() => setShowFilters(!showFilters)}
className="flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<Filter className="w-5 h-5" />
Filtres
</button>
</div>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg text-sm">
{showFilters && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3 pt-3 border-t">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">Tous les statuts</option>
<option value="En attente">En attente</option>
<option value="Validée">Validée</option>
<option value="Refusée">Refusée</option>
</select>
<select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg text-sm">
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className="px-3 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">Tous les types</option>
<option value="Congés payés">Congés payés</option>
<option value="Congé payé">Congé payé</option>
<option value="RTT">RTT</option>
<option value="Congé maladie">Congé maladie</option>
<option value="Autres">Autres types</option>
<option value="Arrêt maladie">Arrêt maladie</option>
<option value="Absence">Absence</option>
</select>
</div>
<div className="mt-4 text-sm text-gray-600">
{filteredRequests.length} demande{filteredRequests.length > 1 ? 's' : ''} trouvée{filteredRequests.length > 1 ? 's' : ''}
{allRequests.length !== filteredRequests.length && ` sur ${allRequests.length} au total`}
</div>
)}
</div>
{/* Main content: left = list/table, right = details (desktop) */}
<div className="flex flex-col lg:flex-row gap-6">
{/* Left: table (desktop) + cards (mobile) */}
<div className="flex-1 bg-white rounded-xl shadow-sm border border-gray-100">
<div className="p-6 border-b border-gray-100 flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900">Toutes mes demandes</h2>
<div className="flex items-center gap-2 text-sm text-gray-500">
<Filter className="w-4 h-4" />
<span>Page {currentPage} / {totalPages || 1}</span>
{/* Requests list */}
{isLoading ? (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-8 text-center">
<RefreshCw className="w-8 h-8 animate-spin mx-auto text-blue-600 mb-2" />
<p className="text-gray-500">Chargement...</p>
</div>
) : currentRequests.length === 0 ? (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-8 text-center">
<Info className="w-12 h-12 mx-auto text-gray-300 mb-3" />
<p className="text-gray-500">Aucune demande trouvée</p>
</div>
<div className="p-6">
{currentRequests.length === 0 ? (
<div className="text-center py-8 text-gray-600">Aucune demande à afficher.</div>
) : (
<>
{/* Desktop table */}
<div className="hidden lg:block overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-3 px-4 text-gray-700">Type</th>
<th className="text-left py-3 px-4 text-gray-700">Dates</th>
<th className="text-left py-3 px-4 text-gray-700">Jours</th>
<th className="text-left py-3 px-4 text-gray-700">Statut</th>
<th className="text-left py-3 px-4 text-gray-700">Soumis</th>
<th className="text-left py-3 px-4 text-gray-700">Actions</th>
</tr>
</thead>
<tbody>
{currentRequests.map(request => (
<tr key={request.id} className="border-b hover:bg-gray-50">
<td className="py-4 px-4">
{request.type.split(',').map((t, i) => (
<span key={i} className="inline-block bg-gray-100 text-gray-800 text-[11px] px-2 py-0.5 rounded-full mr-1">
{t.trim()}
</span>
))}
</td>
<td className="py-4 px-4">{request.dateDisplay}</td>
<td className="py-4 px-4">{request.days}</td>
<td className="py-4 px-4">
<div className="space-y-3">
{currentRequests.map((request) => (
<div key={request.id} className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 hover:shadow-md transition-shadow">
<div className="flex justify-between items-start mb-2">
<div className="flex-1">
<h3 className="font-semibold text-gray-900">{request.type}</h3>
<p className="text-sm text-gray-600">{request.dateDisplay}</p>
</div>
<span className={`px-2 py-0.5 rounded-full text-[10px] font-medium ${getStatusColor(request.status)}`}>
{request.status}
</span>
</td>
<td className="py-4 px-4">{request.submittedDisplay}</td>
<td className="py-4 px-4">
<button onClick={() => handleViewRequest(request)} className="text-blue-600 hover:underline text-sm flex items-center gap-1">
<Eye className="w-4 h-4" /> Voir
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="lg:hidden space-y-4">
{currentRequests.map(request => (
<div key={request.id} className="bg-white rounded-lg shadow-sm p-4 border border-gray-200">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex flex-wrap gap-1 mb-2">
{request.type.split(',').map((t, i) => (
<span key={i} className="bg-gray-100 text-gray-800 text-[11px] px-2 py-0.5 rounded-full">
{t.trim()}
</span>
))}
</div>
<p className="text-sm text-gray-600">{request.dateDisplay}</p>
<p className="text-sm font-medium mt-1">{request.days} jour{request.days > 1 ? 's' : ''}</p>
</div>
<div className="ml-3">
<span className={`px-2 py-0.5 rounded-full text-[10px] font-medium ${getStatusColor(request.status)}`}>
{request.status}
</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-500 mb-3">
<span className="bg-gray-100 px-2 py-1 rounded">{request.days} jour(s)</span>
</div>
<div className="mt-3 flex justify-between items-center text-sm">
<span className="text-gray-500">{request.submittedDisplay}</span>
<button onClick={() => handleViewRequest(request)} className="text-blue-600 flex items-center gap-1">
<Eye className="w-4 h-4" /> Voir
<div className="flex items-center gap-2">
{/* Bouton Modifier (seulement si En attente) */}
{request.status === 'En attente' && (
<button
onClick={() => handleEditRequest(request)}
className="text-blue-600 hover:text-blue-700 flex items-center gap-1 px-2 py-1 hover:bg-blue-50 rounded"
title="Modifier"
>
<Edit2 className="w-4 h-4" />
<span className="hidden sm:inline">Modifier</span>
</button>
)}
{/* Bouton Supprimer */}
<button
onClick={() => handleDeleteRequest(request)}
className="text-red-600 hover:text-red-700 flex items-center gap-1 px-2 py-1 hover:bg-red-50 rounded"
title="Supprimer"
>
<Trash2 className="w-4 h-4" />
<span className="hidden sm:inline">Supprimer</span>
</button>
{/* Bouton Voir */}
<button
onClick={() => handleViewRequest(request)}
className="text-gray-600 hover:text-gray-700 flex items-center gap-1 px-2 py-1 hover:bg-gray-50 rounded"
>
<Eye className="w-4 h-4" />
<span className="hidden sm:inline">Voir</span>
</button>
</div>
</div>
</div>
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-4 flex justify-center gap-2">
{[...Array(totalPages)].map((_, i) => (
<button
key={i}
onClick={() => paginate(i + 1)}
className={`px-3 py-1 rounded ${currentPage === i + 1
? 'bg-blue-600 text-white'
: 'bg-white border border-gray-200 text-gray-700 hover:bg-gray-50'
}`}
>
{i + 1}
</button>
))}
</div>
)}
</>
)}
</div>
</div>
{/* Right: details (desktop) */}
<div className="hidden lg:block w-full lg:max-w-sm">
{/* Right: details */}
<div className="hidden lg:block">
{selectedRequest ? (
<div className="bg-white rounded-xl shadow-md border border-gray-100 p-6 sticky top-20">
<div className="flex justify-between items-start mb-6">
@@ -373,14 +614,35 @@ const Requests = () => {
<p className="italic">{selectedRequest.reason}</p>
</div>
)}
{selectedRequest.fileUrl && (
{(selectedRequest.status === 'Validée' || selectedRequest.status === 'Refusée') && selectedRequest.validationComment && (
<div>
<p className="text-gray-500">Arrêt maladie</p>
<a href={selectedRequest.fileUrl} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline flex items-center gap-2">
<Eye className="w-4 h-4" /> Voir le fichier
</a>
<p className="text-gray-500">{selectedRequest.status === 'Validée' ? 'Commentaire de validation' : 'Motif du refus'}</p>
<p className="italic text-sm bg-gray-50 p-3 rounded-lg border-l-4" style={{ borderLeftColor: selectedRequest.status === 'Validée' ? '#10b981' : '#ef4444' }}>{selectedRequest.validationComment}</p>
</div>
)}
<MedicalDocuments demandeId={selectedRequest.id} />
</div>
{/* Actions dans la sidebar */}
<div className="mt-6 pt-4 border-t space-y-2">
{selectedRequest.status === 'En attente' && (
<button
onClick={() => handleEditRequest(selectedRequest)}
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Edit2 className="w-4 h-4" />
Modifier cette demande
</button>
)}
<button
onClick={() => handleDeleteRequest(selectedRequest)}
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
>
<Trash2 className="w-4 h-4" />
Supprimer cette demande
</button>
</div>
</div>
) : (
@@ -390,74 +652,78 @@ const Requests = () => {
)}
</div>
</div>
{/* Mobile details modal */}
{selectedRequest && (
<div className="fixed inset-0 z-50 lg:hidden">
<div className="absolute inset-0 bg-black bg-opacity-40" onClick={handleCloseDetails}></div>
<div className="absolute inset-0 flex items-center justify-center p-4">
<div className="bg-white w-full max-w-md h-full overflow-y-auto rounded-lg p-5">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">Détails</h3>
<button onClick={handleCloseDetails} className="text-gray-500">
<X className="w-6 h-6" />
</button>
</div>
<div className="space-y-4 text-sm text-gray-700">
<div>
<p className="text-gray-500">Type</p>
<p className="text-base font-medium text-gray-900">{selectedRequest.type}</p>
</div>
<div>
<p className="text-gray-500">Dates</p>
<p className="text-base font-medium text-gray-900">{selectedRequest.dateDisplay}</p>
</div>
<div>
<p className="text-gray-500">Nombre de jours</p>
<p className="text-base font-medium text-gray-900">{selectedRequest.days}</p>
</div>
<div>
<p className="text-gray-500">Statut</p>
<span className={`px-2 py-0.5 rounded-full text-[10px] font-medium ${getStatusColor(selectedRequest.status)}`}>
{selectedRequest.status}
</span>
</div>
{selectedRequest.reason && (
<div>
<p className="text-gray-500">Motif</p>
<p className="italic">{selectedRequest.reason}</p>
</div>
)}
{selectedRequest.fileUrl && (
<div>
<p className="text-gray-500">Arrêt maladie</p>
<a href={selectedRequest.fileUrl} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline flex items-center gap-2">
<Eye className="w-4 h-4" /> Voir le fichier
</a>
</div>
)}
</div>
</div>
</div>
</div>
)}
{/* New request modal */}
{showNewRequestModal && (
{/* Modals */}
{showNewRequestModal && detailedCounters && (
<NewLeaveRequestModal
onClose={() => setShowNewRequestModal(false)}
availableLeaveCounters={leaveCounters}
userId={user?.id}
availableLeaveCounters={{
availableCP_N: detailedCounters.cpN?.solde || 0,
totalCP_N: detailedCounters.cpN?.acquis || 0,
availableCP_N1: detailedCounters.cpN1?.solde || 0,
availableRTT_N: detailedCounters.rttN?.solde || 0,
totalRTT_N: detailedCounters.rttN?.acquis || 0,
availableRTT_N1: 0,
availableABS: 0,
availableCP: (detailedCounters.cpN1?.solde || 0) + (detailedCounters.cpN?.solde || 0),
availableRTT: detailedCounters.rttN?.solde || 0
}}
accessToken={graphToken}
userId={userId}
userEmail={user.email}
userRole={user.role}
userName={`${user.prenom} ${user.nom}`}
onRequestSubmitted={() => {
fetchLeaveCounters();
fetchAllRequests();
refreshAllData();
}}
/>
)}
{/* Modal d'édition */}
{showEditRequestModal && requestToEdit && detailedCounters && (
<EditLeaveRequestModal
onClose={() => {
setShowEditRequestModal(false);
setRequestToEdit(null);
}}
request={requestToEdit}
availableLeaveCounters={{
availableCP_N: detailedCounters.cpN?.solde || 0,
totalCP_N: detailedCounters.cpN?.acquis || 0,
availableCP_N1: detailedCounters.cpN1?.solde || 0,
availableRTT_N: detailedCounters.rttN?.solde || 0,
totalRTT_N: detailedCounters.rttN?.acquis || 0,
availableRTT_N1: 0,
availableABS: 0,
availableCP: (detailedCounters.cpN1?.solde || 0) + (detailedCounters.cpN?.solde || 0),
availableRTT: detailedCounters.rttN?.solde || 0
}}
accessToken={graphToken}
userId={userId}
userEmail={user.email}
userRole={user.role}
userName={`${user.prenom} ${user.nom}`}
onRequestUpdated={() => {
refreshAllData();
}}
/>
)}
</div>
{/* Styles */}
<style>{`
@keyframes slideInRight {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
`}</style>
</div>
);
};

View File

@@ -0,0 +1,7 @@
import '@testing-library/jest-dom';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(() => {
cleanup();
});

View File

@@ -0,0 +1,115 @@
/**
* @vitest-environment jsdom
*/
import React from 'react';
import { it, expect, vi, beforeEach, describe } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
vi.mock('../context/AuthContext', () => ({
useAuth: vi.fn(() => ({
user: {
id: 1,
role: 'collaborateur',
email: 'test@example.com',
prenom: 'John',
nom: 'Doe'
}
}))
}));
vi.mock('../components/Sidebar', () => ({
default: () => <div data-testid="sidebar">Sidebar</div>
}));
vi.mock('../components/NewLeaveRequestModal', () => ({
default: ({ onClose }) => (
<div data-testid="leave-modal">
<div>Formulaire complet...</div>
<button onClick={onClose}>Close Modal</button>
</div>
)
}));
import Calendar from '../pages/Calendar';
global.fetch = vi.fn();
describe('Calendar', () => {
beforeEach(() => {
global.fetch.mockImplementation((url) => {
if (url.includes('calendrier.api.gouv.fr')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({})
});
}
if (url.includes('getTeamLeaves.php')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
success: true,
leaves: [],
filters: {}
})
});
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve({})
});
});
vi.clearAllMocks();
});
it('ouvre le menu contextuel sur clic droit', async () => {
render(
<MemoryRouter>
<Calendar />
</MemoryRouter>
);
// Attendre que le texte "Calendrier" soit rendu (max 30s)
const calendrierElement = await screen.findByText('Calendrier', {}, { timeout: 30000 });
expect(calendrierElement).toBeTruthy();
// Pause courte pour que le calendrier se rende complètement
await new Promise(resolve => setTimeout(resolve, 1000));
const allDayElements = screen.getAllByText(/^\d+$/);
let selectedDay;
for (const day of ['15', '16', '17', '18', '19', '20']) {
try {
selectedDay = screen.getByText(day);
break;
} catch {
continue;
}
}
if (!selectedDay && allDayElements.length > 0) {
selectedDay = allDayElements[Math.floor(allDayElements.length / 2)];
}
// Alternative à toBeInTheDocument
expect(selectedDay).toBeTruthy();
expect(document.body.contains(selectedDay)).toBe(true);
fireEvent.click(selectedDay);
// Attendre lapparition de lindication du jour sélectionné
const selectedText = await screen.findByText(/Date sélectionnée|jour.*sélectionné/, {}, { timeout: 5000 });
expect(selectedText).toBeTruthy();
// Simuler clic droit
fireEvent.contextMenu(selectedDay);
// Attendre lapparition du menu contextuel (modal)
const leaveModal = await screen.findByTestId('leave-modal', {}, { timeout: 5000 });
expect(leaveModal).toBeTruthy();
expect(screen.getByText('Formulaire complet...')).toBeTruthy();
}, 20000);
});

View File

@@ -0,0 +1,81 @@
/**
* @vitest-environment jsdom
*/
import React from 'react';
import { describe, test, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import Login from '../pages/Login';
const mockLogin = vi.fn();
const mockLoginWithO365 = vi.fn();
vi.mock('../context/AuthContext', () => ({
useAuth: () => ({
login: mockLogin,
loginWithO365: mockLoginWithO365,
isAuthorized: false,
}),
}));
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return { ...actual, useNavigate: () => mockNavigate };
});
describe('Login Component', () => {
beforeEach(() => {
vi.clearAllMocks();
});
test('affiche le bouton Office 365', () => {
render(<MemoryRouter><Login /></MemoryRouter>);
const o365Button = screen.getByRole('button', { name: /Se connecter avec Office 365/i });
expect(o365Button).toBeTruthy();
});
test('redirection après login O365 réussi', async () => {
mockLoginWithO365.mockResolvedValue(true);
render(<MemoryRouter><Login /></MemoryRouter>);
const o365Button = screen.getByRole('button', { name: /Se connecter avec Office 365/i });
fireEvent.click(o365Button);
await waitFor(() => {
expect(mockLoginWithO365).toHaveBeenCalled();
// On teste uniquement la fonction, pas l'ouverture réelle de Microsoft
});
});
test('affiche une erreur si login O365 échoue', async () => {
mockLoginWithO365.mockRejectedValue(new Error('Accès refusé'));
render(<MemoryRouter><Login /></MemoryRouter>);
const o365Button = screen.getByRole('button', { name: /Se connecter avec Office 365/i });
fireEvent.click(o365Button);
await waitFor(() => {
expect(screen.getByText(/Accès refusé/i)).toBeTruthy();
});
});
test('login classique réussi', async () => {
mockLogin.mockResolvedValue(true);
render(<MemoryRouter><Login /></MemoryRouter>);
fireEvent.change(screen.getByPlaceholderText(/Email/i), { target: { value: 'test@example.com' } });
fireEvent.change(screen.getByPlaceholderText(/Mot de passe/i), { target: { value: 'password' } });
fireEvent.click(screen.getByRole('button', { name: /Se connecter/i }));
await waitFor(() => {
expect(mockLogin).toHaveBeenCalledWith('test@example.com', 'password');
expect(mockNavigate).toHaveBeenCalledWith('/dashboard');
});
});
test('login classique échoue', async () => {
mockLogin.mockResolvedValue(false);
render(<MemoryRouter><Login /></MemoryRouter>);
fireEvent.change(screen.getByPlaceholderText(/Email/i), { target: { value: 'test@example.com' } });
fireEvent.change(screen.getByPlaceholderText(/Mot de passe/i), { target: { value: 'wrongpass' } });
fireEvent.click(screen.getByRole('button', { name: /Se connecter/i }));
await waitFor(() => {
expect(screen.getByText(/Identifiants incorrects/i)).toBeTruthy();
});
});
});

View File

@@ -1,10 +1,14 @@
import { defineConfig } from 'vite';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
optimizeDeps: {
exclude: ['lucide-react'],
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/setupTests.js',
},
});