feat(directory) : type prestataire, validateurs front, autocomplete adresse BAN
- Prestataire : entité/repo + ressource API Platform (RBAC directory.providers.*), ownership prestataire sur contacts/adresses/comptes-rendus (CHECK XOR à 3), DTO/service/drawer/fiche détail + onglet dédié dans le répertoire. - Prospect : société uniquement (suppression du champ name, company requis) ; migration de backfill, conversion prospect→client et MCP adaptés. - Champ site web sur client/prospect/prestataire (entités, DTO, onglet Information, MCP). - Validateurs front email / téléphone FR (0549200910) / URL sur Information et Contacts, enregistrement bloqué tant qu'un champ est invalide. - Autocomplete adresse branché sur la Base Adresse Nationale (api-adresse.data.gouv.fr) avec mode dégradé en saisie libre. - Administration : retrait de l'onglet Clients.
This commit is contained in:
@@ -54,7 +54,7 @@ import { useCommercialReportService } from '~/modules/directory/services/commerc
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
report: CommercialReport | null
|
||||
owner: { client?: string, prospect?: string }
|
||||
owner: { client?: string, prospect?: string, prestataire?: string }
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -141,7 +141,7 @@ import { useCommercialReportService } from '~/modules/directory/services/commerc
|
||||
import { useReportDocumentService } from '~/modules/directory/services/report-documents'
|
||||
|
||||
const props = defineProps<{
|
||||
owner: { client?: string, prospect?: string }
|
||||
owner: { client?: string, prospect?: string, prestataire?: string }
|
||||
canManage: boolean
|
||||
}>()
|
||||
|
||||
|
||||
@@ -19,13 +19,33 @@
|
||||
:readonly="readonly"
|
||||
@update:model-value="update('label', $event)"
|
||||
/>
|
||||
<MalioInputText
|
||||
class="col-span-2"
|
||||
:label="$t('directory.addresses.fields.street')"
|
||||
:model-value="modelValue.street ?? ''"
|
||||
:readonly="readonly"
|
||||
@update:model-value="update('street', $event)"
|
||||
/>
|
||||
|
||||
<!-- Rue : saisie assistée (BAN) en édition, champ texte en lecture seule.
|
||||
allow-create conserve le texte saisi si la BAN ne propose rien
|
||||
(erreur/timeout). Choisir une suggestion remplit rue + CP + ville. -->
|
||||
<div class="col-span-2">
|
||||
<MalioInputAutocomplete
|
||||
v-if="!readonly"
|
||||
:model-value="modelValue.street ?? ''"
|
||||
:options="addressOptions"
|
||||
:loading="addressLoading"
|
||||
:min-search-length="3"
|
||||
:allow-create="true"
|
||||
:label="$t('directory.addresses.fields.street')"
|
||||
:no-results-text="$t('directory.addresses.streetNotFound')"
|
||||
@update:model-value="(v) => update('street', v === null ? '' : String(v))"
|
||||
@search="onAddressSearch"
|
||||
@select="onAddressSelect"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:label="$t('directory.addresses.fields.street')"
|
||||
:model-value="modelValue.street ?? ''"
|
||||
:readonly="readonly"
|
||||
@update:model-value="update('street', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MalioInputText
|
||||
class="col-span-2"
|
||||
:label="$t('directory.addresses.fields.streetComplement')"
|
||||
@@ -33,13 +53,27 @@
|
||||
:readonly="readonly"
|
||||
@update:model-value="update('streetComplement', $event)"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
:label="$t('directory.addresses.fields.postalCode')"
|
||||
:model-value="modelValue.postalCode ?? ''"
|
||||
:readonly="readonly"
|
||||
@update:model-value="update('postalCode', $event)"
|
||||
@update:model-value="onPostalCodeInput"
|
||||
/>
|
||||
|
||||
<!-- Ville : select alimenté par le code postal (BAN). En mode dégradé
|
||||
(BAN indispo) ou lecture seule, on bascule en saisie libre. -->
|
||||
<MalioSelect
|
||||
v-if="!readonly && !degraded"
|
||||
:model-value="modelValue.city ?? ''"
|
||||
:options="cityOptions"
|
||||
:label="$t('directory.addresses.fields.city')"
|
||||
empty-option-label=""
|
||||
group-class="w-full"
|
||||
@update:model-value="(v) => update('city', v === null ? '' : String(v))"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:label="$t('directory.addresses.fields.city')"
|
||||
:model-value="modelValue.city ?? ''"
|
||||
:readonly="readonly"
|
||||
@@ -50,6 +84,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Address } from '~/modules/directory/services/dto/address'
|
||||
import {
|
||||
useAddressAutocomplete,
|
||||
type AddressSuggestion,
|
||||
} from '~/modules/directory/composables/useAddressAutocomplete'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: Address
|
||||
@@ -63,7 +101,82 @@ const emit = defineEmits<{
|
||||
'remove': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const autocomplete = useAddressAutocomplete()
|
||||
|
||||
type Option = { label: string, value: string | number }
|
||||
|
||||
const addressOptions = ref<Option[]>([])
|
||||
const cityOptions = ref<Option[]>([])
|
||||
const addressLoading = ref(false)
|
||||
// Mode dégradé : BAN indisponible → la ville passe en saisie libre.
|
||||
const degraded = ref(false)
|
||||
let lastAddressSuggestions: AddressSuggestion[] = []
|
||||
let notified = false
|
||||
|
||||
function update(field: keyof Address, value: string): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value === '' ? null : value })
|
||||
}
|
||||
|
||||
// Avertit une seule fois que l'autocomplétion est indisponible (saisie libre).
|
||||
function notifyUnavailable(): void {
|
||||
if (notified) return
|
||||
notified = true
|
||||
toast.info({ title: '', message: t('directory.addresses.autocompleteUnavailable') })
|
||||
}
|
||||
|
||||
/** Recherche d'adresse assistée (event de MalioInputAutocomplete). */
|
||||
async function onAddressSearch(query: string): Promise<void> {
|
||||
if (query.trim().length < 3) {
|
||||
addressOptions.value = []
|
||||
return
|
||||
}
|
||||
addressLoading.value = true
|
||||
try {
|
||||
const postalCode = (props.modelValue.postalCode ?? '').replace(/\D/g, '') || undefined
|
||||
const suggestions = await autocomplete.searchAddress(query, postalCode)
|
||||
lastAddressSuggestions = suggestions
|
||||
addressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
|
||||
}
|
||||
catch {
|
||||
addressOptions.value = []
|
||||
notifyUnavailable()
|
||||
}
|
||||
finally {
|
||||
addressLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Sélection d'une suggestion → remplit rue + ville + code postal. */
|
||||
function onAddressSelect(option: Option | null): void {
|
||||
if (option === null) return
|
||||
const suggestion = lastAddressSuggestions.find(s => s.street === option.value)
|
||||
if (!suggestion) {
|
||||
update('street', String(option.value))
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
street: suggestion.street,
|
||||
city: suggestion.city || props.modelValue.city,
|
||||
postalCode: suggestion.postalCode || props.modelValue.postalCode,
|
||||
})
|
||||
}
|
||||
|
||||
/** Saisie du code postal → met à jour le champ + interroge la BAN pour la ville. */
|
||||
async function onPostalCodeInput(value: string): Promise<void> {
|
||||
update('postalCode', value)
|
||||
const digits = (value ?? '').replace(/\D/g, '')
|
||||
if (digits.length < 5) return
|
||||
try {
|
||||
const suggestions = await autocomplete.searchCity(digits)
|
||||
cityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
|
||||
degraded.value = false
|
||||
}
|
||||
catch {
|
||||
degraded.value = true
|
||||
notifyUnavailable()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -35,18 +35,21 @@
|
||||
:label="$t('directory.contacts.fields.email')"
|
||||
:model-value="modelValue.email ?? ''"
|
||||
:readonly="readonly"
|
||||
:error="emailError"
|
||||
@update:model-value="update('email', $event)"
|
||||
/>
|
||||
<MalioInputText
|
||||
:label="$t('directory.contacts.fields.phonePrimary')"
|
||||
:model-value="modelValue.phonePrimary ?? ''"
|
||||
:readonly="readonly"
|
||||
:error="phonePrimaryError"
|
||||
@update:model-value="update('phonePrimary', $event)"
|
||||
/>
|
||||
<MalioInputText
|
||||
:label="$t('directory.contacts.fields.phoneSecondary')"
|
||||
:model-value="modelValue.phoneSecondary ?? ''"
|
||||
:readonly="readonly"
|
||||
:error="phoneSecondaryError"
|
||||
@update:model-value="update('phoneSecondary', $event)"
|
||||
/>
|
||||
</div>
|
||||
@@ -54,6 +57,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Contact } from '~/modules/directory/services/dto/contact'
|
||||
import { isValidEmail, isValidFrPhone } from '~/modules/directory/utils/validation'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: Contact
|
||||
@@ -67,6 +71,18 @@ const emit = defineEmits<{
|
||||
'remove': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const emailError = computed(() =>
|
||||
isValidEmail(props.modelValue.email) ? '' : t('directory.validation.emailInvalid'),
|
||||
)
|
||||
const phonePrimaryError = computed(() =>
|
||||
isValidFrPhone(props.modelValue.phonePrimary) ? '' : t('directory.validation.phoneInvalid'),
|
||||
)
|
||||
const phoneSecondaryError = computed(() =>
|
||||
isValidFrPhone(props.modelValue.phoneSecondary) ? '' : t('directory.validation.phoneInvalid'),
|
||||
)
|
||||
|
||||
function update(field: keyof Contact, value: string): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value === '' ? null : value })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('prestataires.editPrestataire') : $t('prestataires.addPrestataire') }}</h2>
|
||||
</template>
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
:label="$t('prestataires.fields.name')"
|
||||
input-class="w-full"
|
||||
:error="touched.name && !form.name.trim() ? $t('directory.validation.nameRequired') : ''"
|
||||
@blur="touched.name = true"
|
||||
/>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<MalioButton
|
||||
:label="$t('common.save')"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Prestataire, PrestataireWrite } from '~/modules/directory/services/dto/prestataire'
|
||||
import { usePrestataireService } from '~/modules/directory/services/prestataires'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
prestataire: Prestataire | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const isEditing = computed(() => !!props.prestataire)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
name: false,
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
form.name = props.prestataire?.name ?? ''
|
||||
touched.name = false
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update } = usePrestataireService()
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.name = true
|
||||
if (!form.name.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const payload: PrestataireWrite = {
|
||||
name: form.name.trim(),
|
||||
}
|
||||
|
||||
if (isEditing.value && props.prestataire) {
|
||||
await update(props.prestataire.id, payload)
|
||||
} else {
|
||||
await create(payload)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -5,11 +5,11 @@
|
||||
</template>
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
label="Nom société"
|
||||
v-model="form.company"
|
||||
:label="$t('prospects.fields.company')"
|
||||
input-class="w-full"
|
||||
:error="touched.name && !form.name.trim() ? $t('prospects.validation.nameRequired') : ''"
|
||||
@blur="touched.name = true"
|
||||
:error="touched.company && !form.company.trim() ? $t('prospects.validation.companyRequired') : ''"
|
||||
@blur="touched.company = true"
|
||||
/>
|
||||
|
||||
<div class="mt-6 flex items-center justify-between gap-2">
|
||||
@@ -62,30 +62,30 @@ const isConverted = computed(() => !!props.prospect?.convertedClient)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
company: '',
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
name: false,
|
||||
company: false,
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
form.name = props.prospect?.name ?? ''
|
||||
touched.name = false
|
||||
form.company = props.prospect?.company ?? ''
|
||||
touched.company = false
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update, convert } = useProspectService()
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.name = true
|
||||
if (!form.name.trim()) return
|
||||
touched.company = true
|
||||
if (!form.company.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const payload: ProspectWrite = {
|
||||
name: form.name.trim(),
|
||||
company: form.company.trim(),
|
||||
}
|
||||
|
||||
if (isEditing.value && props.prospect) {
|
||||
|
||||
Reference in New Issue
Block a user