瀏覽代碼

resolve merge conflicts

Olivier Massot 11 月之前
父節點
當前提交
0f1db72add
共有 73 個文件被更改,包括 1396 次插入1100 次删除
  1. 1 0
      .eslintrc.cjs
  2. 5 0
      components/Layout/AlertBar/Env.vue
  3. 7 5
      components/Layout/Header.vue
  4. 192 0
      components/Layout/Parameters/EntityTable.vue
  5. 0 0
      components/Layout/Parameters/ResidenceAreas.vue
  6. 179 0
      components/Layout/Parameters/Table.vue
  7. 9 4
      components/Layout/ParametersMenu.vue
  8. 10 36
      components/Ui/Button/Delete.vue
  9. 17 32
      components/Ui/DatePicker.vue
  10. 21 22
      components/Ui/Form.vue
  11. 1 1
      components/Ui/Form/Creation.vue
  12. 50 0
      components/Ui/Form/DeletionConfirmationDialog.vue
  13. 1 2
      components/Ui/Form/Edition.vue
  14. 4 4
      components/Ui/Input/Autocomplete.vue
  15. 60 68
      components/Ui/Input/Autocomplete/Accesses.vue
  16. 1 1
      components/Ui/Input/AutocompleteWithEnum.vue
  17. 8 7
      components/Ui/Input/DatePicker.vue
  18. 18 0
      components/Ui/Input/Email.vue
  19. 1 1
      components/Ui/Input/Number.vue
  20. 1 1
      components/Ui/Input/Text.vue
  21. 0 0
      components/Ui/LoadingPanel.client.vue
  22. 25 0
      composables/form/useDeleteItem.ts
  23. 12 0
      composables/utils/useRouteUtils.ts
  24. 4 0
      env/.env.ci
  25. 4 0
      env/.env.docker
  26. 4 0
      env/.env.prod
  27. 4 0
      env/.env.test
  28. 4 0
      env/.env.test1
  29. 4 0
      env/.env.test2
  30. 4 0
      env/.env.test3
  31. 4 0
      env/.env.test4
  32. 4 0
      env/.env.test5
  33. 4 0
      env/.env.test6
  34. 4 0
      env/.env.test7
  35. 4 0
      env/.env.test8
  36. 4 0
      env/.env.test9
  37. 73 0
      eslint.config.mjs
  38. 0 0
      i18n/i18n.config.ts
  39. 0 0
      i18n/lang/en.json
  40. 12 5
      i18n/lang/fr.json
  41. 0 0
      i18n/lang/fr.json.removed
  42. 8 8
      middleware/routing.global.ts
  43. 30 0
      models/Custom/Search/UserSearchItem.ts
  44. 4 4
      nuxt.config.ts
  45. 41 40
      package.json
  46. 1 2
      pages/my-settings.vue
  47. 6 1
      pages/parameters.vue
  48. 5 68
      pages/parameters/attendances.vue
  49. 1 5
      pages/parameters/bulletin.vue
  50. 7 12
      pages/parameters/education_notation.vue
  51. 4 71
      pages/parameters/education_timings/index.vue
  52. 1 3
      pages/parameters/general_parameters.vue
  53. 2 0
      pages/parameters/index.vue
  54. 3 3
      pages/parameters/intranet.vue
  55. 4 75
      pages/parameters/residence_areas/index.vue
  56. 13 3
      pages/parameters/sms.vue
  57. 4 5
      pages/parameters/subdomains/[id].vue
  58. 9 3
      pages/parameters/subdomains/new.vue
  59. 13 17
      pages/parameters/super_admin.vue
  60. 22 47
      pages/parameters/teaching.vue
  61. 20 15
      pages/parameters/website.vue
  62. 7 2
      plugins/init.server.ts
  63. 0 1
      regex_pattern.txt
  64. 61 0
      services/data/Filters/InArrayFilter.ts
  65. 1 1
      services/data/Filters/OrderBy.ts
  66. 8 0
      services/data/Query.ts
  67. 18 9
      services/data/entityManager.ts
  68. 2 2
      services/rights/abilityBuilder.ts
  69. 2 2
      services/utils/refUtils.ts
  70. 4 2
      tests/units/services/rights/abilityBuilder.test.ts
  71. 6 0
      types/enum/enums.ts
  72. 13 0
      types/interfaces.d.ts
  73. 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',

