소스 검색

Avancement page évent

Vincent 4 달 전
부모
커밋
f079de815a

+ 158 - 39
components/Form/Freemium/Event.vue

@@ -1,67 +1,186 @@
 <template>
-  <v-container :fluid="true" class="container">
+    <LayoutCommonSection>
+      <v-row>
+        <v-col cols="12" sm="12">
+          <h4 class="mb-8">{{ $t('general_informations') }}</h4>
+
+          <UiInputText
+            v-model="entity.name"
+            field="name"
+            :rules="getAsserts('name')"
+          />
+
+          <span class="label">{{$t('datetimeStart')}}</span>
+          <UiInputDateTimePicker
+            v-model="entity.datetimeStart"
+            field="datetimeStart"
+            label="datetimeStart"
+            :withTimePicker="true"
+            class="my-2"
+            :rules="getAsserts('datetimeStart')"
+            validate-on-blur
+            @update:model-value="onUpdateDateTimeStart(entity, $event)"
+          />
+
+          <span class="label">{{$t('datetimeEnd')}}</span>
+          <UiInputDateTimePicker
+            v-model="entity.datetimeEnd"
+            field="datetimeEnd"
+            label="datetimeEnd"
+            :withTimePicker="true"
+            class="my-2"
+            :rules="getAsserts('datetimeEnd')"
+            @update:model-value="onUpdateDateTimeEnd(entity, $event)"
+          />
+
+          <span class="label">{{$t('description')}}</span>
+          <UiInputTextArea
+            class="mt-3"
+            v-model="entity.description"
+          />
+
+          <UiInputTreeSelectEventCategories
+            v-model="entity.categories"
+            label="event_categories_choices"
+          />
+
+          <UiInputImage
+            v-model="entity.image"
+            field="image"
+            :width="240"
+            :cropping-enabled="true"
+          />
+        </v-col>
+      </v-row>
+    </LayoutCommonSection>
+
+  <LayoutCommonSection>
     <v-row>
-      <v-col cols="12" sm="12">
-        <UiInputText
-          v-model="modelValue.name"
-          field="name"
-          type="string"
-          :rules="getAsserts('name')"
-        />
+      <v-col cols="12">
+        <h4 class="mb-8">{{ $t('place_event') }}</h4>
 
-        <span class="label">{{$t('datetimeStart')}}</span>
-        <UiInputDateTimePicker
-          :model-value="modelValue.datetimeStart"
-          field="datetimeStart"
-          label="datetimeStart"
-          :withTimePicker="true"
-          class="my-2"
-          :rules="getAsserts('datetimeStart')"
-          validate-on-blur
-          @update:model-value="onUpdateDateTimeStart($event)"
+        <UiInputAutocompleteApiResources
+          v-model="entity.place"
+          field="place"
+          :model="PlaceSearchItem"
+          listValue="id"
+          listLabel="name"
+          v-if="!newPlace"
         />
 
-        <span class="label">{{$t('datetimeEnd')}}</span>
-        <UiInputDateTimePicker
-          :model-value="modelValue.datetimeEnd"
-          field="datetimeEnd"
-          label="datetimeEnd"
-          :withTimePicker="true"
-          class="my-2"
-          :rules="getAsserts('datetimeEnd')"
-          @update:model-value="onUpdateDateTimeEnd($event)"
+        <div class="d-flex justify-center"
+             v-if="!newPlace"
+        >
+          <v-btn
+            prepend-icon="fa-solid fa-plus"
+            class="my-5"
+            @click="onAddPlaceClick(entity)"
+          >
+            {{ $t('add_place') }}
+          </v-btn>
+        </div>
+
+        <UiInputText v-if="newPlace" v-model="entity.placeName" field="placeName" />
+
+        <UiInputText v-if="newPlace" v-model="entity.streetAddress" field="streetAddress" />
+
+        <UiInputText v-if="newPlace" v-model="entity.streetAddressSecond" field="streetAddressSecond" />
+
+        <UiInputText v-if="newPlace" v-model="entity.streetAddressThird" field="streetAddressThird" />
+
+        <UiInputText v-if="newPlace" v-model="entity.postalCode" field="postalCode" />
+
+        <UiInputText v-if="newPlace" v-model="entity.addressCity" field="addressCity" />
+
+        <UiInputAutocompleteApiResources
+          v-if="newPlace"
+          v-model="entity.addressCountry"
+          field="addressCountry"
+          :model="Country"
+          listValue="id"
+          listLabel="name"
         />
 
+        <client-only>
+          <UiMapLeaflet
+            v-if="newPlace"
+            v-model:latitude="entity.latitude"
+            v-model:longitude="entity.longitude"
+            :streetAddress="entity.streetAddress"
+            :streetAddressSecond="entity.streetAddressSecond"
+            :streetAddressThird="entity.streetAddressThird"
+            :postalCode="entity.postalCode"
+            :addressCity="entity.addressCity"
+            :addressCountryId="entity.addressCountry"
+            :searchButton="true"
+          ></UiMapLeaflet>
+        </client-only>
+
       </v-col>
     </v-row>
-  </v-container>
+  </LayoutCommonSection>
+
+    <LayoutCommonSection>
+      <v-row>
+        <v-col cols="12">
+
+          <h4 class="mb-8">{{ $t('communication_params') }}</h4>
+
+          <UiInputText v-model="entity.url" field="url" />
+
+          <UiInputAutocompleteEnum
+            v-model="entity.pricing"
+            enum-name="pricing_event"
+            field="pricing"
+          />
+
+          <UiInputText v-if="entity.pricing==='PAID'" v-model="entity.urlTicket" field="urlTicket" />
+
+          <UiInputNumber v-if="entity.pricing==='PAID'" v-model="entity.priceMini" field="priceMini" />
+
+          <UiInputNumber v-if="entity.pricing==='PAID'" v-model="entity.priceMaxi" field="priceMaxi" />
+
+        </v-col>
+      </v-row>
+    </LayoutCommonSection>
 </template>
 
 <script setup lang="ts">
 import Event from "~/models/Freemium/Event";
 import {getAssertUtils} from "~/services/asserts/getAssertUtils";
 import DateUtils from "~/services/utils/dateUtils";
+import Country from "~/models/Core/Country";
+import PlaceSearchItem from "~/models/Custom/Search/PlaceSearchItem";
 
 const props = defineProps<{
-  modelValue: Event
+  entity: Event
 }>()
 
 const getAsserts = (key) => getAssertUtils(Event.getAsserts(), key)
 
-const emit = defineEmits(['update:entity'])
-
-const onUpdateDateTimeStart = (dateTime) =>{
-  if(DateUtils.isBefore(props.modelValue.datetimeEnd, dateTime, false)){
-    props.modelValue.datetimeEnd = props.modelValue.datetimeStart
+const onUpdateDateTimeStart = (entity, dateTime) =>{
+  if(DateUtils.isBefore(props.entity.datetimeEnd, dateTime, false)){
+    entity.datetimeEnd = dateTime
   }
-  emit('update:entity', props.modelValue)
 }
-
-const onUpdateDateTimeEnd = (dateTime) =>{
-  console.log(dateTime)
-
+const onUpdateDateTimeEnd = (entity, dateTime) =>{
+  if(DateUtils.isBefore(dateTime, props.entity.datetimeStart, false)){
+    entity.datetimeStart = dateTime
+  }
 }
 
+const newPlace: Ref<boolean> = ref(false)
+const onAddPlaceClick = function(entity: Event){
+  newPlace.value = true
+  entity.placeName = null
+  entity.streetAddress = null
+  entity.streetAddressSecond = null
+  entity.streetAddressThird = null
+  entity.addressCity = null
+  entity.postalCode = null
+  entity.addressCountry = null
+  entity.place = null
+}
 </script>
 
 <style scoped lang="scss">

+ 1 - 1
components/Layout/Dialog.vue

@@ -14,7 +14,7 @@
         "
       >
         <h3 :class="'d-flex theme-' + theme">
-          <v-icon icon="fa-solid fa-bullhorn" width="25" htight="25" />
+          <v-icon icon="fa-solid fa-bullhorn" width="25" height="25" />
           <span class="pt-4"><slot name="dialogType" /></span>
         </h3>
       </div>

+ 3 - 7
components/Ui/Input/DateTimePicker.vue

@@ -2,7 +2,7 @@
   <v-row>
     <v-col cols="12" md="6">
       <v-text-field
-        :model-value="date.format(dateModel, 'fullDateWithWeekday')"
+        :model-value="dateModel ? date.format(dateModel, 'fullDateWithWeekday') : undefined"
         :label="$t('choose_day')"
         prepend-icon="fas fa-calendar"
         :rules="rules"
@@ -130,12 +130,8 @@ const showMenuDate = ref(false)
 const date = useDate()
 const { fieldViolations, updateViolationState } = useFieldViolation(props.field)
 
-const dateModel: Ref<Date | undefined> = ref(
-  props.modelValue ? new Date(props.modelValue) : new Date(),
-)
-const time: Ref<string | undefined> = ref(
-  props.modelValue ? date.format(new Date(props.modelValue),'fullTime24h') : date.format(new Date(), 'fullTime24h'),
-)
+const dateModel = computed(()=> props.modelValue ? new Date(props.modelValue) : undefined)
+const time = computed(()=>   props.modelValue ? date.format(new Date(props.modelValue),'fullTime24h') : undefined)
 
 const emit = defineEmits(['update:model-value'])
 

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

@@ -31,6 +31,7 @@ const props = defineProps({
   modelValue: {
     type: Number,
     required: true,
+    default: null,
   },
   /**
    * Nom de la propriété d'une entité lorsque l'input concerne cette propriété

+ 588 - 0
components/Ui/Input/TreeSelect.vue

@@ -0,0 +1,588 @@
+<!--
+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.
+
+## Exemple d'utilisation
+```vue
+<TreeSelect
+  v-model="selectedValues"
+  :items="hierarchicalItems"
+  :max-visible-chips="3"
+  label="Sélectionner des éléments"
+/>
+```
+-->
+<template>
+  <v-select
+    :model-value="modelValue"
+    :label="$t(label)"
+    v-bind="$attrs"
+    :items="flattenedItems"
+    item-title="label"
+    item-value="value"
+    multiple
+    chips
+    closable-chips
+    :menu-props="{ maxHeight: 400 }"
+    @update:menu="onMenuUpdate"
+    @update:model-value="$emit('update:modelValue', $event)"
+  >
+    <template #prepend-item>
+      <!-- Champs de recherche textuelle -->
+      <v-text-field
+        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
+        @input="onSearchInputDebounced"
+        @click:clear.stop="onSearchClear"
+      />
+      <v-divider class="mt-2" />
+    </template>
+
+    <template #selection="{ item, index }">
+      <v-chip
+        v-if="maxVisibleChips && index < maxVisibleChips"
+        :key="item.raw.value"
+        closable
+        @click:close="removeItem(item.raw.value!)"
+      >
+        <!-- Always prioritize the mapping for consistent labels, fall back to item label if available -->
+        {{
+          selectedItemsMap[item.raw.value] ||
+          (item.raw.label && item.raw.label !== item.raw.value ? item.raw.label : item.raw.value)
+        }}
+      </v-chip>
+      <span
+        v-if="
+          maxVisibleChips &&
+          index === maxVisibleChips &&
+          modelValue.length > maxVisibleChips
+        "
+        class="text-grey text-caption align-self-center"
+      >
+        (+{{ modelValue.length - maxVisibleChips }} {{ $t('others') }})
+      </span>
+    </template>
+
+    <template #item="{ item }">
+      <template v-if="item.raw.type === 'category'">
+        <v-list-item
+          :ripple="false"
+          :class="{
+            'v-list-item--active': expandedCategories.has(item.raw.id),
+          }"
+          @click.stop="toggleCategory(item.raw.id)"
+        >
+          <template #prepend>
+            <v-icon
+              :icon="
+                'fas ' +
+                (expandedCategories.has(item.raw.id)
+                  ? 'fa-angle-down'
+                  : 'fa-angle-right')
+              "
+              size="small"
+            />
+          </template>
+          <v-list-item-title class="font-weight-medium">
+            {{ item.raw.label }}
+          </v-list-item-title>
+        </v-list-item>
+      </template>
+
+      <template v-else-if="item.raw.type === 'subcategory'">
+        <v-list-item
+          :ripple="false"
+          :class="{
+            'v-list-item--active': expandedSubcategories.has(item.raw.id),
+            'pl-8': true,
+          }"
+          @click.stop="toggleSubcategory(item.raw.id)"
+        >
+          <template #prepend>
+            <v-icon
+              :icon="
+                'fas ' +
+                (expandedSubcategories.has(item.raw.id)
+                  ? 'fa-angle-down'
+                  : 'fa-angle-right')
+              "
+              size="small"
+            />
+          </template>
+          <v-list-item-title>
+            {{ item.raw.label }}
+          </v-list-item-title>
+        </v-list-item>
+      </template>
+
+      <template v-else>
+        <v-list-item
+          :active="modelValue.includes(item.raw.value!)"
+          :class="{
+            'd-flex': true,
+            'pl-12': item.raw.level === 2,
+            'pl-8': item.raw.level === 1,
+          }"
+          @click="toggleItem(item.raw.value!)"
+        >
+          <template #prepend>
+            <v-checkbox
+              :model-value="modelValue.includes(item.raw.value!)"
+              color="primary"
+              :hide-details="true"
+              @click.stop="toggleItem(item.raw.value!)"
+            />
+          </template>
+          <v-list-item-title>
+            {{ item.raw.label }}
+          </v-list-item-title>
+        </v-list-item>
+      </template>
+    </template>
+  </v-select>
+</template>
+
+<script setup lang="ts">
+import StringUtils from '~/services/utils/stringUtils'
+import _ from 'lodash'
+
+interface SelectItem {
+  id: string
+  label: string
+  normalizedLabel?: string
+  value?: string
+  type: 'category' | 'subcategory' | 'item'
+  parentId?: string
+  level: number
+}
+
+const props = defineProps({
+  modelValue: {
+    type: Array as PropType<string[]>,
+    required: true,
+  },
+  items: {
+    type: Array as PropType<SelectItem[]>,
+    required: true,
+  },
+  maxVisibleChips: {
+    type: Number,
+    required: false,
+    default: null,
+  },
+  /**
+   * Label du champ
+   * Si non défini, c'est le nom de propriété qui est utilisé
+   */
+  label: {
+    type: String,
+    required: false,
+    default: null,
+  },
+})
+
+/**
+ * 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('')
+
+/**
+ * A callback function that is triggered when the menu's open state is updated.
+ */
+const onMenuUpdate = (isOpen: boolean) => {
+  // Réinitialiser la recherche quand le menu se ferme
+  if (!isOpen && searchText.value) {
+    searchText.value = ''
+    onSearchInput()
+  }
+}
+
+/**
+ * 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.
+ */
+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)
+    })
+  } else {
+    expandedCategories.value.add(categoryId)
+  }
+}
+
+/**
+ * Toggles the expansion state of a subcategory.
+ *
+ * @param {string} subcategoryId - The unique identifier of the subcategory to be toggled.
+ */
+const toggleSubcategory = (subcategoryId: string) => {
+  if (expandedSubcategories.value.has(subcategoryId)) {
+    expandedSubcategories.value.delete(subcategoryId)
+  } else {
+    expandedSubcategories.value.add(subcategoryId)
+  }
+}
+
+/**
+ * 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.
+ */
+const toggleItem = (value: string) => {
+  const currentSelection = [...props.modelValue]
+  const index = currentSelection.indexOf(value)
+
+  if (index > -1) {
+    currentSelection.splice(index, 1)
+  } else {
+    currentSelection.push(value)
+  }
+
+  emit('update:modelValue', currentSelection)
+}
+
+/**
+ * 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.
+ */
+const removeItem = (value: string) => {
+  emit(
+    'update:modelValue',
+    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 === 2 && itemMatchesSearch(item),
+    )
+
+    // Pour chaque élément correspondant, ajouter ses parents aux ensembles d'expansion
+    for (const item of matchingItems) {
+      // 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
+        const category = normalizedItems.value.find((i) => i.id === subcategory.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 {SelectItem} 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: SelectItem): 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 2, vérifier son label et les labels de ses parents
+  if (item.type === 'item' && item.level === 2) {
+    // Vérifier le label de l'élément
+    if (anyWordStartsWith(itemWithNormalizedLabel.normalizedLabel!, normalizedSearch))
+      return true
+
+    // Trouver et vérifier le label de la sous-catégorie parente
+    const subcategory = normalizedItems.value.find((i) => i.id === item.parentId)
+    if (
+      subcategory &&
+      anyWordStartsWith(
+        subcategory.normalizedLabel!,
+        normalizedSearch,
+      )
+    )
+      return true
+
+    // Trouver et vérifier le label de la catégorie parente
+    if (subcategory && subcategory.parentId) {
+      const category = normalizedItems.value.find((i) => i.id === subcategory.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 {SelectItem[]} Les éléments de niveau 2 qui correspondent à la recherche.
+ */
+const findMatchingLevel2Items = (): SelectItem[] => {
+  return normalizedItems.value.filter(
+    (item) =>
+      item.type === 'item' && item.level === 2 && 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 {SelectItem[]} matchingItems - Les éléments correspondant à la recherche.
+ * @returns {SelectItem[]} Liste hiérarchique incluant les éléments et leurs parents.
+ */
+const buildSearchResultsList = (matchingItems: SelectItem[]): SelectItem[] => {
+  const result: SelectItem[] = []
+  const addedCategoryIds = new Set<string>()
+  const addedSubcategoryIds = new Set<string>()
+
+  for (const item of matchingItems) {
+    // Trouver la sous-catégorie parente
+    const subcategory = normalizedItems.value.find((i) => i.id === item.parentId)
+    if (!subcategory) continue
+
+    // Trouver la catégorie parente
+    const category = normalizedItems.value.find((i) => i.id === subcategory.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 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)
+    }
+
+    // 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 {SelectItem[]} items - Les éléments à traiter.
+ * @param {SelectItem[]} result - Le tableau résultat à remplir.
+ * @param {boolean} parentExpanded - Indique si le parent est développé.
+ */
+const processItemsRecursively = (
+  items: SelectItem[],
+  result: SelectItem[],
+  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 && 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 {SelectItem[]} Liste hiérarchique basée sur l'état d'expansion.
+ */
+const buildNormalModeList = (): SelectItem[] => {
+  const result: SelectItem[] = []
+  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 {SelectItem[]} 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>

+ 151 - 0
components/Ui/Input/TreeSelect/EventCategories.vue

@@ -0,0 +1,151 @@
+<template>
+  <UiInputTreeSelect
+    :model-value="modelValue"
+    :items="hierarchicalItems"
+    :label="$t(label)"
+    v-bind="$attrs"
+    :loading="status === FETCHING_STATUS.PENDING"
+    @update:model-value="$emit('update:modelValue', $event)"
+  />
+</template>
+
+<script setup lang="ts">
+import { useEntityFetch } from '~/composables/data/useEntityFetch'
+import EventCategory from '~/models/Core/EventCategory'
+import {FETCHING_STATUS} from "~/types/enum/data";
+
+defineProps({
+  modelValue: {
+    type: Array as PropType<string[]>,
+    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,
+  },
+})
+
+const i18n = useI18n()
+
+const emit = defineEmits(['update:modelValue'])
+
+const { fetchCollection } = useEntityFetch()
+
+const { data: categories, status } = fetchCollection(EventCategory)
+
+// Transform event categories into hierarchical items for TreeSelect
+const hierarchicalItems = computed(() => {
+  if (!categories.value || categories.value.length === 0) {
+    return []
+  }
+
+  const result = []
+  const familiesMap = new Map()
+  const subFamiliesMap = new Map()
+
+  // First pass: collect all unique families and subfamilies
+  categories.value.items.forEach((category) => {
+    if (
+      !category.famillyLabel ||
+      !category.subfamillyLabel ||
+      !category.genderLabel
+    )
+      return
+
+    // Create unique keys for families and subfamilies
+    const familyKey = category.famillyLabel
+    const subfamilyKey = `${category.famillyLabel}-${category.subfamillyLabel}`
+
+    // Add family if not already added
+    if (!familiesMap.has(familyKey)) {
+      familiesMap.set(familyKey, {
+        id: `family-${familyKey}`,
+        label: i18n.t(category.famillyLabel),
+        type: 'category',
+        level: 0,
+      })
+    }
+
+    // Add subfamily if not already added
+    if (!subFamiliesMap.has(subfamilyKey)) {
+      subFamiliesMap.set(subfamilyKey, {
+        id: `subfamily-${subfamilyKey}`,
+        label: i18n.t(category.subfamillyLabel),
+        type: 'subcategory',
+        parentId: `family-${familyKey}`,
+        level: 1,
+      })
+    }
+  })
+
+  // Convert families map to array and sort alphabetically by label
+  const sortedFamilies = Array.from(familiesMap.values()).sort((a, b) =>
+    a.label.localeCompare(b.label),
+  )
+
+  // Add sorted families to result
+  sortedFamilies.forEach((family) => {
+    result.push(family)
+  })
+
+  // Convert subfamilies map to array and sort alphabetically by label
+  const sortedSubfamilies = Array.from(subFamiliesMap.values()).sort((a, b) =>
+    a.label.localeCompare(b.label),
+  )
+
+  // Add sorted subfamilies to result
+  sortedSubfamilies.forEach((subfamily) => {
+    result.push(subfamily)
+  })
+
+  // Collect all genders first, then sort and add to result
+  const genders = []
+  categories.value.items.forEach((category) => {
+    if (
+      !category.famillyLabel ||
+      !category.subfamillyLabel ||
+      !category.genderLabel
+    )
+      return
+
+    const familyKey = category.famillyLabel
+    const subfamilyKey = `${category.famillyLabel}-${category.subfamillyLabel}`
+
+    genders.push({
+      id: `gender-${category.id}`,
+      label: i18n.t(category.genderLabel),
+      value: category.id.toString(),
+      type: 'item',
+      parentId: `subfamily-${subfamilyKey}`,
+      level: 2,
+    })
+  })
+
+  // Sort genders alphabetically by label and add to result
+  genders
+    .sort((a, b) => a.label.localeCompare(b.label))
+    .forEach((gender) => {
+      result.push(gender)
+    })
+
+  return result
+})
+
+// Nettoyer les données lors du démontage du composant
+onBeforeUnmount(() => {
+  // Nettoyer les références du store si nécessaire
+  if (process.client) {
+    clearNuxtData('/^' + EventCategory + '_many_/')
+    useRepo(EventCategory).flush()
+  }
+})
+</script>
+
+<style scoped lang="scss">
+/* No specific styles needed */
+</style>

+ 315 - 0
i18n/lang/fr/event_categories.json

@@ -0,0 +1,315 @@
+{
+  "CULTURAL_EVENT": "Evénements culturels",
+  "INTERNAL_EVENT": "Evénements internes",
+  "PEDAGOGIC_EVENT": "Evénements pédagogiques",
+  "LEISURE_ACTIVITY": "Activité loisirs",
+  "ADMINISTRATIVE": "Administratif",
+  "ANIMATION": "Animation",
+  "GENERAL_ASSEMBLY": "Assemblée générale",
+  "CONGRESS": "Congrès",
+  "MISCELLANEOUS": "Divers",
+  "MISCELLANEOUS_FOR_MEMBERS": "Divers (pour les membres)",
+  "EVENT_TO_BENEFIT_ASSOCIATION": "Manifestation au profit de l'association",
+  "OPEN_DOORS": "Portes ouvertes",
+  "CONNECTION": "Raccord",
+  "REPETITION": "Répétition",
+  "PARTIAL_REPEAT": "Répétition partielle",
+  "COMMISSION_MEETING": "Réunion Commission",
+  "MEETING_OF_THE_BOARD": "Réunion du conseil d'administration",
+  "MEETING_OF_THE_BUREAU": "Réunion du bureau",
+  "MEETING": "Réunion",
+  "HEARING": "Audition",
+  "CONTEST": "Concours",
+  "REVIEW": "Examen",
+  "MASTER_CLASS": "Master classe",
+  "TRAINEESHIP": "Stage",
+  "CARNAVAL": "Carnaval",
+  "RACCORD": "Raccord",
+  "MASTER_CLASSE": "Masterclass / Classe de maître",
+  "OFFICIAL_CEREMONY": "Cérémonie officielle",
+  "OUTDOOR_CONCERT": "Concert en plein air",
+  "CONCERT_HALL": "Concert en salle",
+  "PARADE": "Défilé",
+  "FESTIVAL": "Festival",
+  "DAY_CONSCRIPTS": "Fête des conscrits",
+  "WEDDING": "Mariage",
+  "MUNICIPAL_SERVICE": "Service municipal",
+  "RELIGIOUS_SERVICE": "Service religieux",
+  "SPECTACLE": "Spectacle",
+  "REPRESENTATION_OF_THE_STRUCTURE": "Représentativité de la structure",
+  "GENERAL_REPETITION": "Répétition générale",
+  "MUSIC": "Musique",
+  "STREET_ARTS": "Arts de la rue",
+  "CIRCUS": "Cirque",
+  "ART_MUSEUM": "Arts/musées",
+  "CINEMA": "Cinéma",
+  "VARIETY_MUSICAL": "Variété/Comédie musicale",
+  "POP_ROCK_ELECTRONIC_MUSIC": "Pop-Rock/Musique électronique",
+  "CLUBBING_SOIRÉES_GALAS": "Clubbing/Soirées/Galas",
+  "RAP_REGGAE_SOUL-FUNK": "Rap/Reggae/Soul-Funk",
+  "JAZZ_BLUES_GOSPEL": "Jazz/Blues/Gospel",
+  "WORLD_MUSIC": "Musiques du monde",
+  "CLASSICAL_MUSIC_AN_OPERA": "Musique classique et Opéra",
+  "DIVERSE_MUSIC": "Musique diverse",
+  "THEATRE": "Théatre",
+  "HUMOUR": "Humour",
+  "MIME_PUPPET": "Mime/Marionnette",
+  "CONTE_READING_POETRY": "Conte/Lecture/Poésie",
+  "BALLET": "Danse classique",
+  "OTHER_DANCE": "Autre spectacle de danse",
+  "TRADITIONAL_HISTORICAL_DANCE": "Danse traditionnelle/historique",
+  "WORLD_DANCE": "Danse du monde",
+  "CHARACTER_DANCE": "Danse de caractère",
+  "ONLINE_DANCES": "Danses en ligne",
+  "FOLK_DANCES_POLKA_SCOTTISH_MAZURKA": "Danses folk (Polka, Scottish, Mazurka, etc.)",
+  "HIPHOP_DANCE_BREAK_DANCE_BBOYING_POPPING_LOCKING_HOUSE_HIP-HOP": "Danse hip-hop : Break dance, B-boying, Popping, Locking, House, Hip-hop, etc.",
+  "SOCIETY_DANCES": "Danses de société",
+  "LATIN_DANCES": "Danses latines",
+  "STANDARD_DANCES": "Danses standards",
+  "ROCK-SWING": "Rock-swing",
+  "TANGO_MILONGA_WALTZ_CRIOLLO_DANCED_TANGO_TECHNIQUE": "Tango, Milonga, Valse criollo, Technique du tango dansé",
+  "BALLROOM_DANCE_AND_DANCESPORT": "Danses de salon et Danse sportive",
+  "POLE_DANCE": "Pole dance",
+  "DANCE_FOR_CHILDREN": "Danse pour les Enfants",
+  "DANCE_THEATRE": "Danse Théâtre",
+  "THE_TAP": "Les claquettes",
+  "SHOWS_EXPOS_JEUNE_PUBLIC": "Spectacles/Expos jeune public",
+  "ENTERTAINMENT": "Spectacles",
+  "OUTDOOR_ENTERTAINMENT": "Spectacles de plein air",
+  "EXHIBITION_MUSEUM": "Exposition/Musée",
+  "STREET_SHOW": "Spectacle de Rue",
+  "TRADITIONAL_CIRCUS": "Cirque traditionnel",
+  "NEW_CIRCUS": "Nouveau cirque",
+  "AIR": "Aérien",
+  "RINGMASTER": "Monsieur Loyal",
+  "CLOWN": "Clown",
+  "BALANCE_ON_PURPOSE": "Equilibre sur objet",
+  "BALANCE_OF_OBJECT": "Equilibre sur objet",
+  "ACROBATICS": "Acrobatie",
+  "CONTORTION": "Contorsion",
+  "CONJURING": "Prestidigitation",
+  "PICKPOCKET": "Pickpocket",
+  "MIME_VENTRILOQUISM": "Mime, ventriloquie",
+  "FAKIR": "Fakir",
+  "FIRE_BREATHER": "Cracheur de feu",
+  "TRAINING_BEAST": "Dressage/Domptage",
+  "MUSEUM_MONUMENTS": "Musée/Monuments",
+  "EXHIBITION_CONFERENCE_WORKSHOPS": "Exposition/Conférence/Ateliers",
+  "MUSICAL": "Musical",
+  "FRENCH_AND_VARIETY_SONG": "Variété et chanson françaises",
+  "INTERNATIONAL_MUSIC": "Variété internationale",
+  "HARD_ROCK_METAL": "Hard-rock/Métal",
+  "ELECTRONIC_MUSIC": "Musique électronique",
+  "POP_ROCK_FOLK": "Pop-rock / Folk",
+  "CLUBBING_PARTY": "Clubbing & Soirées",
+  "GALAS_EVENINGS_STUDENTS": "Galas/Soirées étudiantes",
+  "EVE": "Réveillon",
+  "RAP_HIP_HOP_SLAM": "Rap/Hip-hop/Slam",
+  "REGGAE": "Reggae",
+  "RNB_SOUL_FUNK": "R'n'B/Soul/Funk",
+  "BLUES_COUNTRY": "Blues/Country",
+  "CINE_CONCERT": "Ciné-concert",
+  "GOSPEL": "Gospel",
+  "JAZZ": "Jazz",
+  "MUSIC_CARIBBEAN_LATIN_AMERICA": "Musiques des Caraïbes & Amérique latine",
+  "MUSIC_FROM_FRANCE_EUROPE": "Musiques de France & Europe",
+  "MUSIC_FROM_EAST_MAGHREB": "Musiques d'Orient & Maghreb",
+  "MUSIC_FROM_ASIA_INDIA_OCEANIA": "Musiques d'Asie, Inde & Océanie",
+  "AFRICAN_MUSIC": "Musiques d'Afrique",
+  "CHOIR_SINGING": "Chant choral",
+  "OPERA": "Opéra",
+  "BAROQUE_MUSIC": "Musique baroque",
+  "CLASSICAL": "Musique classique",
+  "CONTEMPORARY_MUSIC": "Musique contemporaine",
+  "SACRED_MUSIC": "Musique sacrée",
+  "OPERETTA": "Opérette",
+  "CLASSICAL_THEATRE": "Théâtre classique",
+  "CONTEMPORARY_THEATRE": "Théâtre contemporain",
+  "MUSICAL_THEATER": "Théâtre musical",
+  "CHILDREN_THEATER": "Théâtre pour enfants",
+  "STREET_THEATER": "Théâtre de rue",
+  "IMPROVISATIONAL_THEATER": "Théâtre d'improvisation",
+  "DRAMA_THEATRE": "Théâtre arts dramatiques",
+  "ALONE_ON_STAGE": "Seul en scène",
+  "VAUDEVILLE": "Vaudeville",
+  "COMEDY": "Comédie",
+  "COMEDIANS": "Humoristes",
+  "COFFEE_THEATRE": "Café-théâtre",
+  "ONE_MAN_WOMAN_SHOW": "One man/woman show",
+  "PUPPET": "Marionnette",
+  "MIME": "Mime",
+  "TALE": "Conte",
+  "PLAY": "Lecture",
+  "POETRY": "Poésie",
+  "GAVOTTE": "Gavotte",
+  "GIGUE": "Gigue",
+  "ON": "Marche",
+  "MAZURKA": "Mazurka",
+  "POLKA": "Polka",
+  "QUADRILLE": "Quadrille",
+  "SCOTTISH": "Scottish",
+  "FOLK_DANCES": "Danses folkloriques",
+  "AFRICAN_DANCES": "Danses Africaines",
+  "INDIAN_DANCE": "Danses Indiennes",
+  "DANSES_ORIENTALES": "Danses Orientales",
+  "FLAMENCO": "Flamenco",
+  "RUSSIAN_FOLKLORE": "Folklore Russe",
+  "IRISH_FOLKLORE": "Folklore Irlandais",
+  "HULLY_GULLY": "Hully-Gully",
+  "MADISON": "Madison",
+  "PACHANGA": "Pachanga",
+  "TWIST": "Twist",
+  "BREAK_DANCE": "Break dance",
+  "B_BOYING": "B-boying",
+  "POPPING": "Popping",
+  "LOCKING": "Locking",
+  "HOUSE": "house",
+  "HIP_HOP": "Hip-hop",
+  "SAMBA": "Samba",
+  "CHA_CHA": "Cha cha",
+  "RUMBA": "Rumba",
+  "PASODOBLE": "Pasodoble",
+  "JIVE": "Jive",
+  "BACHATA": "Bachata",
+  "MERENGUE": "Merengue",
+  "ZOUK": "Zouk",
+  "LAMBADA": "Lambada",
+  "MAMBO": "Mambo",
+  "SALSA": "Salsa",
+  "BROCA": "Broca",
+  "VIENNESE_WALTZ": "Valse viennoise",
+  "ENGLISH_WALTZ": "Valse anglaise",
+  "THE_QUICKSTEP": "Le quickstep",
+  "SLOWFOX": "Le slowfox",
+  "THE_SALON_TANGO": "Le tango de salon",
+  "BALBOA": "Balboa",
+  "BOOGIE_WOOGIE": "Boogie-woogie",
+  "CHARLESTON": "Charleston",
+  "ACROBATIC_DANCE_ROCK_JUMPED_ACROBATIC_ROCK": "Danses acrobatiques (Rock sauté, Rock acrobatique)",
+  "LINDY_HOP": "Lindy Hop",
+  "ROCK_N_ROLL": "Rock'n'roll",
+  "SHAG": "Shag",
+  "WEST_COAST_SWING": "West Coast Swing",
+  "TANGO": "Tango",
+  "MILONGA": "Milonga",
+  "WALTZ_CRIOLLO": "Valse criollo",
+  "TANGO_DANCE_TECHNIQUE": "Technique du tango dansé",
+  "WORKSHOP_FOR_CHILDREN": "Atelier pour enfants",
+  "CINEMA_FOR_YOUNG_AUDIENCES": "Cinéma jeune public",
+  "EXHIBITION_CHILDREN_MUSEUM": "Exposition/Musée pour enfants",
+  "MAGIC_SHOW": "Spectacle de magie",
+  "MUSIC_CONCERT_FOR_CHILDREN": "Musique/concert pour enfants",
+  "GREAT_SHOW": "Grand spectacle",
+  "SPECTACLE_ON_ICE": "Spectacle sur glace",
+  "HORSE_SHOW": "Spectacle équestre",
+  "FIREWORKS": "Feux d'artifice",
+  "SOUND_AND_LIGHT": "Son et lumière",
+  "FANFARE_STREET": "Fanfare de rue",
+  "HUMOR": "Humour",
+  "ROUNDABOUTS": "Manèges",
+  "MACHINERY_AMBULATORY": "Engins déambulatoires",
+  "MAN_CANON": "Homme-canon",
+  "FIXED_TRAPEZE": "Trapèze fixe",
+  "FLYING_TRAPEZE": "Trapèze volant",
+  "SWINGING_TRAPEZE": "Trapèze ballant",
+  "TRAPEZE_DANCE": "Trapèze danse",
+  "HOOP": "Cerceau",
+  "FABRIC": "Tissu",
+  "SMOOTH_STRING": "Corde lisse",
+  "STRAP_SLACKLINE": "Sangle – slackline",
+  "MULTICORDE": "Multicorde",
+  "WHITE_CLOWN": "Clown blanc",
+  "AUGUSTUS": "Auguste",
+  "JESTER_BURLESQUE": "Bouffon/burlesque",
+  "WIRE": "Fil de fer",
+  "SOFT_STRING": "Corde molle",
+  "FUNAMBULE": "Funambule",
+  "UNICYCLE": "Monocycle",
+  "ACROBATIC_BICYCLE": "Bicyclette acrobatique",
+  "STILT": "Echasse",
+  "BALANCE_OF_CANE": "Canne d'équilibre",
+  "SCALE": "Echelle",
+  "CHAIRS": "Chaises",
+  "KOREAN_SWITCHES_SAUTOIRE_BOARD": "Bascule coréenne/planche sautoire",
+  "RUSSIAN_BAR": "Barre russe",
+  "EQUESTRIAN": "Equestre",
+  "CHINESE_MAT": "Mat chinois",
+  "ACROBATIC_SCOPE": "Portée acrobatique",
+  "HUMAN_PYRAMID": "Pyramide humaine",
+  "GERMAN_WHEEL": "Roue allemande",
+  "CYR_WHEEL": "Roue cyr",
+  "WHEEL_OF_DEATH": "Roue de la mort",
+  "BARREL": "Tonneau",
+  "SWORD_SWALLOWER": "Avaleur de sabre",
+  "ANTIPODISTE": "Antipodiste",
+  "BALLS_RINGS_CLUBS": "Balles/anneaux/massues",
+  "CONTACT_BALL": "Balle contact",
+  "STICK_FIRE_INDIAN_STICK_POI_POI_ROPES_METEOR_PYROTECHNICS": "Bâton de feu, bâton indien, bolas, poï, cordes, météore (pyrotechnie)",
+  "DEVIL_STICK_STICK_FLOWER": "Bâton du diable / Bâton fleur",
+  "CIGAR_BOX_HULA_HOOP": "Boîte à cigare, hula hoop",
+  "HOOPS": "Cerceaux",
+  "HAT": "Chapeau",
+  "DIABOLO_DIABOLO_SPINNING": "Diabolo, diabolo-toupie",
+  "LASSO": "Lasso",
+  "THROWING_KNIVES": "Lancer de couteaux",
+  "GUIDED_TOURS": "Visites guidées",
+  "PASS_TOURS_MUSEUMS": "Pass visites/musées",
+  "VISIT_MONUMENTS": "Visite de monuments",
+  "MUSEUM": "Musée",
+  "PASS_SUBSCRIPTION_EXPOS": "Pass/Abonnement expos",
+  "EXPOSITION_CONFERENCE": "Exposition et Conférence",
+  "MUSEUM_EXHIBITION": "Musée et Exposition",
+  "MUSEUM_CONFERENCE": "Musée et Conférence",
+  "ADULT_WORKSHOP": "Atelier pour adultes",
+  "CONFERENCE": "Conférence",
+  "EXHIBITION": "Exposition",
+  "FILM": "Film",
+  "SUBSCRIPTION_PASS_CINEMA": "Abonnement/Pass cinéma",
+  "ACTION": "Action",
+  "MARTIAL_ARTS": "Arts martiaux",
+  "ADVENTURE": "Aventure",
+  "PREVIEW": "Aperçu",
+  "BIOPIC": "Biopic",
+  "BOLLYWOOD": "Bollywood",
+  "CLASSIC": "Classique",
+  "DRAMA": "Drame",
+  "CARTOON": "Dessin animé",
+  "DOCUMENTARY": "Documentaire",
+  "HORROR": "Epouvante-horreur",
+  "EROTIC": "Erotique",
+  "ESPIONAGE": "Espionnage",
+  "FANTASY": "Fantastique",
+  "SEX_COMEDY": "Comédie érotique",
+  "FAMILY": "Famille",
+  "WAR": "Guerre",
+  "HISTORY": "Historique",
+  "JUDICIARY": "Judiciaire",
+  "MEDICAL": "Médical",
+  "MOBISODE": "Mobisode",
+  "THEME_NIGHT_FILM": "Nuit à thème (cinéma)",
+  "PEPLUM": "Péplum",
+  "THRILLER": "Thriller",
+  "RETRANSMISSION_OPERA_CONCERT": "Retransmission Opéra/Concert",
+  "ROMANCE": "Romance",
+  "SCIENCE_FICTION": "Science fiction",
+  "SOAP": "Soap",
+  "WEB_SERIES": "Web série",
+  "WESTERN": "Western",
+  "NEWS_SERVICE": "Service municipal",
+  "CONSCRIPTS_DAY": "Fête des conscrits",
+  "MUSICAL_AWAKENING": "Eveil musical",
+  "MUSICAL_TRAINING": "Formation musicale",
+  "INSTRUMENTAL_TRAINING": "Formation instrumentale",
+  "MUSICAL_INSTRUMENTAL_TRAINING": "Formation musicale et instrumentale",
+  "WORKSHOPS": "Ateliers",
+  "DANCE": "Danse",
+  "THEATER": "Théâtre",
+  "CIRCUS_ARTS": "Arts du cirque",
+  "ALL_SUB_FAMILY": "Toute sous famille",
+  "ALL_GENDER": "Tout genre",
+  "CONTEMPORARY_DANCE": "Danse contemporaine",
+  "JAZZ_DANCE": "Danse jazz",
+  "SHOW": "Spectacles",
+  "EXTERNAL_REPRESENTATION": "Représentation extérieure",
+  "JUGGLING": "Jonglerie"
+}

+ 17 - 0
i18n/lang/fr.json → i18n/lang/fr/general.json

@@ -1,4 +1,21 @@
 {
+  "event_categories_choices": "Choisissez à quelles catégories appartient votre événement",
+  "refresh_page": "Actualiser la page",
+  "search": "Rechercher",
+  "others": "autres",
+  "placeName": "Nom du lieu",
+  "missing_value": "Champs vide",
+  "add_place": "Ajouter un nouveau lieu",
+  "place": "Vos lieux déjà enregistrés",
+  "place_event": "Lieu de votre événement",
+  "FREE": "Gratuit",
+  "PAID": "Payant",
+  "pricing": "Tarification",
+  "priceMini": "Prix minimum",
+  "priceMaxi": "Prix maximum",
+  "url": "Site web",
+  "urlTicket": "URL de la billetterie",
+  "freemium_event_edit_page": "Édition de l'événement",
   "choose_hour": "Heure",
   "choose_day": "Jour",
   "datetimeStart": "Date et heure de début",

+ 26 - 0
models/Core/EventCategory.ts

@@ -0,0 +1,26 @@
+import { Str, Uid } from 'pinia-orm/dist/decorators'
+import ApiResource from '~/models/ApiResource'
+
+/**
+ * Event Category model
+ *
+ * Represents a category for events with family, subfamily, and gender information
+ */
+export default class EventCategory extends ApiResource {
+  static entity = 'event-categories'
+
+  @Uid()
+  declare id: number | string | null
+
+  @Str('')
+  declare label: string
+
+  @Str('')
+  declare famillyLabel: string
+
+  @Str('')
+  declare subfamillyLabel: string
+
+  @Str('')
+  declare genderLabel: string
+}

+ 17 - 0
models/Custom/Search/PlaceSearchItem.ts

@@ -0,0 +1,17 @@
+import { Uid, Str } from 'pinia-orm/dist/decorators'
+import ApiModel from '~/models/ApiModel'
+
+/**
+ * AP2i Model : SearchPlaceItem
+ *
+ * @see https://gitlab.2iopenservice.com/opentalent/ap2i/-/blob/develop/src/ApiResource/Search/SearchPlaceItem.php
+ */
+export default class PlaceSearchItem extends ApiModel {
+  static override entity = 'search/places'
+
+  @Uid()
+  declare id: number | string
+
+  @Str('')
+  declare name: string
+}

+ 5 - 2
models/Freemium/Event.ts

@@ -45,6 +45,9 @@ export default class Event extends ApiModel {
   @IriEncoded(Place)
   declare place: number | null
 
+  @Str(null)
+  declare placeName: string | null
+
   @Str(null)
   declare streetAddress: string | null
 
@@ -61,8 +64,8 @@ export default class Event extends ApiModel {
   declare addressCity: string | null
 
   @IriEncoded(Country)
-  @Num(0)
-  declare addressCountry: number
+  @Attr(null)
+  declare addressCountry: number | null
 
   @Num(null)
   declare latitude: number | null

+ 1 - 1
models/Freemium/Organization.ts

@@ -46,7 +46,7 @@ export default class Organization extends ApiModel {
   declare addressCity: string | null
 
   @IriEncoded(Country)
-  @Num(0)
+  @Attr(null)
   declare addressCountry: number
 
   @Num(null)

+ 1 - 1
nuxt.config.ts

@@ -218,7 +218,7 @@ export default defineNuxtConfig({
       {
         code: 'fr',
         iso: 'fr-FR',
-        file: 'fr.json',
+        files: ['fr/general.json', 'fr/event_categories.json'],
         name: 'Français',
       },
     ],

+ 8 - 12
pages/freemium/events/[id].vue

@@ -1,16 +1,12 @@
 <template>
-  <div>
-    <LayoutCommonSection>
-      <UiFormEdition
-        :model="Event"
-        go-back-route="/freemium/dashboard"
-      >
-        <template #default="{ entity }">
-          <FormFreemiumEvent v-if="entity !== null" :model-value="entity" @update:model-value="$emit('update:entity')" />
-        </template>
-      </UiFormEdition>
-    </LayoutCommonSection>
-  </div>
+  <UiFormEdition
+    :model="Event"
+    go-back-route="/freemium/dashboard"
+  >
+    <template #default="{ entity }">
+      <FormFreemiumEvent v-if="entity !== null" :entity="entity" />
+    </template>
+  </UiFormEdition>
 </template>
 
 <script setup lang="ts">

+ 8 - 12
pages/freemium/events/new.vue

@@ -1,16 +1,12 @@
 <template>
-  <div>
-    <LayoutCommonSection>
-      <UiFormCreation
-        :model="Event"
-        go-back-route="/freemium/dashboard"
-      >
-        <template #default="{ entity }">
-          <FormFreemiumEvent :entity="entity" />
-        </template>
-      </UiFormCreation>
-    </LayoutCommonSection>
-  </div>
+  <UiFormCreation
+    :model="Event"
+    go-back-route="/freemium/dashboard"
+  >
+    <template #default="{ entity }">
+      <FormFreemiumEvent :entity="entity" />
+    </template>
+  </UiFormCreation>
 </template>
 
 <script setup lang="ts">

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

@@ -50,6 +50,7 @@ export default class InArrayFilter extends AbstractFilter implements ApiFilter {
       this.filterValue,
       this.reactiveFilter,
     )
+
     if (filterValue.value === null) {
       return ''
     }
@@ -58,6 +59,8 @@ export default class InArrayFilter extends AbstractFilter implements ApiFilter {
       filterValue.value = [filterValue.value]
     }
 
+    filterValue.value = filterValue.value.filter((value)=> value !== null)
+
     if (!filterValue.value.length > 0) {
       return ''
     }

+ 1 - 1
services/utils/dateUtils.ts

@@ -77,7 +77,7 @@ export default class DateUtils {
     return format(date, 'yyyy-MM-dd')
   }
 
-  public static combineDateAndTime(date: Date, time: string): Date {
+  public static combineDateAndTime(date: Date = new Date(), time: string = '00:00'): Date {
     const [hours, minutes] = time.split(':').map(Number)
 
     const result = new Date(date)