diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..723ef36 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.idea \ No newline at end of file diff --git a/.idea/jsLibraryMappings.xml b/.idea/jsLibraryMappings.xml deleted file mode 100644 index cc3da93..0000000 --- a/.idea/jsLibraryMappings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/README.md b/README.md index f197301..ebb32ac 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,28 @@ # Bienvue sur SIRH - ## Participants + - Jarod Couprie - Ethan Collignon - Théo Roblin +## Démarrage du projet + +Pour démarrer le projet et créer les différents conteneurs dont l'application a besoin, il faut utiliser la commande +suivante +> Attention cependant, le dossier contient des variables d'environnement qu'il sera nécessaire de prendre en +> compte lors de la première initialisation. Ce projet ne contient que des variables d'environnement de développement et +> ne sont en aucun cas à prendre telles quelles pour mettre l'application en phase de production. + +### Développement + +```bash +docker compose -f ./docker-compose.dev.yml -p sirh-dev up -d +``` + +### Production + +```bash +docker compose -f ./docker-compose.yml -p sirh up -d +``` + diff --git a/api-server/.env.example b/api-server/.env.example index 7c224e3..a144d94 100644 --- a/api-server/.env.example +++ b/api-server/.env.example @@ -12,8 +12,6 @@ #MINIO_PORT=9000 #MINIO_USE_SSL=false #MINIO_REGION=eu-west-1 -#MINIO_ACCESS_KEY=iHmA43LQkS23AYmhbtRK -#MINIO_SECRET_KEY=GVmxeDPREQZvNCimm03OMxvPtVpOrwEtqOyZLWol -#PC MAISON -#MINIO_ACCESS_KEY=PocckwfAMBZ8ZCfAYiom -#MINIO_SECRET_KEY=2BbG0VQNz0n3nYQJ23ptDVqyB0tdwaht1M55TAcO +#MINIO_ACCESS_KEY=HfHQdNRNGexTknt6JFhq +#MINIO_SECRET_KEY=Eg8uv3EwYMlGnvOZb5Yo1tMj9sdbCvaPZ7QIsUPm + diff --git a/api-server/package-lock.json b/api-server/package-lock.json index a622905..64d5df8 100644 --- a/api-server/package-lock.json +++ b/api-server/package-lock.json @@ -15,6 +15,9 @@ "minio": "^8.0.1", "multer": "^1.4.5-lts.1", "mysql2": "^3.11.0", + "socket.io": "^4.7.5", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", "winston": "^3.14.2", "zod": "^3.23.8" }, @@ -27,6 +30,8 @@ "@types/multer": "^1.4.11", "@types/node": "^20.14.15", "@types/supertest": "^6.0.2", + "@types/swagger-jsdoc": "^6.0.4", + "@types/swagger-ui-express": "^4.1.6", "@vitest/coverage-v8": "^2.0.5", "@vitest/ui": "^2.0.5", "dotenv": "^16.4.5", @@ -54,6 +59,62 @@ "node": ">=6.0.0" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz", + "integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.6", + "call-me-maybe": "^1.0.1", + "js-yaml": "^4.1.0" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, + "node_modules/@apidevtools/json-schema-ref-parser/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^9.0.6", + "@apidevtools/openapi-schemas": "^2.0.4", + "@apidevtools/swagger-methods": "^3.0.2", + "@jsdevtools/ono": "^7.1.3", + "call-me-maybe": "^1.0.1", + "z-schema": "^5.0.1" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, "node_modules/@babel/code-frame": { "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", @@ -1524,6 +1585,11 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==" + }, "node_modules/@mapbox/node-pre-gyp": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", @@ -1826,6 +1892,11 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==" + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -1919,6 +1990,11 @@ "@types/node": "*" } }, + "node_modules/@types/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==" + }, "node_modules/@types/cookiejar": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", @@ -1929,7 +2005,6 @@ "version": "2.8.17", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", - "dev": true, "dependencies": { "@types/node": "*" } @@ -2013,6 +2088,11 @@ "pretty-format": "^29.0.0" } }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" + }, "node_modules/@types/jsonwebtoken": { "version": "9.0.6", "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.6.tgz", @@ -2047,7 +2127,6 @@ "version": "20.14.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.15.tgz", "integrity": "sha512-Fz1xDMCF/B00/tYSVMlmK7hVeLh7jE5f3B7X1/hmV0MJBwE27KlS7EvD/Yp+z1lm8mVhwV5w+n8jOZG8AfTlKw==", - "dev": true, "dependencies": { "undici-types": "~5.26.4" } @@ -2113,6 +2192,22 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/swagger-jsdoc": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/swagger-jsdoc/-/swagger-jsdoc-6.0.4.tgz", + "integrity": "sha512-W+Xw5epcOZrF/AooUM/PccNMSAFOKWZA5dasNyMujTwsBkU74njSJBpvCCJhHAJ95XRMzQrrW844Btu0uoetwQ==", + "dev": true + }, + "node_modules/@types/swagger-ui-express": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.6.tgz", + "integrity": "sha512-UVSiGYXa5IzdJJG3hrc86e8KdZWLYxyEsVoUI4iPXc7CO4VZ3AfNP8d/8+hrDRIqz+HAaSMtZSqAsF3Nq2X/Dg==", + "dev": true, + "dependencies": { + "@types/express": "*", + "@types/serve-static": "*" + } + }, "node_modules/@types/triple-beam": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", @@ -2380,7 +2475,6 @@ "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dev": true, "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" @@ -2537,8 +2631,7 @@ "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "dev": true + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, "node_modules/asap": { "version": "2.0.6", @@ -2712,6 +2805,14 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/bcrypt": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", @@ -2749,7 +2850,6 @@ "version": "1.20.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", - "dev": true, "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", @@ -2881,7 +2981,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, "engines": { "node": ">= 0.8" } @@ -2913,6 +3012,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==" + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3161,6 +3265,14 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", + "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", + "engines": { + "node": ">= 6" + } + }, "node_modules/component-emitter": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", @@ -3225,7 +3337,6 @@ "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "dev": true, "dependencies": { "safe-buffer": "5.2.1" }, @@ -3237,7 +3348,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -3252,7 +3362,6 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -3260,8 +3369,7 @@ "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", - "dev": true + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, "node_modules/cookiejar": { "version": "2.1.4", @@ -3331,7 +3439,6 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, "dependencies": { "ms": "2.0.0" } @@ -3418,7 +3525,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, "engines": { "node": ">= 0.8" } @@ -3427,7 +3533,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "dev": true, "engines": { "node": ">= 0.8", "npm": "1.2.8000 || >= 1.4.16" @@ -3478,6 +3583,17 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/dotenv": { "version": "16.4.5", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", @@ -3507,8 +3623,7 @@ "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==", - "dev": true + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "node_modules/ejs": { "version": "3.1.10", @@ -3557,11 +3672,67 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "dev": true, "engines": { "node": ">= 0.8" } }, + "node_modules/engine.io": { + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.5.tgz", + "integrity": "sha512-C5Pn8Wk+1vKBoHghJODM63yk8MvrO9EWZUfkAt5HAqIgPE4/8FF0PEGHXtEd40l223+cE5ABWuPzm38PHFXfMA==", + "dependencies": { + "@types/cookie": "^0.4.1", + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.4.1", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/debug": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -3640,8 +3811,7 @@ "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==", - "dev": true + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" }, "node_modules/escape-string-regexp": { "version": "2.0.0", @@ -3674,11 +3844,18 @@ "@types/estree": "^1.0.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -3740,7 +3917,6 @@ "version": "4.19.2", "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", - "dev": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -3910,7 +4086,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", - "dev": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~1.0.2", @@ -4016,7 +4191,6 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -4025,7 +4199,6 @@ "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -4335,7 +4508,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dev": true, "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", @@ -4393,7 +4565,6 @@ "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -5451,6 +5622,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -5461,6 +5637,11 @@ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==" + }, "node_modules/lodash.isinteger": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", @@ -5487,6 +5668,11 @@ "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", "dev": true }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==" + }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", @@ -5604,8 +5790,7 @@ "node_modules/merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==", - "dev": true + "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" }, "node_modules/merge-stream": { "version": "2.0.0", @@ -5626,7 +5811,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -5648,7 +5832,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "dev": true, "bin": { "mime": "cli.js" }, @@ -5786,8 +5969,7 @@ "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "node_modules/multer": { "version": "1.4.5-lts.1", @@ -5891,7 +6073,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -6063,7 +6244,6 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -6075,7 +6255,6 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, "dependencies": { "ee-first": "1.1.1" }, @@ -6114,6 +6293,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "peer": true + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -6193,7 +6378,6 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, "engines": { "node": ">= 0.8" } @@ -6255,8 +6439,7 @@ "node_modules/path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==", - "dev": true + "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" }, "node_modules/pathe": { "version": "1.1.2", @@ -6411,7 +6594,6 @@ "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==", - "dev": true, "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" @@ -6424,7 +6606,6 @@ "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==", - "dev": true, "engines": { "node": ">= 0.10" } @@ -6455,7 +6636,6 @@ "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", - "dev": true, "dependencies": { "side-channel": "^1.0.4" }, @@ -6507,7 +6687,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -6516,7 +6695,6 @@ "version": "2.5.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", - "dev": true, "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", @@ -6749,7 +6927,6 @@ "version": "0.18.0", "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", - "dev": true, "dependencies": { "debug": "2.6.9", "depd": "2.0.0", @@ -6772,8 +6949,7 @@ "node_modules/send/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==", - "dev": true + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/seq-queue": { "version": "0.0.5", @@ -6784,7 +6960,6 @@ "version": "1.15.0", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", - "dev": true, "dependencies": { "encodeurl": "~1.0.2", "escape-html": "~1.0.3", @@ -6819,8 +6994,7 @@ "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, "node_modules/shebang-command": { "version": "2.0.0", @@ -6847,7 +7021,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", - "dev": true, "dependencies": { "call-bind": "^1.0.7", "es-errors": "^1.3.0", @@ -6926,6 +7099,107 @@ "node": ">=8" } }, + "node_modules/socket.io": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.7.5.tgz", + "integrity": "sha512-DmeAkF6cwM9jSfmp6Dr/5/mfMwb5Z5qRrSXLpo3Fq5SqyU8CMF15jIN4ZhfSwu35ksM1qmHZDQ/DK5XTccSTvA==", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "engine.io": "~6.5.2", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz", + "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", + "dependencies": { + "debug": "~4.3.4", + "ws": "~8.17.1" + } + }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-adapter/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/socket.io/node_modules/debug": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -7006,7 +7280,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "dev": true, "engines": { "node": ">= 0.8" } @@ -7246,6 +7519,75 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-jsdoc": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", + "integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==", + "dependencies": { + "commander": "6.2.0", + "doctrine": "3.0.0", + "glob": "7.1.6", + "lodash.mergewith": "^4.6.2", + "swagger-parser": "^10.0.3", + "yaml": "2.0.0-1" + }, + "bin": { + "swagger-jsdoc": "bin/swagger-jsdoc.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/swagger-jsdoc/node_modules/glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==", + "dependencies": { + "@apidevtools/swagger-parser": "10.0.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.17.14", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.17.14.tgz", + "integrity": "sha512-CVbSfaLpstV65OnSjbXfVd6Sta3q3F7Cj/yYuvHMp1P90LztOLs6PfUnKEVAeiIVQt9u2SaPwv0LiH/OyMjHRw==" + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/tar": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", @@ -7369,7 +7711,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, "engines": { "node": ">=0.6" } @@ -7556,14 +7897,12 @@ "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "dev": true, "engines": { "node": ">= 0.8" } @@ -7619,7 +7958,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "dev": true, "engines": { "node": ">= 0.4.0" } @@ -7644,6 +7982,14 @@ "node": ">=10.12.0" } }, + "node_modules/validator": { + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", + "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -8155,6 +8501,26 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml2js": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", @@ -8198,6 +8564,14 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true }, + "node_modules/yaml": { + "version": "2.0.0-1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", + "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", + "engines": { + "node": ">= 6" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", @@ -8246,6 +8620,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/z-schema": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", + "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", + "dependencies": { + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "validator": "^13.7.0" + }, + "bin": { + "z-schema": "bin/z-schema" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "commander": "^9.4.1" + } + }, + "node_modules/z-schema/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "optional": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/zod": { "version": "3.23.8", "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", diff --git a/api-server/package.json b/api-server/package.json index 6da4d71..9e75ba4 100644 --- a/api-server/package.json +++ b/api-server/package.json @@ -7,7 +7,8 @@ "dev-back": "node --watch --no-warnings=ExperimentalWarning --loader ts-node/esm src/index.ts", "start": "tsc && node dist/index.js", "prettier": "prettier --write src/", - "test": "vitest --ui" + "test": "vitest", + "test-ui": "vitest --ui" }, "author": "Jarod Couprie", "devDependencies": { @@ -19,6 +20,8 @@ "@types/multer": "^1.4.11", "@types/node": "^20.14.15", "@types/supertest": "^6.0.2", + "@types/swagger-jsdoc": "^6.0.4", + "@types/swagger-ui-express": "^4.1.6", "@vitest/coverage-v8": "^2.0.5", "@vitest/ui": "^2.0.5", "dotenv": "^16.4.5", @@ -40,6 +43,9 @@ "minio": "^8.0.1", "multer": "^1.4.5-lts.1", "mysql2": "^3.11.0", + "socket.io": "^4.7.5", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", "winston": "^3.14.2", "zod": "^3.23.8" } diff --git a/api-server/sql/data.sql b/api-server/sql/data.sql index 5920995..66782c4 100644 --- a/api-server/sql/data.sql +++ b/api-server/sql/data.sql @@ -24,24 +24,73 @@ VALUES (1, 2), INSERT INTO expense(type, amount, motivation, status, id_owner, id_validator, facturation_date) -VALUES ("TRAVEL", 300, "Voyage d'affaire", "WAITING", 2, null, '2024-03-11'), - ("COMPENSATION", 50, "Indemnisation", "REFUNDED", 2, 1, '2023-05-19'), - ("FOOD", 100, "Repas pro", "WAITING", 1, null, '2024-04-15'), - ("HOUSING", 149, "Hotel", "WAITING", 1, null, '2024-04-01'), +VALUES ("TRAVEL", 300, "Voyage d'affaire", "WAITING", 2, null, '2024-09-01'), + ("COMPENSATION", 50, "Indemnisation", "REFUNDED", 2, 1, '2023-09-01'), + ("FOOD", 100, "Repas pro", "WAITING", 1, null, '2024-09-01'), + ("HOUSING", 149, "Hotel", "WAITING", 1, null, '2024-09-01'), ("TRAVEL", 300, "Voyage d'affaire", "WAITING", 2, null, '2024-03-11'), - ("COMPENSATION", 50, "Compensation accident", "REFUNDED", 2, 1, '2023-12-13'), + ("COMPENSATION", 50, "Compensation accident", "REFUNDED", 2, 1, '2024-09-02'), ("FOOD", 102, "Repas pro", "WAITING", 1, null, '2024-04-15'), ("HOUSING", 149, "Hotel", "WAITING", 1, null, '2024-04-01'), ("TRAVEL", 300, "Voyage d'affaire", "WAITING", 2, null, '2024-03-11'), - ("COMPENSATION", 89, "Prime", "REFUNDED", 2, 1, '2023-12-13'), - ("FOOD", 20.99, "Restauration en déplacement", "NOT_REFUNDED", 1, null, '2024-04-15'), - ("HOUSING", 149, "Hotel", "WAITING", 1, null, '2024-05-01'), - ("TRAVEL", 300, "Voyage d'affaire", "WAITING", 2, null, '2024-03-11'), - ("COMPENSATION", 50, "Indemnisation", "REFUNDED", 2, 1, '2023-05-13'), + ("COMPENSATION", 89, "Prime", "REFUNDED", 2, 1, '2023-09-01'), + ("FOOD", 20.99, "Restauration en déplacement", "NOT_REFUNDED", 1, null, '2024-09-01'), + ("COMPENSATION", 20.99, "Raison non valide", "NOT_REFUNDED", 2, null, '2024-09-01'), + ("FOOD", 20.99, "Restauration en déplacement", "NOT_REFUNDED", 2, null, '2024-09-01'), + ("HOUSING", 149, "Hotel", "NOT_REFUNDED", 1, null, '2024-09-03'), + ("TRAVEL", 300, "Voyage d'affaire", "REFUNDED", 2, 1, '2024-09-03'), + ("COMPENSATION", 50, "Indemnisation", "REFUNDED", 2,1, '2023-09-01'), + ("TRAVEL", 300, "Voyage d'affaire", "REFUNDED", 1, 2, '2024-09-03'), + ("COMPENSATION", 50, "Indemnisation", "REFUNDED", 1, 2, '2023-09-01'), ("FOOD", 34.99, "Repas d'affaire", "WAITING", 1, null, '2024-04-15'), ("FOOD", 34.99, "Repas de test", "REFUNDED", 1, null, '2024-04-15'), ("HOUSING", 99, "AirBNB", "NOT_REFUNDED", 1, null, '2024-05-01'); INSERT INTO `demand`(`id`, `start_date`, `end_date`, `motivation`, `created_at`, `status`, `type`, `number_day`, `id_owner`) -VALUES ('1', CURRENT_DATE, CURRENT_DATE, 'je suis motive', CURRENT_DATE, 'WAITING', 'CA', 1, '1'); \ No newline at end of file +VALUES ('1', CURRENT_DATE, CURRENT_DATE, 'je suis motive', CURRENT_DATE, 'WAITING', 'CA', 1, '1'); +INSERT INTO address (id, street, streetNumber, locality, zipcode, lat, lng) VALUES + (101, 'Rue aux arènes', '86', 'Metz', '57000', 49.1068, 6.1764), + (102, 'Rue de la République', '45', 'Lyon', '69002', 45.7640, 4.8357), + (103, 'Boulevard Michelet', '90', 'Marseille', '13008', 43.2965, 5.3698), + (104, 'Rue Nationale', '12', 'Lille', '59800', 50.6292, 3.0573), + (105, 'Avenue Jean Jaurès', '34', 'Toulouse', '31000', 43.6047, 1.4442); + +INSERT INTO agency (id, label, id_address) VALUES + (1, 'Metz Numérique School', 101), + (2, 'Agence Lyon Nord', 102), + (3, 'Agence Marseille Est', 103), + (4, 'Agence Lille Ouest', 104), + (5, 'Agence Toulouse Sud', 105); + +INSERT INTO service (id, label, minimum_users, id_user_lead_service, id_agency) VALUES + (1, 'Service Educatif', 5, 1, 1), + (2, 'Service IT', 10, 1, 2), + (3, 'Service Commercial', 8, 1, 3), + (4, 'Service Marketing', 6, 2, 4), + (5, 'Service Logistique', 7, 2, 5); + +INSERT INTO team (id, label, minimum_users, id_user_lead_team, id_service) VALUES + (1, 'Équipe Intervenant', 3, 1, 1), + (2, 'Équipe Support IT', 5, 1, 2), + (3, 'Équipe Vente', 4, 2, 3), + (4, 'Équipe SEO', 4, 2, 4), + (5, 'Équipe Transport', 6, 2, 5); + +INSERT INTO belong_team (id_team, id_user) VALUES + (1, 1), + (1, 2), + (2, 1), + (2, 2), + (3, 1), + (3, 2), + (4, 1), + (4, 2), + (5, 1), + (5, 2); + + + + +INSERT INTO `notification` (`description`, `type`, `id_receiver`, `id_sender`, touched) +VALUES ('Première notification que je dois décrire simplement', 'EXPENSE', '1', '2', true); \ No newline at end of file diff --git a/api-server/sql/db.sql b/api-server/sql/db.sql index 2067e2a..d6094b1 100644 --- a/api-server/sql/db.sql +++ b/api-server/sql/db.sql @@ -1,4 +1,4 @@ -DROP TABLE IF EXISTS belong_team, agency, address, service, team, expense, demand, users, role, own_role; +DROP TABLE IF EXISTS belong_team, agency, address, service, team, expense, demand, users, role, own_role, notification; CREATE TABLE address ( @@ -75,7 +75,7 @@ CREATE TABLE expense id_owner BIGINT NOT NULL, id_validator BIGINT NULL, file_key VARCHAR(255) NULL, - validated_at DATETIME NULL, + validated_at DATETIME NULL, PRIMARY KEY (id), FOREIGN KEY (id_owner) REFERENCES users (id), FOREIGN KEY (id_validator) REFERENCES users (id) @@ -112,7 +112,7 @@ CREATE TABLE team id_service BIGINT NOT NULL, PRIMARY KEY (id), FOREIGN KEY (id_user_lead_team) REFERENCES users (id), - FOREIGN KEY (id_service) REFERENCES service (id) + FOREIGN KEY (id_service) REFERENCES service (id) ON DELETE CASCADE ); CREATE TABLE belong_team @@ -120,7 +120,7 @@ CREATE TABLE belong_team id_team BIGINT, id_user BIGINT, PRIMARY KEY (id_team, id_user), - FOREIGN KEY (id_team) REFERENCES team (id), + FOREIGN KEY (id_team) REFERENCES team (id) ON DELETE CASCADE, FOREIGN KEY (id_user) REFERENCES users (id) ); @@ -132,3 +132,16 @@ CREATE TABLE own_role FOREIGN KEY (id_user) REFERENCES users (id), FOREIGN KEY (id_role) REFERENCES role (id) ); + +CREATE TABLE notification +( + id BIGINT UNIQUE NOT NULL AUTO_INCREMENT, + description VARCHAR(255), + type VARCHAR(50), + id_receiver BIGINT, + id_sender BIGINT, + touched BOOLEAN NOT NULL DEFAULT FALSE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + FOREIGN KEY (id_receiver) REFERENCES users (id) +) diff --git a/api-server/src/common/enum/NotificationType.ts b/api-server/src/common/enum/NotificationType.ts new file mode 100644 index 0000000..8fd6561 --- /dev/null +++ b/api-server/src/common/enum/NotificationType.ts @@ -0,0 +1,4 @@ +export enum NotificationType { + EXPENSE = "EXPENSE", + DEMAND = "DEMAND", +} diff --git a/api-server/src/common/helper/NotificationSender.ts b/api-server/src/common/helper/NotificationSender.ts new file mode 100644 index 0000000..a60fd46 --- /dev/null +++ b/api-server/src/common/helper/NotificationSender.ts @@ -0,0 +1,10 @@ +import { getIo, userSockets } from "./Socket"; + +export class NotificationSender { + static send = (data: any, userId: number) => { + const socket = userSockets[userId]; + if (socket) { + socket.emit("notification", { data }); + } + }; +} diff --git a/api-server/src/common/helper/Socket.ts b/api-server/src/common/helper/Socket.ts new file mode 100644 index 0000000..2f940e7 --- /dev/null +++ b/api-server/src/common/helper/Socket.ts @@ -0,0 +1,47 @@ +import { Server } from "socket.io"; +import { logger } from "./Logger"; +import jwt from "jsonwebtoken"; + +let io: any; + +export const userSockets: any = {}; + +export const initSocket = () => { + const corsOptions = { + origin: "http://localhost:3000", + credentials: true, + }; + + io = new Server(4000, { + cors: corsOptions, + }); + + io.on("connection", (socket: any) => { + const token = socket.handshake.query.token; + const secret = process.env.ACCESS_TOKEN_SECRET ?? "secret"; + + jwt.verify(token, secret, (err: any, decoded: any) => { + if (err) { + logger.error("Invalid JWT"); + return socket.disconnect(); + } + + const userId = decoded.userId; + userSockets[userId] = socket; + + io.on("disconnect", () => { + console.log(`Utilisateur ${userId} déconnecté`); + delete userSockets[userId]; + }); + }); + }); + + // return io; +}; + +export const getIo = () => { + if (!io) { + logger.error("Socket.io not initialized"); + } + return io; +}; diff --git a/api-server/src/common/middleware/AuthMiddleware.ts b/api-server/src/common/middleware/AuthMiddleware.ts index bb81cd5..ea25389 100644 --- a/api-server/src/common/middleware/AuthMiddleware.ts +++ b/api-server/src/common/middleware/AuthMiddleware.ts @@ -7,7 +7,7 @@ dotenv.config(); export function verifyToken(req: Request, res: Response, next: NextFunction) { const token = req.headers.authorization?.replace("Bearer ", ""); - if (!token) return res.status(401).json({ error: "Access denied" }); + if (!token) return res.status(401).json({ error: "Accès refusé" }); try { (req as CustomRequest).token = ( jwt.verify(token, String(process.env.ACCESS_TOKEN_SECRET)) diff --git a/api-server/src/common/middleware/ValidationMiddleware.ts b/api-server/src/common/middleware/ValidationMiddleware.ts deleted file mode 100644 index 7b9c733..0000000 --- a/api-server/src/common/middleware/ValidationMiddleware.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { NextFunction, Request, Response } from "express"; -import { - baseObjectInputType, - baseObjectOutputType, - objectUtil, - ZodDate, - ZodEffects, - ZodError, - ZodNumber, - ZodObject, - ZodString, - ZodTypeAny, -} from "zod"; - -export function validateData( - schema: ZodEffects< - ZodObject< - { - endDate: ZodString; - type: ZodString; - startDate: ZodString; - }, - "strip", - ZodTypeAny, - { - [k in keyof objectUtil.addQuestionMarks< - baseObjectOutputType<{ - endDate: ZodString; - type: ZodString; - startDate: ZodString; - }>, - any - >]: objectUtil.addQuestionMarks< - baseObjectOutputType<{ - endDate: ZodString; - type: ZodString; - startDate: ZodString; - }>, - any - >[k]; - }, - { - [k_1 in keyof baseObjectInputType<{ - endDate: ZodString; - type: ZodString; - startDate: ZodString; - }>]: baseObjectInputType<{ - endDate: ZodString; - type: ZodString; - startDate: ZodString; - }>[k_1]; - } - >, - { - [k in keyof objectUtil.addQuestionMarks< - baseObjectOutputType<{ - endDate: ZodString; - type: ZodString; - startDate: ZodString; - }>, - any - >]: objectUtil.addQuestionMarks< - baseObjectOutputType<{ - endDate: ZodString; - type: ZodString; - startDate: ZodString; - }>, - any - >[k]; - }, - { - [k_1 in keyof baseObjectInputType<{ - endDate: ZodString; - type: ZodString; - startDate: ZodString; - }>]: baseObjectInputType<{ - endDate: ZodString; - type: ZodString; - startDate: ZodString; - }>[k_1]; - } - >, -) { - return (req: Request, res: Response, next: NextFunction) => { - try { - schema.parse(req.body); - next(); - } catch (error) { - if (error instanceof ZodError) { - const errorMessages = error.errors.map((issue: any) => ({ - message: `${issue.path.join(".")} ${issue.message}`, - })); - res.status(400).json({ error: "Invalid data", details: errorMessages }); - } else { - res.status(500).json({ error: "Internal Server Error" }); - } - } - }; -} - -export function validateExpenseData( - schema: ZodEffects< - ZodObject< - { - facturation_date: ZodDate; - amount: ZodNumber; - motivation: ZodString; - type: ZodString; - }, - "strip", - ZodTypeAny, - { - [k in keyof objectUtil.addQuestionMarks< - baseObjectOutputType<{ - facturation_date: ZodDate; - amount: ZodNumber; - motivation: ZodString; - type: ZodString; - }>, - any - >]: objectUtil.addQuestionMarks< - baseObjectOutputType<{ - facturation_date: ZodDate; - amount: ZodNumber; - motivation: ZodString; - type: ZodString; - }>, - any - >[k]; - }, - { - [k_1 in keyof baseObjectInputType<{ - facturation_date: ZodDate; - amount: ZodNumber; - motivation: ZodString; - type: ZodString; - }>]: baseObjectInputType<{ - facturation_date: ZodDate; - amount: ZodNumber; - motivation: ZodString; - type: ZodString; - }>[k_1]; - } - >, - { - [k in keyof objectUtil.addQuestionMarks< - baseObjectOutputType<{ - facturation_date: ZodDate; - amount: ZodNumber; - motivation: ZodString; - type: ZodString; - }>, - any - >]: objectUtil.addQuestionMarks< - baseObjectOutputType<{ - facturation_date: ZodDate; - amount: ZodNumber; - motivation: ZodString; - type: ZodString; - }>, - any - >[k]; - }, - { - [k_1 in keyof baseObjectInputType<{ - facturation_date: ZodDate; - amount: ZodNumber; - motivation: ZodString; - type: ZodString; - }>]: baseObjectInputType<{ - facturation_date: ZodDate; - amount: ZodNumber; - motivation: ZodString; - type: ZodString; - }>[k_1]; - } - >, -) { - return (req: Request, res: Response, next: NextFunction) => { - try { - schema.parse(req.body); - next(); - } catch (error) { - if (error instanceof ZodError) { - const errorMessages = error.errors.map((issue: any) => ({ - message: `${issue.path.join(".")} ${issue.message}`, - })); - res.status(400).json({ error: "Invalid data", details: errorMessages }); - } else { - res.status(500).json({ error: "Internal Server Error" }); - } - } - }; -} diff --git a/api-server/src/common/model/Department.ts b/api-server/src/common/model/Department.ts index e3d7e0e..f705091 100644 --- a/api-server/src/common/model/Department.ts +++ b/api-server/src/common/model/Department.ts @@ -4,6 +4,9 @@ export class Department { minimum_users: number; id_user_lead_service: number; id_agency: number; + lead_service_firstname: string; + lead_service_lastname: string; + team_count: number; constructor( id: number, @@ -11,14 +14,21 @@ export class Department { minimum_users: number, id_user_lead_service: number, id_agency: number, + lead_service_firstname: string, + lead_service_lastname: string, + team_count: number, ) { this.id = id; this.label = label; this.minimum_users = minimum_users; this.id_user_lead_service = id_user_lead_service; this.id_agency = id_agency; + this.lead_service_firstname = lead_service_firstname; + this.lead_service_lastname = lead_service_lastname; + this.team_count = team_count; } } + export class CreateDepartment { label: string; minimum_users: number; diff --git a/api-server/src/common/model/Expense.ts b/api-server/src/common/model/Expense.ts index a8a3f07..11ff181 100644 --- a/api-server/src/common/model/Expense.ts +++ b/api-server/src/common/model/Expense.ts @@ -2,7 +2,7 @@ import { ExpenseStatus } from "../enum/ExpenseStatus"; import { ExpenseType } from "../enum/ExpenseType"; export class Expense { - id: string; + id: number; type: ExpenseType; amount: number; motivation: string; @@ -11,14 +11,14 @@ export class Expense { status: ExpenseStatus; id_owner: number; fileKey?: string; - id_validator: number; - justification: string; + id_validator?: number; + justification?: string; validator_firstname?: string; validator_lastname?: string; validated_at?: Date; constructor( - id: string, + id: number, type: ExpenseType, amount: number, motivation: string, @@ -26,8 +26,8 @@ export class Expense { facturation_date: Date, status: ExpenseStatus, ownerId: number, - id_validator: number, - justification: string, + id_validator?: number, + justification?: string, validator_firstname?: string, validator_lastname?: string, validated_at?: Date, @@ -38,7 +38,7 @@ export class Expense { this.amount = amount; this.motivation = motivation; this.created_at = created_at; - this.facturation_date = created_at; + this.facturation_date = facturation_date; this.status = status || ExpenseStatus.WAITING; this.id_owner = ownerId; this.fileKey = fileKey; diff --git a/api-server/src/common/model/Notification.ts b/api-server/src/common/model/Notification.ts new file mode 100644 index 0000000..e9d92c9 --- /dev/null +++ b/api-server/src/common/model/Notification.ts @@ -0,0 +1,48 @@ +import { NotificationType } from "../enum/NotificationType.js"; + +export class Notification { + id: number; + description: string; + type: NotificationType; + id_receiver: number; + id_sender: number; + touched: boolean; + created_at: Date; + + constructor( + id: number, + description: string, + type: NotificationType, + id_receiver: number, + id_sender: number, + touched: boolean, + created_at: Date, + ) { + this.id = id; + this.description = description; + this.type = type; + this.id_receiver = id_receiver; + this.id_sender = id_sender; + this.touched = touched; + this.created_at = created_at; + } +} + +export class CreateNotification { + description: string; + type: NotificationType; + id_sender: number; + id_receiver?: number; + + constructor( + description: string, + type: NotificationType, + id_sender: number, + id_receiver?: number, + ) { + this.description = description; + this.type = type; + this.id_receiver = id_receiver; + this.id_sender = id_sender; + } +} diff --git a/api-server/src/common/model/Team.ts b/api-server/src/common/model/Team.ts index c003d5d..8c8f809 100644 --- a/api-server/src/common/model/Team.ts +++ b/api-server/src/common/model/Team.ts @@ -2,23 +2,78 @@ export class Team { id: number; label: string; minimum_users: number; - id_user_lead_service: number; + id_user_lead_team: number; id_service: number; service_label: string; + lead_team_firstname: string; + lead_team_lastname: string; + lead_team_email: string; + members: TeamMembers[]; + is_present: number | null; constructor( id: number, label: string, minimum_users: number, - id_user_lead_service: number, + id_user_lead_team: number, id_service: number, service_label: string, + lead_team_firstname: string, + lead_team_lastname: string, + lead_team_email: string, + members: TeamMembers[], + is_present: number | null, ) { this.id = id; this.label = label; this.minimum_users = minimum_users; - this.id_user_lead_service = id_user_lead_service; + this.id_user_lead_team = id_user_lead_team; this.id_service = id_service; this.service_label = service_label; + this.lead_team_firstname = lead_team_firstname; + this.lead_team_lastname = lead_team_lastname; + this.lead_team_email = lead_team_email; + this.members = members; + this.is_present = is_present; + } +} + +export class TeamMembers { + id_member: number; + member_firstname: string; + member_lastname: string; + member_email: string; + member_avatar: string; + is_present: number; + + constructor(member: any) { + this.id_member = member.id_member; + this.member_firstname = member.member_firstname; + this.member_lastname = member.member_lastname; + this.member_email = member.member_email; + this.member_avatar = member.member_avatar; + this.is_present = member.is_present; + } +} + +export class CreateTeam { + label: string; + minimum_users: number; + id_user_lead_team: number; + id_service: number; + members: number[]; + + constructor( + label: string, + minimum_users: number, + id_user_lead_team: number, + id_service: number, + members: number[], + ) { + this.label = label; + this.minimum_users = minimum_users; + this.id_user_lead_team = id_user_lead_team; + this.id_service = id_service; + this.members = members; } } diff --git a/api-server/src/index.ts b/api-server/src/index.ts index b4d3ed9..3073605 100644 --- a/api-server/src/index.ts +++ b/api-server/src/index.ts @@ -10,8 +10,12 @@ import agency from "./resources/agency/AgencyController.js"; import department from "./resources/department/DepartmentController.js"; import team from "./resources/team/TeamController.js"; import userProfile from "./resources/userProfile/UserProfileController.js"; +import notification from "./resources/notification/NotificationController.js"; import cors from "cors"; import helmet from "helmet"; +import swaggerjsdoc from "swagger-jsdoc"; +import swaggerUi from "swagger-ui-express"; +import { initSocket } from "./common/helper/Socket"; dotenv.config(); @@ -22,6 +26,8 @@ const corsOptions = { credentials: true, }; +initSocket(); + app.use(express.json({ limit: "50mb" })); app.use(express.urlencoded({ limit: "50mb", extended: true })); app.use(cors(corsOptions)); @@ -36,6 +42,7 @@ app.use("/api/agency", agency); app.use("/api/service", department); app.use("/api/team", team); app.use("/api/profile", userProfile); +app.use("/api/notification", notification); app.get("/", verifyToken, (req: Request, res: Response) => { res.send("API SIRH"); @@ -47,4 +54,24 @@ if (process.env.NODE_ENV !== "test") { }); } +const swaggerOptions = { + swaggerDefinition: { + openapi: "3.0.0", + info: { + title: "API SIRH", + description: "API de gestion des données de l'application SIRH", + }, + servers: [ + { + url: "http://localhost:5000/", + }, + ], + }, + apis: ["./src/resources/**/*.ts"], +}; + +// @ts-ignore +const swaggerDocs = swaggerjsdoc(swaggerOptions); +app.use("/api-doc", swaggerUi.serve, swaggerUi.setup(swaggerDocs)); + export default app; diff --git a/api-server/src/resources/agency/AgencyController.ts b/api-server/src/resources/agency/AgencyController.ts index d5a1771..05795ca 100644 --- a/api-server/src/resources/agency/AgencyController.ts +++ b/api-server/src/resources/agency/AgencyController.ts @@ -1,14 +1,95 @@ import { Request, Response, Router } from "express"; import { verifyToken } from "../../common/middleware/AuthMiddleware.js"; import { AgencyService } from "./AgencyService.js"; +import { CustomRequest } from "../../common/helper/CustomRequest.js"; +import { DemandService } from "../demand/DemandService.js"; const router = Router(); +/** + * @swagger + * /api/agency/: + * get: + * summary: Récupère les agences. + * description: Récupère les agences . + * responses: + * 200: + * description: Retourne les agences souhaitées + * content: + * application/json: + * schema: + * type: object + * properties: + * totalData: + * type: integer + * description: Nombre d'agence trouvées + * list: + * type: array + * description: Liste des agences retrouvées + * items: + * type: object + * properties: + * id: + * type: integer + * description: identifiant de l'agence + * label: + * type: string + * description: nom de l'agence + * street: + * type: string + * description: rue de l'agence + * streetNumber: + * type: integer + * description: numéro de rue de l'agence + * locality: + * type: string + * description: nom de la ville de l'agence + * zipcode: + * type: integer + * description: code postale de l'agence + * lat: + * type: integer + * description: lattitude de l'agence + * lng: + * type: integer + * description: longitude de l'agence + * 500: + * description: Échec de la récupération de l'agence + */ router.get("/", verifyToken, async (req: Request, res: Response) => { const { code, message, data } = await AgencyService.getAgency(req); res.status(code).json({ message, data }); }); +/** + * @swagger + * /api/agency/coordinates: + * get: + * summary: Récupère les coordonnées de l'agence. + * description: Récupère les coordonnées de l'agence . + * responses: + * 200: + * description: Retourne les coordonnées de l'agence souhaitée + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: integer + * description: identifiant de l'agence + * label: + * type: string + * description: nom de l'agence + * lat: + * type: integer + * description: lattitude de l'agence + * lng: + * type: integer + * description: longitude de l'agence + * 500: + * description: Échec de la récupération des coordonnées l'agence + */ router.get("/coordinates", verifyToken, async (req: Request, res: Response) => { const { code, message, data } = await AgencyService.getAgencyCoord(req); res.status(code).json({ message, data }); @@ -20,6 +101,39 @@ router.get("/data", verifyToken, async (req: Request, res: Response) => { res.status(code).json({ message, data }); }); +/** + * @swagger + * /api/agency/{id_agency}: + * get: + * summary: Récupère une agence. + * description: Récupère une agence en fonction de l'id en paramètre . + * parameters: + * - in: path + * name: id_agency + * schema: + * type: integer + * required: true + * description: Id de l'agence + * responses: + * 200: + * description: Retourne l'agence souhaitée + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: integer + * description: identifiant de l'agence + * label: + * type: string + * description: nom de l'agence + * address: + * type: string + * description: adresse de l'agence + * 500: + * description: Échec de la récupération des coordonnées l'agence + */ router.get("/:id", verifyToken, async (req: Request, res: Response) => { const { code, message, data } = await AgencyService.getAgencyById( +req.params.id, @@ -47,4 +161,15 @@ router.post( }, ); +router.delete( + "/:id_agency", + verifyToken, + async (req: Request, res: Response) => { + const { code, message, data } = await AgencyService.deleteAgency( + +req.params.id_agency, + ); + res.status(code).json({ message, data }); + }, +); + export default router; diff --git a/api-server/src/resources/agency/AgencyRepository.ts b/api-server/src/resources/agency/AgencyRepository.ts index 665a59d..e2cac0a 100644 --- a/api-server/src/resources/agency/AgencyRepository.ts +++ b/api-server/src/resources/agency/AgencyRepository.ts @@ -74,7 +74,7 @@ export class AgencyRepository { public static async getDemandGroupedByMonth() { const [rows] = await this.pool.query( - `SELECT DATE_FORMAT(created_at, '%Y-%m') AS date, + `SELECT DATE_FORMAT(start_date, '%Y-%m') AS date, COUNT(*) AS count FROM demand WHERE status = 'ACCEPTED' @@ -83,6 +83,41 @@ export class AgencyRepository { ); return rows; } + public static async getDemandGroupedByWeek() { + const [rows] = await this.pool.query( + `SELECT DATE_FORMAT(start_date, '%Y-%m-%d') AS date, + COUNT(*) AS count + FROM demand + WHERE status = 'ACCEPTED' + AND WEEKDAY(start_date) IN (0, 4) + GROUP BY date + ORDER BY date`, + ); + return rows; + } + + public static async countUserInAgency(idAgency: number) { + const [result]: any = await this.pool.query( + ` + SELECT agency.label AS agency_name, + COUNT(users.id) AS total_users, + COUNT(CASE WHEN demand.id IS NULL THEN 1 END) AS total_present, + COUNT(CASE WHEN demand.id IS NOT NULL THEN 1 END) AS total_absent + FROM users + JOIN belong_team ON users.id = belong_team.id_user + JOIN team ON belong_team.id_team = team.id + JOIN service ON team.id_service = service.id + JOIN agency ON service.id_agency = agency.id + LEFT JOIN demand ON users.id = demand.id_owner + AND demand.status = 'ACCEPTED' + AND CURDATE() BETWEEN demand.start_date AND demand.end_date + WHERE agency.id = ? + GROUP BY agency.label; + `, + [idAgency], + ); + return result; + } public static async createAgency(agency: CreateAgency) { const [rows]: any = await this.pool.query( @@ -95,4 +130,16 @@ export class AgencyRepository { ); return rows[0]; } + + public static async deleteAgency(id: number) { + const [rows]: any = await this.pool.query( + ` + DELETE + FROM agency + WHERE id = ?; + `, + [id], + ); + return rows[0]; + } } diff --git a/api-server/src/resources/agency/AgencyService.ts b/api-server/src/resources/agency/AgencyService.ts index 1cbdca2..c97be1a 100644 --- a/api-server/src/resources/agency/AgencyService.ts +++ b/api-server/src/resources/agency/AgencyService.ts @@ -7,6 +7,9 @@ import { logger } from "../../common/helper/Logger.js"; import { AgencyRepository } from "./AgencyRepository.js"; import { AgencyCoord, AgencyDTO, AgencyList } from "./dto/AgencyDTO.js"; import { AgencyEntity } from "../../common/entity/agency/agency.entity.js"; +import { DemandRepository } from "../demand/DemandRepository.js"; +import { UserService } from "../user/UserService.js"; +import { updateUserDays } from "../demand/DemandService.js"; export class AgencyService { public static async getAgency(req: Request) { @@ -62,6 +65,9 @@ export class AgencyService { public static async getDemandGroupedByMonthData(req: Request) { const agencyData: any = await AgencyRepository.getDemandGroupedByMonth(); + const weekData: any = await AgencyRepository.getDemandGroupedByWeek(); + const userAgency: any = await AgencyRepository.countUserInAgency(1); + return new ControllerResponse(200, "", agencyData); } @@ -157,7 +163,6 @@ export class AgencyService { return new ControllerResponse(401, "L'agence n'existe pas"); } const agencyToSend = new AgencyList(agency); - return new ControllerResponse( 200, "Adresse de l'agence modifiée", @@ -171,4 +176,19 @@ export class AgencyService { ); } } + + public static async deleteAgency(id: number) { + try { + const agency: any = await AgencyRepository.getAgencyById(+id); + + if (!agency) { + return new ControllerResponse(404, "pas d'agence"); + } + await AgencyRepository.deleteAgency(+id); + return new ControllerResponse(200, ""); + } catch (error) { + logger.error(`Failed to delete the agency. Error: ${error}`); + return new ControllerResponse(500, "Failed to delete the agency"); + } + } } diff --git a/api-server/src/resources/demand/DemandController.ts b/api-server/src/resources/demand/DemandController.ts index a59a22b..fedd692 100644 --- a/api-server/src/resources/demand/DemandController.ts +++ b/api-server/src/resources/demand/DemandController.ts @@ -14,12 +14,171 @@ const upload = multer({ limits: { fileSize: 50 * 1024 * 1024 }, // Set the file size limit (50MB in this case) }); -router.get("/", verifyToken, async (req: Request, res: Response) => { +/** + * @swagger + * /api/demand/list/{type}: + * get: + * summary: Récupère les demandes de l'utilisateur. + * description: Récupère les demandes de l'utilisateur selon le type passé en paramètre. + * parameters: + * - in: path + * name: type + * schema: + * type: string + * required: true + * description: Type de demande + * responses: + * 200: + * description: Retourne les demandes souhaitées + * content: + * application/json: + * schema: + * type: object + * properties: + * totalData: + * type: integer + * description: Nombre de demandes trouvées + * list: + * type: array + * description: Liste des demandes retrouvées + * items: + * type: object + * properties: + * id: + * type: string + * description: identifiant de la demande + * start_date: + * type: string + * format: date + * description : date de début de la demande + * end_date: + * type: string + * format: date + * description : date de fin de la demande + * created_at: + * type: string + * format: date + * description: date de création de la demande + * motivation: + * type: string + * description: motivation de la demande + * required: false + * justification: + * type: string + * description: justification de la demande + * required: false + * status: + * type: string + * description: Status actuel de la demande (DRAFT, WAITING, ACCPETED, DENIED) + * number_day: + * type: integer + * description: nombre de jour de la demande + * id_owner: + * type: integer + * description: id du créateur de la demande + * type: + * type: string + * description: Type de la demande (CA, TT, RTT, ABSENCE, SICKNESS) + * file_url: + * type: string + * description: url du fichier donnée avec la demande + * id_validator: + * type: integer + * description: id du validateur de la demande + * required: false + * validated_at: + * type: string + * format: date + * description: date de validation + * required: false + * + * + * 500: + * description: Échec de la récupération de la demande + */ +router.get("/list/:type", verifyToken, async (req: Request, res: Response) => { let userId = (req as CustomRequest).token.userId; - const { code, message, data } = await DemandService.getDemand(userId, req); + const { code, message, data } = await DemandService.getDemand( + userId, + req, + req.params.type, + ); res.status(code).json({ message, data }); }); +/** + * @swagger + * /api/demand/{id_demand}: + * get: + * summary: Récupère une demande de l'utilisateur. + * description: Récupère une demande de l'utilisateur selon l'id passé en paramètre.' + * parameters: + * - in: path + * name: id_demand + * schema: + * type: integer + * required: true + * description: Id de la demande + * responses: + * 200: + * description: Retourne la demande souhaitée + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * description: identifiant de la demande + * start_date: + * type: string + * format: date + * description : date de début de la demande + * end_date: + * type: string + * format: date + * description : date de fin de la demande + * created_at: + * type: string + * format: date + * description: date de création de la demande + * motivation: + * type: string + * description: motivation de la demande + * required: false + * justification: + * type: string + * description: justification de la demande + * required: false + * status: + * type: string + * description: Status actuel de la demande (DRAFT, WAITING, ACCPETED, DENIED) + * number_day: + * type: integer + * description: nombre de jour de la demande + * id_owner: + * type: integer + * description: id du créateur de la demande + * type: + * type: string + * description: Type de la demande (CA, TT, RTT, ABSENCE, SICKNESS) + * file_url: + * type: string + * description: url du fichier donnée avec la demande + * id_validator: + * type: integer + * description: id du validateur de la demande + * required: false + * validated_at: + * type: string + * format: date + * description: date de validation + * required: false + * + * + * 500: + * description: Échec de la récupération de la demande + */ router.get("/:id_demand", verifyToken, async (req: Request, res: Response) => { const { code, message, data } = await DemandService.getDemandById( req.params.id_demand, @@ -27,6 +186,51 @@ router.get("/:id_demand", verifyToken, async (req: Request, res: Response) => { res.status(code).json({ message, data }); }); +/** + * @swagger + * /api/demand/: + * post: + * summary: Créer une nouvelle demande + * description: Créer une nouvelle demande au nom de l'utilisateur connecté + * requestBody: + * required: true + * content: + * demand: + * schema: + * type: object + * properties: + * start_date: + * type: string + * format: date + * description : date de début de la demande + * end_date: + * type: string + * format: date + * description : date de fin de la demande + * motivation: + * type: string + * description: Justification et explication du contexte de la demande + * status: + * type: string + * description: Status actuel de la demande (DRAFT, WAITING, ACCPETED, DENIED) + * type: + * type: string + * description: Type de la demande (CA, TT, RTT, ABSENCE, SICKNESS) + * number_day: + * type: integer + * description: nombre de jour de la demande + * file_url: + * type: string + * description: url du fichier donnée avec la demande + * id_owner: + * type: integer + * description: id du créateur de la demande + * responses: + * 200: + * description: Création effectuée avec succès + * 500: + * description: Échec de la création de la demande + */ router.post( "/", verifyToken, @@ -44,6 +248,48 @@ router.post( }, ); +/** + * @swagger + * /api/demand/{id_demand}: + * put: + * summary: Modifier une demande + * description: Modifier une demande au nom de l'utilisateur connecté + * requestBody: + * required: true + * content: + * demand: + * schema: + * type: object + * properties: + * start_date: + * type: string + * format: date + * description : date de début de la demande + * end_date: + * type: string + * format: date + * description : date de fin de la demande + * motivation: + * type: string + * description: Justification et explication du contexte de la demande + * status: + * type: string + * description: Status actuel de la demande (DRAFT, WAITING, ACCPETED, DENIED) + * type: + * type: string + * description: Type de la demande (CA, TT, RTT, ABSENCE, SICKNESS) + * number_day: + * type: integer + * description: nombre de jour de la demande + * file_url: + * type: string + * description: url du fichier donnée avec la demande + * responses: + * 200: + * description: Modification effectuée avec succès + * 500: + * description: Échec de la modification de la demande + */ router.put( "/:id_demand", verifyToken, @@ -58,7 +304,31 @@ router.put( res.status(code).json({ message, data }); }, ); - +/** + * @swagger + * /api/demand/status/{id_demand}: + * put: + * summary: Modifier le status d'une demande + * description: Modifier le status d'une demande au nom de l'utilisateur connecté + * requestBody: + * required: true + * content: + * demand: + * schema: + * type: object + * properties: + * id: + * type: integer + * description : Id de la demande + * status: + * type: string + * description: Status actuel de la demande (DRAFT, WAITING, ACCPETED, DENIED) + * responses: + * 200: + * description: Modification du status effectuée avec succès + * 500: + * description: Échec de la modification du status de la demande + */ router.put( "/status/:id_demand", verifyToken, @@ -72,6 +342,26 @@ router.put( }, ); +/** + * @swagger + * /api/demand/{id_demand}: + * delete: + * summary: Suppression d'une demande + * description: Suppression d'une demande au nom de l'utilisateur connecté + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: Id de la demande pour suppression + * responses: + * 200: + * description: Suppression effectuée avec succès + * 500: + * description: Échec de la suppression de la demande + */ + router.delete( "/:id_demand", verifyToken, @@ -84,6 +374,84 @@ router.delete( ); res.status(code).json({ message, data }); }, + /** + * @swagger + * /api/demand/validation/list/{userId}: + * get: + * summary: Récupère la liste des demandes de l'utilisateur. + * description: Récupère la liste des demandes de l'utilisateur selon l'id passé en paramètre.' + * parameters: + * - in: path + * name: userId + * schema: + * type: integer + * required: true + * description: Id de l'utilisateur + * responses: + * 200: + * description: Retourne la demande souhaitée + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * description: identifiant de de la demande + * start_date: + * type: string + * format: date + * description : date de début de la demande + * end_date: + * type: string + * format: date + * description : date de fin de la demande + * created_at: + * type: string + * format: date + * description: date de création de la demande + * motivation: + * type: string + * description: motivation de la demande + * required: false + * justification: + * type: string + * description: justification de la demande + * required: false + * status: + * type: string + * description: Status actuel de la demande (DRAFT, WAITING, ACCPETED, DENIED) + * number_day: + * type: integer + * description: nombre de jour de la demande + * id_owner: + * type: integer + * description: id du créateur de la demande + * type: + * type: string + * description: Type de la demande (CA, TT, RTT, ABSENCE, SICKNESS)e + * id_validator: + * type: integer + * description: id du validateur de la demande + * required: false + * validator_firstname: + * type: string + * description: prenom du validateur de la demande + * required: false + * validator_lastname: + * type: integer + * description: nom du validateur de la demande + * required: false + * validated_at: + * type: string + * format: date + * description: date de validation + * required: false + * + * + * 500: + * description: Échec de la récupération de la demande + */ ); router.get( @@ -99,6 +467,38 @@ router.get( }, ); +/** + * @swagger + * /api/demand/confirm/{id_demand}: + * put: + * summary: Modifier le status d'une demande a 'ACCEPTED' + * description: Modifier la validation d'une demande a 'ACCEPTED' au nom de l'utilisateur validateur + * requestBody: + * required: true + * content: + * demand: + * schema: + * type: object + * properties: + * id: + * type: integer + * description : Id de la demande + * validatorId: + * type: integer + * description: Id de la personne qui valide la demande + * status: + * type: string + * description: Status actuel de la demande (DRAFT, WAITING, ACCPETED, DENIED) + * validated_at: + * type: string + * format: date + * description: date de validation de la demande + * responses: + * 200: + * description: Modification effectuée avec succès + * 500: + * description: Échec de la modification du status de la demande + */ router.put( "/confirm/:id", verifyToken, @@ -114,6 +514,38 @@ router.put( }, ); +/** + * @swagger + * /api/demand/reject/{id_demand}: + * put: + * summary: Modifier le status d'une demande a 'DENIED' + * description: Modifier la validation d'une demande a 'DENIED' au nom de l'utilisateur validateur + * requestBody: + * required: true + * content: + * demand: + * schema: + * type: object + * properties: + * id: + * type: integer + * description : Id de la demande + * validatorId: + * type: integer + * description: Id de la personne qui valide la demande + * status: + * type: string + * description: Status actuel de la demande (DRAFT, WAITING, ACCPETED, DENIED) + * validated_at: + * type: string + * format: date + * description: date de validation de la demande + * responses: + * 200: + * description: Modification effectuée avec succès + * 500: + * description: Échec de la modification du status de la demande + */ router.put( "/reject/:id", verifyToken, diff --git a/api-server/src/resources/demand/DemandService.ts b/api-server/src/resources/demand/DemandService.ts index 81b8366..8578c6d 100644 --- a/api-server/src/resources/demand/DemandService.ts +++ b/api-server/src/resources/demand/DemandService.ts @@ -20,6 +20,13 @@ import { User } from "../../common/model/User.js"; import { DemandEntity } from "../../common/entity/demand/demand.entity.js"; import { DemandType } from "../../common/enum/DemandType"; import { DemandStatus } from "../../common/enum/DemandStatus"; +import { ExpenseRepository } from "../expense/ExpenseRepository"; +import { CreateNotification } from "../../common/model/Notification"; +import { NotificationType } from "../../common/enum/NotificationType"; +import { NotificationRepository } from "../notification/NotificationRepository"; +import { NotificationSender } from "../../common/helper/NotificationSender"; +import { NotificationService } from "../notification/NotificationService"; +import { RoleEnum } from "../../common/enum/RoleEnum"; export function calculateNumberOfDays( start_date: Date, @@ -97,11 +104,10 @@ export function updateUserDays( } export class DemandService { - public static async getDemand(userId: number, req: Request) { + public static async getDemand(userId: number, req: Request, type: string) { try { const pageSize = req.query.pageSize || "0"; const pageNumber = req.query.pageNumber || "10"; - const type = req.query.type?.toString() || ""; const limit = +pageSize; const offset = (+pageNumber - 1) * +pageSize; let demandCount = await DemandRepository.getDemandCountWithType(type); @@ -111,7 +117,7 @@ export class DemandService { offset, type, ); - if (!type) { + if (type === "All") { demands = await DemandRepository.getDemandByUser(userId, limit, offset); demandCount = await DemandRepository.geCountByUserId(userId); } @@ -159,6 +165,28 @@ export class DemandService { status: DemandStatus.WAITING, }; const statusChange = await DemandRepository.editStatusDemand(demand_); + + const updatedDemand = await DemandRepository.getDemandById(+id); + + const notification = new CreateNotification( + "Une demande attend votre validation", + NotificationType.DEMAND, + updatedDemand.id, + ); + + await NotificationService.createNotificationsFromUserRoles(notification, [ + RoleEnum.HR, + RoleEnum.ADMIN, + RoleEnum.LEAVE_MANAGER, + ]); + + const notificationCount = + await NotificationRepository.getUntouchedNotificationsCountByUserId( + updatedDemand.id_owner, + ); + + NotificationSender.send(notificationCount, updatedDemand.id_owner); + return new ControllerResponse(200, "", statusChange); } catch (error) { logger.error(`Failed to edit the demand. Error: ${error}`); @@ -360,6 +388,23 @@ export class DemandService { try { const demand = new ConfirmDemand(id, userId); await DemandRepository.confirmDemand(demand); + + const updatedDemand = await DemandRepository.getDemandById(id); + const notification = new CreateNotification( + "Votre demande a été validée", + NotificationType.DEMAND, + updatedDemand.id, + updatedDemand.id_owner, + ); + + await NotificationRepository.createNotification(notification); + const notificationCount = + await NotificationRepository.getUntouchedNotificationsCountByUserId( + updatedDemand.id_owner, + ); + + NotificationSender.send(notificationCount, updatedDemand.id_owner); + return new ControllerResponse(200, "Demande acceptée avec succès"); } catch (error) { logger.error(`Failed to confirm demand. Error: ${error}`); @@ -402,6 +447,23 @@ export class DemandService { } await UserRepository.updateUserDays(user.id, user.rtt, user.ca, user.tt); + + const updatedDemand = await DemandRepository.getDemandById(id); + const notification = new CreateNotification( + "Votre demande a été rejetée", + NotificationType.DEMAND, + updatedDemand.id, + updatedDemand.id_owner, + ); + + await NotificationRepository.createNotification(notification); + const notificationCount = + await NotificationRepository.getUntouchedNotificationsCountByUserId( + updatedDemand.id_owner, + ); + + NotificationSender.send(notificationCount, updatedDemand.id_owner); + return new ControllerResponse(200, "Demande rejetée avec succès"); } catch (error) { logger.error(`Failed to reject demand. Error: ${error}`); diff --git a/api-server/src/resources/department/DepartmentController.ts b/api-server/src/resources/department/DepartmentController.ts index 7508efc..e65e8a8 100644 --- a/api-server/src/resources/department/DepartmentController.ts +++ b/api-server/src/resources/department/DepartmentController.ts @@ -5,6 +5,63 @@ import { DepartmentService } from "./DepartmentService.js"; const router = Router(); +/** + * @swagger + * /api/service/{id_agency}: + * get: + * summary: Récupère les services de l'agence. + * description: Récupère les services de l'agence selon l'id passé en paramètre. + * parameters: + * - in: path + * name: id_agency + * schema: + * type: integer + * required: true + * description: Id de l'agence + * responses: + * 200: + * description: Retourne les services souhaités + * content: + * application/json: + * schema: + * type: object + * properties: + * totalData: + * type: integer + * description: Nombre de serices trouvées + * list: + * type: array + * description: Liste des services retrouvées + * items: + * type: object + * properties: + * id: + * type: integer + * description: identifiant du service + * label: + * type: string + * description: nom du service + * minimum_users: + * type: integer + * description: nombre de membre minimum + * id_user_lead_service: + * type: integer + * description: id du chef de service + * lead_service_lastname: + * type: string + * description: nom du chef de service + * lead_service_firstname: + * type: string + * description: prénom du chef de service + * team_count: + * type: integer + * description: nombre d'équipe dans le service + * count_team: + * type: integer + * description: nombre de membre dans l'équipe + * 500: + * description: Échec de la récupération du service + */ router.get("/:id", verifyToken, async (req: Request, res: Response) => { const { code, message, data } = await DepartmentService.getDepartmentByAgency( +req.params.id, @@ -15,6 +72,106 @@ router.get("/:id", verifyToken, async (req: Request, res: Response) => { }); }); +/** + * @swagger + * /api/service/department/{id_department}: + * get: + * summary: Récupère un service . + * description: Récupère un service selon l'id passé en paramètre. + * parameters: + * - in: path + * name: id_department + * schema: + * type: integer + * required: true + * description: Id du service + * responses: + * 200: + * description: Retourne le service souhaité + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: integer + * description: identifiant du service + * label: + * type: string + * description: nom du service + * minimum_users: + * type: integer + * description: nombre de membre minimum + * id_user_lead_service: + * type: integer + * description: id du chef de service + * lead_service_lastname: + * type: string + * description: nom du chef de service + * lead_service_firstname: + * type: string + * description: prénom du chef de service + * team_count: + * type: integer + * description: nombre d'équipe dans le service + * count_team: + * type: integer + * description: nombre de membre dans l'équipe + * 500: + * description: Échec de la récupération du service + */ +router.get( + "/department/:id", + verifyToken, + async (req: Request, res: Response) => { + const { code, message, data } = await DepartmentService.getDepartmentById( + +req.params.id, + ); + res.status(code).json({ + message, + data, + }); + }, +); + +/** + * @swagger + * /api/service/create/{id_agency}: + * post: + * summary: Créer un nouveau service + * description: Créer un nouveau service + * parameters: + * - in: path + * name: id_agency + * schema: + * type: integer + * required: true + * description: Id de l'agence + * requestBody: + * required: true + * content: + * demand: + * schema: + * type: object + * properties: + * label: + * type: string + * description: nom du service + * minimum_user: + * type: integer + * description: nombre de membre minimum + * id_user_lead_service: + * type: integer + * description: id du chef de service + * id_agency: + * type: integer + * description: id de l'agence où le service est créé + * responses: + * 200: + * description: Création effectuée avec succès + * 500: + * description: Échec de la création du service + */ router.post("/create/:id", verifyToken, async (req: Request, res: Response) => { const { code, message, data } = await DepartmentService.createDepartment( +req.params.id, @@ -26,4 +183,87 @@ router.post("/create/:id", verifyToken, async (req: Request, res: Response) => { }); }); +/** + * @swagger + * /api/service/update-info/{id_department}: + * post: + * summary: Modification du service + * description: Modification du service + * parameters: + * - in: path + * name: id_department + * schema: + * type: integer + * required: true + * description: Id du service + * requestBody: + * required: true + * content: + * demand: + * schema: + * type: object + * properties: + * label: + * type: string + * description: nom du service + * minimum_user: + * type: integer + * description: nombre de membre minimum + * id_user_lead_service: + * type: integer + * description: id du chef de service + * id_agency: + * type: integer + * description: id de l'agence où le service est créé + * responses: + * 200: + * description: Modification effectuée avec succès + * 500: + * description: Échec de la création du service + */ +router.post( + "/update-info/:id_department", + verifyToken, + async (req: Request, res: Response) => { + const { code, message, data } = await DepartmentService.updateDepartment( + +req.params.id_department, + req, + ); + res.status(code).json({ + message, + data, + }); + }, +); + +/** + * @swagger + * /api/service/{id_department}: + * delete: + * summary: Suppression d'un service + * description: Suppression d'un service + * parameters: + * - in: path + * name: id_department + * schema: + * type: integer + * required: true + * description: Id du service pour suppression + * responses: + * 200: + * description: Suppression effectuée avec succès + * 500: + * description: Échec de la suppression du service + */ + +router.delete("/:id", verifyToken, async (req: Request, res: Response) => { + const { code, message, data } = await DepartmentService.deleteDepartment( + +req.params.id, + ); + res.status(code).json({ + message, + data, + }); +}); + export default router; diff --git a/api-server/src/resources/department/DepartmentRepository.ts b/api-server/src/resources/department/DepartmentRepository.ts index cdd6139..b13011d 100644 --- a/api-server/src/resources/department/DepartmentRepository.ts +++ b/api-server/src/resources/department/DepartmentRepository.ts @@ -1,5 +1,7 @@ import { DatabaseClient } from "../../common/helper/DatabaseClient.js"; import { CreateDepartment, Department } from "../../common/model/Department.js"; +import { CreateOrUpdateAddressDTO } from "../address/dto/CreateOrUpdateAddressDTO"; +import { UserAddress } from "../../common/model/Address"; export class DepartmentRepository { private static pool = DatabaseClient.mysqlPool; @@ -10,16 +12,33 @@ export class DepartmentRepository { offset = 0, ) { const [rows] = await this.pool.query( - `SELECT * + `SELECT service.*, + users.firstname as lead_service_firstname, + users.lastname as lead_service_lastname, + COUNT(team.id) as team_count FROM service - WHERE id_agency = ? - ORDER BY label - LIMIT ? OFFSET ? `, + LEFT JOIN users ON service.id_user_lead_service = users.id + LEFT JOIN team ON team.id_service = service.id + WHERE service.id_agency = ? + GROUP BY service.id + ORDER BY service.label + LIMIT ? OFFSET ?`, [agencyId, limit, offset], ); return rows; } + public static async getDepartmentById(id: number) { + const [rows]: any = await this.pool.query( + `SELECT service.*, users.firstname as lead_service_firstname, users.lastname as lead_service_lastname + FROM service + LEFT JOIN users ON service.id_user_lead_service = users.id + WHERE service.id = ?`, + [id], + ); + return rows[0]; + } + public static async getCountByAgencyId(agencyId: number) { const [rows]: any = await this.pool.query( `SELECT COUNT(*) as count @@ -30,6 +49,18 @@ export class DepartmentRepository { return rows[0].count; } + public static async getCountUserInTeamService(id: number) { + const [rows]: any = await this.pool.query( + `SELECT COUNT(belong_team.id_user) AS team_count + FROM team + JOIN service ON team.id_service = service.id + LEFT JOIN belong_team ON team.id = belong_team.id_team + WHERE service.id = ?;`, + [id], + ); + return rows[0].team_count; + } + public static async createDepartment(department: CreateDepartment) { const [rows]: any = await this.pool.query( ` @@ -48,4 +79,30 @@ export class DepartmentRepository { ); return rows[0]; } + + public static async updateDepartment( + department: CreateDepartment, + id: number, + ) { + const [result] = await this.pool.query( + ` + UPDATE service + SET label = ?, + id_user_lead_service = ? + WHERE id = ? + `, + [department.label, department.id_user_lead_service, id], + ); + return result; + } + + public static async deleteDepartment(idDepartment: number) { + const [rows]: any = await this.pool.query( + `DELETE + FROM service + WHERE id = ?`, + [idDepartment], + ); + return rows[0]; + } } diff --git a/api-server/src/resources/department/DepartmentService.ts b/api-server/src/resources/department/DepartmentService.ts index bfeddf2..3fbd1fc 100644 --- a/api-server/src/resources/department/DepartmentService.ts +++ b/api-server/src/resources/department/DepartmentService.ts @@ -4,7 +4,13 @@ import { AgencyRepository } from "../agency/AgencyRepository.js"; import { logger } from "../../common/helper/Logger.js"; import { DepartmentRepository } from "./DepartmentRepository.js"; import { CreateDepartment, Department } from "../../common/model/Department.js"; -import { DepartmentDTO } from "./dto/DepartmentDTO.js"; +import { DepartmentDTO, EditDepartment } from "./dto/DepartmentDTO.js"; +import { DemandDTO } from "../demand/dto/DemandDTO.js"; +import { DemandRepository } from "../demand/DemandRepository.js"; +import { UserService } from "../user/UserService.js"; +import { updateUserDays } from "../demand/DemandService.js"; +import { AgencyList } from "../agency/dto/AgencyDTO"; +import request from "supertest"; export class DepartmentService { public static async getDepartmentByAgency(idAgency: number) { @@ -20,6 +26,7 @@ export class DepartmentService { const departmentsDto: DepartmentDTO[] = departments.map( (department: Department) => new DepartmentDTO(department), ); + return new ControllerResponse(200, "", { totalData: departmentsCount, list: departmentsDto, @@ -30,6 +37,26 @@ export class DepartmentService { } } + public static async getDepartmentById(id: number) { + try { + const department: any = await DepartmentRepository.getDepartmentById(id); + const countMember = + await DepartmentRepository.getCountUserInTeamService(id); + if (!department) { + return new ControllerResponse(401, "Departments doesn't exist"); + } + + const department_: DepartmentDTO = new DepartmentDTO( + department, + countMember, + ); + return new ControllerResponse(200, "", department_); + } catch (error) { + logger.error(`Failed to get the departments. Error: ${error}`); + return new ControllerResponse(500, "Failed to get departments"); + } + } + public static async createDepartment(idAgency: number, req: Request) { const label = req.body.label; const minimal_number = req.body.minimum_user; @@ -45,7 +72,6 @@ export class DepartmentService { ); const createdDepartment = await DepartmentRepository.createDepartment(newDepartment); - return new ControllerResponse( 201, "Service créé avec succès", @@ -55,4 +81,45 @@ export class DepartmentService { return new ControllerResponse(500, "Impossible de créer le service"); } } + + public static async updateDepartment(idDepartment: number, req: Request) { + try { + const department: CreateDepartment = req.body; + await DepartmentRepository.updateDepartment(department, idDepartment); + const countMember = + await DepartmentRepository.getCountUserInTeamService(idDepartment); + const department_ = + await DepartmentRepository.getDepartmentById(idDepartment); + + if (!department_) { + return new ControllerResponse(401, "L'agence n'existe pas"); + } + + const departmentSend = new EditDepartment(department_, countMember); + + return new ControllerResponse( + 200, + "Adresse de l'agence modifiée", + departmentSend, + ); + } catch (error) { + return new ControllerResponse(500, "Impossible de modifier le service"); + } + } + + public static async deleteDepartment(idDepartment: number) { + try { + const department: any = await this.getDepartmentById(+idDepartment); + + if (!department) { + return new ControllerResponse(404, "pas de service"); + } + + await DepartmentRepository.deleteDepartment(+idDepartment); + return new ControllerResponse(200, ""); + } catch (error) { + logger.error(`Failed to delete the department. Error: ${error}`); + return new ControllerResponse(500, "Failed to delete the department"); + } + } } diff --git a/api-server/src/resources/department/dto/DepartmentDTO.ts b/api-server/src/resources/department/dto/DepartmentDTO.ts index 2fec70c..c04c962 100644 --- a/api-server/src/resources/department/dto/DepartmentDTO.ts +++ b/api-server/src/resources/department/dto/DepartmentDTO.ts @@ -6,12 +6,38 @@ export class DepartmentDTO { minimum_users: number; id_user_lead_service: number; id_agency: number; + lead_service_lastname: string; + lead_service_firstname: string; + team_count: number; + count_team: number; - constructor(department: Department) { + constructor(department: Department, countMember: number) { this.id = department.id; this.label = department.label; this.minimum_users = department.minimum_users; this.id_user_lead_service = department.id_user_lead_service; this.id_agency = department.id_agency; + this.lead_service_lastname = department.lead_service_lastname; + this.lead_service_firstname = department.lead_service_firstname; + this.team_count = department.team_count; + this.count_team = countMember; + } +} + +export class EditDepartment { + id: number; + label: string; + id_user_lead_service: number; + lead_service_lastname: string; + lead_service_firstname: string; + count_team: number; + + constructor(department: Department, countMember: number) { + this.id = department.id; + this.label = department.label; + this.id_user_lead_service = department.id_user_lead_service; + this.lead_service_lastname = department.lead_service_lastname; + this.lead_service_firstname = department.lead_service_firstname; + this.count_team = countMember; } } diff --git a/api-server/src/resources/expense/ExpenseController.ts b/api-server/src/resources/expense/ExpenseController.ts index 6977a59..c38c6cd 100644 --- a/api-server/src/resources/expense/ExpenseController.ts +++ b/api-server/src/resources/expense/ExpenseController.ts @@ -14,6 +14,90 @@ const upload = multer({ dotenv.config(); // Recupération des valeurs et données +/** + * @swagger + * /api/expense/list/{type}: + * get: + * summary: Récupère les demandes de frais de l'utilisateur. + * description: Récupère les demandes de frais de l'utilisateur selon l'ID passé par le JWT Token. + * parameters: + * - in: path + * name: type + * schema: + * type: string + * required: true + * description: Type de demande de frais + * responses: + * 200: + * description: Retourne les demandes de frais souhaitées + * content: + * application/json: + * schema: + * type: object + * properties: + * expenses: + * type: array + * description: Liste des demandes de frais retrouvées + * items: + * type: object + * properties: + * id: + * type: string + * description: identifiant de l'expense + * type: + * type: string + * description: Type de la demande (TRAVEL, COMPENSATION, FOOD, HOUSING) + * amount: + * type: integer + * description : montant en euro de la demande + * motivation: + * type: string + * description: Justification et explication du contexte de la demande + * created_at: + * type: string + * format: date + * description: date de création de la demande + * facturation_date: + * type: string + * format: date + * description: date de facturation de la demande + * status: + * type: string + * description: Status actuel de la demande (REFUNDED, NOT_REFUNDED, WAITING) + * id_owner: + * type: integer + * description: id de l'utilisateur propriétaire de la demande + * fileUrl: + * type: string + * description: lien vers le fichier joint à la demande + * required: false + * id_validator: + * type: integer + * description: id de l'utilisateur ayant validé la demande s'il y en a un + * required: false + * justification: + * type: string + * description: justification du choix en cas de refus de la demande + * validator_firstname: + * type: string + * description: prénom du validateur + * required: false + * validator_lastname: + * type: string + * description: prénom du validateur + * required: false + * validated_at: + * type: string + * format: date + * description: date de validation + * required: false + * + * totalExpensesCount: + * type: integer + * description: Nombre de demandes trouvées + * 500: + * description: Échec de la récupération de la demande + */ router.get("/list/:type", verifyToken, async (req: Request, res: Response) => { let userId = (req as CustomRequest).token.userId; const { code, message, data } = @@ -21,6 +105,90 @@ router.get("/list/:type", verifyToken, async (req: Request, res: Response) => { res.status(code).json({ message, data }); }); +/** + * @swagger + * /api/expense/list/all/{type}: + * get: + * summary: Récupère les demandes de frais des utilisateurs. + * description: Récupère les demandes de frais de tous les utilisateurs selon le type indiqué + * parameters: + * - in: path + * name: type + * schema: + * type: string + * required: true + * description: Type de demande de frais + * responses: + * 200: + * description: Retourne les demandes de frais souhaitées + * content: + * application/json: + * schema: + * type: object + * properties: + * expenses: + * type: array + * description: Liste des demandes de frais retrouvées + * items: + * type: object + * properties: + * id: + * type: string + * description: identifiant de l'expense + * type: + * type: string + * description: Type de la demande (TRAVEL, COMPENSATION, FOOD, HOUSING) + * amount: + * type: integer + * description : montant en euro de la demande + * motivation: + * type: string + * description: Justification et explication du contexte de la demande + * created_at: + * type: string + * format: date + * description: date de création de la demande + * facturation_date: + * type: string + * format: date + * description: date de facturation de la demande + * status: + * type: string + * description: Status actuel de la demande (REFUNDED, NOT_REFUNDED, WAITING) + * id_owner: + * type: integer + * description: id de l'utilisateur propriétaire de la demande + * fileUrl: + * type: string + * description: lien vers le fichier joint à la demande + * required: false + * id_validator: + * type: integer + * description: id de l'utilisateur ayant validé la demande s'il y en a un + * required: false + * justification: + * type: string + * description: justification du choix en cas de refus de la demande + * validator_firstname: + * type: string + * description: prénom du validateur + * required: false + * validator_lastname: + * type: string + * description: prénom du validateur + * required: false + * validated_at: + * type: string + * format: date + * description: date de validation + * required: false + * + * totalExpensesCount: + * type: integer + * description: Nombre de demandes trouvées + * 500: + * description: Échec de la récupération de la demande + */ router.get( "/list/all/:type", verifyToken, @@ -41,6 +209,43 @@ router.get( }, ); +/** + * @swagger + * /api/expense/amount-date-and-status: + * get: + * summary: Récupère des données précises des demandes de frais de l'utilisateur. + * description: Récupère le montant, la date de facturation et le status des demandes de frais de l'utilisateur selon l'ID passé par le JWT Token. + * parameters: + * - in: path + * name: type + * schema: + * type: string + * required: true + * description: Type de demande de frais + * responses: + * 200: + * description: Retourne les demandes de frais souhaitées + * content: + * application/json: + * schema: + * type: array + * description: Liste des demandes de frais retrouvées + * items: + * type: object + * properties: + * amount: + * type: integer + * description : montant en euro de la demande + * facturation_date: + * type: string + * format: date + * description: date de facturation de la demande + * status: + * type: string + * description: Status actuel de la demande (REFUNDED, NOT_REFUNDED, WAITING) + * 500: + * description: Échec de la récupération de la demande + */ router.get( "/amount-date-and-status/", verifyToken, @@ -52,6 +257,43 @@ router.get( }, ); +/** + * @swagger + * /api/expense/amount-date-and-status/all: + * get: + * summary: Récupère des données précises des demandes de tous les utilisateurs. + * description: Récupère le montant, la date de facturation et le status des demandes de frais de tous les utilisateurs. + * parameters: + * - in: path + * name: type + * schema: + * type: string + * required: true + * description: Type de demande de frais + * responses: + * 200: + * description: Retourne les demandes de frais souhaitées + * content: + * application/json: + * schema: + * type: array + * description: Liste des demandes de frais retrouvées + * items: + * type: object + * properties: + * amount: + * type: integer + * description : montant en euro de la demande + * facturation_date: + * type: string + * format: date + * description: date de facturation de la demande + * status: + * type: string + * description: Status actuel de la demande (REFUNDED, NOT_REFUNDED, WAITING) + * 500: + * description: Échec de la récupération de la demande + */ router.get( "/amount-date-and-status/all", verifyToken, @@ -62,6 +304,43 @@ router.get( }, ); +/** + * @swagger + * /api/expense/amount-date-and-status-by-date: + * get: + * summary: Récupère les demandes de frais de l'utilisateur. + * description: Récupère les demandes de frais de l'utilisateur selon l'ID passé par le JWT Token sur le mois glissant. + * parameters: + * - in: path + * name: type + * schema: + * type: string + * required: true + * description: Type de demande de frais + * responses: + * 200: + * description: Retourne les demandes de frais souhaitées + * content: + * application/json: + * schema: + * type: array + * description: Liste des demandes de frais retrouvées + * items: + * type: object + * properties: + * amount: + * type: integer + * description : montant en euro de la demande + * facturation_date: + * type: string + * format: date + * description: date de facturation de la demande + * status: + * type: string + * description: Status actuel de la demande (REFUNDED, NOT_REFUNDED, WAITING) + * 500: + * description: Échec de la récupération de la demande + */ router.get( "/amount-date-and-status-by-date", verifyToken, @@ -76,6 +355,43 @@ router.get( }, ); +/** + * @swagger + * /api/expense/amount-date-and-status-by-date/all: + * get: + * summary: Récupère les demandes de frais de tous les utilisateurs. + * description: Récupère les demandes de frais de tous les utilisateurs selon l'ID passé par le JWT Token sur le mois glissant. + * parameters: + * - in: path + * name: type + * schema: + * type: string + * required: true + * description: Type de demande de frais + * responses: + * 200: + * description: Retourne les demandes de frais souhaitées + * content: + * application/json: + * schema: + * type: array + * description: Liste des demandes de frais retrouvées + * items: + * type: object + * properties: + * amount: + * type: integer + * description : montant en euro de la demande + * facturation_date: + * type: string + * format: date + * description: date de facturation de la demande + * status: + * type: string + * description: Status actuel de la demande (REFUNDED, NOT_REFUNDED, WAITING) + * 500: + * description: Échec de la récupération de la demande + */ router.get( "/amount-date-and-status-by-date/all", verifyToken, @@ -86,24 +402,6 @@ router.get( }, ); -router.get("/count/:type", verifyToken, async (req: Request, res: Response) => { - let userId = (req as CustomRequest).token.userId; - const { code, message, data } = await ExpenseService.getExpensesCountByUserId( - req, - userId, - ); - res.status(code).json({ message, data }); -}); - -router.get( - "/count/all/:type", - verifyToken, - async (req: Request, res: Response) => { - const { code, message, data } = await ExpenseService.getExpensesCount(req); - res.status(code).json({ message, data }); - }, -); - // Gestion des demandes liées aux frais router.put( @@ -148,6 +446,25 @@ router.put( }, ); +/** + * @swagger + * /api/expense/{id}: + * delete: + * summary: Supprime une demande de frais + * description: Supprime la demande de frais portant l'id indiqué, il faut être propriétaire de la demande pour pouvoir la supprimer + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: ID de la demande de frais à supprimer + * responses: + * 200: + * description: Suppression effectuée avec succès + * 500: + * description: Échec de la suppression de la demande + */ router.delete("/:id", verifyToken, async (req: Request, res: Response) => { let userId = (req as CustomRequest).token.userId; const { code, message, data } = await ExpenseService.delExpenseDemand( @@ -157,11 +474,85 @@ router.delete("/:id", verifyToken, async (req: Request, res: Response) => { res.status(code).json({ message, data }); }); +/** + * @swagger + * /api/expense/{id}: + * get: + * summary: Récupère la demande de frais souhaitée + * description: Récupère la demande de frais selon l'ID, il faut être admin ou propriétaire de la demande pour la récupérer. + * parameters: + * - in: path + * name: id + * schema: + * type: integer + * required: true + * description: id de la demande de frais cible + * responses: + * 200: + * description: Retourne la demande de frais souhaitée + * content: + * application/json: + * type: object + * properties: + * type: object + * properties: + * id: + * type: string + * description: identifiant de l'expense + * type: + * type: string + * description: Type de la demande (TRAVEL, COMPENSATION, FOOD, HOUSING) + * amount: + * type: integer + * description : montant en euro de la demande + * motivation: + * type: string + * description: Justification et explication du contexte de la demande + * created_at: + * type: string + * format: date + * description: date de création de la demande + * facturation_date: + * type: string + * format: date + * description: date de facturation de la demande + * status: + * type: string + * description: Status actuel de la demande (REFUNDED, NOT_REFUNDED, WAITING) + * id_owner: + * type: integer + * description: id de l'utilisateur propriétaire de la demande + * fileUrl: + * type: string + * description: lien vers le fichier joint à la demande + * required: false + * id_validator: + * type: integer + * description: id de l'utilisateur ayant validé la demande s'il y en a un + * required: false + * justification: + * type: string + * description: justification du choix en cas de refus de la demande + * validator_firstname: + * type: string + * description: prénom du validateur + * required: false + * validator_lastname: + * type: string + * description: prénom du validateur + * required: false + * validated_at: + * type: string + * format: date + * description: date de validation + * required: false + * 500: + * description: Échec de la récupération de la demande + */ router.get("/:id", verifyToken, async (req: Request, res: Response) => { let userId = (req as CustomRequest).token.userId; const { code, message, data } = await ExpenseService.getExpenseDemand( req.params.id, - userId, ); res.status(code).json({ message, data }); }); @@ -175,6 +566,45 @@ router.put("/confirm/:id", verifyToken, async (req: Request, res: Response) => { res.status(code).json({ message, data }); }); +/** + * @swagger + * /api/expense/: + * post: + * summary: Créer une nouvelle demande de frais + * description: Créer une nouvelle demande de frais au nom de l'utilisateur connecté + * requestBody: + * required: true + * content: + * expense: + * schema: + * type: object + * properties: + * type: + * type: string + * description: Type de la demande (TRAVEL, COMPENSATION, FOOD, HOUSING) + * amount: + * type: integer + * description : montant en euro de la demande + * motivation: + * type: string + * description: Justification et explication du contexte de la demande + * facturation_date: + * type: string + * format: date + * description: date de facturation de la demande + * status: + * type: string + * description: Status actuel de la demande (REFUNDED, NOT_REFUNDED, WAITING) + * fileUrl: + * type: string + * description: lien vers le fichier joint à la demande + * required: false + * responses: + * 200: + * description: Création effectuée avec succès + * 500: + * description: Échec de la création de la demande + */ router.post( "/", verifyToken, diff --git a/api-server/src/resources/expense/ExpenseRepository.ts b/api-server/src/resources/expense/ExpenseRepository.ts index 725df23..cc75559 100644 --- a/api-server/src/resources/expense/ExpenseRepository.ts +++ b/api-server/src/resources/expense/ExpenseRepository.ts @@ -92,7 +92,7 @@ export class ExpenseRepository { SELECT * FROM expense WHERE id_owner = ? - ORDER BY id + ORDER BY facturation_date DESC LIMIT ?,?; `, [user_id, offset, limit], diff --git a/api-server/src/resources/expense/ExpenseService.ts b/api-server/src/resources/expense/ExpenseService.ts index 0f7fcf3..2f7eef3 100644 --- a/api-server/src/resources/expense/ExpenseService.ts +++ b/api-server/src/resources/expense/ExpenseService.ts @@ -11,6 +11,12 @@ import { Request } from "express"; import { ExpenseAmountDateAndStatusDTO } from "./dto/ExpenseAmountDateAndStatusDTO.js"; import { MinioClient } from "../../common/helper/MinioClient.js"; import { ExpenseStatus } from "../../common/enum/ExpenseStatus"; +import { CreateNotification } from "../../common/model/Notification"; +import { NotificationType } from "../../common/enum/NotificationType"; +import { NotificationRepository } from "../notification/NotificationRepository"; +import { NotificationSender } from "../../common/helper/NotificationSender"; +import { NotificationService } from "../notification/NotificationService"; +import { RoleEnum } from "../../common/enum/RoleEnum"; export class ExpenseService { public static async getExpensesValuesByUserId(req: Request, userId: number) { @@ -192,6 +198,24 @@ export class ExpenseService { try { const expense_ = new ExpenseValidation(id, userId); const statusChange = await ExpenseRepository.confirmExpense(expense_); + + const expense = await ExpenseRepository.getExpenseDemand(String(id)); + + const notification = new CreateNotification( + "Votre demande de frais a été validée", + NotificationType.EXPENSE, + expense.id, + expense.id_owner, + ); + + await NotificationRepository.createNotification(notification); + const notificationCount = + await NotificationRepository.getUntouchedNotificationsCountByUserId( + expense.id_owner, + ); + + NotificationSender.send(notificationCount, expense.id_owner); + return new ControllerResponse(200, "", statusChange); } catch (error) { logger.error(`Failed to edit the expense. Error: ${error}`); @@ -207,6 +231,23 @@ export class ExpenseService { userId, ); const statusChange = await ExpenseRepository.rejectExpense(expense_); + + const expense = await ExpenseRepository.getExpenseDemand(String(id)); + const notification = new CreateNotification( + "Votre demande de frais a été rejetée", + NotificationType.EXPENSE, + expense.id, + expense.id_owner, + ); + + await NotificationRepository.createNotification(notification); + const notificationCount = + await NotificationRepository.getUntouchedNotificationsCountByUserId( + expense.id_owner, + ); + + NotificationSender.send(notificationCount, expense.id_owner); + return new ControllerResponse(200, "", statusChange); } catch (error) { logger.error(`Failed to edit the expense. Error: ${error}`); @@ -229,7 +270,7 @@ export class ExpenseService { } } - public static async getExpenseDemand(id: string, userId: number) { + public static async getExpenseDemand(id: string) { try { const expenseTemp = await ExpenseRepository.getExpenseDemand(id); let expense: ExpenseListDTO; @@ -241,8 +282,6 @@ export class ExpenseService { } else { expense = new ExpenseListDTO(expenseTemp); } - if (expense.id_owner != userId) - return new ControllerResponse(403, "Access denied"); return new ControllerResponse(200, "", expense); } catch (error) { @@ -257,11 +296,31 @@ export class ExpenseService { public static async confirmExpenseDemand(req: Request, userId: number) { try { const status = req.body.ExpenseStatus; - const result: any = await ExpenseRepository.confirmExpenseDemand( - +req.params.id, - status, - userId, + const expenseId = +req.params.id; + await ExpenseRepository.confirmExpenseDemand(expenseId, status, userId); + + const expense = await ExpenseRepository.getExpenseDemand( + String(expenseId), + ); + + const notification = new CreateNotification( + "Une demande de frais attend votre validation", + NotificationType.EXPENSE, + expense.id, ); + + await NotificationService.createNotificationsFromUserRoles(notification, [ + RoleEnum.HR, + RoleEnum.ADMIN, + ]); + + const notificationCount = + await NotificationRepository.getUntouchedNotificationsCountByUserId( + expense.id_owner, + ); + + NotificationSender.send(notificationCount, expense.id_owner); + return new ControllerResponse(200, "Operation was a success"); } catch (error) { logger.error(`Failed to confirm expenses. Error: ${error}`); @@ -269,45 +328,6 @@ export class ExpenseService { } } - public static async getExpensesCount(req: Request) { - try { - const type: string = req.params.type || "ALL"; - let count: number; - if (type == null || type == "ALL") { - const result: any = await ExpenseRepository.getExpensesCount(); - count = result; - } else { - const result: any = - await ExpenseRepository.getExpensesCountByType(type); - count = result; - } - return new ControllerResponse(200, "", count); - } catch (error) { - logger.error(`Failed to get expenses. Error: ${error}`); - return new ControllerResponse(500, "Failed to get expenses"); - } - } - - public static async getExpensesCountByUserId(req: Request, userId: number) { - try { - const type: string = req.params.type || "ALL"; - let count; - if (type == null || type == "ALL") { - const result: any = - await ExpenseRepository.getExpensesCountByUserId(userId); - count = result; - } else { - const result: any = - await ExpenseRepository.getExpensesCountByTypeAndUserId(type, userId); - count = result; - } - return new ControllerResponse(200, "", count); - } catch (error) { - logger.error(`Failed to get expenses. Error: ${error}`); - return new ControllerResponse(500, "Failed to get expenses"); - } - } - public static async getExpensesAmountDateAndStatus(req: Request) { try { const expenses: Expense[] = diff --git a/api-server/src/resources/expense/dto/ExpenseListDTO.ts b/api-server/src/resources/expense/dto/ExpenseListDTO.ts index ae9d555..f9c481b 100644 --- a/api-server/src/resources/expense/dto/ExpenseListDTO.ts +++ b/api-server/src/resources/expense/dto/ExpenseListDTO.ts @@ -3,7 +3,7 @@ import { ExpenseType } from "../../../common/enum/ExpenseType"; import { ExpenseStatus } from "../../../common/enum/ExpenseStatus"; export class ExpenseListDTO { - id: string; + id: number; type: ExpenseType; amount: number; motivation: string; diff --git a/api-server/src/resources/notification/NotificationController.ts b/api-server/src/resources/notification/NotificationController.ts new file mode 100644 index 0000000..5f195d4 --- /dev/null +++ b/api-server/src/resources/notification/NotificationController.ts @@ -0,0 +1,23 @@ +import { Request, Response, Router } from "express"; +import { verifyToken } from "../../common/middleware/AuthMiddleware.js"; +import { NotificationService } from "./NotificationService.js"; +import { initSocket } from "../../common/helper/Socket"; +import { NotificationSender } from "../../common/helper/NotificationSender"; +import { CustomRequest } from "../../common/helper/CustomRequest"; + +const router = Router(); + +router.get("/", verifyToken, async (req: Request, res: Response) => { + const { code, message, data } = + await NotificationService.getNotificationsByUserId(req); + res.status(code).json({ message, data }); +}); + +router.get("/touch/:id", verifyToken, async (req: Request, res: Response) => { + const { code, message } = await NotificationService.markNotificationAsTouched( + +req.params.id, + ); + res.status(code).json({ message }); +}); + +export default router; diff --git a/api-server/src/resources/notification/NotificationRepository.ts b/api-server/src/resources/notification/NotificationRepository.ts new file mode 100644 index 0000000..aab9113 --- /dev/null +++ b/api-server/src/resources/notification/NotificationRepository.ts @@ -0,0 +1,63 @@ +import { DatabaseClient } from "../../common/helper/DatabaseClient.js"; +import { CreateNotification } from "../../common/model/Notification.js"; + +export class NotificationRepository { + private static pool = DatabaseClient.mysqlPool; + + public static async getNotificationsByUserId( + userId: number, + limit = 10, + offset = 0, + ) { + const [rows] = await this.pool.query( + "SELECT * FROM notification WHERE id_receiver = ? ORDER BY created_at DESC LIMIT ? OFFSET ?", + [userId, limit, offset], + ); + return rows; + } + + public static async getNotificationsCountByUserId(userId: number) { + const [rows]: any = await this.pool.query( + "SELECT COUNT(*) as count FROM notification WHERE notification.id_receiver = ?", + [userId], + ); + return rows[0].count; + } + + public static async getUntouchedNotificationsCountByUserId(userId: number) { + const [rows]: any = await this.pool.query( + "SELECT COUNT(*) as count FROM notification WHERE notification.touched = false AND notification.id_receiver = ?", + [userId], + ); + return rows[0].count; + } + + public static async createNotification(notification: CreateNotification) { + const [result] = await this.pool.query( + ` + INSERT INTO notification (description, + type, + id_receiver, + id_sender) + VALUES (?, ?, ?, ?) + `, + [ + notification.description, + notification.type, + notification.id_receiver, + notification.id_sender, + ], + ); + return result; + } + + public static async markNotificationAsTouched(notificationId: number) { + const [result] = await this.pool.query( + `UPDATE notification + SET touched = true + WHERE id = ?`, + [notificationId], + ); + return result; + } +} diff --git a/api-server/src/resources/notification/NotificationService.ts b/api-server/src/resources/notification/NotificationService.ts new file mode 100644 index 0000000..0b588dd --- /dev/null +++ b/api-server/src/resources/notification/NotificationService.ts @@ -0,0 +1,100 @@ +import { logger } from "../../common/helper/Logger.js"; +import { ControllerResponse } from "../../common/helper/ControllerResponse.js"; +import { UserRepository } from "../user/UserRepository.js"; +import { NotificationRepository } from "./NotificationRepository.js"; +import { + CreateNotification, + Notification, +} from "../../common/model/Notification.js"; +import { NotificationDTO } from "./dto/NotificationDTO.js"; +import { RoleEnum } from "../../common/enum/RoleEnum.js"; +import { Request } from "express"; +import { CustomRequest } from "../../common/helper/CustomRequest.js"; +import { UserEntity } from "../../common/entity/user/user.entity"; +import { NotificationSender } from "../../common/helper/NotificationSender"; +import { initSocket } from "../../common/helper/Socket"; + +export class NotificationService { + public static async getNotificationsByUserId(req: Request) { + try { + const pageSize = req.query.pageSize || "0"; + const pageNumber = req.query.pageNumber || "10"; + const isPageSizeAnInteger = Number.isInteger(+pageSize); + const isPageNumberAnInteger = Number.isInteger(+pageNumber); + if (!isPageSizeAnInteger && !isPageNumberAnInteger) { + return new ControllerResponse(400, "Les paramètres sont incorrects"); + } + + const userId = (req as CustomRequest).token.userId; + const user: any = await UserRepository.getUserById(userId); + if (!user) { + return new ControllerResponse(401, "L'utilisateur n'a pas été trouvé"); + } + + const limit = +pageSize; + const offset = (+pageNumber - 1) * +pageSize; + const notifications: any = + await NotificationRepository.getNotificationsByUserId( + user.id, + limit, + offset, + ); + + const notificationsMapped: NotificationDTO[] = notifications?.map( + (notification: Notification) => { + return new NotificationDTO(notification); + }, + ); + + const totalData = + await NotificationRepository.getNotificationsCountByUserId(userId); + + return new ControllerResponse(200, "", { + list: notificationsMapped, + totalData, + }); + } catch (error) { + logger.error(`Failed to get notification}. Error: ${error}`); + return new ControllerResponse( + 500, + "Impossible de récupérer les notifications de l'utilisateur", + ); + } + } + + public static async createNotificationsFromUserRoles( + notification: CreateNotification, + roles: RoleEnum[], + ) { + try { + const users: UserEntity[] = await UserRepository.getUsersByRoles(roles); + if (!users || users.length === 0) { + return logger.error("No user found"); + } + users.map(async (user) => { + const createNotification = new CreateNotification( + notification.description, + notification.type, + notification.id_sender, + user.id, + ); + await NotificationRepository.createNotification(createNotification); + }); + } catch (error) { + logger.error(`Failed to generate notifications. Error: ${error}`); + } + } + + public static async markNotificationAsTouched(notificationId: number) { + try { + await NotificationRepository.markNotificationAsTouched(notificationId); + return new ControllerResponse(200, "Notification marquée comme lue"); + } catch (error) { + logger.error(`Failed to touch notification. Error: ${error}`); + return new ControllerResponse( + 500, + "Impossible de marquée la notification comme lue", + ); + } + } +} diff --git a/api-server/src/resources/notification/dto/NotificationDTO.ts b/api-server/src/resources/notification/dto/NotificationDTO.ts new file mode 100644 index 0000000..f916095 --- /dev/null +++ b/api-server/src/resources/notification/dto/NotificationDTO.ts @@ -0,0 +1,20 @@ +import { NotificationType } from "../../../common/enum/NotificationType.js"; +import { Notification } from "../../../common/model/Notification.js"; + +export class NotificationDTO { + id: number; + description: string; + type: NotificationType; + id_sender: number; + touched: boolean; + created_at: Date; + + constructor(notification: Notification) { + this.id = notification.id; + this.description = notification.description; + this.type = notification.type; + this.id_sender = notification.id_sender; + this.touched = notification.touched; + this.created_at = new Date(`${notification.created_at} UTC`); + } +} diff --git a/api-server/src/resources/team/TeamController.ts b/api-server/src/resources/team/TeamController.ts index 30455b1..06f0ccc 100644 --- a/api-server/src/resources/team/TeamController.ts +++ b/api-server/src/resources/team/TeamController.ts @@ -4,8 +4,41 @@ import { TeamService } from "./TeamService.js"; const router = Router(); +/** + * @swagger + * /api/team/{id_team}: + * get: + * summary: Récupère les équipes. + * description: Récupère les équipes . + * responses: + * 200: + * description: Retourne les équipes souhaitées + * content: + * application/json: + * schema: + * type: object + * properties: + * totalData: + * type: integer + * description: Nombre de membre dans l'équipe + * list: + * type: array + * description: Liste des équipes retrouvées + * items: + * type: object + * properties: + * id: + * type: integer + * description: identifiant de l'agence + * label: + * type: string + * description: nom de l'agence + * + * 500: + * description: Échec de la récupération de l'agence + */ router.get("/:id", verifyToken, async (req: Request, res: Response) => { - const { code, message, data } = await TeamService.getTeamByAgency( + const { code, message, data } = await TeamService.getTeamByService( +req.params.id, ); res.status(code).json({ @@ -14,4 +47,51 @@ router.get("/:id", verifyToken, async (req: Request, res: Response) => { }); }); +router.get( + "/details/:id_team", + verifyToken, + async (req: Request, res: Response) => { + const { code, message, data } = await TeamService.getTeamById( + +req.params.id_team, + ); + res.status(code).json({ + message, + data, + }); + }, +); + +router.post("/create", verifyToken, async (req: Request, res: Response) => { + const { code, message, data } = await TeamService.createTeam(req); + res.status(code).json({ + message, + data, + }); +}); + +router.put( + "/edit/:id_team", + verifyToken, + async (req: Request, res: Response) => { + const { code, message, data } = await TeamService.editTeam( + +req.params.id_team, + req, + ); + res.status(code).json({ + message, + data, + }); + }, +); + +router.delete("/:id_team", verifyToken, async (req: Request, res: Response) => { + const { code, message, data } = await TeamService.deleteTeam( + +req.params.id_team, + ); + res.status(code).json({ + message, + data, + }); +}); + export default router; diff --git a/api-server/src/resources/team/TeamRepository.ts b/api-server/src/resources/team/TeamRepository.ts index d271e02..0ac4b35 100644 --- a/api-server/src/resources/team/TeamRepository.ts +++ b/api-server/src/resources/team/TeamRepository.ts @@ -1,33 +1,175 @@ import { DatabaseClient } from "../../common/helper/DatabaseClient.js"; +import { CreateTeam } from "../../common/model/Team.js"; export class TeamRepository { private static pool = DatabaseClient.mysqlPool; - public static async getTeamByAgencyId( - agencyId: number, + public static async getTeamByService( + serviceId: number, limit = 10, offset = 0, ) { const [rows] = await this.pool.query( - `SELECT team.*, service.label as service_label + `SELECT team.*, + service.label as service_label, + users.firstname as lead_team_firstname, + users.lastname as lead_team_lastname FROM team JOIN service ON team.id_service = service.id - WHERE service.id_agency = ? + LEFT JOIN users ON team.id_user_lead_team = users.id + WHERE team.id_service = ? ORDER BY team.label LIMIT ? OFFSET ? `, - [agencyId, limit, offset], + [serviceId, limit, offset], + ); + return rows; + } + public static async getTeamById(teamId: number) { + const [rows]: any = await this.pool.query( + `SELECT + team.*, + users.firstname as lead_team_firstname, + users.lastname as lead_team_lastname, + users.email as lead_team_email, + team_members.*, + MIN(CASE WHEN demand.id IS NULL THEN 1 + WHEN demand.id IS NOT NULL THEN 0 END) as is_present + FROM + team + JOIN + service ON team.id_service = service.id + LEFT JOIN + users ON team.id_user_lead_team = users.id + LEFT JOIN + belong_team ON team.id = belong_team.id_team + LEFT JOIN + users as team_members ON belong_team.id_user = team_members.id + LEFT JOIN demand ON team_members.id = demand.id_owner + AND demand.status = 'ACCEPTED' + AND CURDATE() BETWEEN demand.start_date AND demand.end_date + WHERE + team.id = ? + GROUP BY + team_members.id`, + [teamId], ); return rows; } - public static async getCountByAgencyId(agencyId: number) { + public static async getCountByService(serviceId: number) { const [rows]: any = await this.pool.query( `SELECT COUNT(*) as count FROM team JOIN service ON team.id_service = service.id - WHERE service.id_agency = ?`, - [agencyId], + WHERE service.id = ?`, + [serviceId], ); return rows[0].count; } + + public static async createTeam(team: CreateTeam) { + const [result]: any = await this.pool.query( + ` + INSERT INTO team (label, + minimum_users, + id_user_lead_team, + id_service) + VALUES (?, ?, ?, ?) + `, + [team.label, team.minimum_users, team.id_user_lead_team, team.id_service], + ); + return result; + } + + public static async addMemberToTeam(member: { + id_team: number; + id_user: number; + }) { + const [result]: any = await this.pool.query( + ` + INSERT INTO belong_team (id_team, id_user) + VALUES (?, ?) + `, + [member.id_team, member.id_user], + ); + + return result; + } + public static async countTeam(idService: number) { + const [result]: any = await this.pool.query( + ` + SELECT + belong_team.id_team, + COUNT(DISTINCT belong_team.id_user) AS total_team, + COUNT(DISTINCT CASE + WHEN demand.id IS NULL THEN belong_team.id_user + END) AS total_present + FROM + belong_team + JOIN team ON belong_team.id_team = team.id + JOIN users ON belong_team.id_user = users.id + LEFT JOIN demand ON users.id = demand.id_owner + AND demand.status = 'ACCEPTED' + AND CURDATE() BETWEEN demand.start_date AND demand.end_date + WHERE + team.id_service = ? + GROUP BY + team.id; + `, + [idService], + ); + return result; + } + + public static async countTeamById(idTeam: number) { + const [result]: any = await this.pool.query( + ` + SELECT + belong_team.id_team, + COUNT(DISTINCT belong_team.id_user) AS total_team, + COUNT(DISTINCT CASE + WHEN demand.id IS NULL THEN belong_team.id_user + END) AS total_present + FROM + belong_team + JOIN team ON belong_team.id_team = team.id + JOIN users ON belong_team.id_user = users.id + LEFT JOIN demand ON users.id = demand.id_owner + AND demand.status = 'ACCEPTED' + AND CURDATE() BETWEEN demand.start_date AND demand.end_date + WHERE + team.id = ?; + `, + [idTeam], + ); + return result; + } + + public static async editTeam(idTeam: number, idsMembers: number[]) { + const idsMapped = idsMembers.map((idMembre) => [idTeam, idMembre]); + await this.pool.query( + ` + DELETE + FROM belong_team + WHERE id_team = ? + `, + [idTeam], + ); + const [result] = await this.pool.query( + `INSERT INTO belong_team(id_team, id_user) + VALUES ?`, + [idsMapped], + ); + return result; + } + + public static async deleteTeam(idTeam: number) { + const [rows]: any = await this.pool.query( + `DELETE + FROM team + WHERE id = ?`, + [idTeam], + ); + return rows[0]; + } } diff --git a/api-server/src/resources/team/TeamService.ts b/api-server/src/resources/team/TeamService.ts index 92842d3..55d81bd 100644 --- a/api-server/src/resources/team/TeamService.ts +++ b/api-server/src/resources/team/TeamService.ts @@ -1,28 +1,138 @@ import { ControllerResponse } from "../../common/helper/ControllerResponse.js"; import { logger } from "../../common/helper/Logger.js"; import { TeamRepository } from "./TeamRepository.js"; -import { Team } from "../../common/model/Team.js"; -import { TeamDTO } from "./dto/TeamDTO.js"; +import { CreateTeam, Team } from "../../common/model/Team.js"; +import { TeamDTO, TeamsDTO } from "./dto/TeamDTO.js"; +import { Request } from "express"; +import { MinioClient } from "../../common/helper/MinioClient"; +import { DepartmentRepository } from "../department/DepartmentRepository"; export class TeamService { - public static async getTeamByAgency(agencyId: number) { + public static async createTeam(req: Request) { try { - const teams: any = await TeamRepository.getTeamByAgencyId(agencyId); - const teamsCount = await TeamRepository.getCountByAgencyId(agencyId); + const { label, minimum_users, id_user_lead_team, id_service, members } = + req.body; + + const newTeamRequest = new CreateTeam( + label, + minimum_users, + id_user_lead_team, + id_service, + members, + ); + + const createdTeam = await TeamRepository.createTeam(newTeamRequest); + + let teamId: number; + + if ("insertId" in createdTeam) { + teamId = createdTeam.insertId; + if (members && members.length > 0) { + for (const userId of members) { + await TeamRepository.addMemberToTeam({ + id_team: teamId, + id_user: userId, + }); + } + } + } + + return new ControllerResponse(200, "Team created"); + } catch (error) { + logger.error(`Failed to create the team. Error: ${error}`); + return new ControllerResponse(500, "Failed to create Team"); + } + } + + public static async getTeamById(teamId: number) { + try { + const team: any = await TeamRepository.getTeamById(teamId); + const teamPresence = await TeamRepository.countTeamById(teamId); + if (!team) { + return new ControllerResponse(401, "Team doesn't exist"); + } + + const members: any[] = []; + for (const row of team) { + if (row.id) { + const memberAvatarUrl = await MinioClient.getSignedUrl(row.image_key); + members.push({ + id_member: row.id, + member_firstname: row.firstname, + member_lastname: row.lastname, + member_email: row.email, + member_avatar: memberAvatarUrl, + is_present: row.is_present, + }); + } + } + + const teamDto: TeamDTO = new TeamDTO( + team[0], + teamPresence[0].total_present, + teamPresence[0].total_team, + members, + ); + + return new ControllerResponse(200, "", teamDto); + } catch (error) { + logger.error(`Failed to get the team. Error: ${error}`); + return new ControllerResponse(500, "Failed to get Team"); + } + } + + public static async getTeamByService(serviceId: number) { + try { + const teams: any = await TeamRepository.getTeamByService(serviceId); + const teamsCount = await TeamRepository.getCountByService(serviceId); + const teamPresence = await TeamRepository.countTeam(serviceId); if (!teams) { return new ControllerResponse(401, "Teams doesn't exist"); } - const teamsDto: TeamDTO[] = teams.map((team: Team) => new TeamDTO(team)); + const teamsDto: TeamsDTO[] = teams.map( + (team: Team) => + new TeamsDTO( + team, + teamPresence.find( + (presence: any) => presence.id_team === team.id, + ).total_present, + teamPresence.find( + (presence: any) => presence.id_team === team.id, + ).total_team, + ), + ); return new ControllerResponse(200, "", { totalData: teamsCount, list: teamsDto, }); } catch (error) { - logger.error(`Failed to get the departments. Error: ${error}`); - return new ControllerResponse(500, "Failed to get departments"); + logger.error(`Failed to get the teams. Error: ${error}`); + return new ControllerResponse(500, "Failed to get teams"); + } + } + + public static async editTeam(teamId: number, req: Request) { + try { + const ids_member = req.body; + await TeamRepository.editTeam(teamId, ids_member); + + return new ControllerResponse(200, ""); + } catch (error) { + logger.error(`Failed to edit the team. Error: ${error}`); + return new ControllerResponse(500, "Failed to edit the team"); + } + } + + public static async deleteTeam(teamId: number) { + try { + await TeamRepository.deleteTeam(+teamId); + return new ControllerResponse(200, ""); + } catch (error) { + logger.error(`Failed to delete the team. Error: ${error}`); + return new ControllerResponse(500, "Failed to delete the team"); } } } diff --git a/api-server/src/resources/team/dto/TeamDTO.ts b/api-server/src/resources/team/dto/TeamDTO.ts index e4e75d8..2ad96a6 100644 --- a/api-server/src/resources/team/dto/TeamDTO.ts +++ b/api-server/src/resources/team/dto/TeamDTO.ts @@ -1,19 +1,90 @@ -import { Team } from "../../../common/model/Team.js"; +import { Team, TeamMembers } from "../../../common/model/Team.js"; + +export class TeamsDTO { + id: number; + label: string; + minimum_users: number; + id_user_lead_team: number; + id_service: number; + service_label: string; + lead_team_firstname: string; + lead_team_lastname: string; + total_team: number; + total_present: number; + status: TeamStatus | undefined; + + constructor(team: Team, totalPresent: number, totalTeam: number) { + this.id = team.id; + this.label = team.label; + this.minimum_users = team.minimum_users; + this.id_user_lead_team = team.id_user_lead_team; + this.id_service = team.id_service; + this.service_label = team.service_label; + this.lead_team_firstname = team.lead_team_firstname; + this.lead_team_lastname = team.lead_team_lastname; + this.total_team = totalTeam; + this.total_present = totalPresent; + this.status = calculateStatus(totalPresent, totalTeam); + } +} export class TeamDTO { id: number; label: string; minimum_users: number; - id_user_lead_service: number; + id_user_lead_team: number; id_service: number; service_label: string; + lead_team_firstname: string; + lead_team_lastname: string; + lead_team_email: string; + total_team: number; + total_present: number; + status: TeamStatus | undefined; + members: TeamMembers[]; - constructor(team: Team) { + constructor( + team: Team, + totalPresent: number, + totalTeam: number, + members: any[], + ) { this.id = team.id; this.label = team.label; this.minimum_users = team.minimum_users; - this.id_user_lead_service = team.id_user_lead_service; + this.id_user_lead_team = team.id_user_lead_team; this.id_service = team.id_service; this.service_label = team.service_label; + this.lead_team_firstname = team.lead_team_firstname; + this.lead_team_lastname = team.lead_team_lastname; + this.lead_team_email = team.lead_team_email; + this.total_team = totalTeam; + this.total_present = totalPresent; + this.status = calculateStatus(totalPresent, totalTeam); + this.members = members.map((member) => new TeamMembers(member)); + } +} + +const calculateStatus = (totalPresent: number, totalTeam: number) => { + const percent = totalPresent / totalTeam; + let status = ""; + if (percent < 0.33) { + return (status = TeamStatus.NOT_ENOUGH); + } + if (percent >= 0.33 && percent < 0.66) { + return (status = TeamStatus.UNDERSTAFFED); + } + if (percent >= 0.66 && percent < 1) { + return (status = TeamStatus.ENOUGH); } + if (percent === 1) { + return (status = TeamStatus.COMPLETE); + } +}; + +enum TeamStatus { + NOT_ENOUGH = "NOT_ENOUGH", + UNDERSTAFFED = "UNDERSTAFFED", + ENOUGH = "ENOUGH", + COMPLETE = "COMPLETE", } diff --git a/api-server/src/resources/user/UserRepository.ts b/api-server/src/resources/user/UserRepository.ts index 72c5f01..f3d5601 100644 --- a/api-server/src/resources/user/UserRepository.ts +++ b/api-server/src/resources/user/UserRepository.ts @@ -2,6 +2,7 @@ import { CreateUser, ResetUserPassword } from "../../common/model/User.js"; import { DatabaseClient } from "../../common/helper/DatabaseClient.js"; import { UpdateUserInfoDTO } from "./dto/UpdateUserInfoDTO.js"; import { UpdateUserBankInfosDTO } from "./dto/UpdateUserBankInfosDTO.js"; +import { RoleEnum } from "../../common/enum/RoleEnum"; export class UserRepository { private static pool = DatabaseClient.mysqlPool; @@ -119,6 +120,20 @@ export class UserRepository { return rows[0]; } + public static async getUsersByRoles(roles: RoleEnum[]) { + const [rows]: any = await this.pool.query( + ` + SELECT DISTINCT users.* + FROM users + JOIN own_role ON users.id = own_role.id_user + JOIN role ON role.id = own_role.id_role + WHERE role.label IN ?; + `, + [roles], + ); + return rows[0]; + } + public static async createUser(user: CreateUser) { const [result] = await this.pool.query( ` diff --git a/api-server/src/resources/user/UserService.ts b/api-server/src/resources/user/UserService.ts index 219dfff..254515e 100644 --- a/api-server/src/resources/user/UserService.ts +++ b/api-server/src/resources/user/UserService.ts @@ -38,7 +38,7 @@ export class UserService { logger.error(`Failed to get users. Error: ${error}`); return new ControllerResponse( 500, - "Impossible de récupérer la liste des utilisateurs", + "Impossible de récupérer la liste des collaborateurs", ); } } @@ -69,7 +69,7 @@ export class UserService { logger.error(`Failed to get user list. Error: ${error}`); return new ControllerResponse( 500, - "Impossible de récupérer la liste des utilisateurs", + "Impossible de récupérer la liste des collaborateurs", ); } } diff --git a/api-server/src/test/expense.test.ts b/api-server/src/test/expense.test.ts index fef64c2..d66d29a 100644 --- a/api-server/src/test/expense.test.ts +++ b/api-server/src/test/expense.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "vitest"; import { Expense } from "../common/model/Expense"; -import { ExpenseListDTO } from "../resources/expense/dto/ExpenseListDTO"; +import { + ExpenseInvalidation, + ExpenseListDTO, + ExpenseValidation, +} from "../resources/expense/dto/ExpenseListDTO"; import { ExpenseAmountDateAndStatusDTO } from "../resources/expense/dto/ExpenseAmountDateAndStatusDTO"; import { ExpenseType } from "../common/enum/ExpenseType"; import { ExpenseStatus } from "../common/enum/ExpenseStatus"; @@ -197,7 +201,7 @@ describe("ExpenseAmountDateAndStatusDTO", () => { 500, "motivation", new Date("2141"), - new Date(), + new Date("2141"), ExpenseStatus.WAITING, 0, 0, @@ -239,3 +243,95 @@ describe("ExpenseAmountDateAndStatusDTO", () => { expect(expense.status).not.toBe(ExpenseStatus.WAITING); }); }); + +describe("ExpenseValidation", () => { + test("Test ExpenseValidation Should Be Instance Of Expense And Values Equals", () => { + const expenseValidation = new ExpenseValidation(1, 1); + expenseValidation.validated_at = new Date("2099") + .toISOString() + .split("Z")[0] + .replace("T", " ") + .split(".")[0]; + + const dateToTest = new Date("2099") + .toISOString() + .split("Z")[0] + .replace("T", " ") + .split(".")[0]; + + expect(expenseValidation.id).toBe(1); + expect(expenseValidation.id_validator).toBe(1); + expect(expenseValidation.validated_at).toStrictEqual(dateToTest); + expect(expenseValidation).toBeInstanceOf(ExpenseValidation); + }); + + test("Test Incorrect ExpenseValidation Should Not Be Instance Of Expense", () => { + const notExpenseValidation = { + amount: 500, + incorrect_attribute: "this is not supposed to be here", + created_at: new Date(), + }; + + expect(notExpenseValidation).not.toBeInstanceOf(ExpenseValidation); + }); + + test("Test Correct ExpenseValidation False Value Should Not Be Equal", () => { + const expenseValidation = new ExpenseValidation(1, 1); + expenseValidation.validated_at = new Date("2099") + .toISOString() + .split("Z")[0] + .replace("T", " ") + .split(".")[0]; + + expect(expenseValidation.id).not.toBe(2); + expect(expenseValidation.id_validator).not.toBe(3); + expect(expenseValidation.validated_at).not.toBe(new Date("1999")); + }); +}); + +describe("ExpenseInvalidation", () => { + test("Test ExpenseInvalidation Should Be Instance Of Expense And Values Equals", () => { + const expenseInvalidation = new ExpenseInvalidation(1, "justification", 1); + expenseInvalidation.validated_at = new Date("2099") + .toISOString() + .split("Z")[0] + .replace("T", " ") + .split(".")[0]; + + const dateToTest = new Date("2099") + .toISOString() + .split("Z")[0] + .replace("T", " ") + .split(".")[0]; + + expect(expenseInvalidation.id).toBe(1); + expect(expenseInvalidation.id_validator).toBe(1); + expect(expenseInvalidation.validated_at).toStrictEqual(dateToTest); + expect(expenseInvalidation.justification).toBe("justification"); + expect(expenseInvalidation).toBeInstanceOf(ExpenseInvalidation); + }); + + test("Test Incorrect ExpenseInvalidation Should Not Be Instance Of Expense", () => { + const notExpenseInvalidation = { + amount: 500, + incorrect_attribute: "this is not supposed to be here", + created_at: new Date(), + }; + + expect(notExpenseInvalidation).not.toBeInstanceOf(ExpenseInvalidation); + }); + + test("Test Correct ExpenseInvalidation False Value Should Not Be Equal", () => { + const expenseInvalidation = new ExpenseInvalidation(1, "justification", 1); + expenseInvalidation.validated_at = new Date("2099") + .toISOString() + .split("Z")[0] + .replace("T", " ") + .split(".")[0]; + + expect(expenseInvalidation.id).not.toBe(2); + expect(expenseInvalidation.id_validator).not.toBe(3); + expect(expenseInvalidation.validated_at).not.toBe(new Date("1999")); + expect(expenseInvalidation.justification).not.toBe(new Date("test")); + }); +}); diff --git a/api-server/src/test/team.test.ts b/api-server/src/test/team.test.ts index 2f04ea1..5b01e3b 100644 --- a/api-server/src/test/team.test.ts +++ b/api-server/src/test/team.test.ts @@ -3,18 +3,30 @@ import { Team } from "../common/model/Team"; import { UserListDTO } from "../resources/user/dto/UserListDTO"; import { TeamDTO } from "../resources/team/dto/TeamDTO"; -const team = new Team(1754, "Équipe 7", 8, 19, 122, "Marketing"); +const team = new Team( + 1754, + "Équipe 7", + 8, + 19, + 122, + "Marketing", + "admin", + "admin", + "admin@admin.fr", + [1, "user", "user", "user@user.fr", "", 1], +); describe("Team models should be what we give it", () => { test("Test Team Model", () => { expect(team.id).toBe(1754); expect(team.label).toBe("Équipe 7"); expect(team.minimum_users).toBe(8); - expect(team.id_user_lead_service).toBe(19); + expect(team.id_user_lead_team).toBe(19); expect(team.id_service).toBe(122); expect(team.service_label).toBe("Marketing"); + expect(team.members); }); test("Test TeamDTO", () => { - const teamDTO = new TeamDTO(team); + const teamDTO = new TeamDTO(team, 1, 1, team.members); expect(team.id).toBe(1754); }); }); diff --git a/api-server/src/test/verifyTokenMiddleware.test.ts b/api-server/src/test/verifyTokenMiddleware.test.ts new file mode 100644 index 0000000..92b3d92 --- /dev/null +++ b/api-server/src/test/verifyTokenMiddleware.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test, vi } from "vitest"; +import { NextFunction, Request, Response } from "express"; +import { verifyToken } from "../common/middleware/AuthMiddleware"; + +describe("verifyToken middleware", () => { + const mockRequest = (headers: object): Partial => { + return { + ...headers, + }; + }; + + const mockResponse = () => { + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + }; + return res as unknown as Response; + }; + + const mockNext: NextFunction = vi.fn(); + + test("should return 401 if no token is provided", () => { + const req = mockRequest({ headers: { authorization: "" } }); + const res = mockResponse(); + const next = mockNext; + + verifyToken(req as Request, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: "Accès refusé" }); + expect(next).not.toHaveBeenCalled(); + }); + + test("should return 401 if token is invalid", () => { + const req = mockRequest({ + headers: { authorization: "Bearer invalidtoken" }, + }); + const res = mockResponse(); + const next = mockNext; + + verifyToken(req as Request, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ message: "Invalid token" }); + expect(next).not.toHaveBeenCalled(); + }); +}); diff --git a/react-app/index.html b/react-app/index.html index 3b032fa..f06c23b 100644 --- a/react-app/index.html +++ b/react-app/index.html @@ -2,8 +2,10 @@ - + + =6" } }, + "node_modules/cmdk": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.0.0.tgz", + "integrity": "sha512-gDzVf0a09TvoJ5jnuPvygTB77+XdOSwEmJ88L6XPFPlv7T3RxbP9jgenfylrAMD0+Le1aO0nVjQUzl2g+vjz5Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-dialog": "1.0.5", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/cmdk/node_modules/@radix-ui/primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz", + "integrity": "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-compose-refs": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", + "integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-context": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.1.tgz", + "integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-dialog": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.0.5.tgz", + "integrity": "sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-dismissable-layer": "1.0.5", + "@radix-ui/react-focus-guards": "1.0.1", + "@radix-ui/react-focus-scope": "1.0.4", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-portal": "1.0.4", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-use-controllable-state": "1.0.1", + "aria-hidden": "^1.1.1", + "react-remove-scroll": "2.5.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.0.5.tgz", + "integrity": "sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-escape-keydown": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-focus-guards": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.0.1.tgz", + "integrity": "sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-focus-scope": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.0.4.tgz", + "integrity": "sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-id": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz", + "integrity": "sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-portal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.0.4.tgz", + "integrity": "sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-primitive": "1.0.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-presence": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.1.tgz", + "integrity": "sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-primitive": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz", + "integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-slot": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", + "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", + "integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz", + "integrity": "sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.0.3.tgz", + "integrity": "sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz", + "integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/cmdk/node_modules/react-remove-scroll": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.5.tgz", + "integrity": "sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.3", + "react-style-singleton": "^2.2.1", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.0", + "use-sidecar": "^1.1.2" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3013,7 +3398,6 @@ "version": "4.3.6", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", - "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -3112,6 +3496,26 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true }, + "node_modules/engine.io-client": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.4.tgz", + "integrity": "sha512-GeZeeRjpD2qf49cZQ0Wvh/8NJNfeXkXXcoGh+F77oEAgo9gUHwT1fCRxSNU+YEEaysOJTnsFHmM5oAcPy4ntvQ==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1", + "xmlhttprequest-ssl": "~2.0.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -4055,8 +4459,7 @@ "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/mz": { "version": "2.7.0", @@ -5035,6 +5438,32 @@ "node": ">=8" } }, + "node_modules/socket.io-client": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.5.tgz", + "integrity": "sha512-sJ/tqHOCe7Z50JCBCXrsY3I2k03iOiUe+tj1OmKeD2lXPiGH/RUCdTZFoqVyN7l1MnpIzPrGtLcijffmeouNlQ==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/sonner": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.5.0.tgz", @@ -5704,6 +6133,34 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", + "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/yaml": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.0.tgz", diff --git a/react-app/package.json b/react-app/package.json index c12bafb..545dd96 100644 --- a/react-app/package.json +++ b/react-app/package.json @@ -30,6 +30,7 @@ "@radix-ui/react-tooltip": "^1.1.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "cmdk": "^1.0.0", "date-fns": "^3.6.0", "leaflet": "^1.9.4", "lucide-react": "^0.427.0", @@ -41,6 +42,7 @@ "react-router-dom": "^6.26.0", "recharts": "^2.12.7", "save": "^2.9.0", + "socket.io-client": "^4.7.5", "sonner": "^1.5.0", "zod": "^3.23.8" }, diff --git a/react-app/src/assets/Logo_SIRH.png b/react-app/src/assets/Logo_SIRH.png new file mode 100644 index 0000000..d3e0a1f Binary files /dev/null and b/react-app/src/assets/Logo_SIRH.png differ diff --git a/react-app/src/assets/Logo_SIRH.svg b/react-app/src/assets/Logo_SIRH.svg new file mode 100644 index 0000000..eec3b5b --- /dev/null +++ b/react-app/src/assets/Logo_SIRH.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/react-app/src/common/enum/MemberStatus.enum.ts b/react-app/src/common/enum/MemberStatus.enum.ts new file mode 100644 index 0000000..efa05e8 --- /dev/null +++ b/react-app/src/common/enum/MemberStatus.enum.ts @@ -0,0 +1,4 @@ +export enum MemberStatus { + PRESENT = 1, + NOT_PRESENT = 0, +} diff --git a/react-app/src/common/enum/TeamStatus.enum.ts b/react-app/src/common/enum/TeamStatus.enum.ts new file mode 100644 index 0000000..88b72ce --- /dev/null +++ b/react-app/src/common/enum/TeamStatus.enum.ts @@ -0,0 +1,6 @@ +export enum TeamStatus { + NOT_ENOUGH = "NOT_ENOUGH", + UNDERSTAFFED = "UNDERSTAFFED", + ENOUGH = "ENOUGH", + COMPLETE = "COMPLETE", +} diff --git a/react-app/src/common/routes/OrganisationRoutes.tsx b/react-app/src/common/routes/OrganisationRoutes.tsx index 1a0155b..516e61d 100644 --- a/react-app/src/common/routes/OrganisationRoutes.tsx +++ b/react-app/src/common/routes/OrganisationRoutes.tsx @@ -2,7 +2,9 @@ import { Organisation } from "@/modules/organisation/Organisation.tsx"; import { AgencyCreate } from "@/modules/organisation/pages/AgencyCreate.js"; import { Agency } from "@/modules/organisation/pages/Agency.js"; import { AgencyDepartmentCreate } from "@/modules/organisation/components/service/agencyDepartmentCreate.js"; -import { AgencyTeamCreate } from "@/modules/organisation/components/service/agencyTeamCreate.js"; +import { AgencyTeamCreate } from "@/modules/organisation/components/team/agencyTeamCreate.js"; +import { AgencyDepartmentDetails } from "@/modules/organisation/components/service/agencyDepartmentDetails.js"; +import { AgencyTeamDetails } from "@/modules/organisation/components/team/agencyTeamDetails.tsx"; export const organisationRoutes = { path: "organisation", @@ -14,24 +16,53 @@ export const organisationRoutes = { path: "", element: , }, + { path: "agency/create", element: , }, { - path: "agency/:id", + path: "agency/:id_agency", children: [ { path: "", element: , }, { - path: "service/create", - element: , - }, - { - path: "team/create", - element: , + path: "service", + children: [ + { + path: "create", + element: , + }, + { + path: "details", + children: [ + { + path: ":id_service", + children: [ + { + path: "", + element: , + }, + { + path: "team", + children: [ + { + path: "create", + element: , + }, + { + path: "details/:id_team", + element: , + }, + ], + }, + ], + }, + ], + }, + ], }, ], }, diff --git a/react-app/src/common/routes/Routes.tsx b/react-app/src/common/routes/Routes.tsx index 55858ce..d217bb1 100644 --- a/react-app/src/common/routes/Routes.tsx +++ b/react-app/src/common/routes/Routes.tsx @@ -37,8 +37,10 @@ export const Routes = () => { ]; if (currentUser.id && !userHasRequiredRoles) { - const routeIndex = childrenRoutes.indexOf(userRoutes); - childrenRoutes.splice(routeIndex, 1); + const userRoutesIndex = childrenRoutes.indexOf(userRoutes); + const organisationRoutesIndex = childrenRoutes.indexOf(organisationRoutes); + childrenRoutes.splice(userRoutesIndex, 1); + childrenRoutes.splice(organisationRoutesIndex, 1); } const publicRoutes = [ diff --git a/react-app/src/components/navigation/Navbar.tsx b/react-app/src/components/navigation/Navbar.tsx index 1217747..e28cc68 100644 --- a/react-app/src/components/navigation/Navbar.tsx +++ b/react-app/src/components/navigation/Navbar.tsx @@ -22,9 +22,16 @@ export function NavBar() { bg-gray-800 transition-all duration-500 max-md:w-20" >
-