+ 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">

+ 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.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'
 

+ 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


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

@@ -0,0 +1,179 @@
+<!--
+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()

+ 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
 }

+ 21 - 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,9 @@ const closeConfirmationDialog = () => {
   formStore.setShowConfirmToLeave(false)
 }
 
+const emit = defineEmits(['update:model-value'])
+
+
 // ### Actions du formulaire
 /**
  * Soumet le formulaire
@@ -231,8 +227,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 +340,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 +359,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 +382,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 +422,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') }}

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

@@ -0,0 +1,50 @@
+<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 - 2
components/Ui/Form/Edition.vue

@@ -3,8 +3,7 @@
     <UiLoadingPanel v-if="pending" />
     <UiForm
       v-else
-      :model="model"
-      :entity="entity"
+      v-model="entity"
       :submitActions="submitActions"
     >
       <template #form.button>

+ 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',
   },
 })
 

+ 60 - 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,94 @@ 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)
+
+const pending = computed(() => pendingSearch.value || pendingActive.value)
 
-// On a déjà récupéré les access actifs, on relance une requête pour récupérer la première page
-// des accesses suivants
-refresh()
 
 /**
  * Contenu de la liste autocomplete
  */
-const items: ComputedRef<Array<AccessListItem>> = computed(() => {
-  if (pending.value || !collection.value) {
+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 +225,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()

+ 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


+ 25 - 0
composables/form/useDeleteItem.ts

@@ -0,0 +1,25 @@
+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
+  }
+}

+ 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 }
+}

+ 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/

+ 73 - 0
eslint.config.mjs

@@ -0,0 +1,73 @@
+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


+ 12 - 5
lang/fr.json → i18n/lang/fr.json

@@ -186,7 +186,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",
@@ -683,9 +683,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 +701,12 @@
   "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"
 }

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


+ 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
+}

+ 4 - 4
nuxt.config.ts

@@ -48,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: '',
@@ -59,7 +59,7 @@ export default defineNuxtConfig({
      baseUrlMercure: '',
      fileStorageBaseUrl: '',
      supportUrl: '',
-     basicomptaUrl: 'https://app.basicompta.fr/',
+     basicomptaUrl: '',
    },
  },
 
@@ -241,5 +241,5 @@ export default defineNuxtConfig({
    scripts: ['prepare/buildIndex.ts'],
  },
 
- compatibilityDate: '2025-02-07'
-})
+ 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 - 2
pages/my-settings.vue

@@ -11,8 +11,7 @@ Page 'Mes préférences'
               <UiLoadingPanel v-if="pending" />
               <UiForm
                 v-else
-                :model="Preferences"
-                :entity="preferences"
+                v-model="preferences"
                 action-position="bottom"
               >
                 <v-row>

+ 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 - 68
pages/parameters/attendances.vue

@@ -4,9 +4,7 @@
       <UiLoadingPanel v-if="pending" />
       <UiForm
         v-else-if="parameters !== null"
-        :model="Parameters"
-        :entity="parameters"
-        action-position="bottom"
+        v-model="parameters"
       >
         <v-row>
           <v-col cols="12">
@@ -38,56 +36,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 +69,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 - 5
pages/parameters/bulletin.vue

@@ -3,9 +3,7 @@
     <UiLoadingPanel v-if="pending" />
     <UiForm
       v-else
-      :model="Parameters"
-      :entity="parameters"
-      action-position="bottom"
+      v-model="parameters"
     >
       <v-row>
         <v-col cols="12">
@@ -58,14 +56,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 - 12
pages/parameters/education_notation.vue

@@ -3,9 +3,7 @@
     <UiLoadingPanel v-if="pending" />
     <UiForm
       v-else
