feat(directory) : type prestataire, validateurs front, autocomplete adresse BAN
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m20s
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m26s

- 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:
Matthieu
2026-06-24 17:55:09 +02:00
parent 052ef55c79
commit 5764d8f472
48 changed files with 1251 additions and 130 deletions
@@ -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>