Compare commits

...

10 Commits

Author SHA1 Message Date
9d24aff2e9 Merge branch 'master' of https://mygitea.ensup-adm.net/oimer/GTA 2026-01-12 12:17:23 +01:00
91cd1dff2f First_Commit 2026-01-12 12:16:53 +01:00
47c1fb99b8 Merge branch 'master' of https://mygitea.ensup-adm.net/oimer/GTA 2025-12-03 11:02:42 +01:00
048c2929b9 Reapply "V1_Fonctionnel_GTAV1_GTA"
This reverts commit 244db6bfb6.
2025-12-03 11:02:33 +01:00
89d74363f8 Reapply "V1_Fonctionnel_GTAV1_GTA"
This reverts commit 244db6bfb6.
2025-12-02 18:04:52 +01:00
6f75a66906 Revert "V1_GTA"
This reverts commit 881476122c.
2025-12-02 17:50:31 +01:00
0dc7125688 Merge branch 'master' of https://mygitea.ensup-adm.net/oimer/GTA 2025-12-02 17:50:17 +01:00
244db6bfb6 Revert "V1_Fonctionnel_GTAV1_GTA"
This reverts commit 6d244f5323.
2025-12-02 17:49:04 +01:00
6d244f5323 V1_Fonctionnel_GTAV1_GTA 2025-12-02 17:47:02 +01:00
881476122c V1_GTA 2025-11-28 16:55:45 +01:00
58 changed files with 12585 additions and 7210 deletions

View File

@@ -1,32 +0,0 @@
# Étape 1 : Construction de l'application
FROM node:18-alpine AS builder
# Définir le répertoire de travail
WORKDIR /app
# Copier le package.json et package-lock.json depuis le dossier 'project'
# Le contexte de construction est './project' donc Docker peut les trouver
COPY package.json ./
COPY package-lock.json ./
# Installer les dépendances
RUN npm install
# Copier le reste des fichiers du dossier 'project'
# Cela inclut le dossier 'src' et tout le reste
COPY . .
# Lancer la compilation de l'application pour la production
RUN npm run build
# Étape 2 : Servir l'application avec Nginx
FROM nginx:alpine
# Copier les fichiers du build de l'étape précédente
COPY --from=builder /app/build /usr/share/nginx/html
# Exposer le port 80
EXPOSE 80
# Commande pour démarrer Nginx
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,12 +1,38 @@
services:
frontend:
image: ouijdaneim/gta-frontend:latest
backend:
image: ouijdaneim/gta-backend-dev:latest # ✅ Ajoute cette ligne
build:
context: ./project/public/Backend
dockerfile: DockerfileGTA.backend
container_name: gtaDev-backend
hostname: backend
ports:
- "3000:80"
- "8014:3004"
volumes:
- ./project/public/Backend/uploads:/app/uploads
networks:
- gtaDev-network
restart: unless-stopped
extra_hosts:
- "host.docker.internal:host-gateway"
frontend:
image: ouijdaneim/gta-frontend-dev:latest # ✅ Ajoute cette ligne
build:
context: ./project
dockerfile: DockerfileGTA.frontend
container_name: gtaDev-frontend
hostname: frontend
ports:
- "3015:90"
environment:
- VITE_API_URL=http://backend:3004
networks:
- gtaDev-network
depends_on:
- backend
restart: unless-stopped
backend:
image: ouijdaneim/gta-backend:latest
ports:
- "8000:80"
networks:
gtaDev-network:
driver: bridge

229
package-lock.json generated
View File

@@ -6,9 +6,11 @@
"": {
"dependencies": {
"cors": "^2.8.5",
"date-fns": "^4.1.0",
"express": "^5.1.0",
"framer-motion": "^12.23.22",
"node-cron": "^4.2.1"
"node-cron": "^4.2.1",
"react-datepicker": "^9.1.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.8.0",
@@ -79,7 +81,6 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -675,7 +676,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -699,15 +699,14 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@emnapi/core": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz",
"integrity": "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==",
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz",
"integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -717,9 +716,9 @@
}
},
"node_modules/@emnapi/runtime": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz",
"integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==",
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz",
"integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -1180,6 +1179,59 @@
"node": ">=18"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/react": {
"version": "0.27.16",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.16.tgz",
"integrity": "sha512-9O8N4SeG2z++TSM8QA/KTeKFBVCNEz/AGS7gWPJf6KFRzmRWixFRnCnkPHRDwSVZW6QPDO6uT0P2SpWNKCc9/g==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.6",
"@floating-ui/utils": "^0.2.10",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=17.0.0",
"react-dom": ">=17.0.0"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.6",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.6.tgz",
"integrity": "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.7.4"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
"license": "MIT"
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -2017,27 +2069,6 @@
"@sinonjs/commons": "^3.0.1"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.3.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"picocolors": "1.1.1",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@testing-library/jest-dom": {
"version": "6.8.0",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.8.0.tgz",
@@ -2094,9 +2125,9 @@
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz",
"integrity": "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ==",
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -2104,13 +2135,6 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2946,7 +2970,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001737",
"electron-to-chromium": "^1.5.211",
@@ -3236,6 +3259,15 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/co": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@@ -3390,6 +3422,16 @@
"node": ">=18"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@@ -3478,13 +3520,6 @@
"node": ">=8"
}
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT"
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -5464,7 +5499,6 @@
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
@@ -5579,16 +5613,6 @@
"yallist": "^3.0.2"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/magic-string": {
"version": "0.30.18",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.18.tgz",
@@ -6284,34 +6308,6 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/pretty-format/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -6414,38 +6410,27 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/react": {
"version": "19.1.1",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
"integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==",
"devOptional": true,
"node_modules/react-datepicker": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-9.1.0.tgz",
"integrity": "sha512-lOp+m5bc+ttgtB5MHEjwiVu4nlp4CvJLS/PG1OiOe5pmg9kV73pEqO8H0Geqvg2E8gjqTaL9eRhSe+ZpeKP3nA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.1.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz",
"integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
"@floating-ui/react": "^0.27.15",
"clsx": "^2.1.1",
"date-fns": "^4.1.0"
},
"peerDependencies": {
"react": "^19.1.1"
"date-fns-tz": "^3.0.0",
"react": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc",
"react-dom": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"date-fns-tz": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT"
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -6619,13 +6604,6 @@
"node": ">=v12.22.7"
}
},
"node_modules/scheduler": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
"devOptional": true,
"license": "MIT"
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -7130,6 +7108,12 @@
"url": "https://opencollective.com/synckit"
}
},
"node_modules/tabbable": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz",
"integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==",
"license": "MIT"
},
"node_modules/test-exclude": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
@@ -7246,7 +7230,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -7531,7 +7514,6 @@
"integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -7648,7 +7630,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},

View File

@@ -10,8 +10,10 @@
},
"dependencies": {
"cors": "^2.8.5",
"date-fns": "^4.1.0",
"express": "^5.1.0",
"framer-motion": "^12.23.22",
"node-cron": "^4.2.1"
"node-cron": "^4.2.1",
"react-datepicker": "^9.1.0"
}
}

View File

@@ -0,0 +1,53 @@
FROM node:20-alpine
WORKDIR /app
# Copy package files
COPY package.json package-lock.json ./
# Install all dependencies
RUN npm ci --legacy-peer-deps
# Copy source code
COPY . .
# Create vite.config.js with correct proxy settings
RUN cat > vite.config.js << 'VITECONFIG'
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
host: '0.0.0.0',
port: 90,
strictPort: true,
allowedHosts: ['mygta-dev.ensup-adm.net', 'localhost'],
proxy: {
'/api': {
target: 'http://backend:3004',
changeOrigin: true,
secure: false,
configure: (proxy, options) => {
proxy.on('error', (err, req, res) => {
console.log('Proxy error:', err);
});
proxy.on('proxyReq', (proxyReq, req, res) => {
console.log('Proxying:', req.method, req.url, '-> http://backend:3004');
});
}
}
}
}
});
VITECONFIG
EXPOSE 90
CMD ["npx", "vite", "--host", "0.0.0.0", "--port", "90"]

View File

@@ -0,0 +1,16 @@
# Variables
$PFX_PATH = "C:\Users\oimer\.aspnet\https\aspnetapp.pfx"
$PASSWORD = "tGTF2025"
Write-Host "Conversion du certificat via Docker..." -ForegroundColor Yellow
# Convertir en certificat (.crt)
docker run --rm -v C:\Users\oimer\.aspnet\https:/certs alpine/openssl pkcs12 -in /certs/aspnetapp.pfx -clcerts -nokeys -out /certs/aspnetapp.crt -passin pass:$PASSWORD
# Convertir en clé privée (.key)
docker run --rm -v C:\Users\oimer\.aspnet\https:/certs alpine/openssl pkcs12 -in /certs/aspnetapp.pfx -nocerts -nodes -out /certs/aspnetapp.key -passin pass:$PASSWORD
Write-Host "`n✓ Certificats convertis avec succès!" -ForegroundColor Green
Write-Host "Fichiers créés:" -ForegroundColor Cyan
Write-Host " - C:\Users\oimer\.aspnet\https\aspnetapp.crt" -ForegroundColor White
Write-Host " - C:\Users\oimer\.aspnet\https\aspnetapp.key" -ForegroundColor White

View File

@@ -15,11 +15,13 @@
"crypto": "^1.0.1",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"framer-motion": "^12.23.24",
"lucide-react": "^0.344.0",
"multer": "^2.0.2",
"mysql2": "^3.15.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-joyride": "^2.9.3",
"react-router-dom": "^7.7.1"
},
"devDependencies": {
@@ -1040,6 +1042,12 @@
"node": ">=12"
}
},
"node_modules/@gilbarbara/deep-equal": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@gilbarbara/deep-equal/-/deep-equal-0.3.1.tgz",
"integrity": "sha512-I7xWjLs2YSVMc5gGx1Z3ZG1lgFpITPndpi8Ku55GeEIKpACCPQNS/OTqQbxgTCfq0Ncvcc+CrFov96itVh6Qvw==",
"license": "MIT"
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -2000,6 +2008,16 @@
"undici-types": "~7.10.0"
}
},
"node_modules/@types/react": {
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.6.tgz",
"integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
},
"node_modules/@types/stack-utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
@@ -3301,6 +3319,12 @@
"node": ">=4"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@@ -3333,6 +3357,12 @@
}
}
},
"node_modules/deep-diff": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/deep-diff/-/deep-diff-1.0.2.tgz",
"integrity": "sha512-aWS3UIVH+NPGCD1kki+DCU9Dua032iSsO43LqQpcs4R3+dVv7tX0qBGjiVHJHjplsoUM2XRO/KB92glqc68awg==",
"license": "MIT"
},
"node_modules/deep-eql": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
@@ -3347,7 +3377,6 @@
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -3961,6 +3990,33 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/framer-motion": {
"version": "12.23.24",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz",
"integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.23.23",
"motion-utils": "^12.23.6",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
@@ -4405,6 +4461,12 @@
"node": ">=0.10.0"
}
},
"node_modules/is-lite": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-lite/-/is-lite-1.2.1.tgz",
"integrity": "sha512-pgF+L5bxC+10hLBgf6R2P4ZZUBOQIIacbdo8YvuCP8/JvsWxG7aZ9p10DYuLtifFci4l3VITphhMlMV4Y+urPw==",
"license": "MIT"
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@@ -5825,6 +5887,21 @@
"mkdirp": "bin/cmd.js"
}
},
"node_modules/motion-dom": {
"version": "12.23.23",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.23.6"
}
},
"node_modules/motion-utils": {
"version": "12.23.6",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -6342,6 +6419,17 @@
"node": ">=8"
}
},
"node_modules/popper.js": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
"integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==",
"deprecated": "You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/postcss": {
"version": "8.4.47",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz",
@@ -6524,6 +6612,23 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/prop-types/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -6660,6 +6765,55 @@
"react": "^18.3.1"
}
},
"node_modules/react-floater": {
"version": "0.7.9",
"resolved": "https://registry.npmjs.org/react-floater/-/react-floater-0.7.9.tgz",
"integrity": "sha512-NXqyp9o8FAXOATOEo0ZpyaQ2KPb4cmPMXGWkx377QtJkIXHlHRAGer7ai0r0C1kG5gf+KJ6Gy+gdNIiosvSicg==",
"license": "MIT",
"dependencies": {
"deepmerge": "^4.3.1",
"is-lite": "^0.8.2",
"popper.js": "^1.16.0",
"prop-types": "^15.8.1",
"tree-changes": "^0.9.1"
},
"peerDependencies": {
"react": "15 - 18",
"react-dom": "15 - 18"
}
},
"node_modules/react-floater/node_modules/@gilbarbara/deep-equal": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@gilbarbara/deep-equal/-/deep-equal-0.1.2.tgz",
"integrity": "sha512-jk+qzItoEb0D0xSSmrKDDzf9sheQj/BAPxlgNxgmOaA3mxpUa6ndJLYGZKsJnIVEQSD8zcTbyILz7I0HcnBCRA==",
"license": "MIT"
},
"node_modules/react-floater/node_modules/is-lite": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/is-lite/-/is-lite-0.8.2.tgz",
"integrity": "sha512-JZfH47qTsslwaAsqbMI3Q6HNNjUuq6Cmzzww50TdP5Esb6e1y2sK2UAaZZuzfAzpoI2AkxoPQapZdlDuP6Vlsw==",
"license": "MIT"
},
"node_modules/react-floater/node_modules/tree-changes": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/tree-changes/-/tree-changes-0.9.3.tgz",
"integrity": "sha512-vvvS+O6kEeGRzMglTKbc19ltLWNtmNt1cpBoSYLj/iEcPVvpJasemKOlxBrmZaCtDJoF+4bwv3m01UKYi8mukQ==",
"license": "MIT",
"dependencies": {
"@gilbarbara/deep-equal": "^0.1.1",
"is-lite": "^0.8.2"
}
},
"node_modules/react-innertext": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/react-innertext/-/react-innertext-1.1.5.tgz",
"integrity": "sha512-PWAqdqhxhHIv80dT9znP2KvS+hfkbRovFp4zFYHFFlOoQLRiawIic81gKb3U1wEyJZgMwgs3JoLtwryASRWP3Q==",
"license": "MIT",
"peerDependencies": {
"@types/react": ">=0.0.0 <=99",
"react": ">=0.0.0 <=99"
}
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@@ -6667,6 +6821,47 @@
"dev": true,
"license": "MIT"
},
"node_modules/react-joyride": {
"version": "2.9.3",
"resolved": "https://registry.npmjs.org/react-joyride/-/react-joyride-2.9.3.tgz",
"integrity": "sha512-1+Mg34XK5zaqJ63eeBhqdbk7dlGCFp36FXwsEvgpjqrtyywX2C6h9vr3jgxP0bGHCw8Ilsp/nRDzNVq6HJ3rNw==",
"license": "MIT",
"dependencies": {
"@gilbarbara/deep-equal": "^0.3.1",
"deep-diff": "^1.0.2",
"deepmerge": "^4.3.1",
"is-lite": "^1.2.1",
"react-floater": "^0.7.9",
"react-innertext": "^1.1.5",
"react-is": "^16.13.1",
"scroll": "^3.0.1",
"scrollparent": "^2.1.0",
"tree-changes": "^0.11.2",
"type-fest": "^4.27.0"
},
"peerDependencies": {
"react": "15 - 18",
"react-dom": "15 - 18"
}
},
"node_modules/react-joyride/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-joyride/node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
"integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/react-refresh": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
@@ -6931,6 +7126,18 @@
"loose-envify": "^1.1.0"
}
},
"node_modules/scroll": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/scroll/-/scroll-3.0.1.tgz",
"integrity": "sha512-pz7y517OVls1maEzlirKO5nPYle9AXsFzTMNJrRGmT951mzpIBy7sNHOg5o/0MQd/NqliCiWnAi0kZneMPFLcg==",
"license": "MIT"
},
"node_modules/scrollparent": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/scrollparent/-/scrollparent-2.1.0.tgz",
"integrity": "sha512-bnnvJL28/Rtz/kz2+4wpBjHzWoEzXhVg/TE8BeVGJHUqE8THNIRnDxDWMktwM+qahvlRdvlLdsQfYe+cuqfZeA==",
"license": "ISC"
},
"node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -7694,6 +7901,16 @@
"node": ">=0.6"
}
},
"node_modules/tree-changes": {
"version": "0.11.3",
"resolved": "https://registry.npmjs.org/tree-changes/-/tree-changes-0.11.3.tgz",
"integrity": "sha512-r14mvDZ6tqz8PRQmlFKjhUVngu4VZ9d92ON3tp0EGpFBE6PAHOq8Bx8m8ahbNoGE3uI/npjYcJiqVydyOiYXag==",
"license": "MIT",
"dependencies": {
"@gilbarbara/deep-equal": "^0.3.1",
"is-lite": "^1.2.1"
}
},
"node_modules/ts-interface-checker": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
@@ -7704,9 +7921,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
"license": "0BSD"
},
"node_modules/type-detect": {
"version": "4.0.8",

View File

@@ -16,11 +16,13 @@
"crypto": "^1.0.1",
"dotenv": "^17.2.3",
"express": "^5.1.0",
"framer-motion": "^12.23.24",
"lucide-react": "^0.344.0",
"multer": "^2.0.2",
"mysql2": "^3.15.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-joyride": "^2.9.3",
"react-router-dom": "^7.7.1"
},
"devDependencies": {

View File

@@ -0,0 +1,24 @@
FROM node:18-alpine
# Install required tools
RUN apk add --no-cache curl mysql-client python3 make g++
WORKDIR /app
# Copy package files first for better caching
COPY package*.json ./
# Install dependencies
RUN npm install --production
# Copy application code
COPY . .
# Create uploads directory
RUN mkdir -p /app/uploads/medical
# Expose the port
EXPOSE 3004
# Start the server
CMD ["node", "server.js"]

View File

@@ -0,0 +1,27 @@
{
"name": "gta-backend",
"version": "1.0.0",
"description": "GTA Backend API",
"main": "server.js",
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"express": "^4.18.2",
"mssql": "^10.0.0",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"multer": "^1.4.5-lts.1",
"@microsoft/microsoft-graph-client": "^3.0.7",
"@azure/identity": "^4.0.0",
"body-parser": "^1.20.2",
"axios": "^1.6.0",
"node-cron": "^3.0.3"
},
"engines": {
"node": ">=18.0.0"
}
}

View File

@@ -0,0 +1,570 @@
import express from 'express';
import cors from 'cors';
import sql from 'mssql';
import axios from 'axios';
const app = express();
const PORT = 3000;
app.use(cors({ origin: '*' }));
app.use(express.json());
// Configuration Azure AD
const AZURE_CONFIG = {
tenantId: '9840a2a0-6ae1-4688-b03d-d2ec291be0f9',
clientId: '4bb4cc24-bac3-427c-b02c-5d14fc67b561',
clientSecret: 'gvf8Q~545Bafn8yYsgjW~QG_P1lpzaRe6gJNgb2t',
groupId: 'c1ea877c-6bca-4f47-bfad-f223640813a0'
};
// Configuration SQL Server
const dbConfig = {
server: '192.168.0.3',
user: 'gta_app',
password: 'GTA2025!Secure',
database: 'GTA',
port: 1433,
options: {
encrypt: true,
trustServerCertificate: true,
enableArithAbort: true,
connectTimeout: 60000,
requestTimeout: 60000
},
pool: {
max: 10,
min: 0,
idleTimeoutMillis: 30000
}
};
// Créer le pool de connexions
const pool = new sql.ConnectionPool(dbConfig);
// Connexion au démarrage
pool.connect()
.then(() => {
console.log('✅ Connecté à SQL Server');
console.log(` Base: ${dbConfig.database}@${dbConfig.server}`);
})
.catch(err => {
console.error('❌ Erreur connexion SQL Server:', err.message);
});
// ========================================
// WRAPPER POUR COMPATIBILITÉ (style MySQL)
// ========================================
pool.query = async function (queryText, params = []) {
if (!pool.connected) {
await pool.connect();
}
const request = pool.request();
// Ajouter les paramètres
params.forEach((value, index) => {
request.input(`param${index}`, value);
});
// Remplacer ? par @param0, @param1, etc.
let parameterizedQuery = queryText;
let paramIndex = 0;
parameterizedQuery = parameterizedQuery.replace(/\?/g, () => `@param${paramIndex++}`);
// Conversion LIMIT → TOP
parameterizedQuery = parameterizedQuery.replace(
/LIMIT\s+(\d+)/gi,
(match, limit) => {
return parameterizedQuery.includes('SELECT')
? parameterizedQuery.replace(/SELECT/i, `SELECT TOP ${limit}`)
: '';
}
);
const result = await request.query(parameterizedQuery);
return result.recordset || [];
};
// ========================================
// 🔑 FONCTION TOKEN MICROSOFT GRAPH
// ========================================
async function getGraphToken() {
try {
const params = new URLSearchParams({
grant_type: 'client_credentials',
client_id: AZURE_CONFIG.clientId,
client_secret: AZURE_CONFIG.clientSecret,
scope: 'https://graph.microsoft.com/.default'
});
const response = await axios.post(
`https://login.microsoftonline.com/${AZURE_CONFIG.tenantId}/oauth2/v2.0/token`,
params.toString(),
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
);
return response.data.access_token;
} catch (error) {
console.error('❌ Erreur obtention token:', error.message);
return null;
}
}
// ========================================
// 🔄 FONCTION SYNCHRONISATION ENTRA ID
// ========================================
async function syncEntraIdUsers() {
const syncResults = {
processed: 0,
inserted: 0,
updated: 0,
deactivated: 0,
errors: []
};
try {
console.log('\n🔄 === DÉBUT SYNCHRONISATION ENTRA ID ===');
// 1⃣ Obtenir le token
const accessToken = await getGraphToken();
if (!accessToken) {
console.error('❌ Impossible d\'obtenir le token');
return syncResults;
}
console.log('✅ Token obtenu');
// 2⃣ Récupérer le groupe
const groupResponse = await axios.get(
`https://graph.microsoft.com/v1.0/groups/${AZURE_CONFIG.groupId}?$select=id,displayName`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
);
const groupName = groupResponse.data.displayName;
console.log(`📋 Groupe : ${groupName}`);
// 3⃣ Récupérer tous les membres avec pagination
let allAzureMembers = [];
let nextLink = `https://graph.microsoft.com/v1.0/groups/${AZURE_CONFIG.groupId}/members?$select=id,givenName,surname,mail,department,jobTitle,officeLocation,accountEnabled&$top=999`;
console.log('📥 Récupération des membres...');
while (nextLink) {
const membersResponse = await axios.get(nextLink, {
headers: { Authorization: `Bearer ${accessToken}` }
});
allAzureMembers = allAzureMembers.concat(membersResponse.data.value);
nextLink = membersResponse.data['@odata.nextLink'];
if (nextLink) {
console.log(` 📄 ${allAzureMembers.length} membres récupérés...`);
}
}
console.log(`${allAzureMembers.length} membres trouvés`);
// 4⃣ Filtrer les membres valides
const validMembers = allAzureMembers.filter(m => {
if (!m.mail || m.mail.trim() === '') return false;
if (m.accountEnabled === false) return false;
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(m.mail);
});
console.log(`${validMembers.length} membres valides`);
// 5⃣ Traitement avec transaction
const transaction = new sql.Transaction(pool);
await transaction.begin();
try {
const azureEmails = new Set();
validMembers.forEach(m => {
azureEmails.add(m.mail.toLowerCase().trim());
});
console.log('\n📝 Traitement des utilisateurs...');
// 6⃣ Pour chaque membre
for (const m of validMembers) {
try {
const emailClean = m.mail.toLowerCase().trim();
syncResults.processed++;
// Vérifier existence
const request = new sql.Request(transaction);
request.input('email', sql.NVarChar, emailClean);
const result = await request.query(`
SELECT id, email, entraUserId, actif
FROM CollaborateurAD
WHERE LOWER(email) = LOWER(@email)
`);
if (result.recordset.length > 0) {
// MISE À JOUR
const updateRequest = new sql.Request(transaction);
updateRequest.input('entraUserId', sql.NVarChar, m.id);
updateRequest.input('prenom', sql.NVarChar, m.givenName || '');
updateRequest.input('nom', sql.NVarChar, m.surname || '');
updateRequest.input('departement', sql.NVarChar, m.department || '');
updateRequest.input('fonction', sql.NVarChar, m.jobTitle || '');
updateRequest.input('campus', sql.NVarChar, m.officeLocation || '');
updateRequest.input('email', sql.NVarChar, emailClean);
await updateRequest.query(`
UPDATE CollaborateurAD
SET
entraUserId = @entraUserId,
prenom = @prenom,
nom = @nom,
departement = @departement,
fonction = @fonction,
campus = @campus,
actif = 1
WHERE LOWER(email) = LOWER(@email)
`);
syncResults.updated++;
console.log(` ✓ Mis à jour : ${emailClean}`);
} else {
// INSERTION
const insertRequest = new sql.Request(transaction);
insertRequest.input('entraUserId', sql.NVarChar, m.id);
insertRequest.input('prenom', sql.NVarChar, m.givenName || '');
insertRequest.input('nom', sql.NVarChar, m.surname || '');
insertRequest.input('email', sql.NVarChar, emailClean);
insertRequest.input('departement', sql.NVarChar, m.department || '');
insertRequest.input('fonction', sql.NVarChar, m.jobTitle || '');
insertRequest.input('campus', sql.NVarChar, m.officeLocation || '');
await insertRequest.query(`
INSERT INTO CollaborateurAD
(entraUserId, prenom, nom, email, departement, fonction, campus, role, SocieteId, actif, dateCreation, TypeContrat)
VALUES (@entraUserId, @prenom, @nom, @email, @departement, @fonction, @campus, 'Collaborateur', 1, 1, GETDATE(), '37h')
`);
syncResults.inserted++;
console.log(` ✓ Créé : ${emailClean}`);
}
} catch (userError) {
syncResults.errors.push({
email: m.mail,
error: userError.message
});
console.error(` ❌ Erreur ${m.mail}:`, userError.message);
}
}
// 7⃣ DÉSACTIVATION des comptes absents
console.log('\n🔍 Désactivation des comptes obsolètes...');
if (azureEmails.size > 0) {
const activeEmailsList = Array.from(azureEmails).map(e => `'${e}'`).join(',');
const deactivateRequest = new sql.Request(transaction);
const deactivateResult = await deactivateRequest.query(`
UPDATE CollaborateurAD
SET actif = 0
WHERE
email IS NOT NULL
AND email != ''
AND LOWER(email) NOT IN (${activeEmailsList})
AND actif = 1
`);
syncResults.deactivated = deactivateResult.rowsAffected[0];
console.log(`${syncResults.deactivated} compte(s) désactivé(s)`);
}
await transaction.commit();
console.log('\n📊 === RÉSUMÉ ===');
console.log(` Groupe: ${groupName}`);
console.log(` Total Entra: ${allAzureMembers.length}`);
console.log(` Valides: ${validMembers.length}`);
console.log(` Traités: ${syncResults.processed}`);
console.log(` Créés: ${syncResults.inserted}`);
console.log(` Mis à jour: ${syncResults.updated}`);
console.log(` Désactivés: ${syncResults.deactivated}`);
console.log(` Erreurs: ${syncResults.errors.length}`);
} catch (error) {
await transaction.rollback();
throw error;
}
} catch (error) {
console.error('\n❌ ERREUR SYNCHRONISATION:', error.message);
}
return syncResults;
}
// ========================================
// 📡 ROUTES API
// ========================================
// Route test connexion
app.get('/api/db-status', async (req, res) => {
try {
const result = await pool.query('SELECT COUNT(*) AS count FROM CollaborateurAD', []);
const collaboratorCount = result[0]?.count || 0;
res.json({
success: true,
message: 'Connexion SQL Server OK',
collaboratorCount,
});
} catch (error) {
console.error('Erreur connexion:', error);
res.status(500).json({
success: false,
message: 'Erreur connexion base',
error: error.message,
});
}
});
// Route sync unitaire
app.post('/api/initial-sync', async (req, res) => {
try {
const email = (req.body.mail || req.body.userPrincipalName)?.toLowerCase().trim();
const entraUserId = req.body.id;
if (!email) {
return res.json({ success: false, message: 'Email manquant' });
}
console.log(`\n🔄 Sync utilisateur : ${email}`);
const transaction = new sql.Transaction(pool);
await transaction.begin();
try {
// Vérifier existence
const checkRequest = new sql.Request(transaction);
checkRequest.input('email', sql.NVarChar, email);
const existing = await checkRequest.query(`
SELECT id, email, actif
FROM CollaborateurAD
WHERE LOWER(email) = LOWER(@email)
`);
if (existing.recordset.length > 0) {
// UPDATE
const updateRequest = new sql.Request(transaction);
updateRequest.input('collaborateurADId', sql.NVarChar, entraUserId);
updateRequest.input('prenom', sql.NVarChar, req.body.givenName || '');
updateRequest.input('nom', sql.NVarChar, req.body.surname || '');
updateRequest.input('departement', sql.NVarChar, req.body.department || '');
updateRequest.input('fonction', sql.NVarChar, req.body.jobTitle || '');
updateRequest.input('campus', sql.NVarChar, req.body.officeLocation || '');
updateRequest.input('email', sql.NVarChar, email);
updateRequest.input('dateMaj', sql.DateTime, new Date());
await updateRequest.query(`
UPDATE CollaborateurAD
SET
CollaborateurADId = @collaborateurADId,
prenom = @prenom,
nom = @nom,
departement = @departement,
fonction = @fonction,
campus = @campus,
actif = 1,
dateMiseAJour = @dateMaj
WHERE LOWER(email) = LOWER(@email)
`);
console.log(` ✅ Mis à jour : ${email}`);
} else {
// INSERT
const insertRequest = new sql.Request(transaction);
insertRequest.input('collaborateurADId', sql.NVarChar, entraUserId);
insertRequest.input('prenom', sql.NVarChar, req.body.givenName || '');
insertRequest.input('nom', sql.NVarChar, req.body.surname || '');
insertRequest.input('email', sql.NVarChar, email);
insertRequest.input('departement', sql.NVarChar, req.body.department || '');
insertRequest.input('fonction', sql.NVarChar, req.body.jobTitle || '');
insertRequest.input('campus', sql.NVarChar, req.body.officeLocation || '');
insertRequest.input('dateCreation', sql.DateTime, new Date());
insertRequest.input('dateMaj', sql.DateTime, new Date());
await insertRequest.query(`
INSERT INTO CollaborateurAD
(CollaborateurADId, prenom, nom, email, departement, fonction, campus, service, societe, actif, dateCreation, dateMiseAJour)
VALUES (@collaborateurADId, @prenom, @nom, @email, @departement, @fonction, @campus, NULL, NULL, 1, @dateCreation, @dateMaj)
`);
console.log(` ✅ Créé : ${email}`);
}
// Récupérer données
const getUserRequest = new sql.Request(transaction);
getUserRequest.input('email', sql.NVarChar, email);
const userData = await getUserRequest.query(`
SELECT id as localUserId, email, prenom, nom, fonction, departement
FROM CollaborateurAD
WHERE LOWER(email) = LOWER(@email)
`);
await transaction.commit();
if (userData.recordset.length === 0) {
throw new Error('Utilisateur introuvable après sync');
}
res.json({
success: true,
message: 'Sync réussie',
localUserId: userData.recordset[0].localUserId,
user: userData.recordset[0]
});
} catch (error) {
await transaction.rollback();
throw error;
}
} catch (error) {
console.error('❌ Erreur sync:', error);
res.json({
success: false,
message: error.message
});
}
});
// Route check groups
app.post('/api/check-user-groups', async (req, res) => {
try {
const { userPrincipalName } = req.body;
if (!userPrincipalName) {
return res.json({ authorized: false, message: 'Email manquant' });
}
const users = await pool.query(
'SELECT id, email, prenom, nom, actif FROM CollaborateurAD WHERE email = ?',
[userPrincipalName]
);
if (users.length > 0) {
const user = users[0];
if (user.actif === 0) {
return res.json({ authorized: false, message: 'Compte désactivé' });
}
return res.json({
authorized: true,
localUserId: user.id,
user: user
});
}
res.json({
authorized: true,
message: 'Sera créé au login'
});
} catch (error) {
console.error('❌ Erreur check:', error);
res.json({ authorized: false, error: error.message });
}
});
// Route sync complète manuelle
app.post('/api/sync-all', async (req, res) => {
try {
console.log('🚀 Sync complète manuelle...');
const results = await
IdUsers();
res.json({
success: true,
message: 'Sync terminée',
stats: results
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message
});
}
});
// Route diagnostic
app.get('/api/diagnostic-sync', async (req, res) => {
try {
const totalDB = await pool.query(
'SELECT COUNT(*) as total, SUM(CASE WHEN actif = 1 THEN 1 ELSE 0 END) as actifs FROM CollaborateurAD',
[]
);
const sansEmail = await pool.query(
'SELECT COUNT(*) as total FROM CollaborateurAD WHERE email IS NULL OR email = \'\'',
[]
);
const derniers = await pool.query(
'SELECT TOP 10 id, prenom, nom, email, CollaborateurADId, actif FROM CollaborateurAD ORDER BY id DESC',
[]
);
// Test Entra
let entraStatus = { connected: false };
try {
const token = await getGraphToken();
if (token) {
const groupResponse = await axios.get(
`https://graph.microsoft.com/v1.0/groups/${AZURE_CONFIG.groupId}?$select=id,displayName`,
{ headers: { Authorization: `Bearer ${token}` } }
);
entraStatus = {
connected: true,
groupName: groupResponse.data.displayName
};
}
} catch (err) {
entraStatus.error = err.message;
}
res.json({
success: true,
database: {
total: totalDB[0]?.total || 0,
actifs: totalDB[0]?.actifs || 0,
sansEmail: sansEmail[0]?.total || 0
},
entraId: entraStatus,
derniers_utilisateurs: derniers
});
} catch (error) {
res.status(500).json({
success: false,
error: error.message
});
}
});
// ========================================
// 🚀 DÉMARRAGE
// ========================================
app.listen(PORT, "0.0.0.0", async () => {
console.log("✅ ==========================================");
console.log(" SERVEUR TEST DÉMARRÉ");
console.log(" Port:", PORT);
console.log(` Base SQL Server: ${dbConfig.database}@${dbConfig.server}`);
console.log("==========================================");
// Sync auto après 5 secondes
setTimeout(async () => {
console.log("\n🚀 Sync Entra ID automatique...");
await syncEntraIdUsers();
}, 5000);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
// hooks/useSSENotifications.js
import { useEffect, useCallback } from 'react';
export const useSSENotifications = (token, collaborateurId, onEventReceived) => {
useEffect(() => {
if (!token || !collaborateurId) return;
const eventSource = new EventSource(
`/api/events?token=${encodeURIComponent(token)}`
);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('📨 SSE reçu:', data);
// Log spécifique pour les récupérations
if (data.type === 'demande-validated' && data.typeConge === 'Récupération') {
console.log('🎨 Couleur reçue:', data.couleurHex);
}
onEventReceived(data);
} catch (error) {
console.error('❌ Erreur parsing SSE:', error);
}
};
eventSource.onerror = (error) => {
console.error('❌ Erreur SSE:', error);
eventSource.close();
};
return () => {
eventSource.close();
};
}, [token, collaborateurId, onEventReceived]);
};

