瀏覽代碼

reprise de la page organization (ongoing)

Olivier Massot 3 年之前
父節點
當前提交
cf287b5e66
共有 56 個文件被更改,包括 3357 次插入163 次删除
  1. 3 2
      components/Layout/Menu.vue
  2. 71 0
      components/Ui/Button/Delete.vue
  3. 84 0
      components/Ui/Button/Submit.vue
  4. 64 0
      components/Ui/Card.vue
  5. 57 0
      components/Ui/Collection.vue
  6. 87 0
      components/Ui/DataTable.vue
  7. 276 0
      components/Ui/Form.vue
  8. 98 0
      components/Ui/Help.vue
  9. 156 0
      components/Ui/Image.vue
  10. 220 0
      components/Ui/Input/Autocomplete.vue
  11. 145 0
      components/Ui/Input/AutocompleteWithAPI.vue
  12. 68 0
      components/Ui/Input/Checkbox.vue
  13. 141 0
      components/Ui/Input/DatePicker.vue
  14. 78 0
      components/Ui/Input/Email.vue
  15. 96 0
      components/Ui/Input/Enum.vue
  16. 293 0
      components/Ui/Input/Image.vue
  17. 107 0
      components/Ui/Input/Phone.vue
  18. 96 0
      components/Ui/Input/Text.vue
  19. 73 0
      components/Ui/Input/TextArea.vue
  20. 49 0
      components/Ui/Template/DataTable.vue
  21. 26 0
      components/Ui/Template/Date.vue
  22. 27 4
      composables/data/useAp2iRequestService.ts
  23. 20 8
      composables/data/useEntityFetch.ts
  24. 2 2
      composables/data/useEntityManager.ts
  25. 19 0
      composables/data/useEnumFetch.ts
  26. 8 0
      composables/data/useEnumManager.ts
  27. 38 0
      composables/data/useImageFetch.ts
  28. 7 0
      composables/data/useImageManager.ts
  29. 33 0
      composables/form/useFieldViolation.ts
  30. 61 0
      composables/form/useValidation.ts
  31. 25 0
      composables/layout/useExtensionPanel.ts
  32. 7 0
      composables/utils/useDateUtils.ts
  33. 9 0
      composables/utils/useI18nUtils.ts
  34. 5 0
      composables/utils/useValidationUtils.ts
  35. 1 1
      lang/form/fr-FR.js
  36. 2 2
      layouts/default.vue
  37. 10 0
      models/ApiResource.ts
  38. 3 0
      package.json
  39. 85 0
      pages/organization.vue
  40. 541 0
      pages/organization/index.vue
  41. 1 1
      plugins/init.server.ts
  42. 15 10
      services/data/entityManager.ts
  43. 3 3
      services/data/enumManager.ts
  44. 24 3
      services/data/imageManager.ts
  45. 0 30
      services/store/formStoreHelper.ts
  46. 0 19
      services/store/pageStoreHelper.ts
  47. 6 2
      services/utils/dateUtils.ts
  48. 2 2
      services/utils/i18nUtils.ts
  49. 29 0
      services/utils/imageUtils.ts
  50. 11 10
      services/utils/objectUtils.ts
  51. 9 0
      services/utils/validationUtils.ts
  52. 40 12
      store/form.ts
  53. 15 1
      store/page.ts
  54. 6 0
      types/data.d.ts
  55. 1 1
      types/enums.ts
  56. 4 50
      types/interfaces.d.ts

+ 3 - 2
components/Layout/Menu.vue

@@ -85,6 +85,7 @@ Prend en paramètre une liste de ItemMenu et les met en forme
 
 <script setup lang="ts">
 import { ItemsMenu } from '~/types/interfaces'
