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