View File

@@ -1,4 +1,4 @@
// webhook-utils.js (VERSION ES MODULES)
// webhook-utils.js (VERSION ES MODULES - CORRIGÉE)
// Pour projets avec "type": "module" dans package.json
import axios from 'axios';
@@ -65,6 +65,7 @@ class WebhookManager {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
console.log(`📤 Envoi webhook: ${eventType} vers ${targetUrl} (tentative ${attempt}/${retries})`);
console.log(` Données:`, JSON.stringify(data, null, 2));
const response = await axios.post(
`${targetUrl}/api/webhook/receive`,

View File

@@ -1,14 +0,0 @@
# Utilise une image PHP avec Apache et la version 8.1
FROM php:8.1-apache
# Installe l'extension mysqli pour te connecter à la base de données MySQL
RUN docker-php-ext-install mysqli && docker-php-ext-enable mysqli
# Active le module de réécriture d'URL d'Apache (souvent utile)
RUN a2enmod rewrite
# Copie tous les fichiers du back-end dans le dossier de travail d'Apache
COPY . /var/www/html/
# Expose le port 80 (par défaut pour un serveur web)
EXPOSE 80

View File

@@ -1,147 +0,0 @@
<?php
header("Access-Control-Allow-Origin: *");
header("Content-Type: application/json");
header("Access-Control-Allow-Headers: Content-Type, Authorization");
// Connexion DB
$host = "192.168.0.4";
$dbname = "DemandeConge";
$username = "wpuser";
$password = "-2b/)ru5/Bi8P[7_";
$conn = new mysqli($host, $username, $password, $dbname);
if ($conn->connect_error) {
die(json_encode(["authorized" => false, "message" => "Erreur DB: " . $conn->connect_error]));
}
// --- ID du groupe cible (Ensup-Groupe) ---
$groupId = "c1ea877c-6bca-4f47-bfad-f223640813a0";
// Récupération des données POST
$data = json_decode(file_get_contents("php://input"), true);
$userPrincipalName = $data["userPrincipalName"] ?? "";
// Récupération du token dans les headers
$headers = getallheaders();
$accessToken = isset($headers['Authorization'])
? str_replace("Bearer ", "", $headers['Authorization'])
: "";
if (!$userPrincipalName || !$accessToken) {
echo json_encode(["authorized" => false, "message" => "Email ou token manquant"]);
exit;
}
/**
* Fonction générique pour appeler Graph API
*/
function callGraph($url, $accessToken, $method = "GET", $body = null) {
$ch = curl_init($url);
$headers = ["Authorization: Bearer $accessToken"];
if ($method === "POST") {
$headers[] = "Content-Type: application/json";
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
return null;
}
return json_decode($response, true);
}
/**
* Vérifier si utilisateur appartient à un groupe
*/
function isUserInGroup($userId, $groupId, $accessToken) {
$url = "https://graph.microsoft.com/v1.0/users/$userId/checkMemberGroups";
$data = json_encode(["groupIds" => [$groupId]]);
$result = callGraph($url, $accessToken, "POST", $data);
return $result && isset($result["value"]) && in_array($groupId, $result["value"]);
}
// 🔹 1. Vérifier si utilisateur existe déjà en DB
$stmt = $conn->prepare("SELECT id, entraUserId, prenom, nom, email, service, role FROM CollaborateurAD WHERE email = ? LIMIT 1");
$stmt->bind_param("s", $userPrincipalName);
$stmt->execute();
$result = $stmt->get_result();
$user = $result->fetch_assoc();
$stmt->close();
if ($user) {
echo json_encode([
"authorized" => true,
"role" => $user["role"],
"groups" => [$user["role"]],
"localUserId" => (int)$user["id"], // 🔹 ajout important
"user" => $user
]);
$conn->close();
exit;
}
// 🔹 2. Sinon → chercher lutilisateur dans Microsoft Graph
$userGraph = callGraph("https://graph.microsoft.com/v1.0/users/$userPrincipalName?\$select=id,displayName,givenName,surname,mail,department,jobTitle", $accessToken);
if (!$userGraph) {
echo json_encode([
"authorized" => false,
"message" => "Utilisateur introuvable dans Entra ou token invalide"
]);
$conn->close();
exit;
}
// 🔹 3. Vérifier appartenance au groupe Ensup-Groupe
$isInTargetGroup = isUserInGroup($userGraph["id"], $groupId, $accessToken);
if (!$isInTargetGroup) {
echo json_encode([
"authorized" => false,
"message" => "Utilisateur non autorisé : il n'appartient pas au groupe requis"
]);
$conn->close();
exit;
}
// 🔹 4. Insérer dans la base si nouveau
$entraUserId = $userGraph["id"];
$prenom = $userGraph["givenName"] ?? "";
$nom = $userGraph["surname"] ?? "";
$email = $userGraph["mail"] ?? $userPrincipalName;
$service = $userGraph["department"] ?? "";
$role = "Collaborateur"; // rôle par défaut
$stmt = $conn->prepare("INSERT INTO CollaborateurAD (entraUserId, prenom, nom, email, service, role)
VALUES (?, ?, ?, ?, ?, ?)");
$stmt->bind_param("ssssss", $entraUserId, $prenom, $nom, $email, $service, $role);
$stmt->execute();
$newUserId = $stmt->insert_id;
$stmt->close();
// 🔹 5. Réponse finale
echo json_encode([
"authorized" => true,
"role" => $role,
"groups" => [$role],
"localUserId" => (int)$newUserId,
"user" => [
"id" => $newUserId,
"entraUserId" => $entraUserId,
"prenom" => $prenom,
"nom" => $nom,
"email" => $email,
"service" => $service,
"role" => $role
]
]);
$conn->close();
?>

View File

@@ -1,20 +0,0 @@
<?php
// Informations de connexion
$host = "192.168.0.4";
$dbname = "DemandeConge";
$username = "wpuser";
$password = "-2b/)ru5/Bi8P[7_";
// Connexion MySQLi
$conn = new mysqli($host, $username, $password, $dbname);
// Vérification de la connexion
if ($conn->connect_error) {
die(json_encode([
"success" => false,
"message" => "Erreur DB: " . $conn->connect_error
]));
}
// Important : définir lencodage en UTF-8 (pour accents, etc.)
$conn->set_charset("utf8mb4");

View File

@@ -1,103 +0,0 @@
<?php
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit();
}
header("Content-Type: application/json");
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
// Connexion DB
$host = "192.168.0.4";
$dbname = "DemandeConge";
$username = "wpuser";
$password = "-2b/)ru5/Bi8P[7_";
$conn = new mysqli($host, $username, $password, $dbname);
if ($conn->connect_error) {
error_log("Erreur connexion DB: " . $conn->connect_error);
echo json_encode(["success" => false, "message" => "Erreur de connexion DB"]);
exit();
}
// Récupération ID manager
$managerId = $_GET['SuperieurId'] ?? null;
if (!$managerId) {
echo json_encode(["success" => false, "message" => "Paramètre SuperieurId manquant"]);
exit();
}
$sql = "
SELECT
dc.Id,
dc.DateDebut,
dc.DateFin,
dc.Statut,
dc.DateDemande,
dc.Commentaire,
dc.DocumentJoint,
dc.CollaborateurADId AS employee_id,
CONCAT(ca.Prenom, ' ', ca.Nom) as employee_name,
ca.Email as employee_email,
tc.Nom as type
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
JOIN TypeConge tc ON dc.TypeCongeId = tc.Id
JOIN HierarchieValidationAD hv ON hv.CollaborateurId = ca.id
WHERE hv.SuperieurId = ?
ORDER BY dc.DateDemande DESC
";
$stmt = $conn->prepare($sql);
$stmt->bind_param("i", $managerId);
$stmt->execute();
$result = $stmt->get_result();
$requests = [];
while ($row = $result->fetch_assoc()) {
$startDate = new DateTime($row['DateDebut']);
$endDate = new DateTime($row['DateFin']);
$submittedDate = new DateTime($row['DateDemande']);
$days = 0;
$tmp = clone $startDate;
while ($tmp <= $endDate) {
if ((int)$tmp->format('N') < 6) $days++;
$tmp->modify('+1 day');
}
$requests[] = [
"id" => (int)$row['Id'],
"employee_id" => (int)$row['employee_id'],
"employee_name" => $row['employee_name'],
"employee_email" => $row['employee_email'],
"type" => $row['type'],
"start_date" => $row['DateDebut'],
"end_date" => $row['DateFin'],
"date_display" => $row['DateDebut'] === $row['DateFin']
? $startDate->format('d/m/Y')
: $startDate->format('d/m/Y') . ' - ' . $endDate->format('d/m/Y'),
"days" => $days,
"status" => $row['Statut'],
"reason" => $row['Commentaire'] ?: '',
"file" => $row['DocumentJoint'] ?: null,
"submitted_at" => $row['DateDemande'],
"submitted_display" => $submittedDate->format('d/m/Y')
];
}
echo json_encode([
"success" => true,
"requests" => $requests
]);
$stmt->close();
$conn->close();
?>

View File

@@ -1,52 +0,0 @@
<?php
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type, Authorization");
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
http_response_code(200);
exit();
}
header("Content-Type: application/json");
$host = "192.168.0.4";
$dbname = "DemandeConge";
$username = "wpuser";
$password = "-2b/)ru5/Bi8P[7_";
$conn = new mysqli($host, $username, $password, $dbname);
if ($conn->connect_error) {
die(json_encode(["success" => false, "message" => "Erreur DB : " . $conn->connect_error]));
}
// Récupérer l'ID
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
if ($id <= 0) {
echo json_encode(["success" => false, "message" => "ID collaborateur invalide"]);
exit;
}
try {
$stmt = $conn->prepare("
SELECT id, Nom, Prenom, Email
FROM CollaborateurAD
WHERE id = ?
");
$stmt->bind_param("i", $id);
$stmt->execute();
$result = $stmt->get_result();
$employee = $result->fetch_assoc();
if ($employee) {
echo json_encode(["success" => true, "employee" => $employee]);
} else {
echo json_encode(["success" => false, "message" => "Collaborateur non trouvé"]);
}
} catch (Exception $e) {
echo json_encode(["success" => false, "message" => "Erreur DB: " . $e->getMessage()]);
}
$conn->close();
?>

View File

@@ -1,66 +0,0 @@
<?php
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type, Authorization");
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
http_response_code(200);
exit();
}
header("Content-Type: application/json");
$host = "192.168.0.4";
$dbname = "DemandeConge";
$username = "wpuser";
$password = "-2b/)ru5/Bi8P[7_";
$conn = new mysqli($host, $username, $password, $dbname);
if ($conn->connect_error) {
die(json_encode(["success" => false, "message" => "Erreur DB : " . $conn->connect_error]));
}
// Récupérer l'ID
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
if ($id <= 0) {
echo json_encode(["success" => false, "message" => "ID employé invalide"]);
exit;
}
try {
$sql = "SELECT Id, TypeCongeId, NombreJours, DateDebut, DateFin, Statut
FROM DemandeConge
WHERE EmployeeId = ?
ORDER BY DateDemande DESC";
$stmt = $conn->prepare($sql);
$stmt->bind_param("i", $id);
$stmt->execute();
$result = $stmt->get_result();
// Mapping des types de congés
$typeNames = [
1 => "Congé payé",
2 => "RTT",
3 => "Maladie"
];
$requests = [];
while ($row = $result->fetch_assoc()) {
$row['type'] = $typeNames[$row['TypeCongeId']] ?? "Autre";
$row['days'] = (float)$row['NombreJours'];
// Formater jours : 2j ou 1.5j
$row['days_display'] = ((int)$row['days'] == $row['days'] ? (int)$row['days'] : $row['days']) . "j";
$row['date_display'] = date("d/m/Y", strtotime($row['DateDebut']))
. " - "
. date("d/m/Y", strtotime($row['DateFin']));
$requests[] = $row;
}
echo json_encode(["success" => true, "requests" => $requests]);
} catch (Exception $e) {
echo json_encode(["success" => false, "message" => "Erreur DB: " . $e->getMessage()]);
}
$conn->close();
?>

View File

@@ -1,163 +0,0 @@
<?php
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit();
}
header("Content-Type: application/json");
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) {
error_log("Erreur DB: " . $conn->connect_error);
echo json_encode(['success' => false, 'message' => 'Erreur de connexion à la base de données']);
exit;
}
$today = new DateTime();
$yearCurrent = (int)$today->format('Y');
$yearNMinus1 = $yearCurrent - 1;
function getTypeId($conn, $nom) {
$stmt = $conn->prepare("SELECT Id FROM TypeConge WHERE Nom=?");
$stmt->bind_param("s", $nom);
$stmt->execute();
$result = $stmt->get_result();
$id = null;
if ($row = $result->fetch_assoc()) {
$id = (int)$row['Id'];
}
$stmt->close();
error_log("TypeConge '$nom' => Id $id");
return $id;
}
$cpTypeId = getTypeId($conn, 'Congé payé');
$rttTypeId = getTypeId($conn, 'RTT');
$soldeReportInitial_CP = 0.0;
$soldeReportInitial_RTT = 0.0;
$collaborateursResult = $conn->query("SELECT id FROM CollaborateurAD");
if (!$collaborateursResult) {
error_log("Erreur récupération collaborateurs : ".$conn->error);
echo json_encode(['success' => false, 'message' => 'Erreur récupération collaborateurs']);
exit;
}
while ($collab = $collaborateursResult->fetch_assoc()) {
$collabId = (int)$collab['id'];
if ($cpTypeId !== null) {
$existsStmt = $conn->prepare("SELECT Id FROM CompteurConges WHERE CollaborateurADId=? AND TypeCongeId=? AND Annee=?");
$existsStmt->bind_param("iii", $collabId, $cpTypeId, $yearNMinus1);
$existsStmt->execute();
$existsStmt->store_result();
if ($existsStmt->num_rows === 0) {
$insertStmt = $conn->prepare("INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Solde, Total, SoldeReporte) VALUES (?, ?, ?, ?, ?, ?)");
$insertStmt->bind_param("iiiddd", $collabId, $cpTypeId, $yearNMinus1, $soldeReportInitial_CP, $soldeReportInitial_CP, $soldeReportInitial_CP);
if (!$insertStmt->execute()) {
error_log("Erreur insertion CP N-1 collaborateur $collabId : ".$insertStmt->error);
}
$insertStmt->close();
}
$existsStmt->close();
}
if ($rttTypeId !== null) {
$existsStmt = $conn->prepare("SELECT Id FROM CompteurConges WHERE CollaborateurADId=? AND TypeCongeId=? AND Annee=?");
$existsStmt->bind_param("iii", $collabId, $rttTypeId, $yearNMinus1);
$existsStmt->execute();
$existsStmt->store_result();
if ($existsStmt->num_rows === 0) {
$insertStmt = $conn->prepare("INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Solde, Total, SoldeReporte) VALUES (?, ?, ?, ?, ?, ?)");
$insertStmt->bind_param("iiiddd", $collabId, $rttTypeId, $yearNMinus1, $soldeReportInitial_RTT, $soldeReportInitial_RTT, $soldeReportInitial_RTT);
if (!$insertStmt->execute()) {
error_log("Erreur insertion RTT N-1 collaborateur $collabId : ".$insertStmt->error);
}
$insertStmt->close();
}
$existsStmt->close();
}
}
$cpStart = new DateTime("$yearCurrent-06-01");
$cpEnd = new DateTime(($yearCurrent + 1) . "-05-31");
$rttStart = new DateTime("$yearCurrent-01-01");
$rttEnd = new DateTime("$yearCurrent-12-31");
$cpAnnualDays = 25;
$rttAnnualDays = 10;
$cpPeriodDays = $cpEnd->diff($cpStart)->days + 1;
$rttPeriodDays = $rttEnd->diff($rttStart)->days + 1;
$cpDailyIncrement = $cpAnnualDays / $cpPeriodDays;
$rttDailyIncrement = $rttAnnualDays / $rttPeriodDays;
error_log("Incrément CP jour : $cpDailyIncrement");
error_log("Incrément RTT jour : $rttDailyIncrement");
if ($today >= $cpStart && $today <= $cpEnd && $cpTypeId !== null) {
$exerciseYear = (int)$cpStart->format('Y');
$stmt = $conn->prepare("UPDATE CompteurConges SET Solde = Solde + ? WHERE TypeCongeId = ? AND Annee = ?");
$stmt->bind_param("dii", $cpDailyIncrement, $cpTypeId, $exerciseYear);
if (!$stmt->execute()) {
error_log("Erreur incrément CP N : ".$stmt->error);
}
$stmt->close();
}
if ($today >= $rttStart && $today <= $rttEnd && $rttTypeId !== null) {
$exerciseYear = $yearCurrent;
$stmt = $conn->prepare("UPDATE CompteurConges SET Solde = Solde + ? WHERE TypeCongeId = ? AND Annee = ?");
$stmt->bind_param("dii", $rttDailyIncrement, $rttTypeId, $exerciseYear);
if (!$stmt->execute()) {
error_log("Erreur incrément RTT N : ".$stmt->error);
}
$stmt->close();
}
// Récupérer les compteurs actuels de l'utilisateur demandé en GET
$userId = isset($_GET['user_id']) ? (int)$_GET['user_id'] : 0;
$data = [];
if ($userId > 0) {
$stmt = $conn->prepare(
"SELECT tc.Nom, cc.Annee, cc.Solde, cc.Total, cc.SoldeReporte
FROM CompteurConges cc
JOIN TypeConge tc ON cc.TypeCongeId = tc.Id
WHERE cc.CollaborateurADId = ?"
);
$stmt->bind_param("i", $userId);
$stmt->execute();
$result = $stmt->get_result();
while ($row = $result->fetch_assoc()) {
$data[$row['Nom']] = [
'Annee' => $row['Annee'],
'Solde' => (float)$row['Solde'],
'Total' => (float)$row['Total'],
'SoldeReporte' => (float)$row['SoldeReporte'],
];
}
$stmt->close();
}
$conn->close();
echo json_encode([
'success' => true,
'message' => 'Compteurs mis à jour',
'counters' => $data,
]);
exit;

View File

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

View File

@@ -1,159 +0,0 @@
<?php
// Récupération des demandes en attente pour un manager
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
http_response_code(200);
exit();
}
header("Content-Type: application/json");
// Log des erreurs pour debug
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
$host = "192.168.0.4";
$dbname = "DemandeConge";
$username = "wpuser";
$password = "-2b/)ru5/Bi8P[7_";
$conn = new mysqli($host, $username, $password, $dbname);
if ($conn->connect_error) {
error_log("Erreur connexion DB getPendingRequests: " . $conn->connect_error);
echo json_encode(["success" => false, "message" => "Erreur de connexion à la base de données"]);
exit();
}
$managerId = $_GET['manager_id'] ?? null;
if ($managerId === null) {
echo json_encode(["success" => false, "message" => "ID manager manquant"]);
exit();
}
error_log("getPendingRequests - Manager ID: $managerId");
// Fonction pour calculer les jours ouvrés
function getWorkingDays($startDate, $endDate) {
$workingDays = 0;
$current = new DateTime($startDate);
$end = new DateTime($endDate);
while ($current <= $end) {
$dayOfWeek = (int)$current->format('N');
if ($dayOfWeek < 6) {
$workingDays++;
}
$current->modify('+1 day');
}
return $workingDays;
}
try {
// Récupérer le service du manager (table CollaborateurAD)
$queryManagerService = "SELECT ServiceId FROM CollaborateurAD WHERE id = ?";
$stmtManager = $conn->prepare($queryManagerService);
$stmtManager->bind_param("i", $managerId);
$stmtManager->execute();
$resultManager = $stmtManager->get_result();
if ($managerRow = $resultManager->fetch_assoc()) {
$serviceId = $managerRow['ServiceId'];
error_log("getPendingRequests - Service ID du manager: $serviceId");
// Récupérer les demandes en attente (multi-types)
$queryRequests = "
SELECT
dc.Id,
dc.DateDebut,
dc.DateFin,
dc.Statut,
dc.DateDemande,
dc.Commentaire,
dc.CollaborateurADId,
CONCAT(ca.prenom, ' ', ca.nom) as employee_name,
ca.email as employee_email,
GROUP_CONCAT(tc.Nom ORDER BY tc.Nom SEPARATOR ', ') as types
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
JOIN TypeConge tc ON FIND_IN_SET(tc.Id, dc.TypeCongeId)
WHERE ca.ServiceId = ?
AND dc.Statut = 'En attente'
AND ca.id != ?
GROUP BY
dc.Id, dc.DateDebut, dc.DateFin, dc.Statut, dc.DateDemande,
dc.Commentaire, dc.CollaborateurADId, ca.prenom, ca.nom, ca.email
ORDER BY dc.DateDemande ASC
";
$stmtRequests = $conn->prepare($queryRequests);
$stmtRequests->bind_param("ii", $serviceId, $managerId);
$stmtRequests->execute();
$resultRequests = $stmtRequests->get_result();
$requests = [];
while ($row = $resultRequests->fetch_assoc()) {
$workingDays = getWorkingDays($row['DateDebut'], $row['DateFin']);
$startDate = new DateTime($row['DateDebut']);
$endDate = new DateTime($row['DateFin']);
$submittedDate = new DateTime($row['DateDemande']);
if ($row['DateDebut'] === $row['DateFin']) {
$dateDisplay = $startDate->format('d/m/Y');
} else {
$dateDisplay = $startDate->format('d/m/Y') . ' - ' . $endDate->format('d/m/Y');
}
$requests[] = [
'id' => (int)$row['Id'],
'employee_id' => (int)$row['CollaborateurADId'],
'employee_name' => $row['employee_name'],
'employee_email' => $row['employee_email'],
'type' => $row['types'], // ex: "Congé payé, RTT"
'start_date' => $row['DateDebut'],
'end_date' => $row['DateFin'],
'date_display' => $dateDisplay,
'days' => $workingDays,
'status' => $row['Statut'],
'reason' => $row['Commentaire'] ?: '',
'submitted_at' => $row['DateDemande'],
'submitted_display' => $submittedDate->format('d/m/Y')
];
}
error_log("getPendingRequests - Demandes en attente trouvées: " . count($requests));
echo json_encode([
"success" => true,
"message" => "Demandes en attente récupérées avec succès",
"requests" => $requests,
"service_id" => $serviceId
]);
$stmtRequests->close();
} else {
error_log("getPendingRequests - Manager non trouvé: $managerId");
echo json_encode([
"success" => false,
"message" => "Manager non trouvé"
]);
}
$stmtManager->close();
} catch (Exception $e) {
error_log("Erreur getPendingRequests: " . $e->getMessage());
echo json_encode([
"success" => false,
"message" => "Erreur lors de la récupération des demandes: " . $e->getMessage()
]);
}
$conn->close();
?>

View File

@@ -1,133 +0,0 @@
<?php
// En-têtes CORS et JSON
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
http_response_code(200);
exit();
}
header("Content-Type: application/json; charset=utf-8");
// Affichage des erreurs PHP (utile en dev)
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
// Connexion BDD
$host = "192.168.0.4";
$dbname = "DemandeConge";
$username = "wpuser";
$password = "-2b/)ru5/Bi8P[7_";
$conn = new mysqli($host, $username, $password, $dbname);
if ($conn->connect_error) {
echo json_encode(["success" => false, "message" => "Erreur connexion DB: " . $conn->connect_error]);
exit();
}
// Récup paramètre
$userId = $_GET['user_id'] ?? null;
if (!$userId) {
echo json_encode(["success" => false, "message" => "ID utilisateur manquant"]);
exit();
}
// Fonction jours ouvrés
function getWorkingDays($startDate, $endDate) {
$workingDays = 0;
$current = new DateTime($startDate);
$end = new DateTime($endDate);
while ($current <= $end) {
$dayOfWeek = (int)$current->format('N');
if ($dayOfWeek < 6) {
$workingDays++;
}
$current->modify('+1 day');
}
return $workingDays;
}
try {
// Requête multi-types
$query = "
SELECT
dc.Id,
dc.DateDebut,
dc.DateFin,
dc.Statut,
dc.DateDemande,
dc.Commentaire,
dc.Validateur,
dc.DocumentJoint,
GROUP_CONCAT(tc.Nom ORDER BY tc.Nom SEPARATOR ', ') AS TypeConges
FROM DemandeConge dc
JOIN TypeConge tc ON FIND_IN_SET(tc.Id, dc.TypeCongeId)
WHERE (dc.EmployeeId = ? OR dc.CollaborateurADId = ?)
GROUP BY
dc.Id, dc.DateDebut, dc.DateFin, dc.Statut, dc.DateDemande,
dc.Commentaire, dc.Validateur, dc.DocumentJoint
ORDER BY dc.DateDemande DESC
";
$stmt = $conn->prepare($query);
if (!$stmt) {
throw new Exception("Erreur préparation SQL : " . $conn->error);
}
$stmt->bind_param("ii", $userId, $userId);
$stmt->execute();
$result = $stmt->get_result();
$requests = [];
while ($row = $result->fetch_assoc()) {
$workingDays = getWorkingDays($row['DateDebut'], $row['DateFin']);
// Format dates
$startDate = new DateTime($row['DateDebut']);
$endDate = new DateTime($row['DateFin']);
$submittedDate = new DateTime($row['DateDemande']);
$dateDisplay = ($row['DateDebut'] === $row['DateFin'])
? $startDate->format('d/m/Y')
: $startDate->format('d/m/Y') . ' - ' . $endDate->format('d/m/Y');
// Lien fichier si congé maladie
$fileUrl = null;
if (strpos($row['TypeConges'], 'Congé maladie') !== false && !empty($row['DocumentJoint'])) {
$fileUrl = 'http://localhost/GTA/project/uploads/' . basename($row['DocumentJoint']);
}
$requests[] = [
'id' => (int)$row['Id'],
'type' => $row['TypeConges'], // ex: "Congé payé, RTT"
'startDate' => $row['DateDebut'],
'endDate' => $row['DateFin'],
'dateDisplay' => $dateDisplay,
'days' => $workingDays,
'status' => $row['Statut'],
'reason' => $row['Commentaire'] ?: 'Aucun commentaire',
'submittedAt' => $row['DateDemande'],
'submittedDisplay' => $submittedDate->format('d/m/Y'),
'validator' => $row['Validateur'] ?: null,
'fileUrl' => $fileUrl
];
}
echo json_encode([
"success" => true,
"message" => "Demandes récupérées avec succès",
"requests" => $requests,
"total" => count($requests)
]);
} catch (Exception $e) {
echo json_encode([
"success" => false,
"message" => "Erreur: " . $e->getMessage()
]);
}
$conn->close();

View File

