Преглед на файлове

Merge branch 'release/2.4.1'

Olivier Massot преди 1 година
родител
ревизия
a7218da216
променени са 100 файла, в които са добавени 4625 реда и са изтрити 2780 реда
  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,
   env: {
     browser: true,
-    node: true
+    node: true,
   },
-  parser: "vue-eslint-parser",
+  parser: 'vue-eslint-parser',
   parserOptions: {
-    "ecmaVersion": 2020,
-    "parser": "@typescript-eslint/parser",
-    "sourceType": "module"
+    ecmaVersion: 2020,
+    parser: '@typescript-eslint/parser',
+    sourceType: 'module',
+    tsconfigRootDir: __dirname,
   },
   extends: [
     '@nuxtjs/eslint-config-typescript',
     'plugin:nuxt/recommended',
-    "eslint:recommended",
-    "plugin:@typescript-eslint/recommended",
+    'eslint:recommended',
+    'plugin:@typescript-eslint/recommended',
     'plugin:vue/vue3-recommended',
     'plugin:prettier/recommended',
-
-  ],
-  plugins: [
-    "vue",
-    "@typescript-eslint"
   ],
+  ignorePatterns: ['.nuxt', 'coverage/*', 'vendor/*', 'dist/*'],
+  plugins: ['vue', '@typescript-eslint'],
   // add your custom rules here
   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
 .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
-
 yarn.lock
-
 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:
   - test
+  - qa
 
 variables:
   APP_ENV: ci
 
 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:
   paths:
     - ./node_modules
+    - .yarn
+
+code_quality:
+  stage: qa
+  script:
+    - yarn eslint
+
+code_style:
+  stage: qa
+  script:
+    - yarn prettier . --check
 
 unit:
   stage: test
 
   script:
-    - yarn install
     - yarn test
 
   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,
   "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)
 
-| 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)   |
 | 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
 
 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)
 
@@ -28,37 +26,25 @@ Cloner le projet :
 
     git clone git@gitlab.2iopenservice.com:opentalent/app.git
 
-
 Installer les dépendances :
 
     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 :
 
     yarn dev -o
 
-
 ## Déploiement en prod
 
 ### 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 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 :
 
     yarn deploy
@@ -81,7 +67,6 @@ Attention, sur les environnements de test, il faut utiliser nvm pour exécuter l
 
     nvm exec yarn install
 
-
 ## Autres
 
 ### 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
 > 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
 
 ## Structure du projet
 
 | Répertoire     | Rôle                                                                                                |
