/** * Regles metier pures de l'ecran « Ajouter un client » (M1 Commercial). * * Centralisees ici (hors composant) pour rester testables unitairement et * partagees entre la page de creation et les futurs ecrans d'edition (1.11/1.12). * Ces helpers ne touchent ni a l'API ni a l'etat reactif : ils prennent des * brouillons « plats » et retournent des booleens / nouveaux objets. * * Le back reste la source de verite (les RG sont re-validees serveur) ; ces * regles ne servent qu'au feedback UI immediat (gating de boutons, visibilite). * * NOTE RG-1.04 (Information obligatoire pour la Commerciale) : volontairement * NON miroite cote front pour l'instant. Le payload /api/me ne porte pas le code * de role (roles = IRIs opaques) et Bureau partage les memes permissions que * Commerciale : aucun signal fiable pour distinguer le role cote front. Le back * (ClientProcessor, via BusinessRoleAware) applique la regle de maniere fiable ; * a rebrancher ici des qu'un code de role sera expose dans /api/me. */ /** * Onglets « coquille » (non encore implementes) : frame vide, passage * automatique a l'onglet suivant (decision Tristan 28/05). */ export const CLIENT_FORM_PLACEHOLDER_TABS = ['transport', 'statistics', 'reports', 'exchanges'] as const /** * Onglets affiches uniquement en MODIFICATION (selon le role), jamais a la * creation : Statistiques / Rapports / Echanges. A rebrancher dans les ecrans * d'edition (1.11/1.12) via l'option `includeEditOnlyTabs`. */ export const CLIENT_FORM_EDIT_ONLY_TABS = ['statistics', 'reports', 'exchanges'] as const /** * Construit l'ordre des onglets du formulaire client. * - L'onglet Comptabilite n'est present que si l'utilisateur a `accounting.view` * (Bureau / Commerciale ne le voient pas). * - Les onglets edit-only (Statistiques / Rapports / Echanges) sont exclus par * defaut (creation) ; passer `includeEditOnlyTabs: true` pour les afficher en * modification. * Ordre aligne sur la spec M1 § Ecran « Ajouter un client ». */ export function buildClientFormTabKeys( canAccountingView: boolean, options: { includeEditOnlyTabs?: boolean } = {}, ): string[] { const keys = ['information', 'contact', 'address', 'transport'] if (canAccountingView) { keys.push('accounting') } if (options.includeEditOnlyTabs) { keys.push(...CLIENT_FORM_EDIT_ONLY_TABS) } return keys } /** Sous-ensemble d'un contact necessaire aux regles de nommage (RG-1.05/1.14). */ export interface ContactDraft { firstName: string | null lastName: string | null } /** Drapeaux d'usage d'une adresse (RG-1.06/07/08/11). */ export interface AddressFlagsDraft { isProspect: boolean isDelivery: boolean isBilling: boolean } /** Vrai si une chaine porte au moins un caractere non-espace. */ function isFilled(value: string | null | undefined): boolean { return value !== null && value !== undefined && value.trim() !== '' } /** * RG-1.05 : un contact est valide des qu'il porte un nom OU un prenom. */ export function isContactNamed(contact: ContactDraft): boolean { return isFilled(contact.firstName) || isFilled(contact.lastName) } /** * RG-1.14 : l'onglet Contact ne peut etre finalise que s'il reste au moins un * contact nomme (nom ou prenom). */ export function hasAtLeastOneValidContact(contacts: ContactDraft[]): boolean { return contacts.some(isContactNamed) } /** * Primitive reutilisable : vrai si TOUTES les valeurs fournies sont vides (null / * undefined / espaces uniquement). Sert a detecter un bloc de collection * totalement vide (amorce non remplie). Un bloc qui porte la moindre donnee * n'est PAS « blank » : il doit etre soumis pour declencher sa 422 inline plutot * que d'etre saute silencieusement. */ export function isBlankRow(values: (string | null | undefined)[]): boolean { return values.every(value => !isFilled(value)) } /** Champs saisissables d'un bloc contact (pour detecter un bloc totalement vide). */ export interface ContactFillableDraft extends ContactDraft { jobTitle: string | null phonePrimary: string | null phoneSecondary: string | null email: string | null } /** * Vrai si AUCUN champ saisissable du bloc contact n'est rempli. Distingue un bloc * d'amorce vide (a ignorer au submit) d'un bloc partiellement rempli sans nom * (email / telephone / fonction seul) : ce dernier doit etre soumis pour * declencher la 422 RG-1.05 (« prenom ou nom obligatoire ») affichee inline. */ export function isContactBlank(contact: ContactFillableDraft): boolean { return isBlankRow([ contact.firstName, contact.lastName, contact.jobTitle, contact.phonePrimary, contact.phoneSecondary, contact.email, ]) } /** Champs saisissables d'un bloc RIB (pour detecter un bloc totalement vide). */ export interface RibFillableDraft { label: string | null bic: string | null iban: string | null } /** * Vrai si AUCUN champ du bloc RIB n'est rempli. Un RIB partiellement rempli (ex. * IBAN seul) n'est PAS « blank » : il doit etre soumis pour declencher les 422 * NotBlank (label / bic / iban) inline plutot que d'etre saute silencieusement. */ export function isRibBlank(rib: RibFillableDraft): boolean { return isBlankRow([rib.label, rib.bic, 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 * Facturation ne sont coches. */ export function canSelectProspect(flags: AddressFlagsDraft): boolean { return !flags.isDelivery && !flags.isBilling } /** * RG-1.06/07/08 : Livraison et Facturation ne sont selectionnables que si * Prospect n'est pas coche. */ export function canSelectDeliveryOrBilling(flags: AddressFlagsDraft): boolean { return !flags.isProspect } /** * Applique l'exclusivite Prospect / (Livraison|Facturation) au changement d'un * drapeau. Cocher Prospect efface Livraison + Facturation ; cocher Livraison ou * Facturation efface Prospect. Decocher n'a aucun effet de bord. Retourne un * nouvel objet (pas de mutation de l'entree). */ export function applyProspectExclusivity( flags: AddressFlagsDraft, field: keyof AddressFlagsDraft, value: boolean, ): AddressFlagsDraft { const next: AddressFlagsDraft = { ...flags, [field]: value } if (value && field === 'isProspect') { next.isDelivery = false next.isBilling = false } else if (value && (field === 'isDelivery' || field === 'isBilling')) { next.isProspect = false } return next } /** * RG-1.11 : l'email de facturation n'est visible/obligatoire que si l'adresse * est une adresse de facturation. */ export function isBillingEmailRequired(flags: AddressFlagsDraft): boolean { return flags.isBilling } /** * Type d'adresse expose a l'utilisateur (Select unique remplacant les trois * cases a cocher). Sucre purement front : le back continue de recevoir les * drapeaux isProspect / isDelivery / isBilling (aucune RG modifiee). Les seules * combinaisons proposees respectent l'exclusivite Prospect (RG-1.06/07/08). */ export type AddressType = 'prospect' | 'delivery' | 'billing' | 'delivery_billing' /** * Mappe le type d'adresse choisi vers les trois drapeaux back. * « Adresse + Facturation » = livraison ET facturation sur la meme adresse. */ export function addressFlagsFromType(type: AddressType): AddressFlagsDraft { switch (type) { case 'prospect': return { isProspect: true, isDelivery: false, isBilling: false } case 'delivery': return { isProspect: false, isDelivery: true, isBilling: false } case 'billing': return { isProspect: false, isDelivery: false, isBilling: true } case 'delivery_billing': return { isProspect: false, isDelivery: true, isBilling: true } } } /** * Reconstruit le type d'adresse a partir des drapeaux (consultation / edition * d'une adresse persistee, ou amorce vierge). Retourne null si aucun drapeau * n'est positionne — le Select reste alors a saisir (et bloque la validation). */ export function addressTypeFromFlags(flags: AddressFlagsDraft): AddressType | null { if (flags.isProspect) return 'prospect' if (flags.isDelivery && flags.isBilling) return 'delivery_billing' if (flags.isDelivery) return 'delivery' if (flags.isBilling) return 'billing' return null } /** Code stable du type de reglement « virement » (cf. PaymentType.code, RG-1.12). */ const PAYMENT_TYPE_TRANSFER = 'VIREMENT' /** Code stable du type de reglement « lettre de change » (RG-1.13). */ const PAYMENT_TYPE_LCR = 'LCR' /** * RG-1.12 : la banque est obligatoire lorsque le type de reglement est un * virement. */ export function isBankRequiredForPaymentType(code: string | null | undefined): boolean { return code === PAYMENT_TYPE_TRANSFER } /** * RG-1.13 : au moins un RIB complet est obligatoire lorsque le type de reglement * est une LCR. */ export function isRibRequiredForPaymentType(code: string | null | undefined): boolean { return code === PAYMENT_TYPE_LCR } /** Sous-ensemble du brouillon comptable portant les six champs obligatoires. */ export interface AccountingRequiredDraft { siren: string | null accountNumber: string | null nTva: string | null tvaModeIri: string | null paymentDelayIri: string | null paymentTypeIri: string | null } /** * RG-1.30 : les six champs scalaires de l'onglet Comptabilite sont obligatoires * pour valider l'onglet (SIREN, N de compte, Mode de TVA, N de TVA, Delai de * reglement, Type de reglement). bank / RIB restent conditionnels (RG-1.12 / * RG-1.13) et sont evalues a part. Miroir front du * ClientAccountingCompletenessValidator : meme gate que les onglets Contact / * Adresse (bouton « Valider » desactive tant que l'onglet n'est pas complet). */ export function hasAllRequiredAccountingFields(accounting: AccountingRequiredDraft): boolean { const filled = (v: string | null): boolean => v !== null && v.trim() !== '' return filled(accounting.siren) && filled(accounting.accountNumber) && filled(accounting.nTva) && filled(accounting.tvaModeIri) && filled(accounting.paymentDelayIri) && filled(accounting.paymentTypeIri) }