Jelajahi Sumber

merge release 2.3

Olivier Massot 2 tahun lalu
induk
melakukan
a4110648d1
100 mengubah file dengan 2536 tambahan dan 1651 penghapusan
  1. 6 3
      .env.docker
  2. 0 22
      .env.preprod
  3. 14 1
      .env.prod
  4. 17 12
      .env.test
  5. 7 4
      .gitignore
  6. 33 0
      .gitlab-ci.yml
  7. 1 0
      .nvmrc
  8. 0 11
      .project
  9. 26 6
      README.md
  10. 15 2
      assets/css/global.scss
  11. 1 0
      assets/css/import.scss
  12. 5 0
      assets/css/settings.scss
  13. 54 0
      assets/css/theme.scss
  14. 0 9
      assets/css/variables.scss
  15. 11 0
      assets/css/vuetify.scss
  16. 9 7
      components/Layout/Alert/Container.vue
  17. 23 10
      components/Layout/Alert/Content.vue
  18. 4 8
      components/Layout/AlertBar.vue
  19. 64 105
      components/Layout/AlertBar/Cotisation.vue
  20. 9 18
      components/Layout/AlertBar/Env.vue
  21. 41 0
      components/Layout/AlertBar/OnlineRegistration.vue
  22. 45 0
      components/Layout/AlertBar/RegistrationStatus.vue
  23. 9 14
      components/Layout/AlertBar/SuperAdmin.vue
  24. 8 10
      components/Layout/AlertBar/SwitchUser.vue
  25. 32 15
      components/Layout/AlertBar/SwitchYear.vue
  26. 7 4
      components/Layout/BannerTop.vue
  27. 18 11
      components/Layout/Dialog.vue
  28. 23 24
      components/Layout/Header.vue
  29. 6 3
      components/Layout/Header/HomeBtn.vue
  30. 35 21
      components/Layout/Header/Menu.vue
  31. 92 76
      components/Layout/Header/Notification.vue
  32. 138 0
      components/Layout/Header/UniversalCreation/Card.vue
  33. 147 30
      components/Layout/Header/UniversalCreation/CreateButton.vue
  34. 119 0
      components/Layout/Header/UniversalCreation/EventParams.vue
  35. 313 199
      components/Layout/Header/UniversalCreation/GenerateCardsSteps.vue
  36. 0 91
      components/Layout/Header/UniversalCreation/TypeCard.vue
  37. 8 19
      components/Layout/LoadingScreen.vue
  38. 55 34
      components/Layout/MainMenu.vue
  39. 29 24
      components/Layout/SubHeader/ActivityYear.vue
  40. 11 5
      components/Layout/SubHeader/Breadcrumbs.vue
  41. 17 12
      components/Layout/SubHeader/DataTiming.vue
  42. 37 111
      components/Layout/SubHeader/DataTimingRange.vue
  43. 19 24
      components/Layout/SubHeader/PersonnalizedList.vue
  44. 53 16
      components/Layout/Subheader.vue
  45. 26 0
      components/Layout/ThemeSwitcher.vue
  46. 2 2
      components/Ui/Button/Delete.vue
  47. 1 1
      components/Ui/Button/Submit.vue
  48. 2 3
      components/Ui/Collection.vue
  49. 1 1
      components/Ui/DataTable.vue
  50. 71 0
      components/Ui/DatePicker.vue
  51. 126 0
      components/Ui/DateRangePicker.vue
  52. 34 13
      components/Ui/ExpansionPanel.vue
  53. 4 6
      components/Ui/Form.vue
  54. 0 2
      components/Ui/Help.vue
  55. 14 12
      components/Ui/Image.vue
  56. 0 1
      components/Ui/Input/Autocomplete.vue
  57. 2 2
      components/Ui/Input/AutocompleteWithAPI.vue
  58. 0 2
      components/Ui/Input/Checkbox.vue
  59. 7 11
      components/Ui/Input/DatePicker.vue
  60. 13 13
      components/Ui/Input/Image.vue
  61. 107 0
      components/Ui/Input/Number.vue
  62. 2 2
      components/Ui/Input/Phone.vue
  63. 16 4
      components/Ui/Input/Text.vue
  64. 2 2
      components/Ui/Input/TextArea.vue
  65. 2 2
      components/Ui/ItemFromUri.vue
  66. 56 15
      components/Ui/SystemBar.vue
  67. 2 4
      components/Ui/Template/Date.vue
  68. 6 5
      components/Ui/Xeditable/Text.vue
  69. 13 9
      composables/data/useAp2iRequestService.ts
  70. 21 7
      composables/data/useEntityFetch.ts
  71. 10 2
      composables/data/useEntityManager.ts
  72. 2 1
      composables/data/useEnumFetch.ts
  73. 10 4
      composables/data/useEnumManager.ts
  74. 3 3
      composables/data/useImageFetch.ts
  75. 9 2
      composables/data/useImageManager.ts
  76. 4 7
      composables/form/useFieldViolation.ts
  77. 3 3
      composables/form/useValidation.ts
  78. 3 2
      composables/layout/useExtensionPanel.ts
  79. 23 34
      composables/layout/useMenu.ts
  80. 0 13
      composables/layout/useRedirectToLogin.ts
  81. 0 12
      composables/utils/useAbilityUtils.ts
  82. 14 0
      composables/utils/useAdminUrl.ts
  83. 0 7
      composables/utils/useDateUtils.ts
  84. 19 0
      composables/utils/useDownloadFile.ts
  85. 9 5
      composables/utils/useI18nUtils.ts
  86. 21 0
      composables/utils/useRedirect.ts
  87. 7 2
      composables/utils/useValidationUtils.ts
  88. 19 32
      config/abilities/pages/addressBook.yaml
  89. 17 27
      config/abilities/pages/admin2ios.yaml
  90. 24 40
      config/abilities/pages/billing.yaml
  91. 21 27
      config/abilities/pages/communication.yaml
  92. 51 85
      config/abilities/pages/cotisations.yaml
  93. 3 5
      config/abilities/pages/donor.yaml
  94. 21 35
      config/abilities/pages/educational.yaml
  95. 3 5
      config/abilities/pages/equipment.yaml
  96. 3 5
      config/abilities/pages/medals.yaml
  97. 38 57
      config/abilities/pages/myAccount.yaml
  98. 71 101
      config/abilities/pages/parameters.yaml
  99. 25 35
      config/abilities/pages/schedule.yaml
  100. 12 20
      config/abilities/pages/stats.yaml

+ 6 - 3
.env.docker

@@ -1,10 +1,13 @@
 ## LOCAL ENVIRONMENT FILE
-ENV=dev
 DEBUG=1
 
 HOST=0
 PORT=3000
 
+# Current env
+NUXT_ENV=dev
+NUXT_PUBLIC_ENV=dev
+
 ## API Base Url
 NUXT_BASE_URL=http://nginx_new
 NUXT_PUBLIC_BASE_URL=https://local.ap2i.opentalent.fr
@@ -24,8 +27,8 @@ NUXT_PUBLIC_BASE_URL_TYPO3=https://local.sub.opentalent.fr/###subDomain###
 # Mercure push events
 NUXT_BASE_URL_MERCURE=https://mercure/.well-known/mercure
 NUXT_PUBLIC_BASE_URL_MERCURE=https://local.mercure.opentalent.fr/.well-known/mercure
+MERCURE_SUBSCRIBER_JWT_KEY=NQEupdREijrfYvCmF2mnvZQFL9zLKDH9RCYter6tUWzjemPqzicffhc2fSf0yEmM
 
 # Other links
 NUXT_SUPPORT_URL=https://support.opentalent.fr/
-
-MERCURE_SUBSCRIBER_JWT_KEY=NQEupdREijrfYvCmF2mnvZQFL9zLKDH9RCYter6tUWzjemPqzicffhc2fSf0yEmM
+NUXT_PUBLIC_SUPPORT_URL=https://support.opentalent.fr/

+ 0 - 22
.env.preprod

@@ -1,22 +0,0 @@
-## PREPROD ENVIRONMENT FILE
-ENV=preprod
-DEBUG=1
-
-## API Base Url
-NUXT_BASE_URL=https://ap2i.preprod.opentalent.fr
-NUXT_PUBLIC_BASE_URL=https://ap2i.preprod.opentalent.fr
-
-# Legacy API Base Url
-NUXT_BASE_URL_LEGACY=https://api.preprod.opentalent.fr
-NUXT_PUBLIC_BASE_URL_LEGACY=https://api.preprod.opentalent.fr
-
-# Legacy Admin Base Url
-NUXT_BASE_URL_ADMIN_LEGACY=https://admin.preprod.opentalent.fr/#
-NUXT_PUBLIC_BASE_URL_ADMIN_LEGACY=https://admin.preprod.opentalent.fr/#
-
-# Typo3 Base Url
-NUXT_BASE_URL_TYPO3=https://preprod.opentalent.fr/###subDomain###
-NUXT_PUBLIC_BASE_URL_TYPO3=https://preprod.opentalent.fr/###subDomain###
-
-# Other links
-NUXT_SUPPORT_URL=https://support.opentalent.fr/

+ 14 - 1
.env.prod

@@ -1,8 +1,14 @@
 ## PROD ENVIRONMENT FILE
 # /!\ -- USE ONLY IN PRODUCTION --
-ENV=production
 DEBUG=0
 
+HOST=0
+PORT=3002
+
+# Current env
+NUXT_ENV=production
+NUXT_PUBLIC_ENV=production
+
 ## API Base Url
 NUXT_BASE_URL=https://ap2i.opentalent.fr
 NUXT_PUBLIC_BASE_URL=https://ap2i.opentalent.fr
@@ -19,5 +25,12 @@ NUXT_PUBLIC_BASE_URL_ADMIN_LEGACY=https://admin.opentalent.fr/#
 NUXT_BASE_URL_TYPO3=https://###subDomain###.opentalent.fr
 NUXT_PUBLIC_BASE_URL_TYPO3=https://###subDomain###.opentalent.fr
 
+# Mercure push events
+NUXT_BASE_URL_MERCURE=https://mercure.opentalent.fr/.well-known/mercure
+NUXT_PUBLIC_BASE_URL_MERCURE=https://mercure.opentalent.fr/.well-known/mercure
+MERCURE_SUBSCRIBER_JWT_KEY=NQEupdREijrfYvCmF2mnvZQFL9zLKDH9RCYter6tUWzjemPqzicffhc2fSf0yEmM
+
 # Other links
 NUXT_SUPPORT_URL=https://support.opentalent.fr/
+NUXT_PUBLIC_SUPPORT_URL=https://support.opentalent.fr/
+

+ 17 - 12
.env.test

@@ -1,31 +1,36 @@
-## LOCAL ENVIRONMENT FILE
-ENV=dev
+## TEST ENVIRONMENT FILE
+
 DEBUG=1
 
 HOST=0
-PORT=3000
+PORT=3002
+
+# Current env
+NUXT_ENV=test
+NUXT_PUBLIC_ENV=test
 
 ## API Base Url
-NUXT_BASE_URL=https://ap2i.test.opentalent.fr
-NUXT_PUBLIC_BASE_URL=https://ap2i.test.opentalent.fr
+NUXT_BASE_URL=https://ap2i.test1.opentalent.fr
+NUXT_PUBLIC_BASE_URL=https://ap2i.test1.opentalent.fr
 
 # Legacy API Base Url
-NUXT_BASE_URL_LEGACY=http://nginx
-NUXT_PUBLIC_BASE_URL_LEGACY=https://api.test.opentalent.fr
+NUXT_BASE_URL_LEGACY=https://api.test1.opentalent.fr
+NUXT_PUBLIC_BASE_URL_LEGACY=https://api.test1.opentalent.fr
 
 # Legacy Admin Base Url
-NUXT_BASE_URL_ADMIN_LEGACY=https://admin.test.opentalent.fr/#
-NUXT_PUBLIC_BASE_URL_ADMIN_LEGACY=https://admin.test.opentalent.fr/#
+NUXT_BASE_URL_ADMIN_LEGACY=https://admin.test1.opentalent.fr/#
+NUXT_PUBLIC_BASE_URL_ADMIN_LEGACY=https://admin.test1.opentalent.fr/#
 
 # Typo3 Base Url
-NUXT_BASE_URL_TYPO3=https://test.opentalent.fr/###subDomain###
-NUXT_PUBLIC_BASE_URL_TYPO3=https://test.opentalent.fr/###subDomain###
+NUXT_BASE_URL_TYPO3=https://test1.opentalent.fr/###subDomain###
+NUXT_PUBLIC_BASE_URL_TYPO3=https://test1.opentalent.fr/###subDomain###
 
 # Mercure push events
 NUXT_BASE_URL_MERCURE=https://mercure.test.opentalent.fr/.well-known/mercure
 NUXT_PUBLIC_BASE_URL_MERCURE=https://mercure.test.opentalent.fr/.well-known/mercure
-
 MERCURE_SUBSCRIBER_JWT_KEY=NQEupdREijrfYvCmF2mnvZQFL9zLKDH9RCYter6tUWzjemPqzicffhc2fSf0yEmM
 
 # Other links
 NUXT_SUPPORT_URL=https://support.opentalent.fr/
+NUXT_PUBLIC_SUPPORT_URL=https://support.opentalent.fr/
+

+ 7 - 4
.gitignore

@@ -1,10 +1,14 @@
 # Created by .ignore support plugin (hsz.mobi)
 
 # Dependency directories
-node_modules/
-
-# nuxt.js build output
+node_modules
+*.log*
 .nuxt
+.nitro
+.cache
+.output
+.env
+dist
 
 # IDE / Editor
 .idea
@@ -14,7 +18,6 @@ node_modules/
 
 local.app-v3.opentalent.fr.crt
 local.app-v3.opentalent.fr.key
-.env
 /.project
 
 yarn.lock

+ 33 - 0
.gitlab-ci.yml

@@ -0,0 +1,33 @@
+stages:
+  - test
+
+variables:
+  APP_ENV: ci
+
+before_script:
+  - echo "" > ./local.app-v3.opentalent.fr.crt
+  - echo "" > ./local.app-v3.opentalent.fr.key
+
+cache:
+  paths:
+    - ./node_modules
+
+unit:
+  stage: test
+
+  script:
+    - yarn install
+    - yarn test
+
+  artifacts:
+    paths:
+      - ./coverage/
+    when: always
+    reports:
+      junit: coverage/junit.xml
+      coverage_report:
+        coverage_format: cobertura
+        path: coverage/cobertura-coverage.xml
+
+  # Extract total coverage from job logs (https://docs.gitlab.com/15.6/ee/ci/yaml/index.html#coverage)
+  coverage: '/All files\s*|\s*\d+\.\d+/'

+ 1 - 0
.nvmrc

@@ -0,0 +1 @@
+18.10

+ 0 - 11
.project

@@ -1,11 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<projectDescription>
-	<name>app-v3</name>
-	<comment></comment>
-	<projects>
-	</projects>
-	<buildSpec>
-	</buildSpec>
-	<natures>
-	</natures>
-</projectDescription>

+ 26 - 6
README.md

@@ -1,6 +1,14 @@
-# App - Migration Nuxt 3
+# App
 
