3d4ae391fe
Auto Tag Develop / tag (push) Successful in 7s
Empilée sur ERP-142 (#104). ## Périmètre ERP-143 Onglet **Adresse** de l'écran `/providers/new` — saisie multi-adresses (blocs ajoutables) via la sous-ressource addresses. - **`ProviderAddressBlock.vue`** (miroir `SupplierAddressBlock` **simplifié**) : Sélecteur de sites (≥1, RG-3.05) / Catégories (PRESTATAIRE, RG-3.09) / Contact(s) rattaché(s) (depuis l'onglet Contact) / Pays (défaut France) / Code postal / Ville / Adresse (autocomplete BAN) / Complément. **Pas** de type d'adresse, **pas** de bennes, **pas** de triage (différence M2). - **RG-3.06** : `useAddressAutocomplete()` **réutilisé tel quel** — CP → liste des villes (BAN) ; cas dégradé (API down) → ville/adresse en saisie libre + toast unique. - **`useProviderForm`** étendu : `addresses`, `canAddAddress` (RG-3.05/3.09), `add/removeAddress`, `submitAddresses` (POST `/providers/{id}/addresses` + PATCH `/provider_addresses/{id}`, groupe `provider:write:addresses`), erreurs 422 **par ligne**. - **`useProviderReferentials`** : ajout des pays (`/countries`) pour le select Pays. - Helpers purs `utils/forms/providerAddress.ts` (`isProviderAddressValid`, `buildProviderAddressPayload` — relations en IRI, requis vides omis au POST). - « + Nouvelle adresse » / Supprimer (modal) / « Valider ». i18n `technique.providers.form.address` + `confirmDelete.address`. ## Conformité - `useApi()` only ; `Malio*` only ; aucun texte FR en dur ; `useAddressAutocomplete` non réécrit ; pas d'import inter-module (helpers ré-implémentés côté Technique, règle ABSOLUE n°1). ## Vérifications - Vitest : 436/436 (18 nouveaux : helpers adresse, bloc — BAN dégradé/allow-create/mapping erreurs, workflow adresses POST/PATCH/422 par ligne). - ESLint : OK. - `nuxi typecheck` : 0 erreur sur les fichiers source du ticket. - Golden path navigateur : page compile, onglet Contact OK. NB : l'onglet Adresse est gaté derrière la validation principal+contact (multiselect `Malio` non pilotable en a11y) — couvert par tests unitaires (montage + BAN + mapping) + typecheck. Reviewed-on: #105 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
270 lines
10 KiB
Vue
270 lines
10 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)]">
|
|
<!-- Suppression : modal de confirmation cote parent. -->
|
|
<MalioButtonIcon
|
|
v-if="removable && !readonly"
|
|
icon="mdi:delete-outline"
|
|
variant="ghost"
|
|
button-class="absolute top-3 right-3"
|
|
v-bind="{ ariaLabel: t('technique.providers.form.address.remove') }"
|
|
@click="$emit('remove')"
|
|
/>
|
|
|
|
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-3.05). -->
|
|
<MalioSelectCheckbox
|
|
:model-value="model.siteIris"
|
|
:options="siteOptions"
|
|
:label="t('technique.providers.form.address.sites')"
|
|
:display-tag="true"
|
|
:readonly="readonly"
|
|
:required="true"
|
|
:error="errors?.sites"
|
|
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
|
/>
|
|
|
|
<!-- Categories de type PRESTATAIRE (>= 1 obligatoire, RG-3.09). -->
|
|
<MalioSelectCheckbox
|
|
:model-value="model.categoryIris"
|
|
:options="categoryOptions"
|
|
:label="t('technique.providers.form.address.categories')"
|
|
:display-tag="true"
|
|
:readonly="readonly"
|
|
:required="true"
|
|
:error="errors?.categories"
|
|
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
|
/>
|
|
|
|
<!-- Contacts rattaches (M2M, facultatif) : alimente par l'onglet Contact. -->
|
|
<MalioSelectCheckbox
|
|
:model-value="model.contactIris"
|
|
:options="contactOptions"
|
|
:label="t('technique.providers.form.address.contacts')"
|
|
:display-tag="true"
|
|
:readonly="readonly"
|
|
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
|
/>
|
|
|
|
<MalioSelect
|
|
:model-value="model.country"
|
|
:options="countryOptions"
|
|
:label="t('technique.providers.form.address.country')"
|
|
:readonly="readonly"
|
|
:required="true"
|
|
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
|
/>
|
|
|
|
<MalioInputText
|
|
:model-value="model.postalCode"
|
|
:label="t('technique.providers.form.address.postalCode')"
|
|
:mask="POSTAL_CODE_MASK"
|
|
:readonly="readonly"
|
|
:required="true"
|
|
:error="errors?.postalCode"
|
|
@update:model-value="onPostalCodeChange"
|
|
/>
|
|
|
|
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
|
|
<MalioSelect
|
|
v-if="!degraded"
|
|
:model-value="model.city"
|
|
:options="cityOptions"
|
|
:label="t('technique.providers.form.address.city')"
|
|
:readonly="readonly"
|
|
empty-option-label=""
|
|
:required="true"
|
|
:error="errors?.city"
|
|
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
|
|
/>
|
|
<MalioInputText
|
|
v-else
|
|
:model-value="model.city"
|
|
:label="t('technique.providers.form.address.city')"
|
|
:readonly="readonly"
|
|
:required="true"
|
|
:error="errors?.city"
|
|
@update:model-value="(v: string) => update('city', v)"
|
|
/>
|
|
|
|
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
|
|
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
|
|
<div class="col-span-2">
|
|
<MalioInputAutocomplete
|
|
v-if="!readonly"
|
|
:model-value="model.street"
|
|
:options="addressOptions"
|
|
:loading="addressLoading"
|
|
:min-search-length="3"
|
|
:label="t('technique.providers.form.address.street')"
|
|
:readonly="readonly"
|
|
:required="true"
|
|
:error="errors?.street"
|
|
:allow-create="true"
|
|
:no-results-text="t('technique.providers.form.address.streetNotFound')"
|
|
@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('technique.providers.form.address.street')"
|
|
:readonly="readonly"
|
|
:required="true"
|
|
:error="errors?.street"
|
|
@update:model-value="(v: string) => update('street', v)"
|
|
/>
|
|
</div>
|
|
|
|
<div class="col-span-1">
|
|
<MalioInputText
|
|
:model-value="model.streetComplement"
|
|
:label="t('technique.providers.form.address.streetComplement')"
|
|
:readonly="readonly"
|
|
:error="errors?.streetComplement"
|
|
@update:model-value="(v: string) => update('streetComplement', v)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
|
import type { RefOption } from '~/modules/technique/composables/useProviderReferentials'
|
|
import type { ProviderAddressFormDraft } from '~/modules/technique/types/providerForm'
|
|
|
|
// Masque code postal FR : 5 chiffres.
|
|
const POSTAL_CODE_MASK = '#####'
|
|
|
|
const props = defineProps<{
|
|
/** Brouillon de l'adresse (v-model). */
|
|
modelValue: ProviderAddressFormDraft
|
|
/** Categories autorisees sur une adresse (type PRESTATAIRE). */
|
|
categoryOptions: RefOption[]
|
|
/** 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
|
|
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
|
errors?: Record<string, string>
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
'update:modelValue': [value: ProviderAddressFormDraft]
|
|
'remove': []
|
|
/** Emis une fois quand le service d'autocompletion bascule en indisponible. */
|
|
'degraded': []
|
|
}>()
|
|
|
|
const { t } = useI18n()
|
|
const autocomplete = useAddressAutocomplete()
|
|
|
|
const model = computed(() => props.modelValue)
|
|
|
|
// Repli saisie libre de la VILLE quand la BAN est indisponible (recuperable).
|
|
const degraded = ref(false)
|
|
let unavailableNotified = false
|
|
const banCityOptions = ref<RefOption[]>([])
|
|
const banAddressOptions = ref<RefOption[]>([])
|
|
|
|
// Options ville effectives : on garantit que la ville courante figure toujours
|
|
// dans la liste, sinon MalioSelect afficherait un champ vide en lecture seule.
|
|
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 pour le champ Adresse : la rue courante doit toujours figurer
|
|
// dans les options, sinon MalioInputAutocomplete laisse le champ vide.
|
|
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 ProviderAddressFormDraft>(field: K, value: ProviderAddressFormDraft[K]): void {
|
|
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
|
}
|
|
|
|
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
|
|
function notifyUnavailable(): void {
|
|
if (!unavailableNotified) {
|
|
unavailableNotified = true
|
|
emit('degraded')
|
|
}
|
|
}
|
|
|
|
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville (RG-3.06). */
|
|
async function onPostalCodeChange(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)
|
|
banCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
|
|
degraded.value = false
|
|
}
|
|
catch {
|
|
degraded.value = true
|
|
notifyUnavailable()
|
|
}
|
|
}
|
|
|
|
/** Recherche d'adresse assistee (event de MalioInputAutocomplete). */
|
|
async function onAddressSearch(query: string): Promise<void> {
|
|
// La BAN exige au moins 3 caracteres : on n'envoie rien en deca (evite un 400).
|
|
if (query.trim().length < 3) {
|
|
banAddressOptions.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 {
|
|
// Erreur transitoire : on vide les suggestions, la prochaine frappe reessaie.
|
|
banAddressOptions.value = []
|
|
notifyUnavailable()
|
|
}
|
|
finally {
|
|
addressLoading.value = false
|
|
}
|
|
}
|
|
|
|
/** Selection d'une suggestion d'adresse → remplit rue + ville + CP. */
|
|
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>
|