GTFRH_V1
This commit is contained in:
42
GTFRRH/project/DockerfileGTFRH.frontend
Normal file
42
GTFRRH/project/DockerfileGTFRH.frontend
Normal file
@@ -0,0 +1,42 @@
|
||||
FROM node:18
|
||||
|
||||
WORKDIR /GTFRRH/project
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN rm -rf node_modules package-lock.json && \
|
||||
npm install --include=optional
|
||||
|
||||
COPY . .
|
||||
|
||||
# Override du vite.config.ts avec allowedHosts
|
||||
RUN cat > vite.config.ts << 'EOF'
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3002,
|
||||
strictPort: true,
|
||||
allowedHosts: [
|
||||
'mygtf-rh.ensup-adm.net',
|
||||
'localhost',
|
||||
'127.0.0.1'
|
||||
],
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://backend:3000',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
EOF
|
||||
|
||||
EXPOSE 3002
|
||||
|
||||
CMD ["npm", "run", "dev"]
|
||||
18
GTFRRH/project/backend/config/DockerfileGTFRH.backend
Normal file
18
GTFRRH/project/backend/config/DockerfileGTFRH.backend
Normal file
@@ -0,0 +1,18 @@
|
||||
# DockerfileGTFRH.backend
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /GTFRRH/project
|
||||
|
||||
# Copy only package files first for caching
|
||||
COPY package*.json ./
|
||||
|
||||
# Install backend dependencies inside container
|
||||
RUN rm -rf node_modules package-lock.json && npm install
|
||||
|
||||
# Copy backend source code
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8001
|
||||
|
||||
CMD ["node", "serv.js"]
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
{
|
||||
"name": "config",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "server.js",
|
||||
"main": "serv.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"start": "node server.js"
|
||||
"start": "node serv.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -14,7 +14,8 @@
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.2",
|
||||
"express": "^5.1.0",
|
||||
"mssql": "^11.0.1"
|
||||
"mssql": "^11.0.1",
|
||||
"axios": "^1.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19"
|
||||
|
||||
@@ -5,7 +5,7 @@ const axios = require('axios');
|
||||
require('dotenv').config();
|
||||
|
||||
const app = express();
|
||||
const PORT = 3002;
|
||||
const PORT = process.env.PORT || 8000;
|
||||
|
||||
// Configuration base de données
|
||||
const dbConfig = {
|
||||
@@ -23,16 +23,20 @@ const dbConfig = {
|
||||
// Configuration Microsoft OAuth
|
||||
const CLIENT_ID = 'cd99bbea-dcd4-4a76-a0b0-7aeb49931943';
|
||||
const TENANT_ID = '9840a2a0-6ae1-4688-b03d-d2ec291be0f9';
|
||||
const REDIRECT_URI = 'http://localhost:5174';
|
||||
const REDIRECT_URI = 'https://mygtf-rh.ensup-adm.net';
|
||||
const CLIENT_SECRET = 'F5G8Q~qWNzuMdghyIwTX20cAVjqAK4sz~1uEUaLB';
|
||||
const GROUP_ID = 'c1ea877c-6bca-4f47-bfad-f223640813a0';
|
||||
|
||||
// Middleware
|
||||
app.use(cors({
|
||||
origin: ['http://localhost:5174', 'http://localhost:5173', 'http://localhost:3000'],
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization']
|
||||
origin: [
|
||||
'http://localhost:3001',
|
||||
'http://localhost:8000',
|
||||
'http://127.0.0.1:3001',
|
||||
'https://mygtf-rh.ensup-adm.net:3001',
|
||||
'https://mygtf-rh.ensup-adm.net'
|
||||
],
|
||||
credentials: true
|
||||
}));
|
||||
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
@@ -85,23 +89,24 @@ async function checkSystemStatus() {
|
||||
`);
|
||||
systemStatus.hasFormateurEmailColumn = columnCheck.recordset[0].count > 0;
|
||||
|
||||
// 2. Vérifier si la vue Formateurs existe
|
||||
// 2. Vérifier si la vue v_Formateurs_CD existe
|
||||
const viewCheck = await pool.request().query(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM INFORMATION_SCHEMA.VIEWS
|
||||
WHERE TABLE_NAME = 'Formateurs'
|
||||
WHERE TABLE_SCHEMA = 'dbo'
|
||||
AND TABLE_NAME = 'v_Formateurs_CD'
|
||||
`);
|
||||
systemStatus.hasFormateurView = viewCheck.recordset[0].count > 0;
|
||||
|
||||
// 3. Tester l'accès à la vue Formateurs si elle existe
|
||||
// 3. Tester l'accès à la vue v_Formateurs_CD si elle existe
|
||||
if (systemStatus.hasFormateurView) {
|
||||
try {
|
||||
await pool.request().query(`SELECT TOP 1 userPrincipalName FROM [dbo].[Formateurs]`);
|
||||
await pool.request().query(`SELECT TOP 1 userPrincipalName FROM [GTF].[dbo].[v_Formateurs_CD]`);
|
||||
systemStatus.canAccessFormateurView = true;
|
||||
console.log('✅ Accès à la vue Formateurs: OK (RH)');
|
||||
console.log('✅ Accès à la vue v_Formateurs_CD: OK (RH)');
|
||||
} catch (error) {
|
||||
systemStatus.canAccessFormateurView = false;
|
||||
console.log('❌ Accès à la vue Formateurs: ERREUR (RH) -', error.message);
|
||||
console.log('❌ Accès à la vue v_Formateurs_CD: ERREUR (RH) -', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,8 +133,8 @@ async function checkSystemStatus() {
|
||||
|
||||
console.log('📊 État du système RH:');
|
||||
console.log(` - Colonne formateur_email_fk: ${systemStatus.hasFormateurEmailColumn ? '✅' : '❌'}`);
|
||||
console.log(` - Vue Formateurs: ${systemStatus.hasFormateurView ? '✅' : '❌'}`);
|
||||
console.log(` - Accès vue Formateurs: ${systemStatus.canAccessFormateurView ? '✅' : '❌'}`);
|
||||
console.log(` - Vue v_Formateurs_CD: ${systemStatus.hasFormateurView ? '✅' : '❌'}`);
|
||||
console.log(` - Accès vue v_Formateurs_CD: ${systemStatus.canAccessFormateurView ? '✅' : '❌'}`);
|
||||
console.log(` - Table formateurs_local: ${systemStatus.hasFormateurLocal ? '✅' : '❌'}`);
|
||||
console.log(` - Mode de fonctionnement RH: ${systemStatus.operatingMode}`);
|
||||
|
||||
@@ -139,13 +144,33 @@ async function checkSystemStatus() {
|
||||
}
|
||||
}
|
||||
|
||||
// À ajouter dans votre serveur RH (server.js)
|
||||
// Fonction utilitaire pour parser Nom_brut en nom et prénom
|
||||
function parseNomBrut(nomBrut) {
|
||||
if (!nomBrut) {
|
||||
return { nom: '', prenom: '' };
|
||||
}
|
||||
|
||||
const parts = nomBrut.trim().split(/\s+/);
|
||||
if (parts.length > 1) {
|
||||
return {
|
||||
nom: parts[0],
|
||||
prenom: parts.slice(1).join(' ')
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
nom: parts[0],
|
||||
prenom: ''
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Route de diagnostic des campus
|
||||
app.get('/api/debug-campus', async (req, res) => {
|
||||
try {
|
||||
// Vérifier les campus distincts dans la vue
|
||||
const campusResult = await pool.request().query(`
|
||||
SELECT DISTINCT Campus, COUNT(*) as nb_formateurs
|
||||
FROM [dbo].[Formateurs]
|
||||
FROM [GTF].[dbo].[v_Formateurs_CD]
|
||||
GROUP BY Campus
|
||||
ORDER BY Campus
|
||||
`);
|
||||
@@ -154,19 +179,18 @@ app.get('/api/debug-campus', async (req, res) => {
|
||||
const sampleResult = await pool.request().query(`
|
||||
SELECT TOP 10
|
||||
userPrincipalName,
|
||||
displayName,
|
||||
Campus,
|
||||
surname,
|
||||
givenname
|
||||
FROM [dbo].[Formateurs]
|
||||
ORDER BY Campus, displayName
|
||||
Nom_brut,
|
||||
Campus,
|
||||
Contrat
|
||||
FROM [GTF].[dbo].[v_Formateurs_CD]
|
||||
ORDER BY Campus, Nom_brut
|
||||
`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
campus_distincts: campusResult.recordset,
|
||||
echantillon_formateurs: sampleResult.recordset,
|
||||
message: 'Diagnostic des campus dans la vue Formateurs'
|
||||
message: 'Diagnostic des campus dans la vue v_Formateurs_CD'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
@@ -233,7 +257,6 @@ app.get('/api/debug-campus-local', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Fonction pour générer un hash reproductible depuis un email (mode legacy)
|
||||
function generateHashFromEmail(email) {
|
||||
let hash = 0;
|
||||
@@ -449,11 +472,11 @@ app.get('/api/diagnostic', async (req, res) => {
|
||||
break;
|
||||
case 'new_with_local':
|
||||
recommendations.push('⚠️ Fonctionne avec la table locale - pas d\'accès à la vue distante');
|
||||
recommendations.push('💡 Vérifier les permissions sur HP-TO-O365 pour utiliser la vue');
|
||||
recommendations.push('💡 Vérifier les permissions sur GTF pour utiliser la vue');
|
||||
break;
|
||||
case 'new_email_only':
|
||||
recommendations.push('⚠️ Mode dégradé - sauvegarde par email mais pas de détails formateurs');
|
||||
recommendations.push('💡 Restaurer l\'accès à la vue Formateurs ou table formateurs_local');
|
||||
recommendations.push('💡 Restaurer l\'accès à la vue v_Formateurs_CD ou table formateurs_local');
|
||||
break;
|
||||
case 'legacy_hash':
|
||||
recommendations.push('🔄 Mode compatibilité - utilise l\'ancien système de hash');
|
||||
@@ -552,7 +575,7 @@ app.get('/api/db-test', async (req, res) => {
|
||||
let formateurCount = 0;
|
||||
try {
|
||||
if (systemStatus.canAccessFormateurView) {
|
||||
const formateurResult = await pool.request().query('SELECT COUNT(*) as total FROM [dbo].[Formateurs]');
|
||||
const formateurResult = await pool.request().query('SELECT COUNT(*) as total FROM [GTF].[dbo].[v_Formateurs_CD]');
|
||||
formateurCount = formateurResult.recordset[0].total;
|
||||
} else if (systemStatus.hasFormateurLocal) {
|
||||
const formateurResult = await pool.request().query('SELECT COUNT(*) as total FROM formateurs_local');
|
||||
@@ -591,6 +614,136 @@ app.put('/api/declarations/:id/status', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Route pour supprimer une déclaration
|
||||
app.delete('/api/declarations/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
console.log(`🗑️ Tentative de suppression de la déclaration ID: ${id}`);
|
||||
|
||||
// Vérifier si la déclaration existe
|
||||
const existing = await pool.request()
|
||||
.input('id', sql.Int, id)
|
||||
.query('SELECT id, formateur_email_fk FROM declarations WHERE id = @id');
|
||||
|
||||
if (existing.recordset.length === 0) {
|
||||
console.log(`❌ Déclaration ${id} introuvable`);
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Déclaration introuvable'
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`✅ Déclaration ${id} trouvée, suppression en cours...`);
|
||||
|
||||
// Supprimer la déclaration
|
||||
await pool.request()
|
||||
.input('id', sql.Int, id)
|
||||
.query('DELETE FROM declarations WHERE id = @id');
|
||||
|
||||
console.log(`✅ Déclaration ${id} supprimée avec succès`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Déclaration supprimée avec succès'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur suppression déclaration:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Route pour modifier une déclaration
|
||||
app.put('/api/declarations/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { date, duree, heure_debut, heure_fin, description, type_demande_id } = req.body;
|
||||
|
||||
console.log(`✏️ Tentative de modification de la déclaration ID: ${id}`);
|
||||
console.log('Données reçues:', { date, duree, heure_debut, heure_fin, type_demande_id });
|
||||
|
||||
// Validation
|
||||
if (!date || !duree || !type_demande_id) {
|
||||
console.log('❌ Données manquantes');
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Données manquantes (date, durée, type requis)'
|
||||
});
|
||||
}
|
||||
|
||||
// Vérifier si la déclaration existe
|
||||
const existing = await pool.request()
|
||||
.input('id', sql.Int, id)
|
||||
.query('SELECT id FROM declarations WHERE id = @id');
|
||||
|
||||
if (existing.recordset.length === 0) {
|
||||
console.log(`❌ Déclaration ${id} introuvable`);
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Déclaration introuvable'
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`✅ Déclaration ${id} trouvée, mise à jour en cours...`);
|
||||
|
||||
// Formater les heures correctement pour SQL Server (ajouter :00 pour les secondes)
|
||||
const formatTimeForSql = (timeString) => {
|
||||
if (!timeString) return null;
|
||||
// Si le format est HH:MM, ajouter :00 pour les secondes
|
||||
if (timeString.match(/^\d{2}:\d{2}$/)) {
|
||||
return timeString + ':00';
|
||||
}
|
||||
return timeString;
|
||||
};
|
||||
|
||||
const heureDebutFormatted = formatTimeForSql(heure_debut);
|
||||
const heureFinFormatted = formatTimeForSql(heure_fin);
|
||||
|
||||
console.log('Heures formatées:', {
|
||||
original_debut: heure_debut,
|
||||
formatted_debut: heureDebutFormatted,
|
||||
original_fin: heure_fin,
|
||||
formatted_fin: heureFinFormatted
|
||||
});
|
||||
|
||||
// Mettre à jour la déclaration
|
||||
await pool.request()
|
||||
.input('id', sql.Int, id)
|
||||
.input('date', sql.Date, date)
|
||||
.input('duree', sql.Float, duree)
|
||||
.input('heure_debut', sql.VarChar(8), heureDebutFormatted)
|
||||
.input('heure_fin', sql.VarChar(8), heureFinFormatted)
|
||||
.input('description', sql.NVarChar, description || null)
|
||||
.input('type_demande_id', sql.Int, type_demande_id)
|
||||
.query(`
|
||||
UPDATE declarations
|
||||
SET date = @date,
|
||||
duree = @duree,
|
||||
heure_debut = ${heureDebutFormatted ? '@heure_debut' : 'NULL'},
|
||||
heure_fin = ${heureFinFormatted ? '@heure_fin' : 'NULL'},
|
||||
description = @description,
|
||||
type_demande_id = @type_demande_id
|
||||
WHERE id = @id
|
||||
`);
|
||||
|
||||
console.log(`✅ Déclaration ${id} modifiée avec succès`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Déclaration modifiée avec succès'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur modification déclaration:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Route pour récupérer les déclarations (ADAPTÉE AU NOUVEAU SYSTÈME)
|
||||
app.get('/api/get_declarations', async (req, res) => {
|
||||
try {
|
||||
@@ -600,17 +753,15 @@ app.get('/api/get_declarations', async (req, res) => {
|
||||
|
||||
switch (systemStatus.operatingMode) {
|
||||
case 'new_with_view':
|
||||
// Avec vue Formateurs
|
||||
// Avec vue v_Formateurs_CD
|
||||
result = await pool.request().query(`
|
||||
SELECT
|
||||
d.id,
|
||||
d.utilisateur_id,
|
||||
d.formateur_email_fk as formateur_email,
|
||||
f.displayName as formateur_nom_complet,
|
||||
f.surname as nom,
|
||||
f.givenname as prenom,
|
||||
f.Nom_brut as formateur_nom_complet,
|
||||
f.Campus,
|
||||
f.departement,
|
||||
f.Contrat,
|
||||
td.id as type_demande_id,
|
||||
td.libelle as activityType,
|
||||
d.date,
|
||||
@@ -621,7 +772,7 @@ app.get('/api/get_declarations', async (req, res) => {
|
||||
d.status
|
||||
FROM declarations d
|
||||
INNER JOIN types_demandes td ON d.type_demande_id = td.id
|
||||
LEFT JOIN [dbo].[Formateurs] f ON d.formateur_email_fk = f.userPrincipalName
|
||||
LEFT JOIN [GTF].[dbo].[v_Formateurs_CD] f ON d.formateur_email_fk = f.userPrincipalName
|
||||
ORDER BY d.date DESC
|
||||
`);
|
||||
break;
|
||||
@@ -702,8 +853,32 @@ app.get('/api/get_declarations', async (req, res) => {
|
||||
// Traitement selon le mode
|
||||
let processedResults = [];
|
||||
|
||||
if (systemStatus.operatingMode.startsWith('new_')) {
|
||||
// Nouveau système - données déjà enrichies par les jointures
|
||||
if (systemStatus.operatingMode === 'new_with_view') {
|
||||
// Nouveau système avec v_Formateurs_CD - parsing du Nom_brut
|
||||
processedResults = result.recordset.map(row => {
|
||||
const { nom, prenom } = parseNomBrut(row.formateur_nom_complet);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
utilisateur_id: row.utilisateur_id,
|
||||
formateur_email: row.formateur_email,
|
||||
type_demande_id: row.type_demande_id,
|
||||
date: row.date,
|
||||
duree: row.duree,
|
||||
description: row.description,
|
||||
heure_debut: formatSqlTime(row.heure_debut),
|
||||
heure_fin: formatSqlTime(row.heure_fin),
|
||||
status: row.status || 'pending',
|
||||
activityType: row.activityType,
|
||||
nom: nom || (row.formateur_email ? row.formateur_email.split('@')[0] : 'Inconnu'),
|
||||
prenom: prenom,
|
||||
campus: row.Campus || 'Non défini',
|
||||
contrat: row.Contrat || '',
|
||||
formateur_nom_complet: row.formateur_nom_complet || row.formateur_email || 'Utilisateur inconnu'
|
||||
};
|
||||
});
|
||||
} else if (systemStatus.operatingMode === 'new_with_local') {
|
||||
// Table locale avec structure complète
|
||||
processedResults = result.recordset.map(row => ({
|
||||
id: row.id,
|
||||
utilisateur_id: row.utilisateur_id,
|
||||
@@ -716,12 +891,30 @@ app.get('/api/get_declarations', async (req, res) => {
|
||||
heure_fin: formatSqlTime(row.heure_fin),
|
||||
status: row.status || 'pending',
|
||||
activityType: row.activityType,
|
||||
// Informations formateur (peuvent être null si pas de jointure)
|
||||
nom: row.nom || (row.formateur_email ? row.formateur_email.split('@')[0] : 'Inconnu'),
|
||||
prenom: row.prenom || '',
|
||||
campus: row.Campus || 'Non défini',
|
||||
formateur_nom_complet: row.formateur_nom_complet || row.formateur_email || 'Utilisateur inconnu'
|
||||
}));
|
||||
} else if (systemStatus.operatingMode === 'new_email_only') {
|
||||
// Mode email seulement
|
||||
processedResults = result.recordset.map(row => ({
|
||||
id: row.id,
|
||||
utilisateur_id: row.utilisateur_id,
|
||||
formateur_email: row.formateur_email,
|
||||
type_demande_id: row.type_demande_id,
|
||||
date: row.date,
|
||||
duree: row.duree,
|
||||
description: row.description,
|
||||
heure_debut: formatSqlTime(row.heure_debut),
|
||||
heure_fin: formatSqlTime(row.heure_fin),
|
||||
status: row.status || 'pending',
|
||||
activityType: row.activityType,
|
||||
nom: row.formateur_email ? row.formateur_email.split('@')[0] : 'Inconnu',
|
||||
prenom: '',
|
||||
campus: 'Non défini',
|
||||
formateur_nom_complet: row.formateur_email || 'Utilisateur inconnu'
|
||||
}));
|
||||
} else {
|
||||
// Ancien système - mapping manuel
|
||||
const knownMappings = {
|
||||
@@ -799,7 +992,7 @@ app.get('/api/debug-hash', (req, res) => {
|
||||
if (!email) {
|
||||
return res.json({
|
||||
error: 'Email requis',
|
||||
example: 'http://localhost:3002/api/debug-hash?email=oimer@ensup.eu'
|
||||
example: '/api/debug-hash?email=oimer@ensup.eu'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -812,7 +1005,7 @@ app.get('/api/debug-hash', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Nouvelle route pour les formateurs avec déclarations (ADAPTÉE)
|
||||
// Nouvelle route pour les formateurs avec déclarations
|
||||
app.get('/api/formateurs-avec-declarations', async (req, res) => {
|
||||
try {
|
||||
let result;
|
||||
@@ -820,56 +1013,80 @@ app.get('/api/formateurs-avec-declarations', async (req, res) => {
|
||||
switch (systemStatus.operatingMode) {
|
||||
case 'new_with_view':
|
||||
result = await pool.request().query(`
|
||||
SELECT DISTINCT
|
||||
f.givenname,
|
||||
f.surname,
|
||||
f.displayName,
|
||||
f.Campus,
|
||||
f.userPrincipalName,
|
||||
f.Jobtitle,
|
||||
f.Contrat,
|
||||
COUNT(d.id) as nb_declarations
|
||||
FROM [dbo].[Formateurs] f
|
||||
LEFT JOIN declarations d ON f.userPrincipalName = d.formateur_email_fk
|
||||
WHERE (f.Contrat = 'CDD' OR f.Contrat LIKE '%CDD%')
|
||||
AND d.id IS NOT NULL
|
||||
GROUP BY f.givenname, f.surname, f.displayName, f.Campus, f.userPrincipalName, f.Jobtitle, f.Contrat
|
||||
ORDER BY f.surname, f.givenname
|
||||
`);
|
||||
SELECT DISTINCT
|
||||
f.Nom_brut,
|
||||
f.Campus,
|
||||
f.userPrincipalName,
|
||||
f.Contrat,
|
||||
COUNT(d.id) as nb_declarations
|
||||
FROM [GTF].[dbo].[v_Formateurs_CD] f
|
||||
LEFT JOIN declarations d ON f.userPrincipalName = d.formateur_email_fk
|
||||
WHERE LOWER(f.Contrat) LIKE 'cd%'
|
||||
AND d.id IS NOT NULL
|
||||
GROUP BY f.Nom_brut, f.Campus, f.userPrincipalName, f.Contrat
|
||||
ORDER BY f.Nom_brut
|
||||
`);
|
||||
break;
|
||||
|
||||
case 'new_with_local':
|
||||
result = await pool.request().query(`
|
||||
SELECT DISTINCT
|
||||
f.givenname,
|
||||
f.surname,
|
||||
f.displayName,
|
||||
f.Campus,
|
||||
f.userPrincipalName,
|
||||
f.Jobtitle,
|
||||
f.Contrat,
|
||||
COUNT(d.id) as nb_declarations
|
||||
FROM formateurs_local f
|
||||
LEFT JOIN declarations d ON f.userPrincipalName = d.formateur_email_fk
|
||||
WHERE (f.Contrat = 'CDD' OR f.Contrat LIKE '%CDD%')
|
||||
AND d.id IS NOT NULL
|
||||
GROUP BY f.givenname, f.surname, f.displayName, f.Campus, f.userPrincipalName, f.Jobtitle, f.Contrat
|
||||
ORDER BY f.surname, f.givenname
|
||||
`);
|
||||
SELECT DISTINCT
|
||||
f.givenname,
|
||||
f.surname,
|
||||
f.displayName,
|
||||
f.Campus,
|
||||
f.userPrincipalName,
|
||||
f.Jobtitle,
|
||||
f.Contrat,
|
||||
COUNT(d.id) as nb_declarations
|
||||
FROM formateurs_local f
|
||||
LEFT JOIN declarations d ON f.userPrincipalName = d.formateur_email_fk
|
||||
WHERE LOWER(f.Contrat) LIKE 'cd%'
|
||||
AND d.id IS NOT NULL
|
||||
GROUP BY f.givenname, f.surname, f.displayName, f.Campus, f.userPrincipalName, f.Jobtitle, f.Contrat
|
||||
ORDER BY f.surname, f.givenname
|
||||
`);
|
||||
break;
|
||||
default:
|
||||
return res.json({
|
||||
success: false,
|
||||
message: 'Cette fonctionnalité nécessite le nouveau système',
|
||||
mode: systemStatus.operatingMode
|
||||
});
|
||||
}
|
||||
|
||||
const formateurs = result.recordset.map(f => ({
|
||||
userPrincipalName: f.userPrincipalName,
|
||||
displayName: f.displayName,
|
||||
nom: f.surname || '',
|
||||
prenom: f.givenname || '',
|
||||
campus: f.Campus || 'Non défini',
|
||||
poste: f.Jobtitle || '',
|
||||
contrat: f.Contrat || '',
|
||||
nbDeclarations: f.nb_declarations,
|
||||
displayText: `${f.surname || ''} ${f.givenname || ''} (${f.Campus || 'Non défini'}) - CDD`.trim()
|
||||
}));
|
||||
let formateurs;
|
||||
|
||||
if (systemStatus.operatingMode === 'new_with_view') {
|
||||
// Parser le Nom_brut pour v_Formateurs_CD
|
||||
formateurs = result.recordset.map(f => {
|
||||
const { nom, prenom } = parseNomBrut(f.Nom_brut);
|
||||
|
||||
return {
|
||||
userPrincipalName: f.userPrincipalName,
|
||||
displayName: f.Nom_brut,
|
||||
nom: nom,
|
||||
prenom: prenom,
|
||||
campus: f.Campus || 'Non défini',
|
||||
contrat: f.Contrat || '',
|
||||
nbDeclarations: f.nb_declarations,
|
||||
displayText: `${f.Nom_brut} (${f.Campus || 'Non défini'}) - CDD`.trim()
|
||||
};
|
||||
});
|
||||
} else {
|
||||
// Structure normale pour formateurs_local
|
||||
formateurs = result.recordset.map(f => ({
|
||||
userPrincipalName: f.userPrincipalName,
|
||||
displayName: f.displayName,
|
||||
nom: f.surname || '',
|
||||
prenom: f.givenname || '',
|
||||
campus: f.Campus || 'Non défini',
|
||||
poste: f.Jobtitle || '',
|
||||
contrat: f.Contrat || '',
|
||||
nbDeclarations: f.nb_declarations,
|
||||
displayText: `${f.surname || ''} ${f.givenname || ''} (${f.Campus || 'Non défini'}) - CDD`.trim()
|
||||
}));
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -893,21 +1110,20 @@ app.get('/api/formateurs-vue', async (req, res) => {
|
||||
console.log(`🔍 Récupération formateurs CDD seulement (mode: ${systemStatus.operatingMode})...`);
|
||||
|
||||
let formateurs = [];
|
||||
let result; // Déclarer result ici !
|
||||
let result;
|
||||
|
||||
if (systemStatus.canAccessFormateurView) {
|
||||
console.log('Utilisation de la vue Formateurs...');
|
||||
console.log('Utilisation de la vue v_Formateurs_CD...');
|
||||
result = await pool.request().query(`
|
||||
SELECT
|
||||
userPrincipalName,
|
||||
displayName,
|
||||
surname,
|
||||
givenname,
|
||||
Nom_brut,
|
||||
Campus,
|
||||
Contrat
|
||||
FROM [dbo].[Formateurs]
|
||||
WHERE Contrat = 'CDD' OR Contrat LIKE '%CDD%'
|
||||
ORDER BY surname, givenname
|
||||
FROM [GTF].[dbo].[v_Formateurs_CD]
|
||||
WHERE LOWER(Contrat) LIKE 'cd%'
|
||||
|
||||
ORDER BY Nom_brut
|
||||
`);
|
||||
} else if (systemStatus.hasFormateurLocal) {
|
||||
console.log('Utilisation de formateurs_local...');
|
||||
@@ -920,7 +1136,8 @@ app.get('/api/formateurs-vue', async (req, res) => {
|
||||
Campus,
|
||||
Contrat
|
||||
FROM formateurs_local
|
||||
WHERE Contrat = 'CDD' OR Contrat LIKE '%CDD%'
|
||||
WHERE LOWER(Contrat) LIKE 'cd%'
|
||||
|
||||
ORDER BY surname, givenname
|
||||
`);
|
||||
} else {
|
||||
@@ -939,9 +1156,18 @@ app.get('/api/formateurs-vue', async (req, res) => {
|
||||
if (result.recordset.length === 0) {
|
||||
console.log('Aucun formateur CDD trouvé, test sans filtre...');
|
||||
|
||||
// Test sans le filtre CDD pour voir s'il y a des formateurs
|
||||
let testResult;
|
||||
if (systemStatus.hasFormateurLocal) {
|
||||
if (systemStatus.canAccessFormateurView) {
|
||||
testResult = await pool.request().query(`
|
||||
SELECT TOP 5
|
||||
userPrincipalName,
|
||||
Nom_brut,
|
||||
Campus,
|
||||
Contrat
|
||||
FROM [GTF].[dbo].[v_Formateurs_CD]
|
||||
ORDER BY Nom_brut
|
||||
`);
|
||||
} else if (systemStatus.hasFormateurLocal) {
|
||||
testResult = await pool.request().query(`
|
||||
SELECT TOP 5
|
||||
userPrincipalName,
|
||||
@@ -953,20 +1179,41 @@ app.get('/api/formateurs-vue', async (req, res) => {
|
||||
FROM formateurs_local
|
||||
ORDER BY surname, givenname
|
||||
`);
|
||||
}
|
||||
|
||||
if (testResult) {
|
||||
console.log('Test sans filtre:', testResult.recordset);
|
||||
console.log('Types de contrats:', [...new Set(testResult.recordset.map(f => f.Contrat))]);
|
||||
}
|
||||
}
|
||||
|
||||
formateurs = result.recordset.map(f => ({
|
||||
userPrincipalName: f.userPrincipalName,
|
||||
displayName: f.displayName,
|
||||
nom: f.surname || '',
|
||||
prenom: f.givenname || '',
|
||||
campus: f.Campus || 'Non défini',
|
||||
contrat: f.Contrat || '',
|
||||
displayText: `${f.surname || ''} ${f.givenname || ''} (${f.Campus || 'Non défini'})`.trim()
|
||||
}));
|
||||
if (systemStatus.canAccessFormateurView) {
|
||||
// Parser Nom_brut pour v_Formateurs_CD
|
||||
formateurs = result.recordset.map(f => {
|
||||
const { nom, prenom } = parseNomBrut(f.Nom_brut);
|
||||
|
||||
return {
|
||||
userPrincipalName: f.userPrincipalName,
|
||||
displayName: f.Nom_brut,
|
||||
nom: nom,
|
||||
prenom: prenom,
|
||||
campus: f.Campus || 'Non défini',
|
||||
contrat: f.Contrat || '',
|
||||
displayText: `${f.Nom_brut} (${f.Campus || 'Non défini'})`.trim()
|
||||
};
|
||||
});
|
||||
} else {
|
||||
// Structure normale pour formateurs_local
|
||||
formateurs = result.recordset.map(f => ({
|
||||
userPrincipalName: f.userPrincipalName,
|
||||
displayName: f.displayName,
|
||||
nom: f.surname || '',
|
||||
prenom: f.givenname || '',
|
||||
campus: f.Campus || 'Non défini',
|
||||
contrat: f.Contrat || '',
|
||||
displayText: `${f.surname || ''} ${f.givenname || ''} (${f.Campus || 'Non défini'})`.trim()
|
||||
}));
|
||||
}
|
||||
|
||||
console.log(`✅ ${formateurs.length} formateurs CDD traités`);
|
||||
|
||||
@@ -986,7 +1233,8 @@ app.get('/api/formateurs-vue', async (req, res) => {
|
||||
});
|
||||
}
|
||||
});
|
||||
// ==================== ROUTES MICROSOFT GRAPH (INCHANGÉES) ====================
|
||||
|
||||
// ==================== ROUTES MICROSOFT GRAPH ====================
|
||||
|
||||
app.post('/api/auth', async (req, res) => {
|
||||
const { userPrincipalName } = req.body;
|
||||
@@ -1011,9 +1259,6 @@ app.post('/api/auth', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Authentifier un utilisateur avec Microsoft Graph
|
||||
*/
|
||||
async function authenticateUserWithGraph(userPrincipalName, accessToken) {
|
||||
try {
|
||||
const existingUser = await pool.request()
|
||||
@@ -1080,9 +1325,6 @@ async function authenticateUserWithGraph(userPrincipalName, accessToken) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Route pour extraire 3 personnes du service administratif
|
||||
*/
|
||||
app.get('/api/admin-users', async (req, res) => {
|
||||
console.log('=== Route /api/admin-users appelée (RH) ===');
|
||||
|
||||
@@ -1144,9 +1386,6 @@ app.get('/api/admin-users', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Route pour obtenir tous les membres du groupe
|
||||
*/
|
||||
app.get('/api/group-members', async (req, res) => {
|
||||
console.log('=== Route /api/group-members appelée (RH) ===');
|
||||
|
||||
@@ -1223,18 +1462,17 @@ app.get('/api/group-members', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Route de test à ajouter temporairement
|
||||
app.get('/api/test-permissions', async (req, res) => {
|
||||
try {
|
||||
console.log('Test des permissions sur HP-TO-O365...');
|
||||
console.log('Test des permissions sur GTF...');
|
||||
|
||||
// Test direct de la vue
|
||||
const result = await pool.request().query(`
|
||||
SELECT TOP 3
|
||||
userPrincipalName,
|
||||
displayName,
|
||||
Campus
|
||||
FROM [HP-TO-O365].[dbo].[V_Formateurs_Augmentees]
|
||||
Nom_brut,
|
||||
Campus,
|
||||
Contrat
|
||||
FROM [GTF].[dbo].[v_Formateurs_CD]
|
||||
`);
|
||||
|
||||
res.json({
|
||||
@@ -1637,9 +1875,6 @@ app.post('/api/login-hybrid', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Route pour tester la table RH
|
||||
*/
|
||||
app.get('/api/rh-test', async (req, res) => {
|
||||
try {
|
||||
if (!pool) {
|
||||
@@ -1660,9 +1895,117 @@ app.get('/api/rh-test', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Route pour lister tous les utilisateurs de la table RH
|
||||
*/
|
||||
// Routes de clôture
|
||||
app.get('/api/check-cloture', async (req, res) => {
|
||||
try {
|
||||
const { date, campus } = req.query;
|
||||
|
||||
if (!date) {
|
||||
return res.status(400).json({ error: 'Date requise' });
|
||||
}
|
||||
|
||||
const moisAnnee = date.substring(0, 7);
|
||||
|
||||
const result = await pool.request()
|
||||
.input('mois_annee', sql.VarChar, moisAnnee)
|
||||
.input('campus', sql.VarChar, campus || null)
|
||||
.query(`
|
||||
SELECT * FROM clotures_saisie
|
||||
WHERE mois_annee = @mois_annee
|
||||
AND (campus = @campus OR campus IS NULL OR @campus IS NULL)
|
||||
`);
|
||||
|
||||
res.json({
|
||||
cloture: result.recordset.length > 0,
|
||||
details: result.recordset[0] || null
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/clotures', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.request().query(`
|
||||
SELECT * FROM clotures_saisie
|
||||
ORDER BY date_cloture DESC
|
||||
`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
clotures: result.recordset
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/cloturer-periode', async (req, res) => {
|
||||
try {
|
||||
const { mois_annee, date_debut, date_fin, campus, commentaire, email_rh } = req.body;
|
||||
|
||||
if (!mois_annee || !date_debut || !date_fin) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Données manquantes'
|
||||
});
|
||||
}
|
||||
|
||||
const existing = await pool.request()
|
||||
.input('mois_annee', sql.VarChar, mois_annee)
|
||||
.input('campus', sql.VarChar, campus || null)
|
||||
.query(`
|
||||
SELECT id FROM clotures_saisie
|
||||
WHERE mois_annee = @mois_annee
|
||||
AND (campus = @campus OR (campus IS NULL AND @campus IS NULL))
|
||||
`);
|
||||
|
||||
if (existing.recordset.length > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Cette période est déjà clôturée'
|
||||
});
|
||||
}
|
||||
|
||||
await pool.request()
|
||||
.input('mois_annee', sql.VarChar, mois_annee)
|
||||
.input('date_debut', sql.Date, date_debut)
|
||||
.input('date_fin', sql.Date, date_fin)
|
||||
.input('campus', sql.VarChar, campus || null)
|
||||
.input('cloture_par', sql.VarChar, email_rh)
|
||||
.input('commentaire', sql.NVarChar, commentaire || null)
|
||||
.query(`
|
||||
INSERT INTO clotures_saisie
|
||||
(mois_annee, date_debut, date_fin, campus, cloture_par, commentaire)
|
||||
VALUES (@mois_annee, @date_debut, @date_fin, @campus, @cloture_par, @commentaire)
|
||||
`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Période clôturée avec succès'
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/rouvrir-periode/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
await pool.request()
|
||||
.input('id', sql.Int, id)
|
||||
.query('DELETE FROM clotures_saisie WHERE id = @id');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Période réouverte avec succès'
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/rh-users', async (req, res) => {
|
||||
try {
|
||||
const limit = req.query.limit ? parseInt(req.query.limit) : 50;
|
||||
@@ -1710,52 +2053,38 @@ async function startServer() {
|
||||
console.log(`📊 Mode de fonctionnement RH: ${systemStatus.operatingMode}`);
|
||||
console.log('');
|
||||
console.log('Routes disponibles :');
|
||||
console.log('- GET /api/diagnostic (vérifier l\'état)');
|
||||
console.log('- POST /api/migrate (appliquer la migration)');
|
||||
console.log('- GET /api/diagnostic');
|
||||
console.log('- POST /api/migrate');
|
||||
console.log('- GET /api/test');
|
||||
console.log('- GET /api/db-test');
|
||||
console.log('- GET /api/get_declarations');
|
||||
console.log('- PUT /api/declarations/:id/status');
|
||||
console.log('- POST /api/exchange-token (Échange code Microsoft)');
|
||||
console.log('- POST /api/auth (Microsoft Graph - legacy)');
|
||||
console.log('- GET /api/admin-users (3 utilisateurs administratifs)');
|
||||
console.log('- GET /api/group-members (tous les membres du groupe)');
|
||||
console.log('- GET /api/formateurs-avec-declarations');
|
||||
console.log('- DELETE /api/declarations/:id');
|
||||
console.log('- PUT /api/declarations/:id');
|
||||
console.log('- POST /api/exchange-token');
|
||||
console.log('- GET /api/formateurs-vue');
|
||||
console.log('- GET /api/rh-test (test table RH)');
|
||||
console.log('- GET /api/rh-users (liste utilisateurs RH)');
|
||||
console.log('- GET /api/clotures');
|
||||
console.log('- POST /api/cloturer-periode');
|
||||
console.log('- DELETE /api/rouvrir-periode/:id');
|
||||
console.log('');
|
||||
|
||||
switch (systemStatus.operatingMode) {
|
||||
case 'new_with_view':
|
||||
console.log('✅ Système optimal - utilise la vue Formateurs');
|
||||
console.log('✅ Système optimal - utilise la vue v_Formateurs_CD');
|
||||
break;
|
||||
case 'new_with_local':
|
||||
console.log('⚠️ Mode dégradé - utilise la table formateurs_local');
|
||||
console.log('💡 Conseil: Vérifier les permissions sur HP-TO-O365');
|
||||
break;
|
||||
case 'new_email_only':
|
||||
console.log('⚠️ Mode minimal - sauvegarde par email sans détails formateurs');
|
||||
break;
|
||||
case 'legacy_hash':
|
||||
console.log('🔄 Mode compatibilité - utilise l\'ancien système de hash');
|
||||
console.log('💡 Conseil: Appliquer la migration avec POST /api/migrate');
|
||||
break;
|
||||
}
|
||||
|
||||
if (!CLIENT_SECRET) {
|
||||
console.warn('⚠️ Variable d\'environnement manquante: CLIENT_SECRET');
|
||||
console.warn(' Ajoutez CLIENT_SECRET dans votre fichier .env');
|
||||
} else {
|
||||
console.log('✅ Configuration Microsoft OAuth OK');
|
||||
console.log(` Client ID: ${CLIENT_ID}`);
|
||||
console.log(` Tenant ID: ${TENANT_ID}`);
|
||||
console.log(` Redirect URI: ${REDIRECT_URI}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Arrêt propre
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('Arrêt du serveur RH...');
|
||||
if (pool) {
|
||||
@@ -1764,5 +2093,4 @@ process.on('SIGINT', async () => {
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Démarrer
|
||||
startServer();
|
||||
BIN
GTFRRH/project/backend/images/Ensup.png
Normal file
BIN
GTFRRH/project/backend/images/Ensup.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -1,11 +0,0 @@
|
||||
// msalInstance.ts
|
||||
import { PublicClientApplication } from "@azure/msal-browser";
|
||||
import { msalConfig } from "./Authconfig";
|
||||
|
||||
export const msalInstance = new PublicClientApplication(msalConfig);
|
||||
|
||||
// Important : initialiser avant usage
|
||||
(async () => {
|
||||
await msalInstance.initialize();
|
||||
})();
|
||||
|
||||
27
GTFRRH/project/src/.dockerignore
Normal file
27
GTFRRH/project/src/.dockerignore
Normal file
@@ -0,0 +1,27 @@
|
||||
# Dépendances
|
||||
node_modules
|
||||
|
||||
# Cache et build
|
||||
.vite
|
||||
dist
|
||||
node_modules/.vite
|
||||
*.log
|
||||
|
||||
# Environnement
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Autres
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -5,7 +5,7 @@ export const msalConfig = {
|
||||
auth: {
|
||||
clientId: "cd99bbea-dcd4-4a76-a0b0-7aeb49931943", // Application (client) ID dans Azure
|
||||
authority: "https://login.microsoftonline.com/9840a2a0-6ae1-4688-b03d-d2ec291be0f9", // Directory (tenant) ID
|
||||
redirectUri: "http://localhost:5174"
|
||||
redirectUri: "https://mygtf-rh.ensup-adm.net"
|
||||
},
|
||||
cache: {
|
||||
cacheLocation: "sessionStorage",
|
||||
257
GTFRRH/project/src/components/CloturePeriode.tsx
Normal file
257
GTFRRH/project/src/components/CloturePeriode.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Lock, Unlock, Calendar, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface Cloture {
|
||||
id: number;
|
||||
mois_annee: string;
|
||||
date_debut: string;
|
||||
date_fin: string;
|
||||
campus: string | null;
|
||||
cloture_par: string;
|
||||
date_cloture: string;
|
||||
commentaire: string | null;
|
||||
}
|
||||
|
||||
const ClotureManager: React.FC = () => {
|
||||
const [clotures, setClotures] = useState<Cloture[]>([]);
|
||||
const [selectedMonth, setSelectedMonth] = useState(() => {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
});
|
||||
const [selectedCampus, setSelectedCampus] = useState<string>('all');
|
||||
const [commentaire, setCommentaire] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const campuses = ['Cergy', 'Nantes', 'SQY', 'Marseille'];
|
||||
|
||||
const loadClotures = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/clotures');
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setClotures(data.clotures);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erreur chargement clôtures:', err);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadClotures();
|
||||
}, []);
|
||||
|
||||
const handleCloturer = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const [year, month] = selectedMonth.split('-');
|
||||
|
||||
const dateDebutStr = `${year}-${month}-01`;
|
||||
const lastDay = new Date(parseInt(year), parseInt(month), 0).getDate();
|
||||
const dateFinStr = `${year}-${month}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
const response = await fetch('/api/cloturer-periode', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
mois_annee: selectedMonth,
|
||||
date_debut: dateDebutStr,
|
||||
date_fin: dateFinStr,
|
||||
campus: selectedCampus === 'all' ? null : selectedCampus,
|
||||
commentaire
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
alert('Période clôturée avec succès');
|
||||
setCommentaire('');
|
||||
loadClotures();
|
||||
} else {
|
||||
setError(data.error || 'Erreur lors de la clôture');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRouvrir = async (id: number) => {
|
||||
if (!confirm('Voulez-vous vraiment rouvrir cette période ?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/rouvrir-periode/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
alert('Période réouverte avec succès');
|
||||
loadClotures();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erreur:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDateSQL = (dateString: string) => {
|
||||
if (!dateString) return 'Date inconnue';
|
||||
|
||||
try {
|
||||
let dateOnly = dateString;
|
||||
|
||||
if (dateString.includes('T')) {
|
||||
dateOnly = dateString.split('T')[0];
|
||||
}
|
||||
|
||||
const [year, month, day] = dateOnly.split('-').map(num => parseInt(num, 10));
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
|
||||
if (isNaN(date.getTime())) {
|
||||
return 'Date invalide';
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erreur formatage date:', dateString, error);
|
||||
return 'Date invalide';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 mb-6">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Lock className="text-red-600" size={24} />
|
||||
<h2 className="text-xl font-semibold text-gray-800">
|
||||
Gestion des clôtures de saisie
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="text-red-600" size={20} />
|
||||
<span className="text-red-800">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-6">
|
||||
<h3 className="font-medium text-gray-800 mb-4">
|
||||
Clôturer une nouvelle période
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Mois à clôturer
|
||||
</label>
|
||||
<input
|
||||
type="month"
|
||||
value={selectedMonth}
|
||||
onChange={(e) => setSelectedMonth(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Campus
|
||||
</label>
|
||||
<select
|
||||
value={selectedCampus}
|
||||
onChange={(e) => setSelectedCampus(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-2"
|
||||
>
|
||||
<option value="all">Tous les campus</option>
|
||||
{campuses.map(campus => (
|
||||
<option key={campus} value={campus}>{campus}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Commentaire (optionnel)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={commentaire}
|
||||
onChange={(e) => setCommentaire(e.target.value)}
|
||||
placeholder="Ex: Clôture mensuelle"
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCloturer}
|
||||
disabled={loading}
|
||||
className="w-full bg-red-600 hover:bg-red-700 text-white px-6 py-3 rounded-lg font-medium flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<Lock size={20} />
|
||||
{loading ? 'Clôture en cours...' : 'Clôturer cette période'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-800 mb-4">
|
||||
Périodes clôturées ({clotures.length})
|
||||
</h3>
|
||||
|
||||
{clotures.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-4">
|
||||
Aucune période clôturée
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{clotures.map(cloture => (
|
||||
<div
|
||||
key={cloture.id}
|
||||
className="flex items-center justify-between p-4 bg-red-50 border border-red-200 rounded-lg"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar size={16} className="text-red-600" />
|
||||
<span className="font-medium text-gray-800">
|
||||
{formatDateSQL(cloture.date_debut)}
|
||||
{' → '}
|
||||
{formatDateSQL(cloture.date_fin)}
|
||||
</span>
|
||||
<span className="text-sm text-gray-600">
|
||||
({cloture.mois_annee})
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 mt-1">
|
||||
Campus: {cloture.campus || 'Tous'} •
|
||||
Clôturé le {formatDateSQL(cloture.date_cloture)}
|
||||
{cloture.commentaire && ` • ${cloture.commentaire}`}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleRouvrir(cloture.id)}
|
||||
className="ml-4 px-4 py-2 bg-white hover:bg-gray-50 text-red-600 border border-red-300 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<Unlock size={16} />
|
||||
Rouvrir
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClotureManager;
|
||||
@@ -1,208 +1,99 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { AlertTriangle, Users } from 'lucide-react';
|
||||
import React, { useState } from "react";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
const Login = () => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { loginWithO365, isAuthorized } = useAuth();
|
||||
|
||||
// Configuration du backend Node.js
|
||||
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3002';
|
||||
|
||||
// Redirection si déjà connecté
|
||||
useEffect(() => {
|
||||
if (isAuthorized) {
|
||||
navigate('/dashboard');
|
||||
}
|
||||
}, [isAuthorized, navigate]);
|
||||
const { loginWithO365 } = useAuth();
|
||||
|
||||
const handleO365Login = async () => {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
setError("");
|
||||
|
||||
try {
|
||||
// 1. Connexion Office 365 via votre contexte existant
|
||||
const success = await loginWithO365();
|
||||
|
||||
if (!success) {
|
||||
setError("Erreur lors de l'initialisation de la connexion Office 365");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
setError("Erreur lors de la connexion Office 365");
|
||||
} else {
|
||||
navigate("/dashboard");
|
||||
}
|
||||
|
||||
// Le reste sera géré par l'AuthContext lors du retour de Microsoft
|
||||
// (exchangeCodeForToken + vérification backend)
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('Erreur O365:', err);
|
||||
setError(err.message || "Erreur lors de la connexion Office 365");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction pour traiter l'authentification complète (appelée après retour de Microsoft)
|
||||
const completeAuthentication = async () => {
|
||||
try {
|
||||
// 2. Récupération du token et des infos utilisateur
|
||||
const token = localStorage.getItem("o365_token");
|
||||
const userEmail = localStorage.getItem("user_email");
|
||||
|
||||
if (!token || !userEmail) {
|
||||
setError("Token ou email utilisateur manquant");
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Vérification de l'autorisation via votre backend Node.js
|
||||
const authResponse = await fetch(`${BACKEND_URL}/api/check-user-groups`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userPrincipalName: userEmail
|
||||
})
|
||||
});
|
||||
|
||||
if (!authResponse.ok) {
|
||||
throw new Error(`Erreur serveur: ${authResponse.status}`);
|
||||
}
|
||||
|
||||
const authData = await authResponse.json();
|
||||
console.log("Résultat autorisation backend :", authData);
|
||||
|
||||
if (!authData.authorized) {
|
||||
setError(authData.message || "Utilisateur non autorisé");
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Stocker les informations utilisateur complémentaires
|
||||
localStorage.setItem("user_data", JSON.stringify(authData.user));
|
||||
localStorage.setItem("user_role", authData.role);
|
||||
localStorage.setItem("local_user_id", authData.localUserId.toString());
|
||||
|
||||
console.log("Utilisateur autorisé :", authData.user);
|
||||
|
||||
// 5. Optionnel : Déclencher une synchronisation initiale si c'est le premier login
|
||||
try {
|
||||
const syncResponse = await fetch(`${BACKEND_URL}/api/initial-sync`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (syncResponse.ok) {
|
||||
const syncData = await syncResponse.json();
|
||||
console.log("Synchronisation terminée :", syncData);
|
||||
}
|
||||
} catch (syncError) {
|
||||
console.warn("Erreur synchronisation (non bloquante):", syncError);
|
||||
// Ne pas bloquer la connexion pour une erreur de sync
|
||||
}
|
||||
|
||||
// 6. Redirection vers dashboard
|
||||
navigate('/dashboard');
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('Erreur lors de la finalisation de l\'authentification:', err);
|
||||
|
||||
// Gestion des erreurs spécifiques
|
||||
if (err.message?.includes('non autorisé') || err.message?.includes('Accès refusé')) {
|
||||
setError('Accès refusé : Vous devez être membre du groupe autorisé dans votre organisation.');
|
||||
} else if (err.message?.includes('AADSTS')) {
|
||||
setError('Erreur d\'authentification Azure AD. Contactez votre administrateur.');
|
||||
} else if (err.message?.includes('fetch')) {
|
||||
setError('Impossible de contacter le serveur. Vérifiez que le backend est démarré.');
|
||||
} else {
|
||||
setError(err.message || "Erreur lors de la finalisation de la connexion");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Vérifier s'il faut finaliser l'authentification au chargement
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
const authToken = localStorage.getItem('o365_token');
|
||||
|
||||
// Si on a un code ET un token (authentification en cours), finaliser
|
||||
if (code && authToken && !isAuthorized) {
|
||||
setIsLoading(true);
|
||||
completeAuthentication().finally(() => setIsLoading(false));
|
||||
}
|
||||
}, [isAuthorized]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full">
|
||||
<div className="bg-white rounded-lg shadow-md p-8">
|
||||
<div className="min-h-screen flex">
|
||||
{/* Colonne gauche avec image + overlay */}
|
||||
<div className="hidden md:flex w-1/2 relative">
|
||||
<img
|
||||
src="/backend/images/Ensup.png"
|
||||
alt="ENSUP"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-[#6d4a91]/80 to-[#7e5aa2]/40 flex flex-col justify-center items-center text-white p-8">
|
||||
<h1 className="text-3xl font-bold mb-2">Bienvenue chez ENSUP</h1>
|
||||
<p className="text-lg text-gray-200">
|
||||
Espace RH - Gestion et Connexion Sécurisée
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* En-tête */}
|
||||
{/* Colonne droite avec le login */}
|
||||
<div className="flex w-full md:w-1/2 items-center justify-center p-8 bg-gray-50">
|
||||
<div className="w-full max-w-md bg-white shadow-xl rounded-2xl p-8">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 bg-[#7e5aa2] rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<Users className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">GTF - Espace RH</h1>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Connectez-vous avec votre compte Office 365
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
Connexion à l’espace RH
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Utilisez votre compte Office 365
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bouton de connexion Office 365 */}
|
||||
{/* Bouton violet ENSUP */}
|
||||
<button
|
||||
onClick={handleO365Login}
|
||||
disabled={isLoading}
|
||||
className="w-full bg-[#7e5aa2] hover:bg-[#6d4a91] text-white py-3 px-4 rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center space-x-2"
|
||||
className="w-full bg-[#7e5aa2] hover:bg-[#6d4a91] text-white py-3 px-4 rounded-lg font-medium transition-all duration-200 flex items-center justify-center gap-2 shadow-md disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
<span>Connexion en cours...</span>
|
||||
Connexion en cours...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
<span>Se connecter avec Office 365</span>
|
||||
</>
|
||||
<>Se connecter avec Office 365</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Message d'erreur */}
|
||||
{error && (
|
||||
<div className="mt-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="flex items-start space-x-2">
|
||||
<AlertTriangle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-red-800 font-medium text-sm">
|
||||
{error.includes('Accès refusé') ? 'Accès refusé' :
|
||||
error.includes('serveur') ? 'Problème de connexion' :
|
||||
'Erreur de connexion'}
|
||||
</p>
|
||||
<p className="text-red-700 text-xs mt-1">{error}</p>
|
||||
{error.includes('groupe autorisé') && (
|
||||
<p className="text-red-700 text-xs mt-2">
|
||||
Contactez votre administrateur pour être ajouté au groupe GTF.
|
||||
</p>
|
||||
)}
|
||||
{error.includes('serveur') && (
|
||||
<p className="text-red-700 text-xs mt-2">
|
||||
Vérifiez que le serveur backend est démarré sur le port 3002.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-red-500 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-red-800 font-medium text-sm">
|
||||
Erreur de connexion
|
||||
</p>
|
||||
<p className="text-red-700 text-xs mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-xs text-gray-500 mt-6">
|
||||
© {new Date().getFullYear()} ENSUP / ENSITECH. Tous droits réservés.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
export default Login;
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Users, Download, Calendar, Clock, FileText, Filter, LogOut, RefreshCw, AlertCircle } from 'lucide-react';
|
||||
import { Users, Download, Calendar, Clock, FileText, Filter, LogOut, RefreshCw, Lock, Edit2, Trash2, X, Save } from 'lucide-react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import ClotureManager from '../components/CloturePeriode';
|
||||
|
||||
interface TimeEntry {
|
||||
id: string;
|
||||
formateur: string;
|
||||
campus: string;
|
||||
date: string;
|
||||
type: 'preparation' | 'correction';
|
||||
type: 'preparation';
|
||||
hours: number;
|
||||
description: string;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
heure_debut?: string;
|
||||
heure_fin?: string;
|
||||
formateur_numero?: number;
|
||||
formateur_email?: string; // NOUVEAU CHAMP
|
||||
formateur_email?: string;
|
||||
type_demande_id?: number;
|
||||
}
|
||||
|
||||
interface FormateurAvecDeclarations {
|
||||
@@ -42,7 +44,8 @@ const RHDashboard: React.FC = () => {
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
return `${year}-${month}`;
|
||||
});
|
||||
const { logout } = useAuth();
|
||||
const [showClotureManager, setShowClotureManager] = useState(false);
|
||||
const { logout, user } = useAuth();
|
||||
const [selectedFormateur, setSelectedFormateur] = useState<string>('all');
|
||||
const [timeEntries, setTimeEntries] = useState<TimeEntry[]>([]);
|
||||
const [formateursAvecDeclarations, setFormateursAvecDeclarations] = useState<FormateurAvecDeclarations[]>([]);
|
||||
@@ -51,54 +54,34 @@ const RHDashboard: React.FC = () => {
|
||||
const [error, setError] = useState<string>('');
|
||||
const [systemStatus, setSystemStatus] = useState<SystemStatus | null>(null);
|
||||
|
||||
// Fonction pour normaliser/mapper les noms de campus
|
||||
const [editingEntry, setEditingEntry] = useState<TimeEntry | null>(null);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [editForm, setEditForm] = useState({
|
||||
date: '',
|
||||
hours: 0,
|
||||
heure_debut: '',
|
||||
heure_fin: '',
|
||||
description: '',
|
||||
type: 'preparation' as 'preparation'
|
||||
});
|
||||
|
||||
const normalizeCampus = (campus: string): string => {
|
||||
const mapping: { [key: string]: string } = {
|
||||
// Codes courts vers noms longs
|
||||
'MRS': 'Marseille',
|
||||
'mrs': 'Marseille',
|
||||
'NTE': 'Nantes',
|
||||
'nte': 'Nantes',
|
||||
'CGY': 'Cergy',
|
||||
'cgy': 'Cergy',
|
||||
'SQY': 'SQY',
|
||||
'sqy': 'SQY',
|
||||
'ensqy': 'SQY',
|
||||
'SQY/CGY': 'SQY/CGY', // Campus multi-sites
|
||||
'sqy/cgy': 'SQY/CGY',
|
||||
|
||||
// Noms complets (au cas où)
|
||||
'Marseille': 'Marseille',
|
||||
'Nantes': 'Nantes',
|
||||
'Cergy': 'Cergy',
|
||||
'MRS': 'Marseille', 'mrs': 'Marseille',
|
||||
'NTE': 'Nantes', 'nte': 'Nantes',
|
||||
'CGY': 'Cergy', 'cgy': 'Cergy',
|
||||
'SQY': 'SQY', 'sqy': 'SQY', 'ensqy': 'SQY',
|
||||
'SQY/CGY': 'SQY/CGY', 'sqy/cgy': 'SQY/CGY',
|
||||
'Marseille': 'Marseille', 'Nantes': 'Nantes', 'Cergy': 'Cergy',
|
||||
'Saint-Quentin-en-Yvelines': 'SQY',
|
||||
'MARSEILLE': 'Marseille',
|
||||
'NANTES': 'Nantes',
|
||||
'CERGY': 'Cergy',
|
||||
|
||||
// Fallback
|
||||
'Non défini': 'Non défini',
|
||||
'': 'Non défini'
|
||||
'MARSEILLE': 'Marseille', 'NANTES': 'Nantes', 'CERGY': 'Cergy',
|
||||
'Non défini': 'Non défini', '': 'Non défini'
|
||||
};
|
||||
return mapping[campus] || campus || 'Non défini';
|
||||
};
|
||||
|
||||
// Fonction inversée pour obtenir le code depuis le label
|
||||
const getCampusCode = (label: string): string => {
|
||||
const reverseMapping: { [key: string]: string } = {
|
||||
'Marseille': 'MRS',
|
||||
'Nantes': 'NTE',
|
||||
'Cergy': 'CGY',
|
||||
'SQY': 'SQY',
|
||||
'SQY/CGY': 'SQY/CGY'
|
||||
};
|
||||
return reverseMapping[label] || label;
|
||||
};
|
||||
|
||||
// Liste des campus pour l'interface (incluant SQY/CGY comme cas spécial)
|
||||
const campuses = ['Cergy', 'Nantes', 'SQY', 'Marseille', 'SQY/CGY'];
|
||||
|
||||
// Fonction pour générer le hash (même logique que le backend - pour compatibilité)
|
||||
const generateHashFromEmail = (email: string): number => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < email.length; i++) {
|
||||
@@ -109,10 +92,9 @@ const RHDashboard: React.FC = () => {
|
||||
return Math.abs(hash) % 10000 + 1000;
|
||||
};
|
||||
|
||||
// Fonction pour récupérer le statut du système
|
||||
const loadSystemStatus = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:3002/api/diagnostic');
|
||||
const response = await fetch('/api/diagnostic');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setSystemStatus(data.systemStatus);
|
||||
@@ -123,11 +105,9 @@ const RHDashboard: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction améliorée pour associer les déclarations avec les formateurs
|
||||
const getFormateurInfo = (declaration: any): { nom: string, prenom: string, campus: string, displayText: string } => {
|
||||
console.log('🔍 Recherche formateur pour:', declaration);
|
||||
|
||||
// NOUVEAU SYSTÈME : Si on a un email, chercher le formateur correspondant
|
||||
if (declaration.formateur_email || declaration.formateur_email_fk) {
|
||||
const email = declaration.formateur_email || declaration.formateur_email_fk;
|
||||
const formateurTrouve = formateursAvecDeclarations.find(f =>
|
||||
@@ -139,13 +119,12 @@ const RHDashboard: React.FC = () => {
|
||||
return {
|
||||
nom: formateurTrouve.nom,
|
||||
prenom: formateurTrouve.prenom,
|
||||
campus: formateurTrouve.campus, // Garder le campus original de la vue
|
||||
campus: formateurTrouve.campus,
|
||||
displayText: formateurTrouve.displayText
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// FALLBACK : Si on a les champs nom/prenom/campus directement dans la déclaration (nouveau système)
|
||||
if (declaration.nom && declaration.nom !== 'undefined') {
|
||||
console.log(`✅ Formateur trouvé dans les données déclaration: ${declaration.nom} ${declaration.prenom} (${declaration.campus})`);
|
||||
return {
|
||||
@@ -156,11 +135,8 @@ const RHDashboard: React.FC = () => {
|
||||
};
|
||||
}
|
||||
|
||||
// ANCIEN SYSTÈME : Si on a un formateur_numero, essayer de le correspondre
|
||||
if (declaration.formateur_numero && formateursAvecDeclarations.length > 0) {
|
||||
console.log(`🔍 Recherche par hash pour numéro: ${declaration.formateur_numero}`);
|
||||
|
||||
// Méthode 1: Chercher par hash d'email
|
||||
const formateurParHash = formateursAvecDeclarations.find(f => {
|
||||
if (f.userPrincipalName) {
|
||||
const hash = generateHashFromEmail(f.userPrincipalName);
|
||||
@@ -180,7 +156,6 @@ const RHDashboard: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// DERNIER RECOURS : Mapping connu pour les cas difficiles
|
||||
const knownMappings: { [key: number]: { nom: string, prenom: string, campus: string } } = {
|
||||
122: { nom: 'Admin', prenom: 'Ensup', campus: 'SQY' },
|
||||
999: { nom: 'Inconnu', prenom: 'Formateur', campus: 'Non défini' }
|
||||
@@ -197,7 +172,6 @@ const RHDashboard: React.FC = () => {
|
||||
};
|
||||
}
|
||||
|
||||
// FALLBACK FINAL
|
||||
const identifier = declaration.formateur_email || declaration.formateur_numero || 'Inconnu';
|
||||
console.log(`⚠️ Aucune correspondance trouvée, utilisation du fallback pour: ${identifier}`);
|
||||
return {
|
||||
@@ -208,16 +182,13 @@ const RHDashboard: React.FC = () => {
|
||||
};
|
||||
};
|
||||
|
||||
// Fonction pour charger TOUS les formateurs (avec et sans déclarations)
|
||||
const loadFormateursAvecDeclarations = async () => {
|
||||
try {
|
||||
setLoadingFormateurs(true);
|
||||
setError('');
|
||||
|
||||
console.log('🔄 Chargement de tous les formateurs...');
|
||||
|
||||
// Essayer d'abord de récupérer tous les formateurs depuis la vue
|
||||
const response = await fetch('http://localhost:3002/api/formateurs-vue');
|
||||
const response = await fetch('/api/formateurs-vue');
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
@@ -225,7 +196,6 @@ const RHDashboard: React.FC = () => {
|
||||
console.log(`✅ ${data.formateurs.length} formateurs chargés depuis la vue`);
|
||||
console.log('📊 Mode serveur:', data.mode);
|
||||
|
||||
// Convertir le format et initialiser à 0 déclarations
|
||||
const tousLesFormateurs = data.formateurs.map((f: any) => ({
|
||||
...f,
|
||||
nbDeclarations: 0,
|
||||
@@ -242,16 +212,15 @@ const RHDashboard: React.FC = () => {
|
||||
} catch (error: any) {
|
||||
console.error('❌ Erreur chargement formateurs depuis la vue:', error);
|
||||
|
||||
// Fallback: essayer avec formateurs-avec-declarations (ancienne méthode)
|
||||
try {
|
||||
console.log('🔄 Tentative de fallback avec formateurs-avec-declarations...');
|
||||
const fallbackResponse = await fetch('http://localhost:3002/api/formateurs-avec-declarations');
|
||||
const fallbackResponse = await fetch('/api/formateurs-avec-declarations');
|
||||
if (fallbackResponse.ok) {
|
||||
const fallbackData = await fallbackResponse.json();
|
||||
if (fallbackData.success) {
|
||||
setFormateursAvecDeclarations(fallbackData.formateurs);
|
||||
console.log('🔄 Fallback réussi:', fallbackData.formateurs.length, 'formateurs avec déclarations');
|
||||
setError(''); // Effacer l'erreur si le fallback fonctionne
|
||||
setError('');
|
||||
}
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
@@ -263,14 +232,13 @@ const RHDashboard: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Charger les déclarations depuis votre API
|
||||
const loadDeclarations = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
console.log('🔄 Chargement des déclarations...');
|
||||
|
||||
const response = await fetch('http://localhost:3002/api/get_declarations');
|
||||
const response = await fetch('/api/get_declarations');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur HTTP ${response.status} lors du chargement des déclarations`);
|
||||
@@ -280,30 +248,29 @@ const RHDashboard: React.FC = () => {
|
||||
console.log('📊 Données déclarations reçues:', data.length);
|
||||
console.log('📊 Exemple de déclaration:', data[0]);
|
||||
|
||||
// Convertir les données (avec normalisation des campus)
|
||||
const convertedEntries: TimeEntry[] = data.map((d: any) => {
|
||||
const formateurInfo = getFormateurInfo(d);
|
||||
|
||||
return {
|
||||
id: d.id.toString(),
|
||||
formateur: formateurInfo.displayText,
|
||||
campus: normalizeCampus(formateurInfo.campus), // Normaliser le campus
|
||||
campus: normalizeCampus(formateurInfo.campus),
|
||||
date: d.date.split('T')[0],
|
||||
type: d.activityType,
|
||||
type: 'preparation',
|
||||
hours: d.duree,
|
||||
description: d.description || '',
|
||||
status: d.status || 'pending',
|
||||
heure_debut: d.heure_debut || null,
|
||||
heure_fin: d.heure_fin || null,
|
||||
formateur_numero: d.formateur_numero,
|
||||
formateur_email: d.formateur_email || d.formateur_email_fk
|
||||
formateur_email: d.formateur_email || d.formateur_email_fk,
|
||||
type_demande_id: d.type_demande_id
|
||||
};
|
||||
});
|
||||
|
||||
setTimeEntries(convertedEntries);
|
||||
console.log(`✅ ${convertedEntries.length} déclarations traitées`);
|
||||
|
||||
// Log des formateurs uniques pour debug
|
||||
const formateursUniques = [...new Set(convertedEntries.map(e => e.formateur))];
|
||||
console.log(`📊 ${formateursUniques.length} formateurs uniques dans les déclarations:`, formateursUniques.slice(0, 5));
|
||||
|
||||
@@ -315,14 +282,12 @@ const RHDashboard: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Charger les données au démarrage
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
await loadSystemStatus();
|
||||
await loadFormateursAvecDeclarations();
|
||||
await loadDeclarations();
|
||||
|
||||
// Debug: Afficher tous les campus uniques trouvés dans les données
|
||||
setTimeout(() => {
|
||||
const campusUniques = [...new Set(formateursAvecDeclarations.map(f => f.campus))].filter(Boolean);
|
||||
const campusDeclarations = [...new Set(timeEntries.map(e => e.campus))].filter(Boolean);
|
||||
@@ -335,12 +300,10 @@ const RHDashboard: React.FC = () => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
// Re-traiter les déclarations quand les formateurs sont chargés ET calculer les nombres de déclarations
|
||||
useEffect(() => {
|
||||
if (formateursAvecDeclarations.length > 0) {
|
||||
console.log('🔄 Re-traitement des déclarations avec les nouveaux formateurs...');
|
||||
|
||||
// Si on a déjà des déclarations, les retraiter
|
||||
if (timeEntries.length > 0) {
|
||||
const updatedEntries = timeEntries.map(entry => {
|
||||
const originalDeclaration = {
|
||||
@@ -359,21 +322,17 @@ const RHDashboard: React.FC = () => {
|
||||
setTimeEntries(updatedEntries);
|
||||
}
|
||||
|
||||
// Calculer le nombre de déclarations pour chaque formateur
|
||||
const formateursAvecCompte = formateursAvecDeclarations.map(formateur => {
|
||||
const nbDeclarations = timeEntries.filter(entry => {
|
||||
// Correspondance par email
|
||||
if (entry.formateur_email === formateur.userPrincipalName) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Correspondance par hash
|
||||
if (entry.formateur_numero && formateur.userPrincipalName) {
|
||||
const hash = generateHashFromEmail(formateur.userPrincipalName);
|
||||
return hash === entry.formateur_numero;
|
||||
}
|
||||
|
||||
// Correspondance par nom affiché
|
||||
const expectedDisplayText = `${formateur.nom} ${formateur.prenom} (${formateur.campus})`.trim();
|
||||
return entry.formateur === expectedDisplayText || entry.formateur === formateur.displayText;
|
||||
}).length;
|
||||
@@ -386,19 +345,15 @@ const RHDashboard: React.FC = () => {
|
||||
|
||||
setFormateursAvecDeclarations(formateursAvecCompte);
|
||||
}
|
||||
}, [timeEntries.length]); // Déclenché quand le nombre de déclarations change
|
||||
}, [timeEntries.length]);
|
||||
|
||||
// Réinitialiser le filtre formateur quand on change de campus
|
||||
useEffect(() => {
|
||||
// Si un formateur est sélectionné et qu'on change de campus
|
||||
if (selectedFormateur !== 'all') {
|
||||
// Vérifier si le formateur sélectionné existe encore dans le campus filtré
|
||||
const formateurExists = formateursAvecDeclarations.some(formateur => {
|
||||
const campusMatch = selectedCampus === 'all' || formateur.campus === selectedCampus;
|
||||
return campusMatch && formateur.displayText === selectedFormateur;
|
||||
});
|
||||
|
||||
// Si le formateur n'existe pas dans le nouveau campus, le réinitialiser
|
||||
if (!formateurExists) {
|
||||
console.log(`🔄 Réinitialisation du filtre formateur (${selectedFormateur} n'est pas dans ${selectedCampus})`);
|
||||
setSelectedFormateur('all');
|
||||
@@ -406,7 +361,6 @@ const RHDashboard: React.FC = () => {
|
||||
}
|
||||
}, [selectedCampus, formateursAvecDeclarations]);
|
||||
|
||||
// Fonction de déconnexion
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('o365_token');
|
||||
@@ -415,14 +369,83 @@ const RHDashboard: React.FC = () => {
|
||||
window.location.href = '/login';
|
||||
};
|
||||
|
||||
// Fonction de rafraîchissement complète
|
||||
const handleRefresh = async () => {
|
||||
await loadSystemStatus();
|
||||
await loadFormateursAvecDeclarations();
|
||||
await loadDeclarations();
|
||||
};
|
||||
|
||||
// Filtrage des données (avec normalisation des campus)
|
||||
const handleEdit = (entry: TimeEntry) => {
|
||||
setEditingEntry(entry);
|
||||
setEditForm({
|
||||
date: entry.date,
|
||||
hours: entry.hours,
|
||||
heure_debut: entry.heure_debut || '',
|
||||
heure_fin: entry.heure_fin || '',
|
||||
description: entry.description,
|
||||
type: entry.type
|
||||
});
|
||||
setShowEditModal(true);
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editingEntry) return;
|
||||
|
||||
try {
|
||||
const type_demande_id = editingEntry.type_demande_id || 1;
|
||||
|
||||
console.log('Mise à jour avec type_demande_id:', type_demande_id);
|
||||
|
||||
const response = await fetch(`/api/declarations/${editingEntry.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
date: editForm.date,
|
||||
duree: editForm.hours,
|
||||
heure_debut: editForm.heure_debut || null,
|
||||
heure_fin: editForm.heure_fin || null,
|
||||
description: editForm.description,
|
||||
type_demande_id: type_demande_id
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setShowEditModal(false);
|
||||
setEditingEntry(null);
|
||||
await loadDeclarations();
|
||||
} else {
|
||||
alert('Erreur: ' + result.message);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Erreur lors de la modification:', error);
|
||||
alert('Erreur lors de la modification: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (entry: TimeEntry) => {
|
||||
if (!confirm(`Êtes-vous sûr de vouloir supprimer cette déclaration de ${entry.formateur} du ${new Date(entry.date).toLocaleDateString('fr-FR')} ?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/declarations/${entry.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
await loadDeclarations();
|
||||
} else {
|
||||
alert('Erreur: ' + result.message);
|
||||
}
|
||||
} catch (error: any) {
|
||||
alert('Erreur lors de la suppression: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredEntries = timeEntries.filter(entry => {
|
||||
const entryNormalizedCampus = normalizeCampus(entry.campus);
|
||||
const campusMatch = selectedCampus === 'all' || entryNormalizedCampus === selectedCampus;
|
||||
@@ -431,75 +454,60 @@ const RHDashboard: React.FC = () => {
|
||||
return campusMatch && formateurMatch && monthMatch;
|
||||
});
|
||||
|
||||
// Générer la liste des formateurs filtrés par campus (avec déduplication)
|
||||
const formateursUniques = formateursAvecDeclarations
|
||||
.filter(formateur => {
|
||||
// Si "tous les campus" est sélectionné, afficher tous les formateurs
|
||||
if (selectedCampus === 'all') return true;
|
||||
// Normaliser le campus du formateur et comparer avec le campus sélectionné
|
||||
const formateurCampusNormalized = normalizeCampus(formateur.campus);
|
||||
return formateurCampusNormalized === selectedCampus;
|
||||
})
|
||||
.reduce((acc: any[], formateur) => {
|
||||
// Déduplicquer par email (userPrincipalName)
|
||||
const existing = acc.find(f => f.userPrincipalName === formateur.userPrincipalName);
|
||||
if (!existing) {
|
||||
acc.push({
|
||||
displayText: formateur.displayText,
|
||||
nbDeclarations: formateur.nbDeclarations || 0,
|
||||
userPrincipalName: formateur.userPrincipalName,
|
||||
campus: normalizeCampus(formateur.campus) // Normaliser le campus pour l'affichage
|
||||
campus: normalizeCampus(formateur.campus)
|
||||
});
|
||||
} else {
|
||||
// Si le formateur existe déjà, additionner les déclarations
|
||||
existing.nbDeclarations += (formateur.nbDeclarations || 0);
|
||||
}
|
||||
return acc;
|
||||
}, [])
|
||||
.sort((a, b) => a.displayText.localeCompare(b.displayText));
|
||||
|
||||
// Statistiques par campus (avec normalisation)
|
||||
const getStatsForCampus = (campus: string) => {
|
||||
const campusEntries = timeEntries.filter(entry => {
|
||||
const entryNormalizedCampus = normalizeCampus(entry.campus);
|
||||
return entryNormalizedCampus === campus;
|
||||
});
|
||||
const totalHours = campusEntries.reduce((sum, entry) => sum + entry.hours, 0);
|
||||
const preparationHours = campusEntries
|
||||
.filter(entry => entry.type === 'preparation')
|
||||
.reduce((sum, entry) => sum + entry.hours, 0);
|
||||
const correctionHours = campusEntries
|
||||
.filter(entry => entry.type === 'correction')
|
||||
.reduce((sum, entry) => sum + entry.hours, 0);
|
||||
|
||||
return { totalHours, preparationHours, correctionHours };
|
||||
return { totalHours };
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const csvHeaders = ['Date', 'Formateur', 'Campus', 'Type', 'Heures', 'Heure Début', 'Heure Fin', 'Description'];
|
||||
const csvHeaders = ['Date', 'Formateur', 'Campus', 'Heures', 'Heure Début', 'Heure Fin', 'Description'];
|
||||
|
||||
const csvData = filteredEntries.map(entry => [
|
||||
new Date(entry.date).toLocaleDateString('fr-FR'),
|
||||
entry.formateur || 'Non défini',
|
||||
entry.campus || 'Non défini',
|
||||
entry.type === 'preparation' ? 'Préparation' : 'Correction',
|
||||
entry.hours.toString(),
|
||||
entry.heure_debut || 'Non défini',
|
||||
entry.heure_fin || 'Non défini',
|
||||
(entry.description || '').replace(/[\r\n]+/g, ' ').trim()
|
||||
]);
|
||||
|
||||
// Fonction pour nettoyer les valeurs (enlever guillemets et point-virgules problématiques)
|
||||
const cleanCsvValue = (value) => {
|
||||
const cleanCsvValue = (value: any) => {
|
||||
return String(value || '')
|
||||
.replace(/"/g, '""') // Doubler les guillemets
|
||||
.replace(/;/g, ','); // Remplacer ; par , dans les données
|
||||
.replace(/"/g, '""')
|
||||
.replace(/;/g, ',');
|
||||
};
|
||||
|
||||
// Utiliser le point-virgule comme séparateur pour Excel français
|
||||
const csvContent = '\uFEFF' + [csvHeaders, ...csvData]
|
||||
.map(row => row.map(cell => cleanCsvValue(cell)).join(';')) // Point-virgule ici !
|
||||
.join('\r\n'); // Utiliser \r\n pour Windows
|
||||
.map(row => row.map(cell => cleanCsvValue(cell)).join(';'))
|
||||
.join('\r\n');
|
||||
|
||||
const blob = new Blob([csvContent], {
|
||||
type: 'text/csv;charset=utf-8;'
|
||||
@@ -563,17 +571,23 @@ const RHDashboard: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-4">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-800 flex items-center gap-3">
|
||||
<Users className="text-blue-600" />
|
||||
Vue RH - GTF
|
||||
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-2">
|
||||
Gestion et suivi des déclarations des formateurs
|
||||
</p>
|
||||
{user && (
|
||||
<div className="mt-3 flex items-center gap-2 text-sm">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500"></div>
|
||||
<span className="text-gray-700">
|
||||
Connecté en tant que <span className="font-semibold">{user.nom} {user.prenom}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -596,7 +610,6 @@ const RHDashboard: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message d'erreur */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -607,7 +620,6 @@ const RHDashboard: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Indicateur de chargement */}
|
||||
{(loading || loadingFormateurs) && (
|
||||
<div className="bg-white rounded-xl shadow-lg p-8 mb-6 text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
@@ -619,13 +631,23 @@ const RHDashboard: React.FC = () => {
|
||||
|
||||
{!loading && !loadingFormateurs && (
|
||||
<>
|
||||
{/* Filtres */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 mb-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Filter className="text-gray-600" size={20} />
|
||||
<h2 className="text-lg font-semibold text-gray-800">Filtres</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => setShowClotureManager(!showClotureManager)}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors shadow-md"
|
||||
>
|
||||
<Lock size={20} />
|
||||
{showClotureManager ? 'Masquer la gestion des clôtures' : 'Gérer les clôtures de saisie'}
|
||||
</button>
|
||||
</div>
|
||||
{showClotureManager && <ClotureManager />}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
@@ -688,7 +710,6 @@ const RHDashboard: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistiques par campus */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
{campuses.map(campus => {
|
||||
const stats = getStatsForCampus(campus);
|
||||
@@ -703,21 +724,12 @@ const RHDashboard: React.FC = () => {
|
||||
<span className="text-sm text-gray-600">Total:</span>
|
||||
<span className="font-semibold text-gray-800">{stats.totalHours}h</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-blue-600">Préparation:</span>
|
||||
<span className="font-medium text-blue-600">{stats.preparationHours}h</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-orange-600">Correction:</span>
|
||||
<span className="font-medium text-orange-600">{stats.correctionHours}h</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Résumé des résultats */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-6 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -737,7 +749,6 @@ const RHDashboard: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tableau des déclarations */}
|
||||
<div className="bg-white rounded-xl shadow-lg overflow-hidden">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||||
@@ -750,30 +761,14 @@ const RHDashboard: React.FC = () => {
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Formateur
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Campus
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Type
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Heures
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Heure Début
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Heure Fin
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Description
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Formateur</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Campus</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Heures</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Heure Début</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Heure Fin</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Description</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
@@ -798,21 +793,6 @@ const RHDashboard: React.FC = () => {
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{entry.campus}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${entry.type === 'preparation'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-orange-100 text-orange-800'
|
||||
}`}>
|
||||
<div className="flex items-center gap-1">
|
||||
{entry.type === 'preparation' ? (
|
||||
<FileText size={12} />
|
||||
) : (
|
||||
<Clock size={12} />
|
||||
)}
|
||||
{entry.type === 'preparation' ? 'Préparation' : 'Correction'}
|
||||
</div>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-semibold text-gray-900">
|
||||
{entry.hours}h
|
||||
</td>
|
||||
@@ -831,6 +811,24 @@ const RHDashboard: React.FC = () => {
|
||||
<td className="px-6 py-4 text-sm text-gray-500 max-w-xs truncate">
|
||||
{entry.description}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(entry)}
|
||||
className="text-blue-600 hover:text-blue-800 transition-colors"
|
||||
title="Modifier"
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(entry)}
|
||||
className="text-red-600 hover:text-red-800 transition-colors"
|
||||
title="Supprimer"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
@@ -838,6 +836,101 @@ const RHDashboard: React.FC = () => {
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showEditModal && editingEntry && (
|
||||
<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-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-200 flex items-center justify-between sticky top-0 bg-white">
|
||||
<h3 className="text-xl font-bold text-gray-800">Modifier la déclaration</h3>
|
||||
<button onClick={() => setShowEditModal(false)} className="text-gray-400 hover:text-gray-600">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Formateur</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingEntry.formateur}
|
||||
disabled
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-2 bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={editForm.date}
|
||||
onChange={(e) => setEditForm({ ...editForm, date: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Nombre d'heures</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
value={editForm.hours}
|
||||
onChange={(e) => setEditForm({ ...editForm, hours: parseFloat(e.target.value) || 0 })}
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Heure début</label>
|
||||
<input
|
||||
type="time"
|
||||
value={editForm.heure_debut}
|
||||
onChange={(e) => setEditForm({ ...editForm, heure_debut: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Heure fin</label>
|
||||
<input
|
||||
type="time"
|
||||
value={editForm.heure_fin}
|
||||
onChange={(e) => setEditForm({ ...editForm, heure_fin: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Description</label>
|
||||
<textarea
|
||||
value={editForm.description}
|
||||
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
|
||||
rows={4}
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Description de l'activité..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-gray-200 flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => setShowEditModal(false)}
|
||||
className="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveEdit}
|
||||
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg flex items-center gap-2 transition-colors"
|
||||
>
|
||||
<Save size={18} />
|
||||
Enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
|
||||
// Configuration Microsoft OAuth
|
||||
const CLIENT_ID = 'cd99bbea-dcd4-4a76-a0b0-7aeb49931943';
|
||||
const REDIRECT_URI = 'http://localhost:5174';
|
||||
const REDIRECT_URI = 'https://mygtf-rh.ensup-adm.net';
|
||||
const TENANT_ID = '9840a2a0-6ae1-4688-b03d-d2ec291be0f9';
|
||||
|
||||
// Vérifier l'état d'authentification au chargement
|
||||
@@ -136,7 +136,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const userInfo = await userResponse.json();
|
||||
|
||||
// Maintenant vérifier l'autorisation via votre backend
|
||||
const authResponse = await fetch('http://localhost:3002/api/check-user-groups', {
|
||||
const authResponse = await fetch('/api/check-user-groups', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -179,7 +179,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const response = await fetch('http://localhost:3002/api/login-hybrid', {
|
||||
const response = await fetch('/api/login-hybrid', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
optimizeDeps: {
|
||||
exclude: ['lucide-react'],
|
||||
},
|
||||
});
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: true,
|
||||
port: 3002,
|
||||
strictPort: true,
|
||||
allowedHosts: ['mygtf-rh.ensup-adm.net'],
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://backend:3000',
|
||||
changeOrigin: true,
|
||||
secure: false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user