fix : retours métier ERP-193 (4 répertoires) (#139)
Auto Tag Develop / tag (push) Successful in 11s
Auto Tag Develop / tag (push) Successful in 11s
Lot de retours métier **ERP-193** (« Fix tous les retours starseed »), transverse aux 4 répertoires (clients, fournisseurs, prestataires, transporteurs).
## Contenu
- **Pagination** : défaut à 25 items/page sur les 4 répertoires.
- **Libellé** : colonne « Dernière activité » → « Dernière modification ».
- **Consultation** : masquage des onglets vides (coquilles « à venir » + onglets de données sans donnée).
- **Chiffre d'affaires** : plafonné à 999 999 999 999,99 (clamp front + `Assert\LessThanOrEqual` back).
- **Date de création** : interdiction des dates futures (`:max` MalioDate + `Assert\LessThanOrEqual('today')` back).
- **Caractères spéciaux** : blocage des caractères parasites (`²³§~#|…`) dans les champs texte via une allow-list par profil (nom de personne / texte libre / adresse / code alphanumérique) — filtrage front à la frappe + `Assert\Regex` back autoritaire. Email/IBAN/BIC/TVA conservent leurs validateurs de format.
- **UI** : champs en consultation et onglets validés grisés (`readonly` → `disabled`).
- **UI** : boutons « Archiver » en rouge (variant `danger`).
## Tests
- Back : nouveaux tests RG (plafond CA, dates futures, caractères spéciaux) + garde-fou contraintes — suite complète verte (813 tests).
- Front : nouveaux tests unitaires (sanitizers, helpers date/montant) — 615 tests verts, eslint clean.
---------
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Reviewed-on: #139
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #139.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
<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"
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
@@ -21,7 +21,8 @@
|
||||
:options="addressTypeOptions"
|
||||
:label="t('commercial.clients.form.address.addressType')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.isProspect"
|
||||
@update:model-value="onAddressTypeChange"
|
||||
/>
|
||||
@@ -33,17 +34,21 @@
|
||||
:label="t('commercial.clients.form.address.sites')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.sites"
|
||||
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Contacts rattaches (M2M, facultatif). Consultation : masque si aucun (ERP-193). -->
|
||||
<MalioSelectCheckbox
|
||||
v-if="!hideEmpty || isFilled(model.contactIris)"
|
||||
:model-value="model.contactIris"
|
||||
:options="contactOptions"
|
||||
:label="t('commercial.clients.form.address.contacts')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
||||
/>
|
||||
|
||||
@@ -55,8 +60,9 @@
|
||||
v-if="isBillingEmailRequired(model)"
|
||||
:model-value="model.billingEmail"
|
||||
:label="t('commercial.clients.form.address.billingEmail')"
|
||||
:required="true"
|
||||
:required="!readonly && !disabled"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:lowercase="true"
|
||||
:error="errors?.billingEmail"
|
||||
:addable="!model.hasSecondaryBillingEmail && !readonly"
|
||||
@@ -64,13 +70,17 @@
|
||||
@update:model-value="(v: string) => update('billingEmail', v)"
|
||||
@add="revealSecondaryBillingEmail"
|
||||
/>
|
||||
<div v-else aria-hidden="true" />
|
||||
<!-- Filler : aligne la suite de la grille (Categorie au debut de ligne).
|
||||
Inutile en consultation masquee (la grille se recompose sans les
|
||||
champs vides, ERP-193). -->
|
||||
<div v-else-if="!hideEmpty" aria-hidden="true" />
|
||||
|
||||
<MalioInputEmail
|
||||
v-if="isBillingEmailRequired(model) && model.hasSecondaryBillingEmail"
|
||||
:model-value="model.billingEmailSecondary"
|
||||
:label="t('commercial.clients.form.address.billingEmailSecondary')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:lowercase="true"
|
||||
:error="errors?.billingEmailSecondary"
|
||||
@update:model-value="(v: string) => update('billingEmailSecondary', v)"
|
||||
@@ -82,7 +92,8 @@
|
||||
:label="t('commercial.clients.form.address.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.categories"
|
||||
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
||||
/>
|
||||
@@ -92,7 +103,8 @@
|
||||
:options="countryOptions"
|
||||
:label="t('commercial.clients.form.address.country')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||
/>
|
||||
|
||||
@@ -101,7 +113,8 @@
|
||||
:label="t('commercial.clients.form.address.postalCode')"
|
||||
:mask="POSTAL_CODE_MASK"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.postalCode"
|
||||
@update:model-value="onPostalCodeChange"
|
||||
/>
|
||||
@@ -115,17 +128,20 @@
|
||||
:options="cityOptions"
|
||||
:label="t('commercial.clients.form.address.city')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
|
||||
@update:model-value="onCityChange"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:model-value="model.city"
|
||||
:label="t('commercial.clients.form.address.city')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string) => update('city', v)"
|
||||
/>
|
||||
@@ -142,14 +158,15 @@
|
||||
blur/Entree (saisie manuelle) — sinon il serait efface. La ville reste
|
||||
pilotee par le code postal ; choisir une suggestion remplit rue+ville+CP. -->
|
||||
<MalioInputAutocomplete
|
||||
v-if="!readonly"
|
||||
v-if="!readonly && !disabled"
|
||||
:model-value="model.street"
|
||||
:options="addressOptions"
|
||||
:loading="addressLoading"
|
||||
:min-search-length="3"
|
||||
:label="t('commercial.clients.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
:allow-create="true"
|
||||
:no-results-text="t('commercial.clients.form.address.streetNotFound')"
|
||||
@@ -162,17 +179,20 @@
|
||||
:model-value="model.street"
|
||||
:label="t('commercial.clients.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
@update:model-value="(v: string) => update('street', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-1">
|
||||
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1">
|
||||
<MalioInputText
|
||||
:model-value="model.streetComplement"
|
||||
:label="t('commercial.clients.form.address.streetComplement')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.streetComplement"
|
||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||
/>
|
||||
@@ -191,6 +211,8 @@ import {
|
||||
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'
|
||||
import { ADDRESS_MASK } from '~/shared/utils/textSanitize'
|
||||
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||
|
||||
// Masque code postal FR : 5 chiffres.
|
||||
const POSTAL_CODE_MASK = '#####'
|
||||
@@ -209,6 +231,10 @@ const props = defineProps<{
|
||||
countryOptions: RefOption[]
|
||||
removable?: boolean
|
||||
readonly?: boolean
|
||||
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||
disabled?: boolean
|
||||
/** Consultation : masque les champs non remplis (ERP-193). */
|
||||
hideEmpty?: boolean
|
||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||
errors?: Record<string, string>
|
||||
}>()
|
||||
@@ -284,11 +310,37 @@ const addressLoading = ref(false)
|
||||
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
|
||||
let lastAddressSuggestions: AddressSuggestion[] = []
|
||||
|
||||
// Filtrage des caracteres parasites : porte par le mask ADDRESS_MASK (maska) sur
|
||||
// les champs texte editables (complement, ville en mode degrade). La voie en
|
||||
// autocomplete (BAN) et la ville en select ne sont pas masquees (le back valide
|
||||
// via Assert\Regex) ; les emails de facturation valident leur format (Assert\Email).
|
||||
|
||||
/** 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 })
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection d'une ville (select assiste BAN) → vide adresse + complement, devenus
|
||||
* incoherents avec la nouvelle ville. Ne reagit qu'a un vrai changement de valeur.
|
||||
* En mode degrade (saisie libre), la ville reste un simple `update` (pas de reset
|
||||
* a chaque frappe).
|
||||
*/
|
||||
function onCityChange(value: string | number | null): void {
|
||||
const next = value === null ? null : String(value)
|
||||
if (next === (props.modelValue.city ?? null)) {
|
||||
return
|
||||
}
|
||||
banAddressOptions.value = []
|
||||
lastAddressSuggestions = []
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
city: next,
|
||||
street: null,
|
||||
streetComplement: null,
|
||||
})
|
||||
}
|
||||
|
||||
/** Revele le 2e champ email de facturation (clic sur le « + »). */
|
||||
function revealSecondaryBillingEmail(): void {
|
||||
emit('update:modelValue', { ...props.modelValue, hasSecondaryBillingEmail: true })
|
||||
@@ -304,9 +356,27 @@ function notifyUnavailable(): void {
|
||||
|
||||
/** 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)
|
||||
|
||||
const digits = (value ?? '').replace(/\D/g, '')
|
||||
const previousDigits = (props.modelValue.postalCode ?? '').replace(/\D/g, '')
|
||||
|
||||
// CP complet (5 chiffres) et reellement modifie → ville, adresse et complement
|
||||
// deviennent incoherents avec le nouveau code postal : on les vide pour forcer
|
||||
// une re-saisie coherente (on n'efface pas pendant une correction partielle).
|
||||
if (digits.length === 5 && digits !== previousDigits) {
|
||||
banAddressOptions.value = []
|
||||
lastAddressSuggestions = []
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
postalCode: value,
|
||||
city: null,
|
||||
street: null,
|
||||
streetComplement: null,
|
||||
})
|
||||
}
|
||||
else {
|
||||
update('postalCode', value)
|
||||
}
|
||||
|
||||
if (digits.length < 5) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
non supprimable (1er bloc obligatoire RG-1.14) ou en lecture seule.
|
||||
ariaLabel via v-bind objet (prop camelCase ; aria-* serait un attribut HTML). -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly"
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
@@ -13,44 +13,56 @@
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.lastName)"
|
||||
:model-value="model.lastName"
|
||||
:label="t('commercial.clients.form.contact.lastName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.lastName"
|
||||
@update:model-value="(v: string) => update('lastName', v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.firstName)"
|
||||
:model-value="model.firstName"
|
||||
:label="t('commercial.clients.form.contact.firstName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.firstName"
|
||||
@update:model-value="(v: string) => update('firstName', v)"
|
||||
/>
|
||||
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
|
||||
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
|
||||
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
||||
<div class="col-span-2">
|
||||
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
|
||||
<MalioInputText
|
||||
:model-value="model.jobTitle"
|
||||
:label="t('commercial.clients.form.contact.jobTitle')"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.jobTitle"
|
||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputEmail
|
||||
v-if="!hideEmpty || isFilled(model.email)"
|
||||
:model-value="model.email"
|
||||
:label="t('commercial.clients.form.contact.email')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:lowercase="true"
|
||||
:error="errors?.email"
|
||||
@update:model-value="(v: string) => update('email', v)"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
v-if="!hideEmpty || isFilled(model.phonePrimary)"
|
||||
:model-value="model.phonePrimary"
|
||||
:label="t('commercial.clients.form.contact.phonePrimary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phonePrimary"
|
||||
:addable="!model.hasSecondaryPhone && !readonly"
|
||||
:add-button-label="t('commercial.clients.form.contact.addPhone')"
|
||||
@@ -58,11 +70,12 @@
|
||||
@add="revealSecondaryPhone"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
v-if="model.hasSecondaryPhone"
|
||||
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
|
||||
:model-value="model.phoneSecondary"
|
||||
:label="t('commercial.clients.form.contact.phoneSecondary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phoneSecondary"
|
||||
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
||||
/>
|
||||
@@ -71,6 +84,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ContactFormDraft } from '~/modules/commercial/types/clientForm'
|
||||
import { FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
|
||||
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||
|
||||
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste
|
||||
// serveur, cf. formatPhoneFR re-applique a la valeur renvoyee).
|
||||
@@ -85,6 +100,10 @@ const props = defineProps<{
|
||||
removable?: boolean
|
||||
/** Bloc en lecture seule (onglet valide). */
|
||||
readonly?: boolean
|
||||
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||
disabled?: boolean
|
||||
/** Consultation : masque les champs non remplis (ERP-193). */
|
||||
hideEmpty?: boolean
|
||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||
errors?: Record<string, string>
|
||||
}>()
|
||||
@@ -99,6 +118,10 @@ const { t } = useI18n()
|
||||
// Alias local pour la lisibilite du template.
|
||||
const model = computed(() => props.modelValue)
|
||||
|
||||
// Filtrage des caracteres parasites : porte par les masks maska sur les champs
|
||||
// (PERSON_NAME_MASK / FREE_TEXT_MASK), filtrage natif au focus/curseur. L'email n'a
|
||||
// pas de mask (ERP-101 : validation de format via Assert\Email + erreur inline).
|
||||
|
||||
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
||||
function update<K extends keyof ContactFormDraft>(field: K, value: ContactFormDraft[K]): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<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"
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
@@ -18,8 +18,9 @@
|
||||
:options="addressTypeOptions"
|
||||
:label="t('commercial.suppliers.form.address.addressType')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.addressType"
|
||||
@update:model-value="(v: string | number | null) => update('addressType', v === null ? null : (v as SupplierAddressType))"
|
||||
/>
|
||||
@@ -31,24 +32,28 @@
|
||||
:label="t('commercial.suppliers.form.address.sites')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.sites"
|
||||
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Contacts rattaches (M2M, facultatif). -->
|
||||
<MalioSelectCheckbox
|
||||
v-if="!hideEmpty || isFilled(model.contactIris)"
|
||||
:model-value="model.contactIris"
|
||||
:options="contactOptions"
|
||||
:label="t('commercial.suppliers.form.address.contacts')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Filler : aligne le debut de ligne suivant sur la grille (le bloc client
|
||||
porte ici l'email de facturation, absent cote fournisseur). -->
|
||||
<div aria-hidden="true" />
|
||||
porte ici l'email de facturation, absent cote fournisseur). Inutile en
|
||||
consultation masquee (la grille se recompose sans les champs vides). -->
|
||||
<div v-if="!hideEmpty" aria-hidden="true" />
|
||||
|
||||
<!-- Categories de type FOURNISSEUR (>= 1 obligatoire, RG-2.10). -->
|
||||
<MalioSelectCheckbox
|
||||
@@ -57,7 +62,8 @@
|
||||
:label="t('commercial.suppliers.form.address.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.categories"
|
||||
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
||||
/>
|
||||
@@ -67,7 +73,8 @@
|
||||
:options="countryOptions"
|
||||
:label="t('commercial.suppliers.form.address.country')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||
/>
|
||||
|
||||
@@ -76,7 +83,8 @@
|
||||
:label="t('commercial.suppliers.form.address.postalCode')"
|
||||
:mask="POSTAL_CODE_MASK"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.postalCode"
|
||||
@update:model-value="onPostalCodeChange"
|
||||
/>
|
||||
@@ -88,17 +96,20 @@
|
||||
:options="cityOptions"
|
||||
:label="t('commercial.suppliers.form.address.city')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
|
||||
@update:model-value="onCityChange"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:model-value="model.city"
|
||||
:label="t('commercial.suppliers.form.address.city')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string) => update('city', v)"
|
||||
/>
|
||||
@@ -107,14 +118,15 @@
|
||||
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
|
||||
<div class="col-span-2">
|
||||
<MalioInputAutocomplete
|
||||
v-if="!readonly"
|
||||
v-if="!readonly && !disabled"
|
||||
:model-value="model.street"
|
||||
:options="addressOptions"
|
||||
:loading="addressLoading"
|
||||
:min-search-length="3"
|
||||
:label="t('commercial.suppliers.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
:allow-create="true"
|
||||
:no-results-text="t('commercial.suppliers.form.address.streetNotFound')"
|
||||
@@ -126,40 +138,50 @@
|
||||
v-else
|
||||
:model-value="model.street"
|
||||
:label="t('commercial.suppliers.form.address.street')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:disabled="disabled"
|
||||
:required="!readonly && !disabled"
|
||||
:error="errors?.street"
|
||||
@update:model-value="(v: string) => update('street', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-1">
|
||||
<div v-if="!hideEmpty || isFilled(model.streetComplement)" class="col-span-1">
|
||||
<MalioInputText
|
||||
:model-value="model.streetComplement"
|
||||
:label="t('commercial.suppliers.form.address.streetComplement')"
|
||||
:mask="ADDRESS_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.streetComplement"
|
||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Bennes : stepper (specifique fournisseur, defaut 0). -->
|
||||
<!-- Bennes : stepper (specifique fournisseur, defaut 0). En consultation, 0
|
||||
reste affiche (valeur saisie) ; seul un champ vide serait masque. -->
|
||||
<MalioInputNumber
|
||||
v-if="!hideEmpty || isFilled(model.bennes)"
|
||||
:model-value="model.bennes"
|
||||
:label="t('commercial.suppliers.form.address.bennes')"
|
||||
:min="0"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.bennes"
|
||||
@update:model-value="(v: string) => update('bennes', v)"
|
||||
/>
|
||||
|
||||
<!-- Prestation de triage : booleen porte par l'adresse (specifique fournisseur). -->
|
||||
<!-- Prestation de triage : booleen porte par l'adresse (specifique fournisseur).
|
||||
Consultation : masquee si non cochee (ERP-193). -->
|
||||
<MalioCheckbox
|
||||
v-if="!hideEmpty || isFilled(model.triageProvider)"
|
||||
id="address-triage-provider"
|
||||
:label="t('commercial.suppliers.form.address.triageProvider')"
|
||||
:model-value="model.triageProvider"
|
||||
group-class="self-center"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
@update:model-value="(v: boolean) => update('triageProvider', v)"
|
||||
/>
|
||||
</div>
|
||||
@@ -169,6 +191,8 @@
|
||||
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
||||
import type { CategoryOption, RefOption } from '~/modules/commercial/composables/useSupplierReferentials'
|
||||
import type { SupplierAddressFormDraft, SupplierAddressType } from '~/modules/commercial/types/supplierForm'
|
||||
import { ADDRESS_MASK } from '~/shared/utils/textSanitize'
|
||||
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||
|
||||
// Masque code postal FR : 5 chiffres.
|
||||
const POSTAL_CODE_MASK = '#####'
|
||||
@@ -187,6 +211,10 @@ const props = defineProps<{
|
||||
countryOptions: RefOption[]
|
||||
removable?: boolean
|
||||
readonly?: boolean
|
||||
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||
disabled?: boolean
|
||||
/** Consultation : masque les champs non remplis (ERP-193). */
|
||||
hideEmpty?: boolean
|
||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||
errors?: Record<string, string>
|
||||
}>()
|
||||
@@ -238,11 +266,37 @@ const addressLoading = ref(false)
|
||||
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
|
||||
let lastAddressSuggestions: AddressSuggestion[] = []
|
||||
|
||||
// Filtrage des caracteres parasites : porte par le mask ADDRESS_MASK (maska) sur
|
||||
// les champs texte editables (complement, ville en mode degrade, voie en repli). La
|
||||
// voie en autocomplete (BAN) et la ville en select ne sont pas masquees (le back
|
||||
// valide via Assert\Regex).
|
||||
|
||||
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
||||
function update<K extends keyof SupplierAddressFormDraft>(field: K, value: SupplierAddressFormDraft[K]): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection d'une ville (select assiste BAN) → vide adresse + complement, devenus
|
||||
* incoherents avec la nouvelle ville. Ne reagit qu'a un vrai changement de valeur.
|
||||
* En mode degrade (saisie libre), la ville reste un simple `update` (pas de reset
|
||||
* a chaque frappe).
|
||||
*/
|
||||
function onCityChange(value: string | number | null): void {
|
||||
const next = value === null ? null : String(value)
|
||||
if (next === (props.modelValue.city ?? null)) {
|
||||
return
|
||||
}
|
||||
banAddressOptions.value = []
|
||||
lastAddressSuggestions = []
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
city: next,
|
||||
street: null,
|
||||
streetComplement: null,
|
||||
})
|
||||
}
|
||||
|
||||
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
|
||||
function notifyUnavailable(): void {
|
||||
if (!unavailableNotified) {
|
||||
@@ -253,9 +307,27 @@ function notifyUnavailable(): void {
|
||||
|
||||
/** 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)
|
||||
|
||||
const digits = (value ?? '').replace(/\D/g, '')
|
||||
const previousDigits = (props.modelValue.postalCode ?? '').replace(/\D/g, '')
|
||||
|
||||
// CP complet (5 chiffres) et reellement modifie → ville, adresse et complement
|
||||
// deviennent incoherents avec le nouveau code postal : on les vide pour forcer
|
||||
// une re-saisie coherente (on n'efface pas pendant une correction partielle).
|
||||
if (digits.length === 5 && digits !== previousDigits) {
|
||||
banAddressOptions.value = []
|
||||
lastAddressSuggestions = []
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
postalCode: value,
|
||||
city: null,
|
||||
street: null,
|
||||
streetComplement: null,
|
||||
})
|
||||
}
|
||||
else {
|
||||
update('postalCode', value)
|
||||
}
|
||||
|
||||
if (digits.length < 5) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- Suppression : ouvre une modal de confirmation cote parent. Masquee si
|
||||
non supprimable (1er bloc, RG-2.13) ou en lecture seule. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly"
|
||||
v-if="removable && !readonly && !disabled"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
@@ -12,44 +12,56 @@
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.lastName)"
|
||||
:model-value="model.lastName"
|
||||
:label="t('commercial.suppliers.form.contact.lastName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.lastName"
|
||||
@update:model-value="(v: string) => update('lastName', v)"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-if="!hideEmpty || isFilled(model.firstName)"
|
||||
:model-value="model.firstName"
|
||||
:label="t('commercial.suppliers.form.contact.firstName')"
|
||||
:mask="PERSON_NAME_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.firstName"
|
||||
@update:model-value="(v: string) => update('firstName', v)"
|
||||
/>
|
||||
<!-- Fonction sur 2 colonnes : on wrappe car MalioInputText
|
||||
(inheritAttrs:false) renvoie `class` sur l'input interne, pas sur la
|
||||
cellule de grille. Le wrapper porte le col-span-2, le champ le remplit. -->
|
||||
<div class="col-span-2">
|
||||
<div v-if="!hideEmpty || isFilled(model.jobTitle)" class="col-span-2">
|
||||
<MalioInputText
|
||||
:model-value="model.jobTitle"
|
||||
:label="t('commercial.suppliers.form.contact.jobTitle')"
|
||||
:mask="FREE_TEXT_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.jobTitle"
|
||||
@update:model-value="(v: string) => update('jobTitle', v)"
|
||||
/>
|
||||
</div>
|
||||
<MalioInputEmail
|
||||
v-if="!hideEmpty || isFilled(model.email)"
|
||||
:model-value="model.email"
|
||||
:label="t('commercial.suppliers.form.contact.email')"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:lowercase="true"
|
||||
:error="errors?.email"
|
||||
@update:model-value="(v: string) => update('email', v)"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
v-if="!hideEmpty || isFilled(model.phonePrimary)"
|
||||
:model-value="model.phonePrimary"
|
||||
:label="t('commercial.suppliers.form.contact.phonePrimary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phonePrimary"
|
||||
:addable="!model.hasSecondaryPhone && !readonly"
|
||||
:add-button-label="t('commercial.suppliers.form.contact.addPhone')"
|
||||
@@ -57,11 +69,12 @@
|
||||
@add="revealSecondaryPhone"
|
||||
/>
|
||||
<MalioInputPhone
|
||||
v-if="model.hasSecondaryPhone"
|
||||
v-if="model.hasSecondaryPhone && (!hideEmpty || isFilled(model.phoneSecondary))"
|
||||
:model-value="model.phoneSecondary"
|
||||
:label="t('commercial.suppliers.form.contact.phoneSecondary')"
|
||||
:mask="PHONE_MASK"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:error="errors?.phoneSecondary"
|
||||
@update:model-value="(v: string) => update('phoneSecondary', v)"
|
||||
/>
|
||||
@@ -70,6 +83,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SupplierContactFormDraft } from '~/modules/commercial/types/supplierForm'
|
||||
import { FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
|
||||
import { isFilled } from '~/shared/utils/consultationDisplay'
|
||||
|
||||
// Masque telephone FR : 5 groupes de 2 chiffres (la normalisation finale reste serveur).
|
||||
const PHONE_MASK = '## ## ## ## ##'
|
||||
@@ -83,6 +98,10 @@ const props = defineProps<{
|
||||
removable?: boolean
|
||||
/** Bloc en lecture seule (onglet valide). */
|
||||
readonly?: boolean
|
||||
/** Bloc desactive (champs grises, consultation — distinct de readonly). */
|
||||
disabled?: boolean
|
||||
/** Consultation : masque les champs non remplis (ERP-193). */
|
||||
hideEmpty?: boolean
|
||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||
errors?: Record<string, string>
|
||||
}>()
|
||||
@@ -97,6 +116,10 @@ const { t } = useI18n()
|
||||
// Alias local pour la lisibilite du template.
|
||||
const model = computed(() => props.modelValue)
|
||||
|
||||
// Filtrage des caracteres parasites : porte par les masks maska sur les champs
|
||||
// (PERSON_NAME_MASK / FREE_TEXT_MASK), filtrage natif au focus/curseur. L'email n'a
|
||||
// pas de mask (ERP-101 : validation de format via Assert\Email + erreur inline).
|
||||
|
||||
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
||||
function update<K extends keyof SupplierContactFormDraft>(field: K, value: SupplierContactFormDraft[K]): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||
|
||||
@@ -171,6 +171,182 @@ describe('ClientAddressBlock — mapping erreur par champ (ERP-101)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Stub MalioInputText emetteur : re-expose `label` et relaie `update:model-value`,
|
||||
* pour piloter le champ Code postal et observer le brouillon emis.
|
||||
*/
|
||||
const MalioInputTextEmitter = defineComponent({
|
||||
name: 'MalioInputTextEmitter',
|
||||
props: {
|
||||
modelValue: { type: [String, Number, null], default: undefined },
|
||||
label: { type: String, default: '' },
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props) {
|
||||
return () => h('div', { 'data-testid': 'addr-input', 'data-label': props.label })
|
||||
},
|
||||
})
|
||||
|
||||
describe('ClientAddressBlock — changement de code postal vide les champs dependants (ERP-193)', () => {
|
||||
beforeEach(() => {
|
||||
searchCityMock.mockReset()
|
||||
searchCityMock.mockResolvedValue([])
|
||||
})
|
||||
|
||||
function mountFilled() {
|
||||
return mount(ClientAddressBlock, {
|
||||
props: {
|
||||
modelValue: {
|
||||
...emptyAddress(),
|
||||
postalCode: '75001',
|
||||
city: 'Paris',
|
||||
street: '8 Boulevard du Port',
|
||||
streetComplement: 'Bat A',
|
||||
},
|
||||
title: 'Adresse',
|
||||
categoryOptions: [],
|
||||
siteOptions: [],
|
||||
contactOptions: [],
|
||||
countryOptions: [],
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
MalioButtonIcon: true,
|
||||
MalioCheckbox: true,
|
||||
MalioSelect: true,
|
||||
MalioSelectCheckbox: true,
|
||||
MalioInputAutocomplete: MalioInputAutocompleteStub,
|
||||
MalioInputText: MalioInputTextEmitter,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function postalCodeField(wrapper: ReturnType<typeof mountFilled>) {
|
||||
return wrapper.findAllComponents(MalioInputTextEmitter).find(
|
||||
c => c.props('label') === 'commercial.clients.form.address.postalCode',
|
||||
)
|
||||
}
|
||||
|
||||
it('vide ville, adresse et complement quand le CP complet change', async () => {
|
||||
const wrapper = mountFilled()
|
||||
|
||||
postalCodeField(wrapper)!.vm.$emit('update:modelValue', '33000')
|
||||
await flushPromises()
|
||||
|
||||
const last = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Record<string, unknown>
|
||||
expect(last.postalCode).toBe('33000')
|
||||
expect(last.city).toBeNull()
|
||||
expect(last.street).toBeNull()
|
||||
expect(last.streetComplement).toBeNull()
|
||||
})
|
||||
|
||||
it('ne vide pas les champs si le CP reste incomplet (< 5 chiffres)', async () => {
|
||||
const wrapper = mountFilled()
|
||||
|
||||
postalCodeField(wrapper)!.vm.$emit('update:modelValue', '7500')
|
||||
await flushPromises()
|
||||
|
||||
const last = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Record<string, unknown>
|
||||
expect(last.postalCode).toBe('7500')
|
||||
expect(last.city).toBe('Paris')
|
||||
expect(last.street).toBe('8 Boulevard du Port')
|
||||
expect(last.streetComplement).toBe('Bat A')
|
||||
})
|
||||
|
||||
it('ne vide pas les champs si le CP complet est identique', async () => {
|
||||
const wrapper = mountFilled()
|
||||
|
||||
postalCodeField(wrapper)!.vm.$emit('update:modelValue', '75001')
|
||||
await flushPromises()
|
||||
|
||||
const last = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Record<string, unknown>
|
||||
expect(last.city).toBe('Paris')
|
||||
expect(last.street).toBe('8 Boulevard du Port')
|
||||
expect(last.streetComplement).toBe('Bat A')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Stub MalioSelect emetteur : re-expose `label` et relaie `update:model-value`,
|
||||
* pour piloter le select Ville et observer le brouillon emis.
|
||||
*/
|
||||
const MalioSelectEmitter = defineComponent({
|
||||
name: 'MalioSelectEmitter',
|
||||
props: {
|
||||
modelValue: { type: [String, Number, null], default: undefined },
|
||||
label: { type: String, default: '' },
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup(props) {
|
||||
return () => h('div', { 'data-testid': 'addr-select', 'data-label': props.label })
|
||||
},
|
||||
})
|
||||
|
||||
describe('ClientAddressBlock — changement de ville vide adresse + complement (ERP-193)', () => {
|
||||
beforeEach(() => {
|
||||
searchCityMock.mockReset()
|
||||
searchCityMock.mockResolvedValue([])
|
||||
})
|
||||
|
||||
function mountFilled() {
|
||||
return mount(ClientAddressBlock, {
|
||||
props: {
|
||||
modelValue: {
|
||||
...emptyAddress(),
|
||||
postalCode: '75001',
|
||||
city: 'Paris',
|
||||
street: '8 Boulevard du Port',
|
||||
streetComplement: 'Bat A',
|
||||
},
|
||||
title: 'Adresse',
|
||||
categoryOptions: [],
|
||||
siteOptions: [],
|
||||
contactOptions: [],
|
||||
countryOptions: [],
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
MalioButtonIcon: true,
|
||||
MalioCheckbox: true,
|
||||
MalioSelectCheckbox: true,
|
||||
MalioInputText: true,
|
||||
MalioInputAutocomplete: MalioInputAutocompleteStub,
|
||||
MalioSelect: MalioSelectEmitter,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function cityField(wrapper: ReturnType<typeof mountFilled>) {
|
||||
return wrapper.findAllComponents(MalioSelectEmitter).find(
|
||||
c => c.props('label') === 'commercial.clients.form.address.city',
|
||||
)
|
||||
}
|
||||
|
||||
it('vide adresse et complement quand la ville change', async () => {
|
||||
const wrapper = mountFilled()
|
||||
|
||||
cityField(wrapper)!.vm.$emit('update:modelValue', 'Lyon')
|
||||
await flushPromises()
|
||||
|
||||
const last = wrapper.emitted('update:modelValue')?.at(-1)?.[0] as Record<string, unknown>
|
||||
expect(last.city).toBe('Lyon')
|
||||
expect(last.street).toBeNull()
|
||||
expect(last.streetComplement).toBeNull()
|
||||
})
|
||||
|
||||
it('ne vide pas si la ville selectionnee est identique', async () => {
|
||||
const wrapper = mountFilled()
|
||||
|
||||
cityField(wrapper)!.vm.$emit('update:modelValue', 'Paris')
|
||||
await flushPromises()
|
||||
|
||||
// Aucun nouvel emit (valeur inchangee) → l'adresse reste intacte.
|
||||
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ClientAddressBlock — recherche adresse robuste (erreur BAN)', () => {
|
||||
beforeEach(() => {
|
||||
searchAddressMock.mockReset()
|
||||
|
||||
Reference in New Issue
Block a user