diff --git a/Backend/.env b/Backend/.env
new file mode 100644
index 0000000..2966896
--- /dev/null
+++ b/Backend/.env
@@ -0,0 +1,25 @@
+
+
+COLLABORATEURS_URL=http://localhost:3000
+RH_URL=http://localhost:3001
+WEBHOOK_SECRET=secret-rh-2025
+
+
+
+# Configuration Serveur
+PORT=3000
+NODE_ENV=development
+
+# Configuration Azure AD (Microsoft Graph)
+AZURE_TENANT_ID=9840a2a0-6ae1-4688-b03d-d2ec291be0f9
+AZURE_CLIENT_ID=4bb4cc24-bac3-427c-b02c-5d14fc67b561
+AZURE_CLIENT_SECRET=o2q8Q~nGLCkry6XOriVFBMvKUk.5cXtFutAQVdx9
+AZURE_GROUP_ID=c1ea877c-6bca-4f47-bfad-f223640813a0
+JWT_SECRET=o2q8Q~nGLCkry6XOriVFBMvKUk.5cXtFutAQVdx9
+
+# Email configuration
+EMAIL_FROM=gtanoreply@ensup.eu
+
+# Upload configuration
+UPLOAD_DIR=./uploads
+MAX_FILE_SIZE=5242880
\ No newline at end of file
diff --git a/Backend/DockerFileGTARH.backend b/Backend/DockerFileGTARH.backend
new file mode 100644
index 0000000..3622529
--- /dev/null
+++ b/Backend/DockerFileGTARH.backend
@@ -0,0 +1,8 @@
+FROM node:18-alpine
+RUN apk add --no-cache curl mysql-client python3 make g++
+WORKDIR /app
+COPY package*.json ./
+RUN npm install --production
+COPY . .
+EXPOSE 3000
+CMD ["node", "server.js"]
\ No newline at end of file
diff --git a/Backend/package-lock.json b/Backend/package-lock.json
new file mode 100644
index 0000000..3674a8d
--- /dev/null
+++ b/Backend/package-lock.json
@@ -0,0 +1,1841 @@
+{
+ "name": "gtarh-backend",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "gtarh-backend",
+ "version": "1.0.0",
+ "dependencies": {
+ "@azure/msal-node": "^3.8.0",
+ "axios": "^1.12.2",
+ "cors": "^2.8.5",
+ "crypto": "^1.0.1",
+ "dotenv": "^17.2.3",
+ "express": "^5.1.0",
+ "jsonwebtoken": "^9.0.2",
+ "mysql2": "^3.15.1",
+ "node-fetch": "^2.7.0",
+ "pdfkit": "^0.17.2"
+ },
+ "devDependencies": {
+ "nodemon": "^3.1.10"
+ }
+ },
+ "node_modules/@azure/msal-common": {
+ "version": "15.13.0",
+ "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.13.0.tgz",
+ "integrity": "sha512-8oF6nj02qX7eE/6+wFT5NluXRHc05AgdCC3fJnkjiJooq8u7BcLmxaYYSwc2AfEkWRMRi6Eyvvbeqk4U4412Ag==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.0"
+ }
+ },
+ "node_modules/@azure/msal-node": {
+ "version": "3.8.0",
+ "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-3.8.0.tgz",
+ "integrity": "sha512-23BXm82Mp5XnRhrcd4mrHa0xuUNRp96ivu3nRatrfdAqjoeWAGyD0eEAafxAOHAEWWmdlyFK4ELFcdziXyw2sA==",
+ "license": "MIT",
+ "dependencies": {
+ "@azure/msal-common": "15.13.0",
+ "jsonwebtoken": "^9.0.0",
+ "uuid": "^8.3.0"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.5.17",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
+ "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
+ "node_modules/aws-ssl-profiles": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
+ "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/axios": {
+ "version": "1.12.2",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
+ "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-
+ s": "^1.15.6",
+ "form-data": "^4.0.4",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
+ "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^1.0.5",
+ "debug": "^4.4.0",
+ "http-errors": "^2.0.0",
+ "iconv-lite": "^0.6.3",
+ "on-finished": "^2.4.1",
+ "qs": "^6.14.0",
+ "raw-body": "^3.0.0",
+ "type-is": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/brotli": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
+ "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.1.2"
+ }
+ },
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/clone": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
+ "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/content-disposition": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
+ "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/cors": {
+ "version": "2.8.5",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
+ "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
+ "license": "MIT",
+ "dependencies": {
+ "object-assign": "^4",
+ "vary": "^1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/crypto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz",
+ "integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==",
+ "deprecated": "This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in.",
+ "license": "ISC"
+ },
+ "node_modules/crypto-js": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
+ "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/dfa": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
+ "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
+ "license": "MIT"
+ },
+ "node_modules/dotenv": {
+ "version": "17.2.3",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
+ "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
+ "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.0",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
+ "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/follow-redirects": {
+ "version": "1.15.11",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
+ "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fontkit": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
+ "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
+ "license": "MIT",
+ "dependencies": {
+ "@swc/helpers": "^0.5.12",
+ "brotli": "^1.3.2",
+ "clone": "^2.1.2",
+ "dfa": "^1.2.0",
+ "fast-deep-equal": "^3.1.3",
+ "restructure": "^3.0.0",
+ "tiny-inflate": "^1.0.3",
+ "unicode-properties": "^1.4.0",
+ "unicode-trie": "^2.0.0"
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
+ "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/form-data/node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/form-data/node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/generate-function": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
+ "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "is-property": "^1.0.2"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+ "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
+ "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "2.0.0",
+ "inherits": "2.0.4",
+ "setprototypeof": "1.2.0",
+ "statuses": "2.0.1",
+ "toidentifier": "1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/http-errors/node_modules/statuses": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
+ "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ignore-by-default": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
+ "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "license": "MIT"
+ },
+ "node_modules/is-property": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
+ "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
+ "license": "MIT"
+ },
+ "node_modules/jpeg-exif": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
+ "integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==",
+ "license": "MIT"
+ },
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
+ "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^3.2.2",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jwa": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
+ "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
+ "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^1.4.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/linebreak": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
+ "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "0.0.8",
+ "unicode-trie": "^2.0.0"
+ }
+ },
+ "node_modules/linebreak/node_modules/base64-js": {
+ "version": "0.0.8",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
+ "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
+ "node_modules/long": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
+ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/lru-cache": {
+ "version": "7.18.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
+ "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/lru.min": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.2.tgz",
+ "integrity": "sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg==",
+ "license": "MIT",
+ "engines": {
+ "bun": ">=1.0.0",
+ "deno": ">=1.30.0",
+ "node": ">=8.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wellwelwel"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+ "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
+ "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/mysql2": {
+ "version": "3.15.1",
+ "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.1.tgz",
+ "integrity": "sha512-WZMIRZstT2MFfouEaDz/AGFnGi1A2GwaDe7XvKTdRJEYiAHbOrh4S3d8KFmQeh11U85G+BFjIvS1Di5alusZsw==",
+ "license": "MIT",
+ "dependencies": {
+ "aws-ssl-profiles": "^1.1.1",
+ "denque": "^2.1.0",
+ "generate-function": "^2.3.1",
+ "iconv-lite": "^0.7.0",
+ "long": "^5.2.1",
+ "lru.min": "^1.0.0",
+ "named-placeholders": "^1.1.3",
+ "seq-queue": "^0.0.5",
+ "sqlstring": "^2.3.2"
+ },
+ "engines": {
+ "node": ">= 8.0"
+ }
+ },
+ "node_modules/mysql2/node_modules/iconv-lite": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
+ "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/named-placeholders": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz",
+ "integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==",
+ "license": "MIT",
+ "dependencies": {
+ "lru-cache": "^7.14.1"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/node-fetch": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+ "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-url": "^5.0.0"
+ },
+ "engines": {
+ "node": "4.x || >=6.0.0"
+ },
+ "peerDependencies": {
+ "encoding": "^0.1.0"
+ },
+ "peerDependenciesMeta": {
+ "encoding": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/nodemon": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
+ "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chokidar": "^3.5.2",
+ "debug": "^4",
+ "ignore-by-default": "^1.0.1",
+ "minimatch": "^3.1.2",
+ "pstree.remy": "^1.1.8",
+ "semver": "^7.5.3",
+ "simple-update-notifier": "^2.0.0",
+ "supports-color": "^5.5.0",
+ "touch": "^3.1.0",
+ "undefsafe": "^2.0.5"
+ },
+ "bin": {
+ "nodemon": "bin/nodemon.js"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/nodemon"
+ }
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/pako": {
+ "version": "0.2.9",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
+ "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
+ "license": "MIT"
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
+ "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/pdfkit": {
+ "version": "0.17.2",
+ "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz",
+ "integrity": "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==",
+ "license": "MIT",
+ "dependencies": {
+ "crypto-js": "^4.2.0",
+ "fontkit": "^2.0.4",
+ "jpeg-exif": "^1.1.4",
+ "linebreak": "^1.1.0",
+ "png-js": "^1.0.0"
+ }
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/png-js": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz",
+ "integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g=="
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+ "license": "MIT"
+ },
+ "node_modules/pstree.remy": {
+ "version": "1.1.8",
+ "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
+ "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/qs": {
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
+ "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz",
+ "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "3.1.2",
+ "http-errors": "2.0.0",
+ "iconv-lite": "0.7.0",
+ "unpipe": "1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/raw-body/node_modules/iconv-lite": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
+ "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/restructure": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
+ "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
+ "license": "MIT"
+ },
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.7.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
+ "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
+ "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.5",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "mime-types": "^3.0.1",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/seq-queue": {
+ "version": "0.0.5",
+ "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
+ "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="
+ },
+ "node_modules/serve-static": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
+ "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+ "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/simple-update-notifier": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
+ "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.5.3"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/sqlstring": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
+ "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/tiny-inflate": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
+ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
+ "license": "MIT"
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/touch": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz",
+ "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "nodetouch": "bin/nodetouch.js"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "0.0.3",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+ "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+ "license": "MIT"
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/type-is": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
+ "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^1.0.5",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/undefsafe": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
+ "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/unicode-properties": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
+ "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.0",
+ "unicode-trie": "^2.0.0"
+ }
+ },
+ "node_modules/unicode-trie": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
+ "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "pako": "^0.2.5",
+ "tiny-inflate": "^1.0.0"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/uuid": {
+ "version": "8.3.2",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/whatwg-url": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+ "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "~0.0.3",
+ "webidl-conversions": "^3.0.0"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ }
+ }
+}
diff --git a/Backend/package.json b/Backend/package.json
new file mode 100644
index 0000000..ec372a7
--- /dev/null
+++ b/Backend/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "gtarh-backend",
+ "version": "1.0.0",
+ "main": "server.js",
+ "scripts": {
+ "start": "node server.js",
+ "dev": "nodemon server.js"
+ },
+ "dependencies": {
+ "@azure/msal-node": "^3.8.0",
+ "axios": "^1.12.2",
+ "cors": "^2.8.5",
+ "crypto": "^1.0.1",
+ "dotenv": "^17.2.3",
+ "express": "^5.1.0",
+ "jsonwebtoken": "^9.0.2",
+ "mssql": "^11.0.1",
+ "node-fetch": "^2.7.0",
+ "pdfkit": "^0.17.2"
+ },
+ "devDependencies": {
+ "nodemon": "^3.1.10"
+ }
+}
\ No newline at end of file
diff --git a/Backend/server.js b/Backend/server.js
new file mode 100644
index 0000000..fb154db
--- /dev/null
+++ b/Backend/server.js
@@ -0,0 +1,7103 @@
+
+
+const express = require('express');
+const cors = require('cors');
+const sql = require('mssql'); // ⭐ CHANGEMENT: mssql au lieu de mysql2
+const jwt = require('jsonwebtoken');
+const PDFDocument = require('pdfkit');
+
+const { ConfidentialClientApplication } = require('@azure/msal-node');
+
+console.log('✅ 2. Modules de base chargés');
+
+require('dotenv').config();
+console.log('✅ 3. Dotenv chargé');
+
+// HANDLERS D'ERREURS
+process.on('uncaughtException', (error) => {
+ console.error('\n❌❌❌ ERREUR NON CAPTURÉE ❌❌❌');
+ console.error(error);
+ console.error(error.stack);
+});
+
+process.on('unhandledRejection', (reason, promise) => {
+ console.error('\n❌❌❌ PROMESSE REJETÉE ❌❌❌');
+ console.error('Raison:', reason);
+ console.error('Promise:', promise);
+});
+
+process.on('exit', (code) => {
+ console.log(`\n⚠️⚠️⚠️ PROCESSUS EN COURS DE TERMINAISON - CODE: ${code} ⚠️⚠️⚠️\n`);
+});
+
+console.log('✅ 4. Handlers d\'erreurs installés');
+
+// ⭐ IMPORTS WEBHOOKS
+try {
+ console.log('🔄 5. Chargement WebhookManager...');
+ const WebhookManager = require('./webhook-utils.js');
+ console.log(' Type de WebhookManager:', typeof WebhookManager);
+
+ console.log('🔄 6. Chargement webhook-config...');
+ const { WEBHOOKS, EVENTS } = require('./webhook-config');
+ console.log('✅ 7. Webhooks chargés avec succès');
+
+ const app = express();
+ console.log('✅ 8. Express initialisé');
+
+ const PORT = process.env.PORT || 3000;
+ console.log(`✅ 9. Port configuré: ${PORT}`);
+
+ // ⭐ INITIALISER LE WEBHOOK MANAGER
+ console.log('🔄 10. Initialisation WebhookManager...');
+ const webhookManager = new WebhookManager(WEBHOOKS.SECRET_KEY);
+ console.log('✅ 11. WebhookManager créé');
+
+ // Middleware
+ app.use(cors());
+ app.use(express.json());
+ console.log('✅ 12. Middlewares installés');
+
+ // ⭐ 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
+ }
+ };
+
+ console.log('🔄 13. Test connexion SQL Server...');
+ let pool;
+ // ⭐ FONCTION D'INITIALISATION DE LA BASE
+ async function initializeDatabase() {
+ try {
+ pool = await sql.connect(dbConfig);
+ console.log('✅ 14. Connexion SQL Server réussie');
+ console.log(' Server:', dbConfig.server);
+ console.log(' User:', dbConfig.user);
+ console.log(' Database:', dbConfig.database);
+ } catch (err) {
+ console.error('❌ 14. ERREUR CONNEXION SQL SERVER:', err.message);
+ console.error(' Server:', dbConfig.server);
+ console.error(' User:', dbConfig.user);
+ console.error(' Database:', dbConfig.database);
+ throw err;
+ }
+ }
+
+ // Initialiser la connexion
+ initializeDatabase().catch(err => {
+ console.error('❌ Impossible de démarrer le serveur:', err);
+ process.exit(1);
+ });
+
+ console.log('✅ 15. Pool SQL Server configuré');
+
+ // ================================================
+ // SNAPSHOT QUOTIDIEN — Appelle la Stored Procedure
+ // ================================================
+ async function executerSnapshotQuotidien() {
+ try {
+ console.log('[SNAPSHOT] Démarrage du snapshot quotidien...');
+
+ const result = await pool.request()
+ .query('EXEC [dbo].[SP_SnapshotQuotidienCompteurs]');
+
+ const resume = result.recordset?.[0];
+ if (resume) {
+ console.log(`[SNAPSHOT] ✅ Terminé — Date: ${resume.DateSnapshot}, Insérés: ${resume.NbInseres}`);
+ } else {
+ console.log('[SNAPSHOT] ✅ Terminé');
+ }
+ } catch (error) {
+ console.error('[SNAPSHOT] ❌ Erreur:', error.message);
+ }
+ }
+
+ // Configuration Microsoft O365
+ const msalConfig = {
+ auth: {
+ clientId: process.env.AZURE_CLIENT_ID,
+ authority: `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}`,
+ clientSecret: process.env.AZURE_CLIENT_SECRET
+ }
+ };
+
+ const cca = new ConfidentialClientApplication(msalConfig);
+
+ async function getGraphToken() {
+ try {
+ console.log('🔑 Demande token Graph API...');
+ const params = new URLSearchParams({
+ grant_type: 'client_credentials',
+ client_id: process.env.AZURE_CLIENT_ID,
+ client_secret: process.env.AZURE_CLIENT_SECRET,
+ scope: 'https://graph.microsoft.com/.default'
+ });
+
+ const response = await fetch(
+ `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}/oauth2/v2.0/token`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: params.toString()
+ }
+ );
+
+ console.log('📊 Réponse OAuth - Status:', response.status);
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error('❌ Erreur OAuth:', errorText);
+ throw new Error(`OAuth Error: ${response.status}`);
+ }
+
+ const data = await response.json();
+
+ if (data.access_token) {
+ console.log('✅ Token obtenu');
+ return data.access_token;
+ } else {
+ console.error('❌ Pas de token dans la réponse:', data);
+ return null;
+ }
+ } catch (error) {
+ console.error('❌ Erreur getGraphToken:', error.message);
+ return null;
+ }
+ }
+ function agregerLignesParEmploye(demandes) {
+ const employesMap = new Map();
+
+ demandes.forEach(row => {
+ const key = row.collaborateurId;
+
+ if (!employesMap.has(key)) {
+ employesMap.set(key, {
+ employe: row.employe,
+ email: row.email,
+ service: row.service || 'Non assigné',
+ rtt: { nb: 0, dates: [], periodes: [] },
+ cp: { nb: 0, dates: [], periodes: [] },
+ aap: { nb: 0, dates: [], periodes: [] },
+ am: { nb: 0, dates: [], periodes: [] },
+ jposf: { nb: 0, dates: [], periodes: [] },
+ recup: { nb: 0, dates: [], periodes: [] },
+ autres: []
+ });
+ }
+
+ const employe = employesMap.get(key);
+ const dateDebutType = row.dateDebutType ? new Date(row.dateDebutType) : new Date(row.DateDebut);
+ const dateFinType = row.dateFinType ? new Date(row.dateFinType) : new Date(row.DateFin);
+
+ // Pour les demi-journées sans date propre au type,
+ // la date de fin = date de début (une seule journée)
+ const periodeJourneeRaw = row.PeriodeJournee || '';
+ const isDemiJournee = periodeJourneeRaw === 'Matin' || periodeJourneeRaw === 'Après-midi';
+ const dateFinEffective = (isDemiJournee && !row.dateFinType) ? dateDebutType : dateFinType;
+
+ const formatDate = (date) =>
+ String(date.getDate()).padStart(2, '0') + '/' +
+ String(date.getMonth() + 1).padStart(2, '0');
+
+ const dateDebutStr = formatDate(dateDebutType);
+ const dateFinStr = formatDate(dateFinEffective);
+ const periode = dateDebutStr === dateFinStr
+ ? dateDebutStr
+ : `${dateDebutStr}-${dateFinStr}`;
+
+ const nombreJours = parseFloat(row.NombreJours);
+ const typeConge = row.typeConge.toLowerCase();
+
+ let periodeJournee = row.PeriodeJournee || 'Journée';
+ if (periodeJournee === 'Journée entière') periodeJournee = 'Journée';
+
+ if (typeConge === 'jposf') {
+ employe.jposf.nb += nombreJours;
+ employe.jposf.dates.push(periode);
+ employe.jposf.periodes.push(periodeJournee);
+ } else if (typeConge.includes('récup') || typeConge.includes('recup')) {
+ employe.aap.nb += nombreJours;
+ employe.aap.dates.push(periode);
+ employe.aap.periodes.push(periodeJournee);
+ } else if (typeConge.includes('rtt')) {
+ employe.rtt.nb += nombreJours;
+ employe.rtt.dates.push(periode);
+ employe.rtt.periodes.push(periodeJournee);
+ } else if (typeConge.includes('cp') || typeConge.includes('congé payé') || typeConge.includes('conge paye')) {
+ employe.cp.nb += nombreJours;
+ employe.cp.dates.push(periode);
+ employe.cp.periodes.push(periodeJournee);
+ } else if (typeConge.includes('aap') || typeConge.includes('absence autorisée') || typeConge.includes('enfant malade')) {
+ employe.aap.nb += nombreJours;
+ employe.aap.dates.push(periode);
+ employe.aap.periodes.push(periodeJournee);
+ } else if (typeConge.includes('maladie') || typeConge.includes('arrêt') || typeConge.includes('arret')) {
+ employe.am.nb += nombreJours;
+ employe.am.dates.push(periode);
+ employe.am.periodes.push(periodeJournee);
+ } else {
+ const existingAutre = employe.autres.find(a => a.type === row.typeConge);
+ if (existingAutre) {
+ existingAutre.nb += nombreJours;
+ existingAutre.dates.push(periode);
+ existingAutre.periodes.push(periodeJournee);
+ } else {
+ employe.autres.push({
+ type: row.typeConge,
+ nb: nombreJours,
+ dates: [periode],
+ periodes: [periodeJournee]
+ });
+ }
+ }
+ });
+
+ return Array.from(employesMap.values()).map(emp => ({
+ employe: emp.employe,
+ email: emp.email,
+ service: emp.service,
+ rtt: { nb: emp.rtt.nb, dates: emp.rtt.dates.join(' ; '), periodes: emp.rtt.periodes.join(' ; ') },
+ cp: { nb: emp.cp.nb, dates: emp.cp.dates.join(' ; '), periodes: emp.cp.periodes.join(' ; ') },
+ aap: { nb: emp.aap.nb, dates: emp.aap.dates.join(' ; '), periodes: emp.aap.periodes.join(' ; ') },
+ am: { nb: emp.am.nb, dates: emp.am.dates.join(' ; '), periodes: emp.am.periodes.join(' ; ') },
+ jposf: { nb: emp.jposf.nb, dates: emp.jposf.dates.join(' ; '), periodes: emp.jposf.periodes.join(' ; ') },
+ recup: { nb: emp.recup.nb, dates: emp.recup.dates.join(' ; '), periodes: emp.recup.periodes.join(' ; ') },
+ autres: emp.autres.map(a => ({
+ type: a.type,
+ nb: a.nb,
+ dates: a.dates.join(' ; '),
+ periodes: a.periodes.join(' ; ')
+ }))
+ }));
+ }
+
+ function grouperParMoisConge(lignes) {
+ // 1. Séparer les lignes par mois de congé
+ const parMois = {};
+
+ lignes.forEach(row => {
+ const dateConge = new Date(row.dateDebutType || row.DateDebut);
+ const moisKey = `${dateConge.getFullYear()}-${String(dateConge.getMonth() + 1).padStart(2, '0')}`;
+
+ if (!parMois[moisKey]) {
+ parMois[moisKey] = [];
+ }
+ parMois[moisKey].push(row);
+ });
+
+ // 2. Dans chaque mois, agréger par employé
+ const result = {};
+ Object.keys(parMois).sort().forEach(mois => {
+ result[mois] = agregerLignesParEmploye(parMois[mois]);
+ });
+
+ return result;
+ }
+ function estTeletravail(typeCongeNom) {
+ if (!typeCongeNom) return false;
+ const nom = typeCongeNom.toLowerCase();
+ return nom.includes('télétravail') || nom.includes('teletravail') || nom.includes('remote');
+ }
+
+ /**
+ * Détermine si un type de congé est un arrêt maladie
+ */
+ function estArretMaladie(typeCongeNom) {
+ if (!typeCongeNom) return false;
+ const nom = typeCongeNom.toLowerCase();
+ return nom.includes('maladie') || nom.includes('arrêt') || nom.includes('arret') || nom.includes('am');
+ }
+
+ /**
+ * Génère le sujet et le corps HTML de l'email selon le contexte
+ * @param {string} collaborateurNom - Prénom Nom du collaborateur
+ * @param {string} datesPeriode - Ex: "du 02/03 au 05/03" ou "02/03 (Matin)"
+ * @param {number} totalJours - Nombre de jours
+ * @param {string[]} typesCongeNoms - Tableau des noms de types de congé
+ * @param {string|null} commentaire - Commentaire optionnel
+ * @param {string} periodeJournee - "Journée entière", "Matin", "Après-midi"
+ * @returns {{ subject: string, body: string }}
+ */
+ function genererEmailSaisieManuelle(
+ collaborateurNom,
+ datesPeriode,
+ totalJours,
+ typesCongeNoms,
+ commentaire,
+ periodeJournee
+ ) {
+ const tousLesTeletravail = typesCongeNoms.length > 0 && typesCongeNoms.every(t => estTeletravail(t));
+ const tousArretMaladie = typesCongeNoms.length > 0 && typesCongeNoms.every(t => estArretMaladie(t));
+ const contientTeletravail = typesCongeNoms.some(t => estTeletravail(t));
+ const contientMaladie = typesCongeNoms.some(t => estArretMaladie(t));
+
+ // ─── CAS 1 : Télétravail pur ─────────────────────────────────────────────
+ if (tousLesTeletravail) {
+ return {
+ subject: '🏠 Télétravail enregistré',
+ body: `
+
+
+
🏠 Télétravail enregistré
+
+
+
Bonjour ${collaborateurNom},
+
Votre journée de télétravail a été enregistrée par le service RH.
+
+
+
+
+ | 📅 Période : |
+ ${datesPeriode} |
+
+
+ | ⏱️ Durée : |
+ ${totalJours} jour${totalJours > 1 ? 's' : ''} |
+
+ ${periodeJournee && periodeJournee !== 'Journée entière' ? `
+
+ | 🕐 Horaire : |
+ ${periodeJournee} |
+
` : ''}
+ ${commentaire ? `
+
+ | 💬 Commentaire : |
+ ${commentaire} |
+
` : ''}
+
+
+
+
+
+ Pour toute question, contactez le service RH.
+
+
+
`
+ };
+ }
+
+ // ─── CAS 2 : Arrêt maladie pur ──────────────────────────────────────────
+ if (tousArretMaladie) {
+ return {
+ subject: '🩺 Arrêt maladie enregistré',
+ body: `
+
+
+
🩺 Arrêt maladie enregistré
+
+
+
Bonjour ${collaborateurNom},
+
Votre arrêt maladie a bien été pris en compte dans notre système.
+
+
+
+
+ | 📅 Période : |
+ ${datesPeriode} |
+
+
+ | ⏱️ Durée : |
+ ${totalJours} jour${totalJours > 1 ? 's' : ''} |
+
+ ${commentaire ? `
+
+ | 💬 Commentaire : |
+ ${commentaire} |
+
` : ''}
+
+
+
+
+
+
+ Pour toute question, contactez le service RH.
+
+
+
`
+ };
+ }
+
+ // ─── CAS 3 : Mix contenant du télétravail (ex: télétravail + CP) ────────
+ if (contientTeletravail) {
+ const autresTypes = typesCongeNoms.filter(t => !estTeletravail(t));
+ return {
+ subject: '📋 Absence mixte enregistrée',
+ body: `
+
+
+
📋 Absence mixte enregistrée
+
+
+
Bonjour ${collaborateurNom},
+
Votre absence a été enregistrée et validée automatiquement par le service RH.
+
+
+
+
+ | 📅 Période : |
+ ${datesPeriode} |
+
+
+ | 📁 Types : |
+ ${typesCongeNoms.join(', ')} |
+
+
+ | ⏱️ Durée : |
+ ${totalJours} jour${totalJours > 1 ? 's' : ''} |
+
+ ${commentaire ? `
+
+ | 💬 Commentaire : |
+ ${commentaire} |
+
` : ''}
+
+
+
+ Pour toute question, contactez le service RH.
+
+
+
`
+ };
+ }
+
+ // ─── CAS 4 : Congé classique (CP, RTT, AAP, Récupération, etc.) ─────────
+ return {
+ subject: '✅ Congé enregistré et validé',
+ body: `
+
+
+
✅ Congé validé automatiquement
+
+
+
Bonjour ${collaborateurNom},
+
Une demande de congé a été enregistrée et validée automatiquement par le service RH.
+
+
+
+
+ | 📅 Période : |
+ ${datesPeriode} |
+
+
+ | 📁 Type : |
+ ${typesCongeNoms.join(', ')} |
+
+
+ | ⏱️ Durée : |
+ ${totalJours} jour${totalJours > 1 ? 's' : ''} |
+
+ ${periodeJournee && periodeJournee !== 'Journée entière' ? `
+
+ | 🕐 Horaire : |
+ ${periodeJournee} |
+
` : ''}
+ ${commentaire ? `
+
+ | 💬 Commentaire : |
+ ${commentaire} |
+
` : ''}
+
+
+
+ Pour toute question, contactez le service RH.
+
+
+
`
+ };
+ }
+ async function sendMailGraph(accessToken, fromEmail, toEmail, subject, bodyHtml) {
+ try {
+ console.log('📤 Envoi email via Graph API...');
+ console.log(' From:', fromEmail);
+ console.log(' To:', toEmail);
+ console.log(' Subject:', subject);
+
+ const response = await fetch(
+ `https://graph.microsoft.com/v1.0/users/${fromEmail}/sendMail`,
+ {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${accessToken}`,
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ message: {
+ subject,
+ body: { contentType: 'HTML', content: bodyHtml },
+ toRecipients: [{ emailAddress: { address: toEmail } }]
+ },
+ saveToSentItems: false
+ })
+ }
+ );
+
+ console.log('📊 Réponse Graph API - Status:', response.status);
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error('❌ Erreur Graph API:', errorText);
+ throw new Error(`Graph API Error: ${response.status} - ${errorText}`);
+ }
+
+ console.log('✅ Email envoyé avec succès');
+ return true;
+ } catch (error) {
+ console.error('❌ Erreur sendMailGraph:', error.message);
+ return false;
+ }
+ }
+
+ // Middleware d'authentification
+ const authenticateToken = (req, res, next) => {
+ const token = req.headers['authorization']?.split(' ')[1];
+ if (!token) return res.status(401).json({ error: 'Token requis' });
+
+ jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
+ if (err) return res.status(403).json({ error: 'Token invalide' });
+ req.user = user;
+ next();
+ });
+ };
+
+ // ================================================
+ // GESTION DES SERVER-SENT EVENTS (SSE)
+ // ================================================
+
+ const sseClients = new Set();
+
+ // 🔌 ROUTE SSE POUR LE CALENDRIER
+ app.get('/events', (req, res) => {
+ const { token, userid } = req.query;
+
+ let userId = userid;
+
+ // ✅ Si token fourni, extraire l'ID utilisateur
+ if (token && !userId) {
+ try {
+ const decoded = jwt.verify(token, process.env.JWT_SECRET);
+ userId = decoded.id;
+ console.log('🔓 Token JWT décodé, userId:', userId);
+ } catch (error) {
+ console.error('❌ Token invalide:', error.message);
+ return res.status(401).json({ error: 'Token invalide' });
+ }
+ }
+
+ if (!userId) {
+ console.error('❌ Ni userid ni token fourni');
+ return res.status(400).json({ error: 'userid ou token requis' });
+ }
+
+ console.log('🔗 Nouvelle connexion SSE (RH):', userId);
+
+ // Headers SSE critiques
+ res.setHeader('Content-Type', 'text/event-stream');
+ res.setHeader('Cache-Control', 'no-cache');
+ res.setHeader('Connection', 'keep-alive');
+ res.setHeader('X-Accel-Buffering', 'no');
+
+ // Flush pour établir la connexion
+ res.flushHeaders();
+
+ const sendEvent = (data) => {
+ try {
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
+ } catch (error) {
+ console.error('Erreur envoi SSE:', error);
+ }
+ };
+
+ const client = { id: userId, send: sendEvent };
+ sseClients.add(client);
+ console.log(`📊 Clients SSE connectés: ${sseClients.size}`);
+
+ // Envoyer un heartbeat initial
+ sendEvent({ type: 'ping', message: 'Connexion établie', timestamp: new Date().toISOString() });
+
+ // Heartbeat toutes les 30 secondes
+ const heartbeat = setInterval(() => {
+ try {
+ sendEvent({ type: 'ping', timestamp: new Date().toISOString() });
+ } catch (error) {
+ clearInterval(heartbeat);
+ }
+ }, 30000);
+
+ // Gérer la déconnexion
+ req.on('close', () => {
+ console.log('🔌 Déconnexion SSE (RH):', userId);
+ clearInterval(heartbeat);
+ sseClients.delete(client);
+ console.log(`📊 Clients SSE connectés: ${sseClients.size}`);
+ });
+ });
+
+ // 📢 FONCTION POUR NOTIFIER LES CLIENTS
+ const notifyClients = (event, userId = null) => {
+ console.log(`📢 Notification SSE: ${event.type}${userId ? ` pour ${userId}` : ''}`);
+
+ sseClients.forEach(client => {
+ if (userId && client.id !== userId) {
+ return;
+ }
+
+ try {
+ client.send(event);
+ } catch (error) {
+ console.error('❌ Erreur envoi event:', error);
+ }
+ });
+ };
+
+ // ================================================
+ // ROUTE WEBHOOK RECEIVER
+ // ================================================
+
+ app.post('/webhook/receive', async (req, res) => {
+ try {
+ const signature = req.headers['x-webhook-signature'];
+ const payload = req.body;
+
+ console.log('📥 Webhook reçu du serveur RH:', payload.event);
+
+ if (!webhookManager.verifySignature(payload, signature)) {
+ console.error('❌ Signature webhook invalide');
+ return res.status(401).json({ error: 'Signature invalide' });
+ }
+
+ const { event, data } = payload;
+
+ console.log('✅ Signature valide ! Traitement...');
+ console.log(' Event:', event);
+ console.log(' Data:', data);
+
+ switch (event) {
+ case EVENTS.DEMANDE_VALIDATED:
+ console.log(`\n✅ === WEBHOOK DEMANDE_VALIDATED REÇU ===`);
+ console.log(` Demande: ${data.demandeId}`);
+ console.log(` Statut: ${data.statut}`);
+ console.log(` Type: ${data.type || data.typeConge}`);
+ console.log(` Couleur: ${data.couleurHex}`);
+
+ try {
+ // Notification SSE locale (serveur RH)
+ notifyClients({
+ type: 'demande-validated',
+ demandeId: demandeId,
+ statut: 'Validé',
+ typeConge: 'JPOSF',
+ couleurHex: '#d946ef',
+ collaborateurId: parseInt(collaborateurid),
+ timestamp: new Date().toISOString()
+ }, parseInt(collaborateurid));
+
+ // 🔧 WEBHOOK vers serveur COLLABORATEURS (pour calendrier)
+ await webhookManager.sendWebhook(
+ WEBHOOKS.COLLABORATEURS_URL,
+ EVENTS.DEMANDEVALIDATED,
+ {
+ demandeId: demandeId,
+ statut: 'Validé',
+ typeConge: 'JPOSF',
+ couleurHex: '#d946ef',
+ date: date, // ← ESSENTIEL
+ dateDebut: date, // ← ESSENTIEL
+ dateFin: date, // ← ESSENTIEL
+ periode: periodejournee, // ← ESSENTIEL
+ periodeJournee: periodejournee,
+ nombreJours: nombreJours,
+ collaborateurId: parseInt(collaborateurid)
+ }
+ );
+
+ console.log('✅ Webhook JPOSF envoyé au serveur collaborateurs');
+ } catch (sseError) {
+ console.error('❌ Erreur SSE/Webhook:', sseError.message);
+ }
+
+ case EVENTS.DEMANDE_CANCELLED:
+ console.log(`\n🔴 === WEBHOOK DEMANDE_CANCELLED REÇU ===`);
+ console.log(` Demande: ${data.demandeId}`);
+ console.log(` Annulée par: ${data.annulateurRole}`);
+ console.log(` Commentaire: ${data.commentaire || 'Aucun'}`);
+
+ notifyClients({
+ type: 'demande-cancelled',
+ demandeId: data.demandeId,
+ statut: 'Annulée',
+ collaborateurId: data.collaborateurId,
+ annulateurRole: data.annulateurRole,
+ commentaire: data.commentaire,
+ timestamp: new Date().toISOString()
+ });
+
+ notifyClients({
+ type: 'demande-updated',
+ demandeId: data.demandeId,
+ nouveauStatut: 'Annulée',
+ timestamp: new Date().toISOString()
+ }, data.collaborateurId);
+
+ console.log(` ✅ SSE envoyé - Demande ${data.demandeId} marquée comme annulée`);
+ break;
+
+ case EVENTS.COMPTEUR_UPDATED:
+ console.log(`\n🔄 === WEBHOOK COMPTEUR_UPDATED REÇU ===`);
+ console.log(` Collaborateur: ${data.collaborateurId}`);
+
+ notifyClients({
+ type: 'compteur-updated',
+ collaborateurId: data.collaborateurId,
+ typeConge: data.typeConge,
+ typeUpdate: data.typeUpdate,
+ jours: data.jours,
+ timestamp: new Date().toISOString()
+ }, data.collaborateurId);
+
+ console.log(` ✅ SSE envoyé`);
+ break;
+
+ case EVENTS.DEMANDE_UPDATED:
+ console.log(`✏️ Demande ${data.demandeId} modifiée via RH`);
+
+ notifyClients({
+ type: 'demande-updated-rh',
+ demandeId: data.demandeId,
+ timestamp: new Date().toISOString()
+ }, data.collaborateurId);
+ break;
+
+ case EVENTS.DEMANDE_DELETED:
+ console.log(`🗑️ Demande ${data.demandeId} supprimée via RH`);
+
+ notifyClients({
+ type: 'demande-deleted-rh',
+ demandeId: data.demandeId,
+ timestamp: new Date().toISOString()
+ }, data.collaborateurId);
+ break;
+
+ default:
+ console.warn(`⚠️ Type d'événement webhook inconnu: ${event}`);
+ }
+
+ res.json({ success: true, message: 'Webhook traité' });
+
+ } catch (error) {
+ console.error('❌ Erreur traitement webhook:', error);
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+ // ================================================
+ // ROUTES D'AUTHENTIFICATION
+ // ================================================
+
+ app.get('/users-dev', async (req, res) => {
+ try {
+ const result = await pool.request().query(`
+ SELECT id, email, nom, prenom, role, service, Actif
+ FROM CollaborateurAD
+ WHERE Actif = 1 OR Actif IS NULL
+ ORDER BY nom, prenom
+ `);
+ res.json(result.recordset);
+ } catch (error) {
+ console.error('❌ Erreur users-dev:', error);
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+ app.post('/login-dev', async (req, res) => {
+ try {
+ console.log('🔐 Login attempt started');
+ const { accessToken } = req.body;
+
+ if (!accessToken) {
+ console.error('❌ No access token provided');
+ return res.status(400).json({ error: 'Token d\'accès manquant' });
+ }
+
+ console.log('👤 Validating access token...');
+
+ let userInfo;
+ try {
+ const graphResponse = await fetch('https://graph.microsoft.com/v1.0/me', {
+ headers: {
+ 'Authorization': `Bearer ${accessToken}`
+ }
+ });
+
+ if (!graphResponse.ok) {
+ throw new Error('Token invalide');
+ }
+
+ userInfo = await graphResponse.json();
+ console.log('✅ Token validated, user:', userInfo.mail || userInfo.userPrincipalName);
+ } catch (graphError) {
+ console.error('❌ Graph API Error:', graphError);
+ return res.status(401).json({
+ error: 'Token d\'accès invalide',
+ details: graphError.message
+ });
+ }
+
+ const userEmail = userInfo.mail || userInfo.userPrincipalName;
+
+ // ⭐ REQUÊTE SQL SERVER
+ const result = await pool.request()
+ .input('email', sql.VarChar, userEmail)
+ .query(`
+ SELECT * FROM CollaborateurAD
+ WHERE email = @email
+ AND (Actif = 1 OR Actif IS NULL)
+ `);
+
+ const users = result.recordset;
+
+ if (users.length === 0) {
+ console.warn('⚠️ User not found or inactive:', userEmail);
+ return res.status(404).json({
+ error: 'Utilisateur non trouvé ou compte désactivé',
+ details: `Aucun utilisateur actif avec l'email ${userEmail}`
+ });
+ }
+
+ const user = users[0];
+ console.log('👤 User found:', user.email, 'Role:', user.role);
+
+ if (!['RH', 'Admin', 'Validateur'].includes(user.role)) {
+ console.warn('⚠️ Unauthorized role:', user.role);
+ return res.status(403).json({
+ error: 'Accès non autorisé',
+ details: `Le rôle '${user.role}' n'a pas accès à cette application`
+ });
+ }
+
+ const token = jwt.sign(
+ { id: user.id, email: user.email, role: user.role },
+ process.env.JWT_SECRET,
+ { expiresIn: '8h' }
+ );
+
+ console.log('✅ Login successful for:', user.email);
+ res.json({
+ token,
+ user: {
+ id: user.id,
+ nom: user.nom,
+ prenom: user.prenom,
+ role: user.role
+ }
+ });
+ } catch (error) {
+ console.error('❌ Unexpected error in /login-dev:', error);
+ res.status(500).json({
+ error: 'Erreur serveur inattendue',
+ details: error.message
+ });
+ }
+ });
+
+ // ================================================
+ // ROUTES DES DEMANDES DE CONGÉS
+ // ================================================
+
+ // ================================================
+ // ROUTE POST /demandes - VERSION MODIFIÉE AVEC PRIORITÉ CP N-1 puis CP N
+ // Remplacer la route existante par celle-ci
+ // ================================================
+ app.post('/demandes', authenticateToken, async (req, res) => {
+ const transaction = new sql.Transaction(pool);
+ try {
+ await transaction.begin();
+
+ const { collaborateurId, dateDebut, dateFin, typesConge, commentaire, saisieManuelle, periodeJournee } = req.body;
+ const currentYear = new Date().getFullYear();
+
+ console.log('📝 Création demande:', { collaborateurId, dateDebut, dateFin, saisieManuelle, periodeJournee });
+
+ // Vérifier que le collaborateur est actif
+ const collabResult = await transaction.request()
+ .input('collabId', sql.Int, collaborateurId)
+ .query('SELECT Actif, prenom, nom, email FROM CollaborateurAD WHERE id = @collabId');
+
+ const collab = collabResult.recordset;
+
+ if (collab.length === 0 || collab[0].Actif === 0) {
+ await transaction.rollback();
+ return res.status(403).json({ error: 'Impossible de créer une demande pour un compte désactivé' });
+ }
+
+ const totalJours = typesConge.reduce((sum, type) => sum + parseFloat(type.nombreJours), 0);
+ const statut = saisieManuelle ? 'Validée' : 'En attente';
+
+ console.log(`📝 Création demande - Saisie manuelle: ${saisieManuelle}, Statut: ${statut}`);
+
+ // GÉNÉRATION MANUELLE DE L'ID POUR DEMANDECONGE
+ const maxIdResult = await transaction.request()
+ .query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM DemandeConge');
+
+ const demandeId = maxIdResult.recordset[0].NextId;
+ console.log(`📝 Nouvel ID DemandeConge généré: ${demandeId}`);
+
+ if (saisieManuelle) {
+ await transaction.request()
+ .input('id', sql.Int, demandeId)
+ .input('collaborateurId', sql.Int, collaborateurId)
+ .input('dateDebut', sql.Date, dateDebut)
+ .input('dateFin', sql.Date, dateFin)
+ .input('commentaire', sql.NVarChar, commentaire || 'Saisie manuelle RH')
+ .input('totalJours', sql.Decimal(10, 2), totalJours)
+ .input('validateurId', sql.Int, req.user.id)
+ .query(`
+ INSERT INTO DemandeConge
+ (Id, CollaborateurADId, DateDebut, DateFin, Statut, Commentaire, DateDemande, NombreJours, ValidateurADId, DateValidation)
+ VALUES (@id, @collaborateurId, @dateDebut, @dateFin, 'Validée', @commentaire, GETDATE(), @totalJours, @validateurId, GETDATE())
+ `);
+ } else {
+ await transaction.request()
+ .input('id', sql.Int, demandeId)
+ .input('collaborateurId', sql.Int, collaborateurId)
+ .input('dateDebut', sql.Date, dateDebut)
+ .input('dateFin', sql.Date, dateFin)
+ .input('commentaire', sql.NVarChar, commentaire || '')
+ .input('totalJours', sql.Decimal(10, 2), totalJours)
+ .query(`
+ INSERT INTO DemandeConge
+ (Id, CollaborateurADId, DateDebut, DateFin, Statut, Commentaire, DateDemande, NombreJours)
+ VALUES (@id, @collaborateurId, @dateDebut, @dateFin, 'En attente', @commentaire, GETDATE(), @totalJours)
+ `);
+ }
+
+ console.log(`✅ DemandeConge créée - ID: ${demandeId}`);
+
+ // GÉNÉRATION MANUELLE DE L'ID POUR DEMANDECONGETUPE avec PeriodeJournee
+ for (const type of typesConge) {
+ const maxTypeIdResult = await transaction.request()
+ .query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM DemandeCongeType');
+
+ const typeId = maxTypeIdResult.recordset[0].NextId;
+
+ await transaction.request()
+ .input('id', sql.Int, typeId)
+ .input('demandeId', sql.Int, demandeId)
+ .input('typeCongeId', sql.Int, type.typeId)
+ .input('nombreJours', sql.Decimal(10, 2), type.nombreJours)
+ .input('periode', sql.NVarChar(50), periodeJournee || 'Journée entière')
+ .query('INSERT INTO DemandeCongeType (Id, DemandeCongeId, TypeCongeId, NombreJours, PeriodeJournee) VALUES (@id, @demandeId, @typeCongeId, @nombreJours, @periode)');
+
+ console.log(` ✅ DemandeCongeType créée - ID: ${typeId}, TypeCongeId: ${type.typeId}, Période: ${periodeJournee || 'Journée entière'}`);
+ }
+
+ // ⭐ Si saisie manuelle : déduction avec DeductionDetails (même logique que serverGTA)
+ if (saisieManuelle) {
+ console.log('💼 Saisie manuelle - Déduction via DeductionDetails...');
+
+ // ⭐ NOUVEAU : Pré-charger les noms des types pour détecter le télétravail
+ const typeIds = typesConge.map(t => t.typeId);
+ const typesInfoResult = await transaction.request()
+ .query(`SELECT Id, Nom FROM TypeConge WHERE Id IN (${typeIds.join(',')})`);
+ const typeNomMap = {};
+ typesInfoResult.recordset.forEach(t => { typeNomMap[t.Id] = t.Nom; });
+
+ // Déterminer l'exercice CP courant (Annee = année de FIN d'exercice)
+ // Ex : avant 01/06/currentYear → anneeN=currentYear ; après → anneeN=currentYear+1
+ const dateBasculeCP = new Date(currentYear, 5, 1); // 01/06
+ const apresBasculement = new Date() >= dateBasculeCP;
+ const anneeN = apresBasculement ? currentYear + 1 : currentYear;
+ const anneeN1 = anneeN - 1;
+
+ // Exercice du congé (pour détecter l'anticipé)
+ const dateDebutObj = new Date(dateDebut);
+ const moisDebut = dateDebutObj.getMonth() + 1;
+ const anneeDebut = dateDebutObj.getFullYear();
+ const anneeExConge = moisDebut >= 6 ? anneeDebut + 1 : anneeDebut;
+ const isAnticipe = anneeExConge > anneeN;
+
+ // Exercice RTT = année civile du congé
+ const anneeRTT = anneeDebut;
+ const isAnticipeRTT = anneeRTT > currentYear;
+
+ for (const type of typesConge) {
+ const typeId = type.typeId;
+ const nombreJours = parseFloat(type.nombreJours);
+ const typeNom = typeNomMap[typeId] || '';
+
+ // ⭐ NOUVEAU : Télétravail = jour travaillé, pas une absence → aucune déduction
+ if (estTeletravail(typeNom)) {
+ console.log(`🏠 Télétravail (${typeNom}) - ${nombreJours}j - Aucune déduction de compteur`);
+ continue;
+ }
+
+ // ── CP (typeId === 1) ──────────────────────────────────────────
+ if (typeId === 1) {
+ console.log(`\n🔄 CP - ${nombreJours}j à déduire | isAnticipe=${isAnticipe} | anneeExConge=${anneeExConge} | anneeN=${anneeN}`);
+ let joursRestants = nombreJours;
+
+ if (!isAnticipe) {
+ // ── MODE NORMAL : N-1 puis N ──────────────────────────
+ // 1️⃣ CP N-1
+ const cptN1Res = await transaction.request()
+ .input('collabId', sql.Int, collaborateurId)
+ .input('typeId', sql.Int, typeId)
+ .input('annee', sql.Int, anneeN1)
+ .query(`SELECT TOP 1 Id, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId=@collabId AND TypeCongeId=@typeId AND Annee=@annee`);
+
+ if (cptN1Res.recordset.length > 0) {
+ const cptN1 = cptN1Res.recordset[0];
+ const soldeN1 = parseFloat(cptN1.Solde || 0);
+ const aDeduireN1 = Math.min(soldeN1, joursRestants);
+ if (aDeduireN1 > 0) {
+ await transaction.request()
+ .input('ded', sql.Decimal(10, 2), aDeduireN1)
+ .input('id', sql.Int, cptN1.Id)
+ .query(`UPDATE CompteurConges SET Solde=Solde-@ded, SoldeReporte=CASE WHEN SoldeReporte-@ded<0 THEN 0 ELSE SoldeReporte-@ded END, DerniereMiseAJour=GETDATE() WHERE Id=@id`);
+ await transaction.request()
+ .input('demandeId', sql.Int, demandeId)
+ .input('typeId', sql.Int, typeId)
+ .input('annee', sql.Int, anneeN1)
+ .input('jours', sql.Decimal(10, 2), aDeduireN1)
+ .query(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@demandeId, @typeId, @annee, 'Année N-1', @jours)`);
+ console.log(` ✅ CP Année N-1 (${anneeN1}) : -${aDeduireN1}j`);
+ joursRestants -= aDeduireN1;
+ }
+ }
+
+ // 2️⃣ CP N
+ if (joursRestants > 0) {
+ const cptNRes = await transaction.request()
+ .input('collabId', sql.Int, collaborateurId)
+ .input('typeId', sql.Int, typeId)
+ .input('annee', sql.Int, anneeN)
+ .query(`SELECT TOP 1 Id, Solde FROM CompteurConges WHERE CollaborateurADId=@collabId AND TypeCongeId=@typeId AND Annee=@annee`);
+ if (cptNRes.recordset.length > 0) {
+ await transaction.request()
+ .input('ded', sql.Decimal(10, 2), joursRestants)
+ .input('id', sql.Int, cptNRes.recordset[0].Id)
+ .query(`UPDATE CompteurConges SET Solde=Solde-@ded, DerniereMiseAJour=GETDATE() WHERE Id=@id`);
+ await transaction.request()
+ .input('demandeId', sql.Int, demandeId)
+ .input('typeId', sql.Int, typeId)
+ .input('annee', sql.Int, anneeN)
+ .input('jours', sql.Decimal(10, 2), joursRestants)
+ .query(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@demandeId, @typeId, @annee, 'Année N', @jours)`);
+ console.log(` ✅ CP Année N (${anneeN}) : -${joursRestants}j`);
+ joursRestants = 0;
+ } else {
+ console.warn(` ⚠️ Compteur CP N (${anneeN}) introuvable`);
+ }
+ }
+
+ } else {
+ // ── MODE ANTICIPÉ : tout tracé sur anneeN ─────────────
+ // Projeter solde CP N jusqu'au 31/05/anneeN
+ const TAUX_CP = 25 / 360;
+ const cptNRes = await transaction.request()
+ .input('collabId', sql.Int, collaborateurId)
+ .input('typeId', sql.Int, typeId)
+ .input('annee', sql.Int, anneeN)
+ .query(`SELECT TOP 1 Id, Solde FROM CompteurConges WHERE CollaborateurADId=@collabId AND TypeCongeId=@typeId AND Annee=@annee`);
+ const soldeNActuel = cptNRes.recordset.length > 0 ? Math.max(0, parseFloat(cptNRes.recordset[0].Solde || 0)) : 0;
+
+ const finExN = new Date(anneeN, 4, 31);
+ const joursRestantsExN = Math.max(0, Math.floor((finExN - new Date()) / 86400000));
+ const acquisRestantsN = parseFloat(Math.min(25 - soldeNActuel, joursRestantsExN * TAUX_CP).toFixed(2));
+ const soldeNProj = parseFloat(Math.min(25, soldeNActuel + acquisRestantsN).toFixed(2));
+
+ // Part sur solde réel actuel → TypeDeduction='Anticip'
+ const aDeduireAnticip = Math.min(soldeNActuel, joursRestants);
+ if (aDeduireAnticip > 0 && cptNRes.recordset.length > 0) {
+ await transaction.request()
+ .input('ded', sql.Decimal(10, 2), aDeduireAnticip)
+ .input('id', sql.Int, cptNRes.recordset[0].Id)
+ .query(`UPDATE CompteurConges SET Solde=Solde-@ded, DerniereMiseAJour=GETDATE() WHERE Id=@id`);
+ await transaction.request()
+ .input('demandeId', sql.Int, demandeId)
+ .input('typeId', sql.Int, typeId)
+ .input('annee', sql.Int, anneeN)
+ .input('jours', sql.Decimal(10, 2), aDeduireAnticip)
+ .query(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@demandeId, @typeId, @annee, 'Anticip', @jours)`);
+ console.log(` ✅ CP Anticip (${anneeN}) : -${aDeduireAnticip}j sur solde réel`);
+ joursRestants -= aDeduireAnticip;
+ }
+
+ // Part sur acquisitions futures → TypeDeduction='AnticipFutur'
+ if (joursRestants > 0) {
+ const aDeduireAnticipFutur = Math.min(acquisRestantsN, joursRestants);
+ if (aDeduireAnticipFutur > 0) {
+ await transaction.request()
+ .input('demandeId', sql.Int, demandeId)
+ .input('typeId', sql.Int, typeId)
+ .input('annee', sql.Int, anneeN)
+ .input('jours', sql.Decimal(10, 2), aDeduireAnticipFutur)
+ .query(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@demandeId, @typeId, @annee, 'AnticipFutur', @jours)`);
+ console.log(` ✅ CP AnticipFutur (${anneeN}) : ${aDeduireAnticipFutur}j tracé (acquisitions futures)`);
+ joursRestants -= aDeduireAnticipFutur;
+ }
+ }
+
+ // Part sur CP N+1 (après 01/06/anneeN) → TypeDeduction='AnticipFutur' sur anneeExConge
+ if (joursRestants > 0) {
+ await transaction.request()
+ .input('demandeId', sql.Int, demandeId)
+ .input('typeId', sql.Int, typeId)
+ .input('annee', sql.Int, anneeExConge)
+ .input('jours', sql.Decimal(10, 2), joursRestants)
+ .query(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@demandeId, @typeId, @annee, 'AnticipFutur', @jours)`);
+ console.log(` ✅ CP AnticipFutur N+1 (${anneeExConge}) : ${joursRestants}j tracé`);
+ joursRestants = 0;
+ }
+ }
+
+ if (joursRestants > 0) {
+ console.warn(` ⚠️ CP : ${joursRestants}j non couverts (solde insuffisant, traçage forcé sur anneeN)`);
+ await transaction.request()
+ .input('demandeId', sql.Int, demandeId)
+ .input('typeId', sql.Int, typeId)
+ .input('annee', sql.Int, anneeN)
+ .input('jours', sql.Decimal(10, 2), joursRestants)
+ .query(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@demandeId, @typeId, @annee, 'AnticipFutur', @jours)`);
+ }
+
+ // ── RTT (typeId === 2) ─────────────────────────────────────────
+ } else if (typeId === 2) {
+ console.log(`\n🔄 RTT - ${nombreJours}j à déduire | isAnticipeRTT=${isAnticipeRTT} | anneeRTT=${anneeRTT}`);
+ let joursRestants = nombreJours;
+
+ const cptRTTRes = await transaction.request()
+ .input('collabId', sql.Int, collaborateurId)
+ .input('typeId', sql.Int, typeId)
+ .input('annee', sql.Int, anneeRTT)
+ .query(`SELECT TOP 1 Id, Solde FROM CompteurConges WHERE CollaborateurADId=@collabId AND TypeCongeId=@typeId AND Annee=@annee`);
+
+ if (!isAnticipeRTT) {
+ // ── MODE NORMAL RTT ──────────────────────────────────────
+ if (cptRTTRes.recordset.length > 0) {
+ await transaction.request()
+ .input('ded', sql.Decimal(10, 2), joursRestants)
+ .input('id', sql.Int, cptRTTRes.recordset[0].Id)
+ .query(`UPDATE CompteurConges SET Solde=Solde-@ded, DerniereMiseAJour=GETDATE() WHERE Id=@id`);
+ await transaction.request()
+ .input('demandeId', sql.Int, demandeId)
+ .input('typeId', sql.Int, typeId)
+ .input('annee', sql.Int, anneeRTT)
+ .input('jours', sql.Decimal(10, 2), joursRestants)
+ .query(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@demandeId, @typeId, @annee, 'Année N', @jours)`);
+ console.log(` ✅ RTT Année N (${anneeRTT}) : -${joursRestants}j`);
+ joursRestants = 0;
+ } else {
+ console.warn(` ⚠️ Compteur RTT (${anneeRTT}) introuvable`);
+ }
+ } else {
+ // ── MODE ANTICIPÉ RTT ────────────────────────────────────
+ const soldeRTTActuel = cptRTTRes.recordset.length > 0 ? Math.max(0, parseFloat(cptRTTRes.recordset[0].Solde || 0)) : 0;
+
+ // Part sur solde réel → AnticipRTT
+ const aDeduireAnticipRTT = Math.min(soldeRTTActuel, joursRestants);
+ if (aDeduireAnticipRTT > 0 && cptRTTRes.recordset.length > 0) {
+ await transaction.request()
+ .input('ded', sql.Decimal(10, 2), aDeduireAnticipRTT)
+ .input('id', sql.Int, cptRTTRes.recordset[0].Id)
+ .query(`UPDATE CompteurConges SET Solde=Solde-@ded, DerniereMiseAJour=GETDATE() WHERE Id=@id`);
+ await transaction.request()
+ .input('demandeId', sql.Int, demandeId)
+ .input('typeId', sql.Int, typeId)
+ .input('annee', sql.Int, anneeRTT)
+ .input('jours', sql.Decimal(10, 2), aDeduireAnticipRTT)
+ .query(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@demandeId, @typeId, @annee, 'AnticipRTT', @jours)`);
+ console.log(` ✅ RTT AnticipRTT (${anneeRTT}) : -${aDeduireAnticipRTT}j`);
+ joursRestants -= aDeduireAnticipRTT;
+ }
+
+ // Part sur acquisitions futures → AnticipFuturRTT
+ if (joursRestants > 0) {
+ await transaction.request()
+ .input('demandeId', sql.Int, demandeId)
+ .input('typeId', sql.Int, typeId)
+ .input('annee', sql.Int, anneeRTT)
+ .input('jours', sql.Decimal(10, 2), joursRestants)
+ .query(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@demandeId, @typeId, @annee, 'AnticipFuturRTT', @jours)`);
+ console.log(` ✅ RTT AnticipFuturRTT (${anneeRTT}) : ${joursRestants}j tracé`);
+ joursRestants = 0;
+ }
+ }
+
+ // ── Autres types (Récupération, JPOSF, etc.) ──────────────────
+ } else {
+ const anneeType = anneeDebut; // année civile du congé
+ const cptRes = await transaction.request()
+ .input('collabId', sql.Int, collaborateurId)
+ .input('typeId', sql.Int, typeId)
+ .input('annee', sql.Int, anneeType)
+ .query(`SELECT TOP 1 Id FROM CompteurConges WHERE CollaborateurADId=@collabId AND TypeCongeId=@typeId AND Annee=@annee`);
+
+ if (cptRes.recordset.length > 0) {
+ await transaction.request()
+ .input('ded', sql.Decimal(10, 2), nombreJours)
+ .input('id', sql.Int, cptRes.recordset[0].Id)
+ .query(`UPDATE CompteurConges SET Solde=Solde-@ded, DerniereMiseAJour=GETDATE() WHERE Id=@id`);
+ } else {
+ // Fallback : update sans filtre d'année (comportement original)
+ await transaction.request()
+ .input('ded', sql.Decimal(10, 2), nombreJours)
+ .input('collabId', sql.Int, collaborateurId)
+ .input('typeId', sql.Int, typeId)
+ .query(`UPDATE CompteurConges SET Solde=Solde-@ded, DerniereMiseAJour=GETDATE() WHERE CollaborateurADId=@collabId AND TypeCongeId=@typeId AND Annee=YEAR(GETDATE())`);
+ }
+ await transaction.request()
+ .input('demandeId', sql.Int, demandeId)
+ .input('typeId', sql.Int, typeId)
+ .input('annee', sql.Int, anneeType)
+ .input('jours', sql.Decimal(10, 2), nombreJours)
+ .query(`INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (@demandeId, @typeId, @annee, 'Année N', @jours)`);
+ console.log(` ✅ Type ${typeId} : -${nombreJours}j (Année N ${anneeType})`);
+ }
+ }
+
+ // GÉNÉRATION MANUELLE DE L'ID POUR HISTORIQUEACTIONS
+ try {
+ const maxHistIdResult = await transaction.request()
+ .query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM HistoriqueActions');
+
+ const histId = maxHistIdResult.recordset[0].NextId;
+
+ await transaction.request()
+ .input('id', sql.Int, histId)
+ .input('userId', sql.Int, req.user.id)
+ .input('details', sql.NVarChar, `Demande créée et validée automatiquement par ${req.user.role}`)
+ .input('demandeId', sql.Int, demandeId)
+ .query(`
+ INSERT INTO HistoriqueActions (Id, CollaborateurADId, Action, Details, DemandeCongeId, DateAction)
+ VALUES (@id, @userId, 'Saisie manuelle validée', @details, @demandeId, GETDATE())
+ `);
+ } catch (histError) {
+ console.log('⚠️ Tentative sans ID pour HistoriqueActions...');
+ await transaction.request()
+ .input('userId', sql.Int, req.user.id)
+ .input('details', sql.NVarChar, `Demande créée et validée automatiquement par ${req.user.role}`)
+ .input('demandeId', sql.Int, demandeId)
+ .query(`
+ INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DemandeCongeId, DateAction)
+ VALUES (@userId, 'Saisie manuelle validée', @details, @demandeId, GETDATE())
+ `);
+ }
+ }
+
+ await transaction.commit();
+
+ // Notifications SSE
+ if (saisieManuelle) {
+ notifyClients({
+ type: 'demande-validated',
+ demandeId: demandeId,
+ statut: 'Validée',
+ collaborateurId: collaborateurId,
+ timestamp: new Date().toISOString()
+ }, collaborateurId);
+
+ notifyClients({
+ type: 'compteur-updated',
+ collaborateurId: collaborateurId,
+ action: 'saisie_manuelle_rh',
+ timestamp: new Date().toISOString()
+ }, collaborateurId);
+
+ // Webhooks
+ try {
+ await webhookManager.sendWebhook(
+ WEBHOOKS.COLLABORATEURS_URL,
+ EVENTS.DEMANDE_VALIDATED,
+ { demandeId, statut: 'Validée', collaborateurId, validateurId: req.user.id }
+ );
+ await webhookManager.sendWebhook(
+ WEBHOOKS.COLLABORATEURS_URL,
+ EVENTS.COMPTEUR_UPDATED,
+ { collaborateurId }
+ );
+ } catch (webhookError) {
+ console.error('⚠️ Erreur webhook:', webhookError.message);
+ }
+
+ // Email
+ try {
+ const accessToken = await getGraphToken();
+ if (accessToken && collab[0].email) {
+ const collaborateurNom = `${collab[0].prenom} ${collab[0].nom}`;
+ const dateDebutFormatted = new Date(dateDebut).toLocaleDateString('fr-FR');
+ const dateFinFormatted = new Date(dateFin).toLocaleDateString('fr-FR');
+ const datesPeriode = dateDebutFormatted === dateFinFormatted
+ ? `${dateDebutFormatted}${periodeJournee && periodeJournee !== 'Journée entière' ? ` (${periodeJournee})` : ''}`
+ : `du ${dateDebutFormatted} au ${dateFinFormatted}`;
+
+ // Récupérer les noms des types de congé
+ const typesCongeNoms = [];
+ for (const type of typesConge) {
+ const nomRes = await pool.request()
+ .input('typeId', sql.Int, type.typeId)
+ .query('SELECT Nom FROM TypeConge WHERE Id = @typeId');
+ if (nomRes.recordset.length > 0) typesCongeNoms.push(nomRes.recordset[0].Nom);
+ }
+
+ const { subject, body } = genererEmailSaisieManuelle(
+ collaborateurNom,
+ datesPeriode,
+ totalJours,
+ typesCongeNoms,
+ commentaire,
+ periodeJournee
+ );
+
+ await sendMailGraph(accessToken, 'gtanoreply@ensup.eu', collab[0].email, subject, body);
+ }
+ } catch (emailError) {
+ console.error('⚠️ Erreur email:', emailError.message);
+ }
+ } else {
+ notifyClients({
+ type: 'demande-created',
+ demandeId: demandeId,
+ collaborateurId: collaborateurId,
+ timestamp: new Date().toISOString()
+ });
+ }
+
+ console.log(`✅ Demande ${demandeId} créée avec statut: ${statut}`);
+
+ res.json({
+ id: demandeId,
+ message: saisieManuelle ? 'Demande créée et validée automatiquement' : 'Demande créée avec succès',
+ statut: statut
+ });
+
+ } catch (error) {
+ await transaction.rollback();
+ console.error('❌ Erreur création demande:', error);
+ res.status(500).json({ error: error.message });
+ }
+ });
+ // ================================================
+ // ROUTES COMPTE-RENDU D'ACTIVITÉS
+ // ================================================
+
+ // GET /api/compte-rendu/services - Liste des services avec forfaits jour
+ app.get('/compte-rendu/services', authenticateToken, async (req, res) => {
+ try {
+ if (!['Admin', 'RH'].includes(req.user.role)) {
+ return res.status(403).json({ error: 'Accès non autorisé' });
+ }
+
+ const result = await pool.request().query(`
+ SELECT
+ s.Id,
+ s.Nom,
+ COUNT(DISTINCT ca.id) as nombreForfaitJour
+ FROM Services s
+ LEFT JOIN CollaborateurAD ca ON s.Id = ca.ServiceId
+ AND ca.TypeContrat = 'forfait_jour'
+ AND (ca.Actif = 1 OR ca.Actif IS NULL)
+ GROUP BY s.Id, s.Nom
+ HAVING COUNT(DISTINCT ca.id) > 0
+ ORDER BY s.Nom
+ `);
+
+ res.json(result.recordset);
+ } catch (error) {
+ console.error('❌ Erreur /compte-rendu/services:', error);
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+ // GET /api/compte-rendu/stats - Stats globales des comptes-rendus
+ app.get('/compte-rendu/stats', authenticateToken, async (req, res) => {
+ try {
+ if (!['Admin', 'RH'].includes(req.user.role)) {
+ return res.status(403).json({ error: 'Accès non autorisé' });
+ }
+
+ const { annee, mois, service_id } = req.query;
+
+ const request = pool.request()
+ .input('annee', sql.Int, parseInt(annee))
+ .input('mois', sql.Int, parseInt(mois));
+
+ let serviceFilter = '';
+ if (service_id && service_id !== 'all') {
+ serviceFilter = 'AND ca.ServiceId = @serviceId';
+ request.input('serviceId', sql.Int, parseInt(service_id));
+ }
+
+ // Compter les forfaits jour actifs
+ const totalResult = await request.query(`
+ SELECT COUNT(DISTINCT ca.id) as total
+ FROM CollaborateurAD ca
+ WHERE ca.TypeContrat = 'forfait_jour'
+ AND (ca.Actif = 1 OR ca.Actif IS NULL)
+ ${serviceFilter}
+ `);
+
+ const totalCollaborateurs = totalResult.recordset[0].total;
+
+ // Compter les mensuels validés (verrouillés)
+ const validesRequest = pool.request()
+ .input('annee', sql.Int, parseInt(annee))
+ .input('mois', sql.Int, parseInt(mois));
+
+ if (service_id && service_id !== 'all') {
+ validesRequest.input('serviceId', sql.Int, parseInt(service_id));
+ }
+
+ const validesResult = await validesRequest.query(`
+ SELECT COUNT(DISTINCT m.CollaborateurADId) as valides
+ FROM CompteRenduMensuel m
+ JOIN CollaborateurAD ca ON m.CollaborateurADId = ca.id
+ WHERE m.Annee = @annee
+ AND m.Mois = @mois
+ AND m.Verrouille = 1
+ AND ca.TypeContrat = 'forfait_jour'
+ AND (ca.Actif = 1 OR ca.Actif IS NULL)
+ ${serviceFilter}
+ `);
+
+ const mensuelsValides = validesResult.recordset[0].valides || 0;
+ const mensuelsEnAttente = totalCollaborateurs - mensuelsValides;
+
+ res.json({
+ totalCollaborateurs,
+ mensuelsValides,
+ mensuelsEnAttente
+ });
+ } catch (error) {
+ console.error('❌ Erreur /compte-rendu/stats:', error);
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+ // Route GET /api/compte-rendu-activites
+ app.get('/compte-rendu-activites', authenticateToken, async (req, res) => {
+ try {
+ const { userid, annee, mois } = req.query;
+
+ if (!userid || !annee || !mois) {
+ return res.status(400).json({
+ success: false,
+ message: 'Paramètres manquants : userid, annee et mois requis'
+ });
+ }
+
+ // Vérifier si l'utilisateur est RH/Admin
+ if (!['RH', 'Admin'].includes(req.user.role)) {
+ return res.status(403).json({ error: 'Accès réservé aux RH et Administrateurs' });
+ }
+
+ const collaborateurId = parseInt(userid);
+ const anneeInt = parseInt(annee);
+ const moisInt = parseInt(mois);
+
+ // Récupérer les jours du mois
+ const joursResult = await pool.request()
+ .input('collaborateurId', sql.Int, collaborateurId)
+ .input('annee', sql.Int, anneeInt)
+ .input('mois', sql.Int, moisInt)
+ .query(`
+ SELECT
+ JourDate, JourTravaille, ReposQuotidienRespect,
+ ReposHebdomadaireRespect, CommentaireRepos,
+ DateSaisie, DateModification
+ FROM CompteRenduActivites
+ WHERE CollaborateurADId = @collaborateurId
+ AND Annee = @annee
+ AND Mois = @mois
+ ORDER BY JourDate
+ `);
+ // Récupérer les statistiques mensuelles
+ const mensuelResult = await pool.request()
+ .input('collaborateurId', sql.Int, collaborateurId)
+ .input('annee', sql.Int, anneeInt)
+ .input('mois', sql.Int, moisInt)
+ .query(`
+ SELECT
+ CAST(NbJoursTravailles AS DECIMAL(10,2)) as NbJoursTravailles,
+ NbJoursNonRespectsReposQuotidien,
+ NbJoursNonRespectsReposHebdo,
+ Verrouille
+ FROM CompteRenduMensuel
+ WHERE CollaborateurADId = @collaborateurId
+ AND Annee = @annee
+ AND Mois = @mois
+ `);
+
+ const jours = joursResult.recordset;
+ const mensuel = mensuelResult.recordset.length > 0 ? mensuelResult.recordset[0] : null;
+
+ res.json({
+ success: true,
+ jours: jours,
+ mensuel: mensuel
+ });
+
+ } catch (error) {
+ console.error('Erreur /compte-rendu-activites:', error);
+ res.status(500).json({
+ success: false,
+ message: 'Erreur serveur',
+ error: error.message
+ });
+ }
+ });
+
+
+
+
+ // GET /api/export-compte-rendu-pdf - Export PDF
+ // ================================================
+ // ROUTE CORRIGÉE : GET /api/export-compte-rendu-pdf
+ // ================================================
+
+ // ================================================
+ // ROUTE EXPORT PDF COMPTE-RENDU (DESIGN MODERNE)
+ // ================================================
+ app.get('/export-compte-rendu-pdf', authenticateToken, async (req, res) => {
+ try {
+ if (!['Admin', 'RH'].includes(req.user.role)) {
+ return res.status(403).json({ error: 'Accès non autorisé' });
+ }
+
+ const { user_id, annee, mois } = req.query;
+
+ if (!user_id || !annee || !mois) {
+ return res.status(400).json({
+ success: false,
+ message: 'Paramètres manquants (user_id, annee, mois requis)'
+ });
+ }
+
+ console.log(`📄 Génération PDF moderne: User=${user_id}, ${mois}/${annee}`);
+
+ // ✅ DÉFINIR LES HEADERS
+ res.setHeader('Content-Type', 'application/pdf');
+ res.setHeader('Content-Disposition',
+ `attachment; filename="compte-rendu-${annee}-${String(mois).padStart(2, '0')}.pdf"`);
+ res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
+
+ // ✅ REQUÊTES SQL SERVER
+ const collabResult = await pool.request()
+ .input('userId', sql.Int, parseInt(user_id))
+ .query(`
+ SELECT
+ ca.prenom,
+ ca.nom,
+ ca.email,
+ COALESCE(s.Nom, 'Non défini') as service
+ FROM CollaborateurAD ca
+ LEFT JOIN Services s ON ca.ServiceId = s.Id
+ WHERE ca.id = @userId
+ `);
+
+ if (collabResult.recordset.length === 0) {
+ return res.status(404).json({
+ success: false,
+ message: 'Collaborateur non trouvé'
+ });
+ }
+
+ const collab = collabResult.recordset[0];
+
+ // Récupérer le mensuel
+ const mensuelResult = await pool.request()
+ .input('userId', sql.Int, parseInt(user_id))
+ .input('annee', sql.Int, parseInt(annee))
+ .input('mois', sql.Int, parseInt(mois))
+ .query(`
+ SELECT
+ CAST(NbJoursTravailles AS DECIMAL(10,2)) as NbJoursTravailles,
+ NbJoursNonRespectsReposQuotidien,
+ NbJoursNonRespectsReposHebdo,
+ Verrouille,
+ DateValidation
+ FROM CompteRenduMensuel
+WHERE CollaborateurADId = @userId
+ AND Annee = @annee
+ AND Mois = @mois
+ `);
+
+ const mensuel = mensuelResult.recordset.length > 0
+ ? mensuelResult.recordset[0]
+ : null;
+
+ // Récupérer les jours
+ const joursResult = await pool.request()
+ .input('userId', sql.Int, parseInt(user_id))
+ .input('annee', sql.Int, parseInt(annee))
+ .input('mois', sql.Int, parseInt(mois))
+ .query(`
+ SELECT
+ JourDate,
+ JourTravaille,
+ ReposQuotidienRespect,
+ ReposHebdomadaireRespect,
+ CommentaireRepos
+ FROM CompteRenduActivites
+WHERE CollaborateurADId = @userId
+ AND Annee = @annee
+ AND Mois = @mois
+ ORDER BY JourDate
+ `);
+ const jours = joursResult.recordset || [];
+
+ console.log(`✅ Données: ${jours.length} jours`);
+
+ // ✅ CRÉER LE PDF
+ const doc = new PDFDocument({
+ bufferPages: true,
+ margin: 60,
+ size: 'A4'
+ });
+
+ doc.pipe(res);
+
+ const monthNames = ['Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
+ 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'];
+
+ // ===== TITRE PRINCIPAL (CYAN) =====
+ doc.fontSize(24)
+ .fillColor('#06b6d4') // Cyan
+ .text('Compte-rendu d\'activités', { align: 'center' });
+
+ doc.moveDown(0.5);
+
+ // ===== SOUS-TITRE (GRIS) =====
+ doc.fontSize(16)
+ .fillColor('#6b7280')
+ .text(`${monthNames[parseInt(mois) - 1]} ${annee}`, { align: 'center' });
+
+ doc.moveDown(2);
+
+ // ===== SECTION COLLABORATEUR =====
+ doc.fontSize(12)
+ .fillColor('#000000')
+ .text('Collaborateur', { underline: true });
+
+ doc.moveDown(0.3);
+
+ doc.fontSize(10)
+ .fillColor('#374151')
+ .text(`Nom: ${collab.prenom} ${collab.nom}`)
+ .text(`Email: ${collab.email}`)
+ .text(`Service: ${collab.service}`);
+
+ doc.moveDown(1.5);
+
+ // ===== ENCADRÉ RÉSUMÉ (FOND CYAN CLAIR) =====
+ doc.fontSize(12)
+ .fillColor('#000000')
+ .text('Résumé du mois', { underline: true });
+
+ doc.moveDown(0.5);
+
+ const resumeY = doc.y;
+ const resumeHeight = 80;
+
+ // Rectangle avec fond cyan très clair et bordure cyan
+ doc.rect(60, resumeY, 475, resumeHeight)
+ .fillAndStroke('#e0f2fe', '#06b6d4');
+
+ // Contenu de l'encadré (2 colonnes)
+ const col1X = 75;
+ const col2X = 320;
+ const textY = resumeY + 15;
+
+ doc.fontSize(10)
+ .fillColor('#374151');
+
+ // Colonne 1
+ doc.text(`Jours travaillés: ${mensuel?.NbJoursTravailles || 0}`, col1X, textY);
+ doc.text(`Non-respect repos quotidien: ${mensuel?.NbJoursNonRespectsReposQuotidien || 0}`, col1X, textY + 20);
+ doc.text(`Non-respect repos hebdomadaire: ${mensuel?.NbJoursNonRespectsReposHebdo || 0}`, col1X, textY + 40);
+
+ // Colonne 2
+ const statutText = mensuel?.Verrouille ? 'Verrouillé' : 'Ouvert';
+ doc.text(`Statut: ${statutText}`, col2X, textY);
+
+ if (mensuel?.DateValidation) {
+ const dateVal = new Date(mensuel.DateValidation);
+ const dateStr = dateVal.toLocaleDateString('fr-FR');
+ doc.text(`Date validation: ${dateStr}`, col2X, textY + 20);
+ }
+
+ doc.y = resumeY + resumeHeight + 20;
+ doc.moveDown(1);
+
+ // ===== TABLEAU DÉTAIL DES JOURS =====
+ doc.fontSize(12)
+ .fillColor('#000000')
+ .text('Détail des jours', { align: 'center' });
+
+ doc.moveDown(0.5);
+
+ const tableTop = doc.y;
+ const colWidth = {
+ date: 120,
+ travaille: 80,
+ repos: 120,
+ commentaire: 155
+ };
+
+ let currentX = 60;
+ const tableHeaderY = tableTop;
+
+ // En-têtes du tableau
+ doc.fontSize(9)
+ .fillColor('#6b7280');
+
+ doc.text('Date', currentX, tableHeaderY, { width: colWidth.date, align: 'left' });
+ currentX += colWidth.date;
+
+ doc.text('Travaillé', currentX, tableHeaderY, { width: colWidth.travaille, align: 'center' });
+ currentX += colWidth.travaille;
+
+ doc.text('Respect des repos', currentX, tableHeaderY, { width: colWidth.repos, align: 'center' });
+ currentX += colWidth.repos;
+
+ doc.text('Commentaire', currentX, tableHeaderY, { width: colWidth.commentaire, align: 'left' });
+
+ // Ligne de séparation sous les en-têtes
+ doc.moveTo(60, tableHeaderY + 15)
+ .lineTo(535, tableHeaderY + 15)
+ .stroke('#d1d5db');
+
+ let currentY = tableHeaderY + 25;
+ const rowHeight = 20;
+
+ // Lignes du tableau
+ jours.forEach((jour, index) => {
+ // Vérifier nouvelle page
+ if (currentY > 720) {
+ doc.addPage();
+ currentY = 60;
+ }
+
+ // Alterner le fond des lignes
+ if (index % 2 === 0) {
+ doc.rect(60, currentY - 3, 475, rowHeight)
+ .fill('#f9fafb');
+ }
+
+ currentX = 60;
+
+ doc.fontSize(8)
+ .fillColor('#374151');
+
+ // Date
+ const dateStr = new Date(jour.JourDate).toLocaleDateString('fr-FR');
+ doc.text(dateStr, currentX, currentY, { width: colWidth.date, align: 'left' });
+ currentX += colWidth.date;
+
+ // Travaillé (coche simple)
+ const travailleText = jour.JourTravaille ? '✓' : '-';
+ doc.text(travailleText, currentX, currentY, { width: colWidth.travaille, align: 'center' });
+ currentX += colWidth.travaille;
+
+ // Respect des repos (coche verte ou rien)
+ const respectOk = jour.ReposQuotidienRespect && jour.ReposHebdomadaireRespect;
+ if (respectOk) {
+ // Dessiner une coche verte circulaire
+ const centerX = currentX + colWidth.repos / 2;
+ const centerY = currentY + 5;
+
+ doc.circle(centerX, centerY, 6)
+ .fillAndStroke('#22c55e', '#16a34a');
+
+ doc.fontSize(8)
+ .fillColor('#ffffff')
+ .text('✓', centerX - 3, centerY - 4, { width: 6, align: 'center' });
+ } else {
+ doc.fillColor('#374151')
+ .text('-', currentX, currentY, { width: colWidth.repos, align: 'center' });
+ }
+ currentX += colWidth.repos;
+
+ // Commentaire
+ const comment = jour.CommentaireRepos || '-';
+ doc.fillColor('#374151')
+ .text(comment.substring(0, 25), currentX, currentY, { width: colWidth.commentaire, align: 'left' });
+
+ currentY += rowHeight;
+ });
+
+ // Ligne de séparation finale
+ doc.moveTo(60, currentY)
+ .lineTo(535, currentY)
+ .stroke('#d1d5db');
+
+ // ===== PIED DE PAGE =====
+ doc.moveDown(2);
+ doc.fontSize(8)
+ .fillColor('#9ca3af')
+ .text(
+ `Document généré automatiquement le ${new Date().toLocaleDateString('fr-FR')}`,
+ 60,
+ 750,
+ { align: 'center', width: 475 }
+ );
+
+ doc.end();
+
+ console.log(`✅ PDF moderne généré pour ${collab.nom}`);
+
+ } catch (error) {
+ console.error('❌ Erreur export PDF:', error);
+
+ if (!res.headersSent) {
+ res.status(500).json({
+ success: false,
+ message: error.message || 'Erreur lors de l\'export PDF'
+ });
+ }
+ }
+ });
+
+
+ // GET /api/compte-rendu/cumul-annuel - Cumul annuel d'un collaborateur
+ app.get('/compte-rendu/cumul-annuel', authenticateToken, async (req, res) => {
+ try {
+ if (!['Admin', 'RH'].includes(req.user.role)) {
+ return res.status(403).json({ error: 'Accès non autorisé' });
+ }
+
+ const { user_id, annee } = req.query;
+
+ if (!user_id || !annee) {
+ return res.status(400).json({
+ success: false,
+ message: 'Paramètres manquants (user_id, annee)'
+ });
+ }
+
+ console.log(`📊 Calcul cumul annuel: User=${user_id}, Année=${annee}`);
+
+ // Récupérer les données annuelles
+ const cumulResult = await pool.request()
+ .input('userId', sql.Int, parseInt(user_id))
+ .input('annee', sql.Int, parseInt(annee))
+ .query(`
+ SELECT
+ SUM(CAST(NbJoursTravailles AS DECIMAL(10,2))) as totalJoursTravailles,
+ SUM(NbJoursNonRespectsReposQuotidien) as totalReposQuotidien,
+ SUM(NbJoursNonRespectsReposHebdo) as totalReposHebdo,
+ COUNT(CASE WHEN Verrouille = 1 THEN 1 END) as moisValides,
+ COUNT(*) as moisSaisis
+FROM CompteRenduMensuel
+WHERE CollaborateurADId = @userId
+ AND Annee = @annee
+ `);
+
+ const cumul = cumulResult.recordset[0];
+
+ res.json({
+ success: true,
+ cumul: {
+ totalJoursTravailles: cumul.totalJoursTravailles || 0,
+ totalReposQuotidien: cumul.totalReposQuotidien || 0,
+ totalReposHebdo: cumul.totalReposHebdo || 0,
+ moisValides: cumul.moisValides || 0,
+ moisSaisis: cumul.moisSaisis || 0
+ }
+ });
+
+ } catch (error) {
+ console.error('❌ Erreur cumul annuel:', error);
+ res.status(500).json({
+ success: false,
+ message: error.message
+ });
+ }
+ });
+
+
+
+ // ================================================
+ // ROUTE POST /compteurs/saisir-jposf - VERSION AVEC GÉNÉRATION MANUELLE D'ID
+ // ================================================
+ app.post('/compteurs/saisir-jposf', authenticateToken, async (req, res) => {
+ const transaction = new sql.Transaction(pool);
+
+ try {
+ console.log('\n📥 ========== DÉBUT SAISIE JPOSF ==========');
+
+ await transaction.begin();
+
+ if (!['Admin', 'RH'].includes(req.user.role)) {
+ await transaction.rollback();
+ return res.status(403).json({ success: false, error: 'Accès non autorisé' });
+ }
+
+ const { collaborateur_id, date, periode_journee, commentaire } = req.body;
+
+ if (!collaborateur_id || !date || !periode_journee) {
+ await transaction.rollback();
+ return res.status(400).json({ success: false, message: 'Données manquantes' });
+ }
+
+ console.log('📋 Données:', { collaborateur_id, date, periode_journee });
+
+ // Récupérer le collaborateur
+ const collabResult = await transaction.request()
+ .input('id', sql.Int, parseInt(collaborateur_id))
+ .query('SELECT id, prenom, nom, email, Actif FROM CollaborateurAD WHERE id = @id');
+
+ if (collabResult.recordset.length === 0) {
+ await transaction.rollback();
+ return res.status(404).json({ success: false, message: 'Collaborateur non trouvé' });
+ }
+
+ const collab = collabResult.recordset[0];
+
+ if (collab.Actif === false || collab.Actif === 0) {
+ await transaction.rollback();
+ return res.status(403).json({ success: false, message: 'Compte désactivé' });
+ }
+
+ const currentYear = new Date().getFullYear();
+ const nombreJours = periode_journee === 'Journée entière' ? 1 : 0.5;
+
+ // Récupérer les types de congé
+ const jposfTypeResult = await transaction.request()
+ .query("SELECT Id FROM TypeConge WHERE Nom = 'JPOSF'");
+
+ if (jposfTypeResult.recordset.length === 0) {
+ await transaction.rollback();
+ return res.status(500).json({ success: false, message: 'Type JPOSF non trouvé' });
+ }
+
+ const recupTypeResult = await transaction.request()
+ .query("SELECT Id FROM TypeConge WHERE Nom = 'Récupération'");
+
+ if (recupTypeResult.recordset.length === 0) {
+ await transaction.rollback();
+ return res.status(500).json({ success: false, message: 'Type Récupération non trouvé' });
+ }
+
+ const jposfTypeId = jposfTypeResult.recordset[0].Id;
+ const recupTypeId = recupTypeResult.recordset[0].Id;
+
+ console.log(`📋 Types: JPOSF=${jposfTypeId}, Récupération=${recupTypeId}`);
+
+ // ⭐ ÉTAPE 1: Mettre à jour le compteur Récupération
+ const compteurResult = await transaction.request()
+ .input('collaborateurId', sql.Int, parseInt(collaborateur_id))
+ .input('recupTypeId', sql.Int, recupTypeId)
+ .input('currentYear', sql.Int, currentYear)
+ .query(`
+ SELECT Id, Total, Solde FROM CompteurConges
+ WHERE CollaborateurADId = @collaborateurId
+ AND TypeCongeId = @recupTypeId
+ AND Annee = @currentYear
+ `);
+
+ let nouveauTotalRecup, nouveauSoldeRecup;
+
+ if (compteurResult.recordset.length > 0) {
+ // ✅ Compteur existe : UPDATE
+ const compteur = compteurResult.recordset[0];
+ nouveauTotalRecup = parseFloat(compteur.Total || 0) + nombreJours;
+ nouveauSoldeRecup = parseFloat(compteur.Solde || 0) + nombreJours;
+
+ await transaction.request()
+ .input('total', sql.Decimal(10, 2), nouveauTotalRecup)
+ .input('solde', sql.Decimal(10, 2), nouveauSoldeRecup)
+ .input('id', sql.Int, compteur.Id)
+ .query('UPDATE CompteurConges SET Total = @total, Solde = @solde, DerniereMiseAJour = GETDATE() WHERE Id = @id');
+
+ console.log(`✅ Compteur Récup mis à jour: ${nouveauSoldeRecup}j`);
+ } else {
+ // ⭐ CORRECTION : Générer l'ID manuellement pour CompteurConges
+ const maxCompteurIdResult = await transaction.request()
+ .query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM CompteurConges');
+
+ const compteurId = maxCompteurIdResult.recordset[0].NextId;
+ console.log(`📝 Nouvel ID CompteurConges: ${compteurId}`);
+
+ nouveauTotalRecup = nombreJours;
+ nouveauSoldeRecup = nombreJours;
+
+ // ✅ INSERT avec ID explicite
+ await transaction.request()
+ .input('id', sql.Int, compteurId)
+ .input('collaborateurId', sql.Int, parseInt(collaborateur_id))
+ .input('recupTypeId', sql.Int, recupTypeId)
+ .input('currentYear', sql.Int, currentYear)
+ .input('total', sql.Decimal(10, 2), nouveauTotalRecup)
+ .input('solde', sql.Decimal(10, 2), nouveauSoldeRecup)
+ .query(`
+ INSERT INTO CompteurConges
+ (Id, CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour)
+ VALUES (@id, @collaborateurId, @recupTypeId, @currentYear, @total, @solde, 0, GETDATE())
+ `);
+
+ console.log(`✅ Compteur Récup créé: ${nouveauSoldeRecup}j`);
+ }
+
+ // ⭐ ÉTAPE 2: Générer l'ID pour DemandeConge
+ const maxDemandeIdResult = await transaction.request()
+ .query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM DemandeConge');
+
+ const demandeId = maxDemandeIdResult.recordset[0].NextId;
+ console.log(`📝 Nouvel ID DemandeConge: ${demandeId}`);
+
+ const commentaireFinal = commentaire?.trim() || `JPOSF - ${periode_journee}`;
+
+ // Créer la demande JPOSF
+ await transaction.request()
+ .input('id', sql.Int, demandeId)
+ .input('collaborateurId', sql.Int, parseInt(collaborateur_id))
+ .input('dateDebut', sql.Date, date)
+ .input('dateFin', sql.Date, date)
+ .input('commentaire', sql.NVarChar(500), commentaireFinal)
+ .input('totalJours', sql.Decimal(10, 2), nombreJours)
+ .input('validateurId', sql.Int, req.user.id)
+ .query(`
+ INSERT INTO DemandeConge
+ (Id, CollaborateurADId, DateDebut, DateFin, Statut, Commentaire, DateDemande, NombreJours, ValidateurADId, DateValidation)
+ VALUES (@id, @collaborateurId, @dateDebut, @dateFin, 'Validée', @commentaire, GETDATE(), @totalJours, @validateurId, GETDATE())
+ `);
+
+ console.log(`✅ DemandeConge créée`);
+
+ // ⭐ ÉTAPE 3: Générer l'ID pour DemandeCongeType
+ const maxTypeIdResult = await transaction.request()
+ .query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM DemandeCongeType');
+
+ const demandeCongeTypeId = maxTypeIdResult.recordset[0].NextId;
+ console.log(`📝 Nouvel ID DemandeCongeType: ${demandeCongeTypeId}`);
+
+ // Lier avec le type JPOSF
+ await transaction.request()
+ .input('id', sql.Int, demandeCongeTypeId)
+ .input('demandeId', sql.Int, demandeId)
+ .input('typeId', sql.Int, jposfTypeId)
+ .input('nombreJours', sql.Decimal(10, 2), nombreJours)
+ .query('INSERT INTO DemandeCongeType (Id, DemandeCongeId, TypeCongeId, NombreJours) VALUES (@id, @demandeId, @typeId, @nombreJours)');
+
+ console.log(`✅ DemandeCongeType créée`);
+
+ // Historique (avec gestion d'erreur si la table a IDENTITY ou non)
+ try {
+ const maxHistIdResult = await transaction.request()
+ .query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM HistoriqueActions');
+
+ const histId = maxHistIdResult.recordset[0].NextId;
+
+ await transaction.request()
+ .input('id', sql.Int, histId)
+ .input('userId', sql.Int, req.user.id)
+ .input('details', sql.NVarChar(500), `Saisie JPOSF - ${date} (${periode_journee}) : +${nombreJours}j pour ${collab.prenom} ${collab.nom}`)
+ .input('demandeId', sql.Int, demandeId)
+ .query(`
+ INSERT INTO HistoriqueActions (Id, CollaborateurADId, Action, Details, DemandeCongeId, DateAction)
+ VALUES (@id, @userId, 'Saisie JPOSF', @details, @demandeId, GETDATE())
+ `);
+ } catch (histError) {
+ // Fallback sans ID si la table a IDENTITY
+ console.log('⚠️ Fallback historique sans ID...');
+ await transaction.request()
+ .input('userId', sql.Int, req.user.id)
+ .input('details', sql.NVarChar(500), `Saisie JPOSF - ${date} (${periode_journee}) : +${nombreJours}j pour ${collab.prenom} ${collab.nom}`)
+ .input('demandeId', sql.Int, demandeId)
+ .query(`
+ INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DemandeCongeId, DateAction)
+ VALUES (@userId, 'Saisie JPOSF', @details, @demandeId, GETDATE())
+ `);
+ }
+
+ console.log(`✅ Historique créé`);
+
+ await transaction.commit();
+ console.log('✅ Transaction COMMIT');
+
+ // Notifications SSE
+ // ========== Notifications SSE ==========
+ try {
+ // ⭐ CORRECTION: Envoyer les détails complets de la demande pour affichage calendrier
+ notifyClients({
+ type: 'demande-validated',
+ demandeId: demandeId,
+ statut: 'Validée',
+ typeConge: 'JPOSF',
+ couleurHex: '#d946ef',
+ date: date,
+ dateDebut: date,
+ dateFin: date,
+ periode: periode_journee,
+ periodeJournee: periode_journee,
+ nombreJours: nombreJours,
+ collaborateurId: parseInt(collaborateur_id),
+ timestamp: new Date().toISOString()
+ }, parseInt(collaborateur_id));
+
+ notifyClients({
+ type: 'compteur-updated',
+ collaborateurId: parseInt(collaborateur_id),
+ typeConge: 'Récupération',
+ action: 'saisie_jposf_rh',
+ nouveauSolde: nouveauSoldeRecup,
+ timestamp: new Date().toISOString()
+ }, parseInt(collaborateur_id));
+
+ console.log('✅ Notifications SSE envoyées avec détails complets pour calendrier');
+ } catch (sseError) {
+ console.error('⚠️ Erreur SSE (non bloquant):', sseError.message);
+ }
+ // Webhooks
+ try {
+ if (typeof webhookManager !== 'undefined' && WEBHOOKS && EVENTS) {
+ await webhookManager.sendWebhook(WEBHOOKS.COLLABORATEURS_URL, EVENTS.DEMANDE_VALIDATED, {
+ demandeId, statut: 'Validée', typeConge: 'JPOSF', collaborateurId: parseInt(collaborateur_id)
+ });
+ await webhookManager.sendWebhook(WEBHOOKS.COLLABORATEURS_URL, EVENTS.COMPTEUR_UPDATED, {
+ collaborateurId: parseInt(collaborateur_id), typeConge: 'Récupération'
+ });
+ }
+ } catch (webhookError) {
+ console.error('⚠️ Erreur webhook:', webhookError.message);
+ }
+
+ // Email
+ try {
+ const accessToken = await getGraphToken();
+ if (accessToken && collab.email) {
+ const subject = '📅 JPOSF enregistré';
+ const body = `
+
+
+
📅 JPOSF enregistré
+
+
+
Bonjour ${collab.prenom} ${collab.nom},
+
+
Date : ${new Date(date).toLocaleDateString('fr-FR')}
+
Période : ${periode_journee}
+
Jours ajoutés : ${nombreJours}j
+
+
✅ +${nombreJours}j ajoutés au compteur Récupération
+
+
`;
+ await sendMailGraph(accessToken, 'gtanoreply@ensup.eu', collab.email, subject, body);
+ }
+ } catch (emailError) {
+ console.error('⚠️ Erreur email:', emailError.message);
+ }
+
+ console.log('✅ ========== JPOSF ENREGISTRÉ ==========\n');
+
+ return res.json({
+ success: true,
+ message: 'JPOSF enregistré avec succès',
+ collaborateur: `${collab.prenom} ${collab.nom}`,
+ date: date,
+ periode: periode_journee,
+ jours_ajoutes: nombreJours,
+ nouveau_solde_recup: nouveauSoldeRecup,
+ demandeId: demandeId
+ });
+
+ } catch (error) {
+ console.error('❌ ERREUR JPOSF:', error.message);
+ console.error('Stack:', error.stack);
+ try { await transaction.rollback(); } catch (e) { }
+
+ return res.status(500).json({
+ success: false,
+ message: 'Erreur serveur',
+ error: error.message
+ });
+ }
+ });
+
+ // ⭐ ROUTE GET /api/jposf - Ajoutez cette route dans server.js
+ app.get('/jposf', authenticateToken, async (req, res) => {
+ try {
+ const currentYear = new Date().getFullYear();
+
+ console.log('📊 GET /api/jposf - Année:', currentYear);
+
+ const result = await pool.request()
+ .input('currentYear', sql.Int, currentYear)
+ .query(`
+ SELECT
+ MIN(dc.Id) AS Id,
+ dc.CollaborateurADId,
+ CONCAT(ca.prenom, ' ', ca.nom) AS employe,
+ ca.email,
+ COALESCE(s.Nom, 'Sans service') AS service,
+ COUNT(DISTINCT dc.Id) AS totalEvenements,
+ SUM(dct.NombreJours) AS solde
+ FROM DemandeConge dc
+ JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
+ LEFT JOIN Services s ON ca.ServiceId = s.Id
+ JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
+ JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
+ WHERE tc.Nom = 'JPOSF'
+ AND dc.Statut = 'Validée'
+ AND YEAR(dc.DateDebut) = @currentYear
+ AND (ca.Actif = 1 OR ca.Actif IS NULL)
+ GROUP BY
+ dc.CollaborateurADId,
+ ca.prenom,
+ ca.nom,
+ ca.email,
+ s.Nom
+ ORDER BY ca.nom, ca.prenom
+ `);
+
+ console.log('📊 JPOSF trouvés:', result.recordset.length);
+ res.json(result.recordset);
+
+ } catch (error) {
+ console.error('❌ Erreur GET /api/jposf:', error);
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+
+
+ // ROUTE GET /demandes avec pagination et filtres
+
+ // Dans server.js, remplacer la route GET /demandes
+ app.get('/demandes', authenticateToken, async (req, res) => {
+ try {
+ const { limit, statut, serviceid, campusid } = req.query;
+ const request = pool.request();
+
+ // Filtres WHERE
+ let whereClause = "WHERE (ca.Actif = 1 OR ca.Actif IS NULL)";
+
+ if (statut && statut !== 'all') {
+ whereClause += " AND dc.Statut = @statut";
+ request.input('statut', sql.VarChar, statut);
+ }
+
+ if (serviceid && serviceid !== 'all') {
+ whereClause += " AND ca.ServiceId = @serviceid";
+ request.input('serviceid', sql.Int, parseInt(serviceid));
+ }
+
+ if (campusid && campusid !== 'all') {
+ whereClause += " AND ca.CampusId = @campusid";
+ request.input('campusid', sql.Int, parseInt(campusid));
+ }
+
+ let topClause = '';
+ if (limit) {
+ topClause = `TOP ${parseInt(limit)}`;
+ }
+
+ // Requête principale
+ const query = `
+ SELECT ${topClause}
+ dc.Id,
+ dc.CollaborateurADId,
+ dc.DateDebut,
+ dc.DateFin,
+ dc.Statut,
+ dc.Commentaire,
+ dc.DateDemande,
+ dc.NombreJours,
+ dc.ValidateurADId,
+ dc.DateValidation,
+ dc.CommentaireValidation,
+ CONCAT(ca.prenom, ' ', ca.nom) AS nomEmploye,
+ ca.email AS emailEmploye,
+ s.Nom AS service,
+ c.Nom AS campus,
+ -- Agréger les types de congé (pour affichage simple)
+ STUFF((
+ SELECT DISTINCT ', ' + tc2.Nom
+ FROM DemandeCongeType dct2
+ JOIN TypeConge tc2 ON dct2.TypeCongeId = tc2.Id
+ WHERE dct2.DemandeCongeId = dc.Id
+ ORDER BY ', ' + tc2.Nom
+ FOR XML PATH('')
+ ), 1, 2, '') AS typesConge
+ FROM DemandeConge dc
+ JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
+ LEFT JOIN Services s ON ca.ServiceId = s.Id
+ LEFT JOIN Campus c ON ca.CampusId = c.Id
+ ${whereClause}
+ ORDER BY dc.DateDemande DESC
+ `;
+
+ const result = await request.query(query);
+ const demandes = result.recordset;
+
+ // Récupérer les détails de tous les types de congé
+ const detailsQuery = `
+ SELECT
+ dct.DemandeCongeId,
+ tc.Nom AS typeConge,
+ dct.NombreJours AS nombreJours,
+ dct.PeriodeJournee,
+ dc.DateDebut AS dateDebut,
+ dc.DateFin AS dateFin
+ FROM DemandeCongeType dct
+ JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
+ JOIN DemandeConge dc ON dct.DemandeCongeId = dc.Id
+ WHERE dct.DemandeCongeId IN (${demandes.map(d => d.Id).join(',') || '0'})
+ ORDER BY dct.DemandeCongeId, tc.Nom
+ `;
+
+ const detailsResult = await pool.request().query(detailsQuery);
+
+ // Grouper les détails par demande
+ const detailsParDemande = {};
+ detailsResult.recordset.forEach(detail => {
+ if (!detailsParDemande[detail.DemandeCongeId]) {
+ detailsParDemande[detail.DemandeCongeId] = [];
+ }
+ detailsParDemande[detail.DemandeCongeId].push({
+ typeConge: detail.typeConge,
+ nombreJours: parseFloat(detail.nombreJours),
+ dateDebut: detail.dateDebut,
+ dateFin: detail.dateFin
+ });
+ });
+
+ // Enrichir chaque demande avec ses détails
+ const demandesEnrichies = demandes.map(demande => ({
+ Id: demande.Id,
+ nomEmploye: demande.nomEmploye,
+ emailEmploye: demande.emailEmploye,
+ service: demande.service,
+ Campus: demande.campus,
+ typesConge: demande.typesConge || 'Non spécifié',
+ detailsConges: detailsParDemande[demande.Id] || [], // ✅ Toujours présent
+ DateDebut: demande.DateDebut,
+ DateFin: demande.DateFin,
+ NombreJours: demande.NombreJours,
+ Statut: demande.Statut,
+ Commentaire: demande.Commentaire,
+ DateDemande: demande.DateDemande
+ }));
+
+ console.log(`${demandesEnrichies.length} demandes avec détails`);
+ res.json(demandesEnrichies);
+
+ } catch (error) {
+ console.error('Erreur GET /demandes:', error);
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+
+ app.get('/demandes/:id', authenticateToken, async (req, res) => {
+ try {
+ const result = await pool.request()
+ .input('id', sql.Int, req.params.id)
+ .query(`
+ SELECT
+ dc.*,
+ CONCAT(ca.prenom, ' ', ca.nom) AS nomEmploye,
+ ca.email AS emailEmploye,
+ ca.Actif AS employeActif,
+ ca.DateSortie AS employeDateSortie,
+ s.Nom AS service,
+ s.Id AS serviceId,
+ CONCAT(v.prenom, ' ', v.nom) AS nomValidateur,
+ v.email AS emailValidateur
+ FROM DemandeConge dc
+ JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
+ LEFT JOIN Services s ON ca.ServiceId = s.Id
+ LEFT JOIN CollaborateurAD v ON dc.ValidateurADId = v.id
+ WHERE dc.Id = @id
+ `);
+
+ const demandes = result.recordset;
+
+ if (demandes.length === 0) {
+ return res.status(404).json({ error: 'Demande non trouvée' });
+ }
+
+ const typesResult = await pool.request()
+ .input('id', sql.Int, req.params.id)
+ .query(`
+ SELECT
+ dct.TypeCongeId as typeId,
+ dct.NombreJours as nombreJours,
+ tc.Nom AS nom,
+ tc.CouleurHex AS couleur
+ FROM DemandeCongeType dct
+ JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
+ WHERE dct.DemandeCongeId = @id
+ `);
+
+ const types = typesResult.recordset;
+
+ res.json({
+ ...demandes[0],
+ typesConge: types
+ });
+ } catch (error) {
+ console.error('❌ Erreur détails demande:', error);
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+
+ // ================================================
+ // ROUTE PUT /demandes/:id - AVEC VALIDATION AUTOMATIQUE
+ // Remplacer la route existante dans server.js (ligne ~1438)
+ // ================================================
+ app.put('/demandes/:id', authenticateToken, async (req, res) => {
+ const transaction = new sql.Transaction(pool);
+ try {
+ await transaction.begin();
+
+ const { id } = req.params;
+ const { dateDebut, dateFin, typesConge, commentaire } = req.body;
+
+ console.log('📝 Modification demande:', { id, dateDebut, dateFin, typesConge });
+
+ const demandeResult = await transaction.request()
+ .input('id', sql.Int, id)
+ .query('SELECT Statut, CollaborateurADId FROM DemandeConge WHERE Id = @id');
+
+ const demande = demandeResult.recordset;
+
+ if (demande.length === 0) {
+ await transaction.rollback();
+ return res.status(404).json({ error: 'Demande non trouvée' });
+ }
+
+ const ancienStatut = demande[0].Statut;
+ const collaborateurId = demande[0].CollaborateurADId;
+
+ const rolesAutorises = ['Admin', 'RH', 'Validateur'];
+ const estAuteur = collaborateurId === req.user.id;
+ const estRoleAutorise = rolesAutorises.includes(req.user.role);
+
+ if (!estAuteur && !estRoleAutorise) {
+ await transaction.rollback();
+ return res.status(403).json({
+ error: 'Accès non autorisé',
+ details: 'Seuls les Admin, RH, Validateurs ou l\'auteur peuvent modifier cette demande'
+ });
+ }
+
+ // ⭐ RESTAURER LES COMPTEURS SI LA DEMANDE ÉTAIT VALIDÉE
+ if (ancienStatut === 'Validée') {
+ console.log('⚠️ Modification d\'une demande validée - restauration des compteurs');
+
+ const ancienTypesResult = await transaction.request()
+ .input('id', sql.Int, id)
+ .query('SELECT TypeCongeId, NombreJours FROM DemandeCongeType WHERE DemandeCongeId = @id');
+
+ const ancienTypes = ancienTypesResult.recordset;
+ const currentYear = new Date().getFullYear();
+
+ for (const type of ancienTypes) {
+ // ⭐ Parser correctement les décimales
+ const nombreJoursARestaurer = parseFloat(type.NombreJours);
+
+ // Restaurer avec priorité CP N-1 puis CP N
+ if (type.TypeCongeId === 1) {
+ console.log(`\n🔄 Restauration CP - ${nombreJoursARestaurer}j à restaurer`);
+ let joursRestantsARestaurer = nombreJoursARestaurer;
+
+ // D'abord : Restaurer dans CP N
+ const cpNResult = await transaction.request()
+ .input('collaborateurId', sql.Int, collaborateurId)
+ .input('typeId', sql.Int, type.TypeCongeId)
+ .input('currentYear', sql.Int, currentYear)
+ .query(`
+ SELECT Id, Solde, Total
+ FROM CompteurConges
+ WHERE CollaborateurADId = @collaborateurId
+ AND TypeCongeId = @typeId
+ AND Annee = @currentYear
+ `);
+
+ if (cpNResult.recordset.length > 0) {
+ const compteurN = cpNResult.recordset[0];
+ const soldeN = parseFloat(compteurN.Solde || 0);
+ const totalN = parseFloat(compteurN.Total || 0);
+ const aRestaurerN = Math.min(joursRestantsARestaurer, totalN - soldeN);
+
+ if (aRestaurerN > 0) {
+ await transaction.request()
+ .input('restauration', sql.Decimal(10, 2), aRestaurerN)
+ .input('compteurId', sql.Int, compteurN.Id)
+ .query(`
+ UPDATE CompteurConges
+ SET Solde = Solde + @restauration,
+ DerniereMiseAJour = GETDATE()
+ WHERE Id = @compteurId
+ `);
+
+ console.log(` ✅ CP N (${currentYear}) : +${aRestaurerN}j restaurés`);
+ joursRestantsARestaurer -= aRestaurerN;
+ }
+ }
+
+ // Ensuite : Si besoin, restaurer dans CP N-1
+ if (joursRestantsARestaurer > 0) {
+ console.log(` 🔄 Il reste ${joursRestantsARestaurer}j, restauration dans CP N-1`);
+
+ const cpN1Result = await transaction.request()
+ .input('collaborateurId', sql.Int, collaborateurId)
+ .input('typeId', sql.Int, type.TypeCongeId)
+ .input('previousYear', sql.Int, currentYear - 1)
+ .query(`
+ SELECT Id, Solde, Total
+ FROM CompteurConges
+ WHERE CollaborateurADId = @collaborateurId
+ AND TypeCongeId = @typeId
+ AND Annee = @previousYear
+ `);
+
+ if (cpN1Result.recordset.length > 0) {
+ const compteurN1 = cpN1Result.recordset[0];
+ const soldeN1 = parseFloat(compteurN1.Solde || 0);
+ const totalN1 = parseFloat(compteurN1.Total || 0);
+ const aRestaurerN1 = Math.min(joursRestantsARestaurer, totalN1 - soldeN1);
+
+ if (aRestaurerN1 > 0) {
+ await transaction.request()
+ .input('restauration', sql.Decimal(10, 2), aRestaurerN1)
+ .input('compteurId', sql.Int, compteurN1.Id)
+ .query(`
+ UPDATE CompteurConges
+ SET Solde = Solde + @restauration,
+ DerniereMiseAJour = GETDATE()
+ WHERE Id = @compteurId
+ `);
+
+ console.log(` ✅ CP N-1 (${currentYear - 1}) : +${aRestaurerN1}j restaurés`);
+ }
+ }
+ }
+ } else {
+ // Autres types : restauration simple
+ await transaction.request()
+ .input('nombreJours', sql.Decimal(10, 2), nombreJoursARestaurer)
+ .input('collaborateurId', sql.Int, collaborateurId)
+ .input('typeCongeId', sql.Int, type.TypeCongeId)
+ .query(`
+ UPDATE CompteurConges
+ SET Solde = Solde + @nombreJours,
+ DerniereMiseAJour = GETDATE()
+ WHERE CollaborateurADId = @collaborateurId
+ AND TypeCongeId = @typeCongeId
+ AND Annee = YEAR(GETDATE())
+ `);
+
+ console.log(` ✅ Type ${type.TypeCongeId} : +${nombreJoursARestaurer}j restaurés`);
+ }
+ }
+ }
+
+ // ⭐ Calculer le total avec support des décimales
+ const totalJours = typesConge.reduce((sum, type) => {
+ return sum + parseFloat(type.nombreJours || 0);
+ }, 0);
+
+ const nouveauStatut = 'Validée';
+
+ console.log(`📝 Mise à jour demande ${id} - Nouveau statut: ${nouveauStatut}`);
+
+ // Mise à jour de la demande
+ await transaction.request()
+ .input('dateDebut', sql.Date, dateDebut)
+ .input('dateFin', sql.Date, dateFin)
+ .input('commentaire', sql.NVarChar, commentaire || '')
+ .input('totalJours', sql.Decimal(10, 2), totalJours)
+ .input('nouveauStatut', sql.VarChar, nouveauStatut)
+ .input('validateurId', sql.Int, req.user.id)
+ .input('id', sql.Int, id)
+ .query(`
+ UPDATE DemandeConge
+ SET DateDebut = @dateDebut,
+ DateFin = @dateFin,
+ Commentaire = @commentaire,
+ NombreJours = @totalJours,
+ Statut = @nouveauStatut,
+ ValidateurADId = @validateurId,
+ DateValidation = GETDATE(),
+ CommentaireValidation = 'Validation automatique après modification'
+ WHERE Id = @id
+ `);
+
+ // Supprimer les anciens types
+ await transaction.request()
+ .input('id', sql.Int, id)
+ .query('DELETE FROM DemandeCongeType WHERE DemandeCongeId = @id');
+
+ console.log('✅ Anciens types supprimés');
+
+ // Recréer les types avec génération manuelle d'ID
+ for (const type of typesConge) {
+ const maxTypeIdResult = await transaction.request()
+ .query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM DemandeCongeType');
+
+ const typeId = maxTypeIdResult.recordset[0].NextId;
+
+ // ⭐ Parser correctement le nombre de jours
+ const nombreJours = parseFloat(type.nombreJours || 0);
+
+ await transaction.request()
+ .input('id', sql.Int, typeId)
+ .input('demandeId', sql.Int, id)
+ .input('typeId', sql.Int, type.typeId)
+ .input('nombreJours', sql.Decimal(10, 2), nombreJours)
+ .query('INSERT INTO DemandeCongeType (Id, DemandeCongeId, TypeCongeId, NombreJours) VALUES (@id, @demandeId, @typeId, @nombreJours)');
+
+ console.log(`✅ Type ${type.typeId} recréé avec ID ${typeId} - ${nombreJours}j`);
+ }
+
+ // ⭐ DÉDUIRE LES NOUVEAUX COMPTEURS (AVEC PRIORITÉ CP N-1 puis CP N)
+ console.log('💼 Déduction des nouveaux compteurs avec priorité CP N-1...');
+
+ const currentYear = new Date().getFullYear();
+
+ for (const type of typesConge) {
+ const nombreJours = parseFloat(type.nombreJours || 0);
+
+ // SI C'EST UN TYPE CP (ID = 1), GÉRER LA PRIORITÉ CP N-1 PUIS CP N
+ if (type.typeId === 1) {
+ console.log(`\n🔄 Traitement CP - ${nombreJours}j à déduire`);
+ let joursRestantsADeduire = nombreJours;
+
+ // 1️⃣ D'ABORD : Pioche dans CP N-1
+ const cpN1Result = await transaction.request()
+ .input('collaborateurId', sql.Int, collaborateurId)
+ .input('typeId', sql.Int, type.typeId)
+ .input('previousYear', sql.Int, currentYear - 1)
+ .query(`
+ SELECT Id, Solde
+ FROM CompteurConges
+ WHERE CollaborateurADId = @collaborateurId
+ AND TypeCongeId = @typeId
+ AND Annee = @previousYear
+ `);
+
+ if (cpN1Result.recordset.length > 0) {
+ const compteurN1 = cpN1Result.recordset[0];
+ const soldeN1 = parseFloat(compteurN1.Solde || 0);
+
+ if (soldeN1 > 0) {
+ const aDeduireN1 = Math.min(soldeN1, joursRestantsADeduire);
+
+ await transaction.request()
+ .input('deduction', sql.Decimal(10, 2), aDeduireN1)
+ .input('compteurId', sql.Int, compteurN1.Id)
+ .query(`
+ UPDATE CompteurConges
+ SET Solde = Solde - @deduction,
+ DerniereMiseAJour = GETDATE()
+ WHERE Id = @compteurId
+ `);
+
+ console.log(` ✅ CP N-1 (${currentYear - 1}) : -${aDeduireN1}j`);
+ joursRestantsADeduire -= aDeduireN1;
+ }
+ }
+
+ // 2️⃣ ENSUITE : S'il reste des jours, pioche dans CP N
+ if (joursRestantsADeduire > 0) {
+ console.log(` 🔄 Il reste ${joursRestantsADeduire}j, pioche dans CP N`);
+
+ const cpNResult = await transaction.request()
+ .input('collaborateurId', sql.Int, collaborateurId)
+ .input('typeId', sql.Int, type.typeId)
+ .input('currentYear', sql.Int, currentYear)
+ .query(`
+ SELECT Id, Solde
+ FROM CompteurConges
+ WHERE CollaborateurADId = @collaborateurId
+ AND TypeCongeId = @typeId
+ AND Annee = @currentYear
+ `);
+
+ if (cpNResult.recordset.length > 0) {
+ const compteurN = cpNResult.recordset[0];
+
+ await transaction.request()
+ .input('deduction', sql.Decimal(10, 2), joursRestantsADeduire)
+ .input('compteurId', sql.Int, compteurN.Id)
+ .query(`
+ UPDATE CompteurConges
+ SET Solde = Solde - @deduction,
+ DerniereMiseAJour = GETDATE()
+ WHERE Id = @compteurId
+ `);
+
+ console.log(` ✅ CP N (${currentYear}) : -${joursRestantsADeduire}j`);
+ }
+ }
+ } else {
+ // Pour les autres types (RTT, Récupération, etc.)
+ await transaction.request()
+ .input('nombreJours', sql.Decimal(10, 2), nombreJours)
+ .input('collaborateurId', sql.Int, collaborateurId)
+ .input('typeId', sql.Int, type.typeId)
+ .query(`
+ UPDATE CompteurConges
+ SET Solde = Solde - @nombreJours,
+ DerniereMiseAJour = GETDATE()
+ WHERE CollaborateurADId = @collaborateurId
+ AND TypeCongeId = @typeId
+ AND Annee = YEAR(GETDATE())
+ `);
+
+ console.log(` ✅ Type ${type.typeId} : -${nombreJours}j`);
+ }
+ }
+
+ const actionDetails = `Modification et validation automatique par ${req.user.role}`;
+
+ // Historique
+ try {
+ const maxHistIdResult = await transaction.request()
+ .query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM HistoriqueActions');
+
+ const histId = maxHistIdResult.recordset[0].NextId;
+
+ await transaction.request()
+ .input('id', sql.Int, histId)
+ .input('userId', sql.Int, req.user.id)
+ .input('details', sql.NVarChar, actionDetails)
+ .input('demandeId', sql.Int, id)
+ .query(`
+ INSERT INTO HistoriqueActions (Id, CollaborateurADId, Action, Details, DemandeCongeId, DateAction)
+ VALUES (@id, @userId, 'Modification et validation', @details, @demandeId, GETDATE())
+ `);
+ } catch (histError) {
+ console.log('⚠️ Fallback historique sans ID...');
+ await transaction.request()
+ .input('userId', sql.Int, req.user.id)
+ .input('details', sql.NVarChar, actionDetails)
+ .input('demandeId', sql.Int, id)
+ .query(`
+ INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DemandeCongeId, DateAction)
+ VALUES (@userId, 'Modification et validation', @details, @demandeId, GETDATE())
+ `);
+ }
+
+ await transaction.commit();
+ console.log('✅ Modification et validation terminées');
+
+ // Notifications SSE
+ notifyClients({
+ type: 'demande-validated',
+ demandeId: parseInt(id),
+ statut: 'Validée',
+ collaborateurId: collaborateurId,
+ timestamp: new Date().toISOString()
+ }, collaborateurId);
+
+ notifyClients({
+ type: 'demande-updated',
+ demandeId: parseInt(id),
+ action: 'modification',
+ nouveauStatut: nouveauStatut,
+ ancienStatut: ancienStatut,
+ timestamp: new Date().toISOString()
+ }, collaborateurId);
+
+ notifyClients({
+ type: 'demande-list-updated',
+ action: 'modification',
+ demandeId: parseInt(id),
+ timestamp: new Date().toISOString()
+ });
+
+ // Webhooks
+ try {
+ await webhookManager.sendWebhook(
+ WEBHOOKS.COLLABORATEURS_URL,
+ EVENTS.DEMANDE_VALIDATED,
+ {
+ demandeId: parseInt(id),
+ statut: 'Validée',
+ collaborateurId: collaborateurId,
+ validateurId: req.user.id
+ }
+ );
+
+ await webhookManager.sendWebhook(
+ WEBHOOKS.COLLABORATEURS_URL,
+ EVENTS.COMPTEUR_UPDATED,
+ { collaborateurId: collaborateurId }
+ );
+ } catch (webhookError) {
+ console.error('❌ Erreur envoi webhook (non bloquant):', webhookError.message);
+ }
+
+ res.json({
+ message: 'Demande modifiée et validée automatiquement',
+ nouveauStatut: nouveauStatut,
+ ancienStatut: ancienStatut
+ });
+
+ console.log(`✅ Demande ${id} modifiée et validée par ${req.user.role}`);
+
+ } catch (error) {
+ await transaction.rollback();
+ console.error('❌ Erreur modification demande:', error);
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+ app.delete('/demandes/:id', authenticateToken, async (req, res) => {
+ const transaction = new sql.Transaction(pool);
+ try {
+ await transaction.begin();
+
+ const { id } = req.params;
+
+ const demandeResult = await transaction.request()
+ .input('id', sql.Int, id)
+ .query('SELECT Statut, CollaborateurADId FROM DemandeConge WHERE Id = @id');
+
+ const demande = demandeResult.recordset;
+
+ if (demande.length === 0) {
+ await transaction.rollback();
+ return res.status(404).json({ error: 'Demande non trouvée' });
+ }
+
+ const collaborateurId = demande[0].CollaborateurADId;
+
+ const rolesAutorises = ['Admin', 'RH', 'Validateur'];
+ const estAuteur = collaborateurId === req.user.id;
+ const estRoleAutorise = rolesAutorises.includes(req.user.role);
+
+ if (!estAuteur && !estRoleAutorise) {
+ await transaction.rollback();
+ return res.status(403).json({
+ error: 'Accès non autorisé',
+ details: 'Seuls les Admin, RH, Validateurs ou l\'auteur peuvent supprimer cette demande'
+ });
+ }
+
+ // Supprimer d'abord les entrées d'historique
+ await transaction.request()
+ .input('id', sql.Int, id)
+ .query('DELETE FROM HistoriqueActions WHERE DemandeCongeId = @id');
+
+ // Supprimer les types de congé
+ await transaction.request()
+ .input('id', sql.Int, id)
+ .query('DELETE FROM DemandeCongeType WHERE DemandeCongeId = @id');
+
+ // Supprimer la demande
+ await transaction.request()
+ .input('id', sql.Int, id)
+ .query('DELETE FROM DemandeConge WHERE Id = @id');
+
+ await transaction.commit();
+
+ notifyClients({
+ type: 'demande-deleted',
+ demandeId: parseInt(id),
+ collaborateurId: collaborateurId,
+ timestamp: new Date().toISOString()
+ });
+
+ try {
+ await webhookManager.sendWebhook(
+ WEBHOOKS.COLLABORATEURS_URL,
+ EVENTS.DEMANDE_DELETED,
+ {
+ demandeId: parseInt(id),
+ collaborateurId: collaborateurId
+ }
+ );
+ } catch (webhookError) {
+ console.error('❌ Erreur envoi webhook (non bloquant):', webhookError.message);
+ }
+
+ console.log(`✅ Demande ${id} supprimée par ${req.user.role}`);
+ res.json({ message: 'Demande supprimée avec succès' });
+ } catch (error) {
+ await transaction.rollback();
+ console.error('❌ Erreur suppression demande:', error);
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+ // ⭐ ROUTE VALIDATION AVEC WEBHOOKS
+ // ⭐ ROUTE VALIDATION AVEC WEBHOOKS - CORRIGÉE
+ app.put('/demandes/:id/valider', authenticateToken, async (req, res) => {
+ const transaction = new sql.Transaction(pool);
+ try {
+ await transaction.begin();
+
+ const { id } = req.params;
+ const { statut, commentaire } = req.body;
+
+ console.log('\n🔍 === DÉBUT VALIDATION DEMANDE ===');
+
+ const demandeInfoResult = await transaction.request()
+ .input('id', sql.Int, id)
+ .query(`
+ SELECT
+ dc.Id,
+ dc.CollaborateurADId,
+ dc.DateDebut,
+ dc.DateFin,
+ dc.NombreJours,
+ ca.prenom,
+ ca.nom,
+ ca.email as collaborateur_email,
+ ca.Actif,
+ STRING_AGG(tc.Nom, ', ') as TypeConge
+ FROM DemandeConge dc
+ JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
+ LEFT JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
+ LEFT JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
+ WHERE dc.Id = @id
+ GROUP BY dc.Id, dc.CollaborateurADId, dc.DateDebut, dc.DateFin, dc.NombreJours,
+ ca.prenom, ca.nom, ca.email, ca.Actif
+ `);
+
+ const demandeInfo = demandeInfoResult.recordset;
+
+ if (demandeInfo.length === 0) {
+ await transaction.rollback();
+ return res.status(404).json({ error: 'Demande non trouvée' });
+ }
+
+ const demande = demandeInfo[0];
+ const collaborateurId = demande.CollaborateurADId;
+
+ await transaction.request()
+ .input('statut', sql.VarChar, statut)
+ .input('commentaire', sql.NVarChar, commentaire || '')
+ .input('validateurId', sql.Int, req.user.id)
+ .input('id', sql.Int, id)
+ .query(`
+ UPDATE DemandeConge
+ SET Statut = @statut,
+ CommentaireValidation = @commentaire,
+ ValidateurADId = @validateurId,
+ DateValidation = GETDATE()
+ WHERE Id = @id
+ `);
+
+ if (statut === 'Validée') {
+ const typesResult = await transaction.request()
+ .input('id', sql.Int, id)
+ .query('SELECT TypeCongeId, NombreJours FROM DemandeCongeType WHERE DemandeCongeId = @id');
+
+ const types = typesResult.recordset;
+ const currentYear = new Date().getFullYear();
+
+ for (const type of types) {
+ await transaction.request()
+ .input('nombreJours', sql.Decimal(10, 2), type.NombreJours)
+ .input('collaborateurId', sql.Int, collaborateurId)
+ .input('typeCongeId', sql.Int, type.TypeCongeId)
+ .query(`
+ UPDATE CompteurConges
+ SET Solde = Solde - @nombreJours
+ WHERE CollaborateurADId = @collaborateurId
+ AND TypeCongeId = @typeCongeId
+ AND Annee = YEAR(GETDATE())
+ `);
+
+ // ✅ AJOUT : Insérer dans DeductionDetails pour cohérence avec /annuler
+ await transaction.request()
+ .input('demandeId', sql.Int, id)
+ .input('typeId', sql.Int, type.TypeCongeId)
+ .input('annee', sql.Int, currentYear)
+ .input('jours', sql.Decimal(10, 2), type.NombreJours)
+ .query(`
+ INSERT INTO DeductionDetails
+ (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises)
+ VALUES (@demandeId, @typeId, @annee, 'Année N', @jours)
+ `);
+ }
+ }
+
+ // ⭐ CORRECTION: Génération manuelle de l'ID pour HistoriqueActions
+ try {
+ const maxHistIdResult = await transaction.request()
+ .query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM HistoriqueActions');
+
+ const histId = maxHistIdResult.recordset[0].NextId;
+
+ await transaction.request()
+ .input('id', sql.Int, histId)
+ .input('userId', sql.Int, req.user.id)
+ .input('action', sql.VarChar, statut === 'Validée' ? 'Validation congé' : 'Refus congé')
+ .input('commentaire', sql.NVarChar, commentaire || '')
+ .input('demandeId', sql.Int, id)
+ .query(`
+ INSERT INTO HistoriqueActions (Id, CollaborateurADId, Action, Details, DemandeCongeId, DateAction)
+ VALUES (@id, @userId, @action, @commentaire, @demandeId, GETDATE())
+ `);
+
+ console.log(`✅ Historique validation créé avec ID: ${histId}`);
+ } catch (histError) {
+ console.error('⚠️ Erreur historique (non bloquant):', histError.message);
+ }
+
+ await transaction.commit();
+
+ // ⭐ ENVOI EMAIL
+ const accessToken = await getGraphToken();
+ if (accessToken && demande.collaborateur_email) {
+ const fromEmail = 'noreply@ensup.eu';
+ const collaborateurNom = `${demande.prenom} ${demande.nom}`;
+ const validateurNom = req.user.prenom && req.user.nom
+ ? `${req.user.prenom} ${req.user.nom}`
+ : 'l\'équipe RH';
+
+ const dateDebut = new Date(demande.DateDebut).toLocaleDateString('fr-FR');
+ const dateFin = new Date(demande.DateFin).toLocaleDateString('fr-FR');
+ const datesPeriode = dateDebut === dateFin ? dateDebut : `du ${dateDebut} au ${dateFin}`;
+
+ const subject = statut === 'Validée'
+ ? '✅ Votre demande de congé a été approuvée'
+ : '❌ Votre demande de congé a été refusée';
+
+ const body = statut === 'Validée'
+ ? `
+
+
✅ Demande approuvée
+
+
+
Bonjour ${collaborateurNom},
+
Votre demande de congé a été approuvée par ${validateurNom}.
+
+
Type : ${demande.TypeConge}
+
Période : ${datesPeriode}
+
Durée : ${demande.NombreJours} jour(s)
+ ${commentaire ? `
Commentaire : ${commentaire}
` : ''}
+
+
+
`
+ : `
+
+
❌ Demande refusée
+
+
+
Bonjour ${collaborateurNom},
+
Votre demande de congé a été refusée par ${validateurNom}.
+
+
Type : ${demande.TypeConge}
+
Période : ${datesPeriode}
+
Durée : ${demande.NombreJours} jour(s)
+ ${commentaire ? `
Motif du refus : ${commentaire}
` : ''}
+
+
+
`;
+
+ await sendMailGraph(accessToken, fromEmail, demande.collaborateur_email, subject, body);
+ }
+
+ notifyClients({
+ type: 'demande-validated',
+ demandeId: parseInt(id),
+ statut: statut,
+ collaborateurId: collaborateurId,
+ timestamp: new Date().toISOString()
+ }, collaborateurId);
+
+ notifyClients({
+ type: 'demande-list-updated',
+ action: 'validation',
+ demandeId: parseInt(id),
+ timestamp: new Date().toISOString()
+ });
+
+ try {
+ await webhookManager.sendWebhook(
+ WEBHOOKS.COLLABORATEURS_URL,
+ EVENTS.DEMANDE_VALIDATED,
+ {
+ demandeId: parseInt(id),
+ statut: statut,
+ collaborateurId: collaborateurId,
+ validateurId: req.user.id,
+ commentaire: commentaire
+ }
+ );
+
+ await webhookManager.sendWebhook(
+ WEBHOOKS.COLLABORATEURS_URL,
+ EVENTS.COMPTEUR_UPDATED,
+ { collaborateurId: collaborateurId }
+ );
+ } catch (webhookError) {
+ console.error('❌ Erreur envoi webhook (non bloquant):', webhookError.message);
+ }
+
+ res.json({ message: 'Demande mise à jour' });
+ } catch (error) {
+ await transaction.rollback();
+ console.error('\n❌ ERREUR VALIDATION:', error);
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+ // Route pour export paie par plage de dates
+ app.get('/export/paie-plage', authenticateToken, async (req, res) => {
+ const { dateDebut, dateFin } = req.query;
+
+ if (!dateDebut || !dateFin) {
+ return res.status(400).json({ error: 'Date de début et date de fin requises' });
+ }
+
+ console.log(`📊 Export paie - Plage: ${dateDebut} au ${dateFin}`);
+
+ try {
+ const debut = new Date(dateDebut);
+ const fin = new Date(dateFin);
+
+ if (isNaN(debut.getTime()) || isNaN(fin.getTime())) {
+ return res.status(400).json({ error: 'Dates invalides' });
+ }
+ if (debut > fin) {
+ return res.status(400).json({ error: 'La date de début doit être antérieure à la date de fin' });
+ }
+
+ const result = await pool.request()
+ .input('dateDebut', sql.Date, dateDebut)
+ .input('dateFin', sql.Date, dateFin)
+ .query(`
+ SELECT
+ ca.id AS collaborateurId,
+ CONCAT(ca.prenom, ' ', ca.nom) AS employe,
+ ca.email,
+ COALESCE(s.Nom, 'Non défini') AS service,
+ tc.Nom AS typeConge,
+ dct.NombreJours,
+ dct.PeriodeJournee,
+ -- ⭐ Dates précises du TYPE de congé
+ COALESCE(dct.DateDebut, dc.DateDebut) AS dateDebutType,
+ COALESCE(dct.DateFin, dc.DateFin) AS dateFinType,
+ dc.DateDebut,
+ dc.DateFin,
+ dc.Statut
+ FROM DemandeConge dc
+ JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
+ LEFT JOIN Services s ON ca.ServiceId = s.Id
+ JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
+ JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
+ WHERE dc.Statut = 'Validée'
+ AND (ca.Actif = 1 OR ca.Actif IS NULL)
+ AND (
+ (COALESCE(dct.DateDebut, dc.DateDebut) BETWEEN @dateDebut AND @dateFin)
+ OR (COALESCE(dct.DateFin, dc.DateFin) BETWEEN @dateDebut AND @dateFin)
+ OR (COALESCE(dct.DateDebut, dc.DateDebut) <= @dateDebut AND COALESCE(dct.DateFin, dc.DateFin) >= @dateFin)
+ )
+ ORDER BY ca.nom, ca.prenom, COALESCE(dct.DateDebut, dc.DateDebut)
+ `);
+
+ console.log(`📊 ${result.recordset.length} entrées trouvées`);
+ const dataFormatted = agregerLignesParEmploye(result.recordset);
+ console.log(`✅ Export paie-plage formaté: ${dataFormatted.length} employés`);
+ res.json(dataFormatted);
+
+ } catch (error) {
+ console.error('❌ Erreur export paie plage:', error);
+ res.status(500).json({ error: 'Erreur lors de la génération du rapport', details: error.message });
+ }
+ });
+
+ // ================================================
+ // ROUTE GET /export/paie-saisie
+ // Filtre par date de SAISIE de la demande (DateDemande)
+ // À ajouter dans server.js après la route /export/paie-plage
+ // ================================================
+
+ app.get('/export/paie-saisie', authenticateToken, async (req, res) => {
+ const { dateSaisieDebut, dateSaisieFin } = req.query;
+
+ console.log(`📊 Export paie-saisie: saisie entre ${dateSaisieDebut} et ${dateSaisieFin}`);
+
+ if (!dateSaisieDebut || !dateSaisieFin) {
+ return res.status(400).json({ error: 'Date de saisie début et fin requises' });
+ }
+
+ try {
+ // ✅ DEBUG : voir ce qu'il y a en base pour juin
+ const debug = await pool.request()
+ .input('dateSaisieDebut', sql.Date, dateSaisieDebut)
+ .input('dateSaisieFin', sql.Date, dateSaisieFin)
+ .query(`
+ SELECT
+ CONCAT(ca.prenom, ' ', ca.nom) AS employe,
+ dc.DateDemande,
+ dc.DateDebut,
+ dc.DateFin,
+ COALESCE(dct.DateDebut, dc.DateDebut) AS dateDebutType,
+ COALESCE(dct.DateFin, dc.DateFin) AS dateFinType,
+ tc.Nom AS typeConge,
+ dc.Statut
+ FROM DemandeConge dc
+ JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
+ JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
+ JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
+ WHERE dc.Statut = 'Validée'
+ AND (ca.Actif = 1 OR ca.Actif IS NULL)
+ AND COALESCE(dct.DateDebut, dc.DateDebut) <= @dateSaisieFin
+ AND COALESCE(dct.DateFin, dc.DateFin) >= @dateSaisieDebut
+ `);
+
+ console.log('🔍 DEBUG - Résultats sans filtre saisie:');
+ debug.recordset.forEach(r => {
+ console.log(` 👤 ${r.employe} | Saisie: ${r.DateDemande} | Congé: ${r.DateDebut} → ${r.DateFin} | Type dates: ${r.dateDebutType} → ${r.dateFinType} | Type: ${r.typeConge}`);
+ });
+
+ const result = await pool.request()
+ .input('dateSaisieDebut', sql.Date, dateSaisieDebut)
+ .input('dateSaisieFin', sql.Date, dateSaisieFin)
+ .query(`
+ SELECT
+ ca.id AS collaborateurId,
+ CONCAT(ca.prenom, ' ', ca.nom) AS employe,
+ ca.email,
+ COALESCE(s.Nom, 'Non défini') AS service,
+ tc.Nom AS typeConge,
+ dct.NombreJours,
+ dct.PeriodeJournee,
+ COALESCE(dct.DateDebut, dc.DateDebut) AS dateDebutType,
+ COALESCE(dct.DateFin, dc.DateFin) AS dateFinType,
+ dc.DateDebut,
+ dc.DateFin,
+ dc.Statut,
+ dc.DateDemande
+ FROM DemandeConge dc
+ JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
+ LEFT JOIN Services s ON ca.ServiceId = s.Id
+ JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
+ JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
+ WHERE dc.Statut = 'Validée'
+ AND (ca.Actif = 1 OR ca.Actif IS NULL)
+ AND COALESCE(dct.DateDebut, dc.DateDebut) <= @dateSaisieFin
+ AND COALESCE(dct.DateFin, dc.DateFin) >= @dateSaisieDebut
+ AND CAST(dc.DateDemande AS DATE) <= @dateSaisieFin
+ ORDER BY ca.nom, ca.prenom,
+ COALESCE(dct.DateDebut, dc.DateDebut)
+ `);
+
+ console.log(`📊 ${result.recordset.length} entrées trouvées`);
+ const dataFormatted = agregerLignesParEmploye(result.recordset);
+ console.log(`✅ Export paie-saisie formaté: ${dataFormatted.length} employés`);
+ res.json(dataFormatted);
+
+ } catch (error) {
+ console.error('❌ Erreur export paie-saisie:', error);
+ res.status(500).json({ error: 'Erreur lors de la génération du rapport', details: error.message });
+ }
+ });
+
+ // ================================================
+ // ROUTES EMPLOYÉS
+ // ================================================
+ app.get('/employes', authenticateToken, async (req, res) => {
+ try {
+ const currentYear = new Date().getFullYear();
+ const previousYear = currentYear - 1;
+
+ const result = await pool.request()
+ .input('currentYear', sql.Int, currentYear)
+ .input('previousYear', sql.Int, previousYear)
+ .query(`
+ SELECT
+ ca.id,
+ ca.nom,
+ ca.prenom,
+ ca.email,
+ ca.role,
+ COALESCE(s.Nom, 'Sans service') as service,
+ ca.Actif,
+ ca.TypeContrat,
+
+ -- CP Année N
+ (SELECT TOP 1 COALESCE(Solde, 0)
+ FROM CompteurConges cc1
+ WHERE cc1.CollaborateurADId = ca.id
+ AND cc1.TypeCongeId = (SELECT TOP 1 Id FROM TypeConge WHERE Nom LIKE '%Congé%payé%' OR Nom LIKE '%CP%' ORDER BY Id)
+ AND cc1.Annee = @currentYear) as soldeCPN,
+
+ -- CP Année N-1
+ (SELECT TOP 1 COALESCE(Solde, 0)
+ FROM CompteurConges cc2
+ WHERE cc2.CollaborateurADId = ca.id
+ AND cc2.TypeCongeId = (SELECT TOP 1 Id FROM TypeConge WHERE Nom LIKE '%Congé%payé%' OR Nom LIKE '%CP%' ORDER BY Id)
+ AND cc2.Annee = @previousYear) as soldeCPNMoins1,
+
+ -- RTT
+ (SELECT TOP 1 COALESCE(Solde, 0)
+ FROM CompteurConges cc3
+ WHERE cc3.CollaborateurADId = ca.id
+ AND cc3.TypeCongeId = (SELECT TOP 1 Id FROM TypeConge WHERE Nom = 'RTT' ORDER BY Id)
+ AND cc3.Annee = @currentYear) as soldeRTT,
+
+ -- Récupération
+ (SELECT TOP 1 COALESCE(Solde, 0)
+ FROM CompteurConges cc4
+ WHERE cc4.CollaborateurADId = ca.id
+ AND cc4.TypeCongeId = (SELECT TOP 1 Id FROM TypeConge WHERE Nom LIKE '%Récup%' OR Nom LIKE '%Recup%' ORDER BY Id)
+ AND cc4.Annee = @currentYear) as soldeRecup
+
+ FROM CollaborateurAD ca
+ LEFT JOIN Services s ON ca.ServiceId = s.Id
+ WHERE ca.Actif = 1 OR ca.Actif IS NULL
+ ORDER BY ca.nom, ca.prenom
+ `);
+
+ console.log(`✅ ${result.recordset.length} employés récupérés avec compteurs`);
+ res.json(result.recordset);
+ } catch (error) {
+ console.error('❌ Erreur /api/employes:', error);
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+
+
+ // ✅ NOUVELLE ROUTE: Désactiver un collaborateur
+ app.post('/employes/desactiver', authenticateToken, async (req, res) => {
+ try {
+ if (!['Admin', 'RH'].includes(req.user.role)) {
+ return res.status(403).json({ error: 'Accès non autorisé' });
+ }
+
+ const { collaborateur_id, date_sortie } = req.body;
+
+ if (!collaborateur_id) {
+ return res.json({ success: false, message: 'ID collaborateur manquant' });
+ }
+
+ const dateSortie = date_sortie || new Date().toISOString().split('T')[0];
+
+ const collabResult = await pool.request()
+ .input('id', sql.Int, collaborateur_id)
+ .query('SELECT prenom, nom, email FROM CollaborateurAD WHERE id = @id');
+
+ const collab = collabResult.recordset;
+
+ if (collab.length === 0) {
+ return res.json({ success: false, message: 'Collaborateur non trouvé' });
+ }
+
+ await pool.request()
+ .input('dateSortie', sql.Date, dateSortie)
+ .input('id', sql.Int, collaborateur_id)
+ .query('UPDATE CollaborateurAD SET Actif = 0, DateSortie = @dateSortie WHERE id = @id');
+
+ res.json({
+ success: true,
+ message: 'Collaborateur désactivé',
+ nom: `${collab[0].prenom} ${collab[0].nom}`,
+ date_sortie: dateSortie
+ });
+ } catch (error) {
+ console.error('Erreur desactiverCollaborateur:', error);
+ res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message });
+ }
+ });
+
+ // ✅ NOUVELLE ROUTE: Réactiver un collaborateur
+ app.post('/employes/reactiver', authenticateToken, async (req, res) => {
+ try {
+ if (!['Admin', 'RH'].includes(req.user.role)) {
+ return res.status(403).json({ error: 'Accès non autorisé' });
+ }
+
+ const { collaborateur_id } = req.body;
+
+ if (!collaborateur_id) {
+ return res.json({ success: false, message: 'ID collaborateur manquant' });
+ }
+
+ const collabResult = await pool.request()
+ .input('id', sql.Int, collaborateur_id)
+ .query('SELECT prenom, nom, email FROM CollaborateurAD WHERE id = @id');
+
+ const collab = collabResult.recordset;
+
+ if (collab.length === 0) {
+ return res.json({ success: false, message: 'Collaborateur non trouvé' });
+ }
+
+ await pool.request()
+ .input('id', sql.Int, collaborateur_id)
+ .query('UPDATE CollaborateurAD SET Actif = 1, DateSortie = NULL WHERE id = @id');
+
+ res.json({
+ success: true,
+ message: 'Collaborateur réactivé',
+ nom: `${collab[0].prenom} ${collab[0].nom}`
+ });
+ } catch (error) {
+ console.error('Erreur reactiverCollaborateur:', error);
+ res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message });
+ }
+ });
+
+ app.get('/types-conge', authenticateToken, async (req, res) => {
+ try {
+ const result = await pool.request().query(
+ 'SELECT Id as id, Nom as nom, CouleurHex as couleur FROM TypeConge WHERE Actif = 1 ORDER BY Nom'
+ );
+ res.json(result.recordset);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+ // Suite - Routes export, compteurs, équipes, stats, etc.
+
+ app.get('/export/paie', authenticateToken, async (req, res) => {
+ try {
+ const { mois, annee, dateSaisieDebut, dateSaisieFin } = req.query;
+
+ const premierJour = `${annee}-${String(mois).padStart(2, '0')}-01`;
+ const dernierJour = new Date(annee, mois, 0).getDate();
+ const dernierJourMois = `${annee}-${String(mois).padStart(2, '0')}-${dernierJour}`;
+
+ // Le filtre création est actif uniquement si les deux dates sont fournies
+ const filtreCreationActif = dateSaisieDebut && dateSaisieFin;
+
+ console.log(`📊 Export paie: ${mois}/${annee} (${premierJour} -> ${dernierJourMois})${filtreCreationActif ? ` | Filtre création: ${dateSaisieDebut} -> ${dateSaisieFin}` : ''}`);
+
+ const request = pool.request()
+ .input('premierJour', sql.Date, premierJour)
+ .input('dernierJourMois', sql.Date, dernierJourMois);
+
+ // Construire la clause WHERE du filtre de création
+ let whereCreation = '';
+ if (filtreCreationActif) {
+ request
+ .input('dateSaisieDebut', sql.Date, dateSaisieDebut)
+ .input('dateSaisieFin', sql.Date, dateSaisieFin);
+ whereCreation = `AND CAST(dc.DateDemande AS DATE) >= @dateSaisieDebut
+ AND CAST(dc.DateDemande AS DATE) <= @dateSaisieFin`;
+ }
+
+ const result = await request.query(`
+ SELECT
+ ca.id AS collaborateurId,
+ CONCAT(ca.prenom, ' ', ca.nom) AS employe,
+ ca.email,
+ COALESCE(s.Nom, 'Non assigné') AS service,
+ tc.Nom AS typeConge,
+ dct.NombreJours,
+ dct.PeriodeJournee,
+ -- Dates précises du TYPE de congé
+ COALESCE(dct.DateDebut, dc.DateDebut) AS dateDebutType,
+ COALESCE(dct.DateFin, dc.DateFin) AS dateFinType,
+ -- Dates de la demande (pour le filtre période)
+ dc.DateDebut,
+ dc.DateFin,
+ dc.Statut
+ FROM DemandeConge dc
+ JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
+ LEFT JOIN Services s ON ca.ServiceId = s.Id
+ JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId
+ JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
+ WHERE dc.Statut = 'Validée'
+ AND (ca.Actif = 1 OR ca.Actif IS NULL)
+ AND (
+ -- La période du TYPE chevauche le mois
+ (COALESCE(dct.DateDebut, dc.DateDebut) BETWEEN @premierJour AND @dernierJourMois)
+ OR (COALESCE(dct.DateFin, dc.DateFin) BETWEEN @premierJour AND @dernierJourMois)
+ OR (COALESCE(dct.DateDebut, dc.DateDebut) <= @premierJour AND COALESCE(dct.DateFin, dc.DateFin) >= @dernierJourMois)
+ )
+ ${whereCreation}
+ ORDER BY ca.nom, ca.prenom, COALESCE(dct.DateDebut, dc.DateDebut)
+ `);
+
+ console.log(`📊 ${result.recordset.length} entrées trouvées`);
+ const dataFormatted = agregerLignesParEmploye(result.recordset);
+ console.log(`✅ Export formaté: ${dataFormatted.length} employés`);
+ res.json(dataFormatted);
+
+ } catch (error) {
+ console.error('❌ Erreur export paie:', error);
+ res.status(500).json({ error: error.message });
+ }
+ });
+ // Route pour obtenir les informations d'un collaborateur
+ app.get('/employes/:id/info', authenticateToken, async (req, res) => {
+ try {
+ const result = await pool.request()
+ .input('id', sql.Int, req.params.id)
+ .query(`
+ SELECT
+ id,
+ nom,
+ prenom,
+ email,
+ role,
+ TypeContrat,
+ Actif,
+ DateSortie,
+ ServiceId
+ FROM CollaborateurAD
+ WHERE id = @id
+ `);
+
+ const employe = result.recordset;
+
+ if (employe.length === 0) {
+ return res.status(404).json({ error: 'Employé non trouvé' });
+ }
+
+ const collab = employe[0];
+
+ // Déterminer si c'est un apprenti
+ const estApprenti = collab.TypeContrat === 'Apprentissage' ||
+ collab.role === 'Apprenti' ||
+ collab.role?.toLowerCase().includes('apprenti');
+
+ res.json({
+ ...collab,
+ estApprenti,
+ droitRTT: !estApprenti
+ });
+ } catch (error) {
+ console.error('Erreur récupération info employé:', error);
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+ // ✅ Modifier la route GET /compteurs pour inclure les infos sur les apprentis
+ app.get('/compteurs', authenticateToken, async (req, res) => {
+ try {
+ const includeInactifs = req.query.include_inactifs === 'true';
+ const { user_id } = req.query;
+
+ let query = `
+ SELECT
+ cc.Id AS id,
+ cc.CollaborateurADId AS collaborateurId,
+ cc.TypeCongeId AS typeCongeId,
+ CONCAT(ca.prenom, ' ', ca.nom) AS employe,
+ ca.email AS email,
+ ca.Actif AS Actif,
+ ca.role AS role,
+ ca.TypeContrat AS TypeContrat,
+ COALESCE(s.Nom, 'Sans service') AS service,
+ tc.Nom AS typeConge,
+ cc.Annee AS annee,
+ cc.Total AS total,
+ cc.Solde AS solde,
+ cc.SoldeReporte AS soldeReporte,
+ (cc.Total - cc.Solde) AS consomme
+ FROM CompteurConges cc
+ JOIN CollaborateurAD ca ON cc.CollaborateurADId = ca.id
+ LEFT JOIN Services s ON ca.ServiceId = s.Id
+ JOIN TypeConge tc ON cc.TypeCongeId = tc.Id
+ WHERE 1=1
+ AND (ca.description IS NULL OR ca.description NOT LIKE '%stagiaire%')
+ `;
+
+ const request = pool.request();
+
+ // Filtre par utilisateur si fourni
+ if (user_id) {
+ query += ' AND cc.CollaborateurADId = @userId';
+ request.input('userId', sql.Int, parseInt(user_id));
+ }
+
+ // Filtre actif/inactif
+ if (!includeInactifs) {
+ query += ' AND (ca.Actif = 1 OR ca.Actif IS NULL)';
+ }
+
+ query += ' ORDER BY ca.Actif DESC, ca.nom, ca.prenom, tc.Nom';
+
+ const result = await request.query(query);
+ const compteurs = result.recordset;
+
+ console.log(`✅ ${compteurs.length} compteurs trouvés`);
+
+ // ⭐ DEBUG: Afficher les types de congés trouvés
+ const typesConges = [...new Set(compteurs.map(c => c.typeConge))];
+ console.log('📊 Types de congés:', typesConges);
+
+ // Ajouter l'indicateur apprenti et formater les données
+ const compteursFormates = compteurs.map(c => ({
+ id: c.id,
+ collaborateurId: c.collaborateurId,
+ typeCongeId: c.typeCongeId,
+ employe: c.employe,
+ email: c.email,
+ service: c.service || 'Sans service',
+ typeConge: c.typeConge,
+ annee: c.annee,
+ total: parseFloat(c.total) || 0,
+ solde: parseFloat(c.solde) || 0,
+ soldeReporte: parseFloat(c.soldeReporte) || 0,
+ consomme: parseFloat(c.consomme) || 0,
+ estApprenti: c.TypeContrat === 'Apprentissage' ||
+ c.role === 'Apprenti' ||
+ (c.role || '').toLowerCase().includes('apprenti')
+ }));
+
+ res.json(compteursFormates);
+
+ } catch (error) {
+ console.error('❌ Erreur récupération compteurs:', error);
+ res.status(500).json({ error: error.message });
+ }
+ });
+ app.put('/compteurs/:id', authenticateToken, async (req, res) => {
+ const transaction = new sql.Transaction(pool);
+ try {
+ await transaction.begin();
+
+ if (!['Admin', 'RH', 'Validateur'].includes(req.user.role)) {
+ await transaction.rollback();
+ return res.status(403).json({ error: 'Accès non autorisé' });
+ }
+
+ const { id } = req.params;
+ const { total, solde } = req.body;
+
+ const totalNum = parseFloat(total);
+ const soldeNum = parseFloat(solde);
+
+ const existingResult = await transaction.request()
+ .input('id', sql.Int, id)
+ .query('SELECT * FROM CompteurConges WHERE Id = @id');
+
+ const existing = existingResult.recordset;
+
+ if (existing.length === 0) {
+ await transaction.rollback();
+ return res.status(404).json({ error: 'Compteur non trouvé' });
+ }
+
+ const collaborateurId = existing[0].CollaborateurADId;
+ const typeCongeId = existing[0].TypeCongeId;
+ const annee = existing[0].Annee;
+
+ // Récupérer le nom du type de congé
+ const typeInfoResult = await transaction.request()
+ .input('typeCongeId', sql.Int, typeCongeId)
+ .query('SELECT Nom FROM TypeConge WHERE Id = @typeCongeId');
+
+ const typeConge = typeInfoResult.recordset.length > 0 ? typeInfoResult.recordset[0].Nom : 'Inconnu';
+
+ // Mise à jour
+ await transaction.request()
+ .input('total', sql.Decimal(10, 2), totalNum)
+ .input('solde', sql.Decimal(10, 2), soldeNum)
+ .input('id', sql.Int, id)
+ .query('UPDATE CompteurConges SET Total = @total, Solde = @solde, DerniereMiseAJour = GETDATE() WHERE Id = @id');
+
+ await transaction.commit();
+
+ // Notifier les clients SSE
+ notifyClients({
+ type: 'compteur-updated',
+ collaborateurId: collaborateurId,
+ typeCongeId: typeCongeId,
+ typeConge: typeConge,
+ annee: annee,
+ action: 'modification_rh',
+ nouveauTotal: totalNum,
+ nouveauSolde: soldeNum,
+ timestamp: new Date().toISOString()
+ }, collaborateurId);
+
+ // Webhook
+ try {
+ await webhookManager.sendWebhook(
+ WEBHOOKS.COLLABORATEURS_URL,
+ EVENTS.COMPTEUR_UPDATED,
+ {
+ collaborateurId: collaborateurId,
+ typeCongeId: typeCongeId,
+ typeConge: typeConge,
+ typeUpdate: 'modification_manuelle_rh',
+ annee: annee,
+ nouveauTotal: totalNum,
+ nouveauSolde: soldeNum,
+ source: 'rh',
+ timestamp: new Date().toISOString()
+ }
+ );
+ console.log(`✅ Webhook envoyé pour ${collaborateurId} (${typeConge} ${annee}: Total=${totalNum}j, Solde=${soldeNum}j)`);
+ } catch (webhookError) {
+ console.error('❌ Erreur envoi webhook (non bloquant):', webhookError.message);
+ }
+
+ res.json({
+ message: 'Compteur modifié avec succès',
+ total: totalNum,
+ solde: soldeNum
+ });
+ } catch (error) {
+ await transaction.rollback();
+ console.error('Erreur modification compteur:', error);
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+ // ================================================
+ // ROUTE POUR AJOUTER DES JOURS DE RÉCUPÉRATION
+ // ================================================
+ app.post('/compteurs/ajouter-recup', authenticateToken, async (req, res) => {
+ const transaction = new sql.Transaction(pool);
+ try {
+ await transaction.begin();
+
+ if (!['Admin', 'RH'].includes(req.user.role)) {
+ await transaction.rollback();
+ return res.status(403).json({ error: 'Accès non autorisé' });
+ }
+
+ const { collaborateurId, nombreJours, commentaire } = req.body;
+
+ if (!collaborateurId || !nombreJours || nombreJours <= 0) {
+ await transaction.rollback();
+ return res.json({
+ success: false,
+ message: 'Données invalides'
+ });
+ }
+
+ console.log('📥 Ajout de récupération:', { collaborateurId, nombreJours, commentaire });
+
+ // Récupérer les infos du collaborateur
+ const collaborateurResult = await transaction.request()
+ .input('id', sql.Int, collaborateurId)
+ .query('SELECT prenom, nom, email, Actif FROM CollaborateurAD WHERE id = @id');
+
+ const collaborateur = collaborateurResult.recordset;
+
+ if (collaborateur.length === 0) {
+ await transaction.rollback();
+ return res.status(404).json({
+ success: false,
+ message: 'Collaborateur non trouvé'
+ });
+ }
+
+ if (collaborateur[0].Actif === 0) {
+ await transaction.rollback();
+ return res.status(403).json({
+ success: false,
+ message: 'Impossible d\'ajouter des jours à un compte désactivé'
+ });
+ }
+
+ const collab = collaborateur[0];
+ const currentYear = new Date().getFullYear();
+
+ // Récupérer l'ID du type "Récupération"
+ const recupTypeResult = await transaction.request()
+ .query("SELECT Id FROM TypeConge WHERE Nom = 'Récupération'");
+
+ const recupType = recupTypeResult.recordset;
+
+ if (recupType.length === 0) {
+ await transaction.rollback();
+ return res.status(500).json({
+ success: false,
+ message: 'Type de congé "Récupération" non trouvé'
+ });
+ }
+
+ const recupTypeId = recupType[0].Id;
+
+ // Vérifier si le compteur existe déjà
+ const compteurExistingResult = await transaction.request()
+ .input('collaborateurId', sql.Int, collaborateurId)
+ .input('recupTypeId', sql.Int, recupTypeId)
+ .input('currentYear', sql.Int, currentYear)
+ .query(`
+ SELECT Id, Total, Solde FROM CompteurConges
+ WHERE CollaborateurADId = @collaborateurId
+ AND TypeCongeId = @recupTypeId
+ AND Annee = @currentYear
+ `);
+
+ const compteurExisting = compteurExistingResult.recordset;
+
+ let nouveauTotal, nouveauSolde;
+
+ if (compteurExisting.length > 0) {
+ nouveauTotal = parseFloat(compteurExisting[0].Total) + nombreJours;
+ nouveauSolde = parseFloat(compteurExisting[0].Solde) + nombreJours;
+
+ // Mettre à jour le compteur existant
+ await transaction.request()
+ .input('total', sql.Decimal(10, 2), nouveauTotal)
+ .input('solde', sql.Decimal(10, 2), nouveauSolde)
+ .input('id', sql.Int, compteurExisting[0].Id)
+ .query('UPDATE CompteurConges SET Total = @total, Solde = @solde, DerniereMiseAJour = GETDATE() WHERE Id = @id');
+
+ console.log(`✅ Compteur Récupération mis à jour: +${nombreJours}j (nouveau solde: ${nouveauSolde.toFixed(2)}j)`);
+ } else {
+ nouveauTotal = nombreJours;
+ nouveauSolde = nombreJours;
+
+ // Créer un nouveau compteur
+ await transaction.request()
+ .input('collaborateurId', sql.Int, collaborateurId)
+ .input('recupTypeId', sql.Int, recupTypeId)
+ .input('currentYear', sql.Int, currentYear)
+ .input('total', sql.Decimal(10, 2), nouveauTotal)
+ .input('solde', sql.Decimal(10, 2), nouveauSolde)
+ .query(`
+ INSERT INTO CompteurConges
+ (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour)
+ VALUES (@collaborateurId, @recupTypeId, @currentYear, @total, @solde, 0, GETDATE())
+ `);
+
+ console.log(`✅ Compteur Récupération créé: ${nombreJours}j`);
+ }
+
+ // Enregistrer dans l'historique
+ await transaction.request()
+ .input('userId', sql.Int, req.user.id)
+ .input('details', sql.NVarChar, `Ajout de ${nombreJours}j de récupération pour ${collab.prenom} ${collab.nom}${commentaire ? ` - ${commentaire}` : ''}`)
+ .query(`
+ INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DateAction)
+ VALUES (@userId, 'Ajout récupération', @details, GETDATE())
+ `);
+
+ await transaction.commit();
+
+ // Webhook
+ try {
+ await webhookManager.sendWebhook(
+ WEBHOOKS.COLLABORATEURS_URL,
+ EVENTS.COMPTEUR_UPDATED,
+ {
+ collaborateurId: collaborateurId,
+ typeCongeId: recupTypeId,
+ typeConge: 'Récupération',
+ typeUpdate: 'ajout_manuel_rh',
+ annee: currentYear,
+ nouveauTotal: nouveauTotal,
+ nouveauSolde: nouveauSolde,
+ source: 'bouton_plus_rh',
+ timestamp: new Date().toISOString()
+ }
+ );
+ console.log('✅ Webhook GTA-RH envoyé (compteur récup mis à jour)');
+ } catch (webhookError) {
+ console.error('❌ Erreur webhook (non bloquant):', webhookError.message);
+ }
+
+ // Notifier les clients SSE
+ notifyClients({
+ type: 'compteur-updated',
+ collaborateurId: collaborateurId,
+ typeConge: 'Récupération',
+ action: 'ajout',
+ nombreJours: nombreJours,
+ timestamp: new Date().toISOString()
+ }, collaborateurId);
+
+ // Envoyer un email
+ const accessToken = await getGraphToken();
+ if (accessToken && collab.email) {
+ const fromEmail = 'noreply@ensup.eu';
+ const subject = '✅ Jours de récupération ajoutés';
+ const body = `
+
+
+
✅ Jours de récupération ajoutés
+
+
+
Bonjour ${collab.prenom} ${collab.nom},
+
Des jours de récupération ont été ajoutés à votre compteur.
+
+
Jours ajoutés : ${nombreJours} jour${nombreJours > 1 ? 's' : ''}
+ ${commentaire ? `
Motif : ${commentaire}
` : ''}
+
Année : ${currentYear}
+
+
Ces jours sont désormais disponibles et peuvent être utilisés via une demande de congé.
+
+
+ `;
+
+ try {
+ await sendMailGraph(accessToken, fromEmail, collab.email, subject, body);
+ console.log('✅ Email de notification envoyé');
+ } catch (emailError) {
+ console.error('❌ Erreur envoi email:', emailError);
+ }
+ }
+
+ res.json({
+ success: true,
+ message: 'Jours de récupération ajoutés avec succès',
+ collaborateur: `${collab.prenom} ${collab.nom}`,
+ nombreJours: nombreJours,
+ annee: currentYear
+ });
+
+ } catch (error) {
+ await transaction.rollback();
+ console.error('❌ Erreur ajout récupération:', error);
+ res.status(500).json({
+ success: false,
+ message: 'Erreur serveur',
+ error: error.message
+ });
+ }
+ });
+
+ // Suite - Routes équipes, stats, historique
+
+ // ================================================
+ // ROUTE POUR RETIRER DES JOURS DE RÉCUPÉRATION
+ // ================================================
+ app.post('/compteurs/retirer-recup', authenticateToken, async (req, res) => {
+ const transaction = new sql.Transaction(pool);
+ try {
+ await transaction.begin();
+
+ if (!['Admin', 'RH'].includes(req.user.role)) {
+ await transaction.rollback();
+ return res.status(403).json({ error: 'Accès non autorisé' });
+ }
+
+ const { collaborateurId, nombreJours, commentaire } = req.body;
+
+ if (!collaborateurId || !nombreJours || nombreJours <= 0) {
+ await transaction.rollback();
+ return res.json({
+ success: false,
+ message: 'Données invalides'
+ });
+ }
+
+ console.log('📤 Retrait de récupération:', { collaborateurId, nombreJours, commentaire });
+
+ // Récupérer les infos du collaborateur
+ const collaborateurResult = await transaction.request()
+ .input('id', sql.Int, collaborateurId)
+ .query('SELECT prenom, nom, email FROM CollaborateurAD WHERE id = @id');
+
+ const collaborateur = collaborateurResult.recordset;
+
+ if (collaborateur.length === 0) {
+ await transaction.rollback();
+ return res.status(404).json({
+ success: false,
+ message: 'Collaborateur non trouvé'
+ });
+ }
+
+ const collab = collaborateur[0];
+ const currentYear = new Date().getFullYear();
+
+ // Récupérer l'ID du type "Récupération"
+ const recupTypeResult = await transaction.request()
+ .query("SELECT Id FROM TypeConge WHERE Nom = 'Récupération'");
+
+ const recupType = recupTypeResult.recordset;
+
+ if (recupType.length === 0) {
+ await transaction.rollback();
+ return res.status(500).json({
+ success: false,
+ message: 'Type de congé "Récupération" non trouvé'
+ });
+ }
+
+ const recupTypeId = recupType[0].Id;
+
+ // Vérifier le solde actuel
+ const compteurResult = await transaction.request()
+ .input('collaborateurId', sql.Int, collaborateurId)
+ .input('recupTypeId', sql.Int, recupTypeId)
+ .input('currentYear', sql.Int, currentYear)
+ .query(`
+ SELECT Id, Total, Solde FROM CompteurConges
+ WHERE CollaborateurADId = @collaborateurId
+ AND TypeCongeId = @recupTypeId
+ AND Annee = @currentYear
+ `);
+
+ const compteur = compteurResult.recordset;
+
+ if (compteur.length === 0 || compteur[0].Solde < nombreJours) {
+ await transaction.rollback();
+ return res.status(400).json({
+ success: false,
+ message: 'Solde de récupération insuffisant',
+ soldeActuel: compteur.length > 0 ? compteur[0].Solde : 0
+ });
+ }
+
+ const nouveauTotal = Math.max(0, parseFloat(compteur[0].Total) - nombreJours);
+ const nouveauSolde = Math.max(0, parseFloat(compteur[0].Solde) - nombreJours);
+
+ // Retirer les jours
+ await transaction.request()
+ .input('total', sql.Decimal(10, 2), nouveauTotal)
+ .input('solde', sql.Decimal(10, 2), nouveauSolde)
+ .input('id', sql.Int, compteur[0].Id)
+ .query('UPDATE CompteurConges SET Total = @total, Solde = @solde, DerniereMiseAJour = GETDATE() WHERE Id = @id');
+
+ console.log(`✅ ${nombreJours}j retirés du compteur Récupération`);
+
+ // Enregistrer dans l'historique
+ await transaction.request()
+ .input('userId', sql.Int, req.user.id)
+ .input('details', sql.NVarChar, `Retrait de ${nombreJours}j de récupération pour ${collab.prenom} ${collab.nom}${commentaire ? ` - ${commentaire}` : ''}`)
+ .query(`
+ INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DateAction)
+ VALUES (@userId, 'Retrait récupération', @details, GETDATE())
+ `);
+
+ await transaction.commit();
+
+ // Webhook
+ try {
+ await webhookManager.sendWebhook(
+ WEBHOOKS.COLLABORATEURS_URL,
+ EVENTS.COMPTEUR_UPDATED,
+ {
+ collaborateurId: collaborateurId,
+ typeCongeId: recupTypeId,
+ typeConge: 'Récupération',
+ typeUpdate: 'retrait_manuel_rh',
+ annee: currentYear,
+ nouveauTotal: nouveauTotal,
+ nouveauSolde: nouveauSolde,
+ source: 'bouton_moins_rh',
+ timestamp: new Date().toISOString()
+ }
+ );
+ console.log('✅ Webhook GTA-RH envoyé (compteur récup mis à jour)');
+ } catch (webhookError) {
+ console.error('❌ Erreur webhook (non bloquant):', webhookError.message);
+ }
+
+ // Notifier les clients SSE
+ notifyClients({
+ type: 'compteur-updated',
+ collaborateurId: collaborateurId,
+ typeConge: 'Récupération',
+ action: 'retrait',
+ nombreJours: nombreJours,
+ timestamp: new Date().toISOString()
+ }, collaborateurId);
+
+ res.json({
+ success: true,
+ message: 'Jours de récupération retirés avec succès',
+ collaborateur: `${collab.prenom} ${collab.nom}`,
+ nombreJours: nombreJours,
+ annee: currentYear
+ });
+
+ } catch (error) {
+ await transaction.rollback();
+ console.error('❌ Erreur retrait récupération:', error);
+ res.status(500).json({
+ success: false,
+ message: 'Erreur serveur',
+ error: error.message
+ });
+ }
+ });
+
+ app.get('/equipes', authenticateToken, async (req, res) => {
+ try {
+ const result = await pool.request().query(`
+ SELECT
+ s.Id,
+ s.Nom as nomService,
+ s.Nom as service,
+ COUNT(DISTINCT CASE WHEN (ca.Actif = 1 OR ca.Actif IS NULL) THEN ca.id END) as nombreMembres,
+ COUNT(DISTINCT CASE WHEN dc.Statut = 'En attente' AND (ca.Actif = 1 OR ca.Actif IS NULL) THEN dc.Id END) as demandesEnAttente,
+ COALESCE(
+ STUFF((
+ SELECT DISTINCT ',' + c1.Nom
+ FROM ServiceAffectation sa1
+ JOIN Campus c1 ON sa1.CampusId = c1.Id
+ WHERE sa1.ServiceId = s.Id
+ ORDER BY ',' + c1.Nom
+ FOR XML PATH('')
+ ), 1, 1, ''),
+ STUFF((
+ SELECT DISTINCT ',' + c2.Nom
+ FROM CollaborateurAD ca2
+ JOIN Campus c2 ON ca2.CampusId = c2.Id
+ WHERE ca2.ServiceId = s.Id AND (ca2.Actif = 1 OR ca2.Actif IS NULL)
+ ORDER BY ',' + c2.Nom
+ FOR XML PATH('')
+ ), 1, 1, ''),
+ 'Non assigné'
+ ) as campus
+ FROM Services s
+ LEFT JOIN CollaborateurAD ca ON s.Id = ca.ServiceId AND (ca.Actif = 1 OR ca.Actif IS NULL)
+ LEFT JOIN DemandeConge dc ON ca.id = dc.CollaborateurADId AND dc.Statut = 'En attente'
+ GROUP BY s.Id, s.Nom
+ HAVING COUNT(DISTINCT CASE WHEN (ca.Actif = 1 OR ca.Actif IS NULL) THEN ca.id END) > 0
+ OR EXISTS (SELECT 1 FROM ServiceAffectation sa2 WHERE sa2.ServiceId = s.Id)
+ ORDER BY s.Nom
+ `);
+
+ const equipes = result.recordset;
+ res.json(equipes);
+ } catch (error) {
+ console.error('❌ Erreur /equipes:', error);
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+ app.get('/equipes/:id', authenticateToken, async (req, res) => {
+ try {
+ const result = await pool.request()
+ .input('id', sql.Int, req.params.id)
+ .query(`
+ SELECT
+ s.Id,
+ s.Nom,
+ s.Nom as nomService,
+ COUNT(DISTINCT CASE
+ WHEN (ca.Actif = 1 OR ca.Actif IS NULL)
+ THEN ca.id
+ END) as nombreMembres,
+ COUNT(DISTINCT CASE
+ WHEN dc.Statut = 'En attente'
+ AND (ca.Actif = 1 OR ca.Actif IS NULL)
+ THEN dc.Id
+ END) as demandesEnAttente,
+ COALESCE(
+ STUFF((
+ SELECT DISTINCT ', ' + c.Nom
+ FROM ServiceAffectation sa
+ JOIN Campus c ON sa.CampusId = c.Id
+ WHERE sa.ServiceId = s.Id
+ ORDER BY ', ' + c.Nom
+ FOR XML PATH('')
+ ), 1, 2, ''),
+ 'Non assigné'
+ ) as campus
+ FROM Services s
+ LEFT JOIN CollaborateurAD ca ON s.Id = ca.ServiceId
+ LEFT JOIN DemandeConge dc ON ca.id = dc.CollaborateurADId
+ WHERE s.Id = @id
+ GROUP BY s.Id, s.Nom
+ `);
+
+ const equipes = result.recordset;
+
+ if (equipes.length === 0) {
+ return res.status(404).json({ error: 'Service non trouvé' });
+ }
+
+ res.json(equipes[0]);
+ } catch (error) {
+ console.error('Erreur /equipes/:id:', error);
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+ app.get('/equipes/:id/membres', authenticateToken, async (req, res) => {
+ try {
+ const result = await pool.request()
+ .input('id', sql.Int, req.params.id)
+ .query(`
+ SELECT
+ ca.id,
+ CONCAT(ca.prenom, ' ', ca.nom) as nom,
+ ca.email,
+ ca.role,
+ ca.Actif,
+ ca.DateSortie,
+ ca.TypeContrat,
+ (SELECT MAX(Solde) FROM CompteurConges cc
+ WHERE cc.CollaborateurADId = ca.id
+ AND cc.TypeCongeId = 1
+ AND cc.Annee = YEAR(GETDATE())) as soldeCP,
+ (SELECT MAX(Solde) FROM CompteurConges cc2
+ WHERE cc2.CollaborateurADId = ca.id
+ AND cc2.TypeCongeId = 2
+ AND cc2.Annee = YEAR(GETDATE())) as soldeRTT
+ FROM CollaborateurAD ca
+ WHERE ca.ServiceId = @id
+ AND (ca.Actif = 1 OR ca.Actif IS NULL)
+ ORDER BY ca.nom, ca.prenom
+ `);
+
+ const membres = result.recordset;
+ console.log(`${membres.length} membres trouvés pour le service ${req.params.id}`);
+ res.json(membres);
+ } catch (error) {
+ console.error('Erreur /equipes/:id/membres:', error);
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+ app.get('/stats', authenticateToken, async (req, res) => {
+ try {
+ const result = await pool.request().query(`
+ SELECT
+ COUNT(CASE WHEN Statut = 'En attente' AND (ca.Actif = 1 OR ca.Actif IS NULL) THEN 1 END) as enAttente,
+ COUNT(CASE WHEN Statut = 'Validée' AND MONTH(DateValidation) = MONTH(GETDATE()) AND (ca.Actif = 1 OR ca.Actif IS NULL) THEN 1 END) as valideeCeMois,
+ COUNT(DISTINCT ca.ServiceId) as nombreEquipes
+ FROM DemandeConge dc
+ JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id
+ WHERE (ca.Actif = 1 OR ca.Actif IS NULL)
+ `);
+
+ const stats = result.recordset;
+ res.json(stats[0]);
+ } catch (error) {
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+ // Route /historique modifiée pour inclure le collaborateur concerné ET la personne qui a réalisé l'action
+
+ app.get('/historique', authenticateToken, async (req, res) => {
+ try {
+ const { dateDebut, dateFin, action } = req.query;
+
+ const request = pool.request();
+
+ let whereClause = 'WHERE 1=1';
+
+ if (dateDebut) {
+ whereClause += ' AND ha.DateAction >= @dateDebut';
+ request.input('dateDebut', sql.DateTime, dateDebut);
+ }
+
+ if (dateFin) {
+ whereClause += ' AND ha.DateAction <= @dateFin';
+ request.input('dateFin', sql.DateTime, dateFin);
+ }
+
+ if (action && action !== 'all') {
+ whereClause += ' AND ha.Action = @action';
+ request.input('action', sql.VarChar, action);
+ }
+
+ // ✅ REQUÊTE MODIFIÉE : Inclut le collaborateur concerné ET la personne qui a réalisé l'action
+ const query = `
+ SELECT TOP 500
+ ha.Id,
+ -- Personne qui a réalisé l'action (celui qui est dans HistoriqueActions.CollaborateurADId)
+ CONCAT(ca.prenom, ' ', ca.nom) AS realisePar,
+ ca.email AS realiseParEmail,
+ -- Collaborateur concerné par l'action (récupéré via la demande de congé)
+ CASE
+ WHEN dc.CollaborateurADId IS NOT NULL
+ THEN CONCAT(collab_concerne.prenom, ' ', collab_concerne.nom)
+ ELSE CONCAT(ca.prenom, ' ', ca.nom)
+ END AS collaborateur,
+ CASE
+ WHEN dc.CollaborateurADId IS NOT NULL
+ THEN collab_concerne.email
+ ELSE ca.email
+ END AS collaborateurEmail,
+ ha.Action,
+ ha.Details,
+ ha.DateAction,
+ ha.AdresseIP,
+ ha.DemandeCongeId
+ FROM HistoriqueActions ha
+ -- Jointure pour récupérer la personne qui a réalisé l'action
+ JOIN CollaborateurAD ca ON ha.CollaborateurADId = ca.id
+ -- Jointure optionnelle pour récupérer la demande de congé associée
+ LEFT JOIN DemandeConge dc ON ha.DemandeCongeId = dc.Id
+ -- Jointure optionnelle pour récupérer le collaborateur concerné par la demande
+ LEFT JOIN CollaborateurAD collab_concerne ON dc.CollaborateurADId = collab_concerne.id
+ ${whereClause}
+ ORDER BY ha.DateAction DESC
+ `;
+
+ console.log('📊 Requête historique SQL:', query);
+
+ const result = await request.query(query);
+ const historique = result.recordset || [];
+
+ console.log(`✅ ${historique.length} entrées d'historique trouvées`);
+
+ // ⭐ TOUJOURS retourner un tableau
+ res.json(historique);
+ } catch (error) {
+ console.error('❌ Erreur /historique:', error);
+ res.status(500).json({ error: error.message });
+ }
+ });
+ // ================================================
+ // FONCTIONS DE CALCUL CP ET RTT
+ // ================================================
+
+ /**
+ * Calcule l'acquisition CP avec la formule Excel exacte
+ */
+ function calculerAcquisitionCP(dateReference = new Date(), dateEntree = null) {
+ const d = new Date(dateReference);
+ d.setHours(0, 0, 0, 0);
+ const annee = d.getFullYear();
+ const mois = d.getMonth() + 1; // 1-12
+
+ // 1. Déterminer le début de l'exercice CP (01/06)
+ let exerciceDebut;
+ if (mois >= 6) {
+ exerciceDebut = new Date(annee, 5, 1); // 01/06/N
+ } else {
+ exerciceDebut = new Date(annee - 1, 5, 1); // 01/06/N-1
+ }
+ exerciceDebut.setHours(0, 0, 0, 0);
+
+ // 2. Ajuster si date d'entrée postérieure
+ let dateDebutAcquis = new Date(exerciceDebut);
+ if (dateEntree && dateEntree !== null) {
+ const entree = new Date(dateEntree);
+ entree.setHours(0, 0, 0, 0);
+
+ if (!isNaN(entree.getTime()) && entree > exerciceDebut) {
+ dateDebutAcquis = entree;
+ }
+ }
+
+ // 3. Calculer avec la formule Excel
+ const coeffCP = 25 / 12; // 2.0833
+ const acquisition = calculerAcquisitionFormuleExcel(dateDebutAcquis, d, coeffCP);
+
+ // 4. Plafonner à 25 jours
+ return Math.min(acquisition, 25);
+ }
+
+ /**
+ * Calcule l'acquisition RTT avec la configuration variable
+ */
+ async function calculerAcquisitionRTT(collaborateurId, dateReference = new Date()) {
+ const d = new Date(dateReference);
+ d.setHours(0, 0, 0, 0);
+ const annee = d.getFullYear();
+
+ // 1. Récupérer les infos du collaborateur
+ const collabInfoResult = await pool.request()
+ .input('collaborateurId', sql.Int, collaborateurId)
+ .query('SELECT TypeContrat, DateEntree, role FROM CollaborateurAD WHERE id = @collaborateurId');
+
+ const collabInfo = collabInfoResult.recordset;
+
+ if (collabInfo.length === 0) {
+ throw new Error(`Collaborateur ${collaborateurId} non trouvé`);
+ }
+
+ const typeContrat = collabInfo[0].TypeContrat || '37h';
+ const dateEntree = collabInfo[0].DateEntree;
+ const isApprenti = collabInfo[0].role === 'Apprenti';
+
+ // 2. Apprentis : pas de RTT
+ if (isApprenti) {
+ return {
+ acquisition: 0,
+ moisTravailles: 0,
+ config: { joursAnnuels: 0, acquisitionMensuelle: 0 },
+ typeContrat: typeContrat
+ };
+ }
+
+ // 3. Récupérer la configuration RTT
+ const config = await getConfigurationRTT(annee, typeContrat);
+
+ // 4. Début d'acquisition : 01/01/N ou date d'entrée
+ let dateDebutAcquis = new Date(annee, 0, 1); // 01/01/N
+ dateDebutAcquis.setHours(0, 0, 0, 0);
+
+ if (dateEntree && dateEntree !== null) {
+ const entree = new Date(dateEntree);
+ entree.setHours(0, 0, 0, 0);
+
+ if (!isNaN(entree.getTime())) {
+ if (entree.getFullYear() === annee && entree > dateDebutAcquis) {
+ dateDebutAcquis = entree;
+ }
+ if (entree.getFullYear() > annee) {
+ return {
+ acquisition: 0,
+ moisTravailles: 0,
+ config: config,
+ typeContrat: typeContrat
+ };
+ }
+ }
+ }
+
+ // 5. Calculer avec la formule Excel
+ const acquisition = calculerAcquisitionFormuleExcel(
+ dateDebutAcquis,
+ d,
+ config.acquisitionMensuelle
+ );
+
+ // 6. Calculer les mois travaillés
+ const moisTravailles = config.acquisitionMensuelle > 0
+ ? acquisition / config.acquisitionMensuelle
+ : 0;
+
+ // 7. Plafonner au maximum annuel
+ const acquisitionFinale = Math.min(acquisition, config.joursAnnuels);
+
+ return {
+ acquisition: Math.round(acquisitionFinale * 100) / 100,
+ moisTravailles: Math.round(moisTravailles * 100) / 100,
+ config: config,
+ typeContrat: typeContrat
+ };
+ }
+
+ function dateDifMonths(date1, date2) {
+ const d1 = new Date(date1);
+ const d2 = new Date(date2);
+
+ // Vérifier que les dates sont valides
+ if (isNaN(d1.getTime()) || isNaN(d2.getTime())) {
+ console.error('❌ Date invalide dans dateDifMonths');
+ return 0;
+ }
+
+ let months = (d2.getFullYear() - d1.getFullYear()) * 12;
+ months += d2.getMonth() - d1.getMonth();
+
+ // Si le jour de d2 < jour de d1, on n'a pas encore complété le mois
+ if (d2.getDate() < d1.getDate()) {
+ months--;
+ }
+
+ return Math.max(0, months);
+ }
+
+ /**
+ * Formule Excel exacte pour calculer l'acquisition
+ */
+ function calculerAcquisitionFormuleExcel(dateDebut, dateReference, coeffMensuel) {
+ const b1 = new Date(dateDebut);
+ const b2 = new Date(dateReference);
+ b1.setHours(0, 0, 0, 0);
+ b2.setHours(0, 0, 0, 0);
+
+ // Vérifier que les dates sont valides
+ if (isNaN(b1.getTime()) || isNaN(b2.getTime())) {
+ console.error('❌ Date invalide dans calculerAcquisitionFormuleExcel');
+ return 0;
+ }
+
+ // Vérifier que coeffMensuel est un nombre
+ if (isNaN(coeffMensuel) || coeffMensuel <= 0) {
+ console.error('❌ Coefficient mensuel invalide:', coeffMensuel);
+ return 0;
+ }
+
+ // Si date référence avant date début
+ if (b2 < b1) return 0;
+
+ // Si même mois et même année
+ if (b1.getFullYear() === b2.getFullYear() && b1.getMonth() === b2.getMonth()) {
+ const joursTotal = new Date(b2.getFullYear(), b2.getMonth() + 1, 0).getDate();
+ const joursAcquis = b2.getDate() - b1.getDate() + 1;
+ return Math.round((joursAcquis / joursTotal) * coeffMensuel * 100) / 100;
+ }
+
+ // 1. Fraction du PREMIER mois
+ const joursFinMoisB1 = new Date(b1.getFullYear(), b1.getMonth() + 1, 0).getDate();
+ const jourB1 = b1.getDate();
+ const fractionPremierMois = (joursFinMoisB1 - jourB1 + 1) / joursFinMoisB1;
+
+ // 2. Mois COMPLETS entre
+ const moisComplets = dateDifMonths(b1, b2) - 1;
+
+ // 3. Fraction du DERNIER mois
+ const joursFinMoisB2 = new Date(b2.getFullYear(), b2.getMonth() + 1, 0).getDate();
+ const jourB2 = b2.getDate();
+ const fractionDernierMois = jourB2 / joursFinMoisB2;
+
+ // 4. Total
+ const totalMois = fractionPremierMois + Math.max(0, moisComplets) + fractionDernierMois;
+ const acquisition = totalMois * coeffMensuel;
+
+ return Math.round(acquisition * 100) / 100;
+ }
+
+ /**
+ * Récupère la configuration RTT pour une année et un type de contrat
+ */
+ async function getConfigurationRTT(annee, typeContrat = '37h') {
+ try {
+ const result = await pool.request()
+ .input('annee', sql.Int, annee)
+ .input('typeContrat', sql.VarChar, typeContrat)
+ .query(`
+ SELECT JoursAnnuels, AcquisitionMensuelle
+ FROM ConfigurationRTT
+ WHERE Annee = @annee AND TypeContrat = @typeContrat
+ `);
+
+ const config = result.recordset;
+
+ if (config.length > 0) {
+ return {
+ joursAnnuels: parseFloat(config[0].JoursAnnuels),
+ acquisitionMensuelle: parseFloat(config[0].AcquisitionMensuelle)
+ };
+ }
+
+ // Sinon, utiliser les règles par défaut
+ return getConfigurationRTTDefaut(annee, typeContrat);
+ } catch (error) {
+ console.error('Erreur getConfigurationRTT:', error);
+ return getConfigurationRTTDefaut(annee, typeContrat);
+ }
+ }
+
+ /**
+ * Configuration RTT par défaut selon les règles métier
+ */
+ function getConfigurationRTTDefaut(annee, typeContrat = '37h') {
+ // 37h : toujours 10 RTT/an
+ if (typeContrat === '37h' || typeContrat === 'tempspartiel') {
+ return {
+ joursAnnuels: 10,
+ acquisitionMensuelle: 10 / 12 // 0.8333
+ };
+ }
+
+ // Forfait jour : dépend de l'année
+ if (typeContrat === 'forfaitjour') {
+ if (annee <= 2025) {
+ // 2025 et avant : 10 RTT/an
+ return {
+ joursAnnuels: 10,
+ acquisitionMensuelle: 10 / 12 // 0.8333
+ };
+ } else {
+ // 2026 et après : 12 RTT/an
+ return {
+ joursAnnuels: 12,
+ acquisitionMensuelle: 12 / 12 // 1.0
+ };
+ }
+ }
+
+ // Par défaut : 10 RTT/an
+ return {
+ joursAnnuels: 10,
+ acquisitionMensuelle: 10 / 12
+ };
+ }
+
+ // Suite et fin - Réinitialisation compteurs, routes API V2, etc.
+
+ // ========================================
+ // ROUTE COMPLÈTE : /reinitializeAllCounters
+ // ========================================
+ app.post('/reinitializeAllCounters', async (req, res) => {
+ const transaction = new sql.Transaction(pool);
+ try {
+ await transaction.begin();
+
+ console.log('🔄 Réinitialisation de tous les compteurs...');
+
+ const collaborateursResult = await transaction.request().query(`
+ SELECT id, prenom, nom, DateEntree, TypeContrat, CampusId, SocieteId, role
+ FROM CollaborateurAD
+ WHERE Actif = 1 OR Actif IS NULL
+ `);
+
+ const collaborateurs = collaborateursResult.recordset;
+
+ console.log(`📋 ${collaborateurs.length} collaborateurs trouvés`);
+
+ const dateRefParam = req.body.dateReference;
+ const today = dateRefParam ? new Date(dateRefParam) : new Date();
+ const currentYear = today.getFullYear();
+ const previousYear = currentYear - 1;
+ const results = [];
+
+ // Récupérer les types de congés
+ const cpTypeResult = await transaction.request()
+ .query("SELECT Id FROM TypeConge WHERE Nom = 'Congé payé'");
+ const cpType = cpTypeResult.recordset;
+
+ const rttTypeResult = await transaction.request()
+ .query("SELECT Id FROM TypeConge WHERE Nom = 'RTT'");
+ const rttType = rttTypeResult.recordset;
+
+ for (const collab of collaborateurs) {
+ console.log(`\n${'='.repeat(60)}`);
+ console.log(`👤 ${collab.prenom} ${collab.nom} (ID: ${collab.id})`);
+ console.log(` Type contrat: ${collab.TypeContrat || '37h'}`);
+ console.log(` Date entrée: ${collab.DateEntree ? new Date(collab.DateEntree).toISOString().split('T')[0] : 'NULL'}`);
+ console.log(` Rôle: ${collab.role || 'Non défini'}`);
+
+ const dateEntree = collab.DateEntree;
+ const typeContrat = collab.TypeContrat || '37h';
+ const isApprenti = collab.role === 'Apprenti';
+
+ // ==========================================
+ // CALCUL CP
+ // ==========================================
+ let acquisCP = calculerAcquisitionCP(today, dateEntree);
+
+ if (isNaN(acquisCP) || !isFinite(acquisCP)) {
+ console.warn(`⚠️ Acquisition CP invalide - Forcé à 0`);
+ acquisCP = 0;
+ }
+
+ // ==========================================
+ // CALCUL RTT
+ // ==========================================
+ let acquisRTT = 0;
+ if (!isApprenti) {
+ try {
+ const rttData = await calculerAcquisitionRTT(collab.id, today);
+ acquisRTT = rttData.acquisition;
+
+ if (isNaN(acquisRTT) || !isFinite(acquisRTT)) {
+ console.warn(`⚠️ Acquisition RTT invalide - Forcé à 0`);
+ acquisRTT = 0;
+ }
+ } catch (error) {
+ console.error(`❌ Erreur calcul RTT:`, error.message);
+ acquisRTT = 0;
+ }
+ }
+
+ // ==========================================
+ // MISE À JOUR CP N
+ // ==========================================
+ if (cpType.length > 0) {
+ const deductionsCPResult = await transaction.request()
+ .input('collabId', sql.Int, collab.id)
+ .input('typeId', sql.Int, cpType[0].Id)
+ .input('currentYear', sql.Int, currentYear)
+ .query(`
+ SELECT COALESCE(SUM(dd.JoursUtilises), 0) as totalConsomme
+ FROM DeductionDetails dd
+ JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
+ WHERE dc.CollaborateurADId = @collabId
+ AND dd.TypeCongeId = @typeId
+ AND dd.Annee = @currentYear
+ AND dc.Statut != 'Refusé'
+ `);
+
+ let totalConsomme = parseFloat(deductionsCPResult.recordset[0].totalConsomme || 0);
+
+ if (isNaN(totalConsomme) || !isFinite(totalConsomme)) {
+ console.warn(`⚠️ Total consommé CP invalide - Forcé à 0`);
+ totalConsomme = 0;
+ }
+
+ const compteurExistingResult = await transaction.request()
+ .input('collabId', sql.Int, collab.id)
+ .input('typeId', sql.Int, cpType[0].Id)
+ .input('currentYear', sql.Int, currentYear)
+ .query(`
+ SELECT SoldeReporte FROM CompteurConges
+ WHERE CollaborateurADId = @collabId
+ AND TypeCongeId = @typeId
+ AND Annee = @currentYear
+ `);
+
+ const compteurExisting = compteurExistingResult.recordset;
+
+ let soldeReporte = compteurExisting.length > 0
+ ? parseFloat(compteurExisting[0].SoldeReporte || 0)
+ : 0;
+
+ if (isNaN(soldeReporte) || !isFinite(soldeReporte)) {
+ console.warn(`⚠️ Solde reporté CP invalide - Forcé à 0`);
+ soldeReporte = 0;
+ }
+
+ let nouveauSolde = Math.max(0, acquisCP + soldeReporte - totalConsomme);
+
+ if (isNaN(nouveauSolde) || !isFinite(nouveauSolde)) {
+ console.warn(`⚠️ Nouveau solde CP invalide - Forcé à 0`);
+ nouveauSolde = 0;
+ }
+
+ console.log(` 📊 CP - Acquis: ${acquisCP.toFixed(2)}j, Consommé: ${totalConsomme.toFixed(2)}j, Reporté: ${soldeReporte.toFixed(2)}j, Solde: ${nouveauSolde.toFixed(2)}j`);
+
+ if (compteurExisting.length > 0) {
+ await transaction.request()
+ .input('total', sql.Decimal(10, 2), acquisCP)
+ .input('solde', sql.Decimal(10, 2), nouveauSolde)
+ .input('collabId', sql.Int, collab.id)
+ .input('typeId', sql.Int, cpType[0].Id)
+ .input('currentYear', sql.Int, currentYear)
+ .query(`
+ UPDATE CompteurConges
+ SET Total = @total, Solde = @solde, DerniereMiseAJour = GETDATE()
+ WHERE CollaborateurADId = @collabId AND TypeCongeId = @typeId AND Annee = @currentYear
+ `);
+ } else {
+ await transaction.request()
+ .input('collabId', sql.Int, collab.id)
+ .input('typeId', sql.Int, cpType[0].Id)
+ .input('currentYear', sql.Int, currentYear)
+ .input('total', sql.Decimal(10, 2), acquisCP)
+ .input('solde', sql.Decimal(10, 2), nouveauSolde)
+ .query(`
+ INSERT INTO CompteurConges
+ (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour)
+ VALUES (@collabId, @typeId, @currentYear, @total, @solde, 0, GETDATE())
+ `);
+ }
+
+ // Créer CP N-1 si nécessaire
+ const cpN1Result = await transaction.request()
+ .input('collabId', sql.Int, collab.id)
+ .input('typeId', sql.Int, cpType[0].Id)
+ .input('previousYear', sql.Int, previousYear)
+ .query(`
+ SELECT Id FROM CompteurConges
+ WHERE CollaborateurADId = @collabId AND TypeCongeId = @typeId AND Annee = @previousYear
+ `);
+
+ const cpN1 = cpN1Result.recordset;
+
+ if (cpN1.length === 0) {
+ await transaction.request()
+ .input('collabId', sql.Int, collab.id)
+ .input('typeId', sql.Int, cpType[0].Id)
+ .input('previousYear', sql.Int, previousYear)
+ .query(`
+ INSERT INTO CompteurConges
+ (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour)
+ VALUES (@collabId, @typeId, @previousYear, 0, 0, 0, GETDATE())
+ `);
+ }
+ }
+
+ // ==========================================
+ // MISE À JOUR RTT N
+ // ==========================================
+ if (rttType.length > 0 && !isApprenti) {
+ const deductionsRTTResult = await transaction.request()
+ .input('collabId', sql.Int, collab.id)
+ .input('typeId', sql.Int, rttType[0].Id)
+ .input('currentYear', sql.Int, currentYear)
+ .query(`
+ SELECT COALESCE(SUM(dd.JoursUtilises), 0) as totalConsomme
+ FROM DeductionDetails dd
+ JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
+ WHERE dc.CollaborateurADId = @collabId
+ AND dd.TypeCongeId = @typeId
+ AND dd.Annee = @currentYear
+ AND dc.Statut != 'Refusé'
+ `);
+
+ let totalConsomme = parseFloat(deductionsRTTResult.recordset[0].totalConsomme || 0);
+
+ if (isNaN(totalConsomme) || !isFinite(totalConsomme)) {
+ console.warn(`⚠️ Total consommé RTT invalide - Forcé à 0`);
+ totalConsomme = 0;
+ }
+
+ let nouveauSolde = Math.max(0, acquisRTT - totalConsomme);
+
+ if (isNaN(nouveauSolde) || !isFinite(nouveauSolde)) {
+ console.warn(`⚠️ Nouveau solde RTT invalide - Forcé à 0`);
+ nouveauSolde = 0;
+ }
+
+ console.log(` 📊 RTT - Acquis: ${acquisRTT.toFixed(2)}j, Consommé: ${totalConsomme.toFixed(2)}j, Solde: ${nouveauSolde.toFixed(2)}j`);
+
+ const compteurExistingResult = await transaction.request()
+ .input('collabId', sql.Int, collab.id)
+ .input('typeId', sql.Int, rttType[0].Id)
+ .input('currentYear', sql.Int, currentYear)
+ .query(`
+ SELECT Id FROM CompteurConges
+ WHERE CollaborateurADId = @collabId AND TypeCongeId = @typeId AND Annee = @currentYear
+ `);
+
+ const compteurExisting = compteurExistingResult.recordset;
+
+ if (compteurExisting.length > 0) {
+ await transaction.request()
+ .input('total', sql.Decimal(10, 2), acquisRTT)
+ .input('solde', sql.Decimal(10, 2), nouveauSolde)
+ .input('id', sql.Int, compteurExisting[0].Id)
+ .query(`
+ UPDATE CompteurConges
+ SET Total = @total, Solde = @solde, DerniereMiseAJour = GETDATE()
+ WHERE Id = @id
+ `);
+ } else {
+ await transaction.request()
+ .input('collabId', sql.Int, collab.id)
+ .input('typeId', sql.Int, rttType[0].Id)
+ .input('currentYear', sql.Int, currentYear)
+ .input('total', sql.Decimal(10, 2), acquisRTT)
+ .input('solde', sql.Decimal(10, 2), nouveauSolde)
+ .query(`
+ INSERT INTO CompteurConges
+ (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour)
+ VALUES (@collabId, @typeId, @currentYear, @total, @solde, 0, GETDATE())
+ `);
+ }
+
+ // Créer RTT N-1 si nécessaire
+ const rttN1Result = await transaction.request()
+ .input('collabId', sql.Int, collab.id)
+ .input('typeId', sql.Int, rttType[0].Id)
+ .input('previousYear', sql.Int, previousYear)
+ .query(`
+ SELECT Id FROM CompteurConges
+ WHERE CollaborateurADId = @collabId AND TypeCongeId = @typeId AND Annee = @previousYear
+ `);
+
+ const rttN1 = rttN1Result.recordset;
+
+ if (rttN1.length === 0) {
+ await transaction.request()
+ .input('collabId', sql.Int, collab.id)
+ .input('typeId', sql.Int, rttType[0].Id)
+ .input('previousYear', sql.Int, previousYear)
+ .query(`
+ INSERT INTO CompteurConges
+ (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour)
+ VALUES (@collabId, @typeId, @previousYear, 0, 0, 0, GETDATE())
+ `);
+ }
+ }
+
+ // Ajouter au résultat
+ results.push({
+ collaborateur: `${collab.prenom} ${collab.nom}`,
+ typecontrat: typeContrat,
+ cpacquis: acquisCP.toFixed(2),
+ rttacquis: acquisRTT.toFixed(2)
+ });
+ }
+
+ await transaction.commit();
+ console.log('\n✅ Réinitialisation terminée');
+
+ res.json({
+ success: true,
+ message: `Compteurs réinitialisés pour ${collaborateurs.length} collaborateurs`,
+ datereference: today.toISOString().split('T')[0],
+ totalcollaborateurs: collaborateurs.length,
+ results: results
+ });
+
+ } catch (error) {
+ await transaction.rollback();
+ console.error('❌ Erreur réinitialisation:', error);
+ res.status(500).json({
+ success: false,
+ message: 'Erreur lors de la réinitialisation',
+ error: error.message
+ });
+ }
+ });
+
+ // ============================================================
+ // ENDPOINTS API V2
+ // ============================================================
+
+ app.get('/v2/compteurs', async (req, res) => {
+ try {
+ const result = await pool.request().query(`
+ SELECT
+ cc.Id AS id,
+ cc.CollaborateurADId AS collaborateurId,
+ cc.TypeCongeId AS typeCongeId,
+ CONCAT(c.nom, ' ', c.prenom) AS employe,
+ c.email AS email,
+ c.service AS service,
+ tc.Nom AS typeConge,
+ cc.Annee AS annee,
+ cc.Total AS total,
+ cc.Solde AS solde,
+ cc.SoldeReporte AS soldeReporte,
+ (cc.Total - cc.Solde) AS consomme,
+ c.TypeContrat AS typeContrat,
+ c.role AS role
+ FROM CompteurConges cc
+ INNER JOIN CollaborateurAD c ON cc.CollaborateurADId = c.id
+ INNER JOIN TypeConge tc ON cc.TypeCongeId = tc.Id
+ WHERE (c.description IS NULL OR c.description NOT LIKE '%stagiaire%')
+ ORDER BY c.nom ASC, cc.Annee DESC
+ `);
+
+ return res.json(result.recordset);
+
+ } catch (error) {
+ console.error("❌ Erreur GET compteurs V2 :", error);
+ return res.status(500).json({ message: "Erreur serveur V2" });
+ }
+ });
+
+ app.put('/v2/compteurs/update', async (req, res) => {
+ try {
+ const {
+ collaborateurId,
+ typeConge,
+ annee,
+ nouveauTotal,
+ nouveauSolde,
+ source
+ } = req.body;
+
+ if (!collaborateurId || !typeConge || !annee) {
+ return res.status(400).json({ message: "Champs manquants" });
+ }
+
+ console.log('📝 Modification compteur V2:', { collaborateurId, typeConge, annee, nouveauTotal, nouveauSolde });
+
+ // Mapper le nom exact du type de congé
+ let nomTypeConge;
+ switch (typeConge) {
+ case 'CP':
+ nomTypeConge = 'Congé payé';
+ break;
+ case 'RTT':
+ nomTypeConge = 'RTT';
+ break;
+ case 'Récupération':
+ case 'Recuperation':
+ case 'recup':
+ nomTypeConge = 'Récupération';
+ break;
+ default:
+ nomTypeConge = typeConge;
+ }
+
+ console.log(` Recherche du type: "${nomTypeConge}"`);
+
+ // Trouver l'ID du type de congé
+ const typeRowResult = await pool.request()
+ .input('nomTypeConge', sql.NVarChar, nomTypeConge)
+ .query("SELECT Id FROM TypeConge WHERE Nom = @nomTypeConge");
+
+ const typeRow = typeRowResult.recordset;
+
+ if (typeRow.length === 0) {
+ console.error(`❌ Type de congé "${nomTypeConge}" introuvable`);
+ return res.status(404).json({
+ message: `Type de congé "${nomTypeConge}" introuvable`,
+ nomRecherche: nomTypeConge
+ });
+ }
+
+ const typeCongeId = typeRow[0].Id;
+ console.log(` ✅ Type trouvé - ID: ${typeCongeId}`);
+
+ // Trouver compteur
+ const compResult = await pool.request()
+ .input('collaborateurId', sql.Int, collaborateurId)
+ .input('typeCongeId', sql.Int, typeCongeId)
+ .input('annee', sql.Int, annee)
+ .query(`
+ SELECT Id FROM CompteurConges
+ WHERE CollaborateurADId = @collaborateurId
+ AND TypeCongeId = @typeCongeId
+ AND Annee = @annee
+ `);
+
+ const comp = compResult.recordset;
+
+ if (comp.length === 0) {
+ console.error(`❌ Compteur introuvable pour collaborateur ${collaborateurId}, type ${typeCongeId}, année ${annee}`);
+ return res.status(404).json({
+ message: "Compteur introuvable",
+ details: { collaborateurId, typeCongeId, annee }
+ });
+ }
+
+ // Mise à jour compteur
+ await pool.request()
+ .input('total', sql.Decimal(10, 2), nouveauTotal)
+ .input('solde', sql.Decimal(10, 2), nouveauSolde)
+ .input('id', sql.Int, comp[0].Id)
+ .query('UPDATE CompteurConges SET Total = @total, Solde = @solde, DerniereMiseAJour = GETDATE() WHERE Id = @id');
+
+ console.log(` ✅ Compteur mis à jour - Total: ${nouveauTotal}, Solde: ${nouveauSolde}`);
+
+ // Notifier les clients SSE
+ notifyClients({
+ type: 'compteur-updated',
+ collaborateurId: collaborateurId,
+ typeConge: nomTypeConge,
+ annee: annee,
+ nouveauTotal: nouveauTotal,
+ nouveauSolde: nouveauSolde,
+ source: source || 'RH_UI',
+ timestamp: new Date().toISOString()
+ }, collaborateurId);
+
+ // Envoyer webhook
+ try {
+ if (typeof webhookManager !== 'undefined' && WEBHOOKS && EVENTS) {
+ await webhookManager.sendWebhook(
+ WEBHOOKS.COLLABORATEURS_URL,
+ EVENTS.COMPTEUR_UPDATED,
+ {
+ collaborateurId: collaborateurId,
+ typeConge: nomTypeConge,
+ typeUpdate: 'modification_manuelle_rh',
+ annee: annee,
+ nouveauTotal: nouveauTotal,
+ nouveauSolde: nouveauSolde,
+ source: source || 'RH_UI',
+ timestamp: new Date().toISOString()
+ }
+ );
+ console.log(' ✅ Webhook envoyé au serveur collaborateurs');
+ }
+ } catch (webhookError) {
+ console.error('⚠️ Erreur webhook (non bloquant):', webhookError.message);
+ }
+
+ res.json({
+ success: true,
+ message: "Compteur mis à jour (V2)",
+ details: {
+ typeConge: nomTypeConge,
+ typeCongeId: typeCongeId,
+ nouveauTotal: nouveauTotal,
+ nouveauSolde: nouveauSolde
+ }
+ });
+
+ } catch (err) {
+ console.error("❌ Erreur update compteur V2:", err);
+ res.status(500).json({
+ message: "Erreur serveur",
+ error: err.message
+ });
+ }
+ });
+
+ // ================================================
+ // ROUTE POUR SAISIR UN JPOSF
+ // ================================================
+ app.post('/compteurs/saisir-jposf', authenticateToken, async (req, res) => {
+ const transaction = new sql.Transaction(pool);
+
+ try {
+ console.log('\n📥 ========== DÉBUT SAISIE JPOSF ==========');
+ console.log('📥 Body reçu:', JSON.stringify(req.body, null, 2));
+ console.log('📥 User:', req.user);
+
+ await transaction.begin();
+ console.log('✅ Transaction démarrée');
+
+ // Vérifier les permissions
+ if (!['Admin', 'RH'].includes(req.user.role)) {
+ console.log('❌ Accès refusé - Role:', req.user.role);
+ await transaction.rollback();
+ return res.status(403).json({
+ success: false,
+ error: 'Accès non autorisé',
+ message: 'Seuls les Admin et RH peuvent saisir des JPOSF'
+ });
+ }
+
+ const { collaborateur_id, date, periode_journee, commentaire } = req.body;
+
+ // Validation des données
+ if (!collaborateur_id) {
+ console.log('❌ collaborateur_id manquant');
+ await transaction.rollback();
+ return res.status(400).json({
+ success: false,
+ message: 'ID du collaborateur manquant'
+ });
+ }
+
+ if (!date) {
+ console.log('❌ date manquante');
+ await transaction.rollback();
+ return res.status(400).json({
+ success: false,
+ message: 'Date manquante'
+ });
+ }
+
+ if (!periode_journee) {
+ console.log('❌ periode_journee manquante');
+ await transaction.rollback();
+ return res.status(400).json({
+ success: false,
+ message: 'Période de journée manquante'
+ });
+ }
+
+ console.log('📋 Données validées:', { collaborateur_id, date, periode_journee });
+
+ // ========== ÉTAPE 1: Récupérer le collaborateur ==========
+ console.log('\n🔍 ÉTAPE 1: Récupération du collaborateur...');
+
+ const collabResult = await transaction.request()
+ .input('id', sql.Int, parseInt(collaborateur_id))
+ .query('SELECT id, prenom, nom, email, Actif FROM CollaborateurAD WHERE id = @id');
+
+ console.log(' Résultat:', collabResult.recordset.length, 'enregistrement(s)');
+
+ if (collabResult.recordset.length === 0) {
+ console.log('❌ Collaborateur non trouvé');
+ await transaction.rollback();
+ return res.status(404).json({
+ success: false,
+ message: `Collaborateur ID ${collaborateur_id} non trouvé`
+ });
+ }
+
+ const collab = collabResult.recordset[0];
+ console.log(' ✅ Collaborateur trouvé:', collab.prenom, collab.nom);
+
+ if (collab.Actif === false || collab.Actif === 0) {
+ console.log('❌ Collaborateur inactif');
+ await transaction.rollback();
+ return res.status(403).json({
+ success: false,
+ message: 'Impossible d\'ajouter des jours à un compte désactivé'
+ });
+ }
+
+ const currentYear = new Date().getFullYear();
+ const nombreJours = periode_journee === 'Journée entière' ? 1 : 0.5;
+
+ console.log(' Année:', currentYear);
+ console.log(' Nombre de jours:', nombreJours);
+
+ // ========== ÉTAPE 2: Récupérer les types de congé ==========
+ console.log('\n🔍 ÉTAPE 2: Récupération des types de congé...');
+
+ const jposfTypeResult = await transaction.request()
+ .query("SELECT Id, Nom FROM TypeConge WHERE Nom = 'JPOSF'");
+
+ console.log(' JPOSF trouvé:', jposfTypeResult.recordset.length > 0);
+
+ if (jposfTypeResult.recordset.length === 0) {
+ console.log('❌ Type JPOSF non trouvé dans TypeConge');
+
+ // Lister les types disponibles pour debug
+ const allTypesResult = await transaction.request()
+ .query("SELECT Id, Nom FROM TypeConge WHERE Actif = 1 OR Actif IS NULL");
+ console.log(' Types disponibles:', allTypesResult.recordset.map(t => t.Nom).join(', '));
+
+ await transaction.rollback();
+ return res.status(500).json({
+ success: false,
+ message: 'Type de congé "JPOSF" non trouvé dans la base de données',
+ details: 'Contactez l\'administrateur pour créer ce type de congé'
+ });
+ }
+
+ const recupTypeResult = await transaction.request()
+ .query("SELECT Id, Nom FROM TypeConge WHERE Nom = 'Récupération'");
+
+ console.log(' Récupération trouvé:', recupTypeResult.recordset.length > 0);
+
+ if (recupTypeResult.recordset.length === 0) {
+ console.log('❌ Type Récupération non trouvé dans TypeConge');
+ await transaction.rollback();
+ return res.status(500).json({
+ success: false,
+ message: 'Type de congé "Récupération" non trouvé dans la base de données',
+ details: 'Contactez l\'administrateur pour créer ce type de congé'
+ });
+ }
+
+ const jposfTypeId = jposfTypeResult.recordset[0].Id;
+ const recupTypeId = recupTypeResult.recordset[0].Id;
+
+ console.log(' ✅ JPOSF TypeId:', jposfTypeId);
+ console.log(' ✅ Récupération TypeId:', recupTypeId);
+
+ // ========== ÉTAPE 3: Mettre à jour le compteur Récupération ==========
+ console.log('\n🔍 ÉTAPE 3: Mise à jour du compteur Récupération...');
+
+ const compteurRecupResult = await transaction.request()
+ .input('collaborateurId', sql.Int, parseInt(collaborateur_id))
+ .input('recupTypeId', sql.Int, recupTypeId)
+ .input('currentYear', sql.Int, currentYear)
+ .query(`
+ SELECT Id, Total, Solde FROM CompteurConges
+ WHERE CollaborateurADId = @collaborateurId
+ AND TypeCongeId = @recupTypeId
+ AND Annee = @currentYear
+ `);
+
+ let nouveauTotalRecup, nouveauSoldeRecup;
+
+ if (compteurRecupResult.recordset.length > 0) {
+ const compteur = compteurRecupResult.recordset[0];
+ nouveauTotalRecup = parseFloat(compteur.Total || 0) + nombreJours;
+ nouveauSoldeRecup = parseFloat(compteur.Solde || 0) + nombreJours;
+
+ console.log(' Compteur existant - Ancien Total:', compteur.Total, '-> Nouveau:', nouveauTotalRecup);
+ console.log(' Compteur existant - Ancien Solde:', compteur.Solde, '-> Nouveau:', nouveauSoldeRecup);
+
+ await transaction.request()
+ .input('total', sql.Decimal(10, 2), nouveauTotalRecup)
+ .input('solde', sql.Decimal(10, 2), nouveauSoldeRecup)
+ .input('id', sql.Int, compteur.Id)
+ .query('UPDATE CompteurConges SET Total = @total, Solde = @solde, DerniereMiseAJour = GETDATE() WHERE Id = @id');
+
+ console.log(' ✅ Compteur Récupération mis à jour');
+ } else {
+ nouveauTotalRecup = nombreJours;
+ nouveauSoldeRecup = nombreJours;
+
+ console.log(' Création nouveau compteur - Total:', nouveauTotalRecup, 'Solde:', nouveauSoldeRecup);
+
+ await transaction.request()
+ .input('collaborateurId', sql.Int, parseInt(collaborateur_id))
+ .input('recupTypeId', sql.Int, recupTypeId)
+ .input('currentYear', sql.Int, currentYear)
+ .input('total', sql.Decimal(10, 2), nouveauTotalRecup)
+ .input('solde', sql.Decimal(10, 2), nouveauSoldeRecup)
+ .query(`
+ INSERT INTO CompteurConges
+ (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour)
+ VALUES (@collaborateurId, @recupTypeId, @currentYear, @total, @solde, 0, GETDATE())
+ `);
+
+ console.log(' ✅ Compteur Récupération créé');
+ }
+
+ // ========== ÉTAPE 4: Créer la demande JPOSF ==========
+ console.log('\n🔍 ÉTAPE 4: Création de la demande JPOSF...');
+
+ // Préparer le commentaire
+ const commentaireFinal = commentaire && commentaire.trim()
+ ? commentaire.trim()
+ : `JPOSF - ${periode_journee}`;
+
+ console.log(' Commentaire:', commentaireFinal);
+ console.log(' Date:', date);
+ console.log(' ValidateurId:', req.user.id);
+
+ // ⭐ MÉTHODE ROBUSTE: INSERT puis SELECT séparé
+ try {
+ // D'abord, faire l'INSERT sans OUTPUT
+ await transaction.request()
+ .input('collaborateurId', sql.Int, parseInt(collaborateur_id))
+ .input('dateDebut', sql.Date, date)
+ .input('dateFin', sql.Date, date)
+ .input('commentaire', sql.NVarChar(500), commentaireFinal)
+ .input('totalJours', sql.Decimal(10, 2), nombreJours)
+ .input('validateurId', sql.Int, req.user.id)
+ .query(`
+ INSERT INTO DemandeConge
+ (CollaborateurADId, DateDebut, DateFin, Statut, Commentaire, DateDemande, NombreJours, ValidateurADId, DateValidation)
+ VALUES (@collaborateurId, @dateDebut, @dateFin, 'Validée', @commentaire, GETDATE(), @totalJours, @validateurId, GETDATE())
+ `);
+
+ console.log(' ✅ INSERT DemandeConge réussi');
+
+ // Ensuite, récupérer l'ID avec SCOPE_IDENTITY ou en cherchant le dernier enregistrement
+ const lastIdResult = await transaction.request()
+ .query('SELECT SCOPE_IDENTITY() AS Id');
+
+ let demandeId = lastIdResult.recordset[0]?.Id;
+
+ // Si SCOPE_IDENTITY ne fonctionne pas, fallback
+ if (!demandeId) {
+ console.log(' ⚠️ SCOPE_IDENTITY() a retourné NULL, utilisation du fallback...');
+
+ const fallbackResult = await transaction.request()
+ .input('collaborateurId', sql.Int, parseInt(collaborateur_id))
+ .input('dateDebut', sql.Date, date)
+ .query(`
+ SELECT TOP 1 Id FROM DemandeConge
+ WHERE CollaborateurADId = @collaborateurId
+ AND DateDebut = @dateDebut
+ AND Statut = 'Validée'
+ ORDER BY DateDemande DESC, Id DESC
+ `);
+
+ demandeId = fallbackResult.recordset[0]?.Id;
+ }
+
+ console.log(' DemandeId récupéré:', demandeId);
+
+ if (!demandeId) {
+ throw new Error('Impossible de récupérer l\'ID de la demande créée');
+ }
+
+ // ========== ÉTAPE 5: Lier avec le type JPOSF ==========
+ console.log('\n🔍 ÉTAPE 5: Liaison avec le type JPOSF...');
+
+ await transaction.request()
+ .input('demandeId', sql.Int, demandeId)
+ .input('typeId', sql.Int, jposfTypeId)
+ .input('nombreJours', sql.Decimal(10, 2), nombreJours)
+ .input('periode', sql.NVarChar(50), periode_journee) // ⭐ AJOUTÉ
+ .query('INSERT INTO DemandeCongeType (Id, DemandeCongeId, TypeCongeId, NombreJours, PeriodeJournee) VALUES (@id, @demandeId, @typeId, @nombreJours, @periode)');
+
+
+ console.log(' ✅ DemandeCongeType créé');
+
+ // ========== ÉTAPE 6: Historique ==========
+ console.log('\n🔍 ÉTAPE 6: Enregistrement dans l\'historique...');
+
+ await transaction.request()
+ .input('userId', sql.Int, req.user.id)
+ .input('details', sql.NVarChar(500), `Saisie JPOSF - ${date} (${periode_journee}) : +${nombreJours}j pour ${collab.prenom} ${collab.nom}`)
+ .input('demandeId', sql.Int, demandeId)
+ .query(`
+ INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DemandeCongeId, DateAction)
+ VALUES (@userId, 'Saisie JPOSF', @details, @demandeId, GETDATE())
+ `);
+
+ console.log(' ✅ Historique enregistré');
+
+ // ========== COMMIT ==========
+ await transaction.commit();
+ console.log('\n✅ ========== TRANSACTION COMMIT ==========');
+
+ // ========== Notifications SSE ==========
+ try {
+ notifyClients({
+ type: 'demande-validated',
+ demandeId: demandeId,
+ statut: 'Validée',
+ typeConge: 'JPOSF',
+ couleurHex: '#d946ef',
+ collaborateurId: parseInt(collaborateur_id),
+ timestamp: new Date().toISOString()
+ }, parseInt(collaborateur_id));
+
+ notifyClients({
+ type: 'compteur-updated',
+ collaborateurId: parseInt(collaborateur_id),
+ typeConge: 'Récupération',
+ action: 'saisie_jposf_rh',
+ nouveauSolde: nouveauSoldeRecup,
+ timestamp: new Date().toISOString()
+ }, parseInt(collaborateur_id));
+
+ console.log('✅ Notifications SSE envoyées');
+ } catch (sseError) {
+ console.error('⚠️ Erreur SSE (non bloquant):', sseError.message);
+ }
+
+ // ========== Webhooks ==========
+ try {
+ if (typeof webhookManager !== 'undefined' && WEBHOOKS && EVENTS) {
+ await webhookManager.sendWebhook(
+ WEBHOOKS.COLLABORATEURS_URL,
+ EVENTS.DEMANDE_VALIDATED,
+ {
+ demandeId: demandeId,
+ statut: 'Validée',
+ typeConge: 'JPOSF',
+ collaborateurId: parseInt(collaborateur_id),
+ validateurId: req.user.id
+ }
+ );
+
+ await webhookManager.sendWebhook(
+ WEBHOOKS.COLLABORATEURS_URL,
+ EVENTS.COMPTEUR_UPDATED,
+ {
+ collaborateurId: parseInt(collaborateur_id),
+ typeConge: 'Récupération',
+ typeUpdate: 'saisie_jposf'
+ }
+ );
+ console.log('✅ Webhooks envoyés');
+ }
+ } catch (webhookError) {
+ console.error('⚠️ Erreur webhook (non bloquant):', webhookError.message);
+ }
+
+ // ========== Email ==========
+ try {
+ const accessToken = await getGraphToken();
+ if (accessToken && collab.email) {
+ const fromEmail = 'gtanoreply@ensup.eu';
+ const subject = '📅 JPOSF enregistré';
+ const body = `
+
+
+
📅 JPOSF enregistré
+
+
+
Bonjour ${collab.prenom} ${collab.nom},
+
Une Journée Portes Ouvertes a été enregistrée pour vous :
+
+
Date : ${new Date(date).toLocaleDateString('fr-FR')}
+
Période : ${periode_journee}
+
Jours ajoutés : ${nombreJours}j
+ ${commentaire ? `
Commentaire : ${commentaire}
` : ''}
+
+
✅ +${nombreJours}j ont été ajoutés à votre compteur de récupération
+
+
+ `;
+
+ await sendMailGraph(accessToken, fromEmail, collab.email, subject, body);
+ console.log('✅ Email envoyé à', collab.email);
+ }
+ } catch (emailError) {
+ console.error('⚠️ Erreur email (non bloquant):', emailError.message);
+ }
+
+ // ========== Réponse succès ==========
+ console.log('\n✅ ========== JPOSF ENREGISTRÉ AVEC SUCCÈS ==========\n');
+
+ return res.json({
+ success: true,
+ message: 'JPOSF enregistré avec succès',
+ collaborateur: `${collab.prenom} ${collab.nom}`,
+ date: date,
+ periode: periode_journee,
+ jours_ajoutes: nombreJours,
+ nouveau_solde_recup: nouveauSoldeRecup,
+ demandeId: demandeId
+ });
+
+ } catch (insertError) {
+ console.error('\n❌ ERREUR lors de l\'INSERT DemandeConge:', insertError.message);
+ console.error(' Stack:', insertError.stack);
+ throw insertError;
+ }
+
+ } catch (error) {
+ console.error('\n❌ ========== ERREUR SAISIE JPOSF ==========');
+ console.error('Message:', error.message);
+ console.error('Stack:', error.stack);
+
+ try {
+ await transaction.rollback();
+ console.log('✅ Transaction rollback effectué');
+ } catch (rollbackError) {
+ console.error('⚠️ Erreur rollback:', rollbackError.message);
+ }
+
+ return res.status(500).json({
+ success: false,
+ message: 'Erreur serveur lors de la saisie du JPOSF',
+ error: error.message,
+ details: process.env.NODE_ENV === 'development' ? error.stack : undefined
+ });
+ }
+ });
+
+ // ✅ CORRECTION COMPLÈTE - Route /getAllDetailedCounters
+ app.get('/getAllDetailedCounters', async (req, res) => {
+ try {
+ console.log('📊 Récupération de TOUS les compteurs détaillés');
+
+ const today = new Date();
+ const currentYearSys = today.getFullYear(); // 2026
+
+ // ── Détection bascule CP ──────────────────────────────────────────
+ const cpTypeForCheck = await pool.request()
+ .query("SELECT TOP 1 Id FROM TypeConge WHERE Nom = 'Congé payé'");
+
+ let currentYear = currentYearSys; // CP N (2026 ou 2027 après bascule)
+ let previousYear = currentYearSys - 1; // CP N-1
+
+ if (cpTypeForCheck.recordset.length > 0) {
+ const cpTypeId = cpTypeForCheck.recordset[0].Id;
+ const checkBascule = await pool.request()
+ .input('cpTypeId', sql.Int, cpTypeId)
+ .input('anneeN1', sql.Int, currentYearSys + 1)
+ .query(`
+ SELECT TOP 1 Id FROM CompteurConges
+ WHERE TypeCongeId = @cpTypeId
+ AND Annee = @anneeN1
+ AND SoldeReporte > 0
+ `);
+
+ if (checkBascule.recordset.length > 0) {
+ currentYear = currentYearSys + 1; // 2027
+ previousYear = currentYearSys; // 2026
+ console.log('✅ Bascule CP détectée — currentYear=2027, previousYear=2026');
+ }
+ }
+
+ // RTT et Récup : toujours l'année civile système (jamais basculent)
+ const anneeRTT = currentYearSys; // 2026
+ const anneeRecup = currentYearSys; // 2026
+
+ console.log(`📅 CP N=${currentYear}, CP N-1=${previousYear}, RTT=${anneeRTT}, Récup=${anneeRecup}`);
+
+ const typesCongeResult = await pool.request().query(`
+ SELECT Id, Nom FROM TypeConge WHERE Nom IN ('Congé payé', 'RTT', 'Récupération')
+ `);
+
+ const typeCongeMap = {};
+ typesCongeResult.recordset.forEach(tc => {
+ typeCongeMap[tc.Nom] = tc.Id;
+ });
+
+ const collaborateursResult = await pool.request().query(`
+ SELECT
+ ca.id,
+ ca.prenom,
+ ca.nom,
+ ca.email,
+ ca.role,
+ ca.TypeContrat,
+ ca.DateEntree,
+ COALESCE(s.Nom, 'Non assigné') as service,
+ s.Id as serviceId,
+ ca.CampusId as campusId,
+ COALESCE(camp.Nom, 'Sans campus') as campusNom,
+ ca.SocieteId as societeId,
+ COALESCE(soc.Nom, 'Sans société') as societe
+ FROM CollaborateurAD ca
+ LEFT JOIN Services s ON ca.ServiceId = s.Id
+ LEFT JOIN Campus camp ON ca.CampusId = camp.Id
+ LEFT JOIN Societe soc ON ca.SocieteId = soc.Id
+ WHERE (ca.Actif = 1 OR ca.Actif IS NULL)
+ AND (ca.description IS NULL OR ca.description NOT LIKE '%stagiaire%')
+ ORDER BY soc.Nom, camp.Nom, s.Nom, ca.nom, ca.prenom
+ `);
+
+ const collaborateurs = collaborateursResult.recordset;
+ const resultats = [];
+
+ console.log(`📋 ${collaborateurs.length} collaborateurs trouvés`);
+
+ for (const collab of collaborateurs) {
+ const campusNom = collab.campusNom;
+
+ // ==================== CP N ====================
+ if (typeCongeMap['Congé payé']) {
+ const cpNResult = await pool.request()
+ .input('collabId', sql.Int, collab.id)
+ .input('typeId', sql.Int, typeCongeMap['Congé payé'])
+ .input('annee', sql.Int, currentYear)
+ .query(`
+ SELECT Total, Solde
+ FROM CompteurConges
+ WHERE CollaborateurADId = @collabId
+ AND TypeCongeId = @typeId
+ AND Annee = @annee
+ `);
+
+ const cpN = cpNResult.recordset;
+
+ let cpTotalN, cpSoldeN;
+ if (cpN.length > 0) {
+ cpTotalN = parseFloat(cpN[0].Total);
+ cpSoldeN = parseFloat(cpN[0].Solde);
+ } else {
+ // Après bascule, si pas de ligne en base → 0
+ // Avant bascule, on calcule l'acquisition
+ if (currentYear > currentYearSys) {
+ cpTotalN = 0;
+ cpSoldeN = 0;
+ } else {
+ cpTotalN = calculerAcquisitionCP(new Date(), collab.DateEntree);
+ const consommeResult = await pool.request()
+ .input('collabId', sql.Int, collab.id)
+ .input('typeId', sql.Int, typeCongeMap['Congé payé'])
+ .input('annee', sql.Int, currentYear)
+ .query(`
+ SELECT COALESCE(SUM(JoursUtilises), 0) AS total
+ FROM DeductionDetails dd
+ JOIN DemandeConge dc ON dc.Id = dd.DemandeCongeId
+ WHERE dc.CollaborateurADId = @collabId
+ AND dd.TypeCongeId = @typeId
+ AND dd.Annee = @annee
+ AND dd.TypeDeduction NOT IN ('Accum. Récup', 'N Anticipé')
+ AND dc.Statut != 'Refusé'
+ `);
+ cpSoldeN = Math.max(0, cpTotalN - consommeResult.recordset[0].total);
+ }
+ }
+
+ // ⭐ PUSH CP N (était manquant — cause du bug d'affichage)
+ resultats.push({
+ collaborateurId: collab.id,
+ employe: `${collab.prenom} ${collab.nom}`,
+ email: collab.email,
+ service: collab.service,
+ campus: campusNom,
+ societe: collab.societe,
+ typeCongeId: typeCongeMap['Congé payé'],
+ typeConge: 'Congé payé',
+ annee: currentYear,
+ total: parseFloat(cpTotalN.toFixed(2)),
+ solde: parseFloat(cpSoldeN.toFixed(2)),
+ consomme: parseFloat((cpTotalN - cpSoldeN).toFixed(2)),
+ role: collab.role,
+ typeContrat: collab.TypeContrat,
+ estApprenti: collab.role === 'Apprenti' || collab.TypeContrat === 'Apprentissage'
+ });
+
+ // ==================== CP N-1 ====================
+ const cpN1Result = await pool.request()
+ .input('collabId', sql.Int, collab.id)
+ .input('typeId', sql.Int, typeCongeMap['Congé payé'])
+ .input('annee', sql.Int, previousYear)
+ .query(`
+ SELECT Total, Solde
+ FROM CompteurConges
+ WHERE CollaborateurADId = @collabId
+ AND TypeCongeId = @typeId
+ AND Annee = @annee
+ `);
+
+ const cpN1 = cpN1Result.recordset;
+
+ if (cpN1.length > 0 && cpN1[0].Solde > 0) {
+ const total = parseFloat(cpN1[0].Total);
+ const solde = parseFloat(cpN1[0].Solde);
+ resultats.push({
+ collaborateurId: collab.id,
+ employe: `${collab.prenom} ${collab.nom}`,
+ email: collab.email,
+ service: collab.service,
+ campus: campusNom,
+ societe: collab.societe,
+ typeCongeId: typeCongeMap['Congé payé'],
+ typeConge: 'Congé payé',
+ annee: previousYear,
+ total,
+ solde,
+ consomme: parseFloat((total - solde).toFixed(2)),
+ role: collab.role,
+ typeContrat: collab.TypeContrat,
+ estApprenti: collab.role === 'Apprenti' || collab.TypeContrat === 'Apprentissage'
+ });
+ }
+ }
+
+ // ==================== RTT — toujours anneeRTT (2026) ====================
+ if (typeCongeMap['RTT'] && collab.role !== 'Apprenti' && collab.TypeContrat !== 'Apprentissage') {
+ const rttNResult = await pool.request()
+ .input('collabId', sql.Int, collab.id)
+ .input('typeId', sql.Int, typeCongeMap['RTT'])
+ .input('annee', sql.Int, anneeRTT)
+ .query(`
+ SELECT Total, Solde
+ FROM CompteurConges
+ WHERE CollaborateurADId = @collabId
+ AND TypeCongeId = @typeId
+ AND Annee = @annee
+ `);
+
+ const rttN = rttNResult.recordset;
+
+ let rttTotalN, rttSoldeN;
+ if (rttN.length > 0) {
+ rttTotalN = parseFloat(rttN[0].Total);
+ rttSoldeN = parseFloat(rttN[0].Solde);
+ } else {
+ const rtt = await calculerAcquisitionRTT(collab.id, new Date());
+ const consommeRTTResult = await pool.request()
+ .input('collabId', sql.Int, collab.id)
+ .input('typeId', sql.Int, typeCongeMap['RTT'])
+ .input('annee', sql.Int, anneeRTT)
+ .query(`
+ SELECT COALESCE(SUM(JoursUtilises), 0) AS total
+ FROM DeductionDetails dd
+ JOIN DemandeConge dc ON dc.Id = dd.DemandeCongeId
+ WHERE dc.CollaborateurADId = @collabId
+ AND dd.TypeCongeId = @typeId
+ AND dd.Annee = @annee
+ AND dd.TypeDeduction NOT IN ('Accum. Récup', 'Récup Dosée')
+ AND dc.Statut != 'Refusé'
+ `);
+ rttTotalN = rtt.acquisition;
+ rttSoldeN = Math.max(0, rttTotalN - consommeRTTResult.recordset[0].total);
+ }
+
+ resultats.push({
+ collaborateurId: collab.id,
+ employe: `${collab.prenom} ${collab.nom}`,
+ email: collab.email,
+ service: collab.service,
+ campus: campusNom,
+ societe: collab.societe,
+ typeCongeId: typeCongeMap['RTT'],
+ typeConge: 'RTT',
+ annee: anneeRTT,
+ total: parseFloat(rttTotalN.toFixed(2)),
+ solde: parseFloat(rttSoldeN.toFixed(2)),
+ consomme: parseFloat((rttTotalN - rttSoldeN).toFixed(2)),
+ role: collab.role,
+ typeContrat: collab.TypeContrat,
+ estApprenti: collab.role === 'Apprenti' || collab.TypeContrat === 'Apprentissage'
+ });
+ }
+
+ // ==================== Récupération — toujours anneeRecup (2026) ====================
+ if (typeCongeMap['Récupération']) {
+ const recupNResult = await pool.request()
+ .input('collabId', sql.Int, collab.id)
+ .input('typeId', sql.Int, typeCongeMap['Récupération'])
+ .input('annee', sql.Int, anneeRecup)
+ .query(`
+ SELECT Total, Solde
+ FROM CompteurConges
+ WHERE CollaborateurADId = @collabId
+ AND TypeCongeId = @typeId
+ AND Annee = @annee
+ `);
+
+ const recupN = recupNResult.recordset;
+
+ if (recupN.length > 0) {
+ const recupTotal = parseFloat(recupN[0].Total);
+ const recupSolde = parseFloat(recupN[0].Solde);
+
+ resultats.push({
+ collaborateurId: collab.id,
+ employe: `${collab.prenom} ${collab.nom}`,
+ email: collab.email,
+ service: collab.service,
+ campus: campusNom,
+ societe: collab.societe,
+ typeCongeId: typeCongeMap['Récupération'],
+ typeConge: 'Récupération',
+ annee: anneeRecup,
+ total: recupTotal,
+ solde: recupSolde,
+ consomme: parseFloat((recupTotal - recupSolde).toFixed(2)),
+ role: collab.role,
+ typeContrat: collab.TypeContrat,
+ estApprenti: collab.role === 'Apprenti' || collab.TypeContrat === 'Apprentissage'
+ });
+ }
+ }
+ }
+
+ console.log(`✅ ${resultats.length} compteurs détaillés générés`);
+ res.json(resultats);
+ } catch (err) {
+ console.error('❌ Erreur GET ALL:', err);
+ res.status(500).json({ error: err.message });
+ }
+ });
+
+
+
+ // ================================================
+ // ROUTES POUR LA GESTION DES COLLABORATEURS - Adaptées à votre structure BDD
+ // ================================================
+
+ // GET /api/collaborateurs/details - Liste complète des collaborateurs avec toutes leurs infos
+ app.get('/collaborateurs/details', authenticateToken, async (req, res) => {
+ try {
+ if (!['Admin', 'RH', 'Validateur'].includes(req.user.role)) {
+ return res.status(403).json({ error: 'Accès non autorisé' });
+ }
+
+ const currentYear = new Date().getFullYear();
+ const previousYear = currentYear - 1;
+
+ const result = await pool.request()
+ .input('currentYear', sql.Int, currentYear)
+ .input('previousYear', sql.Int, previousYear)
+ .query(`
+ SELECT
+ ca.id,
+ ca.nom,
+ ca.prenom,
+ ca.email,
+ ca.fonction as poste,
+ ca.TypeContrat as typeContrat,
+ ca.DateEntree as dateEntree,
+ ca.actif as Actif,
+ ca.description,
+
+ -- Société
+ COALESCE(soc.Nom, 'Sans société') as societe,
+ ca.SocieteId as societeId,
+
+ -- Campus
+ COALESCE(camp.Nom, 'Sans campus') as campus,
+ ca.CampusId as campusId,
+
+ -- Service
+ COALESCE(serv.Nom, 'Sans service') as service,
+ ca.ServiceId as serviceId,
+
+ -- Hiérarchie - ✅ CORRECTION : Utiliser [SuperieurIdn+2] avec crochets
+ hv.SuperieurId as nPlus1Id,
+ CONCAT(n1.prenom, ' ', n1.nom) as nPlus1Nom,
+ hv.[SuperieurIdn+2] as nPlus2Id, -- ✅ CROCHETS pour le +
+ CONCAT(n2.prenom, ' ', n2.nom) as nPlus2Nom,
+ NULL as nPlus3Id,
+ NULL as nPlus3Nom,
+
+ -- Compteurs
+ compteurs.soldeCPN,
+ compteurs.soldeCPNMoins1,
+ compteurs.soldeRTT,
+ compteurs.soldeRecup
+
+ FROM CollaborateurAD ca
+ LEFT JOIN Societe soc ON ca.SocieteId = soc.Id
+ LEFT JOIN Campus camp ON ca.CampusId = camp.Id
+ LEFT JOIN Services serv ON ca.ServiceId = serv.Id
+ LEFT JOIN HierarchieValidationAD hv ON ca.id = hv.CollaborateurId
+ LEFT JOIN CollaborateurAD n1 ON hv.SuperieurId = n1.id
+ LEFT JOIN CollaborateurAD n2 ON hv.[SuperieurIdn+2] = n2.id -- ✅ CROCHETS
+ OUTER APPLY (
+ SELECT
+ MAX(CASE WHEN tc.Nom = 'Congé payé' AND cc.Annee = @currentYear THEN cc.Solde END) as soldeCPN,
+ MAX(CASE WHEN tc.Nom = 'Congé payé' AND cc.Annee = @previousYear THEN cc.Solde END) as soldeCPNMoins1,
+ MAX(CASE WHEN tc.Nom = 'RTT' AND cc.Annee = @currentYear THEN cc.Solde END) as soldeRTT,
+ MAX(CASE WHEN tc.Nom = 'Récupération' AND cc.Annee = @currentYear THEN cc.Solde END) as soldeRecup
+ FROM CompteurConges cc
+ JOIN TypeConge tc ON cc.TypeCongeId = tc.Id
+ WHERE cc.CollaborateurADId = ca.id
+ AND tc.Nom IN ('Congé payé', 'RTT', 'Récupération')
+ AND cc.Annee IN (@currentYear, @previousYear)
+ ) compteurs
+ WHERE ca.actif = 1 OR ca.actif IS NULL
+ ORDER BY soc.Nom, camp.Nom, serv.Nom, ca.nom, ca.prenom
+ `);
+
+ const collaborateurs = result.recordset.map(row => ({
+ ...row,
+ soldeCPN: row.soldeCPN || 0,
+ soldeCPNMoins1: row.soldeCPNMoins1 || 0,
+ soldeRTT: row.soldeRTT || 0,
+ soldeRecup: row.soldeRecup || 0
+ }));
+
+ console.log(`✅ ${collaborateurs.length} collaborateurs récupérés avec détails`);
+ res.json(collaborateurs);
+ } catch (error) {
+ console.error('❌ Erreur /collaborateurs/details:', error);
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+
+
+ // GET /api/societes - Liste des sociétés
+ app.get('/societes', authenticateToken, async (req, res) => {
+ try {
+ const result = await pool.request().query(`
+ SELECT Id as id, Nom as nom
+ FROM Societe
+ ORDER BY Nom
+ `);
+ res.json(result.recordset);
+ } catch (error) {
+ console.error('❌ Erreur /societes:', error);
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+ const CAMPUS_MAPPING = {
+ 1: 'Cergy',
+ 2: 'Saint-Quentin-en-Yvelines',
+ 3: 'Marseille',
+ 4: 'Nantes'
+ // Ajouter les autres campus selon tes besoins
+ };
+
+ // GET /api/campus - Liste des campus
+ app.get('/campus', authenticateToken, async (req, res) => {
+ try {
+ const result = await pool.request().query(`
+ SELECT Id as id, Nom as nom
+ FROM Campus
+ ORDER BY Nom
+ `);
+
+ console.log('✅ Campus récupérés:', result.recordset);
+ res.json(result.recordset);
+ } catch (error) {
+ console.error('❌ Erreur /campus:', error);
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+
+
+
+ // GET /api/services - Liste des services
+ app.get('/services', authenticateToken, async (req, res) => {
+ try {
+ const result = await pool.request().query(`
+ SELECT Id as id, Nom as nom
+ FROM Services
+ ORDER BY Nom
+ `);
+ res.json(result.recordset);
+ } catch (error) {
+ console.error('❌ Erreur /services:', error);
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+
+ // PUT /api/collaborateurs/:id - Modifier un collaborateur
+ app.put('/collaborateurs/:id', authenticateToken, async (req, res) => {
+ const transaction = new sql.Transaction(pool);
+ try {
+ await transaction.begin();
+
+ if (!['Admin', 'RH'].includes(req.user.role)) {
+ await transaction.rollback();
+ return res.status(403).json({ error: 'Accès non autorisé' });
+ }
+
+ const { id } = req.params;
+ const {
+ nom,
+ prenom,
+ email,
+ societeId,
+ campusId,
+ serviceId,
+ poste,
+ typeContrat,
+ dateEntree,
+ nPlus1Id,
+ nPlus2Id,
+ description
+ } = req.body;
+
+ console.log('Modification collaborateur', id, req.body);
+
+ // Vérifier que le collaborateur existe
+ const existingResult = await transaction.request()
+ .input('id', sql.Int, id)
+ .query(`SELECT id, prenom, nom FROM CollaborateurAD WHERE id = @id`);
+
+ if (existingResult.recordset.length === 0) {
+ await transaction.rollback();
+ return res.status(404).json({ error: 'Collaborateur non trouvé' });
+ }
+
+ // Mise à jour de CollaborateurAD
+ await transaction.request()
+ .input('id', sql.Int, id)
+ .input('nom', sql.NVarChar(100), nom)
+ .input('prenom', sql.NVarChar(100), prenom)
+ .input('email', sql.NVarChar(255), email)
+ .input('societeId', sql.Int, societeId)
+ .input('campusId', sql.Int, campusId)
+ .input('serviceId', sql.Int, serviceId)
+ .input('fonction', sql.NVarChar(255), poste)
+ .input('typeContrat', sql.NVarChar(50), typeContrat)
+ .input('dateEntree', sql.Date, dateEntree || null)
+ .input('description', sql.NVarChar(500), description || null)
+ .query(`
+ UPDATE CollaborateurAD
+ SET nom = @nom,
+ prenom = @prenom,
+ email = @email,
+ SocieteId = @societeId,
+ CampusId = @campusId,
+ ServiceId = @serviceId,
+ fonction = @fonction,
+ TypeContrat = @typeContrat,
+ DateEntree = @dateEntree,
+ description = @description
+ WHERE id = @id
+ `);
+
+ // Mise à jour de la hiérarchie - ✅ CORRECTION avec crochets
+ const hierarchieExistResult = await transaction.request()
+ .input('collaborateurId', sql.Int, id)
+ .query(`SELECT CollaborateurId FROM HierarchieValidationAD WHERE CollaborateurId = @collaborateurId`);
+
+ if (hierarchieExistResult.recordset.length > 0) {
+ // UPDATE
+ await transaction.request()
+ .input('collaborateurId', sql.Int, id)
+ .input('superieurId', sql.Int, nPlus1Id)
+ .input('superieurIdN2', sql.Int, nPlus2Id)
+ .query(`
+ UPDATE HierarchieValidationAD
+ SET SuperieurId = @superieurId,
+ [SuperieurIdn+2] = @superieurIdN2
+ WHERE CollaborateurId = @collaborateurId
+ `);
+ } else {
+ // INSERT
+ if (nPlus1Id || nPlus2Id) {
+ await transaction.request()
+ .input('collaborateurId', sql.Int, id)
+ .input('superieurId', sql.Int, nPlus1Id)
+ .input('superieurIdN2', sql.Int, nPlus2Id)
+ .query(`
+ INSERT INTO HierarchieValidationAD (CollaborateurId, SuperieurId, [SuperieurIdn+2])
+ VALUES (@collaborateurId, @superieurId, @superieurIdN2)
+ `);
+ }
+ }
+
+ // Enregistrer dans l'historique
+ try {
+ await transaction.request()
+ .input('userId', sql.Int, req.user.id)
+ .input('details', sql.NVarChar(500), `Modification des informations de ${prenom} ${nom}`)
+ .query(`
+ INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DateAction)
+ VALUES (@userId, 'Modification collaborateur', @details, GETDATE())
+ `);
+ } catch (histError) {
+ console.warn('Erreur historique non bloquant:', histError.message);
+ }
+
+ await transaction.commit();
+ console.log(`✅ Collaborateur ${id} mis à jour`);
+ res.json({ success: true, message: 'Collaborateur mis à jour avec succès' });
+
+ } catch (error) {
+ await transaction.rollback();
+ console.error('❌ Erreur PUT /collaborateurs/:id:', error);
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+ // GET /api/snapshot/historique/:employeeId
+ app.get('/snapshot/historique/:employeeId', authenticateToken, async (req, res) => {
+ try {
+ const { employeeId } = req.params;
+ const { typeCongeId, typeConge, annee } = req.query;
+
+ const request = pool.request()
+ .input('employeeId', sql.Int, parseInt(employeeId));
+
+ let where = 'WHERE s.CollaborateurADId = @employeeId';
+
+ // ⭐ Filtrer par ID ou par nom selon ce qui est fourni
+ if (typeCongeId) {
+ request.input('typeCongeId', sql.Int, parseInt(typeCongeId));
+ where += ' AND s.TypeCongeId = @typeCongeId';
+ } else if (typeConge) {
+ request.input('typeCongeNom', sql.NVarChar, typeConge);
+ where += ' AND tc.Nom = @typeCongeNom';
+ }
+
+ if (annee) {
+ request.input('annee', sql.Int, parseInt(annee));
+ where += ' AND s.Annee = @annee';
+ }
+
+ const result = await request.query(`
+ SELECT
+ s.DateSnapshot,
+ s.Solde,
+ s.SoldeAnticipe,
+ s.Total,
+ s.SoldeReporte,
+ ISNULL(s.IsAnticipe, 0) AS IsAnticipe,
+ tc.Nom AS typeConge,
+ s.Annee,
+ -- ⭐ Récupérer SoldeAnticipe depuis CompteurConges (valeur actuelle)
+ ISNULL(cc.SoldeAnticipe, 0) AS SoldeAnticipeActuel
+ FROM SnapshotQuotidienCompteurs s
+ JOIN TypeConge tc ON s.TypeCongeId = tc.Id
+ LEFT JOIN CompteurConges cc
+ ON cc.CollaborateurADId = s.CollaborateurADId
+ AND cc.TypeCongeId = s.TypeCongeId
+ AND cc.Annee = s.Annee
+ ${where}
+ ORDER BY s.DateSnapshot ASC
+`);
+ res.json(result.recordset || []);
+
+ } catch (error) {
+ console.error('❌ Erreur snapshot historique:', error);
+ res.status(500).json({ error: error.message });
+ }
+ });
+
+ // ================================================
+ // ⭐ ROUTE GET /soldes-previsionnel
+ // Calcule les soldes CP et RTT projetés à la date de début du congé,
+ // en lisant DeductionDetails exactement comme serverGTA.
+ // Gère NORMAL et ANTICIPÉ (CP + RTT).
+ // Usage: GET /api/soldes-previsionnel?userid=X&dateDebut=YYYY-MM-DD&joursCP=N&joursRTT=N
+ // ================================================
+ app.get('/soldes-previsionnel', authenticateToken, async (req, res) => {
+ const { userid, dateDebut, joursCP, joursRTT } = req.query;
+ if (!userid || !dateDebut) {
+ return res.json({ success: false, message: 'Paramètres requis: userid, dateDebut' });
+ }
+ const userIdInt = parseInt(userid, 10);
+ if (isNaN(userIdInt)) return res.json({ success: false, message: 'userid invalide' });
+
+ try {
+ const today = new Date(); today.setHours(0, 0, 0, 0);
+ const currentYear = today.getFullYear();
+ const dateDebutObj = new Date(dateDebut); dateDebutObj.setHours(0, 0, 0, 0);
+ const moisDebut = dateDebutObj.getMonth() + 1;
+ const anneeDebut = dateDebutObj.getFullYear();
+
+ // ── Infos collaborateur ────────────────────────────────────────
+ const collabRes = await pool.request()
+ .input('userId', sql.Int, userIdInt)
+ .query(`SELECT TOP 1 role, DateEntree, TypeContrat FROM CollaborateurAD WHERE id=@userId`);
+ if (!collabRes.recordset.length) throw new Error('Collaborateur introuvable');
+ const isApprenti = collabRes.recordset[0].role === 'Apprenti';
+ const dateEntree = collabRes.recordset[0].DateEntree ?? null;
+ const typeContrat = collabRes.recordset[0].TypeContrat ?? '37h';
+
+ // ── Exercice CP (Annee = année de FIN d'exercice) ─────────────
+ const dateBasculeCP = new Date(currentYear, 5, 1); dateBasculeCP.setHours(0, 0, 0, 0);
+ const apresBasculement = today >= dateBasculeCP;
+ const anneeN = apresBasculement ? currentYear + 1 : currentYear;
+ const anneeN1 = anneeN - 1;
+ const anneeExConge = moisDebut >= 6 ? anneeDebut + 1 : anneeDebut;
+ const isAnticipe = anneeExConge > anneeN;
+
+ // ── Exercice RTT (année civile) ────────────────────────────────
+ const anneeRTT = anneeDebut;
+ const isAnticipeRTT = anneeRTT > currentYear;
+
+ // ── TypeConge IDs ──────────────────────────────────────────────
+ const cpTypeRes = await pool.request()
+ .input('nom', sql.NVarChar, 'Congé payé')
+ .query(`SELECT TOP 1 Id FROM TypeConge WHERE Nom=@nom`);
+ if (!cpTypeRes.recordset.length) throw new Error('Type CP introuvable');
+ const cpTypeId = cpTypeRes.recordset[0].Id;
+
+ const rttTypeRes = await pool.request()
+ .input('nom', sql.NVarChar, 'RTT')
+ .query(`SELECT TOP 1 Id FROM TypeConge WHERE Nom=@nom`);
+ const rttTypeId = rttTypeRes.recordset[0]?.Id ?? null;
+
+ // ── Helper : lire compteur CP ──────────────────────────────────
+ const lireCompteurCP = async (annee) => {
+ const r = await pool.request()
+ .input('userId', sql.Int, userIdInt)
+ .input('typeId', sql.Int, cpTypeId)
+ .input('annee', sql.Int, annee)
+ .query(`SELECT TOP 1 CAST(COALESCE(Solde,0) AS FLOAT) AS Solde, CAST(COALESCE(SoldeReporte,0) AS FLOAT) AS SoldeReporte, CAST(COALESCE(Total,0) AS FLOAT) AS Total FROM CompteurConges WHERE CollaborateurADId=@userId AND TypeCongeId=@typeId AND Annee=@annee ORDER BY Id DESC`);
+ if (!r.recordset.length) return null;
+ const row = r.recordset[0];
+ const solde = parseFloat(row.Solde);
+ const report = parseFloat(row.SoldeReporte);
+ return { solde, reporte: report, total: parseFloat(row.Total), dispoN1: Math.max(0, solde), dispoN: Math.max(0, solde - report) };
+ };
+
+ // ── Helper : lire compteur RTT ─────────────────────────────────
+ const lireCompteurRTT = async (annee) => {
+ if (!rttTypeId) return null;
+ const r = await pool.request()
+ .input('userId', sql.Int, userIdInt)
+ .input('typeId', sql.Int, rttTypeId)
+ .input('annee', sql.Int, annee)
+ .query(`SELECT TOP 1 CAST(COALESCE(Solde,0) AS FLOAT) AS Solde, CAST(COALESCE(Total,0) AS FLOAT) AS Total FROM CompteurConges WHERE CollaborateurADId=@userId AND TypeCongeId=@typeId AND Annee=@annee ORDER BY Id DESC`);
+ if (!r.recordset.length) return null;
+ return { solde: parseFloat(r.recordset[0].Solde), total: parseFloat(r.recordset[0].Total) };
+ };
+
+ // ── Compteurs CP ───────────────────────────────────────────────
+ const cptN1 = await lireCompteurCP(anneeN1);
+ const cptN = await lireCompteurCP(anneeN);
+
+ const soldeNActuel = cptN ? Math.max(0, parseFloat(cptN.solde || 0)) : 0;
+ const soldeN1Actuel = cptN1 ? Math.max(0, parseFloat(cptN1.solde || 0)) : 0;
+
+ // ── Déductions futures sur CP N (après dateDebutObj) ──────────
+ const dejaConsoFuturNRes = await pool.request()
+ .input('userId', sql.Int, userIdInt)
+ .input('typeId', sql.Int, cpTypeId)
+ .input('annee', sql.Int, anneeN)
+ .input('dateDebut', sql.Date, dateDebutObj) // ← dateDebutObj au lieu de today
+ .query(`
+ SELECT CAST(COALESCE(SUM(dd.JoursUtilises),0) AS FLOAT) AS total
+ FROM DeductionDetails dd
+ JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
+ WHERE dc.CollaborateurADId = @userId
+ AND dd.TypeCongeId = @typeId
+ AND dd.Annee = @annee
+ AND dc.DateDebut > @dateDebut
+ AND dc.Statut NOT IN ('Refusé','Refusée','Annulé','Annulée')
+ `);
+ const dejaConsoFuturN = parseFloat(dejaConsoFuturNRes.recordset[0]?.total ?? 0);
+
+ // ── Déductions futures sur CP N-1 (après dateDebutObj) ────────
+ const dejaConsoFuturN1Res = await pool.request()
+ .input('userId', sql.Int, userIdInt)
+ .input('typeId', sql.Int, cpTypeId)
+ .input('annee', sql.Int, anneeN1)
+ .input('dateDebut', sql.Date, dateDebutObj) // ← dateDebutObj au lieu de today
+ .query(`
+ SELECT CAST(COALESCE(SUM(dd.JoursUtilises),0) AS FLOAT) AS total
+ FROM DeductionDetails dd
+ JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
+ WHERE dc.CollaborateurADId = @userId
+ AND dd.TypeCongeId = @typeId
+ AND dd.Annee = @annee
+ AND dc.DateDebut > @dateDebut
+ AND dc.Statut NOT IN ('Refusé','Refusée','Annulé','Annulée')
+ `);
+ const dejaConsoFuturN1 = parseFloat(dejaConsoFuturN1Res.recordset[0]?.total ?? 0);
+
+ // ── Soldes disponibles ─────────────────────────────────────────
+ const cpN1Dispo = Math.max(0, soldeN1Actuel - dejaConsoFuturN1);
+ const cpNAcquisAujourdHui = Math.max(0, soldeNActuel - dejaConsoFuturN);
+
+ let totalCPDisponible;
+ let cpNAnticipeDispo = 0;
+ let acquisCPADateDebut = 0;
+ let cpSoldeADateDebut = 0;
+
+ if (!isAnticipe) {
+ // ── MODE NORMAL : calculerAcquisitionFormuleExcel au jour près ──
+ const acquisADateDebut = calculerAcquisitionFormuleExcel(
+ today,
+ dateDebutObj,
+ 25 / 12
+ );
+
+ const soldeNALaDate = parseFloat(
+ Math.min(25, soldeNActuel + acquisADateDebut).toFixed(2)
+ );
+
+ acquisCPADateDebut = soldeNALaDate;
+ cpSoldeADateDebut = Math.max(0, soldeNALaDate - dejaConsoFuturN);
+ cpNAnticipeDispo = Math.max(0, cpSoldeADateDebut - cpNAcquisAujourdHui);
+
+ totalCPDisponible = parseFloat(
+ Math.max(0, cpN1Dispo + cpSoldeADateDebut).toFixed(2)
+ );
+
+ console.log(`🔍 CP NORMAL: solde actuel=${soldeNActuel}j + acq futures=${acquisADateDebut.toFixed(2)}j = à date ${soldeNALaDate}j - conso future=${dejaConsoFuturN}j = ${cpSoldeADateDebut}j | N-1=${cpN1Dispo}j | Total=${totalCPDisponible}j`);
+
+ } else {
+ // ── MODE ANTICIPÉ ────────────────────────────────────────────
+ // Fin exercice N = 31/05/currentYear (toujours l'année civile courante)
+ const finExN = new Date(currentYear, 4, 31);
+ finExN.setHours(23, 59, 59, 0);
+ const debutNp1 = new Date(currentYear, 5, 1); // 01/06/currentYear
+ debutNp1.setHours(0, 0, 0, 0);
+
+ // Acquisitions depuis aujourd'hui jusqu'au 31/05 (peut être 0 si passé)
+ const acquisJusquAuFinExN = today <= finExN
+ ? calculerAcquisitionFormuleExcel(today, finExN, 25 / 12)
+ : 0;
+
+ const soldeNAFinEx = parseFloat(
+ Math.min(25, soldeNActuel + acquisJusquAuFinExN).toFixed(2)
+ );
+
+ // Acquisitions N+1 depuis 01/06/currentYear jusqu'à dateDebutObj
+ const acquisNp1 = dateDebutObj > debutNp1
+ ? parseFloat(Math.min(25, calculerAcquisitionFormuleExcel(debutNp1, dateDebutObj, 25 / 12)).toFixed(2))
+ : 0;
+
+ // Déductions anticipées APRÈS dateDebutObj uniquement
+ const dejaConsoAnticipRes = await pool.request()
+ .input('userId', sql.Int, userIdInt)
+ .input('typeId', sql.Int, cpTypeId)
+ .input('annee', sql.Int, anneeN)
+ .input('dateDebut', sql.Date, dateDebutObj)
+ .query(`
+ SELECT CAST(COALESCE(SUM(dd.JoursUtilises),0) AS FLOAT) AS total
+ FROM DeductionDetails dd
+ JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
+ WHERE dc.CollaborateurADId = @userId
+ AND dd.TypeCongeId = @typeId
+ AND dd.Annee = @annee
+ AND dd.TypeDeduction IN ('Anticip','AnticipFutur')
+ AND dc.DateDebut > @dateDebut
+ AND dc.Statut NOT IN ('Refusé','Refusée','Annulé','Annulée')
+ `);
+ const dejaConsoAnticip = parseFloat(dejaConsoAnticipRes.recordset[0]?.total ?? 0);
+
+ // Déductions sur N+1 après dateDebutObj
+ const dejaConsoNp1Res = await pool.request()
+ .input('userId', sql.Int, userIdInt)
+ .input('typeId', sql.Int, cpTypeId)
+ .input('annee', sql.Int, anneeExConge)
+ .input('dateDebut', sql.Date, dateDebutObj)
+ .query(`
+ SELECT CAST(COALESCE(SUM(dd.JoursUtilises),0) AS FLOAT) AS total
+ FROM DeductionDetails dd
+ JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
+ WHERE dc.CollaborateurADId = @userId
+ AND dd.TypeCongeId = @typeId
+ AND dd.Annee = @annee
+ AND dc.DateDebut > @dateDebut
+ AND dc.Statut NOT IN ('Refusé','Refusée','Annulé','Annulée')
+ `);
+ const dejaConsoNp1 = parseFloat(dejaConsoNp1Res.recordset[0]?.total ?? 0);
+ const dispoNp1 = parseFloat(Math.max(0, acquisNp1 - dejaConsoNp1).toFixed(2));
+
+ acquisCPADateDebut = soldeNAFinEx;
+ cpSoldeADateDebut = Math.max(0, soldeNAFinEx - dejaConsoAnticip);
+ cpNAnticipeDispo = dispoNp1;
+
+ totalCPDisponible = parseFloat(
+ Math.max(0, cpN1Dispo + cpSoldeADateDebut + dispoNp1).toFixed(2)
+ );
+
+ console.log(`🔍 CP ANTICIPÉ: solde=${soldeNActuel}j + acq→31/05=${acquisJusquAuFinExN.toFixed(2)}j = ${soldeNAFinEx}j - conso après dateDebut=${dejaConsoAnticip}j | N+1 (01/06→${dateDebut})=${dispoNp1}j | Total=${totalCPDisponible}j`);
+ }
+
+ console.log(`🔍 CP — N-1: ${cpN1Dispo.toFixed(2)}j | N acquis: ${cpNAcquisAujourdHui.toFixed(2)}j | Anticipé: ${cpNAnticipeDispo.toFixed(2)}j | Total: ${totalCPDisponible.toFixed(2)}j`);
+
+ // ── RTT ────────────────────────────────────────────────────────
+ let rttDispo = 0, acquisRTTADateDebut = 0, rttNAcquisAujourdHui = 0, rttAnticipeDispo = 0;
+
+ if (rttTypeId && !isApprenti) {
+ const rttData = await calculerAcquisitionRTT(userIdInt, dateDebutObj);
+ acquisRTTADateDebut = rttData.acquisition;
+
+ const cptRTT = await lireCompteurRTT(anneeRTT);
+ rttNAcquisAujourdHui = cptRTT ? Math.max(0, cptRTT.solde) : 0;
+
+ const rttConsoFuturRes = await pool.request()
+ .input('userId', sql.Int, userIdInt)
+ .input('typeId', sql.Int, rttTypeId)
+ .input('annee', sql.Int, anneeRTT)
+ .input('dateDebut', sql.Date, dateDebutObj) // ← dateDebutObj
+ .query(`
+ SELECT CAST(COALESCE(SUM(dd.JoursUtilises),0) AS FLOAT) AS total
+ FROM DeductionDetails dd
+ JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
+ WHERE dc.CollaborateurADId = @userId
+ AND dd.TypeCongeId = @typeId
+ AND dd.Annee = @annee
+ AND dc.DateDebut > @dateDebut
+ AND dc.Statut NOT IN ('Refusé','Refusée','Annulé','Annulée')
+ `);
+ const rttConsoFutur = parseFloat(rttConsoFuturRes.recordset[0]?.total ?? 0);
+
+ if (!isAnticipeRTT) {
+ const cfgRTT = await getConfigurationRTT(anneeRTT, typeContrat);
+ const acquisFutRTT = parseFloat(
+ Math.min(cfgRTT.joursAnnuels - rttNAcquisAujourdHui,
+ calculerAcquisitionFormuleExcel(today, dateDebutObj, cfgRTT.joursAnnuels / 12)
+ ).toFixed(2)
+ );
+ const rttALaDate = parseFloat(
+ Math.min(cfgRTT.joursAnnuels, rttNAcquisAujourdHui + acquisFutRTT).toFixed(2)
+ );
+ rttAnticipeDispo = Math.max(0, rttALaDate - rttNAcquisAujourdHui);
+ rttDispo = Math.max(0, rttALaDate - rttConsoFutur);
+
+ } else {
+ const cfgRTT = await getConfigurationRTT(anneeRTT, typeContrat);
+ const finExRTT = new Date(anneeRTT, 11, 31); finExRTT.setHours(23, 59, 59, 0);
+ const acquisRest = Math.min(
+ cfgRTT.joursAnnuels - rttNAcquisAujourdHui,
+ calculerAcquisitionFormuleExcel(today, finExRTT, cfgRTT.joursAnnuels / 12)
+ );
+ const soldeProj = Math.min(cfgRTT.joursAnnuels, rttNAcquisAujourdHui + acquisRest);
+ const dispoRTTN = Math.max(0, soldeProj - rttConsoFutur);
+
+ const anneeRTTNp1 = anneeRTT + 1;
+ const cfgRTTNp1 = await getConfigurationRTT(anneeRTTNp1, typeContrat);
+ const debutNp1RTT = new Date(anneeRTTNp1, 0, 1); debutNp1RTT.setHours(0, 0, 0, 0);
+ const acquisNp1RTT = parseFloat(
+ Math.min(cfgRTTNp1.joursAnnuels,
+ calculerAcquisitionFormuleExcel(debutNp1RTT, dateDebutObj, cfgRTTNp1.joursAnnuels / 12)
+ ).toFixed(2)
+ );
+
+ const rttConsoNp1Res = await pool.request()
+ .input('userId', sql.Int, userIdInt)
+ .input('typeId', sql.Int, rttTypeId)
+ .input('annee', sql.Int, anneeRTTNp1)
+ .input('dateDebut', sql.Date, dateDebutObj) // ← dateDebutObj
+ .query(`
+ SELECT CAST(COALESCE(SUM(dd.JoursUtilises),0) AS FLOAT) AS total
+ FROM DeductionDetails dd
+ JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id
+ WHERE dc.CollaborateurADId = @userId
+ AND dd.TypeCongeId = @typeId
+ AND dd.Annee = @annee
+ AND dc.DateDebut > @dateDebut
+ AND dc.Statut NOT IN ('Refusé','Refusée','Annulé','Annulée')
+ `);
+ const rttConsoNp1 = parseFloat(rttConsoNp1Res.recordset[0]?.total ?? 0);
+ const dispoNp1RTT = parseFloat(Math.max(0, acquisNp1RTT - rttConsoNp1).toFixed(2));
+
+ rttDispo = parseFloat((dispoRTTN + dispoNp1RTT).toFixed(2));
+ rttAnticipeDispo = dispoNp1RTT;
+ console.log(`🔍 RTT ANTICIPÉ: N=${dispoRTTN.toFixed(2)}j + N+1=${dispoNp1RTT.toFixed(2)}j = ${rttDispo.toFixed(2)}j`);
+ }
+ console.log(`🔍 RTT (${anneeRTT}) — Acquis à dateDebut: ${acquisRTTADateDebut.toFixed(2)}j | En base: ${rttNAcquisAujourdHui.toFixed(2)}j | Anticipé: ${rttAnticipeDispo.toFixed(2)}j | Dispo: ${rttDispo.toFixed(2)}j`);
+ }
+
+ // ── Vérification suffisance ────────────────────────────────────
+ const joursNecessairesCP = parseFloat(joursCP) || 0;
+ const joursNecessairesRTT = parseFloat(joursRTT) || 0;
+ const suffisantCP = joursNecessairesCP <= 0 || totalCPDisponible >= joursNecessairesCP;
+ const suffisantRTT = joursNecessairesRTT <= 0 || rttDispo >= joursNecessairesRTT;
+ const suffisant = suffisantCP && suffisantRTT;
+ const deficitCP = suffisantCP ? 0 : parseFloat((joursNecessairesCP - totalCPDisponible).toFixed(2));
+ const deficitRTT = suffisantRTT ? 0 : parseFloat((joursNecessairesRTT - rttDispo).toFixed(2));
+
+ let modeLabel = '';
+ if (!isAnticipe) modeLabel = apresBasculement ? 'NORMAL POST-BASCULEMENT' : 'NORMAL';
+ else if (!apresBasculement) modeLabel = 'ANTICIPÉ CLASSIQUE';
+ else modeLabel = 'ANTICIPÉ POST-BASCULEMENT';
+
+ const exerciceDemande = anneeN;
+ const exerciceConges = anneeExConge - 1;
+
+ res.json({
+ success: true,
+ previsionnel: {
+ mode: modeLabel,
+ isAnticipe,
+ isAnticipeRTT,
+ apresBasculement,
+ exerciceDemande: `${exerciceDemande}-${exerciceDemande + 1}`,
+ exerciceConges: `${exerciceConges}-${exerciceConges + 1}`,
+ anneeN1, anneeN, anneeRTT,
+
+ cpN1Reporte: parseFloat(cpN1Dispo.toFixed(2)),
+ cpNSolde: parseFloat(cpNAcquisAujourdHui.toFixed(2)),
+ cpNSoldeALaDate: parseFloat(cpSoldeADateDebut.toFixed(2)),
+ cpNAnticipeAutorise: parseFloat(cpNAnticipeDispo.toFixed(2)),
+ acquisCPADateDebut: parseFloat(acquisCPADateDebut.toFixed(2)),
+ cpNAcquisAujourdHui: parseFloat(cpNAcquisAujourdHui.toFixed(2)),
+ cpNAnticipeDispo: parseFloat(cpNAnticipeDispo.toFixed(2)),
+ totalCPDisponible,
+
+ acquisRTTADateDebut: parseFloat(acquisRTTADateDebut.toFixed(2)),
+ rttNAcquisAujourdHui: parseFloat(rttNAcquisAujourdHui.toFixed(2)),
+ rttAnticipeDispo: parseFloat(rttAnticipeDispo.toFixed(2)),
+ rttDispo: parseFloat(rttDispo.toFixed(2)),
+
+ joursNecessairesCP, joursNecessairesRTT,
+ totalDisponible: totalCPDisponible,
+ suffisant, suffisantCP, suffisantRTT,
+ deficitCP, deficitRTT,
+
+ message: (() => {
+ const parts = [];
+ if (cpN1Dispo > 0) parts.push(`N-1(${anneeN1}): ${cpN1Dispo.toFixed(2)}j`);
+ if (cpNAcquisAujourdHui > 0) parts.push(`N acquis(${anneeN}): ${cpNAcquisAujourdHui.toFixed(2)}j`);
+ if (cpNAnticipeDispo > 0) parts.push(`N anticipé: ${cpNAnticipeDispo.toFixed(2)}j`);
+ if (rttDispo > 0) parts.push(`RTT(${anneeRTT}): ${rttDispo.toFixed(2)}j`);
+ return `${modeLabel} — ${parts.join(' + ')} → CP total: ${totalCPDisponible.toFixed(2)}j`;
+ })()
+ }
+ });
+
+ } catch (error) {
+ console.error('❌ Erreur soldes-previsionnel:', error);
+ res.status(500).json({ success: false, message: error.message });
+ }
+ });
+ // ================================================
+ // ROUTE PUT /demandes/:id/annuler - ANNULATION SANS SUPPRESSION
+ // ================================================
+
+
+ app.put('/demandes/:id/annuler', authenticateToken, async (req, res) => {
+ const demandeId = parseInt(req.params.id);
+ const { commentaire } = req.body;
+
+ console.log(`🚫 Annulation demande #${demandeId}`);
+
+ const transaction = new sql.Transaction();
+
+ try {
+ await transaction.begin();
+
+ // 1. Récupérer la demande
+ const demandeResult = await transaction.request()
+ .input('demandeId', sql.Int, demandeId)
+ .query(`
+ SELECT
+ dc.*,
+ c.Email as EmailEmploye,
+ c.Nom,
+ c.Prenom
+ FROM DemandeConge dc
+ INNER JOIN CollaborateurAD c ON dc.CollaborateurADId = c.Id
+ WHERE dc.Id = @demandeId
+ `);
+
+ if (demandeResult.recordset.length === 0) {
+ await transaction.rollback();
+ return res.status(404).json({ error: 'Demande non trouvée' });
+ }
+
+ const demande = demandeResult.recordset[0];
+ const collaborateurId = demande.CollaborateurADId;
+ const ancienStatut = demande.Statut;
+
+ console.log(`📋 Demande trouvée - Statut actuel: ${ancienStatut}`);
+
+ // 2. Récupérer les types de congés
+ const typesResult = await transaction.request()
+ .input('demandeId', sql.Int, demandeId)
+ .query(`
+ SELECT
+ dct.Id,
+ dct.TypeCongeId,
+ dct.NombreJours,
+ tc.Nom as TypeConge
+ FROM DemandeCongeType dct
+ INNER JOIN TypeConge tc ON dct.TypeCongeId = tc.Id
+ WHERE dct.DemandeCongeId = @demandeId
+ `);
+
+ console.log(`📊 ${typesResult.recordset.length} type(s) de congé trouvé(s)`);
+
+ // 3. ⭐ RESTAURATION VIA DeductionDetails (même logique que serverGTA)
+ if (ancienStatut === 'En attente' || ancienStatut === 'Validée') {
+ console.log('💰 Restauration des compteurs via DeductionDetails...');
+
+ // Lire toutes les lignes DeductionDetails de cette demande
+ const deductionsResult = await transaction.request()
+ .input('demandeId', sql.Int, demandeId)
+ .query(`
+ SELECT dd.Id AS DeductionId, dd.TypeCongeId, dd.Annee,
+ dd.TypeDeduction, dd.JoursUtilises, tc.Nom AS TypeNom
+ FROM DeductionDetails dd
+ JOIN TypeConge tc ON dd.TypeCongeId = tc.Id
+ WHERE dd.DemandeCongeId = @demandeId
+ ORDER BY dd.Id DESC
+ `);
+
+ const deductions = deductionsResult.recordset;
+ console.log(`📊 ${deductions.length} ligne(s) DeductionDetails trouvée(s)`);
+
+ const restorations = [];
+
+ for (const ded of deductions) {
+ const { DeductionId, TypeCongeId, Annee, TypeDeduction, JoursUtilises, TypeNom } = ded;
+ const jours = parseFloat(JoursUtilises);
+ console.log(`\n🔍 ${TypeNom} - ${TypeDeduction} - ${jours}j (Année: ${Annee})`);
+
+ // Helper : lire compteur
+ const lireCpt = async (annee) => {
+ const r = await transaction.request()
+ .input('collabId', sql.Int, collaborateurId)
+ .input('typeId', sql.Int, TypeCongeId)
+ .input('annee', sql.Int, annee)
+ .query(`SELECT TOP 1 Id, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId=@collabId AND TypeCongeId=@typeId AND Annee=@annee`);
+ return r.recordset[0] ?? null;
+ };
+
+ // ── Année N-1 → Solde + SoldeReporte ─────────────────
+ if (TypeDeduction === 'Année N-1' || TypeDeduction === 'Anne N-1') {
+ const cpt = await lireCpt(Annee);
+ if (cpt) {
+ await transaction.request()
+ .input('jours', sql.Decimal(10, 2), jours)
+ .input('id', sql.Int, cpt.Id)
+ .query(`UPDATE CompteurConges SET Solde=Solde+@jours, SoldeReporte=SoldeReporte+@jours, DerniereMiseAJour=GETDATE() WHERE Id=@id`);
+ console.log(`✅ Année N-1 restaurée: +${jours}j (Solde + SoldeReporte)`);
+ }
+ await transaction.request().input('id', sql.Int, DeductionId).query(`DELETE FROM DeductionDetails WHERE Id=@id`);
+ restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: jours });
+ continue;
+ }
+
+ // ── Année N → Solde seulement ─────────────────────────
+ if (TypeDeduction === 'Année N' || TypeDeduction === 'Anne N') {
+ const cpt = await lireCpt(Annee);
+ if (cpt) {
+ await transaction.request()
+ .input('jours', sql.Decimal(10, 2), jours)
+ .input('id', sql.Int, cpt.Id)
+ .query(`UPDATE CompteurConges SET Solde=Solde+@jours, DerniereMiseAJour=GETDATE() WHERE Id=@id`);
+ console.log(`✅ Année N restaurée: +${jours}j`);
+ }
+ await transaction.request().input('id', sql.Int, DeductionId).query(`DELETE FROM DeductionDetails WHERE Id=@id`);
+ restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: jours });
+ continue;
+ }
+
+ // ── Anticip (jours réels déduits du solde CP N) → remettre ──
+ if (TypeDeduction === 'Anticip') {
+ const cpt = await lireCpt(Annee);
+ if (cpt) {
+ await transaction.request()
+ .input('jours', sql.Decimal(10, 2), jours)
+ .input('id', sql.Int, cpt.Id)
+ .query(`UPDATE CompteurConges SET Solde=Solde+@jours, DerniereMiseAJour=GETDATE() WHERE Id=@id`);
+ console.log(`✅ Anticip restauré: +${jours}j sur CompteurConges`);
+ }
+ await transaction.request().input('id', sql.Int, DeductionId).query(`DELETE FROM DeductionDetails WHERE Id=@id`);
+ restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: jours });
+ continue;
+ }
+
+ // ── AnticipFutur (tracé uniquement, solde jamais touché) → juste supprimer ──
+ if (TypeDeduction === 'AnticipFutur') {
+ await transaction.request().input('id', sql.Int, DeductionId).query(`DELETE FROM DeductionDetails WHERE Id=@id`);
+ restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: jours });
+ console.log(`✅ AnticipFutur supprimé: ${jours}j tracé retiré (aucun solde à remettre)`);
+ continue;
+ }
+
+ // ── AnticipRTT (solde RTT N réel déduit) → remettre ──
+ if (TypeDeduction === 'AnticipRTT') {
+ const cpt = await lireCpt(Annee);
+ if (cpt) {
+ await transaction.request()
+ .input('jours', sql.Decimal(10, 2), jours)
+ .input('id', sql.Int, cpt.Id)
+ .query(`UPDATE CompteurConges SET Solde=Solde+@jours, DerniereMiseAJour=GETDATE() WHERE Id=@id`);
+ console.log(`✅ AnticipRTT restauré: +${jours}j sur RTT ${Annee}`);
+ }
+ await transaction.request().input('id', sql.Int, DeductionId).query(`DELETE FROM DeductionDetails WHERE Id=@id`);
+ restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: jours });
+ continue;
+ }
+
+ // ── AnticipFuturRTT (tracé uniquement) → juste supprimer ──
+ if (TypeDeduction === 'AnticipFuturRTT') {
+ await transaction.request().input('id', sql.Int, DeductionId).query(`DELETE FROM DeductionDetails WHERE Id=@id`);
+ restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: jours });
+ console.log(`✅ AnticipFuturRTT supprimé: ${jours}j tracé retiré (aucun solde à remettre)`);
+ continue;
+ }
+
+ // ── Cas non reconnu → log warning ───────────────────
+ console.warn(`⚠️ TypeDeduction non géré: "${TypeDeduction}" — ignoré`);
+ }
+
+ // ── Fallback : si aucune ligne DeductionDetails (anciennes demandes) ──
+ // On applique l'ancienne logique simple pour rester compatible
+ if (deductions.length === 0) {
+ console.log('⚠️ Aucune DeductionDetails — fallback restauration simple');
+ for (const type of typesResult.recordset) {
+ const nombreJours = parseFloat(type.NombreJours);
+ const typeCongeId = type.TypeCongeId;
+ const anneeDemande = new Date(demande.DateDebut).getFullYear();
+ const cptFallback = await transaction.request()
+ .input('collabId', sql.Int, collaborateurId)
+ .input('typeId', sql.Int, typeCongeId)
+ .input('annee', sql.Int, anneeDemande)
+ .query(`SELECT TOP 1 Id, Solde FROM CompteurConges WHERE CollaborateurADId=@collabId AND TypeCongeId=@typeId AND Annee=@annee`);
+ if (cptFallback.recordset.length > 0) {
+ await transaction.request()
+ .input('jours', sql.Decimal(10, 2), nombreJours)
+ .input('id', sql.Int, cptFallback.recordset[0].Id)
+ .query(`UPDATE CompteurConges SET Solde=Solde+@jours, DerniereMiseAJour=GETDATE() WHERE Id=@id`);
+ console.log(`✅ Fallback: ${type.TypeConge} +${nombreJours}j`);
+ }
+ }
+ }
+
+ // Historique global
+ try {
+ await transaction.request()
+ .input('collabId', sql.Int, collaborateurId)
+ .input('details', sql.NVarChar(500), `Annulation demande #${demandeId} - Restauration compteurs via DeductionDetails`)
+ .input('demandeId', sql.Int, demandeId)
+ .query(`INSERT INTO HistoriqueActions (CollaborateurADId, Action, Details, DemandeCongeId, DateAction) VALUES (@collabId, 'Restauration compteur', @details, @demandeId, GETDATE())`);
+ } catch (histErr) {
+ console.warn('⚠️ Historique non créé (non bloquant):', histErr.message);
+ }
+
+ console.log(`✅ Restauration terminée: ${restorations.length} opération(s)`);
+ } else {
+ console.log(`ℹ️ Pas de restauration de compteurs (statut était "${ancienStatut}")`);
+ }
+
+ // 4. Mettre à jour le statut de la demande
+ await transaction.request()
+ .input('demandeId', sql.Int, demandeId)
+ .input('commentaire', sql.NVarChar, commentaire || 'Annulée par le service RH')
+ .query(`
+ UPDATE DemandeConge
+ SET Statut = 'Annulée',
+ Commentaire = @commentaire,
+ DateValidation = GETDATE()
+ WHERE Id = @demandeId
+ `);
+
+ await transaction.commit();
+
+ console.log(`✅ Demande #${demandeId} annulée avec succès`);
+
+ // 5. ✅ CORRECTION : Envoyer un email avec Microsoft Graph
+ const emailEmploye = demande.EmailEmploye;
+ const nomComplet = `${demande.Prenom} ${demande.Nom}`;
+
+ try {
+ const accessToken = await getGraphToken();
+
+ if (accessToken && emailEmploye) {
+ const subject = '🚫 Votre demande de congé a été annulée';
+ const body = `
+
+
+
🚫 Annulation de demande de congé
+
+
+
Bonjour ${nomComplet},
+
+
Période : ${new Date(demande.DateDebut).toLocaleDateString('fr-FR')} au ${new Date(demande.DateFin).toLocaleDateString('fr-FR')}
+
Statut : Annulée
+ ${commentaire ? `
Motif : ${commentaire}
` : ''}
+
+ ${(ancienStatut === 'En attente' || ancienStatut === 'Validée') ? '
ℹ️ Les jours de congés ont été automatiquement restitués à votre compteur.
' : ''}
+
Pour toute question, veuillez contacter le service RH.
+
+
+ `;
+
+ await sendMailGraph(accessToken, 'gtanoreply@ensup.eu', emailEmploye, subject, body);
+ console.log(`📧 Email d'annulation envoyé à ${emailEmploye}`);
+ }
+ } catch (emailError) {
+ console.error('❌ Erreur envoi email (non bloquant):', emailError.message);
+ }
+
+ // 6. Notification SSE
+ notifyClients({
+ type: 'demande-cancelled',
+ demandeId: demandeId,
+ collaborateurId: collaborateurId,
+ message: 'Demande annulée'
+ });
+
+ // 7. Webhook (optionnel)
+ try {
+ if (typeof webhookManager !== 'undefined' && (ancienStatut === 'En attente' || ancienStatut === 'Validée')) {
+ await webhookManager.sendWebhook(
+ WEBHOOKS.COLLABORATEURS.URL,
+ EVENTS.DEMANDE_CANCELLED,
+ {
+ demandeId: demandeId,
+ collaborateur: nomComplet,
+ dateDebut: demande.DateDebut,
+ dateFin: demande.DateFin,
+ ancienStatut: ancienStatut,
+ compteursRestaurés: true
+ }
+ );
+ }
+ } catch (webhookError) {
+ console.error('⚠️ Erreur webhook (non bloquant):', webhookError.message);
+ }
+
+ res.json({
+ success: true,
+ message: 'Demande annulée avec succès',
+ compteursRestaurés: ancienStatut === 'En attente' || ancienStatut === 'Validée'
+ });
+
+ } catch (error) {
+ // ✅ CORRECTION : Vérifier si la transaction est toujours active avant rollback
+ try {
+ if (transaction._acquiredConnection) {
+ await transaction.rollback();
+ console.log('🔄 Transaction annulée (rollback)');
+ }
+ } catch (rollbackError) {
+ console.error('⚠️ Erreur rollback:', rollbackError.message);
+ }
+
+ console.error('❌ Erreur annulation demande:', error);
+ res.status(500).json({
+ error: 'Erreur lors de l\'annulation de la demande',
+ details: error.message
+ });
+ }
+ });
+
+ // PUT /api/compte-rendu/jour - RH modifie un jour du compte-rendu d'activité
+ app.put('/compte-rendu/jour', authenticateToken, async (req, res) => {
+ const transaction = new sql.Transaction(pool);
+ try {
+ await transaction.begin();
+
+ if (!['Admin', 'RH'].includes(req.user.role)) {
+ await transaction.rollback();
+ return res.status(403).json({ success: false, error: 'Accès non autorisé' });
+ }
+
+ const { collaborateurid, jourdate, jourtravaille, reposquotidien, reposhebdo, commentaire } = req.body;
+
+ if (!collaborateurid || !jourdate) {
+ await transaction.rollback();
+ return res.status(400).json({ success: false, error: 'collaborateurid et jourdate sont requis' });
+ }
+
+ const collaborateurId = parseInt(collaborateurid);
+ const dateObj = new Date(jourdate);
+ const annee = dateObj.getFullYear();
+ const mois = dateObj.getMonth() + 1;
+
+ // UPSERT du jour dans CompteRenduActivites
+ const existing = await transaction.request()
+ .input('collaborateurId', sql.Int, collaborateurId)
+ .input('jourDate', sql.Date, jourdate)
+ .query(`SELECT Id FROM CompteRenduActivites
+ WHERE CollaborateurADId = @collaborateurId AND JourDate = @jourDate`);
+
+ if (existing.recordset.length > 0) {
+ await transaction.request()
+ .input('collaborateurId', sql.Int, collaborateurId)
+ .input('jourDate', sql.Date, jourdate)
+ .input('jourTravaille', sql.Bit, jourtravaille ? 1 : 0)
+ .input('reposQ', sql.Bit, reposquotidien ? 1 : 0)
+ .input('reposH', sql.Bit, reposhebdo ? 1 : 0)
+ .input('commentaire', sql.NVarChar(500), commentaire || '')
+ .query(`UPDATE CompteRenduActivites SET
+ JourTravaille = @jourTravaille,
+ ReposQuotidienRespect = @reposQ,
+ ReposHebdomadaireRespect = @reposH,
+ CommentaireRepos = @commentaire,
+ DateModification = GETDATE()
+ WHERE CollaborateurADId = @collaborateurId AND JourDate = @jourDate`);
+ } else {
+ // ✅ Générer l'ID manuellement
+ const maxIdResult = await transaction.request()
+ .query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM CompteRenduActivites');
+ const newId = maxIdResult.recordset[0].NextId;
+
+ await transaction.request()
+ .input('id', sql.Int, newId)
+ .input('collaborateurId', sql.Int, collaborateurId)
+ .input('jourDate', sql.Date, jourdate)
+ .input('annee', sql.Int, annee)
+ .input('mois', sql.Int, mois)
+ .input('jourTravaille', sql.Bit, jourtravaille ? 1 : 0)
+ .input('reposQ', sql.Bit, reposquotidien ? 1 : 0)
+ .input('reposH', sql.Bit, reposhebdo ? 1 : 0)
+ .input('commentaire', sql.NVarChar(500), commentaire || '')
+ .query(`INSERT INTO CompteRenduActivites
+ (Id, CollaborateurADId, JourDate, Annee, Mois, JourTravaille,
+ ReposQuotidienRespect, ReposHebdomadaireRespect, CommentaireRepos, DateSaisie, DateModification)
+ VALUES
+ (@id, @collaborateurId, @jourDate, @annee, @mois, @jourTravaille,
+ @reposQ, @reposH, @commentaire, GETDATE(), GETDATE())`);
+ }
+
+ // Recalculer et mettre à jour CompteRenduMensuel
+ const statsResult = await transaction.request()
+ .input('collaborateurId', sql.Int, collaborateurId)
+ .input('annee', sql.Int, annee)
+ .input('mois', sql.Int, mois)
+ .query(`SELECT
+ COUNT(CASE WHEN JourTravaille = 1 THEN 1 END) AS NbJoursTravailles,
+ COUNT(CASE WHEN JourTravaille = 1 AND ReposQuotidienRespect = 0 THEN 1 END) AS NbReposQ,
+ COUNT(CASE WHEN JourTravaille = 1 AND ReposHebdomadaireRespect = 0 THEN 1 END) AS NbReposH
+ FROM CompteRenduActivites
+ WHERE CollaborateurADId = @collaborateurId AND Annee = @annee AND Mois = @mois`);
+
+ const stats = statsResult.recordset[0];
+
+ const mensuelExist = await transaction.request()
+ .input('collaborateurId', sql.Int, collaborateurId)
+ .input('annee', sql.Int, annee)
+ .input('mois', sql.Int, mois)
+ .query(`SELECT Id FROM CompteRenduMensuel
+ WHERE CollaborateurADId = @collaborateurId AND Annee = @annee AND Mois = @mois`);
+
+ if (mensuelExist.recordset.length > 0) {
+ await transaction.request()
+ .input('collaborateurId', sql.Int, collaborateurId)
+ .input('annee', sql.Int, annee)
+ .input('mois', sql.Int, mois)
+ .input('nbJours', sql.Decimal(10, 2), stats.NbJoursTravailles)
+ .input('nbReposQ', sql.Int, stats.NbReposQ)
+ .input('nbReposH', sql.Int, stats.NbReposH)
+ .query(`UPDATE CompteRenduMensuel SET
+ NbJoursTravailles = @nbJours,
+ NbJoursNonRespectsReposQuotidien = @nbReposQ,
+ NbJoursNonRespectsReposHebdo = @nbReposH
+ WHERE CollaborateurADId = @collaborateurId AND Annee = @annee AND Mois = @mois`);
+ } else {
+ // ✅ Générer l'ID manuellement pour CompteRenduMensuel aussi
+ const maxMensuelIdResult = await transaction.request()
+ .query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM CompteRenduMensuel');
+ const newMensuelId = maxMensuelIdResult.recordset[0].NextId;
+
+ await transaction.request()
+ .input('id', sql.Int, newMensuelId)
+ .input('collaborateurId', sql.Int, collaborateurId)
+ .input('annee', sql.Int, annee)
+ .input('mois', sql.Int, mois)
+ .input('nbJours', sql.Decimal(10, 2), stats.NbJoursTravailles)
+ .input('nbReposQ', sql.Int, stats.NbReposQ)
+ .input('nbReposH', sql.Int, stats.NbReposH)
+ .query(`INSERT INTO CompteRenduMensuel
+ (Id, CollaborateurADId, Annee, Mois, NbJoursTravailles,
+ NbJoursNonRespectsReposQuotidien, NbJoursNonRespectsReposHebdo, Verrouille)
+ VALUES (@id, @collaborateurId, @annee, @mois, @nbJours, @nbReposQ, @nbReposH, 0)`);
+ }
+
+ await transaction.commit();
+ res.json({ success: true, message: 'Jour mis à jour avec succès' });
+
+ } catch (error) {
+ try { await transaction.rollback(); } catch (e) { }
+ console.error('Erreur PUT compte-rendu/jour', error);
+ res.status(500).json({ success: false, error: error.message });
+ }
+ });
+
+ // Verrouiller un mois
+ app.post('/compte-rendu/verrouiller', authenticateToken, async (req, res) => {
+ try {
+ const { collaborateurid, annee, mois } = req.body;
+
+ // Upsert : créer la ligne si elle n'existe pas, puis verrouiller
+ const existing = await pool.request()
+ .input('collaborateurId', sql.Int, collaborateurid)
+ .input('annee', sql.Int, annee)
+ .input('mois', sql.Int, mois)
+ .query(`SELECT Id FROM CompteRenduMensuel
+ WHERE CollaborateurADId = @collaborateurId
+ AND Annee = @annee AND Mois = @mois`);
+
+ if (existing.recordset.length > 0) {
+ await pool.request()
+ .input('collaborateurId', sql.Int, collaborateurid)
+ .input('annee', sql.Int, annee)
+ .input('mois', sql.Int, mois)
+ .query(`UPDATE CompteRenduMensuel
+ SET Verrouille = 1
+ WHERE CollaborateurADId = @collaborateurId
+ AND Annee = @annee AND Mois = @mois`);
+ } else {
+ // Créer l'entrée mensuelle si elle n'existe pas encore
+ await pool.request()
+ .input('collaborateurId', sql.Int, collaborateurid)
+ .input('annee', sql.Int, annee)
+ .input('mois', sql.Int, mois)
+ .query(`INSERT INTO CompteRenduMensuel
+ (CollaborateurADId, Annee, Mois, NbJoursTravailles,
+ NbJoursNonRespectsReposQuotidien, NbJoursNonRespectsReposHebdo, Verrouille)
+ VALUES (@collaborateurId, @annee, @mois, 0, 0, 0, 1)`);
+ }
+
+ res.json({ success: true, message: 'Mois verrouillé' });
+ } catch (error) {
+ console.error('Erreur verrouillage:', error);
+ res.status(500).json({ success: false, error: error.message });
+ }
+ });
+
+ // Déverrouiller un mois
+ app.post('/compte-rendu/deverrouiller', authenticateToken, async (req, res) => {
+ try {
+ const { collaborateurid, annee, mois } = req.body;
+
+ await pool.request()
+ .input('collaborateurId', sql.Int, collaborateurid)
+ .input('annee', sql.Int, annee)
+ .input('mois', sql.Int, mois)
+ .query(`UPDATE CompteRenduMensuel
+ SET Verrouille = 0
+ WHERE CollaborateurADId = @collaborateurId
+ AND Annee = @annee AND Mois = @mois`);
+
+ res.json({ success: true, message: 'Mois déverrouillé' });
+ } catch (error) {
+ console.error('Erreur déverrouillage:', error);
+ res.status(500).json({ success: false, error: error.message });
+ }
+ });
+ // ================================================
+ // CRON — Snapshot quotidien à 23h55 heure de Paris
+ // ================================================
+ function planifierSnapshotQuotidien() {
+ const maintenant = new Date();
+ const prochainDeclenchement = new Date();
+
+ prochainDeclenchement.setHours(23, 55, 0, 0);
+
+ // Si 23h55 est déjà passé aujourd'hui, programmer pour demain
+ if (prochainDeclenchement <= maintenant) {
+ prochainDeclenchement.setDate(prochainDeclenchement.getDate() + 1);
+ }
+
+ const delai = prochainDeclenchement - maintenant;
+
+ console.log(`[CRON] ✅ Snapshot planifié dans ${Math.round(delai / 1000 / 60)} minutes`);
+
+ setTimeout(async () => {
+ await executerSnapshotQuotidien();
+ // Re-planifier pour le lendemain
+ planifierSnapshotQuotidien();
+ }, delai);
+ }
+
+ planifierSnapshotQuotidien();
+
+ // ================================================
+ // DÉMARRAGE DU SERVEUR
+ // ================================================
+
+ console.log('✅ 99. Toutes les routes définies, démarrage du serveur...');
+
+ const server = app.listen(PORT, '0.0.0.0', () => { // ← AJOUT DE '0.0.0.0'
+ console.log('\n================================================');
+ console.log(`✅ SERVEUR RH DÉMARRÉ sur http://0.0.0.0:${PORT}`); // ← Changé localhost en 0.0.0.0
+ console.log('🔔 Server-Sent Events activés sur /events');
+ console.log('🔗 WEBHOOKS configurés:');
+ console.log(` - Serveur Collaborateurs: ${WEBHOOKS.COLLABORATEURS_URL}`);
+ console.log(` - Route webhook receiver: POST /webhook/receive`);
+ console.log('');
+ console.log('📋 Routes disponibles:');
+ console.log(' - GET /events (SSE)');
+ console.log(' - POST /webhook/receive');
+ console.log(' - POST /login-dev ✅ SQL SERVER');
+ console.log(' - GET /demandes');
+ console.log(' - GET /demandes/:id');
+ console.log(' - POST /demandes');
+ console.log(' - PUT /demandes/:id');
+ console.log(' - DELETE /demandes/:id');
+ console.log(' - PUT /demandes/:id/valider');
+ console.log(' - GET /employes');
+ console.log(' - POST /employes/desactiver');
+ console.log(' - POST /employes/reactiver');
+ console.log(' - GET /types-conge');
+ console.log(' - GET /export/paie');
+ console.log(' - GET /compteurs');
+ console.log(' - POST /reinitializeAllCounters ✅ SQL SERVER');
+ console.log(' - PUT /compteurs/:id');
+ console.log(' - POST /compteurs/ajouter-recup');
+ console.log(' - POST /compteurs/retirer-recup');
+ console.log(' - GET /equipes');
+ console.log(' - GET /equipes/:id');
+ console.log(' - GET /equipes/:id/membres');
+ console.log(' - GET /stats');
+ console.log(' - GET /historique');
+ console.log(' - GET /v2/compteurs ✅ API V2');
+ console.log(' - PUT /v2/compteurs/update ✅ API V2');
+ console.log(' - GET /getAllDetailedCounters ✅ SQL SERVER');
+ console.log('================================================');
+ });
+
+ server.on('error', (error) => {
+ console.error('\n❌ ERREUR SERVEUR:', error);
+ if (error.code === 'EADDRINUSE') {
+ console.error(`⚠️ Le port ${PORT} est déjà utilisé`);
+ }
+ });
+
+ setInterval(() => { }, 60000);
+
+} catch (error) {
+ console.error('\n❌❌❌ ERREUR FATALE AU CHARGEMENT ❌❌❌');
+ console.error(error);
+ console.error(error.stack);
+ process.exit(1);
+}
\ No newline at end of file
diff --git a/Backend/webhook-config.js b/Backend/webhook-config.js
new file mode 100644
index 0000000..cfe85e8
--- /dev/null
+++ b/Backend/webhook-config.js
@@ -0,0 +1,18 @@
+// Configuration des webhooks
+const WEBHOOKS = {
+ COLLABORATEURS_URL: process.env.COLLABORATEURS_URL || 'http://localhost:3000',
+ RH_URL: process.env.RH_URL || 'http://localhost:3001',
+ SECRET_KEY: process.env.WEBHOOK_SECRET || 'secret-key-securise'
+};
+
+// Types d'événements
+const EVENTS = {
+ DEMANDE_VALIDATED: 'demande.validated',
+ DEMANDE_CREATED: 'demande.created',
+ DEMANDE_UPDATED: 'demande.updated',
+ DEMANDE_CANCELLED: 'demande.cancelled',
+ DEMANDE_DELETED: 'demande.deleted',
+ COMPTEUR_UPDATED: 'compteur.updated'
+};
+
+module.exports = { WEBHOOKS, EVENTS };
\ No newline at end of file
diff --git a/Backend/webhook-utils.js b/Backend/webhook-utils.js
new file mode 100644
index 0000000..9fb4d78
--- /dev/null
+++ b/Backend/webhook-utils.js
@@ -0,0 +1,111 @@
+// webhook-utils.js (VERSION COMMONJS)
+const axios = require('axios');
+const crypto = require('crypto');
+
+class WebhookManager {
+ constructor(secretKey) {
+ this.secretKey = secretKey;
+ }
+
+ /**
+ * Génère une signature HMAC SHA-256 pour sécuriser le webhook
+ * @param {Object} payload - Les données à signer
+ * @returns {string} La signature hexadécimale
+ */
+ generateSignature(payload) {
+ return crypto
+ .createHmac('sha256', this.secretKey)
+ .update(JSON.stringify(payload))
+ .digest('hex');
+ }
+
+ /**
+ * Vérifie la signature d'un webhook reçu
+ * @param {Object} payload - Les données reçues
+ * @param {string} receivedSignature - La signature reçue dans le header
+ * @returns {boolean} True si la signature est valide
+ */
+ verifySignature(payload, receivedSignature) {
+ if (!receivedSignature) {
+ console.error('❌ Aucune signature fournie');
+ return false;
+ }
+
+ try {
+ const expectedSignature = this.generateSignature(payload);
+ return crypto.timingSafeEqual(
+ Buffer.from(expectedSignature),
+ Buffer.from(receivedSignature)
+ );
+ } catch (error) {
+ console.error('❌ Erreur vérification signature:', error);
+ return false;
+ }
+ }
+
+ /**
+ * Envoie un webhook à une URL cible avec retry automatique
+ * @param {string} targetUrl - URL du serveur cible
+ * @param {string} eventType - Type d'événement (ex: 'demande.validated')
+ * @param {Object} data - Données de l'événement
+ * @param {number} retries - Nombre de tentatives (défaut: 3)
+ * @returns {Promise