Olivier Massot 3 месяцев назад
Родитель
Сommit
aab5b51388

+ 97 - 505
components/Ui/Input/TreeSelect.vue

@@ -1,12 +1,12 @@
 <!--
-Composant de sélection hiérarchique à plusieurs niveaux permettant de naviguer
-et sélectionner des éléments organisés en catégories et sous-catégories.
+Composant de sélection hiérarchique simplifié pour les arbres de ressources.
+Les items fournis sont déjà triés, traduits et structurés au format ResourceTreeContent.
 
 ## Exemple d'utilisation
 ```vue
 <TreeSelect
   v-model="selectedValues"
-  :items="hierarchicalItems"
+  :items="resourceTreeContent"
   :max-visible-chips="3"
   label="Sélectionner des éléments"
 />
@@ -15,7 +15,7 @@ et sélectionner des éléments organisés en catégories et sous-catégories.
 <template>
   <v-select
     :model-value="modelValue"
-    :label="$t(label)"
+    :label="label"
     v-bind="$attrs"
     :items="flattenedItems"
     item-title="label"
@@ -23,30 +23,8 @@ et sélectionner des éléments organisés en catégories et sous-catégories.
     :variant="variant"
     multiple
     :menu-props="{ maxHeight: 400 }"
-    @update:menu="onMenuUpdate"
     @update:model-value="$emit('update:modelValue', $event)"
   >
-    <template #prepend-item>
-      <!-- Champs de recherche textuelle -->
-      <v-text-field
-        ref="searchInput"
-        v-model="searchText"
-        density="compact"
-        hide-details
-        :placeholder="$t('search') + '...'"
-        prepend-inner-icon="fas fa-magnifying-glass"
-        variant="outlined"
-        clearable
-        class="mx-2 my-2"
-        @click.stop="focusSearchInput"
-        @mousedown.stop
-        @keydown.stop="onKeyDown"
-        @input="onSearchInputDebounced"
-        @click:clear.stop="onSearchClear"
-      />
-      <v-divider class="mt-2" />
-    </template>
-
     <template #selection="{ item, index }">
       <v-chip
         v-if="maxVisibleChips && index < maxVisibleChips"
@@ -54,7 +32,7 @@ et sélectionner des éléments organisés en catégories et sous-catégories.
         closable
         @click:close="removeItem(String(item.raw.value!))"
       >
-        {{ selectedItemsMap[item.raw.value] }}
+        {{ item.raw.label }}
       </v-chip>
       <span
         v-if="
@@ -64,7 +42,7 @@ et sélectionner des éléments organisés en catégories et sous-catégories.
         "
         class="text-grey text-caption align-self-center"
       >
-        (+{{ modelValue.length - maxVisibleChips }} {{ $t('others') }})
+        (+{{ modelValue.length - maxVisibleChips }} autres)
       </span>
     </template>
 
@@ -148,19 +126,16 @@ et sélectionner des éléments organisés en catégories et sous-catégories.
 </template>
 
 <script setup lang="ts">
-import StringUtils from '~/services/utils/stringUtils'
-import _ from 'lodash'
-import { ref, computed, nextTick, type PropType, type Ref } from 'vue'
-import type { TreeSelectItem } from '~/types/layout'
+import { ref, computed, type PropType, type Ref } from 'vue'
+import type { ResourceTreeContent } from '~/types/data'
 
-interface SelectItem {
+interface FlattenedItem {
   id: string
   label: string
-  normalizedLabel?: string
-  value?: number
+  value?: string
   type: 'category' | 'subcategory' | 'item'
-  parentId?: string
   level: number
+  parentId?: string
 }
 
 const props = defineProps({
@@ -169,7 +144,7 @@ const props = defineProps({
     required: true,
   },
   items: {
-    type: Array as PropType<TreeSelectItem[]>,
+    type: [Array, Object] as PropType<ResourceTreeContent>,
     required: true,
   },
   maxVisibleChips: {
@@ -177,23 +152,11 @@ const props = defineProps({
     required: false,
     default: null,
   },
-  nbLevel: {
-    type: Number,
-    required: false,
-    default: 3,
-  },
-  /**
-   * Label du champ
-   * Si non défini, c'est le nom de propriété qui est utilisé
-   */
   label: {
     type: String,
     required: false,
-    default: null,
+    default: '',
   },
-  /**
-   * @see https://vuetifyjs.com/en/api/v-autocomplete/#props-variant
-   */
   variant: {
     type: String as PropType<
       | 'filled'
@@ -210,131 +173,103 @@ const props = defineProps({
   },
 })
 
-const searchInput = ref()
-
-/**
- * Force le focus sur l'input de recherche
- */
-const focusSearchInput = () => {
-  nextTick(() => {
-    if (searchInput.value?.$el) {
-      const input = searchInput.value.$el.querySelector('input')
-      if (input) {
-        input.focus()
-      }
-    }
-  })
-}
-
-/**
- * Gère les événements clavier pour éviter les conflits avec la navigation du menu
- */
-const onKeyDown = (event: KeyboardEvent) => {
-  // Empêcher la propagation pour tous les caractères alphanumériques
-  // et les touches spéciales de navigation
-  if (
-    event.key.length === 1 || // Caractères simples (a, c, etc.)
-    ['Backspace', 'Delete', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(
-      event.key,
-    )
-  ) {
-    event.stopPropagation()
-  }
-
-  // Empêcher également Escape de fermer le menu quand on est dans l'input
-  if (event.key === 'Escape') {
-    event.stopPropagation()
-    // Optionnel : vider le texte de recherche
-    if (searchText.value) {
-      searchText.value = ''
-      onSearchClear()
-    }
-  }
-}
-/**
- * A computed property that normalizes the labels of all items upfront.
- * This avoids having to normalize labels during each search operation.
- */
-const normalizedItems = computed(() => {
-  return props.items.map((item) => ({
-    ...item,
-    normalizedLabel: StringUtils.normalize(item.label),
-  }))
-})
-
 const emit = defineEmits(['update:modelValue'])
 
 const expandedCategories: Ref<Set<string>> = ref(new Set())
 const expandedSubcategories: Ref<Set<string>> = ref(new Set())
-const searchText: Ref<string> = ref('')
 
 /**
- * Expands all parent categories and subcategories of selected items.
+ * Flatten the ResourceTreeContent into a flat array for v-select
  */
-const expandParentsOfSelectedItems = () => {
-  expandedCategories.value.clear()
-  expandedSubcategories.value.clear()
-
-  for (const selectedId of props.modelValue) {
-    const item = normalizedItems.value.find(
-      (i) => i.value === Number(selectedId),
-    )
-    if (!item) continue
-
-    let parentId = null
-    if (props.nbLevel === 3) {
-      // Trouver la sous-catégorie
-      const subcategory = normalizedItems.value.find(
-        (i) => i.id === item.parentId,
-      )
-      if (subcategory) {
-        expandedSubcategories.value.add(subcategory.id)
-        parentId = subcategory.parentId
-      }
-    } else {
-      parentId = item.parentId
-    }
-
-    if (parentId) {
-      // Trouver la catégorie
-      const category = normalizedItems.value.find((i) => i.id === parentId)
-      if (category) {
-        expandedCategories.value.add(category.id)
+const flattenedItems = computed(() => {
+  const items: FlattenedItem[] = []
+  let itemCounter = 0
+
+  if (Array.isArray(props.items)) {
+    // Simple array of strings
+    props.items.forEach((item) => {
+      items.push({
+        id: `item-${itemCounter++}`,
+        label: item,
+        value: item,
+        type: 'item',
+        level: 0,
+      })
+    })
+  } else {
+    // Object structure (2 or 3 levels)
+    Object.entries(props.items).forEach(([categoryKey, categoryValue]) => {
+      const categoryId = `category-${categoryKey}`
+
+      // Add category
+      items.push({
+        id: categoryId,
+        label: categoryKey,
+        type: 'category',
+        level: 0,
+      })
+
+      if (Array.isArray(categoryValue)) {
+        // 2-level structure: Record<string, string[]>
+        if (expandedCategories.value.has(categoryId)) {
+          categoryValue.forEach((item) => {
+            items.push({
+              id: `item-${itemCounter++}`,
+              label: item,
+              value: item,
+              type: 'item',
+              level: 1,
+              parentId: categoryId,
+            })
+          })
+        }
+      } else {
+        // 3-level structure: Record<string, Record<string, string[]>>
+        if (expandedCategories.value.has(categoryId)) {
+          Object.entries(categoryValue).forEach(([subcategoryKey, subcategoryValue]) => {
+            const subcategoryId = `subcategory-${categoryKey}-${subcategoryKey}`
+
+            // Add subcategory
+            items.push({
+              id: subcategoryId,
+              label: subcategoryKey,
+              type: 'subcategory',
+              level: 1,
+              parentId: categoryId,
+            })
+
+            if (expandedSubcategories.value.has(subcategoryId)) {
+              subcategoryValue.forEach((item) => {
+                items.push({
+                  id: `item-${itemCounter++}`,
+                  label: item,
+                  value: item,
+                  type: 'item',
+                  level: 2,
+                  parentId: subcategoryId,
+                })
+              })
+            }
+          })
+        }
       }
-    }
+    })
   }
-}
 
-/**
- * A callback function that is triggered when the menu's open state is updated.
- */
-const onMenuUpdate = (isOpen: boolean) => {
-  if (isOpen) {
-    expandParentsOfSelectedItems()
-  }
-  // Réinitialiser la recherche quand le menu se ferme
-  else if (searchText.value) {
-    searchText.value = ''
-    onSearchInput()
-  }
-}
+  return items
+})
 
 /**
- * Toggles the expanded state of a given category. If the category is currently
- * expanded, it will collapse the category and also collapse its subcategories.
- * If the category is not expanded, it will expand the category.
- *
- * @param {string} categoryId - The unique identifier of the category to toggle.
+ * Toggle category expansion
  */
 const toggleCategory = (categoryId: string) => {
   if (expandedCategories.value.has(categoryId)) {
     expandedCategories.value.delete(categoryId)
-    // Fermer aussi les sous-catégories
-    const subcategories = normalizedItems.value.filter(
-      (i) => i.parentId === categoryId && i.type === 'subcategory',
-    )
-    subcategories.forEach((sub) => {
-      expandedSubcategories.value.delete(sub.id)
+    // Also collapse all subcategories
+    Array.from(expandedSubcategories.value).forEach(subId => {
+      if (subId.startsWith(`subcategory-${categoryId.replace('category-', '')}-`)) {
+        expandedSubcategories.value.delete(subId)
+      }
     })
   } else {
     expandedCategories.value.add(categoryId)
@@ -342,9 +277,7 @@ const toggleCategory = (categoryId: string) => {
 }
 
 /**
- * Toggles the expansion state of a subcategory.
- *
- * @param {string} subcategoryId - The unique identifier of the subcategory to be toggled.
+ * Toggle subcategory expansion
  */
 const toggleSubcategory = (subcategoryId: string) => {
   if (expandedSubcategories.value.has(subcategoryId)) {
@@ -355,10 +288,7 @@ const toggleSubcategory = (subcategoryId: string) => {
 }
 
 /**
- * A function that toggles the inclusion of a specific value in
- * the selected items list.
- *
- * @param {string} value - The value to toggle in the selected items list.
+ * Toggle item selection
  */
 const toggleItem = (value: string) => {
   const currentSelection = [...props.modelValue]
@@ -374,11 +304,7 @@ const toggleItem = (value: string) => {
 }
 
 /**
- * Removes the specified item from the model value and emits an update event.
- *
- * @param {string} value - The item to be removed from the model value.
- * @emits update:modelValue - A custom event emitted with the updated model value
- * after the specified item has been removed.
+ * Remove item from selection
  */
 const removeItem = (value: string) => {
   emit(
@@ -386,338 +312,4 @@ const removeItem = (value: string) => {
     props.modelValue.filter((item) => item !== value),
   )
 }
-
-/**
- * Fonction appellée lorsque l'input de recherche textuelle est modifié
- */
-const onSearchInput = () => {
-  // Réinitialiser les états d'expansion dans tous les cas
-  expandedCategories.value.clear()
-  expandedSubcategories.value.clear()
-
-  if (searchText.value.trim()) {
-    // Trouver tous les éléments qui correspondent à la recherche
-    const matchingItems = normalizedItems.value.filter(
-      (item) =>
-        item.type === 'item' &&
-        item.level === props.nbLevel - 1 &&
-        itemMatchesSearch(item),
-    )
-    // Pour chaque élément correspondant, ajouter ses parents aux ensembles d'expansion
-    for (const item of matchingItems) {
-      let category
-      if (props.nbLevel === 3) {
-        // Trouver et ajouter la sous-catégorie parente
-        const subcategory = normalizedItems.value.find(
-          (i) => i.id === item.parentId,
-        )
-        if (subcategory) {
-          expandedSubcategories.value.add(subcategory.id)
-
-          // Trouver et ajouter la catégorie parente
-          category = normalizedItems.value.find(
-            (i) => i.id === subcategory.parentId,
-          )
-        }
-      } else {
-        // Trouver et ajouter la catégorie parente
-        category = normalizedItems.value.find((i) => i.id === item.parentId)
-      }
-
-      if (category) {
-        expandedCategories.value.add(category.id)
-      }
-    }
-  }
-}
-
-const onSearchInputDebounced = _.debounce(onSearchInput, 200)
-
-const onSearchClear = () => {
-  searchText.value = ''
-  onSearchInput()
-}
-
-/**
- * Checks if any word in the normalized text starts with the normalized search term.
- *
- * @param {string} normalizedText - The normalized text to check.
- * @param {string} normalizedSearch - The normalized search term.
- * @returns {boolean} `true` if any word in the text starts with the search term; otherwise, `false`.
- */
-const anyWordStartsWith = (
-  normalizedText: string,
-  normalizedSearch: string,
-): boolean => {
-  if (normalizedText.indexOf(normalizedSearch) === 0) return true
-
-  const spaceIndex = normalizedText.indexOf(' ')
-  if (spaceIndex === -1) return false
-
-  return normalizedText
-    .split(' ')
-    .some((word) => word.startsWith(normalizedSearch))
-}
-
-/**
- * Determines if a given item matches the current search text by checking its normalized label
- * and, for certain items, the normalized labels of its parent elements.
- *
- * The search text is normalized using `StringUtils.normalize` before comparison.
- * If no search text is provided, the item matches by default.
- *
- * For items of type `item` at level 2, the function checks:
- * - The normalized label of the item itself
- * - The normalized label of its parent subcategory
- * - The normalized label of the grandparent category (if applicable)
- *
- * For all other item types, only the item's normalized label is checked.
- *
- * The matching is done by checking if any word in the normalized label starts with the normalized search text.
- *
- * @param {TreeSelectItem} item - The item to evaluate against the search text.
- * @returns {boolean} `true` if the item or its relevant parent(s) match the search text; otherwise, `false`.
- */
-const itemMatchesSearch = (item: TreeSelectItem): boolean => {
-  if (!searchText.value) return true
-
-  const normalizedSearch = StringUtils.normalize(searchText.value)
-
-  // Find the item with normalized label from our computed property
-  const itemWithNormalizedLabel = normalizedItems.value.find(
-    (i) => i.id === item.id,
-  )
-  if (!itemWithNormalizedLabel) return false
-
-  // Si c'est un élément de niveau nbLevel - 1, vérifier son label et les labels de ses parents
-  if (item.type === 'item' && item.level === props.nbLevel - 1) {
-    // Vérifier le label de l'élément
-    if (
-      anyWordStartsWith(
-        itemWithNormalizedLabel.normalizedLabel!,
-        normalizedSearch,
-      )
-    )
-      return true
-
-    let parentId = item.parentId
-    if (props.nbLevel === 3) {
-      // Trouver et vérifier le label de la sous-catégorie parente
-      const subcategory = normalizedItems.value.find((i) => i.id === parentId)
-      if (
-        subcategory &&
-        anyWordStartsWith(subcategory.normalizedLabel!, normalizedSearch)
-      )
-        return true
-
-      // Trouver et vérifier le label de la catégorie parente
-      if (subcategory && subcategory.parentId) {
-        parentId = subcategory.parentId
-      }
-    }
-
-    // Trouver et vérifier le label de la catégorie parente
-    const category = normalizedItems.value.find((i) => i.id === parentId)
-    if (
-      category &&
-      anyWordStartsWith(category.normalizedLabel!, normalizedSearch)
-    )
-      return true
-
-    return false
-  }
-
-  // Pour les autres types d'éléments, vérifier simplement leur label
-  return anyWordStartsWith(
-    itemWithNormalizedLabel.normalizedLabel!,
-    normalizedSearch,
-  )
-}
-
-/**
- * Filtre les éléments de niveau 2 qui correspondent au texte de recherche.
- *
- * @returns {TreeSelectItem[]} Les éléments de niveau 2 qui correspondent à la recherche.
- */
-const findMatchingLevel2Items = (): TreeSelectItem[] => {
-  return normalizedItems.value.filter(
-    (item) =>
-      item.type === 'item' &&
-      item.level === props.nbLevel - 1 &&
-      itemMatchesSearch(item),
-  )
-}
-
-/**
- * Construit une liste hiérarchique d'éléments basée sur les résultats de recherche.
- * Pour chaque élément correspondant, ajoute sa hiérarchie complète (catégorie et sous-catégorie).
- *
- * @param {TreeSelectItem[]} matchingItems - Les éléments correspondant à la recherche.
- * @returns {TreeSelectItem[]} Liste hiérarchique incluant les éléments et leurs parents.
- */
-const buildSearchResultsList = (
-  matchingItems: TreeSelectItem[],
-): TreeSelectItem[] => {
-  const result: TreeSelectItem[] = []
-  const addedCategoryIds = new Set<string>()
-  const addedSubcategoryIds = new Set<string>()
-
-  for (const item of matchingItems) {
-    let parentId = item.parentId
-    if (props.nbLevel === 3) {
-      // Trouver la sous-catégorie parente
-      const subcategory = normalizedItems.value.find((i) => i.id === parentId)
-      if (!subcategory) continue
-
-      // Ajouter la sous-catégorie si elle n'est pas déjà présente
-      if (!addedSubcategoryIds.has(subcategory.id)) {
-        result.push(subcategory)
-        addedSubcategoryIds.add(subcategory.id)
-        expandedSubcategories.value.add(subcategory.id)
-      }
-      parentId = subcategory.parentId
-    }
-
-    // Trouver la catégorie parente
-    const category = normalizedItems.value.find((i) => i.id === parentId)
-    if (!category) continue
-
-    // Ajouter la catégorie si elle n'est pas déjà présente
-    if (!addedCategoryIds.has(category.id)) {
-      result.push(category)
-      addedCategoryIds.add(category.id)
-      expandedCategories.value.add(category.id)
-    }
-
-    // Ajouter l'élément
-    result.push(item)
-  }
-
-  return result
-}
-
-/**
- * Traite récursivement les éléments pour construire une liste hiérarchique
- * basée sur l'état d'expansion des catégories et sous-catégories.
- *
- * @param {TreeSelectItem[]} items - Les éléments à traiter.
- * @param {TreeSelectItem[]} result - Le tableau résultat à remplir.
- * @param {boolean} parentExpanded - Indique si le parent est développé.
- */
-const processItemsRecursively = (
-  items: TreeSelectItem[],
-  result: TreeSelectItem[],
-  parentExpanded = true,
-): void => {
-  for (const item of items) {
-    if (item.type === 'category') {
-      result.push(item)
-      if (expandedCategories.value.has(item.id)) {
-        const subcategories = normalizedItems.value.filter(
-          (i) =>
-            i.parentId === item.id &&
-            ((props.nbLevel == 2 && i.type === 'item') ||
-              (props.nbLevel == 3 && i.type === 'subcategory')),
-        )
-        processItemsRecursively(subcategories, result, true)
-      }
-    } else if (item.type === 'subcategory') {
-      if (parentExpanded) {
-        result.push(item)
-        if (expandedSubcategories.value.has(item.id)) {
-          const subItems = normalizedItems.value.filter(
-            (i) => i.parentId === item.id && i.type === 'item',
-          )
-          processItemsRecursively(subItems, result, true)
-        }
-      }
-    } else if (item.type === 'item' && parentExpanded) {
-      result.push(item)
-    }
-  }
-}
-
-/**
- * Construit une liste hiérarchique d'éléments en mode normal (sans recherche).
- *
- * @returns {TreeSelectItem[]} Liste hiérarchique basée sur l'état d'expansion.
- */
-const buildNormalModeList = (): TreeSelectItem[] => {
-  const result: TreeSelectItem[] = []
-  const topLevelItems = normalizedItems.value.filter((item) => !item.parentId)
-  processItemsRecursively(topLevelItems, result)
-  return result
-}
-
-/**
- * A computed property that generates a flattened and organized list of items
- * from a hierarchical structure, based on the current search text and
- * expanded categories/subcategories.
- *
- * @returns {TreeSelectItem[]} Flattened and organized list of items.
- */
-const flattenedItems = computed(() => {
-  const hasSearch = !!searchText.value.trim()
-
-  if (hasSearch) {
-    const matchingItems = findMatchingLevel2Items()
-    return buildSearchResultsList(matchingItems)
-  }
-
-  return buildNormalModeList()
-})
-
-/**
- * A computed property that maps selected values to their corresponding labels.
- * This is used to display the correct labels in the chips when the dropdown is closed.
- *
- * @returns {Record<string, string>} A map of selected values to their labels.
- */
-const selectedItemsMap = computed(() => {
-  const map: Record<string, string> = {}
-
-  // Find all selectable items (type 'item') in the items array with normalized labels
-  const selectableItems = normalizedItems.value.filter(
-    (item) => item.type === 'item' && item.value,
-  )
-
-  // Create a map of values to labels
-  selectableItems.forEach((item) => {
-    if (item.value) {
-      map[item.value] = item.label
-    }
-  })
-
-  return map
-})
 </script>
-
-<style scoped lang="scss">
-.v-list-item--active {
-  background-color: rgba(var(--v-theme-primary), 0.1);
-}
-
-.v-list-item {
-  contain: layout style paint;
-  height: 40px !important; /* Ensure consistent height for virtual scrolling */
-  min-height: 40px !important;
-  max-height: 40px !important;
-  padding-top: 0 !important;
-  padding-bottom: 0 !important;
-}
-
-.search-icon {
-  color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
-}
-
-:deep(.v-field__prepend-inner) {
-  padding-top: 0;
-}
-
-:deep(.v-list) {
-  padding-top: 0;
-  contain: content;
-  will-change: transform;
-  transform-style: preserve-3d;
-}
-</style>

+ 1 - 1
components/Ui/Input/TreeSelect/EventCategories.vue

@@ -16,7 +16,7 @@ import { useEntityFetch } from '~/composables/data/useEntityFetch'
 import EventCategory from '~/models/Core/EventCategory'
 import { FETCHING_STATUS } from '~/types/enum/data'
 
-const props = defineProps({
+defineProps({
   modelValue: {
     type: Array as PropType<string[]>,
     required: true,

+ 105 - 0
components/Ui/Input/TreeSelect/Resource.vue

@@ -0,0 +1,105 @@
+<template>
+  <UiInputTreeSelect
+    :model-value="modelValue"
+    :items="content"
+    :label="$t(label)"
+    v-bind="$attrs"
+    :loading="status === FETCHING_STATUS.PENDING"
+    :max-visible-chips="maxVisibleChips"
+    :nb-level="nbLevel"
+    @update:model-value="$emit('update:modelValue', $event)"
+  />
+</template>
+
+<script setup lang="ts">
+import { useEntityFetch } from '~/composables/data/useEntityFetch'
+import { FETCHING_STATUS } from '~/types/enum/data'
+import type { PropType } from 'vue'
+import type ApiResource from '~/models/ApiResource'
+import { useI18nUtils } from '~/composables/utils/useI18nUtils'
+import ArrayUtils from '~/services/utils/arrayUtils'
+
+const props = defineProps({
+  modelValue: {
+    type: Array as PropType<string[]>,
+    required: true,
+  },
+  /**
+   * ResourceTree qui sera fetch
+   */
+  model: {
+    type: Object as PropType<typeof ApiResource>,
+    required: true,
+  },
+  /**
+   * Label du champ
+   * Si non défini, c'est le nom de propriété qui est utilisé
+   */
+  label: {
+    type: String,
+    required: false,
+    default: null,
+  },
+  /**
+   * Si true, traduit les items du ResourceTree
+   */
+  translate: {
+    type: Boolean,
+    default: true,
+  },
+  /**
+   * Si true, trie les items du ResourceTree par ordre alphabétique
+   */
+  sort: {
+    type: Boolean,
+    default: false,
+  },
+  /**
+   * Nombre de puces maximum à afficher
+   */
+  maxVisibleChips: {
+    type: Number,
+    required: false,
+    default: 6,
+  },
+  // TODO: revoir si nécessaire
+  nbLevel: {
+    type: Number,
+    required: false,
+    default: 3,
+    validator: (value: number) => {
+      return value > 0 && value <= 3
+    },
+  },
+})
+
+const i18nUtils = useI18nUtils()
+
+const { fetch } = useEntityFetch()
+
+const { data: tree, status } = fetch(props.model)
+
+const content = computed(
+  () => {
+    if (status.value !== FETCHING_STATUS.SUCCESS || !tree.value) {
+      return []
+    }
+
+    let content = tree.value.content
+
+    if (props.translate) {
+      content = i18nUtils.translateResourceTree(content)
+    }
+
+    if (props.sort) {
+      content = ArrayUtils.sortResourceTree(content)
+    }
+
+    return content
+  }
+)
+</script>
+
+<style scoped lang="scss">
+/* No specific styles needed */
+</style>

+ 77 - 54
components/Ui/Input/TreeSelect/TypeOfPractices.vue

@@ -16,8 +16,13 @@ import { useEntityFetch } from '~/composables/data/useEntityFetch'
 import EventCategory from '~/models/Core/EventCategory'
 import { FETCHING_STATUS } from '~/types/enum/data'
 import TypeOfPractice from '~/models/Organization/TypeOfPractice'
+import TypeOfPracticeTree from '~/models/Custom/ResourceTree/TypeOfPracticeTree'
+import I18nUtils from '~/services/utils/i18nUtils'
+import ArrayUtils from '~/services/utils/arrayUtils'
+import type { ResourceTree } from '~/types/data'
+import { useI18nUtils } from '~/composables/utils/useI18nUtils'
 
-const props = defineProps({
+defineProps({
   modelValue: {
     type: Array as PropType<string[]>,
     required: true,
@@ -35,73 +40,91 @@ const props = defineProps({
 
 const i18n = useI18n()
 
+const i18nUtils = useI18nUtils()
+
 const emit = defineEmits(['update:modelValue'])
 
-const { fetchCollection } = useEntityFetch()
+const { fetch } = useEntityFetch()
 
-const { data: typeOfPractices, status } = fetchCollection(TypeOfPractice)
+const { data, status } = fetch(TypeOfPracticeTree)
 
-// Transform type of practices into hierarchical items for TreeSelect
-const hierarchicalItems = computed(() => {
-  if (!typeOfPractices.value || typeOfPractices.value.items.length === 0) {
-    return []
+const typeOfPractices = computed(() => {
+  if (!data.value) {
+    return null
   }
+  const tree = i18nUtils.translateResourceTree((data.value as ResourceTree).content)
+  console.log('*** b')
+  console.log(tree)
+  return ArrayUtils.sortResourceTree(tree)
+})
 
-  const result = []
-  const categoriesMap = new Map()
-
-  // First pass: collect all unique category
-  typeOfPractices.value.items.forEach((typeOfPractice) => {
-    if (!typeOfPractice.category || !typeOfPractice.name) return
-
-    // Create unique keys for categories
-    const categoryKey = typeOfPractice.category
-
-    // Add category if not already added
-    if (!categoriesMap.has(categoryKey)) {
-      categoriesMap.set(categoryKey, {
-        id: `category-${categoryKey}`,
-        label: i18n.t(typeOfPractice.category),
-        type: 'category',
-        level: 0,
-      })
-    }
-  })
+const hierarchicalItems = computed(() => [])
+const sortedCategories = []
+const types = []
+
+
+
+// // Transform type of practices into hierarchical items for TreeSelect
+// const hierarchicalItems = computed(() => {
+//   if (!typeOfPractices.value || typeOfPractices.value.items.length === 0) {
+//     return []
+//   }
+//
+//   const result = []
+//   const categoriesMap = new Map()
+//
+//   // First pass: collect all unique category
+//   typeOfPractices.value.items.forEach((typeOfPractice) => {
+//     if (!typeOfPractice.category || !typeOfPractice.name) return
+//
+//     // Create unique keys for categories
+//     const categoryKey = typeOfPractice.category
+//
+//     // Add category if not already added
+//     if (!categoriesMap.has(categoryKey)) {
+//       categoriesMap.set(categoryKey, {
+//         id: `category-${categoryKey}`,
+//         label: i18n.t(typeOfPractice.category),
+//         type: 'category',
+//         level: 0,
+//       })
+//     }
+//   })
 
   // Convert categories map to array and sort alphabetically by label
-  const sortedCategories = Array.from(categoriesMap.values()).sort((a, b) =>
-    a.label.localeCompare(b.label),
-  )
+  // const sortedCategories = Array.from(categoriesMap.values()).sort((a, b) =>
+  //   a.label.localeCompare(b.label),
+  // )
 
   // Add sorted families to result
-  sortedCategories.forEach((cat) => {
-    result.push(cat)
-  })
+  // sortedCategories.forEach((cat) => {
+  //   result.push(cat)
+  // })
 
   // Collect all type first, then sort and add to result
-  const types = []
-  typeOfPractices.value.items.forEach((typeOfPractice) => {
-    if (!typeOfPractice.category || !typeOfPractice.name) return
-
-    types.push({
-      id: `type-${typeOfPractice.id}`,
-      label: i18n.t(typeOfPractice.name),
-      value: typeOfPractice.id,
-      type: 'item',
-      parentId: `category-${typeOfPractice.category}`,
-      level: 1,
-    })
-  })
+  // const types = []
+  // typeOfPractices.value.items.forEach((typeOfPractice) => {
+  //   if (!typeOfPractice.category || !typeOfPractice.name) return
+  //
+  //   types.push({
+  //     id: `type-${typeOfPractice.id}`,
+  //     label: i18n.t(typeOfPractice.name),
+  //     value: typeOfPractice.id,
+  //     type: 'item',
+  //     parentId: `category-${typeOfPractice.category}`,
+  //     level: 1,
+  //   })
+  // })
 
   // Sort types alphabetically by label and add to result
-  types
-    .sort((a, b) => a.label.localeCompare(b.label))
-    .forEach((type) => {
-      result.push(type)
-    })
-
-  return result
-})
+//   types
+//     .sort((a, b) => a.label.localeCompare(b.label))
+//     .forEach((type) => {
+//       result.push(type)
+//     })
+//
+//   return result
+// })
 
 // Nettoyer les données lors du démontage du composant
 onBeforeUnmount(() => {

+ 18 - 0
models/Custom/ResourceTree/TypeOfPracticeTree.ts

@@ -0,0 +1,18 @@
+import { Uid, Attr } from 'pinia-orm/dist/decorators'
+import ApiResource from '~/models/ApiResource'
+import type { ResourceTreeContent } from '~/types/data'
+
+/**
+ * AP2i Model : SearchPlaceItem
+ *
+ * @see https://gitlab.2iopenservice.com/opentalent/ap2i/-/blob/develop/src/ApiResource/Tree/TypeOfPracticeTree.php
+ */
+export default class TypeOfPracticeTree extends ApiResource {
+  static override entity = 'tree/type_of_practices'
+
+  @Uid()
+  declare id: number | string
+
+  @Attr({})
+  declare content: ResourceTreeContent
+}

+ 0 - 0
pages/dev/poc_tree_select_input.vue → pages/dev/poc_tree_select/input.vue


+ 39 - 0
pages/dev/poc_tree_select/type_of_practices.vue

@@ -0,0 +1,39 @@
+<template>
+  <div class="pa-4">
+    <h2>TreeSelect - Types de pratique</h2>
+
+    <UiInputTreeSelectResource
+      v-if="status === FETCHING_STATUS.SUCCESS"
+      v-model="selectedCategories"
+      :model="TypeOfPracticeTree"
+      label="Choisissez des catégories"
+      placeholder="Sélectionnez des catégories..."
+    />
+
+    <div class="mt-4">
+      <h3>Catégories sélectionnées :</h3>
+      <pre>{{ selectedCategories }}</pre>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import TypeOfPracticeTree from '~/models/Custom/ResourceTree/TypeOfPracticeTree'
+import { useI18nUtils } from '~/composables/utils/useI18nUtils'
+import { useEntityFetch } from '~/composables/data/useEntityFetch'
+import { FETCHING_STATUS } from '~/types/enum/data'
+
+const selectedCategories = ref<string[]>([])
+
+const i18nUtils = useI18nUtils()
+
+const { fetch } = useEntityFetch()
+
+const { data: typeOfPracticesTree, status } = fetch(TypeOfPracticeTree)
+
+const translatedContent = computed(
+  () => typeOfPracticesTree.value ?
+    i18nUtils.translateResourceTree(typeOfPracticesTree.value.content) :
+    {}
+)
+</script>

+ 0 - 288
pages/freemium/dashboard.vue

@@ -1,288 +0,0 @@
-<template>
-  <div>
-    <v-container fluid class="inner-container">
-      <v-row>
-        <!-- Bloc événements -->
-        <v-col cols="12" md="7">
-          <v-card>
-            <v-tabs v-model="tab" class="tabs-title">
-              <v-tab value="future">{{ $t('futur_event') }}</v-tab>
-              <v-tab value="past">{{ $t('past_event') }}</v-tab>
-            </v-tabs>
-
-            <v-btn color="primary" to="events/new" class="ml-5 mt-5">{{
-              $t('add_event')
-            }}</v-btn>
-
-            <v-tabs-window v-model="tab">
-              <v-tabs-window-item value="future">
-                <UiLoadingPanel
-                  v-if="statusUpcomingEvents == FETCHING_STATUS.PENDING"
-                />
-
-                <UiEventList
-                  v-if="
-                    statusUpcomingEvents == FETCHING_STATUS.SUCCESS &&
-                    upcomingEvents?.items
-                  "
-                  :events="upcomingEvents.items"
-                  :pagination="upcomingEvents.pagination"
-                  @load="loadUpcomingEvents"
-                  @edit="editEvent"
-                />
-                <span v-if="upcomingEvents.items.length == 0" class="no_event">
-                  {{ $t('no_future_event') }}
-                </span>
-              </v-tabs-window-item>
-              <v-tabs-window-item value="past">
-                <UiLoadingPanel
-                  v-if="statusPastEvents == FETCHING_STATUS.PENDING"
-                />
-
-                <UiEventList
-                  v-if="
-                    statusPastEvents == FETCHING_STATUS.SUCCESS &&
-                    pastEvents?.items
-                  "
-                  :events="pastEvents.items"
-                  :pagination="pastEvents.pagination"
-                  @load="loadPastEvents"
-                  @edit="editEvent"
-                />
-                <span v-if="pastEvents.items.length == 0" class="no_event">
-                  {{ $t('no_past_event') }}
-                </span>
-              </v-tabs-window-item>
-            </v-tabs-window>
-          </v-card>
-        </v-col>
-
-        <!-- Bloc structure -->
-        <v-col cols="12" md="5">
-          <v-card
-            v-if="statusOrganization == FETCHING_STATUS.SUCCESS"
-            class="pa-5"
-          >
-            <v-card-title class="text-h6">
-              <v-icon icon="fa fa-hotel" class="text-button icon-hotel" />
-              <span class="organization_title">{{
-                $t('my_organization')
-              }}</span>
-            </v-card-title>
-            <v-card-text>
-              <div>
-                <strong>{{ $t('name') }} :</strong> {{ organization?.name }}
-              </div>
-              <div>
-                <strong>{{ $t('email') }} :</strong> {{ organization?.email }}
-              </div>
-            </v-card-text>
-          </v-card>
-
-          <v-btn block class="mb-2 btn btn_edit_orga" to="organization">
-            <i class="fa fa-pen mr-2" />{{ $t('edit_organization') }}
-          </v-btn>
-
-          <v-btn block class="text-black btn btn_trial" @click="startTrial">
-            <span
-              ><v-icon icon="fa fa-ticket" /> {{ $t('try_premium_light')
-              }}<br />
-              {{ $t('30_days_free') }}</span
-            >
-          </v-btn>
-        </v-col>
-      </v-row>
-    </v-container>
-
-    <LayoutDialogTrialAlreadyDid
-      :show="showDialogTrialAlReadyDid"
-      @close-dialog="showDialogTrialAlReadyDid = false"
-    />
-  </div>
-</template>
-
-<script setup lang="ts">
-import Query from '~/services/data/Query'
-
-import { type Ref, ref } from 'vue'
-import { useEntityFetch } from '~/composables/data/useEntityFetch'
-import Organization from '~/models/Freemium/Organization'
-import Event from '~/models/Freemium/Event'
-import type { AsyncData } from '#app'
-import OrderBy from '~/services/data/Filters/OrderBy'
-import {
-  FETCHING_STATUS,
-  ORDER_BY_DIRECTION,
-  TIME_STRATEGY,
-} from '~/types/enum/data'
-import PageFilter from '~/services/data/Filters/PageFilter'
-import TimeFilter from '~/services/data/Filters/TimeFilter'
-import Country from '~/models/Core/Country'
-import DateUtils from '~/services/utils/dateUtils'
-import UrlUtils from '~/services/utils/urlUtils'
-import { useApiLegacyRequestService } from '~/composables/data/useApiLegacyRequestService'
-import { useAdminUrl } from '~/composables/utils/useAdminUrl'
-
-definePageMeta({
-  name: 'freemium_dashboard_page',
-})
-
-//Ref Définition
-const runtimeConfig = useRuntimeConfig()
-const { fetch, fetchCollection } = useEntityFetch()
-const { apiRequestService } = useApiLegacyRequestService()
-const { makeAdminUrl } = useAdminUrl()
-const tab = ref(null)
-const upcomingPage = ref(1)
-const pastPage = ref(1)
-const showDialogTrialAlReadyDid: Ref<boolean> = ref(false)
-
-//Fetch
-const { data: organization, status: statusOrganization } = fetch(Organization)
-const {
-  data: upcomingEvents,
-  status: statusUpcomingEvents,
-  refresh: refreshUpcomingEvents,
-} = fetchEvents()
-const {
-  data: pastEvents,
-  status: statusPastEvents,
-  refresh: refreshPastEvents,
-} = fetchEvents(true)
-
-/**
- * Charge une page des événements à venir
- * @param pageNumber
- */
-function loadUpcomingEvents(pageNumber: number) {
-  upcomingPage.value = pageNumber
-  refreshPastEvents()
-}
-
-/**
- * Cahrge une page des événements passées
- * @param pageNumber
- */
-function loadPastEvents(pageNumber: number) {
-  pastPage.value = pageNumber
-  refreshPastEvents()
-}
-
-/**
- * Redirige vers la page d'édition d'un événement
- * @param eventId
- */
-function editEvent(eventId: number) {
-  navigateTo(UrlUtils.join('events', eventId))
-}
-
-/**
- * Récupère la liste des événements
- * @param past
- */
-function fetchEvents(past: boolean = false) {
-  const today = computed(() => DateUtils.formatIsoShortDate(new Date()))
-  const query = new Query(
-    new OrderBy(
-      'datetimeStart',
-      past ? ORDER_BY_DIRECTION.DESC : ORDER_BY_DIRECTION.ASC,
-    ),
-    new PageFilter(past ? pastPage : upcomingPage, ref(5)),
-    new TimeFilter(
-      'datetimeStart',
-      today,
-      past ? TIME_STRATEGY.BEFORE : TIME_STRATEGY.AFTER,
-    ),
-  )
-
-  return fetchCollection(Event, null, query)
-}
-
-/**
- * Action lorsque l'on souhaite démarrer l'essai
- */
-async function startTrial() {
-  try {
-    await apiRequestService.get('/trial/is_available')
-    await navigateTo(makeAdminUrl('trial'), {
-      external: true,
-    })
-  } catch (error) {
-    showDialogTrialAlReadyDid.value = true
-  }
-}
-
-/**
- * Nettoyage du store
- */
-onUnmounted(() => {
-  useRepo(Organization).flush()
-  useRepo(Event).flush()
-  useRepo(Country).flush()
-})
-</script>
-
-<style scoped lang="scss">
-.tabs-title {
-  margin-top: 20px;
-  padding-left: 20px;
-  background-color: rgb(var(--v-theme-neutral));
-  .v-tab--selected {
-    color: rgb(var(--v-theme-on-neutral--clickable));
-  }
-}
-
-.v-card {
-  margin-bottom: 16px;
-  color: rgb(var(--v-theme-on-neutral));
-}
-
-.v-card-text {
-  div {
-    line-height: 2;
-  }
-}
-
-.organization_title {
-  font-weight: 500;
-}
-
-.icon-hotel {
-  margin: 0 5px 4px 0;
-}
-
-.btn {
-  border: 1px solid;
-  cursor: pointer;
-}
-
-.inner-container {
-  margin: 0 auto;
-  padding: 30px;
-}
-.btn_trial {
-  height: 55px;
-  background-color: rgb(var(--v-theme-standout));
-  color: #000;
-
-  span {
-    text-align: center;
-    line-height: 1.2; /* optionnel : pour resserrer ou espacer */
-  }
-  .v-icon {
-    transform: rotate(135deg); /* angle en degrés */
-    font-size: 16px;
-    padding-right: 5px;
-    margin: 0 5px 4px 0;
-  }
-}
-
-.btn_edit_orga {
-  color: rgb(var(--v-theme-on-neutral)) !important;
-}
-
-.no_event {
-  padding: 25px;
-  font-size: 16px;
-}
-</style>

+ 3 - 3
pages/freemium/index.vue

@@ -21,7 +21,7 @@
               <v-tabs-window-item value="future">
                 <v-btn
                   color="primary"
-                  to="freemium/events/new"
+                  to="/freemium/events/new"
                   class="ml-5 mt-5"
                   >{{ $t('add_event') }}</v-btn
                 >
@@ -106,7 +106,7 @@
             block
             prepend-icon="fa-solid fa-pen"
             class="my-5 text-black"
-            to="freemium/organization"
+            to="/freemium/organization"
           >
             {{ $t('edit_organization') }}
           </v-btn>
@@ -199,7 +199,7 @@ function loadPastEvents(pageNumber: number) {
  * @param eventId
  */
 function editEvent(eventId: number) {
-  navigateTo(UrlUtils.join('freemium/events', eventId))
+  navigateTo(UrlUtils.join('/freemium/events', eventId))
 }
 
 /**

+ 58 - 1
services/utils/arrayUtils.ts

@@ -1,4 +1,4 @@
-import type { AnyJson } from '~/types/data'
+import type { AnyJson, ResourceTree, ResourceTreeContent } from '~/types/data'
 
 const ArrayUtils = {
   /**
@@ -35,6 +35,63 @@ const ArrayUtils = {
       )
     })
   },
+
+  /**
+   * Sort the different levels of a resource tree's content
+   * @param tree
+   */
+  sortResourceTree(tree: ResourceTreeContent): ResourceTreeContent {
+    // Si c'est un tableau simple (premier niveau)
+    if (Array.isArray(tree)) {
+      return [...tree].sort()
+    }
+
+    // Si c'est un objet (deuxième ou troisième niveau)
+    const sortedTree: Record<string, any> = {}
+
+    // Trier les clés du premier niveau
+    const sortedKeys = Object.keys(tree).sort()
+
+    for (const key of sortedKeys) {
+      const value = tree[key]
+
+      // Si la valeur est un tableau (deuxième niveau)
+      if (Array.isArray(value)) {
+        sortedTree[key] = [...value].sort()
+      }
+
+      // Si la valeur est un objet (troisième niveau)
+      else if (typeof value === 'object' && value !== null) {
+        const sortedSubTree: Record<string, string[]> = {}
+        const sortedSubKeys = Object.keys(value).sort()
+
+        for (const subKey of sortedSubKeys) {
+          const subValue = value[subKey]
+          if (Array.isArray(subValue)) {
+            sortedSubTree[subKey] = [...subValue].sort()
+          }
+        }
+
+        sortedTree[key] = sortedSubTree
+      }
+    }
+
+    return sortedTree
+  },
+
+  *iterate(array: Array<string> | object): Generator<string> {
+    if (Array.isArray(array)) {
+      for (const item of array) {
+        yield item
+      }
+    } else if (typeof array === 'object') {
+      for (const key in array) {
+        yield key
+      }
+    } else {
+      throw new Error('Invalid array or object')
+    }
+  }
 }
 
 export default ArrayUtils

+ 21 - 0
services/utils/i18nUtils.ts

@@ -3,6 +3,7 @@ import type { CountryCode } from 'libphonenumber-js'
 import { parsePhoneNumber } from 'libphonenumber-js'
 import type { EnumChoice, EnumChoices } from '~/types/interfaces'
 import ArrayUtils from '~/services/utils/arrayUtils'
+import type { ResourceTreeContent } from '~/types/data'
 
 export default class I18nUtils {
   private i18n!: VueI18n
@@ -39,4 +40,24 @@ export default class I18nUtils {
     const parsed = parsePhoneNumber(number, defaultCountry)
     return parsed ? parsed.formatNational() : ''
   }
+
+  /**
+   * Translate the different levels of a resource tree's content
+   * @param tree
+   */
+  public translateResourceTree(tree: ResourceTreeContent): ResourceTreeContent {
+    if (Array.isArray(tree)) {
+      return tree.map(item => this.i18n.t(item))
+    }
+
+    const translatedTree: ResourceTreeContent = {}
+
+    for (const key in tree) {
+      const value = tree[key]
+      const translatedKey = this.i18n.t(key)
+      // @ts-expect-error Nécessaire pour la récursion
+      translatedTree[translatedKey] = this.translateResourceTree(value)
+    }
+    return translatedTree
+  }
 }

+ 7 - 0
types/data.d.ts

@@ -75,3 +75,10 @@ interface CollectionResponse {
 type CollectionResponsePromise = Response<CollectionResponse>
 
 type Enum = Array<EnumChoice>
+
+type ResourceTreeContent = string[] | Record<string, string[]> | Record<string, Record<string, string[]>>
+
+interface ResourceTree {
+  id: int
+  content: ResourceTreeContent
+}