V1_Sans_Congé_Anticipéfemini collaboratrice
8087
package-lock.json
generated
Normal file
17
package.json
Normal 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
@@ -2,7 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>GTA</title>
|
<title>GTA</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
5846
project/package-lock.json
generated
@@ -11,16 +11,27 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@azure/msal-browser": "^4.19.0",
|
"@azure/msal-browser": "^4.19.0",
|
||||||
"@azure/msal-react": "^3.0.17",
|
"@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",
|
"lucide-react": "^0.344.0",
|
||||||
|
"multer": "^2.0.2",
|
||||||
|
"mysql2": "^3.15.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^7.7.1"
|
"react-router-dom": "^7.7.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^6.8.0",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
"autoprefixer": "^10.4.18",
|
"autoprefixer": "^10.4.18",
|
||||||
|
"jest": "^30.1.1",
|
||||||
"postcss": "^8.4.35",
|
"postcss": "^8.4.35",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"vite": "^5.4.2"
|
"vite": "^5.4.2",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6245
project/public/Backend/server.js
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
19
project/public/Backend/webhook-config.js
Normal 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'
|
||||||
|
|
||||||
|
};
|
||||||
115
project/public/Backend/webhook-utils.js
Normal 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;
|
||||||
BIN
project/public/assets/GA.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
1
project/public/assets/GA.svg
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
1
project/public/assets/GATitre.svg
Normal file
|
After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 2.8 MiB |
@@ -2,146 +2,162 @@
|
|||||||
header("Access-Control-Allow-Origin: *");
|
header("Access-Control-Allow-Origin: *");
|
||||||
header("Access-Control-Allow-Methods: GET, OPTIONS");
|
header("Access-Control-Allow-Methods: GET, OPTIONS");
|
||||||
header("Access-Control-Allow-Headers: Content-Type");
|
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");
|
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";
|
||||||
|
$username = "wpuser";
|
||||||
|
$password = "-2b/)ru5/Bi8P[7_";
|
||||||
|
$dbname = "DemandeConge";
|
||||||
|
|
||||||
$host="192.168.0.4"; $dbname="DemandeConge"; $username="wpuser"; $password="-2b/)ru5/Bi8P[7_";
|
|
||||||
$conn = new mysqli($host, $username, $password, $dbname);
|
$conn = new mysqli($host, $username, $password, $dbname);
|
||||||
if ($conn->connect_error) { echo json_encode(["success"=>false,"message"=>"Erreur DB: ".$conn->connect_error]); exit(); }
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
$userId = isset($_GET['user_id']) ? (int)$_GET['user_id'] : null;
|
$today = new DateTime();
|
||||||
if (!$userId) { echo json_encode(["success"=>false,"message"=>"user_id manquant"]); $conn->close(); exit(); }
|
$yearCurrent = (int)$today->format('Y');
|
||||||
|
$yearNMinus1 = $yearCurrent - 1;
|
||||||
|
|
||||||
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 getTypeId($conn, $nom) {
|
||||||
function getRTTYear($date=null){ $d=$date?new DateTime($date):new DateTime(); return (int)$d->format('Y');}
|
$stmt = $conn->prepare("SELECT Id FROM TypeConge WHERE Nom=?");
|
||||||
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;}
|
$stmt->bind_param("s", $nom);
|
||||||
|
$stmt->execute();
|
||||||
|
$result = $stmt->get_result();
|
||||||
|
$id = null;
|
||||||
|
if ($row = $result->fetch_assoc()) {
|
||||||
|
$id = (int)$row['Id'];
|
||||||
|
}
|
||||||
|
$stmt->close();
|
||||||
|
error_log("TypeConge '$nom' => Id $id");
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
|
||||||
// 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é');
|
$cpTypeId = getTypeId($conn, 'Congé payé');
|
||||||
$rttTypeId = getTypeId($conn, 'RTT');
|
$rttTypeId = getTypeId($conn, 'RTT');
|
||||||
$absTypeId = getTypeId($conn,'Congé maladie');
|
|
||||||
|
|
||||||
$leaveYear = getLeaveYear();
|
$soldeReportInitial_CP = 0.0;
|
||||||
$rttYear = getRTTYear();
|
$soldeReportInitial_RTT = 0.0;
|
||||||
$currentDate = date('Y-m-d');
|
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
|
||||||
|
while ($collab = $collaborateursResult->fetch_assoc()) {
|
||||||
|
$collabId = (int)$collab['id'];
|
||||||
|
|
||||||
// --- Soldes initiaux (CompteurConges pour CollaborateurAD) ---
|
|
||||||
$cpSolde = 0; $rttSolde = 0; $absSolde = 0;
|
|
||||||
if ($cpTypeId !== null) {
|
if ($cpTypeId !== null) {
|
||||||
$q="SELECT Solde FROM CompteurConges WHERE CollaborateurADId=? AND TypeCongeId=? AND Annee=?";
|
$existsStmt = $conn->prepare("SELECT Id 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();
|
$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) {
|
if ($rttTypeId !== null) {
|
||||||
$q="SELECT Solde FROM CompteurConges WHERE CollaborateurADId=? AND TypeCongeId=? AND Annee=?";
|
$existsStmt = $conn->prepare("SELECT Id 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();
|
$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();
|
||||||
}
|
}
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Calcul CP en cours ---
|
$cpStart = new DateTime("$yearCurrent-06-01");
|
||||||
$cpInProcess = 0;
|
$cpEnd = new DateTime(($yearCurrent + 1) . "-05-31");
|
||||||
if ($cpTypeId !== null) {
|
$rttStart = new DateTime("$yearCurrent-01-01");
|
||||||
$sql = "
|
$rttEnd = new DateTime("$yearCurrent-12-31");
|
||||||
SELECT dc.Id, dc.DateDebut, dc.DateFin, dct.NombreJours
|
|
||||||
FROM DemandeConge dc
|
$cpAnnualDays = 25;
|
||||||
LEFT JOIN DemandeCongeType dct
|
$rttAnnualDays = 10;
|
||||||
ON dct.DemandeCongeId = dc.Id AND dct.TypeCongeId = ?
|
|
||||||
WHERE dc.CollaborateurADId = ?
|
$cpPeriodDays = $cpEnd->diff($cpStart)->days + 1;
|
||||||
AND dc.Statut IN ('En attente','Validée')
|
$rttPeriodDays = $rttEnd->diff($rttStart)->days + 1;
|
||||||
AND dc.DateFin >= ?
|
|
||||||
AND (dct.NombreJours IS NOT NULL OR FIND_IN_SET(?, dc.TypeCongeId))
|
$cpDailyIncrement = $cpAnnualDays / $cpPeriodDays;
|
||||||
";
|
$rttDailyIncrement = $rttAnnualDays / $rttPeriodDays;
|
||||||
$s = $conn->prepare($sql);
|
|
||||||
$s->bind_param("iisi", $cpTypeId, $userId, $currentDate, $cpTypeId);
|
error_log("Incrément CP jour : $cpDailyIncrement");
|
||||||
$s->execute();
|
error_log("Incrément RTT jour : $rttDailyIncrement");
|
||||||
$res = $s->get_result();
|
|
||||||
while ($r = $res->fetch_assoc()) {
|
if ($today >= $cpStart && $today <= $cpEnd && $cpTypeId !== null) {
|
||||||
if ($r['NombreJours'] !== null) {
|
$exerciseYear = (int)$cpStart->format('Y');
|
||||||
$cpInProcess += (float)$r['NombreJours'];
|
$stmt = $conn->prepare("UPDATE CompteurConges SET Solde = Solde + ? WHERE TypeCongeId = ? AND Annee = ?");
|
||||||
} else {
|
$stmt->bind_param("dii", $cpDailyIncrement, $cpTypeId, $exerciseYear);
|
||||||
$cpInProcess += getWorkingDays($r['DateDebut'], $r['DateFin']);
|
if (!$stmt->execute()) {
|
||||||
|
error_log("Erreur incrément CP N : ".$stmt->error);
|
||||||
}
|
}
|
||||||
}
|
$stmt->close();
|
||||||
$s->close();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Calcul RTT en cours ---
|
if ($today >= $rttStart && $today <= $rttEnd && $rttTypeId !== null) {
|
||||||
$rttInProcess = 0;
|
$exerciseYear = $yearCurrent;
|
||||||
if ($rttTypeId !== null) {
|
$stmt = $conn->prepare("UPDATE CompteurConges SET Solde = Solde + ? WHERE TypeCongeId = ? AND Annee = ?");
|
||||||
$sql = "
|
$stmt->bind_param("dii", $rttDailyIncrement, $rttTypeId, $exerciseYear);
|
||||||
SELECT dc.Id, dc.DateDebut, dc.DateFin, dct.NombreJours
|
if (!$stmt->execute()) {
|
||||||
FROM DemandeConge dc
|
error_log("Erreur incrément RTT N : ".$stmt->error);
|
||||||
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']);
|
|
||||||
}
|
}
|
||||||
}
|
$stmt->close();
|
||||||
$s->close();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Calcul absenteisme validé ---
|
// Récupérer les compteurs actuels de l'utilisateur demandé en GET
|
||||||
$absenteism = 0;
|
$userId = isset($_GET['user_id']) ? (int)$_GET['user_id'] : 0;
|
||||||
if ($absTypeId !== null) {
|
$data = [];
|
||||||
$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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$s->close();
|
|
||||||
}
|
|
||||||
|
|
||||||
$availableCPCalculated = max(0, $cpSolde - $cpInProcess);
|
if ($userId > 0) {
|
||||||
$availableRTTCalculated = max(0, $rttSolde - $rttInProcess);
|
$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();
|
||||||
|
|
||||||
echo json_encode([
|
while ($row = $result->fetch_assoc()) {
|
||||||
"success" => true,
|
$data[$row['Nom']] = [
|
||||||
"message" => "Compteurs récupérés avec succès.",
|
'Annee' => $row['Annee'],
|
||||||
"counters" => [
|
'Solde' => (float)$row['Solde'],
|
||||||
"availableCP" => (int)$availableCPCalculated,
|
'Total' => (float)$row['Total'],
|
||||||
"availableRTT" => (int)$availableRTTCalculated,
|
'SoldeReporte' => (float)$row['SoldeReporte'],
|
||||||
"availableABS" => (int)$absSolde,
|
];
|
||||||
"rttInProcess" => (int)$rttInProcess,
|
}
|
||||||
"absenteism" => (int)$absenteism
|
$stmt->close();
|
||||||
],
|
}
|
||||||
"debug" => [
|
|
||||||
"cpSolde"=>$cpSolde,"cpInProcess"=>$cpInProcess,
|
|
||||||
"rttSolde"=>$rttSolde,"rttInProcess"=>$rttInProcess,
|
|
||||||
"absSolde"=>$absSolde,"absenteism"=>$absenteism
|
|
||||||
]
|
|
||||||
]);
|
|
||||||
|
|
||||||
$conn->close();
|
$conn->close();
|
||||||
?>
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Compteurs mis à jour',
|
||||||
|
'counters' => $data,
|
||||||
|
]);
|
||||||
|
exit;
|
||||||
|
|||||||
75
project/public/php/getNotifications.php
Normal 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
|
||||||
|
]);
|
||||||
@@ -190,15 +190,9 @@ try {
|
|||||||
$stmtEmployees->close();
|
$stmtEmployees->close();
|
||||||
|
|
||||||
} elseif ($role === 'directeur de campus') {
|
} elseif ($role === 'directeur de campus') {
|
||||||
|
// Pour le directeur, les filtres se basent sur les congés de son campus
|
||||||
$filters['societes'] = [];
|
$filters['societes'] = array_values(array_unique(array_column($leaves, 'societe_nom')));
|
||||||
$filters['services'] = [];
|
$filters['services'] = array_values(array_unique(array_column($leaves, 'service_nom')));
|
||||||
$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'];
|
|
||||||
|
|
||||||
|
|
||||||
} elseif (in_array($role, ['president', 'rh'])) {
|
} elseif (in_array($role, ['president', 'rh'])) {
|
||||||
// 🔹 Récupérer tous les campus, sociétés, services de manière unique
|
// 🔹 Récupérer tous les campus, sociétés, services de manière unique
|
||||||
|
|||||||
@@ -75,18 +75,17 @@ foreach ($members as $m) {
|
|||||||
$nom = $m["surname"] ?? "";
|
$nom = $m["surname"] ?? "";
|
||||||
$email = $m["mail"] ?? "";
|
$email = $m["mail"] ?? "";
|
||||||
$service = $m["department"] ?? "";
|
$service = $m["department"] ?? "";
|
||||||
|
$description = $m["jobTitle"] ?? null;
|
||||||
if (!$email) continue;
|
if (!$email) continue;
|
||||||
|
|
||||||
// Insertion ou mise à jour de l’utilisateur
|
$stmt = $conn->prepare("INSERT INTO CollaborateurAD (entraUserId, prenom, nom, email, service, description, role)
|
||||||
$stmt = $conn->prepare("INSERT INTO CollaborateurAD (entraUserId, prenom, nom, email, service, role)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
ON DUPLICATE KEY UPDATE prenom=?, nom=?, email=?, service=?, description=?");
|
||||||
ON DUPLICATE KEY UPDATE prenom=?, nom=?, email=?, service=?");
|
|
||||||
if ($stmt) {
|
if ($stmt) {
|
||||||
$role = "Collaborateur"; // attribué uniquement si nouvel utilisateur
|
$role = "Collaborateur";
|
||||||
$stmt->bind_param("ssssssssss",
|
$stmt->bind_param("ssssssssssss",
|
||||||
$entraUserId, $prenom, $nom, $email, $service, $role,
|
$entraUserId, $prenom, $nom, $email, $service, $description, $role,
|
||||||
$prenom, $nom, $email, $service
|
$prenom, $nom, $email, $service, $description
|
||||||
);
|
);
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$usersInserted++;
|
$usersInserted++;
|
||||||
|
|||||||
62
project/public/php/markNotificationRead.php
Normal 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();
|
||||||
@@ -9,6 +9,7 @@ import Manager from './pages/Manager';
|
|||||||
import ProtectedRoute from './components/ProtectedRoute';
|
import ProtectedRoute from './components/ProtectedRoute';
|
||||||
import EmployeeDetails from './pages/EmployeeDetails';
|
import EmployeeDetails from './pages/EmployeeDetails';
|
||||||
import Collaborateur from './pages/Collaborateur';
|
import Collaborateur from './pages/Collaborateur';
|
||||||
|
import CompteRenduActivites from './pages/CompteRenduActivite'; // ⭐ Ajout
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -31,7 +32,7 @@ function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="/demandes"
|
path="/demandes"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute allowedRoles={['Collaborateur', 'RH']}>
|
<ProtectedRoute allowedRoles={['Collaborateur', 'Collaboratrice', 'Apprenti', 'RH', 'Admin']}>
|
||||||
<Requests />
|
<Requests />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@@ -40,7 +41,7 @@ function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="/calendrier"
|
path="/calendrier"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute allowedRoles={['Collaborateur', 'Manager', 'RH']}>
|
<ProtectedRoute allowedRoles={['Collaborateur', 'Collaboratrice', 'Apprenti', 'Manager', 'Validateur', 'Validatrice', 'Directeur de campus', 'Directrice de campus', 'RH', 'Admin', 'President']}>
|
||||||
<Calendar />
|
<Calendar />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@@ -49,7 +50,7 @@ function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="/manager"
|
path="/manager"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute allowedRoles={['Manager']}>
|
<ProtectedRoute allowedRoles={['Manager', 'Validateur', 'Validatrice', 'Directeur de campus', 'Directrice de campus', 'RH', 'Admin', 'President']}>
|
||||||
<Manager />
|
<Manager />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@@ -58,7 +59,7 @@ function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="/collaborateur"
|
path="/collaborateur"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute allowedRoles={['Collaborateur']}>
|
<ProtectedRoute allowedRoles={['Collaborateur', 'Collaboratrice', 'Apprenti']}>
|
||||||
<Collaborateur />
|
<Collaborateur />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@@ -67,14 +68,27 @@ function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="/employee/:id"
|
path="/employee/:id"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute allowedRoles={['RH', 'Manager']}>
|
<ProtectedRoute allowedRoles={['RH', 'Manager', 'Validateur', 'Validatrice', 'Directeur de campus', 'Directrice de campus', 'Admin', 'President']}>
|
||||||
<EmployeeDetails />
|
<EmployeeDetails />
|
||||||
</ProtectedRoute>
|
</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 */}
|
{/* Redirection par défaut */}
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
|
|
||||||
|
{/* Route 404 - Redirection vers dashboard */}
|
||||||
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Router>
|
</Router>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|||||||
515
project/src/components/EditLeaveRequestModal.jsx
Normal 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;
|
||||||
1
project/src/components/Layout.jsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
133
project/src/components/MedicalDocuments.jsx
Normal 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;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
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';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
|
||||||
const Sidebar = ({ isOpen, onToggle }) => {
|
const Sidebar = ({ isOpen, onToggle }) => {
|
||||||
@@ -15,13 +15,40 @@ const Sidebar = ({ isOpen, onToggle }) => {
|
|||||||
return 'bg-red-100 text-red-800';
|
return 'bg-red-100 text-red-800';
|
||||||
case 'Validateur':
|
case 'Validateur':
|
||||||
return 'bg-green-100 text-green-800';
|
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':
|
case 'Collaborateur':
|
||||||
return 'bg-cyan-600 text-white';
|
return 'bg-cyan-600 text-white';
|
||||||
|
case 'Collaboratrice':
|
||||||
|
return 'bg-cyan-600 text-white';
|
||||||
default:
|
default:
|
||||||
return 'bg-gray-100 text-gray-800';
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
@@ -46,14 +73,12 @@ const Sidebar = ({ isOpen, onToggle }) => {
|
|||||||
|
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="p-6 border-b border-gray-100">
|
<div className="p-6 border-b border-gray-100">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<div className="w-10 h-10 bg-cyan-600 rounded-lg flex items-center justify-center">
|
<img
|
||||||
<Building2 className="w-6 h-6 text-white" />
|
src="/assets/GA.svg"
|
||||||
</div>
|
alt="GTA Logo"
|
||||||
<div>
|
className="h-24 w-auto"
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -123,28 +148,50 @@ const Sidebar = ({ isOpen, onToggle }) => {
|
|||||||
<span className="font-medium">Calendrier</span>
|
<span className="font-medium">Calendrier</span>
|
||||||
</Link>
|
</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 === "Collaborateur" ||
|
||||||
|
user?.role === "Collaboratrice" ||
|
||||||
|
user?.role === "Apprenti" ||
|
||||||
user?.role === "Validateur" ||
|
user?.role === "Validateur" ||
|
||||||
|
user?.role === "Validatrice" ||
|
||||||
user?.role === "Manager" ||
|
user?.role === "Manager" ||
|
||||||
user?.role === "RH" ||
|
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
|
<Link
|
||||||
to={user?.role === "Collaborateur" ? "/collaborateur" : "/manager"}
|
to={targetPath}
|
||||||
onClick={() => window.innerWidth < 1024 && onToggle()}
|
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"
|
? "bg-blue-50 text-cyan-700 border-r-2 border-cyan-700"
|
||||||
: "text-gray-700 hover:bg-gray-50"
|
: "text-gray-700 hover:bg-gray-50"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Users className="w-5 h-5" />
|
<Users className="w-5 h-5" />
|
||||||
<span className="font-medium">
|
<span className="font-medium">Mon équipe</span>
|
||||||
{user?.role === "Collaborateur"
|
|
||||||
? "Mon équipe"
|
|
||||||
: "Mon équipe"}
|
|
||||||
</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
);
|
||||||
|
})()}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Bouton déconnexion */}
|
{/* Bouton déconnexion */}
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ const AuthContext = createContext();
|
|||||||
|
|
||||||
export const useAuth = () => {
|
export const useAuth = () => {
|
||||||
const context = useContext(AuthContext);
|
const context = useContext(AuthContext);
|
||||||
if (!context) {
|
if (!context) throw new Error('useAuth must be used within an AuthProvider');
|
||||||
throw new Error('useAuth must be used within an AuthProvider');
|
|
||||||
}
|
|
||||||
return context;
|
return context;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -16,25 +14,17 @@ const msalInstance = new msal.PublicClientApplication(msalConfig);
|
|||||||
|
|
||||||
export const AuthProvider = ({ children }) => {
|
export const AuthProvider = ({ children }) => {
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [userGroups, setUserGroups] = useState([]);
|
const [userGroups, setUserGroups] = useState([]);
|
||||||
const [isAuthorized, setIsAuthorized] = useState(false);
|
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) => `http://localhost:3000/${endpoint}`;
|
||||||
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
|
|
||||||
};
|
|
||||||
|
|
||||||
// Vérifier les groupes utilisateur via l'API backend
|
// --- Vérifie l'autorisation de l'utilisateur via groupes
|
||||||
const checkUserAuthorization = async (userPrincipalName, accessToken) => {
|
const checkUserAuthorization = async (userPrincipalName, accessToken) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(getApiUrl('check-user-groups.php'), {
|
const response = await fetch(getApiUrl('check-user-groups'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'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) => {
|
const syncUserToDatabase = async (entraUser, accessToken) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(getApiUrl('check-user-groups.php'), {
|
const response = await fetch(getApiUrl('initial-sync'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${accessToken}`
|
'Authorization': `Bearer ${accessToken}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(entraUser)
|
||||||
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
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
return await response.json();
|
const data = await response.json();
|
||||||
|
console.log('Utilisateur synchronisé:', entraUser.userPrincipalName);
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur synchronisation utilisateur:', error);
|
console.error('Erreur synchronisation utilisateur:', error);
|
||||||
@@ -87,22 +69,52 @@ export const AuthProvider = ({ children }) => {
|
|||||||
return null;
|
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(() => {
|
useEffect(() => {
|
||||||
const initializeMsal = async () => {
|
const initializeMsal = async () => {
|
||||||
try {
|
try {
|
||||||
await msalInstance.initialize();
|
await ensureMsalInitialized();
|
||||||
|
|
||||||
// Vérifier si il y a un utilisateur connecté
|
|
||||||
const accounts = msalInstance.getAllAccounts();
|
const accounts = msalInstance.getAllAccounts();
|
||||||
if (accounts.length > 0) {
|
if (accounts.length > 0) {
|
||||||
// Essayer de récupérer un token silencieusement
|
|
||||||
try {
|
try {
|
||||||
const response = await msalInstance.acquireTokenSilent({
|
const response = await msalInstance.acquireTokenSilent({
|
||||||
...loginRequest,
|
...loginRequest,
|
||||||
account: accounts[0]
|
account: accounts[0]
|
||||||
});
|
});
|
||||||
|
|
||||||
await handleSuccessfulAuth(response);
|
await handleSuccessfulAuth(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('Token silent acquisition failed:', error);
|
console.log('Token silent acquisition failed:', error);
|
||||||
@@ -118,18 +130,12 @@ export const AuthProvider = ({ children }) => {
|
|||||||
initializeMsal();
|
initializeMsal();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Gérer l'authentification réussie
|
// --- Gestion login réussi
|
||||||
// Gérer l'authentification réussie
|
|
||||||
const handleSuccessfulAuth = async (authResponse) => {
|
const handleSuccessfulAuth = async (authResponse) => {
|
||||||
try {
|
try {
|
||||||
const account = authResponse.account;
|
const account = authResponse.account;
|
||||||
const accessToken = authResponse.accessToken;
|
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 = {
|
let entraUser = {
|
||||||
id: account.homeAccountId,
|
id: account.homeAccountId,
|
||||||
displayName: account.name,
|
displayName: account.name,
|
||||||
@@ -137,35 +143,31 @@ export const AuthProvider = ({ children }) => {
|
|||||||
mail: account.username
|
mail: account.username
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const graphResponse = await fetch('https://graph.microsoft.com/v1.0/me', {
|
||||||
|
headers: { 'Authorization': `Bearer ${accessToken}` }
|
||||||
|
});
|
||||||
|
|
||||||
if (graphResponse.ok) {
|
if (graphResponse.ok) {
|
||||||
const graphData = await graphResponse.json();
|
const graphData = await graphResponse.json();
|
||||||
entraUser = { ...entraUser, ...graphData };
|
entraUser = { ...entraUser, ...graphData };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔹 Synchroniser l’utilisateur dans la DB
|
// 1️⃣ Synchroniser l’utilisateur connecté
|
||||||
const syncResult = await syncUserToDatabase(entraUser, accessToken);
|
const syncResult = await syncUserToDatabase(entraUser, accessToken);
|
||||||
console.log("Résultat syncUserToDatabase:", syncResult);
|
|
||||||
|
|
||||||
// 🚀 Si admin → lancer full-sync.php
|
// 2️⃣ Full sync si admin
|
||||||
if (syncResult?.role === "Admin") {
|
if (syncResult?.role === 'Admin') {
|
||||||
try {
|
console.log('Admin détecté → lancement full sync...');
|
||||||
const syncResp = await fetch(getApiUrl('full-sync.php'), {
|
await fullSyncDatabase(accessToken);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔹 Vérifier autorisation via groupes DB
|
// 3️⃣ Vérifier groupes
|
||||||
const authResult = await checkUserAuthorization(entraUser.userPrincipalName, accessToken);
|
const authResult = await checkUserAuthorization(entraUser.userPrincipalName, accessToken);
|
||||||
|
|
||||||
if (authResult.authorized) {
|
if (authResult.authorized) {
|
||||||
const userData = {
|
setUser({
|
||||||
id: syncResult?.localUserId || entraUser.id,
|
id: syncResult?.localUserId || entraUser.id,
|
||||||
|
CollaborateurADId: syncResult?.localUserId, // ⭐ AJOUT
|
||||||
entraUserId: entraUser.id,
|
entraUserId: entraUser.id,
|
||||||
name: entraUser.displayName,
|
name: entraUser.displayName,
|
||||||
prenom: entraUser.givenName || entraUser.displayName?.split(' ')[0] || '',
|
prenom: entraUser.givenName || entraUser.displayName?.split(' ')[0] || '',
|
||||||
@@ -173,22 +175,15 @@ export const AuthProvider = ({ children }) => {
|
|||||||
email: entraUser.mail || entraUser.userPrincipalName,
|
email: entraUser.mail || entraUser.userPrincipalName,
|
||||||
userPrincipalName: entraUser.userPrincipalName,
|
userPrincipalName: entraUser.userPrincipalName,
|
||||||
role: syncResult?.role || 'Employe',
|
role: syncResult?.role || 'Employe',
|
||||||
|
service: syncResult?.service || entraUser.department || 'Non défini',
|
||||||
// ✅ Correction ici
|
|
||||||
service: syncResult?.service
|
|
||||||
|| syncResult?.user?.service
|
|
||||||
|| entraUser.department
|
|
||||||
|| 'Non défini',
|
|
||||||
|
|
||||||
jobTitle: entraUser.jobTitle,
|
jobTitle: entraUser.jobTitle,
|
||||||
department: entraUser.department,
|
department: entraUser.department,
|
||||||
officeLocation: entraUser.officeLocation,
|
officeLocation: entraUser.officeLocation,
|
||||||
|
typeContrat: syncResult?.typeContrat || '37h', // ⭐ AJOUT
|
||||||
|
dateEntree: syncResult?.dateEntree || null, // ⭐ AJOUT
|
||||||
groups: authResult.groups
|
groups: authResult.groups
|
||||||
};
|
});
|
||||||
|
|
||||||
setUser(userData);
|
|
||||||
setIsAuthorized(true);
|
setIsAuthorized(true);
|
||||||
return true;
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Utilisateur non autorisé - pas membre des groupes requis');
|
throw new Error('Utilisateur non autorisé - pas membre des groupes requis');
|
||||||
}
|
}
|
||||||
@@ -198,32 +193,20 @@ export const AuthProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Connexion classique
|
||||||
|
|
||||||
// Connexion classique (email/mot de passe)
|
|
||||||
const login = async (email, password) => {
|
const login = async (email, password) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(getApiUrl('login.php'), {
|
const response = await fetch(getApiUrl('login'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email, mot_de_passe: password }),
|
body: JSON.stringify({ email, mot_de_passe: password })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) throw new Error('Erreur de connexion');
|
||||||
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.");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
const userData = {
|
setUser({
|
||||||
id: data.user.id,
|
id: data.user.id,
|
||||||
name: `${data.user.prenom} ${data.user.nom}`,
|
name: `${data.user.prenom} ${data.user.nom}`,
|
||||||
prenom: data.user.prenom,
|
prenom: data.user.prenom,
|
||||||
@@ -231,9 +214,7 @@ export const AuthProvider = ({ children }) => {
|
|||||||
email: data.user.email,
|
email: data.user.email,
|
||||||
role: data.user.role || 'Employe',
|
role: data.user.role || 'Employe',
|
||||||
service: data.user.service || 'Non défini'
|
service: data.user.service || 'Non défini'
|
||||||
};
|
});
|
||||||
|
|
||||||
setUser(userData);
|
|
||||||
setIsAuthorized(true);
|
setIsAuthorized(true);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -244,9 +225,10 @@ export const AuthProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Connexion Office 365
|
// --- Connexion Office 365
|
||||||
const loginWithO365 = async () => {
|
const loginWithO365 = async () => {
|
||||||
try {
|
try {
|
||||||
|
await ensureMsalInitialized();
|
||||||
const authResponse = await msalInstance.loginPopup(loginRequest);
|
const authResponse = await msalInstance.loginPopup(loginRequest);
|
||||||
await handleSuccessfulAuth(authResponse);
|
await handleSuccessfulAuth(authResponse);
|
||||||
return true;
|
return true;
|
||||||
@@ -259,14 +241,12 @@ export const AuthProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Déconnexion
|
// --- Déconnexion
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
try {
|
try {
|
||||||
const accounts = msalInstance.getAllAccounts();
|
const accounts = msalInstance.getAllAccounts();
|
||||||
if (accounts.length > 0) {
|
if (accounts.length > 0) {
|
||||||
await msalInstance.logoutPopup({
|
await msalInstance.logoutPopup({ account: accounts[0] });
|
||||||
account: accounts[0]
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de la déconnexion:', 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 () => {
|
const getAccessToken = async () => {
|
||||||
try {
|
try {
|
||||||
|
await ensureMsalInitialized();
|
||||||
const accounts = msalInstance.getAllAccounts();
|
const accounts = msalInstance.getAllAccounts();
|
||||||
if (accounts.length === 0) {
|
if (accounts.length === 0) throw new Error('Aucun compte connecté');
|
||||||
throw new Error('Aucun compte connecté');
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await msalInstance.acquireTokenSilent({
|
const response = await msalInstance.acquireTokenSilent({
|
||||||
...loginRequest,
|
...loginRequest,
|
||||||
@@ -308,11 +287,7 @@ export const AuthProvider = ({ children }) => {
|
|||||||
getAccessToken
|
getAccessToken
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
<AuthContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</AuthContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AuthContext;
|
export default AuthContext;
|
||||||
@@ -6,7 +6,7 @@ import { Users, CheckCircle, XCircle, Clock, Calendar, FileText, Menu, Eye, Mess
|
|||||||
const Collaborateur = () => {
|
const Collaborateur = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const isEmployee = user?.role === 'Collaborateur';
|
const isEmployee = user?.role === 'Collaborateur'||'Apprenti';
|
||||||
const [teamMembers, setTeamMembers] = useState([]);
|
const [teamMembers, setTeamMembers] = useState([]);
|
||||||
const [pendingRequests, setPendingRequests] = useState([]);
|
const [pendingRequests, setPendingRequests] = useState([]);
|
||||||
const [allRequests, setAllRequests] = useState([]);
|
const [allRequests, setAllRequests] = useState([]);
|
||||||
@@ -44,7 +44,7 @@ const Collaborateur = () => {
|
|||||||
|
|
||||||
const fetchTeamMembers = async () => {
|
const fetchTeamMembers = async () => {
|
||||||
try {
|
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();
|
const text = await response.text();
|
||||||
console.log('Réponse équipe:', text);
|
console.log('Réponse équipe:', text);
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ const Collaborateur = () => {
|
|||||||
|
|
||||||
const fetchPendingRequests = async () => {
|
const fetchPendingRequests = async () => {
|
||||||
try {
|
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();
|
const text = await response.text();
|
||||||
console.log('Réponse demandes en attente:', text);
|
console.log('Réponse demandes en attente:', text);
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ const Collaborateur = () => {
|
|||||||
|
|
||||||
const fetchAllTeamRequests = async () => {
|
const fetchAllTeamRequests = async () => {
|
||||||
try {
|
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();
|
const text = await response.text();
|
||||||
console.log('Réponse toutes demandes équipe:', text);
|
console.log('Réponse toutes demandes équipe:', text);
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ const Collaborateur = () => {
|
|||||||
|
|
||||||
const handleValidateRequest = async (requestId, action, comment = '') => {
|
const handleValidateRequest = async (requestId, action, comment = '') => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('http://localhost/GTA/project/public/php/validateRequest.php', {
|
const response = await fetch('http://localhost:3000/validateRequest', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
984
project/src/pages/CompteRenduActivite.jsx
Normal 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;
|
||||||
@@ -13,12 +13,13 @@ const EmployeeDetails = () => {
|
|||||||
fetchEmployeeData();
|
fetchEmployeeData();
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
// Dans EmployeeDetails.jsx, modifier fetchEmployeeData:
|
||||||
const fetchEmployeeData = async () => {
|
const fetchEmployeeData = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
// 1️⃣ Données employé
|
// 1️⃣ Données employé (avec compteurs inclus)
|
||||||
const resEmployee = await fetch(`http://localhost/GTA/project/public/php/getEmploye.php?id=${id}`);
|
const resEmployee = await fetch(`http://localhost:3000/getEmploye?id=${id}`);
|
||||||
const dataEmployee = await resEmployee.json();
|
const dataEmployee = await resEmployee.json();
|
||||||
console.log("Réponse API employé:", dataEmployee);
|
console.log("Réponse API employé:", dataEmployee);
|
||||||
|
|
||||||
@@ -26,23 +27,16 @@ const EmployeeDetails = () => {
|
|||||||
setEmployee(null);
|
setEmployee(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✅ Les compteurs sont déjà dans la réponse
|
||||||
setEmployee(dataEmployee.employee);
|
setEmployee(dataEmployee.employee);
|
||||||
|
|
||||||
// 2️⃣ Historique des demandes
|
// 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();
|
const dataRequests = await resRequests.json();
|
||||||
|
|
||||||
|
if (dataRequests.success) {
|
||||||
setRequests(dataRequests.requests || []);
|
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) {
|
} catch (err) {
|
||||||
|
|||||||
0
project/src/pages/LeaveScheduling.jsx
Normal file
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 Login = () => {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
@@ -9,10 +9,10 @@ const Login = () => {
|
|||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
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 navigate = useNavigate();
|
||||||
const { login, loginWithO365, isAuthorized } = useAuth();
|
const { login, loginWithO365 } = useAuth();
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -40,7 +40,6 @@ const Login = () => {
|
|||||||
setAuthMethod('o365');
|
setAuthMethod('o365');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Étape 1 : Login O365
|
|
||||||
const success = await loginWithO365();
|
const success = await loginWithO365();
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
@@ -49,30 +48,7 @@ const Login = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Étape 2 : Récupération du token d’authentification (si ton context le fournit)
|
// Redirection vers le dashboard
|
||||||
const token = localStorage.getItem("o365_token");
|
|
||||||
// ⚠️ Ici j’imagine 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
|
|
||||||
navigate('/dashboard');
|
navigate('/dashboard');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -90,8 +66,8 @@ const Login = () => {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex flex-col lg:flex-row">
|
<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 */}
|
{/* Image côté gauche */}
|
||||||
<div className="h-32 lg:h-auto lg:flex lg:w-1/2 bg-cover bg-center"
|
<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="max-w-md w-full">
|
||||||
<div className="bg-white rounded-2xl shadow-xl p-6 lg:p-8">
|
<div className="bg-white rounded-2xl shadow-xl p-6 lg:p-8">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="text-center mb-6 lg:mb-8">
|
<div className="text-center mb-4">
|
||||||
<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">
|
<img
|
||||||
<Building2 className="w-6 h-6 lg:w-8 lg:h-8 text-white" />
|
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>
|
</div>
|
||||||
<h1 className="text-xl lg:text-2xl font-bold text-gray-900">GTA</h1>
|
{/* Bouton Office 365 */}
|
||||||
<p className="text-sm lg:text-base text-gray-600">Gestion de congés</p>
|
<div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Connexion Office 365 prioritaire */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<button
|
<button
|
||||||
|
data-testid="o365-login-btn"
|
||||||
onClick={handleO365Login}
|
onClick={handleO365Login}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -134,16 +113,9 @@ const Login = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Message d'erreur */}
|
||||||
|
|
||||||
|
|
||||||
{/* Formulaire classique */}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Affichage des erreurs */}
|
|
||||||
{error && (
|
{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">
|
<div className="flex items-start space-x-2">
|
||||||
<AlertTriangle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
|
<AlertTriangle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -160,12 +132,6 @@ const Login = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Info sur l'authentification */}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,44 +1,49 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from "react";
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Sidebar from '../components/Sidebar';
|
import Sidebar from "../components/Sidebar";
|
||||||
import { Users, CheckCircle, XCircle, Clock, Calendar, FileText, Menu, Eye, MessageSquare } from 'lucide-react';
|
import {
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 Manager = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const isEmployee = user?.role === 'validateur';
|
const isEmployee = user?.role === "Collaborateur" || user?.role === "Apprenti";
|
||||||
|
|
||||||
const [teamMembers, setTeamMembers] = useState([]);
|
const [teamMembers, setTeamMembers] = useState([]);
|
||||||
const [pendingRequests, setPendingRequests] = useState([]);
|
const [pendingRequests, setPendingRequests] = useState([]);
|
||||||
const [allRequests, setAllRequests] = useState([]);
|
const [allRequests, setAllRequests] = useState([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
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 navigate = useNavigate();
|
||||||
|
const [toast, setToast] = useState(null);
|
||||||
|
const [validationModal, setValidationModal] = useState(null);
|
||||||
|
const [comment, setComment] = useState("");
|
||||||
|
const [isValidating, setIsValidating] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.id) {
|
if (user?.id) fetchTeamData();
|
||||||
fetchTeamData();
|
|
||||||
}
|
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const fetchTeamData = async () => {
|
const fetchTeamData = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
await Promise.all([
|
||||||
// Récupérer les membres de l'équipe
|
fetchTeamMembers(),
|
||||||
await fetchTeamMembers();
|
fetchPendingRequests(),
|
||||||
|
fetchAllTeamRequests(),
|
||||||
// Récupérer les demandes en attente
|
]);
|
||||||
await fetchPendingRequests();
|
|
||||||
|
|
||||||
// Récupérer toutes les demandes de l'équipe
|
|
||||||
await fetchAllTeamRequests();
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de la récupération des données équipe:', error);
|
console.error("Erreur lors du chargement:", error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -46,373 +51,432 @@ const Manager = () => {
|
|||||||
|
|
||||||
const fetchTeamMembers = async () => {
|
const fetchTeamMembers = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`http://localhost/GTA/project/public/php/getTeamMembers.php?manager_id=${user.id}`);
|
const res = await fetch(`http://localhost:3000/getTeamMembers?manager_id=${user.id}`);
|
||||||
const text = await response.text();
|
const data = await res.json();
|
||||||
console.log('Réponse équipe:', text);
|
if (data.success) setTeamMembers(data.team_members || []);
|
||||||
|
else setTeamMembers([]);
|
||||||
const data = JSON.parse(text);
|
} catch {
|
||||||
if (data.success) {
|
|
||||||
setTeamMembers(data.team_members || []);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur récupération équipe:', error);
|
|
||||||
setTeamMembers([]);
|
setTeamMembers([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchPendingRequests = async () => {
|
const fetchPendingRequests = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`http://localhost/GTA/project/public/php/getPendingRequests.php?manager_id=${user.id}`);
|
const res = await fetch(`http://localhost:3000/getPendingRequests?manager_id=${user.id}`);
|
||||||
const text = await response.text();
|
const data = await res.json();
|
||||||
console.log('Réponse demandes en attente:', text);
|
if (data.success) setPendingRequests(data.requests || []);
|
||||||
|
else setPendingRequests([]);
|
||||||
const data = JSON.parse(text);
|
} catch {
|
||||||
if (data.success) {
|
|
||||||
setPendingRequests(data.requests || []);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur récupération demandes en attente:', error);
|
|
||||||
setPendingRequests([]);
|
setPendingRequests([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchAllTeamRequests = async () => {
|
const fetchAllTeamRequests = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`http://localhost/GTA/project/public/php/getAllTeamRequests.php?SuperieurId=${user.id}`);
|
const res = await fetch(`http://localhost:3000/getAllTeamRequests?SuperieurId=${user.id}`);
|
||||||
const text = await response.text();
|
const data = await res.json();
|
||||||
console.log('Réponse toutes demandes équipe:', text);
|
if (data.success) setAllRequests(data.requests || []);
|
||||||
|
else setAllRequests([]);
|
||||||
const data = JSON.parse(text);
|
} catch {
|
||||||
if (data.success) {
|
|
||||||
setAllRequests(data.requests || []);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
|
|
||||||
console.error('Erreur récupération toutes demandes:', error);
|
|
||||||
console.log('Réponse brute:', text);
|
|
||||||
setAllRequests([]);
|
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 = '') => {
|
const handleValidateRequest = async (requestId, action, comment = '') => {
|
||||||
|
if (!user || !user.id) {
|
||||||
|
alert('❌ Utilisateur non identifié');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
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',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
request_id: requestId,
|
request_id: requestId,
|
||||||
action: action, // 'approve' ou 'reject'
|
action: action,
|
||||||
comment: comment,
|
validator_id: user.id,
|
||||||
validator_id: user.id
|
comment: comment
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const text = await response.text();
|
const data = await response.json();
|
||||||
console.log('Réponse validation:', text);
|
|
||||||
|
|
||||||
const data = JSON.parse(text);
|
|
||||||
|
|
||||||
if (data.success) {
|
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 {
|
} else {
|
||||||
alert(`Erreur: ${data.message}`);
|
alert(`❌ Erreur : ${data.message}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur validation:', error);
|
console.error('❌ Erreur lors de la validation:', error);
|
||||||
alert('Erreur lors de la validation');
|
alert('❌ Erreur lors de la validation de la demande');
|
||||||
|
} finally {
|
||||||
|
setIsValidating(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const showToast = (type, message) => {
|
||||||
const openValidationModal = (request, action) => {
|
setToast({ type, message });
|
||||||
setSelectedRequest(request);
|
setTimeout(() => setToast(null), 4000);
|
||||||
setValidationAction(action);
|
|
||||||
setValidationComment('');
|
|
||||||
setShowValidationModal(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusColor = (status) => {
|
const getStatusColor = (status) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'En attente': return 'bg-yellow-100 text-yellow-800';
|
case "En attente": return "bg-yellow-100 text-yellow-800";
|
||||||
case 'Validée':
|
case "Validée":
|
||||||
case 'Approuvé': return 'bg-green-100 text-green-800';
|
case "Approuvé": return "bg-green-100 text-green-800";
|
||||||
case 'Refusée': return 'bg-red-100 text-red-800';
|
case "Refusée": return "bg-red-100 text-red-800";
|
||||||
default: return 'bg-gray-100 text-gray-800';
|
default: return "bg-gray-100 text-gray-800";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTypeColor = (type) => {
|
const getTypeColor = (type) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'Congés payés':
|
case "Congés payés":
|
||||||
case 'Congé payé': return 'bg-blue-100 text-blue-800';
|
case "Congé payé": return "bg-blue-100 text-blue-800";
|
||||||
case 'RTT': return 'bg-green-100 text-green-800';
|
case "RTT": return "bg-green-100 text-green-800";
|
||||||
case 'Congé maladie': return 'bg-red-100 text-red-800';
|
case "Congé maladie": return "bg-red-100 text-red-800";
|
||||||
default: return 'bg-gray-100 text-gray-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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
<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="text-center">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></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 équipe...</p>
|
<p className="text-gray-600">Chargement des données...</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex">
|
<div className="relative min-h-screen bg-gray-50 flex overflow-hidden">
|
||||||
<Sidebar isOpen={sidebarOpen} onToggle={() => setSidebarOpen(!sidebarOpen)} />
|
{/* Toast Notification */}
|
||||||
|
<AnimatePresence>
|
||||||
<div className="flex-1 lg:ml-60">
|
{toast && (
|
||||||
<div className="p-4 lg:p-8 w-full">
|
<motion.div
|
||||||
{/* Mobile menu button */}
|
initial={{ opacity: 0, y: -50, scale: 0.9 }}
|
||||||
<div className="lg:hidden mb-4">
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
<button
|
exit={{ opacity: 0, y: -20, scale: 0.95 }}
|
||||||
onClick={() => setSidebarOpen(true)}
|
className="fixed top-6 left-1/2 transform -translate-x-1/2 z-50 max-w-md w-full mx-4"
|
||||||
className="p-2 rounded-lg bg-white shadow-sm border border-gray-200"
|
|
||||||
>
|
>
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Modal de validation */}
|
||||||
<div className="mb-8">
|
<AnimatePresence>
|
||||||
<h1 className="text-2xl lg:text-3xl font-bold text-gray-900 mb-2">
|
{validationModal && (
|
||||||
{isEmployee ? 'Mon équipe 👥' : 'Gestion d\'équipe 👥'}
|
<motion.div
|
||||||
</h1>
|
initial={{ opacity: 0 }}
|
||||||
<p className="text-sm lg:text-base text-gray-600">
|
animate={{ opacity: 1 }}
|
||||||
{isEmployee ? 'Consultez les congés de votre équipe' : 'Gérez les demandes de congés de votre équipe'}
|
exit={{ opacity: 0 }}
|
||||||
</p>
|
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||||
</div>
|
onClick={closeValidationModal}
|
||||||
|
>
|
||||||
{/* Stats Cards */}
|
<motion.div
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-6 mb-8">
|
initial={{ scale: 0.9, opacity: 0 }}
|
||||||
<div className="bg-white rounded-xl p-6 shadow-sm border border-gray-100">
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
<div className="flex items-center justify-between">
|
exit={{ scale: 0.9, opacity: 0 }}
|
||||||
<div>
|
onClick={(e) => e.stopPropagation()}
|
||||||
<p className="text-xs lg:text-sm font-medium text-gray-600">Équipe</p>
|
className="bg-white rounded-xl shadow-2xl max-w-md w-full"
|
||||||
<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 className="p-6 border-b border-gray-100">
|
||||||
</div>
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 lg:w-12 lg:h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
<div className={`p-2 rounded-full ${validationModal.action === "approve" ? "bg-green-100" : "bg-red-100"
|
||||||
<Users className="w-4 h-4 lg:w-6 lg:h-6 text-blue-600" />
|
}`}>
|
||||||
</div>
|
{validationModal.action === "approve" ? (
|
||||||
</div>
|
<CheckCircle className="w-6 h-6 text-green-600" />
|
||||||
</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>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<XCircle className="w-6 h-6 text-red-600" />
|
||||||
{pendingRequests.map((request) => (
|
)}
|
||||||
<div key={request.id} className="border border-gray-200 rounded-lg p-4">
|
</div>
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div>
|
||||||
<div className="flex-1">
|
<h3 className="text-lg font-semibold text-gray-900">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
{validationModal.action === "approve" ? "Approuver la demande" : "Refuser la demande"}
|
||||||
<h3 className="font-medium text-gray-900">{request.employee_name}</h3>
|
</h3>
|
||||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getTypeColor(request.type)}`}>
|
<p className="text-sm text-gray-600">{validationModal.request.employee_name}</p>
|
||||||
{request.type}
|
</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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600">{request.date_display}</p>
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs text-gray-500">Soumis le {request.submitted_display}</p>
|
<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>
|
||||||
<div className="text-right">
|
<div className="flex items-center justify-between">
|
||||||
<p className="font-medium text-gray-900">{request.days}j</p>
|
<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>
|
||||||
</div>
|
{validationModal.request.reason && (
|
||||||
|
<div className="pt-2 border-t border-gray-200">
|
||||||
{request.reason && (
|
<p className="text-xs text-gray-500 mb-1">Motif :</p>
|
||||||
<div className="mb-3 p-2 bg-gray-50 rounded text-sm text-gray-700">
|
<p className="text-sm text-gray-700">{validationModal.request.reason}</p>
|
||||||
<strong>Motif:</strong> {request.reason}
|
|
||||||
</div>
|
</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">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => openValidationModal(request, 'approve')}
|
onClick={() => openValidationModal(r, "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"
|
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
|
Approuver
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => openValidationModal(request, 'reject')}
|
onClick={() => openValidationModal(r, "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"
|
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
|
Refuser
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
</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={`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">
|
||||||
<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">
|
|
||||||
<Users className="w-5 h-5 text-blue-600" />
|
<Users className="w-5 h-5 text-blue-600" />
|
||||||
Mon équipe ({teamMembers.length})
|
<h2 className="font-semibold text-gray-900">Mon équipe ({teamMembers.length})</h2>
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 lg:p-6">
|
<div className="p-4 space-y-2">
|
||||||
{teamMembers.length === 0 ? (
|
{teamMembers.length === 0 ? (
|
||||||
<div className="text-center py-8">
|
<p className="text-center text-gray-500">Aucun membre d'équipe</p>
|
||||||
<Users className="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
|
||||||
<p className="text-gray-600">Aucun membre d'équipe</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
teamMembers.map((m) => (
|
||||||
{teamMembers.map((member) => (
|
<div
|
||||||
<div key={member.id}
|
key={m.id}
|
||||||
onClick={() => navigate(`/employee/${member.id}`)}
|
onClick={() => navigate(`/employee/${m.id}`)}
|
||||||
className={`flex items-center justify-between p-3 bg-gray-50 rounded-lg ${isEmployee ? 'lg:p-4' : ''}`}>
|
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="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
<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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-900">{member.prenom} {member.nom}</p>
|
<p className="font-medium text-gray-900">{m.prenom} {m.nom}</p>
|
||||||
<p className="text-sm text-gray-600">{member.email}</p>
|
<p className="text-sm text-gray-600">{m.email}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!isEmployee && (
|
{!isEmployee && (
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-sm font-medium text-gray-900">
|
<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>
|
||||||
<p className="text-xs text-gray-500">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Historique des demandes */}
|
|
||||||
{!isEmployee && (
|
{!isEmployee && (
|
||||||
<div className="mt-6 bg-white rounded-xl shadow-sm border border-gray-100">
|
<div className="bg-white rounded-xl shadow-sm border border-gray-100 mt-6">
|
||||||
<div className="p-4 lg:p-6 border-b border-gray-100">
|
<div className="p-4 border-b border-gray-100 flex items-center gap-2">
|
||||||
<h2 className="text-lg lg:text-xl font-semibold text-gray-900 flex items-center gap-2">
|
|
||||||
<FileText className="w-5 h-5 text-gray-600" />
|
<FileText className="w-5 h-5 text-gray-600" />
|
||||||
Historique des demandes ({allRequests.length})
|
<h2 className="font-semibold text-gray-900">Historique des demandes ({allRequests.length})</h2>
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 lg:p-6">
|
<div className="p-4 space-y-3 max-h-80 overflow-y-auto">
|
||||||
{allRequests.length === 0 ? (
|
{allRequests.length === 0 ? (
|
||||||
<div className="text-center py-8">
|
<p className="text-center text-gray-500">Aucune demande</p>
|
||||||
<FileText className="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
|
||||||
<p className="text-gray-600">Aucune demande</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3 max-h-80 overflow-y-auto">
|
allRequests.map((r) => (
|
||||||
{allRequests.map((request) => (
|
<div key={r.id} className="p-3 border border-gray-100 rounded-lg hover:bg-gray-50 transition-colors">
|
||||||
<div key={request.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">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<p className="font-medium text-gray-900">{request.employee_name}</p>
|
<p className="font-medium text-gray-900">{r.employee_name}</p>
|
||||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getTypeColor(request.type)}`}>
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getTypeColor(r.type)}`}>
|
||||||
{request.type}
|
{r.type}
|
||||||
</span>
|
</span>
|
||||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(request.status)}`}>
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(r.status)}`}>
|
||||||
{request.status}
|
{r.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600">{request.date_display}</p>
|
<p className="text-sm text-gray-600">{r.date_display}</p>
|
||||||
<p className="text-xs text-gray-500 mb-2">Soumis le {request.submitted_display}</p>
|
<p className="text-xs text-gray-500 mb-2">Soumis le {r.submitted_display}</p>
|
||||||
|
{r.reason && (
|
||||||
{request.reason && (
|
<p className="text-sm text-gray-700 mb-1">
|
||||||
<p className="text-sm text-gray-700 mb-1"><strong>Motif :</strong> {request.reason}</p>
|
<strong>Motif :</strong> {r.reason}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{r.file && (
|
||||||
{request.file && (
|
|
||||||
<div className="text-sm mt-1">
|
<div className="text-sm mt-1">
|
||||||
<p className="text-gray-500">Document joint</p>
|
<p className="text-gray-500">Document joint</p>
|
||||||
<a
|
<a
|
||||||
href={`http://localhost/GTA/project/uploads/${request.file}`}
|
href={`http://localhost:3000/uploads/${r.file}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline flex items-center gap-1 mt-1"
|
className="text-blue-600 hover:underline flex items-center gap-1 mt-1"
|
||||||
@@ -423,105 +487,14 @@ const Manager = () => {
|
|||||||
</div>
|
</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>
|
</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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,22 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import Sidebar from '../components/Sidebar';
|
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 NewLeaveRequestModal from '../components/NewLeaveRequestModal';
|
||||||
|
import EditLeaveRequestModal from '../components/EditLeaveRequestModal';
|
||||||
import { useMsal } from "@azure/msal-react";
|
import { useMsal } from "@azure/msal-react";
|
||||||
|
import MedicalDocuments from '../components/MedicalDocuments';
|
||||||
|
|
||||||
const Requests = () => {
|
const Requests = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [leaveCounters, setLeaveCounters] = useState({
|
const [detailedCounters, setDetailedCounters] = useState(null);
|
||||||
availableCP: 0,
|
|
||||||
availableRTT: 0,
|
|
||||||
availableABS: 0,
|
|
||||||
rttInProcess: 0,
|
|
||||||
absenteism: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
|
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
|
||||||
|
const [showEditRequestModal, setShowEditRequestModal] = useState(false);
|
||||||
|
const [requestToEdit, setRequestToEdit] = useState(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
const [allRequests, setAllRequests] = useState([]);
|
const [allRequests, setAllRequests] = useState([]);
|
||||||
const [filteredRequests, setFilteredRequests] = useState([]);
|
const [filteredRequests, setFilteredRequests] = useState([]);
|
||||||
@@ -37,6 +35,16 @@ const Requests = () => {
|
|||||||
const { instance, accounts } = useMsal();
|
const { instance, accounts } = useMsal();
|
||||||
const userId = user?.id || user?.CollaborateurADId || user?.ID;
|
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(() => {
|
useEffect(() => {
|
||||||
if (accounts.length > 0) {
|
if (accounts.length > 0) {
|
||||||
const request = {
|
const request = {
|
||||||
@@ -47,7 +55,6 @@ const Requests = () => {
|
|||||||
instance.acquireTokenSilent(request)
|
instance.acquireTokenSilent(request)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
setGraphToken(response.accessToken);
|
setGraphToken(response.accessToken);
|
||||||
console.log("✅ Token Graph récupéré :", response.accessToken);
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error("❌ Erreur récupération token Graph:", err);
|
console.error("❌ Erreur récupération token Graph:", err);
|
||||||
@@ -55,64 +62,24 @@ const Requests = () => {
|
|||||||
}
|
}
|
||||||
}, [accounts, instance]);
|
}, [accounts, instance]);
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchDetailedCounters = async () => {
|
||||||
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 () => {
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`http://localhost/GTA/project/public/php/getLeaveCounters.php?user_id=${userId}`);
|
const response = await fetch(`http://localhost:3000/getDetailedLeaveCounters?user_id=${userId}`);
|
||||||
const text = await response.text();
|
const data = await response.json();
|
||||||
let data;
|
|
||||||
try {
|
|
||||||
data = JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
throw new Error('Le serveur PHP ne répond pas correctement');
|
|
||||||
}
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setLeaveCounters(data.counters);
|
setDetailedCounters(data.data);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(data.message || 'Erreur lors de la récupération des compteurs');
|
console.error("Erreur compteurs:", data.message);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur compteurs:', error);
|
console.error('💥 Erreur compteurs:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchAllRequests = async () => {
|
const fetchAllRequests = async () => {
|
||||||
try {
|
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 response = await fetch(url);
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
let data;
|
let data;
|
||||||
@@ -128,17 +95,199 @@ const Requests = () => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur requêtes:', 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) => {
|
const getStatusColor = (status) => {
|
||||||
switch (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 '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';
|
case 'Refusée': return 'bg-red-100 text-red-800';
|
||||||
default: return 'bg-gray-100 text-gray-800';
|
default: return 'bg-gray-100 text-gray-800';
|
||||||
}
|
}
|
||||||
@@ -152,193 +301,285 @@ const Requests = () => {
|
|||||||
setSelectedRequest(null);
|
setSelectedRequest(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const indexOfLastRequest = currentPage * requestsPerPage;
|
const handleRefresh = () => {
|
||||||
const indexOfFirstRequest = indexOfLastRequest - requestsPerPage;
|
refreshAllData();
|
||||||
const currentRequests = filteredRequests.slice(indexOfFirstRequest, indexOfLastRequest);
|
};
|
||||||
const totalPages = Math.ceil(filteredRequests.length / requestsPerPage);
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<Sidebar isOpen={sidebarOpen} onToggle={() => setSidebarOpen(!sidebarOpen)} />
|
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
{/* Toast container */}
|
||||||
<div className="min-h-screen bg-gray-50 flex">
|
<div className="fixed top-4 right-4 z-50 space-y-2">
|
||||||
<Sidebar isOpen={sidebarOpen} onToggle={() => setSidebarOpen(!sidebarOpen)} />
|
{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">
|
{/* Modal de confirmation de suppression */}
|
||||||
{/* Mobile top bar */}
|
{showDeleteConfirm && (
|
||||||
<div className="lg:hidden flex justify-between items-center mb-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
<button onClick={() => setSidebarOpen(true)} className="p-2 rounded-lg bg-white shadow-sm border border-gray-200">
|
<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" />
|
<Menu className="w-6 h-6" />
|
||||||
</button>
|
</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>
|
<div>
|
||||||
<h1 className="text-2xl lg:text-3xl font-bold text-gray-900 mb-1">Mes Demandes de Congés</h1>
|
<h1 className="text-2xl lg:text-3xl font-bold text-gray-900">Mes demandes</h1>
|
||||||
<p className="text-sm text-gray-600">Gérez toutes vos demandes de congés</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hidden lg:flex items-center gap-3">
|
</div>
|
||||||
<button onClick={() => setShowNewRequestModal(true)} className="bg-cyan-600 text-white px-4 py-2 rounded-lg flex items-center gap-2">
|
</div>
|
||||||
<Plus className="w-4 h-4" /> Nouvelle demande
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters panel (mobile toggle + desktop always visible) */}
|
{/* Compteurs */}
|
||||||
<div className={`${showFilters ? 'block' : 'hidden'} lg:block bg-white rounded-xl shadow-sm border border-gray-100 p-4 lg:p-6 mb-6`}>
|
{detailedCounters && (
|
||||||
<div className="flex flex-col lg:flex-row gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
<div className="flex-1 relative">
|
{/* CP N */}
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Rechercher par type, motif ou date..."
|
placeholder="Rechercher..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
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>
|
||||||
|
</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="all">Tous les statuts</option>
|
||||||
<option value="En attente">En attente</option>
|
<option value="En attente">En attente</option>
|
||||||
<option value="Validée">Validée</option>
|
<option value="Validée">Validée</option>
|
||||||
<option value="Refusée">Refusée</option>
|
<option value="Refusée">Refusée</option>
|
||||||
</select>
|
</select>
|
||||||
|
<select
|
||||||
<select value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-lg text-sm">
|
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="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="RTT">RTT</option>
|
||||||
<option value="Congé maladie">Congé maladie</option>
|
<option value="Arrêt maladie">Arrêt maladie</option>
|
||||||
<option value="Autres">Autres types</option>
|
<option value="Absence">Absence</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Main content: left = list/table, right = details (desktop) */}
|
{/* Requests list */}
|
||||||
<div className="flex flex-col lg:flex-row gap-6">
|
{isLoading ? (
|
||||||
{/* Left: table (desktop) + cards (mobile) */}
|
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-8 text-center">
|
||||||
<div className="flex-1 bg-white rounded-xl shadow-sm border border-gray-100">
|
<RefreshCw className="w-8 h-8 animate-spin mx-auto text-blue-600 mb-2" />
|
||||||
<div className="p-6 border-b border-gray-100 flex items-center justify-between">
|
<p className="text-gray-500">Chargement...</p>
|
||||||
<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>
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="p-6">
|
|
||||||
{currentRequests.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-gray-600">Aucune demande à afficher.</div>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Desktop table */}
|
<div className="space-y-3">
|
||||||
<div className="hidden lg:block overflow-x-auto">
|
{currentRequests.map((request) => (
|
||||||
<table className="w-full">
|
<div key={request.id} className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 hover:shadow-md transition-shadow">
|
||||||
<thead>
|
<div className="flex justify-between items-start mb-2">
|
||||||
<tr className="border-b border-gray-200">
|
<div className="flex-1">
|
||||||
<th className="text-left py-3 px-4 text-gray-700">Type</th>
|
<h3 className="font-semibold text-gray-900">{request.type}</h3>
|
||||||
<th className="text-left py-3 px-4 text-gray-700">Dates</th>
|
<p className="text-sm text-gray-600">{request.dateDisplay}</p>
|
||||||
<th className="text-left py-3 px-4 text-gray-700">Jours</th>
|
</div>
|
||||||
<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">
|
|
||||||
<span className={`px-2 py-0.5 rounded-full text-[10px] font-medium ${getStatusColor(request.status)}`}>
|
<span className={`px-2 py-0.5 rounded-full text-[10px] font-medium ${getStatusColor(request.status)}`}>
|
||||||
{request.status}
|
{request.status}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Mobile cards */}
|
<div className="flex items-center gap-2 text-sm text-gray-500 mb-3">
|
||||||
<div className="lg:hidden space-y-4">
|
<span className="bg-gray-100 px-2 py-1 rounded">{request.days} jour(s)</span>
|
||||||
{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>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex justify-between items-center text-sm">
|
<div className="mt-3 flex justify-between items-center text-sm">
|
||||||
<span className="text-gray-500">{request.submittedDisplay}</span>
|
<span className="text-gray-500">{request.submittedDisplay}</span>
|
||||||
<button onClick={() => handleViewRequest(request)} className="text-blue-600 flex items-center gap-1">
|
<div className="flex items-center gap-2">
|
||||||
<Eye className="w-4 h-4" /> Voir
|
{/* 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>
|
</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>
|
</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>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: details (desktop) */}
|
{/* Right: details */}
|
||||||
<div className="hidden lg:block w-full lg:max-w-sm">
|
<div className="hidden lg:block">
|
||||||
{selectedRequest ? (
|
{selectedRequest ? (
|
||||||
<div className="bg-white rounded-xl shadow-md border border-gray-100 p-6 sticky top-20">
|
<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">
|
<div className="flex justify-between items-start mb-6">
|
||||||
@@ -373,14 +614,35 @@ const Requests = () => {
|
|||||||
<p className="italic">{selectedRequest.reason}</p>
|
<p className="italic">{selectedRequest.reason}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selectedRequest.fileUrl && (
|
{(selectedRequest.status === 'Validée' || selectedRequest.status === 'Refusée') && selectedRequest.validationComment && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-gray-500">Arrêt maladie</p>
|
<p className="text-gray-500">{selectedRequest.status === 'Validée' ? 'Commentaire de validation' : 'Motif du refus'}</p>
|
||||||
<a href={selectedRequest.fileUrl} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline flex items-center gap-2">
|
<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>
|
||||||
<Eye className="w-4 h-4" /> Voir le fichier
|
|
||||||
</a>
|
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -390,74 +652,78 @@ const Requests = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div className="space-y-4 text-sm text-gray-700">
|
{/* Modals */}
|
||||||
<div>
|
{showNewRequestModal && detailedCounters && (
|
||||||
<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 && (
|
|
||||||
<NewLeaveRequestModal
|
<NewLeaveRequestModal
|
||||||
onClose={() => setShowNewRequestModal(false)}
|
onClose={() => setShowNewRequestModal(false)}
|
||||||
availableLeaveCounters={leaveCounters}
|
availableLeaveCounters={{
|
||||||
userId={user?.id}
|
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}
|
userEmail={user.email}
|
||||||
|
userRole={user.role}
|
||||||
userName={`${user.prenom} ${user.nom}`}
|
userName={`${user.prenom} ${user.nom}`}
|
||||||
onRequestSubmitted={() => {
|
onRequestSubmitted={() => {
|
||||||
fetchLeaveCounters();
|
refreshAllData();
|
||||||
fetchAllRequests();
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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>
|
</div>
|
||||||
|
|
||||||
|
{/* Styles */}
|
||||||
|
<style>{`
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from {
|
||||||
|
transform: translateX(400px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
7
project/src/setupTests.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { afterEach } from 'vitest';
|
||||||
|
import { cleanup } from '@testing-library/react';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
115
project/src/tests/Calendar.test.jsx
Normal 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 l’apparition de l’indication 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 l’apparition du menu contextuel (modal)
|
||||||
|
const leaveModal = await screen.findByTestId('leave-modal', {}, { timeout: 5000 });
|
||||||
|
|
||||||
|
expect(leaveModal).toBeTruthy();
|
||||||
|
expect(screen.getByText('Formulaire complet...')).toBeTruthy();
|
||||||
|
}, 20000);
|
||||||
|
});
|
||||||
81
project/src/tests/Login.test.jsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
exclude: ['lucide-react'],
|
exclude: ['lucide-react'],
|
||||||
},
|
},
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: './src/setupTests.js',
|
||||||
|
},
|
||||||
});
|
});
|
||||||