fix(commercial) : corrections formulaire client (ajout de bloc, onglet Information)

- Boutons « ajouter » Adresse/RIB desactives tant que le dernier bloc n'est pas valide (predicats isAddressValid/isRibComplete, reutilises par la validation d'onglet).

- Onglet Information : bouton Valider desactive si aucun champ rempli (pas de validation a vide) — creation uniquement, l'edition garde la possibilite de tout vider.

- Onglet Contact accessible des la creation du client (onglet Information facultatif).
This commit is contained in:
2026-06-08 09:58:34 +02:00
parent 786638a02f
commit 0df18da00c
4 changed files with 199 additions and 40 deletions
@@ -205,6 +205,7 @@
icon-name="mdi:add-bold" icon-name="mdi:add-bold"
icon-position="left" icon-position="left"
:label="t('commercial.clients.form.address.add')" :label="t('commercial.clients.form.address.add')"
:disabled="!canAddAddress"
@click="addAddress" @click="addAddress"
/> />
<MalioButton <MalioButton
@@ -334,6 +335,7 @@
icon-name="mdi:add-bold" icon-name="mdi:add-bold"
icon-position="left" icon-position="left"
:label="t('commercial.clients.form.accounting.addRib')" :label="t('commercial.clients.form.accounting.addRib')"
:disabled="!canAddRib"
@click="addRib" @click="addRib"
/> />
<MalioButton <MalioButton
@@ -410,15 +412,16 @@ import {
type MainFormDraft, type MainFormDraft,
} from '~/modules/commercial/utils/clientEdit' } from '~/modules/commercial/utils/clientEdit'
import { import {
addressTypeFromFlags,
buildClientFormTabKeys, buildClientFormTabKeys,
hasAllRequiredAccountingFields, hasAllRequiredAccountingFields,
hasAtLeastOneValidContact, hasAtLeastOneValidContact,
isAddressValid,
isBankRequiredForPaymentType, isBankRequiredForPaymentType,
isBillingEmailRequired, isBillingEmailRequired,
isContactBlank, isContactBlank,
isContactNamed, isContactNamed,
isRibBlank, isRibBlank,
isRibComplete,
isRibRequiredForPaymentType, isRibRequiredForPaymentType,
} from '~/modules/commercial/utils/clientFormRules' } from '~/modules/commercial/utils/clientFormRules'
import { import {
@@ -787,18 +790,17 @@ async function submitContacts(): Promise<void> {
// ── Onglet Adresse ─────────────────────────────────────────────────────────── // ── Onglet Adresse ───────────────────────────────────────────────────────────
const canValidateAddresses = computed(() => const canValidateAddresses = computed(() =>
addresses.value.length > 0 addresses.value.length > 0 && addresses.value.every(isAddressValid),
&& addresses.value.every((a) => {
const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== ''
return addressTypeFromFlags(a) !== null
&& a.siteIris.length >= 1
&& a.categoryIris.length >= 1
&& (!isBillingEmailRequired(a) || filledBillingEmail)
}),
) )
// « + Adresse » desactive tant que la derniere adresse n'est pas valide.
const canAddAddress = computed(() => {
const last = addresses.value[addresses.value.length - 1]
return last !== undefined && isAddressValid(last)
})
function addAddress(): void { function addAddress(): void {
addresses.value.push(emptyAddress()) if (canAddAddress.value) addresses.value.push(emptyAddress())
} }
function askRemoveAddress(index: number): void { function askRemoveAddress(index: number): void {
@@ -875,20 +877,21 @@ function onPaymentTypeChange(value: string | number | null): void {
if (!isBankRequired.value) accounting.bankIri = null if (!isBankRequired.value) accounting.bankIri = null
} }
function ribIsComplete(rib: { label: string | null, bic: string | null, iban: string | null }): boolean {
const filled = (v: string | null) => v !== null && v.trim() !== ''
return filled(rib.label) && filled(rib.bic) && filled(rib.iban)
}
const canValidateAccounting = computed(() => { const canValidateAccounting = computed(() => {
if (!hasAllRequiredAccountingFields(accounting)) return false if (!hasAllRequiredAccountingFields(accounting)) return false
if (isBankRequired.value && accounting.bankIri === null) return false if (isBankRequired.value && accounting.bankIri === null) return false
if (isRibRequired.value && !ribs.value.some(ribIsComplete)) return false if (isRibRequired.value && !ribs.value.some(isRibComplete)) return false
return true return true
}) })
// « + RIB » desactive tant que le dernier bloc RIB n'est pas complet.
const canAddRib = computed(() => {
const last = ribs.value[ribs.value.length - 1]
return last !== undefined && isRibComplete(last)
})
function addRib(): void { function addRib(): void {
ribs.value.push(emptyRib()) if (canAddRib.value) ribs.value.push(emptyRib())
} }
function askRemoveRib(index: number): void { function askRemoveRib(index: number): void {
@@ -134,13 +134,15 @@
/> />
</div> </div>
<div v-if="!isValidated('information')" class="mt-12 flex justify-center"> <div v-if="!isValidated('information')" class="mt-12 flex justify-center">
<!-- Desactive tant que le client n'est pas cree : evite un PATCH <!-- Desactive tant que le client n'est pas cree (evite un PATCH
avant le POST si l'utilisateur clique trop tot (le panneau avant le POST si clic trop tot, Information etant l'onglet
Information est l'onglet actif par defaut). --> actif par defaut) OU si aucun champ n'est rempli : onglet
facultatif, mais pas de validation a vide (on passe alors
directement a Contact). -->
<MalioButton <MalioButton
variant="primary" variant="primary"
:label="t('commercial.clients.form.submit')" :label="t('commercial.clients.form.submit')"
:disabled="tabSubmitting || clientId === null" :disabled="tabSubmitting || clientId === null || !canValidateInformation"
@click="submitInformation" @click="submitInformation"
/> />
</div> </div>
@@ -204,6 +206,7 @@
icon-name="mdi:add-bold" icon-name="mdi:add-bold"
icon-position="left" icon-position="left"
:label="t('commercial.clients.form.address.add')" :label="t('commercial.clients.form.address.add')"
:disabled="!canAddAddress"
@click="addAddress" @click="addAddress"
/> />
<MalioButton <MalioButton
@@ -333,6 +336,7 @@
icon-name="mdi:add-bold" icon-name="mdi:add-bold"
icon-position="left" icon-position="left"
:label="t('commercial.clients.form.accounting.addRib')" :label="t('commercial.clients.form.accounting.addRib')"
:disabled="!canAddRib"
@click="addRib" @click="addRib"
/> />
<MalioButton <MalioButton
@@ -380,16 +384,18 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useClientReferentials, type RefOption } from '~/modules/commercial/composables/useClientReferentials' import { useClientReferentials, type RefOption } from '~/modules/commercial/composables/useClientReferentials'
import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors' import { useClientFormErrors } from '~/modules/commercial/composables/useClientFormErrors'
import { import {
addressTypeFromFlags,
buildClientFormTabKeys, buildClientFormTabKeys,
CLIENT_FORM_PLACEHOLDER_TABS, CLIENT_FORM_PLACEHOLDER_TABS,
hasAllRequiredAccountingFields, hasAllRequiredAccountingFields,
hasAtLeastOneInformationField,
hasAtLeastOneValidContact, hasAtLeastOneValidContact,
isAddressValid,
isBankRequiredForPaymentType, isBankRequiredForPaymentType,
isBillingEmailRequired, isBillingEmailRequired,
isContactBlank, isContactBlank,
isContactNamed, isContactNamed,
isRibBlank, isRibBlank,
isRibComplete,
isRibRequiredForPaymentType, isRibRequiredForPaymentType,
} from '~/modules/commercial/utils/clientFormRules' } from '~/modules/commercial/utils/clientFormRules'
import { import {
@@ -538,7 +544,9 @@ async function submitMain(): Promise<void> {
main.companyName = created.companyName ?? main.companyName main.companyName = created.companyName ?? main.companyName
mainLocked.value = true mainLocked.value = true
unlockedIndex.value = 0 // Information est facultatif : on deverrouille jusqu'a Contact (index 1)
// pour que l'utilisateur puisse y aller directement sans valider Information.
unlockedIndex.value = tabIndex('contact')
activeTab.value = 'information' activeTab.value = 'information'
toast.success({ title: t('commercial.clients.toast.createSuccess') }) toast.success({ title: t('commercial.clients.toast.createSuccess') })
} }
@@ -625,9 +633,12 @@ const information = reactive({
directorName: null as string | null, directorName: null as string | null,
}) })
// Onglet facultatif, mais pas de validation « a vide » : au moins un champ rempli.
const canValidateInformation = computed(() => hasAtLeastOneInformationField(information))
/** PATCH /clients/{id} — mode strict : uniquement les champs du groupe information. */ /** PATCH /clients/{id} — mode strict : uniquement les champs du groupe information. */
async function submitInformation(): Promise<void> { async function submitInformation(): Promise<void> {
if (clientId.value === null || tabSubmitting.value) return if (clientId.value === null || tabSubmitting.value || !canValidateInformation.value) return
tabSubmitting.value = true tabSubmitting.value = true
informationErrors.clearErrors() informationErrors.clearErrors()
try { try {
@@ -753,18 +764,17 @@ const countryOptions: RefOption[] = [
// Type d'adresse (Select) obligatoire + RG-1.10 (>= 1 site) + RG-1.11 (email // Type d'adresse (Select) obligatoire + RG-1.10 (>= 1 site) + RG-1.11 (email
// facturation si Facturation) sur chaque adresse. // facturation si Facturation) sur chaque adresse.
const canValidateAddresses = computed(() => const canValidateAddresses = computed(() =>
addresses.value.length > 0 addresses.value.length > 0 && addresses.value.every(isAddressValid),
&& addresses.value.every((a) => {
const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== ''
return addressTypeFromFlags(a) !== null
&& a.siteIris.length >= 1
&& a.categoryIris.length >= 1
&& (!isBillingEmailRequired(a) || filledBillingEmail)
}),
) )
// « + Adresse » desactive tant que la derniere adresse n'est pas valide.
const canAddAddress = computed(() => {
const last = addresses.value[addresses.value.length - 1]
return last !== undefined && isAddressValid(last)
})
function addAddress(): void { function addAddress(): void {
addresses.value.push(emptyAddress()) if (canAddAddress.value) addresses.value.push(emptyAddress())
} }
function askRemoveAddress(index: number): void { function askRemoveAddress(index: number): void {
@@ -859,23 +869,24 @@ function onPaymentTypeChange(value: string | number | null): void {
if (!isBankRequired.value) accounting.bankIri = null if (!isBankRequired.value) accounting.bankIri = null
} }
function ribIsComplete(rib: RibFormDraft): boolean {
const filled = (v: string | null) => v !== null && v.trim() !== ''
return filled(rib.label) && filled(rib.bic) && filled(rib.iban)
}
// RG-1.30 : les 6 champs scalaires obligatoires (comme les onglets Contact / // RG-1.30 : les 6 champs scalaires obligatoires (comme les onglets Contact /
// Adresse, le bouton reste desactive tant que l'onglet n'est pas complet). // Adresse, le bouton reste desactive tant que l'onglet n'est pas complet).
// RG-1.12 : banque requise si VIREMENT. RG-1.13 : >= 1 RIB complet si LCR. // RG-1.12 : banque requise si VIREMENT. RG-1.13 : >= 1 RIB complet si LCR.
const canValidateAccounting = computed(() => { const canValidateAccounting = computed(() => {
if (!hasAllRequiredAccountingFields(accounting)) return false if (!hasAllRequiredAccountingFields(accounting)) return false
if (isBankRequired.value && (accounting.bankIri === null)) return false if (isBankRequired.value && (accounting.bankIri === null)) return false
if (isRibRequired.value && !ribs.value.some(ribIsComplete)) return false if (isRibRequired.value && !ribs.value.some(isRibComplete)) return false
return true return true
}) })
// « + RIB » desactive tant que le dernier bloc RIB n'est pas complet.
const canAddRib = computed(() => {
const last = ribs.value[ribs.value.length - 1]
return last !== undefined && isRibComplete(last)
})
function addRib(): void { function addRib(): void {
ribs.value.push(emptyRib()) if (canAddRib.value) ribs.value.push(emptyRib())
} }
function askRemoveRib(index: number): void { function askRemoveRib(index: number): void {
@@ -7,14 +7,18 @@ import {
canSelectDeliveryOrBilling, canSelectDeliveryOrBilling,
canSelectProspect, canSelectProspect,
hasAllRequiredAccountingFields, hasAllRequiredAccountingFields,
hasAtLeastOneInformationField,
hasAtLeastOneValidContact, hasAtLeastOneValidContact,
isAddressValid,
isBankRequiredForPaymentType, isBankRequiredForPaymentType,
isBillingEmailRequired, isBillingEmailRequired,
isBlankRow, isBlankRow,
isContactBlank, isContactBlank,
isContactNamed, isContactNamed,
isRibBlank, isRibBlank,
isRibComplete,
isRibRequiredForPaymentType, isRibRequiredForPaymentType,
type AddressValidityDraft,
type ContactDraft, type ContactDraft,
type ContactFillableDraft, type ContactFillableDraft,
} from '../clientFormRules' } from '../clientFormRules'
@@ -271,3 +275,79 @@ describe('hasAllRequiredAccountingFields (RG-1.30)', () => {
})).toBe(false) })).toBe(false)
}) })
}) })
describe('hasAtLeastOneInformationField (pas de validation a vide a la creation)', () => {
const blank = {
description: null,
competitors: null,
foundedAt: null,
employeesCount: null,
revenueAmount: null,
profitAmount: null,
directorName: null,
}
it('faux quand aucun champ n\'est rempli (onglet vierge)', () => {
expect(hasAtLeastOneInformationField(blank)).toBe(false)
expect(hasAtLeastOneInformationField({ ...blank, description: ' ' })).toBe(false)
})
it('vrai des qu\'un champ porte une valeur', () => {
expect(hasAtLeastOneInformationField({ ...blank, description: 'Acteur majeur' })).toBe(true)
expect(hasAtLeastOneInformationField({ ...blank, employeesCount: '42' })).toBe(true)
expect(hasAtLeastOneInformationField({ ...blank, foundedAt: '2020-01-01' })).toBe(true)
})
})
describe('isAddressValid (gating « + Adresse » + validation onglet)', () => {
/** Adresse de livraison valide (type + site + categorie ; pas de facturation). */
function validDelivery(): AddressValidityDraft {
return {
isProspect: false,
isDelivery: true,
isBilling: false,
categoryIris: ['/api/client_categories/1'],
siteIris: ['/api/sites/1'],
billingEmail: null,
}
}
it('vrai quand type + >= 1 site + >= 1 categorie (sans facturation)', () => {
expect(isAddressValid(validDelivery())).toBe(true)
})
it('faux si aucun drapeau (type d\'adresse non renseigne, amorce vierge)', () => {
expect(isAddressValid({ ...validDelivery(), isDelivery: false })).toBe(false)
})
it('faux si aucun site (RG-1.10)', () => {
expect(isAddressValid({ ...validDelivery(), siteIris: [] })).toBe(false)
})
it('faux si aucune categorie', () => {
expect(isAddressValid({ ...validDelivery(), categoryIris: [] })).toBe(false)
})
it('RG-1.11 : email de facturation obligatoire quand l\'adresse est de facturation', () => {
const billing: AddressValidityDraft = { ...validDelivery(), isBilling: true }
expect(isAddressValid({ ...billing, billingEmail: null })).toBe(false)
expect(isAddressValid({ ...billing, billingEmail: ' ' })).toBe(false)
expect(isAddressValid({ ...billing, billingEmail: 'facture@acme.fr' })).toBe(true)
})
})
describe('isRibComplete (gating « + RIB » + RG-1.13)', () => {
it('vrai quand label + BIC + IBAN sont remplis', () => {
expect(isRibComplete({ label: 'Compte courant', bic: 'BNPAFRPP', iban: 'FR1420041010050500013M02606' })).toBe(true)
})
it('faux si un champ manque (null ou vide apres trim)', () => {
expect(isRibComplete({ label: null, bic: 'BNPAFRPP', iban: 'FR14...' })).toBe(false)
expect(isRibComplete({ label: 'Compte', bic: ' ', iban: 'FR14...' })).toBe(false)
expect(isRibComplete({ label: 'Compte', bic: 'BNPAFRPP', iban: null })).toBe(false)
})
it('faux pour un bloc totalement vide (amorce)', () => {
expect(isRibComplete({ label: null, bic: null, iban: null })).toBe(false)
})
})
@@ -138,6 +138,16 @@ export function isRibBlank(rib: RibFillableDraft): boolean {
return isBlankRow([rib.label, rib.bic, rib.iban]) return isBlankRow([rib.label, rib.bic, rib.iban])
} }
/**
* RG-1.13 : un RIB est complet quand ses trois champs sont remplis (label, BIC,
* IBAN). Predicat par-bloc partage entre le gating du bouton « + RIB » (le
* dernier bloc doit etre complet avant d'en ajouter un autre) et la validation
* de l'onglet (au moins un RIB complet si reglement LCR).
*/
export function isRibComplete(rib: RibFillableDraft): boolean {
return isFilled(rib.label) && isFilled(rib.bic) && isFilled(rib.iban)
}
/** /**
* RG-1.06/07/08 : une adresse de prospection est exclusive d'une adresse de * RG-1.06/07/08 : une adresse de prospection est exclusive d'une adresse de
* livraison/facturation. Prospect n'est selectionnable que si ni Livraison ni * livraison/facturation. Prospect n'est selectionnable que si ni Livraison ni
@@ -226,6 +236,31 @@ export function addressTypeFromFlags(flags: AddressFlagsDraft): AddressType | nu
return null return null
} }
/**
* Sous-ensemble d'une adresse necessaire a sa validite par-bloc : drapeaux
* d'usage (pour le type + l'email de facturation conditionnel), sites et
* categories rattaches, email de facturation.
*/
export interface AddressValidityDraft extends AddressFlagsDraft {
categoryIris: string[]
siteIris: string[]
billingEmail: string | null
}
/**
* Validite par-bloc d'une adresse : type renseigne (RG-1.06/07/08), >= 1 site
* (RG-1.10), >= 1 categorie, et email de facturation rempli si l'adresse est de
* facturation (RG-1.11). Predicat partage entre le gating du bouton « + Adresse »
* (le dernier bloc doit etre valide avant d'en ajouter un autre) et la
* validation de l'onglet (toutes les adresses valides).
*/
export function isAddressValid(address: AddressValidityDraft): boolean {
return addressTypeFromFlags(address) !== null
&& address.siteIris.length >= 1
&& address.categoryIris.length >= 1
&& (!isBillingEmailRequired(address) || isFilled(address.billingEmail))
}
/** Code stable du type de reglement « virement » (cf. PaymentType.code, RG-1.12). */ /** Code stable du type de reglement « virement » (cf. PaymentType.code, RG-1.12). */
const PAYMENT_TYPE_TRANSFER = 'VIREMENT' const PAYMENT_TYPE_TRANSFER = 'VIREMENT'
@@ -248,6 +283,36 @@ export function isRibRequiredForPaymentType(code: string | null | undefined): bo
return code === PAYMENT_TYPE_LCR return code === PAYMENT_TYPE_LCR
} }
/** Champs saisissables de l'onglet Information (tous facultatifs). */
export interface InformationFieldsDraft {
description: string | null
competitors: string | null
foundedAt: string | null
employeesCount: string | null
revenueAmount: string | null
profitAmount: string | null
directorName: string | null
}
/**
* Vrai si au moins un champ de l'onglet Information est rempli. L'onglet est
* facultatif (aucun champ obligatoire), mais on n'autorise pas une validation
* « a vide » a la creation : sans donnee, rien a enregistrer, l'utilisateur
* passe directement a l'onglet Contact. (En edition, vider tous les champs reste
* une action legitime : ce gate n'y est pas applique.)
*/
export function hasAtLeastOneInformationField(information: InformationFieldsDraft): boolean {
return !isBlankRow([
information.description,
information.competitors,
information.foundedAt,
information.employeesCount,
information.revenueAmount,
information.profitAmount,
information.directorName,
])
}
/** Sous-ensemble du brouillon comptable portant les six champs obligatoires. */ /** Sous-ensemble du brouillon comptable portant les six champs obligatoires. */
export interface AccountingRequiredDraft { export interface AccountingRequiredDraft {
siren: string | null siren: string | null