f4313d1f3d
- ClientAddressBlock : la rue courante est toujours reinjectee dans les options de MalioInputAutocomplete (computed, miroir de cityOptions). Sinon, des que la liste de suggestions BAN est vide (remontage apres validation, edition d'une adresse existante), le composant ne resolvait plus la valeur liee et affichait un champ vide alors que la donnee etait bien persistee. Test de montage ajoute. - useClientReferentials : le libelle des sites = numero de departement (2 premiers chiffres du code postal, deja expose par /sites) au lieu du nom.
329 lines
13 KiB
Vue
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>
|