@@ -1,228 +0,0 @@
<?php
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
http_response_code(200);
exit();
}
header("Content-Type: application/json");
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
$host = "192.168.0.4";
$dbname = "DemandeConge";
$username = "wpuser";
$password = "-2b/)ru5/Bi8P[7_";
$conn = new mysqli($host, $username, $password, $dbname);
if ($conn->connect_error) {
echo json_encode(["success" => false, "message" => "Erreur de connexion à la base de données"]);
exit();
}
// On récupère le rôle directement depuis la requête GET pour la logique PHP
$userId = $_GET['user_id'] ?? null;
$role = strtolower($_GET['role'] ?? 'collaborateur');
if ($userId === null) {
echo json_encode(["success" => false, "message" => "ID utilisateur manquant"]);
exit();
}
try {
// 🔹 Infos utilisateur
$queryUser = "
SELECT ca.ServiceId, sa.CampusId, sa.SocieteId,
s.Nom as service_nom, c.Nom as campus_nom, so.Nom as societe_nom
FROM CollaborateurAD ca
JOIN ServiceAffectation sa ON sa.ServiceId = ca.ServiceId
JOIN Services s ON ca.ServiceId = s.Id
JOIN Campus c ON sa.CampusId = c.Id
JOIN Societe so ON sa.SocieteId = so.Id
WHERE ca.id = ?
LIMIT 1
";
$stmtUser = $conn->prepare($queryUser);
$stmtUser->bind_param("i", $userId);
$stmtUser->execute();
$resultUser = $stmtUser->get_result();
if (!$userRow = $resultUser->fetch_assoc()) {
echo json_encode(["success" => false, "message" => "Collaborateur non trouvé"]);
exit();
}
$serviceId = $userRow['ServiceId'];
$campusId = $userRow['CampusId'];
$societeId = $userRow['SocieteId'];
// -------------------------
// 🔹 Construire la requête selon le rôle
// -------------------------
switch ($role) {
case 'president':
case 'rh':
$queryLeaves = "
SELECT
DATE_FORMAT(dc.DateDebut, '%Y-%m-%d') as start_date,
DATE_FORMAT(dc.DateFin, '%Y-%m-%d') as end_date,
CONCAT(ca.prenom, ' ', ca.nom) as employee_name,
tc.Nom as type,
tc.CouleurHex as color,
s.Nom as service_nom,
c.Nom as campus_nom,
so.Nom as societe_nom
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
JOIN TypeConge tc ON dc.TypeCongeId = tc.Id
JOIN ServiceAffectation sa ON sa.ServiceId = ca.ServiceId
JOIN Services s ON sa.ServiceId = s.Id
JOIN Campus c ON sa.CampusId = c.Id
JOIN Societe so ON sa.SocieteId = so.Id -- CORRIGÉ ICI
WHERE dc.Statut = 'Validée'
ORDER BY c.Nom, so.Nom, s.Nom, dc.DateDebut ASC
";
$stmtLeaves = $conn->prepare($queryLeaves);
break;
case 'directeur de campus':
$queryLeaves = "
SELECT
DATE_FORMAT(dc.DateDebut, '%Y-%m-%d') as start_date,
DATE_FORMAT(dc.DateFin, '%Y-%m-%d') as end_date,
CONCAT(ca.prenom, ' ', ca.nom) as employee_name,
tc.Nom as type,
tc.CouleurHex as color,
s.Nom as service_nom,
so.Nom as societe_nom,
c.Nom as campus_nom
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
JOIN TypeConge tc ON dc.TypeCongeId = tc.Id
JOIN ServiceAffectation sa ON sa.ServiceId = ca.ServiceId
JOIN Services s ON sa.ServiceId = s.Id
JOIN Societe so ON sa.SocieteId = so.Id -- CORRIGÉ ICI
JOIN Campus c ON sa.CampusId = c.Id
WHERE sa.CampusId = ?
AND dc.Statut = 'Validée'
ORDER BY so.Nom, s.Nom, dc.DateDebut ASC
";
$stmtLeaves = $conn->prepare($queryLeaves);
$stmtLeaves->bind_param("i", $campusId);
break;
case 'validateur':
case 'collaborateur':
default:
$queryLeaves = "
SELECT
DATE_FORMAT(dc.DateDebut, '%Y-%m-%d') as start_date,
DATE_FORMAT(dc.DateFin, '%Y-%m-%d') as end_date,
CONCAT(ca.prenom, ' ', ca.nom) as employee_name,
tc.Nom as type,
tc.CouleurHex as color,
s.Nom as service_nom,
c.Nom as campus_nom,
so.Nom as societe_nom
FROM DemandeConge dc
JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
JOIN TypeConge tc ON dc.TypeCongeId = tc.Id
JOIN ServiceAffectation sa ON sa.ServiceId = ca.ServiceId
JOIN Services s ON sa.ServiceId = s.Id
JOIN Campus c ON sa.CampusId = c.Id
JOIN Societe so ON sa.SocieteId = so.Id -- CORRIGÉ ICI
WHERE ca.ServiceId = ?
AND sa.CampusId = ?
AND dc.Statut = 'Validée'
AND dc.DateFin >= CURDATE() - INTERVAL 30 DAY
ORDER BY dc.DateDebut ASC
";
$stmtLeaves = $conn->prepare($queryLeaves);
$stmtLeaves->bind_param("ii", $serviceId, $campusId);
}
$stmtLeaves->execute();
$resultLeaves = $stmtLeaves->get_result();
$leaves = [];
while ($row = $resultLeaves->fetch_assoc()) {
$leaves[] = [
'start_date' => $row['start_date'],
'end_date' => $row['end_date'],
'employee_name' => $row['employee_name'],
'type' => $row['type'],
'color' => $row['color'] ?? '#3B82F6',
'service_nom' => $row['service_nom'],
'campus_nom' => $row['campus_nom'] ?? null,
'societe_nom' => $row['societe_nom'] ?? null
];
}
// -------------------------
// 🔹 Construire les filtres dynamiques
// -------------------------
$filters = [];
if (in_array($role, ['collaborateur', 'validateur'])) {
$queryEmployees = "
SELECT CONCAT(ca.prenom, ' ', ca.nom) as employee_name
FROM CollaborateurAD ca
JOIN ServiceAffectation sa ON sa.ServiceId = ca.ServiceId
WHERE ca.ServiceId = ?
AND sa.CampusId = ?
ORDER BY ca.prenom, ca.nom
";
$stmtEmployees = $conn->prepare($queryEmployees);
$stmtEmployees->bind_param("ii", $serviceId, $campusId);
$stmtEmployees->execute();
$resultEmployees = $stmtEmployees->get_result();
$employees = [];
while ($row = $resultEmployees->fetch_assoc()) {
$employees[] = $row['employee_name'];
}
$filters['employees'] = $employees;
$stmtEmployees->close();
} elseif ($role === 'directeur de campus') {
// Pour le directeur, les filtres se basent sur les congés de son campus
$filters['societes'] = array_values(array_unique(array_column($leaves, 'societe_nom')));
$filters['services'] = array_values(array_unique(array_column($leaves, 'service_nom')));
} elseif (in_array($role, ['president', 'rh'])) {
// 🔹 Récupérer tous les campus, sociétés, services de manière unique
$filters['campus'] = [];
$filters['societes'] = [];
$filters['services'] = [];
$result = $conn->query("SELECT DISTINCT Nom as campus_nom FROM Campus ORDER BY campus_nom");
while($row = $result->fetch_assoc()) $filters['campus'][] = $row['campus_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'];
}
echo json_encode([
"success" => true,
"role" => $role,
"leaves" => $leaves,
"filters" => $filters
]);
$stmtLeaves->close();
$stmtUser->close();
} catch (Exception $e) {
echo json_encode(["success" => false, "message" => "Erreur: " . $e->getMessage()]);
}
$conn->close();
?>

View File

@@ -1,116 +0,0 @@
<?php
// Récupération des membres de l'équipe pour un manager AD
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
http_response_code(200);
exit();
}
header("Content-Type: application/json");
// Debug erreurs
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
$host = "192.168.0.4";
$dbname = "DemandeConge";
$username = "wpuser";
$password = "-2b/)ru5/Bi8P[7_";
$conn = new mysqli($host, $username, $password, $dbname);
if ($conn->connect_error) {
error_log("Erreur connexion DB getTeamMembersAD: " . $conn->connect_error);
echo json_encode(["success" => false, "message" => "Erreur de connexion à la base de données"]);
exit();
}
$managerId = $_GET['manager_id'] ?? null;
if ($managerId === null) {
echo json_encode(["success" => false, "message" => "ID manager manquant"]);
exit();
}
error_log("getTeamMembersAD - Manager ID: $managerId");
try {
// 🔹 1. Récupérer le ServiceId du manager
$queryManagerService = "SELECT ServiceId FROM CollaborateurAD WHERE id = ?";
$stmtManager = $conn->prepare($queryManagerService);
$stmtManager->bind_param("i", $managerId);
$stmtManager->execute();
$resultManager = $stmtManager->get_result();
if ($managerRow = $resultManager->fetch_assoc()) {
$serviceId = $managerRow['ServiceId'];
error_log("getTeamMembersAD - ServiceId du manager: $serviceId");
// 🔹 2. Récupérer tous les collaborateurs du même service (sauf le manager)
$queryTeam = "
SELECT
c.id,
c.nom,
c.prenom,
c.email,
c.role,
s.Nom as service_name
FROM CollaborateurAD c
JOIN Services s ON c.ServiceId = s.Id
WHERE c.ServiceId = ? AND c.id != ?
ORDER BY c.prenom, c.nom
";
$stmtTeam = $conn->prepare($queryTeam);
$stmtTeam->bind_param("ii", $serviceId, $managerId);
$stmtTeam->execute();
$resultTeam = $stmtTeam->get_result();
$teamMembers = [];
while ($row = $resultTeam->fetch_assoc()) {
$teamMembers[] = [
'id' => (int)$row['id'],
'nom' => $row['nom'],
'prenom' => $row['prenom'],
'email' => $row['email'],
'role' => $row['role'],
'service_name' => $row['service_name']
];
}
error_log("getTeamMembersAD - Membres trouvés: " . count($teamMembers));
echo json_encode([
"success" => true,
"message" => "Équipe récupérée avec succès",
"team_members" => $teamMembers,
"service_id" => $serviceId
]);
$stmtTeam->close();
} else {
error_log("getTeamMembersAD - Manager non trouvé: $managerId");
echo json_encode([
"success" => false,
"message" => "Manager non trouvé"
]);
}
$stmtManager->close();
} catch (Exception $e) {
error_log("Erreur getTeamMembersAD: " . $e->getMessage());
echo json_encode([
"success" => false,
"message" => "Erreur lors de la récupération de l'équipe: " . $e->getMessage()
]);
}
$conn->close();
?>

View File

@@ -1,104 +0,0 @@
<?php
header("Access-Control-Allow-Origin: *");
header("Content-Type: application/json");
header("Access-Control-Allow-Headers: Content-Type, Authorization");
// --- Connexion DB ---
$host = "192.168.0.4";
$dbname = "DemandeConge";
$username = "wpuser";
$password = "-2b/)ru5/Bi8P[7_";
$conn = new mysqli($host, $username, $password, $dbname);
if ($conn->connect_error) {
die(json_encode(["success" => false, "message" => "Erreur DB: " . $conn->connect_error]));
}
$tenantId = "9840a2a0-6ae1-4688-b03d-d2ec291be0f9";
$clientId = "4bb4cc24-bac3-427c-b02c-5d14fc67b561";
$clientSecret = "ViC8Q~n4F5YweE18wjS0kfhp3kHh6LB2gZ76_b4R";
$scope = "https://graph.microsoft.com/.default";
$url = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token";
$data = [
"grant_type" => "client_credentials",
"client_id" => $clientId,
"client_secret" => $clientSecret,
"scope" => $scope
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = curl_exec($ch);
curl_close($ch);
$tokenData = json_decode($result, true);
$accessToken = $tokenData["access_token"] ?? "";
if (!$accessToken) {
die(json_encode(["success" => false, "message" => "Impossible d'obtenir un token Microsoft", "details" => $tokenData]));
}
// --- ID du groupe cible (Ensup-Groupe) ---
$groupId = "c1ea877c-6bca-4f47-bfad-f223640813a0";
// --- Récupérer infos du groupe ---
$urlGroup = "https://graph.microsoft.com/v1.0/groups/$groupId?\$select=id,displayName,description,mail,createdDateTime";
$ch = curl_init($urlGroup);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer $accessToken"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$respGroup = curl_exec($ch);
curl_close($ch);
$group = json_decode($respGroup, true);
if (!isset($group["id"])) {
die(json_encode(["success" => false, "message" => "Impossible de récupérer le groupe Ensup-Groupe"]));
}
$displayName = $group["displayName"] ?? "";
// --- Récupérer les membres du groupe ---
$urlMembers = "https://graph.microsoft.com/v1.0/groups/$groupId/members?\$select=id,givenName,surname,mail,department,jobTitle";
$ch = curl_init($urlMembers);
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer $accessToken"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$respMembers = curl_exec($ch);
curl_close($ch);
$members = json_decode($respMembers, true)["value"] ?? [];
$usersInserted = 0;
foreach ($members as $m) {
$entraUserId = $m["id"];
$prenom = $m["givenName"] ?? "";
$nom = $m["surname"] ?? "";
$email = $m["mail"] ?? "";
$service = $m["department"] ?? "";
$description = $m["jobTitle"] ?? null;
if (!$email) continue;
$stmt = $conn->prepare("INSERT INTO CollaborateurAD (entraUserId, prenom, nom, email, service, description, role)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE prenom=?, nom=?, email=?, service=?, description=?");
if ($stmt) {
$role = "Collaborateur";
$stmt->bind_param("ssssssssssss",
$entraUserId, $prenom, $nom, $email, $service, $description, $role,
$prenom, $nom, $email, $service, $description
);
$stmt->execute();
$usersInserted++;
}
}
// --- Réponse finale ---
echo json_encode([
"success" => true,
"message" => "Synchronisation terminée",
"groupe_sync" => $displayName,
"users_sync" => $usersInserted
]);
$conn->close();
?>

View File

@@ -1,152 +0,0 @@
<?php
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type, Authorization");
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
http_response_code(200);
exit();
}
header("Content-Type: application/json");
$host = "192.168.0.4";
$dbname = "DemandeConge";
$username = "wpuser";
$password = "-2b/)ru5/Bi8P[7_";
$conn = new mysqli($host, $username, $password, $dbname);
if ($conn->connect_error) {
die(json_encode(["success" => false, "message" => "Erreur DB : " . $conn->connect_error]));
}
$data = json_decode(file_get_contents('php://input'), true);
$email = $data['email'] ?? '';
$mot_de_passe = $data['mot_de_passe'] ?? '';
$entraUserId = $data['entraUserId'] ?? '';
$userPrincipalName = $data['userPrincipalName'] ?? '';
$headers = getallheaders();
$accessToken = isset($headers['Authorization']) ? str_replace('Bearer ', '', $headers['Authorization']) : '';
// ======================================================
// 1⃣ Mode Azure AD (avec token + Entra)
// ======================================================
if ($accessToken && $entraUserId) {
// Vérifier si utilisateur existe déjà dans CollaborateurAD
$stmt = $conn->prepare("SELECT * FROM CollaborateurAD WHERE entraUserId=? OR email=? LIMIT 1");
$stmt->bind_param("ss", $entraUserId, $email);
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows === 0) {
echo json_encode(["success" => false, "message" => "Utilisateur non autorisé (pas dans l'annuaire)"]);
exit();
}
$user = $result->fetch_assoc();
// Récupérer groupes de lutilisateur via Graph
$ch = curl_init("https://graph.microsoft.com/v1.0/users/$userPrincipalName/memberOf?\$select=id");
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer $accessToken"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
$dataGraph = json_decode($response, true);
$userGroups = [];
if (isset($dataGraph['value'])) {
foreach ($dataGraph['value'] as $g) {
if (isset($g['id'])) {
$userGroups[] = $g['id'];
}
}
}
// Vérifier si au moins un groupe est autorisé
$res = $conn->query("SELECT Id FROM EntraGroups WHERE IsActive=1");
$allowedGroups = [];
while ($row = $res->fetch_assoc()) {
$allowedGroups[] = $row['Id'];
}
$authorized = count(array_intersect($userGroups, $allowedGroups)) > 0;
if ($authorized) {
echo json_encode([
"success" => true,
"message" => "Connexion réussie via Azure AD",
"user" => [
"id" => $user['id'],
"prenom" => $user['prenom'],
"nom" => $user['nom'],
"email" => $user['email'],
"role" => $user['role'],
"service" => $user['service']
]
]);
} else {
echo json_encode(["success" => false, "message" => "Utilisateur non autorisé - pas dans un groupe actif"]);
}
$conn->close();
exit();
}
// ======================================================
// 2⃣ Mode local (login/password → Users)
// ======================================================
if ($email && $mot_de_passe) {
$query = "
SELECT
u.ID,
u.Prenom,
u.Nom,
u.Email,
u.Role,
u.ServiceId,
s.Nom AS ServiceNom
FROM Users u
LEFT JOIN Services s ON u.ServiceId = s.Id
WHERE u.Email = ? AND u.MDP = ?
";
$stmt = $conn->prepare($query);
if ($stmt === false) {
die(json_encode(["success" => false, "message" => "Erreur de préparation : " . $conn->error]));
}
$stmt->bind_param("ss", $email, $mot_de_passe);
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows === 1) {
$user = $result->fetch_assoc();
echo json_encode([
"success" => true,
"message" => "Connexion réussie (mode local)",
"user" => [
"id" => $user['ID'],
"prenom" => $user['Prenom'],
"nom" => $user['Nom'],
"email" => $user['Email'],
"role" => $user['Role'],
"service" => $user['ServiceNom'] ?? 'Non défini'
]
]);
} else {
echo json_encode(["success" => false, "message" => "Identifiants incorrects (mode local)"]);
}
$stmt->close();
$conn->close();
exit();
}
// ======================================================
// 3⃣ Aucun mode ne correspond
// ======================================================
echo json_encode(["success" => false, "message" => "Aucune méthode de connexion fournie"]);
$conn->close();
?>

View File