+import {WatchStopHandle} from "@vue/runtime-core";
 
 const props = defineProps({
   menu: {
@@ -123,8 +124,8 @@ onUnmounted(() => {
   }
 
   .v-application--is-ltr .v-list-group--no-action > .v-list-group__header{
-    margin-left: 0px;
-    padding-left: 0px;
+    margin-left: 0;
+    padding-left: 0;
   }
   .v-application--is-ltr .v-list-group--no-action > .v-list-group__items > .v-list-item {
     padding-left: 30px;

+ 71 - 0
components/Ui/Button/Delete.vue

@@ -0,0 +1,71 @@
+<!--
+Bouton Delete avec modale de confirmation de la suppression
+-->
+
+<template>
+  <main>
+    <v-btn :icon="true" @click="alertDeleteItem()">
+      <v-icon>mdi-delete</v-icon>
+    </v-btn>
+
+    <LazyLayoutDialog
+      :show="showDialog"
+    >
+      <template #dialogType>{{ $t('delete_assistant') }}</template>
+      <template #dialogTitle>{{ $t('attention') }}</template>
+      <template #dialogText>
+        <v-card-text>
+          <p>{{ $t('confirm_to_delete') }}</p>
+        </v-card-text>
+      </template>
+      <template #dialogBtn>
+        <v-btn class="mr-4 submitBtn ot_grey ot_white--text" @click="closeDialog">
+          {{ $t('cancel') }}
+        </v-btn>
+        <v-btn class="mr-4 submitBtn ot_danger ot_white--text" @click="deleteItem">
+          {{ $t('delete') }}
+        </v-btn>
+      </template>
+    </LazyLayoutDialog>
+  </main>
+</template>
+
+<script setup lang="ts">
+import {TYPE_ALERT} from '~/types/enums'
+import {Ref} from "@vue/reactivity";
+import {useEntityManager} from "~/composables/data/useEntityManager";
+import ApiResource from "~/models/ApiResource";
+import {usePageStore} from "~/store/page";
+
+const props = defineProps({
+    model: {
+      type: Object as () => typeof ApiResource,
+      required: true
+    },
+    entity: {
+      type: Object as () => ApiResource,
+      required: true
+    }
+})
+
+const showDialog: Ref<boolean> = ref(false)
+
+const { em } = useEntityManager()
+
+const deleteItem = async () => {
+  try {
+    await em.delete(props.model, props.entity)
+    usePageStore().addAlerts(TYPE_ALERT.SUCCESS, ['deleteSuccess'])
+  } catch (error: any) {
+    usePageStore().addAlerts(TYPE_ALERT.ALERT, [error.message])
+  }
+  showDialog.value = false
+}
+
+const alertDeleteItem = () => { showDialog.value = true }
+const closeDialog = () => { showDialog.value = false }
+
+</script>
+
+<style scoped>
+</style>

+ 84 - 0
components/Ui/Button/Submit.vue

@@ -0,0 +1,84 @@
+<template>
+  <v-btn class="mr-4 ot_green ot_white--text" :class="otherActions ? 'pr-0' : ''" @click="onClick(mainAction)" ref="mainBtn">
+    {{ $t(mainAction) }}
+
+    <v-divider class="ml-3" :vertical="true" v-if="otherActions"></v-divider>
+
+    <v-menu
+      :top="dropDirection === 'top'"
+      offset-y
+      left
+      v-if="otherActions"
+      :nudge-top="dropDirection === 'top' ? 6 : 0"
+      :nudge-bottom="dropDirection === 'bottom' ? 6 : 0"
+    >
+      <template #activator="{ on, attrs }">
+        <v-toolbar-title v-on="on">
+          <v-icon class="pl-3 pr-3">
+            {{ dropDirection === 'top' ? 'fa-caret-up' : 'fa-caret-down'}}
+          </v-icon>
+        </v-toolbar-title>
+      </template>
+      <v-list
+        :min-width="menuSize"
+      >
+        <v-list-item
+          dense
+          v-for="(action, index) in actions"
+          :key="index"
+          class="subAction"
+          v-if="index > 0"
+        >
+          <v-list-item-title v-text="$t(action)" @click="onClick(action)" />
+        </v-list-item>
+      </v-list>
+    </v-menu>
+  </v-btn>
+</template>
+
+<script setup lang="ts">
+import {useNuxtApp} from "#app";
+import {computed, ComputedRef, ref, Ref} from "@vue/reactivity";
+
+const props = defineProps({
+    actions: {
+      type: Array,
+      required: true
+    },
+    dropDirection: {
+      type: String,
+      required: false,
+      default:'bottom'
+    }
+})
+
+const { emit } = useNuxtApp()
+
+const mainBtn: Ref<any> = ref(null)
+const menuSize = computed(()=>{
+  // Btn size + 40px de padding
+  return mainBtn.value?.$el.clientWidth + 40
+})
+
+const onClick = (action: string) => {
+  emit('submit', action)
+}
+
+const mainAction: ComputedRef<any> = computed(()=>{
+  return props.actions[0] as string
+})
+
+const otherActions: ComputedRef<boolean> = computed(()=>{
+  return props.actions.length > 1
+})
+
+</script>
+
+<style scoped>
+.v-list-item--dense{
+  min-height: 25px;
+}
+.subAction{
+  cursor: pointer;
+}
+</style>

+ 64 - 0
components/Ui/Card.vue

@@ -0,0 +1,64 @@
+<!--
+Container de type Card
+-->
+
+<template>
+  <v-card
+    elevation="2"
+    outlined
+    shaped
+    min-height="200"
+  >
+    <!-- Titre -->
+    <v-card-title>
+      <slot name="card.title" />
+    </v-card-title>
+
+    <!-- Texte -->
+    <v-card-text>
+      <slot name="card.text" />
+    </v-card-text>
+
+    <!-- Actions -->
+    <v-card-actions>
+      <v-spacer />
+
+      <v-btn :icon="true">
+        <NuxtLink :to="link" class="no-decoration">
+          <v-icon>mdi-pencil</v-icon>
+        </NuxtLink>
+      </v-btn>
+
+      <UiButtonDelete v-if="withDeleteAction" :model="model" :entity="entity" />
+
+      <slot name="card.action" />
+    </v-card-actions>
+
+  </v-card>
+</template>
+
+<script setup lang="ts">
+
+const props = defineProps({
+  link: {
+    type: String,
+    required: true
+  },
+  model: {
+    type: Function,
+    required: true
+  },
+  entity: {
+    type: Object,
+    required: true
+  },
+  withDeleteAction:{
+    type: Boolean,
+    required: false,
+    default: true
+  }
+})
+</script>
+
+<style scoped>
+</style>

+ 57 - 0
components/Ui/Collection.vue

@@ -0,0 +1,57 @@
+<!-- Permet de requêter une subResource et de donner son contenu à un slot -->
+
+<template>
+  <main>
+    <v-skeleton-loader v-if="fetchState.pending" :type="loaderType" />
+    <div v-else>
+
+      <!-- Content -->
+      <slot name="list.item" v-bind="{items}" />
+
+      <!-- New button -->
+      <v-btn v-if="newLink" class="ot_white--text ot_green float-right">
+        <NuxtLink :to="newLink" class="no-decoration">
+          <v-icon>fa-plus-circle</v-icon>
+          <span>{{$t('add')}}</span>
+        </NuxtLink>
+      </v-btn>
+    </div>
+    <slot />
+  </main>
+</template>
+
+<script setup lang="ts">
+
+import {computed, ComputedRef, toRefs, ToRefs} from "@vue/reactivity";
+import {useEntityFetch} from "~/composables/data/useEntityFetch";
+import {Collection} from "~/types/data";
+
+const props = defineProps({
+  model: {
+    type: Function,
+    required: true
+  },
+  parent: {
+    type: Object,
+    required: false
+  },
+  loaderType: {
+    type: String,
+    required: false,
+    default: 'text'
+  },
+  newLink: {
+    type: String,
+    required: false
+  }
+})
+
+const { model, parent }: ToRefs = toRefs(props)
+
+const { fetchCollection } = useEntityFetch()
+
+const { data: collection, pending } = await fetchCollection(model.value, parent.value)
+
+const items: ComputedRef<Collection> = computed(() => collection.value ?? { items: [], pagination: {}, totalItems: 0 })
+
+</script>

+ 87 - 0
components/Ui/DataTable.vue

@@ -0,0 +1,87 @@
+<!--
+Tableau interactif
+
+@see https://vuetifyjs.com/en/components/data-tables/
+-->
+
+<template>
+  <v-col
+    cols="12"
+    sm="12"
+  >
+    <v-data-table
+      :headers="headersWithItem"
+      :items="collection.items"
+      :server-items-length="collection.pagination.totalItems"
+      :loading="$fetchState.pending"
+      class="elevation-1"
+    >
+      <template v-for="header in headersWithItem" #[header.item]="props">
+        <slot :name="header.item" v-bind="props">
+          {{ props.item[header.value] }}
+        </slot>
+      </template>
+
+      <template #item.actions="{ item }">
+        <v-icon
+          small
+          class="mr-2"
+          @click="editItem(item)"
+        >
+          mdi-pencil
+        </v-icon>
+        <v-icon
+          small
+          @click="deleteItem(item)"
+        >
+          mdi-delete
+        </v-icon>
+      </template>
+    </v-data-table>
+  </v-col>
+</template>
+
+<script setup lang="ts">
+
+import {ref, Ref, toRefs} from "@vue/reactivity";
+import {AnyJson} from "~/types/data";
+import {useEntityFetch} from "~/composables/data/useEntityFetch";
+import ApiResource from "~/models/ApiResource";
+
+const props = defineProps({
+  parent: {
+    type: Object,
+    required: true
+  },
+  model: {
+    type: Function,
+    required: true
+  },
+  headers: {
+    type: Array,
+    required: true
+  }
+})
+
+const { parent, model, headers } = toRefs(props)
+
+const headersWithItem = computed(() => {
+  return headers.value.map((header:any) => {
+    header.item = 'item.' + header.value
+    return header
+  })
+})
+
+const totalEntries: Ref<number> = ref(0)
+const entries: Ref<Array<AnyJson>> = ref(Array<AnyJson>())
+
+const { fetchCollection } = useEntityFetch()
+
+const { data: collection, pending } = await fetchCollection(model.value as typeof ApiResource, parent.value as ApiResource)
+
+const itemId: Ref<number> = ref(0)
+
+const editItem = (item: AnyJson) => {
+  itemId.value = item.id
+}
+</script>

+ 276 - 0
components/Ui/Form.vue

@@ -0,0 +1,276 @@
+<!--
+Formulaire générique
+
+@see https://vuetifyjs.com/en/components/forms/#usage
+-->
+
+<template>
+  <main>
+    <v-form
+      ref="form"
+      lazy-validation
+      :readonly="readonly"
+    >
+      <!-- Top action bar -->
+      <v-container fluid class="container btnActions">
+        <v-row>
+          <v-col cols="12" sm="12">
+            <slot name="form.button"/>
+
+            <UiButtonSubmit
+              v-if="!readonly"
+              @submit="submit"
+              :actions="actions"
+            ></UiButtonSubmit>
+          </v-col>
+        </v-row>
+      </v-container>
+
+      <!-- Content -->
+      <slot name="form.input" v-bind="{model, entity}"/>
+
+      <!-- Bottom action bar -->
+      <v-container fluid class="container btnActions">
+        <v-row>
+          <v-col cols="12" sm="12">
+            <slot name="form.button"/>
+
+            <UiButtonSubmit
+              @submit="submit"
+              :actions="actions"
+            ></UiButtonSubmit>
+          </v-col>
+        </v-row>
+      </v-container>
+    </v-form>
+
+    <!-- Confirmation dialog -->
+    <LazyLayoutDialog
+      :show="showDialog"
+    >
+      <template #dialogText>
+        <v-card-title class="text-h5 grey lighten-2">
+          {{ $t('caution') }}
+        </v-card-title>
+        <v-card-text>
+          <br>
+          <p>{{ $t('quit_without_saving_warning') }}</p>
+        </v-card-text>
+      </template>
+      <template #dialogBtn>
+        <v-btn class="mr-4 submitBtn ot_green ot_white--text" @click="closeDialog">
+          {{ $t('back_to_form') }}
+        </v-btn>
+        <v-btn class="mr-4 submitBtn ot_green ot_white--text" @click="saveAndQuit">
+          {{ $t('save_and_quit') }}
+        </v-btn>
+        <v-btn class="mr-4 submitBtn ot_danger ot_white--text" @click="quitForm">
+          {{ $t('quit_form') }}
+        </v-btn>
+      </template>
+    </LazyLayoutDialog>
+
+  </main>
+</template>
+
+<script setup lang="ts">
+
+import {computed, ComputedRef, Ref} from "@vue/reactivity";
+import {AnyJson} from "~/types/data";
+import {FORM_FUNCTION, SUBMIT_TYPE, TYPE_ALERT} from "~/types/enums";
+import {useNuxtApp, useRouter} from "#app";
+import { useFormStore } from "~/store/form";
+import {Route} from "@intlify/vue-router-bridge";
+import {useEntityManager} from "~/composables/data/useEntityManager";
+import ApiModel from "~/models/ApiModel";
+import {usePageStore} from "~/store/page";
+
+const props = defineProps({
+  model: {
+    type: Object as () => typeof ApiModel,
+    required: true
+  },
+  entity: {
+    type: Object as () => Ref<ApiModel>,
+    required: true
+  },
+  submitActions: {
+    type: Object,
+    required: false,
+    default: () => {
+      let actions: AnyJson = {}
+      actions[SUBMIT_TYPE.SAVE] = {}
+      return actions
+    }
+  }
+})
+
+const { i18n } = useNuxtApp()
+
+const router = useRouter()
+
+const { em } = useEntityManager()
+
+/**
+ * Réference au component v-form
+ */
+const form: Ref<any> = ref(null)
+
+/**
+ * Handle events if the form is dirty to prevent submission
+ * @param e
+ */
+const preventingEventHandler = (e: any) => {
+  // Cancel the event
+  e.preventDefault()
+  // Chrome requires returnValue to be set
+  e.returnValue = ''
+}
+
+/**
+ * Définit l'état dirty (modifié) du formulaire
+ * // TODO: renommer, on ne se rend pas compte au nom que cette méthode fait plus que changer l'état dirty
+ */
+const setIsDirty = (dirty: boolean) => {
+  useFormStore().setDirty(dirty)
+  if (process.browser) {
+    if (dirty) {
+      window.addEventListener('beforeunload', preventingEventHandler)
+    } else {
+      window.removeEventListener('beforeunload', preventingEventHandler)
+    }
+  }
+}
+
+const readonly: ComputedRef<boolean> = computed(() => {
+  return useFormStore().readonly
+})
+
+// <--- TODO: revoir
+/**
+ * Action Sauvegarder qui redirige vers la page d'edition si on est en mode create
+ * @param route
+ * @param id
+ * @param router
+ */
+function save(route: Route, id: number, router: any){
+  if(useFormStore().formFunction === FORM_FUNCTION.CREATE){
+    route.path += id
+    router.push(route)
+  }
+}
+
+/**
+ * Action sauvegarder et route suivante qui redirige vers une route
+ * @param route
+ * @param router
+ */
+function saveAndGoTo(route: Route, router: any){
+  router.push(route)
+}
+
+/**
+ * Factory des fonction permettant d'assurer l'étape suivant à la soumission d'un formulaire
+ *
+ * @param args
+ * @param response
+ * @param router
+ */
+function nextStepFactory(args: any, response: AnyJson, router: any){
+  const factory: AnyJson = {}
+  factory[SUBMIT_TYPE.SAVE] = () => save(args, response.id, router)
+  factory[SUBMIT_TYPE.SAVE_AND_BACK] = () => saveAndGoTo(args, router)
+  return factory
+}
+
+const nextStep = (next: string | null, response: AnyJson) => {
+  if (next === null)
+    return
+  nextStepFactory(props.submitActions[next], response, router)[next]()
+}
+
+// --->
+
+/**
+ * Utilise la méthode validate() de v-form pour valider le formulaire
+ *
+ * @see https://vuetifyjs.com/en/api/v-form/#functions-validate
+ */
+const validate = function () {
+  return form.value.validate()
+}
+
+/**
+ * Soumet le formulaire
+ *
+ * @param next
+ */
+const submit = async (next: string|null = null) => {
+  if (!await validate()) {
+    usePageStore().addAlerts(TYPE_ALERT.ALERT, ['invalid_form'])
+    return
+  }
+
+  setIsDirty(false)
+
+  try {
+    const updatedEntity = await em.persist(props.model, props.entity.value)
+
+    usePageStore().addAlerts(TYPE_ALERT.SUCCESS, ['saveSuccess'])
+
+    nextStep(next, updatedEntity)
+
+  } catch (error: any) {
+
+    if (error.response.status === 422 && error.response.data['violations']) {
+        const violations: Array<string> = []
+        let fields: AnyJson = {}
+
+        for (const violation of error.response.data['violations']) {
+          violations.push(i18n.t(violation['message']) as string)
+          fields = Object.assign(fields, {[violation['propertyPath']] : violation['message']})
+        }
+
+        useFormStore().addViolations(fields)
+
+        usePageStore().addAlerts(TYPE_ALERT.ALERT, ['invalid_form'])
+    }
+  }
+}
+
+const showDialog: ComputedRef<boolean> = computed(() => {
+  return useFormStore().showConfirmToLeave
+})
+
+const closeDialog = () => {
+  useFormStore().setShowConfirmToLeave(false)
+}
+
+const saveAndQuit = async () => {
+  await submit()
+  quitForm()
+}
+
+const quitForm = () => {
+  setIsDirty(false)
+
+  useFormStore().setShowConfirmToLeave(false)
+
+  em.reset(props.model, props.entity.value)
+
+  if (router) {
+    // @ts-ignore
+    router.push(useFormStore().goAfterLeave)
+  }
+}
+
+const actions = computed(()=>{
+  return useKeys(props.submitActions)
+})
+</script>
+
+<style scoped>
+.btnActions {
+  text-align: right;
+}
+</style>

+ 98 - 0
components/Ui/Help.vue

@@ -0,0 +1,98 @@
+<template>
+  <v-tooltip
+    :value="show"
+    :top="top"
+    :bottom="bottom"
+    :right="right"
+    :left="leftOrDefault"
+    :open-on-hover="false"
+    :open-on-focus="false"
+    :open-on-click="false"
+    max-width="360px"
+  >
+    <template #activator="{}">
+      <v-icon
+        @click="onIconClicked"
+        icon
+        class="ml-3"
+        size="18px"
+        ref="iconRef"
+      >
+        {{ icon }}
+      </v-icon>
+    </template>
+
+    <div ref="slotDiv" class="tooltip" v-click-out="onClickOutside">
+      <slot></slot>
+    </div>
+  </v-tooltip>
+</template>
+
+<script setup lang="ts">
+
+import {useNuxtApp} from "#app";
+import {Ref} from "@vue/reactivity";
+
+const props = defineProps({
+  left: {
+    type: Boolean,
+    required: false,
+    default: false
+  },
+  right: {
+    type: Boolean,
+    required: false,
+    default: false
+  },
+  top: {
+    type: Boolean,
+    required: false,
+    default: false
+  },
+  bottom: {
+    type: Boolean,
+    required: false,
+    default: false
+  },
+  icon: {
+    type: String,
+    required: false,
+    default: 'mdi-help-circle'
+  }
+})
+
+const { $refs } = useNuxtApp()
+
+const show: Ref<Boolean> = ref(false)
+
+// Template reference to the icon object
+const iconRef = ref(null)
+
+// Left is the default, set it to true if not any other is true
+const leftOrDefault: Ref<Boolean> = ref(props.left || (!props.right && !props.bottom && !props.top))
+
+const onIconClicked = (e: any) => {
+  show.value = !show.value
+  e.stopPropagation()
+}
+
+const onClickOutside = (e: any) => {
+  if (show.value) {
+    if (e.target === (iconRef.value as any).$el) {
+      return
+    }
+    show.value = false
+  }
+}
+</script>
+
+<style>
+.v-icon {
+  cursor: pointer;
+  height: 18px;
+  width: 18px;
+}
+.v-tooltip__content {
+  pointer-events: all !important;
+}
+</style>

+ 156 - 0
components/Ui/Image.vue

@@ -0,0 +1,156 @@
+<template>
+  <main>
+    <div class="image-wrapper" :style="{width: width + 'px'}">
+      <v-img
+        :src="imgSrcReload ? imgSrcReload : imageLoaded"
+        :lazy-src="require(`/assets/images/byDefault/${imageByDefault}`)"
+        :height="height"
+        :width="width"
+        aspect-ratio="1"
+      >
+        <template #placeholder>
+          <v-row
+            class="fill-height ma-0"
+            align="center"
+            justify="center"
+          >
+            <v-progress-circular
+              :indeterminate="true"
+              color="grey lighten-1">
+            </v-progress-circular>
+          </v-row>
+        </template>
+      </v-img>
+
+      <div>
+        <div v-if="upload" class="click-action hover" @click="openUpload=true"><v-icon>mdi-upload</v-icon></div>
+        <UiInputImage
+          v-if="openUpload"
+          @close="openUpload=false"
+          :existingImageId="id"
+          :field="field"
+          :ownerId="ownerId"
+          @update="$emit('update', $event, field); openUpload=false"
+          @reload="fetch();openUpload=false"
+          @reset="reset"
+        ></UiInputImage>
+      </div>
+    </div>
+  </main>
+</template>
+
+
+<script lang="ts">
+import {defineComponent, onUnmounted, ref, Ref, useContext, watch} from '@nuxtjs/composition-api'
+import {useImageProvider} from "~/composables/data/useImageProvider";
+import {WatchStopHandle} from "@vue/composition-api";
+
+export default defineComponent({
+  props: {
+    id: {
+      type: Number,
+      required: false
+    },
+    field: {
+      type: String,
+      required: false
+    },
+    imageByDefault: {
+      type: String,
+      required: false,
+      default: 'default_pic.jpeg'
+    },
+    height: {
+      type: Number,
+      required: false
+    },
+    width: {
+      type: Number,
+      required: false
+    },
+    upload: {
+      type: Boolean,
+      required: false,
+      default: false
+    },
+    ownerId:{
+      type: Number,
+      required: false
+    }
+  },
+  fetchOnServer: false,
+  setup(props) {
+    const openUpload: Ref<Boolean> = ref(false)
+    const imgSrcReload: Ref<any> = ref(null)
+    const {$dataProvider, $config} = useContext()
+    const {getOne, provideImg} = useImageProvider($dataProvider, $config)
+
+    const { imageLoaded, fetch } = getOne(props.id, props.imageByDefault, props.height, props.width)
+    const unwatch: WatchStopHandle = watch(() => props.id, async (newValue, oldValue) => {
+      imgSrcReload.value = await provideImg(newValue as number, props.height, props.width)
+    })
+
+    /**
+     * Quand on souhaite faire un reset de l'image
+     */
+    const reset = () => {
+      imgSrcReload.value = null
+      imageLoaded.value = require(`assets/images/byDefault/${props.imageByDefault}`)
+      openUpload.value = false
+    }
+
+    /**
+     * Lorsqu'on démonte le component on supprime le watcher
+     */
+    onUnmounted(() => {
+      unwatch()
+    })
+
+    return {
+      imgSrcReload,
+      imageLoaded,
+      openUpload,
+      fetch,
+      reset
+    }
+  }
+})
+</script>
+
+<style lang="scss">
+  div.image-wrapper {
+    display: block;
+    position: relative;
+    img{
+      max-width: 100%;
+    }
+    .click-action{
+      position: absolute;
+      top:0;
+      left:0;
+      width: 100%;
+      height: 100%;
+      background: transparent;
+      opacity: 0;
+      transition: all .2s;
+      &:hover{
+        opacity: 1;
+        background:rgba(0,0,0,0.3);
+        cursor: pointer;
+      }
+      i{
+        color: #fff;
+        position: absolute;
+        top: 50%;
+        left: 50%;
+        transform: translate(-50% , -50%);
+        font-size: 50px;
+        z-index: 1;
+        opacity: 1;
+        &:hover{
+          color: rgba(#3fb37f, 0.7);
+        }
+      }
+    }
+  }
+</style>

+ 220 - 0
components/Ui/Input/Autocomplete.vue

@@ -0,0 +1,220 @@
+<!--
+Liste déroulante avec autocompletion
+
+@see https://vuetifyjs.com/en/components/autocompletes/#usage
+-->
+
+<template>
+  <main>
+    <v-autocomplete
+      autocomplete="search"
+      :value="data"
+      :items="itemsToDisplayed"
+      :label="$t(fieldLabel)"
+      item-text="itemTextDisplay"
+      :item-value="itemValue"
+      :no-data-text="$t('autocomplete_research')"
+      :no-filter="noFilter"
+      auto-select-first
+      :multiple="multiple"
+      :loading="isLoading"
+      :return-object="returnObject"
+      :search-input.sync="search"
+      :prepend-icon="prependIcon"
+      :error="error || !!violation"
+      :error-messages="errorMessage || violation ? $t(violation) : ''"
+      :rules="rules"
+      :chips="chips"
+      @input="onChange($event)"
+    >
+      <template v-if="slotText" #item="data">
+        <v-list-item-content v-text="data.item.slotTextDisplay"></v-list-item-content>
+      </template>
+    </v-autocomplete>
+  </main>
+</template>
+
+<script setup lang="ts">
+import {useNuxtApp} from "#app";
+import {computed, ComputedRef, Ref} from "@vue/reactivity";
+import {useFieldViolation} from "~/composables/form/useFieldViolation";
+import {AnyJson} from "~/types/data";
+import {onUnmounted, watch} from "@vue/runtime-core";
+import ObjectUtils from "~/services/utils/objectUtils";
+
+const props = defineProps({
+  label: {
+    type: String,
+    required: false,
+    default: null
+  },
+  field: {
+    type: String,
+    required: false,
+    default: null
+  },
+  data: {
+    type: [String, Number, Object, Array],
+    required: false,
+    default: null
+  },
+  items: {
+    type: Array,
+    required: false,
+    default: () => []
+  },
+  readonly: {
+    type: Boolean,
+    required: false
+  },
+  itemValue: {
+    type: String,
+    default: 'id'
+  },
+  itemText: {
+    type: Array,
+    required: true
+  },
+  group:{
+    type: String,
+    required: false,
+    default: null
+  },
+  slotText: {
+    type: Array,
+    required: false,
+    default: null
+  },
+  returnObject: {
+    type: Boolean,
+    default: false
+  },
+  multiple: {
+    type: Boolean,
+    default: false
+  },
+  isLoading: {
+    type: Boolean,
+    default: false
+  },
+  noFilter: {
+    type: Boolean,
+    default: false
+  },
+  prependIcon: {
+    type: String
+  },
+  translate: {
+    type: Boolean,
+    default: false
+  },
+  rules: {
+    type: Array,
+    required: false,
+    default: () => []
+  },
+  chips: {
+    type: Boolean,
+    default: false
+  },
+  error: {
+    type: Boolean,
+    required: false
+  },
+  errorMessage: {
+    type: String,
+    required: false,
+    default: null
+  }
+})
+
+const { emit } = useNuxtApp()
+
+const { i18n } = useNuxtApp()
+
+const search: Ref<string|null> = ref(null)
+
+const fieldLabel = props.label ?? props.field
+
+const { violation, onChange } = useFieldViolation(props.field, emit)
+
+// On reconstruit les items à afficher...
+const itemsToDisplayed: ComputedRef<Array<AnyJson>> = computed(() => {
+  const itemsByGroup:Array<Array<string>> = classItemsByGroup(props.items)
+  return prepareItemsToDisplayed(itemsByGroup)
+})
+
+const unwatch = watch(
+    search,
+    useDebounce(async (newResearch, oldResearch) => {
+      if(newResearch !== oldResearch && oldResearch !== null)
+        emit('research', newResearch)
+    }, 500)
+)
+
+onUnmounted(() => {
+  unwatch()
+})
+
+/**
+ * On construit l'Array à double entrée contenant les groups (headers) et les propositions
+ *
+ * @param items
+ */
+const classItemsByGroup = (items:Array<any>): Array<Array<string>> => {
+  const group = props.group as string
+  const itemsByGroup: Array<Array<string>> = []
+
+  for (const item of items) {
+    if (item) {
+      if (!itemsByGroup[item[group]]) {
+        itemsByGroup[item[group]] = []
+      }
+
+      itemsByGroup[item[group]].push(item)
+    }
+  }
+
+  return itemsByGroup
+}
+
+/**
+ * Construction de l'Array JSON contenant toutes les propositions à afficher dans le select
+ *
+ * @param itemsByGroup
+ */
+const prepareItemsToDisplayed = (itemsByGroup: Array<Array<string>>): Array<AnyJson> => {
+  let finalItems: Array<AnyJson> = []
+
+  for (const group in itemsByGroup) {
+
+    // Si un groupe est présent, alors on créé le groupe options header
+    if (group !== 'undefined') {
+      finalItems.push({header: i18n.t(group as string)})
+    }
+
+    // On parcourt les items pour préparer les texts / slotTexts à afficher
+    finalItems = finalItems.concat(itemsByGroup[group].map((item: any) => {
+      const slotTextDisplay: Array<string> = []
+      const itemTextDisplay: Array<string> = []
+
+      item = ObjectUtils.cloneAndFlatten(item)
+
+      // Si on souhaite avoir un texte différent dans les propositions que dans la sélection finale de select
+      if (props.slotText) {
+        for (const text of props.slotText) {
+          slotTextDisplay.push(props.translate ? i18n.t(item[text as string]) : item[text as string])
+        }
+      }
+
+      for (const text of props.itemText) {
+        itemTextDisplay.push(props.translate ? i18n.t(item[text as string]) : item[text as string])
+      }
+
+      // On reconstruit l'objet
+      return Object.assign({}, item, { itemTextDisplay: itemTextDisplay.join(' '), slotTextDisplay: slotTextDisplay.join(' ') })
+    }))
+  }
+  return finalItems
+}
+</script>

+ 145 - 0
components/Ui/Input/AutocompleteWithAPI.vue

@@ -0,0 +1,145 @@
+<!--
+Liste déroulante avec autocompletion (les données sont issues
+d'une api)
+
+@see https://vuetifyjs.com/en/components/autocompletes/#usage
+-->
+<template>
+  <main>
+    <UiInputAutocomplete
+      :field="field"
+      :label="label"
+      :data="remoteData ? remoteData : data"
+      :items="items"
+      :isLoading="isLoading"
+      :item-text="itemText"
+      :slotText="slotText"
+      :item-value="itemValue"
+      :multiple="multiple"
+      :chips="chips"
+      prependIcon="mdi-magnify"
+      :return-object="returnObject"
+      @research="search"
+      :no-filter="noFilter"
+      @update="$emit('update', $event, field)"
+    />
+  </main>
+</template>
+
+<script setup lang="ts">
+
+import {Ref, ref, toRefs} from "@vue/reactivity";
+import Url from "~/services/utils/url";
+import {FetchOptions} from "ohmyfetch";
+import {useFetch} from "#app";
+import {watch} from "@vue/runtime-core";
+
+const props = defineProps({
+  label: {
+    type: String,
+    required: false,
+    default: null
+  },
+  field: {
+    type: String,
+    required: false,
+    default: null
+  },
+  searchFunction: {
+    type: Function,
+    required: true
+  },
+  data: {
+    type: [String, Number, Object, Array],
+    required: false,
+    default: null
+  },
+  remoteUri: {
+    type: [Array],
+    required: false,
+    default: null
+  },
+  remoteUrl: {
+    type: String,
+    required: false,
+    default: null
+  },
+  readonly: {
+    type: Boolean,
+    required: false
+  },
+  itemValue: {
+    type: String,
+    default: 'id'
+  },
+  itemText: {
+    type: Array,
+    required: true
+  },
+  slotText: {
+    type: Array,
+    required: false
+  },
+  returnObject: {
+    type: Boolean,
+    default: false
+  },
+  noFilter: {
+    type: Boolean,
+    default: false
+  },
+  multiple: {
+    type: Boolean,
+    default: false
+  },
+  chips: {
+    type: Boolean,
+    default: false
+  }
+})
+
+const { data } = toRefs(props)
+const items = ref([])
+const remoteData: Ref<Array<string> | null> = ref(null)
+const isLoading = ref(false)
+
+
+if (props.data) {
+  items.value = props.multiple ? (data.value ?? []) : [data.value]
+
+} else if (props.remoteUri) {
+
+  const ids:Array<any> = []
+
+  for(const uri of props.remoteUri){
+    ids.push(Url.extractIdFromUri(uri as string))
+  }
+
+  const options: FetchOptions = { method: 'GET', query: {key: 'id', value: ids.join(',')} }
+
+  useFetch(async () => {
+    isLoading.value = true
+
+    const r: any = await $fetch(props.remoteUrl, options)
+
+    isLoading.value = false
+    remoteData.value = r.data
+    items.value = r.data
+  })
+}
+
+const search = async (research:string) => {
+  isLoading.value = true
+  const func: Function = props.searchFunction
+  items.value = items.value.concat(await func(research, props.field))
+  isLoading.value = false
+}
+
+const unwatch = watch(data,(d) => {
+  items.value = props.multiple ? d : [d]
+})
+
+onUnmounted(() => {
+  unwatch()
+})
+</script>

+ 68 - 0
components/Ui/Input/Checkbox.vue

@@ -0,0 +1,68 @@
+<!--
+Case à cocher
+
+@see https://vuetifyjs.com/en/components/checkboxes/
+-->
+
+<template>
+  <v-container
+    class="px-0"
+    fluid
+  >
+    <v-checkbox
+      v-model="data"
+      :value="data"
+      :label="$t(fieldLabel)"
+      :disabled="readonly"
+      :error="error || !!violation"
+      :error-messages="errorMessage || violation ? $t(violation) : ''"
+      @change="onChange($event)"
+    />
+  </v-container>
+</template>
+
+<script setup lang="ts">
+
+import {useNuxtApp} from "#app";
+import {useFieldViolation} from "~/composables/form/useFieldViolation";
+
+const props = defineProps({
+  field: {
+    type: String,
+    required: false,
+    default: null
+  },
+  label: {
+    type: String,
+    required: false,
+    default: null
+  },
+  data: {
+    type: Boolean,
+    required: false
+  },
+  readonly: {
+    type: Boolean,
+    required: false
+  },
+  error: {
+    type: Boolean,
+    required: false
+  },
+  errorMessage: {
+    type: String,
+    required: false,
+    default: null
+  }
+})
+
+const { emit } = useNuxtApp()
+
+const { violation, onChange } = useFieldViolation(props.field, emit)
+
+const fieldLabel = props.label ?? props.field
+
+</script>
+
+<style scoped>
+</style>

+ 141 - 0
components/Ui/Input/DatePicker.vue

@@ -0,0 +1,141 @@
+<!--
+Sélecteur de dates
+
+@see https://vuetifyjs.com/en/components/date-pickers/
+-->
+
+<template>
+  <main>
+    <v-menu
+      v-model="dateOpen"
+      :close-on-content-click="false"
+      :nudge-right="40"
+      transition="scale-transition"
+      offset-y
+      min-width="auto"
+    >
+      <template #activator="{ on, attrs }">
+        <v-text-field
+          v-model="datesFormatted"
+          autocomplete="off"
+          :label="$t(fieldLabel)"
+          prepend-icon="mdi-calendar"
+          :disabled="readonly"
+          v-bind="attrs"
+          :dense="dense"
+          :single-line="singleLine"
+          v-on="on"
+          :error="error || !!violation"
+          :error-messages="errorMessage || violation ? $t(violation) : ''"
+        />
+      </template>
+      <v-date-picker
+        v-model="datesParsed"
+        :range="range"
+        color="ot_green lighten-1"
+        @input="dateOpen = range && datesParsed.length < 2"
+      />
+    </v-menu>
+  </main>
+</template>
+
+<script setup lang="ts">
+
+import {useNuxtApp} from "#app";
+import {useFieldViolation} from "~/composables/form/useFieldViolation";
+import {computed, ComputedRef, Ref} from "@vue/reactivity";
+import {useDateUtils} from "~/composables/utils/useDateUtils";
+import {onUnmounted, watch, WatchStopHandle} from "@vue/runtime-core";
+
+const props = defineProps({
+  field: {
+    type: String,
+    required: false,
+    default: null
+  },
+  label: {
+    type: String,
+    required: false,
+    default: null
+  },
+  data: {
+    type: [String, Array],
+    required: false,
+    default: null
+  },
+  readonly: {
+    type: Boolean,
+    required: false
+  },
+  range: {
+    type: Boolean,
+    required: false
+  },
+  dense: {
+    type: Boolean,
+    required: false
+  },
+  singleLine: {
+    type: Boolean,
+    required: false
+  },
+  format: {
+    type: String,
+    required: false,
+    default: 'DD/MM/YYYY'
+  },
+  error: {
+    type: Boolean,
+    required: false
+  },
+  errorMessage: {
+    type: String,
+    required: false,
+    default: null
+  }
+})
+
+const { emit, $moment } = useNuxtApp()
+
+const { data, range } = props
+
+const {violation, onChange} = useFieldViolation(props.field, emit)
+
+const dateUtils = useDateUtils()
+
+const datesParsed: Ref<Array<string>|string|null> = range ? ref(Array<string>()) : ref(null)
+
+const fieldLabel = props.label ?? props.field
+
+const dateOpen: Ref<boolean> = ref(false)
+
+if (Array.isArray(datesParsed.value)) {
+  for (const date of data as Array<string>) {
+    if (date) {
+      datesParsed.value.push($moment(date).format('YYYY-MM-DD'))
+    }
+  }
+} else {
+  datesParsed.value = data ? $moment(data as string).format('YYYY-MM-DD') : null
+}
+
+const datesFormatted: ComputedRef<string|null> = computed(() => {
+  if (props.range && datesParsed.value && datesParsed.value.length < 2) {
+    return null
+  }
+  return datesParsed.value ? dateUtils.formatDatesAndConcat(datesParsed.value, props.format) :  null
+})
+
+const unwatch: WatchStopHandle = watch(datesParsed, (newValue, oldValue) => {
+  if (newValue === oldValue) { return }
+  if (props.range && newValue && newValue.length < 2) { return }
+  onChange(Array.isArray(newValue) ? dateUtils.sortDate(newValue) : newValue)
+})
+
+onUnmounted(() => {
+  unwatch()
+})
+</script>
+
+<style scoped>
+</style>

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

@@ -0,0 +1,78 @@
+<!--
+Champs de saisie de type Text dédié à la saisie d'emails
+-->
+
+<template>
+  <UiInputText
+    :data="data"
+    :label="$t(fieldLabel)"
+    :readonly="readonly"
+    :error="error || !!violation"
+    :error-messages="errorMessage || violation ? $t(violation) : ''"
+    :rules="rules"
+    @update="onChange"
+  />
+</template>
+
+<script setup lang="ts">
+
+import {useNuxtApp} from "#app";
+import {useFieldViolation} from "~/composables/form/useFieldViolation";
+import {useValidationUtils} from "~/composables/utils/useValidationUtils";
+
+const props = defineProps({
+  label: {
+    type: String,
+    required: false,
+    default: null
+  },
+  field: {
+    type: String,
+    required: false,
+    default: null
+  },
+  data: {
+    type: [String, Number],
+    required: false,
+    default: null
+  },
+  readonly: {
+    type: Boolean,
+    required: false,
+    default: false
+  },
+  required: {
+    type: Boolean,
+    required: false,
+    default: false
+  },
+  error: {
+    type: Boolean,
+    required: false
+  },
+  errorMessage: {
+    type: String,
+    required: false,
+    default: null
+  }
+})
+
+const { emit, i18n } = useNuxtApp()
+
+const fieldLabel = props.label ?? props.field
+
+const {violation, onChange} = useFieldViolation(props.field, emit)
+
+const validationUtils = useValidationUtils()
+
+const rules = [
+  (email: string) => validationUtils.validEmail(email) || i18n.t('email_error')
+]
+
+if (props.required) {
+  rules.push(
+    (email: string) => !!email || i18n.t('required')
+  )
+}
+
+</script>

+ 96 - 0
components/Ui/Input/Enum.vue

@@ -0,0 +1,96 @@
+<!--
+Liste déroulante dédiée à l'affichage d'objets Enum
+
+@see https://vuetifyjs.com/en/components/selects/
+-->
+
+<template>
+  <main>
+    <v-skeleton-loader
+      v-if="$fetchState.pending"
+      type="list-item"
+      loading
+    />
+
+    <v-select
+      v-else
+      :label="$t(fieldLabel)"
+      :value="data"
+      :items="items"
+      item-text="label"
+      item-value="value"
+      :rules="rules"
+      :disabled="readonly"
+      :error="error || !!violation"
+      :error-messages="errorMessage || violation ? $t(violation) : ''"
+      @change="onChange($event)"
+    />
+  </main>
+</template>
+
+<script setup lang="ts">
+import {useNuxtApp} from "#app";
+import {useFieldViolation} from "~/composables/form/useFieldViolation";
+import {ref, Ref} from "@vue/reactivity";
+import {useEnumFetch} from "~/composables/data/useEnumFetch";
+import {Enum} from "~/types/data";
+
+const props = defineProps({
+  enumType: {
+    type: String,
+    required: true
+  },
+  label: {
+    type: String,
+    required: false,
+    default: null
+  },
+  field: {
+    type: String,
+    required: false,
+    default: null
+  },
+  data: {
+    type: String,
+    required: false,
+    default: null
+  },
+  readonly: {
+    type: Boolean,
+    required: false
+  },
+  rules: {
+    type: Array,
+    required: false,
+    default: () => []
+  },
+  error: {
+    type: Boolean,
+    required: false
+  },
+  errorMessage: {
+    type: String,
+    required: false,
+    default: null
+  }
+})
+
+const { emit } = useNuxtApp()
+
+const fieldLabel = props.label ?? props.field
+
+const { enumType } = props
+
+const { violation, onChange } = useFieldViolation(props.field, emit)
+
+const items: Ref<Enum> = ref([])
+
+const { fetch } = useEnumFetch()
+const { data: fetched, pending } = fetch(enumType)
+
+items.value = fetched.value || []
+
+</script>
+
+<style scoped>
+</style>

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

@@ -0,0 +1,293 @@
+<!--
+Assistant de création d'image
+https://norserium.github.io/vue-advanced-cropper/
+-->
+<template>
+    <LazyLayoutDialog :show="true">
+      <template #dialogType>{{ $t('image_assistant') }}</template>
+      <template #dialogTitle>{{ $t('modif_picture') }}</template>
+      <template #dialogText>
+        <div class="upload">
+          <v-row
+            v-if="fetchState.pending"
+            class="fill-height ma-0 loading"
+            align="center"
+            justify="center"
+          >
+            <v-progress-circular
+              :indeterminate="true"
+              color="grey lighten-1">
+            </v-progress-circular>
+          </v-row>
+
+          <div v-else >
+            <div class="upload__cropper-wrapper">
+              <cropper
+                ref="cropper"
+                class="upload__cropper"
+                check-orientation
+                :src="image.src"
+                :default-position="{left : coordinates.left, top : coordinates.top}"
+                :default-size="coordinates.width ? {width : coordinates.width, height : coordinates.height}: defaultSize"
+                @change="onChange"
+              />
+              <div v-if="image.src" class="upload__reset-button" title="Reset Image" @click="reset()">
+                <v-icon>mdi-delete</v-icon>
+              </div>
+            </div>
+            <div class="upload__buttons-wrapper">
+              <button class="upload__button" @click="$refs.file.click()">
+                <input ref="file" type="file" accept="image/*" @change="uploadImage($event)" />
+                {{$t('upload_image')}}
+              </button>
+            </div>
+          </div>
+
+        </div>
+      </template>
+      <template #dialogBtn>
+        <v-btn class="mr-4 submitBtn ot_grey ot_white--text" @click="$emit('close')">
+          {{ $t('cancel') }}
+        </v-btn>
+        <v-btn class="mr-4 submitBtn ot_danger ot_white--text" @click="save">
+          {{ $t('save') }}
+        </v-btn>
+      </template>
+    </LazyLayoutDialog>
+
+</template>
+
+<script setup lang="ts">
+
+import {useNuxtApp} from "#app";
+import {ref, Ref} from "@vue/reactivity";
+import {AnyJson} from "~/types/data";
+import {File} from '~/models/Core/File'
+import {useAp2iRequestService} from "~/composables/data/useAp2iRequestService";
+import Url from "~/services/utils/url";
+import {useImageFetch} from "~/composables/data/useImageFetch";
+import {onUnmounted, watch, WatchStopHandle} from "@vue/runtime-core";
+import {useEntityManager} from "~/composables/data/useEntityManager";
+
+const props = defineProps({
+  imageId: {
+    type: Number,
+    required: false
+  },
+  ownerId: {
+    type: Number,
+    required: false
+  },
+  field: {
+    type: String,
+    required: true
+  }
+})
+
+const { emit } = useNuxtApp()
+
+const { em } = useEntityManager()
+
+const file = new File()
+
+const cropper: Ref<any> = ref(null)
+
+const image: Ref<AnyJson> = ref({
+  id: null,
+  src: null,
+  file: null,
+  name: null
+})
+
+const coordinates: Ref<AnyJson> = ref({})
+
+const defaultSize = ({ imageSize, visibleArea }: any) => {
+  return {
+    width: (visibleArea || imageSize).width,
+    height: (visibleArea || imageSize).height,
+  };
+}
+
+// Si l'id est renseigné, on récupère l'Item File afin d'avoir les informations de config, le nom, etc.
+if (props.imageId && props.imageId > 0) {
+  const { apiRequestService } = useAp2iRequestService()
+  const result: any = await apiRequestService.get(Url.join('api/files', '' + props.imageId))
+
+  const config = JSON.parse(result.data.config)
+  coordinates.value.left = config.x
+  coordinates.value.top = config.y
+  coordinates.value.height = config.height
+  coordinates.value.width = config.width
+  image.value.name = result.data.name
+  image.value.id = result.data.id
+}
+
+//On récupère l'image...
+const { fetch } = useImageFetch()
+const { data: imageLoaded, pending } = fetch(props.imageId ?? null)
+
+const unwatch: WatchStopHandle = watch(
+  imageLoaded,
+(newValue, oldValue) => {
+    if (newValue === oldValue || typeof newValue === 'undefined') {
+      return
+    }
+    image.value.src = newValue
+  }
+)
+
+/**
+ * Quand l'utilisateur choisit une image sur sa machine
+ * @param event
+ */
+const uploadImage = (event:any) => {
+  const { files } = event.target
+  if (files && files[0]) {
+    reset()
+    image.value.name = files[0].name
+    image.value.src = URL.createObjectURL(files[0])
+    image.value.file = files[0]
+  }
+}
+
+/**
+ * Lorsque le cropper change de position / taille, on met à jour les coordonnées
+ * @param config
+ */
+const onChange = ({ coordinates: config } : any) => {
+  coordinates.value = config;
+}
+
+/**
+ * Lorsque l'on sauvegarde l'image
+ */
+// TODO: Voir si tout ou partie de cette fonction peut passer dans le useImageFetch
+const save = async () => {
+  file.config = JSON.stringify({
+    x: coordinates.value.left,
+    y: coordinates.value.top,
+    height: coordinates.value.height,
+    width: coordinates.value.width
+  })
+
+  // Mise à jour d'une image existante : on bouge simplement le cropper
+  if (image.value.id > 0) {
+    file.id = image.value.id as number
+    em.save(File, file) // TODO: le save devrait être inutile maintenant, à vérifier
+
+    await em.persist(File, file) // TODO: à revoir
+
+    // On émet un évent afin de mettre à jour le formulaire de départ
+    emit('reload')
+  }
+
+  // Création d'une nouvelle image
+  else {
+    if (image.value.file) {
+
+      // On créé l'objet File à sauvegarder
+      file.name = image.value.name
+      file.imgFieldName = props.field
+      file.visibility = 'EVERYBODY'
+      file.folder = 'IMAGES'
+      file.status = 'READY'
+
+      if (props.ownerId) {
+        file.ownerId = props.ownerId
+      }
+
+      const returnedFile = await em.persist(File, file) // TODO: à revoir, il faudra pouvoir passer `image.value.file` avec la requête
+
+      //On émet un évent afin de mettre à jour le formulaire de départ
+      emit('update', returnedFile.data['@id'])
+
+    } else {
+      // On reset l'image : on a appuyé sur "poubelle" puis on enregistre
+      emit('reset')
+    }
+  }
+}
+
+/**
+ * On choisit de supprimer l'image présente
+ */
+const reset = () => {
+  image.value.src = null
+  image.value.file = null
+  image.value.name = null
+  image.value.id = null
+  URL.revokeObjectURL(image.value.src)
+}
+
+/**
+ * Lorsqu'on démonte le component on supprime le watcher et on revoke l'objet URL
+ */
+onUnmounted(() => {
+  unwatch()
+    if (image.value.src) {
+      URL.revokeObjectURL(image.value.src)
+    }
+})
+</script>
+
+<style lang="scss">
+  .vue-advanced-cropper__stretcher{
+    height: auto !important;
+    width: auto !important;
+  }
+  .loading{
+    height: 300px;
+  }
+  .upload {
+    user-select: none;
+    padding: 20px;
+    display: block;
+    &__cropper {
+       border: solid 1px #eee;
+       min-height: 500px;
+       max-height: 500px;
+     }
+    &__cropper-wrapper {
+       position: relative;
+     }
+    &__reset-button {
+      position: absolute;
+      right: 20px;
+      bottom: 20px;
+      cursor: pointer;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      height: 42px;
+      width: 42px;
+      background: rgba(#3fb37f, 0.7);
+      transition: background 0.5s;
+      &:hover {
+        background: #3fb37f;
+      }
+    }
+    &__buttons-wrapper {
+       display: flex;
+       justify-content: center;
+       margin-top: 17px;
+     }
+    &__button {
+       border: none;
+       outline: solid transparent;
+       color: white;
+       font-size: 16px;
+       padding: 10px 20px;
+       background: #3fb37f;
+       cursor: pointer;
+       transition: background 0.5s;
+       margin: 0 16px;
+      &:hover,
+      &:focus {
+         background: #38d890;
+       }
+      input {
+        display: none;
+      }
+    }
+  }
+</style>

+ 107 - 0
components/Ui/Input/Phone.vue

@@ -0,0 +1,107 @@
+<!--
+Champs de saisie d'un numéro de téléphone
+
+@see https://github.com/yogakurniawan/vue-tel-input-vuetify
+
+// TODO: vérifier compatibilité avec Vue 3
+-->
+
+<template>
+  <client-only>
+    <vue-tel-input-vuetify
+      :error="error || !!violation"
+      :error-messages="errorMessage || violation ? $t(violation) : ''"
+      :field="field"
+      :label="label"
+      v-model="myPhone"
+      :readonly="readonly"
+      clearable
+      valid-characters-only
+      validate-on-blur
+      :rules="rules"
+      @input="onInput"
+      @change="onChangeValue"
+    />
+  </client-only>
+</template>
+
+<script setup lang="ts">
+
+import {useNuxtApp} from "#app";
+import {useFieldViolation} from "~/composables/form/useFieldViolation";
+import {Ref} from "@vue/reactivity";
+
+const props = defineProps({
+  label: {
+    type: String,
+    required: false,
+    default: ''
+  },
+  field: {
+    type: String,
+    required: false,
+    default: null
+  },
+  data: {
+    type: [String, Number],
+    required: false,
+    default: null
+  },
+  readonly: {
+    type: Boolean,
+    required: false
+  },
+  error: {
+    type: Boolean,
+    required: false
+  },
+  errorMessage: {
+    type: String,
+    required: false,
+    default: null
+  }
+})
+
+const { emit, i18n } = useNuxtApp()
+
+const { violation, onChange } = useFieldViolation(props.field, emit)
+
+const nationalNumber: Ref<string | number> = ref('')
+const internationalNumber: Ref<string | number> = ref('')
+const isValid: Ref<boolean> = ref(false)
+const onInit: Ref<boolean> = ref(true)
+
+const onInput = (_: any, { number, valid, countryChoice }: { number: any, valid: boolean, countryChoice: any }) => {
+  isValid.value = valid
+  nationalNumber.value = number.national
+  internationalNumber.value = number.international
+  onInit.value = false
+}
+
+const onChangeValue = () => {
+  if (isValid.value) {
+    onChange(internationalNumber.value)
+  }
+}
+
+const myPhone = computed(
+  {
+    get:()=>{
+      return onInit.value ? props.data : nationalNumber.value
+    },
+    set:(value)=>{
+      return props.data
+    }
+  }
+)
+
+const rules = [
+  (phone: string) => (!phone || isValid.value) || i18n.t('phone_error')
+]
+</script>
+
+<style>
+input:read-only{
+  color: #666 !important;
+}
+</style>

+ 96 - 0
components/Ui/Input/Text.vue

@@ -0,0 +1,96 @@
+<!--
+Champs de saisie de texte
+
+@see https://vuetifyjs.com/en/components/text-fields/
+-->
+
+<template>
+  <v-text-field
+    autocomplete="off"
+    :value="data"
+    :label="$t(label_field)"
+    :rules="rules"
+    :disabled="readonly"
+    :type="type === 'password' ? (show ? 'text' : type) : type"
+    :error="error || !!violation"
+    :error-messages="errorMessage || (violation ? $t(violation) : '')"
+    @change="onChange($event); $emit('change', $event)"
+    @input="$emit('input', $event, field)"
+    v-mask="mask"
+    :append-icon="type === 'password' ? (show ? 'mdi-eye' : 'mdi-eye-off') : ''"
+    @click:append="show = !show"
+  />
+</template>
+
+<script setup lang="ts">
+import {mask} from 'vue-the-mask';
+import {ref} from "@vue/reactivity";
+import {useFieldViolation} from "~/composables/form/useFieldViolation";
+
+const props = defineProps({
+  label: {
+    type: String,
+    required: false,
+    default: null
+  },
+  field: {
+    type: String,
+    required: false,
+    default: null
+  },
+  type: {
+    type: String,
+    required: false,
+    default: null
+  },
+  data: {
+    type: [String, Number],
+    required: false,
+    default: null
+  },
+  readonly: {
+    type: Boolean,
+    required: false
+  },
+  rules: {
+    type: Array,
+    required: false,
+    default: () => []
+  },
+  error: {
+    type: Boolean,
+    required: false
+  },
+  errorMessage: {
+    type: String,
+    required: false,
+    default: null
+  },
+  mask: {
+    type: [Array, Boolean],
+    required: false,
+    default: false
+  }
+})
+
+const { app, emit } = useNuxtApp()
+
+const { violation, onChange } = useFieldViolation(props.field, emit)
+
+const show = ref(false)
+
+const label_field = props.label ?? props.field
+
+app.directive('mask', {
+    mask: (el: any, binding: any, vnode: any, oldVnode: any) => {
+      if (!binding.value) return;
+      mask(el, binding, vnode, oldVnode);
+    }
+})
+</script>
+
+<style scoped>
+  input:read-only{
+    color: #666 !important;
+  }
+</style>

+ 73 - 0
components/Ui/Input/TextArea.vue

@@ -0,0 +1,73 @@
+<!--
+Champs de saisie de bloc texte
+
+@see https://vuetifyjs.com/en/components/textareas/
+-->
+
+<template>
+  <v-textarea
+      outlined
+      :value="data"
+      :label="$t(fieldLabel)"
+      :rules="rules"
+      :disabled="readonly"
+      :error="error || !!violation"
+      :error-messages="errorMessage || violation ? $t(violation) : ''"
+      @change="onChange($event)"
+    />
+</template>
+
+<script setup lang="ts">
+
+import {useNuxtApp} from "#app";
+import {useFieldViolation} from "~/composables/form/useFieldViolation";
+
+const props = defineProps({
+  label: {
+    type: String,
+    required: false,
+    default: null
+  },
+  field: {
+    type: String,
+    required: false,
+    default: null
+  },
+  data: {
+    type: [String, Number],
+    required: false,
+    default: null
+  },
+  readonly: {
+    type: Boolean,
+    required: false
+  },
+  rules: {
+    type: Array,
+    required: false,
+    default: () => []
+  },
+  error: {
+    type: Boolean,
+    required: false
+  },
+  errorMessage: {
+    type: String,
+    required: false,
+    default: null
+  }
+})
+
+const { emit } = useNuxtApp()
+
+const fieldLabel = props.label ?? props.field
+
+const {violation, onChange} = useFieldViolation(props.field, emit)
+
+</script>
+
+<style>
+  input:read-only{
+    color: #666 !important;
+  }
+</style>

+ 49 - 0
components/Ui/Template/DataTable.vue

@@ -0,0 +1,49 @@
+<!--
+Tableau interactif
+
+@see https://vuetifyjs.com/en/components/data-tables/
+// TODO: expliquer la différence avec UiDatatable
+-->
+
+<template>
+  <v-col
+    cols="12"
+    sm="12"
+  >
+    <v-data-table
+      :headers="headersWithItem"
+      :items="items"
+      class="elevation-1"
+    >
+      <template v-for="header in headersWithItem" #[header.item]="props">
+        <slot :name="header.item" v-bind="props">
+          {{ props.item[header.value] }}
+        </slot>
+      </template>
+
+    </v-data-table>
+  </v-col>
+</template>
+
+<script setup lang="ts">
+
+const props = defineProps({
+  items: {
+    type: Array,
+    required: true
+  },
+  headers: {
+    type: Array,
+    required: true
+  }
+})
+
+const { headers } = toRefs(props)
+
+const headersWithItem = computed(() => {
+  return headers.value.map((header:any) => {
+    header.item = 'item.' + header.value
+    return header
+  })
+})
+</script>

+ 26 - 0
components/Ui/Template/Date.vue

@@ -0,0 +1,26 @@
+<!--
+Date formatée
+-->
+
+<template>
+  <span>{{ datesFormatted }}</span>
+</template>
+
+<script setup lang="ts">
+import {useDateUtils} from "~/composables/utils/useDateUtils";
+import {computed, ComputedRef} from "@vue/reactivity";
+
+const props = defineProps({
+  data: {
+    type: [String, Array],
+    required: false,
+    default: null
+  }
+})
+
+const dateUtils = useDateUtils()
+
+const datesFormatted: ComputedRef<string> = computed(() => {
+  return dateUtils.format(props.data, 'DD/MM/YYYY')
+})
+</script>

+ 27 - 4
composables/data/useAp2iRequestService.ts

@@ -1,10 +1,11 @@
 import {useProfileAccessStore} from "~/store/profile/access";
 import {FetchContext, FetchOptions} from "ohmyfetch";
-import PageStore from "~/services/store/pageStoreHelper";
 import {TYPE_ALERT} from "~/types/enums";
 import {useRuntimeConfig} from "#app";
 import ApiRequestService from "~/services/data/apiRequestService";
 import {AssociativeArray} from "~/types/data";
+import {Ref} from "@vue/reactivity";
+import {usePageStore} from "~/store/page";
 
 /**
  * Retourne une instance de ApiRequestService configurée pour interroger l'api Ap2i
@@ -16,6 +17,8 @@ export const useAp2iRequestService = () => {
 
     const baseURL = runtimeConfig.baseUrl ?? runtimeConfig.public.baseUrl
 
+    const pending: Ref<boolean> = ref(false)
+
     /**
      * Peuple les headers avant l'envoi de la requête
      *
@@ -41,9 +44,25 @@ export const useAp2iRequestService = () => {
 
         options.headers = { ...options.headers, ...headers }
 
+        pending.value = true
         console.log('Request : ' + request + ' (SSR: ' + process.server + ')')
     }
 
+    const onRequestError = async function({ request, options, response }: FetchContext) {
+        pending.value = false
+    }
+
+    /**
+     * Server responded
+     *
+     * @param request
+     * @param options
+     * @param response
+     */
+    const onResponse = async function({ request, options, response }: FetchContext) {
+        pending.value = false
+    }
+
     /**
      * Gère les erreurs retournées par l'api
      *
@@ -52,17 +71,19 @@ export const useAp2iRequestService = () => {
      * @param error
      */
     const onResponseError = async function ({ request, response, error }: FetchContext) {
+        pending.value = false
+
         if (response && response.status === 401) {
             // navigateTo('/login')
             console.error('Unauthorized')
         }
         else if (response && response.status === 403) {
-            new PageStore().addAlerts(TYPE_ALERT.ALERT, ['forbidden'])
+            usePageStore().addAlerts(TYPE_ALERT.ALERT, ['forbidden'])
             console.error('Forbidden')
         }
         else if (response && response.status >= 404) {
             // @see https://developer.mozilla.org/fr/docs/Web/HTTP/Status
-            new PageStore().addAlerts(TYPE_ALERT.ALERT, [error ? error.message : response.statusText])
+            usePageStore().addAlerts(TYPE_ALERT.ALERT, [error ? error.message : response.statusText])
             console.error(error ? error.message : response.statusText)
         }
     }
@@ -70,11 +91,13 @@ export const useAp2iRequestService = () => {
     const config : FetchOptions = {
         baseURL,
         onRequest,
+        onRequestError,
+        onResponse,
         onResponseError
     }
 
     // Utilise la fonction `create` de ohmyfetch pour générer un fetcher dédié à l'interrogation de Ap2i
     const fetcher = $fetch.create(config)
 
-    return new ApiRequestService(fetcher)
+    return { apiRequestService: new ApiRequestService(fetcher), pending: pending }
 }

+ 20 - 8
composables/data/useEntityFetch.ts

@@ -1,16 +1,28 @@
 import {useAsyncData, AsyncData} from "#app";
-import {File} from "~/models/Core/File";
 import {useEntityManager} from "~/composables/data/useEntityManager";
 import ApiResource from "~/models/ApiResource";
-import {Ref} from "@vue/reactivity";
+import {AssociativeArray, Collection} from "~/types/data";
 
-export const useEntityFetch = (model: typeof ApiResource, id: Ref<number>, lazy: boolean = false): AsyncData<ApiResource, ApiResource | true> => {
-    const em = useEntityManager()
+interface useEntityFetchReturnType {
+    fetch: (model: typeof ApiResource, id: number) => AsyncData<ApiResource, ApiResource | true>,
+    fetchCollection: (model: typeof ApiResource, parent?: ApiResource | null, query?: AssociativeArray) => AsyncData<Collection, any>
+}
 
-    //@ts-ignore
-    return useAsyncData(
-        File.entity + '_' + id.value, // TODO: je me demande si on a besoin de cette clé? (https://v3.nuxtjs.org/api/composables/use-async-data#params)
-        () => em.fetch(File, id.value),
+export const useEntityFetch = (lazy: boolean = false): useEntityFetchReturnType => {
+    const { em } = useEntityManager()
+
+    const fetch = (model: typeof ApiResource, id: number) => useAsyncData(
+        model.entity + '_' + id, // TODO: je me demande si on a besoin de cette clé? (https://v3.nuxtjs.org/api/composables/use-async-data#params)
+        () => em.fetch(model, id),
+        { lazy }
+    )
+
+    const fetchCollection = (model: typeof ApiResource, parent: ApiResource | null = null, query: AssociativeArray = []) => useAsyncData(
+        model.entity + '_many',
+        () => em.fetchCollection(model, parent, query),
         { lazy }
     )
+
+    //@ts-ignore
+    return { fetch, fetchCollection }
 }

+ 2 - 2
composables/data/useEntityManager.ts

@@ -2,6 +2,6 @@ import EntityManager from "~/services/data/entityManager";
 import {useAp2iRequestService} from "~/composables/data/useAp2iRequestService";
 
 export const useEntityManager = () => {
-    const apiRequestService = useAp2iRequestService()
-    return new EntityManager(apiRequestService)
+    const { apiRequestService, pending } = useAp2iRequestService()
+    return { em: new EntityManager(apiRequestService), pending: pending }
 }

+ 19 - 0
composables/data/useEnumFetch.ts

@@ -0,0 +1,19 @@
+import {useAsyncData, AsyncData} from "#app";
+import {Enum} from "~/types/data";
+import {useEnumManager} from "~/composables/data/useEnumManager";
+
+interface useEnumFetchReturnType {
+    fetch: (enumName: string) => AsyncData<Enum, null | true | Error>,
+}
+
+export const useEnumFetch = (lazy: boolean = false): useEnumFetchReturnType => {
+    const { enumManager } = useEnumManager()
+
+    const fetch = (enumName: string) => useAsyncData(
+        enumName,
+        () => enumManager.fetch(enumName),
+        { lazy }
+    )
+
+    return { fetch }
+}

+ 8 - 0
composables/data/useEnumManager.ts

@@ -0,0 +1,8 @@
+import EntityManager from "~/services/data/entityManager";
+import {useAp2iRequestService} from "~/composables/data/useAp2iRequestService";
+import EnumManager from "~/services/data/enumManager";
+
+export const useEnumManager = () => {
+    const { apiRequestService, pending } = useAp2iRequestService()
+    return { enumManager: new EnumManager(apiRequestService), pending: pending }
+}

+ 38 - 0
composables/data/useImageFetch.ts

@@ -0,0 +1,38 @@
+import {useAsyncData, AsyncData, useFetch, FetchResult} from "#app";
+import {useEntityManager} from "~/composables/data/useEntityManager";
+import ApiResource from "~/models/ApiResource";
+import {AssociativeArray} from "~/types/data";
+import {useImageManager} from "~/composables/data/useImageManager";
+import {ref, Ref} from "@vue/reactivity";
+
+interface useImageFetchReturnType {
+    fetch: (id: number | null, defaultImage?: string, height?: number, width?: number) => FetchResult<any>
+}
+
+export const useImageFetch = (lazy: boolean = false): useImageFetchReturnType => {
+    const { imageManager } = useImageManager()
+
+    const fetch = (
+        id: number | null,  // If id is null, fetch shall return the default value
+        defaultImage: string = '',
+        height: number = 0,
+        width: number = 0
+    ): FetchResult<any> => useFetch(
+        // @ts-ignore
+        async () => {
+            const image: Ref<String> = ref(defaultImage ? require(`assets/images/byDefault/${defaultImage}`) : '')
+
+            if (id !== null) {
+                try {
+                    image.value = await imageManager.get(id, height, width)
+                } catch (e) {
+                    console.error(e)
+                }
+            }
+
+            return image
+        }
+    )
+
+    return { fetch }
+}

+ 7 - 0
composables/data/useImageManager.ts

@@ -0,0 +1,7 @@
+import {useAp2iRequestService} from "~/composables/data/useAp2iRequestService";
+import ImageManager from "~/services/data/imageManager";
+
+export const useImageManager = () => {
+    const { apiRequestService } = useAp2iRequestService()
+    return { imageManager: new ImageManager(apiRequestService) }
+}

+ 33 - 0
composables/form/useFieldViolation.ts

@@ -0,0 +1,33 @@
+import { AnyStore } from '~/types/interfaces'
+import {ComputedRef} from "@vue/reactivity";
+import {useFormStore} from "~/store/form";
+
+/**
+ * Composable pour gérer l'apparition de message d'erreurs de validation d'un champ de formulaire
+ *
+ * @param field
+ * @param emit
+ */
+export function useFieldViolation(field: string, emit: any){
+  const violation: ComputedRef<string> = computed(()=>{
+    return useGet(useFormStore().violations, field, '')
+  })
+
+  /**
+   * Lorsque la valeur d'un champ change, on supprime le fait qu'il puisse être "faux" dans le store, et on émet sa nouvelle valeur au parent
+   * @param emit
+   * @param fieldValue
+   * @param changeField
+   */
+  function onChange (emit:any, fieldValue:any, changeField:string) {
+    //@ts-ignore
+    useFormStore().setViolations(useOmit(useFormStore().violations, changeField))
+
+    emit('update', fieldValue, changeField)
+  }
+
+  return {
+    onChange: (fieldValue:any) => onChange(emit, fieldValue, field),
+    violation
+  }
+}

+ 61 - 0
composables/form/useValidation.ts

@@ -0,0 +1,61 @@
+import VueI18n, {useI18n} from 'vue-i18n'
+import {useAp2iRequestService} from "~/composables/data/useAp2iRequestService";
+import Url from "~/services/utils/url";
+import {Ref} from "@vue/reactivity";
+
+/**
+ * @category composables/form
+ * Composable pour des utils de verifications
+ */
+export function useValidation() {
+
+  /**
+   * Use méthode fournissant une fonction pour tester la validité d'un Siret ainsi que la gestion du message d'erreur
+   */
+  function useValidateSiret() {
+    const siretError: Ref<boolean> = ref(false)
+    const siretErrorMessage: Ref<string> = ref('')
+
+    const validateSiret = async (siret: string) => {
+
+      const { apiRequestService } = useAp2iRequestService()
+      const response: any = await apiRequestService.get(Url.join('/api/siret-checking', siret))
+
+      if (typeof response === 'undefined') {
+        siretError.value = false
+        siretErrorMessage.value = ''
+      }
+
+      const i18n = useI18n()
+      siretError.value = !response.isCorrect
+      siretErrorMessage.value = response.isCorrect ? '' : i18n.t('siret_error') as string
+    }
+
+    return {
+      siretError,
+      siretErrorMessage,
+      validateSiret
+    }
+  }
+
+  function useValidateSubdomain() {
+    const isSubdomainAvailable = async (subdomain: string | null): Promise<boolean> => {
+      if (subdomain === null) {
+        return true
+      }
+
+      const { apiRequestService } = useAp2iRequestService()
+      const response: any = await apiRequestService.get('/api/subdomains', {'subdomain': subdomain})
+
+      return typeof response !== 'undefined' && response.metadata.totalItems === 0
+    }
+    return {
+      isSubdomainAvailable
+    }
+  }
+
+  return {
+    useValidateSiret,
+    useValidateSubdomain
+  }
+}

+ 25 - 0
composables/layout/useExtensionPanel.ts

@@ -0,0 +1,25 @@
+import {Ref} from "@vue/reactivity";
+
+/**
+ * @category composables/form
+ * Composable pour gérer les expansions des accordions
+ */
+export function useExtensionPanel(route: Ref) {
+  const panel: Ref<number> = ref(0)
+  const activeAccordionId = route.value.query.accordion
+
+  onMounted(() => {
+    setTimeout(function () {
+      useEach(document.getElementsByClassName('v-expansion-panel'), (element, index) => {
+        if (element.id == activeAccordionId) {
+          panel.value = index
+        }
+      })
+      if (!panel.value) { panel.value = 0 }
+    }, 0)
+  })
+
+  return {
+    panel
+  }
+}

+ 7 - 0
composables/utils/useDateUtils.ts

@@ -0,0 +1,7 @@
+import {useNuxtApp} from "#app";
+import DateUtils from "~/services/utils/dateUtils";
+
+export const useDateUtils = () => {
+    const { $moment } = useNuxtApp()
+    return new DateUtils($moment)
+}

+ 9 - 0
composables/utils/useI18nUtils.ts

@@ -0,0 +1,9 @@
+import {useI18n, VueI18n} from "vue-i18n";
+import I18nUtils from "~/services/utils/i18nUtils";
+
+export const useI18nUtils = () => {
+    const i18n = useI18n()
+
+    //@ts-ignore
+    return new I18nUtils(i18n)
+}

+ 5 - 0
composables/utils/useValidationUtils.ts

@@ -0,0 +1,5 @@
+import ValidationUtils from "~/services/utils/validationUtils";
+
+export const useValidationUtils = () => {
+    return new ValidationUtils()
+}

+ 1 - 1
lang/form/fr-FR.js

@@ -17,7 +17,7 @@ export default (context, locale) => {
     quit_form: 'Quitter le formulaire',
     save_and_quit: 'Sauvegarder et quitter le formulaire',
     back_to_form: 'Retourner au formulaire',
-    attention: 'Attention',
+    caution: 'Attention',
     updateMap: 'Mise à jour de la carte',
     start_your_research: 'Commencer à écrire pour rechercher...',
     no_coordinate_corresponding: 'Aucune coordonnées GPS ne correspondent à votre adresse',

+ 2 - 2
layouts/default.vue

@@ -4,7 +4,7 @@
 <!--    <ClientOnly placeholder-tag="client-only-placeholder" placeholder=" " />-->
     <h1>Test</h1>
     <v-app>
-<!--      <LayoutMenu v-if="displayedMenu" :menu="menu" :mini-variant="properties.miniVariant" :openMenu="properties.openMenu" />-->
+      <LayoutMenu v-if="displayMenu" :menu="menu" :mini-variant="properties.miniVariant" :openMenu="properties.openMenu" />
 
 <!--      <LayoutHeader @handle-open-menu-click="handleOpenMenu" @handle-open-mini-menu-click="handleOpenMiniMenu" />-->
 
@@ -31,7 +31,7 @@ const menu = UseMenu().getLateralMenu()
 
 const profileAccessStore = useProfileAccessStore()
 
-const displayedMenu = computed(() => profileAccessStore.hasLateralMenu)
+const displayMenu = computed(() => profileAccessStore.hasLateralMenu)
 
 const properties = reactive({
   clipped: false,

+ 10 - 0
models/ApiResource.ts

@@ -5,6 +5,16 @@ import {Model} from "pinia-orm";
  */
 export class ApiResource extends Model {
 
+    private _model: typeof ApiResource | undefined = undefined;
+
+    public getModel() {
+        return this._model
+    }
+
+    public setModel(model: typeof ApiResource ) {
+        this._model = model
+    }
+
     /**
      * Fix the 'Cannot stringify arbitrary non-POJOs' warning, meaning server can not parse the store
      *

+ 3 - 0
package.json

@@ -57,6 +57,7 @@
     "@pinia-orm/nuxt": "^1.0.18",
     "@pinia/nuxt": "^0.4.0",
     "@types/js-yaml": "^4.0.5",
+    "@types/vue-the-mask": "^0.11.1",
     "date-fns": "^2.29.3",
     "event-source-polyfill": "^1.0.31",
     "js-yaml": "^4.1.0",
@@ -66,6 +67,8 @@
     "pinia-orm": "1.0.3",
     "sass": "^1.54.5",
     "uuid": "^9.0.0",
+    "vue-tel-input-vuetify": "^1.5.3",
+    "vue-the-mask": "^0.11.1",
     "vuetify": "3.0.0-beta.13",
     "yaml-import": "^2.0.0"
   }

+ 85 - 0
pages/organization.vue

@@ -0,0 +1,85 @@
+<!-- Page de détails de l'organization courante -->
+
+<template>
+  <div>
+    <LayoutContainer v-if="organization">
+      <!-- Définit le contenu des trois slots du header de la page -->
+      <LayoutBannerTop>
+        <template #block1>
+          {{ organization.name }}
+        </template>
+        <template #block2>
+          N°Siret : {{ organization.siretNumber }}
+        </template>
+        <template #block3>
+          {{ organization.description }}
+        </template>
+      </LayoutBannerTop>
+
+      <!-- Rend le contenu de la page -->
+      <NuxtPage />
+    </LayoutContainer>
+    <LayoutContainer v-else>
+      Pending : {{ emPending }}
+    </LayoutContainer>
+  </div>
+</template>
+
+<script setup lang="ts">
+
+import {useEntityManager} from "~/composables/data/useEntityManager";
+import {useEntityFetch} from "~/composables/data/useEntityFetch";
+import {computed, ComputedRef, Ref} from "@vue/reactivity";
+import {useProfileOrganizationStore} from "~/store/profile/organization";
+import {Organization} from "~/models/Organization/Organization";
+import {ContactPoint} from "~/models/Core/ContactPoint";
+import {BankAccount} from "~/models/Core/BankAccount";
+import {OrganizationAddressPostal} from "~/models/Organization/OrganizationAddressPostal";
+import {AddressPostal} from "~/models/Core/AddressPostal";
+import {Country} from "~/models/Core/Country";
+import {TypeOfPractice} from "~/models/Organization/TypeOfPractice";
+import {Network} from "~/models/Network/Network";
+import {NetworkOrganization} from "~/models/Network/NetworkOrganization";
+import {OrganizationArticle} from "~/models/Organization/OrganizationArticle";
+import {File} from "~/models/Core/File";
+
+const { em, pending: emPending } = useEntityManager()
+
+const id: number | null = useProfileOrganizationStore().id
+if (id === null) {
+  throw new Error('Missing organization id')
+}
+
+const { fetch } = useEntityFetch()
+
+const { pending } = fetch(Organization, id)
+
+// Get file from store
+const organization: ComputedRef<Organization> = computed( () => {
+  return em.find(Organization, id)
+})
+
+
+
+// TODO: restaurer le middleware  (peut-être utiliser beforeMount?)
+// middleware({ $ability, redirect }) {
+//   if(!$ability.can('display', 'organization_page'))
+//     return redirect('/error')
+// }
+
+
+onBeforeUnmount(() => {
+  console.log('flush store') // TODO: vérifier le bon fonctionnement
+  em.flush(Organization)
+  em.flush(ContactPoint)
+  em.flush(BankAccount)
+  em.flush(OrganizationAddressPostal)
+  em.flush(AddressPostal)
+  em.flush(Country)
+  em.flush(TypeOfPractice)
+  em.flush(Network)
+  em.flush(NetworkOrganization)
+  em.flush(OrganizationArticle)
+  em.flush(File)
+})
+</script>

+ 541 - 0
pages/organization/index.vue

@@ -0,0 +1,541 @@
+<!--
+Contenu de la page pages/organization.vue
+Contient toutes les informations sur l'organization courante
+-->
+<template>
+  <LayoutContainer>
+    <UiForm :model="model" :entity="organization">
+      <template #form.input="{model, entity}">
+        <v-expansion-panels :value="panel" focusable accordion>
+          <!-- Description -->
+          <UiExpansionPanel id="description" icon="fa-info">
+            <v-container fluid class="container">
+              <v-row>
+                <v-col cols="12" sm="6">
+                  <UiInputText field="name" :data="entity['name']" @update="updateRepository" :rules="rules().nameRules" />
+                </v-col>
+
+                <v-col cols="12" sm="6">
+                  <UiInputText field="acronym" :data="entity['acronym']" @update="updateRepository" />
+                </v-col>
+
+                <v-col v-if="organizationProfile.isInsideNetwork()" cols="12" sm="6">
+                  <UiInputText :label="organizationProfile.isCmf() ? 'identifierCmf' : 'identifierFfec'" field="identifier" :data="entity['identifier']" @update="updateRepository" />
+                </v-col>
+
+                <v-col v-if="organizationProfile.isFfec()" cols="12" sm="6">
+                  <UiInputText field="ffecApproval" :data="entity['ffecApproval']" @update="updateRepository" />
+                </v-col>
+
+                <v-col cols="12" sm="6">
+                  <UiInputText field="description" :data="entity['description']" @update="updateRepository" />
+                </v-col>
+
+                <v-col cols="12" sm="6">
+                  <div>
+                    <span>{{ $t('logo') }}</span>
+                    <UiHelp right>
+                      <p v-html="$t('logo_upload')" />
+                    </UiHelp>
+                  </div>
+                  <UiImage
+                    :id="getIdFromUri(entity['logo'])"
+                    :upload="true"
+                    :width="200"
+                    field="logo"
+                    :ownerId="id"
+                    @update="updateRepository"
+                  ></UiImage>
+                </v-col>
+
+                <v-col v-if="!organizationProfile.isManagerProduct()" cols="12" sm="6">
+                  <UiInputEnum field="principalType" :data="entity['principalType']" enum-type="organization_principal_type" @update="updateRepository" />
+                </v-col>
+
+                <v-col v-if="!organizationProfile.isFfec() && !organizationProfile.isManagerProduct() && !organizationProfile.isArtist()" cols="12" sm="6">
+                  <UiInputEnum field="schoolCategory" :data="entity['schoolCategory']" enum-type="organization_school_cat" @update="updateRepository" />
+                </v-col>
+
+                <v-col v-if="organizationProfile.isFfec()" cols="12" sm="6">
+                  <UiInputEnum field="typeEstablishment" :data="entity['typeEstablishment']" enum-type="organization_type_establishment" @update="updateRepository" />
+                </v-col>
+
+                <v-col v-if="entity.typeEstablishment === 'MULTIPLE'" cols="12" sm="6">
+                  <UiInputEnum field="typeEstablishmentDetail" :data="entity['typeEstablishmentDetail']" enum-type="organization_type_establishment_detail" @update="updateRepository" />
+                </v-col>
+
+                <v-col cols="12" sm="6" v-if="organizationProfile.isCmf()">
+                  <div class="d-flex flex-row">
+                    <UiInputAutocomplete
+                      field="typeOfPractices"
+                      :items="typeOfPractices"
+                      :isLoading="typeOfPracticesFetchingState.pending"
+                      :item-text="['name']"
+                      :data="getIdsFromUris(entity['typeOfPractices'])"
+                      :translate="true"
+                      :multiple="true"
+                      group="category"
+                      :rules="rules().typeOfPractice"
+                      @update="updateRepository($event.map((id) => `/api/type_of_practices/${id}`), 'typeOfPractices')"
+                      class="flex"
+                    />
+                    <UiHelp>
+                      {{ $t('type_of_practices_autocomplete') }}
+                    </UiHelp>
+                  </div>
+                </v-col>
+                <v-col cols="12" sm="6" v-if="getIdsFromUris(entity['typeOfPractices']).indexOf(37) >= 0">
+                  <UiInputTextArea field="otherPractice" :data="entity['otherPractice']" @update="updateRepository" />
+                </v-col>
+              </v-row>
+            </v-container>
+          </UiExpansionPanel>
+
+          <!-- Adresses -->
+          <UiExpansionPanel id="address_postal" icon="fa-globe-europe">
+            <v-container fluid class="container">
+              <v-row>
+                <v-col cols="12" sm="12">
+                  <UiCollection
+                    :model="models().OrganizationAddressPostal"
+                    :parent="organization"
+                    loaderType="image"
+                    newLink="/organization/address/new"
+                  >
+                    <template #list.item="{items}">
+                      <v-container fluid>
+                        <v-row dense>
+                          <v-col
+                            v-for="item in items"
+                            :key="item.id"
+                            cols="4"
+                          >
+                            <UiCard
+                              :id="item.id"
+                              :link="`/organization/address/${item.id}`"
+                              :model="models().OrganizationAddressPostal"
+                            >
+                              <template #card.title>
+                                {{ $t(item.type) }}
+                              </template>
+                              <template #card.text>
+                                {{ item.addressPostal.streetAddress }} <br>
+                                <span v-if="item.addressPostal.streetAddressSecond">{{ item.addressPostal.streetAddressSecond }} <br></span>
+                                <span v-if="item.addressPostal.streetAddressThird">{{ item.addressPostal.streetAddressThird }} <br></span>
+                                {{ item.addressPostal.postalCode }} {{ item.addressPostal.addressCity }}<br>
+                                <span v-if="item.addressPostal.addressCountry">
+                                  <UiItemFromUri
+                                    :model="models().Country"
+                                    :query="repositories().countryRepository.query()"
+                                    :uri="item.addressPostal.addressCountry"
+                                  >
+                                    <template #item.text="{item}">
+                                      {{item.name}}
+                                    </template>
+                                  </UiItemFromUri>
+                                </span>
+                              </template>
+                            </UiCard>
+                          </v-col>
+                        </v-row>
+                      </v-container>
+                    </template>
+                  </UiCollection>
+                </v-col>
+              </v-row>
+            </v-container>
+          </UiExpansionPanel>
+
+          <!--  Point de Contact-->
+          <UiExpansionPanel id="contact_point" icon="fa-phone">
+            <v-container class="container">
+              <v-row>
+                <v-col cols="12" sm="12">
+                  <UiCollection
+                    :model="models().ContactPoint"
+                    :parent="organization"
+                    loaderType="image"
+                    newLink="/organization/contact_points/new"
+                  >
+                    <template #list.item="{items}">
+                      <v-container fluid>
+                        <v-row :dense="true">
+                          <v-col
+                            v-for="item in items"
+                            :key="item.id"
+                            cols="4"
+                          >
+                            <UiCard
+                              :id="item.id"
+                              :link="`/organization/contact_points/${item.id}`"
+                              :model="models().ContactPoint"
+                            >
+                              <template #card.title>
+                                {{ $t(item.contactType) }}
+                              </template>
+                              <template #card.text>
+                                <span v-if="item.email"><strong>{{ $t('email') }}</strong> : {{ item.email }} <br></span>
+                                <span v-if="item.emailInvalid" class="ot_danger--text"><v-icon class="ot_danger--text">mdi-alert</v-icon> <strong>{{ $t('emailInvalid') }}</strong> : {{ item.emailInvalid }} <br></span>
+
+                                <span v-if="item.telphone"><strong>{{ $t('telphone') }}</strong> : {{ formatPhoneNumber(item.telphone) }} <br></span>
+                                <span v-if="item.telphoneInvalid" class="ot_danger--text"><v-icon class="ot_danger--text">mdi-alert</v-icon> <strong>{{ $t('telphoneInvalid') }}</strong> : {{ formatPhoneNumber(item.telphoneInvalid) }} <br></span>
+
+                                <span v-if="item.mobilPhone"><strong>{{ $t('mobilPhone') }}</strong> : {{ formatPhoneNumber(item.mobilPhone) }} <br></span>
+                                <span v-if="item.mobilPhoneInvalid" class="ot_danger--text"><v-icon class="ot_danger--text">mdi-alert</v-icon> <strong>{{ $t('mobilPhoneInvalid') }}</strong> : {{ formatPhoneNumber(item.mobilPhoneInvalid) }} </span>
+                              </template>
+                            </UiCard>
+                          </v-col>
+                        </v-row>
+                      </v-container>
+                    </template>
+                  </UiCollection>
+                </v-col>
+              </v-row>
+            </v-container>
+          </UiExpansionPanel>
+
+          <!-- Informations légales -->
+          <UiExpansionPanel id="legalInformation" icon="fa-gavel">
+            <v-container fluid class="container">
+              <v-row>
+                <v-col cols="12" sm="6">
+                  <UiInputText
+                    field="siretNumber"
+                    :data="entity['siretNumber']"
+                    :error="siretError"
+                    :error-message="siretErrorMessage"
+                    :rules="rules().siretRule"
+                    @update="checkSiretHook($event, 'siretNumber', updateRepository)"
+                  />
+                </v-col>
+
+                <v-col cols="12" sm="6">
+                  <UiInputText field="apeNumber" :data="entity['apeNumber']" @update="updateRepository" />
+                </v-col>
+
+                <v-col v-if="entity['legalStatus'] === 'ASSOCIATION_LAW_1901'" cols="12" sm="6">
+                  <UiInputText field="waldecNumber" :data="entity['waldecNumber']" @update="updateRepository" />
+                </v-col>
+
+                <v-col cols="12" sm="6">
+                  <UiInputDatePicker field="creationDate" :data="entity['creationDate']" @update="updateRepository" />
+                </v-col>
+
+                <v-col cols="12" sm="6">
+                  <UiInputText field="prefectureName" :data="entity['prefectureName']" @update="updateRepository" />
+                </v-col>
+
+                <v-col cols="12" sm="6">
+                  <UiInputText field="prefectureNumber" :data="entity['prefectureNumber']" @update="updateRepository" />
+                </v-col>
+
+                <v-col cols="12" sm="6">
+                  <UiInputDatePicker field="declarationDate" :data="entity['declarationDate']" @update="updateRepository" />
+                </v-col>
+
+                <v-col cols="12" sm="6">
+                  <UiInputText field="tvaNumber" :data="entity['tvaNumber']" @update="updateRepository" />
+                </v-col>
+
+                <v-col cols="12" sm="6">
+                  <UiInputEnum field="legalStatus" :data="entity['legalStatus']" enum-type="organization_legal" @update="updateRepository" />
+                </v-col>
+
+              </v-row>
+            </v-container>
+          </UiExpansionPanel>
+
+          <!--  Agréments -->
+          <UiExpansionPanel id="agrements" icon="fa-certificate">
+            <v-container class="container">
+              <v-row>
+                <v-col cols="12" sm="6">
+                  <UiInputText field="youngApproval" :data="entity['youngApproval']" @update="updateRepository" />
+                </v-col>
+
+                <v-col cols="12" sm="6">
+                  <UiInputText field="trainingApproval" :data="entity['trainingApproval']" @update="updateRepository" />
+                </v-col>
+
+                <v-col cols="12" sm="6">
+                  <UiInputText field="otherApproval" :data="entity['otherApproval']" @update="updateRepository" />
+                </v-col>
+              </v-row>
+            </v-container>
+          </UiExpansionPanel>
+
+          <!-- Salariés -->
+          <UiExpansionPanel id="salary" icon="fa-users">
+            <v-container class="container">
+              <v-row>
+                <v-col cols="12" sm="6">
+                  <UiInputText field="collectiveAgreement" :data="entity['collectiveAgreement']" @update="updateRepository" />
+                </v-col>
+
+                <v-col cols="12" sm="6">
+                  <UiInputEnum field="opca" :data="entity['opca']" enum-type="organization_opca" @update="updateRepository" />
+                </v-col>
+
+                <v-col cols="12" sm="6">
+                  <UiInputText field="icomNumber" :data="entity['icomNumber']" @update="updateRepository" />
+                </v-col>
+
+                <v-col cols="12" sm="6">
+                  <UiInputText field="urssafNumber" :data="entity['urssafNumber']" @update="updateRepository" />
+                </v-col>
+              </v-row>
+            </v-container>
+          </UiExpansionPanel>
+
+          <!-- Réseaux -->
+          <UiExpansionPanel v-if="organizationProfile.isInsideNetwork()" id="network" icon="fa-share-alt">
+            <v-container class="container">
+              <v-row>
+                <v-col cols="12" sm="12">
+                  <UiCollection
+                    :model="models().NetworkOrganization"
+                    :parent="organization"
+                    loaderType="text"
+                  >
+                    <template #list.item="{items}">
+                      <div v-for="item in items" :key="item.id">
+                        <span>{{ item.network.name }}</span> - <span>{{$t('first_subscription')}} : <UiTemplateDate :data="item.startDate" /></span>
+                      </div>
+                    </template>
+                  </UiCollection>
+                </v-col>
+                <v-col v-if="organizationProfile.isFfec()" cols="12" sm="6">
+                  <UiInputText field="budget" :data="entity['budget']" type="number" @update="updateRepository" />
+                </v-col>
+
+                <v-col v-if="organizationProfile.isFfec()" cols="12" sm="6">
+                  <UiInputCheckbox field="isPedagogicIsPrincipalActivity" :data="entity['isPedagogicIsPrincipalActivity']" @update="updateRepository" />
+                </v-col>
+
+                <v-col v-if="organizationProfile.isFfec()" cols="12" sm="6">
+                  <UiInputText field="pedagogicBudget" :data="entity['pedagogicBudget']" type="number" @update="updateRepository" />
+                </v-col>
+              </v-row>
+            </v-container>
+          </UiExpansionPanel>
+
+          <!-- Communication -->
+          <UiExpansionPanel id="communication" icon="fa-rss">
+            <v-container class="container">
+              <v-row>
+                <v-col cols="12" sm="6">
+                  <UiInputText field="twitter" :data="entity['twitter']" @update="updateRepository" />
+                </v-col>
+
+                <v-col cols="12" sm="6">
+                  <UiInputText field="youtube" :data="entity['youtube']" @update="updateRepository" />
+                </v-col>
+
+                <v-col cols="12" sm="6">
+                  <UiInputText field="facebook" :data="entity['facebook']" @update="updateRepository" />
+                </v-col>
+
+                <v-col cols="12" sm="6">
+                  <UiInputText field="instagram" :data="entity['instagram']" @update="updateRepository" />
+                </v-col>
+
+                <v-col cols="12" sm="6">
+                  <UiInputCheckbox field="portailVisibility" :data="entity['portailVisibility']" @update="updateRepository" />
+                </v-col>
+
+                <v-col cols="12" sm="6">
+                  <div class="d-flex flex-column">
+                    <UiHelp class="d-flex flex-row">
+                      <span>{{ $t('image') }}</span>
+                      <p v-html="$t('communication_image_upload')" />
+                    </UiHelp>
+                    <UiImage
+                      :id="getIdFromUri(entity['image'])"
+                      :upload="true"
+                      :width="200"
+                      field="image"
+                      :ownerId="id"
+                      @update="updateRepository"
+                    ></UiImage>
+                  </div>
+                </v-col>
+
+                <v-col cols="12" sm="12">
+                  <UiCollection
+                    :model="models().OrganizationArticle"
+                    :parent="organization"
+                    loaderType="text"
+                  >
+                    <template #list.item="{items}">
+                      <h4 class="ot_grey--text font-weight-regular">{{$t('organizationArticle')}}</h4>
+                      <UiTemplateDataTable
+                        :headers="[
+                          { text: $t('title'), value: 'title' },
+                          { text: $t('link'), value: 'link' },
+                          { text: $t('date'), value: 'date' },
+                        ]"
+                        :items="items"
+                      >
+                        <template #item.date="{item}">
+                          <UiTemplateDate :data="item.date" />
+                        </template>
+                      </UiTemplateDataTable>
+                    </template>
+                  </UiCollection>
+                </v-col>
+
+              </v-row>
+            </v-container>
+          </UiExpansionPanel>
+
+          <!-- IBAN -->
+          <UiExpansionPanel id="bank_account" icon="fa-euro-sign">
+            <v-container class="container">
+              <v-row>
+                <v-col cols="12" sm="12">
+                  <UiCollection
+                    :model="models().BankAccount"
+                    :parent="organization"
+                    loaderType="image"
+                    newLink="/organization/bank_account/new"
+                  >
+                    <template #list.item="{items}">
+                      <v-container fluid>
+                        <v-row :dense="true">
+                          <v-col
+                            v-for="item in items"
+                            :key="item.id"
+                            cols="4"
+                          >
+                            <UiCard
+                              :id="item.id"
+                              :link="`/organization/bank_account/${item.id}`"
+                              :model="models().BankAccount"
+                            >
+                              <template #card.text>
+                                <span v-if="item.bankName"><strong>{{ $t('bankName') }}</strong> : {{ item.bankName }} <br></span>
+
+                                <span v-if="item.bic"><strong>{{ $t('bic') }}</strong> : {{ item.bic }} <br></span>
+                                <span v-if="item.bicInvalid" class="ot_danger--text"><v-icon class="ot_danger--text">mdi-alert</v-icon> <strong>{{ $t('bicInvalid') }}</strong> : {{ item.bicInvalid }} <br></span>
+
+                                <span v-if="item.iban"><strong>{{ $t('iban') }}</strong> : {{ item.iban }} <br></span>
+                                <span v-if="item.ibanInvalid" class="ot_danger--text"><v-icon class="ot_danger--text">mdi-alert</v-icon> <strong>{{ $t('ibanInvalid') }}</strong> : {{ item.ibanInvalid }} <br></span>
+
+                              </template>
+                            </UiCard>
+                          </v-col>
+                        </v-row>
+                      </v-container>
+                    </template>
+                  </UiCollection>
+                </v-col>
+              </v-row>
+            </v-container>
+          </UiExpansionPanel>
+        </v-expansion-panels>
+      </template>
+    </UiForm>
+  </LayoutContainer>
+</template>
+
+<script lang="ts">
+
+import {useEntityFetch} from "~/composables/data/useEntityFetch";
+import {TypeOfPractice} from "~/models/Organization/TypeOfPractice";
+import {reactive, ref} from "@vue/reactivity";
+import {$organizationProfile} from "~/services/profile/organizationProfile";
+import {useProfileOrganizationStore} from "~/store/profile/organization";
+import {useExtensionPanel} from "~/composables/layout/useExtensionPanel";
+import {useRoute} from "#app";
+import { useValidation } from "~/composables/form/useValidation";
+import {useEntityManager} from "~/composables/data/useEntityManager";
+import Url from "~/services/utils/url";
+import {Organization} from "~/models/Organization/Organization";
+import {useI18nUtils} from "~/composables/utils/useI18nUtils";
+import {ContactPoint} from "~/models/Core/ContactPoint";
+import {BankAccount} from "~/models/Core/BankAccount";
+import {OrganizationAddressPostal} from "~/models/Organization/OrganizationAddressPostal";
+import {Country} from "~/models/Core/Country";
+import {NetworkOrganization} from "~/models/Network/NetworkOrganization";
+import {OrganizationArticle} from "~/models/Organization/OrganizationArticle";
+
+const id: number | null = useProfileOrganizationStore().id
+if (id === null) {
+  throw new Error('Missing organization id')
+}
+
+const { em } = useEntityManager()
+
+const organizationProfile = reactive($organizationProfile())
+
+const { fetch, fetchCollection } = useEntityFetch()
+
+const model = Organization
+const { data: organization, pending: organizationPending } = fetch(Organization, id)
+
+const { data: typeOfPractices, pending: typeOfPracticesPending } = fetchCollection(TypeOfPractice)
+
+const route = ref(useRoute())
+const { panel } = useExtensionPanel(route)
+
+
+const { siretError, siretErrorMessage, validateSiret } = useValidation().useValidateSiret()
+
+const validateSiretHook = async (siret: string, field: string, updateRepository: any) => {
+  await validateSiret(siret)
+  if (organization.value !== null && !siretError.value) {
+    em.save(Organization, organization.value)
+  }
+}
+
+const formatPhoneNumber = (number: string): string => {
+  return useI18nUtils().formatPhoneNumber(number)
+}
+
+const getIdsFromUris = (uris: Array<string>) => {
+  const ids:Array<any> = []
+  for(const uri of uris){
+    ids.push(Url.extractIdFromUri(uri))
+  }
+  return ids
+}
+
+const getIdFromUri = (uri: string) => Url.extractIdFromUri(uri)
+
+function getValidationRules (i18n: any, $organizationProfile:any) {
+  return {
+    nameRules: [
+      (nameValue: string) => !!nameValue || i18n.t('required'),
+      (nameValue: string) => (nameValue || '').length <= 128 || i18n.t('name_length_rule')
+    ],
+    siretRule: [
+      (siretValue: string) => /^([0-9]{9}|[0-9]{14})$/.test(siretValue) || i18n.t('siret_error')
+    ],
+    typeOfPractice: [
+      (typeOfPracticeValue: Array<number>) => {
+        if(!$organizationProfile.isManagerProduct())
+          return typeOfPracticeValue.length > 0 || i18n.t('required')
+        return true
+      }
+    ]
+  }
+}
+
+const models = () => {
+  return {
+    Organization,
+    ContactPoint,
+    BankAccount,
+    OrganizationAddressPostal,
+    Country,
+    NetworkOrganization,
+    OrganizationArticle
+  }
+}
+</script>
+
+<style scoped>
+  .v-icon.v-icon {
+    font-size: 14px;
+  }
+</style>

+ 1 - 1
plugins/init.server.ts

@@ -12,6 +12,6 @@ export default defineNuxtPlugin(async ({ssrContext}) => {
         id: parseInt(accessId.value)
     })
 
-    const em = useEntityManager()
+    const { em } = useEntityManager()
     await em.refreshProfile()
 })

+ 15 - 10
services/data/entityManager.ts

@@ -50,6 +50,7 @@ class EntityManager {
         const repository = this.getRepository(model)
 
         let entity = repository.make(properties)
+        entity.setModel(model)
 
         // @ts-ignore
         if (!properties.hasOwnProperty('id') || !properties.id) {
@@ -60,7 +61,6 @@ class EntityManager {
         entity = repository.save(entity)
 
         this.saveInitialState(model, entity)
-
         return entity
     }
 
@@ -118,11 +118,20 @@ class EntityManager {
         return this.newInstance(model, attributes)
     }
 
-    public async fetchBy(model: typeof ApiResource, query: AssociativeArray, page: number = 1): Promise<Collection> {
-        let url = Url.join('api', model.entity)
-
-        if (page !== 1) {
-            query['page'] = page
+    /**
+     * Fetch a collection of entity
+     * The content of `query` is converted into a query-string in the request URL
+     *
+     * @param model
+     * @param query
+     * @param parent
+     */
+    public async fetchCollection(model: typeof ApiResource, parent: ApiResource | null, query: AssociativeArray = []): Promise<Collection> {
+        let url
+        if (parent !== null) {
+            url = Url.join('api', parent.entity, '' + parent.id, model.entity)
+        } else {
+            url = Url.join('api', model.entity)
         }
 
         const response = await this.apiRequestService.get(url, query)
@@ -146,10 +155,6 @@ class EntityManager {
         }
     }
 
-    public async fetchAll(model: typeof ApiResource, page: number = 1): Promise<Collection> {
-        return this.fetchBy(model, [], page)
-    }
-
     /**
      * Persist the entity as it is in the store into the data source via the API
      *

+ 3 - 3
services/data/enumManager.ts

@@ -1,7 +1,7 @@
 import ApiRequestService from "./apiRequestService";
 import Url from "~/services/utils/url";
 import HydraDenormalizer from "~/services/data/serializer/denormalizer/hydraDenormalizer";
-import {AssociativeArray} from "~/types/data.d";
+import {Enum} from "~/types/data.d";
 
 class EnumManager {
     private apiRequestService: ApiRequestService;
@@ -10,12 +10,12 @@ class EnumManager {
         this.apiRequestService = apiRequestService
     }
 
-    public async fetch(enumName: string): Promise<Array<AssociativeArray>> {
+    public async fetch(enumName: string): Promise<Enum> {
         const url = Url.join('api', 'enum', enumName)
 
         const response = await this.apiRequestService.get(url)
 
-        const data = await HydraDenormalizer.denormalize(response)
+        const data: any = await HydraDenormalizer.denormalize(response)
 
         return data.items.map(
             (v: string, k: string | number) => {

+ 24 - 3
services/data/imageManager.ts

@@ -1,15 +1,36 @@
 import ApiRequestService from "./apiRequestService";
+import Url from "~/services/utils/url";
+import ImageUtils from "~/services/utils/imageUtils";
 
-class FileManager {
+class ImageManager {
     private apiRequestService: ApiRequestService;
 
     public constructor(apiRequestService: ApiRequestService) {
         this.apiRequestService = apiRequestService
     }
 
-    public fetch() {
+    public async get(id: number, height: number = 0, width: number = 0) {
 
+        let url = `api/files/${id}/download`
+
+        // Set requested size if needed
+        if (height > 0 || width > 0) {
+            // @see https://thumbor.readthedocs.io/en/latest/crop_and_resize_algorithms.html
+            url = Url.join(url, `${height}x${width}`)
+        }
+
+        // Une image doit toujours avoir le time en options pour éviter les problème de cache
+        const query = [new Date().getTime().toString()]
+
+        const response: any = await this.apiRequestService.get(url, query)
+
+        if(!response.data || response.data.size === 0) {
+            throw new Error('Error: image not found or invalid')
+        }
+
+        const blob = await ImageUtils.newBlob(response.data)
+        return await ImageUtils.blobToBase64(blob);
     }
 }
 
-export default FileManager
+export default ImageManager

+ 0 - 30
services/store/formStoreHelper.ts

@@ -1,30 +0,0 @@
-import {FORM_STATUS} from "~/types/enums";
-import {useFormStore} from "~/store/form";
-
-export default class FormStoreHelper {
-
-  /**
-   * Actions devant être gérées si on souhaite quitter une page
-   * @param to
-   */
-  handleActionsAfterLeavePage(to: any){
-    // TODO: pourquoi ces méthodes sont ici et pas dans les actions du store lui même?
-    const formStore = useFormStore()
-    if (formStore.dirty) {
-      formStore.showConfirmToLeave = true
-      formStore.goAfterLeave = to
-    } else {
-      formStore.formStatus = FORM_STATUS.EDIT
-      formStore.violations = []
-    }
-  }
-
-  /**
-   * Ajout des violations dans le store
-   * @param invalidFields
-   */
-  addViolations(invalidFields: []){
-    const formStore = useFormStore()
-    formStore.violations = invalidFields
-  }
-}

+ 0 - 19
services/store/pageStoreHelper.ts

@@ -1,19 +0,0 @@
-import {TYPE_ALERT} from "~/types/enums";
-import {Alert} from "~/types/interfaces";
-import {usePageStore} from "~/store/page";
-
-export default class PageStoreHelper {
-  /**
-   * Ajout des alerts dans le store
-   * @param type
-   * @param alerts
-   */
-  addAlerts(type: TYPE_ALERT, alerts: Array<string>){
-    const pageStore = usePageStore()
-    const alert:Alert = {
-      type: type,
-      messages: alerts
-    }
-    pageStore.alerts.push(alert)
-  }
-}

+ 6 - 2
services/utils/datesUtils.ts → services/utils/dateUtils.ts

@@ -1,12 +1,16 @@
 import { format } from 'date-fns';
 
-export default class DatesUtils {
+export default class DateUtils {
   private $dateFns: dateFns;
 
   constructor(dateFns: dateFns) {
     this.$dateFns = dateFns
   }
 
+  format(date: any, fmt: string): string {
+    return format(date, fmt)
+  }
+
   /**
    * Formate la ou les dates au format donné et retourne la liste concaténée
    *
@@ -19,7 +23,7 @@ export default class DatesUtils {
 
     const dFormat: Array<string> = Array.isArray(dates) ? dates : [dates]
     for (const date of dates) {
-      dFormat.push(format(date, fmt))
+      dFormat.push(this.format(date, fmt))
     }
     return dFormat.join(sep)
   }

+ 2 - 2
services/utils/i18n.ts → services/utils/i18nUtils.ts

@@ -3,7 +3,7 @@ import {EnumChoice, EnumChoices} from "~/types/interfaces";
 import {parsePhoneNumber} from "libphonenumber-js";
 
 
-class I18n {
+export default class I18nUtils {
     private i18n!: VueI18n
 
     public constructor(i18n: VueI18n) {
@@ -39,7 +39,7 @@ class I18n {
         return enum_
     }
 
-    static formatPhoneNumber (number: string): string {
+    public formatPhoneNumber (number: string): string {
         const parsed = parsePhoneNumber(number)
         return parsed ? parsed.formatNational() : ''
     }

+ 29 - 0
services/utils/imageUtils.ts

@@ -0,0 +1,29 @@
+/**
+ * Manipulation des images
+ */
+class ImageUtils {
+
+    /**
+     * Returns a blob with the given data and the image filetype
+     *
+     * @param data
+     * @param filetype
+     */
+    public static async newBlob(data: string, filetype: string = 'image/jpeg') {
+        return new Blob([data as BlobPart], {type: filetype});
+    }
+
+    /**
+     * Transforme un Blob en Base64
+     * @param {Blob} blob
+     */
+    public static async blobToBase64(blob: Blob): Promise<any> {
+        // TODO: mieux typer la sortie
+        return new Promise((resolve, _) => {
+            const reader = new FileReader();
+            reader.onloadend = () => resolve(reader.result);
+            reader.readAsDataURL(blob);
+        });
+    }
+}
+export default ImageUtils

+ 11 - 10
services/utils/objectUtils.ts

@@ -17,11 +17,12 @@ export default class ObjectUtils {
    * @param excludedProperties
    * @return {AnyJson}
    */
-  cloneAndFlatten (object: AnyJson, excludedProperties: Array<string> = []): AnyJson {
+  static cloneAndFlatten (object: AnyJson, excludedProperties: Array<string> = []): AnyJson {
     if (typeof object !== 'object') {
       throw new TypeError('Expecting an object parameter')
     }
-    return ObjectUtils.keys(object).reduce(
+
+    return Object.keys(object).reduce(
       (values: AnyJson, name: string) => {
         if (!object.hasOwnProperty(name)) {
           return values
@@ -30,7 +31,7 @@ export default class ObjectUtils {
         if (this.isObject(object[name])) {
           if (!excludedProperties.includes(name)) {
             const flatObject = this.cloneAndFlatten(object[name])
-            ObjectUtils.keys(flatObject).forEach((flatObjectKey) => {
+            Object.keys(flatObject).forEach((flatObjectKey) => {
               if (!flatObject.hasOwnProperty(flatObjectKey)) { return }
               values[name + '.' + flatObjectKey] = flatObject[flatObjectKey]
             })
@@ -51,11 +52,11 @@ export default class ObjectUtils {
    * @param {AnyJson} object
    * @return {AnyJson}
    */
-  cloneAndNest (object: AnyJson): AnyJson {
+  static cloneAndNest (object: AnyJson): AnyJson {
     if (typeof object !== 'object') {
       throw new TypeError('Expecting an object parameter')
     }
-    return ObjectUtils.keys(object).reduce((values, name) => {
+    return Object.keys(object).reduce((values, name) => {
       if (!object.hasOwnProperty(name)) {
         return values
       }
@@ -79,7 +80,7 @@ export default class ObjectUtils {
    * @param {AnyJson} value
    * @return {boolean}
    */
-  isObject (value: any): boolean {
+  static isObject (value: any): boolean {
     return value !== null &&
       typeof value === 'object' &&
       !Array.isArray(value) &&
@@ -91,8 +92,8 @@ export default class ObjectUtils {
    * @param {ObjectUtils} object
    * @return {ObjectUtils}
    */
-  clone (object: AnyJson): AnyJson {
-    return ObjectUtils.keys(object).reduce((values: AnyJson, name: string) => {
+  static clone (object: AnyJson): AnyJson {
+    return Object.keys(object).reduce((values: AnyJson, name: string) => {
       if (object.hasOwnProperty(name)) {
         values[name] = object[name]
       }
@@ -105,11 +106,11 @@ export default class ObjectUtils {
    * @example sortObjectByKey({b:1, d:2, c:3, a:4}) => {a:4, b:1, c:3, d:2}
    * @param toSort
    */
-  sortObjectByKey (toSort: any): any {
+  static sortObjectByKey (toSort: any): any {
     if (typeof toSort !== 'object') {
       throw new TypeError('Expecting an object parameter')
     }
-    return ObjectUtils.keys(toSort).sort().reduce(
+    return Object.keys(toSort).sort().reduce(
       (obj:any, key:string) => {
         obj[key] = toSort[key]
         return obj

+ 9 - 0
services/utils/validationUtils.ts

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

+ 40 - 12
store/form.ts

@@ -1,25 +1,53 @@
 import {formState} from '~/types/interfaces'
-import {FORM_STATUS} from "~/types/enums";
+import {FORM_FUNCTION} from "~/types/enums";
 import {defineStore} from "pinia";
+import {AnyJson} from "~/types/data";
 
 export const useFormStore = defineStore('form', {
   state: (): formState => {
     return {
-      formStatus: FORM_STATUS.EDIT,
-      violations: [],
+      formFunction: FORM_FUNCTION.EDIT,
+      violations: {},
       readonly: false,
       dirty: false,
       showConfirmToLeave: false,
       goAfterLeave: null
     }
+  },
+  actions: {
+    setViolations (violations: Array<string>) {
+      this.violations = violations
+    },
+    addViolations(invalidFields: AnyJson){
+      this.violations = invalidFields
+    },
+    setReadOnly (readonly: boolean) {
+      this.readonly = readonly
+    },
+    setFormFunction (formFunction: FORM_FUNCTION) {
+      this.formFunction = formFunction
+    },
+    setDirty (dirty: boolean) {
+      this.dirty = dirty
+    },
+    setShowConfirmToLeave (showConfirmToLeave: boolean) {
+      this.showConfirmToLeave = showConfirmToLeave
+    },
+    setGoAfterLeave (goAfterLeave: string) {
+      this.goAfterLeave = goAfterLeave
+    },
+    /**
+     * Actions devant être gérées si on souhaite quitter une page
+     * @param to
+     */
+    handleActionsAfterLeavePage(to: any){
+      if (this.dirty) {
+        this.showConfirmToLeave = true
+        this.goAfterLeave = to
+      } else {
+        this.formFunction = FORM_FUNCTION.EDIT
+        this.violations = []
+      }
+    }
   }
 })
-
-export const state = () => ({
-  formStatus: FORM_STATUS.EDIT,
-  violations: [],
-  readonly: false,
-  dirty: false,
-  showConfirmToLeave: false,
-  goAfterLeave: null
-})

+ 15 - 1
store/page.ts

@@ -1,5 +1,7 @@
-import {pageState} from '~/types/interfaces'
+import {Alert, pageState} from '~/types/interfaces'
 import {defineStore} from "pinia";
+import {useFormStore} from "~/store/form";
+import {FORM_FUNCTION, TYPE_ALERT} from "~/types/enums";
 
 export const usePageStore = defineStore('page', {
   state: (): pageState => {
@@ -12,6 +14,18 @@ export const usePageStore = defineStore('page', {
       setTimeout(() => {
         this.alerts.shift()
       }, 300)
+    },
+    /**
+     * Ajout des alerts dans le store
+     * @param type
+     * @param alerts
+     */
+    addAlerts(type: TYPE_ALERT, alerts: Array<string>){
+      const alert:Alert = {
+        type: type,
+        messages: alerts
+      }
+      this.alerts.push(alert)
     }
   }
 })

+ 6 - 0
types/data.d.ts

@@ -1,4 +1,5 @@
 import ApiResource from "~/models/ApiResource";
+import {EnumChoice} from "~/types/interfaces";
 
 type AnyJson = Record<string, any>
 
@@ -61,4 +62,9 @@ interface Collection {
     totalItems: number | undefined
 }
 
+interface EnumItem {
+    value: string
+    label: string
+}
 
+type Enum = Array<EnumChoice>

+ 1 - 1
types/enums.ts

@@ -1,4 +1,4 @@
-export const enum FORM_STATUS {
+export const enum FORM_FUNCTION {
   CREATE = 'CREATE',
   EDIT = 'EDIT'
 }

+ 4 - 50
types/interfaces.d.ts

@@ -6,13 +6,14 @@ import DataProvider from '~/services/data/dataProvider'
 import DataDeleter from '~/services/data/dataDeleter'
 import {
   ABILITIES,
-  FORM_STATUS,
+  FORM_FUNCTION,
   GENDER,
   METADATA_TYPE,
   QUERY_TYPE,
   TYPE_ALERT,
 } from '~/types/enums'
 import ApiResource from "~/models/ApiResource";
+import {AnyJson} from "~/types/data";
 
 /**
  * Upgrade du @nuxt/types pour TypeScript
@@ -72,9 +73,9 @@ interface AbilitiesType {
 }
 
 interface formState {
-  violations: Array<string>
+  violations: AnyJson
   readonly: boolean
-  formStatus: FORM_STATUS
+  formFunction: FORM_FUNCTION
   dirty: boolean
   showConfirmToLeave: boolean
   goAfterLeave: string | null
@@ -170,60 +171,13 @@ interface EnumChoice {
   label: string
 }
 
-interface UrlArgs {
-  readonly type: QUERY_TYPE
-  readonly url?: string
-  readonly baseUrl?: string
-  readonly enumType?: string
-  readonly model?: typeof Model
-  readonly rootModel?: typeof Model
-  readonly id?: any
-  readonly idTemp?: any
-  readonly rootId?: number
-  readonly showProgress?: boolean
-  readonly hook?: string
-  readonly params?: AnyJson
-}
-
-interface ImageArgs {
-  readonly id: number
-  readonly height: number
-  readonly width: number
-}
-
-interface FileArgs {
-  readonly fileId: number
-}
-
 interface Filter {
   readonly key: string
   readonly value: string | boolean | number
 }
 
-interface ListArgs {
-  readonly itemsPerPage?: number
-  readonly page?: number
-  readonly filters?: Array<Filter>
-}
-
-interface DataProviderArgs extends UrlArgs {
-  imgArgs?: ImageArgs
-  listArgs?: ListArgs
-  fileArgs?: FileArgs
-}
-interface DataPersisterArgs extends UrlArgs {
-  data?: AnyJson
-  query?: Query
-  file?: string
-}
-type DataDeleterArgs = UrlArgs
-
 type EnumChoices = Array<EnumChoice>
 
-interface DataManager {
-  invoke(args: UrlArgs): Promise<any>
-}
-
 interface HookProvider {
   invoke(args: DataProviderArgs): Promise<any>
 }