Ver código fonte

Merge branch 'release/2.4.1'

Olivier Massot 1 ano atrás
pai
commit
a7218da216
100 arquivos alterados com 4625 adições e 2780 exclusões
  1. 12 0
      .editorconfig
  2. 35 13
      .eslintrc.cjs
  3. 12 4
      .gitignore
  4. 18 3
      .gitlab-ci.yml
  5. 1 0
      .nuxtignore
  6. 1 1
      .nvmrc
  7. 5 0
      .prettierignore
  8. 1 0
      .prettierrc
  9. 1 0
      .yarnrc.yml
  10. 29 30
      README.md
  11. 12 0
      assets/css/global.scss
  12. 1 1
      assets/css/settings.scss
  13. 0 1
      assets/css/theme.scss
  14. 13 14
      components/Layout/Alert/Container.vue
  15. 10 12
      components/Layout/Alert/Content.vue
  16. 10 6
      components/Layout/AlertBar.vue
  17. 43 24
      components/Layout/AlertBar/Cotisation.vue
  18. 7 7
      components/Layout/AlertBar/Env.vue
  19. 17 16
      components/Layout/AlertBar/OnlineRegistration.vue
  20. 17 16
      components/Layout/AlertBar/RegistrationStatus.vue
  21. 36 28
      components/Layout/AlertBar/SuperAdmin.vue
  22. 14 9
      components/Layout/AlertBar/SwitchUser.vue
  23. 50 41
      components/Layout/AlertBar/SwitchYear.vue
  24. 10 10
      components/Layout/BannerTop.vue
  25. 3 3
      components/Layout/Container.vue
  26. 51 35
      components/Layout/Dialog.vue
  27. 58 38
      components/Layout/Header.vue
  28. 11 16
      components/Layout/Header/HomeBtn.vue
  29. 57 57
      components/Layout/Header/Menu.vue
  30. 95 93
      components/Layout/Header/Notification.vue
  31. 91 91
      components/Layout/Header/UniversalCreation/Card.vue
  32. 153 136
      components/Layout/Header/UniversalCreation/CreateButton.vue
  33. 72 61
      components/Layout/Header/UniversalCreation/EventParams.vue
  34. 190 179
      components/Layout/Header/UniversalCreation/GenerateCardsSteps.vue
  35. 15 18
      components/Layout/LoadingScreen.vue
  36. 74 76
      components/Layout/MainMenu.vue
  37. 0 0
      components/Layout/Parameters/ResidenceAreas.vue
  38. 135 0
      components/Layout/ParametersMenu.vue
  39. 47 38
      components/Layout/SubHeader/ActivityYear.vue
  40. 15 13
      components/Layout/SubHeader/Breadcrumbs.vue
  41. 47 35
      components/Layout/SubHeader/DataTiming.vue
  42. 28 23
      components/Layout/SubHeader/DataTimingRange.vue
  43. 47 40
      components/Layout/SubHeader/PersonnalizedList.vue
  44. 55 41
      components/Layout/Subheader.vue
  45. 14 14
      components/Layout/ThemeSwitcher.vue
  46. 34 26
      components/Ui/Button/Delete.vue
  47. 36 23
      components/Ui/Button/Submit.vue
  48. 8 16
      components/Ui/Card.vue
  49. 12 16
      components/Ui/Collection.vue
  50. 17 28
      components/Ui/DataTable.vue
  51. 34 26
      components/Ui/DatePicker.vue
  52. 69 66
      components/Ui/DateRangePicker.vue
  53. 30 30
      components/Ui/ExpansionPanel.vue
  54. 295 168
      components/Ui/Form.vue
  55. 81 0
      components/Ui/Form/Creation.vue
  56. 106 0
      components/Ui/Form/Edition.vue
  57. 10 8
      components/Ui/Help.vue
  58. 86 92
      components/Ui/Image.vue
  59. 246 104
      components/Ui/Input/Autocomplete.vue
  60. 263 0
      components/Ui/Input/Autocomplete/Accesses.vue
  61. 30 31
      components/Ui/Input/AutocompleteWithAPI.vue
  62. 146 0
      components/Ui/Input/AutocompleteWithAp2i.vue
  63. 72 0
      components/Ui/Input/AutocompleteWithEnum.vue
  64. 60 31
      components/Ui/Input/Checkbox.vue
  65. 106 0
      components/Ui/Input/Combobox.vue
  66. 79 109
      components/Ui/Input/DatePicker.vue
  67. 14 18
      components/Ui/Input/Email.vue
  68. 20 21
      components/Ui/Input/Enum.vue
  69. 387 185
      components/Ui/Input/Image.vue
  70. 60 23
      components/Ui/Input/Number.vue
  71. 28 24
      components/Ui/Input/Phone.vue
  72. 49 37
      components/Ui/Input/Text.vue
  73. 23 25
      components/Ui/Input/TextArea.vue
  74. 14 18
      components/Ui/ItemFromUri.vue
  75. 9 0
      components/Ui/LoadingPanel.vue
  76. 38 36
      components/Ui/SystemBar.vue
  77. 6 15
      components/Ui/Template/DataTable.vue
  78. 5 4
      components/Ui/Template/Date.vue
  79. 39 43
      components/Ui/Xeditable/Text.vue
  80. 96 92
      composables/data/useAp2iRequestService.ts
  81. 67 29
      composables/data/useEntityFetch.ts
  82. 21 8
      composables/data/useEntityManager.ts
  83. 8 12
      composables/data/useEnumFetch.ts
  84. 12 11
      composables/data/useEnumManager.ts
  85. 22 14
      composables/data/useImageFetch.ts
  86. 9 9
      composables/data/useImageManager.ts
  87. 66 0
      composables/data/useRefreshProfile.ts
  88. 9 8
      composables/form/useFieldViolation.ts
  89. 19 27
      composables/form/useValidation.ts
  90. 13 0
      composables/form/validation/useSubdomainValidation.ts
  91. 13 8
      composables/layout/useExtensionPanel.ts
  92. 19 11
      composables/layout/useMenu.ts
  93. 14 9
      composables/utils/useAdminUrl.ts
  94. 14 11
      composables/utils/useDownloadFile.ts
  95. 9 0
      composables/utils/useHomeUrl.ts
  96. 10 10
      composables/utils/useI18nUtils.ts
  97. 17 13
      composables/utils/useRedirect.ts
  98. 8 8
      composables/utils/useValidationUtils.ts
  99. 2 3
      config/abilities/config.yaml
  100. 52 31
      config/abilities/pages/addressBook.yaml

+ 12 - 0
.editorconfig

@@ -0,0 +1,12 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+indent_style = space
+indent_size = 2
+trim_trailing_whitespace = true
+
+[*.md]
+trim_trailing_whitespace = false

+ 35 - 13
.eslintrc.cjs

@@ -2,28 +2,50 @@ module.exports = {
   root: true,
   root: true,
   env: {
   env: {
     browser: true,
     browser: true,
-    node: true
+    node: true,
   },
   },
-  parser: "vue-eslint-parser",
+  parser: 'vue-eslint-parser',
   parserOptions: {
   parserOptions: {
-    "ecmaVersion": 2020,
-    "parser": "@typescript-eslint/parser",
-    "sourceType": "module"
+    ecmaVersion: 2020,
+    parser: '@typescript-eslint/parser',
+    sourceType: 'module',
+    tsconfigRootDir: __dirname,
   },
   },
   extends: [
   extends: [
     '@nuxtjs/eslint-config-typescript',
     '@nuxtjs/eslint-config-typescript',
     'plugin:nuxt/recommended',
     'plugin:nuxt/recommended',
-    "eslint:recommended",
-    "plugin:@typescript-eslint/recommended",
+    'eslint:recommended',
+    'plugin:@typescript-eslint/recommended',
     'plugin:vue/vue3-recommended',
     'plugin:vue/vue3-recommended',
     'plugin:prettier/recommended',
     'plugin:prettier/recommended',
-
-  ],
-  plugins: [
-    "vue",
-    "@typescript-eslint"
   ],
   ],
+  ignorePatterns: ['.nuxt', 'coverage/*', 'vendor/*', 'dist/*'],
+  plugins: ['vue', '@typescript-eslint'],
   // add your custom rules here
   // add your custom rules here
   rules: {
   rules: {
-  }
+    'no-console': 0, // on autorise les appels à la console (puisque ceux-ci seront de toute façon nettoyés à la compilation)
+    'vue/valid-v-slot': [
+      'error',
+      {
+        allowModifiers: true,
+      },
+    ],
+    'vue/multi-word-component-names': 0,
+    '@typescript-eslint/no-inferrable-types': 0,
+  },
+  globals: {
+    useRuntimeConfig: 'readonly',
+    useAsyncData: 'readonly',
+    navigateTo: 'readonly',
+    computed: 'readonly',
+    ref: 'readonly',
+    definePageMeta: 'readonly',
+    useRouter: 'readonly',
+    useRoute: 'readonly',
+    useI18n: 'readonly',
+    onMounted: 'readonly',
+    onUnmounted: 'readonly',
+    watch: 'readonly',
+    useRepo: 'readonly',
+  },
 }
 }

+ 12 - 4
.gitignore

@@ -16,10 +16,18 @@ dist
 # macOS
 # macOS
 .DS_Store
 .DS_Store
 
 
-local.app.opentalent.fr.crt
-local.app.opentalent.fr.key
+env/local.app.opentalent.fr.crt
+env/local.app.opentalent.fr.key
 /.project
 /.project
-
 yarn.lock
 yarn.lock
-
 coverage/
 coverage/
+/.env.*.local
+
+# https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored
+.pnp.*
+.yarn/*
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/sdks
+!.yarn/versions

+ 18 - 3
.gitlab-ci.yml

@@ -1,22 +1,37 @@
 stages:
 stages:
   - test
   - test
+  - qa
 
 
 variables:
 variables:
   APP_ENV: ci
   APP_ENV: ci
 
 
 before_script:
 before_script:
-  - echo "" > ./local.app.opentalent.fr.crt
-  - echo "" > ./local.app.opentalent.fr.key
+  - echo "" > ./env/local.app.opentalent.fr.crt
+  - echo "" > ./env/local.app.opentalent.fr.key
+  - corepack enable
+  - yarn set version berry
+  - yarn install --network-timeout 10000
+  - HOST=ci yarn prepare
 
 
 cache:
 cache:
   paths:
   paths:
     - ./node_modules
     - ./node_modules
+    - .yarn
+
+code_quality:
+  stage: qa
+  script:
+    - yarn eslint
+
+code_style:
+  stage: qa
+  script:
+    - yarn prettier . --check
 
 
 unit:
 unit:
   stage: test
   stage: test
 
 
   script:
   script:
-    - yarn install
     - yarn test
     - yarn test
 
 
   artifacts:
   artifacts:

+ 1 - 0
.nuxtignore

@@ -0,0 +1 @@
+coverage

+ 1 - 1
.nvmrc

@@ -1 +1 @@
-18.10
+18.19

+ 5 - 0
.prettierignore

@@ -0,0 +1,5 @@
+.nuxt
+coverage
+node_modules
+dist
+assets/css/*.css

+ 1 - 0
.prettierrc

@@ -1,4 +1,5 @@
 {
 {
+  "tabWidth": 2,
   "semi": false,
   "semi": false,
   "singleQuote": true
   "singleQuote": true
 }
 }

+ 1 - 0
.yarnrc.yml

@@ -0,0 +1 @@
+nodeLinker: node-modules

+ 29 - 30
README.md

@@ -2,25 +2,23 @@
 
 
 [![Latest Release](http://gitlab.2iopenservice.com/opentalent/app/-/badges/release.svg)](http://gitlab.2iopenservice.com/opentalent/app_nuxt3/-/releases)
 [![Latest Release](http://gitlab.2iopenservice.com/opentalent/app/-/badges/release.svg)](http://gitlab.2iopenservice.com/opentalent/app_nuxt3/-/releases)
 
 
-| Branch  | Status                                                                                                                                                                         | Coverage                                                                                                                                                                       |
-|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| Branch  | Status                                                                                                                                                             | Coverage                                                                                                                                                                       |
+| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
 | master  | [![pipeline status](http://gitlab.2iopenservice.com/opentalent/app/badges/master/pipeline.svg)](http://gitlab.2iopenservice.com/opentalent/app/-/commits/master)   | [![coverage report](http://gitlab.2iopenservice.com/opentalent/app_nuxt3/badges/master/coverage.svg)](http://gitlab.2iopenservice.com/opentalent/app_nuxt3/-/commits/master)   |
 | master  | [![pipeline status](http://gitlab.2iopenservice.com/opentalent/app/badges/master/pipeline.svg)](http://gitlab.2iopenservice.com/opentalent/app/-/commits/master)   | [![coverage report](http://gitlab.2iopenservice.com/opentalent/app_nuxt3/badges/master/coverage.svg)](http://gitlab.2iopenservice.com/opentalent/app_nuxt3/-/commits/master)   |
 | develop | [![pipeline status](http://gitlab.2iopenservice.com/opentalent/app/badges/develop/pipeline.svg)](http://gitlab.2iopenservice.com/opentalent/app/-/commits/develop) | [![coverage report](http://gitlab.2iopenservice.com/opentalent/app_nuxt3/badges/develop/coverage.svg)](http://gitlab.2iopenservice.com/opentalent/app_nuxt3/-/commits/develop) |
 | develop | [![pipeline status](http://gitlab.2iopenservice.com/opentalent/app/badges/develop/pipeline.svg)](http://gitlab.2iopenservice.com/opentalent/app/-/commits/develop) | [![coverage report](http://gitlab.2iopenservice.com/opentalent/app_nuxt3/badges/develop/coverage.svg)](http://gitlab.2iopenservice.com/opentalent/app_nuxt3/-/commits/develop) |
 
 
-
 Frontend Opentalent, avec NuxtJs 3
 Frontend Opentalent, avec NuxtJs 3
 
 
 A voir :
 A voir :
 
 
-* [vuejs.org](https://vuejs.org/guide/introduction.html) : Vue est le framework de base de l'application
-* [nuxtjs.org](https://nuxt.com/docs/getting-started/introduction) : Nuxt est une surcouche à Vue qui automatise et simplifie beaucoup de choses
-* [pinia.vuejs.org](https://pinia.vuejs.org/introduction.html) : Store library that allow you to share a state accross your components / pages
-* [pinia-orm.codedredd.de](https://pinia-orm.codedredd.de/guide/getting-started/quick-start) : Ajoute une gestion par modèles / repos au store Pinia
-* [vuetifyjs.com](https://cdn.vuetifyjs.com/docs/images/logos/vuetify-logo-v3-slim-text-light.svg) : Composants graphiques préconstruits
-* [typescriptlang.org](https://www.typescriptlang.org/) : Typescript
-* [jestjs.io](https://jestjs.io/docs/getting-started) : Tests unitaires pour JS
-* [cypress.io](https://docs.cypress.io/guides/getting-started/installing-cypress) : Tests end-to-end pour JS
-
+- [vuejs.org](https://vuejs.org/guide/introduction.html) : Vue est le framework de base de l'application
+- [nuxtjs.org](https://nuxt.com/docs/getting-started/introduction) : Nuxt est une surcouche à Vue qui automatise et simplifie beaucoup de choses
+- [pinia.vuejs.org](https://pinia.vuejs.org/introduction.html) : Store library that allow you to share a state accross your components / pages
+- [pinia-orm.codedredd.de](https://pinia-orm.codedredd.de/guide/getting-started/quick-start) : Ajoute une gestion par modèles / repos au store Pinia
+- [vuetifyjs.com](https://cdn.vuetifyjs.com/docs/images/logos/vuetify-logo-v3-slim-text-light.svg) : Composants graphiques préconstruits
+- [typescriptlang.org](https://www.typescriptlang.org/) : Typescript
+- [jestjs.io](https://jestjs.io/docs/getting-started) : Tests unitaires pour JS
+- [cypress.io](https://docs.cypress.io/guides/getting-started/installing-cypress) : Tests end-to-end pour JS
 
 
 ## Installation (mode dev)
 ## Installation (mode dev)
 
 
@@ -28,37 +26,25 @@ Cloner le projet :
 
 
     git clone git@gitlab.2iopenservice.com:opentalent/app.git
     git clone git@gitlab.2iopenservice.com:opentalent/app.git
 
 
-
 Installer les dépendances :
 Installer les dépendances :
 
 
     yarn install
     yarn install
 
 
-Créer le symlink vers le bon fichier env (remplacer `<environnement>` par l'env courant):
-
-    ln -s .env.<environnement> .env
-
-
-Copier les certificats à la racine de ce projet :
-
-* local.app.opentalent.fr.crt
-* local.app.opentalent.fr.key
+Copier les certificats dans le répertoire `env/` de ce projet :
 
 
+- local.app.opentalent.fr.crt
+- local.app.opentalent.fr.key
 
 
 Lancer le serveur de développement :
 Lancer le serveur de développement :
 
 
     yarn dev -o
     yarn dev -o
 
 
-
 ## Déploiement en prod
 ## Déploiement en prod
 
 
 ### Premier déploiement en tant que service
 ### Premier déploiement en tant que service
 
 
 On commence par cloner le projet app, puis par se placer dans le répertoire ainsi créé.
 On commence par cloner le projet app, puis par se placer dans le répertoire ainsi créé.
 
 
-On créé un symlink vers le fichier .env.xxx voulu sous le nom de .env (selon l'environnement)
-
-    ln -s .env.xxx .env
-
 Pour déployer le projet en mode SSR, on commence par mettre à jour et compiler avec la commande custom :
 Pour déployer le projet en mode SSR, on commence par mettre à jour et compiler avec la commande custom :
 
 
     yarn deploy
     yarn deploy
@@ -81,7 +67,6 @@ Attention, sur les environnements de test, il faut utiliser nvm pour exécuter l
 
 
     nvm exec yarn install
     nvm exec yarn install
 
 
-
 ## Autres
 ## Autres
 
 
 ### Lancer les tests
 ### Lancer les tests
@@ -105,12 +90,27 @@ Sur les environnements où app est servie par supervisor, on peut consulter les
 > le `-6000` étant le nombre de bytes à afficher
 > le `-6000` étant le nombre de bytes à afficher
 > Voir plus : http://supervisord.org/running.html#supervisorctl-command-line-options
 > Voir plus : http://supervisord.org/running.html#supervisorctl-command-line-options
 
 
+### Exécuter ESLint
+
+    yarn lint
+
+### Faire fonctionner le HMR
+
+Si le HMR (Hot Module Reload) ne fontionne pas et qu'un message d'erreur est logué en console disant que l'adresse
+n'est pas accessible, alors suivre les étapes suivantes :
+
+- Ouvrir l'inspecteur de son navigateur, onglet Réseau
+- Rafraichir la page
+- Trouver la requête en erreur. Elle devrait être de la forme `https://local.app.opentalent.fr:24678/_nuxt/`
+- Clic droit dessus, puis "ouvrir dans un nouvel onglet"
+- Ajouter une exception de sécurité dans le navigateur
+
 ## Plus d'infos
 ## Plus d'infos
 
 
 ## Structure du projet
 ## Structure du projet
 
 
 | Répertoire     | Rôle                                                                                                |
 | Répertoire     | Rôle                                                                                                |
-|----------------|-----------------------------------------------------------------------------------------------------|
+| -------------- | --------------------------------------------------------------------------------------------------- |
 | `assets`       | Contient les fichiers style et medias                                                               |
 | `assets`       | Contient les fichiers style et medias                                                               |
 | `components`   | Les différents composants graphiques qui composent l'application                                    |
 | `components`   | Les différents composants graphiques qui composent l'application                                    |
 | `composables`  | Des fonctions conscientes du contexte applicatif, qui font le lien entre les pages et les services  |
 | `composables`  | Des fonctions conscientes du contexte applicatif, qui font le lien entre les pages et les services  |
@@ -128,4 +128,3 @@ Sur les environnements où app est servie par supervisor, on peut consulter les
 | `stores`       | Le store et ses composants servent d'entrepôt de donnés, et s'assurent de la cohérence de celles-ci |
 | `stores`       | Le store et ses composants servent d'entrepôt de donnés, et s'assurent de la cohérence de celles-ci |
 | `tests`        | Regroupe les tests (unitaires, end-to-end...)                                                       |
 | `tests`        | Regroupe les tests (unitaires, end-to-end...)                                                       |
 | `types`        | Types Typescript (interfaces, enums...)                                                             |
 | `types`        | Types Typescript (interfaces, enums...)                                                             |
-

Diferenças do arquivo suprimidas por serem muito extensas
+ 12 - 0
assets/css/global.scss


+ 1 - 1
assets/css/settings.scss

@@ -2,4 +2,4 @@
 @forward 'vuetify/settings' with (
 @forward 'vuetify/settings' with (
   $button-color: green,
   $button-color: green,
   $button-font-weight: 700
   $button-font-weight: 700
-);
+);

+ 0 - 1
assets/css/theme.scss

@@ -1,4 +1,3 @@
-
 .theme-primary {
 .theme-primary {
   background-color: rgb(var(--v-theme-primary)) !important;
   background-color: rgb(var(--v-theme-primary)) !important;
   color: rgb(var(--v-theme-on-primary)) !important;
   color: rgb(var(--v-theme-on-primary)) !important;

+ 13 - 14
components/Layout/Alert/Container.vue

@@ -16,28 +16,27 @@ Container principal pour l'affichage d'une ou plusieurs alertes
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import { Alert } from '~/types/interfaces'
-import {usePageStore} from "~/stores/page";
-import {ComputedRef} from "@vue/reactivity";
+import type { ComputedRef } from 'vue'
+import type { Alert } from '~/types/interfaces'
+import { usePageStore } from '~/stores/page'
 
 
 const pageStore = usePageStore()
 const pageStore = usePageStore()
 
 
 const alerts: ComputedRef<Array<Alert>> = computed(() => {
 const alerts: ComputedRef<Array<Alert>> = computed(() => {
   return pageStore.alerts
   return pageStore.alerts
 })
 })
-
 </script>
 </script>
 
 
 <style scoped>
 <style scoped>
-  .alertContainer {
-    position: fixed;
-    bottom: 0;
-    right: 20px;
-    z-index: 1000;
-  }
+.alertContainer {
+  position: fixed;
+  bottom: 0;
+  right: 20px;
+  z-index: 1000;
+}
 
 
-  .alertContainer > .alertContent {
-    position: relative;
-    margin-bottom: 10px;
-  }
+.alertContainer > .alertContent {
+  position: relative;
+  margin-bottom: 10px;
+}
 </style>
 </style>

+ 10 - 12
components/Layout/Alert/Content.vue

@@ -13,20 +13,20 @@
     @mouseout="onMouseOut"
     @mouseout="onMouseOut"
   >
   >
     <ul v-if="props.alert.messages.length > 1">
     <ul v-if="props.alert.messages.length > 1">
-       <li v-for="message in props.alert.messages">
+      <li v-for="message in props.alert.messages" :key="message">
         {{ $t(message) }}
         {{ $t(message) }}
       </li>
       </li>
     </ul>
     </ul>
     <span v-else>
     <span v-else>
-        {{ $t(props.alert.messages[0]) }}
+      {{ $t(props.alert.messages[0]) }}
     </span>
     </span>
   </v-alert>
   </v-alert>
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import {Alert} from '~/types/interfaces'
-import {Ref} from "@vue/reactivity";
-import {usePageStore} from "~/stores/page";
+import type { Ref } from 'vue'
+import type { Alert } from '~/types/interfaces'
+import { usePageStore } from '~/stores/page'
 
 
 const props = defineProps({
 const props = defineProps({
   /**
   /**
@@ -34,7 +34,7 @@ const props = defineProps({
    */
    */
   alert: {
   alert: {
     type: Object as () => Alert,
     type: Object as () => Alert,
-    required: true
+    required: true,
   },
   },
   /**
   /**
    * The time after which the alert disappears
    * The time after which the alert disappears
@@ -42,8 +42,8 @@ const props = defineProps({
   timeout: {
   timeout: {
     type: Number,
     type: Number,
     required: false,
     required: false,
-    default: 3000
-  }
+    default: 3000,
+  },
 })
 })
 
 
 const show: Ref<boolean> = ref(true)
 const show: Ref<boolean> = ref(true)
@@ -55,7 +55,7 @@ const pageStore = usePageStore()
  * Retire l'alerte après `time` (en ms)
  * Retire l'alerte après `time` (en ms)
  * @param time
  * @param time
  */
  */
-const clearAlert = (time: number = 2000) => {
+const clearAlert = (time: number = 4000) => {
   timeout = setTimeout(() => {
   timeout = setTimeout(() => {
     show.value = false
     show.value = false
     pageStore.removeSlowlyAlert()
     pageStore.removeSlowlyAlert()
@@ -77,8 +77,6 @@ const onMouseOut = () => {
 }
 }
 
 
 clearAlert()
 clearAlert()
-
 </script>
 </script>
 
 
-<style scoped>
-</style>
+<style scoped></style>

+ 10 - 6
components/Layout/AlertBar.vue

@@ -11,19 +11,23 @@ Contient les différentes barres d'alertes qui s'affichent dans certains cas
     <LayoutAlertBarSwitchUser />
     <LayoutAlertBarSwitchUser />
 
 
     <client-only>
     <client-only>
-      <LayoutAlertBarCotisation v-if="organizationProfile.isCmf && ability.can('manage', 'cotisation')" />
+      <LayoutAlertBarCotisation
+        v-if="organizationProfile.isCmf && ability.can('manage', 'cotisation')"
+      />
     </client-only>
     </client-only>
 
 
     <LayoutAlertBarSwitchYear />
     <LayoutAlertBarSwitchYear />
     <LayoutAlertBarSuperAdmin />
     <LayoutAlertBarSuperAdmin />
-    <LayoutAlertBarRegistrationStatus v-if="organizationProfile.hasModule('IEL')" />
+    <LayoutAlertBarRegistrationStatus
+      v-if="organizationProfile.hasModule('IEL')"
+    />
   </main>
   </main>
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-  import {useOrganizationProfileStore} from "~/stores/organizationProfile";
-  import {useAbility} from "@casl/vue";
+import { useOrganizationProfileStore } from '~/stores/organizationProfile'
+import { useAbility } from '@casl/vue'
 
 
-  const organizationProfile = useOrganizationProfileStore()
-  const ability = useAbility()
+const organizationProfile = useOrganizationProfileStore()
+const ability = useAbility()
 </script>
 </script>

+ 43 - 24
components/Layout/AlertBar/Cotisation.vue

@@ -7,22 +7,22 @@ Barre d'alerte qui s'affiche pour donner l'état de la cotisation
 <template>
 <template>
   <main>
   <main>
     <UiSystemBar
     <UiSystemBar
-        v-if="alert && alert.text && alert.callback"
-        :text="$t(alert.text)"
-        icon="fas fa-info-circle"
-        :on-click="alert.callback"
-        class="theme-info"
+      v-if="alert && alert.text && alert.callback"
+      :text="$t(alert.text)"
+      icon="fas fa-info-circle"
+      :on-click="alert.callback"
+      class="theme-info"
     />
     />
   </main>
   </main>
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import {useOrganizationProfileStore} from "~/stores/organizationProfile";
-import {Ref} from "vue";
-import UrlUtils from "~/services/utils/urlUtils";
-import {ALERT_STATE_COTISATION} from "~/types/enum/enums";
-import {useEntityFetch} from "~/composables/data/useEntityFetch";
-import Cotisation from "~/models/Organization/Cotisation";
+import { useOrganizationProfileStore } from '~/stores/organizationProfile'
+import type { Ref } from 'vue'
+import UrlUtils from '~/services/utils/urlUtils'
+import { ALERT_STATE_COTISATION } from '~/types/enum/enums'
+import { useEntityFetch } from '~/composables/data/useEntityFetch'
+import Cotisation from '~/models/Organization/Cotisation'
 
 
 const organizationProfile = useOrganizationProfileStore()
 const organizationProfile = useOrganizationProfileStore()
 
 
@@ -38,7 +38,12 @@ const goToCotisation = () => {
   if (!organizationProfile.id) {
   if (!organizationProfile.id) {
     throw new Error('missing organization id')
     throw new Error('missing organization id')
   }
   }
-  window.location.href = UrlUtils.join(baseLegacyUrl, '/cotisation/cotisation_steps', organizationProfile.id, 'steps/1')
+  window.location.href = UrlUtils.join(
+    baseLegacyUrl,
+    '/cotisation/cotisation_steps',
+    organizationProfile.id,
+    'steps/1',
+  )
 }
 }
 
 
 /**
 /**
@@ -48,21 +53,30 @@ const openInvoiceWindow = () => {
   if (!cotisationYear.value) {
   if (!cotisationYear.value) {
     throw new Error('no cotisation year defined')
     throw new Error('no cotisation year defined')
   }
   }
-  window.open(UrlUtils.join(baseLegacyUrl, 'cotisation/invoice', cotisationYear.value), '_blank')
+  window.open(
+    UrlUtils.join(baseLegacyUrl, 'cotisation/invoice', cotisationYear.value),
+    '_blank',
+  )
 }
 }
 
 
 /**
 /**
  * Redirige l'utilisateur vers la page des assurances
  * Redirige l'utilisateur vers la page des assurances
  */
  */
 const goToInsurancePage = () => {
 const goToInsurancePage = () => {
-  window.location.href = UrlUtils.join(baseLegacyUrl, 'cotisation/insuranceedit')
+  window.location.href = UrlUtils.join(
+    baseLegacyUrl,
+    'cotisation/insuranceedit',
+  )
 }
 }
 
 
 /**
 /**
  * Redirige (dans un nouvel onglet) l'utilsateur vers le site web de la CMF
  * Redirige (dans un nouvel onglet) l'utilsateur vers le site web de la CMF
  */
  */
 const openCmfSubscriptionPage = () => {
 const openCmfSubscriptionPage = () => {
-  window.open('https://www.cmf-musique.org/services/assurances/assurance-de-groupe/', '_blank')
+  window.open(
+    'https://www.cmf-musique.org/services/assurances/assurance-de-groupe/',
+    '_blank',
+  )
 }
 }
 
 
 // On récupère l'état des cotisations via l'API
 // On récupère l'état des cotisations via l'API
@@ -71,7 +85,10 @@ if (!organizationProfile.id) {
 }
 }
 
 
 const { fetch } = useEntityFetch()
 const { fetch } = useEntityFetch()
-const { data: cotisation, pending } = await fetch(Cotisation, organizationProfile.id)
+const { data: cotisation, pending } = await fetch(
+  Cotisation,
+  organizationProfile.id,
+)
 
 
 interface Alert {
 interface Alert {
   text: string
   text: string
@@ -86,10 +103,13 @@ const alert: ComputedRef<Alert | null> = computed(() => {
   cotisationYear.value = cotisation.value.cotisationYear
   cotisationYear.value = cotisation.value.cotisationYear
 
 
   const mapping: Record<ALERT_STATE_COTISATION, Alert> = {
   const mapping: Record<ALERT_STATE_COTISATION, Alert> = {
-    'AFFILIATION': { text: 'cotisation_access', callback: goToCotisation },
-    'INVOICE': { text: 'upload_cotisation_invoice', callback: openInvoiceWindow },
-    'INSURANCE': { text: 'renew_insurance_cmf', callback: goToInsurancePage },
-    'ADVERTISINGINSURANCE': { text: 'insurance_cmf_subscription', callback: openCmfSubscriptionPage },
+    AFFILIATION: { text: 'cotisation_access', callback: goToCotisation },
+    INVOICE: { text: 'upload_cotisation_invoice', callback: openInvoiceWindow },
+    INSURANCE: { text: 'renew_insurance_cmf', callback: goToInsurancePage },
+    ADVERTISINGINSURANCE: {
+      text: 'insurance_cmf_subscription',
+      callback: openCmfSubscriptionPage,
+    },
   }
   }
 
 
   if (!cotisation.value.alertState) {
   if (!cotisation.value.alertState) {
@@ -98,11 +118,10 @@ const alert: ComputedRef<Alert | null> = computed(() => {
 
 
   return mapping[cotisation.value.alertState as ALERT_STATE_COTISATION]
   return mapping[cotisation.value.alertState as ALERT_STATE_COTISATION]
 })
 })
-
 </script>
 </script>
 
 
 <style scoped lang="scss">
 <style scoped lang="scss">
-  :deep(.clickable:hover) {
-    text-decoration: none !important;
-  }
+:deep(.clickable:hover) {
+  text-decoration: none !important;
+}
 </style>
 </style>

+ 7 - 7
components/Layout/AlertBar/Env.vue

@@ -6,16 +6,16 @@ Barre d'alerte qui s'affiche lorsque l'utilisateur n'est pas dans un environneme
 
 
 <template>
 <template>
   <UiSystemBar
   <UiSystemBar
-      v-if="show"
-      :text="$t('not_production_environment', { env: env })"
-      icon="fas fa-exclamation-triangle"
-      class="theme-warning"
+    v-if="show"
+    :text="$t('not_production_environment', { env: env })"
+    icon="fas fa-exclamation-triangle"
+    class="theme-warning"
   />
   />
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-  const runtimeConfig = useRuntimeConfig()
+const runtimeConfig = useRuntimeConfig()
 
 
-  const env = runtimeConfig.env ?? 'unknown'
-  const show = env !== 'production'
+const env = runtimeConfig.public.env ?? 'unknown'
+const show = env !== 'production'
 </script>
 </script>

+ 17 - 16
components/Layout/AlertBar/OnlineRegistration.vue

@@ -5,37 +5,38 @@ Barre d'alerte sur l'ouverture ou non de l'inscription en ligne
 
 
 <template>
 <template>
   <UiSystemBar
   <UiSystemBar
-      v-if="show"
-      :text="$t(message)"
-      icon="fas fa-id-card"
-      class="theme-secondary-alt"
+    v-if="show"
+    :text="$t(message)"
+    icon="fas fa-id-card"
+    class="theme-secondary-alt"
   />
   />
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-
-import {useEntityFetch} from "~/composables/data/useEntityFetch";
-import {useAccessProfileStore} from "~/stores/accessProfile";
-import RegistrationAvailability from "~/models/OnlineRegistration/RegistrationAvailability";
-import {ComputedRef} from "@vue/reactivity";
+import { useEntityFetch } from '~/composables/data/useEntityFetch'
+import { useAccessProfileStore } from '~/stores/accessProfile'
+import RegistrationAvailability from '~/models/OnlineRegistration/RegistrationAvailability'
+import { ComputedRef } from '@vue/reactivity'
 
 
 const { fetch } = useEntityFetch()
 const { fetch } = useEntityFetch()
 
 
 const accessProfile = useAccessProfileStore()
 const accessProfile = useAccessProfileStore()
 
 
-const { data: registrationAvailability, pending } = fetch(RegistrationAvailability, accessProfile.id ?? 0)
+const { data: registrationAvailability, pending } = fetch(
+  RegistrationAvailability,
+  accessProfile.id ?? 0,
+)
 
 
 const show: ComputedRef<boolean> = computed(() => {
 const show: ComputedRef<boolean> = computed(() => {
-  return !pending && (registrationAvailability.value as RegistrationAvailability).available
+  return (
+    !pending &&
+    (registrationAvailability.value as RegistrationAvailability).available
+  )
 })
 })
 
 
 const message: ComputedRef<string> = computed(() => {
 const message: ComputedRef<string> = computed(() => {
   return (registrationAvailability.value as RegistrationAvailability).message
   return (registrationAvailability.value as RegistrationAvailability).message
 })
 })
-
-
 </script>
 </script>
 
 
-<style scoped lang="scss">
-
-</style>
+<style scoped lang="scss"></style>

+ 17 - 16
components/Layout/AlertBar/RegistrationStatus.vue

@@ -5,30 +5,33 @@ Barre d'alerte quand au statut (l'avancement) de l'inscription en ligne de l'uti
 
 
 <template>
 <template>
   <UiSystemBar
   <UiSystemBar
-      v-if="!pending && message"
-      :text="$t(message)"
-      icon="fas fa-id-card"
-      class="theme-secondary"
+    v-if="!pending && message"
+    :text="$t(message)"
+    icon="fas fa-id-card"
+    class="theme-secondary"
   />
   />
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import {useEntityFetch} from "~/composables/data/useEntityFetch";
-import {useAccessProfileStore} from "~/stores/accessProfile";
-import RegistrationStatus from "~/models/OnlineRegistration/RegistrationStatus";
-import {ComputedRef} from "@vue/reactivity";
+import { useEntityFetch } from '~/composables/data/useEntityFetch'
+import { useAccessProfileStore } from '~/stores/accessProfile'
+import RegistrationStatus from '~/models/OnlineRegistration/RegistrationStatus'
+import type { ComputedRef } from '@vue/reactivity'
 
 
 const { fetch } = useEntityFetch()
 const { fetch } = useEntityFetch()
 
 
 const accessProfile = useAccessProfileStore()
 const accessProfile = useAccessProfileStore()
 
 
-const { data: registrationStatus, pending } = fetch(RegistrationStatus, accessProfile.id ?? 0)
+const { data: registrationStatus, pending } = fetch(
+  RegistrationStatus,
+  accessProfile.id ?? 0,
+)
 
 
 const messagesByStatus = {
 const messagesByStatus = {
-  'NEGOTIABLE': "your_application_is_awaiting_processing",
-  'PENDING': "you_have_been_placed_on_the_waiting_list",
-  'ACCEPTED': "your_registration_file_has_been_validated",
-  'DENIED': "your_application_has_been_refused",
+  NEGOTIABLE: 'your_application_is_awaiting_processing',
+  PENDING: 'you_have_been_placed_on_the_waiting_list',
+  ACCEPTED: 'your_registration_file_has_been_validated',
+  DENIED: 'your_application_has_been_refused',
 }
 }
 
 
 const message: ComputedRef<string> = computed(() => {
 const message: ComputedRef<string> = computed(() => {
@@ -40,6 +43,4 @@ const message: ComputedRef<string> = computed(() => {
 })
 })
 </script>
 </script>
 
 
-<style scoped lang="scss">
-
-</style>
+<style scoped lang="scss"></style>

+ 36 - 28
components/Layout/AlertBar/SuperAdmin.vue

@@ -6,40 +6,48 @@ Barre d'alerte qui s'affiche lorsque l'utilisateur est un super admin en mode sw
 
 
 <template>
 <template>
   <!-- TODO : fonctionnement à valider -->
   <!-- TODO : fonctionnement à valider -->
-  <UiSystemBar v-if="show" class="theme-danger">
+  <UiSystemBar v-if="show" class="theme-danger" :on-click="onClick">
     <v-icon small>fas fa-exclamation-triangle</v-icon>
     <v-icon small>fas fa-exclamation-triangle</v-icon>
     <span>{{ $t('super_admin_switch_account') }} </span>
     <span>{{ $t('super_admin_switch_account') }} </span>
-
-    <a v-if="url" :href="url" class="text-decoration-none on-danger">
-      &nbsp;<strong>{{ $t('click_here') }}</strong>
-    </a>
   </UiSystemBar>
   </UiSystemBar>
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-  import {useAccessProfileStore} from "~/stores/accessProfile";
-  import UrlUtils from "~/services/utils/urlUtils";
-  import {ComputedRef} from "@vue/reactivity";
-
-  const runtimeConfig = useRuntimeConfig()
-
-  const baseLegacyUrl: string = runtimeConfig.baseUrlAdminLegacy
-
-  const accessProfile = useAccessProfileStore()
-
-  const show: ComputedRef<boolean> = computed(() =>
-      accessProfile.originalAccess !== null && accessProfile.originalAccess.isSuperAdminAccess
-  )
-
-  const url: ComputedRef<string> = computed(() => {
-    const orgId = accessProfile.originalAccess ? accessProfile.originalAccess.organization.id : null
-    const originalAccessId = accessProfile.originalAccess ? accessProfile.originalAccess.id : null
-
-    if (show && orgId && originalAccessId) {
-      return UrlUtils.join(baseLegacyUrl, '#', 'switch_user', orgId, originalAccessId, 'exit')
-    }
-    return ''
-  })
+import { useAccessProfileStore } from '~/stores/accessProfile'
+import UrlUtils from '~/services/utils/urlUtils'
+import type { ComputedRef } from '@vue/reactivity'
+import { navigateTo } from '#app'
+import { useAdminUrl } from '~/composables/utils/useAdminUrl'
+
+const { makeAdminUrl } = useAdminUrl()
+
+const accessProfile = useAccessProfileStore()
+
+const show: ComputedRef<boolean> = computed(
+  () =>
+    accessProfile.originalAccess !== null &&
+    accessProfile.originalAccess.isSuperAdminAccess,
+)
+
+const url: ComputedRef<string> = computed(() => {
+  const orgId = accessProfile.originalAccess
+    ? accessProfile.originalAccess.organization.id
+    : null
+  const originalAccessId = accessProfile.originalAccess
+    ? accessProfile.originalAccess.id
+    : null
+
+  if (show && orgId && originalAccessId) {
+    return makeAdminUrl(
+      UrlUtils.join('#', 'switch_user', orgId, originalAccessId, 'exit'),
+    )
+  }
+  return ''
+})
+
+const onClick = () => {
+  navigateTo(url.value, { external: true })
+}
 </script>
 </script>
 
 
 <style scoped lang="scss">
 <style scoped lang="scss">

+ 14 - 9
components/Layout/AlertBar/SwitchUser.vue

@@ -7,22 +7,27 @@ Barre qui s'affiche lorsque l'utilisateur possède un compte multi user
 <template>
 <template>
   <UiSystemBar v-if="show" class="theme-info">
   <UiSystemBar v-if="show" class="theme-info">
     <v-icon small icon="fas fa-info-circle" />
     <v-icon small icon="fas fa-info-circle" />
-    <span v-html="$t('multi_account_alert', { fullName })" />&nbsp;
-
-    <v-icon class="pl-1" small icon="fa fa-users"/> &nbsp;{{$t('multi_account_alert_next')}}
+    <span>
+      {{ $t('multi_account_alert_part1') }} <strong>{{ fullName }}</strong>
+      {{ $t('multi_account_alert_part2') }}
+    </span>
+
+    <v-icon class="pl-1" small icon="fa fa-users" /> &nbsp;{{
+      $t('multi_account_alert_part3')
+    }}
   </UiSystemBar>
   </UiSystemBar>
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-  import {useAccessProfileStore} from "~/stores/accessProfile";
-  import {useMenu} from "~/composables/layout/useMenu";
+import { useAccessProfileStore } from '~/stores/accessProfile'
+import { useMenu } from '~/composables/layout/useMenu'
 
 
-  const accessProfile = useAccessProfileStore()
-  const { hasMenu } = useMenu()
+const accessProfile = useAccessProfileStore()
+const { hasMenu } = useMenu()
 
 
-  const show = computed(() => hasMenu('MyFamily'))
+const show = computed(() => hasMenu('MyFamily'))
 
 
-  const fullName = `${accessProfile.givenName} ${accessProfile.name}`
+const fullName = `${accessProfile.givenName} ${accessProfile.name}`
 </script>
 </script>
 
 
 <style scoped lang="scss">
 <style scoped lang="scss">

+ 50 - 41
components/Layout/AlertBar/SwitchYear.vue

@@ -7,61 +7,70 @@ Barre d'alerte qui s'affiche lorsque l'utilisateur n'est pas sur l'année couran
 <template>
 <template>
   <!-- TODO : fonctionnement à valider -->
   <!-- TODO : fonctionnement à valider -->
   <UiSystemBar v-if="show" class="theme-warning flex-column">
   <UiSystemBar v-if="show" class="theme-warning flex-column">
-    {{$t('not_current_year')}}
+    {{ $t('not_current_year') }}
 
 
-    <a @click="resetYear" class="text-decoration-none on-warning" style="cursor: pointer;">
+    <a
+      @click="resetYear"
+      class="text-decoration-none on-warning"
+      style="cursor: pointer"
+    >
       <strong class="pl-2 text-neutral-strong">
       <strong class="pl-2 text-neutral-strong">
-        {{$t('not_current_year_reset')}}
+        {{ $t('not_current_year_reset') }}
       </strong>
       </strong>
     </a>
     </a>
   </UiSystemBar>
   </UiSystemBar>
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-  import {useAccessProfileStore} from "~/stores/accessProfile";
-  import {useOrganizationProfileStore} from "~/stores/organizationProfile";
-  import {ComputedRef} from "@vue/reactivity";
-  import {useFormStore} from "~/stores/form";
-  import Access from "~/models/Access/Access";
-  import {usePageStore} from "~/stores/page";
-  import {useEntityManager} from "~/composables/data/useEntityManager";
+import { useAccessProfileStore } from '~/stores/accessProfile'
+import { useOrganizationProfileStore } from '~/stores/organizationProfile'
+import type { ComputedRef } from '@vue/reactivity'
+import { useFormStore } from '~/stores/form'
+import Access from '~/models/Access/Access'
+import { usePageStore } from '~/stores/page'
+import { useEntityManager } from '~/composables/data/useEntityManager'
+import { useRefreshProfile } from '~/composables/data/useRefreshProfile'
 
 
-  const { em } = useEntityManager()
-  const accessProfile = useAccessProfileStore()
-  const organizationProfile = useOrganizationProfileStore()
-  const { setDirty } = useFormStore()
-  const pageStore = usePageStore()
+const { em } = useEntityManager()
+const accessProfile = useAccessProfileStore()
+const organizationProfile = useOrganizationProfileStore()
+const { setDirty } = useFormStore()
+const pageStore = usePageStore()
+const { refreshProfile } = useRefreshProfile()
 
 
-  const show: ComputedRef<boolean> = computed(() => {
-    return (
-        accessProfile.historical.past || accessProfile.historical.future ||
-        (accessProfile.historical.dateStart && accessProfile.historical.dateStart.length > 0) ||
-        (accessProfile.historical.dateEnd && accessProfile.historical.dateEnd.length > 0) ||
-        accessProfile.activityYear !== organizationProfile.currentActivityYear
-    )
-  })
+const show: ComputedRef<boolean> = computed(() => {
+  return (
+    accessProfile.historical.past ||
+    accessProfile.historical.future ||
+    (accessProfile.historical.dateStart &&
+      accessProfile.historical.dateStart.length > 0) ||
+    (accessProfile.historical.dateEnd &&
+      accessProfile.historical.dateEnd.length > 0) ||
+    accessProfile.activityYear !== organizationProfile.currentActivityYear
+  )
+})
 
 
-  const resetYear = async () => {
-      const defaultValues = {
-        historical: {
-            "future": false,
-            "past": false,
-            "present": true,
-        },
-        activityYear: organizationProfile.currentActivityYear
-      }
+const resetYear = async () => {
+  const defaultValues = {
+    historical: {
+      future: false,
+      past: false,
+      present: true,
+    },
+    activityYear: organizationProfile.currentActivityYear,
+  }
 
 
-    // Il faut ajouter un patch sur le profile ici
-    setDirty(false)
+  // Il faut ajouter un patch sur le profile ici
+  setDirty(false)
 
 
-    pageStore.loading = true
-    await em.patch(Access, accessProfile.currentAccessId, defaultValues)
-    if (process.server) {
-        // Force profile refresh server side to avoid a bug where server and client stores diverge on profile refresh
-        await em.refreshProfile()
-    }
-    window.location.reload()
+  pageStore.loading = true
+  await em.patch(Access, accessProfile.currentAccessId, defaultValues)
+  if (process.server) {
+    // Force profile refresh server side to avoid a bug where server and client stores diverge on profile refresh
+    await refreshProfile()
   }
   }
+  window.location.reload()
+}
 </script>
 </script>
 
 
 <style scoped lang="scss">
 <style scoped lang="scss">

+ 10 - 10
components/Layout/BannerTop.vue

@@ -18,14 +18,14 @@ Troisième bandeau en partant du haut, contenant entre autre le numéro SIRET de
 </template>
 </template>
 
 
 <style scoped>
 <style scoped>
-  .bannerTopForm{
-    min-height: 100px;
-    margin-top: 10px !important;
-    margin-bottom: 10px !important;
-  }
-  .bannerTopForm > .col{
-    min-height: 100px;
-    padding: 10px;
-    padding-left: 24px;
-  }
+.bannerTopForm {
+  min-height: 100px;
+  margin-top: 10px !important;
+  margin-bottom: 10px !important;
+}
+.bannerTopForm > .col {
+  min-height: 100px;
+  padding: 10px;
+  padding-left: 24px;
+}
 </style>
 </style>

+ 3 - 3
components/Layout/Container.vue

@@ -12,7 +12,7 @@
 </template>
 </template>
 
 
 <style scoped>
 <style scoped>
-  .container{
-    padding-top: 0;
-  }
+.container {
+  padding-top: 0;
+}
 </style>
 </style>

+ 51 - 35
components/Layout/Dialog.vue

@@ -1,13 +1,18 @@
 <!-- Fenêtre de dialogue -->
 <!-- Fenêtre de dialogue -->
 <template>
 <template>
   <v-dialog
   <v-dialog
-    :model-value="show"
-    persistent
+    :model-value="_show"
+    :persistent="true"
     :max-width="maxWidth"
     :max-width="maxWidth"
     :content-class="contentClass"
     :content-class="contentClass"
   >
   >
     <v-card class="d-flex flex-row">
     <v-card class="d-flex flex-row">
-      <div :class="'dialog-type flex-column justify-center d-none d-sm-flex theme-' + theme">
+      <div
+        :class="
+          'dialog-type flex-column justify-center d-none d-sm-flex theme-' +
+          theme
+        "
+      >
         <h3 class="d-flex">
         <h3 class="d-flex">
           <slot name="dialogType" />
           <slot name="dialogType" />
         </h3>
         </h3>
@@ -36,12 +41,12 @@
 <script setup lang="ts">
 <script setup lang="ts">
 const props = defineProps({
 const props = defineProps({
   show: {
   show: {
-    type: Boolean,
-    required: true
+    type: [Boolean, Object],
+    required: true,
   },
   },
   contentClass: {
   contentClass: {
     type: String,
     type: String,
-    required: false
+    required: false,
   },
   },
   theme: {
   theme: {
     type: String,
     type: String,
@@ -51,46 +56,57 @@ const props = defineProps({
   maxWidth: {
   maxWidth: {
     type: [Number, String],
     type: [Number, String],
     required: false,
     required: false,
-    default: 800
-  }
+    default: 800,
+  },
 })
 })
+
+// @ts-ignore  -> just to avoid the error with the prop's type of v-dialog
+const _show = computed(() => props.show) as boolean
 </script>
 </script>
 
 
 <style lang="scss" scoped>
 <style lang="scss" scoped>
-  .dialog-title {
-    padding-left: 40px;
+.dialog-title {
+  padding-left: 40px;
+  font-weight: normal;
+}
+
+.dialog-type {
+  width: 60px;
+  min-width: 60px;
+  max-width: 60px;
+  min-height: 120px;
+  padding: 25px 10px;
+
+  h3 {
+    font-size: 25px;
     font-weight: normal;
     font-weight: normal;
+    writing-mode: vertical-lr;
+    transform: rotate(-180deg);
   }
   }
+}
 
 
-  .dialog-type {
-    width: 60px;
-    min-width: 60px;
-    max-width: 60px;
-    min-height: 400px;
-    padding: 25px 10px;
+.dialog-container {
+  overflow-x: scroll;
+}
 
 
-   h3 {
-     font-size: 25px;
-     font-weight: normal;
-     writing-mode: vertical-lr;
-     transform: rotate(-180deg);
-    }
-  }
+.dialog-text-container {
+  max-height: 70vh;
+  overflow: auto;
+}
 
 
-  .dialog-text-container {
-    max-height: 70vh;
-    overflow: auto;
+.modal-level-alert {
+  .dialog-type {
+    background: rgb(var(--v-theme-danger, #f56954));
   }
   }
+}
 
 
-  .modal-level-alert {
-    .dialog-type{
-      background: rgb(var(--v-theme-danger, #f56954));
-    }
+.modal-level-warning {
+  .dialog-type {
+    background: rgb(var(--v-theme-warning, #f39c12));
   }
   }
+}
 
 
-  .modal-level-warning {
-    .dialog-type{
-      background: rgb(var(--v-theme-warning, #f39c12));
-    }
-  }
+:deep(.v-card-actions) {
+  min-height: 62px;
+}
 </style>
 </style>

+ 58 - 38
components/Layout/Header.vue

@@ -4,24 +4,32 @@ Contient entre autres le nom de l'organisation, l'accès à l'aide et aux préf
 -->
 -->
 
 
 <template>
 <template>
-  <v-app-bar
-      order="0"
-      density="compact"
-      class="theme-primary"
-  >
+  <v-app-bar order="0" density="compact" class="theme-primary">
     <template #prepend>
     <template #prepend>
       <v-app-bar-nav-icon
       <v-app-bar-nav-icon
-          v-if="hasMainMenu"
-          :icon="isMainMenuOpened ? 'mdi:mdi-menu-open' : 'mdi:mdi-menu'"
-          @click="toggleMainMenu"
+        v-if="hasMainMenu && layoutStore.name !== 'parameters'"
+        :icon="isMainMenuOpened ? 'mdi:mdi-menu-open' : 'mdi:mdi-menu'"
+        @click="toggleMainMenu"
       />
       />
+      <div v-else-if="hasParametersMenu && layoutStore.name === 'parameters'">
+        <v-app-bar-nav-icon v-if="mdAndUp" icon="fa fa-gear" />
+        <v-app-bar-nav-icon
+          v-else
+          :icon="isParametersMenuOpened ? 'mdi:mdi-menu-open' : 'mdi:mdi-menu'"
+          @click="toggleParametersMenu"
+        />
+      </div>
     </template>
     </template>
 
 
-    <v-toolbar-title v-if="mdAndUp" v-text="title"/>
+    <v-toolbar-title v-if="mdAndUp" v-text="title" />
 
 
-    <LayoutThemeSwitcher v-if="false" /> <!-- En attente validation PO -->
+    <LayoutThemeSwitcher v-if="false" />
+    <!-- En attente validation PO -->
 
 
-    <LayoutHeaderUniversalCreationCreateButton v-if="showUniversalButton" class="mr-3" />
+    <LayoutHeaderUniversalCreationCreateButton
+      v-if="showUniversalButton"
+      class="mr-3"
+    />
 
 
     <LayoutHeaderHomeBtn v-if="smAndUp" />
     <LayoutHeaderHomeBtn v-if="smAndUp" />
 
 
@@ -38,27 +46,34 @@ Contient entre autres le nom de l'organisation, l'accès à l'aide et aux préf
     <LayoutHeaderMenu name="Account" color="on-primary" icon="fas fa-sun" />
     <LayoutHeaderMenu name="Account" color="on-primary" icon="fas fa-sun" />
 
 
     <a
     <a
-        :href="runtimeConfig.supportUrl"
-        class="text-body pa-3 ml-2 theme-secondary text-decoration-none h-100"
-        target="_blank"
+      :href="runtimeConfig.supportUrl"
+      class="text-body px-3 py-4 ml-2 theme-secondary text-decoration-none h-100"
+      target="_blank"
     >
     >
       <span class="d-none d-sm-none d-md-flex">{{ $t('help_access') }}</span>
       <span class="d-none d-sm-none d-md-flex">{{ $t('help_access') }}</span>
-      <v-icon icon="fas fa-question-circle" class="d-sm-flex d-md-none" color="on-secondary" />
+      <v-icon
+        icon="fas fa-question-circle"
+        class="d-sm-flex d-md-none"
+        color="on-secondary"
+      />
     </a>
     </a>
   </v-app-bar>
   </v-app-bar>
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-
-import {computed, ComputedRef} from "@vue/reactivity";
-import {useMenu} from "~/composables/layout/useMenu";
-import {useAbility} from "@casl/vue";
-import {useDisplay, useTheme} from 'vuetify'
-import {useOrganizationProfileStore} from "~/stores/organizationProfile";
+import { computed } from '@vue/reactivity'
+import type { ComputedRef } from '@vue/reactivity'
+import { useMenu } from '~/composables/layout/useMenu'
+import { useAbility } from '@casl/vue'
+import { useDisplay } from 'vuetify'
+import { useOrganizationProfileStore } from '~/stores/organizationProfile'
+import { useLayoutStore } from '~/stores/layout'
 
 
 const organizationProfile = useOrganizationProfileStore()
 const organizationProfile = useOrganizationProfileStore()
 const runtimeConfig = useRuntimeConfig()
 const runtimeConfig = useRuntimeConfig()
-const title: ComputedRef<string> = computed(() => organizationProfile.name ?? 'Opentalent')
+const title: ComputedRef<string> = computed(
+  () => organizationProfile.name ?? 'Opentalent',
+)
 
 
 const { hasMenu, isMenuOpened, toggleMenu } = useMenu()
 const { hasMenu, isMenuOpened, toggleMenu } = useMenu()
 
 
@@ -68,25 +83,30 @@ const hasMainMenu = computed(() => hasMenu('Main'))
 const isMainMenuOpened = computed(() => isMenuOpened('Main'))
 const isMainMenuOpened = computed(() => isMenuOpened('Main'))
 const toggleMainMenu = () => toggleMenu('Main')
 const toggleMainMenu = () => toggleMenu('Main')
 
 
+const hasParametersMenu = computed(() => hasMenu('Parameters'))
+const isParametersMenuOpened = computed(() => isMenuOpened('Parameters'))
+const toggleParametersMenu = () => toggleMenu('Parameters')
+
 const ability = useAbility()
 const ability = useAbility()
 const showUniversalButton =
 const showUniversalButton =
-    ability.can('manage', 'users')
-    || ability.can('manage', 'courses')
-    || ability.can('manage', 'examens')
-    || ability.can('manage', 'educationalprojects')
-    || ability.can('manage', 'events')
-    || ability.can('manage', 'emails')
-    || ability.can('manage', 'mails')
-    || ability.can('manage', 'texto')
-    || ability.can('display', 'message_send_page')
-    || ability.can('manage', 'equipments')
-
+  ability.can('manage', 'users') ||
+  ability.can('manage', 'courses') ||
+  ability.can('manage', 'examens') ||
+  ability.can('manage', 'educationalprojects') ||
+  ability.can('manage', 'events') ||
+  ability.can('manage', 'emails') ||
+  ability.can('manage', 'mails') ||
+  ability.can('manage', 'texto') ||
+  ability.can('display', 'message_send_page') ||
+  ability.can('manage', 'equipments')
+
+const layoutStore = useLayoutStore()
 </script>
 </script>
 
 
 <style scoped>
 <style scoped>
-  .help {
-    padding: 14px 14px 13px;
-    font-size: 14px;
-    text-decoration: none;
-  }
+.help {
+  padding: 14px 14px 13px;
+  font-size: 14px;
+  text-decoration: none;
+}
 </style>
 </style>

+ 11 - 16
components/Layout/Header/HomeBtn.vue

@@ -1,29 +1,24 @@
 <template>
 <template>
   <div>
   <div>
     <v-btn
     <v-btn
-        ref="btn"
-        icon="fas fa-home"
-        size="small"
-        :href="homeUrl"
-        class="on-primary"
+      ref="btn"
+      icon="fas fa-home"
+      size="small"
+      :href="homeUrl"
+      class="on-primary"
     />
     />
     <v-tooltip :activator="btn" :text="$t('welcome')" location="bottom" />
     <v-tooltip :activator="btn" :text="$t('welcome')" location="bottom" />
   </div>
   </div>
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-  import {ref} from "@vue/reactivity";
-  import {useDisplay} from "vuetify";
-  import UrlUtils from "~/services/utils/urlUtils";
+import { ref } from '@vue/reactivity'
+import { useDisplay } from 'vuetify'
+import { useHomeUrl } from '~/composables/utils/useHomeUrl'
 
 
-  const runtimeConfig = useRuntimeConfig()
-  const homeUrl = UrlUtils.join(runtimeConfig.baseUrlAdminLegacy, '#', 'dashboard')
+const { homeUrl } = useHomeUrl()
 
 
-  const { mdAndUp } = useDisplay()
-
-  const btn = ref(null);
+const btn = ref(null)
 </script>
 </script>
 
 
-<style scoped>
-
-</style>
+<style scoped></style>

+ 57 - 57
components/Layout/Header/Menu.vue

@@ -5,79 +5,79 @@ header principal (configuration, paramètres du compte...)
 
 
 <template>
 <template>
   <div v-if="displayMenu">
   <div v-if="displayMenu">
-
-    <v-btn
-        ref="btn"
-        icon
-        size="small"
-        class="ml-2"
-    >
+    <v-btn ref="btn" icon size="small" class="ml-2">
       <v-avatar
       <v-avatar
-          v-if="menu.icon.avatarId || menu.icon.avatarByDefault"
-          size="30"
+        v-if="menu.icon.avatarId || menu.icon.avatarByDefault"
+        size="30"
       >
       >
         <UiImage
         <UiImage
-            :id="menu.icon.avatarId"
-            :defaultImage="menu.icon.avatarByDefault"
-            :width="30"
+          :imageId="menu.icon.avatarId"
+          :defaultImage="menu.icon.avatarByDefault"
+          :width="30"
         />
         />
       </v-avatar>
       </v-avatar>
 
 
-      <v-icon
-          v-else
-          :icon="menu.icon.name"
-          class="on-primary"
-      />
+      <v-icon v-else :icon="menu.icon.name" class="on-primary" />
     </v-btn>
     </v-btn>
 
 
-    <v-tooltip
-        :activator="btn"
-        :text="$t(menu.label)"
-        location="bottom"
-    />
+    <v-tooltip :activator="btn" :text="$t(menu.label)" location="bottom" />
 
 
     <v-menu
     <v-menu
-        :activator="btn"
-        :model-value="isOpened()"
-        @update:modelValue="onStateUpdated"
+      :activator="btn"
+      :model-value="isOpened()"
+      @update:modelValue="onStateUpdated"
     >
     >
       <v-card>
       <v-card>
         <v-card-title class="theme-neutral text-body-2 font-weight-bold">
         <v-card-title class="theme-neutral text-body-2 font-weight-bold">
-          {{$t(menu.label)}}
+          {{ $t(menu.label) }}
         </v-card-title>
         </v-card-title>
 
 
         <v-card-text class="ma-0 pa-0 header-menu">
         <v-card-text class="ma-0 pa-0 header-menu">
           <v-list density="compact" :subheader="true">
           <v-list density="compact" :subheader="true">
             <template v-for="(child, index) in menu.children" :key="index">
             <template v-for="(child, index) in menu.children" :key="index">
               <v-list-item
               <v-list-item
-                  :id="child.label"
-                  :href="!isInternalLink(child) ? child.to : undefined"
-                  :to="isInternalLink(child) ? child.to : undefined"
+                :id="child.label"
+                :href="!isInternalLink(child) ? child.to : undefined"
+                :to="isInternalLink(child) ? child.to : undefined"
               >
               >
                 <span v-if="child.icon" class="pr-2 d-flex align-center">
                 <span v-if="child.icon" class="pr-2 d-flex align-center">
-                  <v-avatar v-if="menu.icon.avatarId || child.icon.avatarByDefault" size="30" >
-                    <UiImage :id="child.icon.avatarId" :defaultImage="child.icon.avatarByDefault" :width="30" />
+                  <v-avatar
+                    v-if="menu.icon.avatarId || child.icon.avatarByDefault"
+                    size="30"
+                  >
+                    <UiImage
+                      :imageId="child.icon.avatarId"
+                      :defaultImage="child.icon.avatarByDefault"
+                      :width="30"
+                    />
                   </v-avatar>
                   </v-avatar>
                   <v-icon v-else class="on-primary" size="small">
                   <v-icon v-else class="on-primary" size="small">
                     {{ child.icon.name }}
                     {{ child.icon.name }}
                   </v-icon>
                   </v-icon>
                 </span>
                 </span>
 
 
-                <span>{{ translateLabel ? $t(child.label) : child.label }}</span>
+                <span>{{
+                  translateLabel ? $t(child.label) : child.label
+                }}</span>
               </v-list-item>
               </v-list-item>
-
             </template>
             </template>
           </v-list>
           </v-list>
         </v-card-text>
         </v-card-text>
 
 
-        <v-card-actions v-if="menu.actions.length > 0" class="ma-0 pa-0 theme-primary">
+        <v-card-actions
+          v-if="menu.actions.length > 0"
+          class="ma-0 pa-0 theme-primary"
+        >
           <template v-for="(action, index) in menu.actions" :key="index">
           <template v-for="(action, index) in menu.actions" :key="index">
             <v-list-item
             <v-list-item
-                :id="action.label"
-                :href="!isInternalLink(action) ? action.to : undefined"
-                :to="isInternalLink(action) ? action.to : undefined"
+              :id="action.label"
+              :href="!isInternalLink(action) ? action.to : undefined"
+              :to="isInternalLink(action) ? action.to : undefined"
             >
             >
-              <v-list-item-title class="text-body-2" v-text="$t(action.label)"/>
+              <v-list-item-title
+                class="text-body-2"
+                v-text="$t(action.label)"
+              />
             </v-list-item>
             </v-list-item>
           </template>
           </template>
         </v-card-actions>
         </v-card-actions>
@@ -87,22 +87,23 @@ header principal (configuration, paramètres du compte...)
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import {useMenu} from "~/composables/layout/useMenu";
-import {computed, ref} from "@vue/reactivity";
+import { useMenu } from '~/composables/layout/useMenu'
+import { computed, ref } from '@vue/reactivity'
 
 
 const props = defineProps({
 const props = defineProps({
   name: {
   name: {
     type: String,
     type: String,
-    required: true
+    required: true,
   },
   },
   translateLabel: {
   translateLabel: {
     type: Boolean,
     type: Boolean,
     required: false,
     required: false,
-    default: true
-  }
+    default: true,
+  },
 })
 })
 
 
-const { getMenu, isInternalLink, hasMenu, setMenuState, isMenuOpened } = useMenu()
+const { getMenu, isInternalLink, hasMenu, setMenuState, isMenuOpened } =
+  useMenu()
 
 
 const menu = getMenu(props.name)
 const menu = getMenu(props.name)
 const displayMenu = computed(() => hasMenu(props.name))
 const displayMenu = computed(() => hasMenu(props.name))
@@ -113,23 +114,22 @@ const onStateUpdated = (e: any) => {
 }
 }
 
 
 const btn = ref(null)
 const btn = ref(null)
-
 </script>
 </script>
 
 
 <style scoped lang="scss">
 <style scoped lang="scss">
-  :deep(.v-btn .v-icon) {
-    font-size: 1rem !important;
-  }
+:deep(.v-btn .v-icon) {
+  font-size: 1rem !important;
+}
 
 
-  .v-list {
-    padding: 0;
-  }
+.v-list {
+  padding: 0;
+}
 
 
-  .v-list-item {
-    width: 100%;
-  }
+.v-list-item {
+  width: 100%;
+}
 
 
-  .header-menu .v-list .v-list-item:last-child {
-    border-bottom: none;
-  }
+.header-menu .v-list .v-list-item:last-child {
+  border-bottom: none;
+}
 </style>
 </style>

+ 95 - 93
components/Layout/Header/Notification.vue

@@ -1,19 +1,13 @@
 <template>
 <template>
-  <v-btn
-      ref="btn"
-      icon
-      size="small"
-      class="ml-2"
-  >
+  <v-btn ref="btn" icon size="small" class="ml-2">
     <v-badge
     <v-badge
-        color="warning"
-        offset-x="-4"
-        offset-y="17"
-        :model-value="unreadNotification.length > 0"
-        :content="unreadNotification.length">
-      <v-icon class="on-primary">
-        fa fa-bell
-      </v-icon>
+      color="warning"
+      offset-x="-4"
+      offset-y="17"
+      :model-value="unreadNotification.length > 0"
+      :content="unreadNotification.length"
+    >
+      <v-icon class="on-primary"> fa fa-bell </v-icon>
     </v-badge>
     </v-badge>
   </v-btn>
   </v-btn>
 
 
@@ -22,10 +16,10 @@
   </v-tooltip>
   </v-tooltip>
 
 
   <v-menu
   <v-menu
-      v-if="btn !== null"
-      :activator="btn"
-      v-model="isOpen"
-      location="bottom left"
+    v-if="btn !== null"
+    v-model="isOpen"
+    :activator="btn"
+    location="bottom left"
   >
   >
     <v-card max-width="400">
     <v-card max-width="400">
       <v-card-title class="bg-neutral text-body-2 font-weight-bold">
       <v-card-title class="bg-neutral text-body-2 font-weight-bold">
@@ -35,21 +29,23 @@
       <v-card-text class="ma-0 pa-0 header-menu">
       <v-card-text class="ma-0 pa-0 header-menu">
         <v-list density="compact" :subheader="true" class="pa-0">
         <v-list density="compact" :subheader="true" class="pa-0">
           <v-list-item
           <v-list-item
-              v-for="(notification, index) in notifications"
-              :key="index"
-              :class="'list_item py-3' + `${notification.notificationUsers.length === 0 ? ' unread' : ''}`"
+            v-for="(notification, index) in notifications"
+            :key="index"
+            :class="
+              'list_item py-3' +
+              `${notification.notificationUsers.length === 0 ? ' unread' : ''}`
+            "
           >
           >
             <span class="">{{ getMessage(notification) }}</span>
             <span class="">{{ getMessage(notification) }}</span>
 
 
             <template #append>
             <template #append>
               <v-icon
               <v-icon
-                  v-if="notification.link"
-                  icon="mdi:mdi-download"
-                  @click="download(notification.link)"
-                  class="pt-4"
+                v-if="notification.link"
+                icon="mdi:mdi-download"
+                class="pt-4"
+                @click="download(notification.link)"
               />
               />
             </template>
             </template>
-
           </v-list-item>
           </v-list-item>
 
 
           <v-divider></v-divider>
           <v-divider></v-divider>
@@ -58,19 +54,14 @@
           <span v-intersect="onLastNotificationIntersect" />
           <span v-intersect="onLastNotificationIntersect" />
 
 
           <v-row
           <v-row
-              v-if="pending"
-              class="fill-height mt-3 mb-3"
-              align="center"
-              justify="center"
+            v-if="pending"
+            class="fill-height mt-3 mb-3"
+            align="center"
+            justify="center"
           >
           >
-            <v-progress-circular
-                indeterminate
-                color="neutral"
-            />
+            <v-progress-circular indeterminate color="neutral" />
           </v-row>
           </v-row>
-
         </v-list>
         </v-list>
-
       </v-card-text>
       </v-card-text>
 
 
       <v-card-actions class="ma-0 pa-0">
       <v-card-actions class="ma-0 pa-0">
@@ -79,12 +70,11 @@
           :href="notificationUrl"
           :href="notificationUrl"
           router
           router
           class="theme-primary"
           class="theme-primary"
-          style="width: 100%; height: 52px;"
+          style="width: 100%; height: 52px"
         >
         >
-          <v-list-item-title
-              class="text-body-2"
-              v-text="$t('all_notification')"
-          />
+          <v-list-item-title class="text-body-2">
+            <span v-text="$t('all_notification')" />
+          </v-list-item-title>
         </v-list-item>
         </v-list-item>
       </v-card-actions>
       </v-card-actions>
     </v-card>
     </v-card>
@@ -92,23 +82,26 @@
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import {NOTIFICATION_TYPE} from "~/types/enum/enums";
-import Notification from "~/models/Core/Notification";
-import NotificationUsers from "~/models/Core/NotificationUsers";
-import {useAccessProfileStore} from "~/stores/accessProfile";
-import {computed, ComputedRef, Ref, ref} from "@vue/reactivity";
-import {useEntityFetch} from "~/composables/data/useEntityFetch";
-import {AnyJson, Pagination} from "~/types/data";
-import {useEntityManager} from "~/composables/data/useEntityManager";
-import UrlUtils from "~/services/utils/urlUtils";
-import {useRepo} from "pinia-orm";
-import NotificationRepository from "~/stores/repositories/NotificationRepository";
+import { computed, ref } from 'vue'
+import type { ComputedRef, Ref } from 'vue'
+import { useRepo } from 'pinia-orm'
+import { NOTIFICATION_TYPE } from '~/types/enum/enums'
+import Notification from '~/models/Core/Notification'
+import NotificationUsers from '~/models/Core/NotificationUsers'
+import { useAccessProfileStore } from '~/stores/accessProfile'
+import { useEntityFetch } from '~/composables/data/useEntityFetch'
+import type { AnyJson, Pagination } from '~/types/data'
+import { useEntityManager } from '~/composables/data/useEntityManager'
+import UrlUtils from '~/services/utils/urlUtils'
+import NotificationRepository from '~/stores/repositories/NotificationRepository'
+import Query from '~/services/data/Query'
+import PageFilter from '~/services/data/Filters/PageFilter'
 
 
 const accessProfileStore = useAccessProfileStore()
 const accessProfileStore = useAccessProfileStore()
 
 
-const loading: Ref<Boolean> = ref(true)
-const isOpen: Ref<Boolean> = ref(false)
+const isOpen: Ref<boolean> = ref(false)
 const page: Ref<number> = ref(1)
 const page: Ref<number> = ref(1)
+const itemsPerPage: Ref<number> = ref(5)
 
 
 const i18n = useI18n()
 const i18n = useI18n()
 const runtimeConfig = useRuntimeConfig()
 const runtimeConfig = useRuntimeConfig()
@@ -119,14 +112,16 @@ const { em } = useEntityManager()
 const { fetchCollection } = useEntityFetch()
 const { fetchCollection } = useEntityFetch()
 const notificationRepo = useRepo(NotificationRepository)
 const notificationRepo = useRepo(NotificationRepository)
 
 
-const query: ComputedRef<AnyJson> = computed(() => {
-  return { 'page': page.value }
-})
+const query = new Query(new PageFilter(page, itemsPerPage))
 
 
-let { data: collection, pending, refresh } = await fetchCollection(Notification, null, query)
+const {
+  data: collection,
+  pending,
+  refresh,
+} = fetchCollection(Notification, null, query)
 
 
 /**
 /**
- * On récupère les Notifications via le store (sans ça, les mises à jour SSE ne seront pas prises en compte)
+ * On récupère les Notifications via le store
  */
  */
 const notifications: ComputedRef<Array<Notification>> = computed(() => {
 const notifications: ComputedRef<Array<Notification>> = computed(() => {
   return notificationRepo.getNotifications()
   return notificationRepo.getNotifications()
@@ -143,10 +138,13 @@ const unreadNotification: ComputedRef<Array<Notification>> = computed(() => {
  * Les metadata dépendront de la dernière valeur du GET lancé
  * Les metadata dépendront de la dernière valeur du GET lancé
  */
  */
 const pagination: ComputedRef<Pagination> = computed(() => {
 const pagination: ComputedRef<Pagination> = computed(() => {
-  return (!pending.value && collection.value !== null) ? collection.value.pagination : {}
+  return collection.value !== null ? collection.value.pagination : {}
 })
 })
 
 
-const notificationUrl = UrlUtils.join(runtimeConfig.baseUrlAdminLegacy, '#/notifications/list/')
+const notificationUrl = UrlUtils.join(
+  runtimeConfig.baseUrlAdminLegacy,
+  '#/notifications/list/',
+)
 
 
 /**
 /**
  * L'utilisateur a fait défiler le menu jusqu'à la dernière notification affichée
  * L'utilisateur a fait défiler le menu jusqu'à la dernière notification affichée
@@ -164,10 +162,10 @@ const onLastNotificationIntersect = (isIntersecting: boolean) => {
  */
  */
 const update = async () => {
 const update = async () => {
   if (
   if (
-      !pending.value &&
-      pagination.value &&
-      pagination.value.next &&
-      pagination.value.next > 0
+    !pending.value &&
+    pagination.value &&
+    pagination.value.next &&
+    pagination.value.next > 0
   ) {
   ) {
     pending.value = true
     pending.value = true
     page.value = pagination.value.next
     page.value = pagination.value.next
@@ -184,20 +182,26 @@ const update = async () => {
  * @param notification
  * @param notification
  */
  */
 const getMessage = (notification: Notification) => {
 const getMessage = (notification: Notification) => {
-  switch (notification.type){
-    case NOTIFICATION_TYPE.FILE :
-      return `${i18n.t('your_file')} ${notification.message?.fileName} ${i18n.t('is_ready_to_be_downloaded')}`
+  switch (notification.type) {
+    case NOTIFICATION_TYPE.FILE:
+      return `${i18n.t('your_file')} ${notification.message?.fileName} ${i18n.t(
+        'is_ready_to_be_downloaded',
+      )}`
 
 
     case NOTIFICATION_TYPE.MESSAGE:
     case NOTIFICATION_TYPE.MESSAGE:
       if (notification.message?.action)
       if (notification.message?.action)
-        return `${i18n.t('your_message')} ${notification.message?.fileName} ${i18n.t('is_ready_to_be')} ${notification.message.action}`
+        return `${i18n.t('your_message')} ${
+          notification.message?.fileName
+        } ${i18n.t('is_ready_to_be')} ${notification.message.action}`
 
 
-      return `${i18n.t('your_message')} ${notification.message?.about ?? ''} ${i18n.t('has_been_sent')} `
+      return `${i18n.t('your_message')} ${
+        notification.message?.about ?? ''
+      } ${i18n.t('has_been_sent')} `
 
 
-    case NOTIFICATION_TYPE.SYSTEM :
+    case NOTIFICATION_TYPE.SYSTEM:
       if (notification.message?.about)
       if (notification.message?.about)
         return `${i18n.t(notification.message.about)}`
         return `${i18n.t(notification.message.about)}`
-      break;
+      break
 
 
     default:
     default:
       return i18n.t(notification.name)
       return i18n.t(notification.name)
@@ -207,8 +211,8 @@ const getMessage = (notification: Notification) => {
 /**
 /**
  * Dès la fermeture du menu, on indique que les notifications non lues, le sont.
  * Dès la fermeture du menu, on indique que les notifications non lues, le sont.
  */
  */
-const unwatch = watch(isOpen, (newValue, oldValue) => {
-  if (!newValue){
+const unwatch = watch(isOpen, (newValue, _) => {
+  if (!newValue) {
     markNotificationsAsRead()
     markNotificationsAsRead()
   }
   }
 })
 })
@@ -224,9 +228,9 @@ const markNotificationAsRead = (notification: Notification) => {
     throw new Error('Current access id is null')
     throw new Error('Current access id is null')
   }
   }
   const notificationUsers = em.newInstance(NotificationUsers, {
   const notificationUsers = em.newInstance(NotificationUsers, {
-    access:`/api/accesses/${accessProfileStore.switchId ?? accessProfileStore.id}`,
-    notification:`/api/notifications/${notification.id}`,
-    isRead: true
+    access: `/api/accesses/${accessProfileStore.currentAccessId}`,
+    notification: `/api/notifications/${notification.id}`,
+    isRead: true,
   })
   })
 
 
   em.persist(NotificationUsers, notificationUsers)
   em.persist(NotificationUsers, notificationUsers)
@@ -239,7 +243,7 @@ const markNotificationAsRead = (notification: Notification) => {
  */
  */
 const markNotificationsAsRead = () => {
 const markNotificationsAsRead = () => {
   unreadNotification.value.map((notification: Notification) => {
   unreadNotification.value.map((notification: Notification) => {
-    markNotificationAsRead(notification)
+    return markNotificationAsRead(notification)
   })
   })
 }
 }
 
 
@@ -253,29 +257,27 @@ const download = (link: string) => {
   }
   }
   // TODO: passer cette logique dans un service ; tester ; voir si possible de réunir avec composables/utils/useDownloadFile.ts
   // TODO: passer cette logique dans un service ; tester ; voir si possible de réunir avec composables/utils/useDownloadFile.ts
 
 
-  const path: string = link.split('/api')[1];
+  const path: string = link.split('/api')[1]
 
 
   // En switch : https://api.test5.opentalent.fr/api/{accessId}/{switchId}/files/{fileId}/download
   // En switch : https://api.test5.opentalent.fr/api/{accessId}/{switchId}/files/{fileId}/download
   // Sans switch : https://local.api.opentalent.fr/api/{accessId}/files/{fileId}/download
   // Sans switch : https://local.api.opentalent.fr/api/{accessId}/files/{fileId}/download
   const url = UrlUtils.join(
   const url = UrlUtils.join(
-      runtimeConfig.baseUrlLegacy,
-      'api',
-      String(accessProfileStore.id),
-      String(accessProfileStore.switchId || ''),
-      path
+    runtimeConfig.baseUrlLegacy,
+    'api',
+    String(accessProfileStore.id),
+    String(accessProfileStore.switchId || ''),
+    path,
   )
   )
 
 
-  window.open(url);
+  window.open(url)
 }
 }
-
-
 </script>
 </script>
 
 
 <style scoped lang="scss">
 <style scoped lang="scss">
-  .list_item{
-    white-space: normal;
-  }
-  .unread{
-    background: rgb(var(--v-theme-neutral-soft, white));
-  }
+.list_item {
+  white-space: normal;
+}
+.unread {
+  background: rgb(var(--v-theme-neutral-soft, white));
+}
 </style>
 </style>

+ 91 - 91
components/Layout/Header/UniversalCreation/Card.vue

@@ -12,22 +12,22 @@
   <v-card
   <v-card
     class="col-md-6"
     class="col-md-6"
     color=""
     color=""
-    flat
+    :flat="true"
     border="solid 1px"
     border="solid 1px"
     @click="onClick"
     @click="onClick"
   >
   >
-    <v-row no-gutters style="height: 100px">
+    <v-row :no-gutters="true" style="height: 100px">
       <v-col cols="3" class="flex-grow-0 flex-shrink-0 d-flex justify-center">
       <v-col cols="3" class="flex-grow-0 flex-shrink-0 d-flex justify-center">
         <v-icon
         <v-icon
-            :icon="icon"
-            size="50"
-            class="ma-2 pa-2 align-self-center text-neutral-strong"
+          :icon="icon"
+          size="50"
+          class="ma-2 pa-2 align-self-center text-neutral-strong"
         />
         />
       </v-col>
       </v-col>
       <v-col
       <v-col
-          cols="9"
-          align-self="center"
-          class="pl-2 infos-container flex-grow-1 flex-shrink-1"
+        cols="9"
+        align-self="center"
+        class="pl-2 infos-container flex-grow-1 flex-shrink-1"
       >
       >
         <h4 class="text-primary">{{ $t(title) }}</h4>
         <h4 class="text-primary">{{ $t(title) }}</h4>
         <p class="text-neutral-strong">
         <p class="text-neutral-strong">
@@ -39,100 +39,100 @@
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-  import {PropType} from "@vue/runtime-core";
-  import {MENU_LINK_TYPE} from "~/types/enum/layout";
-  import {useAdminUrl} from "~/composables/utils/useAdminUrl";
-  import UrlUtils from "~/services/utils/urlUtils";
+import type { PropType } from '@vue/runtime-core'
+import { MENU_LINK_TYPE } from '~/types/enum/layout'
+import { useAdminUrl } from '~/composables/utils/useAdminUrl'
+import UrlUtils from '~/services/utils/urlUtils'
 
 
-  const props = defineProps({
-    /**
-     * Target location in the wizard
-     */
-    to: {
-      type: String,
-      required: false,
-      default: null
-    },
-    /**
-     * Target url
-     */
-    href: {
-      type: String,
-      required: false,
-      default: null
-    },
-    /**
-     * Target url
-     */
-    linkType: {
-      type: Number as PropType<MENU_LINK_TYPE>,
-      required: false,
-      default: MENU_LINK_TYPE.V1
-    },
-    /**
-     * Title displayed on the card
-     */
-    title: {
-      type: String,
-      required: true
-    },
-    /**
-     * Description displayed on the card
-     */
-    textContent: {
-      type: String,
-      required: true
-    },
-    /**
-     * Icon displayed on the card
-     */
-    icon: {
-      type: String,
-      required: true
-    }
-  })
+const props = defineProps({
+  /**
+   * Target location in the wizard
+   */
+  to: {
+    type: String,
+    required: false,
+    default: null,
+  },
+  /**
+   * Target url
+   */
+  href: {
+    type: String,
+    required: false,
+    default: null,
+  },
+  /**
+   * Target url
+   */
+  linkType: {
+    type: Number as PropType<MENU_LINK_TYPE>,
+    required: false,
+    default: MENU_LINK_TYPE.V1,
+  },
+  /**
+   * Title displayed on the card
+   */
+  title: {
+    type: String,
+    required: true,
+  },
+  /**
+   * Description displayed on the card
+   */
+  textContent: {
+    type: String,
+    required: true,
+  },
+  /**
+   * Icon displayed on the card
+   */
+  icon: {
+    type: String,
+    required: true,
+  },
+})
 
 
-  const emit = defineEmits(['click'])
+const emit = defineEmits(['click'])
 
 
-  const { makeAdminUrl } = useAdminUrl()
+const { makeAdminUrl } = useAdminUrl()
 
 
-  let url: string | null = null;
+let url: string | null = null
 
 
-  if (props.href !== null) {
-    switch (props.linkType) {
-      case MENU_LINK_TYPE.V1:
-        url = makeAdminUrl(props.href)
-        break;
-      case MENU_LINK_TYPE.EXTERNAL:
-        url = UrlUtils.prependHttps(props.href)
-        break;
-      default:
-        url = props.href
-    }
+if (props.href !== null) {
+  switch (props.linkType) {
+    case MENU_LINK_TYPE.V1:
+      url = makeAdminUrl(props.href)
+      break
+    case MENU_LINK_TYPE.EXTERNAL:
+      url = UrlUtils.prependHttps(props.href)
+      break
+    default:
+      url = props.href
   }
   }
+}
 
 
-  const onClick = () => {
-    emit('click', props.to, url)
-  }
+const onClick = () => {
+  emit('click', props.to, url)
+}
 </script>
 </script>
 
 
 <style lang="scss" scoped>
 <style lang="scss" scoped>
-  h4 {
-    font-size: 15px;
-    font-weight: bold;
-    margin-bottom: 6px;
-  }
+h4 {
+  font-size: 15px;
+  font-weight: bold;
+  margin-bottom: 6px;
+}
 
 
-  p {
-    font-size: 13px;
-  }
+p {
+  font-size: 13px;
+}
 
 
-  .infos-container {
-    padding: 15px 0;
-  }
+.infos-container {
+  padding: 15px 0;
+}
 
 
-  .v-card:hover {
-    cursor: pointer;
-    background: rgb(var(--v-theme-primary-alt));
-  }
+.v-card:hover {
+  cursor: pointer;
+  background: rgb(var(--v-theme-primary-alt));
+}
 </style>
 </style>

+ 153 - 136
components/Layout/Header/UniversalCreation/CreateButton.vue

@@ -5,56 +5,74 @@
 <template>
 <template>
   <main>
   <main>
     <v-btn
     <v-btn
-        v-if="asIcon"
-        :elevation="0"
-        class="theme-primary"
-        :icon="true"
-        size="small"
-        @click="show"
+      v-if="asIcon"
+      :elevation="0"
+      class="theme-primary"
+      :icon="true"
+      size="small"
+      @click="show"
     >
     >
       <v-icon>fas fa-plus</v-icon>
       <v-icon>fas fa-plus</v-icon>
     </v-btn>
     </v-btn>
 
 
     <v-btn
     <v-btn
-        v-else
-        :elevation="2"
-        height="30"
-        class="theme-x-create-btn"
-        @click="show"
+      v-else
+      :elevation="2"
+      height="30"
+      class="theme-x-create-btn"
+      @click="show"
     >
     >
       <span>{{ $t('create') }}</span>
       <span>{{ $t('create') }}</span>
     </v-btn>
     </v-btn>
 
 
-    <LayoutDialog :show="showCreateDialog" :max-width="850" >
+    <LayoutDialog :show="showCreateDialog" :max-width="850">
       <template #dialogType>{{ $t('creative_assistant') }}</template>
       <template #dialogType>{{ $t('creative_assistant') }}</template>
 
 
       <template #dialogTitle>
       <template #dialogTitle>
-        <span v-if="location === 'home'">{{ $t('what_do_you_want_to_create') }}</span>
-        <span v-else-if="location === 'access'">{{ $t('what_type_of_contact_do_you_want_to_create') }}</span>
-        <span v-else-if="location === 'event'">{{ $t('what_do_you_want_to_add_to_your_planning') }}</span>
-        <span v-else-if="location === 'message'">{{ $t('what_do_you_want_to_send') }}</span>
-        <span v-else-if="location === 'event-params'">{{ $t('which_date_and_which_hour') }}</span>
+        <span v-if="location === 'home'">{{
+          $t('what_do_you_want_to_create')
+        }}</span>
+        <span v-else-if="location === 'access'">{{
+          $t('what_type_of_contact_do_you_want_to_create')
+        }}</span>
+        <span v-else-if="location === 'event'">{{
+          $t('what_do_you_want_to_add_to_your_planning')
+        }}</span>
+        <span v-else-if="location === 'message'">{{
+          $t('what_do_you_want_to_send')
+        }}</span>
+        <span v-else-if="location === 'event-params'">{{
+          $t('which_date_and_which_hour')
+        }}</span>
       </template>
       </template>
 
 
       <template #dialogText>
       <template #dialogText>
-         <LayoutHeaderUniversalCreationGenerateCardsSteps
-             :path="path"
-             @cardClick="onCardClick"
-             @urlUpdate="onUrlUpdate"
-         />
+        <LayoutHeaderUniversalCreationGenerateCardsSteps
+          :path="path"
+          @cardClick="onCardClick"
+          @urlUpdate="onUrlUpdate"
+        />
       </template>
       </template>
 
 
       <template #dialogBtn>
       <template #dialogBtn>
         <div class="text-center">
         <div class="text-center">
-          <v-btn class="theme-neutral-soft" @click="close" >
+          <v-btn class="theme-neutral-soft" @click="close">
             {{ $t('cancel') }}
             {{ $t('cancel') }}
           </v-btn>
           </v-btn>
 
 
-          <v-btn v-if="path.length > 1" class="theme-neutral-soft" @click="goToPrevious" >
+          <v-btn
+            v-if="path.length > 1"
+            class="theme-neutral-soft"
+            @click="goToPrevious"
+          >
             {{ $t('previous_step') }}
             {{ $t('previous_step') }}
           </v-btn>
           </v-btn>
 
 
-          <v-btn v-if="targetUrl !== null && !directRedirectionOngoing" class="theme-primary" @click="validate" >
+          <v-btn
+            v-if="targetUrl !== null && !directRedirectionOngoing"
+            class="theme-primary"
+            @click="validate"
+          >
             {{ $t('validate') }}
             {{ $t('validate') }}
           </v-btn>
           </v-btn>
         </div>
         </div>
@@ -64,121 +82,120 @@
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-  import {Ref, ref} from "@vue/reactivity";
-  import {useDisplay} from "vuetify";
-  import {ComputedRef} from "vue";
-  import {usePageStore} from "~/stores/page";
-
-  const { mdAndDown: asIcon } = useDisplay()
-
-  // Set to true to show the Create dialog
-  const showCreateDialog: Ref<boolean> = ref(false);
-
-  // The succession of menus the user has been through; used to keep track of the navigation
-  const path: Ref<Array<string>> = ref(['home'])
-
-  // The current menu
-  const location: ComputedRef<string> = computed(() => {
-    return path.value.at(-1) ?? 'home'
-  })
-
-  // The current target URL (@see onUrlUpdate())
-  const targetUrl: Ref<string | null> = ref(null)
-
-  // Already redirecting (to avoid the display of the 'validate' button when page has already been redirected and is loading)
-  const directRedirectionOngoing: Ref<boolean> = ref(false)
-
-  /**
-   * Return to the home menu
-   */
-  const reset = () => {
-    path.value = ['home']
-  }
-
-  /**
-   * Go back to the previous step
-   */
-  const goToPrevious = () => {
-    if (path.value.length === 1) {
-      return
-    }
-    path.value.pop()
-  }
-
-  /**
-   * Display the create dialog
-   */
-  const show = () => {
-    reset()
-    showCreateDialog.value = true
+import { ref } from '@vue/reactivity'
+import type { Ref } from '@vue/reactivity'
+import { useDisplay } from 'vuetify'
+import type { ComputedRef } from 'vue'
+import { usePageStore } from '~/stores/page'
+
+const { mdAndDown: asIcon } = useDisplay()
+
+// Set to true to show the Create dialog
+const showCreateDialog: Ref<boolean> = ref(false)
+
+// The succession of menus the user has been through; used to keep track of the navigation
+const path: Ref<Array<string>> = ref(['home'])
+
+// The current menu
+const location: ComputedRef<string> = computed(() => {
+  return path.value.at(-1) ?? 'home'
+})
+
+// The current target URL (@see onUrlUpdate())
+const targetUrl: Ref<string | null> = ref(null)
+
+// Already redirecting (to avoid the display of the 'validate' button when page has already been redirected and is loading)
+const directRedirectionOngoing: Ref<boolean> = ref(false)
+
+/**
+ * Return to the home menu
+ */
+const reset = () => {
+  path.value = ['home']
+}
+
+/**
+ * Go back to the previous step
+ */
+const goToPrevious = () => {
+  if (path.value.length === 1) {
+    return
   }
   }
-
-  const pageStore = usePageStore()
-
-  /**
-   * Redirect the user to the given url
-   * @param url
-   */
-  const redirect = (url: string) => {
-    pageStore.loading = true
-    window.location.href = url
-  }
-
-  /**
-   * Go to the current targetUrl
-   */
-  const validate = () => {
-    if (targetUrl.value === null) {
-      console.warn('No url defined')
-      return
-    }
-    redirect(targetUrl.value)
+  path.value.pop()
+}
+
+/**
+ * Display the create dialog
+ */
+const show = () => {
+  reset()
+  showCreateDialog.value = true
+}
+
+const pageStore = usePageStore()
+
+/**
+ * Redirect the user to the given url
+ * @param url
+ */
+const redirect = (url: string) => {
+  pageStore.loading = true
+  window.location.href = url
+}
+
+/**
+ * Go to the current targetUrl
+ */
+const validate = () => {
+  if (targetUrl.value === null) {
+    console.warn('No url defined')
+    return
   }
   }
-
-  /**
-   * Close the Create dialog
-   */
-  const close = () => {
-    showCreateDialog.value = false
-  }
-
-  /**
-   * A cart has been clicked. The reaction depends on the card's properties.
-   *
-   * @param to  Target location in the wizard
-   * @param href  Target absolute url
-   */
-  const onCardClick = (to: string | null, href: string | null) => {
-    if (to !== null) {
-      // La carte définit une nouvelle destination : on se dirige vers elle.
-      path.value.push(to)
-
-    } else if (href !== null) {
-      // La carte définit une url avec href, et pas de nouvelle destination : on suit directement le lien pour éviter
-      // l'étape de validation devenue inutile.
-      directRedirectionOngoing.value = true
-      redirect(href)
-
-    } else {
-      console.warn('Error: card has no `to` nor `href` defined')
-    }
-  }
-
-  /**
-   * The url has been updated in the GenerateCardsStep component
-   * @param url
-   */
-  const onUrlUpdate = (url: string) => {
-    targetUrl.value = url
+  redirect(targetUrl.value)
+}
+
+/**
+ * Close the Create dialog
+ */
+const close = () => {
+  showCreateDialog.value = false
+}
+
+/**
+ * A cart has been clicked. The reaction depends on the card's properties.
+ *
+ * @param to  Target location in the wizard
+ * @param href  Target absolute url
+ */
+const onCardClick = (to: string | null, href: string | null) => {
+  if (to !== null) {
+    // La carte définit une nouvelle destination : on se dirige vers elle.
+    path.value.push(to)
+  } else if (href !== null) {
+    // La carte définit une url avec href, et pas de nouvelle destination : on suit directement le lien pour éviter
+    // l'étape de validation devenue inutile.
+    directRedirectionOngoing.value = true
+    redirect(href)
+  } else {
+    console.warn('Error: card has no `to` nor `href` defined')
   }
   }
+}
+
+/**
+ * The url has been updated in the GenerateCardsStep component
+ * @param url
+ */
+const onUrlUpdate = (url: string) => {
+  targetUrl.value = url
+}
 </script>
 </script>
 
 
 <style scoped lang="scss">
 <style scoped lang="scss">
-  :deep(.v-btn .v-icon) {
-    font-size: 16px !important;
-  }
-  :deep(.v-btn) {
-    text-transform: none !important;
-    font-weight: 600;
-  }
+:deep(.v-btn .v-icon) {
+  font-size: 16px !important;
+}
+:deep(.v-btn) {
+  text-transform: none !important;
+  font-weight: 600;
+}
 </style>
 </style>

+ 72 - 61
components/Layout/Header/UniversalCreation/EventParams.vue

@@ -17,7 +17,10 @@ Event parameters page in the create dialog
     <v-row v-show="eventStart < now" class="anteriorDateWarning mt-0">
     <v-row v-show="eventStart < now" class="anteriorDateWarning mt-0">
       <v-col cols="2" class="pt-1"></v-col>
       <v-col cols="2" class="pt-1"></v-col>
       <v-col cols="9" class="pt-1">
       <v-col cols="9" class="pt-1">
-        <i class="fa fa-circle-info" /> {{ $t('please_note_that_this_reservation_start_on_date_anterior_to_now') }}
+        <i class="fa fa-circle-info" />
+        {{
+          $t('please_note_that_this_reservation_start_on_date_anterior_to_now')
+        }}
       </v-col>
       </v-col>
     </v-row>
     </v-row>
 
 
@@ -36,7 +39,6 @@ Event parameters page in the create dialog
         <UiInputNumber v-model="eventDurationMinutes" class="mx-3" :min="0" />
         <UiInputNumber v-model="eventDurationMinutes" class="mx-3" :min="0" />
         <span>{{ $t('minute(s)') }}</span>
         <span>{{ $t('minute(s)') }}</span>
       </v-col>
       </v-col>
-
     </v-row>
     </v-row>
 
 
     <v-row>
     <v-row>
@@ -52,68 +54,77 @@ Event parameters page in the create dialog
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-  import {ref, Ref} from "@vue/reactivity";
-  import {add, format, startOfHour, formatISO} from "date-fns";
-  import {ComputedRef} from "vue";
-  import DateUtils, {supportedLocales} from "~/services/utils/dateUtils";
-
-  const i18n = useI18n()
-
-  // An event is sent each time the resulting params are updated
-  const emit = defineEmits(['paramsUpdated'])
-
-  // Get the start of the next hour as a default event start
-  const now: Date = new Date()
-  const eventStart: Ref<Date> = ref(startOfHour(add(now, { 'hours': 1 })))
-
-  const eventDurationDays: Ref<number> = ref(0)
-  const eventDurationHours: Ref<number> = ref(1)
-  const eventDurationMinutes: Ref<number> = ref(0)
-
-  // Duration of the events, in minutes
-  const eventDuration: ComputedRef<number> = computed(() => {
-    return (eventDurationDays.value * 24 * 60) + (eventDurationHours.value * 60) + eventDurationMinutes.value
+import { ref } from '@vue/reactivity'
+import type { Ref } from '@vue/reactivity'
+import { add, format, startOfHour, formatISO } from 'date-fns'
+import type { ComputedRef } from 'vue'
+import DateUtils, { supportedLocales } from '~/services/utils/dateUtils'
+
+const i18n = useI18n()
+
+// An event is sent each time the resulting params are updated
+const emit = defineEmits(['paramsUpdated'])
+
+// Get the start of the next hour as a default event start
+const now: Date = new Date()
+const eventStart: Ref<Date> = ref(startOfHour(add(now, { hours: 1 })))
+
+const eventDurationDays: Ref<number> = ref(0)
+const eventDurationHours: Ref<number> = ref(1)
+const eventDurationMinutes: Ref<number> = ref(0)
+
+// Duration of the events, in minutes
+const eventDuration: ComputedRef<number> = computed(() => {
+  return (
+    eventDurationDays.value * 24 * 60 +
+    eventDurationHours.value * 60 +
+    eventDurationMinutes.value
+  )
+})
+
+// Event end
+const eventEnd: ComputedRef<Date> = computed(() =>
+  add(eventStart.value, { minutes: eventDuration.value }),
+)
+
+const fnsLocale = DateUtils.getFnsLocale(i18n.locale.value as supportedLocales)
+const formattedEventEnd: ComputedRef<string> = computed(() => {
+  return format(eventEnd.value, 'EEEE dd MMMM yyyy HH:mm', {
+    locale: fnsLocale,
   })
   })
+})
 
 
-  // Event end
-  const eventEnd: ComputedRef<Date> = computed(() => add(eventStart.value, { 'minutes': eventDuration.value }))
-
-  const fnsLocale = DateUtils.getFnsLocale(i18n.locale.value as supportedLocales)
-  const formattedEventEnd: ComputedRef<string> = computed(() => {
-    return format(eventEnd.value, 'EEEE dd MMMM yyyy HH:mm', {locale: fnsLocale})
-  })
-
-  // Build the event params
-  const params: ComputedRef<{'start': string, 'end': string}> = computed(() => {
-    return {
-      'start': formatISO(eventStart.value),
-      'end': formatISO(eventEnd.value),
-    }
-  })
-
-  // Send an update event as soon as the page is mounted
-  onMounted(() => {
-    emit('paramsUpdated', params.value)
-  })
-
-  // Send an update event every time the params change
-  const unwatch = watch(params, (newParams) => {
-    emit('paramsUpdated', newParams)
-  })
-  onUnmounted(() => {
-    unwatch()
-  })
+// Build the event params
+const params: ComputedRef<{ start: string; end: string }> = computed(() => {
+  return {
+    start: formatISO(eventStart.value),
+    end: formatISO(eventEnd.value),
+  }
+})
+
+// Send an update event as soon as the page is mounted
+onMounted(() => {
+  emit('paramsUpdated', params.value)
+})
+
+// Send an update event every time the params change
+const unwatch = watch(params, (newParams) => {
+  emit('paramsUpdated', newParams)
+})
+onUnmounted(() => {
+  unwatch()
+})
 </script>
 </script>
 
 
 <style scoped lang="scss">
 <style scoped lang="scss">
-  .endDate {
-    font-weight: 600;
-    text-transform: capitalize;
-    color: rgb(var(--v-theme-on-neutral));
-  }
-
-  .anteriorDateWarning {
-    color: rgb(var(--v-theme-info));
-    font-weight: 600;
-  }
+.endDate {
+  font-weight: 600;
+  text-transform: capitalize;
+  color: rgb(var(--v-theme-on-neutral));
+}
+
+.anteriorDateWarning {
+  color: rgb(var(--v-theme-info));
+  font-weight: 600;
+}
 </style>
 </style>

+ 190 - 179
components/Layout/Header/UniversalCreation/GenerateCardsSteps.vue

@@ -3,57 +3,67 @@
 -->
 -->
 
 
 <template>
 <template>
-
   <!-- Menu Accueil -->
   <!-- Menu Accueil -->
   <v-container v-if="location === 'home'">
   <v-container v-if="location === 'home'">
     <v-row>
     <v-row>
-
       <!-- Une personne -->
       <!-- Une personne -->
       <v-col cols="6" v-if="ability.can('manage', 'users')">
       <v-col cols="6" v-if="ability.can('manage', 'users')">
-          <LayoutHeaderUniversalCreationCard
-              to="access"
-              title="a_person"
-              text-content="add_new_person_student"
-              icon="fa fa-user"
-              @click="onCardClick"
-          />
+        <LayoutHeaderUniversalCreationCard
+          to="access"
+          title="a_person"
+          text-content="add_new_person_student"
+          icon="fa fa-user"
+          @click="onCardClick"
+        />
       </v-col>
       </v-col>
 
 
       <!-- Un évènement -->
       <!-- Un évènement -->
-      <v-col cols="6" v-if="ability.can('display', 'agenda_page')
-                && (
-                   ability.can('display', 'course_page') ||
-                   ability.can('display', 'exam_page') ||
-                   ability.can('display', 'pedagogics_project_page')
-                )">
+      <v-col
+        cols="6"
+        v-if="
+          ability.can('display', 'agenda_page') &&
+          (ability.can('display', 'course_page') ||
+            ability.can('display', 'exam_page') ||
+            ability.can('display', 'pedagogics_project_page'))
+        "
+      >
         <LayoutHeaderUniversalCreationCard
         <LayoutHeaderUniversalCreationCard
-            to="event"
-            title="an_event"
-            text-content="add_an_event_course"
-            icon="fa fa-calendar-alt"
-            @click="onCardClick"
+          to="event"
+          title="an_event"
+          text-content="add_an_event_course"
+          icon="fa fa-calendar-alt"
+          @click="onCardClick"
         />
         />
       </v-col>
       </v-col>
 
 
       <!-- Autre évènement -->
       <!-- Autre évènement -->
-      <v-col cols="6" v-else-if="ability.can('display', 'agenda_page') && ability.can('manage', 'events')">
+      <v-col
+        cols="6"
+        v-else-if="
+          ability.can('display', 'agenda_page') &&
+          ability.can('manage', 'events')
+        "
+      >
         <LayoutHeaderUniversalCreationCard
         <LayoutHeaderUniversalCreationCard
-            to="event-params"
-            title="other_event"
-            text-content="other_event_text_creation_card"
-            icon="far fa-calendar"
-            href="/calendar/create/events"
-            @click="onCardClick"
+          to="event-params"
+          title="other_event"
+          text-content="other_event_text_creation_card"
+          icon="far fa-calendar"
+          href="/calendar/create/events"
+          @click="onCardClick"
         />
         />
       </v-col>
       </v-col>
 
 
       <!-- Une correspondance -->
       <!-- Une correspondance -->
-      <v-col cols="6" v-if="ability.can('display', 'message_send_page')
-                   && (
-                    ability.can('manage', 'emails') ||
-                    ability.can('manage', 'mails') ||
-                    ability.can('manage', 'texto')
-                  )">
+      <v-col
+        cols="6"
+        v-if="
+          ability.can('display', 'message_send_page') &&
+          (ability.can('manage', 'emails') ||
+            ability.can('manage', 'mails') ||
+            ability.can('manage', 'texto'))
+        "
+      >
         <LayoutHeaderUniversalCreationCard
         <LayoutHeaderUniversalCreationCard
           to="message"
           to="message"
           title="a_correspondence"
           title="a_correspondence"
@@ -83,99 +93,99 @@
       <!-- Un adhérent -->
       <!-- Un adhérent -->
       <v-col cols="6" v-if="isLaw1901">
       <v-col cols="6" v-if="isLaw1901">
         <LayoutHeaderUniversalCreationCard
         <LayoutHeaderUniversalCreationCard
-            title="an_adherent"
-            text-content="adherent_text_creation_card"
-            icon="fa fa-user"
-            href="/universal_creation_person/adherent"
-            @click="onCardClick"
+          title="an_adherent"
+          text-content="adherent_text_creation_card"
+          icon="fa fa-user"
+          href="/universal_creation_person/adherent"
+          @click="onCardClick"
         />
         />
       </v-col>
       </v-col>
 
 
       <!-- Un membre du CA -->
       <!-- Un membre du CA -->
       <v-col cols="6" v-if="isLaw1901">
       <v-col cols="6" v-if="isLaw1901">
         <LayoutHeaderUniversalCreationCard
         <LayoutHeaderUniversalCreationCard
-            title="a_ca_member"
-            text-content="ca_member_text_creation_card"
-            icon="fa fa-users"
-            href="/universal_creation_person/ca_member"
-            @click="onCardClick"
+          title="a_ca_member"
+          text-content="ca_member_text_creation_card"
+          icon="fa fa-users"
+          href="/universal_creation_person/ca_member"
+          @click="onCardClick"
         />
         />
       </v-col>
       </v-col>
 
 
       <!-- Un élève -->
       <!-- Un élève -->
       <v-col cols="6">
       <v-col cols="6">
         <LayoutHeaderUniversalCreationCard
         <LayoutHeaderUniversalCreationCard
-            title="a_student"
-            text-content="student_text_creation_card"
-            icon="fa fa-user"
-            href="/universal_creation_person/student"
-            @click="onCardClick"
+          title="a_student"
+          text-content="student_text_creation_card"
+          icon="fa fa-user"
+          href="/universal_creation_person/student"
+          @click="onCardClick"
         />
         />
       </v-col>
       </v-col>
 
 
       <!-- Un tuteur -->
       <!-- Un tuteur -->
       <v-col cols="6">
       <v-col cols="6">
         <LayoutHeaderUniversalCreationCard
         <LayoutHeaderUniversalCreationCard
-            title="a_guardian"
-            text-content="guardian_text_creation_card"
-            icon="fa fa-female"
-            href="/universal_creation_person/guardian"
-            @click="onCardClick"
+          title="a_guardian"
+          text-content="guardian_text_creation_card"
+          icon="fa fa-female"
+          href="/universal_creation_person/guardian"
+          @click="onCardClick"
         />
         />
       </v-col>
       </v-col>
 
 
       <!-- Un professeur -->
       <!-- Un professeur -->
       <v-col cols="6">
       <v-col cols="6">
         <LayoutHeaderUniversalCreationCard
         <LayoutHeaderUniversalCreationCard
-            title="a_teacher"
-            text-content="teacher_text_creation_card"
-            icon="fa fa-graduation-cap"
-            href="/universal_creation_person/teacher"
-            @click="onCardClick"
+          title="a_teacher"
+          text-content="teacher_text_creation_card"
+          icon="fa fa-graduation-cap"
+          href="/universal_creation_person/teacher"
+          @click="onCardClick"
         />
         />
       </v-col>
       </v-col>
 
 
       <!-- Un membre du personnel -->
       <!-- Un membre du personnel -->
       <v-col cols="6">
       <v-col cols="6">
         <LayoutHeaderUniversalCreationCard
         <LayoutHeaderUniversalCreationCard
-            title="a_member_of_staff"
-            text-content="personnel_text_creation_card"
-            icon="fa fa-suitcase"
-            href="/universal_creation_person/personnel"
-            @click="onCardClick"
+          title="a_member_of_staff"
+          text-content="personnel_text_creation_card"
+          icon="fa fa-suitcase"
+          href="/universal_creation_person/personnel"
+          @click="onCardClick"
         />
         />
       </v-col>
       </v-col>
 
 
       <!-- Une entité légale -->
       <!-- Une entité légale -->
       <v-col cols="6">
       <v-col cols="6">
         <LayoutHeaderUniversalCreationCard
         <LayoutHeaderUniversalCreationCard
-            title="a_legal_entity"
-            text-content="moral_text_creation_card"
-            icon="fa fa-building"
-            href="/universal_creation_person/company"
-            @click="onCardClick"
+          title="a_legal_entity"
+          text-content="moral_text_creation_card"
+          icon="fa fa-building"
+          href="/universal_creation_person/company"
+          @click="onCardClick"
         />
         />
       </v-col>
       </v-col>
 
 
       <!-- Une inscription en ligne -->
       <!-- Une inscription en ligne -->
       <v-col cols="6" v-if="hasOnlineRegistrationModule">
       <v-col cols="6" v-if="hasOnlineRegistrationModule">
         <LayoutHeaderUniversalCreationCard
         <LayoutHeaderUniversalCreationCard
-            title="online_registration"
-            text-content="online_registration_text_creation_card"
-            icon="fa fa-list-alt"
-            href="/online/registration/new_registration"
-            @click="onCardClick"
+          title="online_registration"
+          text-content="online_registration_text_creation_card"
+          icon="fa fa-list-alt"
+          href="/online/registration/new_registration"
+          @click="onCardClick"
         />
         />
       </v-col>
       </v-col>
 
 
       <!-- Un autre type de contact -->
       <!-- Un autre type de contact -->
       <v-col cols="6">
       <v-col cols="6">
         <LayoutHeaderUniversalCreationCard
         <LayoutHeaderUniversalCreationCard
-            title="another_type_of_contact"
-            text-content="other_contact_text_creation_card"
-            icon="fa fa-plus"
-            href="/universal_creation_person/other_contact"
-            @click="onCardClick"
+          title="another_type_of_contact"
+          text-content="other_contact_text_creation_card"
+          icon="fa fa-plus"
+          href="/universal_creation_person/other_contact"
+          @click="onCardClick"
         />
         />
       </v-col>
       </v-col>
     </v-row>
     </v-row>
@@ -187,48 +197,48 @@
       <!-- Un cours -->
       <!-- Un cours -->
       <v-col cols="6" v-if="ability.can('display', 'course_page')">
       <v-col cols="6" v-if="ability.can('display', 'course_page')">
         <LayoutHeaderUniversalCreationCard
         <LayoutHeaderUniversalCreationCard
-            to="event-params"
-            href="/calendar/create/courses"
-            title="course"
-            text-content="course_text_creation_card"
-            icon="fa fa-users"
-            @click="onCardClick"
+          to="event-params"
+          href="/calendar/create/courses"
+          title="course"
+          text-content="course_text_creation_card"
+          icon="fa fa-users"
+          @click="onCardClick"
         />
         />
       </v-col>
       </v-col>
 
 
       <!-- Un examen -->
       <!-- Un examen -->
       <v-col cols="6" v-if="ability.can('display', 'exam_page')">
       <v-col cols="6" v-if="ability.can('display', 'exam_page')">
         <LayoutHeaderUniversalCreationCard
         <LayoutHeaderUniversalCreationCard
-            to="event-params"
-            href="/calendar/create/examens"
-            title="exam"
-            text-content="exam_text_creation_card"
-            icon="fa fa-graduation-cap"
-            @click="onCardClick"
+          to="event-params"
+          href="/calendar/create/examens"
+          title="exam"
+          text-content="exam_text_creation_card"
+          icon="fa fa-graduation-cap"
+          @click="onCardClick"
         />
         />
       </v-col>
       </v-col>
 
 
       <!-- Un projet pédagogique -->
       <!-- Un projet pédagogique -->
       <v-col cols="6" v-if="ability.can('display', 'pedagogics_project_page')">
       <v-col cols="6" v-if="ability.can('display', 'pedagogics_project_page')">
         <LayoutHeaderUniversalCreationCard
         <LayoutHeaderUniversalCreationCard
-            to="event-params"
-            href="/calendar/create/educational_projects"
-            title="educational_services"
-            text-content="educational_services_text_creation_card"
-            icon="fa fa-suitcase"
-            @click="onCardClick"
+          to="event-params"
+          href="/calendar/create/educational_projects"
+          title="educational_services"
+          text-content="educational_services_text_creation_card"
+          icon="fa fa-suitcase"
+          @click="onCardClick"
         />
         />
       </v-col>
       </v-col>
 
 
       <!-- Un autre évènement -->
       <!-- Un autre évènement -->
       <v-col cols="6" v-if="ability.can('manage', 'events')">
       <v-col cols="6" v-if="ability.can('manage', 'events')">
         <LayoutHeaderUniversalCreationCard
         <LayoutHeaderUniversalCreationCard
-            to="event-params"
-            href="/calendar/create/events"
-            title="other_event"
-            text-content="other_event_text_creation_card"
-            icon="far fa-calendar"
-            @click="onCardClick"
+          to="event-params"
+          href="/calendar/create/events"
+          title="other_event"
+          text-content="other_event_text_creation_card"
+          icon="far fa-calendar"
+          @click="onCardClick"
         />
         />
       </v-col>
       </v-col>
     </v-row>
     </v-row>
@@ -240,33 +250,33 @@
       <!-- Un email -->
       <!-- Un email -->
       <v-col cols="6" v-if="ability.can('manage', 'emails')">
       <v-col cols="6" v-if="ability.can('manage', 'emails')">
         <LayoutHeaderUniversalCreationCard
         <LayoutHeaderUniversalCreationCard
-            title="an_email"
-            text-content="email_text_creation_card"
-            icon="far fa-envelope"
-            href="/list/create/emails"
-            @click="onCardClick"
+          title="an_email"
+          text-content="email_text_creation_card"
+          icon="far fa-envelope"
+          href="/list/create/emails"
+          @click="onCardClick"
         />
         />
       </v-col>
       </v-col>
 
 
       <!-- Un courrier -->
       <!-- Un courrier -->
       <v-col cols="6" v-if="ability.can('manage', 'mails')">
       <v-col cols="6" v-if="ability.can('manage', 'mails')">
         <LayoutHeaderUniversalCreationCard
         <LayoutHeaderUniversalCreationCard
-            title="a_letter"
-            text-content="letter_text_creation_card"
-            icon="far fa-file-alt"
-            href="/list/create/mails"
-            @click="onCardClick"
+          title="a_letter"
+          text-content="letter_text_creation_card"
+          icon="far fa-file-alt"
+          href="/list/create/mails"
+          @click="onCardClick"
         />
         />
       </v-col>
       </v-col>
 
 
       <!-- Un SMS -->
       <!-- Un SMS -->
       <v-col cols="6" v-if="ability.can('manage', 'texto')">
       <v-col cols="6" v-if="ability.can('manage', 'texto')">
         <LayoutHeaderUniversalCreationCard
         <LayoutHeaderUniversalCreationCard
-            title="a_sms"
-            text-content="sms_text_creation_card"
-            icon="fa fa-mobile-alt"
-            href="/list/create/sms"
-            @click="onCardClick"
+          title="a_sms"
+          text-content="sms_text_creation_card"
+          icon="fa fa-mobile-alt"
+          href="/list/create/sms"
+          @click="onCardClick"
         />
         />
       </v-col>
       </v-col>
     </v-row>
     </v-row>
@@ -274,75 +284,76 @@
 
 
   <!-- Page de pré-paramétrage des évènements -->
   <!-- Page de pré-paramétrage des évènements -->
   <LayoutHeaderUniversalCreationEventParams
   <LayoutHeaderUniversalCreationEventParams
-      v-if="location === 'event-params'"
-      @params-updated="onEventParamsUpdated"
+    v-if="location === 'event-params'"
+    @params-updated="onEventParamsUpdated"
   />
   />
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-  import {Ref, ref} from "@vue/reactivity";
-  import {useOrganizationProfileStore} from "~/stores/organizationProfile";
-  import {useAbility} from "@casl/vue";
-  import {ComputedRef} from "vue";
-  import {useAdminUrl} from "~/composables/utils/useAdminUrl";
-  import UrlUtils from "~/services/utils/urlUtils";
-
-  const props = defineProps({
-    /**
-     * The path that the user followed troughout the wizard
-     */
-    path: {
-      type: Array<string>,
-      required: true
-    }
-  })
-
-  const location: ComputedRef<string> = computed(() => {
-    return props.path.at(-1) ?? 'home'
-  })
-
-  const ability = useAbility()
-
-  const organizationProfile = useOrganizationProfileStore()
-  const isLaw1901: ComputedRef<boolean> = organizationProfile.isAssociation
-  const hasOnlineRegistrationModule: Ref<boolean> = ref(organizationProfile.hasModule('IEL'))
-
-  const baseUrl: Ref<string | null> = ref(null)
-  const query: Ref<Record<string, string>> = ref({})
-
-  const url: ComputedRef<string | null> = computed(() => {
-    if (baseUrl.value === null) {
-      return null
-    }
-    return UrlUtils.addQuery(baseUrl.value, query.value)
-  })
-
-  const emit = defineEmits(['cardClick', 'urlUpdate'])
+import type { Ref } from '@vue/reactivity'
+import { useOrganizationProfileStore } from '~/stores/organizationProfile'
+import { useAbility } from '@casl/vue'
+import type { ComputedRef } from 'vue'
+import UrlUtils from '~/services/utils/urlUtils'
 
 
+const props = defineProps({
   /**
   /**
-   * Called when a card is clicked
-   * @param to  Target location in the wizard
-   * @param href  Target absolute url
+   * The path that the user followed troughout the wizard
    */
    */
-  const onCardClick = (to: string | null, href: string | null) => {
-    if (href !== null) {
-      baseUrl.value = href
-    }
-    emit('cardClick', to, url.value)
+  path: {
+    type: Array<string>,
+    required: true,
+  },
+})
+
+const location: ComputedRef<string> = computed(() => {
+  return props.path.at(-1) ?? 'home'
+})
+
+const ability = useAbility()
+
+const organizationProfile = useOrganizationProfileStore()
+const isLaw1901: ComputedRef<boolean> = organizationProfile.isAssociation
+const hasOnlineRegistrationModule: Ref<boolean> = ref(
+  organizationProfile.hasModule('IEL'),
+)
+
+const baseUrl: Ref<string | null> = ref(null)
+const query: Ref<Record<string, string>> = ref({})
+
+const url: ComputedRef<string | null> = computed(() => {
+  if (baseUrl.value === null) {
+    return null
   }
   }
-
-  /**
-   * Called when the event parameters page is updated
-   * @param event
-   */
-  const onEventParamsUpdated = (event: {'start': string, 'end': string}) => {
-    query.value = event
+  return UrlUtils.addQuery(baseUrl.value, query.value)
+})
+
+const emit = defineEmits(['cardClick', 'urlUpdate'])
+
+/**
+ * Called when a card is clicked
+ * @param to  Target location in the wizard
+ * @param href  Target absolute url
+ */
+const onCardClick = (to: string | null, href: string | null) => {
+  if (href !== null) {
+    baseUrl.value = href
   }
   }
-
-  const unwatch = watch(url, (newUrl: string | null) => {
-    emit('urlUpdate', newUrl)
-  })
-  onUnmounted(() => {
-    unwatch()
-  })
+  emit('cardClick', to, url.value)
+}
+
+/**
+ * Called when the event parameters page is updated
+ * @param event
+ */
+const onEventParamsUpdated = (event: { start: string; end: string }) => {
+  query.value = event
+}
+
+const unwatch = watch(url, (newUrl: string | null) => {
+  emit('urlUpdate', newUrl)
+})
+onUnmounted(() => {
+  unwatch()
+})
 </script>
 </script>

+ 15 - 18
components/Layout/LoadingScreen.vue

@@ -2,31 +2,28 @@
 
 
 <template>
 <template>
   <v-overlay
   <v-overlay
-          v-model="pageStore.loading"
-          z-index="9000"
-          persistent
-          class="align-center justify-center"
+    v-model="pageStore.loading"
+    z-index="9000"
+    persistent
+    class="align-center justify-center"
   >
   >
-    <v-progress-circular
-      indeterminate
-      size="64"
-    />
+    <v-progress-circular indeterminate size="64" />
   </v-overlay>
   </v-overlay>
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-  import {usePageStore} from "~/stores/page";
+import { usePageStore } from '~/stores/page'
 
 
-  const pageStore = usePageStore()
+const pageStore = usePageStore()
 </script>
 </script>
 
 
 <style scoped>
 <style scoped>
-  .loading-page {
-    position: fixed;
-    top: 0;
-    left: 0;
-    width: 100%;
-    height: 100%;
-    z-index: 1001!important;
-  }
+.loading-page {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  z-index: 1001 !important;
+}
 </style>
 </style>

+ 74 - 76
components/Layout/MainMenu.vue

@@ -5,23 +5,18 @@ Prend en paramètre une liste de ItemMenu et les met en forme
 
 
 <template>
 <template>
   <v-navigation-drawer
   <v-navigation-drawer
-      v-model="displayMenu"
-      :rail="isRail"
-      :disable-resize-watcher="true"
-      class="theme-secondary main-menu"
+    v-model="displayMenu"
+    :rail="isRail"
+    :disable-resize-watcher="true"
+    class="theme-secondary main-menu"
   >
   >
     <template #prepend>
     <template #prepend>
       <slot name="title"></slot>
       <slot name="title"></slot>
     </template>
     </template>
 
 
-    <v-list
-        open-strategy="single"
-        active-class="active"
-        class="left-menu"
-    >
+    <v-list open-strategy="single" active-class="active" class="left-menu">
       <!-- TODO: que se passe-t-il si le menu ne comprend qu'un seul MenuItem? -->
       <!-- TODO: que se passe-t-il si le menu ne comprend qu'un seul MenuItem? -->
       <div v-for="(item, i) in items" :key="i">
       <div v-for="(item, i) in items" :key="i">
-
         <!-- Cas 1 : l'item n'a pas d'enfants, c'est un lien (ou le menu est en mode réduit) -->
         <!-- Cas 1 : l'item n'a pas d'enfants, c'est un lien (ou le menu est en mode réduit) -->
         <v-list-item
         <v-list-item
           v-if="!item.children || isRail"
           v-if="!item.children || isRail"
@@ -42,11 +37,11 @@ Prend en paramètre une liste de ItemMenu et les met en forme
         >
         >
           <template #activator="{ props }">
           <template #activator="{ props }">
             <v-list-item
             <v-list-item
-                v-bind="props"
-                :prepend-icon="item.icon.name"
-                :title="$t(item.label)"
-                class="theme-secondary menu-item"
-                height="48px"
+              v-bind="props"
+              :prepend-icon="item.icon.name"
+              :title="$t(item.label)"
+              class="theme-secondary menu-item"
+              height="48px"
             />
             />
           </template>
           </template>
 
 
@@ -69,16 +64,16 @@ Prend en paramètre une liste de ItemMenu et les met en forme
       <slot name="foot"></slot>
       <slot name="foot"></slot>
     </template>
     </template>
   </v-navigation-drawer>
   </v-navigation-drawer>
-
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import {useMenu} from "~/composables/layout/useMenu";
-import {computed} from "@vue/reactivity";
+import { useMenu } from '~/composables/layout/useMenu'
+import { computed } from '@vue/reactivity'
 import { useDisplay } from 'vuetify'
 import { useDisplay } from 'vuetify'
-import { MenuGroup, MenuItem } from "~/types/layout";
+import type { MenuGroup, MenuItem } from '~/types/layout'
 
 
-const { getMenu, hasMenu, isInternalLink, setMenuState, isMenuOpened } = useMenu()
+const { getMenu, hasMenu, isInternalLink, setMenuState, isMenuOpened } =
+  useMenu()
 
 
 const { mdAndUp, lgAndUp } = useDisplay()
 const { mdAndUp, lgAndUp } = useDisplay()
 
 
@@ -103,11 +98,16 @@ const displayMenu = computed(() => {
 // En vue md+, fermer le menu le passe simplement en mode rail
 // En vue md+, fermer le menu le passe simplement en mode rail
 // Sinon, le fermer le masque complètement
 // Sinon, le fermer le masque complètement
 const isRail = computed(() => {
 const isRail = computed(() => {
-  return menu !== null && mdAndUp.value && !isOpened.value && !items.some((item) => item.expanded)
+  return (
+    menu !== null &&
+    mdAndUp.value &&
+    !isOpened.value &&
+    !items.some((item) => item.expanded)
+  )
 })
 })
 
 
 const unwatch = watch(lgAndUp, (newValue, oldValue) => {
 const unwatch = watch(lgAndUp, (newValue, oldValue) => {
-// Par défaut si l'écran est trop petit au chargement de la page, le menu doit rester fermé.
+  // Par défaut si l'écran est trop petit au chargement de la page, le menu doit rester fermé.
   if (process.client && menu !== null) {
   if (process.client && menu !== null) {
     setMenuState('Main', lgAndUp.value)
     setMenuState('Main', lgAndUp.value)
   }
   }
@@ -119,69 +119,67 @@ onUnmounted(() => {
 </script>
 </script>
 
 
 <style scoped lang="scss">
 <style scoped lang="scss">
+.v-list-item {
+  min-height: 10px !important;
+}
 
 
-  .v-list-item {
-    min-height: 10px !important;
-  }
-
-  :deep(.v-list-item-title),
-  :deep(.v-icon),
-  {
-    font-size: 14px;
-    color: rgb(var(--v-theme-on-secondary));
-  }
-
-  .v-list-item__prepend {
-    margin: 10px 0;
-    margin-right: 10px !important;
-  }
+:deep(.v-list-item-title),
+:deep(.v-icon) {
+  font-size: 14px;
+  color: rgb(var(--v-theme-on-secondary));
+}
 
 
-  .v-application--is-ltr .v-list-group--no-action > .v-list-group__header {
-    margin-left: 0;
-    padding-left: 0;
-  }
-  .v-application--is-ltr .v-list-group--no-action > .v-list-group__items > .v-list-item {
-    padding-left: 30px;
-  }
+.v-list-item__prepend {
+  margin: 10px 0;
+  margin-right: 10px !important;
+}
 
 
-  .v-list-item__content {
-    padding: 8px 0;
-  }
+.v-application--is-ltr .v-list-group--no-action > .v-list-group__header {
+  margin-left: 0;
+  padding-left: 0;
+}
+.v-application--is-ltr
+  .v-list-group--no-action
+  > .v-list-group__items
+  > .v-list-item {
+  padding-left: 30px;
+}
 
 
-  .v-list-group__items .v-list-item {
-    padding-inline-start: 30px !important;
-  }
+.v-list-item__content {
+  padding: 8px 0;
+}
 
 
-  .v-list-group--no-action > .v-list-group__header,
-  .v-list-item
-  {
-    border-left: 3px solid rgb(var(--v-theme-secondary));
-    height: 48px;
-  }
+.v-list-group__items .v-list-item {
+  padding-inline-start: 30px !important;
+}
 
 
-  .v-list-item:hover,
-  .v-list-item.active,
-  :deep(.v-list-group__items .v-list-item)
-  {
-    border-left: 3px solid rgb(var(--v-theme-primary));
-    background-color: rgb(var(--v-theme-secondary-alt)) !important;
-    color: rgb(var(--v-theme-on-secondary-alt)) !important;
-  }
+.v-list-group--no-action > .v-list-group__header,
+.v-list-item {
+  border-left: 3px solid rgb(var(--v-theme-secondary));
+  height: 48px;
+}
 
 
-  :deep(.v-list-group__items .v-list-item-title) {
-    color: rgb(var(--v-theme-on-secondary-alt));
-  }
+.v-list-item:hover,
+.v-list-item.active,
+:deep(.v-list-group__items .v-list-item) {
+  border-left: 3px solid rgb(var(--v-theme-primary));
+  background-color: rgb(var(--v-theme-secondary-alt)) !important;
+  color: rgb(var(--v-theme-on-secondary-alt)) !important;
+}
 
 
-  :deep(.v-list-group__items .v-icon) {
-    color: rgb(var(--v-theme-on-secondary-alt));
-  }
+:deep(.v-list-group__items .v-list-item-title) {
+  color: rgb(var(--v-theme-on-secondary-alt));
+}
 
 
-  :deep(.v-list-item .v-icon) {
-    margin-right: 10px;
-  }
+:deep(.v-list-group__items .v-icon) {
+  color: rgb(var(--v-theme-on-secondary-alt));
+}
 
 
-  :deep(.menu-item .fa) {
-    text-align: center;
-  }
+:deep(.v-list-item .v-icon) {
+  margin-right: 10px;
+}
 
 
+:deep(.menu-item .fa) {
+  text-align: center;
+}
 </style>
 </style>

+ 0 - 0
components/Layout/Parameters/ResidenceAreas.vue


+ 135 - 0
components/Layout/ParametersMenu.vue

@@ -0,0 +1,135 @@
+<template>
+  <v-navigation-drawer
+    v-if="displayMenu"
+    v-model="isOpened"
+    mobile-breakpoint="sm"
+  >
+    <template v-slot:prepend>
+      <div class="title">
+        <h3>{{ $t('parameters') }}</h3>
+      </div>
+    </template>
+
+    <v-list active-class="active">
+      <v-list-item
+        v-for="(item, i) in menu!!.children"
+        :key="i"
+        :title="$t(item.label)"
+        :prepend-icon="item.icon ? item.icon.name : ''"
+        :to="(item as MenuItem).to"
+      >
+      </v-list-item>
+    </v-list>
+
+    <template v-slot:append>
+      <v-btn
+        :href="homeUrl"
+        prepend-icon="fa fa-right-from-bracket"
+        :flat="true"
+        color="on-neutral-very-soft"
+        class="cancel-btn py-2"
+      >
+        {{ $t('exit') }}
+      </v-btn>
+    </template>
+  </v-navigation-drawer>
+</template>
+
+<script setup lang="ts">
+import { useMenu } from '~/composables/layout/useMenu'
+import { useHomeUrl } from '~/composables/utils/useHomeUrl'
+import { useDisplay } from 'vuetify'
+import { computed } from '@vue/reactivity'
+import type { MenuGroup, MenuItem } from '~/types/layout'
+
+const { mdAndUp } = useDisplay()
+
+const { getMenu, hasMenu, isMenuOpened, setMenuState } = useMenu()
+
+const menu: MenuGroup | null = getMenu('Parameters')
+
+const displayMenu = computed(() => {
+  return menu !== null && hasMenu('Parameters')
+})
+
+const isOpened = computed(() => isMenuOpened('Parameters'))
+
+const unwatch = watch(mdAndUp, () => {
+  // Par défaut si l'écran est trop petit au chargement de la page, le menu doit rester fermé.
+  if (process.client && menu !== null) {
+    setMenuState('Parameters', mdAndUp.value)
+  }
+})
+
+const { homeUrl } = useHomeUrl()
+
+onUnmounted(() => {
+  unwatch()
+})
+</script>
+
+<style scoped lang="scss">
+.title {
+  display: flex;
+  align-items: center;
+  height: 48px;
+  vertical-align: center;
+  margin-top: 18px;
+  padding: 4px 16px;
+  font-size: 18px;
+  color: rgb(var(--v-theme-on-neutral-very-soft));
+}
+
+.v-navigation-drawer {
+  background-color: rgb(var(--v-theme-neutral-very-soft));
+  border-right: solid 1px rgb(var(--v-theme-neutral-strong));
+}
+
+:deep(.v-list-item-title),
+:deep(.v-icon) {
+  font-size: 14px;
+  color: rgb(var(--v-theme-on-neutral-very-soft));
+}
+
+.v-list-item:hover,
+.v-list-item.active,
+:deep(.v-list-group__items .v-list-item) {
+  background-color: rgb(var(--v-theme-neutral)) !important;
+  color: rgb(var(--v-theme-on-secondary-alt)) !important;
+}
+
+:deep(.v-list-item.active .v-list-item-title) {
+  font-weight: 800 !important;
+}
+
+:deep(.v-list-item-title),
+:deep(.v-icon) {
+  font-size: 14px;
+  color: rgb(var(--v-theme-on-neutral-very-soft));
+}
+
+:deep(.v-list-item__prepend) {
+  margin: 10px 0;
+  margin-right: 10px !important;
+}
+
+:deep(.v-list-item .v-icon) {
+  max-width: 24px;
+  margin-right: 10px;
+}
+
+.cancel-btn {
+  height: 42px;
+  color: rgb(var(--v-theme-on-neutral-very-soft));
+  background-color: transparent;
+  width: 100%;
+  border-top: solid 1px rgb(var(--v-theme-on-neutral-very-soft));
+  display: flex;
+  flex-direction: row;
+  justify-content: flex-start;
+}
+
+:deep(.cancel-btn .v-btn__prepend) {
+  margin: 0 16px 4px 2px;
+}
+</style>

+ 47 - 38
components/Layout/SubHeader/ActivityYear.vue

@@ -1,8 +1,7 @@
 <template>
 <template>
   <main class="d-flex flex-row align-center">
   <main class="d-flex flex-row align-center">
-    <span v-show="mdAndUp"
-          class="mr-2 font-weight-bold on-neutral">
-        {{ $t(label) }} :
+    <span v-show="mdAndUp" class="mr-2 font-weight-bold on-neutral">
+      {{ $t(label) }} :
     </span>
     </span>
 
 
     <UiXeditableText
     <UiXeditableText
@@ -11,14 +10,17 @@
       :data="currentActivityYear"
       :data="currentActivityYear"
       @update="setActivityYear"
       @update="setActivityYear"
     >
     >
-      <template #xeditable.read="{inputValue}">
+      <template #xeditable.read="{ inputValue }">
         <div class="d-flex align-center on-neutral--clickable">
         <div class="d-flex align-center on-neutral--clickable">
-          <v-icon aria-hidden="false" size="small" class="mr-1" icon="fas fa-edit" />
-          <strong >
+          <v-icon
+            aria-hidden="false"
+            size="small"
+            class="mr-1"
+            icon="fas fa-edit"
+          />
+          <strong>
             {{ inputValue }}
             {{ inputValue }}
-            <span v-if="yearPlusOne">
-              / {{ parseInt(inputValue) + 1 }}
-            </span>
+            <span v-if="yearPlusOne"> / {{ parseInt(inputValue) + 1 }} </span>
           </strong>
           </strong>
         </div>
         </div>
       </template>
       </template>
@@ -27,13 +29,14 @@
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import {useEntityManager} from "~/composables/data/useEntityManager";
-import {useFormStore} from "~/stores/form";
-import {useOrganizationProfileStore} from "~/stores/organizationProfile";
-import {useAccessProfileStore} from "~/stores/accessProfile";
-import Access from "~/models/Access/Access";
-import {useDisplay} from "vuetify";
-import {usePageStore} from "~/stores/page";
+import { useEntityManager } from '~/composables/data/useEntityManager'
+import { useFormStore } from '~/stores/form'
+import { useOrganizationProfileStore } from '~/stores/organizationProfile'
+import { useAccessProfileStore } from '~/stores/accessProfile'
+import Access from '~/models/Access/Access'
+import { useDisplay } from 'vuetify'
+import { usePageStore } from '~/stores/page'
+import { useRefreshProfile } from '~/composables/data/useRefreshProfile'
 
 
 const { em } = useEntityManager()
 const { em } = useEntityManager()
 const accessProfileStore = useAccessProfileStore()
 const accessProfileStore = useAccessProfileStore()
@@ -41,10 +44,17 @@ const organizationProfileStore = useOrganizationProfileStore()
 const formStore = useFormStore()
 const formStore = useFormStore()
 const pageStore = usePageStore()
 const pageStore = usePageStore()
 const { mdAndUp } = useDisplay()
 const { mdAndUp } = useDisplay()
+const { refreshProfile } = useRefreshProfile()
 
 
-const currentActivityYear: ComputedRef<number | undefined> = computed(() => accessProfileStore.activityYear ?? undefined)
+const currentActivityYear: ComputedRef<number | undefined> = computed(
+  () => accessProfileStore.activityYear ?? undefined,
+)
 const yearPlusOne: boolean = !organizationProfileStore.isManagerProduct
 const yearPlusOne: boolean = !organizationProfileStore.isManagerProduct
-const label: string = organizationProfileStore.isSchool ? 'schooling_year' : organizationProfileStore.isArtist ? 'season_year' : 'cotisation_year'
+const label: string = organizationProfileStore.isSchool
+  ? 'schooling_year'
+  : organizationProfileStore.isArtist
+    ? 'season_year'
+    : 'cotisation_year'
 
 
 /**
 /**
  * Persist a new activityYear
  * Persist a new activityYear
@@ -56,16 +66,15 @@ const setActivityYear = async (event: string) => {
   if (!(1900 < activityYear) || !(activityYear <= 2100)) {
   if (!(1900 < activityYear) || !(activityYear <= 2100)) {
     throw new Error("Error: 'year' shall be a valid year")
     throw new Error("Error: 'year' shall be a valid year")
   }
   }
-  if (accessProfileStore.id === null) {
-    throw new Error("Error: invalid access id")
-  }
   formStore.setDirty(false)
   formStore.setDirty(false)
 
 
   pageStore.loading = true
   pageStore.loading = true
-  await em.patch(Access, accessProfileStore.currentAccessId, { activityYear: activityYear })
+  await em.patch(Access, accessProfileStore.currentAccessId, {
+    activityYear: activityYear,
+  })
   if (process.server) {
   if (process.server) {
-      // Force profile refresh server side to avoid a bug where server and client stores diverge on profile refresh
-      await em.refreshProfile()
+    // Force profile refresh server side to avoid a bug where server and client stores diverge on profile refresh
+    await refreshProfile()
   }
   }
 
 
   window.location.reload()
   window.location.reload()
@@ -73,21 +82,21 @@ const setActivityYear = async (event: string) => {
 </script>
 </script>
 
 
 <style lang="scss">
 <style lang="scss">
-  .activity-year-input {
-    width: 120px;
-    max-height: 20px;
+.activity-year-input {
+  width: 120px;
+  max-height: 20px;
 
 
-    .v-input {
-      min-width: 70px;
-    }
+  .v-input {
+    min-width: 70px;
+  }
 
 
-    input{
-      font-size: 14px;
-      width: 55px !important;
-      padding: 0 !important;
-      margin-top: 0 !important;
-      min-height: 24px;
-      height: 24px;
-    }
+  input {
+    font-size: 14px;
+    width: 55px !important;
+    padding: 0 !important;
+    margin-top: 0 !important;
+    min-height: 24px;
+    height: 24px;
   }
   }
+}
 </style>
 </style>

+ 15 - 13
components/Layout/SubHeader/Breadcrumbs.vue

@@ -1,25 +1,24 @@
 <template>
 <template>
-  <v-breadcrumbs
-    :items="items"
-  />
+  <v-breadcrumbs :items="items" />
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import {computed, ComputedRef} from "@vue/reactivity";
-import {AnyJson} from "~/types/data";
-import {useI18n} from "vue-i18n";
-import UrlUtils from "~/services/utils/urlUtils";
+import { computed } from '@vue/reactivity'
+import type { ComputedRef } from '@vue/reactivity'
+import type { AnyJson } from '~/types/data'
+import { useI18n } from 'vue-i18n'
+import UrlUtils from '~/services/utils/urlUtils'
 
 
 const runtimeConfig = useRuntimeConfig()
 const runtimeConfig = useRuntimeConfig()
 const i18n = useI18n()
 const i18n = useI18n()
 const router = useRouter()
 const router = useRouter()
 
 
 const items: ComputedRef<Array<AnyJson>> = computed(() => {
 const items: ComputedRef<Array<AnyJson>> = computed(() => {
-  const crumbs:Array<AnyJson> = []
+  const crumbs: Array<AnyJson> = []
 
 
   crumbs.push({
   crumbs.push({
     title: i18n.t('welcome'),
     title: i18n.t('welcome'),
-    href: UrlUtils.join(runtimeConfig.baseUrlAdminLegacy, '#', 'dashboard')
+    href: UrlUtils.join(runtimeConfig.baseUrlAdminLegacy, '#', 'dashboard'),
   })
   })
 
 
   const pathPart: Array<string> = UrlUtils.split(router.currentRoute.value.path)
   const pathPart: Array<string> = UrlUtils.split(router.currentRoute.value.path)
@@ -29,13 +28,16 @@ const items: ComputedRef<Array<AnyJson>> = computed(() => {
   pathPart.forEach((part) => {
   pathPart.forEach((part) => {
     path = UrlUtils.join(path, part)
     path = UrlUtils.join(path, part)
 
 
-    const match = router.resolve(path)
+    let match
 
 
-    if (match.name !== null) {
+    match = router.resolve(path)
+    if (match.name) {
       crumbs.push({
       crumbs.push({
-        title: !parseInt(part, 10) ? i18n.t(part + '_breadcrumbs') : i18n.t('item'),
+        title: !parseInt(part, 10)
+          ? i18n.t(part + '_breadcrumbs')
+          : i18n.t('item'),
         exact: true,
         exact: true,
-        to: path
+        to: path,
       })
       })
     }
     }
   })
   })

+ 47 - 35
components/Layout/SubHeader/DataTiming.vue

@@ -1,6 +1,8 @@
 <template>
 <template>
   <main class="d-flex align-baseline">
   <main class="d-flex align-baseline">
-    <span v-show="mdAndUp" class="mr-2 font-weight-bold on-neutral">{{ $t('display_data') }} : </span>
+    <span v-show="mdAndUp" class="mr-2 font-weight-bold on-neutral"
+      >{{ $t('display_data') }} :
+    </span>
 
 
     <v-btn-toggle
     <v-btn-toggle
       ref="toggle"
       ref="toggle"
@@ -15,10 +17,15 @@
       @update:modelValue="onUpdate"
       @update:modelValue="onUpdate"
     >
     >
       <v-btn
       <v-btn
-          v-for="choice in historicalChoices"
-          :value="choice"
-          max-height="25"
-          :class="'font-weight-normal text-caption ' + (historicalValue.includes(choice) ? 'theme-primary' : 'theme-neutral-soft')"
+        v-for="choice in historicalChoices"
+        :value="choice"
+        max-height="25"
+        :class="
+          'font-weight-normal text-caption ' +
+          (historicalValue.includes(choice)
+            ? 'theme-primary'
+            : 'theme-neutral-soft')
+        "
       >
       >
         <!-- TODO: on ne devrait pas avoir besoin du if et de la classe 'btn-selected' dans v-btn, mais à l'heure
         <!-- TODO: on ne devrait pas avoir besoin du if et de la classe 'btn-selected' dans v-btn, mais à l'heure
          qu'il est, le component ne fonctionne pas comme attendu. A revoir quand vuetify 3 sera plus stable -->
          qu'il est, le component ne fonctionne pas comme attendu. A revoir quand vuetify 3 sera plus stable -->
@@ -29,13 +36,14 @@
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import {useFormStore} from "~/stores/form";
-import {useAccessProfileStore} from "~/stores/accessProfile";
-import {Ref} from "@vue/reactivity";
-import {useEntityManager} from "~/composables/data/useEntityManager";
-import {useDisplay, useTheme} from "vuetify";
-import Access from "~/models/Access/Access";
-import {usePageStore} from "~/stores/page";
+import { useFormStore } from '~/stores/form'
+import { useAccessProfileStore } from '~/stores/accessProfile'
+import type { Ref } from '@vue/reactivity'
+import { useEntityManager } from '~/composables/data/useEntityManager'
+import { useDisplay, useTheme } from 'vuetify'
+import Access from '~/models/Access/Access'
+import { usePageStore } from '~/stores/page'
+import { useRefreshProfile } from '~/composables/data/useRefreshProfile'
 
 
 // TODO: en v3.0.5, pas de solution documentée pour renseigner directement la couleur dans le template, à revoir
 // TODO: en v3.0.5, pas de solution documentée pour renseigner directement la couleur dans le template, à revoir
 const color = useTheme().current.value.colors['primary']
 const color = useTheme().current.value.colors['primary']
@@ -45,52 +53,56 @@ const accessProfileStore = useAccessProfileStore()
 const { em } = useEntityManager()
 const { em } = useEntityManager()
 const { mdAndUp } = useDisplay()
 const { mdAndUp } = useDisplay()
 const pageStore = usePageStore()
 const pageStore = usePageStore()
+const { refreshProfile } = useRefreshProfile()
 
 
 const toggle = ref(null)
 const toggle = ref(null)
 
 
-const historicalChoices: Array<'past' | 'present' | 'future'> = ['past', 'present', 'future']
+const historicalChoices: Array<'past' | 'present' | 'future'> = [
+  'past',
+  'present',
+  'future',
+]
 
 
-const historicalValue: Ref<Array<string>> = ref(historicalChoices.filter((item) => accessProfileStore.historical[item]))
+const historicalValue: Ref<Array<string>> = ref(
+  historicalChoices.filter((item) => accessProfileStore.historical[item]),
+)
 
 
 const onUpdate = async (newValue: Array<string>) => {
 const onUpdate = async (newValue: Array<string>) => {
   historicalValue.value = newValue
   historicalValue.value = newValue
 
 
-  const accessId = accessProfileStore.switchId ?? accessProfileStore.id
-
-  if (accessId === null) {
-    throw new Error('Invalid profile id')
-  }
+  const accessId = accessProfileStore.currentAccessId
 
 
   accessProfileStore.setHistorical(
   accessProfileStore.setHistorical(
-      historicalValue.value.includes('past'),
-      historicalValue.value.includes('present'),
-      historicalValue.value.includes('future')
+    historicalValue.value.includes('past'),
+    historicalValue.value.includes('present'),
+    historicalValue.value.includes('future'),
   )
   )
 
 
   setDirty(false)
   setDirty(false)
   pageStore.loading = true
   pageStore.loading = true
 
 
-  await em.patch(Access, accessId, {'historical': accessProfileStore.historical})
+  await em.patch(Access, accessId, {
+    historical: accessProfileStore.historical,
+  })
   if (process.server) {
   if (process.server) {
-      // Force profile refresh server side to avoid a bug where server and client stores diverge on profile refresh
-      await em.refreshProfile()
+    // Force profile refresh server side to avoid a bug where server and client stores diverge on profile refresh
+    await refreshProfile()
   }
   }
 
 
   window.location.reload()
   window.location.reload()
 }
 }
-
 </script>
 </script>
 
 
 <style scoped lang="scss">
 <style scoped lang="scss">
-  .v-btn-group {
-    max-height: 22px;
-  }
+.v-btn-group {
+  max-height: 22px;
+}
 
 
-  .v-btn {
-    padding: 0 8px;
-  }
+.v-btn {
+  padding: 0 8px;
+}
 
 
-  .v-btn.btn-selected {
-    background-color: rgb(var(--v-theme-primary)) !important;
-  }
+.v-btn.btn-selected {
+  background-color: rgb(var(--v-theme-primary)) !important;
+}
 </style>
 </style>

+ 28 - 23
components/Layout/SubHeader/DataTimingRange.vue

@@ -6,47 +6,50 @@
       </span>
       </span>
 
 
       <UiDateRangePicker
       <UiDateRangePicker
-          :model-value="datesRange"
-          :max-height="28"
-          @update:model-value="updateDateTimeRange"
+        :model-value="datesRange"
+        :max-height="28"
+        @update:model-value="updateDateTimeRange"
       />
       />
     </div>
     </div>
   </main>
   </main>
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import {Ref} from "@vue/reactivity";
-import {useAccessProfileStore} from "~/stores/accessProfile";
-import {useFormStore} from "~/stores/form";
-import {useEntityManager} from "~/composables/data/useEntityManager";
-import Access from "~/models/Access/Access";
-import DateUtils from "~/services/utils/dateUtils";
-import {usePageStore} from "~/stores/page";
+import type { Ref } from '@vue/reactivity'
+import { useAccessProfileStore } from '~/stores/accessProfile'
+import { useFormStore } from '~/stores/form'
+import { useEntityManager } from '~/composables/data/useEntityManager'
+import Access from '~/models/Access/Access'
+import DateUtils from '~/services/utils/dateUtils'
+import { usePageStore } from '~/stores/page'
+import { useRefreshProfile } from '~/composables/data/useRefreshProfile'
 
 
 const { setDirty } = useFormStore()
 const { setDirty } = useFormStore()
 const accessProfileStore = useAccessProfileStore()
 const accessProfileStore = useAccessProfileStore()
 const { em } = useEntityManager()
 const { em } = useEntityManager()
 const pageStore = usePageStore()
 const pageStore = usePageStore()
+const { refreshProfile } = useRefreshProfile()
 
 
 const start = accessProfileStore.historical.dateStart
 const start = accessProfileStore.historical.dateStart
 const end = accessProfileStore.historical.dateEnd
 const end = accessProfileStore.historical.dateEnd
 
 
-const datesRange: Ref<Array<Date> | null> = ref((start && end) ? [new Date(start), new Date(end)] : null)
+const datesRange: Ref<Array<Date> | null> = ref(
+  start && end ? [new Date(start), new Date(end)] : null,
+)
 
 
 const updateDateTimeRange = async (dates: Array<Date>): Promise<any> => {
 const updateDateTimeRange = async (dates: Array<Date>): Promise<any> => {
-
-  const accessId = accessProfileStore.switchId ?? accessProfileStore.id
-
-  if (accessId === null) {
-    throw new Error('Invalid profile id')
-  }
+  const accessId = accessProfileStore.currentAccessId
 
 
   datesRange.value = dates
   datesRange.value = dates
 
 
-  if (datesRange.value !== null && datesRange.value[0] !== null && datesRange.value[1] !== null) {
+  if (
+    datesRange.value !== null &&
+    datesRange.value[0] !== null &&
+    datesRange.value[1] !== null
+  ) {
     accessProfileStore.setHistoricalRange(
     accessProfileStore.setHistoricalRange(
-        DateUtils.formatIsoShortDate(datesRange.value[0]),
-        DateUtils.formatIsoShortDate(datesRange.value[1])
+      DateUtils.formatIsoShortDate(datesRange.value[0]),
+      DateUtils.formatIsoShortDate(datesRange.value[1]),
     )
     )
   } else {
   } else {
     accessProfileStore.setHistorical(false, true, false)
     accessProfileStore.setHistorical(false, true, false)
@@ -54,10 +57,12 @@ const updateDateTimeRange = async (dates: Array<Date>): Promise<any> => {
   setDirty(false)
   setDirty(false)
   pageStore.loading = true
   pageStore.loading = true
 
 
-  await em.patch(Access, accessId, {'historical': accessProfileStore.historical})
+  await em.patch(Access, accessId, {
+    historical: accessProfileStore.historical,
+  })
   if (process.server) {
   if (process.server) {
-      // Force profile refresh server side to avoid a bug where server and client stores diverge on profile refresh
-      await em.refreshProfile()
+    // Force profile refresh server side to avoid a bug where server and client stores diverge on profile refresh
+    await refreshProfile()
   }
   }
 
 
   window.location.reload()
   window.location.reload()

+ 47 - 40
components/Layout/SubHeader/PersonnalizedList.vue

@@ -5,12 +5,17 @@
     </a>
     </a>
 
 
     <v-menu
     <v-menu
-        :activator="btn"
-        offset="10"
-        min-width="440"
-        :close-on-content-click="false"
+      :activator="btn"
+      offset="10"
+      min-width="440"
+      :close-on-content-click="false"
     >
     >
-      <v-card v-if="collection.totalItems === 0" height="80" width="440" class="pa-4">
+      <v-card
+        v-if="collection.totalItems === 0"
+        height="80"
+        width="440"
+        class="pa-4"
+      >
         <v-card-text class="ma-0 pa-0 header_menu">
         <v-card-text class="ma-0 pa-0 header_menu">
           {{ $t('nothing_to_show') }}
           {{ $t('nothing_to_show') }}
         </v-card-text>
         </v-card-text>
@@ -19,11 +24,11 @@
       <v-card v-else width="440">
       <v-card v-else width="440">
         <v-card-title class="text-body-2 header-personalized">
         <v-card-title class="text-body-2 header-personalized">
           <v-text-field
           <v-text-field
-              v-model="search"
-              :label="$t('searchList')"
-              :loading="pending"
-              density="compact"
-              clear-icon="header-personalized"
+            v-model="search"
+            :label="$t('searchList')"
+            :loading="pending"
+            density="compact"
+            clear-icon="header-personalized"
           />
           />
         </v-card-title>
         </v-card-title>
 
 
@@ -36,51 +41,53 @@
               :href="getListURL(item)"
               :href="getListURL(item)"
               exact
               exact
             >
             >
-              <strong>{{item.menuKey}}</strong> - {{item.label}}
+              <strong>{{ item.menuKey }}</strong> - {{ item.label }}
             </v-list-item>
             </v-list-item>
           </v-list>
           </v-list>
         </v-card-text>
         </v-card-text>
       </v-card>
       </v-card>
-
     </v-menu>
     </v-menu>
   </main>
   </main>
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
 import PersonalizedList from '~/models/Access/PersonalizedList'
 import PersonalizedList from '~/models/Access/PersonalizedList'
-import {useEntityFetch} from "~/composables/data/useEntityFetch";
-import {ComputedRef, Ref, ref} from "@vue/reactivity";
-import {AnyJson} from "~/types/data";
-import ApiResource from "~/models/ApiResource";
-import UrlUtils from "~/services/utils/urlUtils";
+import { useEntityFetch } from '~/composables/data/useEntityFetch'
+import { ref } from '@vue/reactivity'
+import type { ComputedRef, Ref } from '@vue/reactivity'
+import type { AnyJson } from '~/types/data'
+import ApiResource from '~/models/ApiResource'
+import UrlUtils from '~/services/utils/urlUtils'
 
 
 const btn: Ref = ref(null)
 const btn: Ref = ref(null)
 
 
-const { fetch, fetchCollection } = useEntityFetch()
+const { fetchCollection } = useEntityFetch()
 
 
-const { data: collection, pending } = await fetchCollection(PersonalizedList)
+const { data: collection, pending } = fetchCollection(PersonalizedList)
 
 
 const i18n = useI18n()
 const i18n = useI18n()
 
 
 const items: ComputedRef<Array<AnyJson>> = computed(() => {
 const items: ComputedRef<Array<AnyJson>> = computed(() => {
-  const lists: Array<ApiResource> = collection.value !== null ? collection.value.items : []
+  const lists: Array<ApiResource> =
+    collection.value !== null ? collection.value.items : []
 
 
-  lists.map(item => {
+  lists.map((item) => {
     item.menuKey = i18n.t(item.menuKey) as string
     item.menuKey = i18n.t(item.menuKey) as string
   })
   })
 
 
   return lists
   return lists
 })
 })
 
 
-const search = ref('');
+const search = ref('')
 
 
 const filteredItems = computed(() => {
 const filteredItems = computed(() => {
-  return items.value.filter( item => {
-      return !search.value ||
-             item.label.toLowerCase().indexOf(search.value.toLowerCase()) >= 0 ||
-             item.menuKey.toLowerCase().indexOf(search.value.toLowerCase()) >= 0
-    }
-  )
+  return items.value.filter((item) => {
+    return (
+      !search.value ||
+      item.label.toLowerCase().indexOf(search.value.toLowerCase()) >= 0 ||
+      item.menuKey.toLowerCase().indexOf(search.value.toLowerCase()) >= 0
+    )
+  })
 })
 })
 
 
 const runtimeConfig = useRuntimeConfig()
 const runtimeConfig = useRuntimeConfig()
@@ -92,16 +99,16 @@ const getListURL = (list: PersonalizedList) => {
 </script>
 </script>
 
 
 <style scoped lang="scss">
 <style scoped lang="scss">
-  #activator {
-    cursor: pointer;
-  }
-
-  #activator:hover {
-    color: rgb(var(--var-theme-on-neutral)) !important;
-  }
-
-  .header-personalized {
-    margin-bottom: 0;
-    padding-bottom: 0;
-  }
+#activator {
+  cursor: pointer;
+}
+
+#activator:hover {
+  color: rgb(var(--var-theme-on-neutral)) !important;
+}
+
+.header-personalized {
+  margin-bottom: 0;
+  padding-bottom: 0;
+}
 </style>
 </style>

+ 55 - 41
components/Layout/Subheader.vue

@@ -8,47 +8,59 @@ Contient entre autres le breadcrumb, les commandes de changement d'année et les
     <v-card
     <v-card
       id="subheader"
       id="subheader"
       class="d-flex theme-neutral text-body-2 px-2"
       class="d-flex theme-neutral text-body-2 px-2"
-      flat
+      :flat="true"
       rounded="0"
       rounded="0"
     >
     >
       <LayoutSubHeaderBreadcrumbs v-if="lgAndUp" class="mr-auto d-flex" />
       <LayoutSubHeaderBreadcrumbs v-if="lgAndUp" class="mr-auto d-flex" />
 
 
       <span class="flex-fill" />
       <span class="flex-fill" />
 
 
-      <v-card
-        class="d-flex flex-row align-center mr-6"
-        flat
-        tile
-      >
-        <LayoutSubHeaderActivityYear v-if="smAndUp && !showDateTimeRange" class="activity-year" />
+      <v-card class="d-flex flex-row align-center mr-6" :flat="true" tile>
+        <LayoutSubHeaderActivityYear
+          v-if="smAndUp && !showDateTimeRange"
+          class="activity-year"
+        />
 
 
-        <div v-if="hasMenuOrIsTeacher" class="d-flex flex-row align-center h-100">
+        <div
+          v-if="hasMenuOrIsTeacher"
+          class="d-flex flex-row align-center h-100"
+        >
           <LayoutSubHeaderDataTiming
           <LayoutSubHeaderDataTiming
-              v-if="smAndUp && !showDateTimeRange"
-              class="data-timing ml-2"
+            v-if="smAndUp && !showDateTimeRange"
+            class="data-timing ml-2"
           />
           />
 
 
           <LayoutSubHeaderDataTimingRange
           <LayoutSubHeaderDataTimingRange
-              v-if="smAndUp && showDateTimeRange"
-              class="data-timing-range ml-n1"
+            v-if="smAndUp && showDateTimeRange"
+            class="data-timing-range ml-n1"
           />
           />
 
 
           <v-btn
           <v-btn
-              v-if="smAndUp"
-              ref="btn"
-              class="switch-btn ml-1 theme-neutral-soft"
-              height="22" min-height="22" max-height="22"
-              width="25" min-width="25" max-width="25"
-              elevation="0"
-              @click="showDateTimeRange = !showDateTimeRange"
+            v-if="smAndUp"
+            ref="btn"
+            class="switch-btn ml-1 theme-neutral-soft"
+            height="22"
+            min-height="22"
+            max-height="22"
+            width="25"
+            min-width="25"
+            max-width="25"
+            elevation="0"
+            @click="showDateTimeRange = !showDateTimeRange"
           >
           >
-              <v-icon icon="fas fa-history" class="font-weight-normal" style="font-size: 14px;" />
+            <v-icon
+              icon="fas fa-history"
+              class="font-weight-normal"
+              style="font-size: 14px"
+            />
           </v-btn>
           </v-btn>
           <v-tooltip location="bottom" :activator="btn">
           <v-tooltip location="bottom" :activator="btn">
-              <span>{{ $t('history_help') }}</span>
+            <span>{{ $t('history_help') }}</span>
           </v-tooltip>
           </v-tooltip>
 
 
-          <LayoutSubHeaderPersonnalizedList class="personalized-list ml-2 d-flex align-center" />
+          <LayoutSubHeaderPersonnalizedList
+            class="personalized-list ml-2 d-flex align-center"
+          />
         </div>
         </div>
       </v-card>
       </v-card>
     </v-card>
     </v-card>
@@ -56,28 +68,30 @@ Contient entre autres le breadcrumb, les commandes de changement d'année et les
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-    import {useAccessProfileStore} from "~/stores/accessProfile";
-    import {computed, ComputedRef, ref, Ref} from "@vue/reactivity";
-    import {useMenu} from "~/composables/layout/useMenu";
-    import {useDisplay} from "vuetify";
-
-    const { smAndUp, mdAndUp, lgAndUp } = useDisplay()
-    const accessProfile = useAccessProfileStore()
-    const { hasMenu } = useMenu()
-    const btn: Ref = ref(null)
-
-    const hasMenuOrIsTeacher: ComputedRef<boolean> = computed(
-        () => hasMenu('Main') || (accessProfile.isTeacher ?? false)
-    )
-
-    const showDateTimeRange: Ref<boolean> = ref(
-        Object.hasOwn(accessProfile.historical, 'dateStart') && accessProfile.historical.dateStart !== null &&
-        Object.hasOwn(accessProfile.historical, 'dateEnd') && accessProfile.historical.dateEnd !== null
-    )
+import { useAccessProfileStore } from '~/stores/accessProfile'
+import { computed, ref } from '@vue/reactivity'
+import type { ComputedRef, Ref } from '@vue/reactivity'
+import { useMenu } from '~/composables/layout/useMenu'
+import { useDisplay } from 'vuetify'
+
+const { smAndUp, lgAndUp } = useDisplay()
+const accessProfile = useAccessProfileStore()
+const { hasMenu } = useMenu()
+const btn: Ref = ref(null)
+
+const hasMenuOrIsTeacher: ComputedRef<boolean> = computed(
+  () => hasMenu('Main') || (accessProfile.isTeacher ?? false),
+)
+
+const showDateTimeRange: Ref<boolean> = ref(
+  Object.hasOwn(accessProfile.historical, 'dateStart') &&
+    accessProfile.historical.dateStart !== null &&
+    Object.hasOwn(accessProfile.historical, 'dateEnd') &&
+    accessProfile.historical.dateEnd !== null,
+)
 </script>
 </script>
 
 
 <style scoped lang="scss">
 <style scoped lang="scss">
-
 main {
 main {
   font-size: 12px;
   font-size: 12px;
 }
 }

+ 14 - 14
components/Layout/ThemeSwitcher.vue

@@ -1,26 +1,26 @@
 <template>
 <template>
   <v-switch
   <v-switch
-      v-model="theme.global.name.value"
-      density="compact"
-      inline
-      false-value="light"
-      false-icon="fas fa-sun"
-      true-value="dark"
-      true-icon="fas fa-moon"
+    v-model="theme.global.name.value"
+    density="compact"
+    :inline="true"
+    false-value="light"
+    false-icon="fas fa-sun"
+    true-value="dark"
+    true-icon="fas fa-moon"
   />
   />
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import {useTheme} from "vuetify";
+import { useTheme } from 'vuetify'
 
 
 const theme = useTheme()
 const theme = useTheme()
 </script>
 </script>
 
 
 <style scoped lang="scss">
 <style scoped lang="scss">
-  .v-switch {
-    min-width: 60px;
-    max-width: 60px;
-    min-height: 40px;
-    max-height: 40px;
-  }
+.v-switch {
+  min-width: 60px;
+  max-width: 60px;
+  min-height: 40px;
+  max-height: 40px;
+}
 </style>
 </style>

+ 34 - 26
components/Ui/Button/Delete.vue

@@ -4,15 +4,13 @@ Bouton Delete avec modale de confirmation de la suppression
 
 
 <template>
 <template>
   <main>
   <main>
-    <v-btn :icon="true" @click="alertDeleteItem()">
-      <v-icon>mdi-delete</v-icon>
+    <v-btn :icon="true" :flat="flat" @click="alertDeleteItem()">
+      <v-icon>fas fa-trash</v-icon>
     </v-btn>
     </v-btn>
 
 
-    <LazyLayoutDialog
-      :show="showDialog"
-    >
+    <LazyLayoutDialog :show="showDialog">
       <template #dialogType>{{ $t('delete_assistant') }}</template>
       <template #dialogType>{{ $t('delete_assistant') }}</template>
-      <template #dialogTitle>{{ $t('attention') }}</template>
+      <template #dialogTitle>{{ $t('caution') }}</template>
       <template #dialogText>
       <template #dialogText>
         <v-card-text>
         <v-card-text>
           <p>{{ $t('confirm_to_delete') }}</p>
           <p>{{ $t('confirm_to_delete') }}</p>
@@ -31,21 +29,27 @@ Bouton Delete avec modale de confirmation de la suppression
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import {TYPE_ALERT} from '~/types/enum/enums'
-import {Ref} from "@vue/reactivity";
-import {useEntityManager} from "~/composables/data/useEntityManager";
-import ApiResource from "~/models/ApiResource";
-import {usePageStore} from "~/stores/page";
+import { TYPE_ALERT } from '~/types/enum/enums'
+import type { Ref } from '@vue/reactivity'
+import { useEntityManager } from '~/composables/data/useEntityManager'
+import ApiResource from '~/models/ApiResource'
+import { usePageStore } from '~/stores/page'
+import ApiModel from '~/models/ApiModel'
 
 
 const props = defineProps({
 const props = defineProps({
-    model: {
-      type: Object,
-      required: true
-    },
-    entity: {
-      type: Object as () => ApiResource,
-      required: true
-    }
+  model: {
+    type: Function as any as () => typeof ApiModel,
+    required: true,
+  },
+  entity: {
+    type: Object as () => ApiResource,
+    required: true,
+  },
+  flat: {
+    type: Boolean,
+    required: false,
+    default: false,
+  },
 })
 })
 
 
 const showDialog: Ref<boolean> = ref(false)
 const showDialog: Ref<boolean> = ref(false)
@@ -54,18 +58,22 @@ const { em } = useEntityManager()
 
 
 const deleteItem = async () => {
 const deleteItem = async () => {
   try {
   try {
+    //@ts-ignore
     await em.delete(props.model, props.entity)
     await em.delete(props.model, props.entity)
-    usePageStore().addAlerts(TYPE_ALERT.SUCCESS, ['deleteSuccess'])
+    usePageStore().addAlert(TYPE_ALERT.SUCCESS, ['deleteSuccess'])
   } catch (error: any) {
   } catch (error: any) {
-    usePageStore().addAlerts(TYPE_ALERT.ALERT, [error.message])
+    usePageStore().addAlert(TYPE_ALERT.ALERT, [error.message])
+    throw error
   }
   }
   showDialog.value = false
   showDialog.value = false
 }
 }
 
 
-const alertDeleteItem = () => { showDialog.value = true }
-const closeDialog = () => { showDialog.value = false }
-
+const alertDeleteItem = () => {
+  showDialog.value = true
+}
+const closeDialog = () => {
+  showDialog.value = false
+}
 </script>
 </script>
 
 
-<style scoped>
-</style>
+<style scoped></style>

+ 36 - 23
components/Ui/Button/Submit.vue

@@ -1,6 +1,11 @@
 <template>
 <template>
-  <v-btn class="mr-4 theme-primary" :class="hasOtherActions ? 'pr-0' : ''" @click="submitAction(mainAction)" ref="mainBtn">
-
+  <v-btn
+    class="mr-4 theme-primary"
+    :class="hasOtherActions ? 'pr-0' : ''"
+    @click="submitAction(mainAction)"
+    ref="mainBtn"
+    :disabled="validationPending"
+  >
     {{ $t(mainAction) }}
     {{ $t(mainAction) }}
 
 
     <v-divider class="ml-3" :vertical="true" v-if="hasOtherActions"></v-divider>
     <v-divider class="ml-3" :vertical="true" v-if="hasOtherActions"></v-divider>
@@ -16,13 +21,13 @@
       <template #activator="{ on, attrs }">
       <template #activator="{ on, attrs }">
         <v-toolbar-title v-on="on">
         <v-toolbar-title v-on="on">
           <v-icon class="pl-3 pr-3">
           <v-icon class="pl-3 pr-3">
-            {{ dropDirection === 'top' ? 'fa-caret-up' : 'fa-caret-down'}}
+            {{
+              dropDirection === 'top' ? 'fa fa-caret-up' : 'fa fa-caret-down'
+            }}
           </v-icon>
           </v-icon>
         </v-toolbar-title>
         </v-toolbar-title>
       </template>
       </template>
-      <v-list
-        :min-width="menuSize"
-      >
+      <v-list :min-width="menuSize">
         <v-list-item
         <v-list-item
           dense
           dense
           v-for="(action, index) in actions"
           v-for="(action, index) in actions"
@@ -30,7 +35,10 @@
           class="subAction"
           class="subAction"
           v-if="index > 0"
           v-if="index > 0"
         >
         >
-          <v-list-item-title v-text="$t(action)" @click="submitAction(action)" />
+          <v-list-item-title
+            v-text="$t(action)"
+            @click="submitAction(action)"
+          />
         </v-list-item>
         </v-list-item>
       </v-list>
       </v-list>
     </v-menu>
     </v-menu>
@@ -38,24 +46,30 @@
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import {computed, ComputedRef, ref, Ref} from "@vue/reactivity";
+import { computed, ref } from '@vue/reactivity'
+import type { ComputedRef, Ref } from '@vue/reactivity'
 
 
 const props = defineProps({
 const props = defineProps({
-    actions: {
-      type: Array,
-      required: true
-    },
-    dropDirection: {
-      type: String,
-      required: false,
-      default:'bottom'
-    }
+  actions: {
+    type: Array,
+    required: true,
+  },
+  dropDirection: {
+    type: String,
+    required: false,
+    default: 'bottom',
+  },
+  validationPending: {
+    type: Boolean,
+    required: false,
+    default: false,
+  },
 })
 })
 
 
 const emit = defineEmits(['submit'])
 const emit = defineEmits(['submit'])
 
 
 const mainBtn: Ref = ref(null)
 const mainBtn: Ref = ref(null)
-const menuSize = computed(()=>{
+const menuSize = computed(() => {
   // Btn size + 40px de padding
   // Btn size + 40px de padding
   return mainBtn.value?.$el.clientWidth + 40
   return mainBtn.value?.$el.clientWidth + 40
 })
 })
@@ -64,21 +78,20 @@ const submitAction = (action: string) => {
   emit('submit', action)
   emit('submit', action)
 }
 }
 
 
-const mainAction: ComputedRef<string> = computed(()=>{
+const mainAction: ComputedRef<string> = computed(() => {
   return props.actions[0] as string
   return props.actions[0] as string
 })
 })
 
 
-const hasOtherActions: ComputedRef<boolean> = computed(()=>{
+const hasOtherActions: ComputedRef<boolean> = computed(() => {
   return props.actions.length > 1
   return props.actions.length > 1
 })
 })
-
 </script>
 </script>
 
 
 <style scoped>
 <style scoped>
-.v-list-item--dense{
+.v-list-item--dense {
   min-height: 25px;
   min-height: 25px;
 }
 }
-.subAction{
+.subAction {
   cursor: pointer;
   cursor: pointer;
 }
 }
 </style>
 </style>

+ 8 - 16
components/Ui/Card.vue

@@ -3,12 +3,7 @@ Container de type Card
 -->
 -->
 
 
 <template>
 <template>
-  <v-card
-    elevation="2"
-    outlined
-    shaped
-    min-height="200"
-  >
+  <v-card elevation="2" outlined shaped min-height="200">
     <!-- Titre -->
     <!-- Titre -->
     <v-card-title>
     <v-card-title>
       <slot name="card.title" />
       <slot name="card.title" />
@@ -33,32 +28,29 @@ Container de type Card
 
 
       <slot name="card.action" />
       <slot name="card.action" />
     </v-card-actions>
     </v-card-actions>
-
   </v-card>
   </v-card>
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-
 const props = defineProps({
 const props = defineProps({
   link: {
   link: {
     type: String,
     type: String,
-    required: true
+    required: true,
   },
   },
   model: {
   model: {
     type: Object,
     type: Object,
-    required: true
+    required: true,
   },
   },
   entity: {
   entity: {
     type: Object,
     type: Object,
-    required: true
+    required: true,
   },
   },
-  withDeleteAction:{
+  withDeleteAction: {
     type: Boolean,
     type: Boolean,
     required: false,
     required: false,
-    default: true
-  }
+    default: true,
+  },
 })
 })
 </script>
 </script>
 
 
-<style scoped>
-</style>
+<style scoped></style>

+ 12 - 16
components/Ui/Collection.vue

@@ -4,15 +4,14 @@
   <main>
   <main>
     <v-skeleton-loader v-if="pending" :type="loaderType" />
     <v-skeleton-loader v-if="pending" :type="loaderType" />
     <div v-else>
     <div v-else>
-
       <!-- Content -->
       <!-- Content -->
-      <slot name="list.item" v-bind="{items}" />
+      <slot name="list.item" v-bind="{ collection.items }" />
 
 
       <!-- New button -->
       <!-- New button -->
       <v-btn v-if="newLink" class="theme-primary float-right">
       <v-btn v-if="newLink" class="theme-primary float-right">
         <NuxtLink :to="newLink" class="no-decoration">
         <NuxtLink :to="newLink" class="no-decoration">
           <v-icon>fa-plus-circle</v-icon>
           <v-icon>fa-plus-circle</v-icon>
-          <span>{{$t('add')}}</span>
+          <span>{{ $t('add') }}</span>
         </NuxtLink>
         </NuxtLink>
       </v-btn>
       </v-btn>
     </div>
     </div>
@@ -21,37 +20,34 @@
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-
-import {computed, ComputedRef, toRefs, ToRefs} from "@vue/reactivity";
-import {useEntityFetch} from "~/composables/data/useEntityFetch";
-import {Collection} from "~/types/data";
+import { computed, toRefs } from '@vue/reactivity'
+import type { ComputedRef, ToRefs } from '@vue/reactivity'
+import { useEntityFetch } from '~/composables/data/useEntityFetch'
+import type { Collection } from '~/types/data'
 
 
 const props = defineProps({
 const props = defineProps({
   model: {
   model: {
     type: Object,
     type: Object,
-    required: true
+    required: true,
   },
   },
   parent: {
   parent: {
     type: Object,
     type: Object,
-    required: false
+    required: false,
   },
   },
   loaderType: {
   loaderType: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: 'text'
+    default: 'text',
   },
   },
   newLink: {
   newLink: {
     type: String,
     type: String,
-    required: false
-  }
+    required: false,
+  },
 })
 })
 
 
 const { model, parent }: ToRefs = toRefs(props)
 const { model, parent }: ToRefs = toRefs(props)
 
 
 const { fetchCollection } = useEntityFetch()
 const { fetchCollection } = useEntityFetch()
 
 
-const { data: collection, pending } = await fetchCollection(model.value, parent.value)
-
-const items: ComputedRef<Collection> = computed(() => collection.value ?? { items: [], pagination: {}, totalItems: 0 })
-
+const { data: collection, pending } = fetchCollection(model.value, parent.value)
 </script>
 </script>

+ 17 - 28
components/Ui/DataTable.vue

@@ -5,10 +5,7 @@ Tableau interactif conçu pour l'affichage d'une collection d'entités
 -->
 -->
 
 
 <template>
 <template>
-  <v-col
-    cols="12"
-    sm="12"
-  >
+  <v-col cols="12" sm="12">
     <v-data-table
     <v-data-table
       :headers="headersWithItem"
       :headers="headersWithItem"
       :items="collection.items"
       :items="collection.items"
@@ -23,50 +20,39 @@ Tableau interactif conçu pour l'affichage d'une collection d'entités
       </template>
       </template>
 
 
       <template #item.actions="{ item }">
       <template #item.actions="{ item }">
-        <v-icon
-          small
-          class="mr-2"
-          @click="editItem(item)"
-        >
-          mdi-pencil
-        </v-icon>
-        <v-icon
-          small
-          @click="deleteItem(item)"
-        >
-          mdi-delete
-        </v-icon>
+        <v-icon small class="mr-2" @click="editItem(item)"> mdi-pencil </v-icon>
+        <v-icon small @click="deleteItem(item)"> mdi-delete </v-icon>
       </template>
       </template>
     </v-data-table>
     </v-data-table>
   </v-col>
   </v-col>
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-
-import {ref, Ref, toRefs} from "@vue/reactivity";
-import {useEntityFetch} from "~/composables/data/useEntityFetch";
-import ApiResource from "~/models/ApiResource";
-import {AnyJson} from "~/types/data";
+import { ref, toRefs } from '@vue/reactivity'
+import type { Ref } from '@vue/reactivity'
+import { useEntityFetch } from '~/composables/data/useEntityFetch'
+import ApiResource from '~/models/ApiResource'
+import type { AnyJson } from '~/types/data'
 
 
 const props = defineProps({
 const props = defineProps({
   parent: {
   parent: {
     type: Object,
     type: Object,
-    required: true
+    required: true,
   },
   },
   model: {
   model: {
     type: Object,
     type: Object,
-    required: true
+    required: true,
   },
   },
   headers: {
   headers: {
     type: Array,
     type: Array,
-    required: true
-  }
+    required: true,
+  },
 })
 })
 
 
 const { parent, model, headers } = toRefs(props)
 const { parent, model, headers } = toRefs(props)
 
 
 const headersWithItem = computed(() => {
 const headersWithItem = computed(() => {
-  return headers.value.map((header:any) => {
+  return headers.value.map((header: any) => {
     header.item = 'item.' + header.value
     header.item = 'item.' + header.value
     return header
     return header
   })
   })
@@ -77,7 +63,10 @@ const entries: Ref<Array<AnyJson>> = ref(Array<AnyJson>())
 
 
 const { fetchCollection } = useEntityFetch()
 const { fetchCollection } = useEntityFetch()
 
 
-const { data: collection, pending } = await fetchCollection(model.value as typeof ApiResource, parent.value as ApiResource)
+const { data: collection, pending } = await fetchCollection(
+  model.value as typeof ApiResource,
+  parent.value as ApiResource,
+)
 
 
 const itemId: Ref<number> = ref(0)
 const itemId: Ref<number> = ref(0)
 
 

+ 34 - 26
components/Ui/DatePicker.vue

@@ -1,61 +1,72 @@
 <!--
 <!--
 Sélecteur de dates
 Sélecteur de dates
 
 
-@see https://vuetifyjs.com/en/components/date-pickers/
+@see https://vue3datepicker.com/
 -->
 -->
 
 
 <template>
 <template>
   <main>
   <main>
-    <!-- @see https://vue3datepicker.com/props/modes/#multi-calendars -->
     <VueDatePicker
     <VueDatePicker
-        :model-value="modelValue"
-        :locale="i18n.locale.value"
-        :format-locale="fnsLocale"
-        :format="dateFormat"
-        :enable-time-picker="withTime"
-        :teleport="true"
-        text-input
-        :auto-apply="true"
-        :select-text="$t('select')"
-        :cancel-text="$t('cancel')"
-        @update:model-value="onUpdate"
+      :model-value="modelValue"
+      :locale="i18n.locale.value"
+      :format-locale="fnsLocale"
+      :format="dateFormat"
+      :enable-time-picker="withTime"
+      :teleport="true"
+      text-input
+      :auto-apply="true"
+      :select-text="$t('select')"
+      :cancel-text="$t('cancel')"
+      :disabled="readonly"
+      :position="position"
+      @update:model-value="onUpdate"
     />
     />
   </main>
   </main>
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import {computed} from "@vue/reactivity";
-import DateUtils, {supportedLocales} from "~/services/utils/dateUtils";
-import {PropType} from "@vue/runtime-core";
+import DateUtils, { supportedLocales } from '~/services/utils/dateUtils'
+import type { PropType } from '@vue/runtime-core'
 
 
 const i18n = useI18n()
 const i18n = useI18n()
 
 
 const fnsLocale = DateUtils.getFnsLocale(i18n.locale.value as supportedLocales)
 const fnsLocale = DateUtils.getFnsLocale(i18n.locale.value as supportedLocales)
-const defaultFormatPattern = DateUtils.getFormatPattern(i18n.locale.value as supportedLocales)
 
 
 const props = defineProps({
 const props = defineProps({
   modelValue: {
   modelValue: {
     type: Object as PropType<Date>,
     type: Object as PropType<Date>,
     required: false,
     required: false,
-    default: null
+    default: null,
   },
   },
   readonly: {
   readonly: {
     type: Boolean,
     type: Boolean,
     required: false,
     required: false,
-    default: false
+    default: false,
   },
   },
   format: {
   format: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: null
+    default: null,
   },
   },
   withTime: {
   withTime: {
     type: Boolean,
     type: Boolean,
     required: false,
     required: false,
-    default: false
-  }
+    default: false,
+  },
+  /**
+   * @see https://vue3datepicker.com/props/positioning/#position
+   */
+  position: {
+    type: String as PropType<'left' | 'center' | 'right'>,
+    required: false,
+    default: 'center',
+  },
 })
 })
 
 
+const defaultFormatPattern = props.withTime
+  ? DateUtils.getFormatPattern(i18n.locale.value as supportedLocales)
+  : DateUtils.getShortFormatPattern(i18n.locale.value as supportedLocales)
+
 const dateFormat: Ref<string> = ref(props.format ?? defaultFormatPattern)
 const dateFormat: Ref<string> = ref(props.format ?? defaultFormatPattern)
 
 
 const emit = defineEmits(['update:model-value'])
 const emit = defineEmits(['update:model-value'])
@@ -63,9 +74,6 @@ const emit = defineEmits(['update:model-value'])
 const onUpdate = (event: Date) => {
 const onUpdate = (event: Date) => {
   emit('update:model-value', event)
   emit('update:model-value', event)
 }
 }
-
 </script>
 </script>
 
 
-<style scoped>
-
-</style>
+<style scoped></style>

+ 69 - 66
components/Ui/DateRangePicker.vue

@@ -1,43 +1,43 @@
 <template>
 <template>
   <!-- @see https://vue3datepicker.com/props/modes/#multi-calendars -->
   <!-- @see https://vue3datepicker.com/props/modes/#multi-calendars -->
   <VueDatePicker
   <VueDatePicker
-      :model-value="modelValue"
-      range
-      multi-calendars
-      :auto-apply="autoApply"
-      :locale="i18n.locale.value"
-      :format-locale="fnsLocale"
-      :format="dateFormatPattern"
-      :start-date="today"
-      :teleport="true"
-      :alt-position="dateRangePickerAltPosition"
-      :enable-time-picker="false"
-      close-on-scroll
-      text-input
-      :select-text="$t('select')"
-      :cancel-text="$t('cancel')"
-      input-class-name="date-range-picker-input"
-      @update:model-value="updateDateTimeRange"
-      class="date-range-picker"
-      :style="style"
+    :model-value="modelValue"
+    range
+    multi-calendars
+    :auto-apply="autoApply"
+    :locale="i18n.locale.value"
+    :format-locale="fnsLocale"
+    :format="dateFormatPattern"
+    :start-date="today"
+    :teleport="true"
+    :alt-position="dateRangePickerAltPosition"
+    :enable-time-picker="false"
+    close-on-scroll
+    text-input
+    :select-text="$t('select')"
+    :cancel-text="$t('cancel')"
+    input-class-name="date-range-picker-input"
+    @update:model-value="updateDateTimeRange"
+    class="date-range-picker"
+    :style="style"
   />
   />
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import DateUtils, {supportedLocales} from "~/services/utils/dateUtils";
-import {PropType} from "@vue/runtime-core";
+import DateUtils, { supportedLocales } from '~/services/utils/dateUtils'
+import type { PropType } from '@vue/runtime-core'
 
 
 const props = defineProps({
 const props = defineProps({
   modelValue: {
   modelValue: {
     type: Array as PropType<Array<Date> | null>,
     type: Array as PropType<Array<Date> | null>,
     required: false,
     required: false,
-    default: null
+    default: null,
   },
   },
   maxHeight: {
   maxHeight: {
     type: Number,
     type: Number,
     required: false,
     required: false,
-    default: null
-  }
+    default: null,
+  },
 })
 })
 
 
 const emit = defineEmits(['update:modelValue'])
 const emit = defineEmits(['update:modelValue'])
@@ -51,13 +51,16 @@ const updateDateTimeRange = (value: [string, string]) => {
 const i18n = useI18n()
 const i18n = useI18n()
 
 
 const fnsLocale = DateUtils.getFnsLocale(i18n.locale.value as supportedLocales)
 const fnsLocale = DateUtils.getFnsLocale(i18n.locale.value as supportedLocales)
-const dateFormatPattern = DateUtils.getShortFormatPattern(i18n.locale.value as supportedLocales)
+const dateFormatPattern = DateUtils.getShortFormatPattern(
+  i18n.locale.value as supportedLocales,
+)
 
 
 const today = new Date()
 const today = new Date()
 
 
-let style = '';
+let style = ''
 if (props.maxHeight !== null) {
 if (props.maxHeight !== null) {
-  style += 'height: ' + props.maxHeight + 'px;max-height: ' + props.maxHeight + 'px;'
+  style +=
+    'height: ' + props.maxHeight + 'px;max-height: ' + props.maxHeight + 'px;'
 }
 }
 
 
 /**
 /**
@@ -70,57 +73,57 @@ const dateRangePickerAltPosition = (el: HTMLElement) => {
   const rightPadding = 30
   const rightPadding = 30
   const rect = el.getBoundingClientRect()
   const rect = el.getBoundingClientRect()
 
 
-  if ((rect.left + fullWidth + rightPadding) > window.innerWidth) {
+  if (rect.left + fullWidth + rightPadding > window.innerWidth) {
     xOffset = window.innerWidth - (rect.left + fullWidth + rightPadding)
     xOffset = window.innerWidth - (rect.left + fullWidth + rightPadding)
   }
   }
 
 
   return {
   return {
     top: rect.bottom,
     top: rect.bottom,
-    left: rect.left + xOffset
+    left: rect.left + xOffset,
   }
   }
 }
 }
 </script>
 </script>
 
 
 <style lang="scss">
 <style lang="scss">
+// @see https://vue3datepicker.com/customization/theming/
+// [!] Sass variables overriding does not work in scoped mode
+.dp__theme_light,
+.dp__theme_dark {
+  --dp-background-color: #ffffff;
+  --dp-text-color: #212121;
+  --dp-hover-color: #f3f3f3;
+  --dp-hover-text-color: #212121;
+  --dp-hover-icon-color: #959595;
+  --dp-primary-color: rgb(var(--v-theme-primary)) !important;
+  --dp-primary-text-color: rgb(var(--v-theme-on-primary)) !important;
+  --dp-secondary-color: rgb(var(--v-theme-neutral-strong)) !important;
+  --dp-border-color: #ddd;
+  --dp-menu-border-color: #ddd;
+  --dp-border-color-hover: #aaaeb7;
+  --dp-disabled-color: #f6f6f6;
+  --dp-scroll-bar-background: #f3f3f3;
+  --dp-scroll-bar-color: #959595;
+  --dp-success-color: rgb(var(--v-theme-success)) !important;
+  --dp-success-color-disabled: rgb(var(--v-theme-neutral-strong)) !important;
+  --dp-icon-color: #959595;
+  --dp-danger-color: #ff6f60;
+  --dp-highlight-color: rgba(25, 118, 210, 0.1);
+}
 
 
-  // @see https://vue3datepicker.com/customization/theming/
-  // [!] Sass variables overriding does not work in scoped mode
-  .dp__theme_light, .dp__theme_dark {
-    --dp-background-color: #ffffff;
-    --dp-text-color: #212121;
-    --dp-hover-color: #f3f3f3;
-    --dp-hover-text-color: #212121;
-    --dp-hover-icon-color: #959595;
-    --dp-primary-color: rgb(var(--v-theme-primary)) !important;
-    --dp-primary-text-color: rgb(var(--v-theme-on-primary)) !important;
-    --dp-secondary-color: rgb(var(--v-theme-neutral-strong)) !important;
-    --dp-border-color: #ddd;
-    --dp-menu-border-color: #ddd;
-    --dp-border-color-hover: #aaaeb7;
-    --dp-disabled-color: #f6f6f6;
-    --dp-scroll-bar-background: #f3f3f3;
-    --dp-scroll-bar-color: #959595;
-    --dp-success-color: rgb(var(--v-theme-success)) !important;
-    --dp-success-color-disabled: rgb(var(--v-theme-neutral-strong)) !important;
-    --dp-icon-color: #959595;
-    --dp-danger-color: #ff6f60;
-    --dp-highlight-color: rgba(25, 118, 210, 0.1);
+.date-range-picker {
+  div {
+    height: 100% !important;
+    max-height: 100% !important;
   }
   }
 
 
-  .date-range-picker {
-    div[role="textbox"] {
-      height: 100% !important;
-      max-height: 100% !important;
-    }
-
-    .dp__input_wrap {
-      height: 100% !important;
-      max-height: 100% !important;
-    }
-
-    .date-range-picker-input {
-      height: 100% !important;
-      max-height: 100% !important;
-    }
+  .dp__input_wrap {
+    height: 100% !important;
+    max-height: 100% !important;
   }
   }
+
+  .date-range-picker-input {
+    height: 100% !important;
+    max-height: 100% !important;
+  }
+}
 </style>
 </style>

+ 30 - 30
components/Ui/ExpansionPanel.vue

@@ -25,47 +25,47 @@ Panneaux déroulants de type "accordéon"
 const props = defineProps({
 const props = defineProps({
   title: {
   title: {
     type: String,
     type: String,
-    required: true
+    required: true,
   },
   },
   icon: {
   icon: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: null
-  }
+    default: null,
+  },
 })
 })
 </script>
 </script>
 
 
 <style scoped>
 <style scoped>
-  .icon {
-    width: 47px;
-    height: 47px;
-    padding: 10px;
-    margin-right: 10px;
-    flex: none !important;
-  }
+.icon {
+  width: 47px;
+  height: 47px;
+  padding: 10px;
+  margin-right: 10px;
+  flex: none !important;
+}
 
 
-  .v-expansion-panel-header {
-    padding: 0;
-    padding-right: 20px;
-  }
+.v-expansion-panel-header {
+  padding: 0;
+  padding-right: 20px;
+}
 
 
-  .v-expansion-panel-title {
-    padding-left: 0;
-    padding-top: 0;
-    padding-bottom: 0;
-    max-height: 47px;
-    min-height: 47px;
-  }
+.v-expansion-panel-title {
+  padding-left: 0;
+  padding-top: 0;
+  padding-bottom: 0;
+  max-height: 47px;
+  min-height: 47px;
+}
 
 
-  .v-expansion-panel--active > .v-expansion-panel-title {
-    min-height: 47px !important;
-  }
+.v-expansion-panel--active > .v-expansion-panel-title {
+  min-height: 47px !important;
+}
 
 
-  :deep(.v-expansion-panel-title__icon > .v-icon) {
-    font-size: 16px;
-  }
+:deep(.v-expansion-panel-title__icon > .v-icon) {
+  font-size: 16px;
+}
 
 
-  .icon {
-    text-align: center;
-  }
+.icon {
+  text-align: center;
+}
 </style>
 </style>

+ 295 - 168
components/Ui/Form.vue

@@ -1,45 +1,57 @@
 <!--
 <!--
 Formulaire générique
 Formulaire générique
 
 
+Assure la validation des données, les actions de base (enregistrement, annulation, ...), et la confirmation avant
+de quitter si des données ont été modifiées.
+
 @see https://vuetifyjs.com/en/components/forms/#usage
 @see https://vuetifyjs.com/en/components/forms/#usage
 -->
 -->
 
 
 <template>
 <template>
-  <main>
+  <LayoutContainer>
     <v-form
     <v-form
       ref="form"
       ref="form"
-      lazy-validation
+      v-model="isValid"
       :readonly="readonly"
       :readonly="readonly"
       @submit.prevent=""
       @submit.prevent=""
-      @update:entity="onFormChange"
     >
     >
       <!-- Top action bar -->
       <!-- Top action bar -->
-      <v-container fluid class="container btnActions">
+      <v-container
+        v-if="actionPosition === 'both' || actionPosition === 'top'"
+        :fluid="true"
+        class="container btnActions"
+      >
         <v-row>
         <v-row>
           <v-col cols="12" sm="12">
           <v-col cols="12" sm="12">
-            <slot name="form.button"/>
+            <slot name="form.button" />
 
 
             <UiButtonSubmit
             <UiButtonSubmit
               v-if="!readonly"
               v-if="!readonly"
-              @submit="submit"
               :actions="actions"
               :actions="actions"
+              :validation-pending="validationPending || !isValid"
+              @submit="submit"
             ></UiButtonSubmit>
             ></UiButtonSubmit>
           </v-col>
           </v-col>
         </v-row>
         </v-row>
       </v-container>
       </v-container>
 
 
       <!-- Content -->
       <!-- Content -->
-      <slot name="form.input" v-bind="{model, entity}"/>
+      <slot v-bind="{ model, entity }" />
 
 
       <!-- Bottom action bar -->
       <!-- Bottom action bar -->
-      <v-container fluid class="container btnActions">
+      <v-container
+        v-if="actionPosition === 'both' || actionPosition === 'bottom'"
+        :fluid="true"
+        class="container btnActions mt-6"
+      >
         <v-row>
         <v-row>
           <v-col cols="12" sm="12">
           <v-col cols="12" sm="12">
-            <slot name="form.button"/>
+            <slot name="form.button" />
 
 
             <UiButtonSubmit
             <UiButtonSubmit
-              @submit="submit"
+              :validation-pending="validationPending || !isValid"
               :actions="actions"
               :actions="actions"
+              @submit="submit"
             ></UiButtonSubmit>
             ></UiButtonSubmit>
           </v-col>
           </v-col>
         </v-row>
         </v-row>
@@ -47,270 +59,385 @@ Formulaire générique
     </v-form>
     </v-form>
 
 
     <!-- Confirmation dialog -->
     <!-- Confirmation dialog -->
-    <LazyLayoutDialog
-      :show="showDialog"
-    >
+    <LazyLayoutDialog :show="isConfirmationDialogShowing" :max-width="1000">
       <template #dialogText>
       <template #dialogText>
         <v-card-title class="text-h5 theme-neutral">
         <v-card-title class="text-h5 theme-neutral">
           {{ $t('caution') }}
           {{ $t('caution') }}
         </v-card-title>
         </v-card-title>
         <v-card-text>
         <v-card-text>
-          <br>
-          <p>{{ $t('quit_without_saving_warning') }}</p>
+          <br />
+          <p>{{ $t('quit_without_saving_warning') }}.</p>
         </v-card-text>
         </v-card-text>
       </template>
       </template>
+
       <template #dialogBtn>
       <template #dialogBtn>
-        <v-btn class="mr-4 submitBtn theme-primary" @click="closeDialog">
-          {{ $t('back_to_form') }}
-        </v-btn>
-        <v-btn class="mr-4 submitBtn theme-primary" @click="saveAndQuit">
-          {{ $t('save_and_quit') }}
-        </v-btn>
-        <v-btn class="mr-4 submitBtn theme-danger" @click="quitForm">
-          {{ $t('quit_form') }}
-        </v-btn>
+        <div class="confirmation-dlg-actions">
+          <v-btn class="theme-primary" @click="closeConfirmationDialog">
+            {{ $t('cancel') }}
+          </v-btn>
+
+          <v-btn class="theme-primary" @click="saveAndQuit">
+            {{ $t('save_and_quit') }}
+          </v-btn>
+
+          <v-btn class="theme-danger" @click="cancel">
+            {{ $t('quit_with_no_saving') }}
+          </v-btn>
+        </div>
       </template>
       </template>
     </LazyLayoutDialog>
     </LazyLayoutDialog>
-
-  </main>
+  </LayoutContainer>
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import {computed, ComputedRef, ref, Ref} from "@vue/reactivity";
-import {AnyJson} from "~/types/enum/data";
-import {FORM_FUNCTION, SUBMIT_TYPE, TYPE_ALERT} from "~/types/enum/enums";
-import { useFormStore } from "~/stores/form";
-import {Route} from "@intlify/vue-router-bridge";
-import {useEntityManager} from "~/composables/data/useEntityManager";
-import ApiModel from "~/models/ApiModel";
-import {usePageStore} from "~/stores/page";
-import {watch} from "@vue/runtime-core";
+import { computed, ref, watch } from 'vue'
+import type { ComputedRef, Ref, PropType } from 'vue'
+import type { RouteLocationNormalized, RouteLocationRaw } from 'vue-router'
+import * as _ from 'lodash-es'
+import { FORM_FUNCTION, SUBMIT_TYPE, TYPE_ALERT } from '~/types/enum/enums'
+import { useFormStore } from '~/stores/form'
+import { useEntityManager } from '~/composables/data/useEntityManager'
+import ApiModel from '~/models/ApiModel'
+import { usePageStore } from '~/stores/page'
+import type { AnyJson } from '~/types/data'
+import { useRefreshProfile } from '~/composables/data/useRefreshProfile'
 
 
 const props = defineProps({
 const props = defineProps({
+  /**
+   * Classe de l'ApiModel (ex: Organization, Notification, ...)
+   */
   model: {
   model: {
     type: Function as any as () => typeof ApiModel,
     type: Function as any as () => typeof ApiModel,
-    required: true
+    required: true,
   },
   },
+  /**
+   * Instance de l'objet
+   */
   entity: {
   entity: {
     type: Object as () => ApiModel,
     type: Object as () => ApiModel,
-    required: true
+    required: true,
   },
   },
+  /**
+   * TODO: compléter
+   */
   onChanged: {
   onChanged: {
     type: Function,
     type: Function,
-    required: false
+    required: false,
+    default: null,
+  },
+  goBackRoute: {
+    type: Object as PropType<RouteLocationRaw>,
+    required: false,
+    default: null,
   },
   },
+  /**
+   * Types de soumission disponibles (enregistrer / enregistrer et quitter)
+   */
   submitActions: {
   submitActions: {
     type: Object,
     type: Object,
     required: false,
     required: false,
     default: () => {
     default: () => {
-      let actions: AnyJson = {}
+      const actions: AnyJson = {}
       actions[SUBMIT_TYPE.SAVE] = {}
       actions[SUBMIT_TYPE.SAVE] = {}
       return actions
       return actions
-    }
-  }
+    },
+  },
+  /**
+   * La validation est en cours
+   */
+  validationPending: {
+    type: Boolean,
+    required: false,
+    default: false,
+  },
+  /**
+   * Faut-il rafraichir le profil à la soumission du formulaire?
+   */
+  refreshProfile: {
+    type: Boolean,
+    required: false,
+    default: false,
+  },
+  actionPosition: {
+    type: String as PropType<'top' | 'bottom' | 'both'>,
+    required: false,
+    default: 'both',
+  },
 })
 })
 
 
-const { i18n } = useNuxtApp()
+// ### Définitions
+
+const i18n = useI18n()
 const router = useRouter()
 const router = useRouter()
 const { em } = useEntityManager()
 const { em } = useEntityManager()
+const { refreshProfile } = useRefreshProfile()
+const route = useRoute()
 
 
+// Le formulaire est-il valide
 const isValid: Ref<boolean> = ref(true)
 const isValid: Ref<boolean> = ref(true)
+
+// Erreurs de validation
 const errors: Ref<Array<string>> = ref([])
 const errors: Ref<Array<string>> = ref([])
 
 
-/**
- * Référence au component v-form
- */
+// Référence au component v-form
 const form: Ref = ref(null)
 const form: Ref = ref(null)
 
 
+const formStore = useFormStore()
+
+// Le formulaire est-il en lecture seule
 const readonly: ComputedRef<boolean> = computed(() => {
 const readonly: ComputedRef<boolean> = computed(() => {
-  return useFormStore().readonly
+  return formStore.readonly
 })
 })
 
 
 /**
 /**
- * Utilise la méthode validate() de v-form pour valider le formulaire et mettre à jour les variables isValid et errors
- *
- * @see https://vuetifyjs.com/en/api/v-form/#functions-validate
+ * Si l'utilisateur veut quitter le formulaire sans enregistrer ses modifications,
+ * on affiche la fenêtre de confirmation. En attendant, on garde en mémoire la route qu'il
+ * voulait suivre au cas où il confirmerait.
  */
  */
-const validate = async function () {
-  const validation = await form.value.validate()
+const requestedLeavingRoute: Ref<RouteLocationNormalized | null> = ref(null)
 
 
-  isValid.value = validation.valid
-  errors.value = validation.errors
-}
+// La fenêtre de confirmation est-elle affichée
+const isConfirmationDialogShowing: ComputedRef<boolean> = computed(() => {
+  return formStore.showConfirmToLeave
+})
 
 
 /**
 /**
- * Handle events if the form is dirty to prevent submission
- * @param e
+ * Ferme la fenêtre de confirmation
  */
  */
-// TODO: voir si encore nécessaire avec le @submit.prevent
-const preventSubmit = (e: any) => {
-  // Cancel the event
-  e.preventDefault()
-  // Chrome requires returnValue to be set
-  e.returnValue = ''
+const closeConfirmationDialog = () => {
+  requestedLeavingRoute.value = null
+  formStore.setShowConfirmToLeave(false)
 }
 }
 
 
+// ### Actions du formulaire
 /**
 /**
- * Définit l'état dirty (modifié) du formulaire
+ * Soumet le formulaire
+ *
+ * @param next
  */
  */
-const setIsDirty = (dirty: boolean) => {
-  useFormStore().setDirty(dirty)
+const submit = async (next: string | null = null) => {
+  if (props.validationPending) {
+    return
+  }
 
 
-  // If dirty, add the preventSubmit event listener
-  // TODO: voir si encore nécessaire avec le @submit.prevent
-  if (process.browser) {
-    if (dirty) {
-      window.addEventListener('beforeunload', preventSubmit)
-    } else {
-      window.removeEventListener('beforeunload', preventSubmit)
-    }
+  // Valide les données
+  await validate()
+
+  if (!isValid.value) {
+    usePageStore().addAlert(TYPE_ALERT.ALERT, ['invalid_form'])
+    return
   }
   }
-}
 
 
-watch(props.entity, async (newEntity, oldEntity) => {
-  await onFormChange()
-})
+  try {
+    usePageStore().loading = true
 
 
-/**
- *  Update store when form is changed (if valid)
- */
-const onFormChange = async () => {
-  console.log('form save')
+    // TODO: est-ce qu'il faut re-fetch l'entité après le persist?
+    const updatedEntity = await em.persist(props.model, props.entity)
 
 
-  await validate()
+    if (props.refreshProfile) {
+      await refreshProfile()
+    }
 
 
-  if (isValid.value) {
-    em.save(props.model, props.entity)
-    setIsDirty(true)
+    usePageStore().addAlert(TYPE_ALERT.SUCCESS, ['saveSuccess'])
 
 
-    if (props.onChanged) {
-      // Execute the custom onChange method, if defined
-      // TODO: voir quelles variables passer à cette méthode custom ; d'ailleurs, vérifier aussi si cette méthode est utilisée
-      props.onChanged()
+    // On retire l'état 'dirty'
+    setIsDirty(false)
+
+    const actionArgs = next ? props.submitActions[next] : null
+
+    if (next === SUBMIT_TYPE.SAVE) {
+      onSaveAction(actionArgs, updatedEntity.id)
+    } else if (next === SUBMIT_TYPE.SAVE_AND_BACK) {
+      onSaveAndQuitAction(actionArgs)
     }
     }
+  } catch (error: any) {
+    if (
+      error.response &&
+      error.response.status === 422 &&
+      error.response.data.violations
+    ) {
+      // TODO: à revoir
+      const violations: Array<string> = []
+      let fields: AnyJson = {}
+
+      for (const violation of error.response.data.violations) {
+        violations.push(i18n.t(violation.message) as string)
+        fields = Object.assign(fields, {
+          [violation.propertyPath]: violation.message,
+        })
+      }
+
+      formStore.addViolation(fields)
+
+      usePageStore().addAlert(TYPE_ALERT.ALERT, ['invalid_form'])
+    } else {
+      throw error
+    }
+  } finally {
+    usePageStore().loading = false
   }
   }
 }
 }
 
 
+/**
+ * Enregistre et quitte
+ */
+const saveAndQuit = async () => {
+  await submit()
+  cancel()
+}
 
 
-// <--- TODO: revoir les 4 méthodes qui suivent
 /**
 /**
- * Action Sauvegarder qui redirige vers la page d'édition si on est en mode create
+ * Après l'action Sauvegarder
+ *
+ * Si on était en mode édition, on reste sur cette page (on ne fait rien).
+ * Si on était en mode création, on bascule sur le mode édition
+ *
  * @param route
  * @param route
  * @param id
  * @param id
- * @param router
  */
  */
-function save(route: Route, id: number, router: any){
-  if(useFormStore().formFunction === FORM_FUNCTION.CREATE){
+function onSaveAction(route: Route, id: number) {
+  if (formStore.formFunction === FORM_FUNCTION.CREATE) {
     route.path += id
     route.path += id
-    router.push(route)
+    navigateTo(route)
   }
   }
 }
 }
 
 
 /**
 /**
- * Action sauvegarder et route suivante qui redirige vers une route
+ * Après l'action Sauvegarder et Quitter
+ *
+ * On redirige vers la route donnée
+ *
  * @param route
  * @param route
- * @param router
  */
  */
-function saveAndGoTo(route: Route, router: any){
-  router.push(route)
+function onSaveAndQuitAction(route: Route) {
+  navigateTo(route)
 }
 }
 
 
 /**
 /**
- * Factory des fonctions permettant d'assurer l'étape suivant à la soumission d'un formulaire
- *
- * @param args
- * @param response
- * @param router
+ * Avant de quitter le formulaire, si le formulaire a été modifié, on demande confirmation
  */
  */
-function nextStepFactory(args: any, response: AnyJson, router: any){
-  const factory: AnyJson = {}
-  factory[SUBMIT_TYPE.SAVE] = () => save(args, response.id, router)
-  factory[SUBMIT_TYPE.SAVE_AND_BACK] = () => saveAndGoTo(args, router)
-  return factory
-}
-
-const nextStep = (next: string | null, response: AnyJson) => {
-  if (next === null)
-    return
-  nextStepFactory(props.submitActions[next], response, router)[next]()
-}
-
-// ---> Fin du todo
+onBeforeRouteLeave(
+  (to: RouteLocationNormalized, from: RouteLocationNormalized) => {
+    if (formStore.dirty === true) {
+      requestedLeavingRoute.value = to
+      formStore.setShowConfirmToLeave(true)
+      return false
+    }
+    return true
+  },
+)
 
 
+onMounted(() => {
+  window.addEventListener('beforeunload', (event) => {
+    if (formStore.dirty === true) {
+      event.returnValue = i18n.t('quit_without_saving_warning')
+    }
+  })
+})
 
 
 /**
 /**
- * Soumet le formulaire
- *
- * @param next
+ * Quitte le formulaire sans enregistrer
  */
  */
-const submit = async (next: string|null = null) => {
-  await validate()
-
-  if (!isValid.value) {
-    usePageStore().addAlerts(TYPE_ALERT.ALERT, ['invalid_form'])
-    return
-  }
-
+const cancel = () => {
   setIsDirty(false)
   setIsDirty(false)
 
 
-  try {
-    const updatedEntity = await em.persist(props.model, props.entity)
-
-    usePageStore().addAlerts(TYPE_ALERT.SUCCESS, ['saveSuccess'])
-
-    // nextStep(next, updatedEntity)
+  formStore.setShowConfirmToLeave(false)
 
 
-  } catch (error: any) {
+  em.reset(props.model, props.entity)
 
 
-    if (error.response.status === 422 && error.response.data['violations']) {
-        const violations: Array<string> = [] // TODO: cette variable est-elle utile?
-        let fields: AnyJson = {}
+  if (requestedLeavingRoute.value !== null) {
+    navigateTo(requestedLeavingRoute.value)
+  } else if (formStore.goAfterLeave !== null) {
+    router.push(formStore.goAfterLeave) // TODO: voir si on peut pas passer ça comme prop du component
+  }
+}
 
 
-        for (const violation of error.response.data['violations']) {
-          violations.push(i18n.t(violation['message']) as string)
-          fields = Object.assign(fields, {[violation['propertyPath']] : violation['message']})
-        }
+const actions = computed(() => {
+  return _.keys(props.submitActions)
+})
 
 
-        useFormStore().addViolations(fields)
+// #### Validation et store
+/**
+ *  Update store when form is changed (if valid)
+ */
+const onFormChange = async () => {
+  if (isValid.value) {
+    em.save(props.model, props.entity)
+    setIsDirty(true)
 
 
-        usePageStore().addAlerts(TYPE_ALERT.ALERT, ['invalid_form'])
+    if (props.onChanged) {
+      // Execute the custom onChange method, if defined
+      // TODO: voir quelles variables passer à cette méthode custom ; d'ailleurs, vérifier aussi si cette méthode est utilisée
+      props.onChanged()
     }
     }
   }
   }
 }
 }
 
 
-const showDialog: ComputedRef<boolean> = computed(() => {
-  return useFormStore().showConfirmToLeave
+/**
+ * Utilise la méthode validate() de v-form pour valider le formulaire et mettre à jour les variables isValid et errors
+ *
+ * @see https://vuetifyjs.com/en/api/v-form/#functions-validate
+ */
+const validate = async function () {
+  const validation = await form.value.validate()
+  isValid.value = validation.valid
+  errors.value = validation.errors
+}
+
+// #### Gestion de l'état dirty
+watch(props.entity, async (newEntity, oldEntity) => {
+  setIsDirty(true)
 })
 })
 
 
-const closeDialog = () => {
-  useFormStore().setShowConfirmToLeave(false)
+/**
+ * Handle events if the form is dirty to prevent submission
+ * @param e
+ */
+// TODO: voir si encore nécessaire avec le @submit.prevent
+const preventSubmit = (e: any) => {
+  // Cancel the event
+  e.preventDefault()
+  // Chrome requires returnValue to be set
+  e.returnValue = ''
 }
 }
 
 
-const saveAndQuit = async () => {
-  await submit()
-  quitForm()
+/**
+ * Applique ou retire l'état dirty (modifié) du formulaire
+ */
+const setIsDirty = (dirty: boolean) => {
+  formStore.setDirty(dirty)
 }
 }
 
 
-const quitForm = () => {
-  setIsDirty(false)
+defineExpose({ validate })
+</script>
 
 
-  useFormStore().setShowConfirmToLeave(false)
+<style scoped>
+.btnActions {
+  text-align: right;
+}
 
 
-  em.reset(props.model, props.entity.value)
+.confirmation-dlg-actions {
+  display: flex;
+  flex-direction: row;
+}
 
 
-  if (router) {
-    // @ts-ignore
-    router.push(useFormStore().goAfterLeave)
-  }
+.confirmation-dlg-actions .v-btn {
+  min-width: 255px;
+  max-width: 255px;
+  margin: 0 8px;
 }
 }
 
 
-const actions = computed(()=>{
-  return useKeys(props.submitActions)
-})
-</script>
+@media (max-width: 960px) {
+  .confirmation-dlg-actions {
+    width: 100%;
+    flex-direction: column;
+    align-items: center;
+  }
 
 
-<style scoped>
-.btnActions {
-  text-align: right;
+  .confirmation-dlg-actions .v-btn {
+    min-width: 80%;
+    max-width: 80%;
+    margin: 6px 0 !important;
+  }
 }
 }
 </style>
 </style>

+ 81 - 0
components/Ui/Form/Creation.vue

@@ -0,0 +1,81 @@
+<template>
+  <UiForm :model="model" :entity="entity" :submitActions="submitActions">
+    <template #form.button>
+      <v-btn v-if="goBackRoute" class="theme-neutral mr-3" @click="quit">
+        {{ $t('cancel') }}
+      </v-btn>
+    </template>
+
+    <slot v-bind="{ model, entity }" />
+  </UiForm>
+</template>
+
+<script setup lang="ts">
+import type { PropType } from '@vue/runtime-core'
+import type { RouteLocationRaw } from '@intlify/vue-router-bridge'
+import ApiModel from '~/models/ApiModel'
+import type { AnyJson } from '~/types/data'
+import { SUBMIT_TYPE } from '~/types/enum/enums'
+import { useEntityManager } from '~/composables/data/useEntityManager'
+
+const props = defineProps({
+  /**
+   * Classe de l'ApiModel (ex: Organization, Notification, ...)
+   */
+  model: {
+    type: Function as any as () => typeof ApiModel,
+    required: true,
+  },
+  /**
+   * Route de retour
+   */
+  goBackRoute: {
+    type: Object as PropType<RouteLocationRaw>,
+    required: false,
+    default: null,
+  },
+  /**
+   * La validation est en cours
+   */
+  validationPending: {
+    type: Boolean,
+    required: false,
+    default: false,
+  },
+  /**
+   * Faut-il rafraichir le profil à la soumission du formulaire ?
+   */
+  refreshProfile: {
+    type: Boolean,
+    required: false,
+    default: false,
+  },
+})
+
+const router = useRouter()
+const { em } = useEntityManager()
+
+//@ts-ignore Pour une raison que j'ignore, le type Ref<ApiModel> met en erreur la prop entity de UiForm...
+const entity: ApiModel = reactive(em.newInstance(props.model))
+
+const submitActions = computed(() => {
+  let actions: AnyJson = {}
+
+  if (props.goBackRoute !== null) {
+    actions[SUBMIT_TYPE.SAVE_AND_BACK] = props.goBackRoute
+  } else {
+    actions[SUBMIT_TYPE.SAVE] = null
+  }
+  return actions
+})
+
+const quit = () => {
+  if (!props.goBackRoute) {
+    throw Error('no go back route defined')
+  }
+
+  router.push(props.goBackRoute)
+}
+</script>
+
+<style scoped lang="scss"></style>

+ 106 - 0
components/Ui/Form/Edition.vue

@@ -0,0 +1,106 @@
+<template>
+  <LayoutContainer>
+    <UiLoadingPanel v-if="pending" />
+    <UiForm
+      v-else
+      :model="model"
+      :entity="entity"
+      :submitActions="submitActions"
+    >
+      <template #form.button>
+        <v-btn v-if="goBackRoute" class="theme-neutral mr-3" @click="quit">
+          {{ $t('cancel') }}
+        </v-btn>
+      </template>
+
+      <slot v-bind="{ model, entity }" />
+    </UiForm>
+  </LayoutContainer>
+</template>
+
+<script setup lang="ts">
+import type { PropType } from '@vue/runtime-core'
+import ApiModel from '~/models/ApiModel'
+import type { AnyJson } from '~/types/data'
+import { SUBMIT_TYPE } from '~/types/enum/enums'
+import { useRoute } from 'vue-router'
+import type { RouteLocationRaw } from 'vue-router'
+import { useEntityFetch } from '~/composables/data/useEntityFetch'
+import type { AsyncData } from '#app'
+
+const props = defineProps({
+  /**
+   * Classe de l'ApiModel (ex: Organization, Notification, ...)
+   */
+  model: {
+    type: Function as any as () => typeof ApiModel,
+    required: true,
+  },
+  /**
+   * Id de l'objet
+   * Si non renseigné, le component essaiera de l'extraire de la route actuelle
+   */
+  id: {
+    type: Number as PropType<number | null>,
+    required: false,
+    default: null,
+  },
+  /**
+   * Route de retour
+   */
+  goBackRoute: {
+    type: Object as PropType<RouteLocationRaw | null>,
+    required: false,
+    default: null,
+  },
+  /**
+   * La validation est en cours
+   */
+  validationPending: {
+    type: Boolean,
+    required: false,
+    default: false,
+  },
+  /**
+   * Faut-il rafraichir le profil à la soumission du formulaire ?
+   */
+  refreshProfile: {
+    type: Boolean,
+    required: false,
+    default: false,
+  },
+})
+
+const { fetch } = useEntityFetch()
+const route = useRoute()
+const router = useRouter()
+
+const entityId =
+  props.id !== null ? props.id : parseInt(route.params.id as string)
+
+const { data: entity, pending } = fetch(props.model, entityId) as AsyncData<
+  ApiModel,
+  Error | null
+>
+
+const submitActions = computed(() => {
+  let actions: AnyJson = {}
+
+  if (props.goBackRoute !== null) {
+    actions[SUBMIT_TYPE.SAVE_AND_BACK] = props.goBackRoute
+  } else {
+    actions[SUBMIT_TYPE.SAVE] = null
+  }
+  return actions
+})
+
+const quit = () => {
+  if (!props.goBackRoute) {
+    throw Error('no go back route defined')
+  }
+
+  router.push(props.goBackRoute)
+}
+</script>
+
+<style scoped lang="scss"></style>

+ 10 - 8
components/Ui/Help.vue

@@ -29,34 +29,34 @@
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import {Ref} from "@vue/reactivity";
+import type { Ref } from '@vue/reactivity'
 
 
 const props = defineProps({
 const props = defineProps({
   left: {
   left: {
     type: Boolean,
     type: Boolean,
     required: false,
     required: false,
-    default: false
+    default: false,
   },
   },
   right: {
   right: {
     type: Boolean,
     type: Boolean,
     required: false,
     required: false,
-    default: false
+    default: false,
   },
   },
   top: {
   top: {
     type: Boolean,
     type: Boolean,
     required: false,
     required: false,
-    default: false
+    default: false,
   },
   },
   bottom: {
   bottom: {
     type: Boolean,
     type: Boolean,
     required: false,
     required: false,
-    default: false
+    default: false,
   },
   },
   icon: {
   icon: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: 'mdi-help-circle'
-  }
+    default: 'mdi-help-circle',
+  },
 })
 })
 
 
 const { $refs } = useNuxtApp()
 const { $refs } = useNuxtApp()
@@ -67,7 +67,9 @@ const show: Ref<Boolean> = ref(false)
 const iconRef = ref(null)
 const iconRef = ref(null)
 
 
 // Left is the default, set it to true if not any other is true
 // Left is the default, set it to true if not any other is true
-const leftOrDefault: Ref<Boolean> = ref(props.left || (!props.right && !props.bottom && !props.top))
+const leftOrDefault: Ref<Boolean> = ref(
+  props.left || (!props.right && !props.bottom && !props.top),
+)
 
 
 const onIconClicked = (e: any) => {
 const onIconClicked = (e: any) => {
   show.value = !show.value
   show.value = !show.value

+ 86 - 92
components/Ui/Image.vue

@@ -1,14 +1,12 @@
 <!--
 <!--
-Composant Image permettant de charger et d'afficher une image stockée sur les serveurs Opentalent à partir de son id.
+Composant Image permettant d'afficher une image stockée sur les serveurs Opentalent à partir de son id.
 Permet d'afficher une image par défaut si l'image demandée n'est pas disponible ou invalide.
 Permet d'afficher une image par défaut si l'image demandée n'est pas disponible ou invalide.
-
-Si la propriété 'upload' est à 'true', propose aussi un input pour uploader une nouvelle image.
 -->
 -->
 <template>
 <template>
   <main>
   <main>
-    <div class="image-wrapper" :style="{width: width + 'px'}">
+    <div class="image-wrapper" :style="{ width: width + 'px' }">
       <v-img
       <v-img
-        :src="imageSrc"
+        :src="imageSrc ?? undefined"
         :lazy-src="defaultImagePath"
         :lazy-src="defaultImagePath"
         :height="height"
         :height="height"
         :width="width"
         :width="width"
@@ -21,97 +19,96 @@ Si la propriété 'upload' est à 'true', propose aussi un input pour uploader u
             justify="center"
             justify="center"
             v-if="pending"
             v-if="pending"
           >
           >
-            <v-progress-circular
-              :indeterminate="true"
-              color="neutral"
-            />
+            <v-progress-circular :indeterminate="true" color="neutral" />
           </v-row>
           </v-row>
         </template>
         </template>
-      </v-img>
 
 
-      <div>
-        <div v-if="upload" class="click-action hover" @click="openUpload=true"><v-icon>mdi-upload</v-icon></div>
-        <UiInputImage
-          v-if="openUpload"
-          @close="openUpload=false"
-          :existingImageId="id"
-          :field="field"
-          :ownerId="ownerId"
-          @update="$emit('update', $event, field); openUpload=false"
-          @reload="onReload"
-          @reset="reset"
-        ></UiInputImage>
-      </div>
+        <div
+          v-if="!pending && overlayIcon"
+          class="overlay"
+          @click="emit('overlay-clicked')"
+        >
+          <v-icon>{{ overlayIcon }}</v-icon>
+        </div>
+      </v-img>
     </div>
     </div>
   </main>
   </main>
 </template>
 </template>
 
 
-
 <script setup lang="ts">
 <script setup lang="ts">
-import {ref, Ref} from "@vue/reactivity";
-import {useImageFetch} from "~/composables/data/useImageFetch";
-import {onUnmounted, watch, WatchStopHandle} from "@vue/runtime-core";
-import {useImageManager} from "~/composables/data/useImageManager";
-import ImageManager from "~/services/data/imageManager";
+import { useImageFetch } from '~/composables/data/useImageFetch'
+import ImageManager from '~/services/data/imageManager'
+import type { WatchStopHandle } from '@vue/runtime-core'
+import type { Ref } from '@vue/reactivity'
 
 
 const props = defineProps({
 const props = defineProps({
-  id: {
-    type: Number,
+  /**
+   * Id de l'image (null si aucune)
+   */
+  imageId: {
+    type: Number as PropType<number | null>,
     required: false,
     required: false,
-    default: null
+    default: null,
   },
   },
+  /**
+   * Image par défaut
+   */
   defaultImage: {
   defaultImage: {
     type: String,
     type: String,
-    required: false
+    required: false,
   },
   },
+  /**
+   * Hauteur de l'image à l'écran (en px)
+   */
   height: {
   height: {
     type: Number,
     type: Number,
-    required: false
+    required: false,
   },
   },
+  /**
+   * Largeur de l'image à l'écran (en px)
+   */
   width: {
   width: {
     type: Number,
     type: Number,
-    required: false
+    required: false,
   },
   },
-  field: {
+  /**
+   * Icône à afficher en overlay au survol de la souris
+   */
+  overlayIcon: {
     type: String,
     type: String,
-    required: false
-  },
-  upload: {
-    type: Boolean,
     required: false,
     required: false,
-    default: false
+    default: null,
   },
   },
-  ownerId:{
-    type: Number,
-    required: false
-  }
 })
 })
 
 
-const { imageManager } = useImageManager()
 const { fetch } = useImageFetch()
 const { fetch } = useImageFetch()
 
 
 const defaultImagePath = props.defaultImage ?? ImageManager.defaultImage
 const defaultImagePath = props.defaultImage ?? ImageManager.defaultImage
 
 
-const openUpload: Ref<Boolean> = ref(false)
+const emit = defineEmits(['overlay-clicked'])
 
 
-const { data: imageSrc, pending, refresh } = fetch(props.id ?? null, defaultImagePath, props.height, props.width)
+const fileId = toRef(props, 'imageId')
 
 
-const unwatch: WatchStopHandle = watch(() => props.id, async (newValue, oldValue) => {
-  await refresh()
-})
+const {
+  data: imageSrc,
+  pending,
+  refresh: refreshImage,
+} = (await fetch(fileId, defaultImagePath, props.height, props.width)) as any
 
 
-const onReload = async () => {
-  await refresh()
-  openUpload.value = false
+const refresh = () => {
+  refreshImage()
 }
 }
+defineExpose({ refresh })
 
 
 /**
 /**
- * Quand on souhaite faire un reset de l'image
+ * Si l'id change, on recharge l'image
  */
  */
-const reset = () => {
-  imageSrc.value = defaultImagePath
-  openUpload.value = false
-}
+const unwatch: WatchStopHandle = watch(
+  () => props.imageId,
+  async (value, oldValue) => {
+    refresh()
+  },
+)
 
 
 /**
 /**
  * Lorsqu'on démonte le component, on supprime le watcher
  * Lorsqu'on démonte le component, on supprime le watcher
@@ -122,41 +119,38 @@ onUnmounted(() => {
 </script>
 </script>
 
 
 <style lang="scss">
 <style lang="scss">
-  div.image-wrapper {
+div.image-wrapper {
+  display: block;
+  position: relative;
+
+  img {
     display: block;
     display: block;
-    position: relative;
+    max-width: 100%;
+  }
 
 
-    img {
-      max-width: 100%;
-    }
+  .overlay {
+    position: absolute;
+    top: 0;
+    bottom: 0;
+    left: 0;
+    right: 0;
+    height: 100%;
+    width: 100%;
+    opacity: 0;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    transition: 0.3s ease;
+  }
+  .overlay:hover {
+    opacity: 0.8;
+    background-color: rgb(var(--v-theme-neutral-strong));
+    cursor: pointer;
+  }
 
 
-    .click-action {
-      position: absolute;
-      top:0;
-      left:0;
-      width: 100%;
-      height: 100%;
-      background: transparent;
-      opacity: 0;
-      transition: all .2s;
-      &:hover {
-        opacity: 1;
-        background: rgb(var(--v-theme-neutral-strong));
-        cursor: pointer;
-      }
-      i {
-        color: rgb(var(--v-theme-on-neutral-strong));
-        position: absolute;
-        top: 50%;
-        left: 50%;
-        transform: translate(-50% , -50%);
-        font-size: 50px;
-        z-index: 1;
-        opacity: 1;
-        &:hover{
-          color: rgb(var(--v-theme-primary-alt));
-        }
-      }
-    }
+  .overlay .v-icon {
+    color: rgb(var(--v-theme-on-neutral-strong));
+    font-size: 36px;
   }
   }
+}
 </style>
 </style>

+ 246 - 104
components/Ui/Input/Autocomplete.vue

@@ -1,176 +1,299 @@
 <!--
 <!--
-Liste déroulante avec autocompletion
+Liste déroulante avec autocompletion, à placer dans un composant `UiForm`
 
 
 @see https://vuetifyjs.com/en/components/autocompletes/#usage
 @see https://vuetifyjs.com/en/components/autocompletes/#usage
 -->
 -->
 
 
 <template>
 <template>
   <main>
   <main>
+    <!--suppress TypeScriptValidateTypes -->
     <v-autocomplete
     <v-autocomplete
+      :model-value="modelValue"
       autocomplete="search"
       autocomplete="search"
-      :value="data"
-      :items="itemsToDisplayed"
+      :items="items"
       :label="$t(fieldLabel)"
       :label="$t(fieldLabel)"
-      item-text="itemTextDisplay"
+      :item-title="itemTitle"
       :item-value="itemValue"
       :item-value="itemValue"
-      :no-data-text="$t('autocomplete_research')"
       :no-filter="noFilter"
       :no-filter="noFilter"
-      auto-select-first
+      :auto-select-first="autoSelectFirst"
       :multiple="multiple"
       :multiple="multiple"
       :loading="isLoading"
       :loading="isLoading"
       :return-object="returnObject"
       :return-object="returnObject"
       :search-input.sync="search"
       :search-input.sync="search"
       :prepend-icon="prependIcon"
       :prepend-icon="prependIcon"
-      :error="error || !!violation"
-      :error-messages="errorMessage || violation ? $t(violation) : ''"
+      :error="error || !!fieldViolations"
+      :error-messages="
+        errorMessage || fieldViolations ? $t(fieldViolations) : ''
+      "
       :rules="rules"
       :rules="rules"
       :chips="chips"
       :chips="chips"
-      @input="onChange($event)"
+      :hide-no-data="hideNoData"
+      :no-data-text="
+        isLoading ? $t('please_wait') : $t('no_result_matching_your_request')
+      "
+      :variant="variant"
+      @update:model-value="onUpdate"
+      @update:search="emit('update:search', $event)"
+      @update:menu="emit('update:menu', $event)"
+      @update:focused="emit('update:focused', $event)"
     >
     >
       <template v-if="slotText" #item="data">
       <template v-if="slotText" #item="data">
-        <v-list-item-content v-text="data.item.slotTextDisplay"></v-list-item-content>
+        <!--        <v-list-item-content v-text="data.item.slotTextDisplay"></v-list-item-content>-->
       </template>
       </template>
     </v-autocomplete>
     </v-autocomplete>
   </main>
   </main>
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import {useNuxtApp} from "#app";
-import {computed, ComputedRef, Ref} from "@vue/reactivity";
-import {useFieldViolation} from "~/composables/form/useFieldViolation";
-import {onUnmounted, watch} from "@vue/runtime-core";
-import ObjectUtils from "~/services/utils/objectUtils";
+import { computed } from '@vue/reactivity'
+import type { ComputedRef, Ref } from '@vue/reactivity'
+import { useFieldViolation } from '~/composables/form/useFieldViolation'
+import ObjectUtils from '~/services/utils/objectUtils'
+import type { AnyJson } from '~/types/data'
+import type { PropType } from '@vue/runtime-core'
 
 
 const props = defineProps({
 const props = defineProps({
-  label: {
-    type: String,
+  /**
+   * v-model
+   */
+  modelValue: {
+    type: [String, Number, Object, Array] as PropType<any>,
     required: false,
     required: false,
-    default: null
+    default: null,
   },
   },
+  /**
+   * Nom de la propriété d'une entité lorsque l'input concerne cette propriété
+   * - Utilisé par la validation
+   * - Laisser null si le champ ne s'applique pas à une entité
+   */
   field: {
   field: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: null
+    default: null,
   },
   },
-  data: {
-    type: [String, Number, Object, Array],
+  /**
+   * Label du champ
+   * Si non défini, c'est le nom de propriété qui est utilisé
+   */
+  label: {
+    type: String,
     required: false,
     required: false,
-    default: null
+    default: null,
   },
   },
+  /**
+   * Liste des éléments de la liste
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-items
+   */
   items: {
   items: {
-    type: Array,
+    type: Array as PropType<Array<Object>>,
     required: false,
     required: false,
-    default: () => []
+    default: () => [],
   },
   },
+  /**
+   * Définit si le champ est en lecture seule
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-readonly
+   */
   readonly: {
   readonly: {
     type: Boolean,
     type: Boolean,
-    required: false
+    required: false,
   },
   },
-  itemValue: {
-    type: String,
-    default: 'id'
+  /**
+   * Le model est l'objet lui-même, et non pas son id (ou la propriété définie avec itemValue)
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-return-object
+   */
+  returnObject: {
+    type: Boolean,
+    default: false,
   },
   },
-  itemText: {
-    type: Array,
-    required: true
+  /**
+   * Autorise la sélection multiple
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-multiple
+   */
+  multiple: {
+    type: Boolean,
+    default: false,
   },
   },
-  group:{
+  /**
+   * Propriété de l'objet à utiliser comme label
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-item-title
+   */
+  itemTitle: {
     type: String,
     type: String,
-    required: false,
-    default: null
+    default: 'title',
   },
   },
-  slotText: {
-    type: Array,
-    required: false,
-    default: null
+  /**
+   * Propriété de l'objet à utiliser comme clé (et correspondant au v-model)
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-item-value
+   */
+  itemValue: {
+    type: String,
+    default: 'id',
   },
   },
-  returnObject: {
-    type: Boolean,
-    default: false
+  /**
+   * Icône de gauche
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-prepend-icon
+   */
+  prependIcon: {
+    type: String,
   },
   },
-  multiple: {
+  /**
+   * Rends les résultats sous forme de puces
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-chips
+   */
+  chips: {
     type: Boolean,
     type: Boolean,
-    default: false
+    default: false,
   },
   },
+  /**
+   * Le contenu de la liste est en cours de chargement
+   */
   isLoading: {
   isLoading: {
     type: Boolean,
     type: Boolean,
-    default: false
+    required: false,
+    default: false,
   },
   },
+  /**
+   * Propriété de l'objet utilisé pour grouper les items ; laisser null pour ne pas grouper
+   */
+  group: {
+    type: String,
+    required: false,
+    default: null,
+  },
+  /**
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-hide-no-data
+   */
+  hideNoData: {
+    type: Boolean,
+    required: false,
+    default: false,
+  },
+  // TODO: c'est quoi?
+  slotText: {
+    type: Array,
+    required: false,
+    default: null,
+  },
+  /**
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-no-filter
+   */
   noFilter: {
   noFilter: {
     type: Boolean,
     type: Boolean,
-    default: false
+    default: false,
   },
   },
-  prependIcon: {
-    type: String
+  /**
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-auto-select-first
+   */
+  autoSelectFirst: {
+    type: Boolean,
+    default: true,
   },
   },
+  // TODO: c'est quoi?
   translate: {
   translate: {
     type: Boolean,
     type: Boolean,
-    default: false
+    default: false,
   },
   },
+  /**
+   * Règles de validation
+   * @see https://vuetify.cn/en/components/forms/#validation-with-submit-clear
+   */
   rules: {
   rules: {
     type: Array,
     type: Array,
     required: false,
     required: false,
-    default: () => []
-  },
-  chips: {
-    type: Boolean,
-    default: false
+    default: () => [],
   },
   },
+  /**
+   * Le champ est-il actuellement en état d'erreur
+   */
   error: {
   error: {
     type: Boolean,
     type: Boolean,
-    required: false
+    required: false,
   },
   },
+  /**
+   * Si le champ est en état d'erreur, quel est le message d'erreur?
+   */
   errorMessage: {
   errorMessage: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: null
-  }
+    default: null,
+  },
+  /**
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-variant
+   */
+  variant: {
+    type: String as PropType<
+      | 'filled'
+      | 'outlined'
+      | 'plain'
+      | 'underlined'
+      | 'solo'
+      | 'solo-inverted'
+      | 'solo-filled'
+      | undefined
+    >,
+    required: false,
+    default: 'filled',
+  },
 })
 })
 
 
-const { emit } = useNuxtApp()
-
-const { i18n } = useNuxtApp()
+const i18n = useI18n()
 
 
-const search: Ref<string|null> = ref(null)
+const search: Ref<string | null> = ref(null)
 
 
-const fieldLabel = props.label ?? props.field
+const fieldLabel: string = props.label ?? props.field
 
 
-const { violation, onChange } = useFieldViolation(props.field, emit)
+const { fieldViolations, updateViolationState } = useFieldViolation(props.field)
 
 
-// On reconstruit les items à afficher...
-const itemsToDisplayed: ComputedRef<Array<AnyJson>> = computed(() => {
-  const itemsByGroup:Array<Array<string>> = classItemsByGroup(props.items)
-  return prepareItemsToDisplayed(itemsByGroup)
-})
+const emit = defineEmits([
+  'update:model-value',
+  'update:search',
+  'update:focused',
+  'update:menu',
+])
 
 
-const unwatch = watch(
-    search,
-    useDebounce(async (newResearch, oldResearch) => {
-      if(newResearch !== oldResearch && oldResearch !== null)
-        emit('research', newResearch)
-    }, 500)
-)
+const onUpdate = (event: string) => {
+  updateViolationState(event)
+  emit('update:model-value', event)
+}
 
 
-onUnmounted(() => {
-  unwatch()
+/**
+ * Items à afficher
+ * TODO: à revoir
+ */
+const items: ComputedRef<Array<AnyJson>> = computed(() => {
+  let _items: Array<any> = props.items
+  return _items
+  // if (props.group !== null) {
+  //   _items = groupItems(props.items)
+  // }
+  //
+  // return prepareGroups(_items)
 })
 })
 
 
 /**
 /**
- * On construit l'Array à double entrée contenant les groups (headers) et les propositions
+ * On construit l'Array à double entrée contenant les groups (headers) et les items
+ * TODO: à revoir
  *
  *
  * @param items
  * @param items
  */
  */
-const classItemsByGroup = (items:Array<any>): Array<Array<string>> => {
-  const group = props.group as string
+const groupItems = (items: Array<any>): Array<Array<string>> => {
+  const group = props.group as string | null
+  if (group === null) {
+    return items
+  }
+
   const itemsByGroup: Array<Array<string>> = []
   const itemsByGroup: Array<Array<string>> = []
+  let groupValue = null
 
 
   for (const item of items) {
   for (const item of items) {
     if (item) {
     if (item) {
-      if (!itemsByGroup[item[group]]) {
-        itemsByGroup[item[group]] = []
+      groupValue = item[group]
+
+      if (!itemsByGroup[groupValue]) {
+        itemsByGroup[groupValue] = []
       }
       }
 
 
-      itemsByGroup[item[group]].push(item)
+      itemsByGroup[groupValue].push(item)
     }
     }
   }
   }
 
 
@@ -179,41 +302,60 @@ const classItemsByGroup = (items:Array<any>): Array<Array<string>> => {
 
 
 /**
 /**
  * Construction de l'Array JSON contenant toutes les propositions à afficher dans le select
  * Construction de l'Array JSON contenant toutes les propositions à afficher dans le select
+ * TODO: à revoir
  *
  *
- * @param itemsByGroup
+ * @param groupedItems
  */
  */
-const prepareItemsToDisplayed = (itemsByGroup: Array<Array<string>>): Array<AnyJson> => {
+const prepareGroups = (groupedItems: Array<Array<string>>): Array<AnyJson> => {
   let finalItems: Array<AnyJson> = []
   let finalItems: Array<AnyJson> = []
 
 
-  for (const group in itemsByGroup) {
-
+  for (const group in groupedItems) {
     // Si un groupe est présent, alors on créé le groupe options header
     // Si un groupe est présent, alors on créé le groupe options header
     if (group !== 'undefined') {
     if (group !== 'undefined') {
-      finalItems.push({header: i18n.t(group as string)})
+      finalItems.push({ header: i18n.t(group as string) })
     }
     }
 
 
     // On parcourt les items pour préparer les texts / slotTexts à afficher
     // On parcourt les items pour préparer les texts / slotTexts à afficher
-    finalItems = finalItems.concat(itemsByGroup[group].map((item: any) => {
-      const slotTextDisplay: Array<string> = []
-      const itemTextDisplay: Array<string> = []
+    finalItems = finalItems.concat(
+      groupedItems[group].map((item: any) => {
+        return prepareItem(item)
+      }),
+    )
+  }
+  return finalItems
+}
 
 
-      item = ObjectUtils.cloneAndFlatten(item)
+/**
+ * Construction d'un item
+ * TODO: à revoir
+ *
+ * @param item
+ */
+const prepareItem = (item: Object): AnyJson => {
+  const slotTextDisplay: Array<string> = []
+  const itemTextDisplay: Array<string> = []
 
 
-      // Si on souhaite avoir un texte différent dans les propositions que dans la sélection finale de select
-      if (props.slotText) {
-        for (const text of props.slotText) {
-          slotTextDisplay.push(props.translate ? i18n.t(item[text as string]) : item[text as string])
-        }
-      }
+  item = ObjectUtils.cloneAndFlatten(item)
 
 
-      for (const text of props.itemText) {
-        itemTextDisplay.push(props.translate ? i18n.t(item[text as string]) : item[text as string])
-      }
+  // Si on souhaite avoir un texte différent dans les propositions que dans la sélection finale de select
+  if (props.slotText) {
+    for (const text of props.slotText) {
+      slotTextDisplay.push(
+        props.translate ? i18n.t(item[text as string]) : item[text as string],
+      )
+    }
+  }
 
 
-      // On reconstruit l'objet
-      return Object.assign({}, item, { itemTextDisplay: itemTextDisplay.join(' '), slotTextDisplay: slotTextDisplay.join(' ') })
-    }))
+  for (const text of props.itemTitle) {
+    itemTextDisplay.push(
+      props.translate ? i18n.t(item[text as string]) : item[text as string],
+    )
   }
   }
-  return finalItems
+
+  // On reconstruit l'objet
+  return Object.assign({}, item, {
+    itemTextDisplay: itemTextDisplay.join(' '),
+    slotTextDisplay: slotTextDisplay.join(' '),
+  })
 }
 }
 </script>
 </script>

+ 263 - 0
components/Ui/Input/Autocomplete/Accesses.vue

@@ -0,0 +1,263 @@
+<!--
+Champs autocomplete dédié à la recherche des access d'une structure
+
+@see https://vuetifyjs.com/en/components/autocompletes/#usage
+-->
+
+<template>
+  <main>
+    <UiInputAutocomplete
+      :model-value="modelValue"
+      :field="field"
+      :label="label"
+      :items="items"
+      item-value="id"
+      :isLoading="pending"
+      :multiple="multiple"
+      hide-no-data
+      :chips="chips"
+      :auto-select-first="false"
+      prependIcon="fas fa-magnifying-glass"
+      :return-object="false"
+      :variant="variant"
+      @update:model-value="onUpdateModelValue"
+      @update:search="onUpdateSearch"
+    />
+  </main>
+</template>
+
+<script setup lang="ts">
+import type { PropType } from '@vue/runtime-core'
+import { computed } from '@vue/reactivity'
+import type { ComputedRef, Ref } from '@vue/reactivity'
+import type { AnyJson, AssociativeArray } from '~/types/data'
+import { useEntityFetch } from '~/composables/data/useEntityFetch'
+import Access from '~/models/Access/Access'
+import { useEntityManager } from '~/composables/data/useEntityManager'
+import ArrayUtils from '~/services/utils/arrayUtils'
+import * as _ from 'lodash-es'
+
+const props = defineProps({
+  /**
+   * v-model
+   */
+  modelValue: {
+    type: [Object, Array],
+    required: false,
+    default: null,
+  },
+  /**
+   * Filtres à transmettre à la source de données
+   */
+  filters: {
+    type: Object as PropType<Ref<AssociativeArray>>,
+    required: false,
+    default: ref(null),
+  },
+  /**
+   * Nom de la propriété d'une entité lorsque l'input concerne cette propriété
+   * - Utilisé par la validation
+   * - Laisser null si le champ ne s'applique pas à une entité
+   */
+  field: {
+    type: String,
+    required: false,
+    default: null,
+  },
+  /**
+   * Label du champ
+   * Si non défini, c'est le nom de propriété qui est utilisé
+   */
+  label: {
+    type: String,
+    required: false,
+    default: null,
+  },
+  /**
+   * Définit si le champ est en lecture seule
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-readonly
+   */
+  readonly: {
+    type: Boolean,
+    required: false,
+  },
+  /**
+   * Autorise la sélection multiple
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-multiple
+   */
+  multiple: {
+    type: Boolean,
+    default: false,
+  },
+  /**
+   * Rends les résultats sous forme de puces
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-chips
+   */
+  chips: {
+    type: Boolean,
+    default: false,
+  },
+  /**
+   * Closes the menu and clear the current search after the selection has been updated
+   */
+  clearSearchAfterUpdate: {
+    type: Boolean,
+    default: false,
+  },
+  /**
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-variant
+   */
+  variant: {
+    type: String as PropType<
+      | 'filled'
+      | 'outlined'
+      | 'plain'
+      | 'underlined'
+      | 'solo'
+      | 'solo-inverted'
+      | 'solo-filled'
+      | undefined
+    >,
+    required: false,
+    default: 'filled',
+  },
+})
+
+/**
+ * Element de la liste autocomplete
+ */
+interface AccessListItem {
+  id: number | string
+  title: string
+}
+
+const { fetchCollection } = useEntityFetch()
+const { em } = useEntityManager()
+const i18n = useI18n()
+
+/**
+ * Génère un AccessListItem à partir d'un Access
+ * @param access
+ */
+const accessToItem = (access: Access): AccessListItem => {
+  return {
+    id: access.id,
+    title: access.person
+      ? `${access.person.name} ${access.person.givenName}`
+      : i18n.t('unknown'),
+  }
+}
+
+const initialized: Ref<boolean> = ref(false)
+
+/**
+ * Saisie de l'utilisateur utilisée pour filtrer la recherche
+ */
+const nameFilter: Ref<string | null> = ref(null)
+
+/**
+ * Query transmise à l'API lors des changements de filtre de recherche
+ */
+const query: ComputedRef<AnyJson> = computed(() => {
+  let q: AnyJson = { 'groups[]': 'access_people_ref', 'order[name]': 'asc' }
+
+  if (!initialized.value && props.modelValue) {
+    if (Array.isArray(props.modelValue) && props.modelValue.length > 0) {
+      q['id[in]'] = props.modelValue.join(',')
+    } else {
+      q['id[in]'] = props.modelValue
+    }
+    return q
+  }
+
+  if (nameFilter.value) {
+    q['fullname'] = nameFilter.value
+  }
+
+  return q
+})
+
+/**
+ * On commence par fetcher les accesses déjà actifs, pour affichage des noms
+ */
+const {
+  data: collection,
+  pending,
+  refresh,
+} = await fetchCollection(Access, null, query)
+initialized.value = true
+
+// On a déjà récupéré les access actifs, on relance une requête pour récupérer la première page
+// des accesses suivants
+refresh()
+
+/**
+ * Contenu de la liste autocomplete
+ */
+const items: ComputedRef<Array<AccessListItem>> = computed(() => {
+  if (pending.value || !collection.value) {
+    return []
+  }
+
+  if (!collection.value) {
+    return []
+  }
+
+  //@ts-ignore
+  const fetchedItems = collection.value.items.map(accessToItem)
+
+  // move the active items to the top and sort by name
+  fetchedItems.sort((a, b) => {
+    if (props.modelValue.includes(a.id) && !props.modelValue.includes(b.id)) {
+      return -1
+    } else if (
+      !props.modelValue.includes(a.id) &&
+      props.modelValue.includes(b.id)
+    ) {
+      return 1
+    } else {
+      return a.title.localeCompare(b.title)
+    }
+  })
+
+  return fetchedItems
+})
+
+/**
+ * Délai entre le dernier caractère saisi et la requête de vérification de la mise à jour des résultats (en ms)
+ */
+const inputDelay = 600
+
+/**
+ * Version debounced de la fonction refresh
+ * @see https://docs-lodash.com/v4/debounce/
+ */
+const refreshDebounced: _.DebouncedFunc<() => void> = _.debounce(async () => {
+  await refresh()
+}, inputDelay)
+
+// ### Events
+const emit = defineEmits(['update:model-value'])
+
+/**
+ * La recherche textuelle a changé.
+ * @param event
+ */
+const onUpdateSearch = (event: string) => {
+  nameFilter.value = event
+  refreshDebounced()
+}
+
+const onUpdateModelValue = (event: Array<number>) => {
+  if (props.clearSearchAfterUpdate) {
+    nameFilter.value = ''
+  }
+  emit('update:model-value', event)
+}
+</script>
+
+<style scoped lang="scss">
+.v-autocomplete {
+  min-width: 350px;
+}
+</style>

+ 30 - 31
components/Ui/Input/AutocompleteWithAPI.vue

@@ -27,75 +27,74 @@ d'une api)
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-
-import {Ref, ref, toRefs} from "@vue/reactivity";
-import UrlUtils from "~/services/utils/urlUtils";
-import {FetchOptions} from "ohmyfetch";
-import {useFetch} from "#app";
-import {watch} from "@vue/runtime-core";
+import { ref, toRefs } from '@vue/reactivity'
+import type { Ref } from '@vue/reactivity'
+import UrlUtils from '~/services/utils/urlUtils'
+import { useFetch } from '#app'
+import { watch } from '@vue/runtime-core'
 
 
 const props = defineProps({
 const props = defineProps({
   label: {
   label: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: null
+    default: null,
   },
   },
   field: {
   field: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: null
+    default: null,
   },
   },
   searchFunction: {
   searchFunction: {
     type: Function,
     type: Function,
-    required: true
+    required: true,
   },
   },
   data: {
   data: {
     type: [String, Number, Object, Array],
     type: [String, Number, Object, Array],
     required: false,
     required: false,
-    default: null
+    default: null,
   },
   },
   remoteUri: {
   remoteUri: {
     type: [Array],
     type: [Array],
     required: false,
     required: false,
-    default: null
+    default: null,
   },
   },
   remoteUrl: {
   remoteUrl: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: null
+    default: null,
   },
   },
   readonly: {
   readonly: {
     type: Boolean,
     type: Boolean,
-    required: false
+    required: false,
   },
   },
   itemValue: {
   itemValue: {
     type: String,
     type: String,
-    default: 'id'
+    default: 'id',
   },
   },
-  itemText: {
+  itemTitle: {
     type: Array,
     type: Array,
-    required: true
+    required: true,
   },
   },
   slotText: {
   slotText: {
     type: Array,
     type: Array,
-    required: false
+    required: false,
   },
   },
   returnObject: {
   returnObject: {
     type: Boolean,
     type: Boolean,
-    default: false
+    default: false,
   },
   },
   noFilter: {
   noFilter: {
     type: Boolean,
     type: Boolean,
-    default: false
+    default: false,
   },
   },
   multiple: {
   multiple: {
     type: Boolean,
     type: Boolean,
-    default: false
+    default: false,
   },
   },
   chips: {
   chips: {
     type: Boolean,
     type: Boolean,
-    default: false
-  }
+    default: false,
+  },
 })
 })
 
 
 const { data } = toRefs(props)
 const { data } = toRefs(props)
@@ -103,19 +102,19 @@ const items = ref([])
 const remoteData: Ref<Array<string> | null> = ref(null)
 const remoteData: Ref<Array<string> | null> = ref(null)
 const isLoading = ref(false)
 const isLoading = ref(false)
 
 
-
 if (props.data) {
 if (props.data) {
-  items.value = props.multiple ? (data.value ?? []) : [data.value]
-
+  items.value = props.multiple ? data.value ?? [] : [data.value]
 } else if (props.remoteUri) {
 } else if (props.remoteUri) {
+  const ids: Array<any> = []
 
 
-  const ids:Array<any> = []
-
-  for(const uri of props.remoteUri){
+  for (const uri of props.remoteUri) {
     ids.push(UrlUtils.extractIdFromUri(uri as string))
     ids.push(UrlUtils.extractIdFromUri(uri as string))
   }
   }
 
 
-  const options: FetchOptions = { method: 'GET', query: {key: 'id', value: ids.join(',')} }
+  const options: FetchOptions = {
+    method: 'GET',
+    query: { key: 'id', value: ids.join(',') },
+  }
 
 
   useFetch(async () => {
   useFetch(async () => {
     isLoading.value = true
     isLoading.value = true
@@ -128,14 +127,14 @@ if (props.data) {
   })
   })
 }
 }
 
 
-const search = async (research:string) => {
+const search = async (research: string) => {
   isLoading.value = true
   isLoading.value = true
   const func: Function = props.searchFunction
   const func: Function = props.searchFunction
   items.value = items.value.concat(await func(research, props.field))
   items.value = items.value.concat(await func(research, props.field))
   isLoading.value = false
   isLoading.value = false
 }
 }
 
 
-const unwatch = watch(data,(d) => {
+const unwatch = watch(data, (d) => {
   items.value = props.multiple ? d : [d]
   items.value = props.multiple ? d : [d]
 })
 })
 
 

+ 146 - 0
components/Ui/Input/AutocompleteWithAp2i.vue

@@ -0,0 +1,146 @@
+<!--
+Liste déroulante avec autocompletion issue de Ap2i
+
+@see https://vuetifyjs.com/en/components/autocompletes/#usage
+-->
+<template>
+  <main>
+    <UiInputAutocomplete
+      :v-model="modelValue"
+      :field="field"
+      :label="label"
+      :items="items"
+      :isLoading="pending"
+      item-title="title"
+      item-value="id"
+      :multiple="multiple"
+      :chips="chips"
+      prependIcon="fas fa-magnifying-glass"
+      :return-object="false"
+    />
+  </main>
+</template>
+
+<script setup lang="ts">
+import { computed } from '@vue/reactivity'
+import type { ComputedRef, Ref } from '@vue/reactivity'
+import type { PropType } from '@vue/runtime-core'
+import { useEntityFetch } from '~/composables/data/useEntityFetch'
+import ApiResource from '~/models/ApiResource'
+import ApiModel from '~/models/ApiModel'
+import type { AnyJson, AssociativeArray } from '~/types/data'
+
+const props = defineProps({
+  /**
+   * v-model
+   */
+  modelValue: {
+    type: [String, Number, Object, Array],
+    required: false,
+    default: null,
+  },
+  /**
+   * Classe de l'ApiModel (ex: Organization, Notification, ...) qui sert de source à la liste
+   */
+  model: {
+    type: Function as any as () => typeof ApiModel,
+    required: true,
+  },
+  /**
+   * Filtres à transmettre à la source de données
+   */
+  query: {
+    type: Object as PropType<Ref<AssociativeArray>>,
+    required: false,
+    default: ref(null),
+  },
+  /**
+   * Fonction qui sera exécutée sur chaque item, et qui doit renvoyer un objet contenant les
+   * propriétés 'id' et 'title'
+   *
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-item-title
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-item-value
+   */
+  transformation: {
+    type: Function as PropType<
+      (item: ApiResource) => { id: number | string; title: string }
+    >,
+    required: false,
+    default: (item: ApiResource) => item,
+  },
+  /**
+   * Nom de la propriété d'une entité lorsque l'input concerne cette propriété
+   * - Utilisé par la validation
+   * - Laisser null si le champ ne s'applique pas à une entité
+   */
+  field: {
+    type: String,
+    required: false,
+    default: null,
+  },
+  /**
+   * Label du champ
+   * Si non défini, c'est le nom de propriété qui est utilisé
+   */
+  label: {
+    type: String,
+    required: false,
+    default: null,
+  },
+  /**
+   * Définit si le champ est en lecture seule
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-readonly
+   */
+  readonly: {
+    type: Boolean,
+    required: false,
+  },
+  /**
+   * Autorise la sélection multiple
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-multiple
+   */
+  multiple: {
+    type: Boolean,
+    default: false,
+  },
+  /**
+   * Rends les résultats sous forme de puces
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-chips
+   */
+  chips: {
+    type: Boolean,
+    default: false,
+  },
+  // TODO: c'est quoi?
+  slotText: {
+    type: Array,
+    required: false,
+    default: null,
+  },
+})
+
+const { fetchCollection } = useEntityFetch()
+
+const query: ComputedRef<AnyJson> = computed(() => {
+  return {
+    ...(props.query.value ?? {}),
+    ...{ 'groups[]': 'access_people_ref' },
+  }
+})
+
+const { data: collection, pending } = await fetchCollection(
+  props.model,
+  null,
+  query,
+)
+
+const items: ComputedRef<Array<{ id: number | string; title: string }>> =
+  computed(() => {
+    if (!pending.value && collection.value && collection.value.items) {
+      console.log(collection)
+
+      return collection.value.items.map(props.transformation)
+    }
+    return []
+  })
+</script>

+ 72 - 0
components/Ui/Input/AutocompleteWithEnum.vue

@@ -0,0 +1,72 @@
+<template>
+  <UiInputAutocomplete
+    :model-value="modelValue"
+    :field="field"
+    :items="items"
+    :is-loading="pending"
+    :return-object="false"
+    item-title="label"
+    item-value="value"
+    :variant="variant"
+    @update:model-value="$emit('update:model-value', $event)"
+  />
+</template>
+
+<script setup lang="ts">
+import type { ComputedRef, PropType } from 'vue'
+import { useEnumFetch } from '~/composables/data/useEnumFetch'
+import ArrayUtils from '~/services/utils/arrayUtils'
+import type { Enum } from '~/types/data'
+
+const props = defineProps({
+  modelValue: {
+    type: String as PropType<string | null>,
+    required: false,
+    default: null,
+  },
+  enumName: {
+    type: String,
+    required: true,
+  },
+  field: {
+    type: String,
+    required: false,
+    default: null,
+  },
+  label: {
+    type: String,
+    required: false,
+    default: null,
+  },
+  /**
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-variant
+   */
+  variant: {
+    type: String as PropType<
+      | 'filled'
+      | 'outlined'
+      | 'plain'
+      | 'underlined'
+      | 'solo'
+      | 'solo-inverted'
+      | 'solo-filled'
+      | undefined
+    >,
+    required: false,
+    default: 'filled',
+  },
+})
+
+const { fetch } = useEnumFetch()
+
+const { data: enumItems, pending } = fetch(props.enumName)
+
+const items: ComputedRef<Array<Enum>> = computed(() => {
+  if (!enumItems.value) {
+    return []
+  }
+  return ArrayUtils.sortObjectsByProp(enumItems.value, 'label') as Array<Enum>
+})
+</script>
+
+<style scoped lang="scss"></style>

+ 60 - 31
components/Ui/Input/Checkbox.vue

@@ -1,66 +1,95 @@
 <!--
 <!--
-Case à cocher
+Case à cocher, à placer dans un composant `UiForm`
 
 
 @see https://vuetifyjs.com/en/components/checkboxes/
 @see https://vuetifyjs.com/en/components/checkboxes/
 -->
 -->
 
 
 <template>
 <template>
-  <v-container
-    class="px-0"
-    fluid
-  >
-    <v-checkbox
-      v-model="data"
-      :value="data"
-      :label="$t(fieldLabel)"
-      :disabled="readonly"
-      :error="error || !!violation"
-      :error-messages="errorMessage || violation ? $t(violation) : ''"
-      @change="onChange($event)"
-    />
-  </v-container>
+  <v-checkbox
+    :model-value="modelValue"
+    :label="$t(fieldLabel)"
+    :disabled="readonly"
+    :error="error || !!fieldViolations"
+    :error-messages="errorMessage || fieldViolations ? $t(fieldViolations) : ''"
+    class="py-1"
+    @update:model-value="onUpdate"
+  />
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import {useFieldViolation} from "~/composables/form/useFieldViolation";
+import { useFieldViolation } from '~/composables/form/useFieldViolation'
 
 
 const props = defineProps({
 const props = defineProps({
+  /**
+   * v-model
+   */
+  modelValue: {
+    type: Boolean,
+    required: false,
+  },
+  /**
+   * Nom de la propriété d'une entité lorsque l'input concerne cette propriété
+   * - Utilisé par la validation
+   * - Laisser null si le champ ne s'applique pas à une entité
+   */
   field: {
   field: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: null
+    default: null,
   },
   },
+  /**
+   * Label du champ
+   * Si non défini, c'est le nom de propriété qui est utilisé
+   */
   label: {
   label: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: null
-  },
-  data: {
-    type: Boolean,
-    required: false
+    default: null,
   },
   },
+  /**
+   * Définit si le champ est en lecture seule
+   */
   readonly: {
   readonly: {
     type: Boolean,
     type: Boolean,
-    required: false
+    required: false,
+    default: false,
+  },
+  /**
+   * Règles de validation
+   * @see https://vuetify.cn/en/components/forms/#validation-with-submit-clear
+   */
+  rules: {
+    type: Array,
+    required: false,
+    default: () => [],
   },
   },
+  /**
+   * Le champ est-il actuellement en état d'erreur
+   */
   error: {
   error: {
     type: Boolean,
     type: Boolean,
-    required: false
+    required: false,
   },
   },
+  /**
+   * Si le champ est en état d'erreur, quel est le message d'erreur?
+   */
   errorMessage: {
   errorMessage: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: null
-  }
+    default: null,
+  },
 })
 })
 
 
-const { emit } = useNuxtApp()
+const { fieldViolations, updateViolationState } = useFieldViolation(props.field)
 
 
-const { violation, onChange } = useFieldViolation(props.field, emit)
+const fieldLabel: string = props.label ?? props.field
 
 
-const fieldLabel = props.label ?? props.field
+const emit = defineEmits(['update:model-value'])
 
 
+const onUpdate = (event: boolean) => {
+  updateViolationState(event)
+  emit('update:model-value', event)
+}
 </script>
 </script>
 
 
-<style scoped>
-</style>
+<style scoped></style>

+ 106 - 0
components/Ui/Input/Combobox.vue

@@ -0,0 +1,106 @@
+<!--
+Liste déroulante, à placer dans un composant `UiForm`
+
+@see https://vuetifyjs.com/en/api/v-combobox/
+-->
+
+<template>
+  <v-container class="px-0" fluid>
+    <v-combobox
+      :model-value="modelValue"
+      :value="modelValue"
+      :label="$t(fieldLabel)"
+      :items="items"
+      :disabled="readonly"
+      :error="error || !!fieldViolations"
+      :error-messages="
+        errorMessage || fieldViolations ? $t(fieldViolations) : ''
+      "
+      @update:model-value="onUpdate($event)"
+    />
+  </v-container>
+</template>
+
+<script setup lang="ts">
+import { useFieldViolation } from '~/composables/form/useFieldViolation'
+
+const props = defineProps({
+  /**
+   * v-model
+   */
+  modelValue: {
+    type: [String, Number],
+    required: false,
+  },
+  /**
+   * Nom de la propriété d'une entité lorsque l'input concerne cette propriété
+   * - Utilisé par la validation
+   * - Laisser null si le champ ne s'applique pas à une entité
+   */
+  field: {
+    type: String,
+    required: false,
+    default: null,
+  },
+  /**
+   * Label du champ
+   * Si non défini, c'est le nom de propriété qui est utilisé
+   */
+  label: {
+    type: String,
+    required: false,
+    default: null,
+  },
+  /**
+   * Liste des éléments de la liste
+   */
+  items: {
+    type: Array,
+    required: true,
+  },
+  /**
+   * Définit si le champ est en lecture seule
+   */
+  readonly: {
+    type: Boolean,
+    required: false,
+  },
+  /**
+   * Règles de validation
+   * @see https://vuetify.cn/en/components/forms/#validation-with-submit-clear
+   */
+  rules: {
+    type: Array,
+    required: false,
+    default: () => [],
+  },
+  /**
+   * Le champ est-il actuellement en état d'erreur
+   */
+  error: {
+    type: Boolean,
+    required: false,
+  },
+  /**
+   * Si le champ est en état d'erreur, quel est le message d'erreur?
+   */
+  errorMessage: {
+    type: String,
+    required: false,
+    default: null,
+  },
+})
+
+const { fieldViolations, updateViolationState } = useFieldViolation(props.field)
+
+const fieldLabel: string = props.label ?? props.field
+
+const emit = defineEmits(['update:model-value'])
+
+const onUpdate = (event: string) => {
+  updateViolationState(event)
+  emit('update:model-value', event)
+}
+</script>
+
+<style scoped></style>

+ 79 - 109
components/Ui/Input/DatePicker.vue

@@ -1,154 +1,124 @@
 <!--
 <!--
-Sélecteur de dates
-
-@see https://vuetifyjs.com/en/components/date-pickers/
+Sélecteur de dates, à placer dans un composant `UiForm`
 -->
 -->
 
 
 <template>
 <template>
   <main>
   <main>
-    <v-text-field
-      ref="input"
-      v-model="datesFormatted"
-      autocomplete="off"
-      :label="$t(fieldLabel)"
-      prepend-icon="mdi:mdi-calendar"
-      :disabled="readonly"
-      :density="dense ? 'compact' : 'default'"
-      :single-line="singleLine"
-      :error="error || !!fieldViolations"
-      :error-messages="errorMessage || fieldViolations ? $t(fieldViolations) : ''"
-      @update:focused=""
-      @focus="onInputFocused($event); $emit('focus', $event)"
-      @blur="onInputBlured($event); $emit('blur', $event)"
-    />
-
-    <v-menu
-      activator="input"
-      :model-value="dateOpen"
-      :close-on-content-click="false"
-      :nudge-right="40"
-      transition="scale-transition"
-      offset-y
-      min-width="auto"
-    >
-      <!-- TODO: terminer une fois v-date-picker implémenté dans vuetify 3 -->
-      <v-date-picker
-          v-model="datesParsed"
-          :range="range"
-          color="primary lighten-1"
-          @input="dateOpen = range && datesParsed.length < 2"
+    <div class="d-flex flex-column">
+      <span>{{ $t(fieldLabel) }}</span>
+
+      <UiDatePicker
+        v-model="date"
+        :readonly="readonly"
+        :format="format"
+        :position="position"
+        @update:model-value="onUpdate($event)"
       />
       />
-    </v-menu>
+
+      <span v-if="error || !!fieldViolations" class="theme-danger">
+        {{ errorMessage || fieldViolations ? $t(fieldViolations) : '' }}
+      </span>
+    </div>
   </main>
   </main>
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import {useFieldViolation} from "~/composables/form/useFieldViolation";
-import {computed, ComputedRef, Ref} from "@vue/reactivity";
-import DateUtils from "~/services/utils/dateUtils";
-import {onUnmounted, watch, WatchStopHandle} from "@vue/runtime-core";
+import { useFieldViolation } from '~/composables/form/useFieldViolation'
+import { formatISO } from 'date-fns'
+import type { PropType } from '@vue/runtime-core'
 
 
 const props = defineProps({
 const props = defineProps({
+  /**
+   * v-model
+   */
+  modelValue: {
+    type: String as PropType<string | null>,
+    required: false,
+    default: null,
+  },
+  /**
+   * Nom de la propriété d'une entité lorsque l'input concerne cette propriété
+   * - Utilisé par la validation
+   * - Laisser null si le champ ne s'applique pas à une entité
+   */
   field: {
   field: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: null
+    default: null,
   },
   },
+  /**
+   * Label du champ
+   * Si non défini, c'est le nom de propriété qui est utilisé
+   */
   label: {
   label: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: null
-  },
-  data: {
-    type: [String, Array],
-    required: false,
-    default: null
+    default: null,
   },
   },
+  /**
+   * Définit si le champ est en lecture seule
+   */
   readonly: {
   readonly: {
     type: Boolean,
     type: Boolean,
-    required: false
-  },
-  range: {
-    type: Boolean,
-    required: false
-  },
-  dense: {
-    type: Boolean,
-    required: false
-  },
-  singleLine: {
-    type: Boolean,
-    required: false
+    required: false,
   },
   },
+  /**
+   * Format d'affichage des dates
+   * @see https://vue3datepicker.com/props/formatting/
+   */
   format: {
   format: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: 'DD/MM/YYYY'
+    default: null,
   },
   },
+  /**
+   * Règles de validation
+   * @see https://vuetify.cn/en/components/forms/#validation-with-submit-clear
+   */
+  rules: {
+    type: Array,
+    required: false,
+    default: () => [],
+  },
+  /**
+   * Le champ est-il actuellement en état d'erreur
+   */
   error: {
   error: {
     type: Boolean,
     type: Boolean,
-    required: false
+    required: false,
   },
   },
+  /**
+   * Si le champ est en état d'erreur, quel est le message d'erreur?
+   */
   errorMessage: {
   errorMessage: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: null
-  }
+    default: null,
+  },
+  /**
+   * @see https://vue3datepicker.com/props/positioning/#position
+   */
+  position: {
+    type: String as PropType<'left' | 'center' | 'right'>,
+    required: false,
+    default: 'center',
+  },
 })
 })
 
 
 const input = ref(null)
 const input = ref(null)
 
 
-const { emit } = useNuxtApp()
-
-const { data, range } = props
-
-const {fieldViolations, updateViolationState} = useFieldViolation(props.field)
-
-const datesParsed: Ref<Array<string>|string|null> = range ? ref(Array<string>()) : ref(null)
+const { fieldViolations, updateViolationState } = useFieldViolation(props.field)
 
 
 const fieldLabel = props.label ?? props.field
 const fieldLabel = props.label ?? props.field
 
 
-const dateOpen: Ref<boolean> = ref(false)
-
-const onInputFocused = (event: any) => {
-  dateOpen.value = true
-}
-
-const onInputBlured = (event: any) => {
-  dateOpen.value = false
-}
-
+const emit = defineEmits(['update:model-value', 'change'])
 
 
+const date: Ref<Date> = ref(new Date(props.modelValue))
 
 
-if (Array.isArray(datesParsed.value)) {
-  for (const date of data as Array<string>) {
-    if (date) {
-      datesParsed.value.push(DateUtils.format(date, 'YYYY-MM-DD'))
-    }
-  }
-} else {
-  datesParsed.value = data ? DateUtils.format(data, 'YYYY-MM-DD') : null
+const onUpdate = (event: string) => {
+  updateViolationState(event)
+  emit('update:model-value', formatISO(date.value))
 }
 }
-
-const datesFormatted: ComputedRef<string|null> = computed(() => {
-  if (props.range && datesParsed.value && datesParsed.value.length < 2) {
-    return null
-  }
-  return datesParsed.value ? DateUtils.formatAndConcat(datesParsed.value, props.format) :  null
-})
-
-const unwatch: WatchStopHandle = watch(datesParsed, (newValue, oldValue) => {
-  if (newValue === oldValue) { return }
-  if (props.range && newValue && newValue.length < 2) { return }
-  updateViolationState(Array.isArray(newValue) ? DateUtils.sort(newValue) : newValue)
-})
-
-onUnmounted(() => {
-  unwatch()
-})
-
 </script>
 </script>
 
 
-<style scoped>
-
-</style>
+<style scoped></style>

+ 14 - 18
components/Ui/Input/Email.vue

@@ -15,64 +15,60 @@ Champs de saisie de type Text dédié à la saisie d'emails
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-
-import {useNuxtApp} from "#app";
-import {useFieldViolation} from "~/composables/form/useFieldViolation";
-import {useValidationUtils} from "~/composables/utils/useValidationUtils";
+import { useNuxtApp } from '#app'
+import { useFieldViolation } from '~/composables/form/useFieldViolation'
+import { useValidationUtils } from '~/composables/utils/useValidationUtils'
 
 
 const props = defineProps({
 const props = defineProps({
   label: {
   label: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: null
+    default: null,
   },
   },
   field: {
   field: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: null
+    default: null,
   },
   },
   data: {
   data: {
     type: [String, Number],
     type: [String, Number],
     required: false,
     required: false,
-    default: null
+    default: null,
   },
   },
   readonly: {
   readonly: {
     type: Boolean,
     type: Boolean,
     required: false,
     required: false,
-    default: false
+    default: false,
   },
   },
   required: {
   required: {
     type: Boolean,
     type: Boolean,
     required: false,
     required: false,
-    default: false
+    default: false,
   },
   },
   error: {
   error: {
     type: Boolean,
     type: Boolean,
-    required: false
+    required: false,
   },
   },
   errorMessage: {
   errorMessage: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: null
-  }
+    default: null,
+  },
 })
 })
 
 
 const { emit, i18n } = useNuxtApp()
 const { emit, i18n } = useNuxtApp()
 
 
 const fieldLabel = props.label ?? props.field
 const fieldLabel = props.label ?? props.field
 
 
-const {violation, onChange} = useFieldViolation(props.field, emit)
+const { violation, onChange } = useFieldViolation(props.field, emit)
 
 
 const validationUtils = useValidationUtils()
 const validationUtils = useValidationUtils()
 
 
 const rules = [
 const rules = [
-  (email: string) => validationUtils.validEmail(email) || i18n.t('email_error')
+  (email: string) => validationUtils.validEmail(email) || i18n.t('email_error'),
 ]
 ]
 
 
 if (props.required) {
 if (props.required) {
-  rules.push(
-    (email: string) => !!email || i18n.t('required')
-  )
+  rules.push((email: string) => !!email || i18n.t('required'))
 }
 }
-
 </script>
 </script>

+ 20 - 21
components/Ui/Input/Enum.vue

@@ -6,11 +6,7 @@ Liste déroulante dédiée à l'affichage d'objets Enum
 
 
 <template>
 <template>
   <main>
   <main>
-    <v-skeleton-loader
-      v-if="pending"
-      type="list-item"
-      loading
-    />
+    <v-skeleton-loader v-if="pending" type="list-item" loading />
 
 
     <v-select
     <v-select
       v-else
       v-else
@@ -23,15 +19,20 @@ Liste déroulante dédiée à l'affichage d'objets Enum
       :rules="rules"
       :rules="rules"
       :disabled="readonly"
       :disabled="readonly"
       :error="error || !!fieldViolations"
       :error="error || !!fieldViolations"
-      :error-messages="errorMessage || (fieldViolations ? $t(fieldViolations) : '')"
-      @update:modelValue="updateViolationState($event); $emit('update:modelValue', $event)"
+      :error-messages="
+        errorMessage || (fieldViolations ? $t(fieldViolations) : '')
+      "
+      @update:modelValue="
+        updateViolationState($event)
+        $emit('update:modelValue', $event)
+      "
     />
     />
   </main>
   </main>
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import {useFieldViolation} from "~/composables/form/useFieldViolation";
-import {useEnumFetch} from "~/composables/data/useEnumFetch";
+import { useFieldViolation } from '~/composables/form/useFieldViolation'
+import { useEnumFetch } from '~/composables/data/useEnumFetch'
 
 
 const props = defineProps({
 const props = defineProps({
   /**
   /**
@@ -40,14 +41,14 @@ const props = defineProps({
   modelValue: {
   modelValue: {
     String,
     String,
     required: false,
     required: false,
-    default: null
+    default: null,
   },
   },
   /**
   /**
    * Nom de l'Enum utilisée pour peupler la liste
    * Nom de l'Enum utilisée pour peupler la liste
    */
    */
   enum: {
   enum: {
     type: String,
     type: String,
-    required: true
+    required: true,
   },
   },
   /**
   /**
    * Nom de la propriété d'une entité lorsque l'input concerne cette propriété
    * Nom de la propriété d'une entité lorsque l'input concerne cette propriété
@@ -57,7 +58,7 @@ const props = defineProps({
   field: {
   field: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: null
+    default: null,
   },
   },
   /**
   /**
    * Label du champ
    * Label du champ
@@ -66,14 +67,14 @@ const props = defineProps({
   label: {
   label: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: null
+    default: null,
   },
   },
   /**
   /**
    * Définit si le champ est en lecture seule
    * Définit si le champ est en lecture seule
    */
    */
   readonly: {
   readonly: {
     type: Boolean,
     type: Boolean,
-    required: false
+    required: false,
   },
   },
   /**
   /**
    * Règles de validation
    * Règles de validation
@@ -82,14 +83,14 @@ const props = defineProps({
   rules: {
   rules: {
     type: Array,
     type: Array,
     required: false,
     required: false,
-    default: () => []
+    default: () => [],
   },
   },
   /**
   /**
    * Le champ est-il actuellement en état d'erreur
    * Le champ est-il actuellement en état d'erreur
    */
    */
   error: {
   error: {
     type: Boolean,
     type: Boolean,
-    required: false
+    required: false,
   },
   },
   /**
   /**
    * Si le champ est en état d'erreur, quel est le message d'erreur ?
    * Si le champ est en état d'erreur, quel est le message d'erreur ?
@@ -97,8 +98,8 @@ const props = defineProps({
   errorMessage: {
   errorMessage: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: null
-  }
+    default: null,
+  },
 })
 })
 
 
 if (typeof props.enum === 'undefined') {
 if (typeof props.enum === 'undefined') {
@@ -117,8 +118,6 @@ const onModelUpdate = (event: any) => {
   emit('change', event)
   emit('change', event)
   emit('update:modelValue', event)
   emit('update:modelValue', event)
 }
 }
-
 </script>
 </script>
 
 
-<style scoped>
-</style>
+<style scoped></style>

+ 387 - 185
components/Ui/Input/Image.vue

@@ -1,292 +1,494 @@
 <!--
 <!--
 Assistant de création d'image
 Assistant de création d'image
-https://norserium.github.io/vue-advanced-cropper/
+
+@see https://norserium.github.io/vue-advanced-cropper/
 -->
 -->
 <template>
 <template>
-    <LazyLayoutDialog :show="true">
+  <div class="input-image">
+    <UiImage
+      ref="uiImage"
+      :image-id="modelValue"
+      :default-image="defaultImage"
+      :width="width"
+      :height="height"
+      class="image"
+      overlay-icon="fas fa-upload"
+      @overlay-clicked="openModal()"
+    />
+
+    <LazyLayoutDialog :show="showModal">
       <template #dialogType>{{ $t('image_assistant') }}</template>
       <template #dialogType>{{ $t('image_assistant') }}</template>
       <template #dialogTitle>{{ $t('modif_picture') }}</template>
       <template #dialogTitle>{{ $t('modif_picture') }}</template>
       <template #dialogText>
       <template #dialogText>
         <div class="upload">
         <div class="upload">
           <v-row
           <v-row
-            v-if="fetchState.pending"
+            v-if="pending"
             class="fill-height ma-0 loading"
             class="fill-height ma-0 loading"
             align="center"
             align="center"
             justify="center"
             justify="center"
           >
           >
-            <v-progress-circular
-              :indeterminate="true"
-              color="neutral">
+            <v-progress-circular :indeterminate="true" color="neutral">
             </v-progress-circular>
             </v-progress-circular>
           </v-row>
           </v-row>
 
 
-          <div v-else >
+          <div v-else>
             <div class="upload__cropper-wrapper">
             <div class="upload__cropper-wrapper">
-              <cropper
+              <Cropper
                 ref="cropper"
                 ref="cropper"
                 class="upload__cropper"
                 class="upload__cropper"
                 check-orientation
                 check-orientation
-                :src="image.src"
-                :default-position="{left : coordinates.left, top : coordinates.top}"
-                :default-size="coordinates.width ? {width : coordinates.width, height : coordinates.height}: defaultSize"
-                @change="onChange"
+                :src="currentImage.src"
+                :default-position="defaultPosition"
+                :default-size="defaultSize"
+                @change="onCropperChange"
               />
               />
-              <div v-if="image.src" class="upload__reset-button" title="Reset Image" @click="reset()">
-                <v-icon>mdi-delete</v-icon>
+
+              <div
+                v-if="currentImage.src"
+                class="upload__reset-button"
+                title="Reset Image"
+                @click="reset()"
+              >
+                <v-icon>fas fa-trash</v-icon>
               </div>
               </div>
             </div>
             </div>
+
             <div class="upload__buttons-wrapper">
             <div class="upload__buttons-wrapper">
-              <button class="upload__button" @click="$refs.file.click()">
-                <input ref="file" type="file" accept="image/*" @change="uploadImage($event)" />
-                {{$t('upload_image')}}
+              <button class="upload__button" @click="fileInput?.click()">
+                <input
+                  ref="fileInput"
+                  type="file"
+                  accept="image/*"
+                  @change="uploadImage($event)"
+                />
+                {{ $t('upload_image') }}
               </button>
               </button>
             </div>
             </div>
-          </div>
 
 
+            <span class="max-size-label">{{ $t('max_size_4_mb') }}</span>
+          </div>
         </div>
         </div>
       </template>
       </template>
       <template #dialogBtn>
       <template #dialogBtn>
-        <v-btn class="mr-4 submitBtn theme-neutral-strong" @click="$emit('close')">
+        <v-btn class="mr-4 submitBtn theme-neutral-strong" @click="cancel">
           {{ $t('cancel') }}
           {{ $t('cancel') }}
         </v-btn>
         </v-btn>
-        <v-btn class="mr-4 submitBtn theme-danger" @click="save">
+        <v-btn
+          class="submitBtn theme-primary"
+          @click="save"
+          :disabled="pending"
+        >
           {{ $t('save') }}
           {{ $t('save') }}
         </v-btn>
         </v-btn>
       </template>
       </template>
     </LazyLayoutDialog>
     </LazyLayoutDialog>
-
+  </div>
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-
-import {useNuxtApp} from "#app";
-import {ref, Ref} from "@vue/reactivity";
+import { Cropper } from 'vue-advanced-cropper'
+import 'vue-advanced-cropper/dist/style.css'
+import { type Ref, ref } from '@vue/reactivity'
 import File from '~/models/Core/File'
 import File from '~/models/Core/File'
-import {useAp2iRequestService} from "~/composables/data/useAp2iRequestService";
-import UrlUtils from "~/services/utils/urlUtils";
-import {useImageFetch} from "~/composables/data/useImageFetch";
-import {onUnmounted, watch, WatchStopHandle} from "@vue/runtime-core";
-import {useEntityManager} from "~/composables/data/useEntityManager";
-import {AnyJson} from "~/types/data";
+import type { PropType } from '@vue/runtime-core'
+import { useEntityManager } from '~/composables/data/useEntityManager'
+import { useImageManager } from '~/composables/data/useImageManager'
+import { FILE_VISIBILITY, TYPE_ALERT } from '~/types/enum/enums'
+import { usePageStore } from '~/stores/page'
+import ImageUtils from '~/services/utils/imageUtils'
 
 
 const props = defineProps({
 const props = defineProps({
-  imageId: {
+  /**
+   * Id de l'objet File, ou null
+   */
+  modelValue: {
+    type: Number as PropType<number | null>,
+    required: false,
+    default: null,
+  },
+  /**
+   * Label du champ
+   * Si non défini, c'est le nom de propriété qui est utilisé
+   */
+  field: {
+    type: String,
+    required: false,
+    default: null,
+  },
+  /**
+   * Image par défaut en cas d'absence d'une image uploadée
+   */
+  defaultImage: {
+    type: String,
+    required: false,
+  },
+  /**
+   * Hauteur de l'image à l'écran (en px)
+   */
+  height: {
+    type: Number,
+    required: false,
+  },
+  /**
+   * Largeur de l'image à l'écran (en px)
+   */
+  width: {
     type: Number,
     type: Number,
-    required: false
+    required: false,
   },
   },
+  /**
+   * TODO: completer
+   */
   ownerId: {
   ownerId: {
     type: Number,
     type: Number,
-    required: false
+    required: false,
   },
   },
-  field: {
-    type: String,
-    required: true
-  }
 })
 })
 
 
-const { emit } = useNuxtApp()
-
 const { em } = useEntityManager()
 const { em } = useEntityManager()
+const { imageManager } = useImageManager()
+const pageStore = usePageStore()
 
 
-const file = new File()
+const emit = defineEmits(['update:modelValue'])
 
 
+/**
+ * Références à des composants
+ */
+const fileInput: Ref<null | any> = ref(null)
 const cropper: Ref<any> = ref(null)
 const cropper: Ref<any> = ref(null)
+const uiImage: Ref<any> = ref(null)
+
+/**
+ * L'objet File ou l'image sont en cours de chargement
+ */
+const pending: Ref<boolean> = ref(false)
+
+/**
+ * Affiche la modale d'upload / modification de l'image
+ */
+const showModal = ref(false)
+
+/**
+ * L'objet File contenant les informations de l'image
+ */
+const file: Ref<File | null> = ref(null)
 
 
-const image: Ref<AnyJson> = ref({
+/**
+ * Données d'une nouvelle image uploadée par l'utilisateur
+ */
+const currentImage: Ref<{
+  id: string | number | null
+  src: string | null
+  content: string | null
+  name: string | null
+}> = ref({
   id: null,
   id: null,
   src: null,
   src: null,
-  file: null,
-  name: null
+  content: null,
+  name: null,
 })
 })
 
 
-const coordinates: Ref<AnyJson> = ref({})
+/**
+ * Taille maximale autorisée pour les images uploadées (en bytes)
+ */
+const MAX_FILE_SIZE = 4 * 1024 * 1024
+
+/**
+ * Coordonnées du cropper
+ */
+const cropperConfig: Ref<{
+  left?: number
+  top?: number
+  height?: number
+  width?: number
+}> = ref({})
+
+/**
+ * @see https://advanced-cropper.github.io/vue-advanced-cropper/components/cropper.html#defaultposition
+ */
+const defaultPosition = () => {
+  return { left: cropperConfig.value.left, top: cropperConfig.value.top }
+}
+
+/**
+ * @see https://advanced-cropper.github.io/vue-advanced-cropper/components/cropper.html#defaultsize
+ */
+const defaultSize = (params: any): { width: number; height: number } | null => {
+  if (!params) {
+    return null
+  }
+  const { imageSize, visibleArea } = params
 
 
-const defaultSize = ({ imageSize, visibleArea }: any) => {
   return {
   return {
-    width: (visibleArea || imageSize).width,
-    height: (visibleArea || imageSize).height,
-  };
+    width: cropperConfig.value.width ?? (visibleArea || imageSize).width,
+    height: cropperConfig.value.height ?? (visibleArea || imageSize).height,
+  }
 }
 }
 
 
-// Si l'id est renseigné, on récupère l'Item File afin d'avoir les informations de config, le nom, etc.
-if (props.imageId && props.imageId > 0) {
-  const { apiRequestService } = useAp2iRequestService()
-  const result: any = await apiRequestService.get(UrlUtils.join('api/files', '' + props.imageId))
-
-  const config = JSON.parse(result.data.config)
-  coordinates.value.left = config.x
-  coordinates.value.top = config.y
-  coordinates.value.height = config.height
-  coordinates.value.width = config.width
-  image.value.name = result.data.name
-  image.value.id = result.data.id
+/**
+ * Charge l'image correspondant au fichier dans le cropper
+ *
+ * @param fileId
+ */
+const loadImage = async (fileId: number) => {
+  file.value = (await em.fetch(File, fileId)) as File
+
+  if (file.value.config) {
+    const fileConfig = JSON.parse(file.value.config)
+    cropperConfig.value.left = fileConfig.x
+    cropperConfig.value.top = fileConfig.y
+    cropperConfig.value.height = fileConfig.height
+    cropperConfig.value.width = fileConfig.width
+  }
+
+  currentImage.value.name = file.value.name
+  currentImage.value.id = file.value.id
+  currentImage.value.src = (await imageManager.get(fileId)) as string
 }
 }
 
 
-//On récupère l'image...
-const { fetch } = useImageFetch()
-const { data: imageLoaded, pending } = fetch(props.imageId ?? null)
+/**
+ * Affiche la modale d'upload / modification de l'image
+ */
+const openModal = async () => {
+  showModal.value = true
+  pending.value = true
 
 
-const unwatch: WatchStopHandle = watch(
-  imageLoaded,
-(newValue, oldValue) => {
-    if (newValue === oldValue || typeof newValue === 'undefined') {
-      return
-    }
-    image.value.src = newValue
+  if (props.modelValue !== null) {
+    // Un objet File existe déjà: on le récupère
+    await loadImage(props.modelValue)
+  } else {
+    // Nouveau File
+    file.value = em.newInstance(File) as File
+  }
+
+  pending.value = false
+}
+
+/**
+ * Réinitialise l'image actuellement chargée dans le cropper
+ */
+const reset = () => {
+  if (currentImage.value.src !== null) {
+    URL.revokeObjectURL(currentImage.value.src)
+  }
+
+  currentImage.value = {
+    src: null,
+    content: null,
+    name: null,
+    id: null,
   }
   }
-)
+
+  cropperConfig.value = {
+    left: undefined,
+    height: undefined,
+    top: undefined,
+    width: undefined,
+  }
+}
 
 
 /**
 /**
- * Quand l'utilisateur choisit une image sur sa machine
+ * Upload une image depuis le poste client
  * @param event
  * @param event
  */
  */
-const uploadImage = (event:any) => {
+const uploadImage = async (event: any) => {
   const { files } = event.target
   const { files } = event.target
-  if (files && files[0]) {
-    reset()
-    image.value.name = files[0].name
-    image.value.src = URL.createObjectURL(files[0])
-    image.value.file = files[0]
+
+  if (!files || !files[0]) {
+    return
+  }
+
+  const uploadedFile = files[0]
+
+  if (uploadedFile.size > MAX_FILE_SIZE) {
+    pageStore.alerts.push({
+      type: TYPE_ALERT.ALERT,
+      messages: ['file_too_large'],
+    })
+    return
   }
   }
+
+  // Met à jour l'image dans le cropper
+  currentImage.value.id = null
+  currentImage.value.name = uploadedFile.name
+  currentImage.value.src = URL.createObjectURL(uploadedFile)
+  currentImage.value.content = await ImageUtils.blobToBase64(uploadedFile)
+
+  // Met à jour la configuration du cropper
+  cropperConfig.value.top = 0
+  cropperConfig.value.left = 0
+  cropperConfig.value.height = uploadedFile.height
+  cropperConfig.value.width = uploadedFile.width
 }
 }
 
 
 /**
 /**
  * Lorsque le cropper change de position / taille, on met à jour les coordonnées
  * Lorsque le cropper change de position / taille, on met à jour les coordonnées
- * @param config
+ * @param newCoordinates
  */
  */
-const onChange = ({ coordinates: config } : any) => {
-  coordinates.value = config;
+const onCropperChange = ({ coordinates: newCoordinates }: any) => {
+  cropperConfig.value = newCoordinates
 }
 }
 
 
 /**
 /**
- * Lorsque l'on sauvegarde l'image
+ * Annule l'upload et les modifications, et ferme la modale
  */
  */
-// TODO: Voir si tout ou partie de cette fonction peut passer dans le useImageFetch, imageManager ou imageUtils
-const save = async () => {
-  file.config = JSON.stringify({
-    x: coordinates.value.left,
-    y: coordinates.value.top,
-    height: coordinates.value.height,
-    width: coordinates.value.width
-  })
-
-  if (image.value.id > 0) {
-    // Mise à jour d'une image existante : on bouge simplement le cropper
-
-    file.id = image.value.id as number
-
-    await em.persist(File, file) // TODO: à revoir
+const cancel = () => {
+  reset()
+  showModal.value = false
+}
 
 
-    // On émet un évent afin de mettre à jour le formulaire de départ
-    emit('reload')
+/**
+ * Enregistre une nouvelle image et retourne l'id du fichier nouvellement créé
+ */
+const saveNewImage = async (): Promise<number> => {
+  if (!file.value) {
+    throw new Error('No File object defined')
+  }
+  if (!currentImage.value.name) {
+    throw new Error("Missing file's name")
+  }
+  if (!currentImage.value.content) {
+    throw new Error("Missing file's content")
+  }
 
 
-  } else {
-    // Création d'une nouvelle image
-    if (image.value.file) {
+  const config = JSON.stringify({
+    x: cropperConfig.value.left,
+    y: cropperConfig.value.top,
+    height: cropperConfig.value.height,
+    width: cropperConfig.value.width,
+  })
 
 
-      // On créé l'objet File à sauvegarder
-      file.name = image.value.name
-      file.imgFieldName = props.field
-      file.visibility = 'EVERYBODY'
-      file.folder = 'IMAGES'
-      file.status = 'READY'
+  const response = (await imageManager.upload(
+    currentImage.value.name,
+    currentImage.value.content,
+    FILE_VISIBILITY.EVERYBODY,
+    config,
+  )) as any
 
 
-      if (props.ownerId) {
-        file.ownerId = props.ownerId
-      }
+  return response.fileId
+}
 
 
-      const returnedFile = await em.persist(File, file) // TODO: à revoir, il faudra pouvoir passer `image.value.file` avec la requête
+/**
+ * Met à jour l'image existante
+ */
+const saveExistingImage = async () => {
+  if (!file.value) {
+    throw new Error('No File object defined')
+  }
 
 
-      //On émet un évent afin de mettre à jour le formulaire de départ
-      emit('update', returnedFile.data['@id'])
+  file.value.config = JSON.stringify({
+    x: cropperConfig.value.left,
+    y: cropperConfig.value.top,
+    height: cropperConfig.value.height,
+    width: cropperConfig.value.width,
+  })
 
 
-    } else {
-      // On reset l'image : on a appuyé sur "poubelle" puis on enregistre
-      emit('reset')
-    }
-  }
+  await em.persist(File, file.value)
 }
 }
 
 
 /**
 /**
- * On choisit de supprimer l'image présente
+ * Lorsque l'on sauvegarde l'image
  */
  */
-const reset = () => {
-  image.value.src = null
-  image.value.file = null
-  image.value.name = null
-  image.value.id = null
-  URL.revokeObjectURL(image.value.src)
+// TODO: Voir si tout ou partie de cette fonction peut passer dans le useImageFetch, imageManager ou imageUtils
+const save = async () => {
+  pageStore.loading = true
+
+  if (currentImage.value.src && currentImage.value.id === null) {
+    // Une nouvelle image a été uploadée
+    const fileId = await saveNewImage()
+    emit('update:modelValue', fileId)
+  } else if (currentImage.value.id) {
+    // L'image existante a été modifiée
+    await saveExistingImage()
+    uiImage.value.refresh()
+  } else {
+    // On a reset l'image
+    emit('update:modelValue', null)
+  }
+
+  showModal.value = false
+  pageStore.loading = false
 }
 }
 
 
 /**
 /**
  * Lorsqu'on démonte le component on supprime le watcher et on revoke l'objet URL
  * Lorsqu'on démonte le component on supprime le watcher et on revoke l'objet URL
  */
  */
 onUnmounted(() => {
 onUnmounted(() => {
-  unwatch()
-    if (image.value.src) {
-      URL.revokeObjectURL(image.value.src)
-    }
+  if (currentImage.value && currentImage.value.src) {
+    URL.revokeObjectURL(currentImage.value.src)
+  }
 })
 })
 </script>
 </script>
 
 
-<style lang="scss">
-  .vue-advanced-cropper__stretcher{
-    height: auto !important;
-    width: auto !important;
+<style scoped lang="scss">
+:deep(.vue-advanced-cropper__stretcher) {
+  height: auto !important;
+  width: auto !important;
+}
+
+.loading {
+  height: 300px;
+}
+
+.upload {
+  user-select: none;
+  padding: 20px;
+  display: block;
+  &__cropper {
+    border: solid 1px rgb(var(--v-theme-on-neutral-strong));
+    min-height: 300px;
+    max-height: 300px;
+  }
+  &__cropper-wrapper {
+    position: relative;
+  }
+  &__reset-button {
+    position: absolute;
+    right: 20px;
+    bottom: 20px;
+    cursor: pointer;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    height: 42px;
+    width: 42px;
+    background: rgb(var(--v-theme-neutral));
+    transition: background 0.5s;
+    &:hover {
+      background: rgb(var(--v-theme-primary-alt));
+    }
   }
   }
-  .loading{
-    height: 300px;
+  &__buttons-wrapper {
+    display: flex;
+    justify-content: center;
+    margin-top: 17px;
   }
   }
-  .upload {
-    user-select: none;
-    padding: 20px;
-    display: block;
-    &__cropper {
-       border: solid 1px rgb(var(--v-theme-on-neutral-strong));;
-       min-height: 500px;
-       max-height: 500px;
-     }
-    &__cropper-wrapper {
-       position: relative;
-     }
-    &__reset-button {
-      position: absolute;
-      right: 20px;
-      bottom: 20px;
-      cursor: pointer;
-      display: flex;
-      align-items: center;
-      justify-content: center;
-      height: 42px;
-      width: 42px;
-      background: rgb(var(--v-theme-neutral));
-      transition: background 0.5s;
-      &:hover {
-        background: rgb(var(--v-theme-primary-alt));
-      }
+  &__button {
+    border: none;
+    outline: solid transparent;
+    color: rgb(var(--v-theme-on-neutral));
+    font-size: 16px;
+    padding: 10px 20px;
+    background: rgb(var(--v-theme-neutral));
+    cursor: pointer;
+    transition: background 0.5s;
+    margin: 0 16px;
+    &:hover,
+    &:focus {
+      background: rgb(var(--v-theme-primary-alt));
     }
     }
-    &__buttons-wrapper {
-       display: flex;
-       justify-content: center;
-       margin-top: 17px;
-     }
-    &__button {
-       border: none;
-       outline: solid transparent;
-       color: rgb(var(--v-theme-on-neutral));
-       font-size: 16px;
-       padding: 10px 20px;
-       background: rgb(var(--v-theme-neutral));
-       cursor: pointer;
-       transition: background 0.5s;
-       margin: 0 16px;
-      &:hover,
-      &:focus {
-         background: rgb(var(--v-theme-primary-alt));
-       }
-      input {
-        display: none;
-      }
+    input {
+      display: none;
     }
     }
   }
   }
+}
+
+.max-size-label {
+  display: block;
+  width: 100%;
+  text-align: center;
+  font-size: 13px;
+  color: rgb(var(--v-theme-on-neutral-soft));
+  margin-top: 6px;
+}
 </style>
 </style>

+ 60 - 23
components/Ui/Input/Number.vue

@@ -1,55 +1,93 @@
 <!--
 <!--
 An input for numeric values
 An input for numeric values
 -->
 -->
+<!-- eslint-disable vue/valid-v-bind -->
 
 
 <template>
 <template>
   <v-text-field
   <v-text-field
-      ref="input"
-      :modelValue.number="modelValue"
-      hide-details
-      single-line
-      :density="density"
-      type="number"
-      @update:modelValue="modelValue = keepInRange(cast($event)); emitUpdate()"
+    ref="input"
+    :model-value.number="modelValue"
+    :label="label || field ? $t(label ?? field) : undefined"
+    hide-details
+    :density="density"
+    type="number"
+    :variant="variant"
+    @update:model-value="onModelUpdate($event)"
   />
   />
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
+import type { PropType, Ref } from 'vue'
 
 
-import {PropType} from "@vue/runtime-core";
-
-type Density = null | 'default' | 'comfortable' | 'compact';
+type Density = null | 'default' | 'comfortable' | 'compact'
 
 
 const props = defineProps({
 const props = defineProps({
   modelValue: {
   modelValue: {
     type: Number,
     type: Number,
-    required: true
+    required: true,
+  },
+  /**
+   * Nom de la propriété d'une entité lorsque l'input concerne cette propriété
+   * - Utilisé par la validation
+   * - Laisser null si le champ ne s'applique pas à une entité
+   */
+  field: {
+    type: String,
+    required: false,
+    default: null,
+  },
+  /**
+   * Label du champ
+   * Si non défini, c'est le nom de propriété qui est utilisé
+   */
+  label: {
+    type: String,
+    required: false,
+    default: null,
   },
   },
   default: {
   default: {
     type: Number,
     type: Number,
     required: false,
     required: false,
-    default: 0
+    default: 0,
   },
   },
   min: {
   min: {
     type: Number,
     type: Number,
     required: false,
     required: false,
-    default: null
+    default: null,
   },
   },
   max: {
   max: {
     type: Number,
     type: Number,
     required: false,
     required: false,
-    default: null
+    default: null,
   },
   },
   density: {
   density: {
     type: String as PropType<Density>,
     type: String as PropType<Density>,
     required: false,
     required: false,
-    default: 'default'
-  }
+    default: 'default',
+  },
+  /**
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-variant
+   */
+  variant: {
+    type: String as PropType<
+      | 'filled'
+      | 'outlined'
+      | 'plain'
+      | 'underlined'
+      | 'solo'
+      | 'solo-inverted'
+      | 'solo-filled'
+      | undefined
+    >,
+    required: false,
+    default: 'filled',
+  },
 })
 })
 
 
 /**
 /**
  * Reference to the v-text-field
  * Reference to the v-text-field
  */
  */
+// eslint-disable-next-line
 const input: Ref<any> = ref(null)
 const input: Ref<any> = ref(null)
 
 
 /**
 /**
@@ -82,21 +120,20 @@ const keepInRange = (val: number) => {
   return val
   return val
 }
 }
 
 
-
 const emit = defineEmits(['update:modelValue'])
 const emit = defineEmits(['update:modelValue'])
 
 
-/**
- * Emit the update event
- */
-const emitUpdate = () => {
-  emit('update:modelValue', props.modelValue)
+const onModelUpdate = (event: string) => {
+  // eslint-disable-next-line vue/no-mutating-props
+  // props.modelValue = keepInRange(cast(event))
+  // emitUpdate()
+  emit('update:modelValue', keepInRange(cast(event)))
 }
 }
 
 
 /**
 /**
  * Setup min and max values at the input level
  * Setup min and max values at the input level
  */
  */
 onMounted(() => {
 onMounted(() => {
-  const inputElement = input.value.$el.querySelector('input')
+  const inputElement = input.value!.$el.querySelector('input')
   if (props.min !== null) {
   if (props.min !== null) {
     inputElement.min = props.min
     inputElement.min = props.min
   }
   }

+ 28 - 24
components/Ui/Input/Phone.vue

@@ -26,40 +26,39 @@ Champs de saisie d'un numéro de téléphone
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-
-import {useNuxtApp} from "#app";
-import {useFieldViolation} from "~/composables/form/useFieldViolation";
-import {Ref} from "@vue/reactivity";
+import { useNuxtApp } from '#app'
+import { useFieldViolation } from '~/composables/form/useFieldViolation'
+import type { Ref } from '@vue/reactivity'
 
 
 const props = defineProps({
 const props = defineProps({
   label: {
   label: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: ''
+    default: '',
   },
   },
   field: {
   field: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: null
+    default: null,
   },
   },
   data: {
   data: {
     type: [String, Number],
     type: [String, Number],
     required: false,
     required: false,
-    default: null
+    default: null,
   },
   },
   readonly: {
   readonly: {
     type: Boolean,
     type: Boolean,
-    required: false
+    required: false,
   },
   },
   error: {
   error: {
     type: Boolean,
     type: Boolean,
-    required: false
+    required: false,
   },
   },
   errorMessage: {
   errorMessage: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: null
-  }
+    default: null,
+  },
 })
 })
 
 
 const { emit, i18n } = useNuxtApp()
 const { emit, i18n } = useNuxtApp()
@@ -71,7 +70,14 @@ const internationalNumber: Ref<string | number> = ref('')
 const isValid: Ref<boolean> = ref(false)
 const isValid: Ref<boolean> = ref(false)
 const onInit: Ref<boolean> = ref(true)
 const onInit: Ref<boolean> = ref(true)
 
 
-const onInput = (_: any, { number, valid, countryChoice }: { number: any, valid: boolean, countryChoice: any }) => {
+const onInput = (
+  _: any,
+  {
+    number,
+    valid,
+    countryChoice,
+  }: { number: any; valid: boolean; countryChoice: any },
+) => {
   isValid.value = valid
   isValid.value = valid
   nationalNumber.value = number.national
   nationalNumber.value = number.national
   internationalNumber.value = number.international
   internationalNumber.value = number.international
@@ -84,24 +90,22 @@ const onChangeValue = () => {
   }
   }
 }
 }
 
 
-const myPhone = computed(
-  {
-    get:()=>{
-      return onInit.value ? props.data : nationalNumber.value
-    },
-    set:(value)=>{
-      return props.data
-    }
-  }
-)
+const myPhone = computed({
+  get: () => {
+    return onInit.value ? props.data : nationalNumber.value
+  },
+  set: (value) => {
+    return props.data
+  },
+})
 
 
 const rules = [
 const rules = [
-  (phone: string) => (!phone || isValid.value) || i18n.t('phone_error')
+  (phone: string) => !phone || isValid.value || i18n.t('phone_error'),
 ]
 ]
 </script>
 </script>
 
 
 <style lang="scss">
 <style lang="scss">
-input:read-only{
+input:read-only {
   color: rgb(var(--v-theme-on-neutral));
   color: rgb(var(--v-theme-on-neutral));
 }
 }
 </style>
 </style>

+ 49 - 37
components/Ui/Input/Text.vue

@@ -1,43 +1,44 @@
 <!--
 <!--
-Champs de saisie de texte
+Champs de saisie de texte, à placer dans un composant `UiForm`
 
 
 @see https://vuetifyjs.com/en/components/text-fields/
 @see https://vuetifyjs.com/en/components/text-fields/
 -->
 -->
 
 
 <template>
 <template>
   <v-text-field
   <v-text-field
+    ref="input"
     :model-value="modelValue"
     :model-value="modelValue"
-    :label="(label || field) ? $t(label ?? field) : undefined"
+    :label="label || field ? $t(label ?? field) : undefined"
     :rules="rules"
     :rules="rules"
     :disabled="readonly"
     :disabled="readonly"
-    :type="(type === 'password' && show) ? 'text' : type"
+    :type="type === 'password' && show ? 'text' : type"
     :error="error || !!fieldViolations"
     :error="error || !!fieldViolations"
-    :error-messages="errorMessage || (fieldViolations ? $t(fieldViolations) : '')"
+    :error-messages="
+      errorMessage || (fieldViolations ? $t(fieldViolations) : '')
+    "
     :append-icon="type === 'password' ? (show ? 'mdi-eye' : 'mdi-eye-off') : ''"
     :append-icon="type === 'password' ? (show ? 'mdi-eye' : 'mdi-eye-off') : ''"
+    :variant="variant"
     @click:append="show = !show"
     @click:append="show = !show"
     @update:model-value="onUpdate($event)"
     @update:model-value="onUpdate($event)"
     @change="onChange($event)"
     @change="onChange($event)"
   />
   />
 
 
-
-<!--  v-cleave="mask"-->
+  <!--  v-cleave="mask"-->
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import {ref} from "@vue/reactivity";
-import {useFieldViolation} from "~/composables/form/useFieldViolation";
-import {useNuxtApp} from "#app";
-import {useI18n} from "vue-i18n";
-import {mask} from "vue-the-mask";
+import { type Ref, ref } from '@vue/reactivity'
+import { useFieldViolation } from '~/composables/form/useFieldViolation'
+import type { PropType } from '@vue/runtime-core'
 
 
 const props = defineProps({
 const props = defineProps({
   /**
   /**
    * v-model
    * v-model
    */
    */
   modelValue: {
   modelValue: {
-    type: [String, Number],
+    type: [String, Number] as PropType<string | number | null>,
     required: false,
     required: false,
-    default: null
+    default: null,
   },
   },
   /**
   /**
    * Nom de la propriété d'une entité lorsque l'input concerne cette propriété
    * Nom de la propriété d'une entité lorsque l'input concerne cette propriété
@@ -47,7 +48,7 @@ const props = defineProps({
   field: {
   field: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: null
+    default: null,
   },
   },
   /**
   /**
    * Label du champ
    * Label du champ
@@ -56,7 +57,7 @@ const props = defineProps({
   label: {
   label: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: null
+    default: null,
   },
   },
   /**
   /**
    * Type d'input HTML
    * Type d'input HTML
@@ -65,7 +66,7 @@ const props = defineProps({
   type: {
   type: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: null
+    default: null,
   },
   },
   /**
   /**
    * Définit si le champ est en lecture seule
    * Définit si le champ est en lecture seule
@@ -73,16 +74,16 @@ const props = defineProps({
   readonly: {
   readonly: {
     type: Boolean,
     type: Boolean,
     required: false,
     required: false,
-    default: false
+    default: false,
   },
   },
   /**
   /**
    * Règles de validation
    * Règles de validation
    * @see https://vuetify.cn/en/components/forms/#validation-with-submit-clear
    * @see https://vuetify.cn/en/components/forms/#validation-with-submit-clear
    */
    */
   rules: {
   rules: {
-    type: Array,
+    type: Array as PropType<any[]>,
     required: false,
     required: false,
-    default: () => []
+    default: () => [],
   },
   },
   /**
   /**
    * Le champ est-il actuellement en état d'erreur
    * Le champ est-il actuellement en état d'erreur
@@ -90,7 +91,7 @@ const props = defineProps({
   error: {
   error: {
     type: Boolean,
     type: Boolean,
     required: false,
     required: false,
-    default: false
+    default: false,
   },
   },
   /**
   /**
    * Si le champ est en état d'erreur, quel est le message d'erreur?
    * Si le champ est en état d'erreur, quel est le message d'erreur?
@@ -98,7 +99,7 @@ const props = defineProps({
   errorMessage: {
   errorMessage: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: null
+    default: null,
   },
   },
   /**
   /**
    * Masque de saisie
    * Masque de saisie
@@ -107,36 +108,47 @@ const props = defineProps({
   mask: {
   mask: {
     type: [Array, Boolean],
     type: [Array, Boolean],
     required: false,
     required: false,
-    default: false
-  }
+    default: false,
+  },
+  /**
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-variant
+   */
+  variant: {
+    type: String as PropType<
+      | 'filled'
+      | 'outlined'
+      | 'plain'
+      | 'underlined'
+      | 'solo'
+      | 'solo-inverted'
+      | 'solo-filled'
+      | undefined
+    >,
+    required: false,
+    default: 'filled',
+  },
 })
 })
 
 
-const { app } = useNuxtApp()
-const i18n = useI18n()
+const input = ref(null)
 
 
 const { fieldViolations, updateViolationState } = useFieldViolation(props.field)
 const { fieldViolations, updateViolationState } = useFieldViolation(props.field)
 
 
-const show = ref(false)
+const show: Ref<boolean> = ref(false)
 
 
 const emit = defineEmits(['update:model-value', 'change'])
 const emit = defineEmits(['update:model-value', 'change'])
 
 
 const onUpdate = (event: string) => {
 const onUpdate = (event: string) => {
-    emit('update:model-value', event)
+  emit('update:model-value', event)
 }
 }
 
 
 const onChange = (event: Event | undefined) => {
 const onChange = (event: Event | undefined) => {
-    updateViolationState(event)
-    emit('change', event)
+  updateViolationState(event)
+  emit('change', event)
 }
 }
-
-// const label = computed(() => {
-//   if (props.label)
-// })
-
 </script>
 </script>
 
 
 <style scoped lang="scss">
 <style scoped lang="scss">
-  input:read-only{
-    color: rgb(var(--v-theme-neutral));
-  }
+input:read-only {
+  color: rgb(var(--v-theme-neutral));
+}
 </style>
 </style>

+ 23 - 25
components/Ui/Input/TextArea.vue

@@ -6,68 +6,66 @@ Champs de saisie de bloc texte
 
 
 <template>
 <template>
   <v-textarea
   <v-textarea
-      outlined
-      :value="data"
-      :label="$t(fieldLabel)"
-      :rules="rules"
-      :disabled="readonly"
-      :error="error || !!violation"
-      :error-messages="errorMessage || violation ? $t(violation) : ''"
-      @change="onChange($event)"
-    />
+    outlined
+    :value="data"
+    :label="$t(fieldLabel)"
+    :rules="rules"
+    :disabled="readonly"
+    :error="error || !!violation"
+    :error-messages="errorMessage || violation ? $t(violation) : ''"
+    @change="onChange($event)"
+  />
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-
-import {useNuxtApp} from "#app";
-import {useFieldViolation} from "~/composables/form/useFieldViolation";
+import { useNuxtApp } from '#app'
+import { useFieldViolation } from '~/composables/form/useFieldViolation'
 
 
 const props = defineProps({
 const props = defineProps({
   label: {
   label: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: null
+    default: null,
   },
   },
   field: {
   field: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: null
+    default: null,
   },
   },
   data: {
   data: {
     type: [String, Number],
     type: [String, Number],
     required: false,
     required: false,
-    default: null
+    default: null,
   },
   },
   readonly: {
   readonly: {
     type: Boolean,
     type: Boolean,
-    required: false
+    required: false,
   },
   },
   rules: {
   rules: {
     type: Array,
     type: Array,
     required: false,
     required: false,
-    default: () => []
+    default: () => [],
   },
   },
   error: {
   error: {
     type: Boolean,
     type: Boolean,
-    required: false
+    required: false,
   },
   },
   errorMessage: {
   errorMessage: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: null
-  }
+    default: null,
+  },
 })
 })
 
 
 const { emit } = useNuxtApp()
 const { emit } = useNuxtApp()
 
 
 const fieldLabel = props.label ?? props.field
 const fieldLabel = props.label ?? props.field
 
 
-const {violation, onChange} = useFieldViolation(props.field, emit)
-
+const { violation, onChange } = useFieldViolation(props.field, emit)
 </script>
 </script>
 
 
 <style lang="scss">
 <style lang="scss">
-  input:read-only{
-    color: rgb(var(--v-theme-on-neutral));
-  }
+input:read-only {
+  color: rgb(var(--v-theme-on-neutral));
+}
 </style>
 </style>

+ 14 - 18
components/Ui/ItemFromUri.vue

@@ -3,12 +3,9 @@ Espace permettant de récupérer un item via une uri et de gérer son affichage
 -->
 -->
 <template>
 <template>
   <main>
   <main>
-    <v-skeleton-loader
-      v-if="pending"
-      :type="loaderType"
-    />
+    <v-skeleton-loader v-if="pending" :type="loaderType" />
     <div v-else>
     <div v-else>
-      <slot name="item.text" v-bind="{item}" />
+      <slot name="item.text" v-bind="{ item }" />
     </div>
     </div>
     <slot />
     <slot />
   </main>
   </main>
@@ -17,33 +14,32 @@ Espace permettant de récupérer un item via une uri et de gérer son affichage
 <script setup lang="ts">
 <script setup lang="ts">
 // TODO: renommer en EntityFromUri? voir si ce component est encore nécessaire, ou si ça ne peut pas être une méthode de l'entity manager
 // TODO: renommer en EntityFromUri? voir si ce component est encore nécessaire, ou si ça ne peut pas être une méthode de l'entity manager
 
 
-import {Query} from "pinia-orm";
-import UrlUtils from "~/services/utils/urlUtils";
-import {useEntityFetch} from "~/composables/data/useEntityFetch";
-import {object} from "@ucast/core";
-import {computed, ComputedRef} from "@vue/reactivity";
-import {schema} from "@pinia-orm/normalizr";
-import ApiResource from "~/models/ApiResource";
+import { Query } from 'pinia-orm'
+import UrlUtils from '~/services/utils/urlUtils'
+import { useEntityFetch } from '~/composables/data/useEntityFetch'
+import { computed } from '@vue/reactivity'
+import type { ComputedRef } from '@vue/reactivity'
+import ApiResource from '~/models/ApiResource'
 
 
 const props = defineProps({
 const props = defineProps({
   uri: {
   uri: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: null
+    default: null,
   },
   },
   model: {
   model: {
     type: Object,
     type: Object,
-    required: true
+    required: true,
   },
   },
   query: {
   query: {
     type: Object as () => Query,
     type: Object as () => Query,
-    required: true
+    required: true,
   },
   },
   loaderType: {
   loaderType: {
     type: String,
     type: String,
     required: false,
     required: false,
-    default: 'text'
-  }
+    default: 'text',
+  },
 })
 })
 
 
 const id = UrlUtils.extractIdFromUri(props.uri)
 const id = UrlUtils.extractIdFromUri(props.uri)
@@ -55,7 +51,7 @@ const { fetch } = useEntityFetch()
 
 
 const { data, pending } = fetch(props.model, id)
 const { data, pending } = fetch(props.model, id)
 
 
-const item: ComputedRef<ApiResource|null> = computed(() => {
+const item: ComputedRef<ApiResource | null> = computed(() => {
   return data.value
   return data.value
 })
 })
 </script>
 </script>

+ 9 - 0
components/Ui/LoadingPanel.vue

@@ -0,0 +1,9 @@
+<template>
+  <v-row class="fill-height ma-0" align="center" justify="center">
+    <v-progress-circular :indeterminate="true" color="neutral" />
+  </v-row>
+</template>
+
+<script setup lang="ts"></script>
+
+<style scoped lang="scss"></style>

+ 38 - 36
components/Ui/SystemBar.vue

@@ -4,9 +4,11 @@ System bars
 
 
 <template>
 <template>
   <v-system-bar
   <v-system-bar
-      height="50"
-      :class="'d-flex flex-row justify-center align-center text-center ' + classes"
-      @click="onClick !== undefined ? onClick() : null"
+    height="50"
+    :class="
+      'd-flex flex-row justify-center align-center text-center ' + classes
+    "
+    @click="onClick !== undefined ? onClick() : null"
   >
   >
     <slot>
     <slot>
       <v-icon v-if="icon" small :icon="icon" />
       <v-icon v-if="icon" small :icon="icon" />
@@ -16,40 +18,40 @@ System bars
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-  const props = defineProps({
-    text: {
-      type: String,
-      required: false,
-      default: ''
-    },
-    icon: {
-      type: String,
-      required: false,
-      default: undefined
-    },
-    backgroundColor: {
-      type: String,
-      required: false,
-      default: 'neutral-soft'
-    },
-    textColor: {
-      type: String,
-      required: false,
-      default: 'on-neutral-soft'
-    },
-    onClick: {
-      type: Function,
-      required: false,
-      default: undefined
-    },
-  })
+const props = defineProps({
+  text: {
+    type: String,
+    required: false,
+    default: '',
+  },
+  icon: {
+    type: String,
+    required: false,
+    default: undefined,
+  },
+  backgroundColor: {
+    type: String,
+    required: false,
+    default: 'neutral-soft',
+  },
+  textColor: {
+    type: String,
+    required: false,
+    default: 'on-neutral-soft',
+  },
+  onClick: {
+    type: Function,
+    required: false,
+    default: undefined,
+  },
+})
 
 
-  // TODO: voir si possible d'utiliser les variables sass à la place?
-  const classes = [
-    'bg-' + props.backgroundColor,
-    'text-' + props.textColor,
-    (props.onClick !== undefined ? 'clickable' : '')
-  ].join(' ')
+// TODO: voir si possible d'utiliser les variables sass à la place?
+const classes = [
+  'bg-' + props.backgroundColor,
+  'text-' + props.textColor,
+  props.onClick !== undefined ? 'clickable' : '',
+].join(' ')
 </script>
 </script>
 
 
 <style scoped lang="scss">
 <style scoped lang="scss">

+ 6 - 15
components/Ui/Template/DataTable.vue

@@ -5,42 +5,33 @@ Template de base d'un tableau interactif
 -->
 -->
 
 
 <template>
 <template>
-  <v-col
-    cols="12"
-    sm="12"
-  >
-    <v-data-table
-      :headers="headersWithItem"
-      :items="items"
-      class="elevation-1"
-    >
+  <v-col cols="12" sm="12">
+    <v-data-table :headers="headersWithItem" :items="items" class="elevation-1">
       <template v-for="header in headersWithItem" #[header.item]="props">
       <template v-for="header in headersWithItem" #[header.item]="props">
         <slot :name="header.item" v-bind="props">
         <slot :name="header.item" v-bind="props">
           {{ props.item[header.value] }}
           {{ props.item[header.value] }}
         </slot>
         </slot>
       </template>
       </template>
-
     </v-data-table>
     </v-data-table>
   </v-col>
   </v-col>
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-
 const props = defineProps({
 const props = defineProps({
   items: {
   items: {
     type: Array,
     type: Array,
-    required: true
+    required: true,
   },
   },
   headers: {
   headers: {
     type: Array,
     type: Array,
-    required: true
-  }
+    required: true,
+  },
 })
 })
 
 
 const { headers } = toRefs(props)
 const { headers } = toRefs(props)
 
 
 const headersWithItem = computed(() => {
 const headersWithItem = computed(() => {
-  return headers.value.map((header:any) => {
+  return headers.value.map((header: any) => {
     header.item = 'item.' + header.value
     header.item = 'item.' + header.value
     return header
     return header
   })
   })

+ 5 - 4
components/Ui/Template/Date.vue

@@ -7,15 +7,16 @@ Date formatée
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import DateUtils from "~/services/utils/dateUtils";
-import {computed, ComputedRef} from "@vue/reactivity";
+import DateUtils from '~/services/utils/dateUtils'
+import { computed } from '@vue/reactivity'
+import type { ComputedRef } from '@vue/reactivity'
 
 
 const props = defineProps({
 const props = defineProps({
   data: {
   data: {
     type: [String, Array],
     type: [String, Array],
     required: false,
     required: false,
-    default: null
-  }
+    default: null,
+  },
 })
 })
 
 
 const datesFormatted: ComputedRef<string> = computed(() => {
 const datesFormatted: ComputedRef<string> = computed(() => {

+ 39 - 43
components/Ui/Xeditable/Text.vue

@@ -8,68 +8,64 @@ Utilisé par exemple pour le choix de l'année active
   <main>
   <main>
     <!-- Mode édition activé -->
     <!-- Mode édition activé -->
     <div v-if="edit" class="d-flex align-center x-editable-input">
     <div v-if="edit" class="d-flex align-center x-editable-input">
-      <UiInputText
-          class="ma-0 pa-0"
-          :type="type"
-          v-model="inputValue"
-      />
+      <UiInputText class="ma-0 pa-0" :type="type" v-model="inputValue" />
 
 
       <v-icon
       <v-icon
-          icon="fas fa-check"
-          aria-hidden="false"
-          class="valid icons text-primary"
-          size="small"
-          @click="update"
+        icon="fas fa-check"
+        aria-hidden="false"
+        class="valid icons text-primary"
+        size="small"
+        @click="update"
       />
       />
       <v-icon
       <v-icon
-          icon="fas fa-times"
-          aria-hidden="false"
-          class="cancel icons text-neutral-strong"
-          size="small"
-          @click="close"
+        icon="fas fa-times"
+        aria-hidden="false"
+        class="cancel icons text-neutral-strong"
+        size="small"
+        @click="close"
       />
       />
     </div>
     </div>
 
 
     <!-- Mode édition désactivé -->
     <!-- Mode édition désactivé -->
-    <div v-else class="edit-link d-flex align-center" @click="edit=true">
-      <slot name="xeditable.read" v-bind="{inputValue}" />
+    <div v-else class="edit-link d-flex align-center" @click="edit = true">
+      <slot name="xeditable.read" v-bind="{ inputValue }" />
     </div>
     </div>
   </main>
   </main>
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import {ref, Ref} from "@vue/reactivity";
-
-  const props = defineProps({
-    type: {
-      type: String,
-      required: false,
-      default: null
-    },
-    data: {
-      type: [String, Number],
-      required: false,
-      default: null
-    }
-  })
+import { ref } from '@vue/reactivity'
+import type { Ref } from '@vue/reactivity'
 
 
-  const emit = defineEmits(['update'])
+const props = defineProps({
+  type: {
+    type: String,
+    required: false,
+    default: null,
+  },
+  data: {
+    type: [String, Number],
+    required: false,
+    default: null,
+  },
+})
 
 
-  const edit: Ref<boolean> = ref(false)
-  const inputValue: Ref<string|number|null> = ref(props.data)
+const emit = defineEmits(['update'])
 
 
-  const update = () => {
-    edit.value = false
-    if (inputValue.value !== props.data) {
-        emit('update', inputValue.value)
-    }
-  }
+const edit: Ref<boolean> = ref(false)
+const inputValue: Ref<string | number | null> = ref(props.data)
 
 
-  const close = () => {
-    edit.value = false
-    inputValue.value = props.data
+const update = () => {
+  edit.value = false
+  if (inputValue.value !== props.data) {
+    emit('update', inputValue.value)
   }
   }
+}
 
 
+const close = () => {
+  edit.value = false
+  inputValue.value = props.data
+}
 </script>
 </script>
 
 
 <style scoped lang="scss">
 <style scoped lang="scss">

+ 96 - 92
composables/data/useAp2iRequestService.ts

@@ -1,109 +1,113 @@
-import {FetchContext, FetchOptions} from "ohmyfetch";
-import {TYPE_ALERT} from "~/types/enum/enums";
-import ApiRequestService from "~/services/data/apiRequestService";
-import {Ref} from "@vue/reactivity";
-import {usePageStore} from "~/stores/page";
-import UnauthorizedError from "~/services/error/UnauthorizedError";
-import {useAccessProfileStore} from "~/stores/accessProfile";
-import {AssociativeArray} from "~/types/data";
+import type { Ref } from 'vue'
+import type { FetchContext, FetchOptions } from 'ofetch'
+import { TYPE_ALERT } from '~/types/enum/enums'
+import ApiRequestService from '~/services/data/apiRequestService'
+import { usePageStore } from '~/stores/page'
+import UnauthorizedError from '~/services/error/UnauthorizedError'
+import { useAccessProfileStore } from '~/stores/accessProfile'
 
 
 /**
 /**
  * Retourne une instance de ApiRequestService configurée pour interroger l'api Ap2i
  * Retourne une instance de ApiRequestService configurée pour interroger l'api Ap2i
  *
  *
  * @see https://github.com/unjs/ohmyfetch/blob/main/README.md#%EF%B8%8F-create-fetch-with-default-options
  * @see https://github.com/unjs/ohmyfetch/blob/main/README.md#%EF%B8%8F-create-fetch-with-default-options
  */
  */
-let apiRequestServiceClass:null|ApiRequestService = null
+let apiRequestServiceClass: null | ApiRequestService = null
 export const useAp2iRequestService = () => {
 export const useAp2iRequestService = () => {
-    const runtimeConfig = useRuntimeConfig()
-
-    const baseURL = runtimeConfig.baseUrl ?? runtimeConfig.public.baseUrl
-
-    const pending: Ref<boolean> = ref(false)
-
-    /**
-     * Peuple les headers avant l'envoi de la requête
-     *
-     * @param request
-     * @param options
-     */
-    const onRequest = async function ({ request, options }: FetchContext) {
-        // @ts-ignore
-        if(options && options.noXaccessId) {
-            return
-        }
-
-        const accessProfileStore = useAccessProfileStore()
-
-        const headers: AssociativeArray = {
-            'x-accessid': String(accessProfileStore.id),
-            'Authorization': 'BEARER ' + accessProfileStore.bearer,
-        }
-
-        if (accessProfileStore.switchId) {
-            headers['x-switch-user'] = String(accessProfileStore.switchId)
-        }
+  const runtimeConfig = useRuntimeConfig()
+
+  const baseURL = runtimeConfig.baseUrl ?? runtimeConfig.public.baseUrl
+
+  const pending: Ref<boolean> = ref(false)
+
+  /**
+   * Peuple les headers avant l'envoi de la requête
+   *
+   * @param request
+   * @param options
+   */
+  const onRequest = function ({ request, options }: FetchContext) {
+    // @ts-expect-error options is not aware of noXaccessId
+    if (options && options.noXaccessId) {
+      return
+    }
 
 
-        options.headers = { ...options.headers, ...headers }
+    const accessProfileStore = useAccessProfileStore()
 
 
-        pending.value = true
-        console.log('Request : ' + request + ' (SSR: ' + process.server + ')')
-    }
+    const headers = new Headers(options.headers)
 
 
-    const onRequestError = async function({ request, options, response }: FetchContext) {
-        pending.value = false
+    headers.set('x-accessid', String(accessProfileStore.id))
+    headers.set('Authorization', 'BEARER ' + accessProfileStore.bearer)
+    if (accessProfileStore.switchId) {
+      headers.set('x-switch-user', String(accessProfileStore.switchId))
     }
     }
-
-    /**
-     * Server responded
-     *
-     * @param request
-     * @param options
-     * @param response
-     */
-    const onResponse = async function({ request, options, response }: FetchContext) {
-        pending.value = false
+    options.headers = headers
+
+    pending.value = true
+    console.log('Request : ' + request + ' (SSR: ' + process.server + ')')
+  }
+
+  const onRequestError = function (_: FetchContext) {
+    pending.value = false
+  }
+
+  /**
+   * Server responded
+   */
+  const onResponse = function (_: FetchContext) {
+    pending.value = false
+  }
+
+  /**
+   * Gère les erreurs retournées par l'api
+   *
+   * @param request
+   * @param response
+   * @param error
+   */
+  const onResponseError = function ({ response, error }: FetchContext) {
+    pending.value = false
+
+    if (response && response.status === 401) {
+      throw new UnauthorizedError('Ap2i - Unauthorized')
+    } else if (response && response.status === 403) {
+      console.error('! Request error: Forbidden')
+      usePageStore().addAlert(TYPE_ALERT.ALERT, ['forbidden'])
+    } else if (
+      response &&
+      (response.status === 400 || response.status >= 404)
+    ) {
+      // @see https://developer.mozilla.org/fr/docs/Web/HTTP/Status
+      let errorMsg
+      if (error) {
+        errorMsg = error.message
+      } else if (response._data && response._data.detail) {
+        errorMsg = response._data.detail
+      } else if (response.statusText) {
+        errorMsg = response.statusText
+      } else {
+        errorMsg = 'An error occured'
+      }
+
+      console.error('! Request error: ' + errorMsg)
+      usePageStore().addAlert(TYPE_ALERT.ALERT, [errorMsg])
     }
     }
+  }
 
 
-    /**
-     * Gère les erreurs retournées par l'api
-     *
-     * @param request
-     * @param response
-     * @param error
-     */
-    const onResponseError = async function ({ request, response, error }: FetchContext) {
-        pending.value = false
-
-        if (response && response.status === 401) {
-            throw new UnauthorizedError('Ap2i - Unauthorized')
-        }
-        else if (response && response.status === 403) {
-            console.error('! Request error: Forbidden')
-            usePageStore().addAlert(TYPE_ALERT.ALERT, ['forbidden'])
-        }
-        else if (response && response.status >= 404) {
-            // @see https://developer.mozilla.org/fr/docs/Web/HTTP/Status
-            const error_msg = error ? error.message : response.statusText
-            console.error('! Request error: ' + error_msg)
-            usePageStore().addAlert(TYPE_ALERT.ALERT, [error_msg])
-        }
-    }
+  const config: FetchOptions = {
+    baseURL,
+    onRequest,
+    onRequestError,
+    onResponse,
+    onResponseError,
+  }
 
 
-    const config : FetchOptions = {
-        baseURL,
-        onRequest,
-        onRequestError,
-        onResponse,
-        onResponseError
-    }
+  // Avoid memory leak
+  if (apiRequestServiceClass === null) {
+    // Utilise la fonction `create` d'ohmyfetch pour générer un fetcher dédié à l'interrogation de Ap2i
+    const fetcher = $fetch.create(config)
 
 
-    //Avoid memory leak
-    if (apiRequestServiceClass === null) {
-        // Utilise la fonction `create` d'ohmyfetch pour générer un fetcher dédié à l'interrogation de Ap2i
-        const fetcher = $fetch.create(config)
-        // @ts-ignore
-        apiRequestServiceClass = new ApiRequestService(fetcher)
-    }
+    apiRequestServiceClass = new ApiRequestService(fetcher)
+  }
 
 
-    return { apiRequestService: apiRequestServiceClass, pending: pending }
+  return { apiRequestService: apiRequestServiceClass, pending }
 }
 }

+ 67 - 29
composables/data/useEntityFetch.ts

@@ -1,42 +1,80 @@
-import {useEntityManager} from "~/composables/data/useEntityManager";
-import ApiResource from "~/models/ApiResource";
-import {AssociativeArray, Collection} from "~/types/data";
-import {AsyncData} from "#app";
-import {ComputedRef, Ref} from "vue";
-import {v4 as uuid4} from "uuid";
+import type { AsyncData } from '#app'
+import type { ComputedRef, Ref } from 'vue'
+import { v4 as uuid4 } from 'uuid'
+import type {
+  AsyncDataExecuteOptions,
+  AsyncDataRequestStatus,
+} from '#app/composables/asyncData'
+import { useEntityManager } from '~/composables/data/useEntityManager'
+import ApiResource from '~/models/ApiResource'
+import type { Collection } from '~/types/data'
+import Query from '~/services/data/Query'
 
 
 interface useEntityFetchReturnType {
 interface useEntityFetchReturnType {
-    fetch: (model: typeof ApiResource, id: number) => AsyncData<ApiResource, ApiResource | true>,
-    fetchCollection: (model: typeof ApiResource, parent?: ApiResource | null, query?: Ref<AssociativeArray>) => AsyncData<Collection, any>
-    // @ts-ignore
-    getRef: <T extends ApiResource>(model: typeof T, id: Ref<number | null>) => ComputedRef<null | T>
+  fetch: (
+    model: typeof ApiResource,
+    id: number,
+  ) => AsyncData<ApiResource | null, Error | null>
+
+  fetchCollection: (
+    model: typeof ApiResource,
+    parent?: ApiResource | null,
+    query?: Query | null,
+  ) => {
+    data: ComputedRef<Collection | null>
+    pending: Ref<boolean>
+    refresh: (
+      opts?: AsyncDataExecuteOptions,
+    ) => Promise<ComputedRef<Collection> | null>
+    error: Ref<Error | null>
+    status: Ref<AsyncDataRequestStatus>
+  }
+
+  getRef: <T extends ApiResource>(
+    model: new () => T,
+    id: Ref<number | null>,
+  ) => ComputedRef<null | T>
 }
 }
 
 
 // TODO: améliorer le typage des fonctions sur le modèle de getRef
 // TODO: améliorer le typage des fonctions sur le modèle de getRef
-export const useEntityFetch = (lazy: boolean = false): useEntityFetchReturnType => {
-    const { em } = useEntityManager()
+export const useEntityFetch = (
+  lazy: boolean = false,
+): useEntityFetchReturnType => {
+  const { em } = useEntityManager()
 
 
-    const fetch = (model: typeof ApiResource, id: number) => useAsyncData(
-        model.entity + '_' + id + '_' + uuid4(),
-        () => em.fetch(model, id, true),
-        { lazy }
+  const fetch = (model: typeof ApiResource, id: number) =>
+    useAsyncData(
+      model.entity + '_' + id + '_' + uuid4(),
+      () => em.fetch(model, id, true),
+      { lazy },
     )
     )
 
 
-    const fetchCollection = (
-        model: typeof ApiResource,
-        parent: ApiResource | null = null,
-        query: Ref<AssociativeArray | null> = ref(null)
-    ) => useAsyncData(
-        model.entity + '_many_' + uuid4(),
-        () => em.fetchCollection(model, parent, query.value ?? undefined),
-        { lazy }
+  const fetchCollection = (
+    model: typeof ApiResource,
+    parent: ApiResource | null = null,
+    query: Query | null = null,
+  ) => {
+    const { data, pending, refresh, error, status } = useAsyncData(
+      model.entity + '_many_' + uuid4(),
+      () => em.fetchCollection(model, parent, query),
+      { lazy, deep: true },
     )
     )
 
 
-    // @ts-ignore
-    const getRef = <T extends ApiResource>(model: typeof T, id: Ref<number | null>): ComputedRef<T | null> => {
-        return computed(() => (id.value ? em.find(model, id.value) as T : null))
+    return {
+      data: computed(() => (data.value !== null ? data.value.value : null)),
+      pending,
+      refresh,
+      error,
+      status,
     }
     }
+  }
+
+  const getRef = <T extends ApiResource>(
+    model: new () => T,
+    id: Ref<number | null>,
+  ): ComputedRef<T | null> => {
+    return computed(() => (id.value ? (em.find(model, id.value) as T) : null))
+  }
 
 
-    //@ts-ignore
-    return { fetch, fetchCollection, getRef }
+  return { fetch, fetchCollection, getRef }
 }
 }

+ 21 - 8
composables/data/useEntityManager.ts

@@ -1,15 +1,28 @@
-import EntityManager from "~/services/data/entityManager";
-import {useAp2iRequestService} from "~/composables/data/useAp2iRequestService";
-import {useRepo} from "pinia-orm";
+import { useRepo } from 'pinia-orm'
+import EntityManager from '~/services/data/entityManager'
+import { useAp2iRequestService } from '~/composables/data/useAp2iRequestService'
+import { useAccessProfileStore } from '~/stores/accessProfile'
 
 
 let entityManager: EntityManager | null = null
 let entityManager: EntityManager | null = null
 
 
 export const useEntityManager = () => {
 export const useEntityManager = () => {
-    if (entityManager === null) {
-        const { apiRequestService } = useAp2iRequestService()
-        const getRepo = useRepo
+  if (entityManager === null) {
+    const { apiRequestService } = useAp2iRequestService()
+    const getRepo = useRepo
 
 
-        entityManager = new EntityManager(apiRequestService, getRepo)
+    const profileStore = useAccessProfileStore()
+    const getProfileMask = () => {
+      return {
+        activityYear: profileStore.activityYear,
+        historical: profileStore.historical,
+      }
     }
     }
-    return { em: entityManager }
+
+    entityManager = new EntityManager(
+      apiRequestService,
+      getRepo,
+      getProfileMask,
+    )
+  }
+  return { em: entityManager }
 }
 }

+ 8 - 12
composables/data/useEnumFetch.ts

@@ -1,20 +1,16 @@
-import {useEnumManager} from "~/composables/data/useEnumManager";
-import {Enum} from "~/types/data";
-import {AsyncData} from "#app";
+import type { AsyncData } from '#app'
+import { useEnumManager } from '~/composables/data/useEnumManager'
+import type { Enum } from '~/types/data'
 
 
 interface useEnumFetchReturnType {
 interface useEnumFetchReturnType {
-    fetch: (enumName: string) => AsyncData<Enum, null | true | Error>,
+  fetch: (enumName: string) => AsyncData<Enum | null, Error | null>
 }
 }
 
 
 export const useEnumFetch = (lazy: boolean = false): useEnumFetchReturnType => {
 export const useEnumFetch = (lazy: boolean = false): useEnumFetchReturnType => {
-    const { enumManager } = useEnumManager()
+  const { enumManager } = useEnumManager()
 
 
-    const fetch = (enumName: string) => useAsyncData(
-        enumName,
-        () => enumManager.fetch(enumName),
-        { lazy }
-    )
+  const fetch = (enumName: string) =>
+    useAsyncData(enumName, () => enumManager.fetch(enumName), { lazy })
 
 
-    //@ts-ignore
-    return { fetch }
+  return { fetch }
 }
 }

+ 12 - 11
composables/data/useEnumManager.ts

@@ -1,15 +1,16 @@
-import {useAp2iRequestService} from "~/composables/data/useAp2iRequestService";
-import EnumManager from "~/services/data/enumManager";
-import {useI18n} from "vue-i18n";
+import { useI18n } from 'vue-i18n'
+import { useAp2iRequestService } from '~/composables/data/useAp2iRequestService'
+import EnumManager from '~/services/data/enumManager'
 
 
-let enumManager:EnumManager | null = null
+let enumManager: EnumManager | null = null
 
 
 export const useEnumManager = () => {
 export const useEnumManager = () => {
-    //Avoid memory leak
-    if (enumManager === null) {
-        const { apiRequestService } = useAp2iRequestService()
-        const i18n = useI18n() as any
-        enumManager = new EnumManager(apiRequestService, i18n)
-    }
-    return { enumManager: enumManager }
+  // Avoid memory leak
+  if (enumManager === null) {
+    const { apiRequestService } = useAp2iRequestService()
+    const i18n = useI18n()
+    // @ts-expect-error TODO: explain the error of conversion from useI18n result to VueI18n
+    enumManager = new EnumManager(apiRequestService, i18n)
+  }
+  return { enumManager }
 }
 }

+ 22 - 14
composables/data/useImageFetch.ts

@@ -1,26 +1,34 @@
-import {useImageManager} from "~/composables/data/useImageManager";
-import {FetchResult} from "#app";
+import type { AsyncData } from '#app'
+import { v4 as uuid4 } from 'uuid'
+import type { Ref } from 'vue'
+import { useImageManager } from '~/composables/data/useImageManager'
 
 
 interface useImageFetchReturnType {
 interface useImageFetchReturnType {
-    fetch: (id: number | null, defaultImage?: string | null, height?: number, width?: number) => FetchResult<any, any>
+  fetch: (
+    id: Ref<number | null>,
+    defaultImage?: string | null,
+    height?: number,
+    width?: number,
+  ) => AsyncData<string | ArrayBuffer | null, Error | null>
 }
 }
 
 
 /**
 /**
  * Sert d'intermédiaire entre les composants et l'ImageManager en fournissant une méthode useAsyncData toute prête.
  * Sert d'intermédiaire entre les composants et l'ImageManager en fournissant une méthode useAsyncData toute prête.
  */
  */
 export const useImageFetch = (): useImageFetchReturnType => {
 export const useImageFetch = (): useImageFetchReturnType => {
-    const { imageManager } = useImageManager()
+  const { imageManager } = useImageManager()
 
 
-    const fetch = (
-        id: number | null,  // If id is null, fetch shall return the default image url
-        defaultImage: string | null = null,
-        height: number = 0,
-        width: number = 0
-    ): FetchResult<string, any> => useAsyncData(
-        'img' + (id ?? defaultImage ?? 0),
-        () => imageManager.get(id, defaultImage, height, width),
-        { lazy: true, server: false }  // Always fetch images client-side
+  const fetch = (
+    id: Ref<number | null>, // If id is null, fetch shall return the default image url
+    defaultImage: string | null = null,
+    height: number = 0,
+    width: number = 0,
+  ) =>
+    useAsyncData(
+      'img' + (id ?? defaultImage ?? 0) + '_' + uuid4(),
+      () => imageManager.get(id.value, defaultImage, height, width),
+      { lazy: true, server: false }, // Always fetch images client-side
     )
     )
 
 
-    return { fetch }
+  return { fetch }
 }
 }

+ 9 - 9
composables/data/useImageManager.ts

@@ -1,14 +1,14 @@
-import {useAp2iRequestService} from "~/composables/data/useAp2iRequestService";
-import ImageManager from "~/services/data/imageManager";
+import { useAp2iRequestService } from '~/composables/data/useAp2iRequestService'
+import ImageManager from '~/services/data/imageManager'
 
 
-let imageManager:ImageManager | null = null
+let imageManager: ImageManager | null = null
 
 
 export const useImageManager = () => {
 export const useImageManager = () => {
-    //Avoid memory leak
-    if (imageManager === null) {
-        const { apiRequestService } = useAp2iRequestService()
-        imageManager = new ImageManager(apiRequestService)
-    }
+  // Avoid memory leak
+  if (imageManager === null) {
+    const { apiRequestService } = useAp2iRequestService()
+    imageManager = new ImageManager(apiRequestService)
+  }
 
 
-    return { imageManager: imageManager }
+  return { imageManager }
 }
 }

+ 66 - 0
composables/data/useRefreshProfile.ts

@@ -0,0 +1,66 @@
+import { useEntityManager } from '~/composables/data/useEntityManager'
+import MyProfile from '~/models/Access/MyProfile'
+import { useAccessProfileStore } from '~/stores/accessProfile'
+import { useOrganizationProfileStore } from '~/stores/organizationProfile'
+
+export const useRefreshProfile = () => {
+  const accessProfileStore = useAccessProfileStore()
+  const organizationProfileStore = useOrganizationProfileStore()
+  const { em } = useEntityManager()
+
+  const fetchProfile = async (
+    accessId: number | null = null,
+  ): Promise<MyProfile> => {
+    if (accessId === null) {
+      accessId = accessProfileStore.currentAccessId
+    }
+
+    return (await em.fetch(MyProfile, accessId, true)) as MyProfile
+  }
+
+  /**
+   * Fetch the access profile and initiate the user profile and organization profile stores
+   *
+   * /!\ Server side only!
+   *
+   * @param accessId
+   * @param bearer
+   * @param switchId
+   */
+  const initiateProfile = async (
+    accessId: number,
+    bearer: string,
+    switchId: number | null,
+  ): Promise<void> => {
+    accessProfileStore.$patch({
+      bearer,
+      id: accessId,
+      switchId,
+    })
+
+    const profile = await fetchProfile(accessId)
+
+    // Sans le flush, on observe un bug non-expliqué au rechargement de la page en mode dev : la fonction save
+    //  du repo de MyProfile ne fonctionne pas quand le plugin init.server.ts re-fetch le profil
+    em.flush(MyProfile)
+
+    accessProfileStore.initiateProfile(profile)
+    organizationProfileStore.initiateProfile(profile.organization)
+  }
+
+  /**
+   * Re-fetch the user profile and update the store
+   */
+  const refreshProfile = async (accessId: number | null = null) => {
+    const profile = await fetchProfile(accessId)
+
+    // Sans le flush, on observe un bug non-expliqué au rechargement de la page en mode dev : la fonction save
+    //  du repo de MyProfile ne fonctionne pas quand le plugin init.server.ts re-fetch le profil
+    em.flush(MyProfile)
+
+    accessProfileStore.setProfile(profile)
+    organizationProfileStore.setProfile(profile.organization)
+  }
+
+  return { initiateProfile, refreshProfile }
+}

+ 9 - 8
composables/form/useFieldViolation.ts

@@ -1,6 +1,7 @@
-import {computed, ComputedRef} from "@vue/reactivity";
-import {useFormStore} from "~/stores/form";
+import { computed } from 'vue'
+import type { ComputedRef } from 'vue'
 import * as _ from 'lodash-es'
 import * as _ from 'lodash-es'
+import { useFormStore } from '~/stores/form'
 
 
 /**
 /**
  * Composable pour gérer l'apparition de message d'erreurs de validation d'un champ de formulaire
  * Composable pour gérer l'apparition de message d'erreurs de validation d'un champ de formulaire
@@ -8,22 +9,22 @@ import * as _ from 'lodash-es'
  * @param field
  * @param field
  */
  */
 export function useFieldViolation(field: string) {
 export function useFieldViolation(field: string) {
-  const fieldViolations: ComputedRef<string> = computed(()=> {
+  const fieldViolations: ComputedRef<string> = computed(() => {
     return _.get(useFormStore().violations, field, '')
     return _.get(useFormStore().violations, field, '')
   })
   })
 
 
   /**
   /**
    * Lorsque la valeur d'un champ change, on supprime le fait qu'il puisse être "faux" dans le store
    * Lorsque la valeur d'un champ change, on supprime le fait qu'il puisse être "faux" dans le store
    * @param field
    * @param field
-   * @param value
    */
    */
-  function updateViolationState(field: string, value: any) {
-    //@ts-ignore
-    useFormStore().setViolations(_.omit(useFormStore().violations, field))
+  function updateViolationState(field: string) {
+    useFormStore().setViolations(
+      _.omit(useFormStore().violations, field) as string[],
+    )
   }
   }
 
 
   return {
   return {
     fieldViolations,
     fieldViolations,
-    updateViolationState: (fieldValue: any) => updateViolationState(fieldValue, field)
+    updateViolationState,
   }
   }
 }
 }

+ 19 - 27
composables/form/useValidation.ts

@@ -1,14 +1,13 @@
-import  {useI18n} from 'vue-i18n'
-import {useAp2iRequestService} from "~/composables/data/useAp2iRequestService";
-import UrlUtils from "~/services/utils/urlUtils";
-import {Ref} from "@vue/reactivity";
+import { useI18n } from 'vue-i18n'
+import type { Ref } from 'vue'
+import { useAp2iRequestService } from '~/composables/data/useAp2iRequestService'
+import UrlUtils from '~/services/utils/urlUtils'
 
 
 /**
 /**
  * @category composables/form
  * @category composables/form
  * Composable pour des utils de verifications
  * Composable pour des utils de verifications
  */
  */
 export function useValidation() {
 export function useValidation() {
-
   /**
   /**
    * Use méthode fournissant une fonction pour tester la validité d'un Siret ainsi que la gestion du message d'erreur
    * Use méthode fournissant une fonction pour tester la validité d'un Siret ainsi que la gestion du message d'erreur
    */
    */
@@ -17,45 +16,38 @@ export function useValidation() {
     const siretErrorMessage: Ref<string> = ref('')
     const siretErrorMessage: Ref<string> = ref('')
 
 
     const validateSiret = async (siret: string) => {
     const validateSiret = async (siret: string) => {
-
       const { apiRequestService } = useAp2iRequestService()
       const { apiRequestService } = useAp2iRequestService()
-      const response: any = await apiRequestService.get(UrlUtils.join('/api/siret-checking', siret))
+      const response: Response = await apiRequestService.get(
+        UrlUtils.join('/api/siret-checking', siret),
+      )
 
 
       if (typeof response === 'undefined') {
       if (typeof response === 'undefined') {
         siretError.value = false
         siretError.value = false
         siretErrorMessage.value = ''
         siretErrorMessage.value = ''
       }
       }
 
 
+      if (!Object.prototype.hasOwnProperty.call(response, 'isCorrect')) {
+        throw new Error('Invalid response format')
+      }
+
+      // @ts-expect-error At this point, response has an 'isCorrect' property
+      const isCorrect = response.isCorrect
+
       const i18n = useI18n()
       const i18n = useI18n()
-      siretError.value = !response.isCorrect
-      siretErrorMessage.value = response.isCorrect ? '' : i18n.t('siret_error') as string
+      siretError.value = !isCorrect
+      siretErrorMessage.value = isCorrect
+        ? ''
+        : (i18n.t('siret_error') as string)
     }
     }
 
 
     return {
     return {
       siretError,
       siretError,
       siretErrorMessage,
       siretErrorMessage,
-      validateSiret
-    }
-  }
-
-  function useValidateSubdomain() {
-    const isSubdomainAvailable = async (subdomain: string | null): Promise<boolean> => {
-      if (subdomain === null) {
-        return true
-      }
-
-      const { apiRequestService } = useAp2iRequestService()
-      const response: any = await apiRequestService.get('/api/subdomains', {'subdomain': subdomain})
-
-      return typeof response !== 'undefined' && response.metadata.totalItems === 0
-    }
-    return {
-      isSubdomainAvailable
+      validateSiret,
     }
     }
   }
   }
 
 
   return {
   return {
     useValidateSiret,
     useValidateSiret,
-    useValidateSubdomain
   }
   }
 }
 }

+ 13 - 0
composables/form/validation/useSubdomainValidation.ts

@@ -0,0 +1,13 @@
+import { useAp2iRequestService } from '~/composables/data/useAp2iRequestService'
+import SubdomainValidation from '~/services/validation/subdomainValidation'
+
+let subdomainValidation: SubdomainValidation | null = null
+
+export function useSubdomainValidation() {
+  if (subdomainValidation === null) {
+    const { apiRequestService } = useAp2iRequestService()
+
+    subdomainValidation = new SubdomainValidation(apiRequestService)
+  }
+  return { subdomainValidation }
+}

+ 13 - 8
composables/layout/useExtensionPanel.ts

@@ -1,4 +1,4 @@
-import {Ref} from "@vue/reactivity";
+import type { Ref } from 'vue'
 import * as _ from 'lodash-es'
 import * as _ from 'lodash-es'
 
 
 /**
 /**
@@ -11,16 +11,21 @@ export function useExtensionPanel(route: Ref) {
 
 
   onMounted(() => {
   onMounted(() => {
     setTimeout(function () {
     setTimeout(function () {
-      _.each(document.getElementsByClassName('v-expansion-panel'), (element, index) => {
-        if (element.id == activeAccordionId) {
-          panel.value = index
-        }
-      })
-      if (!panel.value) { panel.value = 0 }
+      _.each(
+        document.getElementsByClassName('v-expansion-panel'),
+        (element, index) => {
+          if (element.id === activeAccordionId) {
+            panel.value = index
+          }
+        },
+      )
+      if (!panel.value) {
+        panel.value = 0
+      }
     }, 0)
     }, 0)
   })
   })
 
 
   return {
   return {
-    panel
+    panel,
   }
   }
 }
 }

+ 19 - 11
composables/layout/useMenu.ts

@@ -1,11 +1,11 @@
-import {useAccessProfileStore} from "~/stores/accessProfile";
-import {useAbility} from "@casl/vue";
-import {useOrganizationProfileStore} from "~/stores/organizationProfile";
-import {MenuGroup, MenuItem} from "~/types/layout";
-import {MENU_LINK_TYPE} from "~/types/enum/layout";
-import {AccessProfile} from "~/types/interfaces";
-import {useLayoutStore} from "~/stores/layout";
-import MenuComposer from "~/services/layout/menuComposer";
+import { useAbility } from '@casl/vue'
+import { useAccessProfileStore } from '~/stores/accessProfile'
+import { useOrganizationProfileStore } from '~/stores/organizationProfile'
+import type { MenuGroup, MenuItem } from '~/types/layout'
+import { MENU_LINK_TYPE } from '~/types/enum/layout'
+import type { AccessProfile } from '~/types/interfaces'
+import { useLayoutStore } from '~/stores/layout'
+import MenuComposer from '~/services/layout/menuComposer'
 
 
 /**
 /**
  * Renvoie des méthodes pour interagir avec les menus
  * Renvoie des méthodes pour interagir avec les menus
@@ -23,15 +23,23 @@ export const useMenu = () => {
   const organizationProfile = useOrganizationProfileStore()
   const organizationProfile = useOrganizationProfileStore()
   const accessProfile = useAccessProfileStore()
   const accessProfile = useAccessProfileStore()
   const layoutState = useLayoutStore()
   const layoutState = useLayoutStore()
+  const router = useRouter()
 
 
   /**
   /**
    * Construct all Menus
    * Construct all Menus
    * TODO: ce serait mieux de conserver les ids des menus même non possédés, de façon à pouvoir différencier un menu
    * TODO: ce serait mieux de conserver les ids des menus même non possédés, de façon à pouvoir différencier un menu
    * non possédé et un id incorrect dans getMenu par exemple. J'ai eu du mal capter pourquoi hasMenu('Family') renvoyait
    * non possédé et un id incorrect dans getMenu par exemple. J'ai eu du mal capter pourquoi hasMenu('Family') renvoyait
-   * false, jusqu'à ce que je tilte que le menu s'appellait MyFamily, et pas Family
+   * false, jusqu'à ce que je tilte que le menu s'appelait MyFamily, et pas Family
    */
    */
   const buildAllMenu = () => {
   const buildAllMenu = () => {
-    MenuComposer.build(runtimeConfig, ability, organizationProfile, accessProfile as AccessProfile, layoutState)
+    MenuComposer.build(
+      runtimeConfig,
+      ability,
+      organizationProfile,
+      accessProfile as AccessProfile,
+      router,
+      layoutState,
+    )
   }
   }
 
 
   /**
   /**
@@ -130,6 +138,6 @@ export const useMenu = () => {
     closeMenu,
     closeMenu,
     toggleMenu,
     toggleMenu,
     isMenuOpened,
     isMenuOpened,
-    isInternalLink
+    isInternalLink,
   }
   }
 }
 }

+ 14 - 9
composables/utils/useAdminUrl.ts

@@ -1,14 +1,19 @@
-import UrlUtils from "~/services/utils/urlUtils";
+import UrlUtils from '~/services/utils/urlUtils'
 
 
 export const useAdminUrl = () => {
 export const useAdminUrl = () => {
-    const runtimeConfig = useRuntimeConfig()
+  const runtimeConfig = useRuntimeConfig()
 
 
-    const makeAdminUrl = (tail: string, query: Record<string, string> = {}): string => {
-        const baseUrl = runtimeConfig.baseUrlAdminLegacy ?? runtimeConfig.public.baseUrlAdminLegacy
-        let url = UrlUtils.join(baseUrl, '#', tail)
-        url = UrlUtils.addQuery(url, query)
-        return url
-    }
+  const makeAdminUrl = (
+    tail: string,
+    query: Record<string, string> = {},
+  ): string => {
+    const baseUrl =
+      runtimeConfig.baseUrlAdminLegacy ??
+      runtimeConfig.public.baseUrlAdminLegacy
+    let url = UrlUtils.join(baseUrl, '#', tail)
+    url = UrlUtils.addQuery(url, query)
+    return url
+  }
 
 
-    return { makeAdminUrl }
+  return { makeAdminUrl }
 }
 }

+ 14 - 11
composables/utils/useDownloadFile.ts

@@ -1,19 +1,22 @@
-import {useAp2iRequestService} from "~/composables/data/useAp2iRequestService";
-import File from "~/models/Core/File"
-import FileSaver from "file-saver";
+import FileSaver from 'file-saver'
+import { useAp2iRequestService } from '~/composables/data/useAp2iRequestService'
+import File from '~/models/Core/File'
 
 
 export const useDownloadFile = async (file: File) => {
 export const useDownloadFile = async (file: File) => {
-    const { apiRequestService } = useAp2iRequestService()
+  const { apiRequestService } = useAp2iRequestService()
 
 
-    const downloadUrl = `api/download/${file.id}`
+  const downloadUrl = `api/download/${file.id}`
 
 
-    const response: any = await apiRequestService.get(downloadUrl)
+  const response = await apiRequestService.get(downloadUrl)
 
 
-    if (!response || response.size === 0) {
-        console.error('Error: file ' + file.id + ' not found')
-    }
+  const blobPart = await response.blob()
 
 
-    const blob = new Blob([response], { type: response.type })
+  if (!response || blobPart.size === 0) {
+    console.error('Error: file ' + file.id + ' not found')
+  }
 
 
-    FileSaver.saveAs(blob, file.name ?? 'unknown');
+  const blob = new Blob([blobPart], { type: response.type })
+
+  // eslint-disable-next-line import/no-named-as-default-member
+  FileSaver.saveAs(blob, file.name ?? 'unknown')
 }
 }

+ 9 - 0
composables/utils/useHomeUrl.ts

@@ -0,0 +1,9 @@
+import { useAdminUrl } from '~/composables/utils/useAdminUrl'
+
+export const useHomeUrl = () => {
+  const { makeAdminUrl } = useAdminUrl()
+
+  const homeUrl = makeAdminUrl('dashboard')
+
+  return { homeUrl }
+}

+ 10 - 10
composables/utils/useI18nUtils.ts

@@ -1,13 +1,13 @@
-import {useI18n} from "vue-i18n";
-import I18nUtils from "~/services/utils/i18nUtils";
+import { useI18n } from 'vue-i18n'
+import I18nUtils from '~/services/utils/i18nUtils'
 
 
-let i18nUtilsClass:null|I18nUtils = null
+let i18nUtilsClass: null | I18nUtils = null
 export const useI18nUtils = () => {
 export const useI18nUtils = () => {
-    //Avoid memory leak
-    if(i18nUtilsClass === null){
-        const i18n = useI18n()
-        //@ts-ignore
-        i18nUtilsClass = new I18nUtils(i18n)
-    }
-    return i18nUtilsClass
+  // Avoid memory leak
+  if (i18nUtilsClass === null) {
+    const i18n = useI18n()
+    // @ts-expect-error TODO: explain the error of conversion from useI18n result to VueI18n
+    i18nUtilsClass = new I18nUtils(i18n)
+  }
+  return i18nUtilsClass
 }
 }

+ 17 - 13
composables/utils/useRedirect.ts

@@ -1,21 +1,25 @@
-import UrlUtils from "~/services/utils/urlUtils";
+import UrlUtils from '~/services/utils/urlUtils'
 
 
 export const useRedirect = () => {
 export const useRedirect = () => {
-    const runtimeConfig = useRuntimeConfig()
+  const runtimeConfig = useRuntimeConfig()
 
 
-    const redirectToLogout = () => {
-        if (!runtimeConfig.baseUrlAdminLegacy) {
-            throw new Error('Configuration error : no redirection target')
-        }
-        navigateTo(UrlUtils.join(runtimeConfig.baseUrlAdminLegacy, '#/logout'), {external: true})
+  const redirectToLogout = () => {
+    if (!runtimeConfig.baseUrlAdminLegacy) {
+      throw new Error('Configuration error : no redirection target')
     }
     }
+    navigateTo(UrlUtils.join(runtimeConfig.baseUrlAdminLegacy, '#/logout'), {
+      external: true,
+    })
+  }
 
 
-    const redirectToHome = () => {
-        if (!runtimeConfig.baseUrlAdminLegacy) {
-            throw new Error('Configuration error : no redirection target')
-        }
-        navigateTo(UrlUtils.join(runtimeConfig.baseUrlAdminLegacy, '#/dashboard'), {external: true})
+  const redirectToHome = () => {
+    if (!runtimeConfig.baseUrlAdminLegacy) {
+      throw new Error('Configuration error : no redirection target')
     }
     }
+    navigateTo(UrlUtils.join(runtimeConfig.baseUrlAdminLegacy, '#/dashboard'), {
+      external: true,
+    })
+  }
 
 
-    return { redirectToLogout, redirectToHome }
+  return { redirectToLogout, redirectToHome }
 }
 }

+ 8 - 8
composables/utils/useValidationUtils.ts

@@ -1,10 +1,10 @@
-import ValidationUtils from "~/services/utils/validationUtils";
+import ValidationUtils from '~/services/utils/validationUtils'
 
 
-let validationUtilsClass:null|ValidationUtils = null
+let validationUtilsClass: null | ValidationUtils = null
 export const useValidationUtils = () => {
 export const useValidationUtils = () => {
-    //Avoid memory leak
-    if(validationUtilsClass === null){
-        validationUtilsClass = new ValidationUtils()
-    }
-    return validationUtilsClass
-}
+  // Avoid memory leak
+  if (validationUtilsClass === null) {
+    validationUtilsClass = new ValidationUtils()
+  }
+  return validationUtilsClass
+}

+ 2 - 3
config/abilities/config.yaml

@@ -1,3 +1,2 @@
-abilities:
-  !!import/shallow
-    - pages/
+abilities: !!import/shallow
+  - pages/

+ 52 - 31
config/abilities/pages/addressBook.yaml

@@ -1,36 +1,57 @@
-  accesses_page:
-    action: 'display'
-    conditions:
-      - {function: organizationHasAnyModule, parameters: ['Users']}
-      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'users'}]}
+accesses_page:
+  action: 'display'
+  conditions:
+    - { function: organizationHasAnyModule, parameters: ['Users'] }
+    - {
+        function: accessHasAnyRoleAbility,
+        parameters: [{ action: 'read', subject: 'users' }],
+      }
 
 
-  student_registration_page:
-    action: 'display'
-    conditions:
-      - {function: organizationHasAnyModule, parameters: ['UsersSchool']}
-      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'student-registration'}]}
+student_registration_page:
+  action: 'display'
+  conditions:
+    - { function: organizationHasAnyModule, parameters: ['UsersSchool'] }
+    - {
+        function: accessHasAnyRoleAbility,
+        parameters: [{ action: 'read', subject: 'student-registration' }],
+      }
 
 
-  education_student_next_year_page:
-    action: 'display'
-    conditions:
-      - {function: organizationHasAnyModule, parameters: ['PedagogicsAdministation']}
-      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'educationstudent'}]}
+education_student_next_year_page:
+  action: 'display'
+  conditions:
+    - {
+        function: organizationHasAnyModule,
+        parameters: ['PedagogicsAdministation'],
+      }
+    - {
+        function: accessHasAnyRoleAbility,
+        parameters: [{ action: 'read', subject: 'educationstudent' }],
+      }
 
 
-  commissions_page:
-    action: 'display'
-    conditions:
-      - {function: organizationHasAnyModule, parameters: ['Users']}
-      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'commissions'}]}
+commissions_page:
+  action: 'display'
+  conditions:
+    - { function: organizationHasAnyModule, parameters: ['Users'] }
+    - {
+        function: accessHasAnyRoleAbility,
+        parameters: [{ action: 'read', subject: 'commissions' }],
+      }
 
 
-  network_children_page:
-    action: 'display'
-    conditions:
-      - {function: organizationHasAnyModule, parameters: ['Network']}
-      - {function: organizationHasChildren}
-      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'network'}]}
+network_children_page:
+  action: 'display'
+  conditions:
+    - { function: organizationHasAnyModule, parameters: ['Network'] }
+    - { function: organizationHasChildren }
+    - {
+        function: accessHasAnyRoleAbility,
+        parameters: [{ action: 'read', subject: 'network' }],
+      }
 
 
-  network_parents_page:
-    action: 'display'
-    conditions:
-      - {function: organizationHasAnyModule, parameters: ['NetworkOrganization']}
-      - {function: organizationHasChildren, expectedResult: false}
+network_parents_page:
+  action: 'display'
+  conditions:
+    - {
+        function: organizationHasAnyModule,
+        parameters: ['NetworkOrganization'],
+      }
+    - { function: organizationHasChildren, expectedResult: false }

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff