fix : retours métier ERP-193 (4 répertoires) (#139)
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:
2026-06-22 09:40:40 +00:00
committed by Autin
parent 6c938756cc
commit 5e15c1f69f
93 changed files with 2791 additions and 803 deletions
@@ -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()
@@ -25,8 +25,8 @@ function makeHydra(total: number): HydraCollection<Client> {
describe('useClientsRepository', () => {
beforeEach(() => {
mockGet.mockReset()
// 25 items → 3 pages a 10/page : permet de tester la navigation page 2.
mockGet.mockResolvedValue(makeHydra(25))
// 60 items → 3 pages a 25/page : permet de tester la navigation page 2.
mockGet.mockResolvedValue(makeHydra(60))
})
it('cible la ressource /clients en page 1 par defaut', async () => {
@@ -35,7 +35,7 @@ describe('useClientsRepository', () => {
expect(mockGet).toHaveBeenLastCalledWith(
'/clients',
{ page: 1, itemsPerPage: 10 },
{ page: 1, itemsPerPage: 25 },
expect.objectContaining({ toast: false }),
)
})
@@ -65,7 +65,7 @@ describe('useClientsRepository', () => {
'siteId[]': ['1', '2'],
archivedOnly: true,
page: 1,
itemsPerPage: 10,
itemsPerPage: 25,
},
expect.objectContaining({ toast: false }),
)
@@ -78,7 +78,7 @@ describe('useClientsRepository', () => {
expect(mockGet).toHaveBeenLastCalledWith(
'/clients',
{ page: 1, itemsPerPage: 10 },
{ page: 1, itemsPerPage: 25 },
expect.objectContaining({ toast: false }),
)
})
@@ -25,8 +25,8 @@ function makeHydra(total: number): HydraCollection<Supplier> {
describe('useSuppliersRepository', () => {
beforeEach(() => {
mockGet.mockReset()
// 25 items → 3 pages a 10/page : permet de tester la navigation page 2.
mockGet.mockResolvedValue(makeHydra(25))
// 60 items → 3 pages a 25/page : permet de tester la navigation page 2.
mockGet.mockResolvedValue(makeHydra(60))
})
it('cible la ressource /suppliers en page 1 par defaut', async () => {
@@ -35,7 +35,7 @@ describe('useSuppliersRepository', () => {
expect(mockGet).toHaveBeenLastCalledWith(
'/suppliers',
{ page: 1, itemsPerPage: 10 },
{ page: 1, itemsPerPage: 25 },
expect.objectContaining({ toast: false }),
)
})
@@ -65,7 +65,7 @@ describe('useSuppliersRepository', () => {
'siteId[]': ['86', '17'],
archivedOnly: true,
page: 1,
itemsPerPage: 10,
itemsPerPage: 25,
},
expect.objectContaining({ toast: false }),
)
@@ -78,7 +78,7 @@ describe('useSuppliersRepository', () => {
expect(mockGet).toHaveBeenLastCalledWith(
'/suppliers',
{ page: 1, itemsPerPage: 10 },
{ page: 1, itemsPerPage: 25 },
expect.objectContaining({ toast: false }),
)
})
@@ -49,5 +49,6 @@ export interface Client {
* gerer.
*/
export function useClientsRepository() {
return usePaginatedList<Client>({ url: '/clients' })
// Pagination par defaut a 25 sur le repertoire (retour metier ERP-193).
return usePaginatedList<Client>({ url: '/clients', defaultItemsPerPage: 25 })
}
@@ -51,5 +51,6 @@ export interface Supplier {
* `usePaginatedList`. Aucun reset au logout a gerer.
*/
export function useSuppliersRepository() {
return usePaginatedList<Supplier>({ url: '/suppliers' })
// Pagination par defaut a 25 sur le repertoire (retour metier ERP-193).
return usePaginatedList<Supplier>({ url: '/suppliers', defaultItemsPerPage: 25 })
}
@@ -6,6 +6,7 @@
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
:title="t('commercial.clients.edit.back')"
v-bind="{ ariaLabel: t('commercial.clients.edit.back') }"
@click="goBack"
/>
@@ -25,9 +26,10 @@
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="main.companyName"
:mask="FREE_TEXT_MASK"
:label="t('commercial.clients.form.main.companyName')"
:required="true"
:readonly="businessReadonly"
:disabled="businessReadonly"
:error="mainErrors.errors.companyName"
/>
<MalioSelectCheckbox
@@ -35,7 +37,7 @@
:options="mainCategoryOptions"
:label="t('commercial.clients.form.main.categories')"
:display-tag="true"
:readonly="businessReadonly"
:disabled="businessReadonly"
:required="true"
:error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
@@ -46,7 +48,7 @@
:options="relationOptions"
:label="t('commercial.clients.form.main.relation')"
:empty-option-label="t('commercial.clients.form.main.relationNone')"
:readonly="businessReadonly"
:disabled="businessReadonly"
@update:model-value="onRelationChange"
/>
<MalioSelect
@@ -54,7 +56,7 @@
:model-value="main.brokerIri"
:options="brokerOptions"
:label="t('commercial.clients.form.main.brokerName')"
:readonly="businessReadonly"
:disabled="businessReadonly"
:required="true"
:error="mainErrors.errors.broker"
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
@@ -64,7 +66,7 @@
:model-value="main.distributorIri"
:options="distributorOptions"
:label="t('commercial.clients.form.main.distributorName')"
:readonly="businessReadonly"
:disabled="businessReadonly"
:required="true"
:error="mainErrors.errors.distributor"
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
@@ -74,7 +76,7 @@
v-model="main.triageService"
:label="t('commercial.clients.form.main.triageService')"
group-class="self-center"
:readonly="businessReadonly"
:disabled="businessReadonly"
/>
</div>
@@ -101,20 +103,24 @@
resize="none"
group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg"
:readonly="businessReadonly"
:disabled="businessReadonly"
:error="informationErrors.errors.description"
/>
<MalioInputText
v-model="information.competitors"
:mask="FREE_TEXT_MASK"
:label="t('commercial.clients.form.information.competitors')"
:readonly="businessReadonly"
:disabled="businessReadonly"
:error="informationErrors.errors.competitors"
/>
<!-- Date de creation jamais dans le futur (ERP-193) : :max plafonne
le calendrier a aujourd'hui et invalide une saisie future. -->
<MalioDate
v-model="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')"
:readonly="businessReadonly"
:disabled="businessReadonly"
:editable="true"
:max="maxFoundedAt"
:error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v"
/>
@@ -122,25 +128,30 @@
v-model="information.employeesCount"
:label="t('commercial.clients.form.information.employeesCount')"
:mask="EMPLOYEES_MASK"
:readonly="businessReadonly"
:disabled="businessReadonly"
:error="informationErrors.errors.employeesCount"
/>
<!-- CA plafonne a 999 999 999 999,99 (ERP-193) : clamp a la saisie,
:key force le re-affichage quand on plafonne (modelValue inchange). -->
<MalioInputAmount
v-model="information.revenueAmount"
:key="revenueAmountKey"
:model-value="information.revenueAmount"
:label="t('commercial.clients.form.information.revenueAmount')"
:readonly="businessReadonly"
:disabled="businessReadonly"
:error="informationErrors.errors.revenueAmount"
@update:model-value="onRevenueAmountInput"
/>
<MalioInputText
v-model="information.directorName"
:mask="PERSON_NAME_MASK"
:label="t('commercial.clients.form.information.directorName')"
:readonly="businessReadonly"
:disabled="businessReadonly"
:error="informationErrors.errors.directorName"
/>
<MalioInputAmount
v-model="information.profitAmount"
:label="t('commercial.clients.form.information.profitAmount')"
:readonly="businessReadonly"
:disabled="businessReadonly"
:error="informationErrors.errors.profitAmount"
/>
</div>
@@ -167,7 +178,7 @@
:model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)"
:readonly="businessReadonly"
:disabled="businessReadonly"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
@@ -204,7 +215,7 @@
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="isRowRemovable(addresses, index)"
:readonly="businessReadonly"
:disabled="businessReadonly"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)"
@@ -239,14 +250,15 @@
v-model="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')"
:mask="SIREN_MASK"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="accountingErrors.errors.siren"
/>
<MalioInputText
v-model="accounting.accountNumber"
:mask="CODE_ALNUM_MASK"
:label="t('commercial.clients.form.accounting.accountNumber')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="accountingErrors.errors.accountNumber"
/>
@@ -254,7 +266,7 @@
:model-value="accounting.tvaModeIri"
:options="tvaModeOptions"
:label="t('commercial.clients.form.accounting.tvaMode')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.tvaMode"
@@ -262,8 +274,9 @@
/>
<MalioInputText
v-model="accounting.nTva"
:mask="CODE_ALNUM_MASK"
:label="t('commercial.clients.form.accounting.nTva')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="accountingErrors.errors.nTva"
/>
@@ -271,7 +284,7 @@
:model-value="accounting.paymentDelayIri"
:options="paymentDelayOptions"
:label="t('commercial.clients.form.accounting.paymentDelay')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentDelay"
@@ -281,7 +294,7 @@
:model-value="accounting.paymentTypeIri"
:options="paymentTypeOptions"
:label="t('commercial.clients.form.accounting.paymentType')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentType"
@@ -292,7 +305,7 @@
:model-value="accounting.bankIri"
:options="bankOptions"
:label="t('commercial.clients.form.accounting.bank')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.bank"
@@ -319,21 +332,23 @@
<MalioInputText
v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.label"
/>
<MalioInputText
v-model="rib.bic"
:mask="CODE_ALNUM_MASK"
:label="t('commercial.clients.form.accounting.ribBic')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.bic"
/>
<MalioInputText
v-model="rib.iban"
:mask="CODE_ALNUM_MASK"
:label="t('commercial.clients.form.accounting.ribIban')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.iban"
/>
@@ -423,6 +438,9 @@ import {
type InformationFormDraft,
type MainFormDraft,
} from '~/modules/commercial/utils/forms/clientEdit'
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
import { todayIso } from '~/shared/utils/date'
import { CODE_ALNUM_MASK, FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
import {
buildClientFormTabKeys,
isAddressValid,
@@ -491,6 +509,22 @@ const headerTitle = computed(() => client.value?.companyName ?? t('commercial.cl
const main = reactive<MainFormDraft>(mapMainDraft({} as ClientDetail))
const information = reactive<InformationFormDraft>(mapInformationDraft({} as ClientDetail))
const accounting = reactive<AccountingFormDraft>(mapAccountingFormDraft({} as ClientDetail))
// Borne haute de la date de creation : aujourd'hui (ERP-193, pas de date future).
const maxFoundedAt = todayIso()
// CA plafonne a 999 999 999 999,99 (ERP-193). La :key force le re-affichage du
// champ controle quand le plafonnement laisse le modelValue inchange.
const revenueAmountKey = ref(0)
/** Saisie du CA : plafonne au maximum metier et re-synchronise le champ si plafonne. */
function onRevenueAmountInput(value: string | null): void {
const clamped = clampRevenueAmount(value)
information.revenueAmount = clamped ?? null
if (clamped !== value) {
revenueAmountKey.value += 1
}
}
const contacts = ref<ContactFormDraft[]>([])
const addresses = ref<AddressFormDraft[]>([])
const ribs = ref<RibFormDraft[]>([])
@@ -668,6 +702,11 @@ function showError(e: unknown, opts: { duplicateCompany?: boolean } = {}): void
})
}
/** Toast de succès après suppression serveur confirmée d'un bloc (contact / adresse / RIB). */
function notifyRemovalSuccess(): void {
toast.success({ title: t('success.title'), message: t('success.deleted') })
}
// ── Erreurs de validation par champ (ERP-101) ───────────────────────────────
// Etat d'erreurs factorise avec l'ecran de creation (cf. useClientFormErrors) :
// un `useFormErrors` par groupe scalaire + un tableau d'erreurs par ligne pour
@@ -767,6 +806,7 @@ function askRemoveContact(index: number): void {
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyContact,
onError: showError,
onSuccess: notifyRemovalSuccess,
}))
}
@@ -844,6 +884,7 @@ function askRemoveAddress(index: number): void {
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyAddress,
onError: showError,
onSuccess: notifyRemovalSuccess,
}))
}
@@ -944,6 +985,7 @@ function askRemoveRib(index: number): void {
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyRib,
onError: showError,
onSuccess: notifyRemovalSuccess,
}))
}
@@ -6,6 +6,7 @@
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
:title="t('commercial.clients.consultation.back')"
v-bind="{ ariaLabel: t('commercial.clients.consultation.back') }"
@click="goBack"
/>
@@ -23,7 +24,7 @@
/>
<MalioButton
v-if="showArchive"
variant="secondary"
variant="danger"
icon-name="mdi:archive-arrow-down-outline"
icon-position="left"
:label="t('commercial.clients.action.archive')"
@@ -48,43 +49,51 @@
<!-- Formulaire principal (lecture seule) -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-if="isFilled(client.companyName)"
:model-value="client.companyName"
:label="t('commercial.clients.form.main.companyName')"
readonly
disabled
/>
<MalioSelectCheckbox
v-if="isFilled(categoryIris)"
:model-value="categoryIris"
:options="mainCategoryOptions"
:label="t('commercial.clients.form.main.categories')"
:display-tag="true"
readonly
disabled
/>
<!-- Relation toujours affichee (vide = « Aucun »), comme en edition. -->
<!-- Relation : masquee en consultation si aucune (ERP-193) ; en edition
elle reste toujours visible (vide = « Aucun »). -->
<MalioSelect
v-if="isFilled(relation.type)"
:model-value="relation.type"
:options="relationOptions"
:label="t('commercial.clients.form.main.relation')"
:empty-option-label="t('commercial.clients.form.main.relationNone')"
readonly
disabled
/>
<!-- Nom du distributeur/courtier : conditionnel (libelle type-dependant,
aucune valeur sans relation meme comportement qu'en edition). -->
<MalioInputText
v-if="relation.type"
v-if="relation.type && isFilled(relation.name)"
:model-value="relation.name"
:label="relation.type === 'distributeur' ? t('commercial.clients.form.main.distributorName') : t('commercial.clients.form.main.brokerName')"
readonly
disabled
/>
<!-- Service de triage : case a cocher masquee si non cochee (ERP-193). -->
<MalioCheckbox
v-if="isFilled(client.triageService === true)"
:model-value="client.triageService === true"
:label="t('commercial.clients.form.main.triageService')"
group-class="self-center"
readonly
disabled
/>
</div>
<!-- ── Onglets (navigation libre, tout en lecture seule) ─────────── -->
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- ERP-193 : on n'affiche la barre que s'il reste au moins un onglet
non vide (sinon seul le bloc principal est visible). -->
<MalioTabList v-if="visibleTabKeys.length" v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Information -->
<template #information>
<div class="mt-12 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)]">
@@ -92,42 +101,49 @@
sur les inputs (champ 40px centre dans un h-12 -> ~4px de
coussin de chaque cote). -->
<MalioInputTextArea
v-if="isFilled(information.description)"
:model-value="information.description"
:label="t('commercial.clients.form.information.description')"
resize="none"
group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg"
readonly
disabled
/>
<MalioInputText
v-if="isFilled(information.competitors)"
:model-value="information.competitors"
:label="t('commercial.clients.form.information.competitors')"
readonly
disabled
/>
<MalioDate
v-if="isFilled(information.foundedAt)"
:model-value="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')"
readonly
disabled
/>
<MalioInputText
v-if="isFilled(information.employeesCount)"
:model-value="information.employeesCount"
:label="t('commercial.clients.form.information.employeesCount')"
readonly
disabled
/>
<MalioInputAmount
v-if="isFilled(information.revenueAmount)"
:model-value="information.revenueAmount"
:label="t('commercial.clients.form.information.revenueAmount')"
readonly
disabled
/>
<MalioInputText
v-if="isFilled(information.directorName)"
:model-value="information.directorName"
:label="t('commercial.clients.form.information.directorName')"
readonly
disabled
/>
<MalioInputAmount
v-if="isFilled(information.profitAmount)"
:model-value="information.profitAmount"
:label="t('commercial.clients.form.information.profitAmount')"
readonly
disabled
/>
</div>
</template>
@@ -140,7 +156,8 @@
:key="contact.id ?? index"
:model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
readonly
disabled
hide-empty
/>
</div>
</template>
@@ -157,7 +174,8 @@
:site-options="allSiteOptions"
:contact-options="contactOptions"
:country-options="countryOptions"
readonly
disabled
hide-empty
/>
</div>
</template>
@@ -168,41 +186,47 @@
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-if="isFilled(accounting.siren)"
:model-value="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')"
:mask="SIREN_MASK"
readonly
disabled
/>
<MalioInputText
v-if="isFilled(accounting.accountNumber)"
:model-value="accounting.accountNumber"
:label="t('commercial.clients.form.accounting.accountNumber')"
readonly
disabled
/>
<MalioSelect
v-if="isFilled(accounting.tvaModeIri)"
:model-value="accounting.tvaModeIri"
:options="tvaModeOptions"
:label="t('commercial.clients.form.accounting.tvaMode')"
empty-option-label=""
readonly
disabled
/>
<MalioInputText
v-if="isFilled(accounting.nTva)"
:model-value="accounting.nTva"
:label="t('commercial.clients.form.accounting.nTva')"
readonly
disabled
/>
<MalioSelect
v-if="isFilled(accounting.paymentDelayIri)"
:model-value="accounting.paymentDelayIri"
:options="paymentDelayOptions"
:label="t('commercial.clients.form.accounting.paymentDelay')"
empty-option-label=""
readonly
disabled
/>
<MalioSelect
v-if="isFilled(accounting.paymentTypeIri)"
:model-value="accounting.paymentTypeIri"
:options="paymentTypeOptions"
:label="t('commercial.clients.form.accounting.paymentType')"
empty-option-label=""
readonly
disabled
/>
<MalioSelect
v-if="accounting.bankIri"
@@ -210,7 +234,7 @@
:options="bankOptions"
:label="t('commercial.clients.form.accounting.bank')"
empty-option-label=""
readonly
disabled
/>
</div>
</div>
@@ -223,30 +247,30 @@
>
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-if="isFilled(rib.label)"
:model-value="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')"
readonly
disabled
/>
<MalioInputText
v-if="isFilled(rib.bic)"
:model-value="rib.bic"
:label="t('commercial.clients.form.accounting.ribBic')"
readonly
disabled
/>
<MalioInputText
v-if="isFilled(rib.iban)"
:model-value="rib.iban"
:label="t('commercial.clients.form.accounting.ribIban')"
readonly
disabled
/>
</div>
</div>
</div>
</template>
<!-- Onglets non encore implementes : frame vide (navigation libre). -->
<template #transport><ComingSoonPlaceholder /></template>
<template #statistics><ComingSoonPlaceholder /></template>
<template #reports><ComingSoonPlaceholder /></template>
<template #exchanges><ComingSoonPlaceholder /></template>
<!-- ERP-193 : les onglets « a venir » (Transport / Statistiques /
Rapports / Echanges) ne sont plus rendus en consultation
(masquage des onglets vides) — slots supprimes. -->
</MalioTabList>
</template>
@@ -278,13 +302,14 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useClient } from '~/modules/commercial/composables/useClient'
import { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/clientFormRules'
import { isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/clientFormRules'
import { readHistoryTab } from '~/shared/utils/historyTab'
import {
canEditClient,
categoryOptionsOf,
clientConsultationVisibleTabs,
contactOptionsOf,
mapAccountingDraft,
mapAddressView,
@@ -299,6 +324,7 @@ import {
type SelectOption,
} from '~/modules/commercial/utils/forms/clientConsultation'
import { emptyAddress, emptyContact } from '~/modules/commercial/types/clientForm'
import { isFilled } from '~/shared/utils/consultationDisplay'
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
const SIREN_MASK = '#########'
@@ -412,9 +438,11 @@ const paymentTypeOptions = computed(() => referentialOptionOf(client.value?.paym
const bankOptions = computed(() => referentialOptionOf(client.value?.bank))
// ── Onglets : navigation LIBRE (pas de sequence forcee en consultation) ────
// 4 onglets actifs (Information, Contact, Adresse, + Comptabilite si droit) et
// 4 coquilles (Transport, Statistiques, Rapports, Echanges).
const tabKeys = computed(() => buildClientFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true }))
// ERP-193 (retour metier) : on masque les coquilles non implementees ET tout
// onglet de donnees vide. La liste depend donc du payload charge.
const visibleTabKeys = computed(() => clientConsultationVisibleTabs(client.value, {
canAccountingView: canAccountingView.value,
}))
const TAB_ICONS: Record<string, string> = {
information: 'mdi:account-outline',
@@ -427,14 +455,26 @@ const TAB_ICONS: Record<string, string> = {
exchanges: 'mdi:account-group-outline',
}
const tabs = computed(() => tabKeys.value.map(key => ({
const tabs = computed(() => visibleTabKeys.value.map(key => ({
key,
label: t(`commercial.clients.tab.${key}`),
icon: TAB_ICONS[key],
})))
// Onglet initial : repris de l'edition au retour (history.state), sinon Information.
const activeTab = ref(readHistoryTab(tabKeys.value) ?? 'information')
// Onglet initial : vide tant que le client n'est pas charge. Des que la liste
// des onglets visibles est connue, on cale sur l'onglet repris de l'edition
// (history.state) s'il est encore visible, sinon le premier onglet visible.
// Un watcher recale aussi si l'onglet courant disparait (ex: changement de droit).
const activeTab = ref('')
watch(visibleTabKeys, (keys) => {
if (keys.length === 0) {
activeTab.value = ''
return
}
if (!keys.includes(activeTab.value)) {
activeTab.value = readHistoryTab(keys) ?? keys[0]
}
}, { immediate: true })
// ── Navigation ─────────────────────────────────────────────────────────────
function goBack(): void {
@@ -43,7 +43,7 @@
@update:page="goToPage"
@update:per-page="setItemsPerPage"
>
<!-- Categories : codes stables separes par une virgule (ERP-78). -->
<!-- Categories : libelles (name) separes par une virgule (ERP-193). -->
<template #cell-categories="{ item }">
{{ formatCategories(item) }}
</template>
@@ -209,10 +209,10 @@ const columns = [
{ key: 'lastActivity', label: t('commercial.clients.column.lastActivity') },
]
/** Codes des categories du client, separes par une virgule (ERP-78). */
/** Libelles (name) des categories du client, separes par une virgule (ERP-193). */
function formatCategories(item: Record<string, unknown>): string {
const categories = (item.categories as Client['categories']) ?? []
return categories.map(c => c.code).join(', ')
return categories.map(c => c.name).join(', ')
}
/**
@@ -6,6 +6,7 @@
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
:title="t('commercial.clients.form.back')"
v-bind="{ ariaLabel: t('commercial.clients.form.back') }"
@click="goBack"
/>
@@ -19,9 +20,10 @@
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-model="main.companyName"
:mask="FREE_TEXT_MASK"
:label="t('commercial.clients.form.main.companyName')"
:required="true"
:readonly="mainLocked"
:disabled="mainLocked"
:error="mainErrors.errors.companyName"
/>
<MalioSelectCheckbox
@@ -29,7 +31,7 @@
:options="referentials.categories.value"
:label="t('commercial.clients.form.main.categories')"
:display-tag="true"
:readonly="mainLocked"
:disabled="mainLocked"
:required="true"
:error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
@@ -40,7 +42,7 @@
:options="relationOptions"
:label="t('commercial.clients.form.main.relation')"
:empty-option-label="t('commercial.clients.form.main.relationNone')"
:readonly="mainLocked"
:disabled="mainLocked"
@update:model-value="onRelationChange"
/>
<MalioSelect
@@ -48,7 +50,7 @@
:model-value="main.brokerIri"
:options="referentials.brokers.value"
:label="t('commercial.clients.form.main.brokerName')"
:readonly="mainLocked"
:disabled="mainLocked"
:required="true"
:error="mainErrors.errors.broker"
@update:model-value="(v: string | number | null) => main.brokerIri = v === null ? null : String(v)"
@@ -58,7 +60,7 @@
:model-value="main.distributorIri"
:options="referentials.distributors.value"
:label="t('commercial.clients.form.main.distributorName')"
:readonly="mainLocked"
:disabled="mainLocked"
:required="true"
:error="mainErrors.errors.distributor"
@update:model-value="(v: string | number | null) => main.distributorIri = v === null ? null : String(v)"
@@ -68,7 +70,7 @@
v-model="main.triageService"
:label="t('commercial.clients.form.main.triageService')"
group-class="self-center"
:readonly="mainLocked"
:disabled="mainLocked"
/>
</div>
@@ -96,20 +98,24 @@
resize="none"
group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg"
:readonly="isValidated('information')"
:disabled="isValidated('information')"
:error="informationErrors.errors.description"
/>
<MalioInputText
v-model="information.competitors"
:mask="FREE_TEXT_MASK"
:label="t('commercial.clients.form.information.competitors')"
:readonly="isValidated('information')"
:disabled="isValidated('information')"
:error="informationErrors.errors.competitors"
/>
<!-- Date de creation jamais dans le futur (ERP-193) : :max plafonne
le calendrier a aujourd'hui et invalide une saisie future. -->
<MalioDate
v-model="information.foundedAt"
:label="t('commercial.clients.form.information.foundedAt')"
:readonly="isValidated('information')"
:disabled="isValidated('information')"
:editable="true"
:max="maxFoundedAt"
:error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v"
/>
@@ -117,37 +123,42 @@
v-model="information.employeesCount"
:label="t('commercial.clients.form.information.employeesCount')"
:mask="EMPLOYEES_MASK"
:readonly="isValidated('information')"
:disabled="isValidated('information')"
:error="informationErrors.errors.employeesCount"
/>
<!-- CA plafonne a 999 999 999 999,99 (ERP-193) : clamp a la saisie,
:key force le re-affichage quand on plafonne (modelValue inchange). -->
<MalioInputAmount
v-model="information.revenueAmount"
:key="revenueAmountKey"
:model-value="information.revenueAmount"
:label="t('commercial.clients.form.information.revenueAmount')"
:readonly="isValidated('information')"
:disabled="isValidated('information')"
:error="informationErrors.errors.revenueAmount"
@update:model-value="onRevenueAmountInput"
/>
<MalioInputText
v-model="information.directorName"
:mask="PERSON_NAME_MASK"
:label="t('commercial.clients.form.information.directorName')"
:readonly="isValidated('information')"
:disabled="isValidated('information')"
:error="informationErrors.errors.directorName"
/>
<MalioInputAmount
v-model="information.profitAmount"
:label="t('commercial.clients.form.information.profitAmount')"
:readonly="isValidated('information')"
:disabled="isValidated('information')"
:error="informationErrors.errors.profitAmount"
/>
</div>
<div v-if="!isValidated('information')" class="mt-12 flex justify-center">
<!-- Desactive tant que le client n'est pas cree (evite un PATCH
avant le POST si clic trop tot, Information etant l'onglet
actif par defaut). Onglet facultatif : un enregistrement a
vide reste possible, c'est le back qui valide. -->
<!-- Masque tant que le client n'est pas cree : Information etant
l'onglet actif par defaut, son Valider ne doit pas apparaitre a
cote de celui du formulaire principal (ERP-193). Onglet facultatif :
un enregistrement a vide reste possible, c'est le back qui valide. -->
<div v-if="!isValidated('information') && clientId !== null" class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('commercial.clients.form.submit')"
:disabled="tabSubmitting || clientId === null"
:disabled="tabSubmitting"
@click="submitInformation"
/>
</div>
@@ -166,7 +177,7 @@
:model-value="contact"
:title="t('commercial.clients.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)"
:readonly="isValidated('contact')"
:disabled="isValidated('contact')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
@@ -203,7 +214,7 @@
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="isRowRemovable(addresses, index)"
:readonly="isValidated('address')"
:disabled="isValidated('address')"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)"
@@ -237,14 +248,15 @@
v-model="accounting.siren"
:label="t('commercial.clients.form.accounting.siren')"
:mask="SIREN_MASK"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="accountingErrors.errors.siren"
/>
<MalioInputText
v-model="accounting.accountNumber"
:mask="CODE_ALNUM_MASK"
:label="t('commercial.clients.form.accounting.accountNumber')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="accountingErrors.errors.accountNumber"
/>
@@ -252,7 +264,7 @@
:model-value="accounting.tvaModeIri"
:options="referentials.tvaModes.value"
:label="t('commercial.clients.form.accounting.tvaMode')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.tvaMode"
@@ -260,8 +272,9 @@
/>
<MalioInputText
v-model="accounting.nTva"
:mask="CODE_ALNUM_MASK"
:label="t('commercial.clients.form.accounting.nTva')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="accountingErrors.errors.nTva"
/>
@@ -269,7 +282,7 @@
:model-value="accounting.paymentDelayIri"
:options="referentials.paymentDelays.value"
:label="t('commercial.clients.form.accounting.paymentDelay')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentDelay"
@@ -279,7 +292,7 @@
:model-value="accounting.paymentTypeIri"
:options="referentials.paymentTypes.value"
:label="t('commercial.clients.form.accounting.paymentType')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentType"
@@ -290,7 +303,7 @@
:model-value="accounting.bankIri"
:options="referentials.banks.value"
:label="t('commercial.clients.form.accounting.bank')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.bank"
@@ -318,21 +331,23 @@
<MalioInputText
v-model="rib.label"
:label="t('commercial.clients.form.accounting.ribLabel')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.label"
/>
<MalioInputText
v-model="rib.bic"
:mask="CODE_ALNUM_MASK"
:label="t('commercial.clients.form.accounting.ribBic')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.bic"
/>
<MalioInputText
v-model="rib.iban"
:mask="CODE_ALNUM_MASK"
:label="t('commercial.clients.form.accounting.ribIban')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.iban"
/>
@@ -407,6 +422,9 @@ import {
lastFillableTabKey,
showsRelationAndTriageFields,
} from '~/modules/commercial/utils/forms/clientFormRules'
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
import { todayIso } from '~/shared/utils/date'
import { CODE_ALNUM_MASK, FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
import {
buildAddressPayload,
buildMainPayload,
@@ -665,6 +683,22 @@ const information = reactive({
directorName: null as string | null,
})
// Borne haute de la date de creation : aujourd'hui (ERP-193, pas de date future).
const maxFoundedAt = todayIso()
// CA plafonne a 999 999 999 999,99 (ERP-193). La :key force le re-affichage du
// champ controle quand le plafonnement laisse le modelValue inchange.
const revenueAmountKey = ref(0)
/** Saisie du CA : plafonne au maximum metier et re-synchronise le champ si plafonne. */
function onRevenueAmountInput(value: string | null): void {
const clamped = clampRevenueAmount(value)
information.revenueAmount = clamped ?? null
if (clamped !== value) {
revenueAmountKey.value += 1
}
}
/** PATCH /clients/{id} — mode strict : uniquement les champs du groupe information. */
async function submitInformation(): Promise<void> {
if (clientId.value === null || tabSubmitting.value) return
@@ -6,6 +6,7 @@
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
:title="t('commercial.suppliers.edit.back')"
v-bind="{ ariaLabel: t('commercial.suppliers.edit.back') }"
@click="goBack"
/>
@@ -26,15 +27,16 @@
v-model="main.companyName"
:label="t('commercial.suppliers.form.main.companyName')"
:required="true"
:readonly="businessReadonly"
:disabled="businessReadonly"
:error="mainErrors.errors.companyName"
:mask="FREE_TEXT_MASK"
/>
<MalioSelectCheckbox
:model-value="main.categoryIris"
:options="mainCategoryOptions"
:label="t('commercial.suppliers.form.main.categories')"
:display-tag="true"
:readonly="businessReadonly"
:disabled="businessReadonly"
:required="true"
:error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
@@ -62,20 +64,24 @@
resize="none"
group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg"
:readonly="businessReadonly"
:disabled="businessReadonly"
:error="informationErrors.errors.description"
/>
<MalioInputText
v-model="information.competitors"
:label="t('commercial.suppliers.form.information.competitors')"
:readonly="businessReadonly"
:disabled="businessReadonly"
:error="informationErrors.errors.competitors"
:mask="FREE_TEXT_MASK"
/>
<!-- Date de creation jamais dans le futur (ERP-193) : :max plafonne
le calendrier a aujourd'hui et invalide une saisie future. -->
<MalioDate
v-model="information.foundedAt"
:label="t('commercial.suppliers.form.information.foundedAt')"
:readonly="businessReadonly"
:disabled="businessReadonly"
:editable="true"
:max="maxFoundedAt"
:error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v"
/>
@@ -83,25 +89,30 @@
v-model="information.employeesCount"
:label="t('commercial.suppliers.form.information.employeesCount')"
:mask="EMPLOYEES_MASK"
:readonly="businessReadonly"
:disabled="businessReadonly"
:error="informationErrors.errors.employeesCount"
/>
<!-- CA plafonne a 999 999 999 999,99 (ERP-193) : clamp a la saisie,
:key force le re-affichage quand on plafonne (modelValue inchange). -->
<MalioInputAmount
v-model="information.revenueAmount"
:key="revenueAmountKey"
:model-value="information.revenueAmount"
:label="t('commercial.suppliers.form.information.revenueAmount')"
:readonly="businessReadonly"
:disabled="businessReadonly"
:error="informationErrors.errors.revenueAmount"
@update:model-value="onRevenueAmountInput"
/>
<MalioInputText
v-model="information.directorName"
:label="t('commercial.suppliers.form.information.directorName')"
:readonly="businessReadonly"
:disabled="businessReadonly"
:error="informationErrors.errors.directorName"
:mask="PERSON_NAME_MASK"
/>
<MalioInputAmount
v-model="information.profitAmount"
:label="t('commercial.suppliers.form.information.profitAmount')"
:readonly="businessReadonly"
:disabled="businessReadonly"
:error="informationErrors.errors.profitAmount"
/>
<!-- Volume previsionnel : specifique fournisseur (entier). -->
@@ -109,7 +120,7 @@
v-model="information.volumeForecast"
:label="t('commercial.suppliers.form.information.volumeForecast')"
:mask="VOLUME_FORECAST_MASK"
:readonly="businessReadonly"
:disabled="businessReadonly"
:error="informationErrors.errors.volumeForecast"
/>
</div>
@@ -136,7 +147,7 @@
:model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)"
:readonly="businessReadonly"
:disabled="businessReadonly"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
@@ -173,7 +184,7 @@
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="isRowRemovable(addresses, index)"
:readonly="businessReadonly"
:disabled="businessReadonly"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)"
@@ -208,22 +219,23 @@
v-model="accounting.siren"
:label="t('commercial.suppliers.form.accounting.siren')"
:mask="SIREN_MASK"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="accountingErrors.errors.siren"
/>
<MalioInputText
v-model="accounting.accountNumber"
:label="t('commercial.suppliers.form.accounting.accountNumber')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="accountingErrors.errors.accountNumber"
:mask="CODE_ALNUM_MASK"
/>
<MalioSelect
:model-value="accounting.tvaModeIri"
:options="tvaModeOptions"
:label="t('commercial.suppliers.form.accounting.tvaMode')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.tvaMode"
@@ -232,15 +244,16 @@
<MalioInputText
v-model="accounting.nTva"
:label="t('commercial.suppliers.form.accounting.nTva')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="accountingErrors.errors.nTva"
:mask="CODE_ALNUM_MASK"
/>
<MalioSelect
:model-value="accounting.paymentDelayIri"
:options="paymentDelayOptions"
:label="t('commercial.suppliers.form.accounting.paymentDelay')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentDelay"
@@ -250,7 +263,7 @@
:model-value="accounting.paymentTypeIri"
:options="paymentTypeOptions"
:label="t('commercial.suppliers.form.accounting.paymentType')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentType"
@@ -261,7 +274,7 @@
:model-value="accounting.bankIri"
:options="bankOptions"
:label="t('commercial.suppliers.form.accounting.bank')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.bank"
@@ -288,23 +301,25 @@
<MalioInputText
v-model="rib.label"
:label="t('commercial.suppliers.form.accounting.ribLabel')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.label"
/>
<MalioInputText
v-model="rib.bic"
:label="t('commercial.suppliers.form.accounting.ribBic')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.bic"
:mask="CODE_ALNUM_MASK"
/>
<MalioInputText
v-model="rib.iban"
:label="t('commercial.suppliers.form.accounting.ribIban')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.iban"
:mask="CODE_ALNUM_MASK"
/>
</div>
</div>
@@ -392,6 +407,8 @@ import {
type MainFormDraft,
type SupplierEditAbilities,
} from '~/modules/commercial/utils/forms/supplierEdit'
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
import { todayIso } from '~/shared/utils/date'
import {
buildSupplierFormTabKeys,
isAddressValid,
@@ -412,6 +429,7 @@ import {
} from '~/modules/commercial/types/supplierForm'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable, removeCollectionRow } from '~/shared/utils/collectionRow'
import { CODE_ALNUM_MASK, FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
import { readHistoryTab } from '~/shared/utils/historyTab'
// Masques de saisie (la normalisation finale reste serveur).
@@ -457,6 +475,22 @@ const headerTitle = computed(() => supplier.value?.companyName ?? t('commercial.
const main = reactive<MainFormDraft>(mapMainDraft({} as SupplierDetail))
const information = reactive<InformationFormDraft>(mapInformationDraft({} as SupplierDetail))
const accounting = reactive<AccountingFormDraft>(mapAccountingFormDraft({} as SupplierDetail))
// Borne haute de la date de creation : aujourd'hui (ERP-193, pas de date future).
const maxFoundedAt = todayIso()
// CA plafonne a 999 999 999 999,99 (ERP-193). La :key force le re-affichage du
// champ controle quand le plafonnement laisse le modelValue inchange.
const revenueAmountKey = ref(0)
/** Saisie du CA : plafonne au maximum metier et re-synchronise le champ si plafonne. */
function onRevenueAmountInput(value: string | null): void {
const clamped = clampRevenueAmount(value)
information.revenueAmount = clamped ?? null
if (clamped !== value) {
revenueAmountKey.value += 1
}
}
const contacts = ref<SupplierContactFormDraft[]>([])
const addresses = ref<SupplierAddressFormDraft[]>([])
const ribs = ref<SupplierRibFormDraft[]>([])
@@ -583,6 +617,11 @@ function showError(e: unknown): void {
toast.error({ title: t('commercial.suppliers.toast.error'), message: apiErrorMessage(e) })
}
/** Toast de succès après suppression serveur confirmée d'un bloc (contact / adresse / RIB). */
function notifyRemovalSuccess(): void {
toast.success({ title: t('success.title'), message: t('success.deleted') })
}
// ── Erreurs de validation par champ (ERP-101) ───────────────────────────────
const {
mainErrors,
@@ -666,6 +705,7 @@ function askRemoveContact(index: number): void {
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyContact,
onError: showError,
onSuccess: notifyRemovalSuccess,
}))
}
@@ -734,6 +774,7 @@ function askRemoveAddress(index: number): void {
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyAddress,
onError: showError,
onSuccess: notifyRemovalSuccess,
}))
}
@@ -833,6 +874,7 @@ function askRemoveRib(index: number): void {
deleteRow: url => api.delete(url, {}, { toast: false }),
makeEmpty: emptyRib,
onError: showError,
onSuccess: notifyRemovalSuccess,
}))
}
@@ -6,6 +6,7 @@
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
:title="t('commercial.suppliers.consultation.back')"
v-bind="{ ariaLabel: t('commercial.suppliers.consultation.back') }"
@click="goBack"
/>
@@ -23,7 +24,7 @@
/>
<MalioButton
v-if="showArchive"
variant="secondary"
variant="danger"
icon-name="mdi:archive-arrow-down-outline"
icon-position="left"
:label="t('commercial.suppliers.action.archive')"
@@ -48,69 +49,82 @@
<!-- Formulaire principal (lecture seule) -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-if="isFilled(supplier.companyName)"
:model-value="supplier.companyName"
:label="t('commercial.suppliers.form.main.companyName')"
readonly
disabled
/>
<MalioSelectCheckbox
v-if="isFilled(categoryIris)"
:model-value="categoryIris"
:options="mainCategoryOptions"
:label="t('commercial.suppliers.form.main.categories')"
:display-tag="true"
readonly
disabled
/>
</div>
<!-- Onglets (navigation libre, tout en lecture seule) -->
<MalioTabList v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Masque la barre d'onglets (et sa bordure) quand aucun onglet n'est
visible : seul le formulaire principal est rempli (aligné sur le
client). -->
<MalioTabList v-if="visibleTabKeys.length" v-model="activeTab" :tabs="tabs" :max-visible-tabs="5" :max-width="1100" class="mt-[60px]">
<!-- Onglet Information -->
<template #information>
<div class="mt-12 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)]">
<!-- pt-1/pb-1 alignent le textarea (h-full) en haut ET en bas
sur les inputs (champ 40px centre dans un h-12). -->
<MalioInputTextArea
v-if="isFilled(information.description)"
:model-value="information.description"
:label="t('commercial.suppliers.form.information.description')"
resize="none"
group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg"
readonly
disabled
/>
<MalioInputText
v-if="isFilled(information.competitors)"
:model-value="information.competitors"
:label="t('commercial.suppliers.form.information.competitors')"
readonly
disabled
/>
<MalioDate
v-if="isFilled(information.foundedAt)"
:model-value="information.foundedAt"
:label="t('commercial.suppliers.form.information.foundedAt')"
readonly
disabled
/>
<MalioInputText
v-if="isFilled(information.employeesCount)"
:model-value="information.employeesCount"
:label="t('commercial.suppliers.form.information.employeesCount')"
readonly
disabled
/>
<MalioInputAmount
v-if="isFilled(information.revenueAmount)"
:model-value="information.revenueAmount"
:label="t('commercial.suppliers.form.information.revenueAmount')"
readonly
disabled
/>
<MalioInputText
v-if="isFilled(information.directorName)"
:model-value="information.directorName"
:label="t('commercial.suppliers.form.information.directorName')"
readonly
disabled
/>
<MalioInputAmount
v-if="isFilled(information.profitAmount)"
:model-value="information.profitAmount"
:label="t('commercial.suppliers.form.information.profitAmount')"
readonly
disabled
/>
<!-- Volume previsionnel : specifique fournisseur (entier). -->
<MalioInputText
v-if="isFilled(information.volumeForecast)"
:model-value="information.volumeForecast"
:label="t('commercial.suppliers.form.information.volumeForecast')"
readonly
disabled
/>
</div>
</template>
@@ -123,7 +137,8 @@
:key="contact.id ?? index"
:model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
readonly
disabled
hide-empty
/>
</div>
</template>
@@ -140,7 +155,8 @@
:site-options="allSiteOptions"
:contact-options="contactOptions"
:country-options="countryOptions"
readonly
disabled
hide-empty
/>
</div>
</template>
@@ -151,41 +167,47 @@
<div class="bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-if="isFilled(accounting.siren)"
:model-value="accounting.siren"
:label="t('commercial.suppliers.form.accounting.siren')"
:mask="SIREN_MASK"
readonly
disabled
/>
<MalioInputText
v-if="isFilled(accounting.accountNumber)"
:model-value="accounting.accountNumber"
:label="t('commercial.suppliers.form.accounting.accountNumber')"
readonly
disabled
/>
<MalioSelect
v-if="isFilled(accounting.tvaModeIri)"
:model-value="accounting.tvaModeIri"
:options="tvaModeOptions"
:label="t('commercial.suppliers.form.accounting.tvaMode')"
empty-option-label=""
readonly
disabled
/>
<MalioInputText
v-if="isFilled(accounting.nTva)"
:model-value="accounting.nTva"
:label="t('commercial.suppliers.form.accounting.nTva')"
readonly
disabled
/>
<MalioSelect
v-if="isFilled(accounting.paymentDelayIri)"
:model-value="accounting.paymentDelayIri"
:options="paymentDelayOptions"
:label="t('commercial.suppliers.form.accounting.paymentDelay')"
empty-option-label=""
readonly
disabled
/>
<MalioSelect
v-if="isFilled(accounting.paymentTypeIri)"
:model-value="accounting.paymentTypeIri"
:options="paymentTypeOptions"
:label="t('commercial.suppliers.form.accounting.paymentType')"
empty-option-label=""
readonly
disabled
/>
<MalioSelect
v-if="accounting.bankIri"
@@ -193,7 +215,7 @@
:options="bankOptions"
:label="t('commercial.suppliers.form.accounting.bank')"
empty-option-label=""
readonly
disabled
/>
</div>
</div>
@@ -206,30 +228,30 @@
>
<div class="grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-if="isFilled(rib.label)"
:model-value="rib.label"
:label="t('commercial.suppliers.form.accounting.ribLabel')"
readonly
disabled
/>
<MalioInputText
v-if="isFilled(rib.bic)"
:model-value="rib.bic"
:label="t('commercial.suppliers.form.accounting.ribBic')"
readonly
disabled
/>
<MalioInputText
v-if="isFilled(rib.iban)"
:model-value="rib.iban"
:label="t('commercial.suppliers.form.accounting.ribIban')"
readonly
disabled
/>
</div>
</div>
</div>
</template>
<!-- Onglets non encore implementes : frame vide (navigation libre). -->
<template #transport><ComingSoonPlaceholder /></template>
<template #statistics><ComingSoonPlaceholder /></template>
<template #reports><ComingSoonPlaceholder /></template>
<template #exchanges><ComingSoonPlaceholder /></template>
<!-- ERP-193 : les onglets « a venir » (Transport / Statistiques /
Rapports / Echanges) ne sont plus rendus en consultation
(masquage des onglets vides) slots supprimes. -->
</MalioTabList>
</template>
@@ -261,9 +283,9 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useSupplier } from '~/modules/commercial/composables/useSupplier'
import { buildSupplierFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/supplierFormRules'
import { isRibRequiredForPaymentType } from '~/modules/commercial/utils/forms/supplierFormRules'
import { readHistoryTab } from '~/shared/utils/historyTab'
import {
canEditSupplier,
@@ -278,10 +300,12 @@ import {
referentialOptionOf,
showArchiveAction,
showRestoreAction,
supplierConsultationVisibleTabs,
type SelectOption,
type SupplierDetail,
} from '~/modules/commercial/utils/forms/supplierConsultation'
import { emptyContact } from '~/modules/commercial/types/supplierForm'
import { isFilled } from '~/shared/utils/consultationDisplay'
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
const SIREN_MASK = '#########'
@@ -387,9 +411,11 @@ const paymentTypeOptions = computed(() => referentialOptionOf(supplier.value?.pa
const bankOptions = computed(() => referentialOptionOf(supplier.value?.bank))
// ── Onglets : navigation LIBRE (pas de sequence forcee en consultation) ────
// 3 onglets actifs (Information, Contacts, Adresses, + Comptabilite si droit) et
// 4 coquilles (Transport, Statistiques, Rapports, Echanges).
const tabKeys = computed(() => buildSupplierFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true }))
// ERP-193 (retour metier) : on masque les coquilles non implementees ET tout
// onglet de donnees vide. La liste depend donc du payload charge.
const visibleTabKeys = computed(() => supplierConsultationVisibleTabs(supplier.value, {
canAccountingView: canAccountingView.value,
}))
const TAB_ICONS: Record<string, string> = {
information: 'mdi:account-outline',
@@ -402,14 +428,25 @@ const TAB_ICONS: Record<string, string> = {
exchanges: 'mdi:account-group-outline',
}
const tabs = computed(() => tabKeys.value.map(key => ({
const tabs = computed(() => visibleTabKeys.value.map(key => ({
key,
label: t(`commercial.suppliers.tab.${key}`),
icon: TAB_ICONS[key],
})))
// Onglet initial : repris de l'edition au retour (history.state), sinon Information.
const activeTab = ref(readHistoryTab(tabKeys.value) ?? 'information')
// Onglet initial : vide tant que le fournisseur n'est pas charge. Des que la
// liste des onglets visibles est connue, on cale sur l'onglet repris de
// l'edition (history.state) s'il est encore visible, sinon le premier visible.
const activeTab = ref('')
watch(visibleTabKeys, (keys) => {
if (keys.length === 0) {
activeTab.value = ''
return
}
if (!keys.includes(activeTab.value)) {
activeTab.value = readHistoryTab(keys) ?? keys[0]
}
}, { immediate: true })
// ── Navigation ─────────────────────────────────────────────────────────────
function goBack(): void {
@@ -43,7 +43,7 @@
@update:page="goToPage"
@update:per-page="setItemsPerPage"
>
<!-- Categories : libelles (name) separes par une virgule (spec M2). -->
<!-- Categories : libelles (name) separes par une virgule, aligne sur le client (ERP-193). -->
<template #cell-categories="{ item }">
{{ formatCategories(item) }}
</template>
@@ -209,7 +209,7 @@ const columns = [
{ key: 'lastActivity', label: t('commercial.suppliers.column.lastActivity') },
]
/** Libelles des categories du fournisseur, separes par une virgule (spec M2 : name). */
/** Libelles (name) des categories du fournisseur, separes par une virgule (aligne sur le client, ERP-193). */
function formatCategories(item: Record<string, unknown>): string {
const categories = (item.categories as Supplier['categories']) ?? []
return categories.map(c => c.name).join(', ')
@@ -6,6 +6,7 @@
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
:title="t('commercial.suppliers.form.back')"
v-bind="{ ariaLabel: t('commercial.suppliers.form.back') }"
@click="goBack"
/>
@@ -21,15 +22,16 @@
v-model="main.companyName"
:label="t('commercial.suppliers.form.main.companyName')"
:required="true"
:readonly="mainLocked"
:disabled="mainLocked"
:error="mainErrors.errors.companyName"
:mask="FREE_TEXT_MASK"
/>
<MalioSelectCheckbox
:model-value="main.categoryIris"
:options="referentials.categories.value"
:label="t('commercial.suppliers.form.main.categories')"
:display-tag="true"
:readonly="mainLocked"
:disabled="mainLocked"
:required="true"
:error="mainErrors.errors.categories"
@update:model-value="(v: (string | number)[]) => main.categoryIris = v.map(String)"
@@ -56,20 +58,24 @@
resize="none"
group-class="row-span-2 pt-1 pb-1"
text-input="h-full text-lg"
:readonly="isValidated('information')"
:disabled="isValidated('information')"
:error="informationErrors.errors.description"
/>
<MalioInputText
v-model="information.competitors"
:label="t('commercial.suppliers.form.information.competitors')"
:readonly="isValidated('information')"
:disabled="isValidated('information')"
:error="informationErrors.errors.competitors"
:mask="FREE_TEXT_MASK"
/>
<!-- Date de creation jamais dans le futur (ERP-193) : :max plafonne
le calendrier a aujourd'hui et invalide une saisie future. -->
<MalioDate
v-model="information.foundedAt"
:label="t('commercial.suppliers.form.information.foundedAt')"
:readonly="isValidated('information')"
:disabled="isValidated('information')"
:editable="true"
:max="maxFoundedAt"
:error="informationErrors.errors.foundedAt"
@update:raw-value="(v: string) => information.foundedAtRaw = v"
/>
@@ -77,25 +83,30 @@
v-model="information.employeesCount"
:label="t('commercial.suppliers.form.information.employeesCount')"
:mask="EMPLOYEES_MASK"
:readonly="isValidated('information')"
:disabled="isValidated('information')"
:error="informationErrors.errors.employeesCount"
/>
<!-- CA plafonne a 999 999 999 999,99 (ERP-193) : clamp a la saisie,
:key force le re-affichage quand on plafonne (modelValue inchange). -->
<MalioInputAmount
v-model="information.revenueAmount"
:key="revenueAmountKey"
:model-value="information.revenueAmount"
:label="t('commercial.suppliers.form.information.revenueAmount')"
:readonly="isValidated('information')"
:disabled="isValidated('information')"
:error="informationErrors.errors.revenueAmount"
@update:model-value="onRevenueAmountInput"
/>
<MalioInputText
v-model="information.directorName"
:label="t('commercial.suppliers.form.information.directorName')"
:readonly="isValidated('information')"
:disabled="isValidated('information')"
:error="informationErrors.errors.directorName"
:mask="PERSON_NAME_MASK"
/>
<MalioInputAmount
v-model="information.profitAmount"
:label="t('commercial.suppliers.form.information.profitAmount')"
:readonly="isValidated('information')"
:disabled="isValidated('information')"
:error="informationErrors.errors.profitAmount"
/>
<!-- Volume previsionnel : specifique fournisseur. Champ texte
@@ -104,15 +115,18 @@
v-model="information.volumeForecast"
:label="t('commercial.suppliers.form.information.volumeForecast')"
:mask="VOLUME_FORECAST_MASK"
:readonly="isValidated('information')"
:disabled="isValidated('information')"
:error="informationErrors.errors.volumeForecast"
/>
</div>
<div v-if="!isValidated('information')" class="mt-12 flex justify-center">
<!-- Masque tant que le fournisseur n'est pas cree : Information etant
l'onglet actif par defaut, son Valider ne doit pas apparaitre a cote
de celui du formulaire principal (ERP-193). -->
<div v-if="!isValidated('information') && supplierId !== null" class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('commercial.suppliers.form.submit')"
:disabled="tabSubmitting || supplierId === null"
:disabled="tabSubmitting"
@click="submitInformation"
/>
</div>
@@ -131,7 +145,7 @@
:model-value="contact"
:title="t('commercial.suppliers.form.contact.title', { n: index + 1 })"
:removable="isRowRemovable(contacts, index)"
:readonly="isValidated('contacts')"
:disabled="isValidated('contacts')"
:errors="contactErrors[index]"
@update:model-value="(v) => contacts[index] = v"
@remove="askRemoveContact(index)"
@@ -168,7 +182,7 @@
:contact-options="contactOptions"
:country-options="countryOptions"
:removable="isRowRemovable(addresses, index)"
:readonly="isValidated('addresses')"
:disabled="isValidated('addresses')"
:errors="addressErrors[index]"
@update:model-value="(v) => addresses[index] = v"
@remove="askRemoveAddress(index)"
@@ -202,22 +216,23 @@
v-model="accounting.siren"
:label="t('commercial.suppliers.form.accounting.siren')"
:mask="SIREN_MASK"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="accountingErrors.errors.siren"
/>
<MalioInputText
v-model="accounting.accountNumber"
:label="t('commercial.suppliers.form.accounting.accountNumber')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="accountingErrors.errors.accountNumber"
:mask="CODE_ALNUM_MASK"
/>
<MalioSelect
:model-value="accounting.tvaModeIri"
:options="referentials.tvaModes.value"
:label="t('commercial.suppliers.form.accounting.tvaMode')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.tvaMode"
@@ -226,15 +241,16 @@
<MalioInputText
v-model="accounting.nTva"
:label="t('commercial.suppliers.form.accounting.nTva')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="true"
:error="accountingErrors.errors.nTva"
:mask="CODE_ALNUM_MASK"
/>
<MalioSelect
:model-value="accounting.paymentDelayIri"
:options="referentials.paymentDelays.value"
:label="t('commercial.suppliers.form.accounting.paymentDelay')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentDelay"
@@ -244,7 +260,7 @@
:model-value="accounting.paymentTypeIri"
:options="referentials.paymentTypes.value"
:label="t('commercial.suppliers.form.accounting.paymentType')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.paymentType"
@@ -255,7 +271,7 @@
:model-value="accounting.bankIri"
:options="referentials.banks.value"
:label="t('commercial.suppliers.form.accounting.bank')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
empty-option-label=""
:required="true"
:error="accountingErrors.errors.bank"
@@ -282,23 +298,25 @@
<MalioInputText
v-model="rib.label"
:label="t('commercial.suppliers.form.accounting.ribLabel')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.label"
/>
<MalioInputText
v-model="rib.bic"
:label="t('commercial.suppliers.form.accounting.ribBic')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.bic"
:mask="CODE_ALNUM_MASK"
/>
<MalioInputText
v-model="rib.iban"
:label="t('commercial.suppliers.form.accounting.ribIban')"
:readonly="accountingReadonly"
:disabled="accountingReadonly"
:required="isRibRequired"
:error="ribErrors[index]?.iban"
:mask="CODE_ALNUM_MASK"
/>
</div>
</div>
@@ -375,6 +393,8 @@ import {
buildMainPayload,
buildRibPayload,
} from '~/modules/commercial/utils/forms/supplierEdit'
import { clampRevenueAmount } from '~/modules/commercial/utils/forms/amountInput'
import { todayIso } from '~/shared/utils/date'
import {
emptyAddress,
emptyContact,
@@ -385,6 +405,7 @@ import {
} from '~/modules/commercial/types/supplierForm'
import { extractApiErrorMessage } from '~/shared/utils/api'
import { isRowRemovable } from '~/shared/utils/collectionRow'
import { CODE_ALNUM_MASK, FREE_TEXT_MASK, PERSON_NAME_MASK } from '~/shared/utils/textSanitize'
// Masques de saisie (la normalisation finale reste serveur).
const SIREN_MASK = '#########'
@@ -564,6 +585,22 @@ const information = reactive({
volumeForecast: null as string | null,
})
// Borne haute de la date de creation : aujourd'hui (ERP-193, pas de date future).
const maxFoundedAt = todayIso()
// CA plafonne a 999 999 999 999,99 (ERP-193). La :key force le re-affichage du
// champ controle quand le plafonnement laisse le modelValue inchange.
const revenueAmountKey = ref(0)
/** Saisie du CA : plafonne au maximum metier et re-synchronise le champ si plafonne. */
function onRevenueAmountInput(value: string | null): void {
const clamped = clampRevenueAmount(value)
information.revenueAmount = clamped ?? null
if (clamped !== value) {
revenueAmountKey.value += 1
}
}
/** PATCH /suppliers/{id} — mode strict : uniquement les champs du groupe information. */
async function submitInformation(): Promise<void> {
if (supplierId.value === null || tabSubmitting.value) return
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { clampRevenueAmount, REVENUE_AMOUNT_MAX } from '../amountInput'
describe('clampRevenueAmount', () => {
it('laisse les valeurs vides / nulles telles quelles', () => {
expect(clampRevenueAmount(null)).toBeNull()
expect(clampRevenueAmount(undefined)).toBeUndefined()
expect(clampRevenueAmount('')).toBe('')
})
it('laisse une valeur sous le plafond inchangee', () => {
expect(clampRevenueAmount('1000.50')).toBe('1000.50')
expect(clampRevenueAmount('999999999999.99')).toBe('999999999999.99')
})
it('plafonne une valeur au-dessus du maximum', () => {
expect(clampRevenueAmount('1000000000000')).toBe('999999999999.99')
expect(clampRevenueAmount('999999999999999.99')).toBe('999999999999.99')
})
it('tolere une saisie a virgule / avec espaces (securite)', () => {
expect(clampRevenueAmount('1 000 000 000 000,00')).toBe('999999999999.99')
expect(clampRevenueAmount('12,5')).toBe('12,5')
})
it('ne touche pas une saisie non numerique', () => {
expect(clampRevenueAmount('abc')).toBe('abc')
})
it('expose le plafond metier', () => {
expect(REVENUE_AMOUNT_MAX).toBe(999_999_999_999.99)
})
})
@@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest'
import {
canEditClient,
categoryOptionsOf,
clientConsultationVisibleTabs,
contactOptionsOf,
hasAccountingData,
hasInformationData,
iriOf,
mapAccountingDraft,
mapAddressToDraft,
@@ -248,3 +251,73 @@ describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', (
expect(paymentTypeCodeOf(undefined)).toBeNull()
})
})
describe('hasInformationData', () => {
it('faux si tous les champs Information sont vides/absents', () => {
expect(hasInformationData({ '@id': '/api/clients/1', id: 1 })).toBe(false)
expect(hasInformationData({ '@id': '/api/clients/1', id: 1, description: ' ' })).toBe(false)
})
it('vrai des qu\'un champ Information porte une donnee', () => {
expect(hasInformationData({ '@id': '/api/clients/1', id: 1, directorName: 'Dupont' })).toBe(true)
expect(hasInformationData({ '@id': '/api/clients/1', id: 1, employeesCount: 0 })).toBe(true)
})
})
describe('hasAccountingData', () => {
it('faux sans champ comptable ni RIB', () => {
expect(hasAccountingData({ '@id': '/api/clients/1', id: 1 })).toBe(false)
})
it('vrai avec un champ comptable scalaire', () => {
expect(hasAccountingData({ '@id': '/api/clients/1', id: 1, siren: '123456789' })).toBe(true)
})
it('vrai avec une relation comptable embarquee (paymentType)', () => {
expect(hasAccountingData({
'@id': '/api/clients/1', id: 1,
paymentType: { '@id': '/api/payment_types/1', code: 'LCR' },
})).toBe(true)
})
it('vrai avec au moins un RIB', () => {
expect(hasAccountingData({
'@id': '/api/clients/1', id: 1,
ribs: [{ '@id': '/api/ribs/1', id: 1, iban: 'FR76...' }],
})).toBe(true)
})
})
describe('clientConsultationVisibleTabs', () => {
it('retourne [] tant que le client n\'est pas charge', () => {
expect(clientConsultationVisibleTabs(null, { canAccountingView: true })).toEqual([])
expect(clientConsultationVisibleTabs(undefined, { canAccountingView: true })).toEqual([])
})
it('masque les coquilles et les onglets vides (client minimal)', () => {
const client: ClientDetail = { '@id': '/api/clients/1', id: 1, companyName: 'ACME' }
expect(clientConsultationVisibleTabs(client, { canAccountingView: true })).toEqual([])
})
it('affiche les onglets non vides dans l\'ordre information/contact/address/accounting', () => {
const client: ClientDetail = {
'@id': '/api/clients/1', id: 1,
directorName: 'Dupont',
contacts: [{ '@id': '/api/client_contacts/1', id: 1 }],
addresses: [{ '@id': '/api/client_addresses/1', id: 1 }],
siren: '123456789',
}
expect(clientConsultationVisibleTabs(client, { canAccountingView: true }))
.toEqual(['information', 'contact', 'address', 'accounting'])
})
it('masque Comptabilite sans le droit accounting.view meme si des donnees existent', () => {
const client: ClientDetail = {
'@id': '/api/clients/1', id: 1,
contacts: [{ '@id': '/api/client_contacts/1', id: 1 }],
siren: '123456789',
}
expect(clientConsultationVisibleTabs(client, { canAccountingView: false }))
.toEqual(['contact'])
})
})
@@ -3,6 +3,8 @@ import {
canEditSupplier,
categoryOptionsOf,
contactOptionsOf,
hasAccountingData,
hasInformationData,
iriOf,
mapAccountingDraft,
mapAddressToDraft,
@@ -14,6 +16,7 @@ import {
showArchiveAction,
showRestoreAction,
siteOptionsOf,
supplierConsultationVisibleTabs,
type SupplierDetail,
} from '../supplierConsultation'
@@ -237,3 +240,60 @@ describe('paymentTypeCodeOf (ERP-121 : RIB masques hors-LCR en consultation)', (
expect(paymentTypeCodeOf(undefined)).toBeNull()
})
})
describe('hasInformationData (fournisseur)', () => {
it('faux si tous les champs Information (volume previsionnel inclus) sont vides', () => {
expect(hasInformationData({ '@id': '/api/suppliers/1', id: 1 })).toBe(false)
})
it('vrai des qu\'un champ Information porte une donnee', () => {
expect(hasInformationData({ '@id': '/api/suppliers/1', id: 1, directorName: 'Martin' })).toBe(true)
expect(hasInformationData({ '@id': '/api/suppliers/1', id: 1, volumeForecast: 1200 })).toBe(true)
})
})
describe('hasAccountingData (fournisseur)', () => {
it('faux sans champ comptable ni RIB', () => {
expect(hasAccountingData({ '@id': '/api/suppliers/1', id: 1 })).toBe(false)
})
it('vrai avec un champ comptable ou un RIB', () => {
expect(hasAccountingData({ '@id': '/api/suppliers/1', id: 1, siren: '123456789' })).toBe(true)
expect(hasAccountingData({
'@id': '/api/suppliers/1', id: 1,
ribs: [{ '@id': '/api/supplier_ribs/1', id: 1, iban: 'FR76...' }],
})).toBe(true)
})
})
describe('supplierConsultationVisibleTabs', () => {
it('retourne [] tant que le fournisseur n\'est pas charge', () => {
expect(supplierConsultationVisibleTabs(null, { canAccountingView: true })).toEqual([])
})
it('masque les coquilles et les onglets vides (fournisseur minimal)', () => {
expect(supplierConsultationVisibleTabs(
{ '@id': '/api/suppliers/1', id: 1, companyName: 'ACME' },
{ canAccountingView: true },
)).toEqual([])
})
it('affiche information/contacts/addresses/accounting (cles plurielles) dans l\'ordre', () => {
const supplier: SupplierDetail = {
'@id': '/api/suppliers/1', id: 1,
volumeForecast: 1000,
contacts: [{ '@id': '/api/supplier_contacts/1', id: 1 }],
addresses: [{ '@id': '/api/supplier_addresses/1', id: 1 }],
siren: '123456789',
}
expect(supplierConsultationVisibleTabs(supplier, { canAccountingView: true }))
.toEqual(['information', 'contacts', 'addresses', 'accounting'])
})
it('masque Comptabilite sans le droit accounting.view', () => {
expect(supplierConsultationVisibleTabs(
{ '@id': '/api/suppliers/1', id: 1, siren: '123456789' },
{ canAccountingView: false },
)).toEqual([])
})
})
@@ -0,0 +1,29 @@
/**
* Helpers de saisie des montants des formulaires Client / Fournisseur (commercial).
* Purs / testables. Pendant FRONT de la contrainte back `LessThanOrEqual` posee sur
* `revenueAmount` (Client/Supplier) retour metier ERP-193 : le chiffre d'affaires
* est plafonne a 999 999 999 999,99.
*/
/** Plafond metier du chiffre d'affaires (CA) : 999 999 999 999,99. */
export const REVENUE_AMOUNT_MAX = 999_999_999_999.99
/** Valeur « modele » (decimale a point, sans separateur) renvoyee quand on plafonne. */
const REVENUE_AMOUNT_MAX_MODEL = '999999999999.99'
/**
* Plafonne le CA au maximum metier. Recoit le modele emis par `MalioInputAmount`
* (chaine propre a decimale `.`, sans espaces) ; tolere malgre tout une virgule /
* des espaces par securite. Renvoie la valeur telle quelle si elle est vide, non
* numerique ou sous le plafond ; sinon la valeur plafonnee.
*/
export function clampRevenueAmount(value: string | null | undefined): string | null | undefined {
if (value === null || value === undefined || value === '') {
return value
}
const n = Number(String(value).replace(/\s/g, '').replace(',', '.'))
if (Number.isNaN(n)) {
return value
}
return n > REVENUE_AMOUNT_MAX ? REVENUE_AMOUNT_MAX_MODEL : value
}
@@ -317,6 +317,77 @@ export function mapAddressView(address: AddressRead): AddressView {
}
}
/**
* Vrai si une valeur scalaire porte une donnee affichable (non null/undefined,
* et non chaine vide apres trim). Sert aux predicats « onglet vide » ci-dessous.
*/
function hasValue(value: unknown): boolean {
if (value === null || value === undefined) {
return false
}
if (typeof value === 'string') {
return value.trim() !== ''
}
return true
}
/**
* Vrai si l'onglet Information porte au moins une donnee. ERP-193 : en
* consultation on masque les onglets vides ; Information n'echappe pas a la
* regle malgre son statut d'onglet d'atterrissage par defaut.
*/
export function hasInformationData(client: ClientDetail): boolean {
return [
client.description,
client.competitors,
client.foundedAt,
client.employeesCount,
client.revenueAmount,
client.profitAmount,
client.directorName,
].some(hasValue)
}
/**
* Vrai si l'onglet Comptabilite porte au moins un champ comptable OU un RIB.
* (Le gating permission `accounting.view` reste applique en amont par l'appelant.)
*/
export function hasAccountingData(client: ClientDetail): boolean {
const draft = mapAccountingDraft(client)
const hasField = Object.values(draft).some(hasValue)
const hasRib = (client.ribs ?? []).length > 0
return hasField || hasRib
}
/**
* Onglets visibles en consultation (ERP-193, retour metier) : on masque les
* coquilles non implementees (Transport / Statistiques / Rapports / Echanges)
* ET tout onglet de donnees vide. L'ordre reproduit `buildClientFormTabKeys`.
* Retourne `[]` tant que le client n'est pas charge.
*/
export function clientConsultationVisibleTabs(
client: ClientDetail | null | undefined,
options: { canAccountingView: boolean },
): string[] {
if (!client) {
return []
}
const visible: string[] = []
if (hasInformationData(client)) {
visible.push('information')
}
if ((client.contacts ?? []).length > 0) {
visible.push('contact')
}
if ((client.addresses ?? []).length > 0) {
visible.push('address')
}
if (options.canAccountingView && hasAccountingData(client)) {
visible.push('accounting')
}
return visible
}
/**
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet
* `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta
@@ -292,6 +292,78 @@ export function mapAddressView(address: AddressRead): AddressView {
}
}
/**
* Vrai si une valeur scalaire porte une donnee affichable (non null/undefined,
* et non chaine vide apres trim). Sert aux predicats « onglet vide » ci-dessous.
*/
function hasValue(value: unknown): boolean {
if (value === null || value === undefined) {
return false
}
if (typeof value === 'string') {
return value.trim() !== ''
}
return true
}
/**
* Vrai si l'onglet Information porte au moins une donnee (volume previsionnel
* inclus, specifique fournisseur). ERP-193 : en consultation on masque les
* onglets vides, Information comprise.
*/
export function hasInformationData(supplier: SupplierDetail): boolean {
return [
supplier.description,
supplier.competitors,
supplier.foundedAt,
supplier.employeesCount,
supplier.revenueAmount,
supplier.profitAmount,
supplier.directorName,
supplier.volumeForecast,
].some(hasValue)
}
/**
* Vrai si l'onglet Comptabilite porte au moins un champ comptable OU un RIB.
* (Le gating permission `accounting.view` reste applique en amont par l'appelant.)
*/
export function hasAccountingData(supplier: SupplierDetail): boolean {
const draft = mapAccountingDraft(supplier)
const hasField = Object.values(draft).some(hasValue)
const hasRib = (supplier.ribs ?? []).length > 0
return hasField || hasRib
}
/**
* Onglets visibles en consultation (ERP-193, retour metier) : on masque les
* coquilles non implementees (Transport / Statistiques / Rapports / Echanges)
* ET tout onglet de donnees vide. L'ordre reproduit `buildSupplierFormTabKeys`.
* Retourne `[]` tant que le fournisseur n'est pas charge.
*/
export function supplierConsultationVisibleTabs(
supplier: SupplierDetail | null | undefined,
options: { canAccountingView: boolean },
): string[] {
if (!supplier) {
return []
}
const visible: string[] = []
if (hasInformationData(supplier)) {
visible.push('information')
}
if ((supplier.contacts ?? []).length > 0) {
visible.push('contacts')
}
if ((supplier.addresses ?? []).length > 0) {
visible.push('addresses')
}
if (options.canAccountingView && hasAccountingData(supplier)) {
visible.push('accounting')
}
return visible
}
/**
* Bouton « Modifier » : visible si l'utilisateur peut editer au moins un onglet
* `manage` (formulaire/onglets metier) OU `accounting.manage` (le role Compta