-|----------------|-----------------------------------------------------------------------------------------------------|
+| -------------- | --------------------------------------------------------------------------------------------------- |
 | `assets`       | Contient les fichiers style et medias                                                               |
 | `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  |
@@ -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 |
 | `tests`        | Regroupe les tests (unitaires, end-to-end...)                                                       |
 | `types`        | Types Typescript (interfaces, enums...)                                                             |
-

Файловите разлики са ограничени, защото са твърде много
+ 12 - 0
assets/css/global.scss


+ 1 - 1
assets/css/settings.scss

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

+ 0 - 1
assets/css/theme.scss

@@ -1,4 +1,3 @@
-
 .theme-primary {
   background-color: rgb(var(--v-theme-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>
 
 <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 alerts: ComputedRef<Array<Alert>> = computed(() => {
   return pageStore.alerts
 })
-
 </script>
 
 <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>

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

@@ -13,20 +13,20 @@
     @mouseout="onMouseOut"
   >
     <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) }}
       </li>
     </ul>
     <span v-else>
-        {{ $t(props.alert.messages[0]) }}
+      {{ $t(props.alert.messages[0]) }}
     </span>
   </v-alert>
 </template>
 
 <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({
   /**
@@ -34,7 +34,7 @@ const props = defineProps({
    */
   alert: {
     type: Object as () => Alert,
-    required: true
+    required: true,
   },
   /**
    * The time after which the alert disappears
@@ -42,8 +42,8 @@ const props = defineProps({
   timeout: {
     type: Number,
     required: false,
-    default: 3000
-  }
+    default: 3000,
+  },
 })
 
 const show: Ref<boolean> = ref(true)
@@ -55,7 +55,7 @@ const pageStore = usePageStore()
  * Retire l'alerte après `time` (en ms)
  * @param time
  */
-const clearAlert = (time: number = 2000) => {
+const clearAlert = (time: number = 4000) => {
   timeout = setTimeout(() => {
     show.value = false
     pageStore.removeSlowlyAlert()
@@ -77,8 +77,6 @@ const onMouseOut = () => {
 }
 
 clearAlert()
-
 </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 />
 
     <client-only>
-      <LayoutAlertBarCotisation v-if="organizationProfile.isCmf && ability.can('manage', 'cotisation')" />
+      <LayoutAlertBarCotisation
+        v-if="organizationProfile.isCmf && ability.can('manage', 'cotisation')"
+      />
     </client-only>
 
     <LayoutAlertBarSwitchYear />
     <LayoutAlertBarSuperAdmin />
-    <LayoutAlertBarRegistrationStatus v-if="organizationProfile.hasModule('IEL')" />
+    <LayoutAlertBarRegistrationStatus
+      v-if="organizationProfile.hasModule('IEL')"
+    />
   </main>
 </template>
 
 <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>

+ 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>
   <main>
     <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>
 </template>
 
 <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()
 
@@ -38,7 +38,12 @@ const goToCotisation = () => {
   if (!organizationProfile.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) {
     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
  */
 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
  */
 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
@@ -71,7 +85,10 @@ if (!organizationProfile.id) {
 }
 
 const { fetch } = useEntityFetch()
-const { data: cotisation, pending } = await fetch(Cotisation, organizationProfile.id)
+const { data: cotisation, pending } = await fetch(
+  Cotisation,
+  organizationProfile.id,
+)
 
 interface Alert {
   text: string
@@ -86,10 +103,13 @@ const alert: ComputedRef<Alert | null> = computed(() => {
   cotisationYear.value = cotisation.value.cotisationYear
 
   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) {
@@ -98,11 +118,10 @@ const alert: ComputedRef<Alert | null> = computed(() => {
 
   return mapping[cotisation.value.alertState as ALERT_STATE_COTISATION]
 })
-
 </script>
 
 <style scoped lang="scss">
-  :deep(.clickable:hover) {
-    text-decoration: none !important;
-  }
+:deep(.clickable:hover) {
+  text-decoration: none !important;
+}
 </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>
   <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>
 
 <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>

+ 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>
   <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>
 
 <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 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(() => {
-  return !pending && (registrationAvailability.value as RegistrationAvailability).available
+  return (
+    !pending &&
+    (registrationAvailability.value as RegistrationAvailability).available
+  )
 })
 
 const message: ComputedRef<string> = computed(() => {
   return (registrationAvailability.value as RegistrationAvailability).message
 })
-
-
 </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>
   <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>
 
 <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 accessProfile = useAccessProfileStore()
 
-const { data: registrationStatus, pending } = fetch(RegistrationStatus, accessProfile.id ?? 0)
+const { data: registrationStatus, pending } = fetch(
+  RegistrationStatus,
+  accessProfile.id ?? 0,
+)
 
 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(() => {
@@ -40,6 +43,4 @@ const message: ComputedRef<string> = computed(() => {
 })
 </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>
   <!-- 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>
     <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>
 </template>
 
 <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>
 
 <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>
   <UiSystemBar v-if="show" class="theme-info">
     <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>
 </template>
 
 <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>
 
 <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>
   <!-- TODO : fonctionnement à valider -->
   <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">
-        {{$t('not_current_year_reset')}}
+        {{ $t('not_current_year_reset') }}
       </strong>
     </a>
   </UiSystemBar>
 </template>
 
 <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>
 
 <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>
 
 <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>

+ 3 - 3
components/Layout/Container.vue

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

+ 51 - 35
components/Layout/Dialog.vue

@@ -1,13 +1,18 @@
 <!-- Fenêtre de dialogue -->
 <template>
   <v-dialog
-    :model-value="show"
-    persistent
+    :model-value="_show"
+    :persistent="true"
     :max-width="maxWidth"
     :content-class="contentClass"
   >
     <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">
           <slot name="dialogType" />
         </h3>
@@ -36,12 +41,12 @@
 <script setup lang="ts">
 const props = defineProps({
   show: {
-    type: Boolean,
-    required: true
+    type: [Boolean, Object],
+    required: true,
   },
   contentClass: {
     type: String,
-    required: false
+    required: false,
   },
   theme: {
     type: String,
@@ -51,46 +56,57 @@ const props = defineProps({
   maxWidth: {
     type: [Number, String],
     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>
 
 <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;
+    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>

+ 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>
-  <v-app-bar
-      order="0"
-      density="compact"
-      class="theme-primary"
-  >
+  <v-app-bar order="0" density="compact" class="theme-primary">
     <template #prepend>
       <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>
 
-    <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" />
 
@@ -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" />
 
     <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>
-      <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>
   </v-app-bar>
 </template>
 
 <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 runtimeConfig = useRuntimeConfig()
-const title: ComputedRef<string> = computed(() => organizationProfile.name ?? 'Opentalent')
+const title: ComputedRef<string> = computed(
+  () => organizationProfile.name ?? 'Opentalent',
+)
 
 const { hasMenu, isMenuOpened, toggleMenu } = useMenu()
 
@@ -68,25 +83,30 @@ const hasMainMenu = computed(() => hasMenu('Main'))
 const isMainMenuOpened = computed(() => isMenuOpened('Main'))
 const toggleMainMenu = () => toggleMenu('Main')
 
+const hasParametersMenu = computed(() => hasMenu('Parameters'))
+const isParametersMenuOpened = computed(() => isMenuOpened('Parameters'))
+const toggleParametersMenu = () => toggleMenu('Parameters')
+
 const ability = useAbility()
 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>
 
 <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>

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

@@ -1,29 +1,24 @@
 <template>
   <div>
     <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" />
   </div>
 </template>
 
 <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>
 
-<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>
   <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-if="menu.icon.avatarId || menu.icon.avatarByDefault"
-          size="30"
+        v-if="menu.icon.avatarId || menu.icon.avatarByDefault"
+        size="30"
       >
         <UiImage
-            :id="menu.icon.avatarId"
-            :defaultImage="menu.icon.avatarByDefault"
-            :width="30"
+          :imageId="menu.icon.avatarId"
+          :defaultImage="menu.icon.avatarByDefault"
+          :width="30"
         />
       </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-tooltip
-        :activator="btn"
-        :text="$t(menu.label)"
-        location="bottom"
-    />
+    <v-tooltip :activator="btn" :text="$t(menu.label)" location="bottom" />
 
     <v-menu
-        :activator="btn"
-        :model-value="isOpened()"
-        @update:modelValue="onStateUpdated"
+      :activator="btn"
+      :model-value="isOpened()"
+      @update:modelValue="onStateUpdated"
     >
       <v-card>
         <v-card-title class="theme-neutral text-body-2 font-weight-bold">
-          {{$t(menu.label)}}
+          {{ $t(menu.label) }}
         </v-card-title>
 
         <v-card-text class="ma-0 pa-0 header-menu">
           <v-list density="compact" :subheader="true">
             <template v-for="(child, index) in menu.children" :key="index">
               <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">
-                  <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-icon v-else class="on-primary" size="small">
                     {{ child.icon.name }}
                   </v-icon>
                 </span>
 
-                <span>{{ translateLabel ? $t(child.label) : child.label }}</span>
+                <span>{{
+                  translateLabel ? $t(child.label) : child.label
+                }}</span>
               </v-list-item>
-
             </template>
           </v-list>
         </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">
             <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>
           </template>
         </v-card-actions>
@@ -87,22 +87,23 @@ header principal (configuration, paramètres du compte...)
 </template>
 
 <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({
   name: {
     type: String,
-    required: true
+    required: true,
   },
   translateLabel: {
     type: Boolean,
     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 displayMenu = computed(() => hasMenu(props.name))
@@ -113,23 +114,22 @@ const onStateUpdated = (e: any) => {
 }
 
 const btn = ref(null)
-
 </script>
 
 <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>

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

@@ -1,19 +1,13 @@
 <template>
-  <v-btn
-      ref="btn"
-      icon
-      size="small"
-      class="ml-2"
-  >
+  <v-btn ref="btn" icon size="small" class="ml-2">
     <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-btn>
 
@@ -22,10 +16,10 @@
   </v-tooltip>
 
   <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-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-list density="compact" :subheader="true" class="pa-0">
           <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>
 
             <template #append>
               <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>
-
           </v-list-item>
 
           <v-divider></v-divider>
@@ -58,19 +54,14 @@
           <span v-intersect="onLastNotificationIntersect" />
 
           <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-list>
-
       </v-card-text>
 
       <v-card-actions class="ma-0 pa-0">
@@ -79,12 +70,11 @@
           :href="notificationUrl"
           router
           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-card-actions>
     </v-card>
@@ -92,23 +82,26 @@
 </template>
 
 <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 loading: Ref<Boolean> = ref(true)
-const isOpen: Ref<Boolean> = ref(false)
+const isOpen: Ref<boolean> = ref(false)
 const page: Ref<number> = ref(1)
+const itemsPerPage: Ref<number> = ref(5)
 
 const i18n = useI18n()
 const runtimeConfig = useRuntimeConfig()
@@ -119,14 +112,16 @@ const { em } = useEntityManager()
 const { fetchCollection } = useEntityFetch()
 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(() => {
   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é
  */
 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
@@ -164,10 +162,10 @@ const onLastNotificationIntersect = (isIntersecting: boolean) => {
  */
 const update = async () => {
   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
     page.value = pagination.value.next
@@ -184,20 +182,26 @@ const update = async () => {
  * @param 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:
       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)
         return `${i18n.t(notification.message.about)}`
-      break;
+      break
 
     default:
       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.
  */
-const unwatch = watch(isOpen, (newValue, oldValue) => {
-  if (!newValue){
+const unwatch = watch(isOpen, (newValue, _) => {
+  if (!newValue) {
     markNotificationsAsRead()
   }
 })
@@ -224,9 +228,9 @@ const markNotificationAsRead = (notification: Notification) => {
     throw new Error('Current access id is null')
   }
   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)
@@ -239,7 +243,7 @@ const markNotificationAsRead = (notification: Notification) => {
  */
 const markNotificationsAsRead = () => {
   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
 
-  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
   // Sans switch : https://local.api.opentalent.fr/api/{accessId}/files/{fileId}/download
   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>
 
 <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>

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

@@ -12,22 +12,22 @@
   <v-card
     class="col-md-6"
     color=""
-    flat
+    :flat="true"
     border="solid 1px"
     @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-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
-          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>
         <p class="text-neutral-strong">
@@ -39,100 +39,100 @@
 </template>
 
 <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>
 
 <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>

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

@@ -5,56 +5,74 @@
 <template>
   <main>
     <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-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>
     </v-btn>
 
-    <LayoutDialog :show="showCreateDialog" :max-width="850" >
+    <LayoutDialog :show="showCreateDialog" :max-width="850">
       <template #dialogType>{{ $t('creative_assistant') }}</template>
 
       <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 #dialogText>
-         <LayoutHeaderUniversalCreationGenerateCardsSteps
-             :path="path"
-             @cardClick="onCardClick"
-             @urlUpdate="onUrlUpdate"
-         />
+        <LayoutHeaderUniversalCreationGenerateCardsSteps
+          :path="path"
+          @cardClick="onCardClick"
+          @urlUpdate="onUrlUpdate"
+        />
       </template>
 
       <template #dialogBtn>
         <div class="text-center">
-          <v-btn class="theme-neutral-soft" @click="close" >
+          <v-btn class="theme-neutral-soft" @click="close">
             {{ $t('cancel') }}
           </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') }}
           </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') }}
           </v-btn>
         </div>
@@ -64,121 +82,120 @@
 </template>
 
 <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>
 
 <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>

+ 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-col cols="2" class="pt-1"></v-col>
       <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-row>
 
@@ -36,7 +39,6 @@ Event parameters page in the create dialog
         <UiInputNumber v-model="eventDurationMinutes" class="mx-3" :min="0" />
         <span>{{ $t('minute(s)') }}</span>
       </v-col>
-
     </v-row>
 
     <v-row>
@@ -52,68 +54,77 @@ Event parameters page in the create dialog
 </template>
 
 <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>
 
 <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>

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

@@ -3,57 +3,67 @@
 -->
 
 <template>
-
   <!-- Menu Accueil -->
   <v-container v-if="location === 'home'">
     <v-row>
-
       <!-- Une personne -->
       <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>
 
       <!-- 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
-            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>
 
       <!-- 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
-            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>
 
       <!-- 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
           to="message"
           title="a_correspondence"
@@ -83,99 +93,99 @@
       <!-- Un adhérent -->
       <v-col cols="6" v-if="isLaw1901">
         <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>
 
       <!-- Un membre du CA -->
       <v-col cols="6" v-if="isLaw1901">
         <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>
 
       <!-- Un élève -->
       <v-col cols="6">
         <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>
 
       <!-- Un tuteur -->
       <v-col cols="6">
         <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>
 
       <!-- Un professeur -->
       <v-col cols="6">
         <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>
 
       <!-- Un membre du personnel -->
       <v-col cols="6">
         <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>
 
       <!-- Une entité légale -->
       <v-col cols="6">
         <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>
 
       <!-- Une inscription en ligne -->
       <v-col cols="6" v-if="hasOnlineRegistrationModule">
         <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>
 
       <!-- Un autre type de contact -->
       <v-col cols="6">
         <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-row>
@@ -187,48 +197,48 @@
       <!-- Un cours -->
       <v-col cols="6" v-if="ability.can('display', 'course_page')">
         <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>
 
       <!-- Un examen -->
       <v-col cols="6" v-if="ability.can('display', 'exam_page')">
         <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>
 
       <!-- Un projet pédagogique -->
       <v-col cols="6" v-if="ability.can('display', 'pedagogics_project_page')">
         <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>
 
       <!-- Un autre évènement -->
       <v-col cols="6" v-if="ability.can('manage', 'events')">
         <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-row>
@@ -240,33 +250,33 @@
       <!-- Un email -->
       <v-col cols="6" v-if="ability.can('manage', 'emails')">
         <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>
 
       <!-- Un courrier -->
       <v-col cols="6" v-if="ability.can('manage', 'mails')">
         <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>
 
       <!-- Un SMS -->
       <v-col cols="6" v-if="ability.can('manage', 'texto')">
         <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-row>
@@ -274,75 +284,76 @@
 
   <!-- Page de pré-paramétrage des évènements -->
   <LayoutHeaderUniversalCreationEventParams
-      v-if="location === 'event-params'"
-      @params-updated="onEventParamsUpdated"
+    v-if="location === 'event-params'"
+    @params-updated="onEventParamsUpdated"
   />
 </template>
 
 <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>

+ 15 - 18
components/Layout/LoadingScreen.vue

@@ -2,31 +2,28 @@
 
 <template>
   <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>
 </template>
 
 <script setup lang="ts">
-  import {usePageStore} from "~/stores/page";
+import { usePageStore } from '~/stores/page'
 
-  const pageStore = usePageStore()
+const pageStore = usePageStore()
 </script>
 
 <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>

+ 74 - 76
components/Layout/MainMenu.vue

@@ -5,23 +5,18 @@ Prend en paramètre une liste de ItemMenu et les met en forme
 
 <template>
   <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>
       <slot name="title"></slot>
     </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? -->
       <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) -->
         <v-list-item
           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 }">
             <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>
 
@@ -69,16 +64,16 @@ Prend en paramètre une liste de ItemMenu et les met en forme
       <slot name="foot"></slot>
     </template>
   </v-navigation-drawer>
-
 </template>
 
 <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 { 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()
 
@@ -103,11 +98,16 @@ const displayMenu = computed(() => {
 // En vue md+, fermer le menu le passe simplement en mode rail
 // Sinon, le fermer le masque complètement
 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) => {
-// 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) {
     setMenuState('Main', lgAndUp.value)
   }
@@ -119,69 +119,67 @@ onUnmounted(() => {
 </script>
 
 <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>

+ 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>
   <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>
 
     <UiXeditableText
@@ -11,14 +10,17 @@
       :data="currentActivityYear"
       @update="setActivityYear"
     >
-      <template #xeditable.read="{inputValue}">
+      <template #xeditable.read="{ inputValue }">
         <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 }}
-            <span v-if="yearPlusOne">
-              / {{ parseInt(inputValue) + 1 }}
-            </span>
+            <span v-if="yearPlusOne"> / {{ parseInt(inputValue) + 1 }} </span>
           </strong>
         </div>
       </template>
@@ -27,13 +29,14 @@
 </template>
 
 <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 accessProfileStore = useAccessProfileStore()
@@ -41,10 +44,17 @@ const organizationProfileStore = useOrganizationProfileStore()
 const formStore = useFormStore()
 const pageStore = usePageStore()
 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 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
@@ -56,16 +66,15 @@ const setActivityYear = async (event: string) => {
   if (!(1900 < activityYear) || !(activityYear <= 2100)) {
     throw new Error("Error: 'year' shall be a valid year")
   }
-  if (accessProfileStore.id === null) {
-    throw new Error("Error: invalid access id")
-  }
   formStore.setDirty(false)
 
   pageStore.loading = true
-  await em.patch(Access, accessProfileStore.currentAccessId, { activityYear: activityYear })
+  await em.patch(Access, accessProfileStore.currentAccessId, {
+    activityYear: activityYear,
+  })
   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()
@@ -73,21 +82,21 @@ const setActivityYear = async (event: string) => {
 </script>
 
 <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>

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

@@ -1,25 +1,24 @@
 <template>
-  <v-breadcrumbs
-    :items="items"
-  />
+  <v-breadcrumbs :items="items" />
 </template>
 
 <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 i18n = useI18n()
 const router = useRouter()
 
 const items: ComputedRef<Array<AnyJson>> = computed(() => {
-  const crumbs:Array<AnyJson> = []
+  const crumbs: Array<AnyJson> = []
 
   crumbs.push({
     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)
@@ -29,13 +28,16 @@ const items: ComputedRef<Array<AnyJson>> = computed(() => {
   pathPart.forEach((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({
-        title: !parseInt(part, 10) ? i18n.t(part + '_breadcrumbs') : i18n.t('item'),
+        title: !parseInt(part, 10)
+          ? i18n.t(part + '_breadcrumbs')
+          : i18n.t('item'),
         exact: true,
-        to: path
+        to: path,
       })
     }
   })

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

@@ -1,6 +1,8 @@
 <template>
   <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
       ref="toggle"
@@ -15,10 +17,15 @@
       @update:modelValue="onUpdate"
     >
       <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
          qu'il est, le component ne fonctionne pas comme attendu. A revoir quand vuetify 3 sera plus stable -->
@@ -29,13 +36,14 @@
 </template>
 
 <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
 const color = useTheme().current.value.colors['primary']
@@ -45,52 +53,56 @@ const accessProfileStore = useAccessProfileStore()
 const { em } = useEntityManager()
 const { mdAndUp } = useDisplay()
 const pageStore = usePageStore()
+const { refreshProfile } = useRefreshProfile()
 
 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>) => {
   historicalValue.value = newValue
 
-  const accessId = accessProfileStore.switchId ?? accessProfileStore.id
-
-  if (accessId === null) {
-    throw new Error('Invalid profile id')
-  }
+  const accessId = accessProfileStore.currentAccessId
 
   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)
   pageStore.loading = true
 
-  await em.patch(Access, accessId, {'historical': accessProfileStore.historical})
+  await em.patch(Access, accessId, {
+    historical: accessProfileStore.historical,
+  })
   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()
 }
-
 </script>
 
 <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>

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

@@ -6,47 +6,50 @@
       </span>
 
       <UiDateRangePicker
-          :model-value="datesRange"
-          :max-height="28"
-          @update:model-value="updateDateTimeRange"
+        :model-value="datesRange"
+        :max-height="28"
+        @update:model-value="updateDateTimeRange"
       />
     </div>
   </main>
 </template>
 
 <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 accessProfileStore = useAccessProfileStore()
 const { em } = useEntityManager()
 const pageStore = usePageStore()
+const { refreshProfile } = useRefreshProfile()
 
 const start = accessProfileStore.historical.dateStart
 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 accessId = accessProfileStore.switchId ?? accessProfileStore.id
-
-  if (accessId === null) {
-    throw new Error('Invalid profile id')
-  }
+  const accessId = accessProfileStore.currentAccessId
 
   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(
-        DateUtils.formatIsoShortDate(datesRange.value[0]),
-        DateUtils.formatIsoShortDate(datesRange.value[1])
+      DateUtils.formatIsoShortDate(datesRange.value[0]),
+      DateUtils.formatIsoShortDate(datesRange.value[1]),
     )
   } else {
     accessProfileStore.setHistorical(false, true, false)
@@ -54,10 +57,12 @@ const updateDateTimeRange = async (dates: Array<Date>): Promise<any> => {
   setDirty(false)
   pageStore.loading = true
 
-  await em.patch(Access, accessId, {'historical': accessProfileStore.historical})
+  await em.patch(Access, accessId, {
+    historical: accessProfileStore.historical,
+  })
   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()

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

@@ -5,12 +5,17 @@
     </a>
 
     <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">
           {{ $t('nothing_to_show') }}
         </v-card-text>
@@ -19,11 +24,11 @@
       <v-card v-else width="440">
         <v-card-title class="text-body-2 header-personalized">
           <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>
 
@@ -36,51 +41,53 @@
               :href="getListURL(item)"
               exact
             >
-              <strong>{{item.menuKey}}</strong> - {{item.label}}
+              <strong>{{ item.menuKey }}</strong> - {{ item.label }}
             </v-list-item>
           </v-list>
         </v-card-text>
       </v-card>
-
     </v-menu>
   </main>
 </template>
 
 <script setup lang="ts">
 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 { fetch, fetchCollection } = useEntityFetch()
+const { fetchCollection } = useEntityFetch()
 
-const { data: collection, pending } = await fetchCollection(PersonalizedList)
+const { data: collection, pending } = fetchCollection(PersonalizedList)
 
 const i18n = useI18n()
 
 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
   })
 
   return lists
 })
 
-const search = ref('');
+const search = ref('')
 
 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()
@@ -92,16 +99,16 @@ const getListURL = (list: PersonalizedList) => {
 </script>
 
 <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>

+ 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
       id="subheader"
       class="d-flex theme-neutral text-body-2 px-2"
-      flat
+      :flat="true"
       rounded="0"
     >
       <LayoutSubHeaderBreadcrumbs v-if="lgAndUp" class="mr-auto d-flex" />
 
       <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
-              v-if="smAndUp && !showDateTimeRange"
-              class="data-timing ml-2"
+            v-if="smAndUp && !showDateTimeRange"
+            class="data-timing ml-2"
           />
 
           <LayoutSubHeaderDataTimingRange
-              v-if="smAndUp && showDateTimeRange"
-              class="data-timing-range ml-n1"
+            v-if="smAndUp && showDateTimeRange"
+            class="data-timing-range ml-n1"
           />
 
           <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-tooltip location="bottom" :activator="btn">
-              <span>{{ $t('history_help') }}</span>
+            <span>{{ $t('history_help') }}</span>
           </v-tooltip>
 
-          <LayoutSubHeaderPersonnalizedList class="personalized-list ml-2 d-flex align-center" />
+          <LayoutSubHeaderPersonnalizedList
+            class="personalized-list ml-2 d-flex align-center"
+          />
         </div>
       </v-card>
     </v-card>
@@ -56,28 +68,30 @@ Contient entre autres le breadcrumb, les commandes de changement d'année et les
 </template>
 
 <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>
 
 <style scoped lang="scss">
-
 main {
   font-size: 12px;
 }

+ 14 - 14
components/Layout/ThemeSwitcher.vue

@@ -1,26 +1,26 @@
 <template>
   <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>
 
 <script setup lang="ts">
-import {useTheme} from "vuetify";
+import { useTheme } from 'vuetify'
 
 const theme = useTheme()
 </script>
 
 <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>

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

@@ -4,15 +4,13 @@ Bouton Delete avec modale de confirmation de la suppression
 
 <template>
   <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>
 
-    <LazyLayoutDialog
-      :show="showDialog"
-    >
+    <LazyLayoutDialog :show="showDialog">
       <template #dialogType>{{ $t('delete_assistant') }}</template>
-      <template #dialogTitle>{{ $t('attention') }}</template>
+      <template #dialogTitle>{{ $t('caution') }}</template>
       <template #dialogText>
         <v-card-text>
           <p>{{ $t('confirm_to_delete') }}</p>
@@ -31,21 +29,27 @@ Bouton Delete avec modale de confirmation de la suppression
 </template>
 
 <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({
-    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)
@@ -54,18 +58,22 @@ const { em } = useEntityManager()
 
 const deleteItem = async () => {
   try {
+    //@ts-ignore
     await em.delete(props.model, props.entity)
-    usePageStore().addAlerts(TYPE_ALERT.SUCCESS, ['deleteSuccess'])
+    usePageStore().addAlert(TYPE_ALERT.SUCCESS, ['deleteSuccess'])
   } catch (error: any) {
-    usePageStore().addAlerts(TYPE_ALERT.ALERT, [error.message])
+    usePageStore().addAlert(TYPE_ALERT.ALERT, [error.message])
+    throw error
   }
   showDialog.value = false
 }
 
-const alertDeleteItem = () => { showDialog.value = true }
-const closeDialog = () => { showDialog.value = false }
-
+const alertDeleteItem = () => {
+  showDialog.value = true
+}
+const closeDialog = () => {
+  showDialog.value = false
+}
 </script>
 
-<style scoped>
-</style>
+<style scoped></style>

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

@@ -1,6 +1,11 @@
 <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) }}
 
     <v-divider class="ml-3" :vertical="true" v-if="hasOtherActions"></v-divider>
@@ -16,13 +21,13 @@
       <template #activator="{ on, attrs }">
         <v-toolbar-title v-on="on">
           <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-toolbar-title>
       </template>
-      <v-list
-        :min-width="menuSize"
-      >
+      <v-list :min-width="menuSize">
         <v-list-item
           dense
           v-for="(action, index) in actions"
@@ -30,7 +35,10 @@
           class="subAction"
           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>
     </v-menu>
@@ -38,24 +46,30 @@
 </template>
 
 <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({
-    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 mainBtn: Ref = ref(null)
-const menuSize = computed(()=>{
+const menuSize = computed(() => {
   // Btn size + 40px de padding
   return mainBtn.value?.$el.clientWidth + 40
 })
@@ -64,21 +78,20 @@ const submitAction = (action: string) => {
   emit('submit', action)
 }
 
-const mainAction: ComputedRef<string> = computed(()=>{
+const mainAction: ComputedRef<string> = computed(() => {
   return props.actions[0] as string
 })
 
-const hasOtherActions: ComputedRef<boolean> = computed(()=>{
+const hasOtherActions: ComputedRef<boolean> = computed(() => {
   return props.actions.length > 1
 })
-
 </script>
 
 <style scoped>
-.v-list-item--dense{
+.v-list-item--dense {
   min-height: 25px;
 }
-.subAction{
+.subAction {
   cursor: pointer;
 }
 </style>

+ 8 - 16
components/Ui/Card.vue

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

+ 12 - 16
components/Ui/Collection.vue

@@ -4,15 +4,14 @@
   <main>
     <v-skeleton-loader v-if="pending" :type="loaderType" />
     <div v-else>
-
       <!-- Content -->
-      <slot name="list.item" v-bind="{items}" />
+      <slot name="list.item" v-bind="{ collection.items }" />
 
       <!-- New button -->
       <v-btn v-if="newLink" class="theme-primary float-right">
         <NuxtLink :to="newLink" class="no-decoration">
           <v-icon>fa-plus-circle</v-icon>
-          <span>{{$t('add')}}</span>
+          <span>{{ $t('add') }}</span>
         </NuxtLink>
       </v-btn>
     </div>
@@ -21,37 +20,34 @@
 </template>
 
 <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({
   model: {
     type: Object,
-    required: true
+    required: true,
   },
   parent: {
     type: Object,
-    required: false
+    required: false,
   },
   loaderType: {
     type: String,
     required: false,
-    default: 'text'
+    default: 'text',
   },
   newLink: {
     type: String,
-    required: false
-  }
+    required: false,
+  },
 })
 
 const { model, parent }: ToRefs = toRefs(props)
 
 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>

+ 17 - 28
components/Ui/DataTable.vue

@@ -5,10 +5,7 @@ Tableau interactif conçu pour l'affichage d'une collection d'entités
 -->
 
 <template>
-  <v-col
-    cols="12"
-    sm="12"
-  >
+  <v-col cols="12" sm="12">
     <v-data-table
       :headers="headersWithItem"
       :items="collection.items"
@@ -23,50 +20,39 @@ Tableau interactif conçu pour l'affichage d'une collection d'entités
       </template>
 
       <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>
     </v-data-table>
   </v-col>
 </template>
 
 <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({
   parent: {
     type: Object,
-    required: true
+    required: true,
   },
   model: {
     type: Object,
-    required: true
+    required: true,
   },
   headers: {
     type: Array,
-    required: true
-  }
+    required: true,
+  },
 })
 
 const { parent, model, headers } = toRefs(props)
 
 const headersWithItem = computed(() => {
-  return headers.value.map((header:any) => {
+  return headers.value.map((header: any) => {
     header.item = 'item.' + header.value
     return header
   })
@@ -77,7 +63,10 @@ const entries: Ref<Array<AnyJson>> = ref(Array<AnyJson>())
 
 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)
 

+ 34 - 26
components/Ui/DatePicker.vue

@@ -1,61 +1,72 @@
 <!--
 Sélecteur de dates
 
-@see https://vuetifyjs.com/en/components/date-pickers/
+@see https://vue3datepicker.com/
 -->
 
 <template>
   <main>
-    <!-- @see https://vue3datepicker.com/props/modes/#multi-calendars -->
     <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>
 </template>
 
 <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 fnsLocale = DateUtils.getFnsLocale(i18n.locale.value as supportedLocales)
-const defaultFormatPattern = DateUtils.getFormatPattern(i18n.locale.value as supportedLocales)
 
 const props = defineProps({
   modelValue: {
     type: Object as PropType<Date>,
     required: false,
-    default: null
+    default: null,
   },
   readonly: {
     type: Boolean,
     required: false,
-    default: false
+    default: false,
   },
   format: {
     type: String,
     required: false,
-    default: null
+    default: null,
   },
   withTime: {
     type: Boolean,
     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 emit = defineEmits(['update:model-value'])
@@ -63,9 +74,6 @@ const emit = defineEmits(['update:model-value'])
 const onUpdate = (event: Date) => {
   emit('update:model-value', event)
 }
-
 </script>
 
-<style scoped>
-
-</style>
+<style scoped></style>

+ 69 - 66
components/Ui/DateRangePicker.vue

@@ -1,43 +1,43 @@
 <template>
   <!-- @see https://vue3datepicker.com/props/modes/#multi-calendars -->
   <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>
 
 <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({
   modelValue: {
     type: Array as PropType<Array<Date> | null>,
     required: false,
-    default: null
+    default: null,
   },
   maxHeight: {
     type: Number,
     required: false,
-    default: null
-  }
+    default: null,
+  },
 })
 
 const emit = defineEmits(['update:modelValue'])
@@ -51,13 +51,16 @@ const updateDateTimeRange = (value: [string, string]) => {
 const i18n = useI18n()
 
 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()
 
-let style = '';
+let style = ''
 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 rect = el.getBoundingClientRect()
 
-  if ((rect.left + fullWidth + rightPadding) > window.innerWidth) {
+  if (rect.left + fullWidth + rightPadding > window.innerWidth) {
     xOffset = window.innerWidth - (rect.left + fullWidth + rightPadding)
   }
 
   return {
     top: rect.bottom,
-    left: rect.left + xOffset
+    left: rect.left + xOffset,
   }
 }
 </script>
 
 <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>

+ 30 - 30
components/Ui/ExpansionPanel.vue

@@ -25,47 +25,47 @@ Panneaux déroulants de type "accordéon"
 const props = defineProps({
   title: {
     type: String,
-    required: true
+    required: true,
   },
   icon: {
     type: String,
     required: false,
-    default: null
-  }
+    default: null,
+  },
 })
 </script>
 
 <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>

+ 295 - 168
components/Ui/Form.vue

@@ -1,45 +1,57 @@
 <!--
 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
 -->
 
 <template>
-  <main>
+  <LayoutContainer>
     <v-form
       ref="form"
-      lazy-validation
+      v-model="isValid"
       :readonly="readonly"
       @submit.prevent=""
-      @update:entity="onFormChange"
     >
       <!-- 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-col cols="12" sm="12">
-            <slot name="form.button"/>
+            <slot name="form.button" />
 
             <UiButtonSubmit
               v-if="!readonly"
-              @submit="submit"
               :actions="actions"
+              :validation-pending="validationPending || !isValid"
+              @submit="submit"
             ></UiButtonSubmit>
           </v-col>
         </v-row>
       </v-container>
 
       <!-- Content -->
-      <slot name="form.input" v-bind="{model, entity}"/>
+      <slot v-bind="{ model, entity }" />
 
       <!-- 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-col cols="12" sm="12">
-            <slot name="form.button"/>
+            <slot name="form.button" />
 
             <UiButtonSubmit
-              @submit="submit"
+              :validation-pending="validationPending || !isValid"
               :actions="actions"
+              @submit="submit"
             ></UiButtonSubmit>
           </v-col>
         </v-row>
@@ -47,270 +59,385 @@ Formulaire générique
     </v-form>
 
     <!-- Confirmation dialog -->
-    <LazyLayoutDialog
-      :show="showDialog"
-    >
+    <LazyLayoutDialog :show="isConfirmationDialogShowing" :max-width="1000">
       <template #dialogText>
         <v-card-title class="text-h5 theme-neutral">
           {{ $t('caution') }}
         </v-card-title>
         <v-card-text>
-          <br>
-          <p>{{ $t('quit_without_saving_warning') }}</p>
+          <br />
+          <p>{{ $t('quit_without_saving_warning') }}.</p>
         </v-card-text>
       </template>
+
       <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>
     </LazyLayoutDialog>
-
-  </main>
+  </LayoutContainer>
 </template>
 
 <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({
+  /**
+   * Classe de l'ApiModel (ex: Organization, Notification, ...)
+   */
   model: {
     type: Function as any as () => typeof ApiModel,
-    required: true
+    required: true,
   },
+  /**
+   * Instance de l'objet
+   */
   entity: {
     type: Object as () => ApiModel,
-    required: true
+    required: true,
   },
+  /**
+   * TODO: compléter
+   */
   onChanged: {
     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: {
     type: Object,
     required: false,
     default: () => {
-      let actions: AnyJson = {}
+      const actions: AnyJson = {}
       actions[SUBMIT_TYPE.SAVE] = {}
       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 { em } = useEntityManager()
+const { refreshProfile } = useRefreshProfile()
+const route = useRoute()
 
+// Le formulaire est-il valide
 const isValid: Ref<boolean> = ref(true)
+
+// Erreurs de validation
 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 formStore = useFormStore()
+
+// Le formulaire est-il en lecture seule
 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 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
-    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 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)
 
-  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>

+ 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>
 
 <script setup lang="ts">
-import {Ref} from "@vue/reactivity";
+import type { Ref } from '@vue/reactivity'
 
 const props = defineProps({
   left: {
     type: Boolean,
     required: false,
-    default: false
+    default: false,
   },
   right: {
     type: Boolean,
     required: false,
-    default: false
+    default: false,
   },
   top: {
     type: Boolean,
     required: false,
-    default: false
+    default: false,
   },
   bottom: {
     type: Boolean,
     required: false,
-    default: false
+    default: false,
   },
   icon: {
     type: String,
     required: false,
-    default: 'mdi-help-circle'
-  }
+    default: 'mdi-help-circle',
+  },
 })
 
 const { $refs } = useNuxtApp()
@@ -67,7 +67,9 @@ const show: Ref<Boolean> = ref(false)
 const iconRef = ref(null)
 
 // 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) => {
   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.
-
-Si la propriété 'upload' est à 'true', propose aussi un input pour uploader une nouvelle image.
 -->
 <template>
   <main>
-    <div class="image-wrapper" :style="{width: width + 'px'}">
+    <div class="image-wrapper" :style="{ width: width + 'px' }">
       <v-img
-        :src="imageSrc"
+        :src="imageSrc ?? undefined"
         :lazy-src="defaultImagePath"
         :height="height"
         :width="width"
@@ -21,97 +19,96 @@ Si la propriété 'upload' est à 'true', propose aussi un input pour uploader u
             justify="center"
             v-if="pending"
           >
-            <v-progress-circular
-              :indeterminate="true"
-              color="neutral"
-            />
+            <v-progress-circular :indeterminate="true" color="neutral" />
           </v-row>
         </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>
   </main>
 </template>
 
-
 <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({
-  id: {
-    type: Number,
+  /**
+   * Id de l'image (null si aucune)
+   */
+  imageId: {
+    type: Number as PropType<number | null>,
     required: false,
-    default: null
+    default: null,
   },
+  /**
+   * Image par défaut
+   */
   defaultImage: {
     type: String,
-    required: false
+    required: false,
   },
+  /**
+   * Hauteur de l'image à l'écran (en px)
+   */
   height: {
     type: Number,
-    required: false
+    required: false,
   },
+  /**
+   * Largeur de l'image à l'écran (en px)
+   */
   width: {
     type: Number,
-    required: false
+    required: false,
   },
-  field: {
+  /**
+   * Icône à afficher en overlay au survol de la souris
+   */
+  overlayIcon: {
     type: String,
-    required: false
-  },
-  upload: {
-    type: Boolean,
     required: false,
-    default: false
+    default: null,
   },
-  ownerId:{
-    type: Number,
-    required: false
-  }
 })
 
-const { imageManager } = useImageManager()
 const { fetch } = useImageFetch()
 
 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
@@ -122,41 +119,38 @@ onUnmounted(() => {
 </script>
 
 <style lang="scss">
-  div.image-wrapper {
+div.image-wrapper {
+  display: block;
+  position: relative;
+
+  img {
     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>

+ 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
 -->
 
 <template>
   <main>
+    <!--suppress TypeScriptValidateTypes -->
     <v-autocomplete
+      :model-value="modelValue"
       autocomplete="search"
-      :value="data"
-      :items="itemsToDisplayed"
+      :items="items"
       :label="$t(fieldLabel)"
-      item-text="itemTextDisplay"
+      :item-title="itemTitle"
       :item-value="itemValue"
-      :no-data-text="$t('autocomplete_research')"
       :no-filter="noFilter"
-      auto-select-first
+      :auto-select-first="autoSelectFirst"
       :multiple="multiple"
       :loading="isLoading"
       :return-object="returnObject"
       :search-input.sync="search"
       :prepend-icon="prependIcon"
-      :error="error || !!violation"
-      :error-messages="errorMessage || violation ? $t(violation) : ''"
+      :error="error || !!fieldViolations"
+      :error-messages="
+        errorMessage || fieldViolations ? $t(fieldViolations) : ''
+      "
       :rules="rules"
       :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">
-        <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>
     </v-autocomplete>
   </main>
 </template>
 
 <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({
-  label: {
-    type: String,
+  /**
+   * v-model
+   */
+  modelValue: {
+    type: [String, Number, Object, Array] as PropType<any>,
     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: {
     type: String,
     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,
-    default: null
+    default: null,
   },
+  /**
+   * Liste des éléments de la liste
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-items
+   */
   items: {
-    type: Array,
+    type: Array as PropType<Array<Object>>,
     required: false,
-    default: () => []
+    default: () => [],
   },
+  /**
+   * Définit si le champ est en lecture seule
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-readonly
+   */
   readonly: {
     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,
-    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,
-    default: false
+    default: false,
   },
+  /**
+   * Le contenu de la liste est en cours de chargement
+   */
   isLoading: {
     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: {
     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: {
     type: Boolean,
-    default: false
+    default: false,
   },
+  /**
+   * Règles de validation
+   * @see https://vuetify.cn/en/components/forms/#validation-with-submit-clear
+   */
   rules: {
     type: Array,
     required: false,
-    default: () => []
-  },
-  chips: {
-    type: Boolean,
-    default: false
+    default: () => [],
   },
+  /**
+   * Le champ est-il actuellement en état d'erreur
+   */
   error: {
     type: Boolean,
-    required: false
+    required: false,
   },
+  /**
+   * Si le champ est en état d'erreur, quel est le message d'erreur?
+   */
   errorMessage: {
     type: String,
     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
  */
-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>> = []
+  let groupValue = null
 
   for (const item of items) {
     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
+ * 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> = []
 
-  for (const group in itemsByGroup) {
-
+  for (const group in groupedItems) {
     // Si un groupe est présent, alors on créé le groupe options header
     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
-    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>

+ 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>
 
 <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({
   label: {
     type: String,
     required: false,
-    default: null
+    default: null,
   },
   field: {
     type: String,
     required: false,
-    default: null
+    default: null,
   },
   searchFunction: {
     type: Function,
-    required: true
+    required: true,
   },
   data: {
     type: [String, Number, Object, Array],
     required: false,
-    default: null
+    default: null,
   },
   remoteUri: {
     type: [Array],
     required: false,
-    default: null
+    default: null,
   },
   remoteUrl: {
     type: String,
     required: false,
-    default: null
+    default: null,
   },
   readonly: {
     type: Boolean,
-    required: false
+    required: false,
   },
   itemValue: {
     type: String,
-    default: 'id'
+    default: 'id',
   },
-  itemText: {
+  itemTitle: {
     type: Array,
-    required: true
+    required: true,
   },
   slotText: {
     type: Array,
-    required: false
+    required: false,
   },
   returnObject: {
     type: Boolean,
-    default: false
+    default: false,
   },
   noFilter: {
     type: Boolean,
-    default: false
+    default: false,
   },
   multiple: {
     type: Boolean,
-    default: false
+    default: false,
   },
   chips: {
     type: Boolean,
-    default: false
-  }
+    default: false,
+  },
 })
 
 const { data } = toRefs(props)
@@ -103,19 +102,19 @@ const items = ref([])
 const remoteData: Ref<Array<string> | null> = ref(null)
 const isLoading = ref(false)
 
-
 if (props.data) {
-  items.value = props.multiple ? (data.value ?? []) : [data.value]
-
+  items.value = props.multiple ? data.value ?? [] : [data.value]
 } 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))
   }
 
-  const options: FetchOptions = { method: 'GET', query: {key: 'id', value: ids.join(',')} }
+  const options: FetchOptions = {
+    method: 'GET',
+    query: { key: 'id', value: ids.join(',') },
+  }
 
   useFetch(async () => {
     isLoading.value = true
@@ -128,14 +127,14 @@ if (props.data) {
   })
 }
 
-const search = async (research:string) => {
+const search = async (research: string) => {
   isLoading.value = true
   const func: Function = props.searchFunction
   items.value = items.value.concat(await func(research, props.field))
   isLoading.value = false
 }
 
-const unwatch = watch(data,(d) => {
+const unwatch = watch(data, (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/
 -->
 
 <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>
 
 <script setup lang="ts">
-import {useFieldViolation} from "~/composables/form/useFieldViolation";
+import { useFieldViolation } from '~/composables/form/useFieldViolation'
 
 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: {
     type: String,
     required: false,
-    default: null
+    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
-  },
-  data: {
-    type: Boolean,
-    required: false
+    default: null,
   },
+  /**
+   * Définit si le champ est en lecture seule
+   */
   readonly: {
     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: {
     type: Boolean,
-    required: false
+    required: false,
   },
+  /**
+   * Si le champ est en état d'erreur, quel est le message d'erreur?
+   */
   errorMessage: {
     type: String,
     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>
 
-<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>
   <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>
 </template>
 
 <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({
+  /**
+   * 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: {
     type: String,
     required: false,
-    default: null
+    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
-  },
-  data: {
-    type: [String, Array],
-    required: false,
-    default: null
+    default: null,
   },
+  /**
+   * Définit si le champ est en lecture seule
+   */
   readonly: {
     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: {
     type: String,
     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: {
     type: Boolean,
-    required: false
+    required: false,
   },
+  /**
+   * Si le champ est en état d'erreur, quel est le message d'erreur?
+   */
   errorMessage: {
     type: String,
     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 { 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 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>
 
-<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>
 
 <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({
   label: {
     type: String,
     required: false,
-    default: null
+    default: null,
   },
   field: {
     type: String,
     required: false,
-    default: null
+    default: null,
   },
   data: {
     type: [String, Number],
     required: false,
-    default: null
+    default: null,
   },
   readonly: {
     type: Boolean,
     required: false,
-    default: false
+    default: false,
   },
   required: {
     type: Boolean,
     required: false,
-    default: false
+    default: false,
   },
   error: {
     type: Boolean,
-    required: false
+    required: false,
   },
   errorMessage: {
     type: String,
     required: false,
-    default: null
-  }
+    default: null,
+  },
 })
 
 const { emit, i18n } = useNuxtApp()
 
 const fieldLabel = props.label ?? props.field
 
-const {violation, onChange} = useFieldViolation(props.field, emit)
+const { violation, onChange } = useFieldViolation(props.field, emit)
 
 const validationUtils = useValidationUtils()
 
 const rules = [
-  (email: string) => validationUtils.validEmail(email) || i18n.t('email_error')
+  (email: string) => validationUtils.validEmail(email) || i18n.t('email_error'),
 ]
 
 if (props.required) {
-  rules.push(
-    (email: string) => !!email || i18n.t('required')
-  )
+  rules.push((email: string) => !!email || i18n.t('required'))
 }
-
 </script>

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

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

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

@@ -1,292 +1,494 @@
 <!--
 Assistant de création d'image
-https://norserium.github.io/vue-advanced-cropper/
+
+@see https://norserium.github.io/vue-advanced-cropper/
 -->
 <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 #dialogTitle>{{ $t('modif_picture') }}</template>
       <template #dialogText>
         <div class="upload">
           <v-row
-            v-if="fetchState.pending"
+            v-if="pending"
             class="fill-height ma-0 loading"
             align="center"
             justify="center"
           >
-            <v-progress-circular
-              :indeterminate="true"
-              color="neutral">
+            <v-progress-circular :indeterminate="true" color="neutral">
             </v-progress-circular>
           </v-row>
 
-          <div v-else >
+          <div v-else>
             <div class="upload__cropper-wrapper">
-              <cropper
+              <Cropper
                 ref="cropper"
                 class="upload__cropper"
                 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 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>
             </div>
-          </div>
 
+            <span class="max-size-label">{{ $t('max_size_4_mb') }}</span>
+          </div>
         </div>
       </template>
       <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') }}
         </v-btn>
-        <v-btn class="mr-4 submitBtn theme-danger" @click="save">
+        <v-btn
+          class="submitBtn theme-primary"
+          @click="save"
+          :disabled="pending"
+        >
           {{ $t('save') }}
         </v-btn>
       </template>
     </LazyLayoutDialog>
-
+  </div>
 </template>
 
 <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 {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({
-  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,
-    required: false
+    required: false,
   },
+  /**
+   * TODO: completer
+   */
   ownerId: {
     type: Number,
-    required: false
+    required: false,
   },
-  field: {
-    type: String,
-    required: true
-  }
 })
 
-const { emit } = useNuxtApp()
-
 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 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,
   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 {
-    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
  */
-const uploadImage = (event:any) => {
+const uploadImage = async (event: any) => {
   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
- * @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
  */
 onUnmounted(() => {
-  unwatch()
-    if (image.value.src) {
-      URL.revokeObjectURL(image.value.src)
-    }
+  if (currentImage.value && currentImage.value.src) {
+    URL.revokeObjectURL(currentImage.value.src)
+  }
 })
 </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>

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

@@ -1,55 +1,93 @@
 <!--
 An input for numeric values
 -->
+<!-- eslint-disable vue/valid-v-bind -->
 
 <template>
   <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>
 
 <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({
   modelValue: {
     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: {
     type: Number,
     required: false,
-    default: 0
+    default: 0,
   },
   min: {
     type: Number,
     required: false,
-    default: null
+    default: null,
   },
   max: {
     type: Number,
     required: false,
-    default: null
+    default: null,
   },
   density: {
     type: String as PropType<Density>,
     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
  */
+// eslint-disable-next-line
 const input: Ref<any> = ref(null)
 
 /**
@@ -82,21 +120,20 @@ const keepInRange = (val: number) => {
   return val
 }
 
-
 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
  */
 onMounted(() => {
-  const inputElement = input.value.$el.querySelector('input')
+  const inputElement = input.value!.$el.querySelector('input')
   if (props.min !== null) {
     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>
 
 <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({
   label: {
     type: String,
     required: false,
-    default: ''
+    default: '',
   },
   field: {
     type: String,
     required: false,
-    default: null
+    default: null,
   },
   data: {
     type: [String, Number],
     required: false,
-    default: null
+    default: null,
   },
   readonly: {
     type: Boolean,
-    required: false
+    required: false,
   },
   error: {
     type: Boolean,
-    required: false
+    required: false,
   },
   errorMessage: {
     type: String,
     required: false,
-    default: null
-  }
+    default: null,
+  },
 })
 
 const { emit, i18n } = useNuxtApp()
@@ -71,7 +70,14 @@ const internationalNumber: Ref<string | number> = ref('')
 const isValid: Ref<boolean> = ref(false)
 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
   nationalNumber.value = number.national
   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 = [
-  (phone: string) => (!phone || isValid.value) || i18n.t('phone_error')
+  (phone: string) => !phone || isValid.value || i18n.t('phone_error'),
 ]
 </script>
 
 <style lang="scss">
-input:read-only{
+input:read-only {
   color: rgb(var(--v-theme-on-neutral));
 }
 </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/
 -->
 
 <template>
   <v-text-field
+    ref="input"
     :model-value="modelValue"
-    :label="(label || field) ? $t(label ?? field) : undefined"
+    :label="label || field ? $t(label ?? field) : undefined"
     :rules="rules"
     :disabled="readonly"
-    :type="(type === 'password' && show) ? 'text' : type"
+    :type="type === 'password' && show ? 'text' : type"
     :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') : ''"
+    :variant="variant"
     @click:append="show = !show"
     @update:model-value="onUpdate($event)"
     @change="onChange($event)"
   />
 
-
-<!--  v-cleave="mask"-->
+  <!--  v-cleave="mask"-->
 </template>
 
 <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({
   /**
    * v-model
    */
   modelValue: {
-    type: [String, Number],
+    type: [String, Number] as PropType<string | number | null>,
     required: false,
-    default: null
+    default: null,
   },
   /**
    * Nom de la propriété d'une entité lorsque l'input concerne cette propriété
@@ -47,7 +48,7 @@ const props = defineProps({
   field: {
     type: String,
     required: false,
-    default: null
+    default: null,
   },
   /**
    * Label du champ
@@ -56,7 +57,7 @@ const props = defineProps({
   label: {
     type: String,
     required: false,
-    default: null
+    default: null,
   },
   /**
    * Type d'input HTML
@@ -65,7 +66,7 @@ const props = defineProps({
   type: {
     type: String,
     required: false,
-    default: null
+    default: null,
   },
   /**
    * Définit si le champ est en lecture seule
@@ -73,16 +74,16 @@ const props = defineProps({
   readonly: {
     type: Boolean,
     required: false,
-    default: false
+    default: false,
   },
   /**
    * Règles de validation
    * @see https://vuetify.cn/en/components/forms/#validation-with-submit-clear
    */
   rules: {
-    type: Array,
+    type: Array as PropType<any[]>,
     required: false,
-    default: () => []
+    default: () => [],
   },
   /**
    * Le champ est-il actuellement en état d'erreur
@@ -90,7 +91,7 @@ const props = defineProps({
   error: {
     type: Boolean,
     required: false,
-    default: false
+    default: false,
   },
   /**
    * Si le champ est en état d'erreur, quel est le message d'erreur?
@@ -98,7 +99,7 @@ const props = defineProps({
   errorMessage: {
     type: String,
     required: false,
-    default: null
+    default: null,
   },
   /**
    * Masque de saisie
@@ -107,36 +108,47 @@ const props = defineProps({
   mask: {
     type: [Array, Boolean],
     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 show = ref(false)
+const show: Ref<boolean> = ref(false)
 
 const emit = defineEmits(['update:model-value', 'change'])
 
 const onUpdate = (event: string) => {
-    emit('update:model-value', event)
+  emit('update:model-value', event)
 }
 
 const onChange = (event: Event | undefined) => {
-    updateViolationState(event)
-    emit('change', event)
+  updateViolationState(event)
+  emit('change', event)
 }
-
-// const label = computed(() => {
-//   if (props.label)
-// })
-
 </script>
 
 <style scoped lang="scss">
-  input:read-only{
-    color: rgb(var(--v-theme-neutral));
-  }
+input:read-only {
+  color: rgb(var(--v-theme-neutral));
+}
 </style>

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

@@ -6,68 +6,66 @@ Champs de saisie de bloc texte
 
 <template>
   <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>
 
 <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({
   label: {
     type: String,
     required: false,
-    default: null
+    default: null,
   },
   field: {
     type: String,
     required: false,
-    default: null
+    default: null,
   },
   data: {
     type: [String, Number],
     required: false,
-    default: null
+    default: null,
   },
   readonly: {
     type: Boolean,
-    required: false
+    required: false,
   },
   rules: {
     type: Array,
     required: false,
-    default: () => []
+    default: () => [],
   },
   error: {
     type: Boolean,
-    required: false
+    required: false,
   },
   errorMessage: {
     type: String,
     required: false,
-    default: null
-  }
+    default: null,
+  },
 })
 
 const { emit } = useNuxtApp()
 
 const fieldLabel = props.label ?? props.field
 
-const {violation, onChange} = useFieldViolation(props.field, emit)
-
+const { violation, onChange } = useFieldViolation(props.field, emit)
 </script>
 
 <style lang="scss">
-  input:read-only{
-    color: rgb(var(--v-theme-on-neutral));
-  }
+input:read-only {
+  color: rgb(var(--v-theme-on-neutral));
+}
 </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>
   <main>
-    <v-skeleton-loader
-      v-if="pending"
-      :type="loaderType"
-    />
+    <v-skeleton-loader v-if="pending" :type="loaderType" />
     <div v-else>
-      <slot name="item.text" v-bind="{item}" />
+      <slot name="item.text" v-bind="{ item }" />
     </div>
     <slot />
   </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">
 // 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({
   uri: {
     type: String,
     required: false,
-    default: null
+    default: null,
   },
   model: {
     type: Object,
-    required: true
+    required: true,
   },
   query: {
     type: Object as () => Query,
-    required: true
+    required: true,
   },
   loaderType: {
     type: String,
     required: false,
-    default: 'text'
-  }
+    default: 'text',
+  },
 })
 
 const id = UrlUtils.extractIdFromUri(props.uri)
@@ -55,7 +51,7 @@ const { fetch } = useEntityFetch()
 
 const { data, pending } = fetch(props.model, id)
 
-const item: ComputedRef<ApiResource|null> = computed(() => {
+const item: ComputedRef<ApiResource | null> = computed(() => {
   return data.value
 })
 </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>
   <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>
       <v-icon v-if="icon" small :icon="icon" />
@@ -16,40 +18,40 @@ System bars
 </template>
 
 <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>
 
 <style scoped lang="scss">

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

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

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

@@ -7,15 +7,16 @@ Date formatée
 </template>
 
 <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({
   data: {
     type: [String, Array],
     required: false,
-    default: null
-  }
+    default: null,
+  },
 })
 
 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>
     <!-- Mode édition activé -->
     <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
-          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
-          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>
 
     <!-- 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>
   </main>
 </template>
 
 <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>
 
 <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
  *
  * @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 = () => {
-    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 {
-    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
-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
 
 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 {
-    fetch: (enumName: string) => AsyncData<Enum, null | true | Error>,
+  fetch: (enumName: string) => AsyncData<Enum | null, Error | null>
 }
 
 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 = () => {
-    //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 {
-    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.
  */
 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 = () => {
-    //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 { useFormStore } from '~/stores/form'
 
 /**
  * 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
  */
 export function useFieldViolation(field: string) {
-  const fieldViolations: ComputedRef<string> = computed(()=> {
+  const fieldViolations: ComputedRef<string> = computed(() => {
     return _.get(useFormStore().violations, field, '')
   })
 
   /**
    * Lorsque la valeur d'un champ change, on supprime le fait qu'il puisse être "faux" dans le store
    * @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 {
     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
  * Composable pour des utils de verifications
  */
 export function useValidation() {
-
   /**
    * 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 validateSiret = async (siret: string) => {
-
       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') {
         siretError.value = false
         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()
-      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 {
       siretError,
       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 {
     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'
 
 /**
@@ -11,16 +11,21 @@ export function useExtensionPanel(route: Ref) {
 
   onMounted(() => {
     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)
   })
 
   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
@@ -23,15 +23,23 @@ export const useMenu = () => {
   const organizationProfile = useOrganizationProfileStore()
   const accessProfile = useAccessProfileStore()
   const layoutState = useLayoutStore()
+  const router = useRouter()
 
   /**
    * 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
    * 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 = () => {
-    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,
     toggleMenu,
     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 = () => {
-    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) => {
-    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 = () => {
-    //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 = () => {
-    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 = () => {
-    //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 }

Някои файлове не бяха показани, защото твърде много файлове са промени