Files
Starseed/frontend/modules/commercial/components/ClientAddressBlock.vue
T
tristan 912280d24e
Auto Tag Develop / tag (push) Successful in 7s
feat(front) : util httpExternal + autocomplete adresse BAN (ERP-66) (#52)
## ERP-66 — Utilitaires adresse/téléphone + autocomplétion BAN

### feat
- **httpExternal** : client dédié aux API publiques externes (URL absolue, sans cookie de session, timeout). Seul point d'entrée autorisé pour un `$fetch` externe (règle frontend n°4).
- **useAddressAutocomplete** : implémentation BAN (api-adresse.data.gouv.fr) — recherche ville (`type=municipality`) et adresse, mapping GeoJSON, throw en cas d'erreur/timeout (mode dégradé côté composant). La recherche d'adresse n'impose **pas** `type=housenumber` (sinon 0 résultat tant qu'aucun numéro n'est saisi) — spec-front mise à jour.
- Tests Vitest : httpExternal, useAddressAutocomplete, cas limites `formatPhoneFR`.

### fix
- **ClientAddressBlock** : la rue courante est toujours réinjectée dans les options de `MalioInputAutocomplete` (computed, miroir de `cityOptions`). Corrige le champ Adresse qui se vidait après validation / à l'édition d'une adresse existante (valeur pourtant persistée). Test de montage ajouté.
- **useClientReferentials** : libellé des sites = numéro de département (2 premiers chiffres du code postal, déjà exposé par `/sites`) au lieu du nom.

### Vérifs
- ESLint  · Vitest 196/196 
- Changements 100% frontend (+ doc spec).

Reviewed-on: #52
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-03 14:02:14 +00:00

329 lines
13 KiB
Vue

<template>
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<!-- ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
<MalioButtonIcon
v-if="removable && !readonly"
icon="mdi:delete-outline"
variant="ghost"
button-class="absolute top-3 right-3"
v-bind="{ ariaLabel: t('commercial.clients.form.address.remove') }"
@click="$emit('remove')"
/>
<!-- Usage de l'adresse : Prospect exclusif de Livraison/Facturation
(RG-1.06/07/08). L'exclusivite est appliquee au toggle (cocher l'un
decoche l'autre) plutot qu'en masquant les options. -->
<MalioCheckbox
:model-value="model.isProspect"
:label="t('commercial.clients.form.address.prospect')"
group-class="self-center"
:readonly="readonly"
@update:model-value="(v: boolean) => toggleFlag('isProspect', v)"
/>
<MalioCheckbox
:model-value="model.isDelivery"
:label="t('commercial.clients.form.address.delivery')"
group-class="self-center"
:readonly="readonly"
@update:model-value="(v: boolean) => toggleFlag('isDelivery', v)"
/>
<MalioCheckbox
:model-value="model.isBilling"
:label="t('commercial.clients.form.address.billing')"
group-class="self-center"
:readonly="readonly"
@update:model-value="(v: boolean) => toggleFlag('isBilling', v)"
/>
<!-- Cellule vide : laisse un trou en position 4 (ligne 1) pour que
Categorie reparte au debut de la ligne suivante. -->
<div aria-hidden="true" />
<MalioSelectCheckbox
:model-value="model.categoryIris"
:options="categoryOptions"
:label="t('commercial.clients.form.address.categories')"
:display-tag="true"
:disabled="readonly"
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
/>
<MalioSelect
:model-value="model.country"
:options="countryOptions"
:label="t('commercial.clients.form.address.country')"
:disabled="readonly"
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
/>
<MalioInputText
:model-value="model.postalCode"
:label="t('commercial.clients.form.address.postalCode')"
:mask="POSTAL_CODE_MASK"
:readonly="readonly"
@update:model-value="onPostalCodeChange"
/>
<!-- Ville : MalioSelect alimente par le code postal (BAN). En mode
degrade (service indisponible), bascule en saisie libre. -->
<MalioSelect
v-if="!degraded"
:model-value="model.city"
:options="cityOptions"
:label="t('commercial.clients.form.address.city')"
:disabled="readonly"
empty-option-label=""
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
/>
<MalioInputText
v-else
:model-value="model.city"
:label="t('commercial.clients.form.address.city')"
:readonly="readonly"
@update:model-value="(v: string) => update('city', v)"
/>
<!-- Adresse + Adresse complementaire sur 2 colonnes : on wrappe car
MalioInputText/Autocomplete (inheritAttrs:false) renvoient `class`
sur l'input interne, pas sur la cellule de grille. Le wrapper porte
le col-span-2, le champ le remplit (w-full). -->
<div class="col-span-2">
<!-- Adresse : saisie assistee (BAN) en edition ; champ texte simple en
mode degrade OU en lecture seule (MalioInputAutocomplete ne reaffiche
pas sa valeur liee, il n'afficherait rien en readonly). -->
<MalioInputAutocomplete
v-if="!degraded && !readonly"
:model-value="model.street"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:label="t('commercial.clients.form.address.street')"
:readonly="readonly"
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
/>
<MalioInputText
v-else
:model-value="model.street"
:label="t('commercial.clients.form.address.street')"
:readonly="readonly"
@update:model-value="(v: string) => update('street', v)"
/>
</div>
<div class="col-span-2">
<MalioInputText
:model-value="model.streetComplement"
:label="t('commercial.clients.form.address.streetComplement')"
:readonly="readonly"
@update:model-value="(v: string) => update('streetComplement', v)"
/>
</div>
<!-- Sites Starseed : cases a cocher inline (>= 1 obligatoire, RG-1.10). -->
<div class="flex justify-between">
<MalioCheckbox
v-for="site in siteOptions"
:key="site.value"
:model-value="model.siteIris.includes(site.value)"
:label="site.label"
group-class="w-auto self-center"
:readonly="readonly"
@update:model-value="(v: boolean) => toggleSite(site.value, v)"
/>
</div>
<MalioSelectCheckbox
:model-value="model.contactIris"
:options="contactOptions"
:label="t('commercial.clients.form.address.contacts')"
:display-tag="true"
:disabled="readonly"
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
/>
<!-- Email de facturation : visible/obligatoire seulement si Facturation
est coche (RG-1.11). -->
<MalioInputText
v-if="isBillingEmailRequired(model)"
:model-value="model.billingEmail"
:label="t('commercial.clients.form.address.billingEmail')"
:required="true"
:readonly="readonly"
@update:model-value="(v: string) => update('billingEmail', v)"
/>
</div>
</template>
<script setup lang="ts">
import {
applyProspectExclusivity,
isBillingEmailRequired,
type AddressFlagsDraft,
} from '~/modules/commercial/utils/clientFormRules'
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useClientReferentials'
import type { AddressFormDraft } from '~/modules/commercial/types/clientForm'
// Masque code postal FR : 5 chiffres.
const POSTAL_CODE_MASK = '#####'
const props = defineProps<{
/** Brouillon de l'adresse (v-model). */
modelValue: AddressFormDraft
title: string
/** Categories autorisees sur une adresse (DISTRIBUTEUR/COURTIER exclus, RG-1.29). */
categoryOptions: CategoryOption[]
/** Sites Starseed disponibles. */
siteOptions: RefOption[]
/** Contacts deja saisis, rattachables a l'adresse. */
contactOptions: RefOption[]
/** Pays disponibles (France par defaut). */
countryOptions: RefOption[]
removable?: boolean
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: AddressFormDraft]
'remove': []
/** Emis une fois quand le service d'autocompletion bascule en indisponible. */
'degraded': []
}>()
const { t } = useI18n()
const autocomplete = useAddressAutocomplete()
const model = computed(() => props.modelValue)
// Mode degrade : service BAN indisponible → Ville/Adresse en saisie libre.
const degraded = ref(false)
// Villes proposees par la BAN (alimentees a la saisie du code postal).
const banCityOptions = ref<RefOption[]>([])
// Adresses proposees par la BAN (alimentees a la saisie d'adresse).
const banAddressOptions = ref<RefOption[]>([])
// Options ville effectives : on garantit que la ville courante figure toujours
// dans la liste, sinon MalioSelect (qui resout le libelle depuis ses options)
// afficherait un champ vide en lecture seule (consultation 1.11) ou en edition
// d'une adresse existante (1.12), ou la BAN n'a pas (re)peuple les suggestions.
const cityOptions = computed<RefOption[]>(() => {
const current = props.modelValue.city
if (current && !banCityOptions.value.some(o => o.value === current)) {
return [{ value: current, label: current }, ...banCityOptions.value]
}
return banCityOptions.value
})
// Meme garantie que cityOptions pour le champ Adresse : la rue courante doit
// toujours figurer dans les options, sinon MalioInputAutocomplete (qui resout
// l'affichage depuis ses options) laisse le champ VIDE des que la liste de
// suggestions BAN est vide — typiquement juste apres validation (remontage) ou
// a l'edition d'une adresse existante (1.12), alors que la valeur est bien
// persistee. On reinjecte donc la rue liee si la BAN ne l'a pas (re)proposee.
const addressOptions = computed<RefOption[]>(() => {
const current = props.modelValue.street
if (current && !banAddressOptions.value.some(o => o.value === current)) {
return [{ value: current, label: current }, ...banAddressOptions.value]
}
return banAddressOptions.value
})
const addressLoading = ref(false)
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
let lastAddressSuggestions: AddressSuggestion[] = []
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
function update<K extends keyof AddressFormDraft>(field: K, value: AddressFormDraft[K]): void {
emit('update:modelValue', { ...props.modelValue, [field]: value })
}
/** Coche/decoche un site Starseed rattache a l'adresse (M2M par IRI, RG-1.10). */
function toggleSite(siteIri: string, selected: boolean): void {
const current = props.modelValue.siteIris
const next = selected
? [...current, siteIri]
: current.filter(iri => iri !== siteIri)
update('siteIris', next)
}
/** Applique l'exclusivite Prospect / (Livraison|Facturation) au changement. */
function toggleFlag(field: keyof AddressFlagsDraft, value: boolean): void {
const flags = applyProspectExclusivity(
{ isProspect: model.value.isProspect, isDelivery: model.value.isDelivery, isBilling: model.value.isBilling },
field,
value,
)
emit('update:modelValue', { ...props.modelValue, ...flags })
}
/** Bascule définitivement en mode degrade et previent le parent (toast unique). */
function enterDegraded(): void {
if (!degraded.value) {
degraded.value = true
emit('degraded')
}
}
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville. */
async function onPostalCodeChange(value: string): Promise<void> {
update('postalCode', value)
if (degraded.value) {
return
}
const digits = (value ?? '').replace(/\D/g, '')
if (digits.length < 5) {
return
}
try {
const suggestions = await autocomplete.searchCity(digits)
banCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
}
catch {
enterDegraded()
}
}
/** Recherche d'adresse assistee (event de MalioInputAutocomplete). */
async function onAddressSearch(query: string): Promise<void> {
if (degraded.value) {
return
}
addressLoading.value = true
try {
const postalCode = (model.value.postalCode ?? '').replace(/\D/g, '') || undefined
const suggestions = await autocomplete.searchAddress(query, postalCode)
lastAddressSuggestions = suggestions
banAddressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
}
catch {
enterDegraded()
}
finally {
addressLoading.value = false
}
}
/**
* Selection d'une suggestion d'adresse → remplit rue + ville + CP.
* Le type d'option suit le contrat MalioInputAutocomplete ({ label, value }).
*/
function onAddressSelect(option: { label: string, value: string | number } | 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,
postalCode: suggestion.postalCode,
})
}
</script>