浏览代码

merge v84512-page-prefs

Olivier Massot 2 年之前
父节点
当前提交
6b3a72f025
共有 100 个文件被更改,包括 4463 次插入964 次删除
  1. 12 0
      README.md
  2. 1 1
      components/Layout/Alert/Content.vue
  3. 1 1
      components/Layout/AlertBar/Env.vue
  4. 1 5
      components/Layout/AlertBar/SuperAdmin.vue
  5. 4 2
      components/Layout/AlertBar/SwitchUser.vue
  6. 3 1
      components/Layout/AlertBar/SwitchYear.vue
  7. 11 4
      components/Layout/Dialog.vue
  8. 45 0
      components/Layout/Parameters/Attendances.vue
  9. 90 0
      components/Layout/Parameters/Bulletin.vue
  10. 79 0
      components/Layout/Parameters/EducationNotation.vue
  11. 101 0
      components/Layout/Parameters/EducationTimings.vue
  12. 96 0
      components/Layout/Parameters/General.vue
  13. 73 0
      components/Layout/Parameters/Intranet.vue
  14. 95 0
      components/Layout/Parameters/ResidenceAreas.vue
  15. 47 0
      components/Layout/Parameters/Sms.vue
  16. 97 0
      components/Layout/Parameters/SuperAdmin.vue
  17. 116 0
      components/Layout/Parameters/Teaching.vue
  18. 182 0
      components/Layout/Parameters/Website.vue
  19. 3 1
      components/Layout/SubHeader/ActivityYear.vue
  20. 3 2
      components/Layout/SubHeader/Breadcrumbs.vue
  21. 3 1
      components/Layout/SubHeader/DataTiming.vue
  22. 3 1
      components/Layout/SubHeader/DataTimingRange.vue
  23. 14 6
      components/Ui/Button/Delete.vue
  24. 22 11
      components/Ui/Button/Submit.vue
  25. 6 4
      components/Ui/DatePicker.vue
  26. 212 135
      components/Ui/Form.vue
  27. 89 0
      components/Ui/Form/Creation.vue
  28. 115 0
      components/Ui/Form/Edition.vue
  29. 200 95
      components/Ui/Input/Autocomplete.vue
  30. 230 0
      components/Ui/Input/Autocomplete/Accesses.vue
  31. 16 16
      components/Ui/Input/AutocompleteWithAPI.vue
  32. 136 0
      components/Ui/Input/AutocompleteWithAp2i.vue
  33. 60 0
      components/Ui/Input/AutocompleteWithEnum.vue
  34. 50 15
      components/Ui/Input/Checkbox.vue
  35. 109 0
      components/Ui/Input/Combobox.vue
  36. 59 97
      components/Ui/Input/DatePicker.vue
  37. 21 3
      components/Ui/Input/Number.vue
  38. 1 1
      components/Ui/Input/Text.vue
  39. 19 0
      components/Ui/LoadingPanel.vue
  40. 2 2
      composables/data/useAp2iRequestService.ts
  41. 61 0
      composables/data/useRefreshProfile.ts
  42. 1 18
      composables/form/useValidation.ts
  43. 14 0
      composables/form/validation/useSubdomainValidation.ts
  44. 26 0
      i18n.config.ts
  45. 79 12
      lang/fr.json
  46. 6 0
      lang/fr.json.removed
  47. 6 0
      models/Access/Access.ts
  48. 1 1
      models/Access/AdminAccess.ts
  49. 1 9
      models/ApiResource.ts
  50. 3 0
      models/Education/Cycle.ts
  51. 1 1
      models/Education/EducationTiming.ts
  52. 26 19
      models/Organization/Parameters.ts
  53. 15 0
      models/Organization/SubdomainAvailability.ts
  54. 6 0
      models/Person/Person.ts
  55. 24 0
      models/decorators.ts
  56. 1 1
      models/models.ts
  57. 22 40
      nuxt.config.ts
  58. 18 18
      package.json
  59. 14 0
      pages/parameters.vue
  60. 32 0
      pages/parameters/cycles/[id].vue
  61. 10 0
      pages/parameters/cycles/index.vue
  62. 33 0
      pages/parameters/education_timings/[id].vue
  63. 10 0
      pages/parameters/education_timings/index.vue
  64. 42 0
      pages/parameters/education_timings/new.vue
  65. 136 0
      pages/parameters/index.vue
  66. 36 0
      pages/parameters/residence_areas/[id].vue
  67. 10 0
      pages/parameters/residence_areas/index.vue
  68. 40 0
      pages/parameters/residence_areas/new.vue
  69. 88 0
      pages/parameters/subdomains/[id].vue
  70. 10 0
      pages/parameters/subdomains/index.vue
  71. 145 0
      pages/parameters/subdomains/new.vue
  72. 1 1
      plugins/ability.ts
  73. 12 15
      plugins/init.server.ts
  74. 2 2
      plugins/sse.client.ts
  75. 6 16
      services/data/apiRequestService.ts
  76. 10 35
      services/data/entityManager.ts
  77. 2 2
      services/data/enumManager.ts
  78. 0 72
      services/data/normalizer/hydraDenormalizer.ts
  79. 219 0
      services/data/normalizer/hydraNormalizer.ts
  80. 3 10
      services/layout/menuBuilder/configurationMenuBuilder.ts
  81. 30 0
      services/utils/stringUtils.ts
  82. 15 4
      services/utils/urlUtils.ts
  83. 28 0
      services/validation/subdomainValidation.ts
  84. 22 15
      stores/accessProfile.ts
  85. 24 15
      stores/organizationProfile.ts
  86. 18 0
      stores/repositories/EducationTimingsRepository.ts
  87. 1 1
      stores/repositories/NotificationRepository.ts
  88. 16 0
      stores/repositories/ResidenceAreasRepository.ts
  89. 10 17
      tests/units/services/data/apiRequestService.test.ts
  90. 22 12
      tests/units/services/data/entityManager.test.ts
  91. 1 1
      tests/units/services/data/enumManager.test.ts
  92. 0 219
      tests/units/services/data/normalizer/hydraDenormalizer.test.ts
  93. 532 0
      tests/units/services/data/normalizer/hydraNormalizer.test.ts
  94. 10 2
      tests/units/services/layout/menuBuilder/configurationMenuBuilder.test.ts
  95. 1 1
      tests/units/services/sse/sseSource.test.ts
  96. 12 0
      tests/units/services/utils/dateUtils.test.ts
  97. 28 0
      tests/units/services/utils/stringUtils.test.ts
  98. 10 0
      tests/units/services/utils/urlUtils.test.ts
  99. 58 0
      tests/units/services/validation/subdomainValidation.test.ts
  100. 26 1
      types/enum/enums.ts

+ 12 - 0
README.md

@@ -105,6 +105,18 @@ Sur les environnements où app est servie par supervisor, on peut consulter les
 > le `-6000` étant le nombre de bytes à afficher
 > Voir plus : http://supervisord.org/running.html#supervisorctl-command-line-options
 
+### Faire fonctionner le HMR
+
+Si le HMR (Hot Module Reload) ne fontionne pas et qu'un message d'erreur est logué en console disant que l'adresse
+n'est pas accessible, alors suivre les étapes suivantes :
+
+- Ouvrir l'inspecteur de son navigateur, onglet Réseau
+- Rafraichir la page
+- Trouver la requête en erreur. Elle devrait être de la forme `https://local.app.opentalent.fr:24678/_nuxt/`
+- Clic droit dessus, puis "ouvrir dans un nouvel onglet"
+- Ajouter une exception de sécurité dans le navigateur
+
+
 ## Plus d'infos
 
 ## Structure du projet

+ 1 - 1
components/Layout/Alert/Content.vue

@@ -55,7 +55,7 @@ const pageStore = usePageStore()
  * Retire l'alerte après `time` (en ms)
  * @param time
  */