- SIRH -

+
+ Logo SIRH +

+ SIRH +

+
@@ -35,13 +42,15 @@ export function NavBar() { - - - {navLinkDisplayed && ( - - - + <> + + + + + + + )}
diff --git a/react-app/src/components/navigation/UserMenu.tsx b/react-app/src/components/navigation/UserMenu.tsx index 2357f64..a6f2703 100644 --- a/react-app/src/components/navigation/UserMenu.tsx +++ b/react-app/src/components/navigation/UserMenu.tsx @@ -40,6 +40,13 @@ import { } from "@/components/ui/alert-dialog"; import { useCurrentUser } from "@/common/hooks/useCurrentUser.ts"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar.js"; +import io from "socket.io-client"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; + +const socket = io("http://localhost:4000", { + query: { token: localStorage.accessToken }, +}); export function UserMenu() { const { setSystemTheme, setDarkTheme, setLightTheme } = useTheme(); @@ -54,18 +61,41 @@ export function UserMenu() { setSystemTheme(); }; const localTheme = localStorage.theme || "dark"; + + const [notifications, setNotifications] = useState(0); + + useEffect(() => { + //TODO Initial call for notifications count + }, []); + + useEffect(() => { + socket.on("notification", ({ data }) => { + if (data !== notifications) { + toast.message(`Vous avez reçu une nouvelle notification`); + setNotifications(Math.min(data, 99)); + } + }); + + return () => { + socket.off("notification"); + }; + }, []); + return ( - - -
+
+ + -
- - - - {currentUser.firstname} {currentUser.lastname} - - - - - - Mon profil - - - - - - - - Notifications - - - - - - - - Nouveau mot de passe - - - - - - - - - - Thème - - -
- - Clair - - - - + + + + {currentUser.firstname} {currentUser.lastname} + + + + + + Mon profil + + + + + + + + Notifications + + + {notifications > 0 && ( +
+ {notifications} +
+ )} +
+
+
+ + + Nouveau mot de passe + + + + + +
+ + + + Thème + + +
+ + Clair + + + + + + Foncé + + + + +
+ - Foncé + Système - + -
- - - Système - - - - -
-
-
-
- - -
- + + + + + + + + +
); } diff --git a/react-app/src/components/ui/avatar.tsx b/react-app/src/components/ui/avatar.tsx index 45cc746..7623948 100644 --- a/react-app/src/components/ui/avatar.tsx +++ b/react-app/src/components/ui/avatar.tsx @@ -37,7 +37,7 @@ const AvatarFallback = React.forwardRef< , + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Command.displayName = CommandPrimitive.displayName; + +interface CommandDialogProps extends DialogProps {} + +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + return ( + + + + {children} + + + + ); +}; + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)); + +CommandInput.displayName = CommandPrimitive.Input.displayName; + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandList.displayName = CommandPrimitive.List.displayName; + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)); + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName; + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandGroup.displayName = CommandPrimitive.Group.displayName; + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +CommandSeparator.displayName = CommandPrimitive.Separator.displayName; + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +CommandItem.displayName = CommandPrimitive.Item.displayName; + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +CommandShortcut.displayName = "CommandShortcut"; + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; diff --git a/react-app/src/components/ui/dropdown-menu.tsx b/react-app/src/components/ui/dropdown-menu.tsx index 363c8e2..d7aba65 100644 --- a/react-app/src/components/ui/dropdown-menu.tsx +++ b/react-app/src/components/ui/dropdown-menu.tsx @@ -177,7 +177,7 @@ const DropdownMenuShortcut = ({ }: React.HTMLAttributes) => { return ( ); diff --git a/react-app/src/components/ui/multiple-command.tsx b/react-app/src/components/ui/multiple-command.tsx new file mode 100644 index 0000000..f4b9332 --- /dev/null +++ b/react-app/src/components/ui/multiple-command.tsx @@ -0,0 +1,634 @@ +"use client"; + +import { Command as CommandPrimitive, useCommandState } from "cmdk"; +import { X } from "lucide-react"; +import * as React from "react"; +import { forwardRef, useEffect } from "react"; + +import { Badge } from "@/components/ui/badge"; +import { + Command, + CommandGroup, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { cn } from "@/lib/utils"; + +export interface Option { + value: string; + label: string; + disable?: boolean; + /** fixed option that can't be removed. */ + fixed?: boolean; + + /** Group the options by providing key. */ + [key: string]: string | boolean | undefined; +} + +interface GroupOption { + [key: string]: Option[]; +} + +interface MultipleSelectorProps { + value?: Option[]; + defaultOptions?: Option[]; + /** manually controlled options */ + options?: Option[]; + placeholder?: string; + /** Loading component. */ + loadingIndicator?: React.ReactNode; + /** Empty component. */ + emptyIndicator?: React.ReactNode; + /** Debounce time for async search. Only work with `onSearch`. */ + delay?: number; + /** + * Only work with `onSearch` prop. Trigger search when `onFocus`. + * For example, when user click on the input, it will trigger the search to get initial options. + **/ + triggerSearchOnFocus?: boolean; + /** async search */ + onSearch?: (value: string) => Promise; + /** + * sync search. This search will not showing loadingIndicator. + * The rest props are the same as async search. + * i.e.: creatable, groupBy, delay. + **/ + onSearchSync?: (value: string) => Option[]; + onChange?: (options: Option[]) => void; + /** Limit the maximum number of selected options. */ + maxSelected?: number; + /** When the number of selected options exceeds the limit, the onMaxSelected will be called. */ + onMaxSelected?: (maxLimit: number) => void; + /** Hide the placeholder when there are options selected. */ + hidePlaceholderWhenSelected?: boolean; + disabled?: boolean; + /** Group the options base on provided key. */ + groupBy?: string; + className?: string; + badgeClassName?: string; + /** + * First item selected is a default behavior by cmdk. That is why the default is true. + * This is a workaround solution by add a dummy item. + * + * @reference: https://github.com/pacocoursey/cmdk/issues/171 + */ + selectFirstItem?: boolean; + /** Allow user to create option when there is no option matched. */ + creatable?: boolean; + /** Props of `Command` */ + commandProps?: React.ComponentPropsWithoutRef; + /** Props of `CommandInput` */ + inputProps?: Omit< + React.ComponentPropsWithoutRef, + "value" | "placeholder" | "disabled" + >; + /** hide the clear all button. */ + hideClearAllButton?: boolean; +} + +export interface MultipleSelectorRef { + selectedValue: Option[]; + input: HTMLInputElement; +} + +export function useDebounce(value: T, delay?: number): T { + const [debouncedValue, setDebouncedValue] = React.useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay || 500); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} + +function transToGroupOption(options: Option[], groupBy?: string) { + if (options.length === 0) { + return {}; + } + if (!groupBy) { + return { + "": options, + }; + } + + const groupOption: GroupOption = {}; + options.forEach((option) => { + const key = (option[groupBy] as string) || ""; + if (!groupOption[key]) { + groupOption[key] = []; + } + groupOption[key].push(option); + }); + return groupOption; +} + +function removePickedOption(groupOption: GroupOption, picked: Option[]) { + const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption; + + for (const [key, value] of Object.entries(cloneOption)) { + cloneOption[key] = value.filter( + (val) => !picked.find((p) => p.value === val.value), + ); + } + return cloneOption; +} + +function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) { + for (const [, value] of Object.entries(groupOption)) { + if ( + value.some((option) => targetOption.find((p) => p.value === option.value)) + ) { + return true; + } + } + return false; +} + +/** + * The `CommandEmpty` of shadcn/ui will cause the cmdk empty not rendering correctly. + * So we create one and copy the `Empty` implementation from `cmdk`. + * + * @reference: https://github.com/hsuanyi-chou/shadcn-ui-expansions/issues/34#issuecomment-1949561607 + **/ +const CommandEmpty = forwardRef< + HTMLDivElement, + React.ComponentProps +>(({ className, ...props }, forwardedRef) => { + const render = useCommandState((state) => state.filtered.count === 0); + + if (!render) return null; + + return ( +
+ ); +}); + +CommandEmpty.displayName = "CommandEmpty"; + +const MultipleSelector = React.forwardRef< + MultipleSelectorRef, + MultipleSelectorProps +>( + ( + { + value, + onChange, + placeholder, + defaultOptions: arrayDefaultOptions = [], + options: arrayOptions, + delay, + onSearch, + onSearchSync, + loadingIndicator, + emptyIndicator, + maxSelected = Number.MAX_SAFE_INTEGER, + onMaxSelected, + hidePlaceholderWhenSelected, + disabled, + groupBy, + className, + badgeClassName, + selectFirstItem = true, + creatable = false, + triggerSearchOnFocus = false, + commandProps, + inputProps, + hideClearAllButton = false, + }: MultipleSelectorProps, + ref: React.Ref, + ) => { + const inputRef = React.useRef(null); + const [open, setOpen] = React.useState(false); + const [onScrollbar, setOnScrollbar] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(false); + const dropdownRef = React.useRef(null); // Added this + + const [selected, setSelected] = React.useState(value || []); + const [options, setOptions] = React.useState( + transToGroupOption(arrayDefaultOptions, groupBy), + ); + const [inputValue, setInputValue] = React.useState(""); + const debouncedSearchTerm = useDebounce(inputValue, delay || 500); + + React.useImperativeHandle( + ref, + () => ({ + selectedValue: [...selected], + input: inputRef.current as HTMLInputElement, + focus: () => inputRef.current?.focus(), + }), + [selected], + ); + + const handleClickOutside = (event: MouseEvent | TouchEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) && + inputRef.current && + !inputRef.current.contains(event.target as Node) + ) { + setOpen(false); + } + }; + + const handleUnselect = React.useCallback( + (option: Option) => { + const newOptions = selected.filter((s) => s.value !== option.value); + setSelected(newOptions); + onChange?.(newOptions); + }, + [onChange, selected], + ); + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + const input = inputRef.current; + if (input) { + if (e.key === "Delete" || e.key === "Backspace") { + if (input.value === "" && selected.length > 0) { + const lastSelectOption = selected[selected.length - 1]; + // If last item is fixed, we should not remove it. + if (!lastSelectOption.fixed) { + handleUnselect(selected[selected.length - 1]); + } + } + } + // This is not a default behavior of the field + if (e.key === "Escape") { + input.blur(); + } + } + }, + [handleUnselect, selected], + ); + + useEffect(() => { + if (open) { + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("touchend", handleClickOutside); + } else { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("touchend", handleClickOutside); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("touchend", handleClickOutside); + }; + }, [open]); + + useEffect(() => { + if (value) { + setSelected(value); + } + }, [value]); + + useEffect(() => { + /** If `onSearch` is provided, do not trigger options updated. */ + if (!arrayOptions || onSearch) { + return; + } + const newOption = transToGroupOption(arrayOptions || [], groupBy); + if (JSON.stringify(newOption) !== JSON.stringify(options)) { + setOptions(newOption); + } + }, [arrayDefaultOptions, arrayOptions, groupBy, onSearch, options]); + + useEffect(() => { + /** sync search */ + + const doSearchSync = () => { + const res = onSearchSync?.(debouncedSearchTerm); + setOptions(transToGroupOption(res || [], groupBy)); + }; + + const exec = async () => { + if (!onSearchSync || !open) return; + + if (triggerSearchOnFocus) { + doSearchSync(); + } + + if (debouncedSearchTerm) { + doSearchSync(); + } + }; + + void exec(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]); + + useEffect(() => { + /** async search */ + + const doSearch = async () => { + setIsLoading(true); + const res = await onSearch?.(debouncedSearchTerm); + setOptions(transToGroupOption(res || [], groupBy)); + setIsLoading(false); + }; + + const exec = async () => { + if (!onSearch || !open) return; + + if (triggerSearchOnFocus) { + await doSearch(); + } + + if (debouncedSearchTerm) { + await doSearch(); + } + }; + + void exec(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus]); + + const CreatableItem = () => { + if (!creatable) return undefined; + if ( + isOptionsExist(options, [{ value: inputValue, label: inputValue }]) || + selected.find((s) => s.value === inputValue) + ) { + return undefined; + } + + const Item = ( + { + e.preventDefault(); + e.stopPropagation(); + }} + onSelect={(value: string) => { + if (selected.length >= maxSelected) { + onMaxSelected?.(selected.length); + return; + } + setInputValue(""); + const newOptions = [...selected, { value, label: value }]; + setSelected(newOptions); + onChange?.(newOptions); + }} + > + {`Create "${inputValue}"`} + + ); + + // For normal creatable + if (!onSearch && inputValue.length > 0) { + return Item; + } + + // For async search creatable. avoid showing creatable item before loading at first. + if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) { + return Item; + } + + return undefined; + }; + + const EmptyItem = React.useCallback(() => { + if (!emptyIndicator) return undefined; + + // For async search that showing emptyIndicator + if (onSearch && !creatable && Object.keys(options).length === 0) { + return ( + + {emptyIndicator} + + ); + } + + return {emptyIndicator}; + }, [creatable, emptyIndicator, onSearch, options]); + + const selectables = React.useMemo( + () => removePickedOption(options, selected), + [options, selected], + ); + + /** Avoid Creatable Selector freezing or lagging when paste a long string. */ + const commandFilter = React.useCallback(() => { + if (commandProps?.filter) { + return commandProps.filter; + } + + if (creatable) { + return (value: string, search: string) => { + return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1; + }; + } + // Using default filter in `cmdk`. We don't have to provide it. + return undefined; + }, [creatable, commandProps?.filter]); + + return ( + { + handleKeyDown(e); + commandProps?.onKeyDown?.(e); + }} + className={cn( + "h-auto overflow-visible bg-transparent", + commandProps?.className, + )} + shouldFilter={ + commandProps?.shouldFilter !== undefined + ? commandProps.shouldFilter + : !onSearch + } // When onSearch is provided, we don't want to filter the options. You can still override it. + filter={commandFilter()} + > +
{ + if (disabled) return; + inputRef.current?.focus(); + }} + > +
+ {selected.map((option) => { + return ( + + {option.label} + + + ); + })} + {/* Avoid having the "Search" Icon */} + { + setInputValue(value); + inputProps?.onValueChange?.(value); + }} + onBlur={(event) => { + if (!onScrollbar) { + setOpen(false); + } + inputProps?.onBlur?.(event); + }} + onFocus={(event) => { + setOpen(true); + triggerSearchOnFocus && onSearch?.(debouncedSearchTerm); + inputProps?.onFocus?.(event); + }} + placeholder={ + hidePlaceholderWhenSelected && selected.length !== 0 + ? "" + : placeholder + } + className={cn( + "placeholder:text-muted-foreground flex-1 bg-transparent outline-none", + { + "w-full": hidePlaceholderWhenSelected, + "px-3 py-2": selected.length === 0, + "ml-1": selected.length !== 0, + }, + inputProps?.className, + )} + /> + +
+
+
+ {open && ( + { + setOnScrollbar(false); + }} + onMouseEnter={() => { + setOnScrollbar(true); + }} + onMouseUp={() => { + inputRef.current?.focus(); + }} + > + {isLoading ? ( + <>{loadingIndicator} + ) : ( + <> + {EmptyItem()} + {CreatableItem()} + {!selectFirstItem && ( + + )} + {Object.entries(selectables).map(([key, dropdowns]) => ( + + <> + {dropdowns.map((option) => { + return ( + { + e.preventDefault(); + e.stopPropagation(); + }} + onSelect={() => { + if (selected.length >= maxSelected) { + onMaxSelected?.(selected.length); + return; + } + setInputValue(""); + const newOptions = [...selected, option]; + setSelected(newOptions); + onChange?.(newOptions); + }} + className={cn( + "cursor-pointer", + option.disable && + "text-muted-foreground cursor-default", + )} + > + {option.label} + + ); + })} + + + ))} + + )} + + )} +
+
+ ); + }, +); + +MultipleSelector.displayName = "MultipleSelector"; +export default MultipleSelector; diff --git a/react-app/src/components/ui/select.tsx b/react-app/src/components/ui/select.tsx index 149e6e9..d26a220 100644 --- a/react-app/src/components/ui/select.tsx +++ b/react-app/src/components/ui/select.tsx @@ -22,7 +22,7 @@ const SelectTrigger = React.forwardRef< span]:line-clamp-1", + "flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-gray-200 bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-white placeholder:text-gray-500 focus:outline-none focus:ring-1 focus:ring-gray-950 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-800 dark:ring-offset-gray-950 dark:placeholder:text-gray-400 dark:focus:ring-gray-300 [&>span]:line-clamp-1", className, )} {...props} diff --git a/react-app/src/components/ui/table.tsx b/react-app/src/components/ui/table.tsx index fffe201..463c060 100644 --- a/react-app/src/components/ui/table.tsx +++ b/react-app/src/components/ui/table.tsx @@ -6,7 +6,7 @@ const Table = React.forwardRef< HTMLTableElement, React.HTMLAttributes >(({ className, ...props }, ref) => ( -
+
>(({ className, ...props }, ref) => ( - + )); TableHeader.displayName = "TableHeader"; @@ -73,7 +80,7 @@ const TableHead = React.forwardRef<
[role=checkbox]]:translate-y-[2px]", + "h-10 p-2 text-left align-middle text-sm font-semibold text-gray-600 dark:text-gray-400 [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", className, )} {...props} diff --git a/react-app/src/components/ui/tabs.tsx b/react-app/src/components/ui/tabs.tsx index b62a23a..97d01ca 100644 --- a/react-app/src/components/ui/tabs.tsx +++ b/react-app/src/components/ui/tabs.tsx @@ -12,7 +12,7 @@ const TabsList = React.forwardRef< { + const { currentUser, refreshCurrentUser } = useCurrentUser(); + const navigate = useNavigate(); + + useEffect(() => { + refreshCurrentUser(); + }, []); + + const handleNavigateToDemand = () => { + navigate("/demand/create"); + }; + return ( - +
+ + + +
+ + Mon service +
+
+
+ + Vous n'êtes affilié à aucun service + +
+
+ +
+ + + +
+ + Mon équipe +
+
+
+ + Vous n'êtes affilié à aucune équipe + +
+ + + +
+ + Soldes +
+ +
+
+ + {currentUser.ca} jours + {currentUser.rtt} jours + {currentUser.tt} jours + +
+ + + + Metz Numeric School + + +
); }; diff --git a/react-app/src/modules/demand/components/demandCard.tsx b/react-app/src/modules/demand/components/demandCard.tsx index dd80b0c..77d7514 100644 --- a/react-app/src/modules/demand/components/demandCard.tsx +++ b/react-app/src/modules/demand/components/demandCard.tsx @@ -8,7 +8,7 @@ export function DemandCard() { return ( <> -
+
diff --git a/react-app/src/modules/demand/pages/Demand.tsx b/react-app/src/modules/demand/pages/Demand.tsx index c80edd5..ed8d457 100644 --- a/react-app/src/modules/demand/pages/Demand.tsx +++ b/react-app/src/modules/demand/pages/Demand.tsx @@ -42,6 +42,7 @@ import { useCurrentUser } from "@/common/hooks/useCurrentUser.js"; import { DemandStatus } from "@/common/enum/DemandStatus.enum.js"; import { DemandType } from "@/common/enum/DemandType.enum.js"; import { customFetcher } from "@/common/helper/fetchInstance.js"; +import { Card } from "@/components/ui/card"; export function Demand() { const [demandList, setDemandList] = useState([]); @@ -87,11 +88,10 @@ export function Demand() { ) => { try { const response = await customFetcher( - "http://localhost:5000/api/demand?" + + `http://localhost:5000/api/demand/list/${type || "All"}?` + new URLSearchParams({ pageSize: pageSize.toString() || "10", pageNumber: pageNumber.toString() || "1", - type: type || "", }), ); if (response.response.status === 200) { @@ -183,7 +183,7 @@ export function Demand() { const tableDemand = ( <> -
+ @@ -240,7 +240,7 @@ export function Demand() { )}
-
+
@@ -262,7 +262,7 @@ export function Demand() {
- {`${1 + pageSize * (pageNumber - 1)} - ${demandList.length + pageSize * (pageNumber - 1)} sur ${totalData}`} + {`${demandList.length === 0 ? 0 : 1 + pageSize * (pageNumber - 1)} - ${demandList.length + pageSize * (pageNumber - 1)} sur ${totalData}`} ); - return ( - - -
- - - - - - - - - - - - - - - - - - - -
-
+ const tableExpense = ( + <> + Type de demande Frais - Date de facturation + Date de facturation Status - {expenses.map((expense) => ( - - ))} + {expenses.length === 0 ? ( + + + Aucune demande de frais n'a été trouvée + + + ) : ( + expenses.map((expense) => ( + + )) + )}
-
-
- - -
-
- - {`${limit * (pageNumber - 1) + 1} - ${maxValue()} sur ${expensesCount}`} - - - -
+ +
+
+ + +
+
+ + {`${expenses.length === 0 ? 0 : limit * (pageNumber - 1) + 1} - ${maxValue()} sur ${expensesCount}`} + + +
+ + ); + + return ( + + + + + setSelectedType(selectedTypeEnum.ALL)} + > + Général + + setSelectedType(selectedTypeEnum.TRAVEL)} + > + Déplacement + + setSelectedType(selectedTypeEnum.FOOD)} + > + Restauration + + setSelectedType(selectedTypeEnum.COMPENSATION)} + > + Indemnités + + setSelectedType(selectedTypeEnum.HOUSING)} + > + Hébergement + + + {tableExpense} + {tableExpense} + {tableExpense} + {tableExpense} + {tableExpense} + ); } diff --git a/react-app/src/modules/expense/pages/ExpenseDetails.tsx b/react-app/src/modules/expense/pages/ExpenseDetails.tsx index e452ad6..5f2e8f0 100644 --- a/react-app/src/modules/expense/pages/ExpenseDetails.tsx +++ b/react-app/src/modules/expense/pages/ExpenseDetails.tsx @@ -14,7 +14,7 @@ import { ExpenseStatus, ExpenseType, } from "@/models/ExpenseModel.ts"; -import { ArrowLeftIcon } from "@radix-ui/react-icons"; +import { ArrowLeftIcon, Pencil1Icon } from "@radix-ui/react-icons"; import { Badge } from "@/components/ui/badge.tsx"; import { toast } from "sonner"; import { @@ -28,15 +28,17 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog.tsx"; -import { MdOutlineVisibility } from "react-icons/md"; +import { MdOutlineDelete, MdOutlineVisibility } from "react-icons/md"; import { undefined } from "zod"; import { customFetcher } from "@/common/helper/fetchInstance.js"; +import { FieldRow } from "@/components/fieldRow.tsx"; export function ExpenseDetails() { const navigate = useNavigate(); const handleGoBackToList = () => { navigate("/expense"); }; + const [expense, setExpense] = useState( new ExpenseList( "0", @@ -120,23 +122,11 @@ export function ExpenseDetails() { ) => { switch (enumToTranslate) { case ExpenseStatus.WAITING: - return ( - - En attente - - ); + return En attente; case ExpenseStatus.REFUNDED: - return ( - - Remboursé - - ); + return Remboursé; case ExpenseStatus.NOT_REFUNDED: - return ( - - Non remboursé - - ); + return Non remboursé; } }; @@ -189,6 +179,44 @@ export function ExpenseDetails() { window.open(`${expense.fileUrl}`, "_blank"); }; + const buttons = () => { + if (expense.status === "WAITING") { + return ( +
+ + + + + + + + Êtes-vous sûr de vouloir supprimer cette demande ? + + + Cette action est irrévesrible, les données supprimée ne + pourront pas être restaurée. + + + + Annuler + + Confirmer + + + + + +
+ ); + } + }; + return ( <>
@@ -205,79 +233,30 @@ export function ExpenseDetails() { Visualisation de la demande n°{expense.id} -
- - Supprimer - - - - Êtes-vous sûr de vouloir supprimer cette demande ? - - - Cette action est irrévesrible, les données supprimée ne - pourront pas être restaurée. - - - - Annuler - - Confirmer - - - - - -
+ {buttons()}
- -
-
Type
-
- {translateAndDisplayExpenseTypeEnum(expense.type)} -
-
-
-
Montant
-
{expense.amount}€
-
-
-
Description
-
{expense.motivation}
-
-
-
Date de facturation
-
- {expense.facturation_date.toLocaleDateString()} -
-
-
-
Date de création
-
- {expense.created_at.toLocaleDateString()} -
-
-
-
Status
-
- {translateAndDisplayExpenseStatusEnum(expense.status)} -
-
-
-
Fichier
+ + + {translateAndDisplayExpenseTypeEnum(expense.type)} + + {expense.amount}€ + {expense.motivation} + + {expense.facturation_date.toLocaleDateString()} + + + {expense.created_at.toLocaleDateString()} + + + {translateAndDisplayExpenseStatusEnum(expense.status)} + +
-
- {fetchFileNameFromUrl(expense.fileUrl)} -
+
{fetchFileNameFromUrl(expense.fileUrl)}
{previewButton(expense.fileUrl)}
-
+
diff --git a/react-app/src/modules/organisation/Organisation.tsx b/react-app/src/modules/organisation/Organisation.tsx index 7066a64..f86f357 100644 --- a/react-app/src/modules/organisation/Organisation.tsx +++ b/react-app/src/modules/organisation/Organisation.tsx @@ -72,7 +72,7 @@ export const Organisation = () => { const newOrg = ( ); @@ -92,7 +92,10 @@ export const Organisation = () => { - Aucune agence trouvée +
+ Aucune agence trouvé + Céez en une +
diff --git a/react-app/src/modules/organisation/components/agencyAddress.tsx b/react-app/src/modules/organisation/components/agency/agencyAddress.tsx similarity index 96% rename from react-app/src/modules/organisation/components/agencyAddress.tsx rename to react-app/src/modules/organisation/components/agency/agencyAddress.tsx index 31bdfb2..3b4e4bd 100644 --- a/react-app/src/modules/organisation/components/agencyAddress.tsx +++ b/react-app/src/modules/organisation/components/agency/agencyAddress.tsx @@ -142,8 +142,10 @@ export const AgencyAddress: React.FC = ({ Annuler ) : ( - )} diff --git a/react-app/src/modules/organisation/components/agencyChart.tsx b/react-app/src/modules/organisation/components/agency/agencyChart.tsx similarity index 76% rename from react-app/src/modules/organisation/components/agencyChart.tsx rename to react-app/src/modules/organisation/components/agency/agencyChart.tsx index bb50b45..ee35f41 100644 --- a/react-app/src/modules/organisation/components/agencyChart.tsx +++ b/react-app/src/modules/organisation/components/agency/agencyChart.tsx @@ -31,6 +31,8 @@ export const AgencyChart: React.FC = () => {
{ />
- +
- +
diff --git a/react-app/src/modules/organisation/components/agencyDetails.tsx b/react-app/src/modules/organisation/components/agency/agencyDetails.tsx similarity index 94% rename from react-app/src/modules/organisation/components/agencyDetails.tsx rename to react-app/src/modules/organisation/components/agency/agencyDetails.tsx index e90b40c..2dedba4 100644 --- a/react-app/src/modules/organisation/components/agencyDetails.tsx +++ b/react-app/src/modules/organisation/components/agency/agencyDetails.tsx @@ -1,7 +1,7 @@ import React, { Dispatch, SetStateAction } from "react"; -import { AgencyAddress } from "@/modules/organisation/components/agencyAddress.js"; +import { AgencyAddress } from "@/modules/organisation/components/agency/agencyAddress.js"; import { AgencyModel } from "@/models/organisation/agency/Agency.model.js"; -import { AgencyChart } from "@/modules/organisation/components/agencyChart.js"; +import { AgencyChart } from "@/modules/organisation/components/agency/agencyChart.js"; interface AgencyDetailsProps { agency: AgencyModel; diff --git a/react-app/src/modules/organisation/components/agencyTeam.tsx b/react-app/src/modules/organisation/components/agencyTeam.tsx deleted file mode 100644 index e6a10bc..0000000 --- a/react-app/src/modules/organisation/components/agencyTeam.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { AgencyModel } from "@/models/organisation/agency/Agency.model.js"; -import React, { useEffect, useState } from "react"; -import { Button } from "@/components/ui/button.js"; -import { CaretLeftIcon, CaretRightIcon, PlusIcon } from "@radix-ui/react-icons"; -import { customFetcher } from "@/common/helper/fetchInstance.js"; -import { TeamList } from "@/models/organisation/TeamList.model.js"; -import { useNavigate } from "react-router-dom"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table.js"; -import { Label } from "@/components/ui/label.js"; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select.js"; - -interface AgencyDetailsProps { - agency: AgencyModel; -} - -export const AgencyTeam: React.FC = (agency) => { - const [teamList, setTeamList] = useState([]); - const [totalData, setTotalData] = useState(0); - const [pageSize, setPageSize] = useState(5); - const [pageNumber, setPageNumber] = useState(1); - const navigate = useNavigate(); - - const fetchTeam = async (pageSize: number, pageNumber: number) => { - const response = await customFetcher( - `http://localhost:5000/api/team/${agency.agency.id}?` + - new URLSearchParams({ - pageSize: pageSize.toString() || "10", - pageNumber: pageNumber.toString() || "1", - }), - ); - if (response.response.status === 200) { - setTeamList(response.data.data.list); - setTotalData(response.data.data.totalData); - } - }; - - useEffect(() => { - fetchTeam(pageSize, pageNumber).then(); - }, [pageSize, pageNumber]); - - const handlePageSize = (pageSize: string) => { - setPageNumber(1); - setPageSize(+pageSize); - }; - - const handlePreviousPageNumber = () => { - setPageNumber(pageNumber - 1); - }; - - const handleNextPageNumber = () => { - setPageNumber(pageNumber + 1); - }; - - const handleClickCreate = () => { - navigate("team/create"); - }; - - return ( -
-
- -
- - - - Equipe - Service - Collaborateur - Status - - - - {teamList.length === 0 ? ( - - - Aucune équipe trouvé - - - ) : ( - teamList.map((team: TeamList) => ( - handleClick(department.id)} - > - - {team.label} - - - {team.service_label} - - /// - - {/* {getClassForStatus(department.status)} */} - {team.minimum_users} - - - )) - )} - -
-
-
- - -
-
- - {`${1 + pageSize * (pageNumber - 1)} - ${teamList.length + pageSize * (pageNumber - 1)} sur ${totalData}`} - - - -
-
-
- ); -}; diff --git a/react-app/src/modules/organisation/components/chart/areaChart.tsx b/react-app/src/modules/organisation/components/chart/areaChart.tsx index 8b5aa67..7c7456b 100644 --- a/react-app/src/modules/organisation/components/chart/areaChart.tsx +++ b/react-app/src/modules/organisation/components/chart/areaChart.tsx @@ -23,7 +23,7 @@ export function AreaChartAgency({ }) { return ( - +
{title} {description} @@ -34,53 +34,60 @@ export function AreaChartAgency({ config={chartConfig} className="aspect-auto h-[250px] w-full" > - - - - - - - - - { - const date = new Date(value); - return date.toLocaleDateString("fr-FR", { - year: "numeric", - month: "short", - }); - }} - /> - - { - return new Date(value).toLocaleDateString("fr-FR", { - month: "long", - }); - }} - indicator="dot" - /> - } - /> - - + {data.length === 0 ? ( +
+

Pas assez de données

+
+ ) : ( + + + + + + + + + { + const date = new Date(value); + return date.toLocaleDateString("fr-FR", { + year: "numeric", + month: "short", + }); + }} + /> + + { + return new Date(value).toLocaleDateString("fr-FR", { + month: "long", + }); + }} + indicator="dot" + /> + } + /> + + + + )} diff --git a/react-app/src/modules/organisation/components/chart/barChart.tsx b/react-app/src/modules/organisation/components/chart/barChart.tsx index 84cc31e..07cc4c2 100644 --- a/react-app/src/modules/organisation/components/chart/barChart.tsx +++ b/react-app/src/modules/organisation/components/chart/barChart.tsx @@ -1,6 +1,5 @@ "use client"; -import { TrendingUp } from "lucide-react"; import { Bar, BarChart, @@ -14,7 +13,6 @@ import { Card, CardContent, CardDescription, - CardFooter, CardHeader, CardTitle, } from "@/components/ui/card.js"; @@ -26,12 +24,11 @@ import { } from "@/components/ui/chart.js"; const chartData = [ - { month: "January", desktop: 186, mobile: 80 }, - { month: "February", desktop: 305, mobile: 200 }, - { month: "March", desktop: 237, mobile: 120 }, - { month: "April", desktop: 73, mobile: 190 }, - { month: "May", desktop: 209, mobile: 130 }, - { month: "June", desktop: 214, mobile: 140 }, + { day: "Lundi", absence: 5 }, + { day: "Mardi", absence: 2 }, + { day: "Mercredi", absence: 1 }, + { day: "Jeudi", absence: 2 }, + { day: "Vendredi", absence: 3 }, ]; const chartConfig = { @@ -48,12 +45,15 @@ const chartConfig = { }, } satisfies ChartConfig; -export function BarChartAgency() { +export function BarChartAgency({ + title = "Area Chart - Interactive", + description = "Showing total visitors for the last 3 months", +}) { return ( - Bar Chart - Custom Label - January - June 2024 + {title} + {description} @@ -75,26 +75,26 @@ export function BarChartAgency() { tickFormatter={(value) => value.slice(0, 3)} hide /> - + } /> - -
- Trending up by 5.2% this month -
-
- Showing total visitors for the last 6 months -
-
); } diff --git a/react-app/src/modules/organisation/components/chart/radialChart.tsx b/react-app/src/modules/organisation/components/chart/radialChart.tsx index d39604a..c4ec4d0 100644 --- a/react-app/src/modules/organisation/components/chart/radialChart.tsx +++ b/react-app/src/modules/organisation/components/chart/radialChart.tsx @@ -1,8 +1,6 @@ "use client"; -import { TrendingUp } from "lucide-react"; import { Label, PolarRadiusAxis, RadialBar, RadialBarChart } from "recharts"; - import { Card, CardContent, @@ -17,27 +15,31 @@ import { ChartTooltip, ChartTooltipContent, } from "@/components/ui/chart.js"; -const chartData = [{ month: "january", desktop: 1260, mobile: 570 }]; + +const chartData = [{ month: "january", present: 6, absent: 4 }]; const chartConfig = { desktop: { - label: "Desktop", + label: "Present", color: "hsl(var(--chart-1))", }, mobile: { - label: "Mobile", + label: "Absent", color: "hsl(var(--chart-2))", }, } satisfies ChartConfig; -export function RadialChartAgency() { - const totalVisitors = chartData[0].desktop + chartData[0].mobile; +export function RadialChartAgency({ + title = "Area Chart - Interactive", + description = "Showing total visitors for the last 3 months", +}) { + const totalVisitors = chartData[0].present + chartData[0].absent; return ( - Radial Chart - Stacked - January - June 2024 + {title} + {description} {totalVisitors.toLocaleString()} - Visitors + Total employé ); @@ -81,14 +83,14 @@ export function RadialChartAgency() { /> - -
- Trending up by 5.2% this month -
-
- Showing total visitors for the last 6 months -
-
+
); } diff --git a/react-app/src/modules/organisation/components/agencyDepartment.tsx b/react-app/src/modules/organisation/components/service/agencyDepartment.tsx similarity index 62% rename from react-app/src/modules/organisation/components/agencyDepartment.tsx rename to react-app/src/modules/organisation/components/service/agencyDepartment.tsx index c76c0ec..577ce63 100644 --- a/react-app/src/modules/organisation/components/agencyDepartment.tsx +++ b/react-app/src/modules/organisation/components/service/agencyDepartment.tsx @@ -12,7 +12,7 @@ import { CaretLeftIcon, CaretRightIcon, PlusIcon } from "@radix-ui/react-icons"; import { Button } from "@/components/ui/button.js"; import { useNavigate } from "react-router-dom"; import { customFetcher } from "@/common/helper/fetchInstance.js"; -import { DepartmentList } from "@/models/organisation/DepartmentList.model.js"; +import { DepartmentList } from "@/models/organisation/department/DepartmentList.model.ts"; import { Label } from "@/components/ui/label.js"; import { Select, @@ -22,6 +22,8 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select.js"; +import { Card } from "@/components/ui/card"; +import { GrGroup } from "react-icons/gr"; interface AgencyDetailsProps { agency: AgencyModel; @@ -69,54 +71,64 @@ export const AgencyDepartment: React.FC = (agency) => { navigate("service/create"); }; + const handleClick = (id_service: number) => { + navigate(`service/details/${id_service}`); + }; return (
-
+
- - - - Service - Collaborateur - Status - - - - {departmentList.length === 0 ? ( + +
+ - - Aucun département trouvé - + Service + Chef de service - ) : ( - departmentList.map((department: DepartmentList) => ( - handleClick(department.id)} - > - - {department.label} - - - {department.id_user_lead_service} - - - {/* {getClassForStatus(department.status)} */} - {department.minimum_users} + + + {departmentList.length === 0 ? ( + + +
+ Aucun service trouvé + Céez en un +
- )) - )} -
-
+ ) : ( + departmentList.map((department: DepartmentList) => ( + handleClick(department.id)} + > + + +
+
{department.label}
+
+ nombre totale d'équipe {department.team_count} +
+
+
+ + {department.lead_service_firstname}{" "} + {department.lead_service_lastname} + +
+ )) + )} + +
+
- + +
+
+ + +
+ + ); + + return ( + + + +
+ + Informations +
+ {departmentCanBeUpdated ? ( + + ) : ( + + )} +
+
+ {departmentCanBeUpdated ? departmentUpdating : departmentFields} + {departmentCanBeUpdated && ( + + + + )} +
+ ); +}; diff --git a/react-app/src/modules/organisation/components/team/agencyTeam.tsx b/react-app/src/modules/organisation/components/team/agencyTeam.tsx new file mode 100644 index 0000000..764db1f --- /dev/null +++ b/react-app/src/modules/organisation/components/team/agencyTeam.tsx @@ -0,0 +1,179 @@ +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button.js"; +import { CaretLeftIcon, CaretRightIcon } from "@radix-ui/react-icons"; +import { customFetcher } from "@/common/helper/fetchInstance.js"; +import { TeamList } from "@/models/organisation/team/TeamList.model.js"; +import { useNavigate, useParams } from "react-router-dom"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table.js"; +import { Label } from "@/components/ui/label.js"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select.js"; +import { Card } from "@/components/ui/card.js"; +import { TeamStatus } from "@/common/enum/TeamStatus.enum.ts"; +import { Badge } from "@/components/ui/badge.tsx"; +import { MdHomeRepairService } from "react-icons/md"; + +export const AgencyTeam = () => { + const [teamList, setTeamList] = useState([]); + const [totalData, setTotalData] = useState(0); + const [pageSize, setPageSize] = useState(5); + const [pageNumber, setPageNumber] = useState(1); + const navigate = useNavigate(); + const { id_agency, id_service } = useParams(); + + const fetchTeam = async (pageSize: number, pageNumber: number) => { + const response = await customFetcher( + `http://localhost:5000/api/team/${id_service}?` + + new URLSearchParams({ + pageSize: pageSize.toString() || "10", + pageNumber: pageNumber.toString() || "1", + }), + ); + if (response.response.status === 200) { + setTeamList(response.data.data.list); + setTotalData(response.data.data.totalData); + } + }; + + useEffect(() => { + fetchTeam(pageSize, pageNumber); + }, [pageSize, pageNumber]); + + const handlePageSize = (pageSize: string) => { + setPageNumber(1); + setPageSize(+pageSize); + }; + + const handlePreviousPageNumber = () => { + setPageNumber(pageNumber - 1); + }; + + const handleNextPageNumber = () => { + setPageNumber(pageNumber + 1); + }; + + const handleClick = (id: number) => { + navigate( + `/organisation/agency/${id_agency}/service/details/${id_service}/team/details/${id}`, + ); + }; + + const handleStatus = (status: TeamStatus) => { + if (status === TeamStatus.COMPLETE) { + return Effectif complet; + } + if (status === TeamStatus.UNDERSTAFFED) { + return Sous effectif; + } + if (status === TeamStatus.ENOUGH) { + return Effectif suffisant; + } + if (status === TeamStatus.NOT_ENOUGH) { + return Effectif insuffisant; + } + }; + + return ( + <> + + + + + Équipe + Chef d'équipe + Statut + + + + {teamList.length === 0 ? ( + + +
+ Aucune équipe trouvé + Céez en une +
+
+
+ ) : ( + teamList.map((team: TeamList) => { + return ( + handleClick(team.id)} + > + + +
+
{team.label}
+
+ {team.total_present} présent(s) sur {team.total_team} +
+
+
+ + {team.lead_team_firstname} {team.lead_team_lastname} + + {handleStatus(team.status)} +
+ ); + }) + )} +
+
+
+
+
+ + +
+
+ + {`${teamList.length === 0 ? 0 : 1 + pageSize * (pageNumber - 1)} - ${teamList.length + pageSize * (pageNumber - 1)} sur ${totalData}`} + + + +
+
+ + ); +}; diff --git a/react-app/src/modules/organisation/components/team/agencyTeamCreate.tsx b/react-app/src/modules/organisation/components/team/agencyTeamCreate.tsx new file mode 100644 index 0000000..c97abcb --- /dev/null +++ b/react-app/src/modules/organisation/components/team/agencyTeamCreate.tsx @@ -0,0 +1,182 @@ +import { useEffect, useState } from "react"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card.js"; +import { Label } from "@/components/ui/label.js"; +import { Input } from "@/components/ui/input.js"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select.js"; +import { Button } from "@/components/ui/button.js"; +import { useNavigate, useParams } from "react-router-dom"; +import { customFetcher } from "@/common/helper/fetchInstance.js"; +import { CreateTeamFormDataModel } from "@/models/organisation/team/CreateTeamFormData.model.ts"; +import { UserList } from "@/common/type/user/user-list.type.js"; +import { Checkbox } from "@/components/ui/checkbox.js"; + +export const AgencyTeamCreate = () => { + const navigate = useNavigate(); + const { id_service, id_agency } = useParams(); + const [team, setTeam] = useState(new CreateTeamFormDataModel()); + const [selectedUsers, setSelectedUsers] = useState([]); + const [users, setUsers] = useState([]); + + useEffect(() => { + const fetchUsers = async () => { + const response = await customFetcher(`http://localhost:5000/api/user/`); + if (response.response.status === 200) { + setUsers(response.data.data.list); + } + }; + fetchUsers().then(); + }, []); + + const handleClickSubmitButton = async (event: { + preventDefault: () => void; + }) => { + event.preventDefault(); + const config = { + method: "POST", + body: JSON.stringify({ + ...team, + id_service: id_service, + members: selectedUsers, + }), + }; + + const newAgencyFetch = await customFetcher( + `http://localhost:5000/api/team/create`, + config, + ); + + if (newAgencyFetch.response.status === 200) { + navigate( + `/organisation/agency/${id_agency}/service/details/${id_service}`, + ); + } + }; + + const handleTeamFormDataChange = (e: { + target: { name: string; value: string }; + }) => { + setTeam({ + ...team, + [e.target.name]: e.target.value, + }); + }; + + const handleSelectChangeUserLead = (value: string | number) => { + setTeam({ + ...team, + id_user_lead_team: +value, + }); + }; + + const handleCheckboxChange = (userId: number, checked: boolean) => { + if (checked) { + setSelectedUsers((prevSelected) => [...prevSelected, userId]); + } else { + setSelectedUsers((prevSelected) => + prevSelected.filter((id) => id !== userId), + ); + } + }; + + return ( +
+ + + Création d'un service + + +
+ + + + + + + + + + +
+ {users.map((user) => ( +
+ + handleCheckboxChange(user.id, checked as boolean) + } + /> + +
+ ))} +
+ +
+ + +
+
+
+
+
+ ); +}; diff --git a/react-app/src/modules/organisation/components/team/agencyTeamDetails.tsx b/react-app/src/modules/organisation/components/team/agencyTeamDetails.tsx new file mode 100644 index 0000000..1e60c68 --- /dev/null +++ b/react-app/src/modules/organisation/components/team/agencyTeamDetails.tsx @@ -0,0 +1,381 @@ +import { Button } from "@/components/ui/button.tsx"; +import { FaArrowLeft } from "react-icons/fa"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card.tsx"; +import { useNavigate, useParams } from "react-router-dom"; +import { customFetcher } from "@/common/helper/fetchInstance.ts"; +import { useEffect, useState } from "react"; +import { MdHomeRepairService, MdOutlineDelete } from "react-icons/md"; +import { TeamModel } from "@/models/organisation/team/Team.model.ts"; +import { FieldRow } from "@/components/fieldRow.tsx"; +import { TeamStatus } from "@/common/enum/TeamStatus.enum.ts"; +import { Badge } from "@/components/ui/badge.tsx"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table.tsx"; +import { PlusIcon } from "@radix-ui/react-icons"; +import { TeamMembers } from "@/models/organisation/team/TeamMembers.model.ts"; +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@/components/ui/avatar.tsx"; +import { MemberStatus } from "@/common/enum/MemberStatus.enum.ts"; +import { useCurrentUser } from "@/common/hooks/useCurrentUser.ts"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog.tsx"; +import MultipleSelector from "@/components/ui/multiple-command.tsx"; +import { UserList } from "@/common/type/user/user-list.type.ts"; + +export const AgencyTeamDetails = () => { + const [team, setTeam] = useState(new TeamModel()); + const [members, setMembers] = useState([]); + const { id_service, id_agency, id_team } = useParams(); + const navigate = useNavigate(); + + const fetchTeam = async () => { + const { response, data } = await customFetcher( + `http://localhost:5000/api/team/details/${id_team}`, + ); + if (response.status === 200) { + setTeam(data.data); + setMembers(data.data.members); + } + }; + + useEffect(() => { + fetchTeam().then(); + }, []); + + const getStatusBadge = (status: TeamStatus) => { + if (status === TeamStatus.COMPLETE) { + return Effectif complet; + } + if (status === TeamStatus.UNDERSTAFFED) { + return Sous effectif; + } + if (status === TeamStatus.ENOUGH) { + return Effectif suffisant; + } + if (status === TeamStatus.NOT_ENOUGH) { + return Effectif insuffisant; + } + }; + + const getStatusPresent = (status: MemberStatus) => { + if (status === MemberStatus.PRESENT) { + return Présent; + } + if (status === MemberStatus.NOT_PRESENT) { + return Absent; + } + }; + + const handleClick = () => { + navigate(`/organisation/agency/${id_agency}/service/details/${id_service}`); + }; + + const handleUserLeadTeam = (lead_email: string, member_email: string) => { + if (lead_email === member_email) { + return Chef d'équipe; + } else { + return Membre; + } + }; + + const refreshTeam = async () => { + await fetchTeam().then(); + }; + + return ( + <> +
+ +
+
+ + + + +
+ + {team.label} + + + Chef de l'équipe : {team.lead_team_firstname}{" "} + {team.lead_team_lastname} + +
+ + refreshTeam()} + /> +
+
+
+
+ + + Statut {getStatusBadge(team.status)} + + {team.total_present} présent(s) sur {team.total_team} + + + + + + Informations de l'équipe + + + {team.label} + + {team.lead_team_firstname} {team.lead_team_lastname} + + + +
+
+ + + + + + Membre de l'équipe + + Statut + + + + {members.length === 0 ? ( + + + Aucun membre dans l'équipe + + + ) : ( + members.map((member: TeamMembers) => ( + + + + + + {member.member_firstname.charAt(0)} + {member.member_lastname.charAt(0)} + + +
+
+ {member.member_firstname} {member.member_lastname}{" "} + {handleUserLeadTeam( + member.member_email, + team.lead_team_email, + )} +
+
+ {member.member_email} +
+
+
+ + {getStatusPresent(member.is_present)} + +
+ )) + )} +
+
+
+
+
+ + ); +}; + +interface ConfirmDeleteItemProps { + team: TeamModel; + navigate: ReturnType; +} + +export function ConfirmDeleteItem({ team, navigate }: ConfirmDeleteItemProps) { + const { refreshCurrentUser } = useCurrentUser(); + const { id_team, id_service, id_agency } = useParams(); + + const fetchDepartment = async () => { + const response = await customFetcher( + `http://localhost:5000/api/team/${id_team}`, + { + method: "DELETE", + }, + ); + + if (response.response.status === 200) { + navigate( + `/organisation/agency/${id_agency}/service/details/${id_service}`, + { replace: true }, + ); + } + + refreshCurrentUser(); + }; + + return ( + + + + + + + Êtes vous vraiment sur? + + Vous êtes sur le point de supprimer de manière definitive l'équipe + {team.label} du service {team.service_label}, cette action est + irréversible. + + + + Annuler + + + + + + ); +} + +interface ConfirmCreateItemProps { + members: TeamMembers[]; + refreshTeam: () => void; +} + +export function ConfirmCreateItem({ + members, + refreshTeam, +}: ConfirmCreateItemProps) { + const [users, setUsers] = useState([]); + + const { id_team } = useParams(); + + const membersToValue = (teamMembers: TeamMembers[]) => { + return teamMembers.map((member) => ({ + label: `${member.member_firstname} ${member.member_lastname}`, + value: member.id_member, + })); + }; + + const [selectedMembers, setSelectedMembers] = useState( + membersToValue(members), + ); + + const userToValue = (userList: UserList[]) => { + return userList.map((user) => ({ + label: `${user.firstname} ${user.lastname}`, + value: user.id, + })); + }; + + const fetchUsers = async () => { + await customFetcher(`http://localhost:5000/api/user`).then((response) => { + if (response.response.status !== 200) { + return; + } + setUsers(response.data.data.list); + }); + }; + + useEffect(() => { + fetchUsers().then(); + setSelectedMembers(membersToValue(members)); + }, [members]); + + const handleChange = (value: []) => { + setSelectedMembers(value); + }; + + const fetchMember = async () => { + const member = selectedMembers.map((member) => member.value); + + const response = await customFetcher( + `http://localhost:5000/api/team/edit/${id_team}`, + { + method: "PUT", + body: JSON.stringify(member), + }, + ); + + if (response.response.status === 200) { + refreshTeam(); + } + }; + + return ( + + + + + + + + Modification des membres de l'équipe + + + Aucun résultat trouvé. +

+ } + /> +
+ + { + setSelectedMembers(membersToValue(members)); + }} + > + Annuler + + Modifier + +
+
+ ); +} diff --git a/react-app/src/modules/organisation/pages/Agency.tsx b/react-app/src/modules/organisation/pages/Agency.tsx index 5b66756..dda8b6d 100644 --- a/react-app/src/modules/organisation/pages/Agency.tsx +++ b/react-app/src/modules/organisation/pages/Agency.tsx @@ -7,7 +7,7 @@ import { import { useNavigate, useParams } from "react-router-dom"; import { useEffect, useState } from "react"; import { AgencyModel } from "@/models/organisation/agency/Agency.model.js"; -import { AgencyDetails } from "@/modules/organisation/components/agencyDetails.js"; +import { AgencyDetails } from "@/modules/organisation/components/agency/agencyDetails.js"; import { Button } from "@/components/ui/button.js"; import { ArrowLeftIcon } from "@radix-ui/react-icons"; import { @@ -18,11 +18,22 @@ import { } from "@/components/ui/card.js"; import { customFetcher } from "@/common/helper/fetchInstance.js"; import { TbBuildingEstate } from "react-icons/tb"; -import { AgencyDepartment } from "@/modules/organisation/components/agencyDepartment.js"; -import { AgencyTeam } from "@/modules/organisation/components/agencyTeam.js"; +import { AgencyDepartment } from "@/modules/organisation/components/service/agencyDepartment.js"; +import { useCurrentUser } from "@/common/hooks/useCurrentUser.js"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog.js"; +import { MdOutlineDelete } from "react-icons/md"; export const Agency = () => { - const { id } = useParams(); + const { id_agency } = useParams(); const [agencyLoaded, setAgencyLoaded] = useState(false); const [agencyNotFound, setAgencyNotFound] = useState(false); const [foundAgency, setFoundAgency] = useState( @@ -31,7 +42,7 @@ export const Agency = () => { const navigate = useNavigate(); const fetchUser = async () => { - await customFetcher(`http://localhost:5000/api/agency/${id}`).then( + await customFetcher(`http://localhost:5000/api/agency/${id_agency}`).then( (response) => { if (response.response.status !== 200) { return setAgencyNotFound(true); @@ -60,7 +71,7 @@ export const Agency = () => { const agencyMainPage = (
- + @@ -73,13 +84,13 @@ export const Agency = () => { {foundAgency.address.zipcode} {foundAgency.address.locality}
+
- Agence + Général Services - Équipes @@ -87,9 +98,6 @@ export const Agency = () => { - - -
@@ -99,10 +107,64 @@ export const Agency = () => {
{agencyLoaded && agencyMainPage} {agencyNotFound && noAgency}
); }; + +interface ConfirmDeleteItemProps { + agency: AgencyModel; + navigate: ReturnType; +} + +export function ConfirmDeleteItem({ + agency, + navigate, +}: ConfirmDeleteItemProps) { + const { refreshCurrentUser } = useCurrentUser(); + + const fetchAgency = async () => { + const response = await customFetcher( + `http://localhost:5000/api/agency/${agency.id}`, + { + method: "DELETE", + }, + ); + + if (response.response.status === 200) { + navigate("/organisation", { replace: true }); + } + + refreshCurrentUser(); + }; + + return ( + + + + + + + Êtes vous vraiment sur? + + Vous êtes sur le point de supprimer de manière definitive l'agence + sélectionnée, cette action est irréversible. + + + + Annuler + + + + + + ); +} diff --git a/react-app/src/modules/organisation/pages/AgencyCreate.tsx b/react-app/src/modules/organisation/pages/AgencyCreate.tsx index 9488abd..506d564 100644 --- a/react-app/src/modules/organisation/pages/AgencyCreate.tsx +++ b/react-app/src/modules/organisation/pages/AgencyCreate.tsx @@ -9,7 +9,7 @@ import { Button } from "@/components/ui/button.js"; import { useNavigate } from "react-router-dom"; import { Label } from "@/components/ui/label.js"; import React, { useState } from "react"; -import { CreateOrganisationFormDataModel } from "@/models/organisation/CreateOrganisationFormData.model.js"; +import { CreateOrganisationFormDataModel } from "@/models/organisation/CreateOrganisationFormData.model.ts"; import { customFetcher } from "@/common/helper/fetchInstance.js"; // Définir une interface pour les erreurs diff --git a/react-app/src/modules/organisation/pages/AgencyDetails.tsx b/react-app/src/modules/organisation/pages/AgencyDetails.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/react-app/src/modules/organisation/pages/AgencyEdit.tsx b/react-app/src/modules/organisation/pages/AgencyEdit.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/react-app/src/modules/profile/Notifications.tsx b/react-app/src/modules/profile/Notifications.tsx index 1917b25..d2fcf11 100644 --- a/react-app/src/modules/profile/Notifications.tsx +++ b/react-app/src/modules/profile/Notifications.tsx @@ -1,10 +1,10 @@ import { MainRoot } from "@/components/navigation/MainRoot.tsx"; -import { InProgress } from "@/components/navigation/InProgress.tsx"; +import { NotificationsCard } from "@/modules/profile/components/notificationsCard.tsx"; export const Notifications = () => { return ( - + ); }; diff --git a/react-app/src/modules/profile/Profile.tsx b/react-app/src/modules/profile/Profile.tsx index 882e3cd..2f4fcf4 100644 --- a/react-app/src/modules/profile/Profile.tsx +++ b/react-app/src/modules/profile/Profile.tsx @@ -25,7 +25,9 @@ export const Profile = () => { {currentUser?.firstname} {currentUser?.lastname} - {currentUser?.email} + + {currentUser?.email} +
diff --git a/react-app/src/modules/profile/components/notificationsCard.tsx b/react-app/src/modules/profile/components/notificationsCard.tsx new file mode 100644 index 0000000..769b404 --- /dev/null +++ b/react-app/src/modules/profile/components/notificationsCard.tsx @@ -0,0 +1,188 @@ +import { useEffect, useState } from "react"; +import { customFetcher } from "@/common/helper/fetchInstance.ts"; +import { Card } from "@/components/ui/card.tsx"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table.tsx"; +import { dateOptions } from "@/common/helper/DateHelper.ts"; +import { Label } from "@/components/ui/label.tsx"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select.tsx"; +import { Button } from "@/components/ui/button.tsx"; +import { CaretLeftIcon, CaretRightIcon } from "@radix-ui/react-icons"; +import { GoDotFill } from "react-icons/go"; + +export const NotificationsCard = () => { + const [pageSize, setPageSize] = useState(5); + const [pageNumber, setPageNumber] = useState(1); + const [totalData, setTotalData] = useState(0); + const [notifications, setNotifications] = useState([]); + + const fetchNotifications = async (pageSize: number, pageNumber: number) => { + await customFetcher( + "http://localhost:5000/api/notification?" + + new URLSearchParams({ + pageSize: pageSize.toString() || "10", + pageNumber: pageNumber.toString() || "1", + }), + ).then((response) => { + if (response.response.status !== 200) { + return; + } + setTotalData(response.data.data.totalData); + setNotifications(response.data.data.list); + }); + }; + + useEffect(() => { + fetchNotifications(pageSize, pageNumber).then(); + }, [pageSize, pageNumber]); + + const handlePageSize = (pageSize: string) => { + setPageNumber(1); + setPageSize(+pageSize); + }; + + const handlePreviousPageNumber = () => { + setPageNumber(pageNumber - 1); + }; + + const handleNextPageNumber = () => { + setPageNumber(pageNumber + 1); + }; + + const handleTouchNotification = async (notificationId: number) => { + const notification: any = notifications.find( + (notify: any) => notify.id === notificationId, + ); + + if (notification?.touched) { + return; + } + + await customFetcher( + `http://localhost:5000/api/notification/touch/${notificationId}`, + ).then(async (response) => { + if (response.response.status !== 200) { + return; + } + await fetchNotifications(pageSize, pageNumber); + }); + }; + + return ( + <> + + + + + Libellé + Date de création + + + + {notifications?.length ? ( + notifications?.map((notification: any) => + notification?.touched ? ( + handleTouchNotification(notification?.id)} + > + + {notification?.description} + + + {new Date(notification?.created_at).toLocaleDateString( + "fr-FR", + dateOptions, + )} + + + ) : ( + handleTouchNotification(notification?.id)} + > + + + {notification?.description} + + + {new Date(notification?.created_at).toLocaleDateString( + "fr-FR", + dateOptions, + )} + + + ), + ) + ) : ( + + + Aucune Notification + + + )} + +
+
+
+
+ + +
+
+ + {`${notifications.length === 0 ? 0 : 1 + pageSize * (pageNumber - 1)} - ${notifications.length + pageSize * (pageNumber - 1)} sur ${totalData}`} + + + +
+
+ + ); +}; diff --git a/react-app/src/modules/user/components/demand/userDemands.tsx b/react-app/src/modules/user/components/demand/userDemands.tsx index c46ab74..a0edbed 100644 --- a/react-app/src/modules/user/components/demand/userDemands.tsx +++ b/react-app/src/modules/user/components/demand/userDemands.tsx @@ -27,6 +27,7 @@ import { UserConfirmDemand } from "@/modules/user/components/demand/userConfirmD import { DemandStatus } from "@/common/enum/DemandStatus.enum.js"; import { getDemandBadge } from "@/modules/demand/components/demandBadge.js"; import { DemandListLabel } from "@/modules/demand/components/demandListLabel.js"; +import { Card } from "@/components/ui/card"; interface UserDemandProps { user: UserModel; @@ -84,7 +85,7 @@ export const UserDemands: React.FC = ({ user }) => { return ( <> -
+ @@ -169,7 +170,7 @@ export const UserDemands: React.FC = ({ user }) => { )}
-
+
@@ -191,7 +192,7 @@ export const UserDemands: React.FC = ({ user }) => {
- {`${1 + pageSize * (pageNumber - 1)} - ${ + {`${demandList.length === 0 ? 0 : 1 + pageSize * (pageNumber - 1)} - ${ demandList.length + pageSize * (pageNumber - 1) } sur ${totalData}`} diff --git a/react-app/src/modules/user/components/expense/userExpenseDetails.tsx b/react-app/src/modules/user/components/expense/userExpenseDetails.tsx index 4e26b53..4b363fb 100644 --- a/react-app/src/modules/user/components/expense/userExpenseDetails.tsx +++ b/react-app/src/modules/user/components/expense/userExpenseDetails.tsx @@ -94,7 +94,7 @@ export const UserExpenseDetails = () => { }; const handlePreviewFile = () => { - window.open(expense.fileUrl, "_blank"); + window.open(`${expense.fileUrl}`, "_blank"); }; return ( diff --git a/react-app/src/modules/user/components/expense/userExpenses.tsx b/react-app/src/modules/user/components/expense/userExpenses.tsx index 5b27d35..ea34796 100644 --- a/react-app/src/modules/user/components/expense/userExpenses.tsx +++ b/react-app/src/modules/user/components/expense/userExpenses.tsx @@ -36,6 +36,7 @@ import { UserModel } from "@/models/user/User.model.js"; import { UserRejectExpense } from "@/modules/user/components/expense/userRejectExpense.js"; import { UserConfirmExpense } from "@/modules/user/components/expense/userConfirmExpense.js"; import { useNavigate } from "react-router-dom"; +import { Card } from "@/components/ui/card.js"; interface UserExpenseProps { user: UserModel; @@ -143,7 +144,7 @@ export const UserExpenses: React.FC = ({ user }) => { return ( <> -
+ @@ -231,46 +232,46 @@ export const UserExpenses: React.FC = ({ user }) => { )}
-
-
- - -
-
- - {`${1 + pageSize * (pageNumber - 1)} - ${ - expenseList.length + pageSize * (pageNumber - 1) - } sur ${totalData}`} - - - -
+ +
+
+ + +
+
+ + {`${expenseList.length === 0 ? 0 : 1 + pageSize * (pageNumber - 1)} - ${ + expenseList.length + pageSize * (pageNumber - 1) + } sur ${totalData}`} + + +
diff --git a/react-app/src/modules/user/components/userAddress.tsx b/react-app/src/modules/user/components/userAddress.tsx index 059b8f9..d0d261b 100644 --- a/react-app/src/modules/user/components/userAddress.tsx +++ b/react-app/src/modules/user/components/userAddress.tsx @@ -154,7 +154,9 @@ export const UserAddress: React.FC = ({ ) : ( )} diff --git a/react-app/src/modules/user/components/userAvatar.tsx b/react-app/src/modules/user/components/userAvatar.tsx index 93837d0..f09c405 100644 --- a/react-app/src/modules/user/components/userAvatar.tsx +++ b/react-app/src/modules/user/components/userAvatar.tsx @@ -75,7 +75,10 @@ export const UserAvatar: React.FC = ({ - + {user?.firstname?.charAt(0)} {user?.lastname?.charAt(0)} diff --git a/react-app/src/modules/user/components/userBankInfos.tsx b/react-app/src/modules/user/components/userBankInfos.tsx index 60789ed..8e5ac52 100644 --- a/react-app/src/modules/user/components/userBankInfos.tsx +++ b/react-app/src/modules/user/components/userBankInfos.tsx @@ -111,7 +111,9 @@ export const UserBankInfos: React.FC = ({ ) : ( )} diff --git a/react-app/src/modules/user/components/userInfos.tsx b/react-app/src/modules/user/components/userInfos.tsx index 5f02bde..2f42a27 100644 --- a/react-app/src/modules/user/components/userInfos.tsx +++ b/react-app/src/modules/user/components/userInfos.tsx @@ -169,7 +169,9 @@ export const UserInfos: React.FC = ({ ) : ( )} diff --git a/react-app/src/modules/user/pages/User.tsx b/react-app/src/modules/user/pages/User.tsx index 9df0d66..ade84dd 100644 --- a/react-app/src/modules/user/pages/User.tsx +++ b/react-app/src/modules/user/pages/User.tsx @@ -76,7 +76,9 @@ export function User() { )}
-
{foundUser.email}
+
+ {foundUser.email} +
@@ -109,7 +111,7 @@ export function User() {
{userLoaded && userMainPage} {userNotFound && noUser} diff --git a/react-app/src/modules/user/pages/Users.tsx b/react-app/src/modules/user/pages/Users.tsx index 1826518..8c9bccb 100644 --- a/react-app/src/modules/user/pages/Users.tsx +++ b/react-app/src/modules/user/pages/Users.tsx @@ -30,14 +30,15 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar.js"; import { Badge } from "@/components/ui/badge.tsx"; import { UserList } from "@/common/type/user/user-list.type.ts"; import { customFetcher } from "@/common/helper/fetchInstance.js"; +import { Card } from "@/components/ui/card"; export function Users() { const [users, setUsers] = useState([]); const [usersLoaded, setUsersLoaded] = useState(false); - const navigate = useNavigate(); const [pageSize, setPageSize] = useState(5); const [pageNumber, setPageNumber] = useState(1); const [totalData, setTotalData] = useState(0); + const navigate = useNavigate(); function handleClick(userId: number) { navigate(`/user/${userId}`); @@ -89,8 +90,8 @@ export function Users() { }; const usersTable = ( - -
+ + @@ -110,7 +111,10 @@ export function Users() { > - + {user.firstname?.charAt(0)} {user.lastname?.charAt(0)} @@ -151,15 +155,15 @@ export function Users() { )}
-
+
- +