@@ -1,116 +0,0 @@
<?php
// Script manuel pour réinitialiser les compteurs
// Accès direct via navigateur pour les administrateurs
?>
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Réinitialisation des Compteurs</title>
<style>
body { font-family: Arial, sans-serif; max-width: 800px; margin: 50px auto; padding: 20px; }
.container { background: #f5f5f5; padding: 30px; border-radius: 10px; }
.warning { background: #fff3cd; border: 1px solid #ffeaa7; padding: 15px; border-radius: 5px; margin: 20px 0; }
.success { background: #d4edda; border: 1px solid #c3e6cb; padding: 15px; border-radius: 5px; margin: 20px 0; }
.error { background: #f8d7da; border: 1px solid #f5c6cb; padding: 15px; border-radius: 5px; margin: 20px 0; }
button { background: #007bff; color: white; padding: 12px 24px; border: none; border-radius: 5px; cursor: pointer; font-size: 16px; }
button:hover { background: #0056b3; }
.danger { background: #dc3545; }
.danger:hover { background: #c82333; }
pre { background: #f8f9fa; padding: 15px; border-radius: 5px; overflow-x: auto; }
</style>
</head>
<body>
<div class="container">
<h1>🔄 Réinitialisation des Compteurs de Congés</h1>
<div class="warning">
<h3>⚠️ ATTENTION</h3>
<p>Cette opération va réinitialiser TOUS les compteurs de congés selon les règles suivantes :</p>
<ul>
<li><strong>Congés Payés :</strong> 25 jours (exercice du 01/06 au 31/05)</li>
<li><strong>RTT :</strong> 10 jours pour 2025 (exercice du 01/01 au 31/12)</li>
<li><strong>Congés Maladie :</strong> 0 jours (remise à zéro)</li>
</ul>
<p><strong>Cette action est irréversible !</strong></p>
</div>
<?php
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['confirm_reset'])) {
// Appel du script de réinitialisation
$resetUrl = 'http://localhost/project/public/resetLeaveCounters.php';
$context = stream_context_create([
'http' => [
'method' => 'POST',
'header' => 'Content-Type: application/json',
'content' => json_encode(['manual_reset' => true])
]
]);
$result = file_get_contents($resetUrl, false, $context);
$data = json_decode($result, true);
if ($data && $data['success']) {
echo '<div class="success">';
echo '<h3>✅ Réinitialisation réussie !</h3>';
echo '<p>Employés mis à jour : ' . $data['details']['employees_updated'] . '</p>';
echo '<p>Exercice CP : ' . $data['details']['leave_year'] . '</p>';
echo '<p>Année RTT : ' . $data['details']['rtt_year'] . '</p>';
echo '<p>Date de réinitialisation : ' . $data['details']['reset_date'] . '</p>';
if (!empty($data['log'])) {
echo '<details><summary>Voir le détail</summary><pre>';
foreach ($data['log'] as $logLine) {
echo htmlspecialchars($logLine) . "\n";
}
echo '</pre></details>';
}
echo '</div>';
} else {
echo '<div class="error">';
echo '<h3>❌ Erreur lors de la réinitialisation</h3>';
echo '<p>' . ($data['message'] ?? 'Erreur inconnue') . '</p>';
echo '</div>';
}
}
?>
<form method="POST" onsubmit="return confirm('Êtes-vous sûr de vouloir réinitialiser TOUS les compteurs ? Cette action est irréversible.');">
<p>
<label>
<input type="checkbox" name="confirm_reset" value="1" required>
Je confirme vouloir réinitialiser tous les compteurs de congés
</label>
</p>
<button type="submit" class="danger">🔄 RÉINITIALISER LES COMPTEURS</button>
</form>
<hr style="margin: 40px 0;">
<h3>📋 Informations sur les exercices</h3>
<?php
$currentDate = new DateTime();
$currentYear = (int)$currentDate->format('Y');
$currentMonth = (int)$currentDate->format('m');
// Calcul exercice CP
$leaveYear = ($currentMonth < 6) ? $currentYear - 1 : $currentYear;
$leaveYearEnd = $leaveYear + 1;
echo "<p><strong>Exercice Congés Payés actuel :</strong> du 01/06/$leaveYear au 31/05/$leaveYearEnd</p>";
echo "<p><strong>Exercice RTT actuel :</strong> du 01/01/$currentYear au 31/12/$currentYear</p>";
echo "<p><strong>Date actuelle :</strong> " . $currentDate->format('d/m/Y H:i:s') . "</p>";
?>
<h3>🔗 Actions rapides</h3>
<p>
<a href="getLeaveCounters.php?user_id=1" target="_blank">
<button type="button">Voir les compteurs (User ID 1)</button>
</a>
</p>
</div>
</body>
</html>

View File

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

View File

@@ -1,228 +0,0 @@
<?php
// Script de réinitialisation des compteurs de congés
// À exécuter manuellement ou via cron job
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
header("Content-Type: application/json");
// Gère la requête OPTIONS (pré-vol CORS)
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
http_response_code(200);
exit();
}
// Log des erreurs pour debug
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
$host = "192.168.0.4";
$dbname = "DemandeConge";
$username = "wpuser";
$password = "-2b/)ru5/Bi8P[7_";
// Connexion à la base de données
$conn = new mysqli($host, $username, $password, $dbname);
if ($conn->connect_error) {
error_log("Erreur connexion DB reset: " . $conn->connect_error);
echo json_encode([
"success" => false,
"message" => "Erreur de connexion à la base de données : " . $conn->connect_error
]);
exit();
}
// Log de debug
error_log("Reset counters - Début du script");
// Fonction pour déterminer l'exercice des congés payés (01/06 au 31/05)
function getLeaveYear($date = null) {
if ($date === null) {
$date = new DateTime();
} else {
$date = new DateTime($date);
}
$currentYear = (int)$date->format('Y');
$currentMonth = (int)$date->format('m');
// Si on est avant le 1er juin, l'exercice a commencé l'année précédente
if ($currentMonth < 6) {
return $currentYear - 1;
}
// Si on est le 1er juin ou après, l'exercice a commencé cette année
return $currentYear;
}
// Fonction pour déterminer l'année RTT (01/01 au 31/12)
function getRTTYear($date = null) {
if ($date === null) {
$date = new DateTime();
} else {
$date = new DateTime($date);
}
return (int)$date->format('Y');
}
try {
$conn->begin_transaction();
$currentDate = new DateTime();
$leaveYear = getLeaveYear();
$rttYear = getRTTYear();
error_log("Reset counters - Exercice CP: $leaveYear, RTT: $rttYear");
$resetLog = [];
// 1. Récupérer tous les employés depuis la table Users
$queryEmployees = "SELECT ID FROM Users";
$resultEmployees = $conn->query($queryEmployees);
if (!$resultEmployees) {
throw new Exception("Erreur lors de la récupération des employés : " . $conn->error);
}
error_log("Reset counters - Nombre d'employés trouvés: " . $resultEmployees->num_rows);
// 2. Récupérer les IDs des types de congés
$queryTypes = "SELECT Id, Nom FROM TypeConge WHERE Nom IN ('Congé payé', 'RTT', 'Congé maladie')";
$resultTypes = $conn->query($queryTypes);
$typeIds = [];
while ($row = $resultTypes->fetch_assoc()) {
$typeIds[$row['Nom']] = $row['Id'];
}
error_log("Reset counters - Types trouvés: " . print_r($typeIds, true));
if (count($typeIds) < 3) {
throw new Exception("Types de congés manquants dans la base de données");
}
// 3. Pour chaque employé, réinitialiser les compteurs
$employeesUpdated = 0;
while ($employee = $resultEmployees->fetch_assoc()) {
$employeeId = $employee['ID'];
error_log("Reset counters - Traitement employé: $employeeId");
// CONGÉS PAYÉS - Exercice du 01/06 au 31/05 (25 jours)
$queryUpdateCP = "
INSERT INTO CompteurConges (EmployeeId, TypeCongeId, Annee, Solde, Total)
VALUES (?, ?, ?, 25, 25)
ON DUPLICATE KEY UPDATE
Solde = 25,
Total = 25
";
$stmtCP = $conn->prepare($queryUpdateCP);
if (!$stmtCP) {
throw new Exception("Erreur préparation CP: " . $conn->error);
}
$stmtCP->bind_param("iii", $employeeId, $typeIds['Congé payé'], $leaveYear);
if (!$stmtCP->execute()) {
throw new Exception("Erreur lors de la mise à jour des CP pour l'employé $employeeId : " . $stmtCP->error);
}
$stmtCP->close();
// RTT - Année civile du 01/01 au 31/12
// Calcul du nombre de RTT selon l'année
$rttCount = 10; // Par défaut 10 pour 2025
if ($rttYear == 2024) {
$rttCount = 8; // Exemple pour 2024
} elseif ($rttYear >= 2025) {
$rttCount = 10; // 10 pour 2025 et après
}
$queryUpdateRTT = "
INSERT INTO CompteurConges (EmployeeId, TypeCongeId, Annee, Solde, Total)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
Solde = ?,
Total = ?
";
$stmtRTT = $conn->prepare($queryUpdateRTT);
if (!$stmtRTT) {
throw new Exception("Erreur préparation RTT: " . $conn->error);
}
$stmtRTT->bind_param("iiiiiii", $employeeId, $typeIds['RTT'], $rttYear, $rttCount, $rttCount, $rttCount, $rttCount);
if (!$stmtRTT->execute()) {
throw new Exception("Erreur lors de la mise à jour des RTT pour l'employé $employeeId : " . $stmtRTT->error);
}
$stmtRTT->close();
// CONGÉ MALADIE - Réinitialiser à 0 (pas de limite)
$queryUpdateABS = "
INSERT INTO CompteurConges (EmployeeId, TypeCongeId, Annee, Solde, Total)
VALUES (?, ?, ?, 0, 0)
ON DUPLICATE KEY UPDATE
Solde = 0,
Total = 0
";
$stmtABS = $conn->prepare($queryUpdateABS);
if (!$stmtABS) {
throw new Exception("Erreur préparation ABS: " . $conn->error);
}
$stmtABS->bind_param("iii", $employeeId, $typeIds['Congé maladie'], $rttYear);
if (!$stmtABS->execute()) {
throw new Exception("Erreur lors de la mise à jour des ABS pour l'employé $employeeId : " . $stmtABS->error);
}
$stmtABS->close();
$resetLog[] = "Employé $employeeId : CP=$leaveYear (25j), RTT=$rttYear ({$rttCount}j), ABS=$rttYear (0j)";
$employeesUpdated++;
}
error_log("Reset counters - Employés mis à jour: $employeesUpdated");
// 4. Log de la réinitialisation
$logEntry = "
=== RÉINITIALISATION DES COMPTEURS ===
Date: " . $currentDate->format('Y-m-d H:i:s') . "
Exercice CP: $leaveYear (01/06/$leaveYear au 31/05/" . ($leaveYear + 1) . ")
Année RTT: $rttYear (01/01/$rttYear au 31/12/$rttYear)
Employés traités: $employeesUpdated
Détails:
" . implode("\n ", $resetLog) . "
";
// Sauvegarder le log (optionnel - créer une table de logs si nécessaire)
error_log($logEntry, 3, "reset_counters.log");
$conn->commit();
error_log("Reset counters - Transaction commitée avec succès");
echo json_encode([
"success" => true,
"message" => "Compteurs réinitialisés avec succès",
"details" => [
"employees_updated" => $employeesUpdated,
"leave_year" => $leaveYear,
"rtt_year" => $rttYear,
"cp_days" => 25,
"rtt_days" => $rttCount,
"reset_date" => $currentDate->format('Y-m-d H:i:s')
],
"log" => $resetLog
]);
} catch (Exception $e) {
$conn->rollback();
error_log("Erreur réinitialisation compteurs : " . $e->getMessage());
echo json_encode([
"success" => false,
"message" => "Erreur lors de la réinitialisation : " . $e->getMessage()
]);
}
$conn->close();
?>

View File

@@ -1,293 +0,0 @@
<?php
ob_clean();
header("Content-Type: application/json; charset=UTF-8");
header("Access-Control-Allow-Origin: http://localhost:5173");
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type, Authorization");
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit();
}
// Debug
ini_set('display_errors', 1);
error_reporting(E_ALL);
// Connexion DB
$host = "192.168.0.4";
$dbname = "DemandeConge";
$username = "wpuser";
$password = "-2b/)ru5/Bi8P[7_";
try {
$pdo = new PDO("mysql:host=$host;dbname=$dbname;charset=utf8", $username, $password);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch (PDOException $e) {
echo json_encode(["success"=>false,"message"=>"Erreur DB: ".$e->getMessage()]);
exit;
}
// Lecture JSON brut
$input = file_get_contents('php://input');
$data = json_decode($input, true);
// 🔎 Debug pour vérifier ce qui arrive
error_log("📥 Payload reçu : " . print_r($data, true));
if (!$data) {
echo json_encode(["success"=>false,"message"=>"JSON invalide","raw"=>$input]);
exit;
}
// Vérification des champs obligatoires
$required = ['DateDebut','DateFin','Repartition','NombreJours','Email','Nom'];
foreach ($required as $f) {
if (!array_key_exists($f, $data)) {
echo json_encode([
"success"=>false,
"message"=>"Donnée manquante : $f",
"debug"=>$data
]);
exit;
}
}
$dateDebut = $data['DateDebut'];
$dateFin = $data['DateFin'];
$commentaire = $data['Commentaire'] ?? '';
$numDays = (float)$data['NombreJours'];
$userEmail = $data['Email'];
$userName = $data['Nom'];
$statut = 'En attente';
$currentDate = date('Y-m-d H:i:s');
// 🔎 Identifier si c'est un CollaborateurAD ou un User
$stmt = $pdo->prepare("SELECT id FROM CollaborateurAD WHERE email = :email LIMIT 1");
$stmt->execute([':email'=>$userEmail]);
$collabAD = $stmt->fetch(PDO::FETCH_ASSOC);
$isAD = false;
$employeeId = null;
$collaborateurId = null;
if ($collabAD) {
$isAD = true;
$collaborateurId = (int)$collabAD['id'];
} else {
$stmt = $pdo->prepare("SELECT ID FROM Users WHERE Email = :email LIMIT 1");
$stmt->execute([':email'=>$userEmail]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user) {
echo json_encode(["success"=>false,"message"=>"Aucun collaborateur trouvé pour $userEmail"]);
exit;
}
$employeeId = (int)$user['ID'];
}
// 🔎 Résoudre les IDs des types de congés
$typeIds = [];
foreach ($data['Repartition'] as $rep) {
$code = $rep['TypeConge'];
switch ($code) {
case 'CP': $name = 'Congé payé'; break;
case 'RTT': $name = 'RTT'; break;
case 'ABS': $name = 'Congé maladie'; break;
default: $name = $code; break;
}
$s = $pdo->prepare("SELECT Id FROM TypeConge WHERE Nom = :nom LIMIT 1");
$s->execute([':nom'=>$name]);
if ($r = $s->fetch(PDO::FETCH_ASSOC)) {
$typeIds[] = $r['Id'];
}
}
if (empty($typeIds)) {
echo json_encode(["success"=>false,"message"=>"Aucun type de congé valide"]);
exit;
}
$typeCongeIdCsv = implode(',', $typeIds);
// ✅ Insertion DemandeConge
$sql = "INSERT INTO DemandeConge
(EmployeeId, CollaborateurADId, DateDebut, DateFin, TypeCongeId, Statut, DateDemande, Commentaire, Validateur, NombreJours)
VALUES (:eid, :cid, :dd, :df, :tc, :st, :cd, :com, :val, :nj)";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':eid'=> $isAD ? 0 : $employeeId,
':cid'=> $isAD ? $collaborateurId : null,
':dd'=>$dateDebut,
':df'=>$dateFin,
':tc'=>$typeCongeIdCsv,
':st'=>$statut,
':cd'=>$currentDate,
':com'=>$commentaire,
':val'=>'',
':nj'=>$numDays
]);
$demandeId = $pdo->lastInsertId();
// ✅ Insertion DemandeCongeType
$sql = "INSERT INTO DemandeCongeType (DemandeCongeId, TypeCongeId, NombreJours) VALUES (:did, :tid, :nj)";
$stmt = $pdo->prepare($sql);
foreach ($data['Repartition'] as $rep) {
$jours = (float)$rep['NombreJours'];
$code = $rep['TypeConge'];
switch ($code) {
case 'CP': $name = 'Congé payé'; break;
case 'RTT': $name = 'RTT'; break;
case 'ABS': $name = 'Congé maladie'; break;
default: $name = $code; break;
}
$s = $pdo->prepare("SELECT Id FROM TypeConge WHERE Nom = :nom LIMIT 1");
$s->execute([':nom'=>$name]);
if ($r = $s->fetch(PDO::FETCH_ASSOC)) {
$stmt->execute([
':did'=>$demandeId,
':tid'=>$r['Id'],
':nj'=>$jours
]);
}
}
// ✅ Récupérer les validateurs selon hiérarchie
if ($isAD) {
$stmt = $pdo->prepare("
SELECT c.email
FROM HierarchieValidationAD hv
JOIN CollaborateurAD c ON hv.SuperieurId = c.id
WHERE hv.CollaborateurId = :id
");
$stmt->execute([':id'=>$collaborateurId]);
} else {
$stmt = $pdo->prepare("
SELECT u.Email
FROM HierarchieValidation hv
JOIN Users u ON hv.SuperieurId = u.ID
WHERE hv.EmployeId = :id
");
$stmt->execute([':id'=>$employeeId]);
}
$managers = $stmt->fetchAll(PDO::FETCH_COLUMN);
# =============================================================
# 📧 AUTH Microsoft Graph (client_credentials)
# =============================================================
$tenantId = "9840a2a0-6ae1-4688-b03d-d2ec291be0f9";
$clientId = "4bb4cc24-bac3-427c-b02c-5d14fc67b561";
$clientSecret = "gvf8Q~545Bafn8yYsgjW~QG_P1lpzaRe6gJNgb2t";
$url = "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token";
$data = [
"client_id" => $clientId,
"scope" => "https://graph.microsoft.com/.default",
"client_secret" => $clientSecret,
"grant_type" => "client_credentials"
];
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Content-Type: application/x-www-form-urlencoded"
]);
$response = curl_exec($ch);
curl_close($ch);
$tokenData = json_decode($response, true);
if (!isset($tokenData['access_token'])) {
echo json_encode(["success" => false, "message" => "Impossible de générer un token Graph", "debug"=>$tokenData]);
exit;
}
$accessToken = $tokenData['access_token'];
# =============================================================
# 📧 Fonction envoi mail
# =============================================================
function sendMailGraph($accessToken, $fromEmail, $toEmail, $subject, $bodyHtml) {
$url = "https://graph.microsoft.com/v1.0/users/$fromEmail/sendMail";
$mailData = [
"message" => [
"subject" => $subject,
"body" => [
"contentType" => "HTML",
"content" => $bodyHtml
],
"toRecipients" => [
["emailAddress" => ["address" => $toEmail]]
]
],
"saveToSentItems" => "false"
];
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer $accessToken",
"Content-Type: application/json"
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($mailData));
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode >= 200 && $httpCode < 300) {
return true;
} else {
error_log("❌ Erreur envoi mail: $response");
return false;
}
}
# =============================================================
# 📧 Envoi automatique des emails
# =============================================================
$fromEmail = "noreply@ensup.eu";
# Mail au collaborateur
sendMailGraph(
$accessToken,
$fromEmail,
$userEmail,
"Confirmation de votre demande de congés",
"
Bonjour {$userName},<br/><br/>
Votre demande du <b>{$dateDebut}</b> au <b>{$dateFin}</b>
({$numDays} jour(s)) a bien été enregistrée.<br/>
Elle est en attente de validation par votre manager.<br/><br/>
Merci.
"
);
# Mail aux managers
foreach ($managers as $managerEmail) {
sendMailGraph(
$accessToken,
$fromEmail,
$managerEmail,
"Nouvelle demande de congé - {$userName}",
"
Bonjour,<br/><br/>
{$userName} a soumis une demande de congé :<br/>
- Du <b>{$dateDebut}</b> au <b>{$dateFin}</b> ({$numDays} jour(s))<br/>
- Commentaire : " . (!empty($commentaire) ? $commentaire : "Aucun") . "<br/><br/>
Merci de valider cette demande.
"
);
}
# ✅ Réponse finale
echo json_encode([
"success"=>true,
"message"=>"Demande soumise",
"request_id"=>$demandeId,
"managers"=>$managers
]);

View File

@@ -1,157 +0,0 @@
<?php
// Validation/Refus d'une demande de congé par un manager
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
http_response_code(200);
exit();
}
header("Content-Type: application/json");
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
// Connexion DB
$host = "192.168.0.4";
$dbname = "DemandeConge";
$username = "wpuser";
$password = "-2b/)ru5/Bi8P[7_";
$conn = new mysqli($host, $username, $password, $dbname);
if ($conn->connect_error) {
echo json_encode(["success" => false, "message" => "Erreur DB: " . $conn->connect_error]);
exit();
}
// Lecture du JSON envoyé
$input = file_get_contents('php://input');
$data = json_decode($input, true);
if (!isset($data['request_id'], $data['action'], $data['validator_id'])) {
echo json_encode(["success" => false, "message" => "Données manquantes"]);
exit();
}
$requestId = (int)$data['request_id'];
$action = $data['action']; // "approve" | "reject"
$validatorId = (int)$data['validator_id'];
$comment = $data['comment'] ?? '';
try {
$conn->begin_transaction();
// Vérifier que le validateur existe dans CollaborateurAD
$stmt = $conn->prepare("SELECT Id, prenom, nom FROM CollaborateurAD WHERE Id = ?");
$stmt->bind_param("i", $validatorId);
$stmt->execute();
$validator = $stmt->get_result()->fetch_assoc();
$stmt->close();
if (!$validator) {
throw new Exception("Validateur introuvable dans CollaborateurAD");
}
// Récupération de la demande
$queryCheck = "
SELECT dc.Id, dc.CollaborateurADId, dc.TypeCongeId, dc.DateDebut, dc.DateFin, dc.NombreJours,
ca.prenom as CADPrenom, ca.nom as CADNom,
tc.Nom as TypeNom
FROM DemandeConge dc
JOIN TypeConge tc ON dc.TypeCongeId = tc.Id
LEFT JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.Id
WHERE dc.Id = ? AND dc.Statut = 'En attente'
";
$stmtCheck = $conn->prepare($queryCheck);
$stmtCheck->bind_param("i", $requestId);
$stmtCheck->execute();
$requestRow = $stmtCheck->get_result()->fetch_assoc();
$stmtCheck->close();
if (!$requestRow) {
throw new Exception("Demande non trouvée ou déjà traitée");
}
$collaborateurId = $requestRow['CollaborateurADId'];
$typeCongeId = $requestRow['TypeCongeId'];
$nombreJours = $requestRow['NombreJours'];
$employeeName = $requestRow['CADPrenom']." ".$requestRow['CADNom'];
$typeNom = $requestRow['TypeNom'];
$newStatus = ($action === 'approve') ? 'Validée' : 'Refusée';
// 🔹 Mise à jour DemandeConge
$queryUpdate = "
UPDATE DemandeConge
SET Statut = ?,
ValidateurId = ?,
ValidateurADId = ?,
DateValidation = NOW(),
CommentaireValidation = ?
WHERE Id = ?
";
$stmtUpdate = $conn->prepare($queryUpdate);
$stmtUpdate->bind_param("siisi", $newStatus, $validatorId, $validatorId, $comment, $requestId);
$stmtUpdate->execute();
$stmtUpdate->close();
// 🔹 Déduction solde (pas maladie)
if ($action === 'approve' && $typeNom !== 'Congé maladie' && $collaborateurId) {
$year = date("Y");
$queryDeduct = "
UPDATE CompteurConges
SET Solde = GREATEST(0, Solde - ?)
WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?
";
$stmtDeduct = $conn->prepare($queryDeduct);
$stmtDeduct->bind_param("diii", $nombreJours, $collaborateurId, $typeCongeId, $year);
$stmtDeduct->execute();
$stmtDeduct->close();
}
// 🔹 Notification
$notificationTitle = ($action === 'approve') ? 'Demande approuvée' : 'Demande refusée';
$notificationMessage = "Votre demande de $typeNom a été " . (($action === 'approve') ? "approuvée" : "refusée");
if ($comment) $notificationMessage .= " (Commentaire: $comment)";
$notifType = ($action === 'approve') ? 'Success' : 'Error';
$queryNotif = "
INSERT INTO Notifications (CollaborateurADId, Titre, Message, Type, DemandeCongeId)
VALUES (?, ?, ?, ?, ?)
";
$stmtNotif = $conn->prepare($queryNotif);
$stmtNotif->bind_param("isssi", $collaborateurId, $notificationTitle, $notificationMessage, $notifType, $requestId);
$stmtNotif->execute();
$stmtNotif->close();
// 🔹 Historique
$actionText = ($action === 'approve') ? 'Validation congé' : 'Refus congé';
$actionDetails = "$actionText $employeeName ($typeNom)";
if ($comment) $actionDetails .= " - $comment";
$queryHistory = "
INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DemandeCongeId)
VALUES (?, ?, ?, ?)
";
$stmtHistory = $conn->prepare($queryHistory);
$stmtHistory->bind_param("issi", $validatorId, $actionText, $actionDetails, $requestId);
$stmtHistory->execute();
$stmtHistory->close();
$conn->commit();
echo json_encode([
"success" => true,
"message" => "Demande " . (($action === 'approve') ? 'approuvée' : 'refusée'),
"new_status" => $newStatus
]);
} catch (Exception $e) {
$conn->rollback();
echo json_encode(["success" => false, "message" => $e->getMessage()]);
}
$conn->close();

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import { AuthProvider, useAuth } from './context/AuthContext'; // ⭐ Ajout de useAuth
import Dashboard from './pages/Dashboard';
import Login from './pages/Login';
import Requests from './pages/Requests';
@@ -9,87 +9,103 @@ import Manager from './pages/Manager';
import ProtectedRoute from './components/ProtectedRoute';
import EmployeeDetails from './pages/EmployeeDetails';
import Collaborateur from './pages/Collaborateur';
import CompteRenduActivites from './pages/CompteRenduActivite'; // ⭐ Ajout
import CompteRenduActivites from './pages/CompteRenduActivite';
import GlobalTutorial from './components/GlobalTutorial';
// ⭐ Créer un composant séparé pour utiliser useAuth
function AppContent() {
const { user } = useAuth();
const userId = user?.id || user?.CollaborateurADId || user?.ID;
return (
<>
{/* ⭐ Tutoriel global - Il s'affichera sur toutes les pages */}
<GlobalTutorial userId={userId} />
<Routes>
{/* Route publique */}
<Route path="/login" element={<Login />} />
{/* Routes protégées */}
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/demandes"
element={
<ProtectedRoute allowedRoles={['Validateur', 'Validatrice', 'Collaborateur', 'Collaboratrice', 'Apprenti', 'RH', 'Admin', 'Directeur de campus', 'Directrice de campus']}>
<Requests />
</ProtectedRoute>
}
/>
<Route
path="/calendrier"
element={
<ProtectedRoute allowedRoles={['Collaborateur', 'Collaboratrice', 'Apprenti', 'Manager', 'Validateur', 'Validatrice', 'Directeur de campus', 'Directrice de campus', 'RH', 'Admin', 'President']}>
<Calendar />
</ProtectedRoute>
}
/>
<Route
path="/manager"
element={
<ProtectedRoute allowedRoles={['Manager', 'Validateur', 'Validatrice', 'Directeur de campus', 'Directrice de campus', 'RH', 'Admin', 'President']}>
<Manager />
</ProtectedRoute>
}
/>
<Route
path="/collaborateur"
element={
<ProtectedRoute allowedRoles={['Collaborateur', 'Collaboratrice', 'Apprenti']}>
<Collaborateur />
</ProtectedRoute>
}
/>
<Route
path="/employee/:id"
element={
<ProtectedRoute allowedRoles={['RH', 'Manager', 'Validateur', 'Validatrice', 'Directeur de campus', 'Directrice de campus', 'Admin', 'President']}>
<EmployeeDetails />
</ProtectedRoute>
}
/>
{/* ⭐ Nouvelle route pour Compte-Rendu d'Activités */}
<Route
path="/compte-rendu-activites"
element={
<ProtectedRoute allowedRoles={['Collaborateur', 'Collaboratrice', 'Validateur', 'Validatrice', 'Directeur de campus', 'Directrice de campus', 'RH', 'Admin', 'President']}>
<CompteRenduActivites />
</ProtectedRoute>
}
/>
{/* Redirection par défaut */}
<Route path="/" element={<Navigate to="/dashboard" replace />} />
{/* Route 404 - Redirection vers dashboard */}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</>
);
}
function App() {
return (
<AuthProvider>
<Router>
<Routes>
{/* Route publique */}
<Route path="/login" element={<Login />} />
{/* Routes protégées */}
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/demandes"
element={
<ProtectedRoute allowedRoles={['Collaborateur', 'Collaboratrice', 'Apprenti', 'RH', 'Admin']}>
<Requests />
</ProtectedRoute>
}
/>
<Route
path="/calendrier"
element={
<ProtectedRoute allowedRoles={['Collaborateur', 'Collaboratrice', 'Apprenti', 'Manager', 'Validateur', 'Validatrice', 'Directeur de campus', 'Directrice de campus', 'RH', 'Admin', 'President']}>
<Calendar />
</ProtectedRoute>
}
/>
<Route
path="/manager"
element={
<ProtectedRoute allowedRoles={['Manager', 'Validateur', 'Validatrice', 'Directeur de campus', 'Directrice de campus', 'RH', 'Admin', 'President']}>
<Manager />
</ProtectedRoute>
}
/>
<Route
path="/collaborateur"
element={
<ProtectedRoute allowedRoles={['Collaborateur', 'Collaboratrice', 'Apprenti']}>
<Collaborateur />
</ProtectedRoute>
}
/>
<Route
path="/employee/:id"
element={
<ProtectedRoute allowedRoles={['RH', 'Manager', 'Validateur', 'Validatrice', 'Directeur de campus', 'Directrice de campus', 'Admin', 'President']}>
<EmployeeDetails />
</ProtectedRoute>
}
/>
{/* ⭐ Nouvelle route pour Compte-Rendu d'Activités */}
<Route
path="/compte-rendu-activites"
element={
<ProtectedRoute allowedRoles={['Validateur', 'Validatrice', 'Directeur de campus', 'Directrice de campus', 'RH', 'Admin', 'President']}>
<CompteRenduActivites />
</ProtectedRoute>
}
/>
{/* Redirection par défaut */}
<Route path="/" element={<Navigate to="/dashboard" replace />} />
{/* Route 404 - Redirection vers dashboard */}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
<AppContent />
</Router>
</AuthProvider>
);

View File

@@ -1,22 +1,62 @@
// authConfig.js
const hostname = window.location.hostname;
const protocol = window.location.protocol;
// Détection environnements
const isProduction = hostname === "mygta.ensup-adm.net";
// ✅ EXPORT : API URL
export const API_BASE_URL = "/api";
// ✅ EXPORT : MSAL Config - OPTIMISÉ POUR MOBILE iOS
export const msalConfig = {
auth: {
clientId: "4bb4cc24-bac3-427c-b02c-5d14fc67b561", // Application (client) ID dans Azure
authority: "https://login.microsoftonline.com/9840a2a0-6ae1-4688-b03d-d2ec291be0f9", // Directory (tenant) ID
redirectUri: "http://localhost:5173"
clientId: "4bb4cc24-bac3-427c-b02c-5d14fc67b561",
authority: "https://login.microsoftonline.com/9840a2a0-6ae1-4688-b03d-d2ec291be0f9",
redirectUri: isProduction
? "https://mygta.ensup-adm.net"
: `${protocol}//${hostname}`,
navigateToLoginRequestUrl: false, // ✅ false pour éviter double redirection
postLogoutRedirectUri: isProduction
? "https://mygta.ensup-adm.net"
: `${protocol}//${hostname}`,
},
cache: {
cacheLocation: "sessionStorage",
storeAuthStateInCookie: false,
cacheLocation: "localStorage",
storeAuthStateInCookie: true,
},
system: {
allowRedirectInIframe: false,
allowNativeBroker: false,
loggerOptions: {
logLevel: "Verbose",
piiLoggingEnabled: false,
},
windowHashTimeout: 25000,
iframeHashTimeout: 25000,
loadFrameTimeout: 25000,
tokenRenewalOffsetSeconds: 300,
asyncPopups: false,
}
};
// ✅ EXPORT : Permissions Graph
export const loginRequest = {
scopes: [
"User.Read",
"User.Read.All", // Pour lire les profils des autres utilisateurs
"Group.Read.All", // Pour lire les groupes
"GroupMember.Read.All", // Pour lire les membres des groupes
"Mail.Send" //Envoyer les emails.
]
"User.Read.All",
"Group.Read.All",
"GroupMember.Read.All",
"Mail.Send",
],
prompt: "select_account",
responseMode: "fragment",
};
// ✅ Log de configuration au démarrage
console.log("🔧 Config Auth:", {
hostname,
protocol,
API_BASE_URL,
redirectUri: msalConfig.auth.redirectUri
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,728 @@
import React, { useState, useEffect } from 'react';
import Joyride, { STATUS } from 'react-joyride';
import { useLocation } from 'react-router-dom';
const GlobalTutorial = ({ userId, userRole }) => {
const [runTour, setRunTour] = useState(false);
const [dontShowAgain, setDontShowAgain] = useState(false);
const [availableSteps, setAvailableSteps] = useState([]);
const location = useLocation();
const isEmployee = userRole === "Collaborateur" || userRole === "Apprenti";
const canViewAllFilters = ['president', 'rh', 'admin', 'directeur de campus', 'directrice de campus'].includes(userRole?.toLowerCase());
// 🎯 NOUVELLE FONCTION : Vérifier si un élément existe dans le DOM
const elementExists = (selector) => {
return document.querySelector(selector) !== null;
};
// 🎯 NOUVELLE FONCTION : Filtrer les étapes selon les éléments disponibles
const filterAvailableSteps = (steps) => {
return steps.filter(step => {
// Les étapes centrées (body) sont toujours affichées
if (step.target === 'body') return true;
// Pour les autres, vérifier si l'élément existe
const element = document.querySelector(step.target);
if (!element) {
console.log(`⚠️ Élément non trouvé, étape ignorée: ${step.target}`);
return false;
}
// Vérifier si l'élément est visible
const isVisible = element.offsetParent !== null;
if (!isVisible) {
console.log(`⚠️ Élément caché, étape ignorée: ${step.target}`);
return false;
}
return true;
});
};
// 🎯 Déclencher le tutoriel avec vérification
useEffect(() => {
if (userId) {
let tutorialKey = '';
if (location.pathname === '/dashboard') {
tutorialKey = 'dashboard';
} else if (location.pathname === '/manager') {
tutorialKey = 'manager';
} else if (location.pathname === '/calendar') {
tutorialKey = 'calendar';
}
if (tutorialKey) {
const hasSeenTutorial = localStorage.getItem(`${tutorialKey}-tutorial-completed-${userId}`);
if (!hasSeenTutorial) {
// ⭐ NOUVEAU : Attendre que le DOM soit chargé
setTimeout(() => {
const allSteps = getTourSteps();
const available = filterAvailableSteps(allSteps);
console.log(`📊 Étapes totales: ${allSteps.length}, disponibles: ${available.length}`);
if (available.length > 2) { // Au moins 3 étapes (intro + 1 élément + conclusion)
setAvailableSteps(available);
setRunTour(true);
} else {
console.log('⚠️ Pas assez d\'éléments pour le tutoriel, annulation');
}
}, 2000);
}
}
}
}, [userId, location.pathname]);
// 🎯 Obtenir les étapes selon la page actuelle
const getTourSteps = () => {
// ==================== DASHBOARD ====================
if (location.pathname === '/dashboard') {
return [
{
target: 'body',
content: (
<div>
<h2 className="text-xl font-bold mb-2">👋 Bienvenue sur votre application GTA !</h2>
<p>Découvrez toutes les fonctionnalités en quelques étapes. Ce tutoriel ne s'affichera qu'une seule fois.</p>
</div>
),
placement: 'center',
disableBeacon: true,
},
{
target: '[data-tour="dashboard"]',
content: '🏠 Accédez à votre tableau de bord pour voir vos soldes de congés.',
placement: 'right',
},
{
target: '[data-tour="demandes"]',
content: '📋 Consultez et gérez toutes vos demandes de congés ici.',
placement: 'right',
},
{
target: '[data-tour="calendrier"]',
content: '📅 Visualisez vos congés et ceux de votre équipe dans le calendrier.',
placement: 'right',
},
{
target: '[data-tour="mon-equipe"]',
content: '👥 Consultez votre équipe et leurs absences.',
placement: 'right',
},
{
target: '[data-tour="nouvelle-demande"]',
content: ' Cliquez ici pour créer une nouvelle demande de congé, RTT ou récupération.',
placement: 'left',
},
{
target: '[data-tour="notifications"]',
content: '🔔 Consultez ici vos notifications (validations, refus, modifications de vos demandes).',
placement: 'bottom',
},
{
target: '[data-tour="refresh"]',
content: '🔄 Rafraîchissez manuellement vos données. Mais pas d\'inquiétude : elles se mettent à jour automatiquement en temps réel !',
placement: 'bottom',
},
{
target: '[data-tour="demandes-recentes"]',
content: '📄 Consultez rapidement vos 5 dernières demandes et leur statut. Cliquez sur "Voir toutes les demandes" pour accéder à la page complète.',
placement: 'top',
},
{
target: '[data-tour="conges-service"]',
content: '👥 Visualisez les congés de votre service pour le mois en cours. Pratique pour planifier vos absences !',
placement: 'top',
},
{
target: 'body',
content: (
<div>
<h2 className="text-lg font-bold mb-2">📊 Vos compteurs de congés</h2>
<p>Découvrez maintenant vos différents soldes de congés disponibles.</p>
</div>
),
placement: 'center',
},
{
target: '[data-tour="cp-n-1"]',
content: '📅 Vos congés payés de l\'année précédente. ⚠️ Attention : ils doivent être soldés avant le 31 mai de l\'année suivante !',
placement: 'top',
},
{
target: '[data-tour="cp-n"]',
content: '📈 Vos congés payés de l\'année en cours, en cours d\'acquisition. Ils se cumulent au fil des mois travaillés.',
placement: 'top',
},
{
target: '[data-tour="rtt"]',
content: '⏰ Vos RTT disponibles pour l\'année en cours. Ils sont acquis progressivement et à consommer avant le 31/12.',
placement: 'top',
},
{
target: '[data-tour="recup"]',
content: '🔄 Vos jours de récupération accumulés suite au JPO/SF.',
placement: 'top',
},
{
target: 'body',
content: (
<div>
<h2 className="text-xl font-bold mb-2">🎉 Vous êtes prêt !</h2>
<p className="mb-3">Vous pouvez maintenant utiliser l'application en toute autonomie.</p>
<div className="bg-cyan-50 border border-cyan-200 rounded-lg p-3 mt-3">
<p className="text-sm text-cyan-900">
💡 <strong>Besoin d'aide ?</strong> Cliquez sur le bouton <strong>"Aide"</strong> 🆘 en bas à droite pour relancer ce tutoriel à tout moment.
</p>
</div>
</div>
),
placement: 'center',
},
];
}
// ==================== MANAGER ====================
if (location.pathname === '/manager') {
const baseSteps = [
{
target: 'body',
content: (
<div>
<h2 className="text-xl font-bold mb-2">👥 Bienvenue dans la gestion d'équipe !</h2>
<p>Découvrez comment gérer {isEmployee ? 'votre équipe' : 'les demandes de congés de votre équipe'}.</p>
</div>
),
placement: 'center',
disableBeacon: true,
}
];
if (!isEmployee) {
// Pour les managers/validateurs
return [
...baseSteps,
{
target: '[data-tour="demandes-attente"]',
content: ' Consultez ici toutes les demandes en attente de validation. Vous pouvez les approuver ou les refuser directement.',
placement: 'right',
},
{
target: '[data-tour="approuver-btn"]',
content: ' Cliquez sur "Approuver" pour valider une demande. Vous pourrez ajouter un commentaire optionnel.',
placement: 'top',
},
{
target: '[data-tour="refuser-btn"]',
content: ' Cliquez sur "Refuser" pour rejeter une demande. Un commentaire expliquant le motif sera obligatoire.',
placement: 'top',
},
{
target: '[data-tour="mon-equipe"]',
content: '👥 Consultez la liste complète de votre équipe. Cliquez sur un membre pour voir le détail de ses demandes.',
placement: 'left',
},
{
target: '[data-tour="historique-demandes"]',
content: '📋 L\'historique complet de toutes les demandes de votre équipe avec leur statut (validée, refusée, en attente).',
placement: 'top',
},
{
target: '[data-tour="document-joint"]',
content: '📎 Si un document est joint à une demande (certificat médical par exemple), vous pouvez le consulter ici.',
placement: 'left',
},
{
target: 'body',
content: (
<div>
<h2 className="text-xl font-bold mb-2">🎉 Vous êtes prêt à gérer votre équipe !</h2>
<p className="mb-3">Vous savez maintenant valider les demandes et suivre les absences de vos collaborateurs.</p>
<div className="bg-cyan-50 border border-cyan-200 rounded-lg p-3 mt-3">
<p className="text-sm text-cyan-900">
💡 <strong>Astuce :</strong> Les données se mettent à jour automatiquement en temps réel. Vous recevrez des notifications pour chaque nouvelle demande.
</p>
</div>
</div>
),
placement: 'center',
}
];
} else {
// Pour les collaborateurs/apprentis
return [
...baseSteps,
{
target: '[data-tour="mon-equipe"]',
content: '👥 Consultez ici la liste de votre équipe. Vous pouvez voir les membres de votre service.',
placement: 'left',
},
{
target: '[data-tour="membre-equipe"]',
content: '👤 Cliquez sur un membre pour voir le détail de ses informations et absences.',
placement: 'left',
},
{
target: 'body',
content: (
<div>
<h2 className="text-xl font-bold mb-2"> C'est tout pour cette section !</h2>
<p className="mb-3">Vous pouvez maintenant consulter votre équipe facilement.</p>
<div className="bg-cyan-50 border border-cyan-200 rounded-lg p-3 mt-3">
<p className="text-sm text-cyan-900">
💡 <strong>Besoin d'aide ?</strong> N'hésitez pas à contacter votre manager pour toute question.
</p>
</div>
</div>
),
placement: 'center',
}
];
}
}
// ==================== CALENDAR ====================
if (location.pathname === '/calendar') {
const baseSteps = [
{
target: 'body',
content: (
<div>
<h2 className="text-xl font-bold mb-2">📅 Bienvenue dans le calendrier !</h2>
<p>Découvrez comment visualiser et gérer les congés {canViewAllFilters ? 'de toute l\'entreprise' : 'de votre équipe'}.</p>
</div>
),
placement: 'center',
disableBeacon: true,
},
{
target: '[data-tour="pto-counter"]',
content: '📊 Votre solde PTO (Paid Time Off) total : somme de vos CP N-1, CP N et RTT disponibles.',
placement: 'bottom',
},
{
target: '[data-tour="navigation-mois"]',
content: '◀️▶️ Naviguez entre les mois pour consulter les congés passés et à venir.',
placement: 'bottom',
}
];
// Étapes pour les filtres selon le rôle
if (canViewAllFilters) {
baseSteps.push(
{
target: '[data-tour="filtres-btn"]',
content: '🔍 Accédez aux filtres pour affiner votre vue : société, campus, service, collaborateurs...',
placement: 'left',
},
{
target: '[data-tour="filtre-societe"]',
content: '🏢 Filtrez par société pour voir uniquement les congés d\'une entité spécifique.',
placement: 'bottom',
},
{
target: '[data-tour="filtre-campus"]',
content: '🏫 Filtrez par campus pour visualiser les absences par site géographique.',
placement: 'bottom',
},
{
target: '[data-tour="filtre-service"]',
content: '👔 Filtrez par service pour voir les congés d\'un département spécifique.',
placement: 'bottom',
}
);
}
// Étapes communes pour tous
baseSteps.push(
{
target: '[data-tour="selection-collaborateurs"]',
content: '👥 Sélectionnez les collaborateurs que vous souhaitez afficher dans le calendrier. Pratique pour se concentrer sur certaines personnes !',
placement: 'top',
},
{
target: '[data-tour="refresh-btn"]',
content: '🔄 Rafraîchissez manuellement les données. Mais rassurez-vous : elles se mettent à jour automatiquement en temps réel via SSE !',
placement: 'left',
},
{
target: 'body',
content: (
<div>
<h2 className="text-lg font-bold mb-2">📅 Sélectionner des dates</h2>
<p>Vous pouvez sélectionner des dates directement dans le calendrier pour créer une demande de congé rapidement.</p>
</div>
),
placement: 'center',
},
{
target: '[data-tour="calendar-grid"]',
content: '🖱️ Cliquez sur une date de début, puis sur une date de fin pour sélectionner une période. Un menu contextuel apparaîtra pour choisir le type de congé.',
placement: 'top',
},
{
target: '[data-tour="legende"]',
content: '🎨 La légende vous aide à identifier les différents types de congés : validés (vert), en attente (orange), formation (bleu), etc.',
placement: 'top',
},
{
target: 'body',
content: (
<div>
<h2 className="text-xl font-bold mb-2">🎉 Vous maîtrisez le calendrier !</h2>
<p className="mb-3">Vous savez maintenant visualiser les congés, filtrer par équipe et créer rapidement des demandes.</p>
<div className="bg-cyan-50 border border-cyan-200 rounded-lg p-3 mt-3">
<p className="text-sm text-cyan-900">
💡 <strong>Astuce :</strong> Survolez une case de congé pour voir tous les détails (employé, type, période, statut). Sur mobile, appuyez sur la case !
</p>
</div>
</div>
),
placement: 'center',
}
);
return baseSteps;
}
return [];
};
// 🎯 Obtenir la clé localStorage selon la page
const getTutorialKey = () => {
if (location.pathname === '/dashboard') return 'dashboard';
if (location.pathname === '/manager') return 'manager';
if (location.pathname === '/calendar') return 'calendar';
return '';
};
// 🎯 Gérer la fin du tutoriel
const handleJoyrideCallback = (data) => {
const { status } = data;
const finishedStatuses = [STATUS.FINISHED, STATUS.SKIPPED];
if (finishedStatuses.includes(status)) {
setRunTour(false);
setDontShowAgain(false);
}
};
// Si on n'a pas d'étapes disponibles, ne rien afficher
if (availableSteps.length === 0) return null;
return (
<Joyride
steps={availableSteps}
run={runTour}
continuous
showProgress={true}
showSkipButton={false}
scrollToFirstStep
scrollOffset={100}
callback={handleJoyrideCallback}
styles={{
options: {
primaryColor: '#0891b2',
zIndex: 10000,
},
}}
floaterProps={{
disableAnimation: true,
}}
locale={{
back: 'Retour',
close: 'Fermer',
last: 'Terminer',
next: 'Suivant',
skip: 'Passer'
}}
tooltipComponent={({
continuous,
index,
step,
backProps,
primaryProps,
skipProps,
closeProps,
tooltipProps,
size,
isLastStep
}) => {
const [showConfirmModal, setShowConfirmModal] = React.useState(false);
const tutorialKey = getTutorialKey();
const handleFinish = () => {
if (dontShowAgain) {
localStorage.setItem(`${tutorialKey}-tutorial-completed-${userId}`, 'true');
}
setRunTour(false);
setDontShowAgain(false);
};
const handleSkip = () => {
if (dontShowAgain) {
setShowConfirmModal(true);
} else {
setRunTour(false);
setDontShowAgain(false);
}
};
const confirmSkip = () => {
localStorage.setItem(`${tutorialKey}-tutorial-completed-${userId}`, 'true');
setShowConfirmModal(false);
setRunTour(false);
setDontShowAgain(false);
};
const cancelSkip = () => {
setShowConfirmModal(false);
setDontShowAgain(false);
};
return (
<>
{/* Modal de confirmation */}
{showConfirmModal && (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10001
}}
onClick={(e) => {
if (e.target === e.currentTarget) {
cancelSkip();
}
}}>
<div style={{
backgroundColor: 'white',
borderRadius: '16px',
padding: '24px',
maxWidth: '400px',
width: '90%',
boxShadow: '0 20px 50px rgba(0,0,0,0.3)'
}}>
<div style={{
fontSize: '48px',
marginBottom: '16px',
textAlign: 'center'
}}>
</div>
<h3 style={{
fontSize: '18px',
fontWeight: 'bold',
marginBottom: '12px',
color: '#111827',
textAlign: 'center'
}}>
Ne plus afficher le tutoriel ?
</h3>
<p style={{
fontSize: '14px',
color: '#6b7280',
marginBottom: '24px',
textAlign: 'center',
lineHeight: '1.5'
}}>
Êtes-vous sûr de vouloir désactiver définitivement ce tutoriel ?
{tutorialKey === 'dashboard' && ' Vous pourrez le réactiver plus tard en cliquant sur le bouton "Aide".'}
</p>
<div style={{
display: 'flex',
gap: '12px',
justifyContent: 'center'
}}>
<button
onClick={cancelSkip}
style={{
padding: '10px 20px',
borderRadius: '8px',
border: '1px solid #d1d5db',
backgroundColor: 'white',
color: '#374151',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500',
transition: 'all 0.2s'
}}
>
Annuler
</button>
<button
onClick={confirmSkip}
style={{
padding: '10px 20px',
borderRadius: '8px',
border: 'none',
backgroundColor: '#ef4444',
color: 'white',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500',
transition: 'all 0.2s'
}}
>
Oui, ne plus afficher
</button>
</div>
</div>
</div>
)}
{/* Tooltip principal */}
<div {...tooltipProps} style={{
backgroundColor: 'white',
borderRadius: '12px',
padding: '20px',
maxWidth: '400px',
boxShadow: '0 10px 25px rgba(0,0,0,0.15)',
fontSize: '14px'
}}>
<div style={{ marginBottom: '15px', color: '#374151' }}>
{step.content}
</div>
{/* Case à cocher "Ne plus afficher" */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginTop: '12px',
marginBottom: '12px',
padding: '10px',
backgroundColor: '#f9fafb',
borderRadius: '8px',
border: '1px solid #e5e7eb'
}}>
<input
type="checkbox"
id={`dont-show-again-${index}`}
checked={dontShowAgain}
onChange={(e) => setDontShowAgain(e.target.checked)}
style={{
width: '18px',
height: '18px',
cursor: 'pointer',
accentColor: '#0891b2'
}}
/>
<label
htmlFor={`dont-show-again-${index}`}
style={{
fontSize: '13px',
color: '#374151',
cursor: 'pointer',
userSelect: 'none',
fontWeight: '500'
}}
>
Ne plus afficher ce tutoriel
</label>
</div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: '12px',
borderTop: '1px solid #e5e7eb'
}}>
<span style={{ fontSize: '13px', color: '#6b7280', fontWeight: '500' }}>
Étape {index + 1} sur {size}
</span>
<div style={{ display: 'flex', gap: '8px' }}>
{index > 0 && (
<button
{...backProps}
style={{
padding: '8px 14px',
borderRadius: '8px',
border: '1px solid #d1d5db',
backgroundColor: 'white',
color: '#6b7280',
cursor: 'pointer',
fontSize: '13px',
fontWeight: '500',
transition: 'all 0.2s'
}}>
Retour
</button>
)}
{!isLastStep && (
<button
{...primaryProps}
style={{
padding: '8px 18px',
borderRadius: '8px',
border: 'none',
backgroundColor: '#0891b2',
color: 'white',
cursor: 'pointer',
fontSize: '13px',
fontWeight: '500',
transition: 'all 0.2s'
}}
>
Suivant
</button>
)}
{isLastStep && (
<button
onClick={handleFinish}
style={{
padding: '8px 18px',
borderRadius: '8px',
border: 'none',
backgroundColor: '#0891b2',
color: 'white',
cursor: 'pointer',
fontSize: '13px',
fontWeight: '500',
transition: 'all 0.2s'
}}
>
Terminer
</button>
)}
<button
onClick={handleSkip}
style={{
padding: '8px 14px',
borderRadius: '8px',
border: '1px solid #d1d5db',
backgroundColor: 'white',
color: '#6b7280',
cursor: 'pointer',
fontSize: '13px',
fontWeight: '500',
transition: 'all 0.2s'
}}
>
Passer
</button>
</div>
</div>
</div>
</>
);
}}
/>
);
};
export default GlobalTutorial;

