199 lines
7.2 KiB
Vue
199 lines
7.2 KiB
Vue
<template>
|
|
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]">
|
|
<h3 class="col-span-2 text-sm font-semibold text-neutral-700">
|
|
{{ title }}
|
|
</h3>
|
|
<MalioButtonIcon
|
|
v-if="removable && !readonly"
|
|
icon="mdi:delete-outline"
|
|
variant="ghost"
|
|
class="absolute right-3 top-3"
|
|
:aria-label="$t('common.delete')"
|
|
@click="$emit('remove')"
|
|
/>
|
|
|
|
<MalioInputText
|
|
class="col-span-2"
|
|
:label="$t('directory.addresses.fields.label')"
|
|
:model-value="modelValue.label ?? ''"
|
|
:readonly="readonly"
|
|
@update:model-value="update('label', $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')"
|
|
:model-value="modelValue.streetComplement ?? ''"
|
|
:readonly="readonly"
|
|
@update:model-value="update('streetComplement', $event)"
|
|
/>
|
|
|
|
<MalioInputText
|
|
:label="$t('directory.addresses.fields.postalCode')"
|
|
:model-value="modelValue.postalCode ?? ''"
|
|
:readonly="readonly"
|
|
@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"
|
|
@update:model-value="update('city', $event)"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<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
|
|
title: string
|
|
removable?: boolean
|
|
readonly?: boolean
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
'update:modelValue': [value: Address]
|
|
'remove': []
|
|
}>()
|
|
|
|
const { t } = useI18n()
|
|
const toast = useToast()
|
|
const autocomplete = useAddressAutocomplete()
|
|
|
|
type Option = { label: string, value: string | number }
|
|
|
|
const addressOptions = ref<Option[]>([])
|
|
// Villes renvoyées par la BAN pour le code postal courant.
|
|
const fetchedCityOptions = ref<Option[]>([])
|
|
const addressLoading = ref(false)
|
|
|
|
// Le select Ville n'affiche que les valeurs présentes dans ses options : on
|
|
// garantit donc que la ville déjà enregistrée (chargement d'une fiche) ou
|
|
// pré-remplie par l'autocomplétion d'adresse figure toujours dans la liste,
|
|
// même avant toute recherche par code postal — sinon elle s'afficherait vide.
|
|
const cityOptions = computed<Option[]>(() => {
|
|
const current = (props.modelValue.city ?? '').trim()
|
|
const options = [...fetchedCityOptions.value]
|
|
if (current && !options.some(o => o.value === current)) {
|
|
options.unshift({ value: current, label: current })
|
|
}
|
|
return options
|
|
})
|
|
// 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
|
|
// Matching par `label` (adresse complète, unique côté BAN) plutôt que par
|
|
// rue : deux communes peuvent partager le même libellé de voie.
|
|
const suggestion = lastAddressSuggestions.find(s => s.label === option.label)
|
|
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)
|
|
fetchedCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
|
|
degraded.value = false
|
|
}
|
|
catch {
|
|
degraded.value = true
|
|
notifyUnavailable()
|
|
}
|
|
}
|
|
</script>
|