-const clearAlert = (time: number = 2000) => {
+const clearAlert = (time: number = 4000) => {
   timeout = setTimeout(() => {
     show.value = false
     pageStore.removeSlowlyAlert()

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

@@ -16,6 +16,6 @@ Barre d'alerte qui s'affiche lorsque l'utilisateur n'est pas dans un environneme
 <script setup lang="ts">
   const runtimeConfig = useRuntimeConfig()
 
-  const env = runtimeConfig.env ?? 'unknown'
+  const env = runtimeConfig.public.env ?? 'unknown'
   const show = env !== 'production'
 </script>

+ 1 - 5
components/Layout/AlertBar/SuperAdmin.vue

@@ -6,13 +6,9 @@ Barre d'alerte qui s'affiche lorsque l'utilisateur est un super admin en mode sw
 
 <template>
   <!-- TODO : fonctionnement à valider -->
-  <UiSystemBar v-if="show" class="theme-danger">
+  <UiSystemBar v-if="show" class="theme-danger" :href="url">
     <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>
 

+ 4 - 2
components/Layout/AlertBar/SwitchUser.vue

@@ -7,9 +7,11 @@ Barre qui s'affiche lorsque l'utilisateur possède un compte multi user
 <template>
   <UiSystemBar v-if="show" class="theme-info">
     <v-icon small icon="fas fa-info-circle" />
-    <span v-html="$t('multi_account_alert', { fullName })" />&nbsp;
+    <span>
+      {{ $t('multi_account_alert_part1') }} <strong>{{ fullName }}</strong> {{ $t('multi_account_alert_part2') }}
+    </span>
 
-    <v-icon class="pl-1" small icon="fa fa-users"/> &nbsp;{{$t('multi_account_alert_next')}}
+    <v-icon class="pl-1" small icon="fa fa-users"/> &nbsp;{{$t('multi_account_alert_part3')}}
   </UiSystemBar>
 </template>
 

+ 3 - 1
components/Layout/AlertBar/SwitchYear.vue

@@ -25,12 +25,14 @@ Barre d'alerte qui s'affiche lorsque l'utilisateur n'est pas sur l'année couran
   import Access from "~/models/Access/Access";
   import {usePageStore} from "~/stores/page";
   import {useEntityManager} from "~/composables/data/useEntityManager";
+  import {useRefreshProfile} from "~/composables/data/useRefreshProfile";
 
   const { em } = useEntityManager()
   const accessProfile = useAccessProfileStore()
   const organizationProfile = useOrganizationProfileStore()
   const { setDirty } = useFormStore()
   const pageStore = usePageStore()
+  const { refreshProfile } = useRefreshProfile()
 
   const show: ComputedRef<boolean> = computed(() => {
     return (
@@ -58,7 +60,7 @@ Barre d'alerte qui s'affiche lorsque l'utilisateur n'est pas sur l'année couran
     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()
+      await refreshProfile()
     }
     window.location.reload()
   }

+ 11 - 4
components/Layout/Dialog.vue

@@ -1,8 +1,8 @@
 <!-- Fenêtre de dialogue -->
 <template>
   <v-dialog
-    :model-value="show"
-    persistent
+    :model-value="_show"
+    :persistent="true"
     :max-width="maxWidth"
     :content-class="contentClass"
   >
@@ -34,9 +34,11 @@
 </template>
 
 <script setup lang="ts">
+import {PropType} from "@vue/runtime-core";
+
 const props = defineProps({
   show: {
-    type: Boolean,
+    type: [Boolean, Object],
     required: true
   },
   contentClass: {
@@ -54,6 +56,11 @@ const props = defineProps({
     default: 800
   }
 })
+
+// @ts-ignore  -> just to avoid the error with the prop's type of v-dialog
+const _show = computed(() => props.show) as boolean
+
+
 </script>
 
 <style lang="scss" scoped>
@@ -66,7 +73,7 @@ const props = defineProps({
     width: 60px;
     min-width: 60px;
     max-width: 60px;
-    min-height: 400px;
+    min-height: 120px;
     padding: 25px 10px;
 
    h3 {

+ 45 - 0
components/Layout/Parameters/Attendances.vue

@@ -0,0 +1,45 @@
+<template>
+  <LayoutContainer>
+    <UiLoadingPanel v-if="pending" />
+    <UiForm v-else :model="Parameters" :entity="parameters">
+      <v-row>
+        <v-col cols="6">
+          <UiInputCheckbox
+            v-model="parameters.sendAttendanceEmail"
+            field="sendAttendanceEmail"
+            label="sendAttendanceEmail"
+          />
+
+          <UiInputCheckbox
+            v-model="parameters.sendAttendanceSms"
+            field="sendAttendanceSms"
+          />
+
+          <UiInputCheckbox
+            v-model="parameters.notifyAdministrationAbsence"
+            field="notifyAdministrationAbsence"
+          />
+        </v-col>
+      </v-row>
+    </UiForm>
+  </LayoutContainer>
+</template>
+<script setup lang="ts">
+import Parameters from '~/models/Organization/Parameters'
+import { useEntityFetch } from '~/composables/data/useEntityFetch'
+import { useOrganizationProfileStore } from '~/stores/organizationProfile'
+import { AsyncData } from '#app'
+
+const { fetch } = useEntityFetch()
+
+const organizationProfile = useOrganizationProfileStore()
+
+if (organizationProfile.parametersId === null) {
+  throw new Error('Missing organization parameters id')
+}
+
+const { data: parameters, pending } = fetch(
+  Parameters,
+  organizationProfile.parametersId
+) as AsyncData<Parameters, Parameters | true>
+</script>

+ 90 - 0
components/Layout/Parameters/Bulletin.vue

@@ -0,0 +1,90 @@
+
+<template>
+  <LayoutContainer>
+    <UiLoadingPanel v-if="pending" />
+    <UiForm
+        v-else
+        :model="Parameters"
+        :entity="parameters"
+    >
+      <v-row>
+        <v-col cols="6">
+          <UiInputCheckbox
+              v-model="parameters.bulletinWithTeacher"
+              field="bulletinWithTeacher"
+          />
+
+          <UiInputCheckbox
+              v-model="parameters.bulletinSignatureDirector"
+              field="bulletinSignatureDirector"
+          />
+
+          <UiInputCheckbox
+              v-model="parameters.bulletinShowEducationWithoutEvaluation"
+              field="bulletinShowEducationWithoutEvaluation"
+          />
+
+          <UiInputCheckbox
+              v-model="parameters.bulletinShowAbsences"
+              field="bulletinShowAbsences"
+          />
+
+          <UiInputCheckbox
+              v-model="parameters.bulletinEditWithoutEvaluation"
+              field="bulletinEditWithoutEvaluation"
+          />
+        </v-col>
+
+        <v-col cols="6">
+          <UiInputCheckbox
+              v-model="parameters.bulletinPrintAddress"
+              field="bulletinPrintAddress"
+          />
+
+          <UiInputCheckbox
+              v-model="parameters.bulletinDisplayLevelAcquired"
+              field="bulletinDisplayLevelAcquired"
+          />
+
+          <UiInputCheckbox
+              v-model="parameters.bulletinViewTestResults"
+              field="bulletinViewTestResults"
+          />
+
+          <UiInputCheckbox
+              v-model="parameters.bulletinShowAverages"
+              field="bulletinShowAverages"
+          />
+
+          <UiInputAutocompleteWithEnum
+              v-model="parameters.bulletinReceiver"
+              field="bulletinReceiver"
+              enum-name="organization_bulletin_send_to"
+          />
+        </v-col>
+      </v-row>
+    </UiForm>
+  </LayoutContainer>
+</template>
+
+<script setup lang="ts">
+import Parameters from "~/models/Organization/Parameters";
+import {useEntityFetch} from "~/composables/data/useEntityFetch";
+import {useOrganizationProfileStore} from "~/stores/organizationProfile";
+import {AsyncData} from "#app";
+
+const { fetch } = useEntityFetch()
+
+const organizationProfile = useOrganizationProfileStore()
+
+if (organizationProfile.parametersId === null) {
+  throw new Error('Missing organization parameters id')
+}
+
+const { data: parameters, pending } = fetch(Parameters, organizationProfile.parametersId) as AsyncData<Parameters, Parameters | true>
+
+</script>
+
+<style scoped lang="scss">
+
+</style>

+ 79 - 0
components/Layout/Parameters/EducationNotation.vue

@@ -0,0 +1,79 @@
+<template>
+  <LayoutContainer>
+    <UiLoadingPanel v-if="pending" />
+    <UiForm
+        v-else
+        :model="Parameters"
+        :entity="parameters"
+    >
+      <v-row>
+        <v-col cols="6">
+          <UiInputCheckbox
+              v-model="parameters.periodValidation"
+              field="periodValidation"
+              label="define_validation_periods_for_teachers"
+          />
+          <UiInputCheckbox
+              v-model="parameters.editCriteriaNotationByAdminOnly"
+              field="editCriteriaNotationByAdminOnly"
+              label="evaluation_criterium_edition_is_admin_only"
+          />
+
+          <UiInputAutocompleteWithEnum
+              v-if="organizationProfile.hasModule('AdvancedEducationNotation')"
+              v-model="parameters.advancedEducationNotationType"
+              enum-name="advanced_education_notation"
+              field="advancedEducationNotationType"
+          />
+        </v-col>
+
+        <v-col cols="6">
+          <UiInputCheckbox
+              v-model="parameters.requiredValidation"
+              field="requiredValidation"
+              label="mandatory_validation_for_evaluations"
+          />
+
+          <UiInputAutocompleteWithEnum
+              v-model="parameters.educationPeriodicity"
+              enum-name="education_periodicity"
+              field="educationPeriodicity"
+          />
+
+          <UiInputNumber
+            v-model="parameters.average"
+            field="average"
+            label="max_note_for_pedagogical_followup"
+            :default="20"
+            :min="1"
+            :max="100"
+            class="mt-2"
+          />
+        </v-col>
+      </v-row>
+    </UiForm>
+  </LayoutContainer>
+</template>
+
+<script setup lang="ts">
+import Parameters from "~/models/Organization/Parameters";
+import {useEntityFetch} from "~/composables/data/useEntityFetch";
+import {useOrganizationProfileStore} from "~/stores/organizationProfile";
+import {AsyncData} from "#app";
+
+const i18n = useI18n()
+const { fetch } = useEntityFetch()
+
+const organizationProfile = useOrganizationProfileStore()
+
+if (organizationProfile.parametersId === null) {
+  throw new Error('Missing organization parameters id')
+}
+
+const { data: parameters, pending } = fetch(Parameters, organizationProfile.parametersId) as AsyncData<Parameters, Parameters | true>
+
+</script>
+
+<style scoped lang="scss">
+
+</style>

+ 101 - 0
components/Layout/Parameters/EducationTimings.vue

@@ -0,0 +1,101 @@
+<template>
+  <LayoutContainer>
+    <UiLoadingPanel v-if="pending" />
+    <v-container v-else style="width: 500px;">
+      <v-col cols="12">
+        <v-row class="justify-center">
+          <v-table class="w-100">
+            <thead>
+              <tr>
+                <td>{{ $t('educationTimings') }}</td>
+                <td></td>
+              </tr>
+            </thead>
+            <tbody>
+              <tr v-if="educationTimings.length > 0" v-for="timing in educationTimings" :key="timing.id">
+                <td class="cycle-editable-cell">
+                  {{ timing.timing }}
+                </td>
+                <td class="d-flex flex-row">
+                  <v-btn
+                      :flat="true"
+                      icon="fa fa-pen"
+                      class="cycle-edit-icon mr-3"
+                      @click="goToEditPage(timing.id as number)"
+                  />
+                  <UiButtonDelete
+                      :model="EducationTiming"
+                      :entity="timing"
+                      :flat="true"
+                      class="cycle-edit-icon"
+                  />
+                </td>
+              </tr>
+              <tr v-else class="theme-neutral">
+                <td><i>{{ $t('nothing_to_show')}}</i></td>
+                <td></td>
+              </tr>
+            </tbody>
+          </v-table>
+        </v-row>
+        <v-row class="justify-end">
+          <v-btn
+              :flat="true"
+              prepend-icon="fa fa-plus"
+              class="theme-primary mt-2"
+              @click="goToCreatePage"
+          >
+            {{ $t('add') }}
+          </v-btn>
+        </v-row>
+      </v-col>
+    </v-container>
+  </LayoutContainer>
+
+</template>
+
+<script setup lang="ts">
+import { useEntityFetch } from '~/composables/data/useEntityFetch'
+import EducationTiming from '~/models/Education/EducationTiming'
+import { useRepo } from 'pinia-orm'
+import EducationTimingsRepository from '~/stores/repositories/EducationTimingsRepository'
+import {ComputedRef} from "vue";
+import {useOrganizationProfileStore} from "~/stores/organizationProfile";
+import UrlUtils from "~/services/utils/urlUtils";
+
+const organizationProfile = useOrganizationProfileStore()
+
+if (organizationProfile.parametersId === null) {
+  throw new Error('Missing organization parameters id')
+}
+
+const { fetch, fetchCollection } = useEntityFetch()
+
+const { pending } = fetchCollection(EducationTiming)
+
+const educationTimingRepo = useRepo(EducationTimingsRepository)
+
+/**
+ * On récupère les timings via le store
+ * (sans ça, les mises à jour SSE ne seront pas prises en compte)
+ */
+const educationTimings: ComputedRef<Array<EducationTiming>> = computed(() => {
+  return educationTimingRepo.getEducationTimings()
+})
+
+const goToEditPage = (id: number) => {
+  navigateTo(UrlUtils.join('/parameters/education_timings', id))
+}
+
+const goToCreatePage = () => {
+  navigateTo('/parameters/education_timings/new')
+}
+</script>
+
+<style scoped lang="scss">
+// TODO: voir à factoriser ces styles, ptêt en faisant un component de ces boutons?
+:deep(.cycle-edit-icon .v-icon) {
+  color: rgb(var(--v-theme-primary));
+  font-size: 18px;
+}
+</style>

+ 96 - 0
components/Layout/Parameters/General.vue

@@ -0,0 +1,96 @@
+<template>
+  <LayoutContainer>
+    <UiLoadingPanel v-if="pending" />
+    <UiForm
+        v-else
+        :model="Parameters"
+        :entity="parameters"
+    >
+      <v-row>
+        <v-col cols="6">
+          <UiInputDatePicker
+              v-if="organizationProfile.isSchool"
+              v-model="parameters.financialDate"
+              field="financialDate"
+              label="start_date_of_financial_season"
+              class="my-2"
+          />
+
+          <UiInputDatePicker
+              v-if="organizationProfile.isSchool"
+              v-model="parameters.startCourseDate"
+              field="startCourseDate"
+              label="start_date_of_courses"
+              class="my-2"
+          />
+
+          <UiInputCheckbox
+              v-model="parameters.showAdherentList"
+              field="showAdherentList"
+              label="show_adherents_list_and_their_coordinates"
+          />
+
+          <UiInputAutocompleteWithEnum
+              v-model="parameters.timezone"
+              enum-name="timezone"
+              field="timezone"
+          />
+        </v-col>
+
+        <v-col cols="6">
+          <UiInputDatePicker
+              v-if="organizationProfile.isSchool"
+              v-model="parameters.musicalDate"
+              field="musicalDate"
+              label="start_date_of_activity_season"
+              class="my-2"
+          />
+
+          <UiInputDatePicker
+              v-if="organizationProfile.isSchool"
+              v-model="parameters.endCourseDate"
+              field="endCourseDate"
+              label="end_date_of_courses"
+              class="my-2"
+          />
+
+          <UiInputCheckbox
+              v-if="organizationProfile.isSchool && organizationProfile.isAssociation"
+              v-model="parameters.studentsAreAdherents"
+              field="studentsAreAdherents"
+              label="students_are_also_association_members"
+          />
+
+          <!-- TODO: reprendre l'UiInput -->
+          <UiInputImage
+              v-model="parameters['qrCode_id']"
+              field="qrCode_id"
+              label="licenceQrCode"
+          />
+        </v-col>
+      </v-row>
+    </UiForm>
+  </LayoutContainer>
+</template>
+
+<script setup lang="ts">
+import Parameters from "~/models/Organization/Parameters";
+import {useEntityFetch} from "~/composables/data/useEntityFetch";
+import {useOrganizationProfileStore} from "~/stores/organizationProfile";
+import {AsyncData} from "#app";
+
+const { fetch } = useEntityFetch()
+
+const organizationProfile = useOrganizationProfileStore()
+
+if (organizationProfile.parametersId === null) {
+  throw new Error('Missing organization parameters id')
+}
+
+const { data: parameters, pending } = fetch(Parameters, organizationProfile.parametersId) as AsyncData<Parameters, Parameters | true>
+</script>
+
+<style scoped lang="scss">
+
+
+</style>

+ 73 - 0
components/Layout/Parameters/Intranet.vue

@@ -0,0 +1,73 @@
+<template>
+  <LayoutContainer>
+    <UiLoadingPanel v-if="pending" />
+    <UiForm
+        v-else
+        :model="Parameters"
+        :entity="parameters"
+    >
+      <v-row>
+        <v-col cols="6">
+          <UiInputCheckbox
+              v-model="parameters.generateAttendanceReport"
+              field="generateAttendanceReport"
+              label="allow_teachers_to_generate_attendance_reports"
+          />
+
+          <UiInputCheckbox
+              v-model="parameters.administrationCc"
+              field="administrationCc"
+              label="send_teachers_mail_reports_copy_to_administration"
+          />
+
+          <UiInputCheckbox
+              v-model="parameters.allowMembersToChangeGivenNameAndName"
+              field="allowMembersToChangeGivenNameAndName"
+              label="allow_members_to_change_their_names_and_firstnames"
+          />
+        </v-col>
+
+        <v-col cols="6">
+          <UiInputCheckbox
+              v-model="parameters.createCourse"
+              field="createCourse"
+              label="allow_teachers_to_create_courses"
+          />
+
+          <UiInputCheckbox
+              v-model="parameters.consultTeacherListing"
+              field="consultTeacherListing"
+              label="allow_teachers_to_consult_colleagues_informations"
+          />
+
+          <UiInputCheckbox
+              v-model="parameters.showAdherentList"
+              field="showAdherentList"
+              label="allow_students_to_consult_their_pedagogical_followup"
+          />
+        </v-col>
+      </v-row>
+    </UiForm>
+  </LayoutContainer>
+</template>
+
+<script setup lang="ts">
+  import Parameters from "~/models/Organization/Parameters";
+  import {useEntityFetch} from "~/composables/data/useEntityFetch";
+  import {useOrganizationProfileStore} from "~/stores/organizationProfile";
+  import {AsyncData} from "#app";
+
+  const { fetch } = useEntityFetch()
+
+  const organizationProfile = useOrganizationProfileStore()
+
+  if (organizationProfile.parametersId === null) {
+    throw new Error('Missing organization parameters id')
+  }
+
+  const { data: parameters, pending } = fetch(Parameters, organizationProfile.parametersId) as AsyncData<Parameters, Parameters | true>
+</script>
+
+<style scoped lang="scss">
+
+</style>

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

@@ -0,0 +1,95 @@
+<template>
+  <LayoutContainer>
+    <UiLoadingPanel v-if="pending" />
+    <v-container v-else style="width: 500px;">
+      <v-col cols="12">
+        <v-row class="justify-center">
+          <v-table class="w-100">
+            <thead>
+            <tr>
+              <td>{{ $t('residenceAreas') }}</td>
+              <td></td>
+            </tr>
+            </thead>
+            <tbody>
+            <tr v-if="residenceAreas.length > 0" v-for="residenceArea in residenceAreas" :key="residenceArea.id">
+              <td class="cycle-editable-cell">
+                {{ residenceArea.label }}
+              </td>
+              <td class="d-flex flex-row">
+                <v-btn
+                    :flat="true"
+                    icon="fa fa-pen"
+                    class="cycle-edit-icon mr-3"
+                    @click="goToEditPage(residenceArea.id as number)"
+                />
+                <UiButtonDelete
+                    :model="ResidenceArea"
+                    :entity="residenceArea"
+                    :flat="true"
+                    class="cycle-edit-icon"
+                />
+              </td>
+            </tr>
+            <tr v-else class="theme-neutral">
+              <td><i>{{ $t('nothing_to_show')}}</i></td>
+              <td></td>
+            </tr>
+            </tbody>
+          </v-table>
+        </v-row>
+        <v-row class="justify-end">
+          <v-btn
+              :flat="true"
+              prepend-icon="fa fa-plus"
+              class="theme-primary mt-2"
+              @click="goToCreatePage"
+          >
+            {{ $t('add') }}
+          </v-btn>
+        </v-row>
+      </v-col>
+    </v-container>
+  </LayoutContainer>
+</template>
+
+<script setup lang="ts">
+import { useEntityFetch } from '~/composables/data/useEntityFetch'
+import ResidenceArea from '~/models/Billing/ResidenceArea'
+import { useRepo } from 'pinia-orm'
+import ResidenceAreasRepository from '~/stores/repositories/ResidenceAreasRepository'
+import { useRouter } from 'vue-router'
+import UrlUtils from "~/services/utils/urlUtils";
+import EducationTiming from "~/models/Education/EducationTiming";
+const residenceAreasRepo = useRepo(ResidenceAreasRepository)
+
+const router = useRouter()
+const { fetchCollection } = useEntityFetch()
+const i18n = useI18n()
+
+const { pending } = fetchCollection(ResidenceArea)
+
+/**
+ * On récupère les Residence Area via le store
+ * (sans ça, les mises à jour SSE ne seront pas prises en compte)
+ */
+ const residenceAreas: ComputedRef<Array<ResidenceArea>> = computed(() => {
+  return residenceAreasRepo.getResidenceAreas()
+})
+
+const goToEditPage = (id: number) => {
+  navigateTo(UrlUtils.join('/parameters/residence_areas', id))
+}
+
+const goToCreatePage = () => {
+  navigateTo(`/parameters/residence_areas/new`)
+}
+</script>
+
+<style scoped lang="scss">
+// TODO: voir à factoriser ces styles, ptêt en faisant un component de ces boutons?
+:deep(.cycle-edit-icon .v-icon) {
+  color: rgb(var(--v-theme-primary));
+  font-size: 18px;
+}
+</style>

+ 47 - 0
components/Layout/Parameters/Sms.vue

@@ -0,0 +1,47 @@
+<template>
+  <div>
+    <UiForm :model="Parameters" :entity="parameters">
+      <v-row>
+        <v-col cols="12">
+          <UiInputText
+            v-model="parameters.smsSenderName"
+            field="smsSenderName"
+          />
+        </v-col>
+        <v-col cols="12">
+          <UiInputText
+            v-model="parameters.usernameSMS"
+            field="usernameSMS"
+            label="Nom d'utilisateur SMS"
+          />
+        </v-col>
+        <v-col cols="12">
+          <UiInputText
+              v-model="parameters.passwordSMS"
+              field="passwordSMS"
+              type="password"
+          />
+        </v-col>
+      </v-row>
+    </UiForm>
+  </div>
+</template>
+<script setup lang="ts">
+import Parameters from '~/models/Organization/Parameters'
+import { useEntityFetch } from '~/composables/data/useEntityFetch'
+import { useOrganizationProfileStore } from '~/stores/organizationProfile'
+import { AsyncData } from '#app'
+
+const { fetch } = useEntityFetch()
+
+const organizationProfile = useOrganizationProfileStore()
+
+if (organizationProfile.parametersId === null) {
+  throw new Error('Missing organization parameters id')
+}
+
+const { data: parameters, pending } = fetch(
+  Parameters,
+  organizationProfile.parametersId
+) as AsyncData<Parameters, Parameters | true>
+</script>

+ 97 - 0
components/Layout/Parameters/SuperAdmin.vue

@@ -0,0 +1,97 @@
+<template>
+  <div>
+    <v-container>
+      <v-row>
+        <v-col cols="1">
+        </v-col>
+        <v-col cols="4">
+          <div class="explanation">
+            <div class="px-6 d-flex flex-row align-center">
+              <v-icon class="theme-primary">fa fa-info</v-icon>
+            </div>
+            <div class="px-2">
+              {{ $t('super_admin_explanation_text')}}
+            </div>
+          </div>
+        </v-col>
+        <v-col cols="1" />
+        <v-col cols="6">
+          <v-row>
+
+          </v-row>
+          <v-row v-if="pending">
+            <UiLoadingPanel/>
+          </v-row>
+          <v-row v-else>
+            <UiForm
+                ref="form"
+                :model="AdminAccess"
+                :entity="adminAccess"
+                class="w-100"
+            >
+              <div class="d-flex flex-row mx-4 my-6">
+                <span>{{ $t('username') }} :</span> <pre> {{ adminAccess.username }}</pre>
+              </div>
+
+              <UiInputText
+                  field="email"
+                  v-model="adminAccess.email"
+                  :rules="rules()"
+              />
+            </UiForm>
+          </v-row>
+        </v-col>
+      </v-row>
+    </v-container>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useEntityFetch } from '~/composables/data/useEntityFetch'
+import { useAccessProfileStore } from '~/stores/accessProfile'
+import AdminAccess from '~/models/Access/AdminAccess'
+import {useValidationUtils} from "~/composables/utils/useValidationUtils";
+
+const { fetch } = useEntityFetch()
+
+const accessProfile = useAccessProfileStore()
+if (accessProfile.id === null) {
+  throw new Error('Missing access profile id')
+}
+
+const { data: adminAccess, pending } = fetch(AdminAccess, accessProfile.id)
+
+const i18n = useI18n()
+
+const validationUtils = useValidationUtils()
+
+
+const rules = () => [
+  (email: string | null) =>
+    (email && validationUtils.validEmail(email)) || i18n.t('email_error')
+]
+</script>
+
+<style scoped lang="scss">
+.explanation {
+  display: flex;
+  flex-direction: row;
+  padding: 60px 26px;
+  text-align: justify;
+  color: rgb(var(--v-theme-neutral-strong));
+
+  .v-icon {
+    background-color: rgb(var(--v-theme-primary));
+    font-size: 22px;
+    border-radius: 16px;
+    margin: 3px;
+    padding: 3px;
+    height: 28px;
+    width: 28px;
+  }
+
+  div:first-child {
+    border-right: solid 1px rgb(var(--v-theme-primary));
+  }
+}
+</style>

+ 116 - 0
components/Layout/Parameters/Teaching.vue

@@ -0,0 +1,116 @@
+<template>
+  <LayoutContainer>
+    <UiLoadingPanel v-if="pending" />
+    <UiForm
+        v-else
+        :model="Parameters"
+        :entity="parameters"
+    >
+      <v-table>
+        <thead>
+          <tr>
+            <td>{{ $t('originalLabel') }}</td>
+            <td>{{ $t('effectiveLabel') }}</td>
+          </tr>
+        </thead>
+
+        <tbody>
+          <tr v-for="enumItem in cycleEnum">
+            <td>{{ $t(enumItem.value) }}</td>
+            <td class="cycle-editable-cell">
+              {{ orderedCycles[enumItem.value] ? orderedCycles[enumItem.value].label : $t(enumItem.value) }}
+            </td>
+            <td style="max-width: 24px;">
+              <v-btn
+                  v-if="orderedCycles[enumItem.value]"
+                  :flat="true"
+                  icon="fa fa-pen"
+                  class="cycle-edit-icon"
+                  @click="goToCycleEditPage(orderedCycles[enumItem.value].id)"
+              />
+            </td>
+          </tr>
+        </tbody>
+      </v-table>
+
+      <v-row>
+        <v-col cols="6">
+          <UiInputCheckbox
+              v-model="parameters.showEducationIsACollectivePractice"
+              field="showEducationIsACollectivePractice"
+              label="allow_to_configure_teachings_with_played_instrument_choice"
+          />
+        </v-col>
+      </v-row>
+    </UiForm>
+  </LayoutContainer>
+</template>
+
+<script setup lang="ts">
+import Parameters from "~/models/Organization/Parameters";
+import {useEntityFetch} from "~/composables/data/useEntityFetch";
+import Cycle from "~/models/Education/Cycle";
+import {AsyncData} from "#app";
+import {useOrganizationProfileStore} from "~/stores/organizationProfile";
+import {AnyJson} from "~/types/data";
+import {useEnumFetch} from "~/composables/data/useEnumFetch";
+
+const organizationProfile = useOrganizationProfileStore()
+
+if (organizationProfile.parametersId === null) {
+  throw new Error('Missing organization parameters id')
+}
+
+const { fetch, fetchCollection } = useEntityFetch()
+const { fetch: fetchEnum } = useEnumFetch()
+
+
+const { data: cycleEnum, pending: enumPending } = fetchEnum('education_cycle')
+
+const { data: parameters, pending: parametersPending } = fetch(Parameters, organizationProfile.parametersId) as AsyncData<Parameters, Parameters | true>
+
+const { data: cycles, pending: cyclesPending } = fetchCollection(Cycle)
+
+const pending: ComputedRef<boolean> = computed(() => enumPending.value || parametersPending.value || cyclesPending.value)
+
+const orderedCycles: ComputedRef<AnyJson> = computed(() => {
+  if (pending.value) {
+    return []
+  }
+
+  let orderedCycles: AnyJson = {}
+
+  for (const enumItem of cycleEnum.value) {
+    orderedCycles[enumItem.value] = null
+  }
+
+  for (const cycle of cycles.value.items) {
+    if (!orderedCycles.hasOwnProperty(cycle.cycleEnum)) {
+      console.error('Unknown cycle enum : ' + cycle.cycleEnum)
+      continue
+    }
+
+    orderedCycles[cycle.cycleEnum] = cycle
+  }
+
+  return orderedCycles
+})
+
+
+const goToCycleEditPage = (id: number) => {
+  navigateTo(`parameters/cycles/${id}`)
+}
+</script>
+
+<style scoped lang="scss">
+
+.cycle-edit-icon {
+  color: rgb(var(--v-theme-primary));
+}
+
+:deep(.cycle-edit-icon .v-icon) {
+  font-size: 18px;
+}
+
+
+</style>

+ 182 - 0
components/Layout/Parameters/Website.vue

@@ -0,0 +1,182 @@
+<template>
+  <LayoutContainer>
+    <UiLoadingPanel v-if="pending" />
+    <UiForm
+        v-else
+        :model="Parameters"
+        :entity="parameters"
+    >
+      <v-row>
+        <v-col cols="6">
+          <div class="mb-6">
+            <div>{{ $t('your_opentalent_website_address_is')}} : </div>
+            <div class="ma-2 text-primary">
+              <strong>{{ organizationProfile.website }}</strong>
+            </div>
+          </div>
+
+          <div class="mb-6">
+            <div>{{ $t('your_subdomains') }} : </div>
+            <UiLoadingPanel v-if="subdomainsPending" />
+            <div v-else>
+              <v-table class="my-2">
+                <tbody>
+                  <tr
+                      v-for="subdomain in subdomains.items"
+                      :key="subdomain.id"
+                      :title="subdomain.subdomain"
+                      class="subdomainItem"
+                      @click="goToEditPage(subdomain.id)"
+                  >
+                    <td>{{ subdomain.subdomain }}</td>
+                    <td>
+                        <span v-if="subdomain.active">
+                          <v-icon class="text-success icon">
+                            fa-solid fa-check
+                          </v-icon> {{ $t('active') }}
+                        </span>
+                    </td>
+
+                  </tr>
+
+                </tbody>
+
+              </v-table>
+
+              <v-btn
+                  :disabled="!canAddNewSubdomain"
+                  class="my-2"
+                  @click="onAddSubdomainClick"
+              >
+                {{ $t('record_a_new_subdomain')}}
+              </v-btn>
+            </div>
+          </div>
+        </v-col>
+
+        <v-col cols="6">
+            <!-- les publicationDirectors sont des entités Access -->
+            <UiInputAutocompleteAccesses
+                v-model="parameters.publicationDirectors"
+                field="publicationDirectors"
+                multiple
+                chips
+            />
+
+          <div class="my-8" v-if="!organizationProfile.isCmf">
+            <v-btn
+              v-if="!parameters.desactivateOpentalentSiteWeb"
+              color="error"
+              @click="showWebsiteDeactivationDialog=true"
+            >
+              {{ $t('deactivateOpentalentSiteWeb') }}
+            </v-btn>
+            <v-btn
+              v-else
+              color="primary"
+              @click="reactivateWebsite"
+            >
+              {{ $t('reactivateOpentalentSiteWeb') }}
+            </v-btn>
+
+            <LazyLayoutDialog :show="showWebsiteDeactivationDialog">
+              <template #dialogTitle>
+                {{ $t('please_confirm')}}
+              </template>
+              <template #dialogText>
+                <v-col>
+                  <div>{{ $t('yourOpentalentWebsiteWillBeDeactivatedOnceYouLlHaveSaved')}}.</div>
+                  <span>{{ $t('doYouWantToContinue')}} ?</span>
+                </v-col>
+              </template>
+              <template #dialogBtn>
+                <v-btn
+                    class="theme-neutral-soft mr-4"
+                    @click="showWebsiteDeactivationDialog=false"
+                >
+                  {{ $t('cancel') }}
+                </v-btn>
+                <v-btn
+                    class="theme-primary"
+                    @click="showWebsiteDeactivationDialog=false; deactivateWebsite()"
+                >
+                  {{ $t('yes') }}
+                </v-btn>
+              </template>
+            </LazyLayoutDialog>
+          </div>
+
+          <div>
+            <UiInputText
+                v-model="parameters.otherWebsite"
+                field="otherWebsite"
+            />
+          </div>
+        </v-col>
+      </v-row>
+    </UiForm>
+  </LayoutContainer>
+</template>
+
+<script setup lang="ts">
+import {useOrganizationProfileStore} from "~/stores/organizationProfile";
+import Parameters from "~/models/Organization/Parameters";
+import {useEntityFetch} from "~/composables/data/useEntityFetch";
+import {AsyncData} from "#app";
+import Subdomain from "~/models/Organization/Subdomain";
+
+const i18n = useI18n()
+
+const { fetch, fetchCollection } = useEntityFetch()
+
+const organizationProfile = useOrganizationProfileStore()
+
+if (organizationProfile.parametersId === null) {
+  throw new Error('Missing organization parameters id')
+}
+
+const { data: parameters, pending } = fetch(Parameters, organizationProfile.parametersId) as AsyncData<Parameters, Parameters | true>
+
+const { data: subdomains, pending: subdomainsPending } = fetchCollection(Subdomain, null, ref({ 'organization' : organizationProfile.id }) )
+
+const canAddNewSubdomain: ComputedRef<boolean> = computed(() => subdomains.value && subdomains.value.items.length < 3)
+
+const goToEditPage = (id: number) => {
+  console.log(parameters.value)
+  navigateTo(`parameters/subdomains/${id}`)
+}
+
+const onAddSubdomainClick = () => {
+  if (!canAddNewSubdomain) {
+    throw new Error('Max number of subdomains reached')
+  }
+  navigateTo('/parameters/subdomains/new')
+}
+
+const showWebsiteDeactivationDialog: Ref<boolean> = ref(false)
+
+
+const deactivateWebsite = () => {
+  parameters.value.desactivateOpentalentSiteWeb = true
+}
+
+const reactivateWebsite = () => {
+  parameters.value.desactivateOpentalentSiteWeb = false
+}
+</script>
+
+<style scoped lang="scss">
+.v-table {
+  background: transparent;
+}
+.subdomainItem {
+  cursor: pointer;
+}
+.subdomainItem:hover {
+  background: rgb(var(--v-theme-neutral));
+}
+.subdomainItem .icon {
+  font-size: 12px;
+}
+
+</style>

+ 3 - 1
components/Layout/SubHeader/ActivityYear.vue

@@ -34,6 +34,7 @@ import {useAccessProfileStore} from "~/stores/accessProfile";
 import Access from "~/models/Access/Access";
 import {useDisplay} from "vuetify";
 import {usePageStore} from "~/stores/page";
+import {useRefreshProfile} from "~/composables/data/useRefreshProfile";
 
 const { em } = useEntityManager()
 const accessProfileStore = useAccessProfileStore()
@@ -41,6 +42,7 @@ const organizationProfileStore = useOrganizationProfileStore()
 const formStore = useFormStore()
 const pageStore = usePageStore()
 const { mdAndUp } = useDisplay()
+const { refreshProfile } = useRefreshProfile()
 
 const currentActivityYear: ComputedRef<number | undefined> = computed(() => accessProfileStore.activityYear ?? undefined)
 const yearPlusOne: boolean = !organizationProfileStore.isManagerProduct
@@ -65,7 +67,7 @@ const setActivityYear = async (event: string) => {
   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()
+    await refreshProfile()
   }
 
   window.location.reload()

+ 3 - 2
components/Layout/SubHeader/Breadcrumbs.vue

@@ -29,9 +29,10 @@ const items: ComputedRef<Array<AnyJson>> = computed(() => {
   pathPart.forEach((part) => {
     path = UrlUtils.join(path, part)
 
-    const match = router.resolve(path)
+    let match
 
-    if (match.name !== null) {
+    match = router.resolve(path)
+    if (match.name) {
       crumbs.push({
         title: !parseInt(part, 10) ? i18n.t(part + '_breadcrumbs') : i18n.t('item'),
         exact: true,

+ 3 - 1
components/Layout/SubHeader/DataTiming.vue

@@ -36,6 +36,7 @@ import {useEntityManager} from "~/composables/data/useEntityManager";
 import {useDisplay, useTheme} from "vuetify";
 import Access from "~/models/Access/Access";
 import {usePageStore} from "~/stores/page";
+import {useRefreshProfile} from "~/composables/data/useRefreshProfile";
 
 // TODO: en v3.0.5, pas de solution documentée pour renseigner directement la couleur dans le template, à revoir
 const color = useTheme().current.value.colors['primary']
@@ -45,6 +46,7 @@ const accessProfileStore = useAccessProfileStore()
 const { em } = useEntityManager()
 const { mdAndUp } = useDisplay()
 const pageStore = usePageStore()
+const { refreshProfile } = useRefreshProfile()
 
 const toggle = ref(null)
 
@@ -73,7 +75,7 @@ const onUpdate = async (newValue: Array<string>) => {
   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()
+    await refreshProfile()
   }
 
   window.location.reload()

+ 3 - 1
components/Layout/SubHeader/DataTimingRange.vue

@@ -22,11 +22,13 @@ import {useEntityManager} from "~/composables/data/useEntityManager";
 import Access from "~/models/Access/Access";
 import DateUtils from "~/services/utils/dateUtils";
 import {usePageStore} from "~/stores/page";
+import {useRefreshProfile} from "~/composables/data/useRefreshProfile";
 
 const { setDirty } = useFormStore()
 const accessProfileStore = useAccessProfileStore()
 const { em } = useEntityManager()
 const pageStore = usePageStore()
+const { refreshProfile } = useRefreshProfile()
 
 const start = accessProfileStore.historical.dateStart
 const end = accessProfileStore.historical.dateEnd
@@ -57,7 +59,7 @@ const updateDateTimeRange = async (dates: Array<Date>): Promise<any> => {
   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()
+    await refreshProfile()
   }
 
   window.location.reload()

+ 14 - 6
components/Ui/Button/Delete.vue

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

+ 22 - 11
components/Ui/Button/Submit.vue

@@ -1,5 +1,11 @@
 <template>
-  <v-btn class="mr-4 theme-primary" :class="hasOtherActions ? 'pr-0' : ''" @click="submitAction(mainAction)" ref="mainBtn">
+  <v-btn
+      class="mr-4 theme-primary"
+      :class="hasOtherActions ? 'pr-0' : ''"
+      @click="submitAction(mainAction)"
+      ref="mainBtn"
+      :disabled="validationPending"
+  >
 
     {{ $t(mainAction) }}
 
@@ -16,7 +22,7 @@
       <template #activator="{ on, attrs }">
         <v-toolbar-title v-on="on">
           <v-icon class="pl-3 pr-3">
-            {{ dropDirection === 'top' ? 'fa-caret-up' : 'fa-caret-down'}}
+            {{ dropDirection === 'top' ? 'fa fa-caret-up' : 'fa fa-caret-down'}}
           </v-icon>
         </v-toolbar-title>
       </template>
@@ -41,15 +47,20 @@
 import {computed, ComputedRef, ref, Ref} from "@vue/reactivity";
 
 const props = defineProps({
-    actions: {
-      type: Array,
-      required: true
-    },
-    dropDirection: {
-      type: String,
-      required: false,
-      default:'bottom'
-    }
+  actions: {
+    type: Array,
+    required: true
+  },
+  dropDirection: {
+    type: String,
+    required: false,
+    default:'bottom'
+  },
+  validationPending: {
+    type: Boolean,
+    required: false,
+    default: false
+  }
 })
 
 const emit = defineEmits(['submit'])

+ 6 - 4
components/Ui/DatePicker.vue

@@ -1,12 +1,11 @@
 <!--
 Sélecteur de dates
 
-@see https://vuetifyjs.com/en/components/date-pickers/
+@see https://vue3datepicker.com/
 -->
 
 <template>
   <main>
-    <!-- @see https://vue3datepicker.com/props/modes/#multi-calendars -->
     <VueDatePicker
         :model-value="modelValue"
         :locale="i18n.locale.value"
@@ -18,20 +17,19 @@ Sélecteur de dates
         :auto-apply="true"
         :select-text="$t('select')"
         :cancel-text="$t('cancel')"
+        :disabled="readonly"
         @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: {
@@ -56,6 +54,10 @@ const props = defineProps({
   }
 })
 
+const defaultFormatPattern = props.withTime ?
+    DateUtils.getFormatPattern(i18n.locale.value as supportedLocales) :
+    DateUtils.getShortFormatPattern(i18n.locale.value as supportedLocales)
+
 const dateFormat: Ref<string> = ref(props.format ?? defaultFormatPattern)
 
 const emit = defineEmits(['update:model-value'])

+ 212 - 135
components/Ui/Form.vue

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

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

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

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

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

+ 200 - 95
components/Ui/Input/Autocomplete.vue

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

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

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

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

@@ -7,21 +7,21 @@ d'une api)
 <template>
   <main>
     <UiInputAutocomplete
-      :field="field"
-      :label="label"
-      :data="remoteData ? remoteData : data"
-      :items="items"
-      :isLoading="isLoading"
-      :item-text="itemText"
-      :slotText="slotText"
-      :item-value="itemValue"
-      :multiple="multiple"
-      :chips="chips"
-      prependIcon="mdi-magnify"
-      :return-object="returnObject"
-      @research="search"
-      :no-filter="noFilter"
-      @update="$emit('update', $event, field)"
+        :field="field"
+        :label="label"
+        :data="remoteData ? remoteData : data"
+        :items="items"
+        :isLoading="isLoading"
+        :item-text="itemText"
+        :slotText="slotText"
+        :item-value="itemValue"
+        :multiple="multiple"
+        :chips="chips"
+        prependIcon="mdi-magnify"
+        :return-object="returnObject"
+        @research="search"
+        :no-filter="noFilter"
+        @update="$emit('update', $event, field)"
     />
   </main>
 </template>
@@ -72,7 +72,7 @@ const props = defineProps({
     type: String,
     default: 'id'
   },
-  itemText: {
+  itemTitle: {
     type: Array,
     required: true
   },

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

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

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

@@ -0,0 +1,60 @@
+
+<template>
+  <UiInputAutocomplete
+      :model-value="modelValue"
+      :field="field"
+      :items="items"
+      :is-loading="pending"
+      :return-object="false"
+      item-title="label"
+      item-value="value"
+      @update:model-value="$emit('update:model-value', $event)"
+  />
+</template>
+
+<script setup lang="ts">
+
+
+import {useEnumFetch} from "~/composables/data/useEnumFetch";
+import ArrayUtils from "~/services/utils/arrayUtils";
+import {ComputedRef} from "@vue/reactivity";
+import {Enum} from "~/types/data";
+
+const props = defineProps({
+  modelValue: {
+    type: String,
+    required: false,
+    default: null
+  },
+  enumName: {
+    type: String,
+    required: true
+  },
+  field: {
+    type: String,
+    required: false,
+    default: null
+  },
+  label: {
+    type: String,
+    required: false,
+    default: null
+  }
+})
+
+const { fetch } = useEnumFetch()
+
+const { data: enumItems, pending } = fetch(props.enumName)
+
+const items: ComputedRef<Array<Enum>> = computed(() => {
+  if (!enumItems.value) {
+    return []
+  }
+  return ArrayUtils.sortObjectsByProp(enumItems.value, 'label') as Array<Enum>
+})
+
+</script>
+
+<style scoped lang="scss">
+
+</style>

+ 50 - 15
components/Ui/Input/Checkbox.vue

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

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

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

+ 59 - 97
components/Ui/Input/DatePicker.vue

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

+ 21 - 3
components/Ui/Input/Number.vue

@@ -6,8 +6,8 @@ An input for numeric values
   <v-text-field
       ref="input"
       :modelValue.number="modelValue"
+      :label="(label || field) ? $t(label ?? field) : undefined"
       hide-details
-      single-line
       :density="density"
       type="number"
       @update:modelValue="modelValue = keepInRange(cast($event)); emitUpdate()"
@@ -22,8 +22,26 @@ type Density = null | 'default' | 'comfortable' | 'compact';
 
 const props = defineProps({
   modelValue: {
-    type: Number,
-    required: true
+    type: Number
+  },
+  /**
+   * Nom de la propriété d'une entité lorsque l'input concerne cette propriété
+   * - Utilisé par la validation
+   * - Laisser null si le champ ne s'applique pas à une entité
+   */
+  field: {
+    type: String,
+    required: false,
+    default: null
+  },
+  /**
+   * Label du champ
+   * Si non défini, c'est le nom de propriété qui est utilisé
+   */
+  label: {
+    type: String,
+    required: false,
+    default: null
   },
   default: {
     type: Number,

+ 1 - 1
components/Ui/Input/Text.vue

@@ -1,5 +1,5 @@
 <!--
-Champs de saisie de texte
+Champs de saisie de texte, à placer dans un composant `UiForm`
 
 @see https://vuetifyjs.com/en/components/text-fields/
 -->

+ 19 - 0
components/Ui/LoadingPanel.vue

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

+ 2 - 2
composables/data/useAp2iRequestService.ts

@@ -1,4 +1,3 @@
-import {FetchContext, FetchOptions} from "ohmyfetch";
 import {TYPE_ALERT} from "~/types/enum/enums";
 import ApiRequestService from "~/services/data/apiRequestService";
 import {Ref} from "@vue/reactivity";
@@ -6,6 +5,7 @@ import {usePageStore} from "~/stores/page";
 import UnauthorizedError from "~/services/error/UnauthorizedError";
 import {useAccessProfileStore} from "~/stores/accessProfile";
 import {AssociativeArray} from "~/types/data";
+import {FetchContext, FetchOptions} from "ofetch";
 
 /**
  * Retourne une instance de ApiRequestService configurée pour interroger l'api Ap2i
@@ -81,7 +81,7 @@ export const useAp2iRequestService = () => {
             console.error('! Request error: Forbidden')
             usePageStore().addAlert(TYPE_ALERT.ALERT, ['forbidden'])
         }
-        else if (response && response.status >= 404) {
+        else if (response && (response.status === 400 || response.status >= 404)) {
             // @see https://developer.mozilla.org/fr/docs/Web/HTTP/Status
             const error_msg = error ? error.message : response.statusText
             console.error('! Request error: ' + error_msg)

+ 61 - 0
composables/data/useRefreshProfile.ts

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

+ 1 - 18
composables/form/useValidation.ts

@@ -38,24 +38,7 @@ export function useValidation() {
     }
   }
 
-  function useValidateSubdomain() {
-    const isSubdomainAvailable = async (subdomain: string | null): Promise<boolean> => {
-      if (subdomain === null) {
-        return true
-      }
-
-      const { apiRequestService } = useAp2iRequestService()
-      const response: any = await apiRequestService.get('/api/subdomains', {'subdomain': subdomain})
-
-      return typeof response !== 'undefined' && response.metadata.totalItems === 0
-    }
-    return {
-      isSubdomainAvailable
-    }
-  }
-
   return {
-    useValidateSiret,
-    useValidateSubdomain
+    useValidateSiret
   }
 }

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

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

+ 26 - 0
i18n.config.ts

@@ -0,0 +1,26 @@
+import {defineI18nConfig} from "#i18n";
+
+export default defineI18nConfig(() => ({
+        legacy: false,
+        datetimeFormats: {
+            'fr': {
+                short: {
+                    year: 'numeric', month: 'numeric', day: 'numeric'
+                },
+                long: {
+                    year: 'numeric', month: 'numeric', day: 'numeric',
+                    hour: 'numeric', minute: 'numeric'
+                }
+            },
+            'en': {
+                short: {
+                    year: 'numeric', month: 'numeric', day: 'numeric'
+                },
+                long: {
+                    year: 'numeric', month: 'numeric', day: 'numeric',
+                    hour: 'numeric', minute: 'numeric'
+                }
+            }
+        }
+    })
+)

+ 79 - 12
lang/fr.json

@@ -4,9 +4,11 @@
   "subscription_breadcrumbs": "Mon abonnement",
   "address_breadcrumbs": "Adresse postale",
   "contact_points_breadcrumbs": "Points de contact",
+  "parameters_breadcrumbs": "Paramètres",
   "help_super_admin": "Le compte super-admin possède tous les droits de gestion sur votre logiciel. On l’utilise surtout pour la gestion de votre site internet et, à la première connexion au logiciel, afin de créer des comptes pour tous membres de votre structure. Enfin, il peut également être utile en cas de dépannage dans certaines situations particulières.",
   "yourWebsiteAddressIs": "L'adresse de votre site web est",
-  "areYourSureYouWantToDisableYourOpentalentWebsite": "Êtes-vous sûr(e) de vouloir désactiver votre site web Opentalent",
+  "yourOpentalentWebsiteWillBeDeactivatedOnceYouLlHaveSaved": "Votre site web Opentalent sera désactivé une fois que vous aurez enregistré",
+  "doYouWantToContinue": "Voulez-vous continuer",
   "youRegisteredTheFollowingSubdomain": "Vous avez enregistré le sous-domaine suivant",
   "subdomainIsCurrentlyActive": "Le sous-domaine est actuellement actif",
   "doYouWantToActivateThisSubdomain": "Voulez-vous activer ce sous-domaine",
@@ -17,6 +19,7 @@
   "this_subdomain_is_already_in_use": "Ce sous-domaine est déjà utilisé",
   "this_subdomain_is_available": "Ce sous-domaine est disponible",
   "subdomain_can_not_contain_spaces_or_special_cars": "Le sous-domaine ne doit pas contenir d'espaces ni de caractères spéciaux",
+  "please_enter_a_value": "Veuillez saisir une valeur",
   "please_enter_a_value_for_the_subdomain": "Veuillez saisir une valeur pour le sous-domaine",
   "validation_ongoing": "Validation en cours",
   "until": "Jusqu'au",
@@ -170,6 +173,7 @@
   "cycle": "Cycle",
   "timing": "Durée d'un enseignement (en minutes)",
   "educationTiming": "Durée d'un enseignement (en minutes)",
+  "new_education_timings" : "Nouvelle durée d'enseignement",
   "superAdmin": "Compte super-admin",
   "username": "Login de connexion",
   "residenceArea": "Zones de résidence",
@@ -179,6 +183,7 @@
   "usernameSMS": "Nom d'utilisateur SMS",
   "smsSenderName": "Personnaliser le nom de l'expéditeur SMS",
   "attendance": "Absences",
+  "notifyAdministrationAbsence": "Prévenir l'administrateur en cas d'absences consécutives",
   "sendAttendanceEmail": "Prévenir automatiquement la famille par mail en cas d'absence non justifiée",
   "sendAttendanceSms": "Prévenir automatiquement la famille par sms en cas d'absence non justifiée",
   "bulletinReceiver": "Adresser le bulletin à",
@@ -191,10 +196,10 @@
   "bulletinSignatureDirector": "Un cadre « Tampon / Signature » pour l'administration",
   "bulletinPrintAddress": "L'adresse postale de l'élève ou son tuteur",
   "bulletinWithTeacher": "Le nom du professeur",
+  "superAdminEmail" : "Adresse mail associée",
   "bulletin_parameters": "Bulletins",
   "sms": "Sms",
   "web_parameters": "Site internet",
-  "averageMax": "Note maximale pour les notes du suivi pédagogique (entre 1 et 100)",
   "educational_follow_up": "Suivi pédagogique",
   "advancedEducationNotationType": "Type de grilles d'évaluation",
   "educationPeriodicity": "Périodicité des évaluations",
@@ -299,14 +304,14 @@
   "of": "de",
   "allResult": "Tous",
   "itemsPerPage": "Nombre de résultats par page",
-  "autocomplete_research": "Aucun résultat ne correspond à votre recherche",
+  "no_result_matching_your_request": "Aucun résultat ne correspond à votre recherche",
   "add": "Ajouter",
   "save": "Enregistrer",
   "save_and_back": "Enregistrer et retour",
   "back": "Retour",
   "cancel": "Annuler",
   "delete": "Supprimer",
-  "confirm_to_delete": "Vous êtes sur le point de supprimer un élément.",
+  "confirm_to_delete": "Vous êtes sur le point de supprimer un élément.\nVoulez-vous continuer?",
   "saveSuccess": "Sauvegarde effectuée",
   "deleteSuccess": "Suppression effectuée",
   "quit_form": "Quitter le formulaire",
@@ -322,8 +327,6 @@
   "bulletinEditWithoutEvaluationHelp": "Dans le cas où la case n'est pas cochée, s'il n'y a aucune évaluation dans le bulletin, alors le bulletin n'est pas exporté",
   "bulletinShowEducationWithoutEvaluationHelp": "Dans le cas où la case est cochée, alors on affiche le texte \"Aucune évaluation\" pour chaque enseignement sans évaluation",
   "type_of_practices_autocomplete": "Sélectionnez parmi la liste des types de pratiques, une ou plusieurs pratiques correspondant aux activités de votre structure",
-  "logo_upload": "<div>Le logo est utilisée: </div>\n<ul><li>dans l'entête des documents que vos exportez</li><li>sur le site internet</li><li>dans la recherche d'une structure sur le site Opentalent ou sur le site d'une fédération si vous êtes membre de la CMF (voir <a target=\"_blank\" href=\"https://fmfaucigny.opentalent.fr/presentation/societes-adherentes\">exemple ici</a>)</li></ul>",
-  "communication_image_upload": "L'image est utilisé\n dans la description détaillée d'une structure sur le site Opentalent ou sur le site d'une fédération si vous êtes membre de la CMF (voir <a target=\"_blank\" href=\"https://fmfaucigny.opentalent.fr/presentation/societes-adherentes\">exemple ici</a>)",
   "general_params": "Général",
   "communication_params": "Communication",
   "students_params": "Suivi des étudiants",
@@ -379,16 +382,16 @@
   "teacher_text_creation_card": "Ajoutez un professeur à votre personnel et donnez-lui un accès pédagogique",
   "student_text_creation_card": "Inscrivez un nouvel élève via le formulaire de la vue famille",
   "guardian_text_creation_card": "Ajoutez un tuteur à votre carnet d'adresses afin de l'associer ultérieurement à un élève",
-  "click_here": "cliquez ici",
-  "super_admin_switch_account": "Vous utilisez une connexion SWITCH. Afin de retourner sur votre compte veuillez",
+  "super_admin_switch_account": "Vous utilisez une connexion SWITCH. Afin de retourner sur votre compte veuillez cliquer ici",
   "insurance_cmf_subscription": "Souscrire un contrat assurance CMF",
   "renew_insurance_cmf": "Accéder au renouvellement de votre assurance CMF",
   "upload_cotisation_invoice": "Télécharger la facture de votre appel de cotisation",
   "cotisation_access": "Accéder au renouvellement de cotisation à ma fédération",
   "information_new_online_registration": "Nouvelle préinscription",
   "not_production_environment": "ATTENTION ! Vous êtes sur un environnement {env}.",
-  "multi_account_alert": "Vous êtes connecté, en tant que <strong>{fullName}</strong>, avec un accès famille. Utilisez l'icône",
-  "multi_account_alert_next": "en haut à droite pour changer les informations des autres membres de votre famille.",
+  "multi_account_alert_part1": "Vous êtes connecté en tant que",
+  "multi_account_alert_part2": "avec un accès famille. Utilisez l'icône",
+  "multi_account_alert_part3": "en haut à droite pour changer les informations des autres membres de votre famille.",
   "not_current_year": "Les données affichées correspondent à une autre année que l'année actuelle, et/ou sont des données passées ou futures.",
   "not_current_year_reset": "Cliquez ici pour afficher les données de l'année actuelle.",
   "welcome": "Accueil",
@@ -477,9 +480,9 @@
   "cmf_licence_details_url": "Consulter les avantages de la licence CMF",
   "generate": "Générer",
   "parameters": "Préférences",
-  "place": "Lieux",
+  "places": "Lieux",
   "education": "Enseignements",
-  "tag": "Tags",
+  "tags": "Tags",
   "activities": "Sections",
   "billing_settings": "Facturation",
   "online_registration_settings": "Pré-inscription(s) en ligne",
@@ -576,6 +579,7 @@
   "select": "Sélectionner",
   "Internal Server Error": "Erreur de serveur interne",
   "cmf_licence_breadcrumbs": "Licence CMF",
+  "preferences": "Préférences",
   "online_registration": "Inscription en ligne",
   "online_registration_text_creation_card": "Ajouter une nouvelle inscription",
   "start_on": "Débute le",
@@ -591,4 +595,67 @@
   "you_have_been_placed_on_the_waiting_list": "Votre dossier d'inscription est en cours de traitement. Vous avez été placé sur liste d'attente pour une ou plusieurs activités",
   "your_registration_file_has_been_validated": "Votre dossier d'inscription a été validé. Vous pouvez consulter le bloc \"Enseignements en cours et à venir\" sur la page d'accueil pour avoir des détails sur vos activités.",
   "your_application_has_been_refused": "Votre dossier d'inscription a été refusé / annulé. Veuillez contacter l'administration pour plus d'informations."
+  "start_date_of_financial_season": "Début de la saison financière",
+  "start_date_of_activity_season": "Début de saison d'activité",
+  "start_date_of_courses": "Date de début des cours",
+  "end_date_of_courses": "Date de fin des cours",
+  "show_adherents_list_and_their_coordinates": "Afficher la liste des adhérents et leurs coordonnées",
+  "students_are_also_association_members": "Les élèves sont adhérents également de l'association",
+  "general_parameters": "Paramètres généraux",
+  "teaching": "Enseignements",
+  "intranet_access": "Accès intranet (professeurs, élèves...)",
+  "educationNotations": "Suivi pédagogique",
+  "bulletin": "Bulletins",
+  "educationTimings": "Durée des cours (en minutes)",
+  "new_education_timing": "Nouvelle durée de cours",
+  "residenceAreas": "Zones de résidence",
+  "sms_option": "Option SMS",
+  "super_admin": "Compte super-admin",
+  "an_error_happened": "Une erreur s'est produite",
+  "your_opentalent_website_address_is": "L'adresse de votre site Opentalent est",
+  "record_a_new_subdomain": "Enregistrer un nouveau sous-domaine",
+  "your_subdomains": "Vos sous-domaines",
+  "Not Found": "Données non trouvée",
+  "subdomains_breadcrumbs": "Sous-domaines",
+  "new_breadcrumbs": "Nouveau",
+  "validation_pending": "Validation en cours",
+  "The subdomain is already active": "Le sous-domaine est déjà actif",
+  "Not a valid subdomain": "Le sous-domaine est invalide",
+  "This organization can not register new subdomains": "Nombre maximum de sous-domaines enregistrés atteint",
+  "This subdomain is not available": "Ce sous-domaine n'est pas disponible",
+  "This subdomain is already registered": "Ce sous-domaine est déjà enregistré",
+  "subdomain_activated_and_available_in_a_few_minutes": "Le sous-domaine a bien été activé, et sera accessible d'ici quelques minutes",
+  "unknown": "Inconnu",
+  "allow_teachers_to_generate_attendance_reports": "Autoriser les professeurs à générer des fiches de présence",
+  "send_teachers_mail_reports_copy_to_administration": "Mettre l'administration en copie du rapport d'envoi des mails envoyés par les professeurs dans le logiciel",
+  "allow_members_to_change_their_names_and_firstnames": "Autoriser les membres à modifier leur nom et prénom",
+  "allow_teachers_to_consult_colleagues_informations": "Autoriser les professeurs à consulter le listing de leurs collègues (noms, prénoms, et coordonnées)",
+  "allow_students_to_consult_their_pedagogical_followup": "Autoriser les élèves à consulter leur suivi pédagogique",
+  "allow_teachers_to_create_courses": "Autoriser les professeurs à créer des cours",
+  "INITIATION_CYCLE":  "Cycle initiation",
+  "CYCLE_1": "Cycle 1",
+  "CYCLE_2":  "Cycle 2",
+  "CYCLE_3": "Cycle 3",
+  "CYCLE_4":  "Cycle 4",
+  "OUT_CYCLE": "Hors cycle",
+  "originalLabel": "Libellés d'origine",
+  "effectiveLabel": "Libellés actuellement utilisés",
+  "allow_to_configure_teachings_with_played_instrument_choice": "Permettre de configurer les enseignements avec le choix sur l'instrument joué",
+  "label": "Libellé",
+  "undefined": "Indéfini",
+  "define_validation_periods_for_teachers": "Définir des périodes de saisie pour les professeurs",
+  "mandatory_validation_for_evaluations": "Valider obligatoirement les évaluations",
+  "evaluation_criterium_edition_is_admin_only": "Autoriser uniquement l'administration à modifier les critères d'évaluation",
+  "max_note_for_pedagogical_followup": "Note maximale pour les notes du suivi pédagogique (entre 1 et 100) ",
+  "Bad Request": "Requête invalide",
+  "bulletins": "Bulletins",
+  "Indian/Reunion": "Indian/Reunion",
+  "Europe/Zurich": "Europe/Zurich",
+  "Europe/Paris": "Europe/Paris",
+  "licenceQrCode": "QrCode pour la licence",
+  "education_timings_breadcrumbs": "Durée des cours",
+  "create_a_new_residence_area": "Créer une nouvelle zone de résidence",
+  "residence_areas_breadcrumbs": "Zones de résidence",
+  "super_admin_explanation_text": "Le compte super-admin possède tous les droits de gestion sur votre logiciel. On l’utilise entre autre pour la gestion de votre site internet, pour créer les comptes des membres de votre structure à la première connexion au logiciel, ou dans des situations de dépannage.",
+  "cycles_breadcrumbs": "Enseignements"
 }

+ 6 - 0
lang/fr.json.removed

@@ -0,0 +1,6 @@
+# Les lignes suivantes ont été retirées après l'upgrade vers nuxt v3.5 qui n'autorise plus l'html dans les trads
+
+
+
+  "logo_upload": "<div>Le logo est utilisée: </div>\n<ul><li>dans l'entête des documents que vos exportez</li><li>sur le site internet</li><li>dans la recherche d'une structure sur le site Opentalent ou sur le site d'une fédération si vous êtes membre de la CMF (voir <a target=\"_blank\" href=\"https://fmfaucigny.opentalent.fr/presentation/societes-adherentes\">exemple ici</a>)</li></ul>",
+  "communication_image_upload": "L'image est utilisé\n dans la description détaillée d'une structure sur le site Opentalent ou sur le site d'une fédération si vous êtes membre de la CMF (voir <a target=\"_blank\" href=\"https://fmfaucigny.opentalent.fr/presentation/societes-adherentes\">exemple ici</a>)",

+ 6 - 0
models/Access/Access.ts

@@ -2,6 +2,8 @@ import { Historical } from '~/types/interfaces'
 import Person from "~/models/Person/Person";
 import ApiModel from "~/models/ApiModel";
 import {HasOne, Num, Uid, Attr} from "pinia-orm/dist/decorators";
+import {IriEncoded} from "~/models/decorators";
+import Organization from "~/models/Organization/Organization";
 
 /**
  * AP2i Model : Access
@@ -22,4 +24,8 @@ export default class Access extends ApiModel {
 
   @Attr({})
   declare historical: Historical
+
+  @Attr(null)
+  @IriEncoded(Organization)
+  declare organization: number | null
 }

+ 1 - 1
models/Access/AdminAccess.ts

@@ -5,7 +5,7 @@ import ApiResource from "~/models/ApiResource";
  * @see https://gitlab.2iopenservice.com/opentalent/ap2i/-/blob/develop/src/ApiResources/Access/AdminAccess.php
  */
 export default class AdminAccess extends ApiResource {
-  static entity = 'admin'
+  static entity = 'admin-access'
 
   @Uid()
   declare id: number

+ 1 - 9
models/ApiResource.ts

@@ -5,15 +5,7 @@ import {Model} from "pinia-orm";
  */
 export class ApiResource extends Model {
 
-    private _model: typeof ApiResource | undefined = undefined;
-
-    public getModel() {
-        return this._model
-    }
-
-    public setModel(model: typeof ApiResource) {
-        this._model = model
-    }
+    public static relations: Record<string, ApiResource>
 
     /**
      * Fix the 'Cannot stringify arbitrary non-POJOs' warning, meaning server can not parse the store

+ 3 - 0
models/Education/Cycle.ts

@@ -15,6 +15,9 @@ export default class Cycle extends ApiModel {
   @Str(null)
   declare label: string|null
 
+  @Str(null)
+  declare cycleEnum: string|null
+
   @Num(0)
   declare order: number
 }

+ 1 - 1
models/Education/EducationTiming.ts

@@ -10,7 +10,7 @@ export default class EducationTiming extends ApiModel {
   static entity = 'education_timings'
 
   @Uid()
-  declare id: number | string | null
+  declare id: number | string
 
   @Num(null)
   declare timing: number

+ 26 - 19
models/Organization/Parameters.ts

@@ -1,5 +1,8 @@
-import ApiModel from "~/models/ApiModel";
-import {Bool, Num, Str, Uid, Attr} from "pinia-orm/dist/decorators";
+import ApiModel from '~/models/ApiModel'
+import { Bool, Num, Str, Uid, Attr } from 'pinia-orm/dist/decorators'
+import Access from "~/models/Access/Access";
+import ApiResource from "~/models/ApiResource";
+import {IriEncoded} from "~/models/decorators";
 
 /**
  * AP2i Model : Parameters
@@ -13,16 +16,16 @@ export default class Parameters extends ApiModel {
   declare id: number | string | null
 
   @Str(null)
-  declare financialDate: string|null
+  declare financialDate: string | null
 
   @Str(null)
-  declare musicalDate: string|null
+  declare musicalDate: string | null
 
   @Str(null)
-  declare startCourseDate: string|null
+  declare startCourseDate: string | null
 
   @Str(null)
-  declare endCourseDate: string|null
+  declare endCourseDate: string | null
 
   @Bool(false, { notNullable: true })
   declare trackingValidation: boolean
@@ -34,25 +37,26 @@ export default class Parameters extends ApiModel {
   declare editCriteriaNotationByAdminOnly: boolean
 
   @Str(null)
-  declare smsSenderName: string|null
+  declare smsSenderName: string | null
 
   @Bool(true, { notNullable: true })
   declare logoDonorsMove: boolean
 
   @Str(null)
-  declare otherWebsite: string|null
+  declare otherWebsite: string | null
 
   @Str(null)
-  declare customDomain: string|null
+  declare customDomain: string | null
 
   @Bool(false, { notNullable: true })
   declare desactivateOpentalentSiteWeb: boolean
 
   @Attr([])
-  declare publicationDirectors: []
+  @IriEncoded(Access)
+  declare publicationDirectors: number[]
 
   @Str(null)
-  declare bulletinPeriod: string|null
+  declare bulletinPeriod: string | null
 
   @Bool(false, { notNullable: true })
   declare bulletinWithTeacher: boolean
@@ -79,19 +83,19 @@ export default class Parameters extends ApiModel {
   declare bulletinShowAverages: boolean
 
   @Str(null)
-  declare bulletinOutput: string|null
+  declare bulletinOutput: string | null
 
   @Bool(true, { notNullable: true })
   declare bulletinEditWithoutEvaluation: boolean
 
   @Str('STUDENTS_AND_THEIR_GUARDIANS')
-  declare bulletinReceiver: string|null
+  declare bulletinReceiver: string | null
 
   @Str(null)
-  declare usernameSMS: string|null
+  declare usernameSMS: string | null
 
   @Str(null)
-  declare passwordSMS: string|null
+  declare passwordSMS: string | null
 
   @Bool(true, { notNullable: true })
   declare showAdherentList: boolean
@@ -100,16 +104,16 @@ export default class Parameters extends ApiModel {
   declare studentsAreAdherents: boolean
 
   @Str(null)
-  declare qrCode: string|null
+  declare qrCode: string | null
 
   @Str('Europe/Paris')
-  declare timezone: string|null
+  declare timezone: string | null
 
   @Str('ANNUAL')
-  declare educationPeriodicity: string|null
+  declare educationPeriodicity: string | null
 
   @Str('BY_EDUCATION')
-  declare advancedEducationNotationType: string|null
+  declare advancedEducationNotationType: string | null
 
   @Bool(false, { notNullable: true })
   declare sendAttendanceEmail: boolean
@@ -119,4 +123,7 @@ export default class Parameters extends ApiModel {
 
   @Attr([])
   declare subdomains: []
+
+  @Bool(false, { notNullable: true })
+  declare notifyAdministrationAbsence: boolean
 }

+ 15 - 0
models/Organization/SubdomainAvailability.ts

@@ -0,0 +1,15 @@
+import ApiResource from "~/models/ApiResource";
+import {Str, Uid, Attr, Bool, Num} from "pinia-orm/dist/decorators";
+
+export default class SubdomainAvailability extends ApiResource {
+    static entity = 'subdomains/is_available'
+
+    @Uid()
+    declare id: number | string | null
+
+    @Str(null)
+    declare subdomain: string
+
+    @Bool(false)
+    declare available: boolean
+}

+ 6 - 0
models/Person/Person.ts

@@ -17,4 +17,10 @@ export default class Person extends ApiModel {
 
   @Str(null)
   declare username: string|null
+
+  @Str(null)
+  declare name: string|null
+
+  @Str(null)
+  declare givenName: string|null
 }

+ 24 - 0
models/decorators.ts

@@ -0,0 +1,24 @@
+import ApiResource from "~/models/ApiResource";
+
+/**
+ * Decorates an ApiResource's property to signal it as a field that is provided
+ * as an IRI or an array of IRI by the API
+ *
+ * If the property is decorated, the HydraNormalizer will parse the IRI when de-normalizing
+ * to get the id(s), then re-encode it as IRI(s) when re-normalizing.
+ */
+export function IriEncoded (
+    apiResource: typeof ApiResource
+): PropertyDecorator {
+    return (target: Object, propertyKey: string | symbol) => {
+        //@ts-ignore
+        const self = target.$self()
+
+        if (!self.hasOwnProperty("relations")) {
+            self.relations = {}
+        }
+
+        //@ts-ignore
+        self.relations[propertyKey] = apiResource
+    }
+}

+ 1 - 1
models/models.ts

@@ -4,7 +4,7 @@ import ApiResource from "~/models/ApiResource";
 const models: Record<string, typeof ApiResource> = {}
 
 for (const path in modules) {
-    modules[path]().then((mod) => {
+    modules[path]().then((mod: any) => {
         models[mod.default.entity] = mod.default
     })
 }

+ 22 - 40
nuxt.config.ts

@@ -1,6 +1,5 @@
 import fs from 'fs';
 import vuetify from 'vite-plugin-vuetify'
-import {NuxtI18nOptions} from "@nuxtjs/i18n"
 
 let https = {}
 
@@ -18,6 +17,11 @@ if (process.env.NUXT_ENV === 'dev') {
  */
 export default defineNuxtConfig({
     ssr: true,
+    experimental: {
+        // Fix the 'Cannot stringify non POJO' bug
+        // @see https://github.com/nuxt/nuxt/issues/20787
+        renderJsonPayloads: false
+    },
     runtimeConfig: {
         // Private config that is only available on the server
         env: '',
@@ -36,19 +40,10 @@ export default defineNuxtConfig({
             baseUrlTypo3: '',
             baseUrlMercure: '',
             supportUrl: '',
-            school_product: 'school',
-            school_premium_product: 'school-premium',
-            artist_product: 'artist',
-            artist_premium_product: 'artist-premium',
-            manager_product: 'manager',
-            cmf_network: 'CMF',
-            ffec_network: 'FFEC',
-            OPENTALENT_MANAGER_ID: 93931,
-            CMF_ID: 12097
         }
     },
     hooks: {
-        'builder:watch': console.log
+        'builder:watch': console.log,
     },
     app: {
         head: {
@@ -87,6 +82,7 @@ export default defineNuxtConfig({
                 vuetify()
                 //Remplacer par cela quand l'issue https://github.com/vuetifyjs/vuetify-loader/issues/273 sera règlée..
                 // voir aussi : https://github.com/nuxt/nuxt/issues/15412 et https://github.com/vuetifyjs/vuetify-loader/issues/290
+                // voir aussi : https://github.com/jrutila/nuxt3-vuetify3-bug
                 // vuetify({
                 //     styles: { configFile: './assets/css/settings.scss' }
                 // })
@@ -105,8 +101,8 @@ export default defineNuxtConfig({
         ],
         '@pinia-orm/nuxt',
         '@nuxtjs/i18n',
-        '@nuxt/image-edge',
-        // '@nuxt/devtools'
+        '@nuxt/devtools',
+        '@nuxt/image'
     ],
     vite: {
         esbuild: {
@@ -124,7 +120,13 @@ export default defineNuxtConfig({
                 protocol: 'wss',
                 port: 24678
             }
-        },
+        }
+    },
+    // Hide the sourcemaps warnings with vuetify
+    // @see https://github.com/vuetifyjs/vuetify-loader/issues/290#issuecomment-1435702713
+    sourcemap: {
+        server: false,
+        client: false,
     },
     i18n: {
         langDir: 'lang',
@@ -144,33 +146,13 @@ export default defineNuxtConfig({
             }
         ],
         defaultLocale: 'fr',
-        fallbackLocale: 'en',
         detectBrowserLanguage: false,
-        vueI18n: {
-            legacy: false,
-            datetimeFormats: {
-                'fr': {
-                    short: {
-                        year: 'numeric', month: 'numeric', day: 'numeric'
-                    },
-                    long: {
-                        year: 'numeric', month: 'numeric', day: 'numeric',
-                        hour: 'numeric', minute: 'numeric'
-                    }
-                },
-                'en': {
-                    short: {
-                        year: 'numeric', month: 'numeric', day: 'numeric'
-                    },
-                    long: {
-                        year: 'numeric', month: 'numeric', day: 'numeric',
-                        hour: 'numeric', minute: 'numeric'
-                    }
-                }
-            }
-        },
-    } as NuxtI18nOptions,
+        vueI18n: './i18n.config.ts'
+    },
+    image: {
+        provider: 'none'
+    },
     build: {
         transpile: ['vuetify', '@vuepic/vue-datepicker', 'pinia', 'pinia-orm', 'date-fns'],
-    },
+    }
 })

+ 18 - 18
package.json

@@ -15,45 +15,46 @@
     "lint": "eslint --ext \".ts,.js,.vue\" --ignore-path .gitignore .",
     "lint-fix": "eslint --fix --ext \".ts,.js,.vue\" --ignore-path .gitignore .",
     "test": "vitest run",
-    "enable-devtools": "nuxi devtools enable"
+    "enable-devtools": "nuxi devtools enable",
+    "nuxt-upgrade": "nuxi upgrade --force"
   },
   "dependencies": {
     "@casl/ability": "^6.3.3",
     "@casl/vue": "2.2.1",
     "@fortawesome/fontawesome-free": "^6.3.0",
     "@mdi/font": "^7.0.96",
-    "@nuxt/image": "^0.7.1",
-    "@nuxt/image-edge": "^1.0.0-27968280.9739e4d",
-    "@nuxtjs/i18n": "^8.0.0-beta.10",
+    "@nuxt/image": "rc",
+    "@nuxtjs/i18n": "^8.0.0-beta.12",
     "@pinia-orm/nuxt": "^1.6.7",
-    "@pinia/nuxt": "0.4.7",
+    "@pinia/nuxt": "0.4.11",
     "@types/file-saver": "^2.0.5",
     "@types/js-yaml": "^4.0.5",
     "@types/vue-the-mask": "^0.11.1",
-    "@vuepic/vue-datepicker": "^4.2.1",
+    "@vitest/coverage-v8": "^0.32.4",
+    "@vuepic/vue-datepicker": "^5.3.0",
     "cleave.js": "^1.6.0",
     "date-fns": "^2.29.3",
     "event-source-polyfill": "^1.0.31",
     "file-saver": "^2.0.5",
     "js-yaml": "^4.1.0",
-    "libphonenumber-js": "1.10.24",
+    "libphonenumber-js": "1.10.36",
     "lodash": "^4.17.21",
     "lodash-es": "^4.17.21",
-    "nuxt": "^3.3.3",
-    "pinia": "^2.0.33",
-    "pinia-orm": "^1.5.1",
+    "nuxt": "^3.5.3",
+    "pinia": "^2.1.4",
+    "pinia-orm": "^1.6.7",
     "sass": "^1.59.3",
     "uuid": "^9.0.0",
-    "vite-plugin-vuetify": "^1.0.1",
+    "vite-plugin-vuetify": "^1.0.2",
     "vue-tel-input-vuetify": "^1.5.3",
     "vue-the-mask": "^0.11.1",
-    "vuetify": "3.1.15",
+    "vuetify": "3.3.5",
     "yaml-import": "^2.0.0"
   },
   "devDependencies": {
-    "@nuxt/devtools": "^0.2.5",
+    "@nuxt/devtools": "^0.6.2",
     "@nuxt/test-utils": "^3.3.1",
-    "@nuxt/test-utils-edge": "^3.3.2-27981332.886cca19",
+    "@nuxt/test-utils-edge": "3.6.0-28122147.c3c56c14",
     "@nuxtjs/eslint-config": "^12.0.0",
     "@nuxtjs/eslint-config-typescript": "^12.0.0",
     "@nuxtjs/eslint-module": "^4.0.2",
@@ -66,7 +67,6 @@
     "@typescript-eslint/eslint-plugin": "^5.55.0",
     "@typescript-eslint/parser": "^5.55.0",
     "@vitejs/plugin-vue": "^4.0.0",
-    "@vitest/coverage-c8": "^0.29.2",
     "@vue/eslint-config-standard": "^8.0.1",
     "@vue/test-utils": "^2.3.1",
     "blob-polyfill": "^7.0.20220408",
@@ -75,11 +75,11 @@
     "eslint-plugin-nuxt": "^4.0.0",
     "eslint-plugin-prettier": "^4.2.1",
     "eslint-plugin-vue": "^9.7.0",
-    "jsdom": "^21.1.1",
+    "jsdom": "^22.1.0",
     "prettier": "^2.8.4",
     "ts-jest": "^29.0.3",
-    "typescript": "4.9.5",
-    "vitest": "0.30.1",
+    "typescript": "^5.2",
+    "vitest": "0.32.2",
     "vue-jest": "^3.0.7"
   }
 }

+ 14 - 0
pages/parameters.vue

@@ -0,0 +1,14 @@
+<!-- Page de détails des paramètres -->
+
+<template>
+  <LayoutContainer>
+    <!-- Rend le contenu de la page -->
+    <NuxtPage />
+  </LayoutContainer>
+</template>
+
+<script setup lang="ts">
+</script>
+
+<style scoped lang="scss">
+</style>

+ 32 - 0
pages/parameters/cycles/[id].vue

@@ -0,0 +1,32 @@
+<template>
+  <LayoutContainer>
+    <div>
+      <h2>{{ $t('cycle') }}</h2>
+      <UiFormEdition
+          :model="Cycle"
+          :go-back-route="goBackRoute"
+      >
+        <template v-slot="{ entity }">
+          <UiInputText
+              field="label"
+              v-model="entity.label"
+              :rules="rules()"
+          />
+        </template>
+      </UiFormEdition>
+    </div>
+  </LayoutContainer>
+</template>
+<script setup lang="ts">
+import {RouteLocationPathRaw} from 'vue-router'
+import { useI18n } from 'vue-i18n'
+import Cycle from "~/models/Education/Cycle";
+
+const i18n = useI18n()
+
+const goBackRoute = { path: `/parameters`, query: { tab: 'teaching' } }
+
+const rules = () => [
+  (label: string | null) => (label !== null && label.length > 0) || i18n.t('please_enter_a_value'),
+]
+</script>

+ 10 - 0
pages/parameters/cycles/index.vue

@@ -0,0 +1,10 @@
+<template>
+</template>
+
+<script setup lang="ts">
+/** Redirect to /parameters?tab=teaching */
+const router = useRouter()
+router.push(
+    { path: `/parameters`, query: { tab: 'teaching' } }
+)
+</script>

+ 33 - 0
pages/parameters/education_timings/[id].vue

@@ -0,0 +1,33 @@
+<template>
+  <LayoutContainer>
+    <div>
+      <h2>{{ $t('educationTiming') }}</h2>
+      <UiFormEdition
+        :model="EducationTiming"
+        :go-back-route="goBackRoute"
+      >
+        <template v-slot="{ entity }">
+          <UiInputNumber
+            field="educationTiming"
+            v-model="entity.timing"
+            :rules="rules()"
+          />
+        </template>
+      </UiFormEdition>
+    </div>
+  </LayoutContainer>
+</template>
+<script setup lang="ts">
+import EducationTiming from '~/models/Education/EducationTiming'
+import {RouteLocationPathRaw} from 'vue-router'
+import { useI18n } from 'vue-i18n'
+
+const i18n = useI18n()
+
+const goBackRoute: RouteLocationPathRaw = { path: `/parameters`, query: { tab: 'educationTimings' } }
+
+const rules = () => [
+  (timing: string | null) =>
+    (timing !== null && parseInt(timing) > 0) || i18n.t('please_enter_a_value'),
+]
+</script>

+ 10 - 0
pages/parameters/education_timings/index.vue

@@ -0,0 +1,10 @@
+<template>
+</template>
+
+<script setup lang="ts">
+/** Redirect to /parameters?tab=educationTimings */
+const router = useRouter()
+router.push(
+    { path: `/parameters`, query: { tab: 'educationTimings' } }
+)
+</script>

+ 42 - 0
pages/parameters/education_timings/new.vue

@@ -0,0 +1,42 @@
+<template>
+  <LayoutContainer>
+    <div>
+      <h2>{{ $t("new_education_timing")}}</h2>
+      <UiFormCreation
+        :model="EducationTiming"
+        :go-back-route="goBackRoute"
+      >
+        <template v-slot="{ entity }">
+          <v-container :fluid="true" class="container">
+            <v-row>
+              <v-col cols="12" sm="6"> </v-col>
+            </v-row>
+            <v-row>
+              <v-col cols="12" sm="6">
+                <UiInputNumber
+                  v-model="entity.timing"
+                  field="new_education_timings"
+                  :rules="rules()"
+                />
+              </v-col>
+            </v-row>
+          </v-container>
+        </template>
+      </UiFormCreation>
+    </div>
+  </LayoutContainer>
+</template>
+
+<script setup lang="ts">
+import EducationTiming from '~/models/Education/EducationTiming'
+import { useI18n } from 'vue-i18n'
+
+const i18n = useI18n()
+
+const goBackRoute = { path: `/parameters`, query: { tab: 'educationTimings' } }
+
+const rules = () => [
+  (timing: number | null ) =>
+    (timing !== null && timing > 0) || i18n.t('please_enter_a_value'),
+]
+</script>

+ 136 - 0
pages/parameters/index.vue

@@ -0,0 +1,136 @@
+<!--
+Page Paramètres
+-->
+<template>
+  <LayoutContainer>
+    <v-col cols="12" sm="12" md="12">
+      <v-tabs
+          :model-value="currentTab"
+          bg-color="primary"
+          color="on-primary"
+          :grow="true"
+          density="default"
+          @update:model-value="onTabUpdate"
+      >
+        <v-tab v-for="tab in tabs" :value="tab">
+          {{ $t(tab) }}
+        </v-tab>
+      </v-tabs>
+
+      <v-card-text>
+        <v-window v-model="currentTab">
+          <v-window-item value="general_parameters">
+            <LayoutParametersGeneral />
+          </v-window-item>
+
+          <v-window-item value="website">
+            <LayoutParametersWebsite />
+          </v-window-item>
+
+          <v-window-item v-if="organizationProfile.isSchool" value="teaching">
+            <LayoutParametersTeaching />
+          </v-window-item>
+
+          <v-window-item v-if="organizationProfile.isSchool" value="intranet_access">
+            <LayoutParametersIntranet />
+          </v-window-item>
+
+          <v-window-item v-if="organizationProfile.isSchool" value="educationNotations">
+            <LayoutParametersEducationNotation />
+          </v-window-item>
+
+          <v-window-item v-if="organizationProfile.isSchool" value="bulletin">
+            <LayoutParametersBulletin />
+          </v-window-item>
+
+          <v-window-item v-if="organizationProfile.isSchool" value="educationTimings">
+            <LayoutParametersEducationTimings />
+          </v-window-item>
+
+          <v-window-item v-if="organizationProfile.isSchool" value="attendances">
+            <LayoutParametersAttendances />
+          </v-window-item>
+
+          <v-window-item v-if="organizationProfile.isSchool" value="residenceAreas">
+            <LayoutParametersResidenceAreas />
+          </v-window-item>
+
+          <v-window-item v-if="organizationProfile.hasModule('Sms')" value="sms_option">
+            <LayoutParametersSms />
+          </v-window-item>
+
+          <v-window-item value="super_admin">
+            <LayoutParametersSuperAdmin/>
+          </v-window-item>
+        </v-window>
+      </v-card-text>
+
+    </v-col>
+  </LayoutContainer>
+</template>
+
+<script setup lang="ts">
+
+  import {useOrganizationProfileStore} from "~/stores/organizationProfile";
+
+  const organizationProfile = useOrganizationProfileStore()
+
+  const tabs = [
+    'general_parameters',
+    'website',
+    organizationProfile.isSchool ? 'teaching' : null,
+    organizationProfile.isSchool ? 'intranet_access' : null,
+    organizationProfile.isSchool ? 'educationNotations': null,
+    organizationProfile.isSchool ? 'bulletin' : null,
+    organizationProfile.isSchool ? 'educationTimings' : null,
+    organizationProfile.isSchool ? 'attendances' : null,
+    organizationProfile.isSchool ? 'residenceAreas' : null,
+    organizationProfile.hasModule('Sms') ? 'sms_option' : null,
+    'super_admin',
+
+  ].filter((v) => v !== null)
+
+  const router = useRouter()
+  const route = useRoute()
+  let mounted = false
+
+
+  /**
+   * Update the current route's query with a new value for 'tab' parameter
+   * @param tab
+   */
+  const updateQuery = (tab: string) => {
+    router.replace({ query: { ...route.query, tab } })
+  }
+
+  const currentTab: Ref<string | null> = ref(null)
+
+  onMounted(() => {
+    if (!route.query || !route.query.tab || !tabs.includes(route.query.tab as string)) {
+      const tab = tabs[0] ?? 'general_parameters'
+      currentTab.value = tab
+      updateQuery(tab)
+    } else {
+      currentTab.value = route.query.tab as string
+    }
+
+    mounted = true
+  })
+
+  const onTabUpdate = (tab: string) => {
+    if (!mounted) {
+      return
+    }
+    updateQuery(tab)
+    currentTab.value = tab
+  }
+</script>
+
+<style scoped lang="scss">
+
+:deep(.v-tabs .v-btn__content) {
+  text-transform: capitalize;
+  letter-spacing: 0.04em;
+}
+</style>
+

+ 36 - 0
pages/parameters/residence_areas/[id].vue

@@ -0,0 +1,36 @@
+<template>
+  <LayoutContainer>
+    <div>
+      <h2>Éditer la zone de résidence</h2>
+      <UiFormEdition
+        :model="ResidenceArea"
+        :go-back-route="goBackRoute"
+      >
+        <template v-slot="{ entity }">
+          <UiInputText
+            field="label"
+            v-model="entity.label"
+            :rules="rules()"
+          />
+        </template>
+      </UiFormEdition>
+    </div>
+  </LayoutContainer>
+</template>
+
+<script setup lang="ts">
+import { useEntityFetch } from '~/composables/data/useEntityFetch'
+import ResidenceArea from '~/models/Billing/ResidenceArea'
+import { useI18n } from 'vue-i18n'
+
+const i18n = useI18n()
+const { fetch } = useEntityFetch()
+const router = useRouter()
+
+const goBackRoute = { path: `/parameters`, query: { tab: 'residenceAreas' } }
+
+const rules = () => [
+  (label: string | null) =>
+    (label !== null && label.length > 0) || i18n.t('please_enter_a_value'),
+]
+</script>

+ 10 - 0
pages/parameters/residence_areas/index.vue

@@ -0,0 +1,10 @@
+<template>
+</template>
+
+<script setup lang="ts">
+/** Redirect to /parameters?tab=residenceAreas */
+const router = useRouter()
+router.push(
+    { path: `/parameters`, query: { tab: 'residenceAreas' } }
+)
+</script>

+ 40 - 0
pages/parameters/residence_areas/new.vue

@@ -0,0 +1,40 @@
+<template>
+  <LayoutContainer>
+    <div>
+      <h2>{{ $t('create_a_new_residence_area') }}</h2>
+      <UiFormCreation
+        :model="ResidenceArea"
+        :go-back-route="goBackRoute"
+      >
+        <template v-slot="{ entity }">
+          <v-container :fluid="true" class="container">
+            <v-row>
+              <v-col cols="12" sm="6">
+                <UiInputText
+                  v-model="entity.label"
+                  field="label"
+                  type="string"
+                  :rules="rules()"
+                />
+              </v-col>
+            </v-row>
+          </v-container>
+        </template>
+      </UiFormCreation>
+    </div>
+  </LayoutContainer>
+</template>
+
+<script setup lang="ts">
+import ResidenceArea from '~/models/Billing/ResidenceArea'
+import { useI18n } from 'vue-i18n'
+
+const i18n = useI18n()
+
+const goBackRoute = { path: `/parameters`, query: { tab: 'residenceAreas' } }
+
+const rules = () => [
+  (label: string | null) =>
+    (label !== null && label.length > 0) || i18n.t('please_enter_a_value'),
+]
+</script>

+ 88 - 0
pages/parameters/subdomains/[id].vue

@@ -0,0 +1,88 @@
+<!-- Page de détails d'un sous-domaine -->
+<template>
+  <main>
+    <LayoutContainer>
+      <UiLoadingPanel v-if="pending" />
+      <div v-else>
+        <div>
+          {{ $t('youRegisteredTheFollowingSubdomain')}} :
+        </div>
+
+        <div class="pa-8">
+          <b>{{ subdomain.subdomain }}</b>
+          <span class="text-on-neutral">.opentalent.fr</span>
+        </div>
+
+        <div>
+          <div v-if="subdomain.active">
+            <v-icon class="text-success icon small mr-2">
+              fa-solid fa-check
+            </v-icon>
+            {{ $t('subdomainIsCurrentlyActive') }}
+          </div>
+          <div v-else>
+            {{ $t('doYouWantToActivateThisSubdomain') }} ?
+          </div>
+        </div>
+
+        <div class="mt-6 d-flex flex-row">
+          <v-btn class="mr-12" @click="quit">
+            {{ $t('back') }}
+          </v-btn>
+          <div v-if="!subdomain.active">
+            <v-btn color="primary" @click="activateAndQuit" >
+              {{ $t('activate') }}
+            </v-btn>
+          </div>
+        </div>
+      </div>
+    </LayoutContainer>
+  </main>
+</template>
+
+<script setup lang="ts">
+  import Subdomain from "~/models/Organization/Subdomain";
+  import {useEntityFetch} from "~/composables/data/useEntityFetch";
+  import {useOrganizationProfileStore} from "~/stores/organizationProfile";
+  import {useEntityManager} from "~/composables/data/useEntityManager";
+  import {usePageStore} from "~/stores/page";
+  import {TYPE_ALERT} from "~/types/enum/enums";
+  import {useRefreshProfile} from "~/composables/data/useRefreshProfile";
+
+  const { em } = useEntityManager()
+  const { fetch } = useEntityFetch()
+  const organizationProfile = useOrganizationProfileStore()
+
+  const router = useRouter()
+  const route = useRoute()
+
+  const { refreshProfile } = useRefreshProfile()
+
+  if (!route.params.id || isNaN(route.params.id as any)) {
+    throw new Error('no id found')
+  }
+  const id: number = parseInt(route.params.id as string)
+
+  const { data: subdomain, pending } = fetch(Subdomain, id)
+
+  const activationPending: Ref<boolean> = ref(false)
+
+  const pageStore = usePageStore()
+
+  const activateAndQuit = async () => {
+    activationPending.value = true
+    pageStore.loading = true
+    await em.patch(Subdomain, id, { active: true} )
+    await refreshProfile()
+    usePageStore().addAlert(TYPE_ALERT.SUCCESS, ['subdomain_activated_and_available_in_a_few_minutes'])
+    quit()
+  }
+
+  const quit = () => {
+    router.push('/parameters?tab=website')
+    activationPending.value = false
+    pageStore.loading = false
+  }
+
+
+</script>

+ 10 - 0
pages/parameters/subdomains/index.vue

@@ -0,0 +1,10 @@
+<template>
+</template>
+
+<script setup lang="ts">
+/** Redirect to /parameters?tab=website */
+const router = useRouter()
+router.push(
+    { path: `/parameters`, query: { tab: 'website' } }
+)
+</script>

+ 145 - 0
pages/parameters/subdomains/new.vue

@@ -0,0 +1,145 @@
+<template>
+  <main>
+    <LayoutContainer>
+      <UiForm
+        ref="form"
+        :model="Subdomain"
+        :entity="subdomain"
+        :submitActions="submitActions"
+        :validation-pending="validationPending"
+        :refresh-profile="true"
+      >
+        <v-container :fluid="true" class="container">
+          <v-row>
+            <v-col cols="12" sm="6">
+              <div>{{ $t('pleaseEnterYourNewSubdomain') }} :</div>
+            </v-col>
+          </v-row>
+          <v-row>
+            <v-col cols="12" sm="6">
+              <UiInputText
+                v-model="subdomain.subdomain"
+                field="subdomain"
+                type="string"
+                :rules="rules()"
+                @update:modelValue="onSubdomainUpdate"
+              />
+            </v-col>
+          </v-row>
+          <div class="validationMessage">
+            <span v-if="validationPending">
+              <v-progress-circular size="16" indeterminate />
+              <i class="ml-2">{{ $t('validation_ongoing') }}</i>
+            </span>
+            <span v-else-if="subdomainAvailable === true" class="text-success">
+              <v-icon>fa fa-check</v-icon>
+              <i class="ml-2">{{ $t('this_subdomain_is_available') }}</i>
+            </span>
+          </div>
+        </v-container>
+
+        <template #form.button>
+          <NuxtLink :to="goBackRoute" class="no-decoration">
+            <v-btn class="mr-4 theme-neutral">
+              {{ $t('back') }}
+            </v-btn>
+          </NuxtLink>
+        </template>
+      </UiForm>
+    </LayoutContainer>
+  </main>
+</template>
+
+<script setup lang="ts">
+import { useEntityManager } from '~/composables/data/useEntityManager'
+import Subdomain from '~/models/Organization/Subdomain'
+import { SUBMIT_TYPE } from '~/types/enum/enums'
+import { AnyJson } from '~/types/data'
+import SubdomainValidation from '~/services/validation/subdomainValidation'
+import { Ref } from '@vue/reactivity'
+import { useSubdomainValidation } from '~/composables/form/validation/useSubdomainValidation'
+import _ from 'lodash'
+
+const i18n = useI18n()
+
+const { em } = useEntityManager()
+const { subdomainValidation } = useSubdomainValidation()
+
+//@ts-ignore
+const subdomain: Ref<Subdomain> = ref(em.newInstance(Subdomain) as Subdomain)
+
+const goBackRoute = { path: `/parameters`, query: { tab: 'website' } }
+
+const submitActions = computed(() => {
+  let actions: AnyJson = {}
+  actions[SUBMIT_TYPE.SAVE_AND_BACK] = goBackRoute
+  return actions
+})
+
+const form: Ref<HTMLCanvasElement | null> = ref(null)
+const subdomainAvailable: Ref<boolean | null> = ref(null)
+const validationPending: Ref<boolean> = ref(false)
+
+/**
+ * Délai entre le dernier caractère saisi et la requête de vérification de la disponibilité du sous-domaine (en ms)
+ */
+const inputDelay = 600
+
+/**
+ * Procède à la vérification de disponibilité.
+ * @param subdomain
+ */
+const checkAvailability = async (subdomain: string) => {
+  subdomainAvailable.value = await subdomainValidation.isAvailable(subdomain)
+  validationPending.value = false
+
+  //@ts-ignore
+  form.value.validate()
+}
+
+/**
+ * Version debounced de la fonction checkAvailability
+ * @see https://docs-lodash.com/v4/debounce/
+ */
+const checkAvailabilityDebounced: _.DebouncedFunc<() => void> = _.debounce(
+  async () => {
+    if (subdomain.value.subdomain === null) {
+      return
+    }
+    await checkAvailability(subdomain.value.subdomain)
+  },
+  inputDelay
+)
+
+const onSubdomainUpdate = () => {
+  subdomainAvailable.value = null
+  validationPending.value = true
+  checkAvailabilityDebounced()
+}
+
+/**
+ * Règles de validation
+ */
+const rules = () => [
+  (subdomain: string | null) =>
+    (subdomain !== null && subdomain.length > 0) ||
+    i18n.t('please_enter_a_value_for_the_subdomain'),
+  (subdomain: string | null) =>
+    (subdomain !== null && subdomain.length >= 2 && subdomain.length <= 60) ||
+    i18n.t('subdomain_need_to_have_0_to_60_cars'),
+  (subdomain: string | null) =>
+    SubdomainValidation.isValid(subdomain) ||
+    i18n.t('subdomain_can_not_contain_spaces_or_special_cars'),
+  async () =>
+    subdomainAvailable.value !== false ||
+    i18n.t('this_subdomain_is_already_in_use'),
+]
+</script>
+
+<style scoped lang="scss">
+.validationMessage {
+  font-size: 13px;
+  height: 20px;
+  min-height: 20px;
+}
+</style>

+ 1 - 1
plugins/ability.ts

@@ -26,7 +26,7 @@ export default defineNuxtPlugin(() => {
                                                                 onError, // hook if the action throws or rejects
                                                             }: any) => {
         after((result: any)=>{
-            if (name === 'setProfile'){
+            if (name === 'initiateProfile'){
                 // On construit les habilités et on les enregistre dans le store
                 // noinspection UnnecessaryLocalVariableJS
                 const abilities = abilityUtils.buildAbilities();

+ 12 - 15
plugins/init.server.ts

@@ -1,14 +1,14 @@
-import {useAccessProfileStore} from "~/stores/accessProfile";
-import {useEntityManager} from "~/composables/data/useEntityManager";
 import UnauthorizedError from "~/services/error/UnauthorizedError";
 import {useRedirect} from "~/composables/utils/useRedirect";
+import {useRefreshProfile} from "~/composables/data/useRefreshProfile";
+import {CookieRef} from "#app";
 
 export default defineNuxtPlugin(async () => {
     const { redirectToLogout } = useRedirect()
 
-    const bearer = useCookie('BEARER')
-    let accessCookieId = useCookie('AccessId')
-    const switchId = useCookie('SwitchAccessId')
+    const bearer: CookieRef<string | null> = useCookie('BEARER') ?? null
+    let accessCookieId: CookieRef<string | null> = useCookie('AccessId') ?? null
+    const switchId: CookieRef<string | null> = useCookie('SwitchAccessId') ?? null
 
     if (accessCookieId.value === null || Number.isNaN(accessCookieId.value)) {
         redirectToLogout()
@@ -17,18 +17,15 @@ export default defineNuxtPlugin(async () => {
 
     const accessId: number = parseInt(accessCookieId.value)
 
-    const accessProfile = useAccessProfileStore()
-
-    accessProfile.$patch({
-        bearer: bearer.value,
-        id: accessId,
-        switchId: switchId.value !== null ? parseInt(switchId.value) : null
-    })
-
-    const {em} = useEntityManager()
+    const { initiateProfile } = useRefreshProfile()
 
     try {
-        await em.refreshProfile(accessId)
+        await initiateProfile(
+            accessId,
+            bearer.value ?? '',
+            switchId.value !== null ? parseInt(switchId.value) : null
+        )
+
     } catch (error) {
         if (error instanceof UnauthorizedError) {
             redirectToLogout()

+ 2 - 2
plugins/sse.client.ts

@@ -12,7 +12,7 @@ import {AnyJson} from "~/types/data";
 export default defineNuxtPlugin(nuxtApp => {
     const runtimeConfig = useRuntimeConfig()
 
-    if (!runtimeConfig.baseUrlMercure) {
+    if (!runtimeConfig.public.baseUrlMercure) {
         console.error('Mercure : the hub url is not defined')
         return;
     }
@@ -28,7 +28,7 @@ export default defineNuxtPlugin(nuxtApp => {
     }
 
     const sseSource = new SseSource(
-        runtimeConfig.baseUrlMercure,
+        runtimeConfig.public.baseUrlMercure,
         "access/" + accessProfile.id,
         onOpen,
         onMessage,

+ 6 - 16
services/data/apiRequestService.ts

@@ -5,11 +5,10 @@
  */
 import {AssociativeArray} from "~/types/data";
 import {HTTP_METHOD} from "~/types/enum/data";
-import {$Fetch, FetchOptions} from "ohmyfetch";
+import {FetchOptions} from "ofetch";
+import {$Fetch} from "nitropack";
 
 class ApiRequestService {
-    // TODO: fusionner les paramètres `query` et `params` des méthodes, puisque l'un est un alias de l'autre
-    //       dans ohmyfetch : https://github.com/unjs/ofetch#%EF%B8%8F-adding-query-search-params
     private readonly fetch: $Fetch
 
     public constructor(
@@ -28,7 +27,7 @@ class ApiRequestService {
         url: string,
         query: AssociativeArray | null = null
     ) {
-        return await this.request(HTTP_METHOD.GET, url, null, null, query)
+        return await this.request(HTTP_METHOD.GET, url, null, query)
     }
 
     /**
@@ -36,16 +35,14 @@ class ApiRequestService {
      *
      * @param url
      * @param body
-     * @param params
      * @param query
      */
     public async post(
         url: string,
         body: string | null = null,
-        params: AssociativeArray | null = null,
         query: AssociativeArray | null = null
     ) {
-        return await this.request(HTTP_METHOD.POST, url, body, params, query)
+        return await this.request(HTTP_METHOD.POST, url, body, query)
     }
 
     /**
@@ -53,16 +50,14 @@ class ApiRequestService {
      *
      * @param url
      * @param body
-     * @param params
      * @param query
      */
     public async put(
         url: string,
         body: string | null = null,
-        params: AssociativeArray | null = null,
         query: AssociativeArray | null = null
     ) {
-        return await this.request(HTTP_METHOD.PUT, url, body, params, query)
+        return await this.request(HTTP_METHOD.PUT, url, body, query)
     }
 
     /**
@@ -75,7 +70,7 @@ class ApiRequestService {
         url: string,
         query: AssociativeArray | null = null
     ) {
-        return await this.request(HTTP_METHOD.DELETE, url, null, null, query)
+        return await this.request(HTTP_METHOD.DELETE, url, null, query)
     }
 
     /**
@@ -84,7 +79,6 @@ class ApiRequestService {
      * @param method
      * @param url
      * @param body
-     * @param params
      * @param query
      * @protected
      */
@@ -92,13 +86,9 @@ class ApiRequestService {
         method: HTTP_METHOD,
         url: string,
         body: string | null = null,
-        params: AssociativeArray | null = null,
         query: AssociativeArray | null = null
     ): Promise<Response> {
         const config: FetchOptions = { method }
-        if (params) {
-            config.params = params
-        }
         if (query) {
             config.query = query
         }

+ 10 - 35
services/data/entityManager.ts

@@ -1,15 +1,13 @@
 import ApiRequestService from "./apiRequestService"
 import {Repository} from "pinia-orm"
 import UrlUtils from "~/services/utils/urlUtils"
-import HydraDenormalizer from "./normalizer/hydraDenormalizer"
 import ApiModel from "~/models/ApiModel"
 import ApiResource from "~/models/ApiResource"
-import MyProfile from "~/models/Access/MyProfile"
 import {v4 as uuid4} from 'uuid'
 import {AssociativeArray, Collection} from "~/types/data.d"
 import models from "~/models/models";
-import {useAccessProfileStore} from "~/stores/accessProfile"
 import * as _ from "lodash-es"
+import HydraNormalizer from "~/services/data/normalizer/hydraNormalizer";
 
 /**
  * Entity manager: make operations on the models defined with the Pinia-Orm library
@@ -134,7 +132,7 @@ class EntityManager {
      * @param id
      */
     // @ts-ignore
-    public find<T extends ApiResource>(model: typeof T, id: number): T {
+    public find<T extends ApiResource>(model: typeof T, id: number | string): T {
         const repository = this.getRepository(model)
         return repository.find(id) as T
     }
@@ -162,7 +160,7 @@ class EntityManager {
         const response = await this.apiRequestService.get(url)
 
         // deserialize the response
-        const attributes = HydraDenormalizer.denormalize(response).data as object
+        const attributes = HydraNormalizer.denormalize(response, model).data as object
 
         return this.newInstance(model, attributes)
     }
@@ -175,7 +173,7 @@ class EntityManager {
      * @param query
      * @param parent
      */
-    public async fetchCollection(model: typeof ApiResource, parent: ApiResource | null, query: AssociativeArray = []): Promise<Collection> {
+    public async fetchCollection(model: typeof ApiResource, parent: ApiResource | null, query: AssociativeArray | null = null): Promise<Collection> {
         let url
 
         if (parent !== null) {
@@ -187,7 +185,7 @@ class EntityManager {
         const response = await this.apiRequestService.get(url, query)
 
         // deserialize the response
-        const apiCollection = HydraDenormalizer.denormalize(response)
+        const apiCollection = HydraNormalizer.denormalize(response, model)
 
         const items = apiCollection.data.map((attributes: object) => {
             return this.newInstance(model, attributes)
@@ -214,12 +212,12 @@ class EntityManager {
     public async persist(model: typeof ApiModel, instance: ApiModel) {
         // Recast in case class definition has been "lost"
         // TODO: attendre de voir si cette ligne est nécessaire
-        // instance = this.cast(model, instance)
+        instance = this.cast(model, instance)
 
         let url = UrlUtils.join('api', model.entity)
         let response
 
-        const data: any = instance.$toJson()
+        const data: any = HydraNormalizer.normalizeEntity(instance)
 
         if (!instance.isNew()) {
             url = UrlUtils.join(url, String(instance.id))
@@ -229,7 +227,7 @@ class EntityManager {
             response = await this.apiRequestService.post(url, data)
         }
 
-        const hydraResponse = await HydraDenormalizer.denormalize(response)
+        const hydraResponse = await HydraNormalizer.denormalize(response, model)
 
         const newInstance = this.newInstance(model, hydraResponse.data)
 
@@ -255,7 +253,7 @@ class EntityManager {
         const body = JSON.stringify(data)
         const response = await this.apiRequestService.put(url, body)
 
-        const hydraResponse = await HydraDenormalizer.denormalize(response)
+        const hydraResponse = await HydraNormalizer.denormalize(response, model)
         return this.newInstance(model, hydraResponse.data)
     }
 
@@ -266,6 +264,7 @@ class EntityManager {
      * @param instance
      */
     public async delete(model: typeof ApiModel, instance: ApiResource) {
+
         const repository = this.getRepository(model)
 
         // If object has been persisted to the datasource, send a delete request
@@ -296,30 +295,6 @@ class EntityManager {
         return initialInstance
     }
 
-    /**
-     * @todo: à déplacer dans le store directement
-     * @Deprecated : a priori ce n'est pas le bon service pour mettre à jour le profil, on devrait voir ça
-     * depuis un service dédié, un composable, ou directement dans le store ==> oui !
-     *
-     * Re-fetch the user profile and update the store
-     */
-    public async refreshProfile(accessId: number | null = null) {
-        const accessProfileStore = useAccessProfileStore()
-
-        if (accessId === null) {
-            accessId = accessProfileStore.currentAccessId
-        }
-
-        // Sans le flush, on observe un bug non-expliqué au rechargement de la page en mode dev : la fonction save
-        //  du repo de MyProfile ne fonctionne pas quand le plugin init.server.ts re-fetch le profil
-        this.flush(MyProfile)
-
-        const profile = await this.fetch(MyProfile, accessId, true)
-
-        // On met à jour le store accessProfile
-        accessProfileStore.setProfile(profile)
-    }
-
     /**
      * Delete all records in the repository of the model
      *

+ 2 - 2
services/data/enumManager.ts

@@ -1,6 +1,6 @@
 import ApiRequestService from "./apiRequestService";
 import UrlUtils from "~/services/utils/urlUtils";
-import HydraDenormalizer from "~/services/data/normalizer/hydraDenormalizer";
+import HydraNormalizer from "~/services/data/normalizer/hydraNormalizer";
 import {Enum} from "~/types/data.d";
 import {VueI18n} from "vue-i18n";
 
@@ -18,7 +18,7 @@ class EnumManager {
 
         const response = await this.apiRequestService.get(url)
 
-        const { data } = await HydraDenormalizer.denormalize(response)
+        const { data } = HydraNormalizer.denormalize(response)
 
         const enum_: Enum = []
         for (const key in data.items) {

+ 0 - 72
services/data/normalizer/hydraDenormalizer.ts

@@ -1,72 +0,0 @@
-import {AnyJson, ApiResponse, HydraMetadata} from '~/types/data'
-import UrlUtils from '~/services/utils/urlUtils'
-import {METADATA_TYPE} from '~/types/enum/data'
-
-/**
- * Normalisation et dé-normalisation ddu format de données Hydra
- */
-class HydraDenormalizer {
-
-  /**
-   * Parse une réponse Hydra et retourne un objet ApiResponse
-   *
-   * @param {AnyJson} data
-   * @return {AnyJson} réponse parsée
-   */
-  public static denormalize(data: AnyJson): ApiResponse {
-    return {
-      data: HydraDenormalizer.getData(data),
-      metadata: HydraDenormalizer.getMetadata(data)
-    }
-  }
-
-  protected static getData(hydraData: AnyJson): AnyJson {
-    return hydraData['@type'] === 'hydra:Collection' ? hydraData['hydra:member'] : hydraData
-  }
-
-  /**
-   * Génère les métadonnées d'un item ou d'une collection
-   *
-   * @param data
-   * @protected
-   */
-  protected static getMetadata(data: AnyJson): HydraMetadata {
-    if (data['@type'] !== 'hydra:Collection') {
-      // A single item, no metadata
-      return { type: METADATA_TYPE.ITEM }
-    }
-
-    const metadata: HydraMetadata = {
-      totalItems: data['hydra:totalItems']
-    }
-
-    if (data['hydra:view']) {
-      /**
-       * Extract the page number from the IRIs in the hydra:view section
-       */
-      const extractPageNumber = (pos: string, default_: number | undefined=undefined): number | undefined => {
-        const iri = data['hydra:view']['hydra:' + pos]
-        if (!iri) {
-          return default_
-        }
-        return UrlUtils.getParameter(
-            data['hydra:view']['hydra:' + pos],
-            'page',
-            default_
-        ) as number | undefined
-      }
-
-      // TODO: utile d'ajouter la page en cours?
-      metadata.firstPage = extractPageNumber('first', 1)
-      metadata.lastPage = extractPageNumber('last', 1)
-      metadata.nextPage = extractPageNumber('next')
-      metadata.previousPage = extractPageNumber('previous')
-    }
-
-    metadata.type = METADATA_TYPE.COLLECTION
-
-    return metadata
-  }
-}
-
-export default HydraDenormalizer

+ 219 - 0
services/data/normalizer/hydraNormalizer.ts

@@ -0,0 +1,219 @@
+import {AnyJson, ApiResponse, HydraMetadata} from '~/types/data'
+import UrlUtils from '~/services/utils/urlUtils'
+import {METADATA_TYPE} from '~/types/enum/data'
+import models from "~/models/models";
+import ApiResource from "~/models/ApiResource";
+import {isArray} from "lodash";
+
+/**
+ * Normalisation et dé-normalisation du format de données Hydra
+ */
+class HydraNormalizer {
+  static models = models
+
+  /**
+   * Normalize the given entity into a Hydra formatted content.
+   * @param entity
+   */
+  public static normalizeEntity(entity: ApiResource): AnyJson {
+    const iriEncodedFields = HydraNormalizer.getIriEncodedFields(entity)
+
+    for (const field in iriEncodedFields) {
+      const value = entity[field]
+      const targetEntity = iriEncodedFields[field].entity
+
+      if (isArray(value)) {
+        entity[field] = value.map((id: number) => {
+          return UrlUtils.makeIRI(targetEntity, id)
+        })
+      } else {
+        entity[field] = UrlUtils.makeIRI(targetEntity, value)
+      }
+    }
+
+    return entity.$toJson()
+  }
+
+  /**
+   * Parse une réponse Hydra et retourne un objet ApiResponse
+   *
+   * @param {AnyJson} data
+   * @param model
+   * @return {AnyJson} réponse parsée
+   */
+  public static denormalize(data: AnyJson, model?: typeof ApiResource): ApiResponse {
+    return {
+      data: HydraNormalizer.getData(data, model),
+      metadata: HydraNormalizer.getMetadata(data)
+    }
+  }
+
+  protected static getData(hydraData: AnyJson, model?: typeof ApiResource): AnyJson {
+    if (hydraData['@type'] === 'hydra:Collection') {
+      const members = hydraData['hydra:member']
+      return members.map((item: AnyJson) => HydraNormalizer.denormalizeItem(item, model))
+    } else {
+      return HydraNormalizer.denormalizeItem(hydraData, model)
+    }
+  }
+
+  /**
+   * Génère les métadonnées d'un item ou d'une collection
+   *
+   * @param data
+   * @protected
+   */
+  protected static getMetadata(data: AnyJson): HydraMetadata {
+    if (data['@type'] !== 'hydra:Collection') {
+      // A single item, no metadata
+      return { type: METADATA_TYPE.ITEM }
+    }
+
+    const metadata: HydraMetadata = {
+      totalItems: data['hydra:totalItems']
+    }
+
+    if (data['hydra:view']) {
+      /**
+       * Extract the page number from the IRIs in the hydra:view section
+       */
+      const extractPageNumber = (pos: string, default_: number | undefined=undefined): number | undefined => {
+        const iri = data['hydra:view']['hydra:' + pos]
+        if (!iri) {
+          return default_
+        }
+        return UrlUtils.getParameter(
+            data['hydra:view']['hydra:' + pos],
+            'page',
+            default_
+        ) as number | undefined
+      }
+
+      // TODO: utile d'ajouter la page en cours?
+      metadata.firstPage = extractPageNumber('first', 1)
+      metadata.lastPage = extractPageNumber('last', 1)
+      metadata.nextPage = extractPageNumber('next')
+      metadata.previousPage = extractPageNumber('previous')
+    }
+
+    metadata.type = METADATA_TYPE.COLLECTION
+
+    return metadata
+  }
+
+  /**
+   * Dénormalise un item d'une réponse hydra
+   *
+   * @param item
+   * @param model
+   * @protected
+   */
+  protected static denormalizeItem(item: AnyJson, model?: typeof ApiResource): AnyJson {
+
+    if (model) {
+      return HydraNormalizer.denormalizeEntity(model, item)
+    }
+
+    if (!item.hasOwnProperty('@id')) {
+      // Not hydra formatted
+      console.error('De-normalization error : the item is not hydra formatted', item)
+      return item
+    }
+
+    if (item['@id'].match(/\/api\/enum\/\w+/)) {
+      return HydraNormalizer.denormalizeEnum(item)
+    }
+
+    let entity = null
+
+    // On essaie de déterminer la nature de l'objet à partir de son id
+    try {
+      const iri = HydraNormalizer.parseEntityIRI(item['@id'])
+      entity = iri.entity
+    } catch (e) {
+      console.error('De-normalization error : could not parse the IRI', item)
+      return item
+    }
+
+    if (entity && HydraNormalizer.models.hasOwnProperty(entity)) {
+      model = HydraNormalizer.models[entity]
+      return HydraNormalizer.denormalizeEntity(model, item)
+    }
+
+    throw Error('De-normalization error : Could not determine the type of the entity '
+        + item['@id'] + ' (found: ' + entity + ')')
+  }
+
+  protected static denormalizeEntity(model: typeof ApiResource, item: AnyJson) {
+    const instance = new model(item)
+
+    const iriEncodedFields = HydraNormalizer.getIriEncodedFields(instance)
+
+    for (const field in iriEncodedFields) {
+      const value = instance[field]
+      const targetEntity = iriEncodedFields[field].entity
+
+      if (isArray(value)) {
+        instance[field] = value.map((iri: string) => {
+          return HydraNormalizer.getIdFromEntityIri(iri, targetEntity)
+        })
+      } else {
+        instance[field] = HydraNormalizer.getIdFromEntityIri(value, targetEntity)
+      }
+    }
+
+    return instance
+  }
+
+  protected static denormalizeEnum(item: AnyJson): AnyJson {
+    return item
+  }
+
+  /**
+   * Parse the given IRI
+   * @param iri
+   * @protected
+   */
+  protected static parseEntityIRI(iri: string) {
+    const rx = /\/api\/(\w+)\/(\d+)/
+    const match = rx.exec(iri)
+    if (!match) {
+      throw Error('could not parse the IRI : ' + JSON.stringify(iri))
+    }
+
+    return {
+      entity: match[1],
+      id: parseInt(match[2])
+    }
+  }
+
+  /**
+   * Get the array of the entity's fields marked as IRIEncoded
+   * @see models/decorators.ts
+   *
+   * @param entity
+   * @protected
+   */
+  protected static getIriEncodedFields(entity: ApiResource): Record<string, ApiResource> {
+    const prototype = Object.getPrototypeOf(entity)
+    return prototype.constructor.relations
+  }
+
+  /**
+   * Retrieve the entitie's id from the given IRI
+   * Throws an error if the IRI does not match the expected entity
+   *
+   * @param iri
+   * @param expectedEntity
+   * @protected
+   */
+  protected static getIdFromEntityIri(iri: string, expectedEntity: string): number | string {
+    const { entity, id } = HydraNormalizer.parseEntityIRI(iri)
+    if (entity !== expectedEntity) {
+      throw Error("IRI entity does not match the field's target entity (" + entity + ' != ' + expectedEntity + ")")
+    }
+    return id
+  }
+}
+
+export default HydraNormalizer

+ 3 - 10
services/layout/menuBuilder/configurationMenuBuilder.ts

@@ -30,18 +30,11 @@ export default class ConfigurationMenuBuilder extends AbstractMenuBuilder {
     }
 
     if (this.ability.can('display', 'parameters_page')) {
-      children.push(
-          this.createItem(
-              'parameters',
-              undefined,
-              UrlUtils.join('/main/edit/parameters', this.organizationProfile.id ?? ''),
-              MENU_LINK_TYPE.V1
-          )
-      )
+      children.push(this.createItem('parameters', undefined,`/parameters`, MENU_LINK_TYPE.INTERNAL))
     }
 
     if (this.ability.can('display', 'place_page')) {
-      children.push(this.createItem('place', undefined, '/places/list/', MENU_LINK_TYPE.V1))
+      children.push(this.createItem('places', undefined, '/places/list/', MENU_LINK_TYPE.V1))
     }
 
     if (this.ability.can('display', 'education_page')) {
@@ -49,7 +42,7 @@ export default class ConfigurationMenuBuilder extends AbstractMenuBuilder {
     }
 
     if (this.ability.can('display', 'tag_page')) {
-      children.push(this.createItem('tag', undefined, '/taggs/list/', MENU_LINK_TYPE.V1))
+      children.push(this.createItem('tags', undefined, '/taggs/list/', MENU_LINK_TYPE.V1))
     }
 
     if (this.ability.can('display', 'activities_page')) {

+ 30 - 0
services/utils/stringUtils.ts

@@ -0,0 +1,30 @@
+
+export default class StringUtils
+{
+    /**
+     * Normalise une chaine de caractères en retirant la casse et les caractères spéciaux, à des fins de recherche
+     * par exemple
+     * @param s
+     */
+    public static normalize(s: string): string {
+        return s
+            .toLowerCase()
+            .replace(/[éèẽëê]/g, 'e')
+            .replace(/[ç]/g, 'c')
+            .replace(/[îïĩ]/g, 'i')
+            .replace(/[àã]/g, 'a')
+            .replace(/[öôõ]/g, 'o')
+            .replace(/[ûüũ]/g, 'u')
+            .replace(/[-]/g, ' ')
+            .trim()
+    }
+
+    /**
+     * Convertit le paramètre d'entrée en entier
+     * A la différence de parseInt, cette méthode accepte aussi les nombres.
+     * @param s
+     */
+    public static parseInt(s: string | number) {
+        return typeof s === 'number' ? s : parseInt(s)
+    }
+}

+ 15 - 4
services/utils/urlUtils.ts

@@ -1,4 +1,4 @@
-import _ from "lodash";
+import _, {isNumber} from "lodash";
 
 /**
  * Classe permettant de construire une URL pour l'interrogation d'une API externe
@@ -59,9 +59,8 @@ class UrlUtils {
   /**
    * Extrait l'ID de l'URI passée en paramètre
    * L'URI est supposée être de la forme `.../foo/bar/{id}`,
-   * où l'id est un identifiant numérique, à moins que isLiteral soit défini comme vrai.
-   * Dans ce cas, si isLiteral est vrai, l'id sera retourné sous forme de texte.
-   *
+   * où l'id est un identifiant numérique, à moins que `isLiteral` soit défini comme vrai.
+   * Dans ce cas, si `isLiteral` est vrai, l'id sera retourné sous forme de texte.
    *
    * @param uri
    * @param isLiteral
@@ -127,6 +126,18 @@ class UrlUtils {
 
     return result
   }
+
+  /**
+   * Make an ApiPlatform IRI for the given entity and id
+   *
+   * @see https://api-platform.com/docs/admin/handling-relations/
+   */
+  public static makeIRI(entity: string, id: number) {
+    if (!isNumber(id)) {
+      throw Error('Invalid id : ' + id)
+    }
+    return `/api/${entity}/${id}`
+  }
 }
 
 export default UrlUtils

+ 28 - 0
services/validation/subdomainValidation.ts

@@ -0,0 +1,28 @@
+import ApiRequestService from "~/services/data/apiRequestService";
+import {useAp2iRequestService} from "~/composables/data/useAp2iRequestService";
+
+
+export default class SubdomainValidation {
+    private apiRequestService: ApiRequestService
+
+    public constructor(apiRequestService: ApiRequestService) {
+        this.apiRequestService = apiRequestService
+    }
+
+    /**
+     * Le sous-domaine est valide s'il contient entre 2 et 60 caractères, et pas de caractères spéciaux
+     * @param subdomain
+     */
+    public static isValid(subdomain: string | null): boolean {
+        return subdomain !== null && subdomain.match(/^[a-z0-9][a-z0-9\-]{0,61}[a-z0-9]$/) !== null
+    }
+
+    /**
+     * Returns true if the given subdomain has not been registered yet or is not reserved
+     * @param subdomain
+     */
+    public async isAvailable(subdomain: string): Promise<boolean> {
+        const subdomainAvailability: any = await this.apiRequestService.get('/api/subdomains/is_available', {'subdomain': subdomain})
+        return subdomainAvailability && subdomainAvailability['available'] === true
+    }
+}

+ 22 - 15
stores/accessProfile.ts

@@ -97,16 +97,15 @@ export const useAccessProfileStore = defineStore('accessProfile', () => {
     return roles.value && roles.value.includes(role)
   }
 
-  const setProfile = (profile: any) => {
-    const profileRoles: Array<string> = Array.from(Object.values(profile.roles))
+  /**
+   * /!\ Server-side only
+   *
+   * @param profile
+   * @param abilitiesValue
+   */
+  const initiateProfile = (profile: any): void => {
 
-    name.value = profile.name
-    givenName.value = profile.givenName
-    gender.value = profile.gender
-    avatarId.value = profile.avatarId
-    activityYear.value = profile.activityYear
-    historical.value = profile.historical
-    isAdminAccess.value = profile.isAdminAccess
+    const profileRoles: Array<string> = Array.from(Object.values(profile.roles))
 
     // TODO: pqoi est-ce qu'on ne conserve pas les roles fonction et qu'on ne fait pas de ces méthodes des computed?
     //       est-ce que ce ne serait pas plus intuitif? si on fait ça, attention à maj l'abilityBuilder
@@ -119,9 +118,21 @@ export const useAccessProfileStore = defineStore('accessProfile', () => {
     isTeacher.value = RoleUtils.isA('TEACHER', profileRoles)
     isMember.value = RoleUtils.isA('MEMBER', profileRoles)
     isOther.value = RoleUtils.isA('OTHER', profileRoles)
+    roles.value = RoleUtils.filterFunctionRoles(profileRoles)
+
+    setProfile(profile)
+  }
+
+  const setProfile = (profile: any): void => {
+    name.value = profile.name
+    givenName.value = profile.givenName
+    gender.value = profile.gender
+    avatarId.value = profile.avatarId
+    activityYear.value = profile.activityYear
+    historical.value = profile.historical
+    isAdminAccess.value = profile.isAdminAccess
     isGuardian.value = profile.isGuardian
     isPayer.value = profile.isPayor
-    roles.value = RoleUtils.filterFunctionRoles(profileRoles)
 
     // Add the original Access (switch User case)
     if (profile.originalAccess !== null) {
@@ -144,11 +155,6 @@ export const useAccessProfileStore = defineStore('accessProfile', () => {
 
     // Set family-accesses
     setFamilyAccesses(Array.from(profile.familyAccesses))
-
-    // Set organization profile
-    // TODO: à voir si c'est bien d'appeler un autre store d'ici où s'il vaudrait mieux le faire dans la couche supérieure
-    const organizationProfile = useOrganizationProfileStore()
-    organizationProfile.setProfile(profile.organization)
   }
 
   const setHistorical = (past: boolean, present: boolean, future: boolean) => {
@@ -201,6 +207,7 @@ export const useAccessProfileStore = defineStore('accessProfile', () => {
     currentAccessId,
     setMultiAccesses,
     setFamilyAccesses,
+    initiateProfile,
     setProfile,
     setHistorical,
     setHistoricalRange

+ 24 - 15
stores/organizationProfile.ts

@@ -2,6 +2,7 @@ import { BaseOrganizationProfile } from '~/types/interfaces'
 import { defineStore } from "pinia";
 import {computed, Ref} from "@vue/reactivity";
 import * as _ from 'lodash-es'
+import {LEGAL_STATUS, NETWORK, ORGANIZATION_ID, PRODUCT} from '~/types/enum/enums';
 
 export const useOrganizationProfileStore = defineStore('organizationProfile', () => {
 
@@ -19,8 +20,6 @@ export const useOrganizationProfileStore = defineStore('organizationProfile', ()
   const website: Ref<string | null>  = ref(null)
   const parents: Ref<Array<BaseOrganizationProfile>> = ref([])
 
-  const runtimeConfig = useRuntimeConfig()
-
   // Getters
   /**
    * L'organization fait-elle partie du réseau CMF ?
@@ -29,7 +28,7 @@ export const useOrganizationProfileStore = defineStore('organizationProfile', ()
    */
   const isCmf = computed( (): boolean => {
     return networks.value && networks.value.filter((network: string) => {
-      return network === runtimeConfig.cmf_network
+      return network === NETWORK.CMF
     }).length > 0
   })
 
@@ -40,7 +39,7 @@ export const useOrganizationProfileStore = defineStore('organizationProfile', ()
    */
   const isFfec = computed( (): boolean => {
     return networks.value && networks.value.filter((network: string) => {
-      return network === runtimeConfig.ffec_network
+      return network === NETWORK.FFEC
     }).length > 0
   })
 
@@ -58,7 +57,7 @@ export const useOrganizationProfileStore = defineStore('organizationProfile', ()
    * @return {boolean}
    */
   const isArtistProduct = computed( (): boolean => {
-    return product.value === runtimeConfig.artist_product
+    return product.value === PRODUCT.ARTIST
   })
 
   /**
@@ -66,7 +65,7 @@ export const useOrganizationProfileStore = defineStore('organizationProfile', ()
    * @return {boolean}
    */
   const isArtistPremiumProduct = computed( (): boolean => {
-    return product.value === runtimeConfig.artist_premium_product
+    return product.value === PRODUCT.ARTIST_PREMIUM
   })
 
   /**
@@ -83,7 +82,7 @@ export const useOrganizationProfileStore = defineStore('organizationProfile', ()
    * @return {boolean}
    */
   const isSchoolProduct = computed( (): boolean => {
-    return product.value === runtimeConfig.school_product
+    return product.value === PRODUCT.SCHOOL
   })
 
   /**
@@ -92,7 +91,7 @@ export const useOrganizationProfileStore = defineStore('organizationProfile', ()
    * @return {boolean}
    */
   const isSchoolPremiumProduct = computed( (): boolean => {
-    return product.value === runtimeConfig.school_premium_product
+    return product.value === PRODUCT.SCHOOL_PREMIUM
   })
 
   /**
@@ -104,11 +103,11 @@ export const useOrganizationProfileStore = defineStore('organizationProfile', ()
   })
 
   /**
-   * L'organization possède t'elle un produit manager
+   * L'organization possède-t-elle un produit manager
    * @return {boolean}
    */
   const isManagerProduct = computed( (): boolean => {
-    return product.value === runtimeConfig.manager_product
+    return product.value === PRODUCT.MANAGER
   })
 
   /**
@@ -124,7 +123,7 @@ export const useOrganizationProfileStore = defineStore('organizationProfile', ()
    * @return {boolean|null}
    */
   const isAssociation = computed(():any => {
-    return legalStatus.value === 'ASSOCIATION_LAW_1901';
+    return legalStatus.value === LEGAL_STATUS.ASSOCIATION_LAW_1901;
   })
 
   /**
@@ -133,9 +132,7 @@ export const useOrganizationProfileStore = defineStore('organizationProfile', ()
    * @return {boolean}
    */
   const isCMFCentralService = computed((): boolean => {
-    if(runtimeConfig.CMF_ID)
-      return id.value === parseInt(runtimeConfig.CMF_ID)
-    return false
+    return id.value === ORGANIZATION_ID.CMF
   })
 
   const getWebsite = computed((): string | null => {
@@ -146,6 +143,16 @@ export const useOrganizationProfileStore = defineStore('organizationProfile', ()
     return modules.value && modules.value.includes(module)
   }
 
+  /**
+   * /!\ Server side only
+   * @param profile
+   */
+  const initiateProfile = (profile: any) => {
+    setProfile(profile)
+
+    // >>> Triggers a listener in plugins/ability
+  }
+
   // Actions
   const setProfile = (profile: any) => {
     id.value = profile.id
@@ -190,6 +197,7 @@ export const useOrganizationProfileStore = defineStore('organizationProfile', ()
     website,
     parents,
     isCmf,
+    isCMFCentralService,
     isFfec,
     isInsideNetwork,
     isArtistProduct,
@@ -204,6 +212,7 @@ export const useOrganizationProfileStore = defineStore('organizationProfile', ()
     getWebsite,
     hasModule,
     setProfile,
-    refreshProfile
+    refreshProfile,
+    initiateProfile
   }
 })

+ 18 - 0
stores/repositories/EducationTimingsRepository.ts

@@ -0,0 +1,18 @@
+import {Collection} from "pinia-orm";
+import EducationTiming from "~/models/Education/EducationTiming";
+import BaseRepository from "~/stores/repositories/BaseRepository";
+
+class EducationTimingsRepository extends BaseRepository {
+    use = EducationTiming
+
+    /**
+     * On récupère les EducationTimings via le store
+     */
+    public getEducationTimings(): Collection<EducationTiming> {
+        return this.getQuery()
+            .get() as Collection<EducationTiming>
+    }
+
+}
+
+export default EducationTimingsRepository

+ 1 - 1
stores/repositories/NotificationRepository.ts

@@ -26,4 +26,4 @@ class NotificationRepository extends BaseRepository {
     }
 }
 
-export default NotificationRepository
+export default NotificationRepository

+ 16 - 0
stores/repositories/ResidenceAreasRepository.ts

@@ -0,0 +1,16 @@
+import { Collection } from 'pinia-orm'
+import ResidenceArea from '~/models/Billing/ResidenceArea'
+import BaseRepository from '~/stores/repositories/BaseRepository'
+
+class ResidenceAreasRepository extends BaseRepository {
+  use = ResidenceArea
+
+  /**
+   * On récupère les RésidenceArea via le store
+   */
+  public getResidenceAreas(): Collection<ResidenceArea> {
+    return this.getQuery().get() as Collection<ResidenceArea>
+  }
+}
+
+export default ResidenceAreasRepository

+ 10 - 17
tests/units/services/data/apiRequestService.test.ts

@@ -31,7 +31,7 @@ describe('get', () => {
         expect(result).toEqual('a_response')
         // @ts-ignore
         expect(apiRequestService.request).toHaveBeenCalledWith(
-            HTTP_METHOD.GET, 'https://myapi.com/api/item', null, null, { a: 1 }
+            HTTP_METHOD.GET, 'https://myapi.com/api/item', null, { a: 1 }
         )
     })
 })
@@ -44,8 +44,7 @@ describe('post', () => {
         const result = await apiRequestService.post(
             'https://myapi.com/api/item',
             'request_body',
-            { a: 1 },
-            { b: 2 },
+            { a: 1 }
         )
 
         expect(result).toEqual('a_response')
@@ -54,8 +53,7 @@ describe('post', () => {
             HTTP_METHOD.POST,
             'https://myapi.com/api/item',
             'request_body',
-            { a: 1 },
-            { b: 2 }
+            { a: 1 }
         )
     })
 })
@@ -68,8 +66,7 @@ describe('put', () => {
         const result = await apiRequestService.put(
             'https://myapi.com/api/item',
             'request_body',
-            { a: 1 },
-            { b: 2 },
+            { a: 1 }
         )
 
         expect(result).toEqual('a_response')
@@ -78,8 +75,7 @@ describe('put', () => {
             HTTP_METHOD.PUT,
             'https://myapi.com/api/item',
             'request_body',
-            { a: 1 },
-            { b: 2 }
+            { a: 1 }
         )
     })
 })
@@ -91,7 +87,7 @@ describe('delete', () => {
 
         const result = await apiRequestService.delete(
             'https://myapi.com/api/item',
-            { a: 1 },
+            { a: 1 }
         )
 
         expect(result).toEqual('a_response')
@@ -100,8 +96,7 @@ describe('delete', () => {
             HTTP_METHOD.DELETE,
             'https://myapi.com/api/item',
             null,
-            null,
-            { a: 1 },
+            { a: 1 }
         )
     })
 })
@@ -147,14 +142,13 @@ describe('request', () => {
         expect(fetcher).toHaveBeenCalledWith('https://myapi.com/api/item', {method: 'GET'})
     })
 
-    test('with query and params', async () => {
+    test('with query', async () => {
         // @ts-ignore
         const result = await apiRequestService.request(
             HTTP_METHOD.PUT,
             'https://myapi.com/api/item',
             'a_body',
-            { a: 1 },
-            { b: 2 }
+            { a: 1 }
         )
 
         expect(result).toEqual('fetch_response')
@@ -164,8 +158,7 @@ describe('request', () => {
             {
                 method: 'PUT',
                 body: 'a_body',
-                params: { a: 1 },
-                query: { b: 2 },
+                query: { a: 1 },
             }
         )
     })

+ 22 - 12
tests/units/services/data/entityManager.test.ts

@@ -1,20 +1,31 @@
-import { describe, test, it, expect } from 'vitest'
+import { describe, test, vi, expect, beforeEach, afterEach } from 'vitest'
 import EntityManager from "~/services/data/entityManager";
 import ApiResource from "~/models/ApiResource";
 import ApiModel from "~/models/ApiModel";
 import ApiRequestService from "~/services/data/apiRequestService";
 import {Element, Repository} from "pinia-orm";
-import models from "~/models/models";
+import {Str, Uid} from "pinia-orm/dist/decorators";
 
 
 
 class DummyApiResource extends ApiResource {
     static entity = 'dummyResource'
 
+    @Uid()
+    declare id: number | string
+
+    @Str(null)
+    declare name: string
 }
 
 class DummyApiModel extends ApiModel {
     static entity = 'dummyModel'
+
+    @Uid()
+    declare id: number | string
+
+    @Str(null)
+    declare name: string
 }
 
 let _console: any = {
@@ -143,7 +154,6 @@ describe('newInstance', () => {
 
         // @ts-ignore
         const entity = new DummyApiResource(properties)
-        entity.setModel = vi.fn((model: typeof ApiResource) => null)
 
         // @ts-ignore
         repo.make = vi.fn((properties: object) => {
@@ -226,7 +236,7 @@ describe('fetch', () => {
 
         expect(entityManager.find).toHaveBeenCalledWith(DummyApiResource, 1)
         expect(apiRequestService.get).toHaveBeenCalledWith('api/dummyResource/1')
-        expect(entityManager.newInstance).toHaveBeenCalledWith(DummyApiResource, properties)
+        expect(entityManager.newInstance).toHaveBeenCalledWith(DummyApiResource, { id: 1, name: null, _model: undefined})
 
         expect(result).toEqual(entity)
     })
@@ -265,7 +275,7 @@ describe('fetch', () => {
 
         expect(entityManager.find).toHaveBeenCalledTimes(0)
         expect(apiRequestService.get).toHaveBeenCalledWith('api/dummyResource/1')
-        expect(entityManager.newInstance).toHaveBeenCalledWith(DummyApiResource, properties)
+        expect(entityManager.newInstance).toHaveBeenCalledWith(DummyApiResource, { id: 1, name: null, _model: undefined})
 
         expect(result).toEqual(entity)
     })
@@ -294,11 +304,11 @@ describe('fetchCollection', () => {
 
         const result = await entityManager.fetchCollection(DummyApiResource, null)
 
-        expect(apiRequestService.get).toHaveBeenCalledWith('api/dummyResource', [])
+        expect(apiRequestService.get).toHaveBeenCalledWith('api/dummyResource', null)
         expect(entityManager.newInstance).toHaveBeenCalledTimes(3)
-        expect(entityManager.newInstance).toHaveBeenCalledWith(DummyApiResource, {id: 1})
-        expect(entityManager.newInstance).toHaveBeenCalledWith(DummyApiResource, {id: 2})
-        expect(entityManager.newInstance).toHaveBeenCalledWith(DummyApiResource, {id: 3})
+        expect(entityManager.newInstance).toHaveBeenCalledWith(DummyApiResource, { id: 1, name: null, _model: undefined })
+        expect(entityManager.newInstance).toHaveBeenCalledWith(DummyApiResource, { id: 2, name: null, _model: undefined })
+        expect(entityManager.newInstance).toHaveBeenCalledWith(DummyApiResource, { id: 3, name: null, _model: undefined })
 
         expect(result.items).toEqual([
             new DummyApiResource({id: 1}),
@@ -340,7 +350,7 @@ describe('fetchCollection', () => {
 
         await entityManager.fetchCollection(DummyApiResource, parent)
 
-        expect(apiRequestService.get).toHaveBeenCalledWith('api/dummyModel/100/dummyResource', [])
+        expect(apiRequestService.get).toHaveBeenCalledWith('api/dummyModel/100/dummyResource', null)
     })
 
     test('with a query', async () => {
@@ -406,8 +416,8 @@ describe('persist', () => {
             return {id: 'tmp1', name: 'bob'}
         })
 
-        // TODO: attendre de voir si cet appel est nécessaire dans l'entity manager
-        // entityManager.cast = vi.fn((model: typeof ApiResource, entity: ApiResource): ApiResource => entity)
+        // @ts-ignore
+        entityManager.cast = vi.fn((model: typeof ApiResource, entity: ApiResource): ApiResource => entity)
 
         const response = { id: 1, name: 'bob' }
         // @ts-ignore

+ 1 - 1
tests/units/services/data/enumManager.test.ts

@@ -1,4 +1,4 @@
-import { describe, test, it, expect } from 'vitest'
+import { describe, test, it, expect, beforeEach } from 'vitest'
 import ApiRequestService from "~/services/data/apiRequestService";
 import EnumManager from "~/services/data/enumManager";
 import {VueI18n} from "vue-i18n";

+ 0 - 219
tests/units/services/data/normalizer/hydraDenormalizer.test.ts

@@ -1,219 +0,0 @@
-import { describe, test, it, expect } from 'vitest'
-import {AnyJson, ApiResponse, HydraMetadata} from "~/types/data";
-import HydraDenormalizer from "~/services/data/normalizer/hydraDenormalizer";
-import {METADATA_TYPE} from "~/types/enum/data";
-
-describe('denormalize', () => {
-
-    test('should parse a API Item response and return a JSON Object', () => {
-        const data: AnyJson = {
-            '@context': '/api/contexts/Access',
-            '@id': '/api/accesses/7351',
-            '@type': 'Access',
-            organization: '/api/organizations/37306',
-            id: 7351,
-            person: {
-                '@type': 'Person',
-                id: 11344,
-                name: 'BRUEL',
-                givenName: 'Patrick'
-            }
-        }
-
-        const result = HydraDenormalizer.denormalize(data)
-
-        // @ts-ignore
-        expect(result.data).toEqual(HydraDenormalizer.getData(data))
-        // @ts-ignore
-        expect(result.metadata).toEqual(HydraDenormalizer.getMetadata(data))
-
-        const expected = {
-            "data": {
-                "@context": "/api/contexts/Access",
-                "@id": "/api/accesses/7351",
-                "@type": "Access",
-                "id": 7351,
-                "organization": "/api/organizations/37306",
-                "person": {
-                    "@type": "Person",
-                    "givenName": "Patrick",
-                    "id": 11344,
-                    "name": "BRUEL"
-                }
-            },
-            "metadata": {
-                "type": METADATA_TYPE.ITEM
-            }
-        }
-
-        expect(result).toStrictEqual<AnyJson>(expected)
-    })
-
-    it('should parse a API Collection response and return a JSON Object', () => {
-        const data: AnyJson = {
-            '@context': '/api/contexts/Access',
-            '@id': '/api/accesses',
-            '@type': 'hydra:Collection',
-            'hydra:member': [{
-                '@id': '/api/accesses/7351',
-                organization: '/api/organizations/37306',
-                id: 7351,
-                person: {
-                    '@type': 'Person',
-                    id: 11344,
-                    name: 'BRUEL',
-                    givenName: 'Patrick'
-                }
-            }, {
-                '@id': '/api/accesses/7352',
-                organization: '/api/organizations/37306',
-                id: 7352,
-                person: {
-                    '@type': 'Person',
-                    id: 11345,
-                    name: 'BRASSENS',
-                    givenName: 'George'
-                }
-            }
-            ],
-            "hydra:view": {
-                "@id": "/api/accesses?page=3",
-                "@type": "hydra:PartialCollectionView",
-                "hydra:first": "/api/accesses?page=1",
-                "hydra:last": "/api/accesses?page=5",
-                "hydra:next": "/api/accesses?page=4",
-                "hydra:previous": "/api/accesses?page=2"
-            }
-        }
-
-        const result = HydraDenormalizer.denormalize(data)
-
-        // @ts-ignore
-        expect(result.data).toEqual(HydraDenormalizer.getData(data))
-        // @ts-ignore
-        expect(result.metadata).toEqual(HydraDenormalizer.getMetadata(data))
-
-        const expected = JSON.stringify(
-            {"data":[
-                {
-                    '@id': '/api/accesses/7351',
-                    organization: '/api/organizations/37306',
-                    id: 7351,
-                    person:
-                        {
-                            '@type': 'Person',
-                            id: 11344,
-                            name: 'BRUEL',
-                            givenName: 'Patrick'
-                        }
-                },
-                {
-                    '@id': '/api/accesses/7352',
-                    organization: '/api/organizations/37306',
-                    id: 7352,
-                    person:
-                        {
-                            '@type': 'Person',
-                            id: 11345,
-                            name: 'BRASSENS',
-                            givenName: 'George'
-                        }
-                }
-            ],
-            'metadata': {
-                'firstPage': 1,
-                'lastPage': 5,
-                'nextPage': 4,
-                'previousPage': 2,
-                'type': 1
-            }
-        })
-
-        expect(JSON.stringify(result)).toEqual(expected)
-    })
-})
-
-describe('getData', () => {
-    test('With collection', () => {
-        const data = {
-            "@context": "/api/contexts/Foo",
-            "@id": "/api/foo",
-            "@type": "hydra:Collection",
-            "hydra:member": [ 'foo' ],
-        }
-
-        // @ts-ignore
-        expect(HydraDenormalizer.getData(data)).toEqual([ 'foo' ])
-    })
-
-    test('With item', () => {
-        const data = {
-            "@context": "/api/contexts/Foo",
-            "@id": "/api/foo",
-            "@type": "Foo",
-            "param1": 'a',
-        }
-
-        // @ts-ignore
-        expect(HydraDenormalizer.getData(data)).toEqual(data)
-    })
-})
-
-describe('getMetadata', () => {
-    test('With valid collection metadata', () => {
-        const data = {
-            "@context": "/api/contexts/Foo",
-            "@id": "/api/foo",
-            "@type": "hydra:Collection",
-            "hydra:member": [ 'foo' ],
-            "hydra:totalItems": 10,
-            "hydra:view": {
-                "@id": "/api/foo?page=3",
-                "@type": "hydra:PartialCollectionView",
-                "hydra:first": "/api/foo?page=1",
-                "hydra:last": "/api/foo?page=5",
-                "hydra:next": "/api/foo?page=4",
-                "hydra:previous": "/api/foo?page=2"
-            }
-        }
-
-        // @ts-ignore
-        const metadata = HydraDenormalizer.getMetadata(data)
-
-        expect(metadata.totalItems).toEqual(10)
-        expect(metadata.firstPage).toEqual(1)
-        expect(metadata.lastPage).toEqual(5)
-        expect(metadata.nextPage).toEqual(4)
-        expect(metadata.previousPage).toEqual(2)
-        expect(metadata.type).toEqual(METADATA_TYPE.COLLECTION)
-    })
-    test('With partial collection metadata', () => {
-        const data = {
-            "@context": "/api/contexts/Foo",
-            "@id": "/api/foo",
-            "@type": "hydra:Collection",
-            "hydra:member": [ 'foo' ],
-            "hydra:totalItems": 10,
-            "hydra:view": {
-                "@id": "/api/foo?page=3",
-                "@type": "hydra:PartialCollectionView",
-                "hydra:first": "/api/foo?page=1",
-            }
-        }
-
-        // @ts-ignore
-        const metadata = HydraDenormalizer.getMetadata(data)
-
-        expect(metadata.totalItems).toEqual(10)
-        expect(metadata.firstPage).toEqual(1)
-        expect(metadata.lastPage).toEqual(1)
-        expect(metadata.nextPage).toEqual(undefined)
-        expect(metadata.previousPage).toEqual(undefined)
-    })
-
-    test('With item metadata', () => {
-        // @ts-ignore
-        const metadata = HydraDenormalizer.getMetadata({})
-        expect(metadata.type).toEqual(METADATA_TYPE.ITEM)
-    })
-})

+ 532 - 0
tests/units/services/data/normalizer/hydraNormalizer.test.ts

@@ -0,0 +1,532 @@
+import { vi, describe, test, it, expect, afterEach } from 'vitest'
+import {AnyJson} from "~/types/data";
+import HydraNormalizer from "~/services/data/normalizer/hydraNormalizer";
+import {METADATA_TYPE} from "~/types/enum/data";
+import ApiModel from "~/models/ApiModel";
+import {Str, Uid, Attr} from "pinia-orm/dist/decorators";
+import {IriEncoded} from "~/models/decorators";
+import UrlUtils from "~/services/utils/urlUtils";
+import ApiResource from "~/models/ApiResource";
+
+class DummyApiChild extends ApiModel {
+    static entity = 'dummyChild'
+
+    @Uid()
+    declare id: number | string
+}
+
+class DummyApiModel extends ApiModel {
+    static entity = 'dummyModel'
+
+    @Uid()
+    declare id: number | string
+
+    @Attr(null)
+    @IriEncoded(DummyApiChild)
+    declare oneToOneRelation: number | null
+
+    @Attr([])
+    @IriEncoded(DummyApiChild)
+    declare oneToManyRelation: []
+
+    @Str(null)
+    declare name: null
+}
+
+afterEach(() => {
+    vi.clearAllMocks();
+    vi.resetAllMocks();
+});
+
+describe('normalize', () => {
+    const initialMakeUriMethod = UrlUtils.makeIRI
+    //@ts-ignore
+    const initialGetIriEncodedFieldsMethod = HydraNormalizer.getIriEncodedFields
+
+    afterEach(() => {
+        UrlUtils.makeIRI = initialMakeUriMethod
+        //@ts-ignore
+        HydraNormalizer.getIriEncodedFields = initialGetIriEncodedFieldsMethod
+    })
+
+    test('should normalize an entity into a JSON Object', () => {
+
+        const entity: DummyApiModel = new DummyApiModel({
+            id: 7351,
+            oneToOneRelation: 99,
+            oneToManyRelation: [123, 124, 125]
+        })
+
+        //@ts-ignore
+        HydraNormalizer.getIriEncodedFields = vi.fn(
+            (entity) => {
+                return {
+                    oneToOneRelation: DummyApiChild,
+                    oneToManyRelation: DummyApiChild
+                }
+            }
+        )
+
+        //@ts-ignore
+        UrlUtils.makeIRI = vi.fn(
+            (targetEntity, id) => {
+                return {
+                    99: '/api/dummyChild/99',
+                    123: '/api/dummyChild/123',
+                    124: '/api/dummyChild/124',
+                    125: '/api/dummyChild/125',
+                }[id]
+            }
+        )
+
+        const result = HydraNormalizer.normalizeEntity(entity)
+
+        const expected = {
+            id: 7351,
+            name: null,
+            oneToOneRelation: '/api/dummyChild/99',
+            oneToManyRelation: ['/api/dummyChild/123', '/api/dummyChild/124', '/api/dummyChild/125']
+        }
+
+        expect(result).toStrictEqual(expected)
+    })
+})
+
+
+describe('denormalize', () => {
+    //@ts-ignore
+    const initialGetDataMethod = HydraNormalizer.getData
+    //@ts-ignore
+    const initialGetMetadataMethod = HydraNormalizer.getMetadata
+
+    afterEach(() => {
+        //@ts-ignore
+        HydraNormalizer.getData = initialGetDataMethod
+        //@ts-ignore
+        HydraNormalizer.getMetadata = initialGetMetadataMethod
+    })
+
+    test('should parse a API Item response and return a JSON Object', () => {
+        const data: AnyJson = { id: 1, name: 'foo' };
+
+        //@ts-ignore
+        HydraNormalizer.getData = vi.fn((data, model) => {
+            return data
+        })
+
+        //@ts-ignore
+        HydraNormalizer.getMetadata = vi.fn((data, model) => {
+            return { "type": METADATA_TYPE.ITEM }
+        })
+
+        const result = HydraNormalizer.denormalize(data, DummyApiModel)
+
+        const expected = {
+            "data": {
+                id: 1,
+                name: 'foo',
+            },
+            "metadata": {
+                "type": METADATA_TYPE.ITEM
+            }
+        }
+
+        expect(result).toEqual(expected)
+    })
+})
+
+describe('getData', () => {
+    test('With collection', () => {
+        const data = {
+            "@context": "/api/contexts/Foo",
+            "@id": "/api/foo",
+            "@type": "hydra:Collection",
+            "hydra:member": [ 'foo' ],
+        }
+
+        // @ts-ignore
+        expect(HydraNormalizer.getData(data)).toEqual([ 'foo' ])
+    })
+
+    test('With item', () => {
+        const data = {
+            "@context": "/api/contexts/Foo",
+            "@id": "/api/foo",
+            "@type": "Foo",
+            "param1": 'a',
+        }
+
+        // @ts-ignore
+        expect(HydraNormalizer.getData(data)).toEqual(data)
+    })
+})
+
+describe('getMetadata', () => {
+    test('With valid collection metadata', () => {
+        const data = {
+            "@context": "/api/contexts/Foo",
+            "@id": "/api/foo",
+            "@type": "hydra:Collection",
+            "hydra:member": [ 'foo' ],
+            "hydra:totalItems": 10,
+            "hydra:view": {
+                "@id": "/api/foo?page=3",
+                "@type": "hydra:PartialCollectionView",
+                "hydra:first": "/api/foo?page=1",
+                "hydra:last": "/api/foo?page=5",
+                "hydra:next": "/api/foo?page=4",
+                "hydra:previous": "/api/foo?page=2"
+            }
+        }
+
+        // @ts-ignore
+        const metadata = HydraNormalizer.getMetadata(data)
+
+        expect(metadata.totalItems).toEqual(10)
+        expect(metadata.firstPage).toEqual(1)
+        expect(metadata.lastPage).toEqual(5)
+        expect(metadata.nextPage).toEqual(4)
+        expect(metadata.previousPage).toEqual(2)
+        expect(metadata.type).toEqual(METADATA_TYPE.COLLECTION)
+    })
+    test('With partial collection metadata', () => {
+        const data = {
+            "@context": "/api/contexts/Foo",
+            "@id": "/api/foo",
+            "@type": "hydra:Collection",
+            "hydra:member": [ 'foo' ],
+            "hydra:totalItems": 10,
+            "hydra:view": {
+                "@id": "/api/foo?page=3",
+                "@type": "hydra:PartialCollectionView",
+                "hydra:first": "/api/foo?page=1",
+            }
+        }
+
+        // @ts-ignore
+        const metadata = HydraNormalizer.getMetadata(data)
+
+        expect(metadata.totalItems).toEqual(10)
+        expect(metadata.firstPage).toEqual(1)
+        expect(metadata.lastPage).toEqual(1)
+        expect(metadata.nextPage).toEqual(undefined)
+        expect(metadata.previousPage).toEqual(undefined)
+    })
+
+    test('With item metadata', () => {
+        // @ts-ignore
+        const metadata = HydraNormalizer.getMetadata({})
+        expect(metadata.type).toEqual(METADATA_TYPE.ITEM)
+    })
+})
+
+describe('denormalizeItem', () => {
+    //@ts-ignore
+    const initialDenormalizeEntity = HydraNormalizer.denormalizeEntity
+    //@ts-ignore
+    const initialDenormalizeEnum = HydraNormalizer.denormalizeEnum
+    //@ts-ignore
+    const initialParseEntityIRI = HydraNormalizer.parseEntityIRI
+    const initialConsoleError = console.error
+
+    afterEach(() => {
+        //@ts-ignore
+        HydraNormalizer.denormalizeEntity = initialDenormalizeEntity
+        //@ts-ignore
+        HydraNormalizer.denormalizeEnum = initialDenormalizeEnum
+        //@ts-ignore
+        HydraNormalizer.parseEntityIRI = initialParseEntityIRI
+
+        console.error = initialConsoleError
+    })
+
+    test('With provided model', () => {
+
+        const item = {
+            '@id': '/api/dummyModel/1',
+            id: 1,
+            name: 'foo'
+        }
+
+        const model = DummyApiModel
+        const expected = new DummyApiModel(item)
+
+        //@ts-ignore
+        HydraNormalizer.denormalizeEntity = vi.fn((model, item) => {
+            return expected
+        })
+
+        //@ts-ignore
+        const result = HydraNormalizer.denormalizeItem(item, model)
+
+        expect(result).toEqual(expected)
+
+        //@ts-ignore
+        expect(HydraNormalizer.denormalizeEntity).toHaveBeenCalledWith(model, item)
+    })
+
+    test('with no @id prop', () => {
+        const item = {
+            id: 1,
+            name: 'foo'
+        }
+
+        console.error = vi.fn((msg) => {})
+
+        //@ts-ignore
+        const result = HydraNormalizer.denormalizeItem(item)
+
+        expect(result).toEqual(item)
+
+        expect(console.error).toHaveBeenCalledWith(
+            'De-normalization error : the item is not hydra formatted',
+            item
+        )
+    })
+
+    test('with enum', () => {
+        const item = {
+            '@id': '/api/enum/abc',
+            A: 1,
+            B: 2,
+            C: 3
+        }
+
+        //@ts-ignore
+        HydraNormalizer.denormalizeEnum = vi.fn((item) => {
+            return item
+        })
+
+        //@ts-ignore
+        const result = HydraNormalizer.denormalizeItem(item)
+
+        expect(result).toEqual(item)
+
+        //@ts-ignore
+        expect(HydraNormalizer.denormalizeEnum).toHaveBeenCalledWith(item)
+    })
+
+    test('with unparsable iri', () => {
+        const item = {
+            '@id': 'some_invalid_iri',
+            id: 1,
+            name: 'foo'
+        }
+
+        //@ts-ignore
+        HydraNormalizer.parseEntityIRI = vi.fn((iri) => {
+            throw('parsing error')
+        })
+
+        console.error = vi.fn((msg) => {})
+
+        //@ts-ignore
+        const result = HydraNormalizer.denormalizeItem(item)
+
+        expect(result).toEqual(item)
+
+        //@ts-ignore
+        expect(console.error).toHaveBeenCalledWith(
+            'De-normalization error : could not parse the IRI',
+            item
+        )
+    })
+
+    test('With valid iri and existing model', () => {
+
+        HydraNormalizer.models = {
+            'dummyModel': DummyApiModel
+        }
+
+        const item = {
+            '@id': '/api/dummyModel/1',
+            id: 1,
+            name: 'foo'
+        }
+
+        //@ts-ignore
+        HydraNormalizer.parseEntityIRI = vi.fn((iri) => {
+            return { entity: 'dummyModel' }
+        })
+
+        const expected = new DummyApiModel(item)
+
+        //@ts-ignore
+        HydraNormalizer.denormalizeEntity = vi.fn((model, item) => {
+            return expected
+        })
+
+        //@ts-ignore
+        const result = HydraNormalizer.denormalizeItem(item)
+
+        expect(result).toEqual(expected)
+
+        //@ts-ignore
+        expect(HydraNormalizer.denormalizeEntity).toHaveBeenCalledWith(DummyApiModel, item)
+    })
+
+    test('With valid iri and un-existing model', () => {
+
+        HydraNormalizer.models = {
+            'dummyModel': DummyApiModel
+        }
+
+        const item = {
+            '@id': '/api/unknownModel/1',
+            id: 1,
+            name: 'foo'
+        }
+
+        //@ts-ignore
+        HydraNormalizer.parseEntityIRI = vi.fn((iri) => {
+            return 'unknownModel'
+        })
+
+        expect(
+            //@ts-ignore
+            () => HydraNormalizer.denormalizeItem(item)
+        ).toThrowError()
+    })
+})
+
+describe('denormalizeEntity', () => {
+    //@ts-ignore
+    const initialGetIriEncodedFieldsMethod = HydraNormalizer.getIriEncodedFields
+    //@ts-ignore
+    const initialGetIdFromEntityIriMethod = HydraNormalizer.getIdFromEntityIri
+
+    afterEach(() => {
+        //@ts-ignore
+        HydraNormalizer.getIriEncodedFields = initialGetIriEncodedFieldsMethod
+        //@ts-ignore
+        HydraNormalizer.getIdFromEntityIri = initialGetIdFromEntityIriMethod
+    })
+
+    test('should denormalize a Json object into an entity', () => {
+        const data = {
+            id: 7351,
+            name: null,
+            oneToOneRelation: '/api/dummyChild/99',
+            oneToManyRelation: ['/api/dummyChild/123', '/api/dummyChild/124', '/api/dummyChild/125']
+        }
+
+        //@ts-ignore
+        HydraNormalizer.getIriEncodedFields = vi.fn(
+            (entity) => {
+                return {
+                    oneToOneRelation: DummyApiChild,
+                    oneToManyRelation: DummyApiChild
+                }
+            }
+        )
+
+        //@ts-ignore
+        HydraNormalizer.getIdFromEntityIri = vi.fn((iri) => {
+            return {
+                '/api/dummyChild/99': 99,
+                '/api/dummyChild/123': 123,
+                '/api/dummyChild/124': 124,
+                '/api/dummyChild/125': 125
+            }[iri]
+        })
+
+        //@ts-ignore
+        const result = HydraNormalizer.denormalizeEntity(DummyApiModel, data)
+
+        expect(result).toStrictEqual(new DummyApiModel({
+            id: 7351,
+            name: null,
+            oneToOneRelation: 99,
+            oneToManyRelation: [123, 124, 125]
+        }))
+
+    })
+})
+
+describe('denormalizeEnum', () => {
+    test('does nothing', () => {
+        const item = {
+            '@id': '/api/enum/abc',
+            A: 1,
+            B: 2,
+            C: 3
+        }
+
+        //@ts-ignore
+        expect(HydraNormalizer.denormalizeEnum(item)).toStrictEqual(item)
+    })
+})
+
+describe('parseEntityIRI', () => {
+    test('valid iri', () => {
+
+        const iri = '/api/someEntity/456'
+        const expected = {
+            entity: 'someEntity',
+            id: 456
+        }
+
+        //@ts-ignore
+        expect(HydraNormalizer.parseEntityIRI(iri)).toStrictEqual(expected)
+    })
+
+    test('invalid iri', () => {
+        const iri = 'some_invalid_iri'
+
+        //@ts-ignore
+        expect(() => HydraNormalizer.parseEntityIRI(iri)).toThrowError('could not parse the IRI : "some_invalid_iri"')
+    })
+})
+
+describe('getIriEncodedFields', () => {
+    const initialRelations = DummyApiModel.relations
+
+    afterEach(() => {
+        DummyApiModel.relations = initialRelations
+    })
+
+    test('get relations', () => {
+        //@ts-ignore
+        const relations:  Record<string, ApiResource> = {'someField': DummyApiChild}
+
+        DummyApiModel.relations = relations
+
+        const entity = new DummyApiModel()
+
+        //@ts-ignore
+        const results = HydraNormalizer.getIriEncodedFields(entity)
+
+        expect(results).toEqual(relations)
+    })
+})
+
+describe('getIdFromEntityIri', () => {
+    //@ts-ignore
+    const initialParseEntityIRI = HydraNormalizer.parseEntityIRI
+
+    afterEach(() => {
+        //@ts-ignore
+        HydraNormalizer.parseEntityIRI = initialParseEntityIRI
+    })
+
+    test('valid iri', () => {
+        const iri = '/api/someEntity/456'
+
+        //@ts-ignore
+        const result = HydraNormalizer.getIdFromEntityIri(iri, 'someEntity')
+
+        expect(result).toEqual(456)
+    })
+
+    test('entity not matching', () => {
+        const iri = '/api/someEntity/456'
+
+        //@ts-ignore
+        expect(
+            //@ts-ignore
+            () => HydraNormalizer.getIdFromEntityIri(iri, 'otherEntity')
+        ).toThrowError("IRI entity does not match the field's target entity (someEntity != otherEntity)")
+    })
+
+
+})

+ 10 - 2
tests/units/services/layout/menuBuilder/configurationMenuBuilder.test.ts

@@ -67,11 +67,19 @@ describe('build', () => {
         )
     })
 
+    test('has only rights for menu parameters', () => {
+        ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'parameters_page')
+
+        expect(menuBuilder.build()).toEqual(
+            {label: 'parameters', icon: undefined, to: '/parameters', type: MENU_LINK_TYPE.INTERNAL, active: false}
+        )
+    })
+
     test('has only rights for menu place', () => {
         ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'place_page')
 
         expect(menuBuilder.build()).toEqual(
-            {label: 'place', icon: undefined, to: 'https://mydomain.com/#/places/list/', type: MENU_LINK_TYPE.V1, active: false}
+            {label: 'places', icon: undefined, to: 'https://mydomain.com/#/places/list/', type: MENU_LINK_TYPE.V1, active: false}
         )
     })
 
@@ -87,7 +95,7 @@ describe('build', () => {
         ability.can = vi.fn((action: string, subject: string) => action === 'display' && subject === 'tag_page')
 
         expect(menuBuilder.build()).toEqual(
-            {label: 'tag', icon: undefined, to: 'https://mydomain.com/#/taggs/list/', type: MENU_LINK_TYPE.V1, active: false}
+            {label: 'tags', icon: undefined, to: 'https://mydomain.com/#/taggs/list/', type: MENU_LINK_TYPE.V1, active: false}
         )
     })
 

+ 1 - 1
tests/units/services/sse/sseSource.test.ts

@@ -1,4 +1,4 @@
-import { describe, test, it, expect } from 'vitest'
+import { describe, test, it, expect, beforeEach, afterEach, vi } from 'vitest'
 import SseSource from "~/services/sse/sseSource";
 import {EventSourcePolyfill} from "event-source-polyfill";
 

+ 12 - 0
tests/units/services/utils/dateUtils.test.ts

@@ -99,6 +99,18 @@ describe('getShortFormatPattern', () => {
     })
 })
 
+describe('getFormatPattern', () => {
+    test('standard call', () => {
+        expect(DateUtils.getFormatPattern(supportedLocales.FR)).toEqual('dd/MM/yyyy HH:mm')
+        expect(DateUtils.getFormatPattern(supportedLocales.EN)).toEqual('MM/dd/yyyy HH:mm')
+    })
+
+    test('unsupported locale', () => {
+        // @ts-ignore
+        expect(DateUtils.getFormatPattern('xx')).toEqual('dd/MM/yyyy HH:mm')
+    })
+})
+
 describe('formatIsoShortDate', () => {
     test('standard call', () => {
         expect(DateUtils.formatIsoShortDate(new Date(2023, 0, 10))).toEqual('2023-01-10')

+ 28 - 0
tests/units/services/utils/stringUtils.test.ts

@@ -0,0 +1,28 @@
+import { describe, test, expect } from 'vitest'
+import StringUtils from "~/services/utils/stringUtils";
+
+
+describe("normalize", () => {
+    test("simple cases", () => {
+
+        const assertEqual = (input: string, expected: string) => {
+            expect(StringUtils.normalize(input)).toBe(expected)
+        }
+
+        assertEqual("abc", "abc")
+        assertEqual("ABC", "abc")
+        assertEqual("éçï", "eci")
+        assertEqual("éèẽëê-ç-îïĩ-àã-öôõ-ûüũ", "eeeee c iii aa ooo uuu")
+        assertEqual("  abc   ", "abc")
+    })
+})
+
+describe("parseInt", () => {
+    test("simple cases", () => {
+        expect(StringUtils.parseInt(6)).toBe(6)
+        expect(StringUtils.parseInt("6")).toBe(6)
+    })
+})
+
+
+

+ 10 - 0
tests/units/services/utils/urlUtils.test.ts

@@ -118,3 +118,13 @@ describe('addQuery', () => {
         expect(UrlUtils.addQuery('https://foo.com/#/bar/', {'a': 1})).toEqual('https://foo.com/#/bar/?a=1')
     })
 })
+
+describe('makeIRI', () => {
+    test('valid parameters', () => {
+        expect(UrlUtils.makeIRI('someEntity', 123)).toEqual('/api/someEntity/123')
+    })
+    test('invalid id', () => {
+        //@ts-ignore
+        expect(() => UrlUtils.makeIRI('someEntity', 'abc')).toThrowError('Invalid id : abc')
+    })
+})

+ 58 - 0
tests/units/services/validation/subdomainValidation.test.ts

@@ -0,0 +1,58 @@
+import {describe, expect, test} from "vitest";
+import SubdomainValidation from "~/services/validation/subdomainValidation";
+import ApiRequestService from "~/services/data/apiRequestService";
+import {AssociativeArray} from "~/types/data";
+
+let subdomainValidation: SubdomainValidation
+let apiRequestService: ApiRequestService
+
+beforeEach(() => {
+    // @ts-ignore
+    apiRequestService = vi.fn() as ApiRequestService
+
+    subdomainValidation = new SubdomainValidation(apiRequestService)
+})
+
+describe('isValid', () => {
+    test('simple cases', () => {
+        expect(SubdomainValidation.isValid("abcd")).toBeTruthy()
+        expect(SubdomainValidation.isValid("abc-def")).toBeTruthy()
+        expect(SubdomainValidation.isValid("abc-123")).toBeTruthy()
+        expect(SubdomainValidation.isValid("abcdefghijklmnopqrstuvwxyz0123456789")).toBeTruthy()
+
+
+        expect(SubdomainValidation.isValid("0123456789-0123456789-0123456789-0123456789-0123456789-0123456789-0123456789")).toBeFalsy() // Too long
+        expect(SubdomainValidation.isValid("a")).toBeFalsy()
+        expect(SubdomainValidation.isValid("abc_def")).toBeFalsy()
+        expect(SubdomainValidation.isValid("abc%")).toBeFalsy()
+        expect(SubdomainValidation.isValid("ab/")).toBeFalsy()
+        expect(SubdomainValidation.isValid("ab cd")).toBeFalsy()
+        expect(SubdomainValidation.isValid("-abcd")).toBeFalsy()
+        expect(SubdomainValidation.isValid("abcd-")).toBeFalsy()
+    })
+})
+
+describe('isAvailable', () => {
+    test('is available', async () => {
+        apiRequestService.get = vi.fn(async (url: string, query: AssociativeArray) => {
+            if (url !== '/api/subdomains/is_available' || !query.hasOwnProperty('subdomain') || query['subdomain'] !== 'abc' ) {
+                throw new Error("Invalid arguments")
+            }
+            // @ts-ignore
+            return {"available": true} as Response
+        })
+
+        expect(await subdomainValidation.isAvailable('abc')).toBeTruthy()
+    })
+    test('is not available', async () => {
+        apiRequestService.get = vi.fn(async (url: string, query: AssociativeArray) => {
+            if (url !== '/api/subdomains/is_available' || !query.hasOwnProperty('subdomain') || query['subdomain'] !== 'abc' ) {
+                throw new Error("Invalid arguments")
+            }
+            // @ts-ignore
+            return {"available": false} as Response
+        })
+
+        expect(await subdomainValidation.isAvailable('abc')).toBeFalsy()
+    })
+})

+ 26 - 1
types/enum/enums.ts

@@ -1,3 +1,27 @@
+
+
+export const enum PRODUCT {
+  SCHOOL = 'school',
+  SCHOOL_PREMIUM = 'school-premium',
+  ARTIST = 'artist',
+  ARTIST_PREMIUM = 'artist-premium',
+  MANAGER = 'manager'
+}
+
+export const enum NETWORK {
+  CMF = 'CMF',
+  FFEC = 'FFEC',
+}
+
+export const enum LEGAL_STATUS {
+  ASSOCIATION_LAW_1901= 'ASSOCIATION_LAW_1901'
+}
+
+export const enum ORGANIZATION_ID {
+  CMF = 12097,
+  OPENTALENT_MANAGER = 93931
+}
+
 export const enum FORM_FUNCTION {
   CREATE = 'CREATE',
   EDIT = 'EDIT'
@@ -48,5 +72,6 @@ export const enum ALERT_STATE_COTISATION {
 
 export const enum SUBMIT_TYPE {
   SAVE = 'save',
-  SAVE_AND_BACK= 'save_and_back'
+  SAVE_AND_BACK = 'save_and_back'
 }
+

部分文件因为文件数量过多而无法显示