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