diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af2c17b1e..c097f6968 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,9 +10,9 @@ jobs: runs-on : ubuntu-latest defaults : { run: { working-directory: ./client }} steps : - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - with: { node-version: '16' } + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: '18' } - run: yarn install --non-interactive --pure-lockfile - run: yarn lint:js --max-warnings 0 --color @@ -21,9 +21,9 @@ jobs: runs-on : ubuntu-latest defaults : { run: { working-directory: ./client }} steps : - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - with: { node-version: '16' } + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: '18' } - run: yarn install --non-interactive --pure-lockfile - run: yarn lint:scss --max-warnings 0 --color @@ -32,9 +32,9 @@ jobs: runs-on : ubuntu-latest defaults : { run: { working-directory: ./client }} steps : - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - with: { node-version: '16' } + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: '18' } - run: yarn install --non-interactive --pure-lockfile - run: yarn check-types @@ -43,9 +43,9 @@ jobs: runs-on : ubuntu-latest defaults : { run: { working-directory: ./client }} steps : - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 - with: { node-version: '16' } + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: { node-version: '18' } - run: yarn install --non-interactive --pure-lockfile - run: yarn test --colors --coverage --passWithNoTests @@ -54,11 +54,11 @@ jobs: runs-on : ubuntu-latest defaults : { run: { working-directory: ./server }} steps : - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: - php-version : 8.0 - extensions : dom, mbstring, pdo, intl, simplexml + php-version : 8.1 + extensions : dom, mbstring, pdo, intl-74.2, simplexml coverage : none - run: composer install --no-interaction --no-progress - run: composer lint @@ -68,11 +68,11 @@ jobs: runs-on : ubuntu-latest defaults : { run: { working-directory: ./server }} steps : - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: - php-version : 8.0 - extensions : dom, mbstring, pdo, intl, simplexml + php-version : 8.1 + extensions : dom, mbstring, pdo, intl-74.2, simplexml coverage : none - run: composer install --no-interaction --no-progress - run: composer phpstan @@ -88,7 +88,7 @@ jobs: DB_PORT: 3306 strategy: matrix: - php: [8.0, 8.1] + php: [8.1, 8.2, 8.3] defaults: { run: { working-directory: ./server }} services: mysql: @@ -100,11 +100,11 @@ jobs: - 3306 options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: php-version : ${{ matrix.php }} - extensions : dom, mbstring, pdo, intl, simplexml + extensions : dom, mbstring, pdo, intl-74.2, simplexml coverage : xdebug - run: composer install --no-interaction --no-progress - run: ./vendors/bin/phpunit -d --without-creating-snapshots diff --git a/.gitignore b/.gitignore index 9f22ad7cc..204fc3d9c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,25 +1,26 @@ /dist # -# - Fichiers spécifiques à certains systèmes. +# - Fichiers spécifiques aux systèmes. # .DS_Store .AppleDouble .LSOverride - -# - Miniatures ._* - -# - Fichiers qui peuvent apparaître à la racine de volumes. +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db .DocumentRevisions-V100 .fseventsd -.Spotlight-V100 .TemporaryItems -.Trashes .VolumeIcon.icns -# - IDE +# +# - Fichiers spécifiques aux éditeurs. +# + .idea .vscode *.suo @@ -27,3 +28,7 @@ *.njsproj *.sln *.sw? +*.sublime-* +*.stTheme.cache +*.tmlanguage.cache +*.tmPreferences.cache diff --git a/.htaccess b/.htaccess index 95380529a..6a4de0ee4 100644 --- a/.htaccess +++ b/.htaccess @@ -1,17 +1,11 @@ -## Prohibit autoindex +# - Interdit l'`autoindex`. Options -Indexes RewriteEngine On RewriteBase / - ## Restrict access to `.git/`, `var/`, `vendors/` folders & `composer` files - RewriteRule ^\.git / [F,L] - RewriteRule ^server/src/var/(.*)?$ / [F,L] - RewriteRule ^server/vendors/(.*)?$ / [F,L] - RewriteRule ^server/composer\.(lock|json)$ / [F,L] - - ## Redirect all requests to `src/public/` folder + # - Redirige toutes les requêtes vers le dossier `server/src/public/`. RewriteRule ^$ server/src/public/ [QSA,NC,L] RewriteRule ^(.*)$ server/src/public/$1 [QSA,NC,L] diff --git a/.resources/logo-dark.svg b/.resources/logo-dark.svg new file mode 100644 index 000000000..268844e2b --- /dev/null +++ b/.resources/logo-dark.svg @@ -0,0 +1,29 @@ + + Loxya + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.resources/logo-light.svg b/.resources/logo-light.svg new file mode 100644 index 000000000..4454d1a50 --- /dev/null +++ b/.resources/logo-light.svg @@ -0,0 +1,29 @@ + + Loxya + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.resources/logo.svg b/.resources/logo.svg deleted file mode 100644 index 2053398c5..000000000 --- a/.resources/logo.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/CHANGELOG.md b/CHANGELOG.md index 6200d76fe..35a6a9b2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,86 @@ Tous les changements notables sur le projet sont documentés dans ce fichier. Ce projet adhère au principe du [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.24.0 (2024-05-09) + +- __[CHANGEMENT CRITIQUE]__ Loxya requiert maintenant au minimum PHP 8.1 pour fonctionner. +- L'application utilise maintenant le nom "Loxya" partout (plutôt que "Loxya (Robert2)"). +- Ajoute le support PHP 8.2 et PHP 8.3. +- Le compte des événements pour les unités de matériel tient compte des événements supprimés (Premium). +- Les événements supprimés ne sont plus affichés dans le calendrier des techniciens. +- Dans la fiche bénéficiaire, un onglet "Historique" affiche la liste des e-mails qui ont été envoyés + au bénéficiaire. Dans les fenêtres des événements et réservations, un onglet "Historique" affiche + la liste des messages de rappels qui ont été envoyés aux bénéficiaires (Premium). +- Prend en charge l'utilisation d'un wildcard (`*`) pour configurer la création automatique du + bénéficiaire lié à l'utilisateur se connectant via un service externe (CAS ou SAML2) (Premium). +- Ajout de la possibilité de définir les événements à l'heure près. Ceci se choisit à la première + étape de l'édition d'un événement, en cochant, ou non, "Jours entiers?" (activé par défaut) + dans le sélecteur des dates de l'événement. +- Pour les réservations publiques, l'administrateur a la possibilité de configurer les + réservations pour qu'elles soient basées sur des créneaux horaires précis ou sur des + journées entières. Cette configuration est disponible dans les paramètres de réservation (Premium). +- Ajoute la possibilité de distinguer la période de l'événement (et donc de facturation si + celle-ci est activée), parfois appelée "Période d'exploitation", de la période de mobilisation + du matériel. Ceci peut par exemple être utile pour inclure le temps nécessaire à l'installation + et à la désinstallation du matériel avant et après l'événement. + La planification de cette période de mobilisation peut être effectuée lors de l'édition d'un événement. + Quoi qu'il en soit, la mobilisation du matériel commencera dès que l'inventaire de départ aura été marqué + comme terminé (si celui-ci est effectué avant la date de mobilisation initialement prévue). + Pour ce qui est du retour, c'est l'inventaire de retour qui permettra de signifier que le matériel est + de retour en stock (ou bien la date de fin de mobilisation prévue en l'absence d'inventaire de retour + avant celle-ci). +- __Attention__ lors de la mise à jour à l'étape de migration de la base de données, les dates + de mobilisation des événements existants qui ont un inventaire de départ et/ou de retour terminé + seront synchronisées avec les dates de ces inventaires. Si vous êtes abonné à une offre SaaS et que + vous ne souhaitez pas que ces dates soient modifiées, mais plutôt que ce soient les dates des inventaires + de départ et retour qui soient modifiées pour correspondre aux dates des événements, merci de _contacter + le support_ avant de demander la mise à jour. +- Ajoute une page qui liste tous les événements (et réservations pour la Premium), avec une pagination, + une recherche intelligente sur le titre, le lieu et le bénéficiaire, et un filtre par parc + et par catégorie. +- Ajoute un paramètre utilisateur permettant de choisir la vue par défaut entre la frise + temporelle (calendrier), et la liste paginée des événements et réservations. +- Lors de la modification des dates d'un événement (que ce soit en le déplaçant, ou en le faisant commencer + plus tôt ou terminer plus tard), les assignations des techniciens ne seront plus déplacées par l'application + car celle-ci n'avait aucune garantie que le technicien était réellement disponible aux nouvelles dates et heures assignées + (qui pouvaient d'ailleurs se retrouver au beau milieu de la nuit en fonction de la nouvelle date et heure de début de l'événement). + Pour chaque assignation de technicien: + - Si celle-ci est encore "réalisable" pendant les nouvelles dates sans changer quoi que ce soit, celle-ci sera conservée inchangée. + - Si les nouvelles dates impactent en partie l'assignation, celle-ci sera tronquée / raccourcie. + - Si les nouvelles périodes n'incluent plus du tout l'assignation, celle-ci sera supprimée. + Une alerte a été ajoutée au moment d'éditer les dates pour rappeler à l'opérateur d'ajuster les assignations + après avoir changé les dates. +- Lors de la duplication des événements, l'assignation des techniciens ne sera plus dupliquée automatiquement. + En effet, l'application n'avait aucune garantie que les techniciens assignés au précédent événement étaient réellement + disponibles aux nouvelles dates et heures dupliqués. L'assignation de techniciens nécessite dans la majorité des cas + une validation humaine, d'autant qu'en fonction de l'heure de départ du nouvel événement, les assignations pouvaient se + retrouver en dehors des heures ouvrables. +- Les réservations publiques (Premium) prennent maintenant en compte les heures et jours d'ouverture de l'établissement. + Pour mettre en place ces plages sur votre instance hébergée par nos soins, n'hésitez pas à prendre contact avec nos services. +- Affiche le commentaire du demandeur dans la fenêtre d'une réservation, onglet "informations" (Premium). +- Corrige la suppression définitive d'un utilisateur ayant un bénéficiaire (ou technicien) lié dans la corbeille. +- Corrige le comportement des inventaires de retour quand un matériel qui a été supprimé est + présent dans la liste. +- Ajoute la prise en charge de l'envoi des e-mails via le service inclut dans les abonnements SaaS. +- Ajoute une section dans les paramètres généraux, onglet "fiches de sortie", qui permet de choisir + si on veut afficher ou non les colonnes "valeur de remplacement", "description du matériel", + les "tags" associés au matériel, numéros de série des unités (Premium), et la photo du matériel. +- Ajoute la possibilité de joindre la fiche de sortie aux e-mails qui notifient les bénéficiaires que leur + réservation a été approuvée (choix dans les paramètres globaux, onglet "Réservations en ligne") (Premium). +- Dans la fenêtre d'un événement ou d'une réservation, un nouveau bouton permet d'envoyer la liste + du matériel par e-mail au(x) bénéficiaire(s) (Premium). +- À l'étape 3 ("techniciens") de l'édition des événements, un champ de recherche permet de chercher + un technicien dans la liste, par son nom, son prénom ou son adresse e-mail. +- À l'étape 4 de l'édition des événements, les détails du matériel (avec la photo) sont affichés quand le + curseur de la souris survole une ligne dans la liste. +- Dans la fenêtre d'un événement, un nouveau bouton permet d'envoyer la fiche de sortie en PDF à + tous les techniciens qui sont assignés à l'événement (Premium). +- Dans la fenêtre d'un événement ou d'une réservation, un nouveau bouton permet de copier le permalien + de la fiche de sortie dans le presse-papier. Toute personne utilisant ce lien pourra télécharger + la fiche de sortie actualisée, au format PDF, même sans être connecté au logiciel (Premium). +- Il est maintenant possible de remettre les inventaires de départ et de retour en attente. + Cela revient à annuler leur état "terminé" et à rétablir le stock en réintégrant les quantités cassés. + ## 0.23.3 (2024-04-11) - Limite le nombre de vérifications différées simultanées du matériel manquant (2 par défaut). diff --git a/README.md b/README.md index 7c15a6874..efd79c452 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@
- Robert2 + + + Loxya +

Application de gestion de location de matériel simple, efficace, modulable et open-source.

@@ -7,14 +10,14 @@

