Browse Source

Merge branch 'release/2.4.4'

Vincent 7 months ago
parent
commit
dc1e68353a
98 changed files with 2802 additions and 1737 deletions
  1. 1 0
      .eslintrc.cjs
  2. 10 0
      assets/css/theme.scss
  3. 5 0
      components/Layout/AlertBar/Env.vue
  4. 1 1
      components/Layout/AlertBar/SuperAdmin.vue
  5. 8 0
      components/Layout/Dialog.vue
  6. 84 0
      components/Layout/Dialog/Trial/AlreadyDid.vue
  7. 74 0
      components/Layout/Dialog/Trial/StopConfirmation.vue
  8. 7 5
      components/Layout/Header.vue
  9. 36 14
      components/Layout/MainMenu.vue
  10. 140 0
      components/Layout/Pages/Subscription/Card.vue
  11. 36 0
      components/Layout/Pages/Subscription/List.vue
  12. 192 0
      components/Layout/Parameters/EntityTable.vue
  13. 0 0
      components/Layout/Parameters/ResidenceAreas.vue
  14. 174 0
      components/Layout/Parameters/Table.vue
  15. 9 4
      components/Layout/ParametersMenu.vue
  16. 1 1
      components/Layout/Subheader.vue
  17. 110 0
      components/Layout/UpgradePremiumButton.vue
  18. 10 36
      components/Ui/Button/Delete.vue
  19. 17 32
      components/Ui/DatePicker.vue
  20. 2 2
      components/Ui/ExpansionPanel.vue
  21. 20 22
      components/Ui/Form.vue
  22. 1 1
      components/Ui/Form/Creation.vue
  23. 55 0
      components/Ui/Form/DeletionConfirmationDialog.vue
  24. 1 6
      components/Ui/Form/Edition.vue
  25. 4 4
      components/Ui/Input/Autocomplete.vue
  26. 65 68
      components/Ui/Input/Autocomplete/Accesses.vue
  27. 1 1
      components/Ui/Input/AutocompleteWithEnum.vue
  28. 8 7
      components/Ui/Input/DatePicker.vue
  29. 18 0
      components/Ui/Input/Email.vue
  30. 2 2
      components/Ui/Input/Image.vue
  31. 1 1
      components/Ui/Input/Number.vue
  32. 1 1
      components/Ui/Input/Text.vue
  33. 0 0
      components/Ui/LoadingPanel.client.vue
  34. 2 0
      components/Ui/SystemBar.vue
  35. 116 0
      composables/data/useApiLegacyRequestService.ts
  36. 23 0
      composables/form/useDeleteItem.ts
  37. 24 0
      composables/utils/useDownloadFromRoute.ts
  38. 12 0
      composables/utils/useRouteUtils.ts
  39. 8 0
      config/theme.ts
  40. 4 0
      env/.env.ci
  41. 4 0
      env/.env.docker
  42. 4 0
      env/.env.prod
  43. 4 0
      env/.env.test
  44. 4 0
      env/.env.test1
  45. 4 0
      env/.env.test2
  46. 4 0
      env/.env.test3
  47. 4 0
      env/.env.test4
  48. 4 0
      env/.env.test5
  49. 4 0
      env/.env.test6
  50. 4 0
      env/.env.test7
  51. 4 0
      env/.env.test8
  52. 4 0
      env/.env.test9
  53. 86 0
      eslint.config.mjs
  54. 0 0
      i18n/i18n.config.ts
  55. 0 0
      i18n/lang/en.json
  56. 41 6
      i18n/lang/fr.json
  57. 0 0
      i18n/lang/fr.json.removed
  58. 19 1
      layouts/default.vue
  59. 8 8
      middleware/routing.global.ts
  60. 30 0
      models/Custom/Search/UserSearchItem.ts
  61. 3 0
      models/Organization/OrganizationProfile.ts
  62. 18 2
      nuxt.config.ts
  63. 41 40
      package.json
  64. 1 6
      pages/my-settings.vue
  65. 6 1
      pages/parameters.vue
  66. 5 71
      pages/parameters/attendances.vue
  67. 1 8
      pages/parameters/bulletin.vue
  68. 7 15
      pages/parameters/education_notation.vue
  69. 5 73
      pages/parameters/education_timings/index.vue
  70. 1 6
      pages/parameters/general_parameters.vue
  71. 2 0
      pages/parameters/index.vue
  72. 3 6
      pages/parameters/intranet.vue
  73. 5 77
      pages/parameters/residence_areas/index.vue
  74. 13 6
      pages/parameters/sms.vue
  75. 4 5
      pages/parameters/subdomains/[id].vue
  76. 6 4
      pages/parameters/subdomains/new.vue
  77. 15 17
      pages/parameters/super_admin.vue
  78. 28 52
      pages/parameters/teaching.vue
  79. 20 15
      pages/parameters/website.vue
  80. 625 560
      pages/subscription.vue
  81. 8 6
      plugins/init.server.ts
  82. 0 1
      regex_pattern.txt
  83. 67 0
      services/data/Filters/InArrayFilter.ts
  84. 1 1
      services/data/Filters/OrderBy.ts
  85. 8 0
      services/data/Query.ts
  86. 18 12
      services/data/entityManager.ts
  87. 3 3
      services/data/imageManager.ts
  88. 1 1
      services/data/normalizer/hydraNormalizer.ts
  89. 2 2
      services/rights/abilityBuilder.ts
  90. 3 3
      services/utils/fileUtils.ts
  91. 2 2
      services/utils/refUtils.ts
  92. 12 0
      stores/organizationProfile.ts
  93. 7 3
      tests/units/services/data/entityManager.test.ts
  94. 3 2
      tests/units/services/rights/abilityBuilder.test.ts
  95. 4 4
      tests/units/services/utils/fileUtils.test.ts
  96. 6 0
      types/enum/enums.ts
  97. 17 0
      types/interfaces.d.ts
  98. 316 510
      yarn.lock

+ 1 - 0
.eslintrc.cjs

@@ -18,6 +18,7 @@ module.exports = {
     'plugin:@typescript-eslint/recommended',
     'plugin:vue/vue3-recommended',
     'plugin:prettier/recommended',
+    'plugin:you-dont-need-lodash-underscore/compatible',
   ],
   ignorePatterns: [
     '.nuxt',

+ 10 - 0
assets/css/theme.scss

@@ -51,3 +51,13 @@
   background-color: rgb(var(--v-theme-x-create-btn)) !important;
   color: rgb(var(--v-theme-on-x-create-btn)) !important;
 }
+
+.theme-artist {
+  background-color: rgb(var(--v-theme-artist)) !important;
+  color: rgb(var(--v-theme-on-surface)) !important;
+}
+
+.theme-school {
+  background-color: rgb(var(--v-theme-school)) !important;
+  color: rgb(var(--v-theme-on-primary)) !important;
+}

+ 5 - 0
components/Layout/AlertBar/Env.vue

@@ -10,7 +10,12 @@ Barre d'alerte qui s'affiche lorsque l'utilisateur n'est pas dans un environneme
     :text="$t('not_production_environment', { env: env })"
     icon="fas fa-exclamation-triangle"
     class="theme-warning"
+    style="z-index: 1005"
   />
+  <!--
+  Le z-index est précisé pour éviter cette erreur : https://github.com/vuetifyjs/nuxt-module/issues/205
+  Il pourra être retiré dès que le bug aura été corrigé
+  -->
 </template>
 
 <script setup lang="ts">

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

