diff --git a/frontend/modules/commercial/pages/clients/[id]/edit.vue b/frontend/modules/commercial/pages/clients/[id]/edit.vue
index b5387f2..2600aa4 100644
--- a/frontend/modules/commercial/pages/clients/[id]/edit.vue
+++ b/frontend/modules/commercial/pages/clients/[id]/edit.vue
@@ -205,6 +205,7 @@
icon-name="mdi:add-bold"
icon-position="left"
:label="t('commercial.clients.form.address.add')"
+ :disabled="!canAddAddress"
@click="addAddress"
/>
{
// ── Onglet Adresse ───────────────────────────────────────────────────────────
const canValidateAddresses = computed(() =>
- addresses.value.length > 0
- && 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)
- }),
+ addresses.value.length > 0 && addresses.value.every(isAddressValid),
)
+// « + 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 {
- addresses.value.push(emptyAddress())
+ if (canAddAddress.value) addresses.value.push(emptyAddress())
}
function askRemoveAddress(index: number): void {
@@ -875,20 +877,21 @@ function onPaymentTypeChange(value: string | number | null): void {
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(() => {
if (!hasAllRequiredAccountingFields(accounting)) 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
})
+// « + 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 {
- ribs.value.push(emptyRib())
+ if (canAddRib.value) ribs.value.push(emptyRib())
}
function askRemoveRib(index: number): void {
diff --git a/frontend/modules/commercial/pages/clients/new.vue b/frontend/modules/commercial/pages/clients/new.vue
index 720f40e..794384e 100644
--- a/frontend/modules/commercial/pages/clients/new.vue
+++ b/frontend/modules/commercial/pages/clients/new.vue
@@ -134,13 +134,15 @@
/>
-
+
@@ -204,6 +206,7 @@
icon-name="mdi:add-bold"
icon-position="left"
:label="t('commercial.clients.form.address.add')"
+ :disabled="!canAddAddress"
@click="addAddress"
/>
{
main.companyName = created.companyName ?? main.companyName
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'
toast.success({ title: t('commercial.clients.toast.createSuccess') })
}
@@ -625,9 +633,12 @@ const information = reactive({
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. */
async function submitInformation(): Promise {
- if (clientId.value === null || tabSubmitting.value) return
+ if (clientId.value === null || tabSubmitting.value || !canValidateInformation.value) return
tabSubmitting.value = true
informationErrors.clearErrors()
try {
@@ -753,18 +764,17 @@ const countryOptions: RefOption[] = [
// Type d'adresse (Select) obligatoire + RG-1.10 (>= 1 site) + RG-1.11 (email
// facturation si Facturation) sur chaque adresse.
const canValidateAddresses = computed(() =>
- addresses.value.length > 0
- && 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)
- }),
+ addresses.value.length > 0 && addresses.value.every(isAddressValid),
)
+// « + 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 {
- addresses.value.push(emptyAddress())
+ if (canAddAddress.value) addresses.value.push(emptyAddress())
}
function askRemoveAddress(index: number): void {
@@ -859,23 +869,24 @@ function onPaymentTypeChange(value: string | number | null): void {
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 /
// 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.
const canValidateAccounting = computed(() => {
if (!hasAllRequiredAccountingFields(accounting)) 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
})
+// « + 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 {
- ribs.value.push(emptyRib())
+ if (canAddRib.value) ribs.value.push(emptyRib())
}
function askRemoveRib(index: number): void {
diff --git a/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts b/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts
index c8929c9..2945a1b 100644
--- a/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts
+++ b/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts
@@ -7,14 +7,18 @@ import {
canSelectDeliveryOrBilling,
canSelectProspect,
hasAllRequiredAccountingFields,
+ hasAtLeastOneInformationField,
hasAtLeastOneValidContact,
+ isAddressValid,
isBankRequiredForPaymentType,
isBillingEmailRequired,
isBlankRow,
isContactBlank,
isContactNamed,
isRibBlank,
+ isRibComplete,
isRibRequiredForPaymentType,
+ type AddressValidityDraft,
type ContactDraft,
type ContactFillableDraft,
} from '../clientFormRules'
@@ -271,3 +275,79 @@ describe('hasAllRequiredAccountingFields (RG-1.30)', () => {
})).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)
+ })
+})
diff --git a/frontend/modules/commercial/utils/clientFormRules.ts b/frontend/modules/commercial/utils/clientFormRules.ts
index ea838b4..755bd79 100644
--- a/frontend/modules/commercial/utils/clientFormRules.ts
+++ b/frontend/modules/commercial/utils/clientFormRules.ts
@@ -138,6 +138,16 @@ export function isRibBlank(rib: RibFillableDraft): boolean {
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
* livraison/facturation. Prospect n'est selectionnable que si ni Livraison ni
@@ -226,6 +236,31 @@ export function addressTypeFromFlags(flags: AddressFlagsDraft): AddressType | nu
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). */
const PAYMENT_TYPE_TRANSFER = 'VIREMENT'
@@ -248,6 +283,36 @@ export function isRibRequiredForPaymentType(code: string | null | undefined): bo
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. */
export interface AccountingRequiredDraft {
siren: string | null