-### Robert2, c'est pour qui ? +### Loxya, c'est pour qui ? -Si vous êtes une association, une institution, une école ou université, une entreprise ou même un petit entrepreneur, et que vous avez du matériel à louer ou à prêter, Robert2 est fait pour vous. +Si vous êtes une association, une institution, une école ou université, une entreprise ou même un petit entrepreneur, et que vous avez du matériel à louer ou à prêter, Loxya est fait pour vous. Il vous aidera à gérer votre parc de matériel, vos prestations, événements, bénéficiaires et techniciens. -### Robert2, comment ça marche ? +### Loxya, comment ça marche ? -Sur votre ordinateur, tablette ou smartphone, vous vous connectez à Robert2 grâce à un navigateur web, comme Firefox, Chrome, Opera ou Edge, en visitant simplement l'adresse sur laquelle il est installé. +Sur votre ordinateur, tablette ou smartphone, vous vous connectez à Loxya grâce à un navigateur web, comme Firefox, Chrome, Opera ou Edge, en visitant simplement l'adresse sur laquelle il est installé. Une fois entré dans l'application, vous pouvez l'utiliser ! @@ -44,23 +47,10 @@ Gestion du personnel : Gestion des personnes (techniciens) qui peuvent être ass Édition simple et rapide : Création et impression facile de vos factures et devis au format PDF. -### Facile à installer - -Robert2 est écrit en PHP et Javascript, et utilise une base de données MySQL. Cela lui permet d'être installé sur la plupart des serveurs, même les moins puissants ! De plus, un assistant d'installation vous aide lors de vos premiers pas. - ### Accessible partout -Une fois l'application installée sur votre serveur, vous pouvez l'utiliser depuis n'importe où via internet, et un navigateur web, sur votre ordinateur, tablette ou même votre smartphone ! - -### Contributeurs - -Robert2 est maintenu par __Paul Maillardet (Polosson)__ ainsi que __Donovan Lambert__. -Le projet est soutenu par __[Pulsanova](https://pulsanova.com)__, des artisans développeurs orientés qualité et expérience utilisateur. +Vous pouvez utiliser l'application depuis n'importe où via internet, et un navigateur web, sur votre ordinateur, tablette ou même votre smartphone ! ## Licence -Cette application web est distribuée dans l'espoir qu'elle soit utile, mais SANS AUCUNE GARANTIE. - -Robert2 est modifiable et redistribuable sous les termes de la [Licence Creative Commons BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/deed.fr). Elle es utilisable, modifiable et redistribuable dans le cadre de l'usage privé ou personnel. Aucune utilisation commerciale du **code source** de l'application ne peut en être faite (pas de distribution du code source en tant que service commercial). - -Pour plus de détails, voir le fichier [LICENCE.md](/LICENCE.md) à la racine du projet. +Veuillez vous référer au [fichier de licence disponible à la racine du projet](/LICENCE.md). diff --git a/VERSION b/VERSION index 9e40e75c5..2094a100c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.23.3 +0.24.0 diff --git a/bin/release b/bin/release index 0013f7046..e5f532a61 100755 --- a/bin/release +++ b/bin/release @@ -13,7 +13,7 @@ done VERSION="${VERSION_NUMBER}" -releaseName="Robert2-${VERSION}" +releaseName="Loxya-${VERSION}" distFolder="./dist/${releaseName}" # - Header message diff --git a/client/.eslintrc.js b/client/.eslintrc.js index cb8b872f7..f478f0caf 100644 --- a/client/.eslintrc.js +++ b/client/.eslintrc.js @@ -14,7 +14,7 @@ module.exports = { project: './tsconfig.json', tsconfigRootDir: __dirname, babelOptions: { - configFile: './babel.config.js', + configFile: require.resolve('./babel.config.js'), }, }, @@ -32,12 +32,17 @@ module.exports = { { files: ['**/locale/**/*'], rules: { - 'quotes': ['off'], + '@stylistic/js/quotes': ['off'], + '@stylistic/ts/quotes': ['off'], 'global-require': ['off'], }, }, { - files: ['**/tests/**/*', '**/__tests__/*', '**/*.spec.*'], + files: [ + '**/tests/**/*', + '**/__tests__/*', + '**/*.spec.*', + ], env: { jest: true }, settings: { 'import/resolver': { @@ -73,10 +78,25 @@ module.exports = { ], extends: '@pulsanova/node', }, + // - Autorise les imports cycliques dans les stores / fixtures (schemas Zod). + { + files: [ + '**/stores/api/*', + '**/stores/api/**/*', + '**/tests/fixtures/**/*', + ], + rules: { + 'import/no-cycle': ['off'], + }, + }, // - Autorise le `snake_case` dans les types d'API vu que pour le moment // celle-ci accepte et retourne uniquement sous ce format. { - files: ['**/stores/api/*.ts', '**/stores/api/**/*.ts'], + files: [ + '**/stores/api/*.ts', + '**/stores/api/**/*.ts', + '**/tests/fixtures/**/*.ts', + ], rules: { '@typescript-eslint/naming-convention': [ 'error', diff --git a/client/.gitignore b/client/.gitignore index ecaf3cc66..cebaa9e12 100644 --- a/client/.gitignore +++ b/client/.gitignore @@ -3,11 +3,11 @@ node_modules/ /dist/ /tests/coverage -# - Logs +# - Logs. npm-debug.log* yarn-debug.log* yarn-error.log* -# - Fichiers locaux d'environnement +# - Fichiers locaux d'environnement. .env.local .env.*.local diff --git a/client/babel.config.js b/client/babel.config.js index 1c6487677..2f640cd10 100644 --- a/client/babel.config.js +++ b/client/babel.config.js @@ -1,9 +1,10 @@ 'use strict'; module.exports = { + compact: false, presets: [ 'vca-jsx', - '@vue/babel-preset-app', + '@vue/cli-plugin-babel/preset', '@babel/preset-typescript', ], }; diff --git a/client/jest.config.js b/client/jest.config.js index d9f1f0825..6a20bf461 100644 --- a/client/jest.config.js +++ b/client/jest.config.js @@ -24,6 +24,12 @@ module.exports = { '^@/(.*)$': '/src/$1', '^@fixtures/(.*)$': '/tests/fixtures/$1', }, + snapshotSerializers: [ + '/tests/serializers/day.ts', + '/tests/serializers/datetime.ts', + '/tests/serializers/decimal.ts', + '/tests/serializers/period.ts', + ], transform: { '^.+\\.(js|mjs|cjs|jsx|ts|mts|cts|tsx)$': 'babel-jest', '^(?!.*\\.(js|mjs|cjs|ts|mts|cts|tsx|json)$)': 'jest-transform-stub', diff --git a/client/jsconfig.json b/client/jsconfig.json index 23be039da..512ba4f74 100644 --- a/client/jsconfig.json +++ b/client/jsconfig.json @@ -3,7 +3,8 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@fixtures/*": ["./tests/fixtures/*"] } } } diff --git a/client/package.json b/client/package.json index c1b9eb0fc..d3034a059 100644 --- a/client/package.json +++ b/client/package.json @@ -1,40 +1,44 @@ { "private": true, "scripts": { - "start": "NODE_OPTIONS=--openssl-legacy-provider vue-cli-service serve", - "build": "NODE_OPTIONS=--openssl-legacy-provider vue-cli-service build", + "serve": "vue-cli-service serve --no-module", + "build": "vue-cli-service build --no-module", "test": "npx cross-env TZ=UTC jest", "check-types": "tsc", "lint:js": "eslint './**/*.{js,vue,ts,tsx}' --report-unused-disable-directives", - "lint:scss": "stylelint 'src/**/*.scss'" + "lint:scss": "stylelint 'src/**/*.scss'", + "postinstall": "patch-package" }, "dependencies": { "@floating-ui/dom": "1.2.6", "@fortawesome/fontawesome-free": "5.15.3", "@loxya/vis-timeline": "file:./vendors/vis-timeline", "@pulsanova/reboot.css": "2.1.3", - "@vue/composition-api": "1.7.1", + "@vue/composition-api": "1.7.2", "axios": "0.24.0", - "clsx": "1.2.1", + "clsx": "2.0.0", + "collect.js": "4.36.1", + "core-js": "3.34.0", + "dayjs": "1.11.10", "decimal.js": "10.4.3", "deep-freeze-strict": "1.1.1", "invariant": "2.2.4", - "js-cookie": "3.0.4", + "js-cookie": "3.0.5", "lodash": "^4.17.21", "moment": "2.29.4", "p-defer": "3.0.0", "p-queue": "6.6.2", "papaparse": "5.4.1", "portal-vue": "2.1.7", - "status-code-enum": "~1.0.0", - "style-object-to-css-string": "1.0.1", + "status-code-enum": "1.0.0", + "style-object-to-css-string": "1.1.3", "sweetalert2": "11.0.20", "tinycolor2": "1.6.0", + "type-fest": "4.12.0", "v-tooltip": "2.1.3", "vue": "2.6.14", "vue-click-outside": "1.1.0", "vue-js-modal": "2.0.1", - "vue-query": "1.26.0", "vue-router": "3.5.2", "vue-select": "3.12.1", "vue-simple-calendar": "5.0.0", @@ -44,44 +48,56 @@ "vuex": "3.6.2", "vuex-i18n": "1.13.1", "warning": "4.0.3", - "zod": "3.22.4" + "zod": "3.21.1" }, "devDependencies": { - "@babel/core": "7.21.4", - "@babel/preset-typescript": "7.21.4", - "@pulsanova/eslint-config-node": "2.3.0", - "@pulsanova/eslint-config-vue": "2.3.1", + "@babel/core": "7.23.6", + "@babel/preset-typescript": "7.23.3", + "@pulsanova/eslint-config-esnext": "2.5.0", + "@pulsanova/eslint-config-node": "2.5.0", + "@pulsanova/eslint-config-vue": "2.5.0", "@pulsanova/stylelint-config-scss": "2.0.1", "@types/debounce": "1.2", + "@types/deep-freeze-strict": "1.1.2", "@types/invariant": "2.2", "@types/jest": "29", "@types/js-cookie": "3.0", "@types/lodash": "4", - "@types/papaparse": "5.3.7", + "@types/papaparse": "5.3.14", "@types/tinycolor2": "1", - "@vue/babel-preset-app": "4.5.15", - "@vue/cli-plugin-babel": "4.5.13", - "@vue/cli-plugin-typescript": "4.5.13", - "@vue/cli-service": ">=4.5.13", + "@types/warning": "3", + "@types/webpack-env": "*", + "@vue/cli-plugin-babel": "5.0.8", + "@vue/cli-plugin-typescript": "5.0.8", + "@vue/cli-service": "5.0.8", "@vue/test-utils": "1.2.2", "babel-core": "7.0.0-bridge.0", - "babel-jest": "29.5.0", - "babel-loader": "8.2.2", + "babel-jest": "29.7.0", + "babel-loader": "9.1.3", "babel-preset-vca-jsx": "0.3.6", - "eslint": "8.39.0", - "eslint-import-resolver-custom-alias": "1.3.0", - "eslint-import-resolver-webpack": "0.13.2", - "jest": "29.5.0", - "jest-environment-jsdom": "29.5.0", + "eslint": "8.57.0", + "eslint-import-resolver-custom-alias": "1.3.2", + "eslint-import-resolver-webpack": "0.13.8", + "file-loader": "6.2.0", + "jest": "29.7.0", + "jest-environment-jsdom": "29.7.0", "jest-transform-stub": "2.0.0", "jest-watch-typeahead": "2.2.2", - "sass": "1.62.0", - "sass-loader": "10.2.0", + "patch-package": "^8.0.0", + "postinstall-postinstall": "^2.1.0", + "sass": "1.69.5", + "sass-loader": "13.3.2", "stylelint": "14.1.0", - "type-fest": "4.4.0", - "typescript": "5.0.4", - "vue-cli-plugin-svg": "0.1.3", - "vue-cli-plugin-yaml": "1.0.2", - "vue-template-compiler": "2.6.14" + "typescript": "5.4.5", + "vue-svg-loader-2": "0.17.1", + "vue-template-compiler": "2.6.14", + "yaml-loader": "0.8.0" + }, + "engines": { + "node": ">= 18" + }, + "resolutions": { + "vue": "2.6.14", + "vuex": "3.6.2" } } diff --git a/client/patches/vue2-datepicker+3.11.1.patch b/client/patches/vue2-datepicker+3.11.1.patch new file mode 100644 index 000000000..ff3ee6832 --- /dev/null +++ b/client/patches/vue2-datepicker+3.11.1.patch @@ -0,0 +1,395 @@ +diff --git a/node_modules/vue2-datepicker/index.esm.js b/node_modules/vue2-datepicker/index.esm.js +index ad89f7b..ce14f05 100644 +--- a/node_modules/vue2-datepicker/index.esm.js ++++ b/node_modules/vue2-datepicker/index.esm.js +@@ -2077,7 +2077,16 @@ var CalendarRange = { + default: 'mx' + } + }, +- props: _objectSpread2({}, CalendarPanel.props), ++ props: _objectSpread2({}, CalendarPanel.props, { ++ readonly: { ++ type: [Boolean, String], ++ default: false, ++ validator: (value) => ( ++ typeof value === 'boolean' || ++ ['start', 'end'].includes(value) ++ ), ++ }, ++ }), + data: function data() { + return { + innerValue: [], +@@ -2125,7 +2134,29 @@ var CalendarRange = { + startValue = _this$innerValue[0], + endValue = _this$innerValue[1]; + +- if (isValidDate(startValue) && !isValidDate(endValue)) { ++ const isValidStart = isValidDate(startValue); ++ const isValidEnd = isValidDate(endValue); ++ ++ if (this.readonly !== false) { ++ if (isValidStart || isValidEnd) { ++ if (this.readonly === 'start') { ++ if (!isValidStart || startValue.getTime() > date.getTime()) { ++ return; ++ } ++ this.innerValue = [startValue, date]; ++ } ++ if (this.readonly === 'end') { ++ if (!isValidEnd || date.getTime() > endValue.getTime()) { ++ return; ++ } ++ this.innerValue = [date, endValue]; ++ } ++ this.emitDate(this.innerValue, type); ++ } ++ return; ++ } ++ ++ if (isValidStart && !isValidEnd) { + if (startValue.getTime() > date.getTime()) { + this.innerValue = [date, startValue]; + } else { +@@ -2133,9 +2164,10 @@ var CalendarRange = { + } + + this.emitDate(this.innerValue, type); +- } else { +- this.innerValue = [date, new Date(NaN)]; ++ return; + } ++ ++ this.innerValue = [date, new Date(NaN)]; + }, + onDateMouseEnter: function onDateMouseEnter(cell) { + this.hoveredValue = cell; +@@ -2187,6 +2219,30 @@ var CalendarRange = { + }, + getRangeClasses: function getRangeClasses(cellDate, currentDates, classnames) { + var classes = [].concat(this.getClasses(cellDate, currentDates, classnames)); ++ ++ if (classnames.includes('active') && this.readonly !== false) { ++ if (this.readonly === true) { ++ classes = classes.concat('readonly'); ++ } ++ if (this.readonly === 'start' || this.readonly === 'end') { ++ const normalizedInnerValues = this.innerValue.map((value) => ( ++ isValidDate(value) ? new Date(value).setHours(0, 0, 0, 0) : null ++ )); ++ ++ const readonlyValue = normalizedInnerValues[this.readonly === 'start' ? 0 : 1]; ++ const otherValue = normalizedInnerValues[this.readonly === 'start' ? 1 : 0]; ++ const cellValue = cellDate.getTime(); ++ ++ if ( ++ readonlyValue !== null && ++ cellValue === readonlyValue && ++ (otherValue === null || cellValue !== otherValue) ++ ) { ++ classes = classes.concat('readonly'); ++ } ++ } ++ } ++ + if (/disabled|active/.test(classnames)) return classes; + + var inRange = function inRange(data, range) { +@@ -2210,6 +2266,9 @@ var CalendarRange = { + }; + + if (currentDates.length === 2 && inRange(cellDate, currentDates)) { ++ if (this.readonly === true) { ++ classes = classes.concat('readonly-in-range'); ++ } + return classes.concat('in-range'); + } + +@@ -2730,6 +2789,10 @@ var script$7 = { + }, + props: { + date: Date, ++ type: { ++ type: String, ++ default: undefined, ++ }, + options: { + type: [Object, Function], + default: function _default() { +@@ -2753,7 +2816,12 @@ var script$7 = { + var options = this.options; + + if (typeof options === 'function') { +- return options() || []; ++ var selectedDate = new Date(this.date); ++ return ( ++ this.type !== undefined ++ ? options(selectedDate, this.type) ++ : options(selectedDate) ++ ) || []; + } + + var start = parseOption(options.start); +@@ -2879,6 +2947,10 @@ var script$8 = { + return date; + } + }, ++ type: { ++ type: String, ++ default: undefined, ++ }, + format: { + default: 'HH:mm:ss' + }, +@@ -2936,7 +3008,11 @@ var script$8 = { + scrollDuration: { + type: Number, + default: 100 +- } ++ }, ++ readonly: { ++ type: Boolean, ++ default: false, ++ }, + }, + data: function data() { + return { +@@ -3001,6 +3077,10 @@ var script$8 = { + return this.isDisabledTime(value) && this.isDisabledTime(value.setHours(minHour, 0, 0, 0)) && this.isDisabledTime(value.setHours(maxHour, 59, 59, 999)); + }, + isDisabled: function isDisabled(date, type) { ++ if (this.readonly) { ++ return new Date(date).getTime() !== this.innerValue.getTime(); ++ } ++ + if (type === 'hour') { + return this.isDisabledHour(date); + } +@@ -3016,6 +3096,10 @@ var script$8 = { + return this.isDisabledTime(date); + }, + handleSelect: function handleSelect(value, type) { ++ if (this.readonly) { ++ return; ++ } ++ + var date = new Date(value); + + if (!this.isDisabled(value, type)) { +@@ -3037,7 +3121,13 @@ var script$8 = { + } + + if (cellDate.getTime() === this.innerValue.getTime()) { +- return 'active'; ++ const classes = ['active']; ++ ++ if (this.readonly) { ++ classes.push('readonly'); ++ } ++ ++ return classes.join(' '); + } + + return ''; +@@ -3073,6 +3163,7 @@ var __vue_render__$b = function __vue_render__() { + }, [_vm.timePickerOptions ? _c('list-options', { + attrs: { + "date": _vm.innerValue, ++ "type": _vm.type, + "get-classes": _vm.getClasses, + "options": _vm.timePickerOptions, + "format": _vm.innerForamt +@@ -3129,7 +3220,16 @@ var TimeRange = { + default: 'mx' + } + }, +- props: _objectSpread2({}, __vue_component__$b.props), ++ props: _objectSpread2({}, __vue_component__$b.props, { ++ readonly: { ++ type: [Boolean, String], ++ default: false, ++ validator: (value) => ( ++ typeof value === 'boolean' || ++ ['start', 'end'].includes(value) ++ ), ++ }, ++ }), + data: function data() { + return { + startValue: new Date(NaN), +@@ -3160,6 +3260,10 @@ var TimeRange = { + this.$emit('select', date, type === 'time' ? 'time-range' : type, index); + }, + handleSelectStart: function handleSelectStart(date, type) { ++ if ([true, 'start'].includes(this.readonly)) { ++ return; ++ } ++ + this.startValue = date; // check the NaN + + if (!(this.endValue.getTime() >= date.getTime())) { +@@ -3169,6 +3273,10 @@ var TimeRange = { + this.emitChange(type, 0); + }, + handleSelectEnd: function handleSelectEnd(date, type) { ++ if ([true, 'end'].includes(this.readonly)) { ++ return; ++ } ++ + // check the NaN + this.endValue = date; + +@@ -3179,9 +3287,15 @@ var TimeRange = { + this.emitChange(type, 1); + }, + disabledStartTime: function disabledStartTime(date) { ++ if ([true, 'start'].includes(this.readonly)) { ++ return date.getTime() !== this.startValue.getTime(); ++ } + return this.disabledTime(date, 0); + }, + disabledEndTime: function disabledEndTime(date) { ++ if ([true, 'end'].includes(this.readonly)) { ++ return date.getTime() !== this.endValue.getTime(); ++ } + return date.getTime() < this.startValue.getTime() || this.disabledTime(date, 1); + } + }, +@@ -3189,10 +3303,13 @@ var TimeRange = { + var h = arguments[0]; + var defaultValues = Array.isArray(this.defaultValue) ? this.defaultValue : [this.defaultValue, this.defaultValue]; + var prefixClass = this.prefixClass; ++ + return h("div", { + "class": "".concat(prefixClass, "-range-wrapper") + }, [h(__vue_component__$b, { + "props": _objectSpread2({}, _objectSpread2({}, this.$props, { ++ type: 'start', ++ readonly: [true, 'start'].includes(this.readonly), + value: this.startValue, + defaultValue: defaultValues[0], + disabledTime: this.disabledStartTime +@@ -3202,6 +3319,8 @@ var TimeRange = { + })) + }), h(__vue_component__$b, { + "props": _objectSpread2({}, _objectSpread2({}, this.$props, { ++ type: 'end', ++ readonly: [true, 'end'].includes(this.readonly), + value: this.endValue, + defaultValue: defaultValues[1], + disabledTime: this.disabledEndTime +@@ -3474,6 +3593,14 @@ var DatePicker = { + type: Boolean, + default: false + }, ++ readonly: { ++ type: [Boolean, String], ++ default: false, ++ validator: (value) => ( ++ typeof value === 'boolean' || ++ ['start', 'end'].includes(value) ++ ) ++ }, + clearable: { + type: Boolean, + default: true +@@ -3541,8 +3668,15 @@ var DatePicker = { + }; + }, + computed: { ++ normalizedReadonly() { ++ if (typeof this.readonly === 'boolean') { ++ return this.readonly; ++ } ++ return this.range ? this.readonly : true; ++ }, + popupVisible: function popupVisible() { +- return !this.disabled && (typeof this.open === 'boolean' ? this.open : this.defaultOpen); ++ const isDisabled = this.disabled || this.normalizedReadonly === true; ++ return !isDisabled && (typeof this.open === 'boolean' ? this.open : this.defaultOpen); + }, + innerRangeSeparator: function innerRangeSeparator() { + return this.rangeSeparator || (this.multiple ? ',' : ' ~ '); +@@ -3597,7 +3731,13 @@ var DatePicker = { + return this.formatDate(this.innerValue); + }, + showClearIcon: function showClearIcon() { +- return !this.disabled && this.clearable && this.text && this.mouseInInput; ++ return ( ++ !this.disabled && ++ !this.normalizedReadonly && ++ this.clearable && ++ this.text && ++ this.mouseInInput ++ ); + }, + locale: function locale() { + if (isObject(this.lang)) { +@@ -3630,6 +3770,9 @@ var DatePicker = { + if (_typeof(this.format) === 'object') { + console.warn("[vue2-datepicker]: The prop `format` don't support Object any more. You can use the new prop `formatter` to replace it"); + } ++ if (typeof this.readonly !== 'boolean' && !this.range) { ++ console.warn("[vue2-datepicker]: The prop `readonly` should be passed as boolean when used with a non-range datepicker."); ++ } + }, + methods: { + handleMouseEnter: function handleMouseEnter() { +@@ -3784,6 +3927,10 @@ var DatePicker = { + } + }, + clear: function clear() { ++ if (this.normalizedReadonly !== false) { ++ return; ++ } ++ + this.emitValue(this.range ? [null, null] : null); + this.$emit('clear'); + }, +@@ -3808,7 +3955,10 @@ var DatePicker = { + } + }, + openPopup: function openPopup(evt) { +- if (this.popupVisible || this.disabled) return; ++ const isDisabled = this.disabled || this.normalizedReadonly === true; ++ if (this.popupVisible || isDisabled) { ++ return; ++ } + this.defaultOpen = true; + this.$emit('open', evt); + this.$emit('update:open', true); +@@ -3914,8 +4064,8 @@ var DatePicker = { + autocomplete: 'off', + value: this.text, + class: this.inputClass || "".concat(this.prefixClass, "-input"), +- readonly: !this.editable, +- disabled: this.disabled, ++ readonly: !this.editable || this.normalizedReadonly !== false, ++ disabled: this.disabled || this.normalizedReadonly === true, + placeholder: this.placeholder + }, this.inputAttr); + +@@ -4044,13 +4194,21 @@ var DatePicker = { + var h = arguments[0]; + var prefixClass = this.prefixClass, + inline = this.inline, +- disabled = this.disabled; ++ disabled = this.disabled, ++ readonly = this.normalizedReadonly; + var sidedar = this.hasSlot('sidebar') || this.shortcuts.length ? this.renderSidebar() : null; + var content = h("div", { + "class": "".concat(prefixClass, "-datepicker-content") + }, [this.hasSlot('header') ? this.renderHeader() : null, this.renderContent(), this.hasSlot('footer') || this.confirm ? this.renderFooter() : null]); + return h("div", { +- "class": (_class = {}, _defineProperty(_class, "".concat(prefixClass, "-datepicker"), true), _defineProperty(_class, "".concat(prefixClass, "-datepicker-range"), this.range), _defineProperty(_class, "".concat(prefixClass, "-datepicker-inline"), inline), _defineProperty(_class, "disabled", disabled), _class) ++ "class": ( ++ _class = {}, ++ _defineProperty(_class, "".concat(prefixClass, "-datepicker"), true), ++ _defineProperty(_class, "".concat(prefixClass, "-datepicker-range"), this.range), ++ _defineProperty(_class, "".concat(prefixClass, "-datepicker-inline"), inline), ++ _defineProperty(_class, "disabled", disabled || readonly === true), ++ _class ++ ) + }, [!inline ? this.renderInput() : null, !inline ? h(__vue_component__, { + "ref": "popup", + "class": this.popupClass, diff --git a/client/src/components/Fragment/index.tsx b/client/src/components/Fragment/index.tsx index 092a9f837..e4014ac00 100644 --- a/client/src/components/Fragment/index.tsx +++ b/client/src/components/Fragment/index.tsx @@ -1,7 +1,7 @@ import './index.scss'; import { defineComponent } from '@vue/composition-api'; -// @vue/component +/** Un élément permettant d'en grouper d'autres sans élément racine. */ const Fragment = defineComponent({ name: 'Fragment', render() { diff --git a/client/src/globals/config.js b/client/src/globals/config.ts similarity index 96% rename from client/src/globals/config.js rename to client/src/globals/config.ts index 82c086470..fa12349ce 100644 --- a/client/src/globals/config.js +++ b/client/src/globals/config.ts @@ -5,12 +5,12 @@ if (window.__SERVER_CONFIG__ && window.__SERVER_CONFIG__.baseUrl) { baseUrl = window.__SERVER_CONFIG__.baseUrl; } -const defaultConfig = { +const defaultConfig: GlobalConfig = { baseUrl, + version: '__DEV__', api: { url: `${baseUrl}/api`, headers: { Accept: 'application/json' }, - version: '_dev mode_', }, defaultLang: 'fr', currency: { diff --git a/client/src/globals/constants.js b/client/src/globals/constants.js deleted file mode 100644 index bf498adf2..000000000 --- a/client/src/globals/constants.js +++ /dev/null @@ -1,17 +0,0 @@ -import moment from 'moment'; - -const APP_NAME = 'Loxya (Robert2)'; - -const DATE_DB_FORMAT = 'YYYY-MM-DD HH:mm:ss'; -const DEBOUNCE_WAIT = 500; // - En millisecondes - -const TECHNICIAN_EVENT_STEP = moment.duration(15, 'minutes'); -const TECHNICIAN_EVENT_MIN_DURATION = moment.duration(15, 'minutes'); - -export { - APP_NAME, - DATE_DB_FORMAT, - DEBOUNCE_WAIT, - TECHNICIAN_EVENT_STEP, - TECHNICIAN_EVENT_MIN_DURATION, -}; diff --git a/client/src/globals/constants.ts b/client/src/globals/constants.ts new file mode 100644 index 000000000..4ed31e408 --- /dev/null +++ b/client/src/globals/constants.ts @@ -0,0 +1,12 @@ +import DateTime from '@/utils/datetime'; + +/** Durée de temporisation de base (e.g. entres deux requêtes répétitives). */ +const DEBOUNCE_WAIT_DURATION = DateTime.duration(500, 'milliseconds'); + +/** Durée minimum d'assignation d'un technicien dans un événement. */ +const MIN_TECHNICIAN_ASSIGNMENT_DURATION = DateTime.duration(15, 'minutes'); + +export { + DEBOUNCE_WAIT_DURATION, + MIN_TECHNICIAN_ASSIGNMENT_DURATION, +}; diff --git a/client/src/globals/queryClient.ts b/client/src/globals/queryClient.ts deleted file mode 100644 index e238b1e95..000000000 --- a/client/src/globals/queryClient.ts +++ /dev/null @@ -1,19 +0,0 @@ -import HttpCode from 'status-code-enum'; -import { QueryClient } from 'vue-query'; -import { isRequestErrorStatusCode } from '@/utils/errors'; - -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 10 * 60_000, // - 10 minutes - retry: (failureCount: number, error: unknown) => { - if (isRequestErrorStatusCode(error, HttpCode.ClientErrorNotFound)) { - return false; - } - return failureCount < 2; - }, - }, - }, -}); - -export default queryClient; diff --git a/client/src/globals/rawDatetime.ts b/client/src/globals/rawDatetime.ts new file mode 100644 index 000000000..277bb46c1 --- /dev/null +++ b/client/src/globals/rawDatetime.ts @@ -0,0 +1,11 @@ +import RawDateTime from '@/utils/rawDatetime'; +import { getLocale } from '@/globals/lang'; + +import 'dayjs/locale/fr'; +import 'dayjs/locale/en-gb'; + +export const init = (): void => { + RawDateTime.locale(getLocale()); +}; + +export default RawDateTime; diff --git a/client/src/globals/requester.js b/client/src/globals/requester.js index 3f0a36047..4d8e30ae9 100644 --- a/client/src/globals/requester.js +++ b/client/src/globals/requester.js @@ -4,6 +4,8 @@ import isPlainObject from 'lodash/isPlainObject'; import flattenObject from '@/utils/flattenObject'; import config from '@/globals/config'; import cookies from '@/utils/cookies'; +import DateTime from '@/utils/datetime'; +import Day from '@/utils/day'; const requester = axios.create({ baseURL: config.api.url, @@ -69,6 +71,9 @@ requester.interceptors.request.use( if (params[name] === false) { params[name] = '0'; } + if (params[name] instanceof DateTime || params[name] instanceof Day) { + params[name] = params[name].toString(); + } }); request.params = params; } diff --git a/client/src/globals/types/globals.d.ts b/client/src/globals/types/globals.d.ts new file mode 100644 index 000000000..278f7d798 --- /dev/null +++ b/client/src/globals/types/globals.d.ts @@ -0,0 +1,35 @@ +type GlobalConfig = { + baseUrl: string, + version: string, + billingMode: 'all' | 'partial' | 'none', + defaultLang: string, + api: { + url: string, + headers: Record, + }, + auth: { + cookie: string, + timeout: number | null, + }, + currency: { + symbol: string, + name: string, + iso: string, + }, + companyName: string | null, + defaultPaginationLimit: number, + maxConcurrentFetches: number, + maxFileUploadSize: number, + authorizedFileTypes: string[], + authorizedImageTypes: string[], + colorSwatches: string[] | null, +}; + +declare var __SERVER_CONFIG__: GlobalConfig | undefined; + +type ServerMessage = { + type: 'success' | 'info' | 'error', + message: string, +}; + +declare var __SERVER_MESSAGES__: ServerMessage[] | undefined; diff --git a/client/src/globals/types/utils.d.ts b/client/src/globals/types/utils.d.ts index 713b044bc..69c156b66 100644 --- a/client/src/globals/types/utils.d.ts +++ b/client/src/globals/types/utils.d.ts @@ -1,2 +1,6 @@ /** Représente les coordonnées d'une position. */ type Position = { x: number, y: number }; + +type AnyLiteralObject = Record; + +type Nullable = { [K in keyof T]: T[K] | null }; diff --git a/client/src/globals/types/vendors/dayjs.d.ts b/client/src/globals/types/vendors/dayjs.d.ts new file mode 100644 index 000000000..68f7b8c14 --- /dev/null +++ b/client/src/globals/types/vendors/dayjs.d.ts @@ -0,0 +1,18 @@ +import 'dayjs'; + +declare module 'dayjs' { + export type DayjsInput = ConfigType; + + export type TimeUnitTypeLongPlural = 'hours' | 'minutes' | 'seconds' | 'milliseconds'; + export type TimeUnitTypeLong = 'hour' | 'minute' | 'second' | 'millisecond'; + export type TimeUnitTypeShort = 'h' | 'm' | 's' | 'ms'; + export type TimeUnitType = TimeUnitTypeLongPlural | TimeUnitTypeLong | TimeUnitTypeShort; + + // + // - Plugin `explicit`. + // + + export function now(): Dayjs; + export function from(input: DayjsInput): Dayjs; + export function fromFormat(input: string, format: string): Dayjs; +} diff --git a/client/src/globals/types/vendors/vue-js-modal.d.ts b/client/src/globals/types/vendors/vue-js-modal.d.ts index 9b7c0baaa..4ea2e87c8 100644 --- a/client/src/globals/types/vendors/vue-js-modal.d.ts +++ b/client/src/globals/types/vendors/vue-js-modal.d.ts @@ -1,7 +1,7 @@ import 'vue-js-modal'; declare module 'vue-js-modal' { - export type OnCloseEvent> = { + export type OnCloseEvent = { /** Le nom de la modale (créé dynamiquement par vue-js-modal). */ name: string, diff --git a/client/src/globals/types/vendors/vue-simple-calendar.d.ts b/client/src/globals/types/vendors/vue-simple-calendar.d.ts new file mode 100644 index 000000000..a79087b04 --- /dev/null +++ b/client/src/globals/types/vendors/vue-simple-calendar.d.ts @@ -0,0 +1,40 @@ +declare module 'vue-simple-calendar' { + import type { RawComponent } from 'vue'; + + export type CalendarItem = { + id: string | number, + startDate: Date | string, + endDate?: Date | string, + title: string, + classes?: string, + style?: string, + }; + + export const CalendarView: RawComponent<{ + showDate?: Date, + displayPeriodUom?: 'month' | 'year' | 'week', + displayPeriodCount?: number, + startingDayOfWeek?: number, + displayWeekNumbers?: boolean, + showTimes?: boolean, + locale?: string, + dateClasses?: Record, + monthNameFormat?: 'numeric' | '2-digit' | 'long' | 'short' | 'narrow', + weekdayNameFormat?: 'long' | 'short' | 'narrow', + timeFormatOptions?: Intl.DateTimeFormatOptions, + disablePast?: boolean, + disableFuture?: boolean, + enableDateSelection?: boolean, + selectionStart?: Date, + selectionEnd?: Date, + items?: CalendarItem[], + enableDragDrop?: boolean, + itemTop?: string, + itemContentHeight?: string, + itemBorderHeight?: string, + periodChangedCallback?: Function, + currentPeriodLabel?: string, + currentPeriodLabelIcons?: string, + doEmitItemMouseEvents?: boolean, + }>; +} diff --git a/client/src/globals/types/vendors/vue-tables-2.d.ts b/client/src/globals/types/vendors/vue-tables-2.d.ts index 363bca6a2..f186409a5 100644 --- a/client/src/globals/types/vendors/vue-tables-2.d.ts +++ b/client/src/globals/types/vendors/vue-tables-2.d.ts @@ -19,13 +19,13 @@ declare module 'vue-tables-2-premium' { type ColumnsVisibility = Record; - type TemplateRenderFunction = ( - (h: CreateElement, row: Data, index: number) => JSX.Element | string | null + type TemplateRenderFunction = ( + (h: CreateElement, row: Datum, index: number) => JSX.Element | JSX.Element[] | string | null ); - type RowClickEventPayload = { row: Data, event: PointerEvent, index: number }; + type RowClickEventPayload = { row: Datum, event: PointerEvent, index: number }; - type BaseTableOptions = { + type BaseTableOptions = { headings?: Record, initialPage?: number, perPage?: number, @@ -33,19 +33,20 @@ declare module 'vue-tables-2-premium' { sortable?: string[], multiSorting?: Record>, filterByColumn?: boolean, + filterable?: boolean, columnsDropdown?: boolean, preserveState?: boolean, saveState?: boolean, columnsDisplay?: ColumnsVisibility, columnsClasses?: Record, - templates?: Record>, - rowClassCallback(row: Data): VNodeClass, + templates?: Record>, + rowClassCallback?(row: Datum): VNodeClass, }; - interface BaseTableInstance { + interface BaseTableInstance { name: string; columns: string[]; - data: Data[]; + data: Datum[]; filtersCount: number; openChildRows: number[]; selectedRows: number[] | undefined; @@ -61,7 +62,7 @@ declare module 'vue-tables-2-premium' { resetCustomFilters(): void; setLoadingState(): void; $refs: { - table: BaseTableInstance, + table: BaseTableInstance, }; } @@ -75,22 +76,39 @@ declare module 'vue-tables-2-premium' { // - Client component specific types // - export type ClientCustomFilter = { + export type ClientCustomFilter = { name: string, - callback(item: Data, identifier: number | string | boolean): boolean, + callback(item: Datum, identifier: number | string | boolean): boolean, }; - export type CustomSortFunction = (ascending: boolean) => (a: Data, b: Data) => number; + /** + * Fonction personnalisé de tri de la colonne. + * + * Cette fonction, à qui la direction de tri souhaité est passé (via `ascending`), + * doit renvoyer une autre fonction qui s'occupera de comparer deux éléments de + * la colonne et devra renvoyé si le premier élément (`a`) arrive avant (= `-1`) ou + * après (= `1`) le deuxième (`b`) (ou s'ils sont égaux (= `0`)). + * + * Si non spécifié, le tri consistera en une simple comparaison des valeurs + * (e.g si ascendant: `a > b ? 1 : -1`) en ayant au préalable mis les chaînes + * de caractères en minuscules (si ce sont des chaînes qui sont comparés). + * + * @param ascending - Spécifie si le tri doit être effectué de manière + * ascendante ou descendante. + */ + export type ColumnSorter = (ascending: boolean) => ( + (a: Datum, b: Datum) => number + ); - export type ClientTableOptions = BaseTableOptions & { - initFilters: Filters, - customSorting?: Record>, - customFilters?: Array>, + export type ClientTableOptions = BaseTableOptions & { + initFilters?: Filters, + customSorting?: Record>, + customFilters?: Array>, }; - export interface ClientTableInstance extends BaseTableInstance { - filteredData: Data[]; - allFilteredData: Data[]; + export interface ClientTableInstance extends BaseTableInstance { + filteredData: Datum[]; + allFilteredData: Datum[]; } // @@ -101,14 +119,14 @@ declare module 'vue-tables-2-premium' { Promise<{ data: Data } | undefined> ); - export type ServerTableOptions = BaseTableOptions & { + export type ServerTableOptions = BaseTableOptions & { customFilters?: string[], - requestFunction?: RequestFunction, + requestFunction?: RequestFunction, }; - export interface ServerTableInstance extends BaseTableInstance { + export interface ServerTableInstance extends BaseTableInstance { setRequestParams(params: Record): void; - geData(): Data[]; + getData(): Datum[]; getQueryParams(): Record; } } diff --git a/client/src/globals/types/vendors/vue-toasted.d.ts b/client/src/globals/types/vendors/vue-toasted.d.ts new file mode 100644 index 000000000..059a309ed --- /dev/null +++ b/client/src/globals/types/vendors/vue-toasted.d.ts @@ -0,0 +1,7 @@ +import 'vue-toasted'; + +declare module 'vue-toasted' { + interface ToastOptions { + duration?: number | null; + } +} diff --git a/client/src/globals/types/vendors/vue2-datepicker.d.ts b/client/src/globals/types/vendors/vue2-datepicker.d.ts index 5ed213aa8..09b8579a7 100644 --- a/client/src/globals/types/vendors/vue2-datepicker.d.ts +++ b/client/src/globals/types/vendors/vue2-datepicker.d.ts @@ -26,6 +26,11 @@ declare module 'vue2-datepicker' { onClick(): any, }; + export type TimePickerValue = { + value: number, + text: string, + }; + export type TimePickerOptions = { start: string, step: string, @@ -57,8 +62,8 @@ declare module 'vue2-datepicker' { confirmText?: string, multiple?: boolean, disabled?: boolean, - disabledDate?(date: Date, currentValue: Date[]): boolean, - disabledTime?(date: Date): boolean, + disabledDate?(date: Date, currentValue: [?Date, ?Date]): boolean, + disabledTime?(date: Date, side: 0 | 1): boolean, appendToBody?: boolean, inline?: boolean, inputClass?: string, @@ -84,7 +89,10 @@ declare module 'vue2-datepicker' { use12h?: boolean, showTimeHeader?: boolean, timeTitleFormat?: string, - timePickerOptions?: TimePickerOptions, + timePickerOptions?: ( + | TimePickerOptions + | ((selectedDate: Date, type?: 'start' | 'end') => TimePickerOptionValue[]) + ), prefixClass?: string, scrollDuration?: number, }>; diff --git a/client/src/globals/types/vendors/vuex.d.ts b/client/src/globals/types/vendors/vuex.d.ts index a9f839616..2e022b107 100644 --- a/client/src/globals/types/vendors/vuex.d.ts +++ b/client/src/globals/types/vendors/vuex.d.ts @@ -2,7 +2,4 @@ declare module 'vuex' { export * from 'vuex/types/index.d.ts'; - export * from 'vuex/types/helpers.d.ts'; - export * from 'vuex/types/logger.d.ts'; - export * from 'vuex/types/vue.d.ts'; } diff --git a/client/src/hooks/useI18n.ts b/client/src/hooks/useI18n.ts deleted file mode 100644 index 638249ebe..000000000 --- a/client/src/hooks/useI18n.ts +++ /dev/null @@ -1,10 +0,0 @@ -import getRuntimeVm from '@/utils/getRuntimeVm'; - -import type { I18nTranslate } from 'vuex-i18n'; - -const useI18n = (): I18nTranslate => { - const vm = getRuntimeVm(); - return getRuntimeVm().$t.bind(vm); -}; - -export default useI18n; diff --git a/client/src/hooks/useRouteId.js b/client/src/hooks/useRouteId.js deleted file mode 100644 index a7002ce1e..000000000 --- a/client/src/hooks/useRouteId.js +++ /dev/null @@ -1,14 +0,0 @@ -import { computed } from '@vue/composition-api'; -import useRouter from '@/hooks/useRouter'; - -const useRouteId = () => { - const { route } = useRouter(); - - return computed(() => ( - route.value.params.id && route.value.params.id !== 'new' - ? route.value.params.id - : null - )); -}; - -export default useRouteId; diff --git a/client/src/hooks/useRoutePage.ts b/client/src/hooks/useRoutePage.ts deleted file mode 100644 index 8b4c76270..000000000 --- a/client/src/hooks/useRoutePage.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { computed } from '@vue/composition-api'; -import useRouter from '@/hooks/useRouter'; - -import type { Ref } from '@vue/composition-api'; - -const useRoutePage = (): Ref => { - const { route } = useRouter(); - - const page = computed(() => { - const { query } = route.value; - if (!query || !query.page) { - return 1; - } - return Number.parseInt(query.page as string, 10); - }); - - return page; -}; - -export default useRoutePage; diff --git a/client/src/hooks/useRouter.ts b/client/src/hooks/useRouter.ts deleted file mode 100644 index aca3ba7ef..000000000 --- a/client/src/hooks/useRouter.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { ref, watch } from '@vue/composition-api'; -import getRuntimeVm from '@/utils/getRuntimeVm'; - -import type { Ref } from '@vue/composition-api'; -import type { Route } from 'vue-router'; - -type ReturnType = { - route: Ref, - router: any, -}; - -const useRouter = (): ReturnType => { - const vm = getRuntimeVm(); - const route = ref(vm.$route); - - watch(() => vm.$route, (newRoute: Route) => { - route.value = newRoute; - }); - - return { route, router: vm.$router }; -}; - -export default useRouter; diff --git a/client/src/locale/en/common.js b/client/src/locale/en/common.js index ec62cb5de..b52d52975 100644 --- a/client/src/locale/en/common.js +++ b/client/src/locale/en/common.js @@ -1,7 +1,7 @@ export default { 'hello-name': "Hello {name}!", 'your-settings': "Your settings", - 'logout-quit': "Quit Loxya (Robert2)", + 'logout-quit': "Quit Loxya", 'action-add': "Add", 'action-edit': "Edit", 'action-view': "Display details", @@ -31,7 +31,7 @@ export default { 'close': "Close", 'confirm': "Confirm", 'copy-to-clipboard': "Copy to clipboard", - 'copied-in-clipboard': "Copied in clipboard!", + 'copied-to-clipboard': "Copied to clipboard!", 'copy': "Copy", 'copied': "Copied!", 'almost-done': "Almost done...", @@ -46,19 +46,15 @@ export default { "Type again, at least {count} character to search...", "Type again, at least {count} characters to search...", ], + 'count-chars': ["{count} char", "{count} chars"], 'empty-state': "There are no records to display at this time.", 'search-term': "Search", 'no-result-found-try-another-search': "No results. Try another search term.", - 'create-select-item-label': "Create a {label}", - 'add-item': "Add a {item}", - 'remove-item': "Remove this {item}", - 'cancel-add-item': "Cancel adding {item}", - 'item-not-found': "{item} not found. Element may have been deleted.", 'locked': "locked", 'clear-filters': "Clear filters", 'optional': "Optional", 'n-persons': ["{count} person", "{count} persons"], - 'and-n-others': ["and {count} other", "and {count} others"], + 'name-and-n-others': ["{name} and {count} other", "{name} and {count} others"], 'add-comment': "Add a comment", 'modify-comment': "Modify comment", 'save': "Save", @@ -71,15 +67,17 @@ export default { 'reset-date': "Reset date", 'reset-period': "Reset period", 'actions': "Actions", - 'informations': "Informations", + 'informations': "Information", 'connexion-infos': "Credentials", - 'personal-infos': "Personal informations", - 'minimal-infos': "Minimal informations", - 'extra-infos': "Additional informations", - 'stock-infos': "Stock informations", - 'billing-infos': "Billing informations", + 'personal-infos': "Personal information", + 'minimal-infos': "Minimal information", + 'extra-infos': "Additional information", + 'stock-infos': "Stock information", + 'billing-infos': "Billing information", + 'other-infos': "Other information", 'documents': "Documents", 'billing': "Billing", + 'history': "History", 'special-attributes': "Special attributes", 'schedule': "Schedule", 'pseudo': "Pseudo", @@ -93,7 +91,6 @@ export default { 'person': "Person", 'legal-name': "Legal name", 'contact-details': "Contact details", - 'other-infos': "Other informations", 'email': "E-mail", 'phone': "Phone", 'address': "Address", @@ -112,11 +109,13 @@ export default { 'minutes': "minutes", 'notes': "Notes", 'description': "Description", + 'label-colon': "{label}:", 'ref': "Ref.", 'ref-ref': "Ref.: {reference}", 'reference': "Reference", 'number': "Number", 'park': "Park", + 'park-name': "Park \"{name}\"", 'prices': "Prices", 'rental-price': "Rental price", 'replacement-price': "Replacement price", @@ -157,8 +156,8 @@ export default { 'not-limited': "not limited", 'open-trash-bin': "Display trash bin", 'display-not-deleted-items': "Display not deleted items", - 'created-at': "Created at:", - 'updated-at': "Updated at:", + 'created-at': "Created at: {date}", + 'updated-at': "Updated at: {date}", 'state': "State", 'picture': "Picture", 'add-a-picture': "Add a picture", @@ -181,7 +180,6 @@ export default { 'confirmed': "Confirmed", 'not-confirmed': "Not confirmed", 'is-billable': "Is billable?", - 'color-on-calendar': "Color on calendar", 'event-is-now-billable': "This event is now billable.", 'is-not-billable-help': "\"Loan\" Mode: no billing.", 'is-billable-help': "\"Rent\" Mode: billing possible.", @@ -193,11 +191,8 @@ export default { 'print-summary': "Print this summary", 'open': "Open", 'in': "In {location}", + 'mobilization-period': "Mobilization {period}", 'open-in-google-maps': "Open in Google Maps", - 'on-date': "On {date}", - 'from-date': "from\u00A0{date}", - 'to-date': "to\u00A0{date}", - 'from-date-to-date': "from\u00A0{from} to\u00A0{to}", 'or': "or", 'for': "For", 'with': "With", @@ -254,6 +249,10 @@ export default { "{count} item", "{count} items", ], + 'items-count-total': [ + "{count} item in total", + "{count} items in total", + ], 'used-count': [ "{count} used", "{count} used", @@ -269,7 +268,7 @@ export default { 'total-amount': "Total amount", 'total-amount-without-taxes': "Total amount excl. tax:\u00A0{amount}", 'total-amount-after-discount': "Total excl. tax after discount:\u00A0{amount}", - 'total-replacement': "Total replacement price:", + 'total-replacement': "Total replacement price: {total}", 'total-value': "Total value", 'total-quantity': "Total quantity: {total}", 'daily-amount': "Daily amount: {amount}", @@ -305,8 +304,9 @@ export default { 'grouped-by': "Display grouped by:", 'not-grouped': "Not grouped", 'start-on': "Start on", - 'return-scheduled-on': "Return scheduled on", - 'back-to-calendar': "Back to calendar", + 'expected-end-on': "Expected end on", + 'back-to-home': "Back to homepage", + 'back-to-schedule': "Back to schedule", 'previous-month': "Previous month", 'next-month': "Next month", @@ -314,8 +314,6 @@ export default { 'events-count': ['{count} event', '{count} events'], 'use': "Use", - 'use-this-template': "Use this list template", - 'create-company': "Add a new company", 'inventories': "Inventories", @@ -349,7 +347,6 @@ export default { 'categories': "Categories", 'parks': "Parks", 'technician': "Technician", - 'online-reservations': "Online reservations", 'this-feature-is-coming-soon': "This feature implementation is in progress.", @@ -369,8 +366,8 @@ export default { 'confirm-delete': "Move this event in trash bin?", 'event-missing-materials': "Missing materials", - 'event-missing-materials-help': "These are the missing materials for the period of the event, because it is used in another event, the number needed is too high, or there are some out of order. These materials must therefore be added to the park, or rented from another company.", - 'missing-material-count': "Need {quantity}, missing\u00A0{missing}!", + 'event-missing-materials-help': "These are the missing materials for the period of the event, because it is used in another event, the number needed is too high, or there are some out of order.", + 'missing-material-count': "Need {quantity}, missing\u00A0{missing}", 'warning-no-beneficiary': "Warning: this event has no beneficiaries!", 'warning-no-material': "Warning: this event is empty, there is no material at the moment!", @@ -390,15 +387,4 @@ export default { 'has-not-returned-materials': "This event has some not-returned materials.", }, }, - - '@reservation': { - 'statuses': { - 'is-past': "This reservation is past.", - 'is-currently-running': "This reservation is currently running.", - 'is-archived': "This reservation is archived.", - 'has-missing-materials': "This reservation has missing materials.", - 'needs-its-return-inventory': "It's necessary to make the return inventory of this reservation!", - 'has-not-returned-materials': "This reservation has some not-returned materials.", - }, - }, }; diff --git a/client/src/locale/en/date.js b/client/src/locale/en/date.js new file mode 100644 index 000000000..b8123331d --- /dev/null +++ b/client/src/locale/en/date.js @@ -0,0 +1,11 @@ +export default { + // - Formats simples. + 'on-date': "on {date}", + 'from-date': "from\u00A0{date}", + 'to-date': "to\u00A0{date}", + 'from-date-to-date': "from\u00A0{from} to\u00A0{to}", + + // - Formats "dans une phrase". + 'date-in-sentence': "the {date}", + 'period-in-sentence': "the period from {from} to {to}", +}; diff --git a/client/src/locale/en/errors.js b/client/src/locale/en/errors.js index 19568f0bc..78c9daf4f 100644 --- a/client/src/locale/en/errors.js +++ b/client/src/locale/en/errors.js @@ -1,27 +1,17 @@ export default { errors: { + 'unexpected': "An unexpected error occurred, please try again.", 'unexpected-while-saving': "An unexpected error occurred while saving, please try again.", 'unexpected-while-deleting': "An unexpected error occurred while deleting, please try again.", 'unexpected-while-restoring': "An unexpected error occurred while restoring, please try again.", 'unexpected-while-calculating': "An unexpected error occurred while calculating, please try again.", 'unexpected-while-fetching': "An unexpected error occurred while retrieving the data.", - 'api-unreachable': "Sorry, but the API is unreachable... Please check your access to network.", + 'api-unreachable': "The service is currently unreachable. Please check your internet connection and try again.", 'record-not-found': "This record does not exist.", 'page-not-found': "The requested page does not exist.", 'unknown': "Unknown error.", - 'validation': "Please check form informations.", + 'validation': "Please check the information provided in the form.", 'already-exists': "This record already exists.", - 'show-details': "Show error details", - 'details-title': "Details of error", - 'details-intro1': "You can copy and paste the following to get help from the community.", - 'details-intro2': "Please copy it as is, because it's written in markdown to help reading on", - 'details-intro-forum': "the forum", - 'details-intro3': "or on", - 'details-intro-not-detailed': "To get more details about the error, you can modify the parameter `displayErrorDetails` to 'true' in file 'src/App/Config/settings.json'.", - 'details-request': "API request:", - 'details-message': "Error message", - 'details-file': "File:", - 'details-stacktrace': "Stack trace:", 'critical': [ "A critical error has occurred, please refresh the page.", "If the problem persists, please contact an administrator.", diff --git a/client/src/locale/en/index.js b/client/src/locale/en/index.js index 37662e4ef..a991bd892 100644 --- a/client/src/locale/en/index.js +++ b/client/src/locale/en/index.js @@ -1,7 +1,9 @@ import common from './common'; +import date from './date'; import errors from './errors'; export default { ...common, + ...date, ...errors, }; diff --git a/client/src/locale/fr/common.js b/client/src/locale/fr/common.js index d859a04ca..4be906bfe 100644 --- a/client/src/locale/fr/common.js +++ b/client/src/locale/fr/common.js @@ -1,7 +1,7 @@ export default { 'hello-name': "Bonjour {name}\u00A0!", 'your-settings': "Vos paramètres", - 'logout-quit': "Quitter Loxya (Robert2)", + 'logout-quit': "Quitter Loxya", 'action-add': "Ajouter", 'action-edit': "Modifier", 'action-view': "Afficher en détail", @@ -25,13 +25,13 @@ export default { 'yes-permanently-delete': "Oui, supprimer définitivement", 'yes-regenerate-link': "Oui, re-générer le lien", 'yes-restore': "Oui, restaurer", - 'changes-exists-really-cancel': "Des changements n'ont pas été sauvegardés. Voulez-vous vraiment quitter cette page\u00A0?", + 'changes-exists-really-cancel': "Des modifications n'ont pas été enregistrées. Êtes-vous sûr de vouloir quitter cette page\u00A0?", 'yes-leave-page': "Oui, quitter la page", 'cancel': "Annuler", 'close': "Fermer", 'confirm': "Confirmer", 'copy-to-clipboard': "Copier dans le presse-papier", - 'copied-in-clipboard': "Copié dans le presse-papier\u00A0!", + 'copied-to-clipboard': "Copié dans le presse-papiers\u00A0!", 'copy': "Copier", 'copied': "Copié\u00A0!", 'almost-done': "Presque terminé...", @@ -43,22 +43,18 @@ export default { 'please-choose': "Veuillez choisir...", 'start-typing-to-search': "Commencez à écrire pour rechercher...", 'type-at-least-count-chars-to-search': [ - "Entrez encore au moins {count} lettre pour rechercher...", - "Entrez encore au moins {count} lettres pour rechercher...", + "Entrez au moins {count} lettre de plus pour rechercher...", + "Entrez au moins {count} lettres de plus pour rechercher...", ], + 'count-chars': ["{count} caractère", "{count} caractères"], 'empty-state': "Il n'y a aucun enregistrement à afficher pour le moment.", 'search-term': "Recherche", 'no-result-found-try-another-search': "Aucun résultat. Essayez avec une autre recherche.", - 'create-select-item-label': "Créer un {label}", - 'add-item': "Ajouter un {item}", - 'remove-item': "Enlever ce {item}", - 'cancel-add-item': "Annuler l'ajout de {item}", - 'item-not-found': "{item} introuvable. Peut-être a-t-il été supprimé\u00A0?", 'locked': "verrouillé", 'clear-filters': "Réinitialiser les filtres", 'optional': "Optionnel", 'n-persons': ["{count} personne", "{count} personnes"], - 'and-n-others': ["et {count} autre", "et {count} autres"], + 'name-and-n-others': ["{name} et {count} autre", "{name} et {count} autres"], 'add-comment': "Ajouter un commentaire", 'modify-comment': "Modifier le commentaire", 'save': "Sauvegarder", @@ -78,12 +74,14 @@ export default { 'extra-infos': "Informations additionnelles", 'stock-infos': "Informations liées au stock", 'billing-infos': "Informations de facturation", + 'other-infos': "Autres informations", 'documents': "Documents", 'billing': "Facturation", + 'history': "Historique", 'special-attributes': "Caractéristiques spéciales", 'schedule': "Agenda", - 'pseudo': "Pseudo", - 'email-address-or-pseudo': "Adresse e-mail ou Pseudo", + 'pseudo': "Identifiant", + 'email-address-or-pseudo': "Adresse e-mail / Identifiant", 'password': "Mot de passe", 'first-name': "Prénom", 'last-name': "Nom", @@ -93,7 +91,6 @@ export default { 'person': "Personne", 'legal-name': "Raison sociale", 'contact-details': "Coordonnées", - 'other-infos': "Autres informations", 'email': "E-mail", 'phone': "Téléphone", 'address': "Adresse", @@ -112,11 +109,13 @@ export default { 'minutes': "minutes", 'notes': "Notes", 'description': "Description", + 'label-colon': "{label}\u00A0:", 'ref': "Réf.", 'ref-ref': "Ref.\u00A0: {reference}", 'reference': "Référence", 'number': "Numéro", 'park': "Parc", + 'park-name': "Parc «\u00A0{name}\u00A0»", 'prices': "Tarifs", 'rental-price': "Tarif location", 'replacement-price': "Prix de remplacement", @@ -125,7 +124,7 @@ export default { 'value-per-day': '{value}\u00A0/\u00A0jour', 'serial-number': "N° de série", 'examples-list': "Exemples\u00A0: {list}, etc.", - 'not-specified': "Non renseigné", + 'not-specified': "Non renseigné(e)", 'qty': "Qté", 'stock-qty': "Qté stock", @@ -156,8 +155,8 @@ export default { 'not-limited': "non limité", 'open-trash-bin': "Afficher la corbeille", 'display-not-deleted-items': "Afficher les enregistrements non supprimés", - 'created-at': "Créé le\u00A0:", - 'updated-at': "Modifié le\u00A0:", + 'created-at': "Créé le\u00A0: {date}", + 'updated-at': "Modifié le\u00A0: {date}", 'state': "État", 'picture': "Photo", 'add-a-picture': "Ajouter une photo", @@ -181,9 +180,8 @@ export default { 'not-confirmed': "Non confirmé", 'is-billable': "Est facturable\u00A0?", 'event-is-now-billable': "Cet événement est maintenant facturable.", - 'is-not-billable-help': "Mode «\u00A0prêt\u00A0»\u00A0: pas de facturation.", - 'is-billable-help': "Mode «\u00A0location\u00A0»\u00A0: facturation possible.", - 'color-on-calendar': "Couleur sur le calendrier", + 'is-not-billable-help': "Mode «\u00A0Prêt\u00A0»\u00A0: pas de facturation.", + 'is-billable-help': "Mode «\u00A0Location\u00A0»\u00A0: facturation possible.", 'confirm-event': "Confirmer l'événement", 'unconfirm-event': "Remettre l'événement en attente", 'delete-event': "Supprimer l'événement", @@ -192,11 +190,8 @@ export default { 'print-summary': "Imprimer ce récapitulatif", 'open': "Ouvrir", 'in': "À {location}", + 'mobilization-period': "Mobilisation {period}", 'open-in-google-maps': "Ouvrir dans Google Maps", - 'on-date': "Le {date}", - 'from-date': "du\u00A0{date}", - 'to-date': "au\u00A0{date}", - 'from-date-to-date': "du\u00A0{from} au\u00A0{to}", 'or': "ou", 'for': "Pour", 'with': "Avec", @@ -237,9 +232,9 @@ export default { 'download-pdf': "Télécharger au format PDF", 'download-barcode': "Télécharger le code-barre", 'download-invoice': "Télécharger la facture", - 'click-here-to-create-estimate': "Cliquez ici pour pour créer un devis", - 'click-here-to-create-invoice': "Cliquez ici pour pour créer une facture", - 'click-here-to-generate-invoice': "Cliquez ici pour pour générer une facture", + 'click-here-to-create-estimate': "Cliquez ici pour créer un devis", + 'click-here-to-create-invoice': "Cliquez ici pour créer une facture", + 'click-here-to-generate-invoice': "Cliquez ici pour générer une facture", 'click-here-to-regenerate-invoice': "Cliquez ici pour refaire une facture", 'create-new-estimate': "Créer un nouveau devis", 'estimate-created': "Le devis a bien été créé.", @@ -253,6 +248,10 @@ export default { "{count} article", "{count} articles", ], + 'items-count-total': [ + "{count} article au total", + "{count} articles au total", + ], 'used-count': [ "{count} utilisé", "{count} utilisés", @@ -268,7 +267,7 @@ export default { 'total-amount': "Montant total", 'total-amount-without-taxes': "Montant total H.T.\u00A0:\u00A0{amount}", 'total-amount-after-discount': "Total H.T. après remise\u00A0:\u00A0{amount}", - 'total-replacement': "Valeur de remplacement totale\u00A0:", + 'total-replacement': "Valeur de remplacement totale\u00A0: {total}", 'total-value': "Valeur totale", 'total-quantity': "Quantité totale\u00A0: {total}", 'daily-amount': "Montant journalier\u00A0: {amount}", @@ -304,18 +303,16 @@ export default { 'grouped-by': "Voir groupé par\u00A0:", 'not-grouped': "Non groupé", 'start-on': "Débute le", - 'return-scheduled-on': "Retour prévu le", - 'back-to-calendar': "Retour au calendrier", + 'expected-end-on': "Fin prévue le", + 'back-to-home': "Retour à l'accueil", + 'back-to-schedule': "Retour au planning", 'previous-month': "Mois précédent", 'next-month': "Mois suivant", 'used-by': "Utilisé dans", 'events-count': ['{count} événement', '{count} événements'], - 'reservations-count': ['{count} réservation', '{count} réservations'], 'use': "Utiliser", - 'use-this-template': "Utiliser ce modèle de liste", - 'create-company': "Ajouter une nouvelle société", 'inventories': "Inventaires", @@ -350,9 +347,8 @@ export default { 'not-categorized': "Non catégorisé", 'parks': "Parcs", 'technician': "Technicien", - 'online-reservations': "Réservations en ligne", - 'this-feature-is-coming-soon': "Cette fonctionnalité est en cours de développement.", + 'this-feature-is-coming-soon': "Cette fonctionnalité est actuellement en développement.", 'external-links': { 'official-website': "Site web officiel", @@ -363,15 +359,15 @@ export default { 'select-no-options': "Aucune option disponible.", 'select-no-matching-result': "Aucune option ne correspond à cette recherche", - 'confirm-cancel-upload-change-tab': "Attention, un envoi de fichier est en cours, si vous quittez cet onglet, celui-ci sera annulé. Êtes-vous sûr de vouloir continuer\u00A0?", - 'confirm-cancel-upload-close-modal': "Attention, un envoi de fichier est en cours, si vous fermez cette fenêtre, celui-ci sera annulé. Êtes-vous sûr de vouloir continuer\u00A0?", + 'confirm-cancel-upload-change-tab': "Attention, un envoi de fichier est en cours. Si vous quittez cet onglet, l'envoi sera annulé. Êtes-vous sûr de vouloir continuer\u00A0?", + 'confirm-cancel-upload-close-modal': "Attention, un envoi de fichier est en cours. Si vous fermez cette fenêtre, l'envoi sera annulé. Êtes-vous sûr de vouloir continuer\u00A0?", '@event': { 'confirm-delete': "Mettre cet événement à la corbeille\u00A0?", 'event-missing-materials': "Matériel manquant", - 'event-missing-materials-help': "Il s'agit du matériel manquant pour la période de l'événement, car il est utilisé dans un autre événement, le nombre voulu est trop important, ou quelques uns sont en panne. Ce matériel doit donc être ajouté au parc, ou bien loué auprès d'une autre société.", - 'missing-material-count': "Besoin de {quantity}, il en manque\u00A0{missing}\u00A0!", + 'event-missing-materials-help': "Il s'agit du matériel manquant pour la période de l'événement, car il est utilisé dans un autre événement, le nombre voulu est trop important, ou quelques uns sont en panne.", + 'missing-material-count': "Besoin de {quantity}, il en manque\u00A0{missing}", 'warning-no-beneficiary': "Attention, cet événement n'a aucun bénéficiaire\u00A0!", 'warning-no-material': "Attention, cet événement est vide, il ne contient aucun matériel pour le moment\u00A0!", @@ -391,15 +387,4 @@ export default { 'has-not-returned-materials': "Cet événement a du matériel qui n'a pas été retourné.", }, }, - - '@reservation': { - 'statuses': { - 'is-past': "Cette réservation est passée.", - 'is-currently-running': "Cette réservation se déroule en ce moment.", - 'is-archived': "Cette réservation est archivée.", - 'has-missing-materials': "Cette réservation a du matériel manquant.", - 'needs-its-return-inventory': "Il faut faire l'inventaire de retour de cette réservation\u00A0!", - 'has-not-returned-materials': "Cette réservation a du matériel qui n'a pas été retourné.", - }, - }, }; diff --git a/client/src/locale/fr/date.js b/client/src/locale/fr/date.js new file mode 100644 index 000000000..3c547032b --- /dev/null +++ b/client/src/locale/fr/date.js @@ -0,0 +1,11 @@ +export default { + // - Formats simples. + 'on-date': "le {date}", + 'from-date': "du\u00A0{date}", + 'to-date': "au\u00A0{date}", + 'from-date-to-date': "du\u00A0{from} au\u00A0{to}", + + // - Formats "dans une phrase". + 'date-in-sentence': "le {date}", + 'period-in-sentence': "la période du {from} au {to}", +}; diff --git a/client/src/locale/fr/errors.js b/client/src/locale/fr/errors.js index 5995c652e..cc4cb5cf4 100644 --- a/client/src/locale/fr/errors.js +++ b/client/src/locale/fr/errors.js @@ -1,27 +1,17 @@ export default { errors: { + 'unexpected': "Une erreur inattendue s'est produite, veuillez ré-essayer.", 'unexpected-while-saving': "Une erreur inattendue s'est produite lors de l'enregistrement, veuillez ré-essayer.", 'unexpected-while-deleting': "Une erreur inattendue s'est produite lors de la suppression, veuillez ré-essayer.", 'unexpected-while-restoring': "Une erreur inattendue s'est produite lors de la restauration, veuillez ré-essayer.", 'unexpected-while-calculating': "Une erreur inattendue s'est produite lors du calcul, veuillez ré-essayer.", 'unexpected-while-fetching': "Une erreur inattendue s'est produite lors de la récupération des données.", - 'api-unreachable': "Désolé, mais l'API est inaccessible... Veuillez vérifier votre accès au réseau.", + 'api-unreachable': "Le service est actuellement injoignable. Veuillez vérifier votre connexion réseau et réessayer.", 'record-not-found': "Cet enregistrement n'existe pas.", 'page-not-found': "La page demandée n'existe pas ou plus.", - 'validation': "Veuillez vérifier les informations du formulaire.", + 'validation': "Veuillez vérifier les données saisies dans le formulaire.", 'unknown': "Erreur inconnue.", 'already-exists': "Cet enregistrement existe déjà.", - 'show-details': "Voir le détail de l'erreur", - 'details-title': "Détails de l'erreur", - 'details-intro1': "Vous pouvez copier ce qui suit, pour obtenir de l'aide de la part de la communauté.", - 'details-intro2': "Merci de le copier tel quel, car c'est écrit en markdown pour faciliter la lecture sur", - 'details-intro-forum': "le forum", - 'details-intro3': "ou sur", - 'details-intro-not-detailed': "Pour obtenir plus de détails sur l'erreur, vous pouvez modifier le paramètre `displayErrorDetails` à 'true' dans le fichier 'src/App/Config/settings.json'.", - 'details-request': "Requête API\u00A0:", - 'details-message': "Message de l'erreur", - 'details-file': "Fichier\u00A0:", - 'details-stacktrace': "Trace de la pile\u00A0:", 'critical': [ "Une erreur s'est produite, veuillez actualiser la page.", "Si le problème persiste, veuillez contacter un administrateur.", diff --git a/client/src/locale/fr/index.js b/client/src/locale/fr/index.js index 37662e4ef..a991bd892 100644 --- a/client/src/locale/fr/index.js +++ b/client/src/locale/fr/index.js @@ -1,7 +1,9 @@ import common from './common'; +import date from './date'; import errors from './errors'; export default { ...common, + ...date, ...errors, }; diff --git a/client/src/stores/api/@codes.ts b/client/src/stores/api/@codes.ts index 02d9bed3e..f5ec2c6fb 100644 --- a/client/src/stores/api/@codes.ts +++ b/client/src/stores/api/@codes.ts @@ -20,14 +20,4 @@ export enum ApiErrorCode { /** Le payload fourni dans la requête ne doit pas être vide. */ EMPTY_PAYLOAD = 401, - - // - // - Conflits. - // - - /** - * Un conflit dû au fait qu'une tentative d'assignation d'un - * technicien a échoué vu qu'il est déjà mobilisé à ce moment. - */ - TECHNICIAN_ALREADY_BUSY = 201, } diff --git a/client/src/stores/api/@schema.ts b/client/src/stores/api/@schema.ts new file mode 100644 index 000000000..ebe37fff3 --- /dev/null +++ b/client/src/stores/api/@schema.ts @@ -0,0 +1,45 @@ +import { z } from '@/utils/validation'; + +import type { UnionToTupleString } from '@/utils/@types'; +import type { AnyZodObject, ZodEnum, ZodTypeAny } from 'zod'; + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export const withPaginationEnvelope = (dataSchema: T) => ( + z.object({ + data: dataSchema.array(), + pagination: z.object({ + perPage: z.number().positive(), + currentPage: z.number().nonnegative(), + total: z.object({ + items: z.number().nonnegative(), + pages: z.number().nonnegative(), + }), + }), + }) +); + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export const withCountedEnvelope = (dataSchema: T) => ( + z.object({ + data: dataSchema.array(), + count: z.number().nonnegative(), + }) +); + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export const withCsvImportResult = (mapping: T) => ( + z.strictObject({ + total: z.number().nonnegative(), + success: z.number().nonnegative(), + errors: z.array(z.strictObject({ + line: z.number().nonnegative(), + message: z.string(), + errors: z.array(z.strictObject({ + // - Requis pour patcher le `(T: AnyZodObject).keyof()` qui renvoi un `ZodEnum` sinon. + field: z.union([mapping.keyof() as any as ZodEnum>, z.string()]), + value: z.string().nullable(), + error: z.string(), + })), + })), + }) +); diff --git a/client/src/stores/api/@types.ts b/client/src/stores/api/@types.ts index 5f2bb769b..2e7b980e4 100644 --- a/client/src/stores/api/@types.ts +++ b/client/src/stores/api/@types.ts @@ -1,3 +1,7 @@ +// +// - Types liés à la pagination / tri. +// + /** Sens de tri. */ export enum Direction { /** Direction ascendante. */ @@ -49,47 +53,55 @@ export type ListingParams = ( & PaginationParams ); -export type PaginatedData = { - data: T, - pagination: { - perPage: number, - currentPage: number, - total: { - items: number, - pages: number, - }, - }, -}; - -export type WithCount = { - count: number, - data: T, -}; - -// - Types liés aux imports +// +// - Types liés aux imports. +// export type CsvDelimiter = ',' | ';' | ':' | `\t`; -export type CsvImport> = { - mapping: Mapping, - delimiter: CsvDelimiter, +export type CsvMapping = Record; + +export type CsvImport = { + mapping: T, file: File, + delimiter: CsvDelimiter, }; -export type CsvColumnError> = { - field: keyof Mapping, - value: string, +export type CsvColumnError = { + field: keyof T | string, + value: string | null, error: string, }; -export type CsvImportError> = { +export type CsvImportError = { line: number, message: string, - errors: Array>, + errors: Array>, }; -export type CsvImportResults> = { +export type CsvImportResults = { total: number, success: number, - errors: Array>, + errors: Array>, +}; + +// +// - Enveloppes. +// + +export type PaginatedData = { + data: T, + pagination: { + perPage: number, + currentPage: number, + total: { + items: number, + pages: number, + }, + }, +}; + +export type CountedData = { + data: T, + count: number, }; diff --git a/client/src/stores/api/attributes.ts b/client/src/stores/api/attributes.ts index 1ae64aed8..ac44ae11e 100644 --- a/client/src/stores/api/attributes.ts +++ b/client/src/stores/api/attributes.ts @@ -1,61 +1,155 @@ +import { z } from '@/utils/validation'; import requester from '@/globals/requester'; +import { CategorySchema } from './categories'; import type { Category } from './categories'; +import type { SchemaInfer } from '@/utils/validation'; -// -// - Types -// +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ -export type AttributeType = 'string' | 'integer' | 'float' | 'boolean' | 'date'; +export enum AttributeType { + STRING = 'string', + INTEGER = 'integer', + FLOAT = 'float', + BOOLEAN = 'boolean', + DATE = 'date', +} -type AttributeBase = { - id: number, - name: string, -}; +const AttributeBaseSchema = z.strictObject({ + id: z.number(), + name: z.string(), +}); -export type Attribute = AttributeBase & ( - | { type: 'string', maxLength: number | null } - | { type: 'integer' | 'float', unit: string | null, isTotalisable: boolean } - | { type: 'boolean' | 'date' } -); +// NOTE: Pour le moment, pas moyen de faire ça mieux en gardant l'objet `strict`. +// @see https://github.com/colinhacks/zod/discussions/3011#discussioncomment-7718731 +export const AttributeSchema = z.discriminatedUnion('type', [ + AttributeBaseSchema.extend({ + type: z.literal(AttributeType.STRING), + max_length: z.number().nullable(), + }), + AttributeBaseSchema.extend({ + type: z.enum([AttributeType.INTEGER, AttributeType.FLOAT]), + unit: z.string().nullable(), + is_totalisable: z.boolean().nullable().transform( + (value: boolean | null) => value ?? false, + ), + }), + AttributeBaseSchema.extend({ + type: z.literal(AttributeType.BOOLEAN), + }), + AttributeBaseSchema.extend({ + type: z.literal(AttributeType.DATE), + }), +]); -export type AttributeDetails = Attribute & { - categories: Category[], -}; +// NOTE: Pour le moment, pas moyen de faire ça mieux en gardant l'objet `strict`. +// @see https://github.com/colinhacks/zod/discussions/3011#discussioncomment-7718731 +export const AttributeWithValueSchema = z.discriminatedUnion('type', [ + AttributeBaseSchema.extend({ + type: z.literal(AttributeType.STRING), + max_length: z.number().nullable(), + value: z.string().nullable(), + }), + AttributeBaseSchema.extend({ + type: z.enum([AttributeType.INTEGER, AttributeType.FLOAT]), + unit: z.string().nullable(), + is_totalisable: z.boolean().nullable().transform( + (value: boolean | null) => value ?? false, + ), + value: z.number().nullable(), + }), + AttributeBaseSchema.extend({ + type: z.literal(AttributeType.BOOLEAN), + value: z.boolean().nullable(), + }), + AttributeBaseSchema.extend({ + type: z.literal(AttributeType.DATE), + value: z.day().nullable(), + }), +]); + +// NOTE: Pour le moment, pas moyen de faire ça mieux en gardant l'objet `strict`. +// @see https://github.com/colinhacks/zod/discussions/3011#discussioncomment-7718731 +export const AttributeDetailsSchema = (() => { + const baseSchema = AttributeBaseSchema.extend({ + categories: z.lazy(() => CategorySchema.array()), + }); + + return z.discriminatedUnion('type', [ + baseSchema.extend({ + type: z.literal(AttributeType.STRING), + max_length: z.number().nullable(), + }), + baseSchema.extend({ + type: z.enum([AttributeType.INTEGER, AttributeType.FLOAT]), + unit: z.string().nullable(), + is_totalisable: z.boolean().nullable().transform( + (value: boolean | null) => value ?? false, + ), + }), + baseSchema.extend({ + type: z.enum([AttributeType.BOOLEAN, AttributeType.DATE]), + }), + ]); +})(); + +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ + +export type Attribute = SchemaInfer; + +export type AttributeWithValue = SchemaInfer; -export type AttributeEdit = { +export type AttributeDetails = SchemaInfer; + +// +// - Edition +// + +export type AttributeCreate = { name: string, type?: AttributeType, unit?: string, - isTotalisable?: boolean, - maxLength?: string | null, + max_length?: string | number | null, + is_totalisable?: boolean, categories: Array, }; -export type AttributePut = Omit; +export type AttributeEdit = Omit; -// -// - Fonctions -// +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ const all = async (categoryId?: Category['id'] | 'none'): Promise => { - const { data } = await requester.get('/attributes', { - params: { category: categoryId }, - }); - return data; + const params = { ...(categoryId !== undefined ? { category: categoryId } : {}) }; + const response = await requester.get('/attributes', { params }); + return AttributeDetailsSchema.array().parse(response.data); }; -const one = async (id: Attribute['id']): Promise => ( - (await requester.get(`/attributes/${id}`)).data -); +const one = async (id: Attribute['id']): Promise => { + const response = await requester.get(`/attributes/${id}`); + return AttributeDetailsSchema.parse(response.data); +}; -const create = async (data: AttributeEdit): Promise => ( - (await requester.post('/attributes', data)).data -); +const create = async (data: AttributeCreate): Promise => { + const response = await requester.post('/attributes', data); + return AttributeDetailsSchema.parse(response.data); +}; -const update = async (id: Attribute['id'], data: AttributePut): Promise => ( - (await requester.put(`/attributes/${id}`, data)).data -); +const update = async (id: Attribute['id'], data: AttributeEdit): Promise => { + const response = await requester.put(`/attributes/${id}`, data); + return AttributeDetailsSchema.parse(response.data); +}; const remove = async (id: Attribute['id']): Promise => { await requester.delete(`/attributes/${id}`); diff --git a/client/src/stores/api/beneficiaries.ts b/client/src/stores/api/beneficiaries.ts index 0795fdb72..551b6f22b 100644 --- a/client/src/stores/api/beneficiaries.ts +++ b/client/src/stores/api/beneficiaries.ts @@ -1,15 +1,20 @@ -import moment from 'moment'; +import { z } from '@/utils/validation'; import requester from '@/globals/requester'; -import { normalize as normalizeEstimate } from '@/stores/api/estimates'; -import { normalize as normalizeInvoice } from '@/stores/api/invoices'; - -import type { MomentInput } from 'moment'; -import type { Company } from '@/stores/api/companies'; -import type { Country } from '@/stores/api/countries'; -import type { User } from '@/stores/api/users'; -import type { RawEstimate, Estimate } from '@/stores/api/estimates'; -import type { RawInvoice, Invoice } from '@/stores/api/invoices'; -import type { BookingSummary } from '@/stores/api/bookings'; +import { UserSchema } from './users'; +import { CompanySchema } from './companies'; +import { CountrySchema } from './countries'; +import { EstimateSchema } from './estimates'; +import { InvoiceSchema } from './invoices'; +import { BookingExcerptSchema } from './bookings'; +import { + withPaginationEnvelope, +} from './@schema'; + +import type { SchemaInfer } from '@/utils/validation'; +import type DateTime from '@/utils/datetime'; +import type { Estimate } from './estimates'; +import type { Invoice } from './invoices'; +import type { BookingExcerpt } from './bookings'; import type { Direction, ListingParams, @@ -17,40 +22,54 @@ import type { PaginationParams, } from './@types'; +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ + +export const BeneficiarySchema = z.strictObject({ + id: z.number(), + user_id: z.number().nullable(), + first_name: z.string(), + last_name: z.string(), + full_name: z.string(), + reference: z.string().nullable(), + // TODO [zod@>3.22.4]: Remettre `email()`. + email: z.string().nullable(), + phone: z.string().nullable(), + company_id: z.number().nullable(), + company: z.lazy(() => CompanySchema).nullable(), + street: z.string().nullable(), + postal_code: z.string().nullable(), + locality: z.string().nullable(), + country_id: z.number().nullable(), + country: z.lazy(() => CountrySchema).nullable(), + full_address: z.string().nullable(), + note: z.string().nullable(), +}); + +export const BeneficiaryDetailsSchema = BeneficiarySchema.extend({ + user: z.lazy(() => UserSchema).nullable(), + stats: z.strictObject({ + borrowings: z.number().nonnegative(), + }), +}); + +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ + +export type Beneficiary = SchemaInfer; + +export type BeneficiaryDetails = SchemaInfer; + // -// - Types +// - Edition // -export type BeneficiaryStats = { - borrowings: number, -}; - -export type Beneficiary = { - id: number, - first_name: string, - full_name: string, - last_name: string, - reference: string | null, - email: string | null, - phone: string | null, - company_id: number | null, - company: Company | null, - street: string | null, - postal_code: string | null, - locality: string | null, - country_id: number | null, - country: Country | null, - full_address: string | null, - note: string | null, - user_id: number | null, - can_make_reservation: boolean, - stats: BeneficiaryStats, -}; - -export type BeneficiaryDetails = Beneficiary & { - user: User | null, -}; - export type BeneficiaryEdit = { first_name: string, last_name: string, @@ -62,9 +81,15 @@ export type BeneficiaryEdit = { postal_code: string | null, locality: string | null, country_id: number | null, + pseudo?: string, + password?: string, note: string | null, }; +// +// - Récupération +// + type GetAllParams = ListingParams & { /** * Permet de ne récupérer que les bénéficiaires dans la "corbeille". @@ -81,7 +106,7 @@ type GetBookingsParams = PaginationParams & { * * @default undefined */ - after?: MomentInput, + after?: DateTime, /** * Le sens dans lequel on veut récupérer les bookings : @@ -93,53 +118,54 @@ type GetBookingsParams = PaginationParams & { direction?: Direction, }; -// -// - Fonctions -// +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ -const all = async (params: GetAllParams): Promise> => ( - (await requester.get('/beneficiaries', { params })).data -); +const all = async (params: GetAllParams = {}): Promise> => { + const response = await requester.get('/beneficiaries', { params }); + return withPaginationEnvelope(BeneficiarySchema).parse(response.data); +}; -const one = async (id: Beneficiary['id']): Promise => ( - (await requester.get(`/beneficiaries/${id}`)).data -); +const one = async (id: Beneficiary['id']): Promise => { + const response = await requester.get(`/beneficiaries/${id}`); + return BeneficiaryDetailsSchema.parse(response.data); +}; -const create = async (data: BeneficiaryEdit): Promise => ( - (await requester.post('/beneficiaries', data)).data -); +const create = async (data: BeneficiaryEdit): Promise => { + const response = await requester.post('/beneficiaries', data); + return BeneficiaryDetailsSchema.parse(response.data); +}; -const update = async (id: Beneficiary['id'], data: BeneficiaryEdit): Promise => ( - (await requester.put(`/beneficiaries/${id}`, data)).data -); +const update = async (id: Beneficiary['id'], data: BeneficiaryEdit): Promise => { + const response = await requester.put(`/beneficiaries/${id}`, data); + return BeneficiaryDetailsSchema.parse(response.data); +}; -const restore = async (id: Beneficiary['id']): Promise => ( - (await requester.put(`/beneficiaries/restore/${id}`)).data -); +const restore = async (id: Beneficiary['id']): Promise => { + const response = await requester.put(`/beneficiaries/restore/${id}`); + return BeneficiaryDetailsSchema.parse(response.data); +}; const remove = async (id: Beneficiary['id']): Promise => { await requester.delete(`/beneficiaries/${id}`); }; -const bookings = async ( - id: Beneficiary['id'], - { after, ...otherParams }: GetBookingsParams = {}, -): Promise> => { - const params: Record = { ...otherParams }; - if (after !== undefined) { - params.after = moment(after).format(); - } - return (await requester.get(`/beneficiaries/${id}/bookings`, { params })).data; +const bookings = async (id: Beneficiary['id'], params: GetBookingsParams = {}): Promise> => { + const response = await requester.get(`/beneficiaries/${id}/bookings`, { params }); + return withPaginationEnvelope(BookingExcerptSchema).parse(response.data); }; const estimates = async (id: Beneficiary['id']): Promise => { - const rawEstimates: RawEstimate[] = (await requester.get(`/beneficiaries/${id}/estimates`)).data; - return rawEstimates.map(normalizeEstimate); + const response = await requester.get(`/beneficiaries/${id}/estimates`); + return EstimateSchema.array().parse(response.data); }; const invoices = async (id: Beneficiary['id']): Promise => { - const rawInvoices: RawInvoice[] = (await requester.get(`/beneficiaries/${id}/invoices`)).data; - return rawInvoices.map(normalizeInvoice); + const response = await requester.get(`/beneficiaries/${id}/invoices`); + return InvoiceSchema.array().parse(response.data); }; export default { diff --git a/client/src/stores/api/bookings.ts b/client/src/stores/api/bookings.ts index 8318a1031..7c4679e73 100644 --- a/client/src/stores/api/bookings.ts +++ b/client/src/stores/api/bookings.ts @@ -1,105 +1,184 @@ +import { z } from '@/utils/validation'; import requester from '@/globals/requester'; -import { normalize as normalizeEvent } from '@/stores/api/events'; - -import type { Moment } from 'moment'; -import type { Event, RawEvent } from '@/stores/api/events'; -import type { Park } from '@/stores/api/parks'; -import type { Material } from '@/stores/api/materials'; +import { BeneficiarySchema } from './beneficiaries'; +import { withPaginationEnvelope } from './@schema'; +import { + EventTechnicianSchema, + createEventDetailsSchema, +} from './events'; + +import type Period from '@/utils/period'; +import type { Material, UNCATEGORIZED } from '@/stores/api/materials'; import type { Category } from '@/stores/api/categories'; - -// -// - Constants -// +import type { Park } from '@/stores/api/parks'; +import type { SchemaInfer } from '@/utils/validation'; +import type { ZodRawShape } from 'zod'; +import type { + PaginatedData, + SortableParams, + PaginationParams, +} from './@types'; + +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ export enum BookingEntity { EVENT = 'event', } // -// - Types +// - Schemas principaux // -type EventBookingSummary = ( - & Pick - & { - entity: BookingEntity.EVENT, - parks: Array, - categories: Array, - } +// - Booking excerpt schema. +export const BookingExcerptSchema = z.strictObject({ + id: z.number(), + entity: z.literal(BookingEntity.EVENT), + title: z.string(), + location: z.string().nullable(), + color: z.string().nullable(), + mobilization_period: z.period(), + operation_period: z.period(), + beneficiaries: z.lazy(() => BeneficiarySchema.array()), + technicians: z.lazy(() => EventTechnicianSchema.array()), + is_confirmed: z.boolean(), + is_archived: z.boolean(), + is_departure_inventory_done: z.boolean(), + is_return_inventory_done: z.boolean(), + has_not_returned_materials: z.boolean().nullable(), + categories: z.number().array(), // - Ids des catégories liés. + parks: z.number().array(), // - Ids des parcs liés. + created_at: z.datetime(), +}); + +// - Booking summary schema. +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export const createBookingSummarySchema = (augmentation: T) => ( + z + .strictObject({ + id: z.number(), + entity: z.literal(BookingEntity.EVENT), + title: z.string(), + reference: z.string().nullable(), + description: z.string().nullable(), + location: z.string().nullable(), + color: z.string().nullable(), + mobilization_period: z.period(), + operation_period: z.period(), + beneficiaries: z.lazy(() => BeneficiarySchema.array()), + technicians: z.lazy(() => EventTechnicianSchema.array()), + is_confirmed: z.boolean(), + is_billable: z.boolean(), + is_archived: z.boolean(), + is_departure_inventory_done: z.boolean(), + is_return_inventory_done: z.boolean(), + has_missing_materials: z.boolean().nullable(), + has_not_returned_materials: z.boolean().nullable(), + categories: z.number().array(), // - Ids des catégories liés. + parks: z.number().array(), // - Ids des parcs liés. + created_at: z.datetime(), + }) + .extend(augmentation) +); +export const BookingSummarySchema = createBookingSummarySchema({}); + +// - Booking schema. +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export const createBookingSchema = (augmentation: T) => ( + z.lazy(() => ( + createEventDetailsSchema({ + entity: z.literal(BookingEntity.EVENT), + ...augmentation, + }) + )) ); +export const BookingSchema = createBookingSchema({}); -export type BookingSummary = EventBookingSummary; +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ -type RawBooking = ( - | (RawEvent & { entity: BookingEntity.EVENT }) +// +// - Main Types +// + +type NarrowBooking = ( + Extract ); -export type Booking = ( - | (Event & { entity: BookingEntity.EVENT }) +export type BookingExcerpt = ( + NarrowBooking, Entity> ); +export type BookingSummary = ( + NarrowBooking, Entity> +); + +export type Booking = ( + NarrowBooking, Entity> +); + +// +// - Édition +// + export type MaterialQuantity = { id: Material['id'], quantity: number, }; // -// - Normalizer +// - Récupération // -const normalize = (rawBooking: RawBooking): Booking => { - const { entity, ...booking } = rawBooking; +export type BookingListFilters = { + period?: Period, + search?: string, + category?: Category['id'] | typeof UNCATEGORIZED, + park?: Park['id'], + endingToday?: boolean, + returnInventoryTodo?: boolean, + archived?: boolean, + notConfirmed?: boolean, +}; - switch (entity) { - case BookingEntity.EVENT: - return { - entity: BookingEntity.EVENT, - ...normalizeEvent(booking as RawEvent), - }; +type GetAllParamsPaginated = BookingListFilters & SortableParams & PaginationParams & { paginated?: true }; +type GetAllInPeriodParams = { paginated: false, period: Period }; - default: - throw new Error(`Entity '${entity}' not recognized.`); - } -}; +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ -// -// - Fonctions -// +async function all(params: GetAllInPeriodParams): Promise; +async function all(params?: GetAllParamsPaginated): Promise>; +async function all({ period, ...params }: GetAllParamsPaginated | GetAllInPeriodParams = {}): Promise { + const normalizedParams = { paginated: true, ...params, ...period?.toQueryParams('period') }; + const response = await requester.get('/bookings', { params: normalizedParams }); + + return normalizedParams.paginated + ? withPaginationEnvelope(BookingExcerptSchema).parse(response.data) + : BookingExcerptSchema.array().parse(response.data); +} -const all = async (start: Moment, end: Moment): Promise => { - const params = { - start: start.format('YYYY-MM-DD HH:mm:ss'), - end: end.format('YYYY-MM-DD HH:mm:ss'), - }; - return (await requester.get('/bookings', { params })).data; +const oneSummary = async (entity: BookingEntity, id: Booking['id']): Promise => { + const response = await requester.get(`/bookings/${entity}/${id}/summary`); + return BookingSummarySchema.parse(response.data); }; -const updateMaterials = async (entity: BookingEntity, id: BookingSummary['id'], materials: MaterialQuantity[]): Promise => ( - normalize((await requester.put(`/bookings/${entity}/${id}/materials`, materials)).data) -); +const updateMaterials = async (entity: BookingEntity, id: Booking['id'], materials: MaterialQuantity[]): Promise => { + const response = await requester.put(`/bookings/${entity}/${id}/materials`, materials); + return BookingSchema.parse(response.data); +}; export default { all, + oneSummary, updateMaterials, }; diff --git a/client/src/stores/api/categories.ts b/client/src/stores/api/categories.ts index d36ee867e..9c4333e43 100644 --- a/client/src/stores/api/categories.ts +++ b/client/src/stores/api/categories.ts @@ -1,39 +1,62 @@ +import { z } from '@/utils/validation'; import requester from '@/globals/requester'; +import { SubCategorySchema } from './subcategories'; -import type { Subcategory } from '@/stores/api/subcategories'; +import type { SchemaInfer } from '@/utils/validation'; -// -// - Types -// +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ -export type Category = { - id: number, - name: string, -}; +export const CategorySchema = z.object({ + id: z.number(), + name: z.string(), +}); -export type CategoryDetails = Category & { - sub_categories: Subcategory[], -}; +export const CategoryDetailsSchema = CategorySchema.extend({ + sub_categories: z.lazy(() => SubCategorySchema.array()), +}); + +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ + +export type Category = SchemaInfer; + +export type CategoryDetails = SchemaInfer; + +// +// - Edition +// export type CategoryEdit = { name: string, }; -// -// - Fonctions -// +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ -const all = async (): Promise => ( - (await requester.get('/categories')).data -); +const all = async (): Promise => { + const response = await requester.get('/categories'); + return CategoryDetailsSchema.array().parse(response.data); +}; -const create = async (data: CategoryEdit): Promise => ( - (await requester.post('/categories', data)).data -); +const create = async (data: CategoryEdit): Promise => { + const response = await requester.post('/categories', data); + return CategoryDetailsSchema.parse(response.data); +}; -const update = async (id: Category['id'], data: CategoryEdit): Promise => ( - (await requester.put(`/categories/${id}`, data)).data -); +const update = async (id: Category['id'], data: Partial): Promise => { + const response = await requester.put(`/categories/${id}`, data); + return CategoryDetailsSchema.parse(response.data); +}; const remove = async (id: Category['id']): Promise => { await requester.delete(`/categories/${id}`); diff --git a/client/src/stores/api/companies.ts b/client/src/stores/api/companies.ts index c7d3d50f4..e3bce54dc 100644 --- a/client/src/stores/api/companies.ts +++ b/client/src/stores/api/companies.ts @@ -1,25 +1,43 @@ +import { z } from '@/utils/validation'; import requester from '@/globals/requester'; +import { withPaginationEnvelope } from './@schema'; +import { CountrySchema } from './countries'; -import type { Country } from '@/stores/api/countries'; +import type { SchemaInfer } from '@/utils/validation'; +import type { Country } from './countries'; import type { PaginatedData, ListingParams } from './@types'; +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ + +export const CompanySchema = z.strictObject({ + id: z.number(), + legal_name: z.string(), + phone: z.string().nullable(), + street: z.string().nullable(), + postal_code: z.string().nullable(), + locality: z.string().nullable(), + country_id: z.number().nullable(), + country: z.lazy(() => CountrySchema).nullable(), + full_address: z.string().nullable(), + note: z.string().nullable(), +}); + +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ + +export type Company = SchemaInfer; + // -// - Types +// - Edition // -export type Company = { - id: number, - legal_name: string | null, - phone: string | null, - street: string | null, - postal_code: string | null, - locality: string | null, - country_id: Country['id'] | null, - country: Country | null, - full_address: string | null, - note: string | null, -}; - export type CompanyEdit = { legal_name: string, phone: string | null, @@ -30,26 +48,36 @@ export type CompanyEdit = { note: string | null, }; -type GetAllParams = ListingParams & { deleted?: boolean }; - // -// - Fonctions +// - Récupération // -const all = async (params: GetAllParams): Promise> => ( - (await requester.get('/companies', { params })).data -); +type GetAllParams = ListingParams & { deleted?: boolean }; + +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ -const one = async (id: Company['id']): Promise => ( - (await requester.get(`/companies/${id}`)).data -); +const all = async (params: GetAllParams = {}): Promise> => { + const response = await requester.get('/companies', { params }); + return withPaginationEnvelope(CompanySchema).parse(response.data); +}; -const create = async (data: CompanyEdit): Promise => ( - (await requester.post('/companies', data)).data -); +const one = async (id: Company['id']): Promise => { + const response = await requester.get(`/companies/${id}`); + return CompanySchema.parse(response.data); +}; -const update = async (id: Company['id'], data: CompanyEdit): Promise => ( - (await requester.put(`/companies/${id}`, data)).data -); +const create = async (data: CompanyEdit): Promise => { + const response = await requester.post('/companies', data); + return CompanySchema.parse(response.data); +}; + +const update = async (id: Company['id'], data: CompanyEdit): Promise => { + const response = await requester.put(`/companies/${id}`, data); + return CompanySchema.parse(response.data); +}; export default { all, one, create, update }; diff --git a/client/src/stores/api/countries.ts b/client/src/stores/api/countries.ts index 6ca3fa5e2..90ae8d6b9 100644 --- a/client/src/stores/api/countries.ts +++ b/client/src/stores/api/countries.ts @@ -1,21 +1,37 @@ +import { z } from '@/utils/validation'; import requester from '@/globals/requester'; -// -// - Types -// +import type { SchemaInfer } from '@/utils/validation'; -export type Country = { - id: number, - name: string, - code: string, -}; +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ + +export const CountrySchema = z.strictObject({ + id: z.number(), + name: z.string(), + code: z.string(), +}); + +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ -// -// - Fonctions -// +export type Country = SchemaInfer; -const all = async (): Promise => ( - (await requester.get('/countries')).data -); +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ + +const all = async (): Promise => { + const response = await requester.get('/countries'); + return CountrySchema.array().parse(response.data); +}; export default { all }; diff --git a/client/src/stores/api/documents.ts b/client/src/stores/api/documents.ts index 28670b6c2..af805de51 100644 --- a/client/src/stores/api/documents.ts +++ b/client/src/stores/api/documents.ts @@ -1,21 +1,36 @@ +import { z } from '@/utils/validation'; import requester from '@/globals/requester'; -// -// - Types -// - -export type Document = { - id: number, - name: string, - type: string, - size: number, - url: string, - created_at: string, -}; +import type { SchemaInfer } from '@/utils/validation'; + +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ + +export const DocumentSchema = z.strictObject({ + id: z.number(), + name: z.string(), + type: z.string(), + size: z.number(), + url: z.string(), + created_at: z.datetime(), +}); + +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ + +export type Document = SchemaInfer; -// -// - Fonctions -// +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ const remove = async (id: Document['id']): Promise => { await requester.delete(`/documents/${id}`); diff --git a/client/src/stores/api/estimates.ts b/client/src/stores/api/estimates.ts index 6bc73ed7c..5ee459358 100644 --- a/client/src/stores/api/estimates.ts +++ b/client/src/stores/api/estimates.ts @@ -1,36 +1,37 @@ +import { z } from '@/utils/validation'; import requester from '@/globals/requester'; -import Decimal from 'decimal.js'; - -// -// - Types -// - -export type RawEstimate = { - id: number, - date: string, - url: string, - discount_rate: DecimalType, - total_without_taxes: DecimalType, - total_with_taxes: DecimalType, - currency: string, -}; -export type Estimate = RawEstimate; +import type { SchemaInfer } from '@/utils/validation'; + +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ + +export const EstimateSchema = z.strictObject({ + id: z.number(), + date: z.datetime(), + url: z.string(), + discount_rate: z.decimal(), + total_without_taxes: z.decimal(), + total_with_taxes: z.decimal(), + currency: z.string(), +}); -// -// - Normalizer -// +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ -export const normalize = (estimate: RawEstimate): Estimate => ({ - ...estimate, - discount_rate: new Decimal(estimate.discount_rate), - total_without_taxes: new Decimal(estimate.total_without_taxes), - total_with_taxes: new Decimal(estimate.total_with_taxes), -}); +export type Estimate = SchemaInfer; -// -// - Fonctions -// +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ const remove = async (id: Estimate['id']): Promise => { await requester.delete(`/estimates/${id}`); diff --git a/client/src/stores/api/events.ts b/client/src/stores/api/events.ts index 08608ab08..3c7375ba1 100644 --- a/client/src/stores/api/events.ts +++ b/client/src/stores/api/events.ts @@ -1,148 +1,240 @@ -import requester from '@/globals/requester'; +import { z } from '@/utils/validation'; import Decimal from 'decimal.js'; -import { normalize as normalizeEstimate } from '@/stores/api/estimates'; -import { normalize as normalizeInvoice } from '@/stores/api/invoices'; - +import requester from '@/globals/requester'; +import { UserSchema } from './users'; +import { DocumentSchema } from './documents'; +import { createMaterialSchema } from './materials'; +import { TechnicianSchema } from './technicians'; +import { withCountedEnvelope } from './@schema'; +import { EstimateSchema } from './estimates'; +import { InvoiceSchema } from './invoices'; +import { + BeneficiarySchema, +} from './beneficiaries'; + +import type Period from '@/utils/period'; +import type { CountedData } from './@types'; +import type { SchemaInfer } from '@/utils/validation'; +import type { Document } from './documents'; +import type { Estimate } from './estimates'; +import type { Invoice } from './invoices'; +import type { Material } from './materials'; +import type { Technician } from './technicians'; +import type { Beneficiary } from './beneficiaries'; import type { AxiosRequestConfig as RequestConfig } from 'axios'; -import type { WithCount } from '@/stores/api/@types'; -import type { Beneficiary } from '@/stores/api/beneficiaries'; -import type { Technician } from '@/stores/api/technicians'; -import type { Material } from '@/stores/api/materials'; -import type { RawEstimate, Estimate } from '@/stores/api/estimates'; -import type { RawInvoice, Invoice } from '@/stores/api/invoices'; -import type { Document } from '@/stores/api/documents'; -import type { User } from '@/stores/api/users'; +import type { ZodRawShape } from 'zod'; + +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ // -// - Types +// - Schemas secondaires // -export type EventMaterial = ( - & Material - & { - pivot: { - quantity: number, - quantity_missing: number, - quantity_departed: number | null, - quantity_returned: number | null, - quantity_returned_broken: number | null, - departure_comment: string | null, - }, - } -); +export const EventTechnicianSchema = z.strictObject({ + id: z.number(), + event_id: z.number(), + technician_id: z.number(), + period: z.period(), + position: z.string().nullable(), + technician: z.lazy(() => TechnicianSchema), +}); + +// +// -- Event material schemas / factory +// + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +const createEventMaterialSchema = (augmentation: T) => { + const pivotSchema = z + .strictObject({ + quantity: z.number().positive(), + quantity_departed: z.number().nonnegative().nullable(), + quantity_returned: z.number().nonnegative().nullable(), + quantity_returned_broken: z.number().nonnegative().nullable(), + departure_comment: z.string().nullable(), + }) + .extend(augmentation); + + return z.lazy(() => createMaterialSchema({ pivot: pivotSchema })); +}; + +const EventMaterialSchema = createEventMaterialSchema({}); + +const EventMaterialWithQuantityMissingSchema = createEventMaterialSchema({ + quantity_missing: z.number().nonnegative(), +}); -export type RawEvent< - DecimalType extends string | Decimal = string, - IsBillable extends boolean = boolean, -> = ( - { - id: number, - title: string, - reference: string | null, - description: string | null, - start_date: string, - end_date: string, - duration: { - days: number, - hours: number, - }, - color: string | null, - location: string | null, - total_replacement: DecimalType, - currency: string, - beneficiaries: Beneficiary[], - technicians: Technician[], - materials: EventMaterial[], - is_confirmed: boolean, - note: string | null, - author: User | null, - created_at: string, - updated_at: string, - } - & ( - | { +// +// - Schemas principaux +// + +export const EventSummarySchema = z.strictObject({ + id: z.number(), + title: z.string(), + mobilization_period: z.period(), + operation_period: z.period(), + location: z.string().nullable(), +}); + +export const EventSchema = EventSummarySchema.extend({ + reference: z.string().nullable(), + description: z.string().nullable(), + color: z.string().nullable(), + is_confirmed: z.boolean(), + is_billable: z.boolean(), + is_archived: z.boolean(), + is_departure_inventory_done: z.boolean(), + is_return_inventory_done: z.boolean(), + note: z.string().nullable(), + created_at: z.datetime(), + updated_at: z.datetime().nullable(), +}); + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export const createEventDetailsSchema = (augmentation: T) => ( + EventSchema + .omit({ + is_billable: true, + is_archived: true, is_departure_inventory_done: true, - departure_inventory_datetime: string | null, - departure_inventory_author: User | null, - } - | { - is_departure_inventory_done: false, - departure_inventory_datetime: null, - departure_inventory_author: null, - } - ) - & ( - | { is_return_inventory_done: true, - is_return_inventory_started: true, - return_inventory_datetime: string | null, - return_inventory_author: User | null, - } - | { - is_return_inventory_done: false, - is_return_inventory_started: boolean, - return_inventory_datetime: null, - return_inventory_author: null, - } - ) - & ( - | { - is_archived: true, - has_missing_materials: null, - has_not_returned_materials: null, - } - | { - is_archived: false, - has_missing_materials: boolean | null, - has_not_returned_materials: boolean | null, - } - ) - & ( - IsBillable extends true - ? { - is_billable: true, - estimates: Array>, - invoices: Array>, - degressive_rate: DecimalType, - discount_rate: DecimalType, - vat_rate: DecimalType, - daily_total: DecimalType, - total_without_discount: DecimalType, - total_discountable: DecimalType, - total_discount: DecimalType, - total_without_taxes: DecimalType, - total_taxes: DecimalType, - total_with_taxes: DecimalType, - } - : { - is_billable: false, - } - ) + }) + .extend({ + total_replacement: z.decimal(), + currency: z.string(), + beneficiaries: z.lazy(() => BeneficiarySchema.array()), + technicians: z.lazy(() => EventTechnicianSchema.array()), + materials: z.lazy(() => EventMaterialSchema.array()), + note: z.string().nullable(), + author: z.lazy(() => UserSchema).nullable(), + }) + .extend(augmentation) + .strip() // TODO: À enlever lorsqu'on pourra garder les objets stricts avec les intersections. + .and(z.discriminatedUnion('is_departure_inventory_done', [ + z.object({ // TODO: `strictObject` lorsque ce sera possible. + is_departure_inventory_done: z.literal(true), + departure_inventory_datetime: z.datetime().nullable(), + departure_inventory_author: z.lazy(() => UserSchema).nullable(), + }), + z.object({ // TODO: `strictObject` lorsque ce sera possible. + is_departure_inventory_done: z.literal(false), + departure_inventory_datetime: z.null(), + departure_inventory_author: z.null(), + }), + ])) + .and(z.discriminatedUnion('is_return_inventory_done', [ + z.object({ // TODO: `strictObject` lorsque ce sera possible. + is_return_inventory_done: z.literal(true), + is_return_inventory_started: z.literal(true), + return_inventory_datetime: z.datetime().nullable(), + return_inventory_author: z.lazy(() => UserSchema).nullable(), + }), + z.object({ // TODO: `strictObject` lorsque ce sera possible. + is_return_inventory_done: z.literal(false), + is_return_inventory_started: z.boolean(), + return_inventory_datetime: z.null(), + return_inventory_author: z.null(), + }), + ])) + .and(z.discriminatedUnion('is_archived', [ + z.object({ // TODO: `strictObject` lorsque ce sera possible. + is_archived: z.literal(true), + has_missing_materials: z.null(), + has_not_returned_materials: z.null(), + }), + z.object({ // TODO: `strictObject` lorsque ce sera possible. + is_archived: z.literal(false), + has_missing_materials: z.boolean().nullable(), + has_not_returned_materials: z.boolean().nullable(), + }), + ])) + .and(z.discriminatedUnion('is_billable', [ + z.object({ // TODO: `strictObject` lorsque ce sera possible. + is_billable: z.literal(true), + estimates: z.lazy(() => EstimateSchema.array()), + invoices: z.lazy(() => InvoiceSchema.array()), + degressive_rate: z.decimal(), + discount_rate: z.decimal(), + vat_rate: z.decimal(), + daily_total: z.decimal(), + total_without_discount: z.decimal(), + total_discountable: z.decimal(), + total_discount: z.decimal(), + total_without_taxes: z.decimal(), + total_taxes: z.decimal(), + total_with_taxes: z.decimal(), + }), + z.object({ // TODO: `strictObject` lorsque ce sera possible. + is_billable: z.literal(false), + }), + ])) ); -export type Event< - IsBillable extends boolean = boolean, -> = RawEvent; +export const EventDetailsSchema = createEventDetailsSchema({}); -export type EventSummary = Pick; +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ -type SearchParams = { - search?: string, - exclude?: number | undefined, +// +// - Main Types +// + +type NarrowEvent = + IsBillable extends true + ? Extract + : Extract; + +export type Event = SchemaInfer; + +export type EventDetails = + NarrowEvent, IsBillable>; + +export type EventSummary = SchemaInfer; + +// +// - Secondary Types +// + +export type EventMaterial = SchemaInfer; +export type EventMaterialWithQuantityMissing = SchemaInfer; + +export type EventTechnician = SchemaInfer; + +// +// - Edition +// + +// FIXME: À compléter. +export type EventEdit = { + title: string, + operation_period: Period | null, + mobilization_period: Period | null, + location: string | null, + description: string | null, + color: string | null, + is_billable: boolean, + is_confirmed: boolean, + beneficiaries?: Array, + note?: string | null, }; +type EventDuplicatePayload = Nullable<{ + operation_period: Period, + mobilization_period: Period, +}>; + type EventReturnInventoryMaterial = { id: Material['id'], actual: number, broken: number, }; - type EventReturnInventory = EventReturnInventoryMaterial[]; type EventDepartureInventoryMaterial = { @@ -150,138 +242,153 @@ type EventDepartureInventoryMaterial = { actual: number, comment?: string | null, }; - type EventDepartureInventory = EventDepartureInventoryMaterial[]; -type EventDuplicatePayload = { - start_date: string, - end_date: string, +export type EventTechnicianEdit = { + period: Period | null, + position: string | null, }; // -// - Normalizer +// - Récupération // -export const normalize = (rawEvent: RawEvent): Event => { - if (!rawEvent.is_billable) { - return { - ...rawEvent, - total_replacement: new Decimal(rawEvent.total_replacement), - }; - } - - const { - estimates: rawEstimates, - invoices: rawInvoices, - ...event - } = rawEvent; - - const invoices = rawInvoices !== undefined - ? rawInvoices.map(normalizeInvoice) - : undefined; - - const estimates = rawEstimates !== undefined - ? rawEstimates.map(normalizeEstimate) - : undefined; - - return { - ...event, - ...(estimates ? { estimates } : undefined), - ...(invoices ? { invoices } : undefined), - vat_rate: new Decimal(event.vat_rate), - degressive_rate: new Decimal(event.degressive_rate), - discount_rate: new Decimal(event.discount_rate), - daily_total: new Decimal(event.daily_total), - total_without_discount: new Decimal(event.total_without_discount), - total_discountable: new Decimal(event.total_discountable), - total_discount: new Decimal(event.total_discount), - total_without_taxes: new Decimal(event.total_without_taxes), - total_taxes: new Decimal(event.total_taxes), - total_with_taxes: new Decimal(event.total_with_taxes), - total_replacement: new Decimal(event.total_replacement), - } as Event; +type GetAllParams = { + search?: string, + exclude?: number | undefined, }; -// -// - Fonctions -// +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ -const all = async (params: SearchParams): Promise> => ( - (await requester.get('/events', { params })).data -); +const all = async (params: GetAllParams = {}): Promise> => { + const response = await requester.get('/events', { params }); + return withCountedEnvelope(EventSummarySchema).parse(response.data); +}; -const one = async (id: Event['id']): Promise => ( - normalize((await requester.get(`/events/${id}`)).data) -); +const one = async (id: Event['id']): Promise => { + const response = await requester.get(`/events/${id}`); + return EventDetailsSchema.parse(response.data); +}; -const missingMaterials = async (id: Event['id']): Promise => ( - (await requester.get(`/events/${id}/missing-materials`)).data -); +const missingMaterials = async (id: Event['id']): Promise => { + const response = await requester.get(`/events/${id}/missing-materials`); + return EventMaterialWithQuantityMissingSchema.array().parse(response.data); +}; -const setConfirmed = async (id: Event['id'], isConfirmed: boolean): Promise => ( - normalize((await requester.put(`/events/${id}`, { is_confirmed: isConfirmed })).data) -); +const setConfirmed = async (id: Event['id'], isConfirmed: boolean): Promise => { + const response = await requester.put(`/events/${id}`, { is_confirmed: isConfirmed }); + return EventDetailsSchema.parse(response.data); +}; -const archive = async (id: Event['id']): Promise => ( - normalize((await requester.put(`/events/${id}/archive`)).data) -); +const archive = async (id: Event['id']): Promise => { + const response = await requester.put(`/events/${id}/archive`); + return EventDetailsSchema.parse(response.data); +}; -const unarchive = async (id: Event['id']): Promise => ( - normalize((await requester.put(`/events/${id}/unarchive`)).data) -); +const unarchive = async (id: Event['id']): Promise => { + const response = await requester.put(`/events/${id}/unarchive`); + return EventDetailsSchema.parse(response.data); +}; -const updateReturnInventory = async (id: Event['id'], inventory: EventReturnInventory): Promise => ( - normalize((await requester.put(`/events/${id}/return`, inventory)).data) -); +const updateDepartureInventory = async (id: Event['id'], inventory: EventDepartureInventory): Promise => { + const response = await requester.put(`/events/${id}/departure`, inventory); + return EventDetailsSchema.parse(response.data); +}; -const finishReturnInventory = async (id: Event['id'], inventory: EventReturnInventory): Promise => ( - normalize((await requester.put(`/events/${id}/return/finish`, inventory)).data) -); +const finishDepartureInventory = async (id: Event['id'], inventory: EventDepartureInventory): Promise => { + const response = await requester.put(`/events/${id}/departure/finish`, inventory); + return EventDetailsSchema.parse(response.data); +}; -const updateDepartureInventory = async (id: Event['id'], inventory: EventDepartureInventory): Promise => ( - normalize((await requester.put(`/events/${id}/departure`, inventory)).data) -); +const cancelDepartureInventory = async (id: Event['id']): Promise => { + const response = await requester.delete(`/events/${id}/departure`); + return EventDetailsSchema.parse(response.data); +}; -const finishDepartureInventory = async (id: Event['id'], inventory: EventDepartureInventory): Promise => ( - normalize((await requester.put(`/events/${id}/departure/finish`, inventory)).data) -); +const updateReturnInventory = async (id: Event['id'], inventory: EventReturnInventory): Promise => { + const response = await requester.put(`/events/${id}/return`, inventory); + return EventDetailsSchema.parse(response.data); +}; -const createInvoice = async (id: Event['id'], discountRate: number = 0): Promise => ( - normalizeInvoice((await requester.post(`/events/${id}/invoices`, { discountRate })).data) -); +const finishReturnInventory = async (id: Event['id'], inventory: EventReturnInventory): Promise => { + const response = await requester.put(`/events/${id}/return/finish`, inventory); + return EventDetailsSchema.parse(response.data); +}; -const createEstimate = async (id: Event['id'], discountRate: number = 0): Promise => ( - normalizeEstimate((await requester.post(`/events/${id}/estimates`, { discountRate })).data) -); +const cancelReturnInventory = async (id: Event['id']): Promise => { + const response = await requester.delete(`/events/${id}/return`); + return EventDetailsSchema.parse(response.data); +}; -const create = async (params: any): Promise => ( - normalize((await requester.post(`/events`, params)).data) -); +const createInvoice = async (id: Event['id'], discountRate: Decimal = new Decimal(0)): Promise => { + const response = await requester.post(`/events/${id}/invoices`, { discountRate }); + return InvoiceSchema.parse(response.data); +}; -const update = async (id: Event['id'], params: any): Promise => ( - normalize((await requester.put(`/events/${id}`, params)).data) -); +const createEstimate = async (id: Event['id'], discountRate: Decimal = new Decimal(0)): Promise => { + const response = await requester.post(`/events/${id}/estimates`, { discountRate }); + return EstimateSchema.parse(response.data); +}; + +const create = async (data: EventEdit): Promise => { + const response = await requester.post(`/events`, data); + return EventDetailsSchema.parse(response.data); +}; + +const update = async (id: Event['id'], data: Partial): Promise => { + const response = await requester.put(`/events/${id}`, data); + return EventDetailsSchema.parse(response.data); +}; -const duplicate = async ( +const getTechnicianAssignment = async (eventTechnicianId: EventTechnician['id']): Promise => { + const response = await requester.get(`/event-technicians/${eventTechnicianId}`); + return EventTechnicianSchema.parse(response.data); +}; + +const addTechnicianAssignment = async ( id: Event['id'], - data: EventDuplicatePayload, - force: boolean = false, -): Promise => { - const params = { force: force || undefined }; - return normalize((await requester.post(`/events/${id}/duplicate`, data, { params })).data); + technicianId: Technician['id'], + data: EventTechnicianEdit, +): Promise => { + const payload = { ...data, event_id: id, technician_id: technicianId }; + const response = await requester.post(`/event-technicians`, payload); + return EventTechnicianSchema.parse(response.data); +}; + +const updateTechnicianAssignment = async ( + eventTechnicianId: EventTechnician['id'], + data: Partial, +): Promise => { + const response = await requester.put(`/event-technicians/${eventTechnicianId}`, data); + return EventTechnicianSchema.parse(response.data); +}; + +const deleteTechnicianAssignment = async (eventTechnicianId: EventTechnician['id']): Promise => { + await requester.delete(`/event-technicians/${eventTechnicianId}`); +}; + +const duplicate = async (id: Event['id'], data: EventDuplicatePayload): Promise => { + const response = await requester.post(`/events/${id}/duplicate`, data); + return EventDetailsSchema.parse(response.data); }; const remove = async (id: Event['id']): Promise => { await requester.delete(`/events/${id}`); }; -const documents = async (id: Event['id']): Promise => ( - (await requester.get(`/events/${id}/documents`)).data -); +const documents = async (id: Event['id']): Promise => { + const response = await requester.get(`/events/${id}/documents`); + return DocumentSchema.array().parse(response.data); +}; const attachDocument = async (id: Event['id'], file: File, options: RequestConfig = {}): Promise => { const formData = new FormData(); formData.append('file', file); - return (await requester.post(`/events/${id}/documents`, formData, options)).data; + const response = await requester.post(`/events/${id}/documents`, formData, options); + return DocumentSchema.parse(response.data); }; export default { @@ -291,15 +398,21 @@ export default { setConfirmed, archive, unarchive, - updateReturnInventory, - finishReturnInventory, updateDepartureInventory, finishDepartureInventory, + cancelDepartureInventory, + updateReturnInventory, + finishReturnInventory, + cancelReturnInventory, createInvoice, createEstimate, - create, duplicate, + create, update, + getTechnicianAssignment, + addTechnicianAssignment, + updateTechnicianAssignment, + deleteTechnicianAssignment, remove, documents, attachDocument, diff --git a/client/src/stores/api/groups.ts b/client/src/stores/api/groups.ts index cf24523b1..8b1e36b3c 100644 --- a/client/src/stores/api/groups.ts +++ b/client/src/stores/api/groups.ts @@ -1,8 +1,10 @@ import Vue from 'vue'; -// -// - Enums -// +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ export enum Group { /** Représente le groupe des administrateurs. */ @@ -15,18 +17,22 @@ export enum Group { VISITOR = 'visitor', } -// -// - Types -// +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ export type GroupDetails = { id: Group, name: string, }; -// -// - Fonctions -// +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ const all = (): GroupDetails[] => { const { translate: __ } = (Vue as any).i18n; @@ -38,9 +44,8 @@ const all = (): GroupDetails[] => { ]; }; -const one = (group: Group): GroupDetails | undefined => { - const allGroups = all(); - return allGroups.find(({ id }: GroupDetails) => id === group); -}; +const one = (group: Group): GroupDetails | undefined => ( + all().find(({ id }: GroupDetails) => id === group) +); export default { all, one }; diff --git a/client/src/stores/api/invoices.ts b/client/src/stores/api/invoices.ts index 07e32fed0..9731374c4 100644 --- a/client/src/stores/api/invoices.ts +++ b/client/src/stores/api/invoices.ts @@ -1,29 +1,28 @@ -import Decimal from 'decimal.js'; +import { z } from '@/utils/validation'; -// -// - Types -// +import type { SchemaInfer } from '@/utils/validation'; -export type RawInvoice = { - id: number, - number: string, - date: string, - url: string, - discount_rate: DecimalType, - total_without_taxes: DecimalType, - total_with_taxes: DecimalType, - currency: string, -}; +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ -export type Invoice = RawInvoice; +export const InvoiceSchema = z.strictObject({ + id: z.number(), + number: z.string(), + date: z.datetime(), + url: z.string(), + discount_rate: z.decimal(), + total_without_taxes: z.decimal(), + total_with_taxes: z.decimal(), + currency: z.string(), +}); -// -// - Normalizer -// +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ -export const normalize = (invoice: RawInvoice): Invoice => ({ - ...invoice, - discount_rate: new Decimal(invoice.discount_rate), - total_without_taxes: new Decimal(invoice.total_without_taxes), - total_with_taxes: new Decimal(invoice.total_with_taxes), -}); +export type Invoice = SchemaInfer; diff --git a/client/src/stores/api/materials.ts b/client/src/stores/api/materials.ts index f7598ea95..48df82820 100644 --- a/client/src/stores/api/materials.ts +++ b/client/src/stores/api/materials.ts @@ -1,67 +1,140 @@ import requester from '@/globals/requester'; +import { z } from '@/utils/validation'; +import { TagSchema } from './tags'; +import { DocumentSchema } from './documents'; +import { AttributeWithValueSchema } from './attributes'; +import { createBookingSummarySchema } from './bookings'; +import { withPaginationEnvelope } from './@schema'; +import type Period from '@/utils/period'; import type { ProgressCallback, AxiosRequestConfig as RequestConfig } from 'axios'; -import type { PaginatedData, SortableParams, PaginationParams } from '@/stores/api/@types'; -import type { Event } from '@/stores/api/events'; -import type { BookingSummary } from '@/stores/api/bookings'; -import type { Category } from '@/stores/api/categories'; -import type { Subcategory } from '@/stores/api/subcategories'; -import type { Park } from '@/stores/api/parks'; -import type { Tag } from '@/stores/api/tags'; -import type { Document } from '@/stores/api/documents'; +import type { PaginatedData, SortableParams, PaginationParams } from './@types'; +import type { Park } from './parks'; +import type { Category } from './categories'; +import type { Event } from './events'; +import type { SubCategory } from './subcategories'; +import type { Attribute } from './attributes'; +import type { Tag } from './tags'; +import type { Document } from './documents'; +import type { SchemaInfer } from '@/utils/validation'; +import type { ZodRawShape } from 'zod'; +import type { Simplify } from 'type-fest'; + +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ /** Représente le matériel non catégorisés. */ export const UNCATEGORIZED = 'uncategorized'; // -// - Types +// - Schemas secondaires // -export type MaterialAttribute = { - id: number, - name: string, - type: 'boolean' | 'string' | 'number' | 'date', - unit: string | null, - value: boolean | string | number | null, -}; +const MaterialBaseSchema = z.strictObject({ + id: z.number(), + name: z.string(), + reference: z.string(), + picture: z.string().nullable(), + description: z.string().nullable(), + category_id: z.number().nullable(), + sub_category_id: z.number().nullable(), + rental_price: z.decimal().nullable().optional(), + replacement_price: z.decimal().nullable(), + stock_quantity: z.number().nullable().transform( + (value: number | null): number => value ?? 0, + ), + out_of_order_quantity: z.number().nullable().transform( + (value: number | null): number => value ?? 0, + ), + park_id: z.number(), + is_hidden_on_bill: z.boolean().optional(), + is_discountable: z.boolean().optional(), + is_reservable: z.boolean(), + attributes: z.lazy(() => AttributeWithValueSchema.array()), + tags: z.lazy(() => TagSchema.array()), + note: z.string().nullable(), + created_at: z.datetime(), + updated_at: z.datetime().nullable(), +}); -export type Material = ( - { - id: number, - name: string, - description: string | null, - reference: string, - category_id: Category['id'] | null, - sub_category_id: Subcategory['id'] | null, - rental_price: number, - stock_quantity: number, - out_of_order_quantity: number | null, - replacement_price: number, - is_hidden_on_bill: boolean, - is_discountable: boolean, - is_reservable: boolean, - tags: [], - attributes: MaterialAttribute[], - created_at: string, - updated_at: string, - park_id: Park['id'], - } +const createMaterialSchemaFactory = (augmentation: T) => ( + (innerAugmentation: InnerT) => ( + MaterialBaseSchema + .extend(augmentation) + .extend(innerAugmentation) + ) ); -export type MaterialDetails = Material; +export const createMaterialSchema = createMaterialSchemaFactory({}); -export type MaterialWithAvailabilities = Material & { - available_quantity?: number, -}; +export const createMaterialDetailsSchema = createMaterialSchemaFactory({}); -export type MaterialBookingSummary = BookingSummary & { - pivot: { - quantity: number, - }, -}; +export const createMaterialWithAvailabilitySchema = createMaterialSchemaFactory( + { available_quantity: z.number() }, +); + +const MaterialBookingSummarySchema = z.lazy(() => ( + createBookingSummarySchema({ + pivot: z.strictObject({ + quantity: z.number().positive(), + }), + }) +)); + +// +// - Schemas principaux +// + +export const MaterialSchema = createMaterialSchema({}); + +export const MaterialDetailsSchema = createMaterialDetailsSchema({}); + +export const MaterialWithAvailabilitySchema = createMaterialWithAvailabilitySchema({}); + +export const MaterialPublicSchema = (() => { + const baseSchema = MaterialBaseSchema.extend({ + available_quantity: z.number(), + }); + + return baseSchema.pick({ + id: true, + name: true, + description: true, + picture: true, + available_quantity: true, + rental_price: true, + }); +})(); + +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ + +export type Material = SchemaInfer; + +export type MaterialDetails = SchemaInfer; + +export type MaterialWithAvailability = SchemaInfer; + +export type MaterialPublic = SchemaInfer; + +// +// - Secondary types. +// + +export type MaterialBookingSummary = SchemaInfer; + +// +// - Edition +// type MaterialEditAttribute = { - id: MaterialAttribute['id'], + id: Attribute['id'], value: string, }; @@ -70,9 +143,10 @@ export type MaterialEdit = { picture?: File | null, reference: string, description: string, + is_unitary: boolean, park_id: Park['id'], category_id: Category['id'], - sub_category_id: Subcategory['id'] | null, + sub_category_id: SubCategory['id'] | null, rental_price: string, stock_quantity: string, out_of_order_quantity: string, @@ -84,88 +158,104 @@ export type MaterialEdit = { attributes?: MaterialEditAttribute[], }; -type BaseFilters = { +// +// - Récupération +// + +export type BaseFilters = Nullable<{ search?: string, category?: Category['id'], - subCategory?: Subcategory['id'], -}; + subCategory?: SubCategory['id'], +}>; -export type Filters = Omit & { - quantitiesPeriod?: { start: string, end: string }, - category?: Category['id'] | typeof UNCATEGORIZED, - park?: Park['id'], - tags?: Array, -}; +export type Filters = Simplify<( + & Omit + & Nullable<{ + quantitiesPeriod?: Period, + category?: Category['id'] | typeof UNCATEGORIZED, + park?: Park['id'], + tags?: Array, + }> +)>; -type GetAllBase = Filters & SortableParams & { deleted?: boolean }; -type GetAllPaginated = GetAllBase & PaginationParams & { paginated?: true }; -type GetAllRaw = GetAllBase & { paginated: false }; +type GetAllParamsBase = Filters & SortableParams & { deleted?: boolean }; +type GetAllParamsPaginated = GetAllParamsBase & PaginationParams & { paginated?: true }; +type GetAllParamsRaw = GetAllParamsBase & { paginated: false }; -// -// - Fonctions -// +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ -async function all(params: GetAllRaw): Promise; -async function all(params: GetAllPaginated): Promise>; -async function all({ quantitiesPeriod, ...otherParams }: GetAllPaginated | GetAllRaw): Promise { - const params: Record = otherParams; - if (quantitiesPeriod !== undefined) { - const isValidPeriod = ( - typeof quantitiesPeriod === 'object' && - 'start' in quantitiesPeriod && - 'end' in quantitiesPeriod - ); - if (!isValidPeriod) { - throw new Error('Invalid quantities period.'); - } - params['quantitiesPeriod[start]'] = quantitiesPeriod.start; - params['quantitiesPeriod[end]'] = quantitiesPeriod.end; - } - return (await requester.get('/materials', { params })).data; +async function all(params: GetAllParamsRaw): Promise; +async function all(params?: GetAllParamsPaginated): Promise>; +async function all({ quantitiesPeriod, ...otherParams }: GetAllParamsPaginated | GetAllParamsRaw = {}): Promise { + const normalizedParams = { + paginated: true, + ...otherParams, + ...quantitiesPeriod?.toQueryParams('quantitiesPeriod'), + }; + + const response = await requester.get('/materials', { + params: normalizedParams, + }); + + const schema = normalizedParams.paginated + ? withPaginationEnvelope(MaterialWithAvailabilitySchema) + : MaterialWithAvailabilitySchema.array(); + + return schema.parse(response.data); } -/* eslint-enable func-style */ -const allWhileEvent = async (eventId: Event['id']): Promise => ( - (await requester.get(`/materials/while-event/${eventId}`)).data -); +const allWhileEvent = async (eventId: Event['id']): Promise => { + const response = await requester.get(`/materials/while-event/${eventId}`); + return MaterialWithAvailabilitySchema.array().parse(response.data); +}; -const one = async (id: Material['id']): Promise => ( - (await requester.get(`/materials/${id}`)).data -); +const one = async (id: Material['id']): Promise => { + const response = await requester.get(`/materials/${id}`); + return MaterialDetailsSchema.parse(response.data); +}; const create = async (data: MaterialEdit, onProgress?: ProgressCallback): Promise => { - const options = { ...(onProgress ? { onProgress } : {}) }; - return (await requester.post('/materials', data, options)).data; + const response = await requester.post('/materials', data, { + ...(onProgress ? { onProgress } : {}), + }); + return MaterialDetailsSchema.parse(response.data); }; const update = async (id: Material['id'], data: Partial, onProgress?: ProgressCallback): Promise => { - const options = { ...(onProgress ? { onProgress } : {}) }; - return (await requester.put(`/materials/${id}`, data, options)).data; + const response = await requester.put(`/materials/${id}`, data, { + ...(onProgress ? { onProgress } : {}), + }); + return MaterialDetailsSchema.parse(response.data); }; -const restore = async (id: Material['id']): Promise => ( - (await requester.put(`/materials/${id}/restore`)).data -); +const restore = async (id: Material['id']): Promise => { + const response = await requester.put(`/materials/${id}/restore`); + return MaterialDetailsSchema.parse(response.data); +}; const remove = async (id: Material['id']): Promise => { await requester.delete(`/materials/${id}`); }; -const bookings = async ( - id: Material['id'], - params?: PaginationParams, -): Promise> => { +const bookings = async (id: Material['id'], params?: PaginationParams): Promise> => { const config = { ...(params ? { params } : {}) }; - return (await requester.get(`/materials/${id}/bookings`, config)).data; + const response = await requester.get(`/materials/${id}/bookings`, config); + return withPaginationEnvelope(MaterialBookingSummarySchema).parse(response.data); }; -const documents = async (id: Material['id']): Promise => ( - (await requester.get(`/materials/${id}/documents`)).data -); +const documents = async (id: Material['id']): Promise => { + const response = await requester.get(`/materials/${id}/documents`); + return DocumentSchema.array().parse(response.data); +}; const attachDocument = async (id: Material['id'], file: File, options: RequestConfig = {}): Promise => { const formData = new FormData(); formData.append('file', file); - return (await requester.post(`/materials/${id}/documents`, formData, options)).data; + const response = await requester.post(`/materials/${id}/documents`, formData, options); + return DocumentSchema.parse(response.data); }; export default { diff --git a/client/src/stores/api/parks.ts b/client/src/stores/api/parks.ts index 0c71752d2..92db4880d 100644 --- a/client/src/stores/api/parks.ts +++ b/client/src/stores/api/parks.ts @@ -1,24 +1,56 @@ +import { z } from '@/utils/validation'; import requester from '@/globals/requester'; - -import type { PaginatedData, ListingParams } from '@/stores/api/@types'; +import { withPaginationEnvelope } from './@schema'; +import { MaterialSchema } from './materials'; + +import type { Material } from './materials'; +import type { SchemaInfer } from '@/utils/validation'; +import type { PaginatedData, ListingParams } from './@types'; + +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ + +export const ParkSchema = z.strictObject({ + id: z.number(), + name: z.string(), + street: z.string().nullable(), + postal_code: z.string().nullable(), + locality: z.string().nullable(), + country_id: z.number().nullable(), + opening_hours: z.string().nullable(), + total_items: z.number().nonnegative(), + total_stock_quantity: z.number().nonnegative(), + note: z.string().nullable(), +}); + +export const ParkSummarySchema = ParkSchema.pick({ + id: true, + name: true, +}); + +export const ParkDetailsSchema = ParkSchema.extend({ + has_ongoing_booking: z.boolean(), +}); + +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ + +export type Park = SchemaInfer; + +export type ParkSummary = SchemaInfer; + +export type ParkDetails = SchemaInfer; // -// - Types +// - Edition // -export type Park = { - id: number, - name: string, - opening_hours: string | null, - total_items: number, - total_stock_quantity: number, - street: string | null, - postal_code: string | null, - locality: string | null, - country_id: number | null, - note: string | null, -}; - export type ParkEdit = { name: string, street: string | null, @@ -29,55 +61,57 @@ export type ParkEdit = { note: string | null, }; -export type ParkDetails = Park & { - has_ongoing_inventory: boolean, - has_ongoing_booking: boolean, -}; - -export type ParkSummary = { - id: Park['id'], - name: Park['name'], -}; - -export type ParkTotalAmountResult = { - id: Park['id'], - totalAmount: number, -}; +// +// - Récupération +// type GetAllParams = ListingParams & { deleted?: boolean }; -// -// - Fonctions -// +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ -const all = async (params: GetAllParams): Promise> => ( - (await requester.get('/parks', { params })).data -); +const all = async (params: GetAllParams = {}): Promise> => { + const response = (await requester.get('/parks', { params })); + return withPaginationEnvelope(ParkSchema).parse(response.data); +}; -const list = async (): Promise => ( - (await requester.get('/parks/list')).data -); +const list = async (): Promise => { + const response = await requester.get('/parks/list'); + return ParkSummarySchema.array().parse(response.data); +}; -const one = async (id: Park['id']): Promise => ( - (await requester.get(`/parks/${id}`)).data -); +const one = async (id: Park['id']): Promise => { + const response = await requester.get(`/parks/${id}`); + return ParkDetailsSchema.parse(response.data); +}; + +const oneTotalAmount = async (id: Park['id']): Promise => { + const response = await requester.get(`/parks/${id}/total-amount`); + return z.number().nonnegative().parse(response.data); +}; -const totalAmount = async (id: Park['id']): Promise => { - const { data } = (await requester.get(`/parks/${id}/total-amount`)); - return data.totalAmount; +const materials = async (id: Park['id']): Promise => { + const response = await requester.get(`/parks/${id}/materials`); + return MaterialSchema.array().parse(response.data); }; -const create = async (data: ParkEdit): Promise => ( - (await requester.post('/parks', data)).data -); +const create = async (data: ParkEdit): Promise => { + const response = await requester.post('/parks', data); + return ParkDetailsSchema.parse(response.data); +}; -const update = async (id: Park['id'], data: ParkEdit): Promise => ( - (await requester.put(`/parks/${id}`, data)).data -); +const update = async (id: Park['id'], data: ParkEdit): Promise => { + const response = await requester.put(`/parks/${id}`, data); + return ParkDetailsSchema.parse(response.data); +}; -const restore = async (id: Park['id']): Promise => ( - (await requester.put(`/parks/restore/${id}`)).data -); +const restore = async (id: Park['id']): Promise => { + const response = await requester.put(`/parks/restore/${id}`); + return ParkDetailsSchema.parse(response.data); +}; const remove = async (id: Park['id']): Promise => { await requester.delete(`/parks/${id}`); @@ -87,9 +121,10 @@ export default { all, list, one, + oneTotalAmount, + materials, create, update, - totalAmount, restore, remove, }; diff --git a/client/src/stores/api/persons.ts b/client/src/stores/api/persons.ts index d83a7fb49..d0b10e47f 100644 --- a/client/src/stores/api/persons.ts +++ b/client/src/stores/api/persons.ts @@ -1,34 +1,51 @@ +import { z } from '@/utils/validation'; +import { withPaginationEnvelope } from './@schema'; +import { CountrySchema } from './countries'; import requester from '@/globals/requester'; -import type { Country } from '@/stores/api/countries'; +import type { SchemaInfer } from '@/utils/validation'; import type { PaginatedData, ListingParams } from './@types'; -// -// - Types -// - -export type Person = { - id: number, - first_name: string, - last_name: string, - full_name: string, - email: string | null, - phone: string | null, - street: string | null, - postal_code: string | null, - locality: string | null, - country_id: number | null, - country: Country | null, - full_address: string | null, - user_id: number | null, -}; +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ + +export const PersonSchema = z.strictObject({ + id: z.number(), + user_id: z.number().nullable(), + first_name: z.string(), + last_name: z.string(), + full_name: z.string(), + // TODO [zod@>3.22.4]: Remettre `email()`. + email: z.string().nullable(), + phone: z.string().nullable(), + street: z.string().nullable(), + postal_code: z.string().nullable(), + locality: z.string().nullable(), + country_id: z.number().nullable(), + country: z.lazy(() => CountrySchema).nullable(), + full_address: z.string().nullable(), +}); + +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ -// -// - Fonctions -// +export type Person = SchemaInfer; -const all = async (params: ListingParams): Promise> => ( - (await requester.get('/persons', { params })).data -); +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ + +const all = async (params: ListingParams = {}): Promise> => { + const response = await requester.get('/persons', { params }); + return withPaginationEnvelope(PersonSchema).parse(response.data); +}; export default { all }; diff --git a/client/src/stores/api/session.ts b/client/src/stores/api/session.ts index 6c37b7244..617ee2701 100644 --- a/client/src/stores/api/session.ts +++ b/client/src/stores/api/session.ts @@ -1,10 +1,14 @@ +import { z } from '@/utils/validation'; import requester from '@/globals/requester'; +import { UserDetailsSchema, UserSettingsSchema } from './users'; -import type { UserDetails } from '@/stores/api/users'; +import type { SchemaInfer } from '@/utils/validation'; -// -// - Constants -// +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ /** Contextes de l'application (Où se trouve l'utilisateur ?). */ export enum AppContext { @@ -15,17 +19,25 @@ export enum AppContext { INTERNAL = 'internal', } -// -// - Types -// +const SessionSchema = UserDetailsSchema.merge(UserSettingsSchema); -export type Session = UserDetails & { - language: string, -}; +const NewSessionSchema = SessionSchema.extend({ + token: z.string(), +}); -type NewSession = Session & { - token: string, -}; +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ + +export type Session = SchemaInfer; + +type NewSession = SchemaInfer; + +// +// - Edition +// export type Credentials = { identifier: string, @@ -33,16 +45,20 @@ export type Credentials = { context?: AppContext, }; -// -// - Fonctions -// +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ -const get = async (): Promise => ( - (await requester.get('/session')).data -); +const get = async (): Promise => { + const response = await requester.get('/session'); + return SessionSchema.parse(response.data); +}; -const create = async (credentials: Credentials): Promise => ( - (await requester.post('/session', credentials)).data -); +const create = async (credentials: Credentials): Promise => { + const response = await requester.post('/session', credentials); + return NewSessionSchema.parse(response.data); +}; export default { get, create }; diff --git a/client/src/stores/api/settings.ts b/client/src/stores/api/settings.ts index cf023d93b..9039e1b15 100644 --- a/client/src/stores/api/settings.ts +++ b/client/src/stores/api/settings.ts @@ -1,10 +1,14 @@ import requester from '@/globals/requester'; +import { z } from '@/utils/validation'; -import type { Merge } from 'type-fest'; +import type { OmitDeep, PartialDeep } from 'type-fest'; +import type { SchemaInfer } from '@/utils/validation'; -// -// - Types -// +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ export enum MaterialDisplayMode { CATEGORIES = 'categories', @@ -18,51 +22,95 @@ export enum ReturnInventoryMode { START_FULL = 'start-full', } -export type Settings = { - eventSummary: { - customText: { - title: string | null, - content: string | null, - }, - materialDisplayMode: MaterialDisplayMode, - showLegalNumbers: boolean, - }, - calendar: { - event: { - showLocation: boolean, - showBorrower: boolean, - }, - public: ( - | { enabled: true, url: string } - | { enabled: false } - ), - }, - returnInventory: { - mode: ReturnInventoryMode, - }, -}; +export enum PublicCalendarPeriodDisplay { + /** Les périodes d'opération uniquement sont affichées. */ + OPERATION = 'operation', + + /** Les périodes de mobilisation uniquement sont affichées. */ + MOBILIZATION = 'mobilization', + + /** Les périodes de mobilisation et d'opération sont affichées. */ + BOTH = 'both', +} + +const OpeningDaySchema = z.strictObject({ + weekday: z.number().int().min(0).max(6), + start_time: z.string().regex(/^(?:0[0-9]|1[0-9]|2[0-4]):[0-5][0-9]:[0-5][0-9]$/), + end_time: z.string().regex(/^(?:0[0-9]|1[0-9]|2[0-4]):[0-5][0-9]:[0-5][0-9]$/), +}); + +const SettingsSchema = z.strictObject({ + general: z.strictObject({ + openingHours: OpeningDaySchema.array(), + }), + eventSummary: z.strictObject({ + customText: z.strictObject({ + title: z.string().nullable(), + content: z.string().nullable(), + }), + materialDisplayMode: z.nativeEnum(MaterialDisplayMode), + showLegalNumbers: z.boolean(), + showReplacementPrices: z.boolean(), + showDescriptions: z.boolean(), + showTags: z.boolean(), + showPictures: z.boolean(), + }), + calendar: z.strictObject({ + event: z.strictObject({ + showLocation: z.boolean(), + showBorrower: z.boolean(), + }), + public: z.discriminatedUnion('enabled', [ + z.strictObject({ + enabled: z.literal(true), + url: z.string().nullable().optional(), + displayedPeriod: z.nativeEnum(PublicCalendarPeriodDisplay), + }), + z.strictObject({ + enabled: z.literal(false), + }), + ]), + }), + returnInventory: z.strictObject({ + mode: z.nativeEnum(ReturnInventoryMode), + }), +}); -type SettingsEdit = Partial - & { public: { enabled: boolean } } - ), -}>>; +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ + +export type OpeningDay = SchemaInfer; + +export type Settings = SchemaInfer; // -// - Fonctions +// - Edition // -const all = async (): Promise => ( - (await requester.get('/settings')).data -); +export type SettingsEdit = PartialDeep>; -const update = async (data: SettingsEdit): Promise => ( - (await requester.put('/settings', data)).data -); +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ + +const all = async (): Promise => { + const response = await requester.get('/settings'); + return SettingsSchema.parse(response.data); +}; -const reset = async (key: string): Promise => ( - (await requester.delete(`/settings/${key}`)).data -); +const update = async (data: SettingsEdit): Promise => { + const response = await requester.put('/settings', data); + return SettingsSchema.parse(response.data); +}; + +const reset = async (key: string): Promise => { + const response = await requester.delete(`/settings/${key}`); + return SettingsSchema.parse(response.data); +}; export default { all, update, reset }; diff --git a/client/src/stores/api/subcategories.ts b/client/src/stores/api/subcategories.ts index 6b7bf9709..5e8c77156 100644 --- a/client/src/stores/api/subcategories.ts +++ b/client/src/stores/api/subcategories.ts @@ -1,38 +1,57 @@ +import { z } from '@/utils/validation'; import requester from '@/globals/requester'; -import type { Category } from '@/stores/api/categories'; +import type { Category } from './categories'; +import type { SchemaInfer } from '@/utils/validation'; + +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ + +export const SubCategorySchema = z.object({ + id: z.number(), + name: z.string(), + category_id: z.number(), +}); + +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ + +export type SubCategory = SchemaInfer; // -// - Types +// - Edition // -export type Subcategory = { - id: number, +export type SubCategoryCreate = { name: string, category_id: Category['id'], }; -export type SubcategoryEdit = { - name: string, -}; - -export type SubcategoryCreate = SubcategoryEdit & { - categoryId: Category['id'], -}; +export type SubCategoryEdit = Omit; -// -// - Fonctions -// +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ -const create = async ({ name, categoryId }: SubcategoryCreate): Promise => ( - (await requester.post('/subcategories', { name, category_id: categoryId })).data -); +const create = async (data: SubCategoryCreate): Promise => { + const response = await requester.post('/subcategories', data); + return SubCategorySchema.parse(response.data); +}; -const update = async (id: Subcategory['id'], data: SubcategoryEdit): Promise => ( - (await requester.put(`/subcategories/${id}`, data)).data -); +const update = async (id: SubCategory['id'], data: SubCategoryEdit): Promise => { + const response = await requester.put(`/subcategories/${id}`, data); + return SubCategorySchema.parse(response.data); +}; -const remove = async (id: Subcategory['id']): Promise => { +const remove = async (id: SubCategory['id']): Promise => { await requester.delete(`/subcategories/${id}`); }; diff --git a/client/src/stores/api/tags.ts b/client/src/stores/api/tags.ts index 76608757b..fa15d9748 100644 --- a/client/src/stores/api/tags.ts +++ b/client/src/stores/api/tags.ts @@ -1,39 +1,66 @@ +import { z } from '@/utils/validation'; import requester from '@/globals/requester'; +import type { SchemaInfer } from '@/utils/validation'; + +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ + +export const TagSchema = z.strictObject({ + id: z.number(), + name: z.string(), +}); + +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ + +export type Tag = SchemaInfer; + // -// - Types +// - Edition // -export type Tag = { - id: number, - name: string, -}; - export type TagEdit = { name: string, }; -type GetAllParams = { deleted?: boolean }; - // -// - Fonctions +// - Récupération // -const all = async (params: GetAllParams): Promise => ( - (await requester.get('/tags', { params })).data -); +type GetAllParams = { deleted?: boolean }; + +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ + +const all = async (params: GetAllParams = {}): Promise => { + const response = await requester.get('/tags', { params }); + return TagSchema.array().parse(response.data); +}; -const create = async (data: TagEdit): Promise => ( - (await requester.post('/tags', data)).data -); +const create = async (data: TagEdit): Promise => { + const response = await requester.post('/tags', data); + return TagSchema.parse(response.data); +}; -const update = async (id: Tag['id'], data: TagEdit): Promise => ( - (await requester.put(`/tags/${id}`, data)).data -); +const update = async (id: Tag['id'], data: TagEdit): Promise => { + const response = await requester.put(`/tags/${id}`, data); + return TagSchema.parse(response.data); +}; -const restore = async (id: Tag['id']): Promise => ( - (await requester.put(`/tags/restore/${id}`)).data -); +const restore = async (id: Tag['id']): Promise => { + const response = await requester.put(`/tags/restore/${id}`); + return TagSchema.parse(response.data); +}; const remove = async (id: Tag['id']): Promise => { await requester.delete(`/tags/${id}`); diff --git a/client/src/stores/api/technicians.ts b/client/src/stores/api/technicians.ts index 3bb4d832b..094b1c908 100644 --- a/client/src/stores/api/technicians.ts +++ b/client/src/stores/api/technicians.ts @@ -1,32 +1,77 @@ +import { z } from '@/utils/validation'; import requester from '@/globals/requester'; +import { CountrySchema } from './countries'; +import { DocumentSchema } from './documents'; +import { EventSchema } from './events'; +import { withPaginationEnvelope } from './@schema'; -import type { AxiosRequestConfig as RequestConfig } from 'axios'; -import type { PaginatedData, ListingParams } from '@/stores/api/@types'; -import type { Country } from '@/stores/api/countries'; -import type { Document } from '@/stores/api/documents'; +import type Period from '@/utils/period'; import type { Event } from './events'; +import type { SchemaInfer } from '@/utils/validation'; +import type { AxiosRequestConfig as RequestConfig } from 'axios'; +import type { PaginatedData, SortableParams, PaginationParams } from './@types'; +import type { Document } from './documents'; + +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ // -// - Types +// - Schemas secondaires // -export type Technician = { - id: number, - first_name: string, - full_name: string, - last_name: string, - nickname: string | null, - email: string | null, - phone: string | null, - full_address: string | null, - street: string | null, - postal_code: string | null, - locality: string | null, - country_id: number | null, - country: Country | null, - note: string | null, - user_id: number | null, // => user.id, etc. -}; +export const TechnicianEventSchema = z.strictObject({ + id: z.number(), + event_id: z.number(), + technician_id: z.number(), + period: z.period(), // FIXME + position: z.string().nullable(), + event: z.lazy(() => EventSchema), +}); + +// +// - Schemas principaux +// + +export const TechnicianSchema = z.strictObject({ + id: z.number(), + user_id: z.number().nullable(), + first_name: z.string(), + last_name: z.string(), + full_name: z.string(), + nickname: z.string().nullable(), + // TODO [zod@>3.22.4]: Remettre `email()`. + email: z.string().nullable(), + phone: z.string().nullable(), + street: z.string().nullable(), + postal_code: z.string().nullable(), + locality: z.string().nullable(), + country_id: z.number().nullable(), + country: z.lazy(() => CountrySchema).nullable(), + full_address: z.string().nullable(), + note: z.string().nullable(), +}); + +export const TechnicianWithEventsSchema = TechnicianSchema.extend({ + events: TechnicianEventSchema.array(), +}); + +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ + +export type Technician = SchemaInfer; + +export type TechnicianEvent = SchemaInfer; +export type TechnicianWithEvents = SchemaInfer; + +// +// - Edition +// export type TechnicianEdit = { first_name: string, @@ -41,63 +86,79 @@ export type TechnicianEdit = { note: string | null, }; -type GetAllParams = ListingParams & { - availabilityPeriod?: { start: string, end: string }, - deleted?: boolean, -}; - // -// - Fonctions +// - Récupération // -const all = async ({ availabilityPeriod, ...otherParams }: GetAllParams): Promise> => { - const params: Record = otherParams; - if (availabilityPeriod !== undefined) { - const isValidPeriod = ( - typeof availabilityPeriod === 'object' && - 'start' in availabilityPeriod && - 'end' in availabilityPeriod - ); - if (!isValidPeriod) { - throw new Error('Invalid quantities period.'); - } - params['availabilityPeriod[start]'] = availabilityPeriod.start; - params['availabilityPeriod[end]'] = availabilityPeriod.end; - } - return (await requester.get('/technicians', { params })).data; +export type Filters = { + search?: string, + availabilityPeriod?: Period, }; -const allWhileEvent = async (eventId: Event['id']): Promise => ( - (await requester.get(`/technicians/while-event/${eventId}`)).data +type GetAllParams = ( + & Filters + & SortableParams + & PaginationParams + & { deleted?: boolean } ); -const one = async (id: Technician['id']): Promise => ( - (await requester.get(`/technicians/${id}`)).data -); +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ + +const all = async ({ availabilityPeriod, ...otherParams }: GetAllParams = {}): Promise> => { + const params: Record = Object.assign(otherParams, { + ...availabilityPeriod?.toQueryParams('availabilityPeriod'), + }); + const response = await requester.get('/technicians', { params }); + return withPaginationEnvelope(TechnicianSchema).parse(response.data); +}; -const create = async (data: TechnicianEdit): Promise => ( - (await requester.post('/technicians', data)).data -); +const allWhileEvent = async (eventId: Event['id']): Promise => { + const response = await requester.get(`/technicians/while-event/${eventId}`); + return TechnicianWithEventsSchema.array().parse(response.data); +}; -const update = async (id: Technician['id'], data: TechnicianEdit): Promise => ( - (await requester.put(`/technicians/${id}`, data)).data -); +const one = async (id: Technician['id']): Promise => { + const response = await requester.get(`/technicians/${id}`); + return TechnicianSchema.parse(response.data); +}; -const restore = async (id: Technician['id']): Promise => ( - (await requester.put(`/technicians/restore/${id}`)).data -); +const create = async (data: TechnicianEdit): Promise => { + const response = await requester.post('/technicians', data); + return TechnicianSchema.parse(response.data); +}; + +const update = async (id: Technician['id'], data: TechnicianEdit): Promise => { + const response = await requester.put(`/technicians/${id}`, data); + return TechnicianSchema.parse(response.data); +}; + +const restore = async (id: Technician['id']): Promise => { + const response = await requester.put(`/technicians/restore/${id}`); + return TechnicianSchema.parse(response.data); +}; const remove = async (id: Technician['id']): Promise => { await requester.delete(`/technicians/${id}`); }; -const documents = async (id: Technician['id']): Promise => ( - (await requester.get(`/technicians/${id}/documents`)).data -); +const assignments = async (id: Technician['id']): Promise => { + const response = await requester.get(`/technicians/${id}/events`); + return TechnicianEventSchema.array().parse(response.data); +}; + +const documents = async (id: Technician['id']): Promise => { + const response = await requester.get(`/technicians/${id}/documents`); + return DocumentSchema.array().parse(response.data); +}; const attachDocument = async (id: Technician['id'], file: File, options: RequestConfig = {}): Promise => { const formData = new FormData(); formData.append('file', file); - return (await requester.post(`/technicians/${id}/documents`, formData, options)).data; + const response = await requester.post(`/technicians/${id}/documents`, formData, options); + return DocumentSchema.parse(response.data); }; export default { @@ -108,6 +169,7 @@ export default { update, remove, restore, + assignments, documents, attachDocument, }; diff --git a/client/src/stores/api/users.ts b/client/src/stores/api/users.ts index 656313aac..b5c72623a 100644 --- a/client/src/stores/api/users.ts +++ b/client/src/stores/api/users.ts @@ -1,30 +1,88 @@ +import { z } from '@/utils/validation'; import requester from '@/globals/requester'; +import { Group } from './groups'; +import { withPaginationEnvelope } from './@schema'; + +import type { PaginatedData, ListingParams } from './@types'; +import type { SchemaInfer } from '@/utils/validation'; + +// ------------------------------------------------------ +// - +// - Schema / Enums +// - +// ------------------------------------------------------ + +/** + * Modes d'affichage des événements. + * + * NOTE IMPORTANTE: + * En cas de modif., pensez à aussi mettre à jour les constantes du modèle back-end. + * {@see {@link /server/src/App/Models/User.php}} + */ +export enum BookingsViewMode { + /** Vue en calendrier (timeline) */ + CALENDAR = 'calendar', + + /** Vue en liste. */ + LISTING = 'listing', +} -import type { PaginatedData, ListingParams } from '@/stores/api/@types'; -import type { Group } from '@/stores/api/groups'; +export const UserSchema = z.strictObject({ + id: z.number(), + pseudo: z.string(), + first_name: z.string().nullable().transform( + // NOTE: Le `?` ci-dessous n'est pas idéal et doit être évité dans la mesure + // du possible, mais étant donné qu'il est possible que la `person` lié + // ait été supprimée, on préfère utiliser `?` en fallback plutôt que de + // planter le retour. Ceci n'est pas censé arriver mais le cas doit être + // géré, au cas où. + (value: string | null) => value ?? '?', + ), + last_name: z.string().nullable().transform( + // NOTE: Le `?` ci-dessous n'est pas idéal et doit être évité dans la mesure + // du possible, mais étant donné qu'il est possible que la `person` lié + // ait été supprimée, on préfère utiliser `?` en fallback plutôt que de + // planter le retour. Ceci n'est pas censé arriver mais le cas doit être + // géré, au cas où. + (value: string | null) => value ?? '?', + ), + full_name: z.string().nullable().transform( + // NOTE: Le `?` ci-dessous n'est pas idéal et doit être évité dans la mesure + // du possible, mais étant donné qu'il est possible que la `person` lié + // ait été supprimée, on préfère utiliser `?` en fallback plutôt que de + // planter le retour. Ceci n'est pas censé arriver mais le cas doit être + // géré, au cas où. + (value: string | null) => value ?? '?', + ), + phone: z.string().nullable(), + // TODO [zod@>3.22.4]: Remettre `email()`. + email: z.string(), + group: z.nativeEnum(Group), +}); + +export const UserDetailsSchema = UserSchema; + +export const UserSettingsSchema = z.strictObject({ + language: z.string(), + default_bookings_view: z.nativeEnum(BookingsViewMode), +}); + +// ------------------------------------------------------ +// - +// - Types +// - +// ------------------------------------------------------ + +export type UserSettings = SchemaInfer; + +export type User = SchemaInfer; + +export type UserDetails = SchemaInfer; // -// - Types +// - Edition // -type UserSettings = { - language: string, - notifications_enabled: boolean, -}; - -export type User = UserSettings & { - id: number, - group: Group, - pseudo: string, - email: string, - first_name: string, - last_name: string, - full_name: string, - phone: string | null, -}; - -export type UserDetails = User; - export type UserEdit = { first_name: string | null, last_name: string | null, @@ -35,49 +93,63 @@ export type UserEdit = { group: Group, }; -type UserSettingsEdit = Partial; +export type UserEditSelf = Omit; -type UserEditSelf = Omit; +export type UserSettingsEdit = Partial; + +// +// - Récupération +// type GetAllParams = ListingParams & { deleted?: boolean, group?: Group, }; -// -// - Fonctions -// +// ------------------------------------------------------ +// - +// - Fonctions +// - +// ------------------------------------------------------ -const all = async (params: GetAllParams): Promise> => ( - (await requester.get('/users', { params })).data -); +const all = async (params: GetAllParams = {}): Promise> => { + const response = await requester.get('/users', { params }); + return withPaginationEnvelope(UserSchema).parse(response.data); +}; -const one = async (id: User['id'] | 'self'): Promise => ( - (await requester.get(`/users/${id}`)).data -); +const one = async (id: User['id'] | 'self'): Promise => { + const response = await requester.get(`/users/${id}`); + return UserDetailsSchema.parse(response.data); +}; -const create = async (data: UserEdit): Promise => ( - (await requester.post('/users', data)).data -); +const create = async (data: UserEdit): Promise => { + const response = await requester.post('/users', data); + return UserDetailsSchema.parse(response.data); +}; async function update(id: 'self', data: UserEditSelf): Promise; async function update(id: User['id'], data: UserEdit): Promise; async function update(id: User['id'] | 'self', data: UserEdit | UserEditSelf): Promise { - return (await requester.put(`/users/${id}`, data)).data; + const response = await requester.put(`/users/${id}`, data); + return UserDetailsSchema.parse(response.data); } -/* eslint-enable func-style */ -const getSettings = async (id: User['id']): Promise => ( - (await requester.get(`/users/${id}/settings`)).data -); +const getSettings = async (id: User['id'] | 'self'): Promise => { + const response = await requester.get(`/users/${id}/settings`); + return UserSettingsSchema.parse(response.data); +}; -const saveSettings = async (id: User['id'], data: UserSettingsEdit): Promise => ( - (await requester.put(`/users/${id}/settings`, data)).data -); +const updateSettings = async (id: User['id'] | 'self', data: UserSettingsEdit): Promise => { + const response = await requester.put(`/users/${id}/settings`, data); + return UserSettingsSchema.parse(response.data); +}; -const restore = async (id: User['id']): Promise => ( - (await requester.put(`/users/restore/${id}`)).data -); +const restore = async (id: User['id']): Promise => { + const response = await requester.put(`/users/restore/${id}`); + return UserDetailsSchema.parse(response.data); +}; const remove = async (id: User['id']): Promise => { await requester.delete(`/users/${id}`); @@ -89,7 +161,7 @@ export default { create, update, getSettings, - saveSettings, + updateSettings, restore, remove, }; diff --git a/client/src/stores/auth.ts b/client/src/stores/auth.ts index 21ae5e71f..60a5c708e 100644 --- a/client/src/stores/auth.ts +++ b/client/src/stores/auth.ts @@ -7,6 +7,7 @@ import apiSession from '@/stores/api/session'; import type { Module, ActionContext } from 'vuex'; import type { Session, Credentials } from '@/stores/api/session'; import type { Group } from '@/stores/api/groups'; +import type { UserSettings } from '@/stores/api/users'; export type State = { user: Session | null, @@ -41,6 +42,8 @@ const store: Module = { const normalizedGroups = Array.isArray(groups) ? groups : [groups]; return normalizedGroups.includes(state.user.group); }, + + user: (state: State) => state.user, }, mutations: { setUser(state: State, user: Session) { @@ -54,6 +57,10 @@ const store: Module = { setLocale(state: State, language: string) { state.user!.language = language; }, + + setInterfaceSettings(state: State, settings: UserSettings) { + state.user!.default_bookings_view = settings.default_bookings_view; + }, }, actions: { async fetch({ dispatch, commit }: ActionContext) { @@ -86,13 +93,9 @@ const store: Module = { }, async logout(_: ActionContext, full: boolean = true) { - const hasPotentiallyStatefulSession = !!( - config.auth.isCASEnabled || - config.auth.isSAML2Enabled - ); const theme = ''; - if (hasPotentiallyStatefulSession && full) { + if (full) { window.location.assign(`${config.baseUrl}${theme}/logout`); } else { cookies.remove(config.auth.cookie); diff --git a/client/src/stores/settings.ts b/client/src/stores/settings.ts index e471412d3..aa071a53b 100644 --- a/client/src/stores/settings.ts +++ b/client/src/stores/settings.ts @@ -1,11 +1,17 @@ +import Day from '@/utils/day'; +import Period from '@/utils/period'; +import DateTime from '@/utils/datetime'; import apiSettings, { MaterialDisplayMode, ReturnInventoryMode } from '@/stores/api/settings'; import type { Module, ActionContext } from 'vuex'; -import type { Settings } from '@/stores/api/settings'; +import type { OpeningDay, Settings } from '@/stores/api/settings'; export type State = Settings; const getDefaults = (): Settings => ({ + general: { + openingHours: [], + }, eventSummary: { customText: { title: null, @@ -13,6 +19,10 @@ const getDefaults = (): Settings => ({ }, materialDisplayMode: MaterialDisplayMode.SUB_CATEGORIES, showLegalNumbers: true, + showReplacementPrices: true, + showDescriptions: false, + showTags: false, + showPictures: false, }, calendar: { event: { @@ -31,6 +41,33 @@ const getDefaults = (): Settings => ({ const store: Module = { namespaced: true, state: getDefaults(), + getters: { + isOpen: (state: State) => (date: Day | DateTime) => ( + state.general.openingHours.some((openingDay: OpeningDay) => { + // - Si la date comparée est à `00:00:00`, on regarde si on a pas une heure + // de fermeture pour le jour précédent à `24:00:00`. + const shouldCheckYesterdayMidnight = ( + date instanceof DateTime && + date.isStartOfDay() && + openingDay.weekday === date.subDay().get('day') + ); + if (shouldCheckYesterdayMidnight && /^24:00(?::00(?:\.000)?)?$/.test(openingDay.end_time)) { + return true; + } + + const isOpenDay = openingDay.weekday === date.get('day'); + if (!isOpenDay || date instanceof Day) { + return isOpenDay; + } + + const openingPeriod = new Period( + date.setTime(openingDay.start_time), + date.setTime(openingDay.end_time), + ); + return date.isBetween(openingPeriod, '[]'); + }) + ), + }, mutations: { reset(state: State) { Object.assign(state, getDefaults()); @@ -40,12 +77,22 @@ const store: Module = { }, }, actions: { - reset({ commit }: ActionContext) { - commit('reset'); + async boot({ dispatch }: ActionContext) { + await dispatch('fetch'); + + const refresh = async (): Promise => { + await dispatch('fetch'); + }; + setInterval(refresh, 30_000); // - 30 secondes. }, + async fetch({ commit }: ActionContext) { commit('set', await apiSettings.all()); }, + + reset({ commit }: ActionContext) { + commit('reset'); + }, }, }; diff --git a/client/src/themes/default/components/Alert/_variables.scss b/client/src/themes/default/components/Alert/_variables.scss new file mode 100644 index 000000000..12fb7c9c0 --- /dev/null +++ b/client/src/themes/default/components/Alert/_variables.scss @@ -0,0 +1,15 @@ +// +// - Variantes +// + +$warning-variant: ( + icon: 'exclamation-triangle', + background: #644a2b, + color: #fff, +) !default; + +$info-variant: ( + icon: 'info', + background: #2d3348, + color: #fff, +) !default; diff --git a/client/src/themes/default/components/Alert/index.scss b/client/src/themes/default/components/Alert/index.scss new file mode 100644 index 000000000..6b2b40ac8 --- /dev/null +++ b/client/src/themes/default/components/Alert/index.scss @@ -0,0 +1,39 @@ +@use './variables' as *; +@use '~@/themes/default/style/globals'; +@use 'sass:map'; + +.Alert { + display: flex; + align-items: center; + padding: globals.$spacing-medium globals.$spacing-medium; + border-radius: globals.$border-radius-large; + white-space: pre-line; + gap: globals.$spacing-medium; + + &::before { + opacity: 0.8; + } + + // + // - Variantes + // + + // stylelint-disable-next-line scss/dollar-variable-first-in-block, order/order + $variants: ( + warning: $warning-variant, + info: $info-variant, + ); + + @each $name, $variant in $variants { + &--#{$name} { + background: map.get($variant, background); + color: map.get($variant, color); + + @include globals.icon(map.get($variant, icon)) { + min-width: 20px; + font-size: 1.5rem; + text-align: center; + } + } + } +} diff --git a/client/src/themes/default/components/Alert/index.tsx b/client/src/themes/default/components/Alert/index.tsx new file mode 100644 index 000000000..fd4730a60 --- /dev/null +++ b/client/src/themes/default/components/Alert/index.tsx @@ -0,0 +1,44 @@ +import './index.scss'; +import { defineComponent } from '@vue/composition-api'; + +import type { PropType } from '@vue/composition-api'; + +enum Type { + /** Une alerte d'avertissement. */ + WARNING = 'warning', + + /** Une alerte d'information. */ + INFO = 'info', +} + +type Props = { + /** Le type (= variante) de l'alerte. */ + type: Type, +}; + +/** Une alerte. */ +const Alert = defineComponent({ + name: 'Alert', + props: { + type: { + type: String as PropType, + required: true, + validator: (value: unknown) => ( + typeof value === 'string' && + (Object.values(Type) as string[]).includes(value) + ), + }, + }, + render() { + const { type } = this; + const children = this.$slots.default; + + return ( +
+ {children} +
+ ); + }, +}); + +export default Alert; diff --git a/client/src/themes/default/components/App/index.js b/client/src/themes/default/components/App/index.js index a256ad269..adf37dbf0 100644 --- a/client/src/themes/default/components/App/index.js +++ b/client/src/themes/default/components/App/index.js @@ -59,7 +59,7 @@ const App = { }, render() { const { layout } = this; - invariant(layout in layouts, `Le layout "${layout}" n'existe pas.`); + invariant(layout in layouts, `The \`${layout}\` layout doesn't exist.`); const Layout = layouts[layout]; return ( diff --git a/client/src/themes/default/components/Button/_variables.scss b/client/src/themes/default/components/Button/_variables.scss index 19a9b875d..e4c41394d 100644 --- a/client/src/themes/default/components/Button/_variables.scss +++ b/client/src/themes/default/components/Button/_variables.scss @@ -4,11 +4,11 @@ /// Padding vertical des boutons. /// @type Number -$padding-y: 0.45rem !default; +$padding-y: 7px !default; /// Padding horizontal des boutons. /// @type Number -$padding-x: 0.626rem !default; +$padding-x: 10px !default; /// Marge entre les éléments internes du bouton (texte, icône(s)). /// @type Number @@ -83,13 +83,13 @@ $primary-variant: ( // - Focused focused-color: #fff, - focused-background: color.adjust(globals.$primary-color, $saturation: 23.3%, $lightness: 5.5%), - focused-border-color: color.adjust(globals.$primary-color, $saturation: 23.3%, $lightness: 5.5%), + focused-background: color.adjust(globals.$primary-color, $lightness: 5.5%), + focused-border-color: color.adjust(globals.$primary-color, $lightness: 5.5%), // - Active active-color: #fff, - active-background: color.adjust(globals.$primary-color, $saturation: 23.8%, $lightness: -4.5%), - active-border-color: color.adjust(globals.$primary-color, $saturation: 23.8%, $lightness: -4.5%), + active-background: color.adjust(globals.$primary-color, $lightness: -4.5%), + active-border-color: color.adjust(globals.$primary-color, $lightness: -4.5%), ) !default; // @@ -98,19 +98,19 @@ $primary-variant: ( // stylelint-disable-next-line value-list-max-empty-lines $secondary-variant: ( - color: #fff, - background: color.adjust(globals.$primary-color, $saturation: -25%, $lightness: -22%), - border-color: globals.$primary-color, + color: color.adjust(globals.$primary-color, $saturation: -30%, $lightness: 60%), + background: color.adjust(globals.$primary-color, $alpha: -0.7), + border-color: transparent, // - Focused - focused-color: map.get($primary-variant, focused-color), - focused-background: map.get($primary-variant, focused-background), - focused-border-color: map.get($primary-variant, focused-border-color), + focused-color: color.adjust(globals.$primary-color, $saturation: -30%, $lightness: 65%), + focused-background: color.adjust(globals.$primary-color, $alpha: -0.7, $lightness: 10%), + focused-border-color: transparent, // - Active - active-color: map.get($primary-variant, active-color), - active-background: map.get($primary-variant, active-background), - active-border-color: map.get($primary-variant, active-border-color), + active-color: color.adjust(globals.$primary-color, $saturation: -30%, $lightness: 55%), + active-background: color.adjust(globals.$primary-color, $alpha: -0.7, $lightness: -6%), + active-border-color: transparent, ) !default; // diff --git a/client/src/themes/default/components/Button/index.scss b/client/src/themes/default/components/Button/index.scss index 8a98e94e9..90c74cc94 100644 --- a/client/src/themes/default/components/Button/index.scss +++ b/client/src/themes/default/components/Button/index.scss @@ -12,7 +12,7 @@ padding: $padding-y $padding-x; border: $border-width solid; border-radius: $border-radius; - font-size: 1rem; + font-size: 1.05rem; line-height: 1; text-decoration: none; white-space: nowrap; @@ -21,50 +21,50 @@ transition: all 300ms; gap: $internal-spacing; - & + & { - margin-left: globals.$spacing-small; - } - &:hover, &:focus { outline: 0; } + &::before, + &__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.1em; + height: 1.1em; + font-size: 1.05em; + line-height: 1.15; + text-align: center; + vertical-align: -0.05rem; + } + // - // - Disabled + // - Modifiers // - &--disabled { - cursor: not-allowed; - opacity: 0.5; + &--collapsible { + &#{$block}--with-icon { + #{$block}__content { + display: none; + } + } } // - // - Loading + // - État // + &--disabled { + cursor: not-allowed; + opacity: 0.5; + } + &--loading { cursor: wait; opacity: 0.75; } - // - // - Icône - // - - &::before, - &__icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 1.1em; - height: 1.1em; - font-size: 1.1em; - line-height: 1.15; - text-align: center; - vertical-align: -0.05rem; - } - // // - Tailles // @@ -127,4 +127,18 @@ } } } + + // + // - Responsive + // + + @media (min-width: globals.$screen-tablet) { + &--collapsible { + &#{$block}--with-icon { + #{$block}__content { + display: block; + } + } + } + } } diff --git a/client/src/themes/default/components/Button/index.tsx b/client/src/themes/default/components/Button/index.tsx index db0e830ac..5dd16adb1 100644 --- a/client/src/themes/default/components/Button/index.tsx +++ b/client/src/themes/default/components/Button/index.tsx @@ -1,12 +1,12 @@ import './index.scss'; import { defineComponent } from '@vue/composition-api'; -import Icon, { VARIANTS as ICON_VARIANTS } from '@/themes/default/components/Icon'; +import Icon, { Variant as IconVariant } from '@/themes/default/components/Icon'; import Fragment from '@/components/Fragment'; import type { Location } from 'vue-router'; import type { TooltipOptions } from 'v-tooltip'; import type { PropType } from '@vue/composition-api'; -import type { Props as IconProps, Variant } from '@/themes/default/components/Icon'; +import type { Props as IconProps } from '@/themes/default/components/Icon'; export const TYPES = [ 'default', 'success', 'warning', 'danger', @@ -21,7 +21,7 @@ const PREDEFINED_TYPES = { icon: 'plus', }, edit: { - type: 'success', + type: 'default', icon: 'edit', }, trash: { @@ -45,7 +45,7 @@ const PREDEFINED_TYPES = { export type PredefinedType = keyof typeof PREDEFINED_TYPES; export type Type = (typeof TYPES)[number]; -type IconName = string | `${string}:${Variant}`; +type IconName = string | `${string}:${IconVariant}`; type IconPosition = 'before' | 'after'; type IconOptions = { name: IconName, position?: IconPosition }; export type IconLoose = IconName | IconOptions; @@ -153,6 +153,17 @@ type Props = { */ loading?: boolean, + /** + * Le bouton peut-il être affiché de manière minimaliste + * (= uniquement l'icône) pour les petits écrans ? + * + * Quand cette prop. vaut `true`, pour les écrans plus petit que le format + * tablette, si le bouton comporte une icône, seule celle-ci sera affichée. + * + * @default false + */ + collapsible?: boolean, + /** * Le bouton est-il désactivé ? * @@ -212,6 +223,10 @@ const Button = defineComponent({ type: Boolean as PropType['external']>, default: false, }, + collapsible: { + type: Boolean as PropType['collapsible']>, + default: false, + }, disabled: { type: Boolean as PropType['disabled']>, default: false, @@ -250,8 +265,8 @@ const Button = defineComponent({ } const [iconType, variant] = icon.split(':'); - return ICON_VARIANTS.includes(variant) - ? { name: iconType, variant: variant as Variant } + return Object.values(IconVariant).includes(variant as any) + ? { name: iconType, variant: variant as IconVariant } : { name: iconType }; }, @@ -288,6 +303,7 @@ const Button = defineComponent({ size, loading, disabled, + collapsible, external, htmlType, iconPosition, @@ -302,6 +318,8 @@ const Button = defineComponent({ `Button--${type}`, `Button--${size}`, { + 'Button--collapsible': collapsible, + 'Button--with-icon': icon !== undefined, 'Button--disabled': disabled || loading, 'Button--loading': loading, }, diff --git a/client/src/themes/default/components/ButtonDropdown/index.scss b/client/src/themes/default/components/ButtonDropdown/index.scss index 4d17568db..3ea5f83f6 100644 --- a/client/src/themes/default/components/ButtonDropdown/index.scss +++ b/client/src/themes/default/components/ButtonDropdown/index.scss @@ -29,10 +29,6 @@ border-bottom-left-radius: 0; } - &__action-button { - width: 100%; - } - // // - Menu // @@ -42,9 +38,12 @@ z-index: 1; top: 100%; right: 0; + display: flex; + flex-direction: column; margin: 2px 0 0; padding: 0; - background: globals.$bg-color-dropdown; + background: globals.$dropdown-background-color; + gap: 2px; box-shadow: 0 4px 9px #1b1b1b; transform-origin: 50% 0%; transform: scaleY(0); @@ -53,13 +52,26 @@ &__item { flex: 0 0 auto; + display: flex; margin: 0; white-space: nowrap; list-style: none; + } + } - & + & { - margin-top: 2px; - } + &__action-button { + flex: 1; + + &--primary { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + &--secondary { + flex: 0 0 auto; + border-left: 1px solid globals.$divider-color; + border-top-left-radius: 0; + border-bottom-left-radius: 0; } } diff --git a/client/src/themes/default/components/ButtonDropdown/index.tsx b/client/src/themes/default/components/ButtonDropdown/index.tsx index adf04350e..31c94a549 100644 --- a/client/src/themes/default/components/ButtonDropdown/index.tsx +++ b/client/src/themes/default/components/ButtonDropdown/index.tsx @@ -53,6 +53,12 @@ type Action = { * N'est utile que quand l'action secondaire n'est pas un lien. */ onClick?(e: MouseEvent): void, + + /** + * Action supplémentaire éventuelle, qui sera affichée sur la même ligne + * que l'action secondaire, à droite. + */ + secondary?: Action, }; type Props = { @@ -232,10 +238,25 @@ const ButtonDropdown = defineComponent({ external={action.external} onClick={action.onClick ?? (() => {})} disabled={disabled} - class="ButtonDropdown__action-button" + class={[ + 'ButtonDropdown__action-button', + { 'ButtonDropdown__action-button--primary': !!action.secondary }, + ]} > {action.label} + {!!action.secondary && ( + ); } diff --git a/client/src/themes/default/components/CriticalError/index.scss b/client/src/themes/default/components/CriticalError/index.scss index 458a849a4..f79ec64d4 100644 --- a/client/src/themes/default/components/CriticalError/index.scss +++ b/client/src/themes/default/components/CriticalError/index.scss @@ -6,7 +6,7 @@ &__illustration { width: 80%; - max-width: 25em; + max-width: 25rem; fill: currentColor; opacity: 0.2; } @@ -16,7 +16,7 @@ white-space: pre-line; } - &__back-to-calendar, + &__back-to-home, &__refresh { padding: 0.5rem 1rem; font-size: 1.1rem; @@ -31,14 +31,14 @@ } @media (min-width: globals.$screen-desktop) { - font-size: 1.5rem; + font-size: 1.3rem; &__message { margin: globals.$spacing-large 0 globals.$spacing-medium; white-space: pre-line; } - &__back-to-calendar, + &__back-to-home, &__refresh { padding: 0.75rem 1.5rem; font-size: 1.2rem; diff --git a/client/src/themes/default/components/DatePicker/__tests__/utils/normalizer.spec.ts b/client/src/themes/default/components/DatePicker/__tests__/utils/normalizer.spec.ts new file mode 100644 index 000000000..b94e2c554 --- /dev/null +++ b/client/src/themes/default/components/DatePicker/__tests__/utils/normalizer.spec.ts @@ -0,0 +1,341 @@ +import Day from '@/utils/day'; +import Period from '@/utils/period'; +import DateTime from '@/utils/datetime'; +import { Type } from '../../_types'; +import { + normalizeInputValue, + normalizeCoreValue, + convertValueType, +} from '../../utils/normalizer'; + +describe('DatePicker Utils: Normalizer', () => { + describe('normalizeInputValue()', () => { + it('should throw when an invalid value is passed for the Datepicker constraints', () => { + const period = new Period('2024-01-01 14:30:00', '2024-01-01 18:00:00'); + const fullDayPeriod = new Period('2024-01-01', '2025-01-01', true); + const dateTime = new DateTime('2024-01-01 14:30:00'); + const day = new Day('2024-01-01'); + + // + // - Type: `date` / Mode: Valeur seule. + // + + // -- ... avec une `Period`. + expect(() => normalizeInputValue(period, Type.DATE, false, false)).toThrow(); + expect(() => normalizeInputValue(fullDayPeriod, Type.DATE, false, false)).toThrow(); + + // -- ... avec une instance de `DateTime`. + expect(() => normalizeInputValue(dateTime, Type.DATE, false, false)).toThrow(); + + // -- ... avec une instance de `Day` => Pas d'erreur. + expect(() => normalizeInputValue(day, Type.DATE, false, false)).not.toThrow(); + + // + // - Type: `date` / Mode: Période. + // + + // -- ... avec une instance de `Day`. + expect(() => normalizeInputValue(day, Type.DATE, true, false)).toThrow(); + + // -- ... avec une instance de `DateTime`. + expect(() => normalizeInputValue(dateTime, Type.DATE, true, false)).toThrow(); + + // -- ... avec une `Period` à l'heure près. + expect(() => normalizeInputValue(period, Type.DATE, true, false)).toThrow(); + + // -- ... avec une `Period` en jours entiers => Pas d'erreur. + expect(() => normalizeInputValue(fullDayPeriod, Type.DATE, true, false)).not.toThrow(); + + // + // - Type: `datetime` / Mode: Valeur seule. + // + + // -- ... avec une `Period`. + expect(() => normalizeInputValue(period, Type.DATETIME, false, false)).toThrow(); + expect(() => normalizeInputValue(fullDayPeriod, Type.DATETIME, false, false)).toThrow(); + + // -- ... avec une instance de `Day`. + expect(() => normalizeInputValue(day, Type.DATETIME, false, false)).toThrow(); + + // -- ... avec une instance de `DateTime` => Pas d'erreur. + expect(() => normalizeInputValue(dateTime, Type.DATETIME, false, false)).not.toThrow(); + + // + // - Type: `datetime` / Mode: Période. + // + + // -- ... avec une instance de `Day`. + expect(() => normalizeInputValue(day, Type.DATETIME, true, false)).toThrow(); + + // -- ... avec une instance de `DateTime`. + expect(() => normalizeInputValue(dateTime, Type.DATETIME, true, false)).toThrow(); + + // -- ... avec une `Period` en jours entiers. + expect(() => normalizeInputValue(fullDayPeriod, Type.DATETIME, true, false)).toThrow(); + + // -- ... avec une `Period` à l'heure près => Pas d'erreur. + expect(() => normalizeInputValue(period, Type.DATETIME, true, false)).not.toThrow(); + }); + + it('should return the right value', () => { + // - Valeur nulle. + expect(normalizeInputValue(null, Type.DATE, false, false)).toBeNull(); + expect(normalizeInputValue(null, Type.DATE, true, false)).toBeNull(); + expect(normalizeInputValue(null, Type.DATETIME, false, false)).toBeNull(); + expect(normalizeInputValue(null, Type.DATETIME, true, false)).toBeNull(); + + // + // - Type: `date` / Mode: Valeur seule. + // + + const day = new Day('2024-01-01'); + + // -- ... Avec les minutes. + const result1 = normalizeInputValue(day, Type.DATE, false, false); + expect(result1).toBeInstanceOf(Day); + expect(result1!.toString()).toBe('2024-01-01'); + + // -- ... Sans les minutes. + const result2 = normalizeInputValue(day, Type.DATE, false, true); + expect(result2).toBeInstanceOf(Day); + expect(result2!.toString()).toBe('2024-01-01'); + + // + // - Type: `date` / Mode: Période. + // + + const fullDayPeriod = new Period('2024-01-01', '2025-01-01', true); + + // -- ... Avec les minutes. + const result3 = normalizeInputValue(fullDayPeriod, Type.DATE, true, false); + expect(result3).toBeInstanceOf(Period); + expect(result3!.toSerialized()).toStrictEqual({ + start: '2024-01-01', + end: '2025-01-01', + isFullDays: true, + }); + + // -- ... Sans les minutes. + const result4 = normalizeInputValue(fullDayPeriod, Type.DATE, true, true); + expect(result4).toBeInstanceOf(Period); + expect(result4!.toSerialized()).toStrictEqual({ + start: '2024-01-01', + end: '2025-01-01', + isFullDays: true, + }); + + // + // - Type: `datetime` / Mode: Valeur seule. + // + + const dateTime = new DateTime('2024-01-01 14:38:24'); + + // -- ... Avec les minutes. + const result5 = normalizeInputValue(dateTime, Type.DATETIME, false, false); + expect(result5).toBeInstanceOf(DateTime); + expect(result5!.toString()).toBe('2024-01-01 14:45:00'); + + // -- ... Sans les minutes. + const result6 = normalizeInputValue(dateTime, Type.DATETIME, false, true); + expect(result6).toBeInstanceOf(DateTime); + expect(result6!.toString()).toBe('2024-01-01 15:00:00'); + + // + // - Type: `datetime` / Mode: Période. + // + + const period = new Period('2024-01-01 14:38:24', '2024-01-01 14:38:24'); + + // -- ... Avec les minutes. + const result7 = normalizeInputValue(period, Type.DATETIME, true, false); + expect(result7).toBeInstanceOf(Period); + expect(result7!.toSerialized()).toStrictEqual({ + start: '2024-01-01 14:30:00', + end: '2024-01-01 14:45:00', + isFullDays: false, + }); + + // -- ... Sans les minutes. + const result8 = normalizeInputValue(period, Type.DATETIME, true, true); + expect(result8).toBeInstanceOf(Period); + expect(result8!.toSerialized()).toStrictEqual({ + start: '2024-01-01 14:00:00', + end: '2024-01-01 15:00:00', + isFullDays: false, + }); + }); + }); + + describe('normalizeCoreValue()', () => { + // - Valeur nulle. + [Type.DATE, Type.DATETIME].forEach((dateMode: Type) => { + expect(normalizeCoreValue(null, dateMode, false)).toBeNull(); + expect(normalizeCoreValue(null, dateMode, true)).toBeNull(); + expect(normalizeCoreValue([] as any, dateMode, true)).toBeNull(); + expect(normalizeCoreValue([null, null], dateMode, true)).toBeNull(); + expect(normalizeCoreValue( + ['2024-01-01', '2024-01-01', '2024-01-01'] as any, + dateMode, + true, + )).toBeNull(); + }); + + // + // - Type: `date` / Mode: Valeur seule. + // + + // -- Avec une valeur simple ... + const result1 = normalizeCoreValue('2024-01-01', Type.DATE, false); + expect(result1).toBeInstanceOf(Day); + expect(result1!.toString()).toBe('2024-01-01'); + + // -- Avec un tableau ... + const result2 = normalizeCoreValue(['2024-01-01', '2024-01-02'], Type.DATE, false); + expect(result2).toBeInstanceOf(Day); + expect(result2!.toString()).toBe('2024-01-01'); + + // + // - Type: `date` / Mode: Période. + // + + // -- Avec un tableau ... + const result3 = normalizeCoreValue(['2024-01-01', '2025-01-01'], Type.DATE, true); + expect(result3).toBeInstanceOf(Period); + expect(result3!.toSerialized()).toStrictEqual({ + start: '2024-01-01', + end: '2025-01-01', + isFullDays: true, + }); + + // -- Avec une valeur simple ... + const result4 = normalizeCoreValue('2024-01-01', Type.DATE, true); + expect(result4).toBeInstanceOf(Period); + expect(result4!.toSerialized()).toStrictEqual({ + start: '2024-01-01', + end: '2024-01-01', + isFullDays: true, + }); + + // + // - Type: `datetime` / Mode: Valeur seule. + // + + // -- Avec une valeur simple ... + const result5 = normalizeCoreValue('2024-01-01 14:30:00', Type.DATETIME, false); + expect(result5).toBeInstanceOf(DateTime); + expect(result5!.toString()).toBe('2024-01-01 14:30:00'); + + // -- Avec un tableau ... + const result6 = normalizeCoreValue(['2024-01-01 14:30:00', '2024-02-12 15:15:00'], Type.DATETIME, false); + expect(result6).toBeInstanceOf(DateTime); + expect(result6!.toString()).toBe('2024-01-01 14:30:00'); + + // + // - Type: `datetime` / Mode: Période. + // + + // -- Avec un tableau ... + const result7 = normalizeCoreValue(['2024-01-01 14:30:00', '2024-02-12 15:15:00'], Type.DATETIME, true); + expect(result7).toBeInstanceOf(Period); + expect(result7!.toSerialized()).toStrictEqual({ + start: '2024-01-01 14:30:00', + end: '2024-02-12 15:15:00', + isFullDays: false, + }); + + // -- Avec une valeur simple ... + const result8 = normalizeCoreValue('2024-01-01 14:30:00', Type.DATETIME, true); + expect(result8).toBeInstanceOf(Period); + expect(result8!.toSerialized()).toStrictEqual({ + start: '2024-01-01 14:30:00', + end: '2024-01-01 14:30:00', + isFullDays: false, + }); + }); + + describe('convertValueType()', () => { + // - Valeur nulle. + expect(convertValueType(null, Type.DATE, false, false)).toBeNull(); + expect(convertValueType(null, Type.DATETIME, false, false)).toBeNull(); + + // + // - Type: `date` / Mode: Valeur seule. + // + + const dateTime = new DateTime('2024-01-01 14:38:24'); + + // -- ... Avec les minutes. + const result1 = convertValueType(dateTime, Type.DATE, false, false); + expect(result1).toBeInstanceOf(Day); + expect(result1!.toString()).toBe('2024-01-01'); + + // -- ... Sans les minutes. + const result2 = convertValueType(dateTime, Type.DATE, false, true); + expect(result2).toBeInstanceOf(Day); + expect(result2!.toString()).toBe('2024-01-01'); + + // + // - Type: `date` / Mode: Période. + // + + const period = new Period('2024-01-01 14:38:24', '2024-01-01 14:38:24'); + + // -- ... Avec les minutes. + const result3 = convertValueType(period, Type.DATE, true, false); + expect(result3).toBeInstanceOf(Period); + expect(result3!.toSerialized()).toStrictEqual({ + start: '2024-01-01', + end: '2024-01-01', + isFullDays: true, + }); + + // -- ... Sans les minutes. + const result4 = convertValueType(period, Type.DATE, true, true); + expect(result4).toBeInstanceOf(Period); + expect(result4!.toSerialized()).toStrictEqual({ + start: '2024-01-01', + end: '2024-01-01', + isFullDays: true, + }); + + // + // - Type: `datetime` / Mode: Valeur seule. + // + + const day = new Day('2024-01-01'); + + // -- ... Avec les minutes. + const result5 = convertValueType(day, Type.DATETIME, false, false); + expect(result5).toBeInstanceOf(DateTime); + expect(result5!.toString()).toBe('2024-01-01 12:00:00'); + + // -- ... Sans les minutes. + const result6 = convertValueType(day, Type.DATETIME, false, true); + expect(result6).toBeInstanceOf(DateTime); + expect(result6!.toString()).toBe('2024-01-01 12:00:00'); + + // + // - Type: `datetime` / Mode: Période. + // + + const fullDayPeriod = new Period('2024-01-01', '2025-01-01', true); + + // -- ... Avec les minutes. + const result7 = convertValueType(fullDayPeriod, Type.DATETIME, true, false); + expect(result7).toBeInstanceOf(Period); + expect(result7!.toSerialized()).toStrictEqual({ + start: '2024-01-01 12:00:00', + end: '2025-01-01 12:00:00', + isFullDays: false, + }); + + // -- ... Sans les minutes. + const result8 = convertValueType(fullDayPeriod, Type.DATETIME, true, true); + expect(result8).toBeInstanceOf(Period); + expect(result8!.toSerialized()).toStrictEqual({ + start: '2024-01-01 12:00:00', + end: '2025-01-01 12:00:00', + isFullDays: false, + }); + }); +}); diff --git a/client/src/themes/default/components/DatePicker/_types.ts b/client/src/themes/default/components/DatePicker/_types.ts new file mode 100644 index 000000000..99e5a1012 --- /dev/null +++ b/client/src/themes/default/components/DatePicker/_types.ts @@ -0,0 +1,59 @@ +import type Period from '@/utils/period'; +import type DateTime from '@/utils/datetime'; +import type Day from '@/utils/day'; + +export enum Type { + /** Sélection de ou des dates uniquement (jour, mois, année). */ + DATE = 'date', + + /** Sélection de date et heures. */ + DATETIME = 'datetime', +} + +export type DisableDateFunction = (date: DateTime, granularity: 'day' | 'minute') => boolean; + +// +// - Valeur +// + +/** Représente une valeur du datepicker. */ +export type Value = ( + | ( + R extends true + ? Period + : T extends Type.DATETIME + ? DateTime + : Day + ) + | null +); + +/** Représente une valeur dans le format du "core" pour une date seule. */ +export type CoreDateValue = string | null; + +/** Représente une valeur dans le format du "core" du datepicker. */ +export type CoreValue = ( + | [start: CoreDateValue, end: CoreDateValue] + | CoreDateValue +); + +// +// - Snippets +// + +export type RawDateSnippet = { + labelKey: string, + period(today: Day): Day, +}; + +export type RawRangeSnippet = { + labelKey: string, + period(today: Day): Period, +}; + +export type Snippet = { + label: string, + periodLabel: string, + isActive: boolean, + period: Date | [Date, Date], +}; diff --git a/client/src/themes/default/components/Datepicker/index.scss b/client/src/themes/default/components/DatePicker/index.scss similarity index 73% rename from client/src/themes/default/components/Datepicker/index.scss rename to client/src/themes/default/components/DatePicker/index.scss index 3d11e15ac..7321b541b 100644 --- a/client/src/themes/default/components/Datepicker/index.scss +++ b/client/src/themes/default/components/DatePicker/index.scss @@ -1,7 +1,7 @@ @use 'sass:color'; @use '~@/themes/default/style/globals'; -.Datepicker { +.DatePicker { display: inline-block; width: 100%; @@ -61,22 +61,27 @@ @extend %reset-dd; color: globals.$text-soft-color; + font-weight: 400; white-space: nowrap; } &:hover { - background: #a84825; + background: rgba(globals.$primary-color, 0.5); + + #{$sub-block}__value { + color: color.adjust(globals.$primary-color, $lightness: 30%, $saturation: -80%); + } } &--active { &, &:hover { - background: rgba(250, 99, 23, 0.2); - color: #fb6418; + background: rgba(globals.$primary-color, 0.2); + color: color.adjust(globals.$primary-color, $lightness: 45%); font-weight: 500; #{$sub-block}__value { - color: color.adjust(#fb6418, $alpha: -0.4); + color: color.adjust(globals.$primary-color, $lightness: 45%, $alpha: -0.4); } } } @@ -89,6 +94,8 @@ &--invalid { .mx-input:not(:focus) { border-color: globals.$input-error-border-color; + background-color: globals.$input-error-background-color; + color: globals.$input-error-text-color; } } } diff --git a/client/src/themes/default/components/Datepicker/index.tsx b/client/src/themes/default/components/DatePicker/index.tsx similarity index 68% rename from client/src/themes/default/components/Datepicker/index.tsx rename to client/src/themes/default/components/DatePicker/index.tsx index d3be9cc0d..f7d912e84 100644 --- a/client/src/themes/default/components/Datepicker/index.tsx +++ b/client/src/themes/default/components/DatePicker/index.tsx @@ -1,21 +1,24 @@ import './index.scss'; -import moment from 'moment'; +import Day from '@/utils/day'; +import warning from 'warning'; +import Period from '@/utils/period'; +import DateTime from '@/utils/datetime'; import { defineComponent } from '@vue/composition-api'; -import CoreDatepicker from 'vue2-datepicker'; +import CoreDatePicker from 'vue2-datepicker'; import Fragment from '@/components/Fragment'; import Switch from '@/themes/default/components/SwitchToggle'; import Button from '@/themes/default/components/Button'; import frPickerTranslations from 'vue2-datepicker/locale/es/fr'; import enPickerTranslations from 'vue2-datepicker/locale/es/en'; +import { RANGE_SNIPPETS, DATE_SNIPPETS } from './utils/snippets'; import { Type } from './_types'; import { - normalizeValue, - RANGE_SNIPPETS, - DATE_SNIPPETS, + normalizeInputValue, + normalizeCoreValue, + convertValueType, MINUTES_STEP, -} from './_utils'; +} from './utils/normalizer'; -import type { Moment, MomentInput } from 'moment'; import type { PropType } from '@vue/composition-api'; import type { Formatter, @@ -25,20 +28,15 @@ import type { DatePickerSlotParams, } from 'vue2-datepicker'; import type { - LooseValue, - LooseDateValue, Value, + CoreValue, Snippet, RawDateSnippet, RawRangeSnippet, + DisableDateFunction, } from './_types'; -const PICKER_TRANSLATIONS: Record = { - fr: frPickerTranslations, - en: enPickerTranslations, -}; - -type Props = { +type Props = { /** * Le nom du champ (attribut `[name]`). * @@ -53,11 +51,9 @@ type Props = { * - `date`: Sélection de date sans heure. * - `datetime`: Sélection de date et heure. * - * Attention, la prop. `canT` - * * @default Type.DATE */ - type?: Type, + type?: T, /** * Mode "période". @@ -72,18 +68,18 @@ type Props = { /** * Active la permutation des "Jours entiers". * - * Ce mode est uniquement compatible avec les types `date` et `datetime`. + * Ce mode est uniquement utilisable avec les types `date` et `datetime`. * * - Si `true`, l'utilisateur pourra choisir d'activer ou non le mode "Jour(s) entier(s)". * - Si `false`, le mode "Jour(s) entier(s)" ne sera pas proposé. * * Si cette option est activée, il faudra veiller à observer l'événement `onChange` et notamment * son deuxième paramètre (qui ne sera passé que quand cette option est activée) qui contiendra - * un booléen `isFullDays`. S'il est à `true`, il faudra veiller à changer le type en `DATE` et + * un booléen `isFullDays`. S'il est à `true`, il faudra veiller à changer le `type` en `DATE` et * dans le cas contraire en `DATETIME`. * * À noter aussi que si cette option est activée et que la prop. `name` est spécifiée, un champ - * hidden `is_full_days` sera utilisé pour stocker la valeur courante du switch. + * hidden `isFullDays` sera utilisé pour stocker la valeur courante du switch. * * @default false */ @@ -103,10 +99,10 @@ type Props = { withoutMinutes?: boolean, /** Date minimum sélectionnable dans le sélecteur. */ - minDate?: 'now' | Moment | Date | string | number, + minDate?: 'now' | DateTime | Day, /** Date maximum sélectionnable dans le sélecteur. */ - maxDate?: 'now' | Moment | Date | string | number, + maxDate?: 'now' | DateTime | Day, /** * Une éventuelle fonction permettant de désactiver certaines dates. @@ -114,10 +110,10 @@ type Props = { * - Si la fonction renvoie `true`, la date ne sera pas sélectionnable. * - Si elle renvoie `false`, elle le sera. */ - disabledDate?(date: Moment, granularity: 'day' | 'minute'): boolean, + disabledDate?: DisableDateFunction, /** La valeur actuelle du champ. */ - value: LooseValue, + value?: Value, /** * L'éventuel texte affiché en filigrane dans le @@ -128,8 +124,22 @@ type Props = { /** Le champ est-il désactivé ? */ disabled?: boolean, + /** + * Le champ est-il en lecture seule ? + * + * Cette prop. peut recevoir les valeurs suivantes: + * - Un booléen qui aura un effet similaire à la prop. `disabled`. + * - Les chaînes `start` ou `end`, uniquement utilisables quand le sélecteur + * de date est en mode `range`. Ceci aura pour effet de mettre en lecture + * seule seulement la partie indiqué tout en laissant l'autre modifiable. + */ + readonly?: boolean | 'start' | 'end', + /** Le champ doit-il être marqué comme invalide ? */ invalid?: boolean, + + /** Le champ peut-il être vidé ? */ + clearable?: boolean, }; type InstanceProperties = { @@ -138,21 +148,26 @@ type InstanceProperties = { type Data = { showTimePanel: boolean, - now: number, + now: DateTime, }; const FORMATTER: Formatter = { stringify: (date: Date | null | undefined, format: string): string => ( - date ? moment(date).format(format) : '' + date ? new DateTime(date).format(format) : '' ), - parse: (value: LooseDateValue): Date | null => ( - value ? moment(value).toDate() : null + parse: (value: string | null | undefined): Date | null => ( + value ? new DateTime(value).toDate() : null ), }; +const PICKER_TRANSLATIONS: Record = { + fr: frPickerTranslations, + en: enPickerTranslations, +}; + /** Un sélecteur de date(s), heure(s) et période. */ -const Datepicker = defineComponent({ - name: 'Datepicker', +const DatePicker = defineComponent({ + name: 'DatePicker', inject: { 'input.invalid': { default: { value: false } }, 'input.disabled': { default: { value: false } }, @@ -173,22 +188,8 @@ const Datepicker = defineComponent({ }, }, value: { - type: [Array, String] as PropType, + type: [Period, DateTime, Day] as PropType['value']>, default: null, - validator: (value: unknown) => { - const isValidDateString = (date: unknown): boolean => ( - [undefined, null].includes(date as any) || - (typeof date === 'string' && moment(date).isValid()) - ); - - if (Array.isArray(value)) { - return !value.some((date: unknown) => ( - !isValidDateString(date) - )); - } - - return isValidDateString(value); - }, }, range: { type: Boolean as PropType['range']>, @@ -199,11 +200,11 @@ const Datepicker = defineComponent({ default: undefined, }, minDate: { - type: [String, Object, Date, Number] as PropType, + type: [String, DateTime, Day] as PropType, default: undefined, }, maxDate: { - type: [String, Object, Date, Number] as PropType, + type: [String, DateTime, Day] as PropType, default: undefined, }, withFullDaysToggle: { @@ -222,32 +223,38 @@ const Datepicker = defineComponent({ type: Boolean as PropType, default: undefined, }, + readonly: { + type: [Boolean, String] as PropType['readonly']>, + default: false, + }, invalid: { type: Boolean as PropType, default: undefined, }, + clearable: { + type: Boolean as PropType, + default: false, + }, placeholder: { type: String as PropType, default: undefined, }, }, + emits: ['input', 'change'], setup: (): InstanceProperties => ({ nowTimer: undefined, }), - data(): Data { - return { - showTimePanel: false, - now: Date.now(), - }; - }, - emit: ['input', 'change'], + data: (): Data => ({ + showTimePanel: false, + now: DateTime.now(), + }), computed: { isFullDays(): boolean { return this.type === Type.DATE; }, normalizedValue(): Value { - return normalizeValue( + return normalizeInputValue( this.value, this.type, this.range, @@ -255,6 +262,22 @@ const Datepicker = defineComponent({ ); }, + coreValue(): CoreValue { + const value = this.normalizedValue; + if (value === null) { + return this.range ? [null, null] : null; + } + + if (this.range) { + return [ + (value as Period).start.toString(), + (value as Period).end.toString(), + ]; + } + + return (value as DateTime | Day).toString(); + }, + inheritedInvalid(): boolean { if (this.invalid !== undefined) { return this.invalid; @@ -275,12 +298,11 @@ const Datepicker = defineComponent({ return this['input.disabled'].value; }, - displayFormat(): string { - const dateFormat = this.range ? 'll' : 'LL'; - - return this.type === Type.DATETIME - ? `${dateFormat} HH:mm` - : dateFormat; + normalizedReadonly(): boolean | 'start' | 'end' { + if (typeof this.readonly === 'boolean') { + return this.readonly; + } + return this.range ? this.readonly : true; }, timePickerOptions(): TimePickerOptions { @@ -303,14 +325,22 @@ const Datepicker = defineComponent({ }; }, - outputFormat(): string { + displayFormat(): string { + const dateFormat = this.range ? 'll' : 'LL'; + + return this.type === Type.DATETIME + ? `${dateFormat} HH:mm` + : dateFormat; + }, + + valueFormat(): string { return this.type === Type.DATETIME ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD'; }, dateFormat(): string { - const currentLocaleData = moment.localeData(); + const currentLocaleData = DateTime.localeData(); return currentLocaleData.longDateFormat('LL'); }, @@ -319,7 +349,7 @@ const Datepicker = defineComponent({ return PICKER_TRANSLATIONS[locale] ?? undefined; }, - disabledDateFactory(): ((granularity: 'day' | 'minute') => (rawDate: MomentInput) => boolean) { + disabledDateFactory(): ((granularity: 'day' | 'minute') => (rawDate: Date) => boolean) { const { now, disabledDate, @@ -330,25 +360,28 @@ const Datepicker = defineComponent({ const withHours = type === Type.DATETIME; const withMinutes = withHours && !this.withoutMinutes; - return (granularity: 'day' | 'minute') => (rawDate: MomentInput): boolean => { - const date = moment(rawDate); + return (granularity: 'day' | 'minute') => (rawDate: Date): boolean => { + const date = new DateTime(rawDate); if (disabledDate !== undefined && disabledDate(date, granularity)) { return true; } if (rawMinDate !== undefined) { - const minDate: Moment = (() => { + const minDate: DateTime = (() => { if (rawMinDate === 'now') { if (withHours) { if (withMinutes) { - return moment(now).startOf('minute'); + return now.startOfMinute(); } - return moment(now).startOf('hour'); + return now.startOfHour(); } - return moment(now).startOf('day'); + return now.startOfDay(); } - return moment(rawMinDate); + + return rawMinDate instanceof Day + ? rawMinDate.toDateTime().startOfDay() + : rawMinDate; })(); if (date.isBefore(minDate, granularity)) { return true; @@ -356,22 +389,26 @@ const Datepicker = defineComponent({ } if (rawMaxDate !== undefined) { - const maxDate: Moment = (() => { + const maxDate: DateTime = (() => { if (rawMaxDate === 'now') { if (withHours) { if (withMinutes) { - return moment(now).endOf('minute'); + return now.endOfMinute(true); } - return moment(now).endOf('hour'); + return now.endOfHour(true); } - return moment(now).endOf('day'); + return now.endOfDay(true); } - return moment(rawMaxDate); + + return rawMaxDate instanceof Day + ? rawMaxDate.toDateTime().endOfDay(true) + : rawMaxDate; })(); if (date.isAfter(maxDate, granularity)) { return true; } } + return false; }; }, @@ -386,25 +423,21 @@ const Datepicker = defineComponent({ return DATE_SNIPPETS.map((snippetGroup: RawDateSnippet[]): Snippet[] => ( snippetGroup.map((snippet: RawDateSnippet): Snippet => { const { labelKey, period: periodFunc } = snippet; - const currentValue = this.normalizedValue as Value; - const period = periodFunc(moment(now)); + const currentDate = this.normalizedValue as Value; + const snippetDay = periodFunc(new Day(now)); return { label: __(labelKey), - periodLabel: period.format('ll'), + periodLabel: snippetDay.format('ll'), isActive: ( - currentValue !== null - ? period.isSame(currentValue, 'day') + currentDate !== null + ? snippetDay.isSame(currentDate, 'day') : false ), period: ( this.type !== Type.DATETIME - ? period.clone().startOf('day').toDate() - : ( - period.clone() - .set({ hour: 12, minute: 0, second: 0 }) - .toDate() - ) + ? snippetDay.toDateTime().toDate() + : snippetDay.toDateTime().set('hour', 12).toDate() ), }; }) @@ -414,19 +447,19 @@ const Datepicker = defineComponent({ return RANGE_SNIPPETS.map((snippetGroup: RawRangeSnippet[]): Snippet[] => ( snippetGroup.map((snippet: RawRangeSnippet): Snippet => { const { labelKey, period: periodFunc } = snippet; - const [currentStart, currentEnd] = this.normalizedValue as Value; - const [periodStart, periodEnd] = periodFunc(moment(now)); + const currentPeriod = this.normalizedValue as Value; + const snippetPeriod = periodFunc(new Day(now)); let periodLabelParts: [string, string]; - if (periodStart.isSame(periodEnd, 'year')) { + if (snippetPeriod.start.isSame(snippetPeriod.end, 'year')) { periodLabelParts = [ - periodStart.format(__('range-format.same-year.start')), - periodEnd.format(__('range-format.same-year.end')), + snippetPeriod.start.format(__('range-format.same-year.start')), + snippetPeriod.end.format(__('range-format.same-year.end')), ]; } else { periodLabelParts = [ - periodStart.format(__('range-format.full.start')), - periodEnd.format(__('range-format.full.end')), + snippetPeriod.start.format(__('range-format.full.start')), + snippetPeriod.end.format(__('range-format.full.end')), ]; } @@ -434,24 +467,20 @@ const Datepicker = defineComponent({ label: __(labelKey), periodLabel: periodLabelParts.join(' - '), isActive: ((): boolean => { - if (currentStart === null || currentEnd === null) { + if (currentPeriod === null) { return false; } return ( - periodStart.isSame(currentStart, 'day') && - periodEnd.isSame(currentEnd, 'day') + snippetPeriod.start.isSame(currentPeriod.start, 'day') && + snippetPeriod.end.isSame(currentPeriod.end, 'day') ); })(), period: ( - [periodStart, periodEnd].map((date: Moment) => ( + [snippetPeriod.start, snippetPeriod.end].map((day: Day) => ( this.type !== Type.DATETIME - ? date.clone().startOf('day').toDate() - : ( - date.clone() - .set({ hour: 12, minute: 0, second: 0 }) - .toDate() - ) + ? day.toDateTime().toDate() + : day.toDateTime().set('hour', 12).toDate() )) as [Date, Date] ), }; @@ -459,9 +488,15 @@ const Datepicker = defineComponent({ )); }, }, + created() { + warning( + typeof this.readonly === 'boolean' || this.range, + 'The prop `readonly` should be passed as boolean when used with a non-range ``.', + ); + }, mounted() { // - Actualise le timestamp courant toutes les 10 secondes. - this.nowTimer = setInterval(() => { this.now = Date.now(); }, 10_000); + this.nowTimer = setInterval(() => { this.now = DateTime.now(); }, 10_000); }, beforeDestroy() { if (this.nowTimer) { @@ -475,12 +510,15 @@ const Datepicker = defineComponent({ // - // ------------------------------------------------------ - handleInput(newValue: LooseValue) { - const normalizedValue = normalizeValue( + handleInput(newValue: CoreValue) { + if (this.inheritedDisabled) { + return; + } + + const normalizedValue = normalizeCoreValue( newValue, this.type, this.range, - this.withoutMinutes, ); if (this.withFullDaysToggle) { @@ -498,7 +536,7 @@ const Datepicker = defineComponent({ } const newIsFullDays = !this.isFullDays; - const newValue = normalizeValue( + const newValue = convertValueType( this.normalizedValue, newIsFullDays ? Type.DATE : Type.DATETIME, this.range, @@ -529,7 +567,7 @@ const Datepicker = defineComponent({ __(key: string, params?: Record, count?: number): string { key = !key.startsWith('global.') - ? `components.Datepicker.${key}` + ? `components.DatePicker.${key}` : key.replace(/^global\./, ''); return this.$t(key, params, count); @@ -544,18 +582,21 @@ const Datepicker = defineComponent({ snippets, isFullDays, translations, + coreValue, normalizedValue: value, inheritedDisabled: disabled, + normalizedReadonly: readonly, inheritedInvalid: invalid, showTimePanel, displayFormat, - outputFormat, + valueFormat, dateFormat, placeholder, disabledDateFactory, withFullDaysToggle, withoutMinutes, withSnippets, + clearable, timePickerOptions, handleToggleFullDays, handleToggleMode, @@ -570,12 +611,12 @@ const Datepicker = defineComponent({ } return ( -
-