@@ -39,7 +39,7 @@ const url: ComputedRef<string> = computed(() => {
 
   if (show && orgId && originalAccessId) {
     return makeAdminUrl(
-      UrlUtils.join('#', 'switch_user', orgId, originalAccessId, 'exit'),
+      UrlUtils.join('switch_user', orgId, originalAccessId, 'exit'),
     )
   }
   return ''

+ 8 - 0
components/Layout/Dialog.vue

@@ -14,6 +14,7 @@
         "
       >
         <h3 class="d-flex">
+          <v-icon icon="fa-solid fa-bullhorn" />
           <slot name="dialogType" />
         </h3>
       </div>
@@ -83,6 +84,13 @@ const _show = computed(() => props.show) as boolean
     writing-mode: vertical-lr;
     transform: rotate(-180deg);
   }
+
+  .v-icon {
+    font-size: 25px;
+    transform: rotate(90deg);
+    padding-right: 20px;
+    padding-bottom: 10px;
+  }
 }
 
 .dialog-container {

+ 84 - 0
components/Layout/Dialog/Trial/AlreadyDid.vue

@@ -0,0 +1,84 @@
+<template>
+  <LazyLayoutDialog :show="show" theme="warning">
+    <template #dialogType>{{ $t('important') }}</template>
+    <template #dialogTitle>{{ $t('trial_all_ready_did') }}</template>
+    <template #dialogText>
+      <v-card-text class="text">
+        <p>
+          Au cours des 6 derniers mois, vous avez bénéficié d’un essai gratuit
+          de 30 jours du logiciel Opentalent Artist Premium.
+        </p>
+        <p>
+          Pour continuer à explorer toutes les fonctionnalités de notre solution
+          et optimiser la gestion de votre structure, nous vous invitons à
+          souscrire à l’une de nos offres adaptées à vos besoins.
+        </p>
+        <p>
+          Si toutefois vous souhaitez une réactivation exceptionnelle de
+          l’essai, n’hésitez pas à contacter notre équipe Opentalent. Nous
+          serons ravis d’évaluer votre demande et de vous accompagner dans vos
+          projets.
+        </p>
+      </v-card-text>
+    </template>
+    <template #dialogBtn>
+      <v-btn class="mr-4 submitBtn theme-neutral-strong" @click="closeDialog">
+        {{ $t('cancel') }}
+      </v-btn>
+      <v-btn class="mr-4 submitBtn theme-warning" @click="goSubscribe">
+        {{ $t('i_subscribe') }}
+      </v-btn>
+      <v-btn class="mr-4 submitBtn theme-neutral-strong" @click="contactOpentalent">
+        {{ $t('opentalent_contact') }}
+      </v-btn>
+    </template>
+  </LazyLayoutDialog>
+</template>
+
+<script setup lang="ts">
+import UrlUtils from "~/services/utils/urlUtils";
+
+const runtimeConfig = useRuntimeConfig()
+
+const props = defineProps({
+  show: {
+    type: Boolean,
+    required: false,
+    default: false,
+  },
+})
+const emit = defineEmits(['closeDialog'])
+
+const closeDialog = () => {
+  emit('closeDialog')
+}
+
+const goSubscribe = async () => {
+  const v1BaseURL =
+    runtimeConfig.baseUrlAdminLegacy || runtimeConfig.public.baseUrlAdminLegacy
+
+  await navigateTo(UrlUtils.join(v1BaseURL, '#', 'subscribe'), {
+    external: true,
+  })
+}
+
+const contactOpentalent = async () => {
+  emit('closeDialog')
+
+  await navigateTo('https://logiciels.opentalent.fr/nous-contacter', {
+    open: {
+      target: '_blank',
+    },
+    external: true,
+  })
+}
+</script>
+
+<style scoped lang="scss">
+.text {
+  font-size: 13px;
+  p {
+    margin-bottom: 10px;
+  }
+}
+</style>

+ 74 - 0
components/Layout/Dialog/Trial/StopConfirmation.vue

@@ -0,0 +1,74 @@
+<template>
+  <LazyLayoutDialog :show="show" theme="danger">
+    <template #dialogType>{{ $t('important') }}</template>
+    <template #dialogTitle
+      >{{ $t('you_want_to_stop_your_premium_trial_period') }} ?
+    </template>
+    <template #dialogText>
+      <v-card-text class="text">
+        <p>
+          {{ $t('stop_trial_period_warning_1a') }}
+          {{
+            $t(
+              organizationProfile.productBeforeTrial ??
+                'stop_trial_missing_version_label',
+            )
+          }}, {{ $t('stop_trial_period_warning_1b') }}
+        </p>
+        <p>
+          <strong>{{ $t('stop_trial_period_warning_2') }}</strong>
+        </p>
+        <ul>
+          <li>{{ $t('stop_trial_period_warning_3') }}</li>
+          <li>{{ $t('stop_trial_period_warning_4') }}</li>
+          <li>{{ $t('stop_trial_period_warning_5') }}</li>
+        </ul>
+        <p>{{ $t('stop_trial_period_warning_6') }}</p>
+      </v-card-text>
+    </template>
+    <template #dialogBtn>
+      <v-btn class="mr-4 submitBtn theme-neutral-strong" @click="closeDialog">
+        {{ $t('cancel') }}
+      </v-btn>
+      <v-btn class="mr-4 submitBtn theme-danger" @click="stopTrial">
+        {{ $t('stop_trial') }}
+      </v-btn>
+    </template>
+  </LazyLayoutDialog>
+</template>
+
+<script setup lang="ts">
+import { useOrganizationProfileStore } from '~/stores/organizationProfile'
+
+defineProps({
+  show: {
+    type: Boolean,
+    required: false,
+    default: false,
+  },
+})
+
+const organizationProfile = useOrganizationProfileStore()
+
+const emit = defineEmits(['closeDialog', 'stopTrial'])
+
+const closeDialog = () => {
+  emit('closeDialog')
+}
+const stopTrial = () => {
+  emit('stopTrial')
+}
+</script>
+
+<style scoped lang="scss">
+.text {
+  p {
+    margin-bottom: 10px;
+  }
+
+  ul {
+    padding-left: 20px;
+    margin-bottom: 10px;
+  }
+}
+</style>

+ 7 - 5
components/Layout/Header.vue

@@ -46,11 +46,13 @@ Contient entre autres le nom de l'organisation, l'accès à l'aide et aux préf
     <LayoutHeaderMenu name="Account" color="on-primary" icon="fas fa-sun" />
 
     <a
-      :href="runtimeConfig.supportUrl"
+      :href="runtimeConfig.supportUrl || runtimeConfig.public.supportUrl"
       class="text-body px-3 py-4 ml-2 theme-secondary text-decoration-none h-100"
       target="_blank"
     >
-      <span class="d-none d-sm-none d-md-flex">{{ $t('help_access') }}</span>
+      <span class="d-none d-sm-none d-md-flex">
+        {{ $t('help_access') }}
+      </span>
       <v-icon
         icon="fas fa-question-circle"
         class="d-sm-flex d-md-none"
@@ -61,11 +63,11 @@ Contient entre autres le nom de l'organisation, l'accès à l'aide et aux préf
 </template>
 
 <script setup lang="ts">
-import { computed } from '@vue/reactivity'
-import type { ComputedRef } from '@vue/reactivity'
-import { useMenu } from '~/composables/layout/useMenu'
+import { computed } from 'vue'
+import type { ComputedRef } from 'vue'
 import { useAbility } from '@casl/vue'
 import { useDisplay } from 'vuetify'
+import { useMenu } from '~/composables/layout/useMenu'
 import { useOrganizationProfileStore } from '~/stores/organizationProfile'
 import { useLayoutStore } from '~/stores/layout'
 

+ 36 - 14
components/Layout/MainMenu.vue

@@ -8,10 +8,13 @@ Prend en paramètre une liste de ItemMenu et les met en forme
     v-model="displayMenu"
     :rail="isRail"
     :disable-resize-watcher="true"
+    style="z-index: 1006"
     class="theme-secondary main-menu"
   >
+    <!-- Forcing z-index to avoid this : https://github.com/vuetifyjs/nuxt-module/issues/205 -->
+
     <template #prepend>
-      <slot name="title"></slot>
+      <slot name="prepend" :is-rail="isRail" />
     </template>
 
     <v-list open-strategy="single" active-class="active" class="left-menu">
@@ -20,6 +23,7 @@ Prend en paramètre une liste de ItemMenu et les met en forme
         <!-- Cas 1 : l'item n'a pas d'enfants, c'est un lien (ou le menu est en mode réduit) -->
         <v-list-item
           v-if="!item.children || isRail"
+          :id="'main-menu-item' + item.label"
           :title="$t(item.label)"
           :prepend-icon="item.icon.name"
           :href="!isInternalLink(item) ? item.to : undefined"
@@ -39,6 +43,7 @@ Prend en paramètre une liste de ItemMenu et les met en forme
           <template #activator="{ props }">
             <v-list-item
               v-bind="props"
+              :id="'main-menu-item' + item.label"
               :prepend-icon="item.icon.name"
               :title="$t(item.label)"
               class="theme-secondary menu-item"
@@ -48,8 +53,9 @@ Prend en paramètre une liste de ItemMenu et les met en forme
 
           <v-list-item
             v-for="child in item.children"
-            :key="$t(child.label)"
+            :key="child.label"
             :title="$t(child.label)"
+            :id="'main-menu-item' + item.label + '-' + child.label"
             :prepend-icon="child.icon.name"
             :href="!isInternalLink(child) ? child.to : undefined"
             :to="isInternalLink(child) ? child.to : undefined"
@@ -62,7 +68,7 @@ Prend en paramètre une liste de ItemMenu et les met en forme
     </v-list>
 
     <template #append>
-      <slot name="foot"></slot>
+      <slot name="foot" :is-rail="isRail" />
     </template>
   </v-navigation-drawer>
 </template>
@@ -72,7 +78,10 @@ import { useMenu } from '~/composables/layout/useMenu'
 import { computed } from '@vue/reactivity'
 import { useDisplay } from 'vuetify'
 import type { MenuGroup, MenuItem } from '~/types/layout'
+import { useApiLegacyRequestService } from '~/composables/data/useApiLegacyRequestService'
 
+const i18n = useI18n()
+const organizationProfile = useOrganizationProfileStore()
 const { getMenu, hasMenu, isInternalLink, setMenuState, isMenuOpened } =
   useMenu()
 
@@ -80,22 +89,15 @@ const { mdAndUp, lgAndUp } = useDisplay()
 
 const menu = getMenu('Main')
 
-const isOpened = computed(() => isMenuOpened('Main'))
-
-let items: Array<MenuGroup | MenuItem>
-if (menu === null) {
-  items = []
-} else if (menu.hasOwnProperty('children')) {
-  items = (menu as MenuGroup).children ?? []
-} else {
-  items = [menu]
-}
-
 // En vue lg+, on affiche toujours le menu
 const displayMenu = computed(() => {
   return menu !== null && hasMenu('Main') && (lgAndUp.value || isOpened.value)
 })
 
+const isOpened = computed(() => isMenuOpened('Main'))
+
+const items: Array<MenuGroup | MenuItem> = getItems(menu)
+
 // En vue md+, fermer le menu le passe simplement en mode rail
 // Sinon, le fermer le masque complètement
 const isRail = computed(() => {
@@ -117,6 +119,26 @@ const unwatch = watch(lgAndUp, (newValue, oldValue) => {
 onUnmounted(() => {
   unwatch()
 })
+
+/**
+ * Récupère les menuItem disponibles
+ * @param menu
+ */
+function getItems(
+  menu: MenuGroup | MenuItem | null,
+): Array<MenuGroup | MenuItem> {
+  let items: Array<MenuGroup | MenuItem>
+
+  if (menu === null) {
+    items = []
+  } else if (menu.hasOwnProperty('children')) {
+    items = (menu as MenuGroup).children ?? []
+  } else {
+    items = [menu]
+  }
+
+  return items
+}
 </script>
 
 <style scoped lang="scss">

+ 140 - 0
components/Layout/Pages/Subscription/Card.vue

@@ -0,0 +1,140 @@
+<template>
+  <v-card
+    elevation="2"
+    outlined
+    shaped
+    class="card"
+    :class="{ ['border-' + color]: true }"
+  >
+    <span
+      v-if="extraHeader"
+      class="extraBorder"
+      :class="'extraBorder-' + color"
+      >{{ extraHeader }}</span
+    >
+
+    <div class="card-content">
+      <!-- Titre -->
+      <v-card-title class="title" :class="{ ['margin-sup']: !extraHeader }">
+        {{ title }}
+      </v-card-title>
+
+      <v-card-subtitle class="subtitle">
+        {{ subTitle }}
+        <slot name="card.subTitle" />
+      </v-card-subtitle>
+
+      <!-- Texte -->
+      <v-card-text>
+        <LayoutPagesSubscriptionList :elements="list" :color="color" />
+      </v-card-text>
+
+      <!-- Actions -->
+      <v-card-actions class="mb-3 card-actions">
+        <slot name="card.action" />
+      </v-card-actions>
+    </div>
+  </v-card>
+</template>
+
+<script setup lang="ts">
+const props = defineProps({
+  title: {
+    type: String,
+    required: true,
+  },
+  subTitle: {
+    type: String,
+    required: false,
+  },
+  extraHeader: {
+    type: String,
+    required: false,
+  },
+  color: {
+    type: String,
+    required: true,
+  },
+  list: {
+    type: Array,
+    required: true,
+  },
+})
+</script>
+
+<style scoped lang="scss">
+.card {
+  border-width: 1px;
+  border-top-width: 4px;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  position: relative;
+
+  .title {
+    padding-top: 10px;
+    white-space: normal;
+    text-transform: uppercase;
+  }
+  .subtitle {
+    text-transform: uppercase;
+    font-weight: bold;
+    font-size: 1.25rem;
+    opacity: unset;
+  }
+  //1280 1670
+  :deep(.v-btn) {
+    padding: 10px;
+    width: 100%;
+    @media (min-width: 1280px) and (max-width: 1670px) {
+      letter-spacing: 0px;
+      font-size: 11px;
+    }
+  }
+}
+
+.card-content {
+  display: flex;
+  flex-direction: column;
+  flex: 1 1 auto;
+  padding: 10px;
+}
+
+.card-actions {
+  margin-top: auto; // pousse les actions en bas
+  display: flex;
+  flex-direction: column;
+}
+
+.extraBorder {
+  text-align: center;
+  text-transform: uppercase;
+  margin: auto;
+  border-radius: 0px 0px 5px 5px;
+  width: 90%;
+  padding: 2px 10px 2px 10px;
+  font-weight: bold;
+  display: block;
+}
+.card.border-primary {
+  border-color: rgb(var(--v-theme-primary));
+}
+.card.border-artist {
+  border-color: rgb(var(--v-theme-artist));
+}
+.card.border-school {
+  border-color: rgb(var(--v-theme-school));
+}
+.extraBorder.extraBorder-artist {
+  background: rgb(var(--v-theme-artist));
+}
+.extraBorder.extraBorder-school {
+  background: rgb(var(--v-theme-school));
+  color: #fff;
+}
+
+.margin-sup {
+  margin-top: 30px;
+}
+</style>

+ 36 - 0
components/Layout/Pages/Subscription/List.vue

@@ -0,0 +1,36 @@
+<template>
+  <ul>
+    <li v-for="li in elements">
+      <v-icon
+        class="check"
+        :color="color"
+        icon="fa-solid fa-check"
+        size="large"
+      ></v-icon>
+      <span class="pl-2">{{ li }}</span>
+    </li>
+  </ul>
+</template>
+
+<script setup lang="ts">
+const props = defineProps({
+  elements: {
+    type: Array,
+    required: true,
+  },
+  color: {
+    type: String,
+    required: true,
+  },
+})
+</script>
+
+<style scoped lang="scss">
+ul {
+  list-style: none;
+  .check {
+    font-size: 15px;
+    font-weight: bold;
+  }
+}
+</style>

+ 192 - 0
components/Layout/Parameters/EntityTable.vue

@@ -0,0 +1,192 @@
+<!--
+A data table for the parameters page
+-->
+<template>
+  <div class="container">
+    <UiLoadingPanel v-if="pending" />
+    <div v-else>
+      <LayoutParametersTable
+        :items="items"
+        :columns-definitions="columns"
+        :actions="actions"
+        :actions-route="actionsRoute"
+        @editClicked="onEditClicked"
+        @deleteClicked="onDeleteClicked"
+        @addClicked="goToCreatePage"
+      />
+
+      <UiFormDeletionConfirmationDialog
+        v-model="showDeletionConfirmationDialog"
+        @deleteClicked="onDeleteConfirmed"
+        @cancelClicked="onCancelClicked"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { TABLE_ACTION } from '~/types/enum/enums'
+import UrlUtils from '~/services/utils/urlUtils'
+import type ApiResource from '~/models/ApiResource'
+import { useEntityFetch } from '~/composables/data/useEntityFetch'
+import type { AssociativeArray } from '~/types/data'
+import type { ColumnDefinition } from '~/types/interfaces'
+import { useDeleteItem } from '~/composables/form/useDeleteItem'
+import { useEntityManager } from '~/composables/data/useEntityManager'
+
+const props = defineProps({
+  /**
+   * The model whom entities shall be to fetch
+   */
+  model: {
+    type: Object as PropType<typeof ApiResource>,
+    required: true,
+  },
+  /**
+   * If provided, define the columns to show.
+   * Else, all the entity's props are shown.
+   *
+   * Ex: [
+   *       { property: 'id', label : 'Identifier'},
+   *       { property: 'name', label : 'Full name'},
+   *     ]
+   */
+  columnsDefinitions: {
+    type: Array as PropType<Array<ColumnDefinition> | null>,
+    required: false,
+    default: null,
+  },
+  /**
+   * List of the actions available for each record
+   */
+  actions: {
+    type: Array as PropType<Array<TABLE_ACTION>>,
+    required: false,
+    default: [TABLE_ACTION.EDIT, TABLE_ACTION.DELETE, TABLE_ACTION.ADD],
+  },
+  /**
+   * The base URL for the edit / create pages
+   * The resulting url will be constructed this way :
+   *
+   * Edition : ({baseUrl}/){apiResource.entity}/{id}
+   * Creation : ({baseUrl}/){apiResource.entity}/new
+   */
+  baseActionsRoute: {
+    type: String,
+    required: false,
+    default: '/parameters',
+  },
+  /**
+   * If provided, sort the record by the given property
+   */
+  sortBy: {
+    type: String as PropType<string>,
+    required: false,
+    default: 'id',
+  },
+})
+
+const i18n = useI18n()
+
+const { em } = useEntityManager()
+
+const { fetchCollection } = useEntityFetch()
+
+const { data: collection, pending } = fetchCollection(props.model)
+
+const { deleteItem } = useDeleteItem()
+
+const pageStore = usePageStore()
+
+/**
+ * Return the properties to display in the table, or all the
+ * props of the model if not specified.
+ */
+const columns: ComputedRef<Array<ColumnDefinition>> = computed(() => {
+  return (
+    props.columnsDefinitions ??
+    Object.getOwnPropertyNames(new props.model()).map((prop) => {
+      return { property: prop }
+    })
+  )
+})
+
+/**
+ * Fetch the collection of ApiResources of the given model, then
+ * map it according to the configuration.
+ */
+const items: ComputedRef<Array<ApiResource> | null> = computed(() => {
+  if (pending.value || collection.value === null) {
+    return null
+  }
+
+  let items: Array<ApiResource> = collection.value!.items
+
+  if (props.columnsDefinitions !== null) {
+    // Filter the columns to show
+    items = items.map((item) => {
+      const newItem: ApiResource = { id: item.id }
+      for (const col of props.columnsDefinitions!) {
+        newItem[col.property] = item[col.property]
+      }
+      return newItem
+    })
+  }
+
+  if (props.sortBy) {
+    items = items.sort((a: AssociativeArray, b: AssociativeArray) => {
+      return a[props.sortBy as keyof typeof a] >
+        b[props.sortBy as keyof typeof b]
+        ? 1
+        : -1
+    })
+  }
+
+  return items
+})
+
+const actionsRoute = computed(() => {
+  return UrlUtils.join(props.baseActionsRoute, props.model.entity)
+})
+
+const showDeletionConfirmationDialog: Ref<boolean> = ref(false)
+const itemToDelete: Ref<ApiResource | null> = ref(null)
+/**
+ * Redirect to the edition page for the given item
+ * @param item
+ */
+const onEditClicked = (item: ApiResource) => {
+  navigateTo(UrlUtils.join(actionsRoute.value, item.id))
+}
+
+/**
+ * Show the deletion confirmation dialog
+ * @param item
+ */
+const onDeleteClicked = (item: ApiResource) => {
+  itemToDelete.value = em.cast(props.model, item)
+  showDeletionConfirmationDialog.value = true
+}
+
+const onCancelClicked = () => {
+  itemToDelete.value = null
+}
+
+/**
+ * Deletion has be confirmed, perform
+ */
+const onDeleteConfirmed = async () => {
+  pageStore.loading = true
+  await deleteItem(itemToDelete.value)
+  pageStore.loading = false
+}
+
+/**
+ * Redirect to the creation page for this model
+ */
+const goToCreatePage = () => {
+  navigateTo(UrlUtils.join(actionsRoute.value, 'new'))
+}
+</script>
+
+<style scoped lang="scss"></style>

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


+ 174 - 0
components/Layout/Parameters/Table.vue

@@ -0,0 +1,174 @@
+<!--
+A data table for the parameters page
+-->
+<template>
+  <div class="container">
+    <v-table>
+      <thead>
+        <tr>
+          <td v-for="col in columns">
+            {{ col.label }}
+          </td>
+          <td>{{ i18n.t('actions') }}</td>
+        </tr>
+      </thead>
+      <tbody v-if="items">
+        <tr v-for="(item, i) in items" :key="i">
+          <td v-for="col in columnsDefinitions" class="cycle-editable-cell">
+            {{ item[col.property] }}
+          </td>
+
+          <td class="d-flex flex-row actions-cell">
+            <slot name="actions" :item="item">
+              <v-btn
+                v-if="actions.includes(TABLE_ACTION.EDIT)"
+                :flat="true"
+                icon="fa fa-pen"
+                class="mr-3"
+                @click="emit('editClicked', item)"
+              />
+              <v-btn
+                v-if="actions.includes(TABLE_ACTION.DELETE)"
+                :flat="true"
+                icon="fas fa-trash"
+                @click="emit('deleteClicked', item)"
+              />
+            </slot>
+          </td>
+        </tr>
+      </tbody>
+      <tbody v-else>
+        <tr class="theme-neutral">
+          <td>
+            <i>{{ i18n.t('nothing_to_show') }}</i>
+          </td>
+          <td></td>
+        </tr>
+      </tbody>
+    </v-table>
+    <div class="d-flex justify-end" v-if="actions.includes(TABLE_ACTION.ADD)">
+      <v-btn
+        :flat="true"
+        prepend-icon="fa fa-plus"
+        class="theme-primary mt-4"
+        @click="emit('addClicked')"
+      >
+        {{ i18n.t('add') }}
+      </v-btn>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { TABLE_ACTION } from '~/types/enum/enums'
+import UrlUtils from '~/services/utils/urlUtils'
+import type { ColumnDefinition } from '~/types/interfaces'
+
+const props = defineProps({
+  /**
+   * Array of objects to display in the table
+   */
+  items: {
+    type: Array as PropType<Array<object>>,
+    required: true,
+  },
+  /**
+   * If provided, define the columns to show.
+   * Else, all the entity's props are shown.
+   *
+   * Ex: [
+   *       { property: 'id', label : 'Identifier'},
+   *       { property: 'name', label : 'Full name'},
+   *     ]
+   */
+  columnsDefinitions: {
+    type: Array as PropType<Array<ColumnDefinition> | null>,
+    required: false,
+    default: null,
+  },
+  /**
+   * The property used as identifier (required by 'edition' link)
+   */
+  identifier: {
+    type: String,
+    required: false,
+    default: 'id',
+  },
+  /**
+   * List of the actions available for each record
+   */
+  actions: {
+    type: Array as PropType<Array<TABLE_ACTION>>,
+    required: false,
+    default: [TABLE_ACTION.EDIT, TABLE_ACTION.DELETE, TABLE_ACTION.ADD],
+  },
+  /**
+   * The URL for the edit / create pages
+   * The resulting url will be constructed this way :
+   *
+   * Edition : {baseUrl}/{id}
+   * Creation : {baseUrl}/new
+   */
+  actionsRoute: {
+    type: String,
+    required: false,
+    default: '/parameters',
+  },
+})
+
+const i18n = useI18n()
+
+const emit = defineEmits(['editClicked', 'deleteClicked', 'addClicked'])
+
+const getId = (item: object) => {
+  return item[props.identifier]
+}
+
+const columns: ComputedRef<Array<ColumnDefinition>> = computed(() => {
+  return props.columnsDefinitions.map((col) => {
+    return {
+      property: col.property,
+      label: col.label ?? i18n.t(col.property),
+    }
+  })
+})
+</script>
+
+<style scoped lang="scss">
+.container {
+  max-width: 1000px;
+}
+
+.v-table {
+  width: 100%;
+
+  thead {
+    color: rgb(var(--v-theme-neutral-strong));
+    font-weight: 600;
+
+    td {
+      border-bottom: thin solid
+        rgba(var(--v-border-color), var(--v-border-opacity));
+    }
+
+    td:last-of-type {
+      padding-left: 30px;
+    }
+  }
+
+  th,
+  td {
+    padding: 10px;
+    text-align: left;
+  }
+
+  td:last-of-type {
+    width: 125px;
+  }
+}
+
+:deep(.actions-cell .v-icon) {
+  color: rgb(var(--v-theme-neutral-strong));
+  font-size: 18px;
+}
+</style>

+ 9 - 4
components/Layout/ParametersMenu.vue

@@ -3,8 +3,13 @@
     v-if="displayMenu"
     v-model="isOpened"
     mobile-breakpoint="sm"
+    style="z-index: 1005"
   >
-    <template v-slot:prepend>
+    <!--
+    Le z-index est précisé pour éviter cette erreur : https://github.com/vuetifyjs/nuxt-module/issues/205
+    Il pourra être retiré dès que le bug aura été corrigé
+    -->
+    <template #prepend>
       <div class="title">
         <h3>{{ $t('parameters') }}</h3>
       </div>
@@ -21,7 +26,7 @@
       </v-list-item>
     </v-list>
 
-    <template v-slot:append>
+    <template #append>
       <v-btn
         :href="homeUrl"
         prepend-icon="fa fa-right-from-bracket"
@@ -36,10 +41,10 @@
 </template>
 
 <script setup lang="ts">
+import { useDisplay } from 'vuetify'
+import { computed } from 'vue'
 import { useMenu } from '~/composables/layout/useMenu'
 import { useHomeUrl } from '~/composables/utils/useHomeUrl'
-import { useDisplay } from 'vuetify'
-import { computed } from '@vue/reactivity'
 import type { MenuGroup, MenuItem } from '~/types/layout'
 
 const { mdAndUp } = useDisplay()

+ 1 - 1
components/Layout/Subheader.vue

@@ -93,7 +93,7 @@ const showDateTimeRange: Ref<boolean> = ref(
 
 <style scoped lang="scss">
 main {
-  font-size: 12px;
+  font-size: 13px;
 }
 
 #subheader {

+ 110 - 0
components/Layout/UpgradePremiumButton.vue

@@ -0,0 +1,110 @@
+<template>
+  <div>
+    <div :class="['btn_trial', { minimized }]" @click="trialAction()">
+      <v-icon icon="fa fa-ticket" />
+
+      <span v-if="organizationProfile.isTrialActive && !minimized">
+        <strong>J-{{ organizationProfile.trialCountDown }}</strong>
+        <br />
+      </span>
+
+      <span v-if="!minimized">{{ btnLabel }}</span>
+    </div>
+
+    <LayoutDialogTrialAlreadyDid
+      :show="showDialog"
+      @closeDialog="showDialog = false"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import UrlUtils from '~/services/utils/urlUtils'
+import { useApiLegacyRequestService } from '~/composables/data/useApiLegacyRequestService'
+import { computed } from '@vue/reactivity'
+
+const runtimeConfig = useRuntimeConfig()
+const organizationProfile = useOrganizationProfileStore()
+const { apiRequestService } = useApiLegacyRequestService()
+const i18n = useI18n()
+
+defineProps({
+  minimized: {
+    type: Boolean,
+    required: false,
+    default: false,
+  },
+})
+
+const showDialog: Ref<boolean> = ref(false)
+
+const btnLabel = computed(() => {
+  if (organizationProfile.isTrialActive) {
+    return i18n.t('trial_started')
+  }
+  return organizationProfile.principalType === 'ARTISTIC_PRACTICE_ONLY'
+    ? i18n.t('try_premium')
+    : i18n.t('discover_offer')
+})
+
+/**
+ * Lorsque l'on appuie sur le bouton pour démarrer l'essai / découvrir les offres
+ */
+const trialAction = async () => {
+  const v1BaseURL =
+    runtimeConfig.baseUrlAdminLegacy || runtimeConfig.public.baseUrlAdminLegacy
+
+  if (organizationProfile.isTrialActive) {
+    await navigateTo(UrlUtils.join(v1BaseURL, '#', 'subscribe'), {
+      external: true,
+    })
+  } else if (organizationProfile.principalType === 'ARTISTIC_PRACTICE_ONLY') {
+    try {
+      await apiRequestService.get('/trial/is_available')
+      await navigateTo(UrlUtils.join(v1BaseURL, '#', 'trial'), {
+        external: true,
+      })
+    } catch (error) {
+      showDialog.value = true
+    }
+  } else {
+    await navigateTo('/subscription')
+  }
+}
+</script>
+
+<style scoped lang="scss">
+.btn_trial {
+  background-color: rgb(var(--v-theme-x-create-btn));
+  border-radius: 5px;
+  border: 1px solid #fff;
+  margin-left: 15px;
+  margin-right: 15px;
+  text-align: center;
+  color: #000;
+  margin-top: 5px;
+  padding: 5px 10px;
+  cursor: pointer;
+  white-space: pre-line;
+  font-size: 12px;
+
+  .v-icon {
+    font-size: 16px;
+    color: #000;
+    padding-right: 5px;
+    margin: 0 5px 4px 0;
+  }
+}
+
+.minimized {
+  font-size: 17px;
+  margin-left: 7px;
+  margin-right: 7px;
+  padding: 0;
+
+  .v-icon {
+    padding-right: 0;
+    margin: 0 0 3px 0;
+  }
+}
+</style>

+ 10 - 36
components/Ui/Button/Delete.vue

@@ -8,32 +8,17 @@ Bouton Delete avec modale de confirmation de la suppression
       <v-icon>fas fa-trash</v-icon>
     </v-btn>
 
-    <LazyLayoutDialog :show="showDialog">
-      <template #dialogType>{{ $t('delete_assistant') }}</template>
-      <template #dialogTitle>{{ $t('caution') }}</template>
-      <template #dialogText>
-        <v-card-text>
-          <p>{{ $t('confirm_to_delete') }}</p>
-        </v-card-text>
-      </template>
-      <template #dialogBtn>
-        <v-btn class="mr-4 submitBtn theme-neutral-strong" @click="closeDialog">
-          {{ $t('cancel') }}
-        </v-btn>
-        <v-btn class="mr-4 submitBtn theme-danger" @click="deleteItem">
-          {{ $t('delete') }}
-        </v-btn>
-      </template>
-    </LazyLayoutDialog>
+    <UiFormDeletionConfirmationDialog
+      v-model="showDialog"
+      @delete-clicked="onDeleteClicked"
+    />
   </main>
 </template>
 
 <script setup lang="ts">
 import type { Ref, PropType } from 'vue'
-import { TYPE_ALERT } from '~/types/enum/enums'
-import { useEntityManager } from '~/composables/data/useEntityManager'
 import ApiResource from '~/models/ApiResource'
-import { usePageStore } from '~/stores/page'
+import { useDeleteItem } from '~/composables/form/useDeleteItem'
 
 const props = defineProps({
   entity: {
@@ -47,27 +32,16 @@ const props = defineProps({
   },
 })
 
-const showDialog: Ref<boolean> = ref(false)
-
-const { em } = useEntityManager()
+const { deleteItem } = useDeleteItem()
 
-const deleteItem = async () => {
-  try {
-    await em.delete(props.entity)
-    usePageStore().addAlert(TYPE_ALERT.SUCCESS, ['deleteSuccess'])
-  } catch (error) {
-    // @ts-expect-error error is supposed to have a message prop
-    usePageStore().addAlert(TYPE_ALERT.ALERT, [error.message])
-    throw error
-  }
-  showDialog.value = false
-}
+const showDialog: Ref<boolean> = ref(false)
 
 const alertDeleteItem = () => {
   showDialog.value = true
 }
-const closeDialog = () => {
-  showDialog.value = false
+
+const onDeleteClicked = async () => {
+  await deleteItem(entity)
 }
 </script>
 

+ 17 - 32
components/Ui/DatePicker.vue

@@ -6,12 +6,14 @@ Sélecteur de dates avec Vuetify
 
 <template>
   <v-layout row wrap>
+    <!--
+    TODO: remplacer par <v-date-input> quand celui ci ne sera plus expérimental
+    (@see https://vuetifyjs.com/en/components/date-inputs)
+    -->
     <v-menu
-      ref="menu"
       v-model="menu"
       :close-on-content-click="false"
       :nudge-right="40"
-      :return-value.sync="modelValue"
       lazy
       transition="scale-transition"
       offset-y
@@ -20,58 +22,43 @@ Sélecteur de dates avec Vuetify
       :position-x="positionX"
       :position-y="positionY"
     >
-      <template v-slot:activator="{ on, attrs }">
+      <template #activator="{ props: attrs }">
         <v-text-field
           v-model="displayDate"
           :label="label"
-          :readonly="readOnly"
+          :readonly="true"
           v-bind="attrs"
-          v-on="on"
-          @blur="menu = false"
-        ></v-text-field>
+          prepend-inner-icon="far fa-calendar"
+          variant="outlined"
+          density="compact"
+        />
       </template>
+
       <v-date-picker
-        v-if="!withTime"
         :model-value="modelValue"
         :locale="i18n.locale.value"
-        @update:model-value="updateDate"
         no-title
         scrollable
-      >
-      </v-date-picker>
-      <v-date-picker
-        v-else
-        :model-value="modelValue"
-        :locale="i18n.locale.value"
         @update:model-value="updateDate"
-        no-title
-        scrollable
-        type="datetime"
-      >
-      </v-date-picker>
+      />
     </v-menu>
   </v-layout>
 </template>
 
 <script setup lang="ts">
-import { ref, computed, nextTick, watch } from 'vue'
+import { ref, computed, nextTick, watch, type PropType } from 'vue'
 import { useI18n } from 'vue-i18n'
 
 const props = defineProps({
   modelValue: Date,
-  label: String,
-  readOnly: {
-    type: Boolean,
-    default: false,
+  label: {
+    type: String,
+    default: '',
   },
   format: {
     type: String,
     default: null,
   },
-  withTime: {
-    type: Boolean,
-    default: false,
-  },
   /**
    * Position du date-picker
    * @see https://vuetifyjs.com/en/api/v-menu/#props-position
@@ -97,8 +84,6 @@ const displayDate = computed({
         year: 'numeric',
         month: '2-digit',
         day: '2-digit',
-        hour: props.withTime ? '2-digit' : undefined,
-        minute: props.withTime ? '2-digit' : undefined,
       }).format(props.modelValue)
     }
     return props.modelValue.toLocaleDateString(i18n.locale.value)
@@ -106,7 +91,7 @@ const displayDate = computed({
   set: () => {},
 })
 
-function updateDate(value) {
+function updateDate(value: Date) {
   emit('update:modelValue', value)
   menu.value = false
 }

+ 2 - 2
components/Ui/ExpansionPanel.vue

@@ -7,7 +7,7 @@ Panneaux déroulants de type "accordéon"
 <template>
   <v-expansion-panel :value="title">
     <v-expansion-panel-title color="neutral">
-      <template v-slot:default="{ expanded }">
+      <template #default>
         <v-icon class="theme-primary icon">
           {{ icon }}
         </v-icon>
@@ -22,7 +22,7 @@ Panneaux déroulants de type "accordéon"
 </template>
 
 <script setup lang="ts">
-const props = defineProps({
+defineProps({
   title: {
     type: String,
     required: true,

+ 20 - 22
components/Ui/Form.vue

@@ -34,9 +34,10 @@ de quitter si des données ont été modifiées.
           </v-col>
         </v-row>
       </v-container>
+      <div v-else class="mt-12" />
 
       <!-- Content -->
-      <slot v-bind="{ model, entity }" />
+      <slot v-bind="{ modelValue }" />
 
       <!-- Bottom action bar -->
       <v-container
@@ -72,17 +73,17 @@ de quitter si des données ont été modifiées.
 
       <template #dialogBtn>
         <div class="confirmation-dlg-actions">
-          <v-btn class="theme-primary" @click="closeConfirmationDialog">
+          <v-btn class="theme-neutral" @click="closeConfirmationDialog">
             {{ $t('cancel') }}
           </v-btn>
 
-          <v-btn class="theme-primary" @click="saveAndQuit">
-            {{ $t('save_and_quit') }}
-          </v-btn>
-
           <v-btn class="theme-danger" @click="cancel">
             {{ $t('quit_with_no_saving') }}
           </v-btn>
+
+          <v-btn class="theme-primary" @click="saveAndQuit">
+            {{ $t('save_and_quit') }}
+          </v-btn>
         </div>
       </template>
     </LazyLayoutDialog>
@@ -104,16 +105,9 @@ import { useRefreshProfile } from '~/composables/data/useRefreshProfile'
 
 const props = defineProps({
   /**
-   * Classe de l'ApiModel (ex: Organization, Notification, ...)
+   * Instance de l'ApiModel
    */
-  model: {
-    type: Function as any as () => typeof ApiModel,
-    required: true,
-  },
-  /**
-   * Instance de l'objet
-   */
-  entity: {
+  modelValue: {
     type: Object as () => ApiModel,
     required: true,
   },
@@ -161,7 +155,7 @@ const props = defineProps({
   actionPosition: {
     type: String as PropType<'top' | 'bottom' | 'both'>,
     required: false,
-    default: 'both',
+    default: 'bottom',
   },
 })
 
@@ -171,7 +165,6 @@ const i18n = useI18n()
 const router = useRouter()
 const { em } = useEntityManager()
 const { refreshProfile } = useRefreshProfile()
-const route = useRoute()
 
 // Le formulaire est-il valide
 const isValid: Ref<boolean> = ref(true)
@@ -209,6 +202,8 @@ const closeConfirmationDialog = () => {
   formStore.setShowConfirmToLeave(false)
 }
 
+const emit = defineEmits(['update:model-value'])
+
 // ### Actions du formulaire
 /**
  * Soumet le formulaire
@@ -231,8 +226,9 @@ const submit = async (next: string | null = null) => {
   try {
     usePageStore().loading = true
 
-    // TODO: est-ce qu'il faut re-fetch l'entité après le persist?
-    const updatedEntity = await em.persist(props.entity)
+    const updatedEntity = await em.persist(props.modelValue)
+
+    emit('update:model-value', updatedEntity)
 
     if (props.refreshProfile) {
       await refreshProfile()
@@ -343,7 +339,7 @@ const cancel = () => {
 
   formStore.setShowConfirmToLeave(false)
 
-  em.reset(props.model, props.entity)
+  em.reset(props.modelValue)
 
   if (requestedLeavingRoute.value !== null) {
     navigateTo(requestedLeavingRoute.value)
@@ -362,7 +358,7 @@ const actions = computed(() => {
  */
 const onFormChange = async () => {
   if (isValid.value) {
-    em.save(props.entity)
+    em.save(props.modelValue)
     setIsDirty(true)
 
     if (props.onChanged) {
@@ -385,7 +381,7 @@ const validate = async function () {
 }
 
 // #### Gestion de l'état dirty
-watch(props.entity, async (newEntity, oldEntity) => {
+watch(props.modelValue, async (newEntity, oldEntity) => {
   setIsDirty(true)
 })
 
@@ -425,6 +421,8 @@ defineExpose({ validate })
   min-width: 255px;
   max-width: 255px;
   margin: 0 8px;
+  font-size: 13px;
+  font-weight: 600;
 }
 
 @media (max-width: 960px) {

+ 1 - 1
components/Ui/Form/Creation.vue

@@ -1,5 +1,5 @@
 <template>
-  <UiForm :model="model" :entity="entity" :submitActions="submitActions">
+  <UiForm v-model="entity" :submitActions="submitActions">
     <template #form.button>
       <v-btn v-if="goBackRoute" class="theme-neutral mr-3" @click="quit">
         {{ $t('cancel') }}

+ 55 - 0
components/Ui/Form/DeletionConfirmationDialog.vue

@@ -0,0 +1,55 @@
+<template>
+  <LazyLayoutDialog :show="modelValue">
+    <template #dialogType>
+      {{ $t('delete_assistant') }}
+    </template>
+
+    <template #dialogTitle>
+      {{ $t('caution') }}
+    </template>
+
+    <template #dialogText>
+      <v-card-text>
+        <p>{{ $t('confirm_to_delete') }}</p>
+      </v-card-text>
+    </template>
+
+    <template #dialogBtn>
+      <v-btn
+        class="mr-4 submitBtn theme-neutral-strong"
+        @click="onCancelClicked"
+      >
+        {{ $t('cancel') }}
+      </v-btn>
+      <v-btn class="mr-4 submitBtn theme-danger" @click="onDeleteClicked">
+        {{ $t('delete') }}
+      </v-btn>
+    </template>
+  </LazyLayoutDialog>
+</template>
+
+<script setup lang="ts">
+const props = defineProps({
+  modelValue: {
+    type: Boolean,
+  },
+})
+
+const emit = defineEmits([
+  'cancelClicked',
+  'deleteClicked',
+  'update:modelValue',
+])
+
+const onCancelClicked = () => {
+  emit('cancelClicked')
+  emit('update:modelValue', false)
+}
+
+const onDeleteClicked = () => {
+  emit('deleteClicked')
+  emit('update:modelValue', false)
+}
+</script>
+
+<style scoped lang="scss"></style>

+ 1 - 6
components/Ui/Form/Edition.vue

@@ -1,12 +1,7 @@
 <template>
   <LayoutContainer>
     <UiLoadingPanel v-if="pending" />
-    <UiForm
-      v-else
-      :model="model"
-      :entity="entity"
-      :submitActions="submitActions"
-    >
+    <UiForm v-else v-model="entity" :submitActions="submitActions">
       <template #form.button>
         <v-btn v-if="goBackRoute" class="theme-neutral mr-3" @click="quit">
           {{ $t('cancel') }}

+ 4 - 4
components/Ui/Input/Autocomplete.vue

@@ -20,7 +20,7 @@ Liste déroulante avec autocompletion, à placer dans un composant `UiForm`
       :loading="isLoading"
       :return-object="returnObject"
       :search-input.sync="search"
-      :prepend-icon="prependIcon"
+      :prepend-inner-icon="prependInnerIcon"
       :error="error || !!fieldViolations"
       :error-messages="
         errorMessage || fieldViolations ? $t(fieldViolations) : ''
@@ -131,9 +131,9 @@ const props = defineProps({
   },
   /**
    * Icône de gauche
-   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-prepend-icon
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-prepend-inner-icon
    */
-  prependIcon: {
+  prependInnerIcon: {
     type: String,
   },
   /**
@@ -232,7 +232,7 @@ const props = defineProps({
       | undefined
     >,
     required: false,
-    default: 'filled',
+    default: 'outlined',
   },
 })
 

+ 65 - 68
components/Ui/Input/Autocomplete/Accesses.vue

@@ -1,5 +1,5 @@
 <!--
-Champs autocomplete dédié à la recherche des access d'une structure
+Champs autocomplete dédié à la recherche des Accesses d'une structure
 
 @see https://vuetifyjs.com/en/components/autocompletes/#usage
 -->
@@ -17,7 +17,7 @@ Champs autocomplete dédié à la recherche des access d'une structure
       hide-no-data
       :chips="chips"
       :auto-select-first="false"
-      prependIcon="fas fa-magnifying-glass"
+      prepend-inner-icon="fas fa-magnifying-glass"
       :return-object="false"
       :variant="variant"
       @update:model-value="onUpdateModelValue"
@@ -28,18 +28,23 @@ Champs autocomplete dédié à la recherche des access d'une structure
 
 <script setup lang="ts">
 import type { PropType } from '@vue/runtime-core'
-import { computed } from '@vue/reactivity'
 import type { ComputedRef, Ref } from '@vue/reactivity'
-import type { AnyJson, AssociativeArray } from '~/types/data'
+import { computed } from '@vue/reactivity'
+import type { 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'
+import Query from '~/services/data/Query'
+import OrderBy from '~/services/data/Filters/OrderBy'
+import { ORDER_BY_DIRECTION, SEARCH_STRATEGY } from '~/types/enum/data'
+import PageFilter from '~/services/data/Filters/PageFilter'
+import InArrayFilter from '~/services/data/Filters/InArrayFilter'
+import SearchFilter from '~/services/data/Filters/SearchFilter'
+import UserSearchItem from '~/models/Custom/Search/UserSearchItem'
 
 const props = defineProps({
   /**
-   * v-model
+   * v-model, ici les ids des Access sélectionnés
    */
   modelValue: {
     type: [Object, Array],
@@ -48,6 +53,7 @@ const props = defineProps({
   },
   /**
    * Filtres à transmettre à la source de données
+   * TODO: voir si à adapter maintenant que les filtres sont des objets Query
    */
   filters: {
     type: Object as PropType<Ref<AssociativeArray>>,
@@ -119,108 +125,99 @@ const props = defineProps({
       | undefined
     >,
     required: false,
-    default: 'filled',
+    default: 'outlined',
   },
 })
 
 /**
  * Element de la liste autocomplete
  */
-interface AccessListItem {
+interface UserListItem {
   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
+ * @param userSearchItem
  */
-const accessToItem = (access: Access): AccessListItem => {
+const accessToItem = (userSearchItem: UserSearchItem): UserListItem => {
   return {
-    id: access.id,
-    title: access.person
-      ? `${access.person.name} ${access.person.givenName}`
-      : i18n.t('unknown'),
+    id: userSearchItem.id,
+    title: userSearchItem.fullName
+      ? userSearchItem.fullName
+      : `(${i18n.t('missing_name')})`,
   }
 }
 
-const initialized: Ref<boolean> = ref(false)
-
 /**
  * Saisie de l'utilisateur utilisée pour filtrer la recherche
  */
 const nameFilter: Ref<string | null> = ref(null)
 
-/**
- * Query transmise à l'API lors des changements de filtre de recherche
- */
-const query: ComputedRef<AnyJson> = computed(() => {
-  let q: AnyJson = { 'groups[]': 'access_people_ref', 'order[name]': 'asc' }
-
-  if (!initialized.value && props.modelValue) {
-    if (Array.isArray(props.modelValue) && props.modelValue.length > 0) {
-      q['id[in]'] = props.modelValue.join(',')
-    } else {
-      q['id[in]'] = props.modelValue
-    }
-    return q
+const activeIds = computed(() => {
+  if (Array.isArray(props.modelValue)) {
+    return props.modelValue
   }
-
-  if (nameFilter.value) {
-    q['fullname'] = nameFilter.value
+  if (props.modelValue !== null && typeof props.modelValue === 'object') {
+    return [props.modelValue.id]
   }
-
-  return q
+  return []
 })
 
+const queryActive = new Query(
+  new OrderBy('fullName', ORDER_BY_DIRECTION.ASC),
+  new PageFilter(ref(1), ref(20)),
+  new InArrayFilter('id', activeIds),
+)
+
+const { data: collectionActive, pending: pendingActive } = fetchCollection(
+  UserSearchItem,
+  null,
+  queryActive,
+)
+
+/**
+ * Query transmise à l'API lors des changements de filtre de recherche
+ */
+const querySearch = new Query(
+  new OrderBy('fullName', ORDER_BY_DIRECTION.ASC),
+  new PageFilter(ref(1), ref(20)),
+  new SearchFilter('fullName', nameFilter, SEARCH_STRATEGY.IPARTIAL),
+)
+
 /**
  * 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
+  data: collectionSearch,
+  pending: pendingSearch,
+  refresh: refreshSearch,
+} = fetchCollection(UserSearchItem, null, querySearch)
 
-// On a déjà récupéré les access actifs, on relance une requête pour récupérer la première page
-// des accesses suivants
-refresh()
+const pending = computed(() => pendingSearch.value || pendingActive.value)
 
 /**
  * Contenu de la liste autocomplete
  */
-const items: ComputedRef<Array<AccessListItem>> = computed(() => {
-  if (pending.value || !collection.value) {
+const items: ComputedRef<Array<UserListItem>> = computed(() => {
+  if (pending.value || !(collectionActive.value && collectionSearch.value)) {
     return []
   }
 
-  if (!collection.value) {
-    return []
-  }
-
-  //@ts-ignore
-  const fetchedItems = collection.value.items.map(accessToItem)
-
-  // move the active items to the top and sort by name
-  fetchedItems.sort((a, b) => {
-    if (props.modelValue.includes(a.id) && !props.modelValue.includes(b.id)) {
-      return -1
-    } else if (
-      !props.modelValue.includes(a.id) &&
-      props.modelValue.includes(b.id)
-    ) {
-      return 1
-    } else {
-      return a.title.localeCompare(b.title)
-    }
-  })
+  const activeItems: UserListItem[] =
+    collectionActive.value.items.map(accessToItem)
+  const searchedItems: UserListItem[] = collectionSearch.value.items
+    .map(accessToItem)
+    .filter(
+      (item) =>
+        !collectionActive.value!.items.find((other) => other.id === item.id),
+    )
 
-  return fetchedItems
+  return activeItems.concat(searchedItems)
 })
 
 /**
@@ -233,7 +230,7 @@ const inputDelay = 600
  * @see https://docs-lodash.com/v4/debounce/
  */
 const refreshDebounced: _.DebouncedFunc<() => void> = _.debounce(async () => {
-  await refresh()
+  await refreshSearch()
 }, inputDelay)
 
 // ### Events

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

@@ -53,7 +53,7 @@ const props = defineProps({
       | undefined
     >,
     required: false,
-    default: 'filled',
+    default: 'outlined',
   },
 })
 

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

@@ -23,16 +23,17 @@ Sélecteur de dates, à placer dans un composant `UiForm`
 </template>
 
 <script setup lang="ts">
-import { useFieldViolation } from '~/composables/form/useFieldViolation'
 import { formatISO } from 'date-fns'
-import type { PropType } from '@vue/runtime-core'
+import type { PropType, Ref } from 'vue'
+import { ref } from 'vue'
+import { useFieldViolation } from '~/composables/form/useFieldViolation'
 
 const props = defineProps({
   /**
    * v-model
    */
   modelValue: {
-    type: String as PropType<string | null>,
+    type: String as PropType<Date | string | null>,
     required: false,
     default: null,
   },
@@ -105,19 +106,19 @@ const props = defineProps({
   },
 })
 
-const input = ref(null)
-
 const { fieldViolations, updateViolationState } = useFieldViolation(props.field)
 
 const fieldLabel = props.label ?? props.field
 
 const emit = defineEmits(['update:model-value', 'change'])
 
-const date: Ref<Date> = ref(new Date(props.modelValue))
+const date: Ref<Date | undefined> = ref(
+  props.modelValue ? new Date(props.modelValue) : undefined,
+)
 
 const onUpdate = (event: string) => {
   updateViolationState(event)
-  emit('update:model-value', formatISO(date.value))
+  emit('update:model-value', date.value ? formatISO(date.value) : undefined)
 }
 </script>
 

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

@@ -18,6 +18,7 @@ Champs de saisie de type Text dédié à la saisie d'emails
 import { useNuxtApp } from '#app'
 import { useFieldViolation } from '~/composables/form/useFieldViolation'
 import { useValidationUtils } from '~/composables/utils/useValidationUtils'
+import type { PropType } from '@vue/runtime-core'
 
 const props = defineProps({
   label: {
@@ -54,6 +55,23 @@ const props = defineProps({
     required: false,
     default: null,
   },
+  /**
+   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-variant
+   */
+  variant: {
+    type: String as PropType<
+      | 'filled'
+      | 'outlined'
+      | 'plain'
+      | 'underlined'
+      | 'solo'
+      | 'solo-inverted'
+      | 'solo-filled'
+      | undefined
+    >,
+    required: false,
+    default: 'outlined',
+  },
 })
 
 const { emit, i18n } = useNuxtApp()

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

@@ -95,7 +95,7 @@ import { useEntityManager } from '~/composables/data/useEntityManager'
 import { useImageManager } from '~/composables/data/useImageManager'
 import { FILE_VISIBILITY, TYPE_ALERT } from '~/types/enum/enums'
 import { usePageStore } from '~/stores/page'
-import ImageUtils from '~/services/utils/imageUtils'
+import FileUtils from '~/services/utils/fileUtils'
 
 const props = defineProps({
   /**
@@ -312,7 +312,7 @@ const uploadImage = async (event: any) => {
   currentImage.value.id = null
   currentImage.value.name = uploadedFile.name
   currentImage.value.src = URL.createObjectURL(uploadedFile)
-  currentImage.value.content = await ImageUtils.blobToBase64(uploadedFile)
+  currentImage.value.content = await FileUtils.blobToBase64(uploadedFile)
 
   // Met à jour la configuration du cropper
   cropperConfig.value.top = 0

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

@@ -80,7 +80,7 @@ const props = defineProps({
       | undefined
     >,
     required: false,
-    default: 'filled',
+    default: 'outlined',
   },
 })
 

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

@@ -125,7 +125,7 @@ const props = defineProps({
       | undefined
     >,
     required: false,
-    default: 'filled',
+    default: 'outlined',
   },
 })
 

+ 0 - 0
components/Ui/LoadingPanel.vue → components/Ui/LoadingPanel.client.vue


+ 2 - 0
components/Ui/SystemBar.vue

@@ -8,8 +8,10 @@ System bars
     :class="
       'd-flex flex-row justify-center align-center text-center ' + classes
     "
+    style="z-index: 1006"
     @click="onClick !== undefined ? onClick() : null"
   >
+    <!-- Forcing z-index to avoid this : https://github.com/vuetifyjs/nuxt-module/issues/205 -->
     <slot>
       <v-icon v-if="icon" small :icon="icon" />
       {{ text }}

+ 116 - 0
composables/data/useApiLegacyRequestService.ts

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

+ 23 - 0
composables/form/useDeleteItem.ts

@@ -0,0 +1,23 @@
+import type ApiResource from '~/models/ApiResource'
+import { usePageStore } from '~/stores/page'
+import { TYPE_ALERT } from '~/types/enum/enums'
+import { useEntityManager } from '~/composables/data/useEntityManager'
+
+export function useDeleteItem() {
+  async function deleteItem(item: ApiResource) {
+    const { em } = useEntityManager()
+
+    try {
+      await em.delete(item)
+      usePageStore().addAlert(TYPE_ALERT.SUCCESS, ['deleteSuccess'])
+    } catch (error) {
+      // @ts-expect-error error is supposed to have a message prop
+      usePageStore().addAlert(TYPE_ALERT.ALERT, [error.message])
+      throw error
+    }
+  }
+
+  return {
+    deleteItem,
+  }
+}

+ 24 - 0
composables/utils/useDownloadFromRoute.ts

@@ -0,0 +1,24 @@
+import FileSaver from 'file-saver'
+import { useAp2iRequestService } from '~/composables/data/useAp2iRequestService'
+import File from '~/models/Core/File'
+
+/**
+ * Permet de télécharger un fichier fourni par la route donnée
+ * @param route
+ * @param filename
+ */
+export const useDownloadFromRoute = async (route: string, filename: string) => {
+  const { apiRequestService } = useAp2iRequestService()
+
+  // @ts-expect-error La méthode get renvoie bien un blob dans ce cas là
+  const response = (await apiRequestService.get(route)) as Blob
+
+  if (!response || response.size === 0) {
+    console.error('Error: no file found at ' + route)
+  }
+
+  const blob = new Blob([response], { type: response.type })
+
+  // eslint-disable-next-line import/no-named-as-default-member
+  FileSaver.saveAs(blob, filename)
+}

+ 12 - 0
composables/utils/useRouteUtils.ts

@@ -0,0 +1,12 @@
+export const useRouteUtils = () => {
+  const route = useRoute()
+
+  const getIdFromRoute = (): number => {
+    if (!route.params.id || !/\d+/.test(route.params.id as string)) {
+      throw new Error('No id found in route')
+    }
+    return parseInt(route.params.id as string)
+  }
+
+  return { getIdFromRoute }
+}

+ 8 - 0
config/theme.ts

@@ -34,6 +34,8 @@ interface Theme {
     'on-warning': string
     info: string
     'on-info': string
+    artist: string
+    school: string
 
     // Special cases
     // TODO: voir ceux dont on peut se passer
@@ -91,6 +93,9 @@ export const lightTheme: Theme = {
 
     'x-create-btn': '#f39c12',
     'on-x-create-btn': '#ffffff',
+
+    artist: '#fac20a',
+    school: '#1893bf',
   },
 }
 
@@ -143,5 +148,8 @@ export const darkTheme: Theme = {
 
     'x-create-btn': '#f39c12',
     'on-x-create-btn': '#ffffff',
+
+    artist: '#fac20a',
+    school: '#1893bf',
   },
 }

+ 4 - 0
env/.env.ci

@@ -32,3 +32,7 @@ MERCURE_SUBSCRIBER_JWT_KEY=
 # Other links
 NUXT_SUPPORT_URL=
 NUXT_PUBLIC_SUPPORT_URL=
+
+# Basicompta
+NUXT_BASICOMPTA_URL=
+NUXT_PUBLIC_BASICOMPTA_URL=

+ 4 - 0
env/.env.docker

@@ -35,3 +35,7 @@ NUXT_PUBLIC_SUPPORT_URL=https://support.opentalent.fr/
 
 NUXT_FILE_STORAGE_BASE_URL=https://local.maestro.opentalent.fr/uploads/share
 NUXT_PUBLIC_FILE_STORAGE_BASE_URL=https://local.maestro.opentalent.fr/uploads/share
+
+# Basicompta
+NUXT_BASICOMPTA_URL=https://app.basicompta.fr/
+NUXT_PUBLIC_BASICOMPTA_URL=https://app.basicompta.fr/

+ 4 - 0
env/.env.prod

@@ -36,3 +36,7 @@ NUXT_PUBLIC_SUPPORT_URL=https://support.opentalent.fr/
 
 NUXT_FILE_STORAGE_BASE_URL=https://maestro.opentalent.fr/uploads/share
 NUXT_PUBLIC_FILE_STORAGE_BASE_URL=https://maestro.opentalent.fr/uploads/share
+
+# Basicompta
+NUXT_BASICOMPTA_URL=https://app.basicompta.fr/
+NUXT_PUBLIC_BASICOMPTA_URL=https://app.basicompta.fr/

+ 4 - 0
env/.env.test

@@ -36,3 +36,7 @@ NUXT_PUBLIC_SUPPORT_URL=https://support.opentalent.fr/
 
 NUXT_FILE_STORAGE_BASE_URL=https://maestro.test.opentalent.fr/uploads/share
 NUXT_PUBLIC_FILE_STORAGE_BASE_URL=https://maestro.test.opentalent.fr/uploads/share
+
+# Basicompta
+NUXT_BASICOMPTA_URL=https://app.basicompta.fr/
+NUXT_PUBLIC_BASICOMPTA_URL=https://app.basicompta.fr/

+ 4 - 0
env/.env.test1

@@ -36,3 +36,7 @@ NUXT_PUBLIC_SUPPORT_URL=https://support.opentalent.fr/
 
 NUXT_FILE_STORAGE_BASE_URL=https://maestro.test1.opentalent.fr/uploads/share
 NUXT_PUBLIC_FILE_STORAGE_BASE_URL=https://maestro.test1.opentalent.fr/uploads/share
+
+# Basicompta
+NUXT_BASICOMPTA_URL=https://app.basicompta.fr/
+NUXT_PUBLIC_BASICOMPTA_URL=https://app.basicompta.fr/

+ 4 - 0
env/.env.test2

@@ -36,3 +36,7 @@ NUXT_PUBLIC_SUPPORT_URL=https://support.opentalent.fr/
 
 NUXT_FILE_STORAGE_BASE_URL=https://maestro.test2.opentalent.fr/uploads/share
 NUXT_PUBLIC_FILE_STORAGE_BASE_URL=https://maestro.test2.opentalent.fr/uploads/share
+
+# Basicompta
+NUXT_BASICOMPTA_URL=https://app.basicompta.fr/
+NUXT_PUBLIC_BASICOMPTA_URL=https://app.basicompta.fr/

+ 4 - 0
env/.env.test3

@@ -36,3 +36,7 @@ NUXT_PUBLIC_SUPPORT_URL=https://support.opentalent.fr/
 
 NUXT_FILE_STORAGE_BASE_URL=https://maestro.test3.opentalent.fr/uploads/share
 NUXT_PUBLIC_FILE_STORAGE_BASE_URL=https://maestro.test3.opentalent.fr/uploads/share
+
+# Basicompta
+NUXT_BASICOMPTA_URL=https://app.basicompta.fr/
+NUXT_PUBLIC_BASICOMPTA_URL=https://app.basicompta.fr/

+ 4 - 0
env/.env.test4

@@ -36,3 +36,7 @@ NUXT_PUBLIC_SUPPORT_URL=https://support.opentalent.fr/
 
 NUXT_FILE_STORAGE_BASE_URL=https://maestro.test4.opentalent.fr/uploads/share
 NUXT_PUBLIC_FILE_STORAGE_BASE_URL=https://maestro.test4.opentalent.fr/uploads/share
+
+# Basicompta
+NUXT_BASICOMPTA_URL=https://app.basicompta.fr/
+NUXT_PUBLIC_BASICOMPTA_URL=https://app.basicompta.fr/

+ 4 - 0
env/.env.test5

@@ -36,3 +36,7 @@ NUXT_PUBLIC_SUPPORT_URL=https://support.opentalent.fr/
 
 NUXT_FILE_STORAGE_BASE_URL=https://maestro.test5.opentalent.fr/uploads/share
 NUXT_PUBLIC_FILE_STORAGE_BASE_URL=https://maestro.test5.opentalent.fr/uploads/share
+
+# Basicompta
+NUXT_BASICOMPTA_URL=https://app.basicompta.fr/
+NUXT_PUBLIC_BASICOMPTA_URL=https://app.basicompta.fr/

+ 4 - 0
env/.env.test6

@@ -36,3 +36,7 @@ NUXT_PUBLIC_SUPPORT_URL=https://support.opentalent.fr/
 
 NUXT_FILE_STORAGE_BASE_URL=https://maestro.test6.opentalent.fr/uploads/share
 NUXT_PUBLIC_FILE_STORAGE_BASE_URL=https://maestro.test6.opentalent.fr/uploads/share
+
+# Basicompta
+NUXT_BASICOMPTA_URL=https://app.basicompta.fr/
+NUXT_PUBLIC_BASICOMPTA_URL=https://app.basicompta.fr/

+ 4 - 0
env/.env.test7

@@ -36,3 +36,7 @@ NUXT_PUBLIC_SUPPORT_URL=https://support.opentalent.fr/
 
 NUXT_FILE_STORAGE_BASE_URL=https://maestro.test7.opentalent.fr/uploads/share
 NUXT_PUBLIC_FILE_STORAGE_BASE_URL=https://maestro.test7.opentalent.fr/uploads/share
+
+# Basicompta
+NUXT_BASICOMPTA_URL=https://app.basicompta.fr/
+NUXT_PUBLIC_BASICOMPTA_URL=https://app.basicompta.fr/

+ 4 - 0
env/.env.test8

@@ -36,3 +36,7 @@ NUXT_PUBLIC_SUPPORT_URL=https://support.opentalent.fr/
 
 NUXT_FILE_STORAGE_BASE_URL=https://maestro.test8.opentalent.fr/uploads/share
 NUXT_PUBLIC_FILE_STORAGE_BASE_URL=https://maestro.test8.opentalent.fr/uploads/share
+
+# Basicompta
+NUXT_BASICOMPTA_URL=https://app.basicompta.fr/
+NUXT_PUBLIC_BASICOMPTA_URL=https://app.basicompta.fr/

+ 4 - 0
env/.env.test9

@@ -36,3 +36,7 @@ NUXT_PUBLIC_SUPPORT_URL=https://support.opentalent.fr/
 
 NUXT_FILE_STORAGE_BASE_URL=https://maestro.test9.opentalent.fr/uploads/share
 NUXT_PUBLIC_FILE_STORAGE_BASE_URL=https://maestro.test9.opentalent.fr/uploads/share
+
+# Basicompta
+NUXT_BASICOMPTA_URL=https://app.basicompta.fr/
+NUXT_PUBLIC_BASICOMPTA_URL=https://app.basicompta.fr/

+ 86 - 0
eslint.config.mjs

@@ -0,0 +1,86 @@
+import vue from 'eslint-plugin-vue'
+import typescriptEslint from '@typescript-eslint/eslint-plugin'
+import globals from 'globals'
+import parser from 'vue-eslint-parser'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+import js from '@eslint/js'
+import { FlatCompat } from '@eslint/eslintrc'
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = path.dirname(__filename)
+const compat = new FlatCompat({
+  baseDirectory: __dirname,
+  recommendedConfig: js.configs.recommended,
+  allConfig: js.configs.all,
+})
+
+export default [
+  {
+    ignores: [
+      '**/.nuxt',
+      'coverage/*',
+      'vendor/*',
+      'dist/*',
+      'models/models.ts',
+    ],
+  },
+  ...compat.extends(
+    '@nuxtjs/eslint-config-typescript',
+    'plugin:nuxt/recommended',
+    'eslint:recommended',
+    'plugin:@typescript-eslint/recommended',
+    'plugin:vue/vue3-recommended',
+    'plugin:prettier/recommended',
+    'plugin:you-dont-need-lodash-underscore/compatible',
+  ),
+  {
+    plugins: {
+      vue,
+      '@typescript-eslint': typescriptEslint,
+    },
+
+    languageOptions: {
+      globals: {
+        ...globals.browser,
+        ...globals.node,
+        useRuntimeConfig: 'readonly',
+        useAsyncData: 'readonly',
+        navigateTo: 'readonly',
+        computed: 'readonly',
+        ref: 'readonly',
+        definePageMeta: 'readonly',
+        useRouter: 'readonly',
+        useRoute: 'readonly',
+        useI18n: 'readonly',
+        onMounted: 'readonly',
+        onUnmounted: 'readonly',
+        watch: 'readonly',
+        useRepo: 'readonly',
+      },
+
+      parser: parser,
+      ecmaVersion: 2020,
+      sourceType: 'module',
+
+      parserOptions: {
+        parser: '@typescript-eslint/parser',
+        tsconfigRootDir: '/home/workspace',
+      },
+    },
+
+    rules: {
+      'no-console': 0,
+
+      'vue/valid-v-slot': [
+        'error',
+        {
+          allowModifiers: true,
+        },
+      ],
+
+      'vue/multi-word-component-names': 0,
+      '@typescript-eslint/no-inferrable-types': 0,
+    },
+  },
+]

+ 0 - 0
i18n.config.ts → i18n/i18n.config.ts


+ 0 - 0
lang/en.json → i18n/lang/en.json


+ 41 - 6
lang/fr.json → i18n/lang/fr.json

@@ -1,4 +1,16 @@
 {
+  "i_subscribe": "Je m'abonne",
+  "price_include_cmf": "Inclus avec votre adhésion CMF",
+  "artist": "Artist Standard",
+  "trial_started": "Sur votre période d'essai.\nSouscrire à l'offre Premium.",
+  "important": "Important",
+  "trial_all_ready_did": "Vous avez déjà bénéficié d’un essai gratuit !",
+  "opentalent_contact": "Contacter Opentalent",
+  "discover_offer": "Découvrir toutes les offres",
+  "try_premium": "Essayer Opentalent Artist Premium gratuitement pendant 30J",
+  "opentalent_options": "Les options Opentalent",
+  "opentalent_offers": "Les offres Opentalent",
+  "service_detail": "Détail des services",
   "my_settings_page": "Mes paramètres",
   "allow_report_message": "Je souhaite recevoir les rapports d'envoi des emails que j'envoie",
   "my-settings_breadcrumbs": "Mes paramètres",
@@ -117,6 +129,7 @@
   "BIG_BAND": "Big band",
   "PRODUCT_ARTIST": "Opentalent Artist",
   "PRODUCT_ARTIST_PREMIUM": "Opentalent Artist Premium",
+  "PRODUCT_ARTIST_PREMIUM_TRIAL": "Opentalent Artist Premium (Essai)",
   "PRODUCT_SCHOOL": "Opentalent School",
   "PRODUCT_SCHOOL_PREMIUM": "Opentalent School Premium",
   "PRODUCT_MANAGER": "Opentalent Manager",
@@ -186,7 +199,7 @@
   "educationTiming": "Durée d'un enseignement (en minutes)",
   "new_education_timings": "Nouvelle durée d'enseignement",
   "superAdmin": "Compte super-admin",
-  "username": "Login de connexion",
+  "username": "Nom d'utilisateur",
   "residenceArea": "Zones de résidence",
   "deactivateOpentalentSiteWeb": "Désactiver le site opentalent",
   "reactivateOpentalentSiteWeb": "Réactiver le site Opentalent",
@@ -529,7 +542,7 @@
   "informations": "Informations",
   "more_features": "Plus de fonctionnalités",
   "client_id": "Numéro de client",
-  "version": "Version",
+  "version": "Version logiciel",
   "services": "Services",
   "bills": "Factures",
   "paid": "Payée",
@@ -683,9 +696,9 @@
   "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",
+  "Indian/Reunion": "Indian/Reunion",
+  "Europe/Zurich": "Europe/Zurich",
+  "Europe/Paris": "Europe/Paris",
   "licenceQrCode": "QrCode pour la licence",
   "parameters_education_timings_page": "Durée des cours",
   "education_timings_breadcrumbs": "Durée des cours",
@@ -701,5 +714,27 @@
   "no_admin_access_recorded": "Aucun compte super-admin enregistré",
   "redirecting": "Redirection en cours",
   "Invalid profile hash": "Le profil de l'utilisateur a été modifié ailleurs, veuillez rafraichir la page et réessayer.",
-  "An error occured": "Une erreur s'est produite."
+  "An error occured": "Une erreur s'est produite.",
+  "teachers": "Professeurs",
+  "pupils-members": "Élèves / Adhérents / Membres",
+  "id": "Id",
+  "missing_name": "Nom manquant",
+  "warning": "Avertissement",
+  "please_enter_a_value_for_the_sms_sender_name": "Le nom d'expediteur ne doit pas comporter plus de 11 caractères, et être composé uniquement de chiffres et/ou de lettres.",
+  "associated_email": "Adresse Email associée",
+  "An error occured": "Une erreur s'est produite.",
+  "you_want_to_stop_your_premium_trial_period": "Vous souhaitez arrêter votre période d’essai Opentalent Artist Premium",
+  "stop_trial_period_warning_1a": "En choisissant d’arrêter votre période d'essai, votre compte reviendra automatiquement à la version",
+  "stop_trial_period_warning_1b": "sans perte de vos données essentielles.",
+  "stop_trial_period_warning_2": "Que se passe-t-il si vous arrêtez votre période d’essai ?",
+  "stop_trial_period_warning_3": "Les fonctionnalités premium de l’essai ne seront plus accessibles.",
+  "stop_trial_period_warning_4": "Vos données et configurations Premium sont conservées pendant 30 jours.",
+  "stop_trial_period_warning_5": "Vous pourrez toujours gérer vos activités grâce aux fonctionnalités de la version de base.",
+  "stop_trial_period_warning_6": "Si vous souhaitez continuer à profiter des avantages complets d’Opentalent Artist Premium, vous pouvez souscrire à une licence à tout moment.",
+  "stop_trial_missing_version_label": "précédente",
+  "stop_trial": "Arrêter l'essai",
+  "trial_ongoing": "En cours d'essai",
+  "try_premium_version": "Essayer la version premium",
+  "subscribe_to_the_offer": "Souscrire à l'offre",
+  "to_know_more": "En savoir plus"
 }

+ 0 - 0
lang/fr.json.removed → i18n/lang/fr.json.removed


+ 19 - 1
layouts/default.vue

@@ -8,7 +8,14 @@
 
       <LayoutHeader />
 
-      <LayoutMainMenu />
+      <LayoutMainMenu>
+        <template #prepend="{ isRail }">
+          <LayoutUpgradePremiumButton
+            v-if="showUpgradePremiumButton"
+            :minimized="isRail"
+          />
+        </template>
+      </LayoutMainMenu>
 
       <v-main class="main">
         <LayoutSubheader />
@@ -29,4 +36,15 @@ import { useLayoutStore } from '~/stores/layout'
 
 const layoutStore = useLayoutStore()
 layoutStore.name = 'default'
+
+const accessProfile = useAccessProfileStore()
+const organizationProfile = useOrganizationProfileStore()
+
+const showUpgradePremiumButton: ComputedRef<boolean> = computed(
+  () =>
+    ((organizationProfile.isArtistProduct ||
+      organizationProfile.isTrialActive) &&
+      (accessProfile.isCaMember || accessProfile.isAdmin)) ??
+    false,
+)
 </script>

+ 8 - 8
middleware/routing.global.ts

@@ -16,14 +16,14 @@ export default defineNuxtRouteMiddleware((to, _) => {
     const name: string = routeName?.toString() ?? ''
 
     // <<- TODO: remove after 2.5 release
-    // const runtimeConfig = useRuntimeConfig()
-    // if (
-    //   runtimeConfig.public.env === 'production' &&
-    //   (name === 'cmf_licence_page' || name === 'parameters_page')
-    // ) {
-    //   const { redirectToHome } = useRedirect()
-    //   redirectToHome()
-    // }
+    const runtimeConfig = useRuntimeConfig()
+    if (
+      runtimeConfig.public.env === 'production' &&
+      (name === 'cmf_licence_page' || name === 'parameters_page')
+    ) {
+      const { redirectToHome } = useRedirect()
+      redirectToHome()
+    }
     // ->>
 
     if (

+ 30 - 0
models/Custom/Search/UserSearchItem.ts

@@ -0,0 +1,30 @@
+import { Num, Uid, Attr, Str } from 'pinia-orm/dist/decorators'
+import type { Historical } from '~/types/interfaces'
+import Person from '~/models/Person/Person'
+import ApiModel from '~/models/ApiModel'
+import { IriEncoded } from '~/models/decorators'
+import Organization from '~/models/Organization/Organization'
+
+/**
+ * AP2i Model : UserSearchItem
+ *
+ * @see https://gitlab.2iopenservice.com/opentalent/ap2i/-/blob/develop/src/Entity/Custom/Search/UserSearchItem.php
+ */
+export default class UserSearchItem extends ApiModel {
+  static override entity = 'search/users'
+
+  @Uid()
+  declare id: number | string
+
+  @Str('')
+  declare username: string
+
+  @Str('')
+  declare name: string
+
+  @Str('')
+  declare givenName: string
+
+  @Str('')
+  declare fullName: string
+}

+ 3 - 0
models/Organization/OrganizationProfile.ts

@@ -41,4 +41,7 @@ export default class OrganizationProfile extends ApiResource {
 
   @Num(null)
   declare parametersId: null
+
+  @Str(null)
+  declare principalType: string
 }

+ 18 - 2
nuxt.config.ts

@@ -31,11 +31,13 @@ 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: '',
@@ -46,7 +48,7 @@ export default defineNuxtConfig({
     baseUrlMercure: '',
     fileStorageBaseUrl: '',
     supportUrl: '',
-    basicomptaUrl: 'https://app.basicompta.fr/',
+    basicomptaUrl: '',
     // Config within public will be also exposed to the client
     public: {
       env: '',
@@ -57,12 +59,14 @@ export default defineNuxtConfig({
       baseUrlMercure: '',
       fileStorageBaseUrl: '',
       supportUrl: '',
-      basicomptaUrl: 'https://app.basicompta.fr/',
+      basicomptaUrl: '',
     },
   },
+
   hooks: {
     'builder:watch': console.log,
   },
+
   app: {
     head: {
       title: 'Opentalent',
@@ -122,15 +126,18 @@ export default defineNuxtConfig({
       ],
     },
   },
+
   css: [
     '@/assets/css/global.scss',
     '@/assets/css/theme.scss',
     '@/assets/css/import.scss',
     '@vuepic/vue-datepicker/dist/main.css',
   ],
+
   typescript: {
     strict: true,
   },
+
   modules: [
     // eslint-disable-next-line require-await
     async (_, nuxt) => {
@@ -165,6 +172,7 @@ export default defineNuxtConfig({
     'nuxt-prepare',
     'nuxt-vitalizer',
   ],
+
   vite: {
     esbuild: {
       drop: process.env.DEBUG ? [] : ['console', 'debugger'],
@@ -188,12 +196,14 @@ export default defineNuxtConfig({
       },
     },
   },
+
   // 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',
     lazy: true,
@@ -216,14 +226,20 @@ export default defineNuxtConfig({
     detectBrowserLanguage: false,
     vueI18n: './i18n.config.ts',
   },
+
   image: {
     provider: 'none',
   },
+
   build: {
     transpile,
   },
+
   ignore: [process.env.NUXT_ENV === 'prod' ? 'pages/dev/*' : ''],
+
   prepare: {
     scripts: ['prepare/buildIndex.ts'],
   },
+
+  compatibilityDate: '2025-03-10',
 })

+ 41 - 40
package.json

@@ -24,42 +24,41 @@
     "prettier-fix": "yarn prettier . --write"
   },
   "dependencies": {
-    "@casl/ability": "^6.7.1",
+    "@casl/ability": "^6.7.3",
     "@casl/vue": "2.2.2",
-    "@fortawesome/fontawesome-free": "^6.5.1",
-    "@mdi/font": "^7.3.67",
-    "@nuxt/image": "1.7.0",
-    "@nuxtjs/i18n": "^8.3.1",
-    "@pinia-orm/nuxt": "^1.7.0",
-    "@pinia/nuxt": "0.5.1",
+    "@fortawesome/fontawesome-free": "^6.7.2",
+    "@mdi/font": "^7.4.47",
+    "@nuxt/image": "1.9.0",
+    "@nuxtjs/i18n": "^9.1.3",
+    "@pinia-orm/nuxt": "^1.10.1",
+    "@pinia/nuxt": "^0.5.1",
     "@vuepic/vue-datepicker": "^7.4.0",
     "cleave.js": "^1.6.0",
-    "date-fns": "^2.30.0",
-    "eslint-import-resolver-typescript": "^3.6.1",
+    "date-fns": "^4.1.0",
     "event-source-polyfill": "^1.0.31",
     "file-saver": "^2.0.5",
-    "glob": "^10.4.2",
+    "glob": "^11.0.1",
     "js-yaml": "^4.1.0",
-    "libphonenumber-js": "1.10.51",
+    "libphonenumber-js": "1.11.18",
     "lodash": "^4.17.21",
     "lodash-es": "^4.17.21",
-    "nuxt": "^3.11.2",
-    "nuxt-prepare": "^2.1.0",
+    "nuxt": "^3.15.4",
+    "nuxt-prepare": "^2.3.1",
     "nuxt-vitalizer": "^0.10.0",
-    "pinia": "^2.1.7",
-    "pinia-orm": "^1.7.2",
+    "pinia": "^2.3.1",
+    "pinia-orm": "^1.10.1",
     "sass": "^1.69.5",
     "uuid": "^9.0.1",
-    "vite-plugin-vuetify": "^1.0.2",
-    "vue-advanced-cropper": "^2.8.8",
+    "vite-plugin-vuetify": "^2.0.4",
+    "vue-advanced-cropper": "^2.8.9",
     "vue-tel-input-vuetify": "^1.5.3",
     "vue-the-mask": "^0.11.1",
-    "vuetify": "3.4.6",
-    "yaml-import": "^2.0.0"
+    "vuetify": "3.6.14",
+    "yaml-import": "^3.0.0"
   },
   "devDependencies": {
-    "@nuxt/devtools": "^1.2.0",
-    "@nuxt/test-utils": "^3.12.1",
+    "@nuxt/devtools": "^1.7.0",
+    "@nuxt/test-utils": "^3.15.4",
     "@nuxt/test-utils-edge": "3.8.0-28284309.b3d3d7f4",
     "@nuxtjs/eslint-config": "^12.0.0",
     "@nuxtjs/eslint-config-typescript": "^12.1.0",
@@ -67,29 +66,31 @@
     "@types/cleave.js": "^1.4.12",
     "@types/event-source-polyfill": "^1.0.5",
     "@types/file-saver": "^2.0.7",
-    "@types/jest": "^29.5.11",
+    "@types/jest": "^29.5.14",
     "@types/js-yaml": "^4.0.9",
     "@types/lodash": "^4.14.202",
     "@types/lodash-es": "^4.17.12",
-    "@types/uuid": "^9.0.7",
-    "@types/vue-the-mask": "^0.11.1",
-    "@typescript-eslint/eslint-plugin": "^7.8.0",
-    "@typescript-eslint/parser": "^7.8.0",
-    "@vitejs/plugin-vue": "^4.5.2",
-    "@vitest/coverage-v8": "1.0.2",
-    "@vue/eslint-config-standard": "^8.0.1",
-    "@vue/test-utils": "^2.4.5",
-    "blob-polyfill": "^7.0.20220408",
-    "eslint": "^8.55.0",
-    "eslint-config-prettier": "^9.1.0",
+    "@types/uuid": "^10.0.0",
+    "@types/vue-the-mask": "^0.11.5",
+    "@typescript-eslint/eslint-plugin": "^8.22.0",
+    "@typescript-eslint/parser": "^8.22.0",
+    "@vitejs/plugin-vue": "^5.2.1",
+    "@vitest/coverage-v8": "3.0.4",
+    "@vue/eslint-config-standard": "^9.0.0",
+    "@vue/test-utils": "^2.4.6",
+    "blob-polyfill": "^9.0.20240710",
+    "eslint": "^9.19.0",
+    "eslint-config-prettier": "^10.0.1",
+    "eslint-import-resolver-typescript": "^3.7.0",
     "eslint-plugin-nuxt": "^4.0.0",
-    "eslint-plugin-prettier": "^5.0.1",
-    "eslint-plugin-vue": "^9.19.2",
-    "jsdom": "^23.0.1",
-    "prettier": "^3.1.0",
-    "ts-jest": "^29.1.1",
-    "typescript": "^5.3.3",
-    "vitest": "1.0.2",
+    "eslint-plugin-prettier": "^5.2.3",
+    "eslint-plugin-vue": "^9.32.0",
+    "eslint-plugin-you-dont-need-lodash-underscore": "^6.14.0",
+    "jsdom": "^26.0.0",
+    "prettier": "^3.4.2",
+    "ts-jest": "^29.2.5",
+    "typescript": "^5.7.3",
+    "vitest": "3.0.4",
     "vue-jest": "^3.0.7"
   },
   "packageManager": "yarn@4.1.1"

+ 1 - 6
pages/my-settings.vue

@@ -9,12 +9,7 @@ Page 'Mes préférences'
           <v-container fluid class="container">
             <v-row>
               <UiLoadingPanel v-if="pending" />
-              <UiForm
-                v-else
-                :model="Preferences"
-                :entity="preferences"
-                action-position="bottom"
-              >
+              <UiForm v-else v-model="preferences" action-position="bottom">
                 <v-row>
                   <v-col cols="12">
                     <UiInputCheckbox

+ 6 - 1
pages/parameters.vue

@@ -18,4 +18,9 @@ definePageMeta({
 })
 </script>
 
-<style scoped lang="scss"></style>
+<style scoped lang="scss">
+:deep(.v-table thead td) {
+  color: rgb(var(--v-theme-on-primary-alt));
+  font-weight: bold;
+}
+</style>

+ 5 - 71
pages/parameters/attendances.vue

@@ -2,12 +2,7 @@
   <LayoutContainer>
     <div v-if="organizationProfile.isSchool">
       <UiLoadingPanel v-if="pending" />
-      <UiForm
-        v-else-if="parameters !== null"
-        :model="Parameters"
-        :entity="parameters"
-        action-position="bottom"
-      >
+      <UiForm v-else-if="parameters !== null" v-model="parameters">
         <v-row>
           <v-col cols="12">
             <UiInputCheckbox
@@ -38,56 +33,10 @@
       <v-divider class="my-10" />
     </div>
 
-    <UiLoadingPanel v-if="attendanceBookingReasonsPending" />
-    <div v-else>
-      <v-table>
-        <thead>
-          <tr>
-            <td>{{ $t('attendanceBookingReasons') }}</td>
-            <td></td>
-          </tr>
-        </thead>
-        <tbody v-if="attendanceBookingReasons!.items.length > 0">
-          <tr
-            v-for="reason in attendanceBookingReasons!.items"
-            :key="reason.id"
-          >
-            <td class="cycle-editable-cell">
-              {{ reason.reason }}
-            </td>
-            <td class="d-flex flex-row">
-              <v-btn
-                :flat="true"
-                icon="fa fa-pen"
-                class="cycle-edit-icon mr-3"
-                @click="goToEditPage(reason.id as number)"
-              />
-              <UiButtonDelete
-                :entity="reason"
-                :flat="true"
-                class="cycle-edit-icon"
-              />
-            </td>
-          </tr>
-        </tbody>
-        <tbody v-else>
-          <tr class="theme-neutral">
-            <td>
-              <i>{{ $t('nothing_to_show') }}</i>
-            </td>
-            <td></td>
-          </tr>
-        </tbody>
-      </v-table>
-      <v-btn
-        :flat="true"
-        prepend-icon="fa fa-plus"
-        class="theme-primary mt-4"
-        @click="goToCreatePage"
-      >
-        {{ $t('add') }}
-      </v-btn>
-    </div>
+    <LayoutParametersEntityTable
+      :model="AttendanceBookingReason"
+      :columns-definitions="[{ property: 'reason' }]"
+    />
   </LayoutContainer>
 </template>
 <script setup lang="ts">
@@ -117,25 +66,10 @@ const { data: parameters, pending } = fetch(
   organizationProfile.parametersId,
 ) as AsyncData<Parameters | null, Error | null>
 
-const { fetchCollection } = useEntityFetch()
-
-const {
-  data: attendanceBookingReasons,
-  pending: attendanceBookingReasonsPending,
-} = fetchCollection(AttendanceBookingReason)
-
 const rules = () => [
   (numberConsecutiveAbsences: string | null) =>
     (numberConsecutiveAbsences !== null &&
       parseInt(numberConsecutiveAbsences) > 0) ||
     i18n.t('please_enter_a_value'),
 ]
-
-const goToEditPage = (id: number) => {
-  navigateTo(UrlUtils.join('/parameters/attendance_booking_reasons', id))
-}
-
-const goToCreatePage = () => {
-  navigateTo('/parameters/attendance_booking_reasons/new')
-}
 </script>

+ 1 - 8
pages/parameters/bulletin.vue

@@ -1,12 +1,7 @@
 <template>
   <LayoutContainer>
     <UiLoadingPanel v-if="pending" />
-    <UiForm
-      v-else
-      :model="Parameters"
-      :entity="parameters"
-      action-position="bottom"
-    >
+    <UiForm v-else v-model="parameters">
       <v-row>
         <v-col cols="12">
           <UiInputCheckbox
@@ -58,14 +53,12 @@
             v-model="parameters.bulletinReceiver"
             field="bulletinReceiver"
             enum-name="organization_bulletin_send_to"
-            variant="underlined"
           />
 
           <UiInputAutocompleteWithEnum
             v-model="parameters.bulletinCriteriaSort"
             field="bulletinCriteriaSort"
             enum-name="organization_bulletin_criteria_sort"
-            variant="underlined"
           />
         </v-col>
       </v-row>

+ 7 - 15
pages/parameters/education_notation.vue

@@ -1,12 +1,7 @@
 <template>
   <LayoutContainer>
     <UiLoadingPanel v-if="pending" />
-    <UiForm
-      v-else
-      :model="Parameters"
-      :entity="parameters"
-      action-position="bottom"
-    >
+    <UiForm v-else v-model="parameters">
       <v-row>
         <v-col cols="12">
           <UiInputCheckbox
@@ -21,25 +16,23 @@
             label="evaluation_criterium_edition_is_admin_only"
           />
 
+          <UiInputCheckbox
+            v-model="parameters.requiredValidation"
+            field="requiredValidation"
+            label="mandatory_validation_for_evaluations"
+          />
+
           <UiInputAutocompleteWithEnum
             v-if="organizationProfile.hasModule('AdvancedEducationNotation')"
             v-model="parameters.advancedEducationNotationType"
             enum-name="advanced_education_notation"
             field="advancedEducationNotationType"
-            variant="underlined"
-          />
-
-          <UiInputCheckbox
-            v-model="parameters.requiredValidation"
-            field="requiredValidation"
-            label="mandatory_validation_for_evaluations"
           />
 
           <UiInputAutocompleteWithEnum
             v-model="parameters.educationPeriodicity"
             enum-name="education_periodicity"
             field="educationPeriodicity"
-            variant="underlined"
           />
 
           <UiInputNumber
@@ -50,7 +43,6 @@
             :min="1"
             :max="100"
             class="mt-2"
-            variant="underlined"
           />
         </v-col>
       </v-row>

+ 5 - 73
pages/parameters/education_timings/index.vue

@@ -1,60 +1,15 @@
 <template>
   <LayoutContainer>
-    <UiLoadingPanel v-if="pending" />
-    <div v-else>
-      <v-table>
-        <thead>
-          <tr>
-            <td>{{ $t('educationTimings') }}</td>
-            <td></td>
-          </tr>
-        </thead>
-        <tbody v-if="educationTimings!.items.length > 0">
-          <tr v-for="timing in educationTimings!.items" :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
-                :entity="timing"
-                :flat="true"
-                class="cycle-edit-icon"
-              />
-            </td>
-          </tr>
-        </tbody>
-        <tbody v-else>
-          <tr class="theme-neutral">
-            <td>
-              <i>{{ $t('nothing_to_show') }}</i>
-            </td>
-            <td></td>
-          </tr>
-        </tbody>
-      </v-table>
-      <v-btn
-        :flat="true"
-        prepend-icon="fa fa-plus"
-        class="theme-primary mt-4"
-        @click="goToCreatePage"
-      >
-        {{ $t('add') }}
-      </v-btn>
-    </div>
+    <LayoutParametersEntityTable
+      :model="EducationTiming"
+      :columns-definitions="[{ property: 'timing' }]"
+    />
   </LayoutContainer>
 </template>
 
 <script setup lang="ts">
-import { useEntityFetch } from '~/composables/data/useEntityFetch'
 import EducationTiming from '~/models/Education/EducationTiming'
 import { useOrganizationProfileStore } from '~/stores/organizationProfile'
-import UrlUtils from '~/services/utils/urlUtils'
 
 definePageMeta({
   name: 'parameters_education_timings_page',
@@ -65,29 +20,6 @@ const organizationProfile = useOrganizationProfileStore()
 if (organizationProfile.parametersId === null) {
   throw new Error('Missing organization parameters id')
 }
-
-const { fetchCollection } = useEntityFetch()
-
-const { data: educationTimings, pending } = fetchCollection(EducationTiming)
-
-const goToEditPage = (id: number) => {
-  navigateTo(UrlUtils.join('/parameters/education_timings', id))
-}
-
-const goToCreatePage = () => {
-  navigateTo('/parameters/education_timings/new')
-}
 </script>
 
-<style scoped lang="scss">
-.v-table {
-  width: 100%;
-  max-width: 800px;
-}
-
-// 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>
+<style scoped lang="scss"></style>

+ 1 - 6
pages/parameters/general_parameters.vue

@@ -1,12 +1,7 @@
 <template>
   <LayoutContainer>
     <UiLoadingPanel v-if="pending" />
-    <UiForm
-      v-else-if="parameters !== null"
-      :model="Parameters"
-      :entity="parameters"
-      action-position="bottom"
-    >
+    <UiForm v-else-if="parameters !== null" v-model="parameters">
       <v-row>
         <v-col cols="12">
           <UiInputDatePicker

+ 2 - 0
pages/parameters/index.vue

@@ -11,3 +11,5 @@
 const router = useRouter()
 router.push({ path: `/parameters/general_parameters` })
 </script>
+
+<style scoped lang="scss"></style>

+ 3 - 6
pages/parameters/intranet.vue

@@ -1,14 +1,10 @@
 <template>
   <LayoutContainer>
     <UiLoadingPanel v-if="pending" />
-    <UiForm
-      v-else
-      :model="Parameters"
-      :entity="parameters"
-      action-position="bottom"
-    >
+    <UiForm v-else v-model="parameters">
       <v-row>
         <v-col cols="12">
+          <h3>{{ $t('teachers') }}</h3>
           <UiInputCheckbox
             v-model="parameters.createCourse"
             field="createCourse"
@@ -33,6 +29,7 @@
             label="allow_teachers_to_generate_attendance_reports"
           />
 
+          <h3>{{ $t('pupils-members') }}</h3>
           <UiInputCheckbox
             v-model="parameters.administrationCc"
             field="administrationCc"

+ 5 - 77
pages/parameters/residence_areas/index.vue

@@ -1,90 +1,18 @@
 <template>
   <LayoutContainer>
-    <UiLoadingPanel v-if="pending" />
-    <div v-else>
-      <v-table>
-        <thead>
-          <tr>
-            <td>{{ $t('residenceAreas') }}</td>
-            <td></td>
-          </tr>
-        </thead>
-        <tbody v-if="residenceAreas!.items.length > 0">
-          <tr
-            v-for="residenceArea in residenceAreas!.items"
-            :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
-                :entity="residenceArea"
-                :flat="true"
-                class="cycle-edit-icon"
-              />
-            </td>
-          </tr>
-        </tbody>
-        <tbody v-else>
-          <tr class="theme-neutral">
-            <td>
-              <i>{{ $t('nothing_to_show') }}</i>
-            </td>
-            <td></td>
-          </tr>
-        </tbody>
-      </v-table>
-
-      <v-btn
-        :flat="true"
-        prepend-icon="fa fa-plus"
-        class="theme-primary mt-4"
-        @click="goToCreatePage"
-      >
-        {{ $t('add') }}
-      </v-btn>
-    </div>
+    <LayoutParametersEntityTable
+      :model="ResidenceArea"
+      :columns-definitions="[{ property: 'label' }]"
+    />
   </LayoutContainer>
 </template>
 
 <script setup lang="ts">
-import { useEntityFetch } from '~/composables/data/useEntityFetch'
 import ResidenceArea from '~/models/Billing/ResidenceArea'
-import UrlUtils from '~/services/utils/urlUtils'
 
 definePageMeta({
   name: 'parameters_residence_areas_page',
 })
-
-const { fetchCollection } = useEntityFetch()
-
-const { data: residenceAreas, pending } = fetchCollection(ResidenceArea)
-
-const goToEditPage = (id: number) => {
-  navigateTo(UrlUtils.join('/parameters/residence_areas', id))
-}
-
-const goToCreatePage = () => {
-  navigateTo(`/parameters/residence_areas/new`)
-}
 </script>
 
-<style scoped lang="scss">
-.v-table {
-  width: 100%;
-  max-width: 800px;
-}
-
-// 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>
+<style scoped lang="scss"></style>

+ 13 - 6
pages/parameters/sms.vue

@@ -1,16 +1,12 @@
 <template>
   <div>
-    <UiForm
-      v-if="parameters"
-      :model="Parameters"
-      :entity="parameters"
-      action-position="bottom"
-    >
+    <UiForm v-if="parameters" v-model="parameters">
       <v-row>
         <v-col cols="12">
           <UiInputText
             v-model="parameters.smsSenderName"
             field="smsSenderName"
+            :rules="rules()"
             variant="underlined"
           />
         </v-col>
@@ -44,6 +40,8 @@ definePageMeta({
   name: 'parameters_sms_page',
 })
 
+const i18n = useI18n()
+
 const { fetch } = useEntityFetch()
 
 const organizationProfile = useOrganizationProfileStore()
@@ -56,6 +54,15 @@ const { data: parameters } = fetch(
   Parameters,
   organizationProfile.parametersId,
 ) as AsyncData<Parameters | null, Error | null>
+
+/**
+ * Règles de validation
+ */
+const rules = () => [
+  (smsSenderName: string | null) =>
+    (smsSenderName !== null && /^\w{3,11}$/.test(smsSenderName)) ||
+    i18n.t('please_enter_a_value_for_the_sms_sender_name'),
+]
 </script>
 
 <style scoped lang="scss">

+ 4 - 5
pages/parameters/subdomains/[id].vue

@@ -44,19 +44,18 @@ import { useEntityManager } from '~/composables/data/useEntityManager'
 import { usePageStore } from '~/stores/page'
 import { TYPE_ALERT } from '~/types/enum/enums'
 import { useRefreshProfile } from '~/composables/data/useRefreshProfile'
+import { useRouteUtils } from '~/composables/utils/useRouteUtils'
 
 const { em } = useEntityManager()
 const { fetch } = useEntityFetch()
 
 const router = useRouter()
-const route = useRoute()
 
 const { refreshProfile } = useRefreshProfile()
 
-if (!route.params.id || /\d+/.test(route.params.id as string)) {
-  throw new Error('no id found')
-}
-const id: number = parseInt(route.params.id as string)
+const { getIdFromRoute } = useRouteUtils()
+
+const id = getIdFromRoute()
 
 const { data: subdomain, pending } = fetch(Subdomain, id)
 

+ 6 - 4
pages/parameters/subdomains/new.vue

@@ -3,8 +3,7 @@
     <LayoutContainer>
       <UiForm
         ref="form"
-        :model="Subdomain"
-        :entity="subdomain"
+        v-model="subdomain"
         :submit-actions="submitActions"
         :validation-pending="validationPending"
         :refresh-profile="true"
@@ -66,8 +65,12 @@ const i18n = useI18n()
 const { em } = useEntityManager()
 const { subdomainValidation } = useSubdomainValidation()
 
+const organizationProfileStore = useOrganizationProfileStore()
+
 // @ts-expect-error TODO à résoudre quand l'EM pourra gérer les types génériques
-const subdomain: Ref<Subdomain> = ref(em.newInstance(Subdomain))
+const subdomain: Ref<Subdomain> = ref(
+  em.newInstance(Subdomain, { organization: organizationProfileStore.id }),
+)
 
 const submitActions = computed(() => {
   const actions: AnyJson = {}
@@ -136,7 +139,6 @@ const rules = () => [
 
 <style scoped lang="scss">
 .validationMessage {
-  font-size: 13px;
   height: 20px;
   min-height: 20px;
 }

+ 15 - 17
pages/parameters/super_admin.vue

@@ -1,8 +1,8 @@
 <template>
   <div>
     <div class="explanation">
-      <div class="px-6 d-flex flex-row align-center">
-        <v-icon class="theme-primary">fa fa-info</v-icon>
+      <div class="px-4 d-flex flex-row align-center">
+        <v-icon class="theme-info">fa fa-info</v-icon>
       </div>
       <div class="px-2">
         {{ $t('super_admin_explanation_text') }}
@@ -13,26 +13,25 @@
     <UiForm
       v-else-if="adminAccess"
       ref="form"
-      :model="AdminAccess"
-      :entity="adminAccess"
+      v-model="adminAccess"
       class="w-100"
-      action-position="bottom"
     >
       <v-table class="mb-4">
         <tbody>
           <tr>
             <td>{{ $t('username') }} :</td>
-            <td>{{ adminAccess.username }}</td>
+            <td>
+              <b>{{ adminAccess.username }}</b>
+            </td>
           </tr>
         </tbody>
       </v-table>
 
-      <UiInputText
+      <UiInputEmail
         v-model="adminAccess.email"
         field="email"
-        :rules="rules()"
+        :label="$t('associated_email')"
         class="mx-4"
-        variant="underlined"
       />
     </UiForm>
     <span v-else>{{ $t('no_admin_access_recorded') }}</span>
@@ -72,23 +71,22 @@ const rules = () => [
 .explanation {
   display: flex;
   flex-direction: row;
-  padding: 60px 26px;
+  margin: 32px;
+  padding: 8px 4px;
+  border-radius: 6px;
   text-align: justify;
-  color: rgb(var(--v-theme-neutral-strong));
+  color: rgb(var(--v-theme-info));
+  border: solid 1px rgb(var(--v-theme-info));
 
   .v-icon {
-    background-color: rgb(var(--v-theme-primary));
+    color: rgb(var(--v-theme-info));
     font-size: 22px;
     border-radius: 16px;
-    margin: 3px;
+    margin: 3px 1px;
     padding: 3px;
     height: 28px;
     width: 28px;
   }
-
-  div:first-child {
-    border-right: solid 1px rgb(var(--v-theme-primary));
-  }
 }
 
 .v-table td:first-child {

+ 28 - 52
pages/parameters/teaching.vue

@@ -1,42 +1,17 @@
 <template>
   <LayoutContainer>
     <UiLoadingPanel v-if="pending" />
-    <UiForm
-      v-else-if="parameters !== null"
-      :model="Parameters"
-      :entity="parameters"
-      action-position="bottom"
-    >
-      <v-table>
-        <thead>
-          <tr>
-            <td>{{ $t('originalLabel') }}</td>
-            <td>{{ $t('effectiveLabel') }}</td>
-          </tr>
-        </thead>
-
-        <tbody>
-          <tr v-for="enumItem in cycleEnum" :key="enumItem.value">
-            <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>
+    <UiForm v-else-if="parameters !== null" v-model="parameters">
+      <LayoutParametersTable
+        :items="tableItems"
+        :columns-definitions="[
+          { property: 'originalLabel' },
+          { property: 'effectiveLabel' },
+        ]"
+        identifier="value"
+        :actions="[TABLE_ACTION.EDIT]"
+        @editClicked="goToCycleEditPage"
+      />
 
       <UiInputCheckbox
         v-model="parameters.showEducationIsACollectivePractice"
@@ -57,6 +32,7 @@ import { useOrganizationProfileStore } from '~/stores/organizationProfile'
 import type { AnyJson } from '~/types/data'
 import { useEnumFetch } from '~/composables/data/useEnumFetch'
 import ApiResource from '~/models/ApiResource'
+import { TABLE_ACTION } from '~/types/enum/enums'
 
 definePageMeta({
   name: 'parameters_teaching_page',
@@ -107,22 +83,22 @@ const orderedCycles: ComputedRef<AnyJson> = computed(() => {
   return orderedCycles
 })
 
-const goToCycleEditPage = (id: number) => {
-  navigateTo(`/parameters/cycles/${id}`)
-}
-</script>
-
-<style scoped lang="scss">
-.v-table {
-  width: 100%;
-  max-width: 800px;
-}
+const tableItems = computed(() => {
+  return (
+    cycleEnum.value?.map((item) => {
+      return {
+        value: item.value,
+        originalLabel: item.label,
+        effectiveLabel: (orderedCycles.value[item.value] ?? item).label,
+      }
+    }) || []
+  )
+})
 
-.cycle-edit-icon {
-  color: rgb(var(--v-theme-primary));
+const goToCycleEditPage = (item: object) => {
+  const cycle = orderedCycles.value[item.value]
+  navigateTo(`/parameters/cycles/${cycle.id}`)
 }
+</script>
 
-:deep(.cycle-edit-icon .v-icon) {
-  font-size: 18px;
-}
-</style>
+<style scoped lang="scss"></style>

+ 20 - 15
pages/parameters/website.vue

@@ -4,8 +4,7 @@
     <UiForm
       v-else-if="parameters !== null"
       :model="Parameters"
-      :entity="parameters"
-      action-position="bottom"
+      v-model="parameters"
     >
       <v-row>
         <v-col cols="12">
@@ -24,21 +23,10 @@
             field="publicationDirectors"
             multiple
             chips
-            variant="underlined"
+            variant="outlined"
             class="my-4"
           />
 
-          <div>
-            <UiInputText
-              v-model="parameters.otherWebsite"
-              field="otherWebsite"
-              variant="underlined"
-              class="my-4"
-            />
-          </div>
-
-          <v-divider class="my-10" />
-
           <div class="mb-6">
             <div>
               <h4>{{ $t('your_subdomains') }} :</h4>
@@ -131,6 +119,15 @@
               </LazyLayoutDialog>
             </div>
           </div>
+
+          <div>
+            <UiInputText
+              v-model="parameters.otherWebsite"
+              field="otherWebsite"
+              variant="underlined"
+              class="my-4"
+            />
+          </div>
         </v-col>
       </v-row>
     </UiForm>
@@ -178,7 +175,6 @@ const canAddNewSubdomain: ComputedRef<boolean> = computed(
 )
 
 const goToEditPage = (id: number) => {
-  console.log(parameters.value)
   navigateTo(`/parameters/subdomains/${id}`)
 }
 
@@ -214,6 +210,7 @@ const onDialogYesBtnClick = () => {
   cursor: pointer;
   border: solid 1px rgb(var(--v-theme-neutral-strong));
 }
+
 .subdomainItem:hover {
   background: rgb(var(--v-theme-neutral));
 }
@@ -222,9 +219,17 @@ const onDialogYesBtnClick = () => {
 }
 
 .subdomainItem td:first-child {
+  border-top: solid 1px rgb(var(--v-theme-neutral));
+  border-bottom: solid 1px rgb(var(--v-theme-neutral));
   border-left: solid 2px rgb(var(--v-theme-neutral));
 }
 
+.subdomainItem td:last-child {
+  border-top: solid 1px rgb(var(--v-theme-neutral));
+  border-bottom: solid 1px rgb(var(--v-theme-neutral));
+  border-right: solid 1px rgb(var(--v-theme-neutral));
+}
+
 .subdomainItem.active td:first-child {
   border-left: solid 2px rgb(var(--v-theme-primary));
 }

+ 625 - 560
pages/subscription.vue

@@ -5,501 +5,434 @@ Page 'Mon abonnement'
 -->
 <template>
   <LayoutContainer>
-    <v-col cols="12" sm="12" md="12">
-      <v-expansion-panels v-model="openedPanels" :multiple="true">
-        <UiExpansionPanel title="informations" icon="fas fa-info">
-          <v-container fluid class="container">
-            <v-row>
-              <v-table>
-                <tbody>
-                  <tr>
-                    <td v-if="smAndUp">{{ $t('client_id') }}</td>
-                    <td class="py-2">
-                      <h5
-                        v-if="!smAndUp"
-                        class="text-decoration-underline py-2"
-                      >
-                        {{ $t('client_id') }}
-                      </h5>
-                      <span>{{
-                        dolibarrAccount ? dolibarrAccount.clientNumber : '-'
-                      }}</span>
-                    </td>
-                  </tr>
-                  <tr>
-                    <td v-if="smAndUp">{{ $t('version') }}</td>
-                    <td class="py-2">
-                      <h5
-                        v-if="!smAndUp"
-                        class="text-decoration-underline py-2"
-                      >
-                        {{ $t('version') }}
-                      </h5>
-                      <span>{{
-                        dolibarrAccount ? $t(dolibarrAccount.product) : '-'
-                      }}</span>
-                    </td>
-                  </tr>
-                  <tr v-if="dolibarrAccount && dolibarrAccount.contract">
-                    <td v-if="smAndUp">{{ $t('services') }}</td>
-                    <td class="py-2">
-                      <h5
-                        v-if="!smAndUp"
-                        class="text-decoration-underline py-2"
-                      >
-                        {{ $t('services') }}
-                      </h5>
-                      <div
-                        v-for="line in dolibarrAccount.contract.lines"
-                        :key="line.id"
-                      >
-                        {{ line.serviceLabel }} - {{ $t('until') }} :
-                        {{ $d(line.dateEnd) }}
-                      </div>
-                    </td>
-                  </tr>
-                  <tr v-if="ability.can('manage', 'texto')">
-                    <td v-if="smAndUp">{{ $t('remaining_sms_credit') }}</td>
-                    <td class="py-2">
-                      <h5
-                        v-if="!smAndUp"
-                        class="text-decoration-underline py-2"
-                      >
-                        {{ $t('remaining_sms_credit') }}
-                      </h5>
-                      <span
-                        v-if="
-                          !mobytPending &&
-                          mobytStatus !== null &&
-                          mobytStatus.active
-                        "
-                      >
-                        {{
-                          mobytStatus.money.toLocaleString($i18n.locale, {
-                            style: 'currency',
-                            currency: 'EUR',
-                          })
-                        }}
-                        {{
-                          i18n.t('convert_price_to_sms', {
-                            nb_sms: mobytStatus.amount,
-                          })
-                        }}
-                      </span>
-                    </td>
-                  </tr>
-                </tbody>
-              </v-table>
-            </v-row>
-          </v-container>
-        </UiExpansionPanel>
-
-        <UiExpansionPanel
-          v-if="showDolibarrPanel"
-          title="bills"
-          icon="fas fa-file"
-        >
-          <v-container :fluid="true" class="container">
-            <v-row>
-              <v-table v-if="dolibarrAccount !== null">
-                <thead>
-                  <tr>
-                    <th>{{ $t('reference') }}</th>
-                    <th>{{ $t('date') }}</th>
-                    <th>{{ $t('taxExcludedAmount') }}</th>
-                    <th>{{ $t('status') }}</th>
-                  </tr>
-                </thead>
-                <tbody>
-                  <tr v-for="bill in dolibarrAccount.bills" :key="bill.id">
-                    <td>{{ bill.ref }}</td>
-                    <td>{{ $d(bill.date) }}</td>
-                    <td>
-                      {{
-                        bill.taxExcludedAmount.toLocaleString($i18n.locale, {
-                          style: 'currency',
-                          currency: 'EUR',
-                        })
-                      }}
-                    </td>
-                    <td>
-                      {{ bill.paid === true ? $t('paid') : $t('unpaid') }}
-                    </td>
-                  </tr>
-                </tbody>
-              </v-table>
-            </v-row>
-          </v-container>
-        </UiExpansionPanel>
-
-        <UiExpansionPanel title="more_features" icon="fas fa-plus">
-          <v-container id="products-section" :fluid="true" class="container">
-            <v-row>
-              <!-- Opentalent Artist Premium -->
-              <v-col
-                v-if="organizationProfile.isArtistProduct"
-                :cols="colWidth"
+    <v-expansion-panels v-model="openedPanels" :multiple="true">
+      <UiExpansionPanel title="subscription_page" icon="fas fa-info">
+        <v-container fluid class="container">
+          <v-row>
+            <v-col cols="12" lg="6" sm="12">
+              {{ $t('version') }} :
+              <strong>{{
+                dolibarrAccount ? $t(dolibarrAccount.product) : '-'
+              }}</strong>
+            </v-col>
+            <v-col cols="12" lg="6" sm="12">
+              {{ $t('client_id') }} :
+              {{ dolibarrAccount ? dolibarrAccount.clientNumber : '-' }}
+            </v-col>
+          </v-row>
+        </v-container>
+      </UiExpansionPanel>
+
+      <UiExpansionPanel
+        title="service_detail"
+        icon="fas fa-info"
+        v-if="dolibarrAccount && dolibarrAccount.contract"
+      >
+        <v-container fluid class="container">
+          <v-row>
+            <v-col
+              cols="12"
+              lg="12"
+              v-for="line in dolibarrAccount.contract.lines"
+              :key="line.id"
+            >
+              <strong>{{ line.serviceLabel }}</strong> - {{ $t('until') }} :
+              {{ $d(line.dateEnd) }}
+            </v-col>
+
+            <v-col cols="12" lg="12" v-if="ability.can('manage', 'texto')">
+              <strong>{{ $t('remaining_sms_credit') }}</strong> -
+              <span
+                v-if="
+                  !mobytPending && mobytStatus !== null && mobytStatus.active
+                "
               >
-                <v-row>
-                  {{ $t('PRODUCT_ARTIST_PREMIUM') }}
-                </v-row>
-                <v-row class="align-end">
-                  <nuxt-img src="/images/Opentalent_Artist.png" />
-                </v-row>
-                <v-row>
-                  <p>
-                    {{ $t('get_more_functionalities_with_version') }}
-                    <b>{{ $t('PRODUCT_ARTIST_PREMIUM') }}</b>
-                  </p>
-
-                  <!-- Cmf member -->
-                  <div v-if="organizationProfile.isCmf">
-                    <p>
-                      <b
-                        >{{
-                          i18n.t('for_only_x_eur_ttc_by_month', {
-                            price: formatCurrency(7.5, 'EUR'),
-                          })
-                        }}&nbsp;*</b
-                      >
-                    </p>
-                    <div>
-                      <i
-                        >*&nbsp;{{
-                          i18n.t('yearly_paid_giving_x_eur_ttc_per_year', {
-                            price: formatCurrency(90.0, 'EUR'),
-                          })
-                        }}</i
-                      >
-                    </div>
-                    <div>
-                      <i
-                        >{{ $t('only_for_cmf_members') }} ({{
-                          i18n.t('public_price_x_ttc_a_year', {
-                            price: formatCurrency(216.0, 'EUR'),
-                          })
-                        }})</i
-                      >
-                    </div>
-                  </div>
-
-                  <!-- Not a cmf member -->
-                  <div v-else>
-                    <p>
-                      <b
-                        >{{
-                          i18n.t('for_only_x_eur_ttc_by_month', {
-                            price: formatCurrency(18.0, 'EUR'),
-                          })
-                        }}&nbsp;*</b
-                      >
-                    </p>
-                    <p>
-                      <i>
-                        *&nbsp;{{
-                          i18n.t('yearly_paid_giving_x_eur_ttc_per_year', {
-                            price: formatCurrency(216.0, 'EUR'),
-                          })
-                        }}
-                      </i>
-                    </p>
-                  </div>
-
-                  <p class="mt-3">
+                {{
+                  mobytStatus.money.toLocaleString($i18n.locale, {
+                    style: 'currency',
+                    currency: 'EUR',
+                  })
+                }}
+                {{
+                  i18n.t('convert_price_to_sms', {
+                    nb_sms: mobytStatus.amount,
+                  })
+                }}
+              </span>
+            </v-col>
+          </v-row>
+        </v-container>
+      </UiExpansionPanel>
+
+      <UiExpansionPanel
+        v-if="showDolibarrPanel"
+        title="bills"
+        icon="fas fa-file"
+      >
+        <v-container :fluid="true" class="container">
+          <v-row>
+            <v-table v-if="dolibarrAccount !== null">
+              <thead>
+                <tr>
+                  <th>{{ $t('reference') }}</th>
+                  <th>{{ $t('date') }}</th>
+                  <th>{{ $t('taxExcludedAmount') }}</th>
+                  <th>{{ $t('status') }}</th>
+                  <th></th>
+                </tr>
+              </thead>
+              <tbody>
+                <tr v-for="bill in dolibarrAccount.bills" :key="bill.id">
+                  <td>{{ bill.ref }}</td>
+                  <td>{{ $d(bill.date) }}</td>
+                  <td>
+                    {{
+                      bill.taxExcludedAmount.toLocaleString($i18n.locale, {
+                        style: 'currency',
+                        currency: 'EUR',
+                      })
+                    }}
+                  </td>
+                  <td>
+                    {{ bill.paid === true ? $t('paid') : $t('unpaid') }}
+                  </td>
+                  <td>
                     <a
-                      :href="
-                        runtimeConfig.public.fileStorageBaseUrl +
-                        '/Fiche_produit/Fiche_produit_Opentalent_Artist.pdf'
-                      "
-                      target="_blank"
+                      @click="downloadDolibarrBill(bill.ref)"
+                      class="clickable"
                     >
-                      {{ $t('product_sheet') }}
-                      {{ $t('PRODUCT_ARTIST_PREMIUM') }}
+                      {{ $t('download') }}
                     </a>
-                  </p>
-
-                  <p v-if="organizationProfile.isCmf" class="mt-3">
-                    <a
-                      :href="
-                        runtimeConfig.public.fileStorageBaseUrl +
-                        '/Bon_de_commande/Opentalent_Artist_CMF.pdf'
-                      "
-                      target="_blank"
+                  </td>
+                </tr>
+              </tbody>
+            </v-table>
+          </v-row>
+        </v-container>
+      </UiExpansionPanel>
+
+      <UiExpansionPanel
+        title="opentalent_offers"
+        icon="fas fa-plus"
+        v-if="!organizationProfile.isManagerProduct"
+      >
+        <v-container fluid class="container">
+          <v-row
+            class="offer_title"
+            v-if="!md && mdAndUp && !organizationProfile.isSchool"
+          >
+            <v-col
+              cols="12"
+              :lg="
+                organizationProfile.isArtistPremiumProduct &&
+                !organizationProfile.isTrialActive
+                  ? 4
+                  : 8
+              "
+              sm="12"
+            >
+              <span class="theme-artist"
+                >Pour les orchestres, chorales, <br />compagnies et troupes
+                artistiques</span
+              >
+            </v-col>
+            <v-col cols="12" lg="4" sm="12">
+              <span class="theme-school"
+                >Pour les établissements d'enseignements <br />
+                artistiques*</span
+              >
+            </v-col>
+          </v-row>
+
+          <v-row class="card-container">
+            <v-col
+              lg="4"
+              sm="12"
+              md="6"
+              v-if="
+                organizationProfile.isArtistProduct ||
+                organizationProfile.isTrialActive
+              "
+            >
+              <LayoutPagesSubscriptionCard
+                class="artistCard"
+                title="Logiciel Artist Standard"
+                :extraHeader="
+                  organizationProfile.isArtistProduct
+                    ? 'Votre version actuelle'
+                    : undefined
+                "
+                color="artist"
+                :list="listCheck.artist"
+              >
+                <template #card.subTitle>
+                  <div class="priceBlock">
+                    <span v-if="organizationProfile.isCmf"
+                      ><strong>{{ $t('price_include_cmf') }}*</strong></span
                     >
-                      <b>{{ $t('download_cmf_order_form') }}</b>
-                    </a>
-                  </p>
-                  <p v-else class="mt-3">
-                    <a
-                      :href="
-                        runtimeConfig.public.fileStorageBaseUrl +
-                        '/Bon_de_commande/Opentalent_Artist_Public.pdf'
+                    <span v-else
+                      ><span class="price">{{
+                        formatCurrency(14.0, 'EUR')
+                      }}</span>
+                      TTC/mois*</span
+                    >
+                  </div>
+                </template>
+                <template #card.action>
+                  <v-row>
+                    <v-col
+                      cols="12"
+                      v-if="
+                        !organizationProfile.isArtistProduct &&
+                        !organizationProfile.isTrialActive
                       "
-                      target="_blank"
                     >
-                      <b>{{ $t('download_order_form') }}</b>
-                    </a>
-                  </p>
-                </v-row>
-              </v-col>
-
-              <!-- Opentalent School Premium -->
-              <v-col v-if="organizationProfile.isArtist" :cols="colWidth">
-                <v-row>
-                  {{ $t('PRODUCT_SCHOOL') }}
-                </v-row>
-                <v-row class="align-end">
-                  <nuxt-img src="/images/Opentalent_School.png" />
-                </v-row>
-                <v-row>
-                  <p>
-                    {{ $t('switch_to_version') }}
-                    <b>{{ $t('PRODUCT_SCHOOL_PREMIUM') }}</b>
-                    {{ $t('and_benefit') }} :
-                  </p>
-
-                  <ul class="mb-2">
-                    <li>{{ $t('of_accounts_for_teachers_and_students') }}</li>
-                    <li>{{ $t('of_a_complete_website') }}</li>
-                  </ul>
-
-                  <!-- Cmf member -->
-                  <div v-if="organizationProfile.isCmf">
-                    <p>
-                      <b
-                        >{{
-                          i18n.t('starting_from_x_eur_ttc_per_month', {
-                            price: formatCurrency(30.5, 'EUR'),
-                          })
-                        }}&nbsp;*</b
-                      >
-                    </p>
-                    <div>
-                      <i
-                        >*
-                        {{
-                          i18n.t('yearly_paid_giving_x_eur_ttc_per_year', {
-                            price: formatCurrency(366.0, 'EUR'),
-                          })
-                        }}</i
-                      >
-                    </div>
-                    <div>
-                      <i>{{
-                        i18n.t('version_x_up_to_x_students', {
-                          product: $t('PRODUCT_SCHOOL_PREMIUM'),
-                          max_students: '69',
-                        })
-                      }}</i>
-                    </div>
-                    <div>
-                      <i>{{ $t('excluding_license_and_training_fees') }}.</i>
-                    </div>
-                    <div>
-                      <i
-                        >{{ $t('only_for_cmf_members') }} ({{
-                          i18n.t('public_price_x_ttc_a_year', {
-                            price: formatCurrency(607.2, 'EUR'),
-                          })
-                        }})</i
+                      <v-btn
+                        class="theme-artist btn"
+                        href="https://logiciels.opentalent.fr/opentalent-artist"
+                        target="_blank"
                       >
-                    </div>
-                  </div>
-                  <!-- Not cmf member -->
-                  <div v-else>
-                    <p>
-                      {{
-                        i18n.t('starting_from_x_eur_ttc_per_month', {
-                          price: formatCurrency(50.6, 'EUR'),
-                        })
-                      }}&nbsp;*
-                    </p>
-                    <div>
-                      <i
-                        >*&nbsp;{{
-                          i18n.t('yearly_paid_giving_x_eur_ttc_per_year', {
-                            price: formatCurrency(607.2, 'EUR'),
-                          })
-                        }}</i
+                        {{ $t('to_know_more') }}
+                        <i class="fa-solid fa-greater-than small"></i>
+                      </v-btn>
+                    </v-col>
+                    <v-col cols="12">
+                      <span
+                        v-if="organizationProfile.isCmf"
+                        class="special_conditions"
                       >
-                    </div>
-                    <div>
-                      <i>{{
-                        i18n.t('version_x_up_to_x_students', {
-                          product: $t('PRODUCT_SCHOOL_PREMIUM'),
-                          max_students: '69',
-                        })
-                      }}</i>
-                    </div>
-                    <div>
-                      <i>{{ $t('excluding_license_and_training_fees') }}.</i>
-                    </div>
+                        *En cas de non-réadhésion, il reste accessible au tarif
+                        public de 14€ TTC/mois (Tarif 2025. Abonnement payable
+                        annuellement).
+                      </span>
+                      <span v-else class="special_conditions">
+                        *Tarif public 2025. Abonnement payable annuellement.
+                      </span>
+                    </v-col>
+                  </v-row>
+                </template>
+              </LayoutPagesSubscriptionCard>
+            </v-col>
+
+            <v-col lg="4" sm="12" md="6" v-if="organizationProfile.isArtist">
+              <LayoutPagesSubscriptionCard
+                class="artistCard"
+                title="Logiciel Artist Premium*"
+                :extraHeader="
+                  organizationProfile.isArtistPremiumProduct
+                    ? organizationProfile.isTrialActive
+                      ? `Version en cours d'essai J-${organizationProfile.trialCountDown}`
+                      : 'Votre version actuelle'
+                    : '1 mois d\'essai offert'
+                "
+                color="artist"
+                :list="listCheck.artistPremium"
+              >
+                <template #card.subTitle>
+                  <div
+                    class="priceBlock"
+                    v-if="
+                      !organizationProfile.isArtistPremiumProduct ||
+                      organizationProfile.isTrialActive
+                    "
+                  >
+                    <span class="price">{{
+                      organizationProfile.isCmf
+                        ? formatCurrency(7.5, 'EUR')
+                        : formatCurrency(18.0, 'EUR')
+                    }}</span>
+                    TTC/mois**
                   </div>
-
-                  <p class="mt-4">
-                    <a
-                      :href="
-                        runtimeConfig.public.fileStorageBaseUrl +
-                        '/Fiche_produit/Fiche_produit_Opentalent_School.pdf'
+                </template>
+                <template #card.action>
+                  <v-row>
+                    <v-col
+                      cols="12"
+                      v-if="
+                        !organizationProfile.isArtistPremiumProduct &&
+                        (accessProfileStore.isAdmin ||
+                          accessProfileStore.isCaMember)
                       "
-                      target="_blank"
                     >
-                      {{ $t('product_sheet') }} {{ $t('PRODUCT_SCHOOL') }}
-                    </a>
-                  </p>
-
-                  <p>
-                    {{ $t('contact_us_at') }}
-                    <a href="tel:+33972126017">0 972 126 017</a>,
-                    {{ $t('or_by_mail_at') }}
-                    <a href="mailto:contact@opentalent.fr"
-                      >contact@opentalent.fr</a
+                      <v-btn class="btn trialBtn" @click="startTrial">
+                        {{ $t('try_premium_version') }}
+                        <i class="fa-solid fa-greater-than small"></i>
+                      </v-btn>
+                    </v-col>
+                    <v-col
+                      cols="12"
+                      v-if="
+                        (!organizationProfile.isArtistPremiumProduct ||
+                          organizationProfile.isTrialActive) &&
+                        (accessProfileStore.isAdmin ||
+                          accessProfileStore.isCaMember)
+                      "
                     >
-                  </p>
-                </v-row>
-              </v-col>
-
-              <!-- SMS -->
-              <v-col :cols="colWidth">
-                <v-row>
-                  {{ $t('sms') }}
-                </v-row>
-                <v-row class="align-end">
-                  <nuxt-img src="/images/sms.png" :height="140" :width="175" />
-                </v-row>
-                <v-row>
-                  <p>
-                    <b
-                      >{{ $t('send_sms') }}
-                      {{ $t('to_your_members_from_app') }}</b
+                      <v-btn class="theme-artist btn" @click="subscription">
+                        {{ $t('subscribe_to_the_offer') }}
+                        <i class="fa-solid fa-greater-than small"></i>
+                      </v-btn>
+                    </v-col>
+
+                    <v-col
+                      cols="12"
+                      v-if="
+                        organizationProfile.isTrialActive &&
+                        (accessProfileStore.isAdmin ||
+                          accessProfileStore.isCaMember)
+                      "
                     >
-                  </p>
-
-                  <!-- Cmf member -->
-                  <div v-if="organizationProfile.isCmf">
-                    <p>
-                      <b
-                        >{{
-                          i18n.t('starting_from_x_eur_ttc_per_sms', {
-                            price: formatCurrency(0.11, 'EUR'),
-                          })
-                        }}&nbsp;*</b
-                      >
-                    </p>
-                    <p>
-                      <i
-                        >*&nbsp;{{ i18n.t('for_x_sms', { amount: '5000' }) }}</i
-                      >
-                    </p>
-
-                    <p>
-                      <b>
-                        <a
-                          :href="
-                            runtimeConfig.public.fileStorageBaseUrl +
-                            '/Bon_de_commande/Achat_SMS_CMF.pdf'
+                      <v-btn class="stop_btn" @click="showStopTrialDialog">
+                        {{ $t('stop_trial') }}
+                      </v-btn>
+                    </v-col>
+                    <v-col cols="12">
+                      <span class="special_conditions">
+                        *Convient aux petites écoles sans besoins spécifiques de
+                        gestion pédagogique, de facturation, etc. Pour une
+                        solution complète optez pour Opentalent School
+                        <span
+                          v-if="
+                            !organizationProfile.isArtistPremiumProduct ||
+                            organizationProfile.isTrialActive
                           "
-                          target="_blank"
                         >
-                          {{ i18n.t('download_cmf_order_form') }}
-                        </a>
-                      </b>
-                    </p>
-                  </div>
-                  <!-- Not cmf member -->
-                  <div v-else>
-                    <p>
-                      <b
-                        >{{
-                          i18n.t('starting_from_x_eur_ttc_per_sms', {
-                            price: formatCurrency(0.13, 'EUR'),
-                          })
-                        }}&nbsp;*</b
-                      >
-                    </p>
-                    <p>
-                      <i
-                        >*&nbsp;{{ i18n.t('for_x_sms', { amount: '5000' }) }}</i
-                      >
-                    </p>
-
-                    <p>
-                      <a
-                        :href="
-                          runtimeConfig.public.fileStorageBaseUrl +
-                          '/Bon_de_commande/Achat_SMS_Public.pdf'
-                        "
-                        target="_blank"
-                      >
-                        <b>{{ $t('download_order_form') }}</b>
-                      </a>
-                    </p>
-                  </div>
-                </v-row>
-              </v-col>
-
-              <!-- Custom domain -->
-              <v-col :cols="colWidth">
-                <v-row>
-                  {{ $t('website') }}
-                </v-row>
-                <v-row class="align-end">
-                  <nuxt-img src="/images/nom_de_domaine.png" :height="160" />
-                </v-row>
-                <v-row>
-                  <v-col>
-                    <p>
-                      <b>{{
-                        i18n.t(
-                          'get_your_own_domain_and_up_to_five_emails_for_only_x_eur_ttc_per_year',
-                          { price: formatCurrency(46.8, 'EUR') },
-                        )
-                      }}</b>
-                    </p>
-
-                    <p>{{ $t('example') }} :</p>
-
-                    <table>
-                      <tbody>
-                        <tr>
-                          <td class="pa-2" style="width: 190px">
-                            {{ $t('domain_name') }}&nbsp;:
-                          </td>
-                          <td>
-                            <i>{{ $t('dummy_domain_name') }}</i>
-                          </td>
-                        </tr>
-                        <tr>
-                          <td class="pa-2">
-                            {{ $t('associated_mail_address') }}&nbsp;:
-                          </td>
-                          <td>
-                            <i>{{ $t('dummy_email_address') }}</i>
-                          </td>
-                        </tr>
-                      </tbody>
-                    </table>
-
-                    <p>
-                      <a
-                        :href="
-                          runtimeConfig.public.fileStorageBaseUrl +
-                          '/Bon_de_commande/Nom_de_domaine.pdf'
-                        "
+                          <br />
+                            **Tarif
+                            <span v-if="organizationProfile.isCmf"
+                            >adhérent CMF</span
+                            ><span v-else>public</span> 2025. Abonnement payable
+                            annuellement.
+                        </span>
+
+                      </span>
+                    </v-col>
+                  </v-row>
+                </template>
+              </LayoutPagesSubscriptionCard>
+            </v-col>
+
+            <v-col
+              lg="4"
+              sm="12"
+              md="6"
+              :offset="
+                !md && mdAndUp ? (organizationProfile.isSchool ? 4 : 0) : 0
+              "
+            >
+              <LayoutPagesSubscriptionCard
+                class="schoolCard"
+                :title="
+                  !organizationProfile.isSchool
+                    ? 'Logiciel School Standard / Premium'
+                    : organizationProfile.isSchoolPremiumProduct
+                      ? 'Logiciel School Premium'
+                      : 'Logiciel School Standard'
+                "
+                :subTitle="!organizationProfile.isSchool ? 'Sur devis' : ''"
+                :extraHeader="
+                  organizationProfile.isSchool
+                    ? 'Votre version actuelle'
+                    : undefined
+                "
+                color="school"
+                :list="listCheck.school"
+              >
+                <template #card.action>
+                  <v-row>
+                    <v-col cols="12">
+                      <v-btn
+                        v-if="!organizationProfile.isSchool"
+                        class="theme-school btn"
+                        href="https://logiciels.opentalent.fr/opentalent-school"
                         target="_blank"
                       >
-                        <b>{{ $t('download_order_form') }}</b>
-                      </a>
-                    </p>
-                  </v-col>
-                </v-row>
-              </v-col>
-            </v-row>
-          </v-container>
-        </UiExpansionPanel>
-      </v-expansion-panels>
-    </v-col>
+                        {{ $t('to_know_more') }}
+                        <i class="fa-solid fa-greater-than small"></i>
+                      </v-btn>
+                    </v-col>
+                    <v-col cols="12">
+                      <span class="special_conditions">
+                        *Extranet disponible uniquement dans la version
+                        Opentalent School Premium
+                      </span>
+                    </v-col>
+                  </v-row>
+                </template>
+              </LayoutPagesSubscriptionCard>
+            </v-col>
+          </v-row>
+        </v-container>
+      </UiExpansionPanel>
+
+      <UiExpansionPanel title="opentalent_options" icon="fas fa-plus">
+        <v-container fluid class="container card-container">
+          <v-row cols="12">
+            <v-col lg="3" sm="12" md="6">
+              <LayoutPagesSubscriptionCard
+                class="optionsCard"
+                title="SMS"
+                sub-title="Option payante"
+                color="primary"
+                :list="listCheck.sms"
+              >
+                <template #card.action>
+                  <v-btn
+                    class="theme-primary btn"
+                    :href="
+                      runtimeConfig.public.fileStorageBaseUrl +
+                      (organizationProfile.isCmf
+                        ? '/Bon_de_commande/Achat_SMS_CMF.pdf'
+                        : '/Bon_de_commande/Achat_SMS_Public.pdf')
+                    "
+                    target="_blank"
+                  >
+                    acheter des credits SMS
+                    <i class="fa-solid fa-greater-than small"></i>
+                  </v-btn>
+                </template>
+              </LayoutPagesSubscriptionCard>
+            </v-col>
+
+            <v-col lg="3" sm="12" md="6">
+              <LayoutPagesSubscriptionCard
+                class="optionsCard"
+                title="Nom de domaine"
+                sub-title="Option payante"
+                color="primary"
+                :list="listCheck.domain"
+              >
+                <template #card.action>
+                  <v-btn
+                    class="theme-primary btn"
+                    :href="
+                      runtimeConfig.public.fileStorageBaseUrl +
+                      '/Bon_de_commande/Nom_de_domaine.pdf'
+                    "
+                    target="_blank"
+                  >
+                    souscrire à l'option
+                    <i class="fa-solid fa-greater-than small"></i>
+                  </v-btn>
+                </template>
+              </LayoutPagesSubscriptionCard>
+            </v-col>
+          </v-row>
+        </v-container>
+      </UiExpansionPanel>
+    </v-expansion-panels>
   </LayoutContainer>
+
+  <LayoutDialogTrialAlreadyDid
+    :show="showDialogTrialAllReadyDid"
+    @closeDialog="showDialogTrialAllReadyDid = false"
+  />
+
+  <LayoutDialogTrialStopConfirmation
+    :show="showDialogTrialStopConfirmation"
+    @closeDialog="showDialogTrialStopConfirmation = false"
+    @stopTrial="stopTrial"
+  />
 </template>
 
 <script setup lang="ts">
@@ -511,130 +444,262 @@ import { useOrganizationProfileStore } from '~/stores/organizationProfile'
 import { useEntityFetch } from '~/composables/data/useEntityFetch'
 import DolibarrAccount from '~/models/Organization/DolibarrAccount'
 import MobytUserStatus from '~/models/Organization/MobytUserStatus'
+import UrlUtils from '~/services/utils/urlUtils'
+import { useDownloadFromRoute } from '~/composables/utils/useDownloadFromRoute'
+import { useApiLegacyRequestService } from '~/composables/data/useApiLegacyRequestService'
+import { usePageStore } from '~/stores/page'
 
-const ability = useAbility()
-
-const runtimeConfig = useRuntimeConfig()
-
+//meta
 definePageMeta({
   name: 'subscription_page',
 })
 
+//Get composables
+const ability = useAbility()
+const runtimeConfig = useRuntimeConfig()
+const { mdAndUp, md } = useDisplay()
+const { fetch } = useEntityFetch()
+const i18n = useI18n()
+const { apiRequestService } = useApiLegacyRequestService()
+
+//Init ref
+const showDialogTrialAllReadyDid: Ref<boolean> = ref(false)
+const showDialogTrialStopConfirmation: Ref<boolean> = ref(false)
+const openedPanels: Ref<Array<string>> = initPanel()
+const organizationProfile = getOrganizationProfile()
+const accessProfileStore = useAccessProfileStore()
+const { mobytStatus, mobytPending } = getMobytInformations()
+
+const { data: dolibarrAccount, pending: dolibarrPending } = fetch(
+  DolibarrAccount,
+  organizationProfile.id,
+)
+
 const showDolibarrPanel = computed(
   () =>
     !dolibarrPending.value &&
     dolibarrAccount.value &&
     dolibarrAccount.value.bills.length > 0,
 )
-
-const { smAndUp } = useDisplay()
-
-// On déplie les expansion panels dans le onMounted en attendant la résolution du bug : https://github.com/vuetifyjs/vuetify/issues/16427#issuecomment-1380927133
-// TODO: quand le bug ci dessus est résolu, remplacer par `const openedPanels: Ref<Array<string>> = ref(['informations', 'bills', 'more_features'])`
-const openedPanels: Ref<Array<string>> = ref([])
-onMounted(() => {
-  openedPanels.value = ['informations', 'bills', 'more_features']
-})
-
-const i18n = useI18n()
-const organizationProfile = useOrganizationProfileStore()
-if (organizationProfile.id === null) {
-  throw new Error("Missing organization's id")
+const formatCurrency = (value: number, currency: string): string => {
+  return value.toLocaleString(i18n.locale.value, {
+    style: 'currency',
+    currency,
+  })
 }
 
-const { fetch } = useEntityFetch()
+const listCheck: Record<string, Array<string>> = {
+  artist: [
+    '100 Mo de stockage',
+    '75 comptes utilisateurs',
+    'Gestion de la structure',
+    'Site internet restreint',
+    'Options disponibles',
+  ],
+  artistPremium: [
+    '1Go de stockage',
+    '150 comptes utilisateurs',
+    'Gestion de la structure',
+    'Module de communication',
+    'Site internet illimité',
+    'Options disponibles',
+  ],
+  school: [
+    '500 Mo ou 1Go de stockage',
+    '3 comptes administrateurs',
+    'Extranet élèves, tuteurs, professeurs*',
+    'Gestion de la structure',
+    'Suivi pédagogique, facturation, ...',
+    'Site internet complet',
+    'Options adaptées à chaque structure',
+  ],
+  sms: [
+    'Envoyez des SMS depuis votre logiciel',
+    'Choisissez le nombre de crédits',
+  ],
+  domain: [
+    'Bénéficiez de votre propre nom de domaine',
+    "Et d'une adresse mail personnalisée",
+  ],
+}
 
-const { data: dolibarrAccount, pending: dolibarrPending } = fetch(
-  DolibarrAccount,
-  organizationProfile.id,
-)
+/**
+ * Initialisation des panels ouverts
+ */
+function initPanel(): Ref<Array<string>> {
+  // On déplie les expansion panels dans le onMounted en attendant la résolution du bug : https://github.com/vuetifyjs/vuetify/issues/16427#issuecomment-1380927133
+  // TODO: quand le bug ci dessus est résolu, remplacer par `const openedPanels: Ref<Array<string>> = ref([...])`
+  const openedPanels: Ref<Array<string>> = ref([])
+  onMounted(() => {
+    openedPanels.value = [
+      'subscription_page',
+      'service_detail',
+      'bills',
+      'opentalent_offers',
+      'opentalent_options',
+    ]
+  })
+  return openedPanels
+}
 
-let mobytStatus: Ref<MobytUserStatus | null>
-let mobytPending: Ref<boolean>
+/**
+ * Récupération de l'organization profile
+ */
+function getOrganizationProfile() {
+  const organizationProfile = useOrganizationProfileStore()
+  if (organizationProfile.id === null) {
+    throw new Error("Missing organization's id")
+  }
+  return organizationProfile
+}
 
-if (ability.can('manage', 'texto')) {
-  const fetchMobytStatus = () => {
+/**
+ * Récupération des informations Mobyt
+ */
+function getMobytInformations(): {
+  mobytStatus: Ref<MobytUserStatus | null>
+  mobytPending: Ref<boolean>
+} {
+  let mobytStatus: Ref<MobytUserStatus | null> = ref(null)
+  let mobytPending: Ref<boolean> = ref(false)
+
+  if (ability.can('manage', 'texto')) {
     const { data, pending } = fetch(
       MobytUserStatus,
       organizationProfile!.id!,
     ) as AsyncData<MobytUserStatus | null, Error | null>
-
     mobytStatus = data
     mobytPending = pending
   }
-  fetchMobytStatus()
-} else {
-  mobytStatus = ref(null)
-  mobytPending = ref(false)
+
+  return { mobytStatus, mobytPending }
 }
 
-const formatCurrency = (value: number, currency: string): string => {
-  return value.toLocaleString(i18n.locale.value, {
-    style: 'currency',
-    currency,
+/**
+ * Action lorsque l'on souhaite démarrer l'essai
+ */
+async function startTrial() {
+  try {
+    await apiRequestService.get('/trial/is_available')
+    const v1BaseURL =
+      runtimeConfig.baseUrlAdminLegacy ||
+      runtimeConfig.public.baseUrlAdminLegacy
+    await navigateTo(UrlUtils.join(v1BaseURL, '#', 'trial'), {
+      external: true,
+    })
+  } catch (error) {
+    showDialogTrialAllReadyDid.value = true
+  }
+}
+
+/**
+ * Action lorsque l'on souhaite souscrire à artist premium
+ */
+async function subscription() {
+  const v1BaseURL =
+    runtimeConfig.baseUrlAdminLegacy || runtimeConfig.public.baseUrlAdminLegacy
+  await navigateTo(UrlUtils.join(v1BaseURL, '#', 'subscribe'), {
+    external: true,
   })
 }
 
-// Compute the number of columns of the more_features pannel
-const nbCols =
-  2 +
-  (organizationProfile.isArtist ? 1 : 0) +
-  (organizationProfile.isArtistProduct ? 1 : 0)
-const colWidth = 12 / nbCols
-</script>
+/**
+ * Action lorsque l'on souhaite afficher la modal de confirmation pour stopper
+ */
+function showStopTrialDialog() {
+  showDialogTrialStopConfirmation.value = true
+}
 
-<style scoped lang="scss">
-#products-section {
-  width: 100%;
-
-  .v-col {
-    min-width: 260px;
-    border: solid 1px rgb(var(--v-theme-on-primary));
-
-    .v-row:nth-child(1) {
-      //background: rgb(var(--v-theme-neutral-soft));
-      height: 64px;
-      color: rgb(var(--v-theme-on-neutral-soft));
-      //font-size: 15px;
-      font-weight: bold;
-      border-bottom: solid 1px rgb(var(--v-theme-neutral));
-    }
+/**
+ * Action lorsque l'on souhaite stopper l'essai
+ */
+async function stopTrial() {
+  usePageStore().loading = true
+  await apiRequestService.post('/trial/stop')
+  const v1BaseURL =
+    runtimeConfig.baseUrlAdminLegacy || runtimeConfig.public.baseUrlAdminLegacy
+  await navigateTo(UrlUtils.join(v1BaseURL, '#', 'dashboard'), {
+    external: true,
+  })
+}
 
-    .v-row:nth-child(2) {
-      height: 230px;
-      display: flex;
-      justify-content: center;
-      border-bottom: solid 1px rgb(var(--v-theme-neutral));
-    }
+const downloadDolibarrBill = (ref: string): void => {
+  const route = UrlUtils.join('api/dolibarr/download/invoice', ref)
 
-    .v-row:nth-child(3) {
-    }
-  }
+  useDownloadFromRoute(route, `${ref}.pdf`)
+}
+</script>
 
-  .v-col:not(:first-child) {
-    border-left: none;
-  }
+<style scoped lang="scss">
+.clickable {
+  cursor: pointer;
+  text-decoration: underline;
+}
 
-  img {
-    max-height: 90%;
-    max-width: 90%;
+.offer_title {
+  span {
+    border-radius: 5px;
+    display: block;
+    font-weight: bold;
+    text-align: center;
+    padding: 5px;
   }
+}
 
+.card-container {
   .v-row {
-    padding: 12px 18px;
-    vertical-align: top;
-    border-bottom: solid 1px rgb(var(--v-theme-on-primary));
+    display: -webkit-box;
+    display: -webkit-flex;
+    display: -ms-flexbox;
+    display: flex;
+    flex-wrap: wrap;
   }
-  .v-row:last-child {
-    border: none;
+  .v-row > [class*='v-col-'] {
+    display: flex;
+    flex-direction: column;
   }
-
-  p {
-    margin-bottom: 12px;
+  .small {
+    font-size: 6px;
+    padding-top: 5px;
+    padding-left: 5px;
+  }
+  .priceBlock {
+    font-size: 15px;
+    font-weight: normal;
+    text-transform: none;
+    .price {
+      text-transform: uppercase;
+      font-size: 30px;
+      font-weight: bold;
+    }
   }
+}
+
+.special_conditions {
+  font-size: 10px;
+  font-style: italic;
+}
 
-  ul {
-    padding-left: 24px;
+.trialBtn {
+  color: #000;
+  border: 1px solid rgb(var(--v-theme-artist));
+}
+
+.btn{
+  font-size: 12px;
+}
+
+.optionsCard {
+  :deep(.margin-sup) {
+    margin-top: 0;
   }
 }
+
+.stop_btn {
+  color: rgb(var(--v-theme-danger));
+}
+
+.plus_btn {
+  color: rgb(var(--v-theme-on-neutral));
+}
 </style>

+ 8 - 6
plugins/init.server.ts

@@ -8,7 +8,8 @@ export default defineNuxtPlugin(async () => {
 
   const bearer: CookieRef<string | null> = useCookie('BEARER') ?? null
   const accessCookieId: CookieRef<string | null> = useCookie('AccessId') ?? null
-  const switchId: CookieRef<string | null> = useCookie('SwitchAccessId') ?? null
+  const switchCookieId: CookieRef<string | null> =
+    useCookie('SwitchAccessId') ?? null
 
   if (accessCookieId.value === null || Number.isNaN(accessCookieId.value)) {
     redirectToLogout()
@@ -21,14 +22,15 @@ export default defineNuxtPlugin(async () => {
     return
   }
 
+  let switchId: number | null = parseInt(switchCookieId.value ?? '')
+  if (isNaN(switchId)) {
+    switchId = null
+  }
+
   const { initiateProfile } = useRefreshProfile()
 
   try {
-    await initiateProfile(
-      accessId,
-      bearer.value ?? '',
-      switchId.value !== null ? parseInt(switchId.value) : null,
-    )
+    await initiateProfile(accessId, bearer.value ?? '', switchId)
   } catch (error) {
     if (error instanceof UnauthorizedError) {
       redirectToLogout()

+ 0 - 1
regex_pattern.txt

@@ -1 +0,0 @@
-(?!typo3/).*

+ 67 - 0
services/data/Filters/InArrayFilter.ts

@@ -0,0 +1,67 @@
+import type { Query as PiniaOrmQuery } from 'pinia-orm'
+import type { Ref } from 'vue'
+import type { ApiFilter } from '~/types/data'
+import ApiResource from '~/models/ApiResource'
+import AbstractFilter from '~/services/data/Filters/AbstractFilter'
+import RefUtils from '~/services/utils/refUtils'
+
+export default class InArrayFilter extends AbstractFilter implements ApiFilter {
+  field: string
+  filterValue:
+    | Array<string | number | null>
+    | Ref<Array<string | number | null>>
+    | null
+
+  /**
+   * @param field
+   * @param value
+   * @param reactiveFilter
+   */
+  constructor(
+    field: string,
+    value:
+      | Array<string | number | null>
+      | Ref<Array<string | number | null>>
+      | null,
+    reactiveFilter: boolean = false,
+  ) {
+    super(reactiveFilter)
+    this.field = field
+    this.filterValue = value
+  }
+
+  public applyToPiniaOrmQuery(
+    query: PiniaOrmQuery<ApiResource>,
+  ): PiniaOrmQuery<ApiResource> {
+    const filterValue = RefUtils.castToRef(
+      this.filterValue,
+      this.reactiveFilter,
+    )
+
+    if (filterValue.value === null) {
+      return query
+    }
+
+    return query.whereIn(this.field, filterValue.value)
+  }
+
+  public getApiQueryPart(): string {
+    const filterValue = RefUtils.castToRef(
+      this.filterValue,
+      this.reactiveFilter,
+    )
+    if (filterValue.value === null) {
+      return ''
+    }
+
+    if (!Array.isArray(filterValue.value)) {
+      filterValue.value = [filterValue.value]
+    }
+
+    if (!filterValue.value.length > 0) {
+      return ''
+    }
+
+    return `${this.field}[in]=${filterValue.value.join(',')}`
+  }
+}

+ 1 - 1
services/data/Filters/OrderBy.ts

@@ -24,7 +24,7 @@ export default class OrderBy implements ApiFilter {
     query: PiniaOrmQuery<ApiResource>,
   ): PiniaOrmQuery<ApiResource> {
     return query.orderBy(
-      (instance) => StringUtils.normalize(instance[this.field]),
+      (instance) => StringUtils.normalize(instance[this.field] ?? ''),
       this.mode,
     )
   }

+ 8 - 0
services/data/Query.ts

@@ -26,6 +26,14 @@ export default class Query {
     return this
   }
 
+  /**
+   * Clear the query filters
+   */
+  public clear(): this {
+    this.filters = []
+    return this
+  }
+
   /**
    * Returns the URL's query in the Api Platform format.
    *

+ 18 - 12
services/data/entityManager.ts

@@ -65,19 +65,19 @@ class EntityManager {
     return this.getRepository(model).where((val) => Number.isInteger(val.id))
   }
 
+  public getModel(instance: ApiResource): typeof ApiResource {
+    return instance.constructor as typeof ApiModel
+  }
+
   /**
    * Cast an object as an ApiResource
    * This in used internally to ensure the object is recognized as an ApiResource
    *
    * @param model
    * @param instance
-   * @protected
    */
   // noinspection JSMethodCanBeStatic
-  protected cast(
-    model: typeof ApiResource,
-    instance: ApiResource,
-  ): ApiResource {
+  public cast(model: typeof ApiResource, instance: ApiResource): ApiResource {
     // eslint-disable-next-line new-cap
     return new model(instance)
   }
@@ -141,7 +141,7 @@ class EntityManager {
    *                  record is also updated.
    */
   public save(instance: ApiResource, permanent: boolean = false): ApiResource {
-    const model = instance.constructor as typeof ApiResource
+    const model = this.getModel(instance)
 
     this.validateEntity(instance)
 
@@ -266,7 +266,7 @@ class EntityManager {
    * @param instance
    */
   public async persist(instance: ApiModel) {
-    const model = instance.constructor as typeof ApiModel
+    const model = this.getModel(instance)
 
     let url = UrlUtils.join('api', model.entity)
     let response
@@ -315,18 +315,22 @@ class EntityManager {
     const body = JSON.stringify(data)
     const response = await this.apiRequestService.put(url, body)
 
-    const hydraResponse = await HydraNormalizer.denormalize(response, model)
+    const hydraResponse = HydraNormalizer.denormalize(response, model)
+
     return this.newInstance(model, hydraResponse.data)
   }
 
   /**
    * Delete the model instance from the datasource via the API
    *
-   * @param model
+   * @param instance
    * @param instance
    */
-  public async delete(instance: ApiResource) {
-    const model = instance.constructor as typeof ApiModel
+  public async delete(instance: ApiModel) {
+    const model = this.getModel(instance)
+    instance = this.cast(model, instance)
+
+    console.log('delete', instance)
 
     this.validateEntity(instance)
 
@@ -350,7 +354,9 @@ class EntityManager {
    * @param model
    * @param instance
    */
-  public reset(model: typeof ApiResource, instance: ApiResource) {
+  public reset(instance: ApiResource) {
+    const model = this.getModel(instance)
+
     const initialInstance = this.getInitialStateOf(model, instance.id)
     if (initialInstance === null) {
       throw new Error(

+ 3 - 3
services/data/imageManager.ts

@@ -1,5 +1,5 @@
 import ApiRequestService from './apiRequestService'
-import ImageUtils from '~/services/utils/imageUtils'
+import FileUtils from '~/services/utils/fileUtils'
 import { FILE_TYPE, FILE_VISIBILITY } from '~/types/enum/enums'
 
 /**
@@ -89,8 +89,8 @@ class ImageManager {
    * @protected
    */
   protected async toBase64(data: BlobPart) {
-    const blob = ImageUtils.newBlob(data)
-    return (await ImageUtils.blobToBase64(blob)) ?? ''
+    const blob = FileUtils.newBlob(data)
+    return (await FileUtils.blobToBase64(blob)) ?? ''
   }
 
   /**

+ 1 - 1
services/data/normalizer/hydraNormalizer.ts

@@ -171,7 +171,7 @@ class HydraNormalizer {
   }
 
   protected static denormalizeEntity(model: typeof ApiResource, item: AnyJson) {
-    item.id = this.getItemIdValue(model, item)
+    item['id'] = this.getItemIdValue(model, item)
 
     // eslint-disable-next-line new-cap
     const instance = new model(item)

+ 2 - 2
services/rights/abilityBuilder.ts

@@ -1,11 +1,11 @@
-import type { MongoAbility } from '@casl/ability/dist/types/Ability'
 // eslint-disable-next-line import/default
-import yaml from 'yaml-import'
+import * as yaml from 'yaml-import'
 import * as _ from 'lodash-es'
 import RoleUtils from '~/services/rights/roleUtils'
 import type { AbilitiesType, AccessProfile } from '~/types/interfaces'
 import { ABILITIES } from '~/types/enum/enums'
 import type OrganizationProfile from '~/models/Organization/OrganizationProfile'
+import type { MongoAbility } from '@casl/ability/dist/types/Ability'
 
 interface ConditionParameters {
   action: string

+ 3 - 3
services/utils/imageUtils.ts → services/utils/fileUtils.ts

@@ -1,9 +1,9 @@
 /**
  * Manipulation des images
  */
-class ImageUtils {
+class FileUtils {
   /**
-   * Returns a blob with the given data and the image filetype
+   * Returns a blob with the given data and the file's type
    *
    * @param data
    * @param filetype
@@ -24,4 +24,4 @@ class ImageUtils {
     })
   }
 }
-export default ImageUtils
+export default FileUtils

+ 2 - 2
services/utils/refUtils.ts

@@ -3,8 +3,8 @@ import { ref, isRef } from 'vue'
 
 export default class RefUtils {
   /**
-   * Convertit la valeur du filtre en référence. S'il s'agit déjà d'une ref,
-   * selon que `maintainReactivity` est vrai ou faux, on conserve la référence existante
+   * Convertit la valeur passée en référence.
+   * S'il s'agit déjà d'une ref, selon que `maintainReactivity` est vrai ou faux, on conserve la référence existante
    * ou bien on la recréé pour briser la réactivité.
    *
    * @param value

+ 12 - 0
stores/organizationProfile.ts

@@ -23,10 +23,14 @@ export const useOrganizationProfileStore = defineStore(
     const modules: Ref<Array<string>> = ref([])
     const hasChildren: Ref<boolean | null> = ref(false)
     const legalStatus: Ref<string | null> = ref(null)
+    const principalType: Ref<string | null> = ref(null)
     const showAdherentList: Ref<boolean | null> = ref(false)
     const networks: Ref<Array<string>> = ref([])
     const website: Ref<string | null> = ref(null)
     const parents: Ref<Array<BaseOrganizationProfile>> = ref([])
+    const isTrialActive: Ref<boolean> = ref(false)
+    const trialCountDown: Ref<number> = ref(0)
+    const productBeforeTrial: Ref<string | null> = ref(null)
 
     // Getters
     /**
@@ -178,8 +182,12 @@ export const useOrganizationProfileStore = defineStore(
       modules.value = Array.from(profile.modules)
       hasChildren.value = profile.hasChildren
       legalStatus.value = profile.legalStatus
+      principalType.value = profile.principalType
       showAdherentList.value = profile.showAdherentList
+      isTrialActive.value = profile.trialActive
+      trialCountDown.value = profile.trialCountDown
       networks.value = Array.from(profile.networks)
+      productBeforeTrial.value = profile.productBeforeTrial
 
       _.each(profile.parents, (parent) => {
         parents.value.push({
@@ -206,6 +214,7 @@ export const useOrganizationProfileStore = defineStore(
       modules,
       hasChildren,
       legalStatus,
+      principalType,
       showAdherentList,
       networks,
       website,
@@ -223,6 +232,9 @@ export const useOrganizationProfileStore = defineStore(
       isManagerProduct,
       isShowAdherentList,
       isAssociation,
+      isTrialActive,
+      trialCountDown,
+      productBeforeTrial,
       getWebsite,
       hasModule,
       setProfile,

+ 7 - 3
tests/units/services/data/entityManager.test.ts

@@ -732,7 +732,7 @@ describe('reset', () => {
 
     const initialEntity = new DummyApiModel()
     initialEntity.id = 1
-    initialEntity.name = 'serges'
+    initialEntity.name = 'serge'
 
     // @ts-ignore
     entityManager.getInitialStateOf = vi.fn(
@@ -750,7 +750,7 @@ describe('reset', () => {
     // @ts-ignore
     repo.save = vi.fn((data: any) => null)
 
-    const result = entityManager.reset(DummyApiModel, entity)
+    const result = entityManager.reset(initialEntity)
 
     // @ts-ignore
     expect(entityManager.getInitialStateOf).toHaveBeenCalledWith(
@@ -769,8 +769,12 @@ describe('reset', () => {
     entityManager.getInitialStateOf = vi.fn(
       (model: typeof ApiResource, id: string | number) => null,
     )
+    // @ts-ignore
+    entityManager.getModel = vi.fn(
+      (instance: ApiResource) => DummyApiModel,
+    )
 
-    expect(() => entityManager.reset(DummyApiModel, entity)).toThrowError(
+    expect(() => entityManager.reset(entity)).toThrowError(
       'no initial state recorded for this object - abort [dummyModel/1]',
     )
   })

+ 3 - 2
tests/units/services/rights/abilityBuilder.test.ts

@@ -37,9 +37,10 @@ const doc = {
     },
   },
 }
-vi.mock('yaml-import', async () => {
+
+vi.mock('yaml-import', () => {
   return {
-    default: { read: vi.fn((data: string) => doc) },
+    read: vi.fn((data: string) => doc),
   }
 })
 

+ 4 - 4
tests/units/services/utils/imageUtils.test.ts → tests/units/services/utils/fileUtils.test.ts

@@ -1,17 +1,17 @@
 import { describe, test, it, expect } from 'vitest'
-import ImageUtils from '~/services/utils/imageUtils'
+import FileUtils from '~/services/utils/fileUtils'
 import 'blob-polyfill'
 
 describe('newBlob', () => {
   test('defaultFiletype', async () => {
-    const blob = ImageUtils.newBlob('test')
+    const blob = FileUtils.newBlob('test')
 
     expect(await blob.text()).toEqual('test')
     expect(await blob.type).toEqual('image/jpeg')
   })
 
   test('otherFiletype', async () => {
-    const blob = ImageUtils.newBlob('test', 'image/png')
+    const blob = FileUtils.newBlob('test', 'image/png')
 
     expect(await blob.text()).toEqual('test')
     expect(await blob.type).toEqual('image/png')
@@ -22,7 +22,7 @@ describe('blobToBase64', () => {
   test('simple blog', async () => {
     const blob = new Blob(['foo' as BlobPart], { type: 'image/jpeg' })
 
-    expect(await ImageUtils.blobToBase64(blob)).toEqual(
+    expect(await FileUtils.blobToBase64(blob)).toEqual(
       'data:image/jpeg;base64,Zm9v',
     )
   })

+ 6 - 0
types/enum/enums.ts

@@ -105,3 +105,9 @@ export const enum LINK_TARGET {
   TOP = '_top',
   FRAMENAME = 'framename',
 }
+
+export const enum TABLE_ACTION {
+  EDIT = 'edit',
+  DELETE = 'delete',
+  ADD = 'add',
+}

+ 17 - 0
types/interfaces.d.ts

@@ -129,8 +129,12 @@ interface organizationState extends BaseOrganizationProfile {
   isSchool: boolean
   showAdherentList?: boolean | null
   legalStatus?: string | null
+  principalType?: string | null
   networks: Array<string>
   parents: Array<BaseOrganizationProfile>
+  isTrialActive: boolean
+  trialCountDown: number
+  productBeforeTrial?: string | null
 
   hasModule(module: string): boolean
 }
@@ -196,3 +200,16 @@ interface LayoutState {
   menus: Record<string, MenuGroup | MenuItem>
   menusOpened: Record<string, boolean>
 }
+
+interface ColumnDefinition {
+  /**
+   * The entity's property to display in this column
+   */
+  property: string
+  /**
+   * Label of the column.
+   * If not provided, a translation of the property's name will be looked for.
+   * If none is found, the property's name will be displayed as it is.
+   */
+  label?: string
+}

File diff suppressed because it is too large
+ 316 - 510
yarn.lock


Some files were not shown because too many files changed in this diff