-Frontend développé avec NuxtJs 3
+[![Latest Release](http://gitlab.2iopenservice.com/opentalent/app/-/badges/release.svg)](http://gitlab.2iopenservice.com/opentalent/app_nuxt3/-/releases)
+
+| 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 :
 
@@ -18,7 +26,7 @@ A voir :
 
 Cloner le projet :
 
-    git clone git@gitlab.2iopenservice.com:opentalent/app_nuxt3.git
+    git clone git@gitlab.2iopenservice.com:opentalent/app.git
 
 
 Installer les dépendances :
@@ -38,8 +46,7 @@ Copier les certificats à la racine de ce projet :
 
 Lancer le serveur de développement :
 
-    yarn dev
-
+    yarn dev -o
 
 
 ## Déploiement en prod
@@ -56,7 +63,7 @@ Pour déployer le projet en mode SSR, on commence par mettre à jour et compiler
 
     yarn deploy
 
-Cette commande est un alias qui équivaut à lancer:
+Cette commande est un alias qui équivaut à lancer :
 
     git pull
     yarn install
@@ -68,6 +75,11 @@ Se placer dans le répertoire de l'application, puis lancer :
 
     yarn deploy
 
+### Spécial : environnement de test
+
+Attention, sur les environnements de test, il faut utiliser nvm pour exécuter la bonne version de node, exemple :
+
+    nvm exec yarn install
 
 
 ## Autres
@@ -84,6 +96,14 @@ Pour régénérer la documentation automatique :
 
     yarn docs
 
+### Déboguer en prod ou en test
+
+Sur les environnements où app est servie par supervisor, on peut consulter les logs d'erreur avec :
+
+    sudo supervisorctl tail -6000 app:app_00 stderr
+
+> le `-6000` étant le nombre de bytes à afficher
+> Voir plus : http://supervisord.org/running.html#supervisorctl-command-line-options
 
 ## Plus d'infos
 

+ 15 - 2
assets/css/global.scss

@@ -1,3 +1,10 @@
+/**
+ * Ces règles s'appliqueront à toutes les pages, layouts et components de l'application
+ *
+ * Quand c'est possible, préférer les règles par page, layout ou composant
+ */
+// TODO: voir si certaines de ces règles ne devraient pas être rapatriées dans des pages, layouts, components
+
 header .v-toolbar__content {
   padding-right: 0;
 }
@@ -15,7 +22,7 @@ header .v-toolbar__content {
 }
 
 .v-application a {
-  color: rgb(var(--v-theme-ot-green))
+  color: rgb(var(--v-theme-primary))
 }
 
 .v-menu__content {
@@ -38,7 +45,13 @@ header .v-toolbar__content {
   .v-list{
     .v-list-item{
       border-bottom: 1px solid;
-      border-bottom-color: rgb(var(--v-theme-ot-border-menu));
+      border-bottom-color: rgb(var(--v-theme-neutral));
     }
   }
 }
+
+
+// A supprimer quand l'issue  https://github.com/vuetifyjs/vuetify-loader/issues/273 sera règlée
+.v-application {
+  font-size: 0.9rem;
+}

+ 1 - 0
assets/css/import.scss

@@ -0,0 +1 @@
+// TODO: utilité?

+ 5 - 0
assets/css/settings.scss

@@ -0,0 +1,5 @@
+// settings.scss
+@forward 'vuetify/settings' with (
+  $button-color: green,
+  $button-font-weight: 700
+);

+ 54 - 0
assets/css/theme.scss

@@ -0,0 +1,54 @@
+
+.theme-primary {
+  background-color: rgb(var(--v-theme-primary)) !important;
+  color: rgb(var(--v-theme-on-primary)) !important;
+}
+
+.theme-secondary {
+  background-color: rgb(var(--v-theme-secondary)) !important;
+  color: rgb(var(--v-theme-on-secondary)) !important;
+}
+
+.theme-neutral-strong {
+  background-color: rgb(var(--v-theme-neutral-strong)) !important;
+  color: rgb(var(--v-theme-on-neutral-strong)) !important;
+}
+
+.theme-neutral {
+  background-color: rgb(var(--v-theme-neutral)) !important;
+  color: rgb(var(--v-theme-on-neutral)) !important;
+
+  a {
+    color: rgb(var(--v-theme-on-neutral--clickable)) !important;
+  }
+}
+
+.theme-neutral-soft {
+  background-color: rgb(var(--v-theme-neutral-soft)) !important;
+  color: rgb(var(--v-theme-on-neutral-soft)) !important;
+}
+
+.theme-danger {
+  background-color: rgb(var(--v-theme-danger)) !important;
+  color: rgb(var(--v-theme-on-danger)) !important;
+}
+
+.theme-success {
+  background-color: rgb(var(--v-theme-success)) !important;
+  color: rgb(var(--v-theme-on-success)) !important;
+}
+
+.theme-warning {
+  background-color: rgb(var(--v-theme-warning)) !important;
+  color: rgb(var(--v-theme-on-warning)) !important;
+}
+
+.theme-info {
+  background-color: rgb(var(--v-theme-info)) !important;
+  color: rgb(var(--v-theme-on-info)) !important;
+}
+
+.theme-x-create-btn {
+  background-color: rgb(var(--v-theme-x-create-btn)) !important;
+  color: rgb(var(--v-theme-on-x-create-btn)) !important;
+}

+ 0 - 9
assets/css/variables.scss

@@ -1,9 +0,0 @@
-// Ref: https://github.com/nuxt-community/vuetify-module#customvariables
-//
-// The variables you want to modify
-// $font-size-root: 20px;
-$btn-text-transform: none;
-
-$breadcrumbs-padding: 5px 5px;
-$breadcrumbs-even-child-padding: 0 10px;
-$body-font-family: 'Source Sans Pro', 'Helvetica Neue', Helvetica, Arial, sans-serif !important;

+ 11 - 0
assets/css/vuetify.scss

@@ -0,0 +1,11 @@
+/**
+ * Redéfinit les variables globales vuetify
+ *
+ * @see https://next.vuetifyjs.com/en/features/sass-variables/#basic-usage
+ */
+
+// Non utilisé tant que l'issue https://github.com/vuetifyjs/vuetify-loader/issues/273 n'est pas règlée
+@use 'vuetify/settings' with (
+  $font-size-root: 0.9rem,
+  $input-font-size: 0.9rem
+);

+ 9 - 7
components/Layout/Alert/Container.vue

@@ -4,12 +4,14 @@ Container principal pour l'affichage d'une ou plusieurs alertes
 
 <template>
   <main class="alertContainer">
-    <LayoutAlertContent
-      v-for="(alert, key) in alerts"
-      :key="key"
-      class="alertContent"
-      :alert="alert"
-    />
+    <client-only>
+      <LayoutAlertContent
+        v-for="(alert, key) in alerts"
+        :key="key"
+        :alert="alert"
+        class="alertContent"
+      />
+    </client-only>
   </main>
 </template>
 
@@ -21,7 +23,7 @@ import {ComputedRef} from "@vue/reactivity";
 const pageStore = usePageStore()
 
 const alerts: ComputedRef<Array<Alert>> = computed(() => {
-  return pageStore.$state.alerts
+  return pageStore.alerts
 })
 
 </script>

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

@@ -3,21 +3,23 @@
 <template>
   <v-alert
     v-model="show"
-    :type="alert.type"
+    :type="props.alert.type"
     class="position"
-    border="left"
+    border="start"
     width="400"
     dismissible
     transition="fade-transition"
     @mouseover="onMouseOver"
     @mouseout="onMouseOut"
   >
-    <ul v-if="alert.messages.length > 1">
-       <li v-for="message in alert.messages">
+    <ul v-if="props.alert.messages.length > 1">
+       <li v-for="message in props.alert.messages">
         {{ $t(message) }}
       </li>
     </ul>
-    <span v-else>{{ $t(alert.messages[0]) }}</span>
+    <span v-else>
+        {{ $t(props.alert.messages[0]) }}
+    </span>
   </v-alert>
 </template>
 
@@ -27,10 +29,21 @@ import {Ref} from "@vue/reactivity";
 import {usePageStore} from "~/stores/page";
 
 const props = defineProps({
-    alert: {
-      type: Object as () => Alert,
-      required: true
-    }
+  /**
+   * The alert object to display
+   */
+  alert: {
+    type: Object as () => Alert,
+    required: true
+  },
+  /**
+   * The time after which the alert disappears
+   */
+  timeout: {
+    type: Number,
+    required: false,
+    default: 3000
+  }
 })
 
 const show: Ref<boolean> = ref(true)
@@ -60,7 +73,7 @@ const onMouseOver = () => {
  * Relance le timer avant le retrait de l'alerte lorsque le curseur quitte l'alerte
  */
 const onMouseOut = () => {
-  clearAlert(2000)
+  clearAlert(props.timeout)
 }
 
 clearAlert()

+ 4 - 8
components/Layout/AlertBar.vue

@@ -6,18 +6,17 @@ Contient les différentes barres d'alertes qui s'affichent dans certains cas
 
 <template>
   <main>
-    <client-only>
-      <LayoutAlertBarEnv />
-    </client-only>
+    <LayoutAlertBarEnv />
 
     <LayoutAlertBarSwitchUser />
 
     <client-only>
-      <LayoutAlertBarCotisation v-if="organizationProfile.isCmf && can('manage', 'cotisation')" />
+      <LayoutAlertBarCotisation v-if="organizationProfile.isCmf && ability.can('manage', 'cotisation')" />
     </client-only>
 
     <LayoutAlertBarSwitchYear />
     <LayoutAlertBarSuperAdmin />
+    <LayoutAlertBarRegistrationStatus v-if="organizationProfile.hasModule('IEL')" />
   </main>
 </template>
 
@@ -26,8 +25,5 @@ Contient les différentes barres d'alertes qui s'affichent dans certains cas
   import {useAbility} from "@casl/vue";
 
   const organizationProfile = useOrganizationProfileStore()
-  const { can } = useAbility()
+  const ability = useAbility()
 </script>
-
-<style scoped>
-</style>

+ 64 - 105
components/Layout/AlertBar/Cotisation.vue

@@ -6,55 +6,23 @@ Barre d'alerte qui s'affiche pour donner l'état de la cotisation
 
 <template>
   <main>
-    <!-- TODO : fonctionnement à valider -->
-    <UiSystemBar v-if="showCotisationAccess" background-color="ot-info" text-color="ot-white">
-      <template #bar.text>
-        <a @click="goOn('AFFILIATION')">
-          <v-icon small>fas fa-info-circle</v-icon>
-          {{$t('cotisation_access')}}
-        </a>
-      </template>
-    </UiSystemBar>
-
-    <UiSystemBar v-else-if="showUploadInvoice" background-color="ot-info" text-color="ot-white">
-      <template #bar.text>
-        <a @click="goOn('INVOICE')">
-          <v-icon small>fas fa-info-circle</v-icon>
-          {{$t('upload_cotisation_invoice')}}
-        </a>
-      </template>
-    </UiSystemBar>
-
-    <UiSystemBar v-else-if="showRenewInsurance" background-color="ot-info" text-color="ot-white">
-      <template #bar.text>
-        <a @click="goOn('INSURANCE')">
-          <v-icon small>fas fa-info-circle</v-icon>
-          {{$t('renew_insurance_cmf')}}
-        </a>
-      </template>
-    </UiSystemBar>
-
-    <UiSystemBar v-else-if="showInsuranceSubscription" background-color="ot-info" text-color="ot-white">
-      <template #bar.text>
-        <a @click="goOn('ADVERTISINGINSURANCE')">
-          <v-icon small>fas fa-info-circle</v-icon>
-          {{$t('insurance_cmf_subscription')}}
-        </a>
-      </template>
-    </UiSystemBar>
+    <UiSystemBar
+        v-if="alert && alert.text && alert.callback"
+        :text="$t(alert.text)"
+        icon="fas fa-info-circle"
+        :on-click="alert.callback"
+        class="theme-info"
+    />
   </main>
 </template>
 
 <script setup lang="ts">
 import {useOrganizationProfileStore} from "~/stores/organizationProfile";
 import {Ref} from "vue";
-import Url from "~/services/utils/url";
+import UrlUtils from "~/services/utils/urlUtils";
 import {ALERT_STATE_COTISATION} from "~/types/enum/enums";
-import {useAsyncData} from "#app";
 import {useEntityFetch} from "~/composables/data/useEntityFetch";
-import {Cotisation} from "~/models/Organization/Cotisation";
-
-const { fetch } = useEntityFetch()
+import Cotisation from "~/models/Organization/Cotisation";
 
 const organizationProfile = useOrganizationProfileStore()
 
@@ -62,88 +30,79 @@ const runtimeConfig = useRuntimeConfig()
 const baseLegacyUrl: string = runtimeConfig.baseUrlAdminLegacy
 
 const cotisationYear: Ref<number | null> = ref(null)
-const showCotisationAccess: Ref<Boolean> = ref(false)
-const showUploadInvoice: Ref<Boolean> = ref(false)
-const showRenewInsurance: Ref<Boolean> = ref(false)
-const showInsuranceSubscription: Ref<Boolean> = ref(false)
 
 /**
- * On récupère l'état des cotisations via l'API
+ * Redirige l'utilisateur vers la page des cotisations
  */
-useAsyncData(async () => {
+const goToCotisation = () => {
   if (!organizationProfile.id) {
     throw new Error('missing organization id')
   }
-  const { data: cotisation } = await fetch(Cotisation, organizationProfile.id)
+  window.location.href = UrlUtils.join(baseLegacyUrl, '/cotisation/cotisation_steps', organizationProfile.id, 'steps/1')
+}
 
-  if (cotisation.value !== null) {
-    cotisationYear.value = cotisation.value.cotisationYear
-    handleShow(cotisation.value.alertState)
+/**
+ * Ouvre la page facturation dans un nouvel onglet
+ */
+const openInvoiceWindow = () => {
+  if (!cotisationYear.value) {
+    throw new Error('no cotisation year defined')
   }
-})
+  window.open(UrlUtils.join(baseLegacyUrl, 'cotisation/invoice', cotisationYear.value), '_blank')
+}
 
 /**
- * Suivant l'état de l'alerte on affiche tel ou tel message
- * @param alertState
+ * Redirige l'utilisateur vers la page des assurances
  */
-const handleShow = (alertState: ALERT_STATE_COTISATION) =>{
-  switch(alertState){
-    case ALERT_STATE_COTISATION.AFFILIATION :
-      showCotisationAccess.value = true
-      break;
-    case ALERT_STATE_COTISATION.INVOICE :
-      showUploadInvoice.value = true
-      break;
-    case ALERT_STATE_COTISATION.INSURANCE :
-      showRenewInsurance.value = true
-      break;
-    case ALERT_STATE_COTISATION.ADVERTISINGINSURANCE :
-      showInsuranceSubscription.value = true
-      break;
-  }
+const goToInsurancePage = () => {
+  window.location.href = UrlUtils.join(baseLegacyUrl, 'cotisation/insuranceedit')
 }
 
 /**
- * Suivant le bandeau, une action différente est réalisée
- * @param type
+ * Redirige (dans un nouvel onglet) l'utilsateur vers le site web de la CMF
  */
-const goOn = (type: ALERT_STATE_COTISATION) => {
-  switch(type){
-    case ALERT_STATE_COTISATION.AFFILIATION :
-      if (!organizationProfile.id) {
-        throw new Error('missing organization id')
-      }
-      window.location.href = Url.join(baseLegacyUrl, '/cotisation/cotisation_steps', organizationProfile.id, 'steps/1')
-      break;
-    case ALERT_STATE_COTISATION.INVOICE :
-      if (!cotisationYear.value) {
-        throw new Error('no cotisation year defined')
-      }
-      window.open(
-          Url.join(baseLegacyUrl, 'cotisation/invoice', cotisationYear.value),
-          '_blank'
-      )
-      break;
-    case ALERT_STATE_COTISATION.INSURANCE :
-      window.location.href = Url.join(baseLegacyUrl, 'cotisation/insuranceedit')
-      break;
-    case ALERT_STATE_COTISATION.ADVERTISINGINSURANCE :
-      window.open(
-          'https://www.cmf-musique.org/services/assurances/assurance-de-groupe/',
-          '_blank'
-      )
-      break;
-  }
+const openCmfSubscriptionPage = () => {
+  window.open('https://www.cmf-musique.org/services/assurances/assurance-de-groupe/', '_blank')
 }
-</script>
 
-<style scoped lang="scss">
-.v-system-bar {
-  font-size: 14px;
+// On récupère l'état des cotisations via l'API
+if (!organizationProfile.id) {
+  throw new Error('missing organization id')
 }
 
-.v-icon {
-  height: 20px;
-  margin: 0 6px;
+const { fetch } = useEntityFetch()
+const { data: cotisation, pending } = await fetch(Cotisation, organizationProfile.id)
+
+interface Alert {
+  text: string
+  callback: () => void
 }
+
+const alert: ComputedRef<Alert | null> = computed(() => {
+  if (pending.value) {
+    return null
+  }
+
+  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 },
+  }
+
+  if (!cotisation.value.alertState) {
+    return null
+  }
+
+  return mapping[cotisation.value.alertState as ALERT_STATE_COTISATION]
+})
+
+</script>
+
+<style scoped lang="scss">
+  :deep(.clickable:hover) {
+    text-decoration: none !important;
+  }
 </style>

+ 9 - 18
components/Layout/AlertBar/Env.vue

@@ -5,26 +5,17 @@ Barre d'alerte qui s'affiche lorsque l'utilisateur n'est pas dans un environneme
 -->
 
 <template>
-  <UiSystemBar v-if="show" background-color="ot-warning" text-color="ot-white">
-    <template #bar.text>
-      <v-icon small>fas fa-exclamation-triangle</v-icon>
-      {{ $t('not_production_environment', { env }) }}
-    </template>
-  </UiSystemBar>
+  <UiSystemBar
+      v-if="show"
+      :text="$t('not_production_environment', { env: env })"
+      icon="fas fa-exclamation-triangle"
+      class="theme-warning"
+  />
 </template>
 
 <script setup lang="ts">
-  const env = process.env.NODE_ENV
+  const runtimeConfig = useRuntimeConfig()
+
+  const env = runtimeConfig.env ?? 'unknown'
   const show = env !== 'production'
 </script>
-
-<style scoped lang="scss">
-.v-system-bar {
-  font-size: 14px;
-}
-
-.v-icon {
-  height: 20px;
-  margin: 0 6px;
-}
-</style>

+ 41 - 0
components/Layout/AlertBar/OnlineRegistration.vue

@@ -0,0 +1,41 @@
+<!--
+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"
+  />
+</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";
+
+const { fetch } = useEntityFetch()
+
+const accessProfile = useAccessProfileStore()
+
+const { data: registrationAvailability, pending } = fetch(RegistrationAvailability, accessProfile.id ?? 0)
+
+const show: ComputedRef<boolean> = computed(() => {
+  return !pending && (registrationAvailability.value as RegistrationAvailability).available
+})
+
+const message: ComputedRef<string> = computed(() => {
+  return (registrationAvailability.value as RegistrationAvailability).message
+})
+
+
+</script>
+
+<style scoped lang="scss">
+
+</style>

+ 45 - 0
components/Layout/AlertBar/RegistrationStatus.vue

@@ -0,0 +1,45 @@
+<!--
+Barre d'alerte quand au statut (l'avancement) de l'inscription en ligne de l'utilisateur
+
+-->
+
+<template>
+  <UiSystemBar
+      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";
+
+const { fetch } = useEntityFetch()
+
+const accessProfile = useAccessProfileStore()
+
+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",
+}
+
+const message: ComputedRef<string> = computed(() => {
+  if (!registrationStatus.value) {
+    return ''
+  }
+  const status = (registrationStatus.value as RegistrationStatus).status
+  return status ? messagesByStatus[status] : ''
+})
+</script>
+
+<style scoped lang="scss">
+
+</style>

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

@@ -6,24 +6,19 @@ Barre d'alerte qui s'affiche lorsque l'utilisateur est un super admin en mode sw
 
 <template>
   <!-- TODO : fonctionnement à valider -->
-  <UiSystemBar v-if="show" color="ot-danger">
-    <template #bar.text>
-      <v-icon small>
-        fas fa-exclamation-triangle
-      </v-icon>
-
-      <span>{{ $t('super_admin_switch_account') }}</span>
-
-      <a v-if="url" :href="url" class="text-ot-black text-decoration-none">
-        <strong>{{ $t('click_here') }}</strong>
-      </a>
-    </template>
+  <UiSystemBar v-if="show" class="theme-danger">
+    <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 Url from "~/services/utils/url";
+  import UrlUtils from "~/services/utils/urlUtils";
   import {ComputedRef} from "@vue/reactivity";
 
   const runtimeConfig = useRuntimeConfig()
@@ -41,7 +36,7 @@ Barre d'alerte qui s'affiche lorsque l'utilisateur est un super admin en mode sw
     const originalAccessId = accessProfile.originalAccess ? accessProfile.originalAccess.id : null
 
     if (show && orgId && originalAccessId) {
-      return Url.join(baseLegacyUrl, 'switch_user', orgId, originalAccessId, 'exit')
+      return UrlUtils.join(baseLegacyUrl, '#', 'switch_user', orgId, originalAccessId, 'exit')
     }
     return ''
   })

+ 8 - 10
components/Layout/AlertBar/SwitchUser.vue

@@ -5,14 +5,11 @@ Barre qui s'affiche lorsque l'utilisateur possède un compte multi user
 -->
 
 <template>
-  <!-- TODO : fonctionnement à valider -->
-  <UiSystemBar v-if="show" color="ot-info">
-    <template #bar.text>
-      <v-icon small icon="fas fa-info-circle" />
-      <span v-html="$t('multi_account_alert', { fullname })" />
-      <v-icon class="ml-1" small icon="fa fa-users" />
-      {{$t('multi_account_alert_next')}}
-    </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')}}
   </UiSystemBar>
 </template>
 
@@ -23,8 +20,9 @@ Barre qui s'affiche lorsque l'utilisateur possède un compte multi user
   const accessProfile = useAccessProfileStore()
   const { hasMenu } = useMenu()
 
-  const show = hasMenu('Family')
-  const fullname = `${accessProfile.givenName} ${accessProfile.name}`
+  const show = computed(() => hasMenu('MyFamily'))
+
+  const fullName = `${accessProfile.givenName} ${accessProfile.name}`
 </script>
 
 <style scoped lang="scss">

+ 32 - 15
components/Layout/AlertBar/SwitchYear.vue

@@ -6,16 +6,14 @@ 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" color="ot-warning">
-    <template #bar.text>
-      {{$t('not_current_year')}}
-
-      <a @click="resetYear">
-        <strong class="text-ot-black">
-          {{$t('not_current_year_reset')}}
-        </strong>
-      </a>
-    </template>
+  <UiSystemBar v-if="show" class="theme-warning flex-column">
+    {{$t('not_current_year')}}
+
+    <a @click="resetYear" class="text-decoration-none on-warning" style="cursor: pointer;">
+      <strong class="pl-2 text-neutral-strong">
+        {{$t('not_current_year_reset')}}
+      </strong>
+    </a>
   </UiSystemBar>
 </template>
 
@@ -24,10 +22,15 @@ Barre d'alerte qui s'affiche lorsque l'utilisateur n'est pas sur l'année couran
   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";
 
+  const { em } = useEntityManager()
   const accessProfile = useAccessProfileStore()
   const organizationProfile = useOrganizationProfileStore()
   const { setDirty } = useFormStore()
+  const pageStore = usePageStore()
 
   const show: ComputedRef<boolean> = computed(() => {
     return (
@@ -39,14 +42,24 @@ Barre d'alerte qui s'affiche lorsque l'utilisateur n'est pas sur l'année couran
   })
 
   const resetYear = async () => {
-    accessProfile.setHistorical(false, true, false)
-
-    if (organizationProfile.currentActivityYear) {
-      accessProfile.activityYear = organizationProfile.currentActivityYear
-    }
+      const defaultValues = {
+        historical: {
+            "future": false,
+            "past": false,
+            "present": true,
+        },
+        activityYear: organizationProfile.currentActivityYear
+      }
 
+    // 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()
   }
 </script>
@@ -60,4 +73,8 @@ Barre d'alerte qui s'affiche lorsque l'utilisateur n'est pas sur l'année couran
   height: 20px;
   margin: 0 6px;
 }
+
+#resetLink:hover {
+  cursor: pointer;
+}
 </style>

+ 7 - 4
components/Layout/BannerTop.vue

@@ -1,14 +1,17 @@
-<!-- Troisième bandeau en partant du haut, contenant entre autre le numéro SIRET de l'organisation -->
+<!--
+Troisième bandeau en partant du haut, contenant entre autre le numéro SIRET de l'organisation
+-->
+<!-- TODO: voir si possible de trouver un nom plus spécifique -->
 
 <template>
   <v-row justify="center" align="center" class="bannerTopForm">
-    <v-col cols="3" class="ot-dark_grey ot-white--text">
+    <v-col cols="3" class="theme-neutral-strong">
       <slot name="block1" />
     </v-col>
-    <v-col cols="6" class="ot-white ot-grey--text">
+    <v-col cols="6" class="theme-neutral">
       <slot name="block2" />
     </v-col>
-    <v-col cols="3" class="ot-light_grey ot-grey--text">
+    <v-col cols="3" class="theme-neutral-soft">
       <slot name="block3" />
     </v-col>
   </v-row>

+ 18 - 11
components/Layout/Dialog.vue

@@ -3,18 +3,18 @@
   <v-dialog
     :model-value="show"
     persistent
-    max-width="800"
+    :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">
+      <div :class="'dialog-type flex-column justify-center d-none d-sm-flex theme-' + theme">
         <h3 class="d-flex">
           <slot name="dialogType" />
         </h3>
       </div>
 
       <div class="dialog-container d-flex flex-column flex-grow-1">
-        <v-card-title class="dialog-title">
+        <v-card-title class="dialog-title theme-neutral">
           <slot name="dialogTitle" />
         </v-card-title>
 
@@ -34,8 +34,6 @@
 </template>
 
 <script setup lang="ts">
-import {useDisplay} from "vuetify";
-
 const props = defineProps({
   show: {
     type: Boolean,
@@ -44,21 +42,30 @@ const props = defineProps({
   contentClass: {
     type: String,
     required: false
+  },
+  theme: {
+    type: String,
+    required: false,
+    default: 'primary',
+  },
+  maxWidth: {
+    type: [Number, String],
+    required: false,
+    default: 800
   }
 })
 </script>
 
 <style lang="scss" scoped>
   .dialog-title {
-    background: #e6e6e6;
     padding-left: 40px;
     font-weight: normal;
   }
 
   .dialog-type {
-    background: rgb(var(--v-theme-ot-green, #00AD8E));
-    color: #fff;
-    width: 50px;
+    width: 60px;
+    min-width: 60px;
+    max-width: 60px;
     min-height: 400px;
     padding: 25px 10px;
 
@@ -77,13 +84,13 @@ const props = defineProps({
 
   .modal-level-alert {
     .dialog-type{
-      background: rgb(var(--v-theme-ot-danger, #f56954));
+      background: rgb(var(--v-theme-danger, #f56954));
     }
   }
 
   .modal-level-warning {
     .dialog-type{
-      background: rgb(var(--v-theme-ot-warning, #f39c12));
+      background: rgb(var(--v-theme-warning, #f39c12));
     }
   }
 </style>

+ 23 - 24
components/Layout/Header.vue

@@ -7,42 +7,43 @@ Contient entre autres le nom de l'organisation, l'accès à l'aide et aux préf
   <v-app-bar
       order="0"
       density="compact"
-      class="bg-ot-green text-ot-white"
+      class="theme-primary"
   >
     <template #prepend>
       <v-app-bar-nav-icon
           v-if="hasMainMenu"
           :icon="isMainMenuOpened ? 'mdi:mdi-menu-open' : 'mdi:mdi-menu'"
-          class="text-ot-white"
           @click="toggleMainMenu"
       />
     </template>
 
     <v-toolbar-title v-if="mdAndUp" v-text="title"/>
 
-    <LayoutHeaderUniversalCreationCreateButton v-if="showUniversalButton" />
+    <LayoutThemeSwitcher v-if="false" /> <!-- En attente validation PO -->
 
-    <LayoutHeaderHomeBtn />
+    <LayoutHeaderUniversalCreationCreateButton v-if="showUniversalButton" class="mr-3" />
 
-    <LayoutHeaderMenu name="WebsiteList" />
+    <LayoutHeaderHomeBtn v-if="smAndUp" />
+
+    <LayoutHeaderMenu name="WebsiteList" :translate-label="false" />
 
     <LayoutHeaderMenu name="MyAccesses" />
 
-    <LayoutHeaderMenu name="MyFamily" />
+    <LayoutHeaderMenu name="MyFamily" :translate-label="false" />
 
     <LayoutHeaderNotification />
 
     <LayoutHeaderMenu name="Configuration" />
 
-    <LayoutHeaderMenu name="Account" />
+    <LayoutHeaderMenu name="Account" color="on-primary" icon="fas fa-sun" />
 
     <a
         :href="runtimeConfig.supportUrl"
-        class="text-body pa-3 ml-2 bg-ot-dark-grey text-ot-white text-decoration-none"
+        class="text-body pa-3 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="white" />
+      <v-icon icon="fas fa-question-circle" class="d-sm-flex d-md-none" color="on-secondary" />
     </a>
   </v-app-bar>
 </template>
@@ -52,35 +53,33 @@ Contient entre autres le nom de l'organisation, l'accès à l'aide et aux préf
 import {computed, ComputedRef} from "@vue/reactivity";
 import {useMenu} from "~/composables/layout/useMenu";
 import {useAbility} from "@casl/vue";
-import { useDisplay } from 'vuetify'
+import {useDisplay, useTheme} from 'vuetify'
 import {useOrganizationProfileStore} from "~/stores/organizationProfile";
 
 const organizationProfile = useOrganizationProfileStore()
 const runtimeConfig = useRuntimeConfig()
-
 const title: ComputedRef<string> = computed(() => organizationProfile.name ?? 'Opentalent')
 
 const { hasMenu, isMenuOpened, toggleMenu } = useMenu()
 
-const { mdAndUp } = useDisplay()
+const { smAndUp, mdAndUp } = useDisplay()
 
 const hasMainMenu = computed(() => hasMenu('Main'))
 const isMainMenuOpened = computed(() => isMenuOpened('Main'))
 const toggleMainMenu = () => toggleMenu('Main')
 
-const { can } = useAbility()
-
+const ability = useAbility()
 const showUniversalButton =
-    can('manage', 'users')
-  || can('manage', 'courses')
-  || can('manage', 'examens')
-  || can('manage', 'educationalprojects')
-  || can('manage', 'events')
-  || can('manage', 'emails')
-  || can('manage', 'mails')
-  || can('manage', 'texto')
-  || can('display', 'message_send_page')
-  || 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')
 
 </script>
 

+ 6 - 3
components/Layout/Header/HomeBtn.vue

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

+ 35 - 21
components/Layout/Header/Menu.vue

@@ -9,8 +9,8 @@ header principal (configuration, paramètres du compte...)
     <v-btn
         ref="btn"
         icon
-        width="48px"
         size="small"
+        class="ml-2"
     >
       <v-avatar
           v-if="menu.icon.avatarId || menu.icon.avatarByDefault"
@@ -26,7 +26,7 @@ header principal (configuration, paramètres du compte...)
       <v-icon
           v-else
           :icon="menu.icon.name"
-          class="text-ot-white"
+          class="on-primary"
       />
     </v-btn>
 
@@ -42,7 +42,7 @@ header principal (configuration, paramètres du compte...)
         @update:modelValue="onStateUpdated"
     >
       <v-card>
-        <v-card-title class="ot-header-menu text-body-2 font-weight-bold">
+        <v-card-title class="theme-neutral text-body-2 font-weight-bold">
           {{$t(menu.label)}}
         </v-card-title>
 
@@ -54,25 +54,23 @@ header principal (configuration, paramètres du compte...)
                   :href="!isInternalLink(child) ? child.to : undefined"
                   :to="isInternalLink(child) ? child.to : undefined"
               >
-                <v-list-item-title>
-                    <span v-if="child.icon">
-                      <v-avatar v-if="menu.icon.avatarId || child.icon.avatarByDefault" size="30">
-                        <UiImage :id="child.icon.avatarId" :defaultImage="child.icon.avatarByDefault" :width="30"></UiImage>
-                      </v-avatar>
-                      <v-icon v-else class="text-ot-white" size="small">
-                        {{ child.icon.name }}
-                      </v-icon>
-                    </span>
-
-                  <span>{{$t(child.label)}}</span>
-                </v-list-item-title>
+                <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-icon v-else class="on-primary" size="small">
+                    {{ child.icon.name }}
+                  </v-icon>
+                </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 actions">
+        <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"
@@ -96,12 +94,17 @@ const props = defineProps({
   name: {
     type: String,
     required: true
+  },
+  translateLabel: {
+    type: Boolean,
+    required: false,
+    default: true
   }
 })
 
-const { buildMenu, isInternalLink, hasMenu, setMenuState, isMenuOpened } = useMenu()
+const { getMenu, isInternalLink, hasMenu, setMenuState, isMenuOpened } = useMenu()
 
-const menu = buildMenu(props.name)
+const menu = getMenu(props.name)
 const displayMenu = computed(() => hasMenu(props.name))
 const isOpened = () => isMenuOpened(props.name)
 
@@ -114,8 +117,19 @@ const btn = ref(null)
 </script>
 
 <style scoped lang="scss">
-  .actions {
-    background: rgb(var(--v-theme-ot-green));
-    color: white;
+  :deep(.v-btn .v-icon) {
+    font-size: 1rem !important;
+  }
+
+  .v-list {
+    padding: 0;
+  }
+
+  .v-list-item {
+    width: 100%;
+  }
+
+  .header-menu .v-list .v-list-item:last-child {
+    border-bottom: none;
   }
 </style>

+ 92 - 76
components/Layout/Header/Notification.vue

@@ -2,47 +2,44 @@
   <v-btn
       ref="btn"
       icon
-      width="48px"
       size="small"
+      class="ml-2"
   >
     <v-badge
-        color="orange"
+        color="warning"
         offset-x="-4"
         offset-y="17"
         :model-value="unreadNotification.length > 0"
         :content="unreadNotification.length">
-      <v-icon class="text-ot-white">
+      <v-icon class="on-primary">
         fa fa-bell
       </v-icon>
     </v-badge>
   </v-btn>
 
-  <v-tooltip :activator="btn" location="bottom">
+  <v-tooltip v-if="btn !== null" :activator="btn" location="bottom">
     <span>{{ $t('notification') }}</span>
   </v-tooltip>
 
   <v-menu
+      v-if="btn !== null"
       :activator="btn"
       v-model="isOpen"
+      location="bottom left"
   >
     <v-card max-width="400">
-      <v-card-title class="ot-header-menu text-body-2 font-weight-bold">
+      <v-card-title class="bg-neutral text-body-2 font-weight-bold">
         {{ $t('notification') }}
       </v-card-title>
 
       <v-card-text class="ma-0 pa-0 header-menu">
-        <v-list density="compact" :subheader="true">
+        <v-list density="compact" :subheader="true" class="pa-0">
           <v-list-item
               v-for="(notification, index) in notifications"
               :key="index"
-              :class="`${notification.notificationUsers.length === 0 ? 'unread' : ''}`"
+              :class="'list_item py-3' + `${notification.notificationUsers.length === 0 ? ' unread' : ''}`"
           >
-
-            <v-list-item-title
-                class="list_item mt-2 mb-2"
-                v-text="getMessage(notification)"
-                style="font-size: 13px; line-height: 16px;"
-            />
+            <span class="">{{ getMessage(notification) }}</span>
 
             <template #append>
               <v-icon
@@ -53,36 +50,39 @@
               />
             </template>
 
-<!--            <v-divider v-if="index < (notifications.length - 1)"/>-->
           </v-list-item>
-        </v-list>
 
-        <!--suppress VueUnrecognizedDirective -->
-        <v-card v-intersect="update"></v-card>
+          <v-divider></v-divider>
+
+          <!--suppress VueUnrecognizedDirective -->
+          <span v-intersect="onLastNotificationIntersect" />
+
+          <v-row
+              v-if="pending"
+              class="fill-height mt-3 mb-3"
+              align="center"
+              justify="center"
+          >
+            <v-progress-circular
+                indeterminate
+                color="neutral"
+            />
+          </v-row>
+
+        </v-list>
 
-        <v-row
-          v-if="pending"
-          class="fill-height mt-3 mb-3"
-          align="center"
-          justify="center"
-        >
-          <v-progress-circular
-            indeterminate
-            color="grey lighten-1"
-          />
-        </v-row>
       </v-card-text>
 
       <v-card-actions class="ma-0 pa-0">
         <v-list-item
-          id="all_notifications"
           :key="$t('all_notification')"
           :href="notificationUrl"
           router
+          class="theme-primary"
           style="width: 100%; height: 52px;"
         >
           <v-list-item-title
-              class="text-body-2 text-ot-white"
+              class="text-body-2"
               v-text="$t('all_notification')"
           />
         </v-list-item>
@@ -92,15 +92,17 @@
 </template>
 
 <script setup lang="ts">
-import {NOTIFICATION_TYPE, QUERY_TYPE} from "~/types/enum/enums";
-import {Notification} from "~/models/Core/Notification";
-import {NotificationUsers} from "~/models/Core/NotificationUsers";
+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 {ComputedRef, Ref, ref} from "@vue/reactivity";
+import {computed, ComputedRef, Ref, ref} from "@vue/reactivity";
 import {useEntityFetch} from "~/composables/data/useEntityFetch";
-import {Pagination} from "~/types/data";
+import {AnyJson, Pagination} from "~/types/data";
 import {useEntityManager} from "~/composables/data/useEntityManager";
-import Url from "~/services/utils/url";
+import UrlUtils from "~/services/utils/urlUtils";
+import {useRepo} from "pinia-orm";
+import NotificationRepository from "~/stores/repositories/NotificationRepository";
 
 const accessProfileStore = useAccessProfileStore()
 
@@ -115,46 +117,59 @@ const btn = ref(null)
 
 const { em } = useEntityManager()
 const { fetchCollection } = useEntityFetch()
+const notificationRepo = useRepo(NotificationRepository)
 
-let { data: collection, pending, refresh } = await fetchCollection(Notification)
+const query: ComputedRef<AnyJson> = computed(() => {
+  return { 'page': page.value }
+})
+
+let { data: collection, pending, refresh } = await 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)
+ */
+const notifications: ComputedRef<Array<Notification>> = computed(() => {
+  return notificationRepo.getNotifications()
+})
 
 /**
- * On récupère les Notifications via le store
+ * Retourne les notifications non lues
  */
-const notifications: ComputedRef = computed(() => {
-  const items = collection.value !== null ? collection.value.items : []
-  items.sort((a, b) => b.id - a.id ) // reverse sort by id
-  return items
+const unreadNotification: ComputedRef<Array<Notification>> = computed(() => {
+  return notificationRepo.getUnreadNotifications()
 })
 
 /**
  * Les metadata dépendront de la dernière valeur du GET lancé
  */
 const pagination: ComputedRef<Pagination> = computed(() => {
-  return collection.value !== null ? collection.value.pagination : {}
+  return (!pending.value && collection.value !== null) ? collection.value.pagination : {}
 })
 
+const notificationUrl = UrlUtils.join(runtimeConfig.baseUrlAdminLegacy, '#/notifications/list/')
+
 /**
- * On calcule le nombre de notifications non lues
+ * L'utilisateur a fait défiler le menu jusqu'à la dernière notification affichée
+ * @param isIntersecting
  */
-const unreadNotification: ComputedRef<Array<Notification>> = computed(() => {
-  return notifications.value.filter((notification: Notification) => {
-    return notification.notificationUsers.length === 0
-  })
-})
+const onLastNotificationIntersect = (isIntersecting: boolean) => {
+  if (isIntersecting) {
+    update()
+  }
+}
 
 /**
- * Lorsque l'utilisateur scroll on regarde la nextPage a charger et on le fait que si le pending du fetch est false
+ * Lorsque l'utilisateur scroll on regarde la nextPage à charger et on le fait que si le pending du fetch est false
  * (si on a fini de télécharger les éléments précédents)
  */
 const update = async () => {
   if (
-      !pending &&
+      !pending.value &&
       pagination.value &&
       pagination.value.next &&
       pagination.value.next > 0
   ) {
-    loading.value = true
+    pending.value = true
     page.value = pagination.value.next
 
     await refresh()
@@ -190,10 +205,10 @@ const getMessage = (notification: Notification) => {
 }
 
 /**
- * Dès l'ouverture du menu, on indique que les notifications non lues, le sont.
+ * Dès la fermeture du menu, on indique que les notifications non lues, le sont.
  */
 const unwatch = watch(isOpen, (newValue, oldValue) => {
-  if (newValue){
+  if (!newValue){
     markNotificationsAsRead()
   }
 })
@@ -202,30 +217,29 @@ onUnmounted(() => {
 })
 
 /**
- * Créer une nouvelle notification users coté back.
- * @param notification
- * @param accessId
+ * Marque une notification comme lue
  */
-const createNewNotificationUsers = (notification: Notification, accessId: number) => {
+const markNotificationAsRead = (notification: Notification) => {
+  if (accessProfileStore.id === null) {
+    throw new Error('Current access id is null')
+  }
   const notificationUsers = em.newInstance(NotificationUsers, {
-    access:`/api/accesses/${accessId}`,
+    access:`/api/accesses/${accessProfileStore.switchId ?? accessProfileStore.id}`,
     notification:`/api/notifications/${notification.id}`,
     isRead: true
   })
 
   em.persist(NotificationUsers, notificationUsers)
   notification.notificationUsers = ['read']
+  em.save(Notification, notification)
 }
 
 /**
- * Marque les notifications non lues comme lues
+ * Marque toutes les notifications non lues comme lues
  */
 const markNotificationsAsRead = () => {
   unreadNotification.value.map((notification: Notification) => {
-    if (accessProfileStore.id === null) {
-      throw new Error('Current access id is null')
-    }
-    createNewNotificationUsers(notification, accessProfileStore.id)
+    markNotificationAsRead(notification)
   })
 }
 
@@ -237,29 +251,31 @@ const download = (link: string) => {
   if (accessProfileStore.id === null) {
     throw new Error('Current access id is null')
   }
-  const url_parts: Array<string> = link.split('/api');
+  // 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];
 
-  if(accessProfileStore.originalAccess)
-    url_parts[0] = Url.join('api', String(accessProfileStore.originalAccess.id), String(accessProfileStore.id))
-  else
-    url_parts[0] = Url.join('api', String(accessProfileStore.id))
+  // 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
+  )
 
-  window.open(Url.join(runtimeConfig.baseUrlLegacy, url_parts.join('')));
+  window.open(url);
 }
 
-const notificationUrl = Url.join(runtimeConfig.baseUrlAdminLegacy, 'notifications/list/')
 
 </script>
 
 <style scoped lang="scss">
-  #all_notifications{
-    background: rgb(var(--v-theme-ot-green, white));
-    color: white;
-  }
   .list_item{
     white-space: normal;
   }
   .unread{
-    background: #ecf0f5;
+    background: rgb(var(--v-theme-neutral-soft, white));
   }
 </style>

+ 138 - 0
components/Layout/Header/UniversalCreation/Card.vue

@@ -0,0 +1,138 @@
+<!--
+  VCard proposant une option dans la boite de dialogue "Assistant de création"
+
+  La carte peut prendre en paramètres des options `to` et `href` :
+  Si `to` est défini et pas `href`, la carte mène simplement à un nouveau menu dans le wizard.
+  Si `href` est défini, et pas `to`, la carte se comporte comme un lien, et doit rediriger la page vers `href` au clic.
+  Enfin, si les deux sont définis, l'url est envoyée au composant parent pour être mémorisée, et la carte mène ensuite à
+  un nouveau menu dans le wizard, qui permettra d'affiner l'url, par exemple en lui ajoutant une query.
+-->
+
+<template>
+  <v-card
+    class="col-md-6"
+    color=""
+    flat
+    border="solid 1px"
+    @click="onClick"
+  >
+    <v-row no-gutters 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"
+        />
+      </v-col>
+      <v-col
+          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">
+          {{ $t(textContent) }}
+        </p>
+      </v-col>
+    </v-row>
+  </v-card>
+</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";
+
+  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 { makeAdminUrl } = useAdminUrl()
+
+  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
+    }
+  }
+
+  const onClick = () => {
+    emit('click', props.to, url)
+  }
+</script>
+
+<style lang="scss" scoped>
+  h4 {
+    font-size: 15px;
+    font-weight: bold;
+    margin-bottom: 6px;
+  }
+
+  p {
+    font-size: 13px;
+  }
+
+  .infos-container {
+    padding: 15px 0;
+  }
+
+  .v-card:hover {
+    cursor: pointer;
+    background: rgb(var(--v-theme-primary-alt));
+  }
+</style>

+ 147 - 30
components/Layout/Header/UniversalCreation/CreateButton.vue

@@ -1,48 +1,61 @@
 <!--
-bouton Créer
+  Bouton Créer du header de l'application et boite de dialogue associée
 -->
 
 <template>
   <main>
     <v-btn
-      elevation="2"
-      class="bg-ot-warning text-ot-white"
-      @click="showDialog=true"
+        v-if="asIcon"
+        :elevation="0"
+        class="theme-primary"
+        :icon="true"
+        size="small"
+        @click="show"
     >
-      {{ $t('create') }}
+      <v-icon>fas fa-plus</v-icon>
     </v-btn>
 
-    <LayoutDialog :show="showDialog" >
+    <v-btn
+        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" >
       <template #dialogType>{{ $t('creative_assistant') }}</template>
 
       <template #dialogTitle>
-        <span v-if="type === 'home'">{{ $t('what_do_you_want_to_create') }}</span>
-        <span v-else-if="type === 'access'">{{ $t('universal_create_title_access') }}</span>
-        <span v-else-if="type === 'event'">{{ $t('universal_create_title_event') }}</span>
-        <span v-else-if="type === 'message'">{{ $t('universal_create_title_message') }}</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>
-        <!-- TODO: réactiver ce component quand les v-steper seront implémentés dans vuetify 3 -->
-        <!-- <LayoutHeaderUniversalCreationGenerateCardsSteps :step="step" @updateStep="updateStep" /> -->
-        <span>TEMP</span>
+         <LayoutHeaderUniversalCreationGenerateCardsSteps
+             :path="path"
+             @cardClick="onCardClick"
+             @urlUpdate="onUrlUpdate"
+         />
       </template>
 
       <template #dialogBtn>
         <div class="text-center">
-          <v-btn
-            class="bg-ot-super-light-grey text-ot-black"
-            @click="showDialog=false; step=1; type='home'"
-          >
+          <v-btn class="theme-neutral-soft" @click="close" >
             {{ $t('cancel') }}
           </v-btn>
 
-          <v-btn
-            v-if="step > 1"
-            class="bg-ot-super-light-grey text-ot-black"
-            @click="step=1; type='home'"
-          >
-            {{ $t('previous') }}
+          <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" >
+            {{ $t('validate') }}
           </v-btn>
         </div>
       </template>
@@ -52,16 +65,120 @@ bouton Créer
 
 <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
+  }
+
+  const pageStore = usePageStore()
 
-  const showDialog: Ref<Boolean> = ref(false);
-  const step: Ref<Number> = ref(1);
-  const type: Ref<String> = ref('home');
+  /**
+   * 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)
+  }
 
-  const updateStep = ({stepChoice, typeChoice}: any) =>{
-    step.value = stepChoice
-    type.value = typeChoice
+  /**
+   * 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>
 
+<style scoped lang="scss">
+  :deep(.v-btn .v-icon) {
+    font-size: 16px !important;
+  }
+  :deep(.v-btn) {
+    text-transform: none !important;
+    font-weight: 600;
+  }
 </style>

+ 119 - 0
components/Layout/Header/UniversalCreation/EventParams.vue

@@ -0,0 +1,119 @@
+<!--
+Event parameters page in the create dialog
+-->
+
+<template>
+  <v-container class="pa-4">
+    <v-row class="align-center">
+      <v-col cols="2">
+        <span>{{ $t('start_on') }}</span>
+      </v-col>
+
+      <v-col cols="6">
+        <UiDatePicker v-model="eventStart" with-time />
+      </v-col>
+    </v-row>
+
+    <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') }}
+      </v-col>
+    </v-row>
+
+    <v-row class="align-center">
+      <v-col cols="2">
+        <span>{{ $t('for_time') }}</span>
+      </v-col>
+
+      <v-col cols="10" class="d-flex flex-row align-center">
+        <UiInputNumber v-model="eventDurationDays" class="mx-3" :min="0" />
+        <span>{{ $t('day(s)') }}</span>
+
+        <UiInputNumber v-model="eventDurationHours" class="mx-3" :min="0" />
+        <span>{{ $t('hour(s)') }}</span>
+
+        <UiInputNumber v-model="eventDurationMinutes" class="mx-3" :min="0" />
+        <span>{{ $t('minute(s)') }}</span>
+      </v-col>
+
+    </v-row>
+
+    <v-row>
+      <v-col cols="2">
+        <span>{{ $t('ending_date') }}</span>
+      </v-col>
+
+      <v-col cols="6" class="endDate">
+        <span>{{ formattedEventEnd }}</span>
+      </v-col>
+    </v-row>
+  </v-container>
+</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
+  })
+
+  // 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()
+  })
+</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;
+  }
+</style>

+ 313 - 199
components/Layout/Header/UniversalCreation/GenerateCardsSteps.vue

@@ -1,234 +1,348 @@
 <!--
-
+  Contenu de la boite de dialogue "Assistant de création"
 -->
 
 <template>
-  <v-stepper v-model="step">
-    <v-stepper-items>
-      <v-stepper-content step="1">
-        <div class="row">
-          <LayoutHeaderUniversalCreationTypeCard
-            v-if="can('manage', 'users')"
-            title="a_person"
-            text-content="add_new_person_student"
-            icon="fa fa-user"
-            type="access"
-            @typeClick="onTypeClick"
+
+  <!-- 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"
           />
+      </v-col>
 
-          <LayoutHeaderUniversalCreationTypeCard
-            v-if="can('display', 'agenda_page')
-                  && (can('display', 'course_page')
-                  || can('display', 'exam_page')
-                  || can('display', 'pedagogics_project_page'))"
+      <!-- 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')
+                )">
+        <LayoutHeaderUniversalCreationCard
+            to="event"
             title="an_event"
             text-content="add_an_event_course"
             icon="fa fa-calendar-alt"
-            type="event"
-            @typeClick="onTypeClick"
-          />
+            @click="onCardClick"
+        />
+      </v-col>
 
-          <LayoutHeaderUniversalCreationTypeCard
-            v-else-if="can('display', 'agenda_page') && can('manage', 'events')"
+      <!-- Autre évènement -->
+      <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"
-            :link="adminLegacy + '/calendar/create/events'"
-          />
+            href="/calendar/create/events"
+            @click="onCardClick"
+        />
+      </v-col>
 
-          <LayoutHeaderUniversalCreationTypeCard
-            v-if="can('display', 'message_send_page')
-                  && (can('manage', 'emails')
-                  || can('manage', 'mails')
-                  || can('manage', 'texto'))"
-            title="a_correspondence"
-            text-content="sen_email_letter"
-            icon="fa fa-comment"
-            type="message"
-            @typeClick="onTypeClick"
-          />
+      <!-- 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')
+                  )">
+        <LayoutHeaderUniversalCreationCard
+          to="message"
+          title="a_correspondence"
+          text-content="send_email_letter"
+          icon="fa fa-comment"
+          type="message"
+          @click="onCardClick"
+        />
+      </v-col>
 
-          <LayoutHeaderUniversalCreationTypeCard
-            v-if="can('manage', 'equipments')"
-            title="a_materiel"
-            text-content="add_any_type_material"
-            icon="fa fa-cube"
-            :link="adminLegacy + '/list/create/equipment'"
-          />
-        </div>
-      </v-stepper-content>
-
-      <v-stepper-content step="2">
-        <div class="row">
-          <div v-if="type === 'access'" class="row">
-            <LayoutHeaderUniversalCreationTypeCard
-                v-if="isLaw1901"
-                title="an_adherent"
-                text-content="adherent_text_creation_card"
-                icon="fa fa-user"
-                :link="adminLegacy + '/universal_creation_person/adherent'" />
-
-            <LayoutHeaderUniversalCreationTypeCard
-                v-if="isLaw1901"
-                title="a_ca_member"
-                text-content="ca_member_text_creation_card"
-                icon="fa fa-users"
-                :link="adminLegacy + '/universal_creation_person/ca_member'" />
-
-            <LayoutHeaderUniversalCreationTypeCard
-                title="a_student"
-                text-content="student_text_creation_card"
-                icon="fa fa-user"
-                :link="adminLegacy + '/universal_creation_person/student'" />
-
-            <LayoutHeaderUniversalCreationTypeCard
-                title="a_guardian"
-                text-content="guardian_text_creation_card"
-                icon="fa fa-female"
-                :link="adminLegacy + '/universal_creation_person/guardian'" />
-
-            <LayoutHeaderUniversalCreationTypeCard
-                title="a_teacher"
-                text-content="teacher_text_creation_card"
-                icon="fa fa-graduation-cap"
-                :link="adminLegacy + '/universal_creation_person/teacher'" />
-
-            <LayoutHeaderUniversalCreationTypeCard
-                title="a_member_of_staff"
-                text-content="personnel_text_creation_card"
-                icon="fa fa-suitcase"
-                :link="adminLegacy + '/universal_creation_person/personnel'" />
-
-            <LayoutHeaderUniversalCreationTypeCard
-                title="a_legal_entity"
-                text-content="moral_text_creation_card"
-                icon="fa fa-building"
-                :link="adminLegacy + '/universal_creation_person/company'" />
-
-            <LayoutHeaderUniversalCreationTypeCard
-                title="another_type_of_contact"
-                text-content="other_contact_text_creation_card"
-                icon="fa fa-plus"
-                :link="adminLegacy + '/universal_creation_person/other_contact'" />
-          </div>
-
-          <div v-if="type === 'event'" class="row">
-            <LayoutHeaderUniversalCreationTypeCard
-                v-if="can('display', 'course_page')"
-                title="course"
-                text-content="course_text_creation_card"
-                icon="fa fa-users"
-                :link="adminLegacy + '/calendar/create/courses'" />
-
-            <LayoutHeaderUniversalCreationTypeCard
-                v-if="can('display', 'exam_page')"
-                title="exam"
-                text-content="exam_text_creation_card"
-                icon="fa fa-graduation-cap"
-                :link="adminLegacy + '/calendar/create/examens'" />
-
-            <LayoutHeaderUniversalCreationTypeCard
-                v-if="can('display', 'pedagogics_project_page')"
-                title="educational_services"
-                text-content="educational_services_text_creation_card"
-                icon="fa fa-suitcase"
-                :link="adminLegacy + '/calendar/create/educational_projects'" />
-
-            <LayoutHeaderUniversalCreationTypeCard
-                v-if="can('manage', 'events')"
-                title="other_event"
-                text-content="other_event_text_creation_card"
-                icon="far fa-calendar"
-                :link="adminLegacy + '/calendar/create/events'" />
-          </div>
-
-          <div v-if="type === 'message'" class="row">
-            <LayoutHeaderUniversalCreationTypeCard
-                v-if="can('manage', 'emails')"
-                title="an_email"
-                text-content="email_text_creation_card"
-                icon="far fa-envelope"
-                :link="adminLegacy + '/list/create/emails'" />
-
-            <LayoutHeaderUniversalCreationTypeCard
-                v-if="can('manage', 'mails')"
-                title="a_letter"
-                text-content="letter_text_creation_card"
-                icon="far fa-file-alt"
-                :link="adminLegacy + '/list/create/mails'" />
-
-            <LayoutHeaderUniversalCreationTypeCard
-                v-if="can('manage', 'texto')"
-                title="an_sms"
-                text-content="sms_text_creation_card"
-                icon="fa fa-mobile-alt"
-                :link="adminLegacy + '/list/create/sms'" />
-          </div>
-        </div>
-
-      </v-stepper-content>
-    </v-stepper-items>
-  </v-stepper>
+      <!-- Un matériel (direct link) -->
+      <v-col cols="6" v-if="ability.can('manage', 'equipments')">
+        <LayoutHeaderUniversalCreationCard
+          title="a_materiel"
+          text-content="add_any_type_material"
+          icon="fa fa-cube"
+          href="/list/create/equipment"
+          @click="onCardClick"
+        />
+      </v-col>
+    </v-row>
+  </v-container>
+
+  <!-- Menu "Créer une personne" -->
+  <v-container v-if="location === 'access'">
+    <v-row>
+      <!-- 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"
+        />
+      </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"
+        />
+      </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"
+        />
+      </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"
+        />
+      </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"
+        />
+      </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"
+        />
+      </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"
+        />
+      </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"
+        />
+      </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"
+        />
+      </v-col>
+    </v-row>
+  </v-container>
+
+  <!-- Menu Évènement -->
+  <v-container v-if="location === 'event'">
+    <v-row>
+      <!-- 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"
+        />
+      </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"
+        />
+      </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"
+        />
+      </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"
+        />
+      </v-col>
+    </v-row>
+  </v-container>
+
+  <!-- Une correspondance -->
+  <v-container v-if="location === 'message'">
+    <v-row>
+      <!-- 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"
+        />
+      </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"
+        />
+      </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"
+        />
+      </v-col>
+    </v-row>
+  </v-container>
+
+  <!-- Page de pré-paramétrage des évènements -->
+  <LayoutHeaderUniversalCreationEventParams
+      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({
-    step: {
-      type: Number,
+    /**
+     * The path that the user followed troughout the wizard
+     */
+    path: {
+      type: Array<string>,
       required: true
     }
   })
 
-  const emit = defineEmits(['updateStep'])
-
-  const { can } = useAbility()
+  const location: ComputedRef<string> = computed(() => {
+    return props.path.at(-1) ?? 'home'
+  })
 
-  const onTypeClick = (step: Number, Cardtype: String) => {
-    type.value = Cardtype;
-    emit('updateStep', { stepChoice: step, typeChoice: Cardtype });
-  }
+  const ability = useAbility()
 
-  const type: Ref<String> = ref('');
   const organizationProfile = useOrganizationProfileStore()
+  const isLaw1901: ComputedRef<boolean> = organizationProfile.isAssociation
+  const hasOnlineRegistrationModule: Ref<boolean> = ref(organizationProfile.hasModule('IEL'))
 
-  const runtimeConfig = useRuntimeConfig()
-  const adminLegacy: Ref<string> = ref(runtimeConfig.baseURL_adminLegacy)
-  const isLaw1901: Ref<boolean> = ref(organizationProfile.isAssociation())
-</script>
+  const baseUrl: Ref<string | null> = ref(null)
+  const query: Ref<Record<string, string>> = ref({})
 
-<style lang="scss" scoped>
-  .creation-type-container{
-    border: none!important;
-    .icon{
-      i{
-        font-size: 50px;
-        color: var(--v-theme-ot-grey, #777777);
-      }
-    }
-    .infos-container{
-      padding: 15px 0;
-      h4{
-        font-size: 15px;
-        color: var(--v-theme-ot-green, #00AD8E);
-        font-weight: bold;
-        margin-bottom: 6px;
-      }
-      p{
-        font-size: 13px;
-        padding: 0;
-        margin: 0;
-        color: #767676;
-      }
+  const url: ComputedRef<string | null> = computed(() => {
+    if (baseUrl.value === null) {
+      return null
     }
-    &>div{
-      &:hover{
-        cursor: pointer;
-        background: var(--v-theme-ot-light_green, #a9e0d6);
-      }
+    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
     }
+    emit('cardClick', to, url.value)
   }
-</style>
+
+  /**
+   * 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>

+ 0 - 91
components/Layout/Header/UniversalCreation/TypeCard.vue

@@ -1,91 +0,0 @@
-<!--
-
--->
-
-<template>
-
-    <v-card
-      class="col-md-6 creation-type-container"
-      color=""
-      :outlined=true
-      :href="link"
-      @click="$emit('typeClick', 2, type)"
-    >
-      <div class="row no-gutters" style="height: 100px">
-        <div class="flex-grow-0 flex-shrink-0 d-flex justify-center col col-3" style="">
-          <div class="icon align-self-center">
-            <i :class="icon" aria-hidden="true"></i>
-          </div>
-        </div>
-        <div class="infos-container flex-grow-1 flex-shrink-1 col col-9" style="">
-          <h4>{{ $t(title) }}</h4>
-          <p>
-            {{ $t(textContent) }}
-          </p>
-        </div>
-      </div>
-    </v-card>
-
-</template>
-
-<script setup lang="ts">
-  const props = defineProps({
-    title: {
-      type: String,
-      required: true
-    },
-    textContent: {
-      type: String,
-      required: true
-    },
-    icon: {
-      type: String,
-      required: true
-    },
-    link: {
-      type: String,
-      required: false
-    },
-    type: {
-      type: String,
-      required: false
-    }
-  })
-</script>
-
-<style lang="scss" scoped>
-.creation-type-container {
-  border: none!important;
-
-  .icon {
-    i{
-      font-size: 50px;
-      color: rgb(var(--v-theme-ot-grey, #777777));
-    }
-  }
-
-  .infos-container {
-    padding: 15px 0;
-
-    h4 {
-      font-size: 15px;
-      color: rgb(var(--v-theme-ot-green, #00AD8E));
-      font-weight: bold;
-      margin-bottom: 6px;
-    }
-    p {
-      font-size: 13px;
-      padding: 0;
-      margin: 0;
-      color: #767676;
-    }
-  }
-
-  &>div {
-    &:hover {
-      cursor: pointer;
-      background: rgb(var(--v-theme-ot-light_green, #a9e0d6));
-    }
-  }
-}
-</style>

+ 8 - 19
components/Layout/LoadingScreen.vue

@@ -1,7 +1,12 @@
 <!-- Animation circulaire à afficher durant les chargements -->
 
 <template>
-  <v-overlay :value="loading" class="loading-page">
+  <v-overlay
+          v-model="pageStore.loading"
+          z-index="9000"
+          persistent
+          class="align-center justify-center"
+  >
     <v-progress-circular
       indeterminate
       size="64"
@@ -10,25 +15,9 @@
 </template>
 
 <script setup lang="ts">
-  import {Ref, ref} from "@vue/reactivity";
+  import {usePageStore} from "~/stores/page";
 
-  const loading: Ref<boolean> = ref(false)
-
-  const set = (_num: number) => {
-    loading.value = true
-  }
-
-  const start = () => {
-    loading.value = true
-  }
-
-  const finish = () => {
-    loading.value = false
-  }
-
-  const fail = () => {
-    loading.value = false
-  }
+  const pageStore = usePageStore()
 </script>
 
 <style scoped>

+ 55 - 34
components/Layout/MainMenu.vue

@@ -8,7 +8,7 @@ Prend en paramètre une liste de ItemMenu et les met en forme
       v-model="displayMenu"
       :rail="isRail"
       :disable-resize-watcher="true"
-      class="bg-ot-dark-grey text-ot-menu-color main-menu"
+      class="theme-secondary main-menu"
   >
     <template #prepend>
       <slot name="title"></slot>
@@ -19,36 +19,35 @@ Prend en paramètre une liste de ItemMenu et les met en forme
         active-class="active"
         class="left-menu"
     >
-      <div v-for="(item, i) in menu.children" :key="i">
+      <!-- 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 -->
+        <!-- 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"
+          v-if="!item.children || isRail"
           :title="$t(item.label)"
           :prepend-icon="item.icon.name"
           :href="!isInternalLink(item) ? item.to : undefined"
           :to="isInternalLink(item) ? item.to : undefined"
           exact
-          class="text-ot-menu-color"
           height="48px"
-        ></v-list-item>
+          class="menu-item"
+        />
 
         <!-- Cas 2 : l'item a des enfants, c'est un groupe -->
         <v-list-group
           v-else
-          expand-icon="fas fa-angle-right"
-          collapse-icon="fas fa-angle-down"
-          class="text-ot-menu-color"
-          v-model="item.expanded"
+          expand-icon="fas fa-angle-down"
+          collapse-icon="fas fa-angle-up"
         >
           <template #activator="{ props }">
             <v-list-item
                 v-bind="props"
                 :prepend-icon="item.icon.name"
                 :title="$t(item.label)"
-                class="text-ot-menu-color"
+                class="theme-secondary menu-item"
                 height="48px"
-            ></v-list-item>
+            />
           </template>
 
           <v-list-item
@@ -59,9 +58,9 @@ Prend en paramètre une liste de ItemMenu et les met en forme
             :href="!isInternalLink(child) ? child.to : undefined"
             :to="isInternalLink(child) ? child.to : undefined"
             exact
-            height="48px"
-            class="text-ot-white"
-          ></v-list-item>
+            height="38px"
+            class="theme-secondary-alt"
+          />
         </v-list-group>
       </div>
     </v-list>
@@ -77,33 +76,46 @@ Prend en paramètre une liste de ItemMenu et les met en forme
 import {useMenu} from "~/composables/layout/useMenu";
 import {computed} from "@vue/reactivity";
 import { useDisplay } from 'vuetify'
+import { MenuGroup, MenuItem } from "~/types/layout";
 
-const { buildMenu, hasMenu, isInternalLink, openMenu, isMenuOpened } = useMenu()
+const { getMenu, hasMenu, isInternalLink, setMenuState, isMenuOpened } = useMenu()
 
-const { mdAndUp } = useDisplay()
+const { mdAndUp, lgAndUp } = useDisplay()
 
-const menu = buildMenu('Main')
-
-const hasMainMenu = computed(() => hasMenu('Main'))
+const menu = getMenu('Main')
 
 const isOpened = computed(() => isMenuOpened('Main'))
 
-// En vue md+, on affiche toujours le menu
-const isRail = computed(() => mdAndUp.value && !isOpened.value)
-const displayMenu = computed(() => hasMainMenu && (mdAndUp.value || isOpened.value))
+let items: Array<MenuGroup | MenuItem>
+if (menu === null) {
+  items = []
+} else if (menu.hasOwnProperty('children')) {
+  items = (menu as MenuGroup).children ?? []
+} else {
+  items = [menu]
+}
+
+// En vue lg+, on affiche toujours le menu
+const displayMenu = computed(() => {
+  return menu !== null && hasMenu('Main') && (lgAndUp.value || isOpened.value)
+})
+
+// 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)
+})
 
-const unwatch = watch(mdAndUp, (newValue, oldValue) => {
+const unwatch = watch(lgAndUp, (newValue, oldValue) => {
 // Par défaut si l'écran est trop petit au chargement de la page, le menu doit rester fermé.
-  if (process.client && mdAndUp.value) {
-    openMenu('Main')
+  if (process.client && menu !== null) {
+    setMenuState('Main', lgAndUp.value)
   }
 })
+
 onUnmounted(() => {
   unwatch()
 })
-
-
-
 </script>
 
 <style scoped lang="scss">
@@ -116,7 +128,7 @@ onUnmounted(() => {
   :deep(.v-icon),
   {
     font-size: 14px;
-    color: rgb(var(--v-theme-ot-menu-color));
+    color: rgb(var(--v-theme-on-secondary));
   }
 
   .v-list-item__prepend {
@@ -143,7 +155,7 @@ onUnmounted(() => {
   .v-list-group--no-action > .v-list-group__header,
   .v-list-item
   {
-    border-left:3px solid rgb(var(--v-theme-ot-dark-grey));
+    border-left: 3px solid rgb(var(--v-theme-secondary));
     height: 48px;
   }
 
@@ -151,16 +163,25 @@ onUnmounted(() => {
   .v-list-item.active,
   :deep(.v-list-group__items .v-list-item)
   {
-    border-left: 3px solid rgb(var(--v-theme-ot-green));
-    background: rgb(var(--v-theme-ot-dark-grey-hover));
+    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-list-item-title) {
-    color: rgb(var(--v-theme-ot-white));
+    color: rgb(var(--v-theme-on-secondary-alt));
+  }
+
+  :deep(.v-list-group__items .v-icon) {
+    color: rgb(var(--v-theme-on-secondary-alt));
   }
 
   :deep(.v-list-item .v-icon) {
     margin-right: 10px;
   }
 
+  :deep(.menu-item .fa) {
+    text-align: center;
+  }
+
 </style>

+ 29 - 24
components/Layout/SubHeader/ActivityYear.vue

@@ -1,70 +1,75 @@
 <template>
   <main class="d-flex flex-row align-center">
-    <span v-show="mdAndUp" class="mr-2 font-weight-bold">{{ $t(label) }} : </span>
+    <span v-show="mdAndUp"
+          class="mr-2 font-weight-bold on-neutral">
+        {{ $t(label) }} :
+    </span>
 
     <UiXeditableText
-      class="activity-year-input bg-ot-light-grey"
+      class="activity-year-input"
       type="number"
       :data="currentActivityYear"
       @update="setActivityYear"
     >
       <template #xeditable.read="{inputValue}">
-        <v-icon aria-hidden="false" size="small" class="text-ot-green mr-1" icon="fas fa-edit" />
-        <strong class="text-ot-green">
-          {{ inputValue }}
-          <span v-if="yearPlusOne">
-            / {{ parseInt(inputValue) + 1 }}
-          </span>
-        </strong>
+        <div class="d-flex align-center on-neutral--clickable">
+          <v-icon aria-hidden="false" size="small" class="mr-1" icon="fas fa-edit" />
+          <strong >
+            {{ inputValue }}
+            <span v-if="yearPlusOne">
+              / {{ parseInt(inputValue) + 1 }}
+            </span>
+          </strong>
+        </div>
       </template>
     </UiXeditableText>
   </main>
 </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 {Ref, ref} from "@vue/reactivity";
+import Access from "~/models/Access/Access";
 import {useDisplay} from "vuetify";
+import {usePageStore} from "~/stores/page";
 
 const { em } = useEntityManager()
 const accessProfileStore = useAccessProfileStore()
 const organizationProfileStore = useOrganizationProfileStore()
 const formStore = useFormStore()
+const pageStore = usePageStore()
 const { mdAndUp } = useDisplay()
 
-const currentActivityYear: Ref<number | null> = ref(accessProfileStore.activityYear)
+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'
 
 /**
  * Persist a new activityYear
- * @param activityYear
+ * @param event
  */
-const setActivityYear = async (activityYear: number) => {
+const setActivityYear = async (event: string) => {
+  const activityYear = parseInt(event)
+
   if (!(1900 < activityYear) || !(activityYear <= 2100)) {
     throw new Error("Error: 'year' shall be a valid year")
   }
   if (accessProfileStore.id === null) {
-    throw new Error("Error: invalide access id")
+    throw new Error("Error: invalid access id")
   }
   formStore.setDirty(false)
 
-  const access = await em.fetch(Access, accessProfileStore.id)
-  access.activityYear = activityYear
-  await em.persist(Access, access)
-
-  // Update the store
-  // TODO: voir si mieux d'automatiser ces maj du profil, ou de les faire à la main au cas par cas?
-  accessProfileStore.$patch({ activityYear: activityYear })
+  pageStore.loading = true
+  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()
+  }
 
   window.location.reload()
 }
-
 </script>
 
 <style lang="scss">

+ 11 - 5
components/Layout/SubHeader/Breadcrumbs.vue

@@ -5,11 +5,10 @@
 </template>
 
 <script setup lang="ts">
-import {useRouter, useRuntimeConfig} from "#app";
 import {computed, ComputedRef} from "@vue/reactivity";
 import {AnyJson} from "~/types/data";
 import {useI18n} from "vue-i18n";
-import Url from "~/services/utils/url";
+import UrlUtils from "~/services/utils/urlUtils";
 
 const runtimeConfig = useRuntimeConfig()
 const i18n = useI18n()
@@ -20,15 +19,15 @@ const items: ComputedRef<Array<AnyJson>> = computed(() => {
 
   crumbs.push({
     title: i18n.t('welcome'),
-    href: runtimeConfig.baseUrlAdminLegacy
+    href: UrlUtils.join(runtimeConfig.baseUrlAdminLegacy, '#', 'dashboard')
   })
 
-  const pathPart: Array<string> = Url.split(router.currentRoute.value.path)
+  const pathPart: Array<string> = UrlUtils.split(router.currentRoute.value.path)
 
   let path: string = ''
 
   pathPart.forEach((part) => {
-    path = `${path}/${part}`
+    path = UrlUtils.join(path, part)
 
     const match = router.resolve(path)
 
@@ -44,3 +43,10 @@ const items: ComputedRef<Array<AnyJson>> = computed(() => {
   return crumbs
 })
 </script>
+
+<style scoped lang="scss">
+:deep(a.v-breadcrumbs-item--disabled) {
+  color: rgb(var(--v-theme-on-neutral)) !important;
+  opacity: 1 !important;
+}
+</style>

+ 17 - 12
components/Layout/SubHeader/DataTiming.vue

@@ -1,6 +1,6 @@
 <template>
   <main class="d-flex align-baseline">
-    <span v-show="mdAndUp" class="mr-2 text-ot-dark_grey font-weight-bold">{{ $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"
@@ -11,14 +11,14 @@
       divider
       border
       :rounded="true"
-      class="bg-ot-light-grey toggle-btn"
+      class="toggle-btn"
       @update:modelValue="onUpdate"
     >
       <v-btn
           v-for="choice in historicalChoices"
           :value="choice"
           max-height="25"
-          :class="'font-weight-normal text-caption' + (historicalValue.includes(choice) ? ' btn-selected' : '')"
+          :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 -->
@@ -34,15 +34,17 @@ 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 Access from "~/models/Access/Access";
+import {usePageStore} from "~/stores/page";
 
 // 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['ot-green']
+const color = useTheme().current.value.colors['primary']
 
 const { setDirty } = useFormStore()
 const accessProfileStore = useAccessProfileStore()
 const { em } = useEntityManager()
 const { mdAndUp } = useDisplay()
+const pageStore = usePageStore()
 
 const toggle = ref(null)
 
@@ -53,7 +55,9 @@ const historicalValue: Ref<Array<string>> = ref(historicalChoices.filter((item)
 const onUpdate = async (newValue: Array<string>) => {
   historicalValue.value = newValue
 
-  if (accessProfileStore.id === null) {
+  const accessId = accessProfileStore.switchId ?? accessProfileStore.id
+
+  if (accessId === null) {
     throw new Error('Invalid profile id')
   }
 
@@ -64,12 +68,13 @@ const onUpdate = async (newValue: Array<string>) => {
   )
 
   setDirty(false)
+  pageStore.loading = true
 
-  await em.patch(
-      Access,
-      accessProfileStore.id,
-      {'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()
+  }
 
   window.location.reload()
 }
@@ -86,6 +91,6 @@ const onUpdate = async (newValue: Array<string>) => {
   }
 
   .v-btn.btn-selected {
-    background-color: rgb(var(--v-theme-ot-green)) !important;
+    background-color: rgb(var(--v-theme-primary)) !important;
   }
 </style>

+ 37 - 111
components/Layout/SubHeader/DataTimingRange.vue

@@ -1,35 +1,16 @@
 <template>
   <main class="d-flex align-center data-timing-range">
-    <div v-if="show" class="d-flex align-center" style="max-height: 100%">
-      <span class="pl-2 mr-2 font-weight-bold">
+    <div class="d-flex align-center" style="max-height: 100%">
+      <span class="label pl-2 mr-2 font-weight-bold on-neutral">
         {{ $t('period_choose') }}
       </span>
 
-      <UiInputDatePicker
-        label="date_choose"
-        :data="datesRange"
-        range
-        dense
-        single-line
-        height="22"
-        @update="updateDateTimeRange"
+      <UiDateRangePicker
+          :model-value="datesRange"
+          :max-height="28"
+          @update:model-value="updateDateTimeRange"
       />
     </div>
-
-    <v-btn
-        ref="btn"
-        class="time-btn ml-1"
-        height="22" min-height="22" max-height="22"
-        width="25" min-width="25" max-width="25"
-        elevation="0"
-        @click="show = !show"
-    >
-      <v-icon icon="fas fa-history" color="ot-grey" class="font-weight-normal" style="font-size: 14px;" />
-    </v-btn>
-
-    <v-tooltip location="bottom" :activator="btn">
-      <span>{{ $t('history_help') }}</span>
-    </v-tooltip>
   </main>
 </template>
 
@@ -37,109 +18,54 @@
 import {Ref} from "@vue/reactivity";
 import {useAccessProfileStore} from "~/stores/accessProfile";
 import {useFormStore} from "~/stores/form";
-import {WatchStopHandle} from "@vue/runtime-core";
 import {useEntityManager} from "~/composables/data/useEntityManager";
-import {Access} from "~/models/Access/Access";
-
-const btn: Ref = ref(null)
-const show: Ref<boolean> = ref(false)
+import Access from "~/models/Access/Access";
+import DateUtils from "~/services/utils/dateUtils";
+import {usePageStore} from "~/stores/page";
 
 const { setDirty } = useFormStore()
 const accessProfileStore = useAccessProfileStore()
 const { em } = useEntityManager()
+const pageStore = usePageStore()
 
-const datesRange: Ref<Array<string | null | undefined>> = ref([
-  accessProfileStore.historical.dateStart,
-  accessProfileStore.historical.dateEnd
-])
-
-const updateDateTimeRange = async (dates:Array<string>): Promise<any> => {
-  if (accessProfileStore.id === null) {
-    throw new Error('Invalid profile id')
-  }
-
-  accessProfileStore.setHistoricalRange(dates[0], dates[1])
-  setDirty(false)
-
-  await em.patch(
-      Access,
-      accessProfileStore.id,
-      {'historical': accessProfileStore.historical}
-  )
-
-  window.location.reload()
-}
-
-/**
- * Emit event when component is hidden / shown
-  */
-const emit = defineEmits(['showDateTimeRange'])
+const start = accessProfileStore.historical.dateStart
+const end = accessProfileStore.historical.dateEnd
 
-const unwatch: WatchStopHandle = watch(show, (newValue) => {
-  emit('showDateTimeRange', newValue)
-})
+const datesRange: Ref<Array<Date> | null> = ref((start && end) ? [new Date(start), new Date(end)] : null)
 
-onUnmounted(() => {
-  unwatch()
-})
+const updateDateTimeRange = async (dates: Array<Date>): Promise<any> => {
 
-// Show by default if a date range is defined in store
-if (accessProfileStore.historical.dateStart || accessProfileStore.historical.dateEnd) {
-  show.value = true
-  emit('showDateTimeRange', true)
-}
-</script>
+  const accessId = accessProfileStore.switchId ?? accessProfileStore.id
 
-<style scoped lang="scss">
-.v-btn--active .v-icon {
-  color: #FFF !important;
-}
-
-.time-btn {
-  border-width: 1px 1px 1px 0;
-  border-style: solid;
-  border-color: rgba(0, 0, 0, 0.12) !important;
-}
-
-.data-timing-range {
-  max-height: 22px;
-
-  :deep(.v-text-field) {
-    padding-top: 0 !important;
-    margin-top: 0 !important;
+  if (accessId === null) {
+    throw new Error('Invalid profile id')
   }
 
-  :deep(.v-input) {
-    max-height: 22px;
-    min-height: 22px;
-    height: 22px;
-  }
+  datesRange.value = dates
 
-  :deep(.v-icon) {
-    max-height: 22px;
-  }
-
-  :deep(.v-field__input) {
-    font-size: 14px;
-    width: 400px !important;
-    padding: 0;
-    max-height: 22px;
-    min-height: 22px;
-    height: 22px;
+  if (datesRange.value !== null && datesRange.value[0] !== null && datesRange.value[1] !== null) {
+    accessProfileStore.setHistoricalRange(
+        DateUtils.formatIsoShortDate(datesRange.value[0]),
+        DateUtils.formatIsoShortDate(datesRange.value[1])
+    )
+  } else {
+    accessProfileStore.setHistorical(false, true, false)
   }
+  setDirty(false)
+  pageStore.loading = true
 
-  :deep(.v-field-label) {
-    top: 0;
-    font-size: 14px;
-    max-height: 22px;
-    min-height: 22px;
-    height: 22px;
+  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()
   }
 
-  :deep(.v-input__prepend) {
-    padding-top: 0;
-    font-size: 14px;
-  }
+  window.location.reload()
 }
+</script>
 
+<style scoped lang="scss">
+.label {
+  min-width: 150px;
+}
 </style>

+ 19 - 24
components/Layout/SubHeader/PersonnalizedList.vue

@@ -1,29 +1,22 @@
 <template>
   <main>
-    <span
-        ref="btn"
-        class="text-ot-green"
-        style="cursor: pointer;"
-    >
+    <a ref="btn" id="activator">
       {{ $t('my_list') }}
-    </span>
+    </a>
 
     <v-menu
         :activator="btn"
-        :model-value="showMenu"
-        location="start"
+        offset="10"
+        min-width="440"
         :close-on-content-click="false"
-        min-width="500"
-        @update:modelValue="onMenuToggled($event)"
     >
-
-      <v-card v-if="collection.totalItems === 0" height="80" 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>
       </v-card>
 
-      <v-card v-else>
+      <v-card v-else width="440">
         <v-card-title class="text-body-2 header-personalized">
           <v-text-field
               v-model="search"
@@ -43,9 +36,7 @@
               :href="getListURL(item)"
               exact
             >
-              <v-list-item-title>
-                {{item.label}} - <strong>{{item.menuKey}}</strong>
-              </v-list-item-title>
+              <strong>{{item.menuKey}}</strong> - {{item.label}}
             </v-list-item>
           </v-list>
         </v-card-text>
@@ -56,18 +47,14 @@
 </template>
 
 <script setup lang="ts">
-import {PersonalizedList} from '~/models/Access/PersonalizedList'
+import PersonalizedList from '~/models/Access/PersonalizedList'
 import {useEntityFetch} from "~/composables/data/useEntityFetch";
 import {ComputedRef, Ref, ref} from "@vue/reactivity";
 import {AnyJson} from "~/types/data";
 import ApiResource from "~/models/ApiResource";
+import UrlUtils from "~/services/utils/urlUtils";
 
 const btn: Ref = ref(null)
-const showMenu: Ref<boolean> = ref(false)
-
-const onMenuToggled = (event: any) => {
-  showMenu.value = event
-}
 
 const { fetch, fetchCollection } = useEntityFetch()
 
@@ -100,11 +87,19 @@ const runtimeConfig = useRuntimeConfig()
 const homeUrl: string = runtimeConfig.baseUrlAdminLegacy
 
 const getListURL = (list: PersonalizedList) => {
-  return `${homeUrl}/${list.entity}/list/${list.id}`
+  return UrlUtils.join(homeUrl, '#', list.entity ?? '', 'list', list.id ?? '')
 }
 </script>
 
-<style lang="scss">
+<style scoped lang="scss">
+  #activator {
+    cursor: pointer;
+  }
+
+  #activator:hover {
+    color: rgb(var(--var-theme-on-neutral)) !important;
+  }
+
   .header-personalized {
     margin-bottom: 0;
     padding-bottom: 0;

+ 53 - 16
components/Layout/Subheader.vue

@@ -6,22 +6,49 @@ Contient entre autres le breadcrumb, les commandes de changement d'année et les
 <template>
   <main>
     <v-card
-      class="d-md-flex bg-ot-light-grey text-body-2 px-2"
+      id="subheader"
+      class="d-flex theme-neutral text-body-2 px-2"
       flat
+      rounded="0"
     >
-      <LayoutSubHeaderBreadcrumbs v-show="mdAndUp" class="mr-auto d-sm-none d-md-flex d-none d-sm-flex" />
+      <LayoutSubHeaderBreadcrumbs v-if="lgAndUp" class="mr-auto d-flex" />
+
+      <span class="flex-fill" />
 
       <v-card
-        class="d-md-flex pt-2 mr-6 align-baseline"
+        class="d-flex flex-row align-center mr-6"
         flat
         tile
       >
-        <LayoutSubHeaderActivityYear v-if="!showDateTimeRange" class="activity-year" />
+        <LayoutSubHeaderActivityYear v-if="smAndUp && !showDateTimeRange" class="activity-year" />
+
+        <div v-if="hasMenuOrIsTeacher" class="d-flex flex-row align-center h-100">
+          <LayoutSubHeaderDataTiming
+              v-if="smAndUp && !showDateTimeRange"
+              class="data-timing ml-2"
+          />
+
+          <LayoutSubHeaderDataTimingRange
+              v-if="smAndUp && showDateTimeRange"
+              class="data-timing-range ml-n1"
+          />
 
-        <div v-if="hasMenuOrIsTeacher" class="d-sm-none d-md-flex d-none d-sm-flex">
-          <LayoutSubHeaderDataTiming v-if="!showDateTimeRange" class="data-timing ml-2" />
-          <LayoutSubHeaderDataTimingRange class="data-timing-range ml-n1" @showDateTimeRange="showDateTimeRange=$event" />
-          <LayoutSubHeaderPersonnalizedList class="personalized-list ml-2" />
+          <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-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>
+          </v-tooltip>
+
+          <LayoutSubHeaderPersonnalizedList class="personalized-list ml-2 d-flex align-center" />
         </div>
       </v-card>
     </v-card>
@@ -29,33 +56,43 @@ 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 { mdAndUp } = useDisplay()
+    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(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 {
-  color: rgb(var(--v-theme-ot-grey));
   font-size: 12px;
 }
 
-.v-card {
-  max-height: 33px;
-  background: transparent;
+#subheader {
+  max-height: 36px;
+}
+
+:deep(#subheader .v-card) {
+  max-height: 36px;
+  background-color: transparent !important;
+}
+
+.switch-btn {
+  border-width: 1px;
+  border-style: solid;
 }
 </style>

+ 26 - 0
components/Layout/ThemeSwitcher.vue

@@ -0,0 +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"
+  />
+</template>
+
+<script setup lang="ts">
+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;
+  }
+</style>

+ 2 - 2
components/Ui/Button/Delete.vue

@@ -19,10 +19,10 @@ Bouton Delete avec modale de confirmation de la suppression
         </v-card-text>
       </template>
       <template #dialogBtn>
-        <v-btn class="mr-4 submitBtn ot-grey ot-white--text" @click="closeDialog">
+        <v-btn class="mr-4 submitBtn theme-neutral-strong" @click="closeDialog">
           {{ $t('cancel') }}
         </v-btn>
-        <v-btn class="mr-4 submitBtn ot-danger ot-white--text" @click="deleteItem">
+        <v-btn class="mr-4 submitBtn theme-danger" @click="deleteItem">
           {{ $t('delete') }}
         </v-btn>
       </template>

+ 1 - 1
components/Ui/Button/Submit.vue

@@ -1,5 +1,5 @@
 <template>
-  <v-btn class="mr-4 ot-green ot-white--text" :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">
 
     {{ $t(mainAction) }}
 

+ 2 - 3
components/Ui/Collection.vue

@@ -9,7 +9,7 @@
       <slot name="list.item" v-bind="{items}" />
 
       <!-- New button -->
-      <v-btn v-if="newLink" class="ot-white--text ot-green float-right">
+      <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>
@@ -24,8 +24,7 @@
 
 import {computed, ComputedRef, toRefs, ToRefs} from "@vue/reactivity";
 import {useEntityFetch} from "~/composables/data/useEntityFetch";
-import {Collection} from "~/types/enum/data";
-import ApiResource from "~/models/ApiResource";
+import {Collection} from "~/types/data";
 
 const props = defineProps({
   model: {

+ 1 - 1
components/Ui/DataTable.vue

@@ -44,9 +44,9 @@ Tableau interactif conçu pour l'affichage d'une collection d'entités
 <script setup lang="ts">
 
 import {ref, Ref, toRefs} from "@vue/reactivity";
-import {AnyJson} from "~/types/enum/data";
 import {useEntityFetch} from "~/composables/data/useEntityFetch";
 import ApiResource from "~/models/ApiResource";
+import {AnyJson} from "~/types/data";
 
 const props = defineProps({
   parent: {

+ 71 - 0
components/Ui/DatePicker.vue

@@ -0,0 +1,71 @@
+<!--
+Sélecteur de dates
+
+@see https://vuetifyjs.com/en/components/date-pickers/
+-->
+
+<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"
+    />
+  </main>
+</template>
+
+<script setup lang="ts">
+import {computed} from "@vue/reactivity";
+import DateUtils, {supportedLocales} from "~/services/utils/dateUtils";
+import {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
+  },
+  readonly: {
+    type: Boolean,
+    required: false,
+    default: false
+  },
+  format: {
+    type: String,
+    required: false,
+    default: null
+  },
+  withTime: {
+    type: Boolean,
+    required: false,
+    default: false
+  }
+})
+
+const dateFormat: Ref<string> = ref(props.format ?? defaultFormatPattern)
+
+const emit = defineEmits(['update:model-value'])
+
+const onUpdate = (event: Date) => {
+  emit('update:model-value', event)
+}
+
+</script>
+
+<style scoped>
+
+</style>

+ 126 - 0
components/Ui/DateRangePicker.vue

@@ -0,0 +1,126 @@
+<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"
+  />
+</template>
+
+<script setup lang="ts">
+import DateUtils, {supportedLocales} from "~/services/utils/dateUtils";
+import {PropType} from "@vue/runtime-core";
+
+const props = defineProps({
+  modelValue: {
+    type: Array as PropType<Array<Date> | null>,
+    required: false,
+    default: null
+  },
+  maxHeight: {
+    type: Number,
+    required: false,
+    default: null
+  }
+})
+
+const emit = defineEmits(['update:modelValue'])
+
+const autoApply = false
+
+const updateDateTimeRange = (value: [string, string]) => {
+  emit('update:modelValue', value)
+}
+
+const i18n = useI18n()
+
+const fnsLocale = DateUtils.getFnsLocale(i18n.locale.value as supportedLocales)
+const dateFormatPattern = DateUtils.getShortFormatPattern(i18n.locale.value as supportedLocales)
+
+const today = new Date()
+
+let style = '';
+if (props.maxHeight !== null) {
+  style += 'height: ' + props.maxHeight + 'px;max-height: ' + props.maxHeight + 'px;'
+}
+
+/**
+ * Recalcule la position du panneau de sélection des dates si trop près du bord droit de l'écran
+ * @param el
+ */
+const dateRangePickerAltPosition = (el: HTMLElement) => {
+  let xOffset = 0
+  const fullWidth = 500
+  const rightPadding = 30
+  const rect = el.getBoundingClientRect()
+
+  if ((rect.left + fullWidth + rightPadding) > window.innerWidth) {
+    xOffset = window.innerWidth - (rect.left + fullWidth + rightPadding)
+  }
+
+  return {
+    top: rect.bottom,
+    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);
+  }
+
+  .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;
+    }
+  }
+</style>

+ 34 - 13
components/Ui/ExpansionPanel.vue

@@ -5,22 +5,25 @@ Panneaux déroulants de type "accordéon"
 -->
 
 <template>
-  <v-expansion-panel :id="id">
-    <v-expansion-panel-header color="ot-light_grey">
-      <v-icon class="ot-white--text ot-green icon">
-        {{ icon }}
-      </v-icon>
-      {{ $t(id) }}
-    </v-expansion-panel-header>
-    <v-expansion-panel-content>
+  <v-expansion-panel :value="title">
+    <v-expansion-panel-title color="neutral">
+      <template v-slot:default="{ expanded }">
+        <v-icon class="theme-primary icon">
+          {{ icon }}
+        </v-icon>
+        {{ $t(title) }}
+      </template>
+    </v-expansion-panel-title>
+
+    <v-expansion-panel-text>
       <slot />
-    </v-expansion-panel-content>
+    </v-expansion-panel-text>
   </v-expansion-panel>
 </template>
 
 <script setup lang="ts">
 const props = defineProps({
-  id: {
+  title: {
     type: String,
     required: true
   },
@@ -33,18 +36,36 @@ const props = defineProps({
 </script>
 
 <style scoped>
-  .icon{
+  .icon {
     width: 47px;
     height: 47px;
     padding: 10px;
     margin-right: 10px;
     flex: none !important;
   }
-  .v-expansion-panel-header{
+
+  .v-expansion-panel-header {
     padding: 0;
     padding-right: 20px;
   }
-  .v-expansion-panel--active > .v-expansion-panel-header{
+
+  .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;
+  }
+
+  :deep(.v-expansion-panel-title__icon > .v-icon) {
+    font-size: 16px;
+  }
+
+  .icon {
+    text-align: center;
+  }
 </style>

+ 4 - 6
components/Ui/Form.vue

@@ -51,7 +51,7 @@ Formulaire générique
       :show="showDialog"
     >
       <template #dialogText>
-        <v-card-title class="text-h5 grey lighten-2">
+        <v-card-title class="text-h5 theme-neutral">
           {{ $t('caution') }}
         </v-card-title>
         <v-card-text>
@@ -60,13 +60,13 @@ Formulaire générique
         </v-card-text>
       </template>
       <template #dialogBtn>
-        <v-btn class="mr-4 submitBtn ot-green ot-white--text" @click="closeDialog">
+        <v-btn class="mr-4 submitBtn theme-primary" @click="closeDialog">
           {{ $t('back_to_form') }}
         </v-btn>
-        <v-btn class="mr-4 submitBtn ot-green ot-white--text" @click="saveAndQuit">
+        <v-btn class="mr-4 submitBtn theme-primary" @click="saveAndQuit">
           {{ $t('save_and_quit') }}
         </v-btn>
-        <v-btn class="mr-4 submitBtn ot-danger ot-white--text" @click="quitForm">
+        <v-btn class="mr-4 submitBtn theme-danger" @click="quitForm">
           {{ $t('quit_form') }}
         </v-btn>
       </template>
@@ -76,11 +76,9 @@ Formulaire générique
 </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 {useNuxtApp, useRouter} from "#app";
 import { useFormStore } from "~/stores/form";
 import {Route} from "@intlify/vue-router-bridge";
 import {useEntityManager} from "~/composables/data/useEntityManager";

+ 0 - 2
components/Ui/Help.vue

@@ -29,8 +29,6 @@
 </template>
 
 <script setup lang="ts">
-
-import {useNuxtApp} from "#app";
 import {Ref} from "@vue/reactivity";
 
 const props = defineProps({

+ 14 - 12
components/Ui/Image.vue

@@ -13,7 +13,6 @@ Si la propriété 'upload' est à 'true', propose aussi un input pour uploader u
         :height="height"
         :width="width"
         aspect-ratio="1"
-        @click="refresh"
       >
         <template #placeholder>
           <v-row
@@ -24,7 +23,7 @@ Si la propriété 'upload' est à 'true', propose aussi un input pour uploader u
           >
             <v-progress-circular
               :indeterminate="true"
-              color="grey lighten-1"
+              color="neutral"
             />
           </v-row>
         </template>
@@ -53,11 +52,12 @@ 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";
 
 const props = defineProps({
   id: {
     type: Number,
-    required: true,
+    required: false,
     default: null
   },
   defaultImage: {
@@ -90,7 +90,7 @@ const props = defineProps({
 const { imageManager } = useImageManager()
 const { fetch } = useImageFetch()
 
-const defaultImagePath = props.defaultImage ?? imageManager.defaultImage
+const defaultImagePath = props.defaultImage ?? ImageManager.defaultImage
 
 const openUpload: Ref<Boolean> = ref(false)
 
@@ -114,7 +114,7 @@ const reset = () => {
 }
 
 /**
- * Lorsqu'on démonte le component on supprime le watcher
+ * Lorsqu'on démonte le component, on supprime le watcher
  */
 onUnmounted(() => {
   unwatch()
@@ -125,10 +125,12 @@ onUnmounted(() => {
   div.image-wrapper {
     display: block;
     position: relative;
-    img{
+
+    img {
       max-width: 100%;
     }
-    .click-action{
+
+    .click-action {
       position: absolute;
       top:0;
       left:0;
@@ -137,13 +139,13 @@ onUnmounted(() => {
       background: transparent;
       opacity: 0;
       transition: all .2s;
-      &:hover{
+      &:hover {
         opacity: 1;
-        background:rgba(0,0,0,0.3);
+        background: rgb(var(--v-theme-neutral-strong));
         cursor: pointer;
       }
-      i{
-        color: #fff;
+      i {
+        color: rgb(var(--v-theme-on-neutral-strong));
         position: absolute;
         top: 50%;
         left: 50%;
@@ -152,7 +154,7 @@ onUnmounted(() => {
         z-index: 1;
         opacity: 1;
         &:hover{
-          color: rgba(#3fb37f, 0.7);
+          color: rgb(var(--v-theme-primary-alt));
         }
       }
     }

+ 0 - 1
components/Ui/Input/Autocomplete.vue

@@ -38,7 +38,6 @@ Liste déroulante avec autocompletion
 import {useNuxtApp} from "#app";
 import {computed, ComputedRef, Ref} from "@vue/reactivity";
 import {useFieldViolation} from "~/composables/form/useFieldViolation";
-import {AnyJson} from "~/types/enum/data";
 import {onUnmounted, watch} from "@vue/runtime-core";
 import ObjectUtils from "~/services/utils/objectUtils";
 

+ 2 - 2
components/Ui/Input/AutocompleteWithAPI.vue

@@ -29,7 +29,7 @@ d'une api)
 <script setup lang="ts">
 
 import {Ref, ref, toRefs} from "@vue/reactivity";
-import Url from "~/services/utils/url";
+import UrlUtils from "~/services/utils/urlUtils";
 import {FetchOptions} from "ohmyfetch";
 import {useFetch} from "#app";
 import {watch} from "@vue/runtime-core";
@@ -112,7 +112,7 @@ if (props.data) {
   const ids:Array<any> = []
 
   for(const uri of props.remoteUri){
-    ids.push(Url.extractIdFromUri(uri as string))
+    ids.push(UrlUtils.extractIdFromUri(uri as string))
   }
 
   const options: FetchOptions = { method: 'GET', query: {key: 'id', value: ids.join(',')} }

+ 0 - 2
components/Ui/Input/Checkbox.vue

@@ -22,8 +22,6 @@ Case à cocher
 </template>
 
 <script setup lang="ts">
-
-import {useNuxtApp} from "#app";
 import {useFieldViolation} from "~/composables/form/useFieldViolation";
 
 const props = defineProps({

+ 7 - 11
components/Ui/Input/DatePicker.vue

@@ -35,7 +35,7 @@ Sélecteur de dates
       <v-date-picker
           v-model="datesParsed"
           :range="range"
-          color="ot-green lighten-1"
+          color="primary lighten-1"
           @input="dateOpen = range && datesParsed.length < 2"
       />
     </v-menu>
@@ -43,11 +43,9 @@ Sélecteur de dates
 </template>
 
 <script setup lang="ts">
-
-import {useNuxtApp} from "#app";
 import {useFieldViolation} from "~/composables/form/useFieldViolation";
 import {computed, ComputedRef, Ref} from "@vue/reactivity";
-import {useDateUtils} from "~/composables/utils/useDateUtils";
+import DateUtils from "~/services/utils/dateUtils";
 import {onUnmounted, watch, WatchStopHandle} from "@vue/runtime-core";
 
 const props = defineProps({
@@ -100,14 +98,12 @@ const props = defineProps({
 
 const input = ref(null)
 
-const { emit, $moment } = useNuxtApp()
+const { emit } = useNuxtApp()
 
 const { data, range } = props
 
 const {fieldViolations, updateViolationState} = useFieldViolation(props.field)
 
-const dateUtils = useDateUtils()
-
 const datesParsed: Ref<Array<string>|string|null> = range ? ref(Array<string>()) : ref(null)
 
 const fieldLabel = props.label ?? props.field
@@ -127,24 +123,24 @@ const onInputBlured = (event: any) => {
 if (Array.isArray(datesParsed.value)) {
   for (const date of data as Array<string>) {
     if (date) {
-      datesParsed.value.push($moment(date).format('YYYY-MM-DD'))
+      datesParsed.value.push(DateUtils.format(date, 'YYYY-MM-DD'))
     }
   }
 } else {
-  datesParsed.value = data ? $moment(data as string).format('YYYY-MM-DD') : null
+  datesParsed.value = data ? DateUtils.format(data, 'YYYY-MM-DD') : null
 }
 
 const datesFormatted: ComputedRef<string|null> = computed(() => {
   if (props.range && datesParsed.value && datesParsed.value.length < 2) {
     return null
   }
-  return datesParsed.value ? dateUtils.formatDatesAndConcat(datesParsed.value, props.format) :  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.sortDate(newValue) : newValue)
+  updateViolationState(Array.isArray(newValue) ? DateUtils.sort(newValue) : newValue)
 })
 
 onUnmounted(() => {

+ 13 - 13
components/Ui/Input/Image.vue

@@ -16,7 +16,7 @@ https://norserium.github.io/vue-advanced-cropper/
           >
             <v-progress-circular
               :indeterminate="true"
-              color="grey lighten-1">
+              color="neutral">
             </v-progress-circular>
           </v-row>
 
@@ -46,10 +46,10 @@ https://norserium.github.io/vue-advanced-cropper/
         </div>
       </template>
       <template #dialogBtn>
-        <v-btn class="mr-4 submitBtn ot-grey ot-white--text" @click="$emit('close')">
+        <v-btn class="mr-4 submitBtn theme-neutral-strong" @click="$emit('close')">
           {{ $t('cancel') }}
         </v-btn>
-        <v-btn class="mr-4 submitBtn ot-danger ot-white--text" @click="save">
+        <v-btn class="mr-4 submitBtn theme-danger" @click="save">
           {{ $t('save') }}
         </v-btn>
       </template>
@@ -61,13 +61,13 @@ https://norserium.github.io/vue-advanced-cropper/
 
 import {useNuxtApp} from "#app";
 import {ref, Ref} from "@vue/reactivity";
-import {AnyJson} from "~/types/enum/data";
-import {File} from '~/models/Core/File'
+import File from '~/models/Core/File'
 import {useAp2iRequestService} from "~/composables/data/useAp2iRequestService";
-import Url from "~/services/utils/url";
+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";
 
 const props = defineProps({
   imageId: {
@@ -111,7 +111,7 @@ const defaultSize = ({ imageSize, visibleArea }: any) => {
 // 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(Url.join('api/files', '' + props.imageId))
+  const result: any = await apiRequestService.get(UrlUtils.join('api/files', '' + props.imageId))
 
   const config = JSON.parse(result.data.config)
   coordinates.value.left = config.x
@@ -242,7 +242,7 @@ onUnmounted(() => {
     padding: 20px;
     display: block;
     &__cropper {
-       border: solid 1px #eee;
+       border: solid 1px rgb(var(--v-theme-on-neutral-strong));;
        min-height: 500px;
        max-height: 500px;
      }
@@ -259,10 +259,10 @@ onUnmounted(() => {
       justify-content: center;
       height: 42px;
       width: 42px;
-      background: rgba(#3fb37f, 0.7);
+      background: rgb(var(--v-theme-neutral));
       transition: background 0.5s;
       &:hover {
-        background: #3fb37f;
+        background: rgb(var(--v-theme-primary-alt));
       }
     }
     &__buttons-wrapper {
@@ -273,16 +273,16 @@ onUnmounted(() => {
     &__button {
        border: none;
        outline: solid transparent;
-       color: white;
+       color: rgb(var(--v-theme-on-neutral));
        font-size: 16px;
        padding: 10px 20px;
-       background: #3fb37f;
+       background: rgb(var(--v-theme-neutral));
        cursor: pointer;
        transition: background 0.5s;
        margin: 0 16px;
       &:hover,
       &:focus {
-         background: #38d890;
+         background: rgb(var(--v-theme-primary-alt));
        }
       input {
         display: none;

+ 107 - 0
components/Ui/Input/Number.vue

@@ -0,0 +1,107 @@
+<!--
+An input for numeric values
+-->
+
+<template>
+  <v-text-field
+      ref="input"
+      :modelValue.number="modelValue"
+      hide-details
+      single-line
+      :density="density"
+      type="number"
+      @update:modelValue="modelValue = keepInRange(cast($event)); emitUpdate()"
+  />
+</template>
+
+<script setup lang="ts">
+
+import {PropType} from "@vue/runtime-core";
+
+type Density = null | 'default' | 'comfortable' | 'compact';
+
+const props = defineProps({
+  modelValue: {
+    type: Number,
+    required: true
+  },
+  default: {
+    type: Number,
+    required: false,
+    default: 0
+  },
+  min: {
+    type: Number,
+    required: false,
+    default: null
+  },
+  max: {
+    type: Number,
+    required: false,
+    default: null
+  },
+  density: {
+    type: String as PropType<Density>,
+    required: false,
+    default: 'default'
+  }
+})
+
+/**
+ * Reference to the v-text-field
+ */
+const input: Ref<any> = ref(null)
+
+/**
+ * Cast the value to a number, or fallback on default value
+ * @param val
+ */
+const cast = (val: number | string): number => {
+  val = Number(val)
+  if (isNaN(val)) {
+    return props.default
+  }
+
+  return val
+}
+
+/**
+ * Ensure the value is between min and max values
+ * @param val
+ */
+const keepInRange = (val: number) => {
+  if (props.min !== null && props.max !== null && props.min >= props.max) {
+    console.warn('Number input: minimum value is greater than maximum value')
+  }
+  if (props.min !== null && val < props.min) {
+    val = props.min
+  }
+  if (props.max !== null && val > props.max) {
+    val = props.max
+  }
+  return val
+}
+
+
+const emit = defineEmits(['update:modelValue'])
+
+/**
+ * Emit the update event
+ */
+const emitUpdate = () => {
+  emit('update:modelValue', props.modelValue)
+}
+
+/**
+ * Setup min and max values at the input level
+ */
+onMounted(() => {
+  const inputElement = input.value.$el.querySelector('input')
+  if (props.min !== null) {
+    inputElement.min = props.min
+  }
+  if (props.max !== null) {
+    inputElement.max = props.max
+  }
+})
+</script>

+ 2 - 2
components/Ui/Input/Phone.vue

@@ -100,8 +100,8 @@ const rules = [
 ]
 </script>
 
-<style>
+<style lang="scss">
 input:read-only{
-  color: #666 !important;
+  color: rgb(var(--v-theme-on-neutral));
 }
 </style>

+ 16 - 4
components/Ui/Input/Text.vue

@@ -15,8 +15,8 @@ Champs de saisie de texte
     :error-messages="errorMessage || (fieldViolations ? $t(fieldViolations) : '')"
     :append-icon="type === 'password' ? (show ? 'mdi-eye' : 'mdi-eye-off') : ''"
     @click:append="show = !show"
-    @update:modelValue="$emit('update:modelValue', $event.target.value)"
-    @change="updateViolationState($event); $emit('change', $event)"
+    @update:model-value="onUpdate($event)"
+    @change="onChange($event)"
   />
 
 
@@ -117,14 +117,26 @@ const i18n = useI18n()
 const { fieldViolations, updateViolationState } = useFieldViolation(props.field)
 
 const show = ref(false)
+
+const emit = defineEmits(['update:model-value', 'change'])
+
+const onUpdate = (event: string) => {
+    emit('update:model-value', event)
+}
+
+const onChange = (event: Event | undefined) => {
+    updateViolationState(event)
+    emit('change', event)
+}
+
 // const label = computed(() => {
 //   if (props.label)
 // })
 
 </script>
 
-<style scoped>
+<style scoped lang="scss">
   input:read-only{
-    color: #666 !important;
+    color: rgb(var(--v-theme-neutral));
   }
 </style>

+ 2 - 2
components/Ui/Input/TextArea.vue

@@ -66,8 +66,8 @@ const {violation, onChange} = useFieldViolation(props.field, emit)
 
 </script>
 
-<style>
+<style lang="scss">
   input:read-only{
-    color: #666 !important;
+    color: rgb(var(--v-theme-on-neutral));
   }
 </style>

+ 2 - 2
components/Ui/ItemFromUri.vue

@@ -18,7 +18,7 @@ Espace permettant de récupérer un item via une uri et de gérer son affichage
 // 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 Url from "~/services/utils/url";
+import UrlUtils from "~/services/utils/urlUtils";
 import {useEntityFetch} from "~/composables/data/useEntityFetch";
 import {object} from "@ucast/core";
 import {computed, ComputedRef} from "@vue/reactivity";
@@ -46,7 +46,7 @@ const props = defineProps({
   }
 })
 
-const id = Url.extractIdFromUri(props.uri)
+const id = UrlUtils.extractIdFromUri(props.uri)
 if (id === null) {
   throw new Error('Uri parsing error : no id found')
 }

+ 56 - 15
components/Ui/SystemBar.vue

@@ -4,28 +4,69 @@ System bars
 
 <template>
   <v-system-bar
-      dark
       height="50"
-      :class="'d-flex flex-row justify-center align-center text-center bg-' + backgroundColor + ' text-' + textColor"
+      :class="'d-flex flex-row justify-center align-center text-center ' + classes"
+      @click="onClick !== undefined ? onClick() : null"
   >
-    <slot name="bar.text" />
+    <slot>
+      <v-icon v-if="icon" small :icon="icon" />
+      {{ text }}
+    </slot>
   </v-system-bar>
 </template>
 
 <script setup lang="ts">
-const props = defineProps({
-  backgroundColor: {
-    type: String,
-    required: false,
-    default: '#eeeeee'
-  },
-  textColor: {
-    type: String,
-    required: false,
-    default: '#5f5f5f'
-  }
-})
+  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(' ')
 </script>
 
 <style scoped lang="scss">
+.v-system-bar {
+  font-size: 14px;
+}
+
+.v-icon {
+  height: 20px;
+  margin: 0 6px;
+}
+
+.clickable {
+  cursor: pointer;
+}
+
+.clickable:hover {
+  text-decoration: underline;
+}
 </style>

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

@@ -7,7 +7,7 @@ Date formatée
 </template>
 
 <script setup lang="ts">
-import {useDateUtils} from "~/composables/utils/useDateUtils";
+import DateUtils from "~/services/utils/dateUtils";
 import {computed, ComputedRef} from "@vue/reactivity";
 
 const props = defineProps({
@@ -18,9 +18,7 @@ const props = defineProps({
   }
 })
 
-const dateUtils = useDateUtils()
-
 const datesFormatted: ComputedRef<string> = computed(() => {
-  return dateUtils.format(props.data, 'DD/MM/YYYY')
+  return DateUtils.format(props.data, 'DD/MM/YYYY')
 })
 </script>

+ 6 - 5
components/Ui/Xeditable/Text.vue

@@ -11,21 +11,20 @@ Utilisé par exemple pour le choix de l'année active
       <UiInputText
           class="ma-0 pa-0"
           :type="type"
-          :modelValue="inputValue"
-          @update="$emit('update:modelValue', $event.target.value)"
+          v-model="inputValue"
       />
 
       <v-icon
           icon="fas fa-check"
           aria-hidden="false"
-          class="valid icons text-ot-green"
+          class="valid icons text-primary"
           size="small"
           @click="update"
       />
       <v-icon
           icon="fas fa-times"
           aria-hidden="false"
-          class="cancel icons text-ot-grey"
+          class="cancel icons text-neutral-strong"
           size="small"
           @click="close"
       />
@@ -61,7 +60,9 @@ import {ref, Ref} from "@vue/reactivity";
 
   const update = () => {
     edit.value = false
-    if (inputValue.value !== props.data) { emit('update', inputValue.value) }
+    if (inputValue.value !== props.data) {
+        emit('update', inputValue.value)
+    }
   }
 
   const close = () => {

+ 13 - 9
composables/data/useAp2iRequestService.ts

@@ -1,6 +1,5 @@
 import {FetchContext, FetchOptions} from "ohmyfetch";
 import {TYPE_ALERT} from "~/types/enum/enums";
-import {navigateTo, useRuntimeConfig} from "#app";
 import ApiRequestService from "~/services/data/apiRequestService";
 import {Ref} from "@vue/reactivity";
 import {usePageStore} from "~/stores/page";
@@ -13,6 +12,7 @@ import {AssociativeArray} from "~/types/data";
  *
  * @see https://github.com/unjs/ohmyfetch/blob/main/README.md#%EF%B8%8F-create-fetch-with-default-options
  */
+let apiRequestServiceClass:null|ApiRequestService = null
 export const useAp2iRequestService = () => {
     const runtimeConfig = useRuntimeConfig()
 
@@ -45,8 +45,6 @@ export const useAp2iRequestService = () => {
 
         options.headers = { ...options.headers, ...headers }
 
-        // $axios.setToken(`${store.state.profile.access.bearer}`, 'Bearer')
-
         pending.value = true
         console.log('Request : ' + request + ' (SSR: ' + process.server + ')')
     }
@@ -80,13 +78,14 @@ export const useAp2iRequestService = () => {
             throw new UnauthorizedError('Ap2i - Unauthorized')
         }
         else if (response && response.status === 403) {
+            console.error('! Request error: Forbidden')
             usePageStore().addAlert(TYPE_ALERT.ALERT, ['forbidden'])
-            console.error('Forbidden')
         }
         else if (response && response.status >= 404) {
             // @see https://developer.mozilla.org/fr/docs/Web/HTTP/Status
-            usePageStore().addAlert(TYPE_ALERT.ALERT, [error ? error.message : response.statusText])
-            // console.error(error ?? response)
+            const error_msg = error ? error.message : response.statusText
+            console.error('! Request error: ' + error_msg)
+            usePageStore().addAlert(TYPE_ALERT.ALERT, [error_msg])
         }
     }
 
@@ -98,8 +97,13 @@ export const useAp2iRequestService = () => {
         onResponseError
     }
 
-    // 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)
+    }
 
-    return { apiRequestService: new ApiRequestService(fetcher), pending: pending }
+    return { apiRequestService: apiRequestServiceClass, pending: pending }
 }

+ 21 - 7
composables/data/useEntityFetch.ts

@@ -1,28 +1,42 @@
-import {useAsyncData, AsyncData} from "#app";
 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";
 
 interface useEntityFetchReturnType {
     fetch: (model: typeof ApiResource, id: number) => AsyncData<ApiResource, ApiResource | true>,
-    fetchCollection: (model: typeof ApiResource, parent?: ApiResource | null, query?: AssociativeArray) => AsyncData<Collection, any>
+    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>
 }
 
+// TODO: améliorer le typage des fonctions sur le modèle de getRef
 export const useEntityFetch = (lazy: boolean = false): useEntityFetchReturnType => {
     const { em } = useEntityManager()
 
     const fetch = (model: typeof ApiResource, id: number) => useAsyncData(
-        model.entity + '_' + id,
+        model.entity + '_' + id + '_' + uuid4(),
         () => em.fetch(model, id, true),
         { lazy }
     )
 
-    const fetchCollection = (model: typeof ApiResource, parent: ApiResource | null = null, query: AssociativeArray = []) => useAsyncData(
-        model.entity + '_many',
-        () => em.fetchCollection(model, parent, query),
+    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 }
     )
 
+    // @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))
+    }
+
     //@ts-ignore
-    return { fetch, fetchCollection }
+    return { fetch, fetchCollection, getRef }
 }

+ 10 - 2
composables/data/useEntityManager.ts

@@ -1,7 +1,15 @@
 import EntityManager from "~/services/data/entityManager";
 import {useAp2iRequestService} from "~/composables/data/useAp2iRequestService";
+import {useRepo} from "pinia-orm";
+
+let entityManager: EntityManager | null = null
 
 export const useEntityManager = () => {
-    const { apiRequestService, pending } = useAp2iRequestService()
-    return { em: new EntityManager(apiRequestService), pending: pending }
+    if (entityManager === null) {
+        const { apiRequestService } = useAp2iRequestService()
+        const getRepo = useRepo
+
+        entityManager = new EntityManager(apiRequestService, getRepo)
+    }
+    return { em: entityManager }
 }

+ 2 - 1
composables/data/useEnumFetch.ts

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

+ 10 - 4
composables/data/useEnumManager.ts

@@ -1,9 +1,15 @@
 import {useAp2iRequestService} from "~/composables/data/useAp2iRequestService";
 import EnumManager from "~/services/data/enumManager";
-import VueI18n, {useI18n} from "vue-i18n";
+import {useI18n} from "vue-i18n";
+
+let enumManager:EnumManager | null = null
 
 export const useEnumManager = () => {
-    const { apiRequestService, pending } = useAp2iRequestService()
-    const i18n = useI18n() as any
-    return { enumManager: new EnumManager(apiRequestService, i18n), pending: pending }
+    //Avoid memory leak
+    if (enumManager === null) {
+        const { apiRequestService } = useAp2iRequestService()
+        const i18n = useI18n() as any
+        enumManager = new EnumManager(apiRequestService, i18n)
+    }
+    return { enumManager: enumManager }
 }

+ 3 - 3
composables/data/useImageFetch.ts

@@ -1,8 +1,8 @@
-import {FetchResult, useAsyncData} from "#app";
 import {useImageManager} from "~/composables/data/useImageManager";
+import {FetchResult} from "#app";
 
 interface useImageFetchReturnType {
-    fetch: (id: number | null, defaultImage?: string | null, height?: number, width?: number) => FetchResult<any>
+    fetch: (id: number | null, defaultImage?: string | null, height?: number, width?: number) => FetchResult<any, any>
 }
 
 /**
@@ -16,7 +16,7 @@ export const useImageFetch = (): useImageFetchReturnType => {
         defaultImage: string | null = null,
         height: number = 0,
         width: number = 0
-    ): FetchResult<string> => useAsyncData(
+    ): FetchResult<string, any> => useAsyncData(
         'img' + (id ?? defaultImage ?? 0),
         () => imageManager.get(id, defaultImage, height, width),
         { lazy: true, server: false }  // Always fetch images client-side

+ 9 - 2
composables/data/useImageManager.ts

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

+ 4 - 7
composables/form/useFieldViolation.ts

@@ -1,7 +1,6 @@
 import {computed, ComputedRef} from "@vue/reactivity";
 import {useFormStore} from "~/stores/form";
-import {useNuxtApp} from "#app";
-import {useGet} from "#imports";
+import * as _ from 'lodash-es'
 
 /**
  * Composable pour gérer l'apparition de message d'erreurs de validation d'un champ de formulaire
@@ -9,20 +8,18 @@ import {useGet} from "#imports";
  * @param field
  */
 export function useFieldViolation(field: string) {
-  const { emit } = useNuxtApp()
-
   const fieldViolations: ComputedRef<string> = computed(()=> {
-    return useGet(useFormStore().violations, field, '')
+    return _.get(useFormStore().violations, field, '')
   })
 
   /**
-   * Lorsque la valeur d'un champ change, on supprime le fait qu'il puisse être "faux" dans le store, et on émet sa nouvelle valeur au parent
+   * 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(useOmit(useFormStore().violations, field))
+    useFormStore().setViolations(_.omit(useFormStore().violations, field))
   }
 
   return {

+ 3 - 3
composables/form/useValidation.ts

@@ -1,6 +1,6 @@
-import VueI18n, {useI18n} from 'vue-i18n'
+import  {useI18n} from 'vue-i18n'
 import {useAp2iRequestService} from "~/composables/data/useAp2iRequestService";
-import Url from "~/services/utils/url";
+import UrlUtils from "~/services/utils/urlUtils";
 import {Ref} from "@vue/reactivity";
 
 /**
@@ -19,7 +19,7 @@ export function useValidation() {
     const validateSiret = async (siret: string) => {
 
       const { apiRequestService } = useAp2iRequestService()
-      const response: any = await apiRequestService.get(Url.join('/api/siret-checking', siret))
+      const response: any = await apiRequestService.get(UrlUtils.join('/api/siret-checking', siret))
 
       if (typeof response === 'undefined') {
         siretError.value = false

+ 3 - 2
composables/layout/useExtensionPanel.ts

@@ -1,8 +1,9 @@
 import {Ref} from "@vue/reactivity";
+import * as _ from 'lodash-es'
 
 /**
  * @category composables/form
- * Composable pour gérer les expansions des accordions
+ * Composable pour gérer les expansions des accordéons
  */
 export function useExtensionPanel(route: Ref) {
   const panel: Ref<number> = ref(0)
@@ -10,7 +11,7 @@ export function useExtensionPanel(route: Ref) {
 
   onMounted(() => {
     setTimeout(function () {
-      useEach(document.getElementsByClassName('v-expansion-panel'), (element, index) => {
+      _.each(document.getElementsByClassName('v-expansion-panel'), (element, index) => {
         if (element.id == activeAccordionId) {
           panel.value = index
         }

+ 23 - 34
composables/layout/useMenu.ts

@@ -1,13 +1,11 @@
-import {Ref, ref} from "@vue/reactivity";
 import {useAccessProfileStore} from "~/stores/accessProfile";
-import {useRuntimeConfig} from "#app";
 import {useAbility} from "@casl/vue";
 import {useOrganizationProfileStore} from "~/stores/organizationProfile";
-import AbstractMenuBuilder from "~/services/menuBuilder/abstractMenuBuilder";
 import {MenuGroup, MenuItem} from "~/types/layout";
-import {usePageStore} from "~/stores/page";
-import menus from "~/services/menuBuilder/_import";
 import {MENU_LINK_TYPE} from "~/types/enum/layout";
+import {AccessProfile} from "~/types/interfaces";
+import {useLayoutStore} from "~/stores/layout";
+import MenuComposer from "~/services/layout/menuComposer";
 
 /**
  * Renvoie des méthodes pour interagir avec les menus
@@ -24,36 +22,26 @@ export const useMenu = () => {
   const ability = useAbility()
   const organizationProfile = useOrganizationProfileStore()
   const accessProfile = useAccessProfileStore()
-  const pageState = usePageStore()
+  const layoutState = useLayoutStore()
 
-  const getBuilder = (name: string): AbstractMenuBuilder => {
-    if (!(name in menus)) {
-      throw new Error('Unknown menu : ' + name)
-    }
-    const menuBuilder = menus[name]
-
-    // @ts-ignore
-    return new menuBuilder(runtimeConfig, ability, organizationProfile, accessProfile)
+  /**
+   * 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
+   */
+  const buildAllMenu = () => {
+    MenuComposer.build(runtimeConfig, ability, organizationProfile, accessProfile as AccessProfile, layoutState)
   }
 
   /**
-   * Construit un menu à partir de son nom
-   * Met à jour le store pour garder en mémoire la présence de ce menu et son état
+   * Retourne le menu depuis le store ou retourne null
+   * si le menu n'a pas été construit pour l'utilisateur courant
    *
    * @param name
    */
-  const buildMenu = (name: string): Ref<MenuGroup> => {
-    const builder = getBuilder(name)
-
-    const menu = builder.build() as MenuGroup
-
-    // On enregistre l'état du menu dans le store de la page
-    if (menu !== null && (menu.children ?? []).length > 0) {
-      pageState.menusOpened[builder.getMenuName()] = false
-      console.log('Menu ' + builder.getMenuName() + ' built (' + (menu.children ?? []).length+ ' entries)')
-    }
-
-    return ref(menu)
+  const getMenu = (name: string): MenuGroup | MenuItem | null => {
+    return layoutState.menus[name] || null
   }
 
   /**
@@ -62,7 +50,7 @@ export const useMenu = () => {
    * @param name
    */
   const hasMenu = (name: string): boolean => {
-    return name in pageState.menusOpened
+    return getMenu(name) !== null
   }
 
   /**
@@ -71,7 +59,7 @@ export const useMenu = () => {
    * @param name
    */
   const assertExists = (name: string) => {
-    if (!(name in pageState.menusOpened)) {
+    if (getMenu(name) === null) {
       throw new Error('Unknown menu : ' + name)
     }
   }
@@ -83,7 +71,7 @@ export const useMenu = () => {
    */
   const isMenuOpened = (name: string): boolean => {
     assertExists(name)
-    return pageState.menusOpened[name]
+    return layoutState.menusOpened[name]
   }
 
   /**
@@ -94,7 +82,7 @@ export const useMenu = () => {
    */
   const setMenuState = (name: string, state: boolean) => {
     assertExists(name)
-    pageState.menusOpened[name] = state
+    layoutState.menusOpened[name] = state
   }
 
   /**
@@ -121,7 +109,7 @@ export const useMenu = () => {
    * @param name
    */
   const toggleMenu = (name: string) => {
-    setMenuState(name, !pageState.menusOpened[name])
+    setMenuState(name, !layoutState.menusOpened[name])
   }
 
   /**
@@ -134,7 +122,8 @@ export const useMenu = () => {
   }
 
   return {
-    buildMenu,
+    buildAllMenu,
+    getMenu,
     hasMenu,
     setMenuState,
     openMenu,

+ 0 - 13
composables/layout/useRedirectToLogin.ts

@@ -1,13 +0,0 @@
-import {navigateTo, useRuntimeConfig} from "#app";
-import Url from "~/services/utils/url";
-
-export const useRedirectToLogin = () => {
-    const runtimeConfig = useRuntimeConfig()
-
-    return () => {
-        if (!runtimeConfig.baseUrlAdminLegacy) {
-            throw new Error('Configuration error : no redirection target')
-        }
-        navigateTo(Url.join(runtimeConfig.baseUrlAdminLegacy, '#/login'), {external: true})
-    }
-}

+ 0 - 12
composables/utils/useAbilityUtils.ts

@@ -1,12 +0,0 @@
-import {useAccessProfileStore} from "~/stores/accessProfile";
-import {useOrganizationProfileStore} from "~/stores/organizationProfile";
-import {useAbility} from "@casl/vue";
-import AbilityUtils from "~/services/rights/abilityUtils";
-
-export const useAbilityUtils = () => {
-    const ability = useAbility()
-    const accessProfile = useAccessProfileStore()
-    const organizationProfile = useOrganizationProfileStore()
-
-    return new AbilityUtils(ability, accessProfile, organizationProfile)
-}

+ 14 - 0
composables/utils/useAdminUrl.ts

@@ -0,0 +1,14 @@
+import UrlUtils from "~/services/utils/urlUtils";
+
+export const useAdminUrl = () => {
+    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
+    }
+
+    return { makeAdminUrl }
+}

+ 0 - 7
composables/utils/useDateUtils.ts

@@ -1,7 +0,0 @@
-import {useNuxtApp} from "#app";
-import DateUtils from "~/services/utils/dateUtils";
-
-export const useDateUtils = () => {
-    const { $moment } = useNuxtApp()
-    return new DateUtils($moment)
-}

+ 19 - 0
composables/utils/useDownloadFile.ts

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

+ 9 - 5
composables/utils/useI18nUtils.ts

@@ -1,9 +1,13 @@
-import {useI18n, VueI18n} from "vue-i18n";
+import {useI18n} from "vue-i18n";
 import I18nUtils from "~/services/utils/i18nUtils";
 
+let i18nUtilsClass:null|I18nUtils = null
 export const useI18nUtils = () => {
-    const i18n = useI18n()
-
-    //@ts-ignore
-    return new I18nUtils(i18n)
+    //Avoid memory leak
+    if(i18nUtilsClass === null){
+        const i18n = useI18n()
+        //@ts-ignore
+        i18nUtilsClass = new I18nUtils(i18n)
+    }
+    return i18nUtilsClass
 }

+ 21 - 0
composables/utils/useRedirect.ts

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

+ 7 - 2
composables/utils/useValidationUtils.ts

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

+ 19 - 32
config/abilities/pages/addressBook.yaml

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

+ 17 - 27
config/abilities/pages/admin2ios.yaml

@@ -1,47 +1,37 @@
   all_accesses_page:
     action: 'display'
-    services:
-      access :
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'user'}]}
-      organization  :
-        - {function: hasModule, parameters: ['Admin2IOS']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['Admin2IOS']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'user'}]}
 
   all_organizations_page:
     action: 'display'
-    services:
-      access :
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'organization'}]}
-      organization  :
-        - {function: hasModule, parameters: ['Admin2IOS']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['Admin2IOS']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'organization'}]}
 
   tips_page:
     action: 'display'
-    services:
-      access :
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'tips'}]}
-      organization  :
-        - {function: hasModule, parameters: ['CorePremium']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['CorePremium']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'tips'}]}
 
   dgv_page:
     action: 'display'
-    services:
-      organization  :
-        - {function: hasModule, parameters: ['Admin2IOS']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['Admin2IOS']}
 
   cmf_cotisation_page:
     action: 'display'
-    services:
-      organization  :
-        - {function: hasModule, parameters: ['Admin2IOS']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['Admin2IOS']}
 
   right_page:
     action: 'display'
-    services:
-      organization  :
-        - {function: hasModule, parameters: ['Admin2IOS']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['Admin2IOS']}
 
   tree_page:
     action: 'display'
-    services:
-      organization  :
-        - {function: hasModule, parameters: ['Admin2IOS']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['Admin2IOS']}

+ 24 - 40
config/abilities/pages/billing.yaml

@@ -1,63 +1,47 @@
   billing_product_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'billings-administration'}]}
-      organization:
-        - {function: hasModule, parameters: ['BillingAdministration']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['BillingAdministration']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'billings-administration'}]}
 
   billing_products_by_student_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'pedagogics-administration'}]}
-      organization:
-        - {function: hasModule, parameters: ['BillingAdministration']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['BillingAdministration']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'pedagogics-administration'}]}
 
   billing_edition_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'manage', subject: 'billings-administration'}]}
-      organization:
-        - {function: hasModule, parameters: ['BillingAdministration']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['BillingAdministration']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'manage', subject: 'billings-administration'}]}
 
   billing_accounting_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'billings-administration'}]}
-      organization:
-        - {function: hasModule, parameters: ['BillingAdministration']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['BillingAdministration']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'billings-administration'}]}
 
   billing_payment_list_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'billings-administration'}]}
-      organization:
-        - {function: hasModule, parameters: ['BillingAdministration']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['BillingAdministration']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'billings-administration'}]}
 
   pes_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'manage', subject: 'billings-administration'}]}
-      organization:
-        - {function: hasModule, parameters: ['Pes']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['Pes']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'manage', subject: 'billings-administration'}]}
 
   berger_levrault_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'manage', subject: 'billings-administration'}]}
-      organization:
-        - {function: hasModule, parameters: ['BergerLevrault']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['BergerLevrault']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'manage', subject: 'billings-administration'}]}
 
   jvs_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'manage', subject: 'billings-administration'}]}
-      organization:
-        - {function: hasModule, parameters: ['Jvs']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['Jvs']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'manage', subject: 'billings-administration'}]}

+ 21 - 27
config/abilities/pages/communication.yaml

@@ -1,35 +1,29 @@
   inbox_page:
     action: 'display'
-    services:
-      access:
-        - function: hasAbility
-          parameters:
-            - {action: 'read', subject: 'mails'}
-            - {action: 'read', subject: 'emails'}
-            - {action: 'read', subject: 'texto'}
-      organization:
-        - {function: hasModule, parameters: ['MessagesAdvanced']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['MessagesAdvanced']}
+      - function: accessHasAnyRoleAbility
+        parameters:
+          - {action: 'read', subject: 'mails'}
+          - {action: 'read', subject: 'emails'}
+          - {action: 'read', subject: 'texto'}
 
   message_send_page:
     action: 'display'
-    services:
-      access:
-        - function: hasAbility
-          parameters:
-            - {action: 'read', subject: 'mails'}
-            - {action: 'read', subject: 'emails'}
-            - {action: 'read', subject: 'texto'}
-      organization:
-        - {function: hasModule, parameters: ['MessagesAdvanced']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['MessagesAdvanced']}
+      - function: accessHasAnyRoleAbility
+        parameters:
+          - {action: 'read', subject: 'mails'}
+          - {action: 'read', subject: 'emails'}
+          - {action: 'read', subject: 'texto'}
 
   message_templates_page:
     action: 'display'
-    services:
-      access:
-        - function: hasAbility
-          parameters:
-            - {action: 'read', subject: 'mails'}
-            - {action: 'read', subject: 'emails'}
-            - {action: 'read', subject: 'texto'}
-      organization:
-        - {function: hasModule, parameters: ['MessagesAdvanced']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['MessagesAdvanced']}
+      - function: accessHasAnyRoleAbility
+        parameters:
+          - {action: 'read', subject: 'mails'}
+          - {action: 'read', subject: 'emails'}
+          - {action: 'read', subject: 'texto'}

+ 51 - 85
config/abilities/pages/cotisations.yaml

@@ -1,135 +1,101 @@
   rate_cotisation_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'cotisation'}]}
-      organization:
-        - {function: hasModule, parameters: ['CotisationRate', 'CotisationCall']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['CotisationRate', 'CotisationCall']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'cotisation'}]}
 
   parameters_cotisation_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'cotisation'}]}
-      organization:
-        - {function: hasModule, parameters: ['CotisationCall']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['CotisationCall']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'cotisation'}]}
 
   send_cotisation_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'cotisation'}]}
-      organization:
-        - {function: hasModule, parameters: ['CotisationCall']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['CotisationCall']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'cotisation'}]}
 
   state_cotisation_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'cotisation'}]}
-      organization:
-        - {function: hasModule, parameters: ['CotisationCall']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['CotisationCall']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'cotisation'}]}
 
   pay_cotisation_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'cotisation'}]}
-      organization:
-        - {function: hasModule, parameters: ['CotisationCall']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['CotisationCall']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'cotisation'}]}
 
   check_cotisation_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'cotisation'}]}
-      organization:
-        - {function: hasModule, parameters: ['CotisationCall']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['CotisationCall']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'cotisation'}]}
 
   ledger_cotisation_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'cotisation'}]}
-      organization:
-        - {function: hasModule, parameters: ['CotisationCall']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['CotisationCall']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'cotisation'}]}
 
   magazine_cotisation_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'cotisation'}]}
-      organization:
-        - {function: hasModule, parameters: ['CotisationCMFAdministration']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['CotisationCMFAdministration']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'cotisation'}]}
 
   ventilated_cotisation_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'cotisation'}]}
-      organization:
-        - {function: hasModule, parameters: ['CotisationCall']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['CotisationCall']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'cotisation'}]}
 
   pay_erase_cotisation_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'cotisation'}]}
-      organization:
-        - {function: hasModule, parameters: ['CotisationCall']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['CotisationCall']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'cotisation'}]}
 
   resume_cotisation_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'cotisation'}]}
-      organization:
-        - {function: hasModule, parameters: ['CotisationTransmissionState']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['CotisationTransmissionState']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'cotisation'}]}
 
   history_cotisation_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'cotisation'}]}
-      organization:
-        - {function: hasModule, parameters: ['CotisationCall']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['CotisationCall']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'cotisation'}]}
 
   call_cotisation_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'cotisation'}]}
-      organization:
-        - {function: hasModule, parameters: ['CotisationStructure']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['CotisationStructure']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'cotisation'}]}
 
   history_structure_cotisation_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'cotisation'}]}
-      organization:
-        - {function: hasModule, parameters: ['CotisationStructure']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['CotisationStructure']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'cotisation'}]}
 
   insurance_cotisation_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'cotisation'}]}
-      organization:
-        - {function: hasModule, parameters: ['CotisationStructure', 'CotisationTransmissionState']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['CotisationStructure', 'CotisationTransmissionState']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'cotisation'}]}
 
   resume_all_cotisation_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'cotisation'}]}
-      organization:
-        - {function: hasModule, parameters: ['CotisationTransmission']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['CotisationTransmission']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'cotisation'}]}
 
   resume_pay_cotisation_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'cotisation'}]}
-      organization:
-        - {function: hasModule, parameters: ['CotisationTransmission']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['CotisationTransmission']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'cotisation'}]}

+ 3 - 5
config/abilities/pages/donor.yaml

@@ -1,7 +1,5 @@
   donors_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'donors'}]}
-      organization:
-        - {function: hasModule, parameters: ['Donors']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['Donors']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'donors'}]}

+ 21 - 35
config/abilities/pages/educational.yaml

@@ -1,55 +1,41 @@
   criteria_notations_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'pedagogics-administration'}]}
-      organization:
-        - {function: hasModule, parameters: ['PedagogicsAdministation']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['PedagogicsAdministation']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'pedagogics-administration'}]}
 
   education_notation_config_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'pedagogics-administration'}]}
-      organization:
-        - {function: hasModule, parameters: ['AdvancedEducationNotation']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['AdvancedEducationNotation']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'pedagogics-administration'}]}
 
   seizure_period_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'pedagogics-administration'}]}
-      organization:
-        - {function: hasModule, parameters: ['PedagogicsAdministation']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['PeriodValidation']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'pedagogics-administration'}]}
 
   test_seizure_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'pedagogics-seizure'}]}
-      organization:
-        - {function: hasModule, parameters: ['PedagogicsSeizure']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['PedagogicsSeizure']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'pedagogics-seizure'}]}
 
   test_validation_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'pedagogics-administration'}]}
-      organization:
-        - {function: hasModule, parameters: ['PedagogicsAdministation']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['PedagogicsAdministation']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'pedagogics-administration'}]}
 
   examen_results_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'pedagogics-administration'}]}
-      organization:
-        - {function: hasModule, parameters: ['PedagogicsAdministation']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['PedagogicsAdministation']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'pedagogics-administration'}]}
 
   education_by_student_validation_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'pedagogics-seizure'}]}
-      organization:
-        - {function: hasModule, parameters: ['PedagogicsSeizure']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['PedagogicsSeizure']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'pedagogics-seizure'}]}

+ 3 - 5
config/abilities/pages/equipment.yaml

@@ -1,7 +1,5 @@
   equipment_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'equipments'}]}
-      organization:
-        - {function: hasModule, parameters: ['Equipments']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['Equipments']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'equipments'}]}

+ 3 - 5
config/abilities/pages/medals.yaml

@@ -1,7 +1,5 @@
   medals_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'medals'}]}
-      organization:
-        - {function: hasModule, parameters: ['Medals']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['Medals']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'medals'}]}

+ 38 - 57
config/abilities/pages/myAccount.yaml

@@ -1,101 +1,82 @@
   my_schedule_page:
     action: 'display'
-    services:
-      access:
-        - { function: isAdminAccount, result: false }
+    conditions:
+      - { function: accessIsAdminAccount, expectedResult: false }
 
   attendance_bookings_page:
     action: 'display'
-    services:
-      access:
-        - { function: hasAbility, parameters: [{action: 'write', subject: 'attendances'}] }
-        - { function: isAdminAccount, result: false }
-      organization:
-        - {function: hasModule, parameters: ['Attendances']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['Attendances']}
+      # TODO: l'action write existe-t-elle?
+      - { function: accessHasAnyRoleAbility, parameters: [{action: 'write', subject: 'attendances'}] }
+      - { function: accessIsAdminAccount, expectedResult: false }
 
   my_attendance_page:
     action: 'display'
-    services:
-      access:
-        - { function: isAdminAccount, result: false }
+    conditions:
+      - { function: accessIsAdminAccount, expectedResult: false }
 
   my_invitation_page:
     action: 'display'
-    services:
-      access:
-        - { function: isAdminAccount, result: false }
+    conditions:
+      - { function: accessIsAdminAccount, expectedResult: false }
 
   my_students_page:
     action: 'display'
-    services:
-      access:
-        - { function: hasProfile, parameters: [ 'teacher'] }
+    conditions:
+      - { function: accessHasAnyProfile, parameters: [ 'teacher'] }
 
   my_students_education_students_page:
     action: 'display'
-    services:
-      access:
-        - { function: hasProfile, parameters: [ 'teacher'] }
+    conditions:
+      - { function: accessHasAnyProfile, parameters: [ 'teacher'] }
 
   criteria_notations_page_from_account_menu:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'criterianotation'}]}
-      organization:
-        - {function: hasModule, parameters: ['PedagogicsAdministation']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['PedagogicsAdministation']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'criterianotation'}]}
 
   my_education_students_page:
     action: 'display'
-    services:
-      access:
-        - { function: hasProfile, parameters: [ 'student'] }
+    conditions:
+      - { function: accessHasAnyProfile, parameters: [ 'student'] }
 
   send_an_email_page:
     action: 'display'
-    services:
-      access:
-        - { function: hasProfile, parameters: [ 'admin', 'teacher' ] }
+    conditions:
+      - { function: accessHasAnyProfile, parameters: [ 'admin', 'teacher' ] }
 
   my_documents_page:
     action: 'display'
-    services:
-      access:
-        - { function: isAdminAccount, result: false }
+    conditions:
+      - { function: accessIsAdminAccount, expectedResult: false }
 
   my_profile_page:
     action: 'display'
-    services:
-      access:
-        - { function: isAdminAccount, result: false }
+    conditions:
+      - { function: accessIsAdminAccount, expectedResult: false }
 
   adherent_list_page:
     action: 'display'
-    services:
-      access:
-        - { function: hasProfile, parameters: [ 'member'] }
-      organization:
-        - {function: isShowAdherentList}
-        - {function: hasModule, parameters: ['Users']}
+    conditions:
+      - { function: organizationHasAnyModule, parameters: ['Users'] }
+      - { function: organizationIsShowAdherentList }
+      - { function: accessHasAnyProfile, parameters: ['member'] }
 
   subscription_page:
     action: 'display'
-    services:
-      access:
-        - { function: hasProfile, parameters: ['admin', 'administratifManager', 'pedagogicManager', 'financialManager']}
-      organization:
-        - {function: hasModule, parameters: ['GeneralConfig']}
+    conditions:
+      - { function: organizationHasAnyModule, parameters: ['GeneralConfig'] }
+      - { function: accessHasAnyProfile, parameters: ['admin', 'administratifManager', 'pedagogicManager', 'financialManager', 'caMember'] }
 
   my_bills_page:
     action: 'display'
-    services:
-      access:
-        - { function: hasProfile, parameters: ['guardian', 'payor']}
+    conditions:
+      - { function: accessHasAnyProfile, parameters: ['guardian', 'payor']}
 
   cmf_licence_person_page:
     action: 'display'
-    services:
-      access:
-        - { function: isAdminAccount, result: false }
-      organization:
-        - {function: isCmf}
+    conditions:
+      - { function: organizationIsCmf }
+      - { function: accessIsAdminAccount, expectedResult: false }

+ 71 - 101
config/abilities/pages/parameters.yaml

@@ -1,154 +1,124 @@
   organization_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'organization'}]}
-      organization:
-        - {function: hasModule, parameters: ['GeneralConfig']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['GeneralConfig']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'organization'}]}
 
   cmf_licence_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'organization'}]}
-      organization:
-        - {function: hasModule, parameters: ['GeneralConfig']}
-        - {function: isCmf}
+    conditions:
+        - { function: organizationIsCmf}
+        - { function: organizationHasAnyModule, parameters: ['GeneralConfig']}
+        - { function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'organization'}] }
 
   parameters_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'general-config'}]}
-      organization:
-        - {function: hasModule, parameters: ['GeneralConfig']}
+    conditions:
+      - { function: organizationHasAnyModule, parameters: ['GeneralConfig']}
+      - { function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'general-config'}] }
 
   parameters_communication_page:
     action: 'display'
-    services:
-      access:
-        - { function: hasAbility, parameters: [ { action: 'read', subject: 'general-config' } ] }
-      organization:
-        - { function: hasModule, parameters: [ 'GeneralConfig' ] }
+    conditions:
+      - { function: organizationHasAnyModule, parameters: [ 'GeneralConfig' ] }
+      - { function: accessHasAnyRoleAbility, parameters: [ { action: 'read', subject: 'general-config' } ] }
 
   parameters_student_page:
     action: 'display'
-    services:
-      access:
-        - { function: hasAbility, parameters: [ { action: 'read', subject: 'general-config' } ] }
-      organization:
-        - { function: hasModule, parameters: [ 'GeneralConfig' ] }
-        - {function: isSchool}
+    conditions:
+      - { function: organizationIsSchool }
+      - { function: organizationHasAnyModule, parameters: [ 'GeneralConfig' ] }
+      - { function: accessHasAnyRoleAbility, parameters: [ { action: 'read', subject: 'general-config' } ] }
 
   parameters_education_page:
     action: 'display'
-    services:
-      access:
-        - { function: hasAbility, parameters: [ { action: 'read', subject: 'general-config' } ] }
-      organization:
-        - { function: hasModule, parameters: [ 'GeneralConfig' ] }
-        - { function: isSchool }
+    conditions:
+      - { function: organizationIsSchool }
+      - { function: organizationHasAnyModule, parameters: [ 'GeneralConfig' ] }
+      - { function: accessHasAnyRoleAbility, parameters: [ { action: 'read', subject: 'general-config' } ] }
 
   parameters_bills_page:
     action: 'display'
-    services:
-      access:
-        - { function: hasAbility, parameters: [ { action: 'read', subject: 'general-config' } ] }
-      organization:
-        - { function: hasModule, parameters: [ 'GeneralConfig' ] }
-        - { function: isSchool }
+    conditions:
+      - { function: organizationIsSchool }
+      - { function: organizationHasAnyModule, parameters: [ 'GeneralConfig' ] }
+      - { function: accessHasAnyRoleAbility, parameters: [ { action: 'read', subject: 'general-config' } ] }
 
   parameters_secure_page:
     action: 'display'
-    services:
-      access:
-        - { function: hasAbility, parameters: [ { action: 'read', subject: 'general-config' } ] }
-      organization:
-        - { function: hasModule, parameters: [ 'GeneralConfig' ] }
+    conditions:
+      - { function: organizationHasAnyModule, parameters: [ 'GeneralConfig' ] }
+      - { function: accessHasAnyRoleAbility, parameters: [ { action: 'read', subject: 'general-config' } ] }
 
   place_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'place'}]}
-      organization:
-        - {function: hasModule, parameters: ['GeneralConfig']}
+    conditions:
+      - { function: organizationHasAnyModule, parameters: ['GeneralConfig']}
+      - { function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'place'}]}
 
   education_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'pedagogics-administration'}]}
-      organization:
-        - {function: hasModule, parameters: ['PedagogicsAdministation']}
+    conditions:
+      - { function: organizationHasAnyModule, parameters: ['PedagogicsAdministation']}
+      - { function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'pedagogics-administration'}]}
 
   tag_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'manage', subject: 'tagg'}]}
-      organization:
-        - {function: hasModule, parameters: ['TaggAdvanced']}
+    conditions:
+      - { function: organizationHasAnyModule, parameters: ['TaggAdvanced']}
+      - { function: accessHasAnyRoleAbility, parameters: [{action: 'manage', subject: 'tagg'}]}
 
   activities_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'activity'}]}
-      organization:
-        - {function: hasModule, parameters: ['GeneralConfig']}
+    conditions:
+      - { function: organizationHasAnyModule, parameters: ['GeneralConfig']}
+      - { function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'activity'}]}
 
   template_systems_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'general-config'}]}
-      organization:
-        - {function: hasModule, parameters: ['TemplateMessages']}
+    conditions:
+      - { function: organizationHasAnyModule, parameters: ['TemplateMessages']}
+      - { function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'general-config'}]}
 
   billing_settings_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'billings-administration'}]}
-      organization:
-        - {function: hasModule, parameters: ['BillingAdministration']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['BillingAdministration']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'billings-administration'}]}
+
+  billing_schedules_settings_page:
+    action: 'display'
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['BillingAdministration']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'billings-administration'}]}
 
   online_registration_settings_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'onlineregistration-administration'}]}
-      organization:
-        - {function: hasModule, parameters: ['IEL']}
-        - {function: isSchool}
+    conditions:
+      - {function: organizationIsSchool}
+      - {function: organizationHasAnyModule, parameters: ['IEL']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'onlineregistration-administration'}]}
 
   transition_next_year_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'manage', subject: 'pedagogics-administration'}]}
-      organization:
-        - {function: hasModule, parameters: ['PedagogicsAdministation']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['PedagogicsAdministation']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'manage', subject: 'pedagogics-administration'}]}
 
   course_duplication_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'manage', subject: 'pedagogics-administration'}]}
-      organization:
-        - {function: hasModule, parameters: ['PedagogicsAdministation']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['PedagogicsAdministation']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'manage', subject: 'pedagogics-administration'}]}
 
   import_page:
     action: 'display'
-    services:
-      access:
-        - function: hasAbility
-          parameters:
-            - {action: 'manage', subject: 'user'}
-            - {action: 'manage', subject: 'equipments'}
-      organization:
-        - function: hasModule
-          parameters:
-            - 'Users'
-            - 'Equipments'
+    conditions:
+      - function: organizationHasAnyModule
+        parameters:
+          - 'Users'
+          - 'Equipments'
+      - function: accessHasAnyRoleAbility
+        parameters:
+          - {action: 'manage', subject: 'user'}
+          - {action: 'manage', subject: 'equipments'}

+ 25 - 35
config/abilities/pages/schedule.yaml

@@ -1,49 +1,39 @@
   agenda_page:
     action: 'display'
-    services:
-      access:
-        - function: hasAbility
-          parameters:
-            - {action: 'read', subject: 'events'}
-            - {action: 'read', subject: 'examens'}
-            - {action: 'read', subject: 'educationalprojects'}
-            - {action: 'read', subject: 'courses'}
-      organization:
-        - function: hasModule
-          parameters:
-            - 'Events'
-            - 'Courses'
-            - 'Examens'
-            - 'EducationalProjects'
+    conditions:
+      - function: organizationHasAnyModule
+        parameters:
+          - 'Events'
+          - 'Courses'
+          - 'Examens'
+          - 'EducationalProjects'
+      - function: accessHasAnyRoleAbility
+        parameters:
+          - {action: 'read', subject: 'events'}
+          - {action: 'read', subject: 'examens'}
+          - {action: 'read', subject: 'educationalprojects'}
+          - {action: 'read', subject: 'courses'}
 
   attendance_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'attendances'}]}
-      organization:
-        - {function: hasModule, parameters: ['Attendances']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['Attendances']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'attendances'}]}
 
   course_page:
     action: 'display'
-    services:
-      access:
-        - { function: hasAbility, parameters: [ { action: 'read', subject: 'courses' } ] }
-      organization:
-        - { function: hasModule, parameters: [ 'Courses' ] }
+    conditions:
+      - { function: organizationHasAnyModule, parameters: [ 'Courses' ] }
+      - { function: accessHasAnyRoleAbility, parameters: [ { action: 'read', subject: 'courses' } ] }
 
   exam_page:
     action: 'display'
-    services:
-      access:
-        - { function: hasAbility, parameters: [ { action: 'read', subject: 'examens' } ] }
-      organization:
-        - { function: hasModule, parameters: [ 'Examens' ] }
+    conditions:
+      - { function: organizationHasAnyModule, parameters: [ 'Examens' ] }
+      - { function: accessHasAnyRoleAbility, parameters: [ { action: 'read', subject: 'examens' } ] }
 
   pedagogics_project_page:
     action: 'display'
-    services:
-      access:
-        - { function: hasAbility, parameters: [ { action: 'read', subject: 'educationalprojects' } ] }
-      organization:
-        - { function: hasModule, parameters: [ 'EducationalProjects' ] }
+    conditions:
+      - { function: organizationHasAnyModule, parameters: [ 'EducationalProjects' ] }
+      - { function: accessHasAnyRoleAbility, parameters: [ { action: 'read', subject: 'educationalprojects' } ] }

+ 12 - 20
config/abilities/pages/stats.yaml

@@ -1,31 +1,23 @@
   report_activity_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'statistic'}]}
-      organization:
-        - {function: hasModule, parameters: ['Statistic']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['Statistic']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'statistic'}]}
 
   education_quotas_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'educationstudent'}]}
-      organization:
-        - {function: hasModule, parameters: ['PedagogicsAdministation']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['PedagogicsAdministation']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'educationstudent'}]}
 
   fede_stats_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'statistic'}]}
-      organization:
-        - {function: hasModule, parameters: ['StatisticFederation']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['StatisticFederation']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'statistic'}]}
 
   structure_stats_page:
     action: 'display'
-    services:
-      access:
-        - {function: hasAbility, parameters: [{action: 'read', subject: 'statistic'}]}
-      organization:
-        - {function: hasModule, parameters: ['StatisticStructure']}
+    conditions:
+      - {function: organizationHasAnyModule, parameters: ['StatisticStructure']}
+      - {function: accessHasAnyRoleAbility, parameters: [{action: 'read', subject: 'statistic'}]}

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini