瀏覽代碼

fix après tests

Vincent 3 月之前
父節點
當前提交
c08286f87b

+ 41 - 5
components/Form/Freemium/Event.vue

@@ -44,6 +44,7 @@
         <UiInputImage
           v-model="proxyEntity.image"
           field="image"
+          label="event_image"
           :width="240"
           :cropping-enabled="true"
         />
@@ -66,7 +67,21 @@
           @update:model-value="getPlace(proxyEntity)"
         />
 
-        <v-row v-if="!newPlace" class="mb-6 justify-center">
+        <v-row class="mb-6 justify-center">
+          <v-col
+            v-if="newPlace"
+            cols="12"
+            sm="6"
+            class="d-flex justify-center mb-2"
+          >
+            <v-btn
+              prepend-icon="fa-solid fa-cancel"
+              @click="onCancelPlaceClick(proxyEntity)"
+            >
+              {{ $t('cancel') }}
+            </v-btn>
+          </v-col>
+
           <v-col
             v-if="proxyEntity.place && !editPlace"
             cols="12"
@@ -81,7 +96,9 @@
             </v-btn>
           </v-col>
 
-          <v-col cols="12" sm="6" class="d-flex justify-center mb-2">
+          <v-col
+            v-if="!newPlace"
+            cols="12" sm="6" class="d-flex justify-center mb-2">
             <v-btn
               prepend-icon="fa-solid fa-plus"
               @click="onAddPlaceClick(proxyEntity)"
@@ -159,7 +176,7 @@
       <v-col cols="12">
         <h4 class="mb-8">{{ $t('communication_params') }}</h4>
 
-        <UiInputText v-model="proxyEntity.url" field="url" />
+        <UiInputText v-model="proxyEntity.url" field="url" :rules="getAsserts('url')" />
 
         <UiInputAutocompleteEnum
           v-model="proxyEntity.pricing"
@@ -170,6 +187,7 @@
         <UiInputText
           v-if="proxyEntity.pricing === 'PAID'"
           v-model="proxyEntity.urlTicket"
+          :rules="getAsserts('urlTicket')"
           field="urlTicket"
         />
 
@@ -177,12 +195,14 @@
           v-if="proxyEntity.pricing === 'PAID'"
           v-model="proxyEntity.priceMini"
           field="priceMini"
+          :rules="getAsserts('priceMini')"
         />
 
         <UiInputNumber
           v-if="proxyEntity.pricing === 'PAID'"
           v-model="proxyEntity.priceMaxi"
           field="priceMaxi"
+          :rules="getAsserts('priceMaxi')"
         />
       </v-col>
     </v-row>
@@ -196,6 +216,10 @@
         <p>
           {{ $t('warning_edit_place') }}
         </p>
+        <br />
+        <p>
+          {{ $t('are_you_sure_to_process') }}
+        </p>
       </v-card-text>
     </template>
     <template #dialogBtn>
@@ -241,7 +265,10 @@ const proxyEntity = computed({
  */
 const onUpdateDateTimeStart = (entity, dateTime) => {
   if (DateUtils.isBefore(props.entity.datetimeEnd, dateTime, false)) {
-    entity.datetimeEnd = dateTime
+    const dateTimeEnd = new Date(dateTime);
+    // Ajouter 1h
+    dateTimeEnd.setHours(dateTimeEnd.getHours() + 1);
+    entity.datetimeEnd = DateUtils.toIsoUtcOffset(dateTimeEnd)
   }
   entity.datetimeStart = dateTime
   emit('update:entity', entity)
@@ -255,7 +282,10 @@ const onUpdateDateTimeStart = (entity, dateTime) => {
  */
 const onUpdateDateTimeEnd = (entity, dateTime) => {
   if (DateUtils.isBefore(dateTime, props.entity.datetimeStart, false)) {
-    entity.datetimeStart = dateTime
+    const dateTimeStart = new Date(dateTime);
+    // Retirer 1h
+    dateTimeStart.setHours(dateTimeStart.getHours() - 1);
+    entity.datetimeStart = DateUtils.toIsoUtcOffset(dateTimeStart)
   }
   entity.datetimeEnd = dateTime
   emit('update:entity', entity)
@@ -276,6 +306,12 @@ const onAddPlaceClick = function (entity: Event) {
   resetPlace(entity)
 }
 
+const onCancelPlaceClick = function (entity: Event) {
+  newPlace.value = false
+  entity.place = null
+  resetPlace(entity)
+}
+
 /**
  * Quand on clique sur le bouton "Editer le lieu", une alerte s'affiche.
  */

+ 6 - 2
components/Layout/Alert/Content.vue

@@ -15,7 +15,7 @@
   >
     <ul v-if="props.alert.messages.length > 1">
       <li v-for="message in props.alert.messages" :key="message">
-        {{ $t(message) }}
+        - {{ $t(message) }}
       </li>
     </ul>
     <span v-else>
@@ -84,4 +84,8 @@ onUnmounted(() => {
 })
 </script>
 
-<style scoped></style>
+<style scoped>
+.message-alert{
+  list-style: disc;
+}
+</style>

+ 1 - 1
components/Layout/Header.vue

@@ -50,7 +50,7 @@ Contient entre autres le nom de l'organisation, l'accès à l'aide et aux préf
 
     <LayoutHeaderMenu name="MyFamily" :translate-label="false" />
 
-    <LayoutHeaderNotification />
+    <LayoutHeaderNotification v-if="layoutStore.name !== 'freemium'" />
 
     <LayoutHeaderMenu name="Configuration" />
 

+ 1 - 5
components/Layout/SubHeader/Breadcrumbs.vue

@@ -22,20 +22,16 @@ const items: ComputedRef<Array<AnyJson>> = computed(() => {
     title: i18n.t('welcome'),
     href: UrlUtils.join(baseUrl, '#', 'dashboard'),
   })
-
   const pathPart: Array<string> = UrlUtils.split(router.currentRoute.value.path)
 
   let path: string = ''
 
   pathPart.forEach((part) => {
     path = UrlUtils.join(path, part)
-
     const match = router.resolve(path)
     if (match.name) {
       crumbs.push({
-        title: !parseInt(part, 10)
-          ? i18n.t(part + '_breadcrumbs')
-          : i18n.t('item'),
+        title: i18n.t(match.name + '_breadcrumbs'),
         exact: true,
         to: path,
       })

+ 3 - 3
components/Ui/Form.vue

@@ -252,19 +252,19 @@ const submit = async (next: string | null = null) => {
     const err = error as {
       response?: {
         status: number
-        data: { violations?: Array<{ message: string; propertyPath: string }> }
+        _data: { violations?: Array<{ message: string; propertyPath: string }> }
       }
     }
     if (
       err.response &&
       err.response.status === 422 &&
-      err.response.data.violations
+      err.response._data.violations
     ) {
       // TODO: à revoir
       const violations: Array<string> = []
       let fields: AnyJson = {}
 
-      for (const violation of err.response.data.violations) {
+      for (const violation of err.response._data.violations) {
         violations.push(i18n.t(violation.message) as string)
         fields = Object.assign(fields, {
           [violation.propertyPath]: violation.message,

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

@@ -166,13 +166,13 @@ const emit = defineEmits(['update:model-value'])
 const onUpdateDate = (event: string) => {
   updateViolationState()
   const date = DateUtils.combineDateAndTime(event, time.value)
-  emit('update:model-value', date.toISOString().replace('.000Z', '+00:00'))
+  emit('update:model-value', DateUtils.toIsoUtcOffset(date))
 }
 
 const onUpdateTime = (event: string) => {
   updateViolationState()
   const date = DateUtils.combineDateAndTime(dateModel.value, event)
-  emit('update:model-value', date.toISOString().replace('.000Z', '+00:00'))
+  emit('update:model-value', DateUtils.toIsoUtcOffset(date))
 }
 
 onBeforeUnmount(() => {

+ 15 - 0
components/Ui/Input/Image.vue

@@ -5,6 +5,7 @@ Assistant de création d'image
 -->
 <template>
   <div class="input-image">
+    <label class="label" v-if="label || field">{{$t(label ?? field)}}</label>
     <UiImage
       ref="uiImage"
       :image-id="modelValue"
@@ -132,6 +133,15 @@ const props = defineProps({
     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,
+  },
   /**
    * Image par défaut en cas d'absence d'une image uploadée
    */
@@ -458,6 +468,11 @@ onBeforeUnmount(() => {
 </script>
 
 <style scoped lang="scss">
+.label{
+  font-size: 16px;
+  color: rgb(var(--v-theme-on-primary-alt));
+}
+
 :deep(.vue-advanced-cropper__stretcher) {
   height: auto !important;
   width: auto !important;

+ 15 - 7
composables/data/useAp2iRequestService.ts

@@ -77,19 +77,27 @@ export const useAp2iRequestService = () => {
       (response.status === 400 || response.status >= 404)
     ) {
       // @see https://developer.mozilla.org/fr/docs/Web/HTTP/Status
-      let errorMsg
+      let errorMsg = []
       if (error) {
-        errorMsg = error.message
-      } else if (response._data && response._data.detail) {
-        errorMsg = response._data.detail
+        errorMsg.push(error.message)
+      }
+      else if(response._data && response._data.violations){
+        for (const violation of response._data.violations) {
+          errorMsg.push(violation.message as string)
+        }
+      }
+      else if (response._data && response._data.detail) {
+        errorMsg.push(response._data.detail)
       } else if (response.statusText) {
-        errorMsg = response.statusText
+        errorMsg.push(response.statusText)
       } else {
-        errorMsg = 'An error occured'
+        errorMsg.push('An error occured')
       }
 
       console.error('! Request error: ' + errorMsg)
-      usePageStore().addAlert(TYPE_ALERT.ALERT, [errorMsg])
+      usePageStore().addAlert(TYPE_ALERT.ALERT, errorMsg)
+
+      throw ({ response, error })
     }
   }
 

+ 30 - 0
i18n/lang/fr/breadcrumbs.json

@@ -0,0 +1,30 @@
+{
+  "freemium_event_create_page_breadcrumbs":  "Création d'un événement",
+  "freemium_dashboard_page_breadcrumbs": "Freemium",
+  "freemium_event_edit_page_breadcrumbs": "Édition d'un événement",
+  "freemium_organization_page_breadcrumbs": "Fiche de ma structure",
+  "new_education_timing_breadcrumbs":  "Création",
+  "record_a_new_subdomain_breadcrumbs":  "Nouveau sous-domaine",
+  "cycle_breadcrumbs": "Édition d'un cycle",
+  "educationTiming_breadcrumbs":  "Détails",
+  "create_a_new_residence_area_breadcrumbs": "Création",
+  "edit_resident_area_breadcrumbs": "Détails",
+  "parameters_residence_areas_page_breadcrumbs":  "Zone de résidence",
+  "activate_a_subdomain_breadcrumbs":  "Activer un sous-domaine",
+  "parameters_attendances_page_breadcrumbs":  "Absences",
+  "new_attendance_booking_reason_breadcrumbs": "Création d'un motif",
+  "attendanceBookingReason_breadcrumbs":  "Édition d'un motif",
+  "parameters_bulletin_page_breadcrumbs": "Bulletins",
+  "parameters_education_notation_page_breadcrumbs":  "Suivi pédagogique",
+  "parameters_education_timings_page_breadcrumbs": "Durée des cours",
+  "parameters_general_page_breadcrumbs":  "Paramètres généraux",
+  "parameters_intranet_page_breadcrumbs": "Accès intranet",
+  "parameters_sms_page_breadcrumbs": "Option SMS",
+  "parameters_super_admin_page_breadcrumbs":  "Compte super admin",
+  "parameters_teaching_page_breadcrumbs": "Enseignements",
+  "parameters_website_page_breadcrumbs":  "Site internet",
+  "cmf_licence_page_breadcrumbs":  "Licence CMF",
+  "my_settings_page_breadcrumbs": "Mes préférences",
+  "subscription_page_breadcrumbs": "Mon abonnement",
+  "parameters_breadcrumbs": "Préférences"
+}

+ 11 - 26
i18n/lang/fr/general.json

@@ -1,5 +1,13 @@
 {
-  "warning_edit_place": "Si vous modifiez les informations de ce lieu et que ce lieu est lié à d'autre événements,alors les changements seront répercutés dans tous vos événements liés.",
+  "url_error": "Le lien n'est pas correct",
+  "organization_logo": "Logo de votre structure",
+  "event_image": "Affiche de l'événement",
+  "must_be_positive_or_egal_0": "La valeur doit être positive ou égale à 0",
+  "must_be_positive": "La valeur doit être positive",
+  "datetimeStart must be less than datetimeEnd": "La date de début doit être avant la date de fin",
+  "priceMini must be less than priceMaxi": "Le prix minimum doit être plus petit que le prix maximum",
+  "are_you_sure_to_process": "Êtes vous sûr(e) de vouloir continuer ?",
+  "warning_edit_place": "En modifiant ce lieu, tous les évènements déjà associés (y compris les évènements passés) seront automatiquement mis à jour avec les nouvelles informations.",
   "agenda_def": "Pour la promotion de votre structure et de vos événements",
   "manager_def": "Pour les fédérations, confédérations et institutions publiques",
   "school_def": "Pour tous les établissements d’enseignement artistique",
@@ -12,10 +20,8 @@
   "add_event": "Ajouter un événement",
   "my_organization": "Ma structure",
   "edit_organization": "Modifier la structure",
-  "dashboard_breadcrumbs": "Tableau de bord",
-  "freemium_breadcrumbs": "Freemium",
   "i_understand": "Je comprends",
-  "place_change_everywhere": "Les changements apportés seront appliqués aux autres événements",
+  "place_change_everywhere": "Attention : Modification d’un lieu existant",
   "event_categories_choices": "Choisissez à quelles catégories appartient votre événement",
   "search": "Rechercher",
   "others": "autres",
@@ -68,7 +74,6 @@
   "service_detail": "Détail des services",
   "my_settings_page": "Mes préférences",
   "allow_report_message": "Je souhaite recevoir les rapports d'envoi des emails que j'envoie",
-  "my-settings_breadcrumbs": "Mes paramètres",
   "message_settings": "Paramètres des messages",
   "rewards_list": "Configuration des distinctions",
   "access_rewards_list": "Gestion des distinctions",
@@ -77,11 +82,6 @@
   "afi_export": "Export AFI",
   "sdd_regie_export": "Export prélèvements SDD Régie",
   "item": "Détails",
-  "organization_breadcrumbs": "Fiche de la structure",
-  "subscription_breadcrumbs": "Mon abonnement",
-  "address_breadcrumbs": "Adresse postale",
-  "contact_points_breadcrumbs": "Points de contact",
-  "parameters_breadcrumbs": "Paramètres",
   "help_super_admin": "Le compte super-admin possède tous les droits de gestion sur votre logiciel. On l’utilise surtout pour la gestion de votre site internet et, à la première connexion au logiciel, afin de créer des comptes pour tous membres de votre structure. Enfin, il peut également être utile en cas de dépannage dans certaines situations particulières.",
   "yourWebsiteAddressIs": "L'adresse de votre site web est",
   "yourOpentalentWebsiteWillBeDeactivatedOnceYouLlHaveSaved": "Votre site web Opentalent sera désactivé une fois que vous aurez enregistré",
@@ -366,7 +366,7 @@
   "mobilPhone": "Portable",
   "mobilPhoneInvalid": "Portable invalide",
   "actions": "Actions",
-  "twitter": "Lien Twitter",
+  "twitter": "Lien X",
   "facebook": "Lien Facebook",
   "instagram": "Lien Instagram",
   "image": "Image",
@@ -500,7 +500,6 @@
   "network": "Réseau",
   "schedule": "Agenda",
   "attendances": "Absences",
-  "attendances_breadcrumbs": "Absences",
   "equipment": "Parc matériel",
   "basicompta_admin": "Comptabilité BasiCompta",
   "education_state": "Suivi pédagogique",
@@ -565,7 +564,6 @@
   "tree_menu": "Gestion de l'arbre",
   "website": "Site internet",
   "parameters_website_page": "Site internet",
-  "website_breadcrumbs": "Site internet",
   "websiteList": "Site(s) internet",
   "advanced_modification": "Administration site internet",
   "simple_modification": "Modifications simplifiées",
@@ -706,19 +704,14 @@
   "show_adherents_list_and_their_coordinates": "Autoriser l'affichage de la liste des adhérents de votre structure, avec leurs coordonnées, dans le compte utilisateur de vos membres.",
   "students_are_also_association_members": "Les élèves sont adhérents également de l'association",
   "parameters_general_page": "Paramètres généraux",
-  "general_parameters_breadcrumbs": "Paramètres généraux",
   "teaching": "Enseignements",
   "parameters_teaching_page": "Enseignements",
-  "teaching_breadcrumbs": "Enseignements",
   "intranet_access": "Accès intranet (professeurs, élèves...)",
   "parameters_intranet_page": "Accès intranet",
-  "intranet_breadcrumbs": "Accès intranet",
   "educationNotations": "Suivi pédagogique",
   "parameters_education_notation_page": "Suivi pédagogique",
-  "education_notation_breadcrumbs": "Suivi pédagogique",
   "bulletin": "Bulletins",
   "parameters_bulletin_page": "Bulletins",
-  "bulletin_breadcrumbs": "Bulletins",
   "educationTimings": "Durée des cours (en minutes)",
   "new_education_timing": "Nouvelle durée de cours",
   "residenceAreas": "Zones de résidence",
@@ -728,10 +721,8 @@
   "sms_option_configuration": "Configuration de l'option SMS",
   "sms_option_configuration_notice": "Pour utiliser l'option SMS, renseignez les informations d'identification Mobyt de votre structure",
   "sms_option_configuration_tip": "Pour utiliser l'option SMS, renseignez les informations d'identification Mobyt de votre structure",
-  "sms_breadcrumbs": "SMS",
   "super_admin": "Compte super-admin",
   "parameters_super_admin_page": "Compte super-admin",
-  "super_admin_breadcrumbs": "Compte super-admin",
   "an_error_happened": "Une erreur s'est produite",
   "your_website": "Votre site web",
   "your_website_address_is": "L'adresse de votre site internet est",
@@ -740,8 +731,6 @@
   "your_subdomains": "Vos sous-domaines",
   "other_website": "Autre site internet",
   "Not Found": "Données non trouvée",
-  "subdomains_breadcrumbs": "Sous-domaines",
-  "new_breadcrumbs": "Nouveau",
   "validation_pending": "Validation en cours",
   "The subdomain is already active": "Le sous-domaine est déjà actif",
   "Not a valid subdomain": "Le sous-domaine est invalide",
@@ -779,16 +768,12 @@
   "Europe/Paris": "Europe/Paris",
   "licenceQrCode": "QrCode pour la licence",
   "parameters_education_timings_page": "Durée des cours",
-  "education_timings_breadcrumbs": "Durée des cours",
   "create_a_new_residence_area": "Créer une nouvelle zone de résidence",
-  "residence_areas_breadcrumbs": "Zones de résidence",
   "edit_resident_area": "Éditer la zone de résidence",
   "super_admin_explanation_text": "Le compte super-admin possède tous les droits de gestion sur votre logiciel. On l’utilise entre autre pour la gestion de votre site internet, pour créer les comptes des membres de votre structure à la première connexion au logiciel, ou dans des situations de dépannage.",
   "exit": "Quitter",
   "max_size_4_mb": "Taille maximum: 4 MO",
   "file_too_large": "Le fichier est trop volumineux",
-  "cycles_breadcrumbs": "Enseignements",
-  "cmf_licence_structure_breadcrumbs": "Licence CMF - Structure",
   "no_recorded_subdomain": "Aucun sous-domaine enregistré",
   "no_admin_access_recorded": "Aucun compte super-admin enregistré",
   "redirecting": "Redirection en cours",

+ 4 - 0
models/Freemium/Event.ts

@@ -36,9 +36,11 @@ export default class Event extends ApiModel {
   declare image: number | null
 
   @Str(null)
+  @Assert({ max: 255, type: 'url' })
   declare url: string
 
   @Str(null)
+  @Assert({ max: 255, type: 'url' })
   declare urlTicket: string
 
   @Attr(null)
@@ -77,9 +79,11 @@ export default class Event extends ApiModel {
   declare pricing: string | null
 
   @Num(null)
+  @Assert({ nullable: true, positive: 'positive' })
   declare priceMini: number | null
 
   @Num(null)
+  @Assert({ nullable: true, positive: 'positive' })
   declare priceMaxi: number | null
 
   @Attr(() => [])

+ 4 - 4
models/Freemium/Organization.ts

@@ -55,19 +55,19 @@ export default class Organization extends ApiModel {
   declare longitude: number | null
 
   @Str(null)
-  @Assert({ max: 255 })
+  @Assert({ max: 255, type: 'url' })
   declare facebook: string
 
   @Str(null)
-  @Assert({ max: 255 })
+  @Assert({ max: 255, type: 'url' })
   declare twitter: string
 
   @Str(null)
-  @Assert({ max: 255 })
+  @Assert({ max: 255, type: 'url' })
   declare youtube: string
 
   @Str(null)
-  @Assert({ max: 255 })
+  @Assert({ max: 255, type: 'url' })
   declare instagram: string
 
   @Bool(true)

+ 1 - 1
nuxt.config.ts

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

+ 1 - 1
pages/freemium/events/[id].vue

@@ -2,7 +2,7 @@
   <UiFormEdition
     class="inner-container"
     :model="Event"
-    go-back-route="/freemium/dashboard"
+    go-back-route="/freemium"
   >
     <template #default="{ entity }">
       <FormFreemiumEvent v-if="entity !== null" :entity="entity" />

+ 1 - 1
pages/freemium/events/new.vue

@@ -2,7 +2,7 @@
   <UiFormCreation
     class="inner-container"
     :model="Event"
-    go-back-route="/freemium/dashboard"
+    go-back-route="/freemium"
   >
     <template #default="{ entity }">
       <FormFreemiumEvent :entity="entity" />

+ 4 - 4
pages/freemium/dashboard.vue → pages/freemium/index.vue

@@ -10,7 +10,7 @@
               <v-tab value="past">{{ $t('past_event') }}</v-tab>
             </v-tabs>
 
-            <v-btn color="primary" to="events/new" class="ml-5 mt-5">{{
+            <v-btn color="primary" to="freemium/events/new" class="ml-5 mt-5">{{
               $t('add_event')
             }}</v-btn>
 
@@ -78,7 +78,7 @@
             </v-card-text>
           </v-card>
 
-          <v-btn block class="mb-2 btn btn_edit_orga" to="organization">
+          <v-btn block class="mb-2 btn btn_edit_orga" to="freemium/organization">
             <i class="fa fa-pen mr-2" />{{ $t('edit_organization') }}
           </v-btn>
 
@@ -153,7 +153,7 @@ const {
  */
 function loadUpcomingEvents(pageNumber: number) {
   upcomingPage.value = pageNumber
-  refreshPastEvents()
+  refreshUpcomingEvents()
 }
 
 /**
@@ -170,7 +170,7 @@ function loadPastEvents(pageNumber: number) {
  * @param eventId
  */
 function editEvent(eventId: number) {
-  navigateTo(UrlUtils.join('events', eventId))
+  navigateTo(UrlUtils.join('freemium/events', eventId))
 }
 
 /**

+ 5 - 4
pages/freemium/organization.vue

@@ -21,6 +21,7 @@
               <UiInputImage
                 v-model="organization.logo"
                 field="logo"
+                label="organization_logo"
                 :width="120"
                 :cropping-enabled="true"
               />
@@ -104,13 +105,13 @@
             <v-col cols="12">
               <h4 class="mb-8">{{ $t('communication_params') }}</h4>
 
-              <UiInputText v-model="organization.facebook" field="facebook" />
+              <UiInputText v-model="organization.facebook" field="facebook" :rules="getAsserts('facebook')" />
 
-              <UiInputText v-model="organization.twitter" field="twitter" />
+              <UiInputText v-model="organization.twitter" field="twitter" :rules="getAsserts('twitter')" />
 
-              <UiInputText v-model="organization.youtube" field="youtube" />
+              <UiInputText v-model="organization.youtube" field="youtube" :rules="getAsserts('youtube')" />
 
-              <UiInputText v-model="organization.instagram" field="instagram" />
+              <UiInputText v-model="organization.instagram" field="instagram" :rules="getAsserts('instagram')" />
 
               <UiInputCheckbox
                 v-model="organization.portailVisibility"

+ 1 - 0
pages/parameters/sms.vue

@@ -48,6 +48,7 @@
 <script setup lang="ts">
 import Parameters from '~/models/Organization/Parameters'
 import { useOrganizationProfileStore } from '~/stores/organizationProfile'
+import {useEntityFetch} from "~/composables/data/useEntityFetch";
 
 definePageMeta({
   name: 'parameters_sms_page',

+ 1 - 1
plugins/error.ts

@@ -1,5 +1,5 @@
 export default defineNuxtPlugin((nuxtApp) => {
   nuxtApp.vueApp.config.errorHandler = (error, _) => {
-    console.error(error)
+   // console.error(error)
   }
 })

+ 2 - 1
services/asserts/AssertRuleRegistry.ts

@@ -2,12 +2,13 @@ import type { AssertRule } from '~/types/interfaces'
 import { MaxAssert } from './MaxAssert'
 import { NullableAssert } from './NullableAssert'
 import { TypeAssert } from './TypeAssert'
+import { PositiveAssert } from './PositiveAssert'
 
 export class AssertRuleRegistry {
   private rules: AssertRule[] = []
 
   constructor() {
-    this.rules = [new MaxAssert(), new NullableAssert(), new TypeAssert()]
+    this.rules = [new MaxAssert(), new NullableAssert(), new TypeAssert(), new PositiveAssert()]
   }
 
   getValidators(

+ 2 - 2
services/asserts/MaxAssert.ts

@@ -5,8 +5,8 @@ export class MaxAssert implements AssertRule {
     return key === 'max'
   }
 
-  createRule(criteria: number): (value: string) => true | string {
-    return (value: string) =>
+  createRule(criteria: number): (value: unknown) => true | string {
+    return (value: unknown) =>
       value === null ||
       value.length <= criteria ||
       `Maximum ${criteria} caractères`

+ 26 - 0
services/asserts/PositiveAssert.ts

@@ -0,0 +1,26 @@
+import type { AssertRule } from '~/types/interfaces'
+import {useI18n} from "vue-i18n";
+
+export class PositiveAssert implements AssertRule {
+  supports(key: string): boolean {
+    return key === 'positive'
+  }
+
+  createRule(criteria: string): (value: unknown) => true | string {
+    const { t } = useI18n()
+
+    if (criteria === 'positive') {
+      return (value: number) =>
+        value === null ||
+        value > 0 ||
+        t(`must_be_positive`)
+    }else if (criteria === 'positive_or_zero'){
+      return (value: number) =>
+        value === null ||
+        value >= 0 ||
+        t(`must_be_positive_or_egal_0`)
+    }
+
+    return () => true
+  }
+}

+ 7 - 0
services/asserts/TypeAssert.ts

@@ -17,6 +17,13 @@ export class TypeAssert implements AssertRule {
         t('email_error')
     }
 
+    if (criteria === 'url') {
+      return (url: unknown) =>
+        url === null ||
+        (typeof url === 'string' && validationUtils.validUrl(url)) ||
+        t('url_error')
+    }
+
     if (criteria === 'integer') {
       return (value: unknown) =>
         Number.isInteger(value as number) || t('need_to_be_integer')

+ 4 - 0
services/utils/dateUtils.ts

@@ -70,6 +70,10 @@ const DateUtils = {
     return format(date, 'yyyy-MM-dd')
   },
 
+  toIsoUtcOffset(date: Date): string {
+    return date.toISOString().replace('.000Z', '+00:00')
+  },
+
   combineDateAndTime(date: Date = new Date(), time: string = '00:00'): Date {
     const [hours, minutes] = time.split(':').map(Number)
 

+ 4 - 1
services/utils/validationUtils.ts

@@ -1,8 +1,11 @@
 export default class ValidationUtils {
   public validEmail(email: string) {
-    // regex from https://fr.vuejs.org/v2/cookbook/form-validation.html#Utiliser-une-validation-personnalisee
     const re =
       /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
     return re.test(email)
   }
+  public validUrl(url: string) {
+    const re = /^(https?:\/\/)([\w.-]+)(:[0-9]+)?(\/[^\s]*)?$/i
+    return re.test(url)
+  }
 }

+ 32 - 0
tests/units/services/asserts/maxAssert.test.ts

@@ -0,0 +1,32 @@
+import { describe, it, expect } from 'vitest'
+import { MaxAssert } from '~/services/asserts/MaxAssert'
+
+describe('MaxAssert', () => {
+  it('supports retourne true uniquement pour "max"', () => {
+    const maxAssert = new MaxAssert()
+    expect(maxAssert.supports('max')).toBe(true)
+    expect(maxAssert.supports('min')).toBe(false)
+    expect(maxAssert.supports('other')).toBe(false)
+  })
+
+  describe('createRule', () => {
+    it('retourne true si value est null', () => {
+      const maxAssert = new MaxAssert()
+      const rule = maxAssert.createRule(5)
+      expect(rule(null as any)).toBe(true)
+    })
+
+    it('retourne true si value.length <= criteria', () => {
+      const maxAssert = new MaxAssert()
+      const rule = maxAssert.createRule(5)
+      expect(rule('abc')).toBe(true) // longueur 3 <= 5
+      expect(rule('12345')).toBe(true) // longueur 5 == 5
+    })
+
+    it('retourne un message si value.length > criteria', () => {
+      const maxAssert = new MaxAssert()
+      const rule = maxAssert.createRule(5)
+      expect(rule('abcdef')).toBe('Maximum 5 caractères')
+    })
+  })
+})

+ 37 - 0
tests/units/services/asserts/nullAssert.test.ts

@@ -0,0 +1,37 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { NullableAssert } from '~/services/asserts/NullableAssert'
+
+// Mock de vue-i18n
+vi.mock('vue-i18n', () => ({
+  useI18n: () => ({
+    t: vi.fn((key: string) => `__translated__${key}__`), // simulons une trad simple
+  }),
+}))
+
+describe('NullableAssert', () => {
+  it('supports retourne true uniquement pour "nullable"', () => {
+    const nullAssert = new NullableAssert()
+    expect(nullAssert.supports('nullable')).toBe(true)
+    expect(nullAssert.supports('other')).toBe(false)
+  })
+
+  describe('createRule', () => {
+    it('retourne true si criteria est true', () => {
+      const nullAssert = new NullableAssert()
+      const rule = nullAssert.createRule(true)
+      expect(rule('joe')).toBe(true)
+    })
+
+    it('retourne true criteria est false et si value est n\'est pas null', () => {
+      const nullAssert = new NullableAssert()
+      const rule = nullAssert.createRule(false)
+      expect(rule('joe')).toBe(true)
+    })
+
+    it('retourne un message si criteria est false et si value est null', () => {
+      const nullAssert = new NullableAssert()
+      const rule = nullAssert.createRule(false)
+      expect(rule(null)).toBe('__translated__please_enter_a_value__')
+    })
+  })
+})

+ 35 - 0
tests/units/services/asserts/typeAssert.test.ts

@@ -0,0 +1,35 @@
+import { describe, it, expect, vi } from 'vitest'
+import { TypeAssert } from '~/services/asserts/TypeAssert'
+
+// Mock de vue-i18n
+vi.mock('vue-i18n', () => ({
+  useI18n: () => ({
+    t: vi.fn((key: string) => `__translated__${key}__`), // simulons une trad simple
+  }),
+}))
+
+// Mock de ValidationUtils
+const validEmailMock = vi.fn()
+vi.mock('~/services/utils/validationUtils', () => {
+  return {
+    default: vi.fn().mockImplementation(() => ({
+      validEmail: validEmailMock,
+    })),
+  }
+})
+
+describe('TypeAssert', () => {
+  it('supports retourne true uniquement pour "nullable"', () => {
+    const typeAssert = new TypeAssert()
+    expect(typeAssert.supports('type')).toBe(true)
+    expect(typeAssert.supports('other')).toBe(false)
+  })
+
+  describe('createRule', () => {
+    it('retourne true si criteria si criteria n\'est pas reconnu parmi les types existants', () => {
+      const typeAssert = new TypeAssert()
+      const rule = typeAssert.createRule('foo')
+      expect(rule('joe')).toBe(true)
+    })
+  })
+})

+ 79 - 0
tests/units/services/data/filters/inArrayFilter.test.ts

@@ -0,0 +1,79 @@
+import { ref } from 'vue'
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import InArrayFilter from '~/services/data/Filters/InArrayFilter'
+
+// Mocks
+vi.mock('~/services/utils/refUtils', () => ({
+  default: {
+    castToRef: vi.fn((val) => {
+      // Si c'est déjà un Ref
+      if (val && typeof val === 'object' && 'value' in val) {
+        return val
+      }
+      // Sinon, on le met dans un Ref
+      return { value: val }
+    }),
+  },
+}))
+
+describe('InArrayFilter', () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+  })
+
+  it('initialise correctement les propriétés', () => {
+    const filter = new InArrayFilter('status', ['active', 'inactive'])
+    expect(filter.field).toBe('status')
+    expect(filter.filterValue).toEqual(['active', 'inactive'])
+  })
+
+  describe('applyToPiniaOrmQuery', () => {
+    it('retourne la query telle quelle si filterValue est null', () => {
+      const queryMock = { whereIn: vi.fn().mockReturnThis() }
+      const filter = new InArrayFilter('role', null)
+      const result = filter.applyToPiniaOrmQuery(queryMock as any)
+      expect(result).toBe(queryMock)
+      expect(queryMock.whereIn).not.toHaveBeenCalled()
+    })
+
+    it('appelle whereIn avec les bonnes valeurs', () => {
+      const queryMock = { whereIn: vi.fn().mockReturnThis() }
+      const filter = new InArrayFilter('role', ['admin', 'user'])
+      const result = filter.applyToPiniaOrmQuery(queryMock as any)
+      expect(queryMock.whereIn).toHaveBeenCalledWith('role', ['admin', 'user'])
+      expect(result).toBe(queryMock)
+    })
+  })
+
+  describe('getApiQueryPart', () => {
+    it('retourne "" si filterValue est null', () => {
+      const filter = new InArrayFilter('role', null)
+      expect(filter.getApiQueryPart()).toBe('')
+    })
+
+    it('transforme en tableau si pas déjà un tableau', () => {
+      const filter = new InArrayFilter('role', 'admin')
+      expect(filter.getApiQueryPart()).toBe('role[in]=admin')
+    })
+
+    it('filtre les null du tableau', () => {
+      const filter = new InArrayFilter('role', ['admin', null, 'user'])
+      expect(filter.getApiQueryPart()).toBe('role[in]=admin,user')
+    })
+
+    it('retourne "" si après filtrage le tableau est vide', () => {
+      const filter = new InArrayFilter('role', [null, null])
+      expect(filter.getApiQueryPart()).toBe('')
+    })
+
+    it('concatène plusieurs valeurs', () => {
+      const filter = new InArrayFilter('role', ['admin', 'user', 'guest'])
+      expect(filter.getApiQueryPart()).toBe('role[in]=admin,user,guest')
+    })
+
+    it('gère correctement un Ref comme filterValue', () => {
+      const filter = new InArrayFilter('status', ref(['active']))
+      expect(filter.getApiQueryPart()).toBe('status[in]=active')
+    })
+  })
+})

+ 105 - 0
tests/units/services/data/filters/timeFilter.test.ts

@@ -0,0 +1,105 @@
+import { ref } from 'vue'
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import TimeFilter from '~/services/data/Filters/TimeFilter'
+import { TIME_STRATEGY } from '~/types/enum/data'
+import DateUtils from '~/services/utils/dateUtils'
+
+// Mocks
+vi.mock('~/services/utils/dateUtils', () => ({
+  default: {
+    isBefore: vi.fn(),
+  },
+}))
+vi.mock('~/services/utils/refUtils', () => ({
+  default: {
+    castToRef: vi.fn((val) => val),
+  },
+}))
+
+describe('TimeFilter', () => {
+  let filter: TimeFilter
+
+  beforeEach(() => {
+    vi.clearAllMocks()
+    filter = new TimeFilter('startTime', ref('2025-01-01T10:00:00'), TIME_STRATEGY.BEFORE)
+  })
+
+  it('initialise correctement les propriétés', () => {
+    expect(filter.field).toBe('startTime')
+    expect(filter.filterValue.value).toBe('2025-01-01T10:00:00')
+    expect(filter.mode).toBe(TIME_STRATEGY.BEFORE)
+  })
+
+  describe('search', () => {
+    it('retourne false si filterValue est null', () => {
+      const f = new TimeFilter('f', ref(null), TIME_STRATEGY.BEFORE)
+      expect(f['search']('2025-01-01', f.filterValue)).toBe(false)
+    })
+
+    it('utilise DateUtils.isBefore pour BEFORE', () => {
+      DateUtils.isBefore.mockReturnValueOnce(true)
+      const result = filter['search']('2025-01-02', filter.filterValue)
+      expect(DateUtils.isBefore).toHaveBeenCalledWith('2025-01-02', '2025-01-01T10:00:00', false)
+      expect(result).toBe(true)
+    })
+
+    it('utilise DateUtils.isBefore pour AFTER', () => {
+      const f = new TimeFilter('f', ref('2025-01-01'), TIME_STRATEGY.AFTER)
+      DateUtils.isBefore.mockReturnValueOnce(false)
+      const result = f['search']('2025-01-02', f.filterValue)
+      expect(DateUtils.isBefore).toHaveBeenCalledWith('2025-01-01', '2025-01-02', false)
+      expect(result).toBe(false)
+    })
+
+    it('utilise DateUtils.isBefore strict pour STRICTLY_BEFORE', () => {
+      const f = new TimeFilter('f', ref('2025-01-01'), TIME_STRATEGY.STRICTLY_BEFORE)
+      DateUtils.isBefore.mockReturnValueOnce(true)
+      const result = f['search']('2025-01-02', f.filterValue)
+      expect(DateUtils.isBefore).toHaveBeenCalledWith('2025-01-02', '2025-01-01')
+      expect(result).toBe(true)
+    })
+
+    it('utilise DateUtils.isBefore strict pour STRICTLY_AFTER', () => {
+      const f = new TimeFilter('f', ref('2025-01-01'), TIME_STRATEGY.STRICTLY_AFTER)
+      DateUtils.isBefore.mockReturnValueOnce(false)
+      const result = f['search']('2025-01-02', f.filterValue)
+      expect(DateUtils.isBefore).toHaveBeenCalledWith('2025-01-01', '2025-01-02')
+      expect(result).toBe(false)
+    })
+
+    it('lève une erreur si mode inconnu', () => {
+      const f = new TimeFilter('f', ref('2025-01-01'), 'INVALID' as any)
+      expect(() => f['search']('2025-01-02', f.filterValue)).toThrow('Unrecognized mode')
+    })
+  })
+
+  describe('applyToPiniaOrmQuery', () => {
+    it('retourne la query telle quelle si filterValue est null', () => {
+      const f = new TimeFilter('f', ref(null), TIME_STRATEGY.BEFORE)
+      const queryMock = { where: vi.fn().mockReturnThis() }
+      const result = f.applyToPiniaOrmQuery(queryMock as any)
+      expect(result).toBe(queryMock)
+      expect(queryMock.where).not.toHaveBeenCalled()
+    })
+
+    it('applique where si filterValue existe', () => {
+      const queryMock = { where: vi.fn().mockReturnThis() }
+      DateUtils.isBefore.mockReturnValue(true)
+
+      const result = filter.applyToPiniaOrmQuery(queryMock as any)
+      expect(queryMock.where).toHaveBeenCalled()
+      expect(result).toBe(queryMock)
+    })
+  })
+
+  describe('getApiQueryPart', () => {
+    it('retourne "" si filterValue est null', () => {
+      const f = new TimeFilter('f', ref(null), TIME_STRATEGY.BEFORE)
+      expect(f.getApiQueryPart()).toBe('')
+    })
+
+    it('retourne correctement la query', () => {
+      expect(filter.getApiQueryPart()).toBe('startTime[before]=2025-01-01T10:00:00')
+    })
+  })
+})