-      :model="Parameters"
-      :entity="parameters"
-      action-position="bottom"
+      v-model="parameters"
     >
       <v-row>
         <v-col cols="12">
@@ -21,25 +19,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 +46,6 @@
             :min="1"
             :max="100"
             class="mt-2"
-            variant="underlined"
           />
         </v-col>
       </v-row>

+ 4 - 71
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,7 @@ 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>

+ 1 - 3
pages/parameters/general_parameters.vue

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

+ 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 - 3
pages/parameters/intranet.vue

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

+ 4 - 75
pages/parameters/residence_areas/index.vue

@@ -1,90 +1,19 @@
 <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>

+ 13 - 3
pages/parameters/sms.vue

@@ -2,15 +2,14 @@
   <div>
     <UiForm
       v-if="parameters"
-      :model="Parameters"
-      :entity="parameters"
-      action-position="bottom"
+      v-model="parameters"
     >
       <v-row>
         <v-col cols="12">
           <UiInputText
             v-model="parameters.smsSenderName"
             field="smsSenderName"
+            :rules="rules()"
             variant="underlined"
           />
         </v-col>
@@ -44,6 +43,8 @@ definePageMeta({
   name: 'parameters_sms_page',
 })
 
+const i18n = useI18n()
+
 const { fetch } = useEntityFetch()
 
 const organizationProfile = useOrganizationProfileStore()
@@ -56,6 +57,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)
 

+ 9 - 3
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,15 @@ 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 = {}

+ 13 - 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,23 @@
     <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 +69,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 {

+ 22 - 47
pages/parameters/teaching.vue

@@ -3,40 +3,15 @@
     <UiLoadingPanel v-if="pending" />
     <UiForm
       v-else-if="parameters !== null"
-      :model="Parameters"
-      :entity="parameters"
-      action-position="bottom"
+      v-model="parameters"
     >
-      <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>
+      <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,21 @@ const orderedCycles: ComputedRef<AnyJson> = computed(() => {
   return orderedCycles
 })
 
-const goToCycleEditPage = (id: number) => {
-  navigateTo(`/parameters/cycles/${id}`)
+const tableItems = computed(() => {
+  return cycleEnum.value?.map((item) => {
+    return {
+      value: item.value,
+      originalLabel: item.label,
+      effectiveLabel: (orderedCycles.value[item.value] ?? item).label,
+    }
+  }) || []
+})
+
+const goToCycleEditPage = (item: object) => {
+  const cycle = orderedCycles.value[item.value]
+  navigateTo(`/parameters/cycles/${cycle.id}`)
 }
 </script>
 
 <style scoped lang="scss">
-.v-table {
-  width: 100%;
-  max-width: 800px;
-}
-
-.cycle-edit-icon {
-  color: rgb(var(--v-theme-primary));
-}
-
-:deep(.cycle-edit-icon .v-icon) {
-  font-size: 18px;
-}
 </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));
 }

+ 7 - 2
plugins/init.server.ts

@@ -8,7 +8,7 @@ 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,13 +21,18 @@ 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,
+      switchId,
     )
   } catch (error) {
     if (error instanceof UnauthorizedError) {

+ 0 - 1
regex_pattern.txt

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

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

@@ -0,0 +1,61 @@
+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 - 9
services/data/entityManager.ts

@@ -65,16 +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(
+  public cast(
     model: typeof ApiResource,
     instance: ApiResource,
   ): ApiResource {
@@ -141,7 +144,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 +269,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 +318,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 +357,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(

+ 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

+ 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

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

@@ -37,12 +37,14 @@ 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)
   }
 })
 
+
 beforeEach(() => {
   ability = vi.fn() as any as MongoAbility
   accessProfile = vi.fn() as any as AccessProfile

+ 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',
+}

+ 13 - 0
types/interfaces.d.ts

@@ -196,3 +196,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
+}

文件差異過大導致無法顯示
+ 316 - 510
yarn.lock


部分文件因文件數量過多而無法顯示