View File

@@ -1 +0,0 @@

View File

@@ -15,7 +15,7 @@ const MedicalDocuments = ({ demandeId }) => {
try {
setLoading(true);
const response = await fetch(`http://localhost:3000/medical-documents/${demandeId}`);
const response = await fetch(`/api/medical-documents/${demandeId}`);
const data = await response.json();
if (data.success) {
@@ -116,7 +116,7 @@ const MedicalDocuments = ({ demandeId }) => {
</div>
</div>
<a
href={`http://localhost:3000${doc.downloadUrl}`}
href={`${doc.downloadUrl}`}
download
className="flex-shrink-0 p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="Télécharger"

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { X, Upload, AlertCircle } from 'lucide-react';
import { X, AlertCircle } from 'lucide-react';
const NewLeaveRequestModal = ({
onClose,
@@ -17,22 +17,122 @@ const NewLeaveRequestModal = ({
types: preselectedType ? [preselectedType] : [],
startDate: preselectedStartDate || '',
endDate: preselectedEndDate || '',
reason: '',
medicalDocuments: []
reason: ''
});
const [repartition, setRepartition] = useState({});
const [periodeSelection, setPeriodeSelection] = useState({});
const [totalDays, setTotalDays] = useState(0);
const [saturdayCount, setSaturdayCount] = useState(0);
const [holidayCount, setHolidayCount] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState('');
const safeCounters = {
availableCP: availableLeaveCounters?.availableCP ?? 0,
availableRTT: availableLeaveCounters?.availableRTT ?? 0
const [countersData, setCountersData] = useState(null);
const [isLoadingCounters, setIsLoadingCounters] = useState(true);
// ⭐ État pour stocker les jours fériés
const [publicHolidays, setPublicHolidays] = useState({});
// ⭐ Fonction pour vérifier si une date est un weekend
const isWeekend = (dateString) => {
if (!dateString) return false;
const date = new Date(dateString);
const day = date.getDay();
return day === 0 || day === 6; // 0 = Dimanche, 6 = Samedi
};
// ⭐ Fonction pour obtenir le prochain jour ouvrable
const getNextWorkingDay = (dateString) => {
const date = new Date(dateString);
const day = date.getDay();
// Si c'est vendredi (5), ajouter 3 jours pour arriver à lundi
if (day === 5) {
date.setDate(date.getDate() + 3);
}
// Si c'est samedi (6), ajouter 2 jours
else if (day === 6) {
date.setDate(date.getDate() + 2);
}
// Si c'est dimanche (0), ajouter 1 jour
else if (day === 0) {
date.setDate(date.getDate() + 1);
}
return date.toISOString().split('T')[0];
};
// ⭐ Charger les jours fériés depuis l'API gouvernementale
useEffect(() => {
const fetchPublicHolidays = async () => {
try {
const currentYear = new Date().getFullYear();
const nextYear = currentYear + 1;
const [currentYearData, nextYearData] = await Promise.all([
fetch(`https://calendrier.api.gouv.fr/jours-feries/metropole/${currentYear}.json`).then(r => r.json()),
fetch(`https://calendrier.api.gouv.fr/jours-feries/metropole/${nextYear}.json`).then(r => r.json())
]);
const allHolidays = { ...currentYearData, ...nextYearData };
setPublicHolidays(allHolidays);
console.log('📅 Jours fériés chargés:', allHolidays);
} catch (error) {
console.error('❌ Erreur chargement jours fériés:', error);
}
};
fetchPublicHolidays();
}, []);
// ⭐ Fonction pour vérifier si une date est un jour férié
const isPublicHoliday = (date) => {
const dateStr = date.toISOString().split('T')[0];
return dateStr in publicHolidays;
};
useEffect(() => {
const fetchCounters = async () => {
if (!userId) return;
setIsLoadingCounters(true);
try {
const response = await fetch(
`/api/getDetailedLeaveCounters?user_id=${userId}`
);
const data = await response.json();
if (data.success) {
console.log('📊 Compteurs reçus:', data);
setCountersData(data);
} else {
console.error('❌ Erreur compteurs:', data.message);
}
} catch (error) {
console.error('❌ Erreur réseau compteurs:', error);
} finally {
setIsLoadingCounters(false);
}
};
fetchCounters();
}, [userId]);
const safeCounters = countersData ? {
availableCP: parseFloat(countersData.data?.totalDisponible?.cp || 0),
availableRTT: parseFloat(countersData.data?.totalDisponible?.rtt || 0),
availableRecup: parseFloat(countersData.data?.totalDisponible?.recup || 0)
} : {
availableCP: 0,
availableRTT: 0,
availableRecup: 0
};
console.log('📊 Compteurs disponibles:', safeCounters);
console.log('📊 Données complètes:', countersData);
useEffect(() => {
if (preselectedStartDate || preselectedEndDate) {
setFormData(prev => ({
@@ -44,37 +144,45 @@ const NewLeaveRequestModal = ({
}
}, [preselectedStartDate, preselectedEndDate, preselectedType]);
// 🔹 Calcul automatique - JOURS OUVRÉS UNIQUEMENT (Lun-Ven)
// Calcul des jours ouvrés en excluant les jours fériés
useEffect(() => {
if (formData.startDate && formData.endDate) {
const start = new Date(formData.startDate);
const end = new Date(formData.endDate);
let workingDays = 0;
let saturdays = 0;
let holidays = 0;
const current = new Date(start);
while (current <= end) {
const dayOfWeek = current.getDay();
if (dayOfWeek === 6) {
const isHoliday = isPublicHoliday(current);
if (isHoliday) {
holidays++;
console.log(`🎉 Jour férié détecté: ${current.toISOString().split('T')[0]} - ${publicHolidays[current.toISOString().split('T')[0]]}`);
} else if (dayOfWeek === 6) {
saturdays++;
} else if (dayOfWeek !== 0) {
workingDays++;
}
current.setDate(current.getDate() + 1);
}
setTotalDays(workingDays);
setSaturdayCount(saturdays);
setHolidayCount(holidays);
console.log('📊 Calcul période:', {
debut: formData.startDate,
fin: formData.endDate,
joursOuvres: workingDays,
samedis: saturdays,
message: 'Les samedis ne sont comptés QUE si "Récup" est coché'
joursFeries: holidays
});
}
}, [formData.startDate, formData.endDate]);
}, [formData.startDate, formData.endDate, publicHolidays]);
const getMinDate = () => {
const tomorrow = new Date();
@@ -82,6 +190,13 @@ const NewLeaveRequestModal = ({
return tomorrow.toISOString().split('T')[0];
};
// ⭐ NOUVELLE FONCTION : Date minimum pour Formation (1 semaine = 7 jours)
const getMinDateFormation = () => {
const nextWeek = new Date();
nextWeek.setDate(nextWeek.getDate() + 7);
return nextWeek.toISOString().split('T')[0];
};
useEffect(() => {
if (formData.types.length > 0) {
const newRepartition = {};
@@ -105,20 +220,15 @@ const NewLeaveRequestModal = ({
const handlePeriodeChange = (type, periode) => {
setPeriodeSelection(prev => ({ ...prev, [type]: periode }));
// ⭐ CORRECTION : Ajuster automatiquement pour TOUTES les situations
if (formData.types.length === 1) {
// Si un seul type : ajuster selon la période
if (periode === 'Matin' || periode === 'Après-midi') {
setRepartition(prev => ({ ...prev, [type]: 0.5 }));
} else {
setRepartition(prev => ({ ...prev, [type]: totalDays }));
}
} else {
// Si plusieurs types : garder la répartition manuelle
// MAIS si c'est une période partielle sur une journée unique
if (formData.startDate === formData.endDate && (periode === 'Matin' || periode === 'Après-midi')) {
const currentRepartition = repartition[type] || 0;
// Suggérer 0.5 si pas encore défini
if (currentRepartition === 0 || currentRepartition === 1) {
setRepartition(prev => ({ ...prev, [type]: 0.5 }));
}
@@ -126,49 +236,14 @@ const NewLeaveRequestModal = ({
}
};
const handleFileUpload = (e) => {
const files = Array.from(e.target.files);
const validFiles = [];
const maxSize = 5 * 1024 * 1024;
for (const file of files) {
const validTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png'];
if (!validTypes.includes(file.type)) {
setError(`Le fichier "${file.name}" n'est pas un format valide.`);
continue;
}
if (file.size > maxSize) {
setError(`Le fichier "${file.name}" est trop volumineux.`);
continue;
}
validFiles.push(file);
}
setFormData(prev => ({
...prev,
medicalDocuments: [...prev.medicalDocuments, ...validFiles]
}));
e.target.value = '';
};
const removeDocument = (index) => {
setFormData(prev => ({
...prev,
medicalDocuments: prev.medicalDocuments.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 validateForm = () => {
console.log('\n🔍 === VALIDATION FORMULAIRE ===');
console.log('📊 countersData:', countersData);
console.log('📊 countersData.success:', countersData?.success);
console.log('📊 countersData.data:', countersData?.data);
console.log('📊 totalDisponible:', countersData?.data?.totalDisponible);
console.log('📊 totalDisponible.cp:', countersData?.data?.totalDisponible?.cp);
console.log('📊 safeCounters:', safeCounters);
if (formData.types.length === 0) {
setError('Veuillez sélectionner au moins un type de congé');
@@ -185,6 +260,17 @@ const NewLeaveRequestModal = ({
const start = new Date(formData.startDate);
const end = new Date(formData.endDate);
// ⭐ VALIDATION SPÉCIALE POUR FORMATION (7 JOURS D'AVANCE)
if (formData.types.includes('Formation')) {
const minFormationDate = new Date(today);
minFormationDate.setDate(minFormationDate.getDate() + 7);
if (start < minFormationDate) {
setError('La formation doit être posée au moins 7 jours à l\'avance (1 semaine minimum).');
return false;
}
}
if (start.getTime() === today.getTime() || end.getTime() === today.getTime()) {
setError("Vous ne pouvez pas poser un congé pour aujourd'hui.");
return false;
@@ -200,58 +286,64 @@ const NewLeaveRequestModal = ({
return false;
}
const hasRecup = formData.types.includes('Récup');
const hasABS = formData.types.includes('ABS');
if (!countersData || !countersData.data || !countersData.data.totalDisponible) {
console.error('❌ Données compteurs non disponibles pour validation !');
setError('Erreur : Les compteurs ne sont pas chargés. Veuillez réessayer.');
return false;
}
// ⭐ NOUVEAU : Calculer le total attendu en tenant compte des demi-journées
let expectedTotal = totalDays;
const joursDemandesParType = {};
// Si un seul type avec demi-journée sur une journée unique
if (formData.types.length === 1 && formData.startDate === formData.endDate) {
if (formData.types.length === 1) {
const type = formData.types[0];
const periode = periodeSelection[type];
const periode = periodeSelection[type] || 'Journée entière';
if ((type === 'CP' || type === 'RTT') && (periode === 'Matin' || periode === 'Après-midi')) {
expectedTotal = 0.5;
console.log('📊 Demi-journée détectée, expectedTotal = 0.5');
if (formData.startDate === formData.endDate && (periode === 'Matin' || periode === 'Après-midi')) {
joursDemandesParType[type] = 0.5;
} else {
joursDemandesParType[type] = totalDays;
}
} else {
formData.types.forEach(type => {
joursDemandesParType[type] = repartition[type] || 0;
});
}
console.log('📊 Jours demandés:', joursDemandesParType);
console.log('📊 Soldes disponibles:', safeCounters);
if (joursDemandesParType['CP'] > 0) {
const cpDemande = joursDemandesParType['CP'];
const cpDisponible = safeCounters.availableCP;
console.log(`🔍 CP: ${cpDemande}j demandés vs ${cpDisponible}j disponibles`);
if (cpDemande > cpDisponible) {
setError(`Solde CP insuffisant (${cpDisponible.toFixed(2)}j disponibles avec anticipation, ${cpDemande}j demandés)`);
return false;
}
}
console.log('📊 Analyse:', {
joursOuvres: totalDays,
expectedTotal: expectedTotal,
samedis: saturdayCount,
typesCoches: formData.types,
hasRecup
});
if (joursDemandesParType['RTT'] > 0) {
const rttDemande = joursDemandesParType['RTT'];
const rttDisponible = safeCounters.availableRTT;
if (hasRecup && saturdayCount === 0) {
setError('Une récupération nécessite au moins un samedi dans la période. Veuillez sélectionner une période incluant un samedi ou décocher "Récupération (samedi)".');
return false;
console.log(`🔍 RTT: ${rttDemande}j demandés vs ${rttDisponible}j disponibles`);
if (rttDemande > rttDisponible) {
setError(`Solde RTT insuffisant (${rttDisponible.toFixed(2)}j disponibles avec anticipation, ${rttDemande}j demandés)`);
return false;
}
}
if (saturdayCount > 0 && !hasRecup) {
console.log(`⚠️ ${saturdayCount} samedi(s) détecté(s) mais "Récup" non coché - Les samedis seront ignorés`);
}
if (joursDemandesParType['Récup'] > 0) {
const recupDemande = joursDemandesParType['Récup'];
const recupDisponible = safeCounters.availableRecup;
if (hasABS && formData.medicalDocuments.length === 0) {
setError('Un justificatif médical est obligatoire pour un arrêt maladie');
return false;
}
console.log(`🔍 Récup: ${recupDemande}j demandés vs ${recupDisponible}j disponibles`);
// ⭐ VALIDATION RÉPARTITION AMÉLIORÉE
if (formData.types.length > 1) {
const sum = Object.values(repartition).reduce((a, b) => a + b, 0);
console.log('📊 Validation répartition:', {
somme: sum,
attendu: expectedTotal,
joursOuvres: totalDays,
samedisIgnores: !hasRecup ? saturdayCount : 0
});
if (Math.abs(sum - expectedTotal) > 0.01) {
setError(`La somme des jours répartis (${sum.toFixed(1)}j) doit être égale au total de jours ouvrés (${expectedTotal}j). ${saturdayCount > 0 && !hasRecup ? `Les ${saturdayCount} samedi(s) ne sont pas comptés.` : ''}`);
if (recupDemande > recupDisponible) {
setError(`Solde Récup insuffisant (${recupDisponible.toFixed(2)}j disponibles, ${recupDemande}j demandés)`);
return false;
}
}
@@ -260,7 +352,6 @@ const NewLeaveRequestModal = ({
return true;
};
const handleSubmit = async () => {
setError('');
@@ -281,15 +372,13 @@ const NewLeaveRequestModal = ({
formDataToSend.append('Nom', userName);
formDataToSend.append('Commentaire', formData.reason || '');
// ⭐ CORRECTION : Calculer le NombreJours total correctement
let totalJoursToSend = totalDays;
// Si un seul type avec demi-journée
if (formData.types.length === 1 && formData.startDate === formData.endDate) {
const type = formData.types[0];
const periode = periodeSelection[type];
if ((type === 'CP' || type === 'RTT') && (periode === 'Matin' || periode === 'Après-midi')) {
if ((type === 'CP' || type === 'RTT' || type === 'Récup') && (periode === 'Matin' || periode === 'Après-midi')) {
totalJoursToSend = 0.5;
}
}
@@ -297,50 +386,31 @@ const NewLeaveRequestModal = ({
formDataToSend.append('NombreJours', totalJoursToSend);
const repartitionArray = formData.types.map(type => {
if (type === 'Récup' && formData.types.length === 1) {
console.log(`📝 Récup seul: ${saturdayCount} samedi(s)`);
return {
TypeConge: type,
NombreJours: saturdayCount,
PeriodeJournee: 'Journée entière'
};
}
if (type === 'Récup' && formData.types.length > 1) {
const joursRecup = repartition[type] || saturdayCount;
console.log(`📝 Récup (répartition): ${joursRecup}j`);
return {
TypeConge: type,
NombreJours: joursRecup,
PeriodeJournee: 'Journée entière'
};
}
// ⭐ CORRECTION : Gérer demi-journées pour un seul type
let nombreJours;
let periodeJournee = 'Journée entière';
if (formData.types.length === 1) {
// Un seul type : utiliser soit 0.5 (demi-journée) soit totalDays
const periode = periodeSelection[type];
if ((type === 'CP' || type === 'RTT') &&
const periode = periodeSelection[type] || 'Journée entière';
if ((type === 'CP' || type === 'RTT' || type === 'Récup') &&
formData.startDate === formData.endDate &&
(periode === 'Matin' || periode === 'Après-midi')) {
nombreJours = 0.5;
periodeJournee = periode;
} else {
nombreJours = totalDays;
}
} else {
// Plusieurs types : utiliser la répartition manuelle
nombreJours = repartition[type] || 0;
periodeJournee = periodeSelection[type] || 'Journée entière';
}
console.log(`📝 ${type}: ${nombreJours}j (${periodeSelection[type] || 'Journée entière'})`);
console.log(`📝 ${type}: ${nombreJours}j (${periodeJournee})`);
return {
TypeConge: type,
NombreJours: nombreJours,
PeriodeJournee: ['CP', 'RTT'].includes(type)
? (periodeSelection[type] || 'Journée entière')
: 'Journée entière'
PeriodeJournee: ['CP', 'RTT', 'Récup'].includes(type) ? periodeJournee : 'Journée entière'
};
});
@@ -349,11 +419,7 @@ const NewLeaveRequestModal = ({
formDataToSend.append('Repartition', JSON.stringify(repartitionArray));
formData.medicalDocuments.forEach((file) => {
formDataToSend.append('medicalDocuments', file);
});
const response = await fetch('http://localhost:3000/submitLeaveRequest', {
const response = await fetch('/api/submitLeaveRequest', {
method: 'POST',
body: formDataToSend
});
@@ -376,7 +442,6 @@ const NewLeaveRequestModal = ({
}
};
const handleTypeToggle = (type) => {
setFormData(prev => ({
...prev,
@@ -386,18 +451,40 @@ const NewLeaveRequestModal = ({
}));
};
// ⭐ Ajout de Formation en bleu pour les apprentis
const availableTypes = userRole === 'Apprenti'
? [
{ key: 'CP', label: 'Congés payés', available: safeCounters.availableCP },
{ key: 'ABS', label: 'Arrêt maladie' },
{
key: 'CP',
label: 'Congé(s) payé(s)',
available: countersData?.data?.cpN?.solde || 0,
details: countersData?.data?.cpN
},
{ key: 'Formation', label: 'Formation' },
{ key: 'Récup', label: 'Récupération (samedi)' },
{
key: 'Récup',
label: 'Récupération(s)',
available: countersData?.data?.recupN?.solde || 0
},
]
: [
{ key: 'CP', label: 'Congés payés', available: safeCounters.availableCP },
{ key: 'RTT', label: 'RTT', available: safeCounters.availableRTT },
{ key: 'ABS', label: 'Arrêt maladie' },
{ key: 'Récup', label: 'Récupération (samedi)' },
{
key: 'CP',
label: 'Congé(s) payé(s)',
available: countersData?.data?.cpN?.solde || 0,
details: countersData?.data?.cpN
},
{
key: 'RTT',
label: 'RTT',
available: countersData?.data?.rttN?.solde || 0,
details: countersData?.data?.rttN
},
{
key: 'Récup',
label: 'Récupération',
available: countersData?.data?.recupN?.solde || 0
}
];
return (
@@ -411,36 +498,69 @@ const NewLeaveRequestModal = ({
</div>
<div className="p-6 space-y-5">
{isLoadingCounters && (
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 text-center">
<div className="animate-spin h-6 w-6 border-2 border-blue-600 border-t-transparent rounded-full mx-auto mb-2"></div>
<p className="text-sm text-gray-600">Chargement des soldes...</p>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-900 mb-3">
Types de congé *
Types d'absences *
</label>
<div className="space-y-2">
{availableTypes.map(type => (
<label key={type.key} className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={formData.types.includes(type.key)}
onChange={() => handleTypeToggle(type.key)}
className="w-4 h-4 rounded border-gray-300"
/>
<span className={`px-2.5 py-1 rounded text-sm font-medium ${type.key === 'CP' ? 'bg-blue-100 text-blue-800' :
{availableTypes.map(type => {
return (
<label
key={type.key}
className="flex items-center gap-3 cursor-pointer"
>
<input
type="checkbox"
checked={formData.types.includes(type.key)}
onChange={() => handleTypeToggle(type.key)}
className="w-4 h-4 rounded border-gray-300"
/>
<span className={`px-2.5 py-1 rounded text-sm font-medium ${type.key === 'CP' ? 'bg-blue-100 text-blue-800' :
type.key === 'RTT' ? 'bg-green-100 text-green-800' :
type.key === 'ABS' ? 'bg-red-100 text-red-800' :
'bg-purple-100 text-purple-800'
}`}>
{type.label}
</span>
{type.available !== undefined && (
<span className="text-sm text-gray-600">
({type.available.toFixed(2)} disponibles)
type.key === 'Récup' ? 'bg-orange-100 text-orange-800' :
type.key === 'Formation' ? 'bg-blue-100 text-blue-800' :
'bg-purple-100 text-purple-800'
}`}>
{type.label}
</span>
)}
</label>
))}
{type.available !== undefined && (
<div className="flex flex-col text-xs">
<span className={`font-semibold ${type.details?.solde < 0 || type.details?.anticipe?.depassement > 0
? 'text-red-600'
: 'text-gray-600'
}`}>
({type.available.toFixed(2)} disponibles)
</span>
{type.details?.anticipe?.depassement > 0 && (
<span className="text-red-600 text-xs italic">
⚠️ Dépassement anticipation
</span>
)}
</div>
)}
</label>
);
})}
</div>
</div>
{/* ⭐ AVERTISSEMENT FORMATION */}
{formData.types.includes('Formation') && (
<div className="flex items-start gap-2 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<AlertCircle className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
<p className="text-blue-700 text-xs">
⚠️ La formation doit être posée au moins <strong>7 jours à l'avance</strong> (1 semaine minimum).
</p>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-900 mb-2">
@@ -449,10 +569,32 @@ const NewLeaveRequestModal = ({
<input
type="date"
value={formData.startDate}
min={getMinDate()}
onChange={(e) => setFormData(prev => ({ ...prev, startDate: e.target.value }))}
min={formData.types.includes('Formation') ? getMinDateFormation() : getMinDate()}
onChange={(e) => {
const newStartDate = e.target.value;
setFormData(prev => {
// Si c'est un weekend, ajuster automatiquement
const adjustedDate = isWeekend(newStartDate)
? getNextWorkingDay(newStartDate)
: newStartDate;
return {
...prev,
startDate: adjustedDate,
// Mettre à jour la date de fin si elle est vide ou antérieure
endDate: !prev.endDate || new Date(prev.endDate) < new Date(adjustedDate)
? adjustedDate
: prev.endDate
};
});
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
{formData.startDate && isWeekend(formData.startDate) && (
<p className="text-xs text-orange-600 mt-1">
Les weekends ne sont pas comptabilisés
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-900 mb-2">
@@ -461,19 +603,53 @@ const NewLeaveRequestModal = ({
<input
type="date"
value={formData.endDate}
min={getMinDate()}
onChange={(e) => setFormData(prev => ({ ...prev, endDate: e.target.value }))}
min={formData.startDate || (formData.types.includes('Formation') ? getMinDateFormation() : getMinDate())}
onChange={(e) => {
const newEndDate = e.target.value;
// Si c'est un weekend, ajuster automatiquement
const adjustedDate = isWeekend(newEndDate)
? getNextWorkingDay(newEndDate)
: newEndDate;
setFormData(prev => ({
...prev,
endDate: adjustedDate
}));
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
{formData.endDate && isWeekend(formData.endDate) && (
<p className="text-xs text-orange-600 mt-1">
Les weekends ne sont pas comptabilisés
</p>
)}
</div>
</div>
{/* ⭐ Affichage du récapitulatif avec jours fériés */}
{totalDays > 0 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-blue-900">
Jours ouvrés : <span className="text-lg font-bold">{totalDays}</span>
</p>
{holidayCount > 0 && (
<p className="text-xs text-blue-700 mt-1">
🎉 {holidayCount} jour{holidayCount > 1 ? 's' : ''} férié{holidayCount > 1 ? 's' : ''} exclu{holidayCount > 1 ? 's' : ''}
</p>
)}
{saturdayCount > 0 && (
<p className="text-xs text-blue-700">
📅 {saturdayCount} samedi{saturdayCount > 1 ? 's' : ''} exclu{saturdayCount > 1 ? 's' : ''}
</p>
)}
</div>
</div>
</div>
)}
{/* ⭐ SECTION PÉRIODE POUR UN SEUL TYPE */}
{/* ⭐ SECTION PÉRIODE POUR UN SEUL TYPE */}
{formData.types.length === 1 && ['CP', 'RTT'].includes(formData.types[0]) && (
{formData.types.length === 1 && ['CP', 'RTT', 'Récup'].includes(formData.types[0]) && (
<div className="border-t border-gray-200 pt-4">
<h3 className="text-sm font-semibold text-gray-900 mb-2">
Période de la journée
@@ -496,7 +672,6 @@ const NewLeaveRequestModal = ({
))}
</div>
{/* 🎯 AFFICHAGE DU NOMBRE DE JOURS */}
<div className="mt-3 flex items-center justify-center">
<div className="bg-blue-50 border border-blue-200 rounded-lg px-4 py-2 inline-flex items-center gap-2">
<span className="text-sm font-medium text-blue-900">Durée sélectionnée :</span>
@@ -506,10 +681,8 @@ const NewLeaveRequestModal = ({
const periode = periodeSelection[type] || 'Journée entière';
if (formData.startDate === formData.endDate) {
// Journée unique
return (periode === 'Matin' || periode === 'Après-midi') ? '0.5 jour' : '1 jour';
} else {
// Plusieurs jours
return (periode === 'Matin' || periode === 'Après-midi')
? `${(totalDays - 0.5).toFixed(1)} jours`
: `${totalDays} jour${totalDays > 1 ? 's' : ''}`;
@@ -521,20 +694,17 @@ const NewLeaveRequestModal = ({
</div>
)}
{/* ⭐ SECTION RÉPARTITION POUR PLUSIEURS TYPES */}
{/* ⭐ SECTION RÉPARTITION POUR PLUSIEURS TYPES */}
{formData.types.length > 1 && totalDays > 0 && (
<div className="border-t border-gray-200 pt-4">
<h3 className="text-sm font-semibold text-gray-900 mb-2">
Répartition des {totalDays} jours ouvrés
</h3>
<p className="text-xs text-gray-500 mb-4">
La somme doit être égale à {totalDays} jour(s)
Indiquez la répartition souhaitée
</p>
<div className="space-y-3">
{formData.types.map((type) => {
const showPeriode = ['CP', 'RTT'].includes(type);
const showPeriode = ['CP', 'RTT', 'Récup'].includes(type);
const currentValue = repartition[type] || 0;
return (
@@ -547,11 +717,10 @@ const NewLeaveRequestModal = ({
type="number"
step="0.5"
min="0"
max={type === 'Récup' ? saturdayCount : totalDays}
max={totalDays}
value={repartition[type] || ''}
onChange={(e) => handleRepartitionChange(type, e.target.value)}
className="w-24 px-2 py-1 border rounded text-right text-sm"
placeholder={type === 'Récup' ? `Max ${saturdayCount}` : ''}
/>
</div>
@@ -593,7 +762,6 @@ const NewLeaveRequestModal = ({
</button>
</div>
{/* 🎯 AFFICHAGE DU NOMBRE DE JOURS POUR CE TYPE */}
<div className="mt-2 text-center">
<span className="text-xs font-medium text-gray-600">
Durée :
@@ -611,7 +779,6 @@ const NewLeaveRequestModal = ({
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-900 mb-2">
Motif (optionnel)
@@ -625,80 +792,10 @@ const NewLeaveRequestModal = ({
/>
</div>
{formData.types.includes('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>
{formData.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 ({formData.medicalDocuments.length}) :
</p>
{formData.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>
)}
</div>
)}
{error && (
<div className="flex items-start gap-2 p-3 bg-red-50 border border-red-200 rounded-lg">
<AlertCircle className="w-4 h-4 text-red-600 flex-shrink-0 mt-0.5" />
<p className="text-red-700 text-sm">{error}</p>
<p className="text-red-700 text-sm whitespace-pre-line">{error}</p>
</div>
)}
@@ -713,7 +810,7 @@ const NewLeaveRequestModal = ({
<button
type="button"
onClick={handleSubmit}
disabled={isSubmitting}
disabled={isSubmitting || isLoadingCounters}
className="flex-1 px-4 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed font-medium transition-colors"
>
{isSubmitting ? 'Envoi...' : 'Soumettre'}

View File

@@ -1,22 +1,35 @@
import React from 'react';
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
const ProtectedRoute = ({ children }) => {
const { user, isLoading } = useAuth();
const ProtectedRoute = ({ children, allowedRoles = [] }) => {
const { isAuthorized, user, isLoading } = useAuth();
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Chargement...</p>
</div>
</div>
);
}
// ✅ FIX MOBILE : Attendre la fin du chargement avant de rediriger
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
<div className="text-center">
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-cyan-600 mx-auto mb-4"></div>
<p className="text-gray-600 font-medium">Chargement en cours...</p>
</div>
</div>
);
}
return user ? children : <Navigate to="/login" replace />;
// ✅ Vérifier si l'utilisateur est autorisé
if (!isAuthorized || !user) {
console.log('❌ ProtectedRoute: Utilisateur non autorisé, redirection vers /login');
return <Navigate to="/login" replace />;
}
// ✅ Vérifier les rôles autorisés si spécifiés
if (allowedRoles.length > 0 && !allowedRoles.includes(user.role)) {
console.log(`❌ ProtectedRoute: Rôle ${user.role} non autorisé pour cette route`);
return <Navigate to="/dashboard" replace />;
}
return children;
};
export default ProtectedRoute;

View File

@@ -27,27 +27,48 @@ const Sidebar = ({ isOpen, onToggle }) => {
return 'bg-cyan-600 text-white';
case 'Collaboratrice':
return 'bg-cyan-600 text-white';
case 'Apprenti':
return 'bg-blue-100 text-blue-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
// Vérifier si l'utilisateur peut voir le compte-rendu d'activités
const canViewCompteRendu = () => {
const allowedRoles = [
'Validateur',
'Validatrice',
'Directeur de campus',
'Directrice de campus',
'President',
'Admin',
'RH'
];
return allowedRoles.includes(user?.role);
};
// ✅ VERSION ULTRA-ROBUSTE pour isForfaitJour
const isForfaitJour = (() => {
if (!user?.TypeContrat && !user?.typeContrat) return false;
// Vérifier si l'utilisateur est en forfait jour
const isForfaitJour = user?.TypeContrat === 'forfait_jour' || user?.typeContrat === 'forfaitjour';
const typeContrat = (user?.TypeContrat || user?.typeContrat || '').toString().toLowerCase();
// Normaliser : retirer espaces, underscores, tirets
const normalized = typeContrat.replace(/[\s_-]/g, '');
return normalized === 'forfaitjour';
})();
// ✅ Vérification pour l'accès équipe
const hasTeamAccess = [
'Collaborateur',
'Collaboratrice',
'Apprenti',
'Validateur',
'Validatrice',
'Manager',
'RH',
'Directeur de campus',
'Directrice de campus',
'President',
'Admin'
].includes(user?.role);
const isCollaboratorRole = ['Collaborateur', 'Collaboratrice', 'Apprenti'].includes(user?.role);
const teamPath = isCollaboratorRole ? '/collaborateur' : '/manager';
// 🐛 DEBUG
console.log('👤 User:', user);
console.log('📋 Type Contrat RAW:', user?.TypeContrat);
console.log('📋 normalized:', (user?.TypeContrat || '').toString().toLowerCase().replace(/[\s_-]/g, ''));
console.log('✅ isForfaitJour:', isForfaitJour);
return (
<>
@@ -64,14 +85,12 @@ const Sidebar = ({ isOpen, onToggle }) => {
${isOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}
>
{/* Bouton fermer (mobile) */}
<div className="lg:hidden flex justify-end p-4">
<button onClick={onToggle} className="p-2 rounded-lg hover:bg-gray-100">
<X className="w-6 h-6" />
</button>
</div>
{/* Logo */}
<div className="p-6 border-b border-gray-100">
<div className="flex flex-col items-center gap-2">
<img
@@ -82,7 +101,6 @@ const Sidebar = ({ isOpen, onToggle }) => {
</div>
</div>
{/* Infos utilisateur */}
<div className="p-4 lg:p-6 border-b border-gray-100">
<div className="flex flex-col items-center text-center">
<img
@@ -110,10 +128,10 @@ const Sidebar = ({ isOpen, onToggle }) => {
</div>
</div>
{/* Navigation */}
<nav className="flex-1 p-4 space-y-2">
<Link
to="/dashboard"
data-tour="dashboard"
onClick={() => window.innerWidth < 1024 && onToggle()}
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${isActive("/dashboard")
? "bg-blue-50 text-cyan-700 border-r-2 border-cyan-700"
@@ -121,11 +139,12 @@ const Sidebar = ({ isOpen, onToggle }) => {
}`}
>
<Home className="w-5 h-5" />
<span className="font-medium">Dashboard</span>
<span className="font-medium">Tableau de bord</span>
</Link>
<Link
to="/demandes"
data-tour="demandes"
onClick={() => window.innerWidth < 1024 && onToggle()}
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${isActive("/demandes")
? "bg-blue-50 text-cyan-700 border-r-2 border-cyan-700"
@@ -138,6 +157,7 @@ const Sidebar = ({ isOpen, onToggle }) => {
<Link
to="/calendrier"
data-tour="calendrier"
onClick={() => window.innerWidth < 1024 && onToggle()}
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${isActive("/calendrier")
? "bg-blue-50 text-cyan-700 border-r-2 border-cyan-700"
@@ -148,10 +168,11 @@ const Sidebar = ({ isOpen, onToggle }) => {
<span className="font-medium">Calendrier</span>
</Link>
{/* Lien Compte-Rendu d'Activités - Visible pour validateurs et directeurs */}
{(canViewCompteRendu() || isForfaitJour) && (
{/* Compte-Rendu avec vérification robuste */}
{isForfaitJour && (
<Link
to="/compte-rendu-activites"
data-tour="compte-rendu"
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"
@@ -159,42 +180,26 @@ const Sidebar = ({ isOpen, onToggle }) => {
}`}
>
<Clock className="w-5 h-5" />
<span className="font-medium">Compte-Rendu</span>
<span className="font-medium">CRA</span>
</Link>
)}
{(user?.role === "Collaborateur" ||
user?.role === "Collaboratrice" ||
user?.role === "Apprenti" ||
user?.role === "Validateur" ||
user?.role === "Validatrice" ||
user?.role === "Manager" ||
user?.role === "RH" ||
user?.role === "Directeur de campus" ||
user?.role === "Directrice de campus" ||
user?.role === "President" ||
user?.role === "Admin") && (() => {
const targetPath = (user?.role === "Collaborateur" || user?.role === "Apprenti" || user?.role === "Collaboratrice")
? "/collaborateur"
: "/manager";
return (
<Link
to={targetPath}
onClick={() => window.innerWidth < 1024 && onToggle()}
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${isActive(targetPath)
? "bg-blue-50 text-cyan-700 border-r-2 border-cyan-700"
: "text-gray-700 hover:bg-gray-50"
}`}
>
<Users className="w-5 h-5" />
<span className="font-medium">Mon équipe</span>
</Link>
);
})()}
{hasTeamAccess && (
<Link
to={teamPath}
data-tour="mon-equipe"
onClick={() => window.innerWidth < 1024 && onToggle()}
className={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${isActive(teamPath)
? "bg-blue-50 text-cyan-700 border-r-2 border-cyan-700"
: "text-gray-700 hover:bg-gray-50"
}`}
>
<Users className="w-5 h-5" />
<span className="font-medium">Mon équipe</span>
</Link>
)}
</nav>
{/* Bouton déconnexion */}
<div className="p-4 border-t border-gray-100">
<button
onClick={logout}

View File

@@ -1,6 +1,6 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import * as msal from '@azure/msal-browser';
import { msalConfig, loginRequest } from '../AuthConfig';
import { useMsal } from '@azure/msal-react';
import { loginRequest, API_BASE_URL } from '../authConfig';
const AuthContext = createContext();
@@ -10,16 +10,30 @@ export const useAuth = () => {
return context;
};
const msalInstance = new msal.PublicClientApplication(msalConfig);
// ✅ Détection mobile améliorée
const isMobileDevice = () => {
const ua = navigator.userAgent;
return /iPhone|iPad|iPod|Android|webOS|BlackBerry|IEMobile|Opera Mini/i.test(ua);
};
const shouldUseRedirect = () => {
if (isMobileDevice()) {
return true;
}
return window.innerWidth < 768;
};
export const AuthProvider = ({ children }) => {
const { instance, accounts, inProgress } = useMsal();
const [user, setUser] = useState(null);
const [userGroups, setUserGroups] = useState([]);
const [isAuthorized, setIsAuthorized] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isMsalInitialized, setIsMsalInitialized] = useState(false);
const getApiUrl = (endpoint) => `http://localhost:3000/${endpoint}`;
const getApiUrl = (endpoint) => {
const cleanEndpoint = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint;
return `${API_BASE_URL}/${cleanEndpoint}`;
};
// --- Vérifie l'autorisation de l'utilisateur via groupes
const checkUserAuthorization = async (userPrincipalName, accessToken) => {
@@ -60,11 +74,11 @@ export const AuthProvider = ({ children }) => {
if (response.ok) {
const data = await response.json();
console.log('Utilisateur synchronisé:', entraUser.userPrincipalName);
console.log('Utilisateur synchronisé:', entraUser.userPrincipalName);
return data;
}
} catch (error) {
console.error('Erreur synchronisation utilisateur:', error);
console.error('Erreur synchronisation utilisateur:', error);
}
return null;
};
@@ -79,63 +93,26 @@ export const AuthProvider = ({ children }) => {
if (response.ok) {
const data = await response.json();
console.log('Full sync terminée:', data);
console.log('Full sync terminée:', data);
return data;
}
} catch (error) {
console.error('Erreur full sync:', error);
console.error('Erreur full sync:', error);
}
return null;
};
// --- S'assurer que MSAL est initialisé avant tout appel
const ensureMsalInitialized = async () => {
if (!isMsalInitialized) {
try {
await msalInstance.initialize();
setIsMsalInitialized(true);
console.log('MSAL initialisé');
} catch (error) {
console.error('Erreur initialisation MSAL:', error);
throw error;
}
}
};
// --- Initialisation au chargement
useEffect(() => {
const initializeMsal = async () => {
try {
await ensureMsalInitialized();
const accounts = msalInstance.getAllAccounts();
if (accounts.length > 0) {
try {
const response = await msalInstance.acquireTokenSilent({
...loginRequest,
account: accounts[0]
});
await handleSuccessfulAuth(response);
} catch (error) {
console.log('Token silent acquisition failed:', error);
}
}
} catch (error) {
console.error("Erreur d'initialisation MSAL:", error);
} finally {
setIsLoading(false);
}
};
initializeMsal();
}, []);
// --- Gestion login réussi
const handleSuccessfulAuth = async (authResponse) => {
try {
console.log('🔐 Traitement authentification réussie...');
const account = authResponse.account;
const accessToken = authResponse.accessToken;
if (!account || !accessToken) {
throw new Error('Données d\'authentification incomplètes');
}
let entraUser = {
id: account.homeAccountId,
displayName: account.name,
@@ -143,31 +120,41 @@ export const AuthProvider = ({ children }) => {
mail: account.username
};
const graphResponse = await fetch('https://graph.microsoft.com/v1.0/me', {
headers: { 'Authorization': `Bearer ${accessToken}` }
});
// Appel Graph API pour enrichir les données
console.log('📞 Appel Graph API...');
try {
const graphResponse = await fetch('https://graph.microsoft.com/v1.0/me', {
headers: { 'Authorization': `Bearer ${accessToken}` }
});
if (graphResponse.ok) {
const graphData = await graphResponse.json();
entraUser = { ...entraUser, ...graphData };
if (graphResponse.ok) {
const graphData = await graphResponse.json();
entraUser = { ...entraUser, ...graphData };
console.log('✅ Données Graph récupérées');
}
} catch (graphError) {
console.warn('⚠️ Erreur Graph API:', graphError);
}
// 1 Synchroniser lutilisateur connecté
// Synchronisation utilisateur
console.log('🔄 Synchronisation utilisateur...');
const syncResult = await syncUserToDatabase(entraUser, accessToken);
// 2⃣ Full sync si admin
if (syncResult?.role === 'Admin') {
console.log('Admin détecté → lancement full sync...');
console.log('👑 Admin détecté → lancement full sync...');
await fullSyncDatabase(accessToken);
}
// 3 Vérifier groupes
// Vérification des groupes
console.log('🔍 Vérification groupes...');
const authResult = await checkUserAuthorization(entraUser.userPrincipalName, accessToken);
if (authResult.authorized) {
setUser({
console.log('✅ Utilisateur autorisé');
const userData = {
id: syncResult?.localUserId || entraUser.id,
CollaborateurADId: syncResult?.localUserId, // ⭐ AJOUT
CollaborateurADId: syncResult?.localUserId,
entraUserId: entraUser.id,
name: entraUser.displayName,
prenom: entraUser.givenName || entraUser.displayName?.split(' ')[0] || '',
@@ -179,20 +166,98 @@ export const AuthProvider = ({ children }) => {
jobTitle: entraUser.jobTitle,
department: entraUser.department,
officeLocation: entraUser.officeLocation,
typeContrat: syncResult?.typeContrat || '37h', // ⭐ AJOUT
dateEntree: syncResult?.dateEntree || null, // ⭐ AJOUT
typeContrat: syncResult?.typeContrat || '37h',
dateEntree: syncResult?.dateEntree || null,
groups: authResult.groups
});
};
setUser(userData);
setIsAuthorized(true);
console.log('✅ Connexion réussie:', userData.email);
} else {
console.error('❌ Utilisateur non autorisé');
throw new Error('Utilisateur non autorisé - pas membre des groupes requis');
}
} catch (error) {
console.error('Erreur lors de la gestion de l\'authentification:', error);
console.error('Erreur handleSuccessfulAuth:', error);
throw error;
} finally {
setIsLoading(false);
}
};
// ✅ SIMPLIFIÉ : L'initialisation MSAL est déjà faite dans main.jsx
useEffect(() => {
const processAuthentication = async () => {
// Attendre que MSAL finisse ses opérations en cours
if (inProgress !== 'none') {
console.log('⏳ MSAL inProgress:', inProgress);
return;
}
console.log('🌐 AuthContext - Vérification session');
console.log('📊 Comptes MSAL:', accounts.length);
// Si un compte existe, récupérer le token et traiter l'auth
if (accounts.length > 0) {
const account = accounts[0];
console.log('✅ Compte trouvé:', account.username);
try {
// Définir le compte actif
instance.setActiveAccount(account);
// Acquérir un token silencieusement
const tokenResponse = await instance.acquireTokenSilent({
...loginRequest,
account: account
});
console.log('✅ Token acquis silencieusement');
await handleSuccessfulAuth(tokenResponse);
} catch (error) {
console.error('❌ Erreur acquireTokenSilent:', error);
// Si interaction requise, relancer l'auth
if (error.name === 'InteractionRequiredAuthError' ||
error.errorCode === 'consent_required' ||
error.errorCode === 'interaction_required' ||
error.errorCode === 'login_required') {
console.log('🔄 Interaction requise, relancement...');
try {
if (shouldUseRedirect()) {
await instance.acquireTokenRedirect({
...loginRequest,
account: account
});
} else {
const response = await instance.acquireTokenPopup({
...loginRequest,
account: account
});
await handleSuccessfulAuth(response);
}
} catch (interactionError) {
console.error('❌ Erreur interaction:', interactionError);
setIsLoading(false);
}
} else {
setIsLoading(false);
}
}
} else {
// Pas de compte = utilisateur non connecté
console.log(' Aucun compte MSAL - utilisateur non connecté');
setIsLoading(false);
}
};
processAuthentication();
}, [instance, accounts, inProgress]);
// --- Connexion classique
const login = async (email, password) => {
try {
@@ -228,15 +293,28 @@ export const AuthProvider = ({ children }) => {
// --- Connexion Office 365
const loginWithO365 = async () => {
try {
await ensureMsalInitialized();
const authResponse = await msalInstance.loginPopup(loginRequest);
await handleSuccessfulAuth(authResponse);
return true;
} catch (error) {
console.error('Erreur login Office 365:', error);
if (error.message?.includes('non autorisé')) {
throw new Error('Accès refusé: Vous n\'êtes pas membre d\'un groupe autorisé.');
const useRedirect = shouldUseRedirect();
console.log(`🔐 Connexion O365: ${useRedirect ? 'REDIRECT' : 'POPUP'}`);
if (useRedirect) {
await instance.loginRedirect(loginRequest);
} else {
try {
const authResponse = await instance.loginPopup(loginRequest);
await handleSuccessfulAuth(authResponse);
return true;
} catch (popupError) {
if (popupError.errorCode === 'popup_window_error' ||
popupError.errorCode === 'empty_window_error') {
console.warn('⚠️ Popup bloqué, fallback redirect');
await instance.loginRedirect(loginRequest);
} else {
throw popupError;
}
}
}
} catch (error) {
console.error('❌ Erreur login O365:', error);
throw error;
}
};
@@ -244,12 +322,23 @@ export const AuthProvider = ({ children }) => {
// --- Déconnexion
const logout = async () => {
try {
const accounts = msalInstance.getAllAccounts();
const useRedirect = shouldUseRedirect();
if (accounts.length > 0) {
await msalInstance.logoutPopup({ account: accounts[0] });
if (useRedirect) {
await instance.logoutRedirect({
account: accounts[0],
postLogoutRedirectUri: window.location.origin
});
} else {
await instance.logoutPopup({
account: accounts[0],
postLogoutRedirectUri: window.location.origin
});
}
}
} catch (error) {
console.error('Erreur lors de la déconnexion:', error);
console.error('Erreur déconnexion:', error);
} finally {
setUser(null);
setUserGroups([]);
@@ -260,11 +349,11 @@ export const AuthProvider = ({ children }) => {
// --- Obtenir token API
const getAccessToken = async () => {
try {
await ensureMsalInitialized();
const accounts = msalInstance.getAllAccounts();
if (accounts.length === 0) throw new Error('Aucun compte connecté');
if (accounts.length === 0) {
throw new Error('Aucun compte connecté');
}
const response = await msalInstance.acquireTokenSilent({
const response = await instance.acquireTokenSilent({
...loginRequest,
account: accounts[0]
});
@@ -272,6 +361,19 @@ export const AuthProvider = ({ children }) => {
return response.accessToken;
} catch (error) {
console.error('Erreur obtention token:', error);
// Tenter une interaction si nécessaire
if (error.name === 'InteractionRequiredAuthError') {
try {
const response = await instance.acquireTokenPopup({
...loginRequest,
account: accounts[0]
});
return response.accessToken;
} catch (popupError) {
console.error('Erreur popup token:', popupError);
}
}
return null;
}
};
@@ -290,4 +392,4 @@ export const AuthProvider = ({ children }) => {
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
export default AuthContext;
export default AuthContext;

View File

@@ -3,15 +3,121 @@ import { createRoot } from 'react-dom/client';
import App from './App.jsx';
import './index.css';
import { MsalProvider } from "@azure/msal-react";
import { PublicClientApplication } from "@azure/msal-browser";
import { msalConfig } from "./AuthConfig";
import { PublicClientApplication, EventType } from "@azure/msal-browser";
import { msalConfig } from "./authConfig";
// ✅ CRITIQUE : Créer l'instance MSAL
const msalInstance = new PublicClientApplication(msalConfig);
createRoot(document.getElementById('root')).render(
<StrictMode>
<MsalProvider instance={msalInstance}>
<App />
</MsalProvider>
</StrictMode>
);
// ✅ CRITIQUE : Fonction d'initialisation asynchrone
async function initializeApp() {
console.log('🚀 Initialisation de l\'application...');
console.log('🔗 Hash actuel:', window.location.hash);
console.log('📍 URL complète:', window.location.href);
// ✅ Sauvegarder le hash OAuth s'il existe (avant que quoi que ce soit ne le supprime)
const currentHash = window.location.hash;
if (currentHash && currentHash.includes('code=')) {
console.log('🚨 Hash OAuth détecté - Sauvegarde...');
sessionStorage.setItem('oauth_hash_backup', currentHash);
sessionStorage.setItem('oauth_url_backup', window.location.href);
sessionStorage.setItem('oauth_capture_time', Date.now().toString());
}
try {
// ✅ CRITIQUE : Initialiser MSAL (requis depuis MSAL 3.x)
console.log('⏳ Initialisation MSAL...');
await msalInstance.initialize();
console.log('✅ MSAL initialisé');
// ✅ CRITIQUE : Traiter la redirection OAuth AVANT le rendu React
console.log('⏳ Traitement handleRedirectPromise...');
const response = await msalInstance.handleRedirectPromise();
if (response) {
console.log('✅ Réponse OAuth reçue:', {
account: response.account?.username,
hasAccessToken: !!response.accessToken,
scopes: response.scopes
});
// Nettoyer le hash de l'URL après traitement réussi
window.history.replaceState({}, document.title, window.location.pathname);
// Nettoyer le backup
sessionStorage.removeItem('oauth_hash_backup');
sessionStorage.removeItem('oauth_url_backup');
sessionStorage.removeItem('oauth_capture_time');
} else {
console.log(' Pas de réponse OAuth (normal si pas de redirection en cours)');
// Vérifier s'il y avait un code mais pas de réponse (échec silencieux)
const backupHash = sessionStorage.getItem('oauth_hash_backup');
if (backupHash && backupHash.includes('code=')) {
const captureTime = sessionStorage.getItem('oauth_capture_time');
const elapsed = Date.now() - parseInt(captureTime || '0');
// Si le backup a moins de 30 secondes, c'est un échec récent
if (elapsed < 30000) {
console.warn('⚠️ Code OAuth détecté mais non traité par MSAL');
console.log('🔧 Le hash était:', backupHash.substring(0, 100) + '...');
}
// Nettoyer le backup après vérification
sessionStorage.removeItem('oauth_hash_backup');
sessionStorage.removeItem('oauth_url_backup');
sessionStorage.removeItem('oauth_capture_time');
}
}
// ✅ Configurer les événements MSAL pour le debug
msalInstance.addEventCallback((event) => {
if (event.eventType === EventType.LOGIN_SUCCESS) {
console.log('🎉 LOGIN_SUCCESS event:', event.payload?.account?.username);
}
if (event.eventType === EventType.LOGIN_FAILURE) {
console.error('❌ LOGIN_FAILURE event:', event.error);
}
if (event.eventType === EventType.ACQUIRE_TOKEN_SUCCESS) {
console.log('🔑 Token acquis pour:', event.payload?.account?.username);
}
if (event.eventType === EventType.HANDLE_REDIRECT_END) {
console.log('🏁 HANDLE_REDIRECT_END');
}
});
// ✅ Définir le compte actif si disponible
const accounts = msalInstance.getAllAccounts();
if (accounts.length > 0) {
console.log('📊 Comptes MSAL trouvés:', accounts.length);
msalInstance.setActiveAccount(accounts[0]);
console.log('✅ Compte actif défini:', accounts[0].username);
}
} catch (error) {
console.error('❌ Erreur lors de l\'initialisation MSAL:', error);
// En cas d'erreur, nettoyer et continuer
sessionStorage.removeItem('oauth_hash_backup');
sessionStorage.removeItem('oauth_url_backup');
sessionStorage.removeItem('oauth_capture_time');
// Nettoyer l'URL si elle contient encore le code
if (window.location.hash.includes('code=')) {
window.history.replaceState({}, document.title, window.location.pathname);
}
}
// ✅ Rendre l'application React APRÈS l'initialisation MSAL
console.log('🎨 Rendu de l\'application React...');
createRoot(document.getElementById('root')).render(
<StrictMode>
<MsalProvider instance={msalInstance}>
<App />
</MsalProvider>
</StrictMode>
);
}
// ✅ Lancer l'initialisation
initializeApp();

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ import { Users, CheckCircle, XCircle, Clock, Calendar, FileText, Menu, Eye, Mess
const Collaborateur = () => {
const { user } = useAuth();
const [sidebarOpen, setSidebarOpen] = useState(false);
const isEmployee = user?.role === 'Collaborateur'||'Apprenti';
const isEmployee = user?.role === 'Collaborateur' || 'Apprenti';
const [teamMembers, setTeamMembers] = useState([]);
const [pendingRequests, setPendingRequests] = useState([]);
const [allRequests, setAllRequests] = useState([]);
@@ -44,7 +44,7 @@ const Collaborateur = () => {
const fetchTeamMembers = async () => {
try {
const response = await fetch(`http://localhost:3000/getTeamMembers?manager_id=${user.id}`);
const response = await fetch(`/api/getTeamMembers?manager_id=${user.id}`);
const text = await response.text();
console.log('Réponse équipe:', text);
@@ -60,7 +60,7 @@ const Collaborateur = () => {
const fetchPendingRequests = async () => {
try {
const response = await fetch(`http://localhost:3000/getPendingRequests?manager_id=${user.id}`);
const response = await fetch(`/api/getPendingRequests?manager_id=${user.id}`);
const text = await response.text();
console.log('Réponse demandes en attente:', text);
@@ -76,7 +76,7 @@ const Collaborateur = () => {
const fetchAllTeamRequests = async () => {
try {
const response = await fetch(`http://localhost:3000/getAllTeamRequests?SuperieurId=${user.id}`);
const response = await fetch(`/api/getAllTeamRequests?SuperieurId=${user.id}`);
const text = await response.text();
console.log('Réponse toutes demandes équipe:', text);
@@ -94,7 +94,7 @@ const Collaborateur = () => {
const handleValidateRequest = async (requestId, action, comment = '') => {
try {
const response = await fetch('http://localhost:3000/validateRequest', {
const response = await fetch('/api/validateRequest', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -191,9 +191,7 @@ const Collaborateur = () => {
<h1 className="text-2xl lg:text-3xl font-bold text-gray-900 mb-2">
{isEmployee ? 'Mon équipe 👥' : 'Gestion d\'équipe 👥'}
</h1>
<p className="text-sm lg:text-base text-gray-600">
{isEmployee ? 'Consultez les congés de votre équipe' : 'Gérez les demandes de congés de votre équipe'}
</p>
</div>
{/* Stats Cards */}
@@ -224,35 +222,9 @@ const Collaborateur = () => {
</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 */}
@@ -408,7 +380,7 @@ const Collaborateur = () => {
<div className="text-sm mt-1">
<p className="text-gray-500">Document joint</p>
<a
href={`http://localhost/GTA/project/uploads/${request.file}`}
href={`/uploads/${request.file}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline flex items-center gap-1 mt-1"
@@ -464,7 +436,7 @@ const Collaborateur = () => {
<div>
<p className="text-gray-500">Document joint</p>
<a
href={`http://localhost/GTA/project/uploads/${selectedRequest.file}`}
href={`/GTA/project/uploads/${selectedRequest.file}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline flex items-center gap-2"

View File

@@ -96,7 +96,7 @@ const CompteRenduActivites = () => {
setIsLoading(true);
try {
const response = await fetch(`http://localhost:3000/getCompteRenduActivites?user_id=${userId}&annee=${annee}&mois=${mois}`);
const response = await fetch(`/api/getCompteRenduActivites?user_id=${userId}&annee=${annee}&mois=${mois}`);
const data = await response.json();
if (data.success) {
@@ -106,11 +106,19 @@ const CompteRenduActivites = () => {
console.log('📊 Détail des jours:', data.jours);
}
const congesResponse = await fetch(`http://localhost:3000/getTeamLeaves?user_id=${userId}&role=${user.role}`);
const congesResponse = await fetch(`/api/getTeamLeaves?user_id=${userId}&role=${user.role}`);
const congesData = await congesResponse.json();
if (congesData.success) {
setCongesData(congesData.leaves || []);
// ⭐ FILTRE : Ne garder que les congés de l'utilisateur actuel
const mesConges = (congesData.leaves || []).filter(leave => {
const employeeName = leave.employeename?.toLowerCase() || '';
const userName = `${user.prenom} ${user.nom}`.toLowerCase();
return employeeName === userName;
});
console.log('🏖️ Congés détectés pour', `${user.prenom} ${user.nom}:`, mesConges.length);
setCongesData(mesConges);
}
} catch (error) {
@@ -118,14 +126,14 @@ const CompteRenduActivites = () => {
} finally {
setIsLoading(false);
}
}, [userId, annee, mois, user?.role]);
}, [userId, annee, mois, user?.role, user?.prenom, user?.nom]);
// 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 response = await fetch(`/api/getStatsAnnuelles?user_id=${userId}&annee=${annee}`);
const data = await response.json();
if (data.success) {
@@ -163,53 +171,79 @@ const CompteRenduActivites = () => {
return selectedYear === previousYear && selectedMonth === previousMonth;
};
// Générer les jours du mois (lundi-vendredi)
// Générer les jours du mois (7 colonnes : Lun-Dim)
const getDaysInMonth = () => {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const firstDay = new Date(year, month, 1);
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();
// Jour de la semaine du 1er (0=dimanche, 1=lundi, ..., 6=samedi)
let firstDayOfWeek = firstDay.getDay();
if (dayOfWeek >= 1 && dayOfWeek <= 5) {
days.push(currentDay);
}
// Convertir pour que lundi = 0
firstDayOfWeek = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1;
const days = [];
// Ajouter des cases vides pour le décalage initial
for (let i = 0; i < firstDayOfWeek; i++) {
days.push(null);
}
// Ajouter tous les jours du mois
for (let day = 1; day <= daysInMonth; day++) {
days.push(new Date(year, month, day));
}
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)
// Normaliser la date de la BDD
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;
};
// ⭐ Détection des congés avec logs
const isJourEnConge = (date) => {
return congesData.some(conge => {
if (!date) return false;
const checkDate = new Date(date);
checkDate.setHours(0, 0, 0, 0);
const enConge = congesData.some(conge => {
const start = new Date(conge.startdate);
const end = new Date(conge.enddate);
return date >= start && date <= end && conge.statut === 'Valide';
start.setHours(0, 0, 0, 0);
end.setHours(0, 0, 0, 0);
const isInRange = checkDate >= start && checkDate <= end;
const isValide = conge.statut === 'Validée' || conge.statut === 'Validé' || conge.statut === 'Valide';
return isInRange && isValide;
});
return enConge;
};
// ⭐ Détection week-end
const isWeekend = (date) => {
if (!date) return false;
const dayOfWeek = date.getDay();
return dayOfWeek === 0 || dayOfWeek === 6; // Dimanche ou Samedi
};
// Vérifier si le jour est STRICTEMENT dans le passé (pas aujourd'hui)
@@ -235,6 +269,8 @@ const CompteRenduActivites = () => {
// Ouvrir le modal de saisie
const handleJourClick = (date) => {
if (!date) return; // Ignorer les cases vides
if (!isMoisAutorise() && !isRH) {
showInfo('Vous ne pouvez saisir que pour le mois en cours ou le mois précédent', 'warning');
return;
@@ -245,11 +281,18 @@ const CompteRenduActivites = () => {
return;
}
// ⭐ Bloquer les week-ends
if (isWeekend(date)) {
showInfo('Les samedis et dimanches ne peuvent pas être saisis', 'info');
return;
}
if (isHoliday(date)) {
showInfo(`Jour férié : ${getHolidayName(date)} - Saisie impossible`, 'info');
return;
}
// ⭐ Bloquer les congés
if (isJourEnConge(date)) {
showInfo('Vous êtes en congé ce jour - Saisie impossible', 'info');
return;
@@ -274,17 +317,32 @@ const CompteRenduActivites = () => {
// 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;
// ⭐ Vérifier uniquement le repos quotidien pour lundi-jeudi
// Pour vendredi, vérifier les deux
const isVendredi = selectedJour.date.getDay() === 5;
if (isVendredi) {
// Vendredi : vérifier repos quotidien ET hebdomadaire
if (!selectedJour.reposQuotidien || !selectedJour.reposHebdo) {
if (!selectedJour.commentaire || selectedJour.commentaire.trim() === '') {
showInfo('Commentaire obligatoire en cas de non-respect des repos', 'warning');
return;
}
}
} else {
// Lundi-Jeudi : vérifier uniquement repos quotidien
if (!selectedJour.reposQuotidien) {
if (!selectedJour.commentaire || selectedJour.commentaire.trim() === '') {
showInfo('Commentaire obligatoire en cas de non-respect du repos quotidien', 'warning');
return;
}
}
}
setIsSaving(true);
try {
const response = await fetch('http://localhost:3000/saveCompteRenduJour', {
const response = await fetch('/api/saveCompteRenduJour', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -292,7 +350,7 @@ const CompteRenduActivites = () => {
date: selectedJour.dateStr,
jour_travaille: selectedJour.jourTravaille,
repos_quotidien: selectedJour.reposQuotidien,
repos_hebdo: selectedJour.reposHebdo,
repos_hebdo: isVendredi ? selectedJour.reposHebdo : true, // true par défaut pour lundi-jeudi
commentaire: selectedJour.commentaire,
rh_override: isRH
})
@@ -305,7 +363,6 @@ const CompteRenduActivites = () => {
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');
}
@@ -327,7 +384,7 @@ const CompteRenduActivites = () => {
setIsSaving(true);
try {
const response = await fetch('http://localhost:3000/saveCompteRenduMasse', {
const response = await fetch('/api/saveCompteRenduMasse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -357,8 +414,6 @@ const CompteRenduActivites = () => {
}
};
const formatDateToString = (date) => {
if (!date) return null;
const year = date.getFullYear();
@@ -487,14 +542,6 @@ const CompteRenduActivites = () => {
<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>
)}
@@ -551,30 +598,35 @@ const CompteRenduActivites = () => {
<FileText className="w-4 h-4" />
<span className="hidden sm:inline">Saisie en masse</span>
</button>
</div>
</div>
</div>
{/* Calendrier */}
{/* Calendrier 7 colonnes (Lun-Dim) */}
<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 className="grid grid-cols-7 gap-2 p-4 bg-gray-50">
{['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi', 'Dimanche'].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">
<div className="grid grid-cols-7 gap-2 p-4">
{days.map((date, index) => {
// Case vide pour le décalage
if (date === null) {
return (
<div key={`empty-${index}`} className="min-h-[100px] p-3"></div>
);
}
const jourData = getJourData(date);
const enConge = isJourEnConge(date);
const weekend = isWeekend(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';
@@ -583,7 +635,12 @@ const CompteRenduActivites = () => {
if (ferie) {
bgClass = 'bg-gray-700 text-white';
cursorClass = 'cursor-not-allowed';
} else if (weekend) {
// ⭐ Week-ends en gris clair et non cliquables
bgClass = 'bg-gray-100';
cursorClass = 'cursor-not-allowed';
} else if (enConge) {
// ⭐ Congés en violet et non cliquables
bgClass = 'bg-purple-100';
cursorClass = 'cursor-not-allowed';
} else if (jourData) {
@@ -605,17 +662,20 @@ const CompteRenduActivites = () => {
`}
title={
ferie ? getHolidayName(date) :
enConge ? 'En con' :
jourData ? 'Jour saisi - Cliquer pour modifier' :
''
weekend ? 'Week-end - Non saisissable' :
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'
weekend ? 'text-gray-500' :
enConge ? 'text-purple-700' :
jourData ? 'text-gray-700' :
'text-gray-700'
}`}>
{date.getDate()}
{jourData && !ferie && !enConge && (
{jourData && !ferie && !enConge && !weekend && (
<Lock className="w-3 h-3 text-gray-600" />
)}
</div>
@@ -624,6 +684,10 @@ const CompteRenduActivites = () => {
<div className="text-center">
<div className="text-xs text-white font-bold truncate">{getHolidayName(date)}</div>
</div>
) : weekend ? (
<div className="text-center">
<div className="text-xs text-gray-500 font-semibold">Week-end</div>
</div>
) : enConge ? (
<div className="text-center">
<div className="text-xs text-purple-700 font-semibold">En congé</div>
@@ -681,6 +745,10 @@ const CompteRenduActivites = () => {
<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-gray-100 border-2 border-gray-200 rounded"></div>
<span>Week-end</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>
@@ -707,18 +775,6 @@ const CompteRenduActivites = () => {
</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">
@@ -740,26 +796,29 @@ const CompteRenduActivites = () => {
</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>
{/* ⭐ Afficher repos hebdomadaire uniquement le vendredi */}
{selectedJour.date.getDay() === 5 && (
<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) && (
{(!selectedJour.reposQuotidien || (selectedJour.date.getDay() === 5 && !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
@@ -815,7 +874,7 @@ const CompteRenduActivites = () => {
<SaisieMasseModal
mois={mois}
annee={annee}
days={days}
days={days.filter(d => d !== null)}
congesData={congesData}
holidays={holidays}
onClose={() => setShowSaisieMasse(false)}
@@ -827,7 +886,7 @@ const CompteRenduActivites = () => {
);
};
// Modal de saisie en masse
// Modal de saisie en masse (7 colonnes)
const SaisieMasseModal = ({ mois, annee, days, congesData, holidays, onClose, onSave, isSaving }) => {
const [selectedDays, setSelectedDays] = useState([]);
@@ -835,10 +894,19 @@ const SaisieMasseModal = ({ mois, annee, days, congesData, holidays, onClose, on
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
const isJourEnConge = (date) => {
const checkDate = new Date(date);
checkDate.setHours(0, 0, 0, 0);
return congesData.some(conge => {
const start = new Date(conge.startdate);
const end = new Date(conge.enddate);
return date >= start && date <= end && conge.statut === 'Valide';
start.setHours(0, 0, 0, 0);
end.setHours(0, 0, 0, 0);
const isInRange = checkDate >= start && checkDate <= end;
const isValide = conge.statut === 'Validée' || conge.statut === 'Validé' || conge.statut === 'Valide';
return isInRange && isValide;
});
};
@@ -850,6 +918,11 @@ const SaisieMasseModal = ({ mois, annee, days, congesData, holidays, onClose, on
);
};
const isWeekend = (date) => {
const dayOfWeek = date.getDay();
return dayOfWeek === 0 || dayOfWeek === 6;
};
const isPastOnly = (date) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
@@ -859,6 +932,11 @@ const SaisieMasseModal = ({ mois, annee, days, congesData, holidays, onClose, on
};
const toggleDay = (date) => {
// ⭐ Bloquer la sélection des week-ends et congés
if (isWeekend(date) || isJourEnConge(date) || isHoliday(date)) {
return;
}
const dateStr = formatDateToString(date);
if (selectedDays.includes(dateStr)) {
setSelectedDays(selectedDays.filter(d => d !== dateStr));
@@ -868,8 +946,9 @@ const SaisieMasseModal = ({ mois, annee, days, congesData, holidays, onClose, on
};
const selectAllWorkingDays = () => {
// ⭐ Exclure week-ends, congés et jours fériés
const workingDays = days
.filter(date => isPastOnly(date) && !isJourEnConge(date) && !isHoliday(date))
.filter(date => isPastOnly(date) && !isWeekend(date) && !isJourEnConge(date) && !isHoliday(date))
.map(date => formatDateToString(date));
setSelectedDays(workingDays);
@@ -894,6 +973,29 @@ const SaisieMasseModal = ({ mois, annee, days, congesData, holidays, onClose, on
onSave(joursTravailles);
};
// Générer les jours avec décalage pour lundi-dimanche
const getDaysWithOffset = () => {
const year = annee;
const month = mois - 1;
const firstDay = new Date(year, month, 1);
let firstDayOfWeek = firstDay.getDay();
firstDayOfWeek = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1;
const daysWithOffset = [];
// Ajouter des cases vides pour le décalage
for (let i = 0; i < firstDayOfWeek; i++) {
daysWithOffset.push(null);
}
daysWithOffset.push(...days);
return daysWithOffset;
};
const daysWithOffset = getDaysWithOffset();
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">
@@ -903,8 +1005,15 @@ const SaisieMasseModal = ({ mois, annee, days, congesData, holidays, onClose, on
<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.
Sélectionnez tous les jours travaillés du mois. Le jour actuel, les week-ends, les jours fériés et les congés sont automatiquement exclus.
</p>
</div>
{/* ⭐ Message d'information sur les repos */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-4 flex items-start gap-3">
<Info className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-800">
<strong>Par cette action, les repos quotidiens et hebdomadaires seront automatiquement considérés comme respectés.</strong>
</p>
</div>
@@ -915,31 +1024,46 @@ const SaisieMasseModal = ({ mois, annee, days, congesData, holidays, onClose, on
Sélectionner tous les jours ouvrés disponibles
</button>
<div className="grid grid-cols-5 gap-2 mb-6">
{days.map((date, index) => {
{/* Grid 7 colonnes */}
<div className="grid grid-cols-7 gap-2 p-4">
{daysWithOffset.map((date, index) => {
// Case vide
if (date === null) {
return (
<div key={`empty-${index}`} className="p-3"></div>
);
}
const dateStr = formatDateToString(date);
const enConge = isJourEnConge(date);
const ferie = isHoliday(date);
const weekend = isWeekend(date);
const isPast = isPastOnly(date);
const isSelected = selectedDays.includes(dateStr);
const isToday = date.toDateString() === new Date().toDateString();
// ⭐ Les week-ends ne sont pas sélectionnables
const isDisabled = !isPast || weekend || enConge || ferie;
return (
<div
key={index}
onClick={() => !enConge && !ferie && isPast && toggleDay(date)}
onClick={() => toggleDay(date)}
className={`
p-3 rounded-lg border-2 text-center cursor-pointer transition-all
p-3 rounded-lg border-2 text-center transition-all
${isToday ? 'border-cyan-500 bg-cyan-100' : ''}
${ferie ? 'bg-gray-700 text-white cursor-not-allowed' : ''}
${weekend ? 'bg-gray-100 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'}
${isSelected && !isDisabled ? 'bg-green-500 border-green-600 text-white' : ''}
${!isDisabled && !isSelected ? 'bg-white border-gray-200 hover:bg-gray-50 cursor-pointer' : ''}
`}
>
<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>}
{weekend && <div className="text-xs mt-1 text-gray-500">WE</div>}
{enConge && <div className="text-xs mt-1 text-purple-700">Congé</div>}
</div>
);

File diff suppressed because it is too large Load Diff

View File

@@ -1,38 +1,53 @@
import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useParams, useNavigate } from 'react-router-dom';
import Sidebar from '../components/Sidebar';
import { Calendar, Clock, CheckCircle, XCircle } from 'lucide-react';
import {
Calendar,
Clock,
CheckCircle,
XCircle,
ArrowLeft,
Mail,
Briefcase,
Building,
TrendingDown,
TrendingUp
} from 'lucide-react';
const EmployeeDetails = () => {
const { id } = useParams();
const navigate = useNavigate();
const [employee, setEmployee] = useState(null);
const [requests, setRequests] = useState([]);
const [detailedCounters, setDetailedCounters] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetchEmployeeData();
}, [id]);
// Dans EmployeeDetails.jsx, modifier fetchEmployeeData:
const fetchEmployeeData = async () => {
try {
setIsLoading(true);
// 1⃣ Données employé (avec compteurs inclus)
const resEmployee = await fetch(`http://localhost:3000/getEmploye?id=${id}`);
const resEmployee = await fetch(`/api/getEmploye?id=${id}`);
const dataEmployee = await resEmployee.json();
console.log("Réponse API employé:", dataEmployee);
if (!dataEmployee.success) {
setEmployee(null);
return;
}
// ✅ Les compteurs sont déjà dans la réponse
setEmployee(dataEmployee.employee);
// 2⃣ Historique des demandes
const resRequests = await fetch(`http://localhost:3000/getEmployeRequest?id=${id}`);
const resCounters = await fetch(`/api/getDetailedLeaveCounters?user_id=${id}`);
const dataCounters = await resCounters.json();
if (dataCounters.success) {
setDetailedCounters(dataCounters.data);
}
const resRequests = await fetch(`/api/getEmployeRequest?id=${id}`);
const dataRequests = await resRequests.json();
if (dataRequests.success) {
@@ -46,64 +61,257 @@ const EmployeeDetails = () => {
}
};
const getStatusIcon = (status) => {
const getStatusConfig = (status) => {
switch (status) {
case 'Validée':
return <CheckCircle className="inline text-green-500 mr-1" />;
return {
icon: <CheckCircle className="w-4 h-4" />,
bg: 'bg-emerald-50',
text: 'text-emerald-700',
dot: 'bg-emerald-500'
};
case 'Refusée':
case 'Annulée':
return <XCircle className="inline text-red-500 mr-1" />;
return {
icon: <XCircle className="w-4 h-4" />,
bg: 'bg-red-50',
text: 'text-red-700',
dot: 'bg-red-500'
};
default:
return <Clock className="inline text-yellow-500 mr-1" />;
return {
icon: <Clock className="w-4 h-4" />,
bg: 'bg-amber-50',
text: 'text-amber-700',
dot: 'bg-amber-500'
};
}
};
if (isLoading) return <p className="text-center p-6">Chargement...</p>;
if (!employee) return <p className="text-center p-6">Collaborateur introuvable</p>;
const getTypeContratLabel = (type) => {
switch (type) {
case '37h': return '37h/sem';
case 'forfait_jour': return 'Forfait jour';
case 'temps_partiel': return 'Temps partiel';
default: return type || '37h/sem';
}
};
const CounterCard = ({ label, solde, acquis, pris, color, icon: Icon }) => {
const colorClasses = {
blue: { bg: 'bg-blue-500', light: 'bg-blue-50', text: 'text-blue-600', border: 'border-blue-200' },
cyan: { bg: 'bg-cyan-500', light: 'bg-cyan-50', text: 'text-cyan-600', border: 'border-cyan-200' },
green: { bg: 'bg-emerald-500', light: 'bg-emerald-50', text: 'text-emerald-600', border: 'border-emerald-200' },
purple: { bg: 'bg-violet-500', light: 'bg-violet-50', text: 'text-violet-600', border: 'border-violet-200' },
};
const c = colorClasses[color] || colorClasses.blue;
return (
<div className={`relative bg-white rounded-2xl border ${c.border} p-5 hover:shadow-md transition-shadow`}>
<div className="flex items-start justify-between mb-4">
<div>
<p className="text-sm font-medium text-gray-500 mb-1">{label}</p>
<p className={`text-3xl font-bold ${c.text}`}>{solde.toFixed(1)}<span className="text-lg ml-1">j</span></p>
</div>
<div className={`${c.bg} p-2.5 rounded-xl`}>
<Icon className="w-5 h-5 text-white" />
</div>
</div>
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1.5">
<TrendingUp className="w-3.5 h-3.5 text-emerald-500" />
<span className="text-gray-600">Acquis:</span>
<span className="font-semibold text-gray-900">{acquis.toFixed(1)}j</span>
</div>
<div className="flex items-center gap-1.5">
<TrendingDown className="w-3.5 h-3.5 text-red-400" />
<span className="text-gray-600">Pris:</span>
<span className="font-semibold text-gray-900">{pris.toFixed(1)}j</span>
</div>
</div>
</div>
);
};
if (isLoading) return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-cyan-600 mx-auto mb-3"></div>
<p className="text-gray-600">Chargement...</p>
</div>
</div>
);
if (!employee) return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<p className="text-gray-600 mb-4">Collaborateur introuvable</p>
<button
onClick={() => navigate(-1)}
className="text-cyan-600 hover:underline"
>
Retour
</button>
</div>
</div>
);
return (
<div className="min-h-screen bg-gray-50 flex">
<Sidebar />
<div className="flex-1 lg:ml-60 p-6">
<h1 className="text-2xl font-bold mb-2">{employee.Prenom} {employee.Nom}</h1>
<p className="text-gray-600 mb-6">{employee.Email}</p>
<div className="flex-1 lg:ml-60 p-6 lg:p-8">
{/* Bouton retour */}
<button
onClick={() => navigate(-1)}
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-6 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
<span className="text-sm font-medium">Retour</span>
</button>
{/* Compteurs congés/RTT */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<div className="bg-white p-4 rounded-xl shadow">
<p className="text-sm text-gray-600">Congés restants</p>
<p className="text-xl font-bold">{employee.conges_restants || 0} jours</p>
</div>
<div className="bg-white p-4 rounded-xl shadow">
<p className="text-sm text-gray-600">RTT restants</p>
<p className="text-xl font-bold">{employee.rtt_restants || 0} jours</p>
{/* Profil employé */}
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6 mb-6">
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
{/* Avatar */}
<div className="w-16 h-16 bg-gradient-to-br from-cyan-400 to-blue-500 rounded-2xl flex items-center justify-center flex-shrink-0">
<span className="text-2xl font-bold text-white">
{employee.Prenom?.charAt(0)}{employee.Nom?.charAt(0)}
</span>
</div>
{/* Infos */}
<div className="flex-1">
<h1 className="text-xl font-bold text-gray-900 mb-1">
{employee.Prenom} {employee.Nom}
</h1>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-600">
<div className="flex items-center gap-1.5">
<Mail className="w-4 h-4 text-gray-400" />
<span>{employee.Email}</span>
</div>
{detailedCounters?.user?.role && (
<div className="flex items-center gap-1.5">
<Briefcase className="w-4 h-4 text-gray-400" />
<span>{detailedCounters.user.role}</span>
</div>
)}
{detailedCounters?.user?.service && (
<div className="flex items-center gap-1.5">
<Building className="w-4 h-4 text-gray-400" />
<span>{detailedCounters.user.service}</span>
</div>
)}
</div>
</div>
{/* Badge contrat */}
{detailedCounters?.user?.typeContrat && (
<div className="px-3 py-1.5 bg-gray-100 rounded-lg text-sm font-medium text-gray-700">
{getTypeContratLabel(detailedCounters.user.typeContrat)}
</div>
)}
</div>
</div>
{/* Historique des congés */}
<h2 className="text-lg font-semibold mb-4">Historique des congés</h2>
<div className="space-y-3">
{requests.length === 0 ? (
<p className="text-gray-500">Aucune demande</p>
) : (
requests.map((r) => (
<div key={r.Id} className="bg-white p-4 rounded-xl shadow border flex justify-between items-center">
<div>
<p className="font-medium">{r.type} - {r.days}j</p>
<p className="text-sm text-gray-600">{r.date_display}</p>
</div>
<div className="flex items-center">
{getStatusIcon(r.status)}
<span className="text-sm text-gray-700">{r.status}</span>
</div>
{/* Compteurs */}
{detailedCounters && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{detailedCounters.cpN1 && (
<CounterCard
label="CP N-1"
solde={detailedCounters.cpN1.solde}
acquis={detailedCounters.cpN1.reporte}
pris={detailedCounters.cpN1.pris}
color="blue"
icon={Calendar}
/>
)}
{detailedCounters.cpN && (
<CounterCard
label="CP N"
solde={detailedCounters.cpN.solde}
acquis={detailedCounters.cpN.acquis}
pris={detailedCounters.cpN.pris}
color="cyan"
icon={Calendar}
/>
)}
{detailedCounters.rttN && detailedCounters.user?.role !== 'Apprenti' && (
<CounterCard
label={`RTT ${detailedCounters.rttN.annee}`}
solde={detailedCounters.rttN.solde}
acquis={detailedCounters.rttN.acquis}
pris={detailedCounters.rttN.pris}
color="green"
icon={Clock}
/>
)}
{detailedCounters.recupN && (
<CounterCard
label="Récupérations"
solde={detailedCounters.recupN.solde}
acquis={detailedCounters.recupN.acquis}
pris={detailedCounters.recupN.pris}
color="purple"
icon={Clock}
/>
)}
</div>
)}
{/* Historique */}
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100">
<h2 className="text-lg font-semibold text-gray-900">Historique des demandes</h2>
<p className="text-sm text-gray-500">{requests.length} demande{requests.length > 1 ? 's' : ''}</p>
</div>
<div className="divide-y divide-gray-100">
{requests.length === 0 ? (
<div className="px-6 py-12 text-center">
<Calendar className="w-12 h-12 text-gray-300 mx-auto mb-3" />
<p className="text-gray-500">Aucune demande de congés</p>
</div>
))
)}
) : (
requests.map((r) => {
const statusConfig = getStatusConfig(r.status);
return (
<div key={r.Id} className="px-6 py-4 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={`w-2 h-2 rounded-full ${statusConfig.dot}`}></div>
<div>
<p className="font-medium text-gray-900">{r.type}</p>
<p className="text-sm text-gray-500">{r.date_display}</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-sm font-semibold text-gray-700">{r.days}j</span>
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${statusConfig.bg} ${statusConfig.text}`}>
{statusConfig.icon}
{r.status}
</span>
</div>
</div>
</div>
);
})
)}
</div>
</div>
</div>
</div>
);
};
export default EmployeeDetails;
export default EmployeeDetails;

View File

@@ -1,56 +1,46 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { useAuth } from '../context/AuthContext';
import { useNavigate } from 'react-router-dom';
import { Building2, AlertTriangle } from 'lucide-react';
import { AlertTriangle } from 'lucide-react';
const Login = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [authMethod, setAuthMethod] = useState('');
const navigate = useNavigate();
const { login, loginWithO365 } = useAuth();
const { loginWithO365, isAuthorized, isLoading: authLoading } = useAuth();
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
setError('');
setAuthMethod('local');
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
try {
const success = await login(email, password);
if (success) {
navigate('/dashboard');
} else {
setError('Identifiants incorrects. Veuillez réessayer.');
}
} catch (error) {
setError(error.message || 'Erreur lors de la connexion');
// ✅ AJOUT : Redirection automatique si déjà connecté (cas retour OAuth mobile)
useEffect(() => {
if (isAuthorized && !authLoading) {
console.log('✅ Utilisateur autorisé détecté, redirection vers dashboard...');
navigate('/dashboard', { replace: true });
}
setIsLoading(false);
};
}, [isAuthorized, authLoading, navigate]);
const handleO365Login = async () => {
setIsLoading(true);
setError('');
setAuthMethod('o365');
try {
const success = await loginWithO365();
if (isMobile) {
console.log('🔐 Redirection mobile vers Office 365...');
await loginWithO365();
// Ce code ne sera jamais atteint sur mobile car il y a une redirection
} else {
const success = await loginWithO365();
if (!success) {
setError("Erreur lors de la connexion Office 365");
setIsLoading(false);
return;
if (!success) {
setError("Erreur lors de la connexion Office 365");
setIsLoading(false);
return;
}
navigate('/dashboard');
}
// Redirection vers le dashboard
navigate('/dashboard');
} catch (error) {
console.error('Erreur O365:', error);
@@ -58,16 +48,29 @@ const Login = () => {
setError('Accès refusé : Vous devez être membre d\'un groupe autorisé dans votre organisation.');
} else if (error.message?.includes('AADSTS')) {
setError('Erreur d\'authentification Azure AD. Contactez votre administrateur.');
} else if (error.errorCode === 'user_cancelled') {
setError('Connexion annulée');
} else {
setError(error.message || "Erreur lors de la connexion Office 365");
}
}
setIsLoading(false);
setIsLoading(false);
}
};
return (
// ✅ AJOUT : Afficher un loader pendant la vérification de l'auth
if (authLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-cyan-600 mx-auto mb-4"></div>
<p className="text-gray-600">Vérification de la connexion...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex flex-col lg:flex-row">
{/* Image côté gauche */}
<div className="h-32 lg:h-auto lg:flex lg:w-1/2 bg-cover bg-center"
@@ -81,7 +84,7 @@ const Login = () => {
<div className="max-w-md w-full">
<div className="bg-white rounded-2xl shadow-xl p-6 lg:p-8">
{/* Logo */}
<div className="text-center mb-4">
<div className="text-center mb-6">
<img
src="/assets/GA.svg"
alt="GTA Logo"
@@ -91,17 +94,18 @@ const Login = () => {
GESTION DES TEMPS ET DES ACTIVITÉS
</p>
</div>
{/* Bouton Office 365 */}
<div>
<div className="mb-4">
<button
data-testid="o365-login-btn"
onClick={handleO365Login}
disabled={isLoading}
type="button"
className="w-full bg-cyan-600 text-white py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center space-x-2"
className="w-full bg-cyan-600 text-white py-3 rounded-lg font-medium hover:bg-cyan-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center space-x-2"
>
{isLoading && authMethod === 'o365' ? (
<span>Connexion Office 365...</span>
{isLoading ? (
<span>Connexion en cours...</span>
) : (
<>
<svg className="w-5 h-5" viewBox="0 0 21 21" fill="currentColor">
@@ -113,6 +117,13 @@ const Login = () => {
</button>
</div>
{/* Message d'information */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 text-center">
<p className="text-blue-800 text-sm">
Connectez-vous avec votre compte professionnel ENSUP
</p>
</div>
{/* Message d'erreur */}
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg mt-4">
@@ -135,7 +146,7 @@ const Login = () => {
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from "react";
import { useAuth } from "../context/AuthContext";
import Sidebar from "../components/Sidebar";
import GlobalTutorial from '../components/GlobalTutorial';
import {
Users,
CheckCircle,
@@ -11,6 +12,7 @@ import {
Check,
X,
MessageSquare,
Loader2,
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
@@ -49,35 +51,77 @@ const Manager = () => {
}
};
// ✅ SIMPLIFIÉ - Le backend gère tout le filtrage
// ✅ SIMPLIFIÉ - Le backend gère tout le filtrage
const fetchTeamMembers = async () => {
try {
const res = await fetch(`http://localhost:3000/getTeamMembers?manager_id=${user.id}`);
const res = await fetch(`/api/getTeamMembers?manager_id=${user.id}`);
const data = await res.json();
if (data.success) setTeamMembers(data.team_members || []);
else setTeamMembers([]);
} catch {
console.log('📊 getTeamMembers:', {
success: data.success,
count: data.team_members?.length || 0,
role: user.role,
service: user.service,
campus: user.campus
});
if (data.success) {
setTeamMembers(data.team_members || []);
} else {
console.error('❌ Erreur getTeamMembers:', data.message);
setTeamMembers([]);
}
} catch (error) {
console.error('❌ Erreur fetch getTeamMembers:', error);
setTeamMembers([]);
}
};
// ✅ SIMPLIFIÉ - Le backend gère tout le filtrage
const fetchPendingRequests = async () => {
try {
const res = await fetch(`http://localhost:3000/getPendingRequests?manager_id=${user.id}`);
const res = await fetch(`/api/getPendingRequests?manager_id=${user.id}`);
const data = await res.json();
if (data.success) setPendingRequests(data.requests || []);
else setPendingRequests([]);
} catch {
console.log('📊 getPendingRequests:', {
success: data.success,
count: data.requests?.length || 0,
role: user.role
});
if (data.success) {
setPendingRequests(data.requests || []);
} else {
console.error('❌ Erreur getPendingRequests:', data.message);
setPendingRequests([]);
}
} catch (error) {
console.error('❌ Erreur fetch getPendingRequests:', error);
setPendingRequests([]);
}
};
// ✅ SIMPLIFIÉ - Le backend gère tout le filtrage
const fetchAllTeamRequests = async () => {
try {
const res = await fetch(`http://localhost:3000/getAllTeamRequests?SuperieurId=${user.id}`);
const res = await fetch(`/api/getAllTeamRequests?SuperieurId=${user.id}`);
const data = await res.json();
if (data.success) setAllRequests(data.requests || []);
else setAllRequests([]);
} catch {
console.log('📊 getAllTeamRequests:', {
success: data.success,
count: data.requests?.length || 0,
role: user.role
});
if (data.success) {
setAllRequests(data.requests || []);
} else {
console.error('❌ Erreur getAllTeamRequests:', data.message);
setAllRequests([]);
}
} catch (error) {
console.error('❌ Erreur fetch getAllTeamRequests:', error);
setAllRequests([]);
}
};
@@ -85,9 +129,11 @@ const Manager = () => {
const openValidationModal = (request, action) => {
setValidationModal({ request, action });
setComment("");
setIsValidating(false);
};
const closeValidationModal = () => {
if (isValidating) return;
setValidationModal(null);
setComment("");
};
@@ -100,49 +146,48 @@ const Manager = () => {
return;
}
await handleValidateRequest(request.id, action, comment);
closeValidationModal();
if (isValidating) return;
setIsValidating(true);
try {
await handleValidateRequest(request.id, action, comment);
showToast("success", action === "approve" ? "Demande approuvée avec succès" : "Demande refusée");
closeValidationModal();
} catch (error) {
showToast("error", "Une erreur est survenue");
setIsValidating(false);
}
};
const handleValidateRequest = async (requestId, action, comment = '') => {
if (!user || !user.id) {
alert('Utilisateur non identifié');
return;
throw new Error('Utilisateur non identifié');
}
try {
setIsValidating(true); // ✅ Maintenant défini
const response = await fetch('/api/validateRequest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
request_id: requestId,
action: action,
validator_id: user.id,
comment: comment
}),
});
const response = await fetch('http://localhost:3000/validateRequest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
request_id: requestId,
action: action,
validator_id: user.id,
comment: comment
}),
});
const data = await response.json();
const data = await response.json();
if (data.success) {
// Rafraîchir les données
await Promise.all([
fetchPendingRequests(),
fetchAllTeamRequests()
]);
} else {
alert(`❌ Erreur : ${data.message}`);
}
} catch (error) {
console.error('❌ Erreur lors de la validation:', error);
alert('❌ Erreur lors de la validation de la demande');
} finally {
setIsValidating(false);
if (!data.success) {
throw new Error(data.message || 'Erreur lors de la validation');
}
await Promise.all([
fetchPendingRequests(),
fetchAllTeamRequests()
]);
};
const showToast = (type, message) => {
setToast({ type, message });
setTimeout(() => setToast(null), 4000);
@@ -197,6 +242,7 @@ const Manager = () => {
);
}
return (
<div className="relative min-h-screen bg-gray-50 flex overflow-hidden">
{/* Toast Notification */}
@@ -303,7 +349,9 @@ const Manager = () => {
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()
disabled={isValidating}
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:outline-none resize-none transition ${isValidating ? 'bg-gray-100 cursor-not-allowed' : ''
} ${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"
}`}
@@ -317,17 +365,25 @@ const Manager = () => {
<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"
disabled={isValidating}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
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"
disabled={isValidating || (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 flex items-center justify-center gap-2 ${validationModal.action === "approve" ? "bg-green-600 hover:bg-green-700" : "bg-red-600 hover:bg-red-700"
}`}
>
{validationModal.action === "approve" ? "Approuver" : "Refuser"}
{isValidating ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span>Traitement...</span>
</>
) : (
validationModal.action === "approve" ? "Approuver" : "Refuser"
)}
</button>
</div>
</motion.div>
@@ -353,7 +409,7 @@ const Manager = () => {
<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="bg-white rounded-xl shadow-sm border border-gray-100" data-tour="demandes-attente">
<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>
@@ -381,15 +437,17 @@ const Manager = () => {
<div className="flex gap-2">
<button
onClick={() => openValidationModal(r, "approve")}
className="flex-1 bg-green-600 text-white px-3 py-2 rounded-lg hover:bg-green-700 text-sm"
>
disabled={isValidating}
className="flex-1 bg-green-600 text-white px-3 py-2 rounded-lg hover:bg-green-700 text-sm disabled:opacity-50 disabled:cursor-not-allowed transition"
data-tour="approuver-btn">
<CheckCircle className="w-4 h-4 inline mr-1" />
Approuver
</button>
<button
onClick={() => openValidationModal(r, "reject")}
className="flex-1 bg-red-600 text-white px-3 py-2 rounded-lg hover:bg-red-700 text-sm"
>
disabled={isValidating}
className="flex-1 bg-red-600 text-white px-3 py-2 rounded-lg hover:bg-red-700 text-sm disabled:opacity-50 disabled:cursor-not-allowed transition"
data-tour="refuser-btn">
<XCircle className="w-4 h-4 inline mr-1" />
Refuser
</button>
@@ -401,7 +459,7 @@ const Manager = () => {
</div>
)}
<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" : ""}`} data-tour="mon-equipe">
<div className="p-4 border-b border-gray-100 flex items-center gap-2">
<Users className="w-5 h-5 text-blue-600" />
<h2 className="font-semibold text-gray-900">Mon équipe ({teamMembers.length})</h2>
@@ -415,7 +473,7 @@ const Manager = () => {
key={m.id}
onClick={() => navigate(`/employee/${m.id}`)}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 cursor-pointer transition"
>
data-tour="membre-equipe">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<span className="text-blue-600 font-medium text-sm">
@@ -445,7 +503,7 @@ const Manager = () => {
</div>
{!isEmployee && (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 mt-6">
<div className="bg-white rounded-xl shadow-sm border border-gray-100 mt-6" data-tour="historique-demandes">
<div className="p-4 border-b border-gray-100 flex items-center gap-2">
<FileText className="w-5 h-5 text-gray-600" />
<h2 className="font-semibold text-gray-900">Historique des demandes ({allRequests.length})</h2>
@@ -473,10 +531,10 @@ const Manager = () => {
</p>
)}
{r.file && (
<div className="text-sm mt-1">
<div className="text-sm mt-1" data-tour="document-joint">
<p className="text-gray-500">Document joint</p>
<a
href={`http://localhost:3000/uploads/${r.file}`}
href={`/uploads/${r.file}`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline flex items-center gap-1 mt-1"
@@ -486,15 +544,15 @@ const Manager = () => {
</a>
</div>
)}
</div>
))
</div>
))
)}
</div>
</div>
</div>
)}
</div>
<GlobalTutorial userId={user?.id} userRole={user?.role} />
</div>
</div >
);
};

View File

@@ -6,6 +6,8 @@ import NewLeaveRequestModal from '../components/NewLeaveRequestModal';
import EditLeaveRequestModal from '../components/EditLeaveRequestModal';
import { useMsal } from "@azure/msal-react";
import MedicalDocuments from '../components/MedicalDocuments';
import Joyride, { STATUS } from 'react-joyride';
const Requests = () => {
const { user } = useAuth();
@@ -41,10 +43,55 @@ const Requests = () => {
const [sseConnected, setSseConnected] = useState(false);
const [toasts, setToasts] = useState([]);
// ⭐ NOUVEAU : State pour la modal de confirmation de suppression
// ⭐ State pour la modal de confirmation de suppression
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [requestToDelete, setRequestToDelete] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// 🎯 STATES POUR LE TUTORIEL
const [runTour, setRunTour] = useState(false);
// 🎯 DÉCLENCHER LE TUTORIEL À CHAQUE FOIS
useEffect(() => {
if (userId && !isLoading) {
setTimeout(() => setRunTour(true), 1500);
}
}, [userId, isLoading]);
// 🎯 DÉFINITION DES ÉTAPES DU TUTORIEL
const tourSteps = [
{
target: '[data-tour="nouvelle-demande"]',
content: ' Créez une nouvelle demande de congé en cliquant ici.',
placement: 'bottom',
},
{
target: '[data-tour="recherche"]',
content: '🔍 Recherchez vos demandes par type ou statut.',
placement: 'bottom',
},
{
target: '[data-tour="filtres"]',
content: '🎯 Filtrez vos demandes par statut ou type de congé.',
placement: 'bottom',
},
{
target: '[data-tour="liste-demandes"]',
content: '📋 Consultez la liste de toutes vos demandes ici.',
placement: 'top',
},
];
// 🎯 GÉRER LA FIN DU TOUR
const handleJoyrideCallback = (data) => {
const { status } = data;
const finishedStatuses = [STATUS.FINISHED, STATUS.SKIPPED];
if (finishedStatuses.includes(status)) {
setRunTour(false);
}
};
useEffect(() => {
if (accounts.length > 0) {
const request = {
@@ -64,7 +111,7 @@ const Requests = () => {
const fetchDetailedCounters = async () => {
try {
const response = await fetch(`http://localhost:3000/getDetailedLeaveCounters?user_id=${userId}`);
const response = await fetch(`/api/getDetailedLeaveCounters?user_id=${userId}`);
const data = await response.json();
if (data.success) {
@@ -79,7 +126,7 @@ const Requests = () => {
const fetchAllRequests = async () => {
try {
const url = `http://localhost:3000/getRequests?user_id=${userId}`;
const url = `/api/getRequests?user_id=${userId}`;
const response = await fetch(url);
const text = await response.text();
let data;
@@ -89,6 +136,10 @@ const Requests = () => {
throw new Error('Le serveur PHP ne répond pas correctement');
}
if (data.success) {
console.log('🔍 DEBUG - Requests reçues:', data.requests);
if (data.requests && data.requests.length > 0) {
console.log('🔍 DEBUG - Premier request:', data.requests[0]);
}
setAllRequests(data.requests || []);
} else {
throw new Error(data.message || 'Erreur lors de la récupération des demandes');
@@ -133,57 +184,163 @@ const Requests = () => {
}, 5000);
}, []);
// ⭐ NOUVELLE FONCTION : Modifier une demande
// ⭐ FONCTION CORRIGÉE : Modifier une demande
const handleEditRequest = (request) => {
if (request.status !== 'En attente') {
showToast('⚠️ Vous ne pouvez modifier que les demandes en attente', 'warning');
return;
}
console.log('🔍 DEBUG - Request à éditer:', request);
console.log('🔍 DEBUG - Request ID:', request.id);
console.log('🔍 DEBUG - Request startDate:', request.startDate);
console.log('🔍 DEBUG - Request endDate:', request.endDate);
console.log('🔍 DEBUG - Request type:', request.type);
console.log('🔍 DEBUG - Request reason:', request.reason);
setRequestToEdit(request);
setShowEditRequestModal(true);
console.log('✅ Modal d\'édition devrait s\'ouvrir');
};
// ⭐ NOUVELLE FONCTION : Supprimer une demande
const handleDeleteRequest = (request) => {
setRequestToDelete(request);
setShowDeleteConfirm(true);
// ⭐ FONCTION : Supprimer/Annuler une demande
const handleDeleteRequest = async (requestId) => {
try {
setIsLoading(true);
console.log('🗑️ Début annulation, ID:', requestId);
// Chercher la demande dans allRequests
let request = allRequests.find(r => r.id === requestId);
if (!request) {
console.log('⚠️ Demande non trouvée dans l\'état local, récupération via API...');
try {
const response = await fetch(`/api/getRequests?user_id=${userId}`);
const result = await response.json();
if (result.success && result.requests) {
request = result.requests.find(r => r.id === requestId);
}
} catch (err) {
console.error('Erreur récupération demande:', err);
}
}
if (!request) {
showToast('❌ Demande introuvable', 'error');
setIsLoading(false);
return;
}
console.log('📋 Demande trouvée:', request);
// Vérifier la date de début
const dateDebut = new Date(request.startDate);
const aujourdhui = new Date();
aujourdhui.setHours(0, 0, 0, 0);
dateDebut.setHours(0, 0, 0, 0);
console.log('📅 Date début:', dateDebut.toLocaleDateString('fr-FR'));
console.log('📅 Aujourd\'hui:', aujourdhui.toLocaleDateString('fr-FR'));
if (dateDebut <= aujourdhui) {
showToast(
`❌ Impossible d'annuler : la date de début (${dateDebut.toLocaleDateString('fr-FR')}) est déjà passée ou c'est aujourd'hui`,
'error'
);
setIsLoading(false);
return;
}
// ⭐ CONFIRMATION AVEC MODAL
setRequestToDelete(request);
setShowDeleteConfirm(true);
setIsLoading(false);
} catch (error) {
console.error('❌ Erreur annulation:', error);
showToast(`Erreur: ${error.message}`, 'error');
setIsLoading(false);
}
};
// ⭐ NOUVELLE FONCTION : Confirmer la suppression
// Fonction helper pour formater les dates
const formatDate = (dateStr) => {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleDateString('fr-FR');
};
// ⭐ FONCTION : Confirmer la suppression
const confirmDeleteRequest = async () => {
if (!requestToDelete) return;
setIsSubmitting(true);
try {
const response = await fetch('http://localhost:3000/deleteRequest', {
const requestData = {
requestId: requestToDelete.id,
userId: userId,
userEmail: user.email,
userName: `${user.prenom} ${user.nom}`,
accessToken: graphToken
};
console.log('📤 Envoi requête:', requestData);
const response = await fetch('/api/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
}),
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestData)
});
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');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
console.log('📥 Réponse API:', result);
if (result.success) {
// ✅ Succès
showToast('✅ Demande annulée avec succès', 'success');
if (result.counterRestored && result.repartition) {
// Afficher les détails de la restauration
const repartitionText = result.repartition
.map(r => `${r.type}: ${r.jours}j${r.periode !== 'Journée entière' ? ` (${r.periode})` : ''}`)
.join(', ');
showToast(`📊 Compteurs restaurés: ${repartitionText}`, 'info');
}
if (result.emailsSent) {
if (result.emailsSent.collaborateur) {
showToast('📧 Email de confirmation envoyé', 'info');
}
if (result.emailsSent.manager) {
showToast('📧 Manager notifié par email', 'info');
}
}
// ⭐ Rafraîchir les données
await refreshAllData();
} else {
// ❌ Erreur
showToast(result.message || 'Erreur lors de l\'annulation', 'error');
}
} catch (error) {
console.error('Erreur suppression:', error);
showToast('❌ Erreur lors de la suppression', 'error');
console.error('Erreur annulation:', error);
showToast(`Erreur serveur: ${error.message}`, 'error');
} finally {
setIsSubmitting(false);
setShowDeleteConfirm(false);
setRequestToDelete(null);
// Fermer les détails si c'était la demande affichée
if (selectedRequest?.id === requestToDelete?.id) {
setSelectedRequest(null);
}
}
};
@@ -193,7 +350,7 @@ const Requests = () => {
console.log('🔌 Connexion SSE au serveur collaborateurs...');
const eventSource = new EventSource(`http://localhost:3000/api/events/collaborateur?user_id=${userId}`);
const eventSource = new EventSource(`/api/events/collaborateur?user_id=${userId}`);
eventSource.onopen = () => {
console.log('✅ SSE connecté');
@@ -253,7 +410,7 @@ const Requests = () => {
if (userId) {
refreshAllData();
}
}, [userId]);
}, [userId, refreshAllData]);
useEffect(() => {
let filtered = allRequests;
@@ -289,6 +446,7 @@ const Requests = () => {
case 'En attente': return 'bg-yellow-100 text-yellow-800';
case 'Validée': return 'bg-green-100 text-green-800';
case 'Refusée': return 'bg-red-100 text-red-800';
case 'Annulée': return 'bg-gray-100 text-gray-800';
default: return 'bg-gray-100 text-gray-800';
}
};
@@ -307,6 +465,32 @@ const Requests = () => {
return (
<div className="min-h-screen bg-gray-50">
{/* 🎯 TUTORIEL INTERACTIF */}
<Joyride
steps={tourSteps}
run={runTour}
continuous
showProgress={false}
showSkipButton
callback={handleJoyrideCallback}
styles={{ options: { primaryColor: '#0891b2', zIndex: 10000 } }}
tooltipComponent={({ continuous, index, step, backProps, primaryProps, skipProps, tooltipProps, size }) => (
<div {...tooltipProps} style={{ backgroundColor: 'white', borderRadius: '12px', padding: '20px', maxWidth: '350px', boxShadow: '0 10px 25px rgba(0,0,0,0.15)', fontSize: '14px' }}>
<div style={{ marginBottom: '15px', color: '#374151' }}>{step.content}</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingTop: '12px', borderTop: '1px solid #e5e7eb' }}>
<span style={{ fontSize: '13px', color: '#6b7280', fontWeight: '500' }}>Étape {index + 1} sur {size}</span>
<div style={{ display: 'flex', gap: '8px' }}>
{index > 0 && <button {...backProps} style={{ padding: '6px 12px', borderRadius: '6px', border: '1px solid #d1d5db', backgroundColor: 'white', color: '#6b7280', cursor: 'pointer', fontSize: '13px', fontWeight: '500' }}>Retour</button>}
{continuous && index < size - 1 && <button {...primaryProps} style={{ padding: '6px 16px', borderRadius: '6px', border: 'none', backgroundColor: '#0891b2', color: 'white', cursor: 'pointer', fontSize: '13px', fontWeight: '500' }}>Suivant</button>}
{(!continuous || index === size - 1) && <button {...primaryProps} style={{ padding: '6px 16px', borderRadius: '6px', border: 'none', backgroundColor: '#0891b2', color: 'white', cursor: 'pointer', fontSize: '13px', fontWeight: '500' }}>Terminer</button>}
<button {...skipProps} style={{ padding: '6px 10px', borderRadius: '6px', border: 'none', backgroundColor: 'transparent', color: '#9ca3af', cursor: 'pointer', fontSize: '12px' }}>Passer</button>
</div>
</div>
</div>
)}
/>
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
{/* Toast container */}
@@ -327,30 +511,72 @@ const Requests = () => {
</div>
{/* Modal de confirmation de suppression */}
{showDeleteConfirm && (
{showDeleteConfirm && requestToDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black bg-opacity-50" onClick={() => setShowDeleteConfirm(false)}></div>
<div className="absolute inset-0 bg-black bg-opacity-50" onClick={() => !isSubmitting && 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>
<h3 className="text-lg font-semibold mb-4 text-gray-900">
Confirmer l'annulation
</h3>
<div className="space-y-3 mb-6">
<p className="text-gray-700">
Voulez-vous annuler cette demande de congé ?
</p>
<div className="bg-gray-50 p-4 rounded-lg space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Type :</span>
<span className="font-medium">{requestToDelete.type}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Période :</span>
<span className="font-medium">{requestToDelete.dateDisplay}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Durée :</span>
<span className="font-medium">{requestToDelete.days} jour(s)</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Statut :</span>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(requestToDelete.status)}`}>
{requestToDelete.status}
</span>
</div>
</div>
{(requestToDelete.status === 'Validée' || requestToDelete.status === 'Validé') && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 text-sm text-blue-800">
<p className="font-medium"> Information</p>
<p className="mt-1">Cette demande a été validée. Vos compteurs seront automatiquement restaurés.</p>
</div>
)}
</div>
<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"
disabled={isSubmitting}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 disabled:opacity-50"
>
Annuler
</button>
<button
onClick={confirmDeleteRequest}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
disabled={isSubmitting}
className="px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 disabled:opacity-50 flex items-center gap-2"
>
Supprimer
{isSubmitting ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Annulation...
</>
) : (
<>
<Trash2 className="w-4 h-4" />
Confirmer l'annulation
</>
)}
</button>
</div>
</div>
@@ -367,7 +593,6 @@ const Requests = () => {
</button>
<div>
<h1 className="text-2xl lg:text-3xl font-bold text-gray-900">Mes demandes</h1>
</div>
</div>
<div className="flex items-center gap-2">
@@ -380,6 +605,7 @@ const Requests = () => {
<RefreshCw className={`w-5 h-5 ${isRefreshing ? 'animate-spin' : ''}`} />
</button>
<button
data-tour="nouvelle-demande"
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"
>
@@ -388,51 +614,6 @@ const Requests = () => {
</div>
</div>
{/* Compteurs */}
{detailedCounters && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{/* CP N */}
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100">
<div className="flex justify-between items-start mb-2">
<h3 className="text-sm font-medium text-gray-500">CP Année N</h3>
</div>
<p className="text-2xl font-bold text-gray-900">{detailedCounters.cpN?.solde?.toFixed(1) || '0.0'}</p>
<p className="text-xs text-gray-500 mt-1">Sur {detailedCounters.cpN?.acquis?.toFixed(1) || '0.0'} acquis</p>
</div>
{/* CP N-1 */}
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100">
<div className="flex justify-between items-start mb-2">
<h3 className="text-sm font-medium text-gray-500">CP Année N-1</h3>
</div>
<p className="text-2xl font-bold text-gray-900">{detailedCounters.cpN1?.solde?.toFixed(1) || '0.0'}</p>
<p className="text-xs text-gray-500 mt-1">Sur {detailedCounters.cpN1?.acquis?.toFixed(1) || '0.0'} acquis</p>
</div>
{/* RTT N */}
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100">
<div className="flex justify-between items-start mb-2">
<h3 className="text-sm font-medium text-gray-500">RTT Année N</h3>
</div>
<p className="text-2xl font-bold text-gray-900">{detailedCounters.rttN?.solde?.toFixed(1) || '0.0'}</p>
<p className="text-xs text-gray-500 mt-1">Sur {detailedCounters.rttN?.acquis?.toFixed(1) || '0.0'} acquis</p>
</div>
{/* Total disponible */}
<div className="bg-gradient-to-br from-blue-500 to-blue-600 p-4 rounded-xl shadow-sm text-white">
<h3 className="text-sm font-medium opacity-90 mb-2">Total disponible</h3>
<p className="text-2xl font-bold">
{(
(detailedCounters.cpN?.solde || 0) +
(detailedCounters.cpN1?.solde || 0) +
(detailedCounters.rttN?.solde || 0)
).toFixed(1)}
</p>
<p className="text-xs opacity-75 mt-1">Jours ouvrés</p>
</div>
</div>
)}
{/* Main content */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left: list */}
@@ -441,7 +622,7 @@ const Requests = () => {
<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">
<div className="flex-1 relative" data-tour="recherche">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5" />
<input
type="text"
@@ -453,6 +634,7 @@ const Requests = () => {
</div>
</div>
<button
data-tour="filtres"
onClick={() => setShowFilters(!showFilters)}
className="flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg hover:bg-gray-50"
>
@@ -472,6 +654,7 @@ const Requests = () => {
<option value="En attente">En attente</option>
<option value="Validée">Validée</option>
<option value="Refusée">Refusée</option>
<option value="Annulée">Annulée</option>
</select>
<select
value={typeFilter}
@@ -479,10 +662,10 @@ const Requests = () => {
className="px-3 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">Tous les types</option>
<option value="Congé payé">Congé payé</option>
<option value="Congé payé">Congé(s) payé(s)</option>
<option value="RTT">RTT</option>
<option value="Arrêt maladie">Arrêt maladie</option>
<option value="Absence">Absence</option>
<option value="Récupération">Récupération</option>
</select>
</div>
)}
@@ -501,7 +684,7 @@ const Requests = () => {
</div>
) : (
<>
<div className="space-y-3">
<div className="space-y-3" data-tour="liste-demandes">
{currentRequests.map((request) => (
<div key={request.id} className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 hover:shadow-md transition-shadow">
<div className="flex justify-between items-start mb-2">
@@ -521,28 +704,28 @@ const Requests = () => {
<div className="mt-3 flex justify-between items-center text-sm">
<span className="text-gray-500">{request.submittedDisplay}</span>
<div className="flex items-center gap-2">
{/* Bouton Modifier (seulement si En attente) */}
{request.status === 'En attente' && (
{/* ⭐ Bouton Modifier - Plus de restriction sur le statut */}
<button
onClick={() => handleEditRequest(request)}
className="text-blue-600 hover:text-blue-700 flex items-center gap-1 px-2 py-1 hover:bg-blue-50 rounded"
title="Modifier"
>
<Edit2 className="w-4 h-4" />
<span className="hidden sm:inline">Modifier</span>
</button>
{/* Bouton Annuler */}
{request.status !== 'Annulée' && request.status !== 'Refusée' && (
<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"
onClick={() => handleDeleteRequest(request.id)}
className="text-orange-600 hover:text-orange-700 flex items-center gap-1 px-2 py-1 hover:bg-orange-50 rounded"
title="Annuler"
>
<Edit2 className="w-4 h-4" />
<span className="hidden sm:inline">Modifier</span>
<X className="w-4 h-4" />
<span className="hidden sm:inline">Annuler</span>
</button>
)}
{/* Bouton Supprimer */}
<button
onClick={() => handleDeleteRequest(request)}
className="text-red-600 hover:text-red-700 flex items-center gap-1 px-2 py-1 hover:bg-red-50 rounded"
title="Supprimer"
>
<Trash2 className="w-4 h-4" />
<span className="hidden sm:inline">Supprimer</span>
</button>
{/* Bouton Voir */}
<button
onClick={() => handleViewRequest(request)}
@@ -620,29 +803,31 @@ const Requests = () => {
<p className="italic text-sm bg-gray-50 p-3 rounded-lg border-l-4" style={{ borderLeftColor: selectedRequest.status === 'Validée' ? '#10b981' : '#ef4444' }}>{selectedRequest.validationComment}</p>
</div>
)}
<MedicalDocuments demandeId={selectedRequest.id} />
<MedicalDocuments demandeId={selectedRequest.id} />
</div>
{/* Actions dans la sidebar */}
<div className="mt-6 pt-4 border-t space-y-2">
{selectedRequest.status === 'En attente' && (
{/* ⭐ Bouton Modifier - Toujours visible */}
<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>
{/* Bouton Annuler */}
{selectedRequest.status !== 'Annulée' && selectedRequest.status !== 'Refusée' && (
<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"
onClick={() => handleDeleteRequest(selectedRequest.id)}
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700"
>
<Edit2 className="w-4 h-4" />
Modifier cette demande
<X className="w-4 h-4" />
Annuler 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>
) : (
@@ -667,7 +852,9 @@ const Requests = () => {
availableRTT_N1: 0,
availableABS: 0,
availableCP: (detailedCounters.cpN1?.solde || 0) + (detailedCounters.cpN?.solde || 0),
availableRTT: detailedCounters.rttN?.solde || 0
availableRTT: detailedCounters.rttN?.solde || 0,
availableRecup: detailedCounters?.recupN?.solde || 0,
availableRecup_N: detailedCounters?.recupN?.solde || 0
}}
accessToken={graphToken}
userId={userId}
@@ -680,10 +867,12 @@ const Requests = () => {
/>
)}
{/* Modal d'édition */}
{/* ⭐ MODAL D'ÉDITION CORRIGÉE */}
{showEditRequestModal && requestToEdit && detailedCounters && (
<EditLeaveRequestModal
isOpen={showEditRequestModal}
onClose={() => {
console.log('❌ Fermeture du modal d\'édition');
setShowEditRequestModal(false);
setRequestToEdit(null);
}}
@@ -697,15 +886,19 @@ const Requests = () => {
availableRTT_N1: 0,
availableABS: 0,
availableCP: (detailedCounters.cpN1?.solde || 0) + (detailedCounters.cpN?.solde || 0),
availableRTT: detailedCounters.rttN?.solde || 0
availableRTT: detailedCounters.rttN?.solde || 0,
availableRecup: detailedCounters?.recupN?.solde || 0,
availableRecup_N: detailedCounters?.recupN?.solde || 0
}}
accessToken={graphToken}
userId={userId}
userEmail={user.email}
userRole={user.role}
userName={`${user.prenom} ${user.nom}`}
onRequestUpdated={() => {
console.log('✅ Demande mise à jour, rafraîchissement...');
refreshAllData();
setShowEditRequestModal(false);
setRequestToEdit(null);
}}
/>
)}

View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: true,
port: 3000
},
build: {
outDir: 'dist',
sourcemap: false,
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom', 'react-router-dom']
}
}
}
}
})

View File

@@ -6,9 +6,23 @@ export default defineConfig({
optimizeDeps: {
exclude: ['lucide-react'],
},
server: {
proxy: {
'/api': {
target: 'http://192.168.0.3:3004',
changeOrigin: true,
secure: false
},
'/uploads': {
target: 'http://192.168.0.3:3004',
changeOrigin: true,
secure: false
}
}
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/setupTests.js',
globals: true,
environment: 'jsdom',
setupFiles: './src/setupTests.js',
},
});

153
setup-complete.ps1 Normal file
View File

@@ -0,0 +1,153 @@
Write-Host "=== Configuration complète du projet GTA ===" -ForegroundColor Cyan
# 1. Créer la structure
Write-Host "`n1. Création de la structure..." -ForegroundColor Yellow
$dirs = @(
"C:\GTA\project\public\backend",
"C:\GTA\project\public\backend\uploads",
"C:\GTA\project\src"
)
foreach ($dir in $dirs) {
if (!(Test-Path $dir)) {
New-Item -Path $dir -ItemType Directory -Force | Out-Null
Write-Host " ✓ Créé: $dir" -ForegroundColor Green
}
}
# 2. Backend package.json
Write-Host "`n2. Création de package.json..." -ForegroundColor Yellow
$backendPackage = @"
{
"name": "gta-backend",
"version": "1.0.0",
"description": "GTA Backend API",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"express": "^4.18.2",
"mysql2": "^3.6.5",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"multer": "^1.4.5-lts.1",
"@microsoft/microsoft-graph-client": "^3.0.7",
"@azure/identity": "^4.0.0",
"body-parser": "^1.20.2"
},
"engines": {
"node": ">=18.0.0"
}
}
"@
Set-Content -Path "C:\GTA\project\backend\package.json" -Value $backendPackage
Write-Host " ✓ package.json créé" -ForegroundColor Green
# 3. Backend .env
Write-Host "`n3. Création de .env..." -ForegroundColor Yellow
$envContent = @"
DB_HOST=mysql
DB_USER=wpuser
DB_PASSWORD=-2b/)ru5/Bi8P[7_
DB_NAME=DemandeConge
PORT=3000
NODE_ENV=production
AZURE_TENANT_ID=9840a2a0-6ae1-4688-b03d-d2ec291be0f9
AZURE_CLIENT_ID=4bb4cc24-bac3-427c-b02c-5d14fc67b561
AZURE_CLIENT_SECRET=gvf8Q~545Bafn8yYsgjW~QG_P1lpzaRe6gJNgb2t
AZURE_GROUP_ID=c1ea877c-6bca-4f47-bfad-f223640813a0
EMAIL_FROM=gtanoreply@ensup.eu
UPLOAD_DIR=./uploads
MAX_FILE_SIZE=5242880
"@
Set-Content -Path "C:\GTA\project\backend\.env" -Value $envContent
Write-Host " ✓ .env créé" -ForegroundColor Green
# 4. Backend server.js
Write-Host "`n4. Création de server.js..." -ForegroundColor Yellow
$serverJs = @"
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const app = express();
const PORT = process.env.PORT || 3000;
app.use(cors());
app.use(express.json());
app.get('/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
env: {
dbHost: process.env.DB_HOST,
dbName: process.env.DB_NAME
}
});
});
app.get('/api/test', (req, res) => {
res.json({ message: 'Backend GTA opérationnel!' });
});
app.listen(PORT, '0.0.0.0', () => {
console.log(\` Serveur démarré sur le port \${PORT}\`);
});
"@
Set-Content -Path "C:\GTA\project\backend\server.js" -Value $serverJs
Write-Host " ✓ server.js créé" -ForegroundColor Green
# 5. Backend Dockerfile
Write-Host "`n5. Création de Dockerfile..." -ForegroundColor Yellow
$dockerfile = @"
FROM node:18-alpine AS base
RUN apk add --no-cache curl mysql-client
WORKDIR /app
COPY package*.json ./
RUN if [ -f package-lock.json ]; then \
npm ci --omit=dev; \
else \
npm install --production; \
fi && npm cache clean --force
COPY . .
RUN mkdir -p /app/uploads && chmod 755 /app/uploads
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 && \
chown -R nodejs:nodejs /app
USER nodejs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
CMD ["node", "server.js"]
"@
Set-Content -Path "C:\GTA\project\backend\DockerfileGTA.backend" -Value $dockerfile
Write-Host " ✓ Dockerfile créé" -ForegroundColor Green
# 6. Afficher le résumé
Write-Host "`n=== Configuration terminée ===" -ForegroundColor Green
Write-Host "`nFichiers créés:" -ForegroundColor Cyan
Get-ChildItem C:\GTA\project\public\Backend | Select-Object Name, Length
Write-Host "`n=== Prochaines étapes ===" -ForegroundColor Yellow
Write-Host "1. cd C:\GTA" -ForegroundColor White
Write-Host "2. docker-compose up --build -d" -ForegroundColor White
Write-Host "3. docker-compose logs -f backend" -ForegroundColor White