diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index b3c0490..c21d4ab 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -99,6 +99,90 @@ "phoneFormat": "Format de téléphone invalide (attendu : XX XX XX XX XX).", "emailFormat": "Format d'email invalide.", "addressCategoryForbidden": "Une catégorie « Distributeur » ou « Courtier » ne peut pas qualifier une adresse." + }, + "form": { + "title": "Ajouter un client", + "back": "Précédent", + "submit": "Valider", + "duplicateCompany": "Un client portant ce nom de société existe déjà.", + "main": { + "companyName": "Nom du client (Entreprise)", + "firstName": "Prénom du contact principal", + "lastName": "Nom du contact principal", + "email": "Email", + "phonePrimary": "Téléphone", + "phoneSecondary": "Téléphone (2)", + "addPhone": "Ajouter un numéro", + "categories": "Catégorie", + "relation": "Distributeur / Courtier", + "relationDistributor": "Dépend du distributeur", + "relationBroker": "Dépend du courtier", + "distributorName": "Nom du distributeur", + "brokerName": "Nom du courtier", + "triageService": "Prestation de triage" + }, + "information": { + "description": "Description", + "competitors": "Concurrent", + "foundedAt": "Date de création", + "employeesCount": "Nombre de salariés", + "revenueAmount": "CA", + "profitAmount": "Résultat", + "directorName": "Dirigeant" + }, + "contact": { + "title": "Contact {n}", + "lastName": "Nom", + "firstName": "Prénom", + "jobTitle": "Fonction", + "email": "Email", + "phonePrimary": "Téléphone", + "phoneSecondary": "Téléphone (2)", + "addPhone": "Ajouter un numéro", + "remove": "Supprimer le contact", + "add": "Nouveau contact" + }, + "address": { + "title": "Adresse {n}", + "prospect": "Prospect", + "delivery": "Adresse de livraison", + "billing": "Facturation", + "categories": "Catégorie", + "country": "Pays", + "postalCode": "Code postal", + "city": "Ville", + "street": "Adresse", + "streetComplement": "Adresse complémentaire", + "sites": "Sites Starseed", + "contacts": "Contact(s) rattaché(s)", + "billingEmail": "Email de facturation", + "remove": "Supprimer l'adresse", + "add": "Nouvelle adresse", + "degraded": "Service d'adresse indisponible : saisie de la ville et de l'adresse en mode libre." + }, + "accounting": { + "siren": "SIREN", + "accountNumber": "Numéro de compte", + "tvaMode": "Mode de TVA", + "nTva": "N° de TVA", + "paymentDelay": "Délai de règlement", + "paymentType": "Type de règlement", + "bank": "Banque", + "ribTitle": "RIB {n}", + "ribLabel": "Libellé", + "ribBic": "BIC", + "ribIban": "IBAN", + "addRib": "Ajouter un RIB", + "removeRib": "Supprimer le RIB" + }, + "confirmDelete": { + "title": "Confirmer la suppression", + "contact": "Supprimer ce contact ?", + "address": "Supprimer cette adresse ?", + "rib": "Supprimer ce RIB ?", + "cancel": "Annuler", + "confirm": "Confirmer" + } } } }, diff --git a/frontend/modules/commercial/components/ClientAddressBlock.vue b/frontend/modules/commercial/components/ClientAddressBlock.vue new file mode 100644 index 0000000..53a93da --- /dev/null +++ b/frontend/modules/commercial/components/ClientAddressBlock.vue @@ -0,0 +1,298 @@ + + + diff --git a/frontend/modules/commercial/components/ClientContactBlock.vue b/frontend/modules/commercial/components/ClientContactBlock.vue new file mode 100644 index 0000000..23c2bfd --- /dev/null +++ b/frontend/modules/commercial/components/ClientContactBlock.vue @@ -0,0 +1,97 @@ + + + diff --git a/frontend/modules/commercial/components/TabPlaceholderBlank.vue b/frontend/modules/commercial/components/TabPlaceholderBlank.vue new file mode 100644 index 0000000..5375cb6 --- /dev/null +++ b/frontend/modules/commercial/components/TabPlaceholderBlank.vue @@ -0,0 +1,14 @@ + + + diff --git a/frontend/modules/commercial/composables/useClientReferentials.ts b/frontend/modules/commercial/composables/useClientReferentials.ts new file mode 100644 index 0000000..941b111 --- /dev/null +++ b/frontend/modules/commercial/composables/useClientReferentials.ts @@ -0,0 +1,141 @@ +import { ref } from 'vue' + +/** + * Charge les referentiels (listes courtes) alimentant les selects de l'ecran + * « Ajouter un client » : categories, sites, modes de TVA, delais et types de + * reglement, banques, et les listes distributeurs / courtiers. + * + * Toutes les collections sont recuperees en entier via l'echappatoire prevue + * `?pagination=false` (referentiels de quelques dizaines d'entrees max), avec + * l'en-tete `Accept: application/ld+json` impose par API Platform 4 pour obtenir + * l'enveloppe Hydra (`member`). Les valeurs d'option sont les IRI Hydra (`@id`) + * pour pouvoir etre renvoyees telles quelles dans les payloads POST/PATCH + * (relations ManyToOne / ManyToMany). + * + * Etat 100 % local a l'instance (refs) — aucune persistance URL. + */ + +/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox ({ label, value }). */ +export interface RefOption { + value: string + label: string +} + +/** Option de type de reglement enrichie de son code stable (RG-1.12 / RG-1.13). */ +export interface PaymentTypeOption extends RefOption { + code: string +} + +/** Option de categorie enrichie de son code stable (filtrage RG-1.29 cote adresse). */ +export interface CategoryOption extends RefOption { + code: string +} + +/** Option de client (distributeur / courtier) — value = IRI du client lie. */ +export type ClientOption = RefOption + +interface HydraMember { + '@id': string +} + +interface CategoryMember extends HydraMember { + code: string + name: string +} + +interface SiteMember extends HydraMember { + name: string +} + +interface ReferentialMember extends HydraMember { + code: string + label: string +} + +interface ClientMember extends HydraMember { + companyName: string +} + +const LD_JSON_HEADERS = { Accept: 'application/ld+json' } + +export function useClientReferentials() { + const api = useApi() + + const categories = ref([]) + const sites = ref([]) + const tvaModes = ref([]) + const paymentDelays = ref([]) + const paymentTypes = ref([]) + const banks = ref([]) + const distributors = ref([]) + const brokers = ref([]) + + /** Recupere une collection complete (pagination desactivee) en Hydra. */ + async function fetchAll( + url: string, + query: Record = {}, + ): Promise { + const res = await api.get<{ member?: T[] }>( + url, + { pagination: 'false', ...query }, + { headers: LD_JSON_HEADERS, toast: false }, + ) + return res.member ?? [] + } + + /** + * Charge en parallele les referentiels communs (hors distributeurs/courtiers, + * charges a la demande selon la relation choisie). Les selects compta ne sont + * pertinents que si l'utilisateur a acces a l'onglet, mais le cout est + * negligeable et simplifie l'orchestration. + */ + async function loadCommon(): Promise { + const [cats, sitesList, tva, delays, types, banksList] = await Promise.all([ + fetchAll('/categories'), + fetchAll('/sites'), + fetchAll('/tva_modes'), + fetchAll('/payment_delays'), + fetchAll('/payment_types'), + fetchAll('/banks'), + ]) + + categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) + sites.value = sitesList.map(s => ({ value: s['@id'], label: s.name })) + tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label })) + paymentDelays.value = delays.map(d => ({ value: d['@id'], label: d.label })) + paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code })) + banks.value = banksList.map(b => ({ value: b['@id'], label: b.label })) + } + + /** Liste des clients pouvant etre choisis comme distributeur (code DISTRIBUTEUR). */ + async function loadDistributors(): Promise { + if (distributors.value.length > 0) { + return + } + const clients = await fetchAll('/clients', { categoryCode: 'DISTRIBUTEUR' }) + distributors.value = clients.map(c => ({ value: c['@id'], label: c.companyName })) + } + + /** Liste des clients pouvant etre choisis comme courtier (code COURTIER). */ + async function loadBrokers(): Promise { + if (brokers.value.length > 0) { + return + } + const clients = await fetchAll('/clients', { categoryCode: 'COURTIER' }) + brokers.value = clients.map(c => ({ value: c['@id'], label: c.companyName })) + } + + return { + categories, + sites, + tvaModes, + paymentDelays, + paymentTypes, + banks, + distributors, + brokers, + loadCommon, + loadDistributors, + loadBrokers, + } +} diff --git a/frontend/modules/commercial/pages/clients/new.vue b/frontend/modules/commercial/pages/clients/new.vue new file mode 100644 index 0000000..f651225 --- /dev/null +++ b/frontend/modules/commercial/pages/clients/new.vue @@ -0,0 +1,960 @@ + + + diff --git a/frontend/modules/commercial/types/clientForm.ts b/frontend/modules/commercial/types/clientForm.ts new file mode 100644 index 0000000..c24474d --- /dev/null +++ b/frontend/modules/commercial/types/clientForm.ts @@ -0,0 +1,98 @@ +/** + * Types « brouillon » de l'ecran « Ajouter un client » (M1 Commercial). + * + * Ces interfaces decrivent l'etat LOCAL du formulaire (refs Vue), distinct des + * DTO de l'API : elles portent en plus des champs purement UI (`hasSecondaryPhone`) + * et l'`iri` Hydra des entites creees (necessaire pour rattacher une adresse a + * des contacts deja persistes, M2M). Partage par la page et les blocs reutilisables + * `ClientContactBlock` / `ClientAddressBlock` (reutilises par 1.11/1.12). + */ + +/** Un contact du client (onglet Contact). */ +export interface ContactFormDraft { + /** Id serveur une fois le contact cree (null tant que non persiste). */ + id: number | null + /** IRI Hydra du contact cree — utilise pour le rattachement M2M cote adresse. */ + iri: string | null + firstName: string | null + lastName: string | null + jobTitle: string | null + phonePrimary: string | null + phoneSecondary: string | null + email: string | null + /** UI : le 2e numero a ete revele via le bouton « + ». */ + hasSecondaryPhone: boolean +} + +/** Une adresse du client (onglet Adresse). */ +export interface AddressFormDraft { + id: number | null + isProspect: boolean + isDelivery: boolean + isBilling: boolean + country: string + postalCode: string | null + city: string | null + street: string | null + streetComplement: string | null + /** IRI des categories rattachees (hors DISTRIBUTEUR/COURTIER — RG-1.29). */ + categoryIris: string[] + /** IRI des sites Starseed rattaches (>= 1 obligatoire — RG-1.10). */ + siteIris: string[] + /** IRI des contacts rattaches (= blocs Contact deja crees). */ + contactIris: string[] + /** Email de facturation (obligatoire si isBilling — RG-1.11). */ + billingEmail: string | null +} + +/** Un RIB du client (onglet Comptabilite). */ +export interface RibFormDraft { + id: number | null + label: string | null + bic: string | null + iban: string | null +} + +/** Fabrique un contact vierge. */ +export function emptyContact(): ContactFormDraft { + return { + id: null, + iri: null, + firstName: null, + lastName: null, + jobTitle: null, + phonePrimary: null, + phoneSecondary: null, + email: null, + hasSecondaryPhone: false, + } +} + +/** Fabrique une adresse vierge (pays prerempli « France »). */ +export function emptyAddress(): AddressFormDraft { + return { + id: null, + isProspect: false, + isDelivery: false, + isBilling: false, + country: 'France', + postalCode: null, + city: null, + street: null, + streetComplement: null, + categoryIris: [], + siteIris: [], + contactIris: [], + billingEmail: null, + } +} + +/** Fabrique un RIB vierge. */ +export function emptyRib(): RibFormDraft { + return { + id: null, + label: null, + bic: null, + iban: null, + } +} diff --git a/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts b/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts new file mode 100644 index 0000000..5c76d00 --- /dev/null +++ b/frontend/modules/commercial/utils/__tests__/clientFormRules.spec.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from 'vitest' +import { + applyProspectExclusivity, + buildClientFormTabKeys, + canSelectDeliveryOrBilling, + canSelectProspect, + hasAtLeastOneValidContact, + isBankRequiredForPaymentType, + isBillingEmailRequired, + isContactNamed, + isRibRequiredForPaymentType, + type ContactDraft, +} from '../clientFormRules' + +describe('buildClientFormTabKeys (gating onglet Comptabilite + onglets edit-only)', () => { + it('inclut l onglet accounting si l utilisateur a accounting.view', () => { + expect(buildClientFormTabKeys(true)).toContain('accounting') + }) + + it('exclut l onglet accounting sinon (Bureau / Commerciale)', () => { + expect(buildClientFormTabKeys(false)).not.toContain('accounting') + }) + + it('a la creation, exclut Statistiques / Rapports / Echanges', () => { + const keys = buildClientFormTabKeys(true) + expect(keys).toEqual(['information', 'contact', 'address', 'transport', 'accounting']) + expect(keys).not.toContain('statistics') + expect(keys).not.toContain('reports') + expect(keys).not.toContain('exchanges') + }) + + it('en modification (includeEditOnlyTabs), ajoute les onglets edit-only en fin', () => { + const keys = buildClientFormTabKeys(true, { includeEditOnlyTabs: true }) + expect(keys).toEqual([ + 'information', + 'contact', + 'address', + 'transport', + 'accounting', + 'statistics', + 'reports', + 'exchanges', + ]) + }) +}) + +describe('isContactNamed (RG-1.05)', () => { + it('vrai si le prenom est renseigne', () => { + expect(isContactNamed({ firstName: 'Alice', lastName: null })).toBe(true) + }) + + it('vrai si le nom est renseigne', () => { + expect(isContactNamed({ firstName: null, lastName: 'Martin' })).toBe(true) + }) + + it('faux si les deux sont vides ou espaces uniquement', () => { + expect(isContactNamed({ firstName: null, lastName: null })).toBe(false) + expect(isContactNamed({ firstName: ' ', lastName: '' })).toBe(false) + }) +}) + +describe('hasAtLeastOneValidContact (RG-1.14)', () => { + it('faux sur une liste vide', () => { + expect(hasAtLeastOneValidContact([])).toBe(false) + }) + + it('faux si aucun contact n a de nom ni prenom', () => { + const contacts: ContactDraft[] = [ + { firstName: null, lastName: null }, + { firstName: '', lastName: ' ' }, + ] + expect(hasAtLeastOneValidContact(contacts)).toBe(false) + }) + + it('vrai des qu un contact a un nom ou un prenom', () => { + const contacts: ContactDraft[] = [ + { firstName: null, lastName: null }, + { firstName: 'Bob', lastName: null }, + ] + expect(hasAtLeastOneValidContact(contacts)).toBe(true) + }) +}) + +describe('exclusivite Prospect / Livraison / Facturation (RG-1.06/07/08)', () => { + it('Prospect est selectionnable tant que ni Livraison ni Facturation', () => { + expect(canSelectProspect({ isProspect: false, isDelivery: false, isBilling: false })).toBe(true) + expect(canSelectProspect({ isProspect: false, isDelivery: true, isBilling: false })).toBe(false) + expect(canSelectProspect({ isProspect: false, isDelivery: false, isBilling: true })).toBe(false) + }) + + it('Livraison / Facturation selectionnables tant que pas Prospect', () => { + expect(canSelectDeliveryOrBilling({ isProspect: false, isDelivery: false, isBilling: false })).toBe(true) + expect(canSelectDeliveryOrBilling({ isProspect: true, isDelivery: false, isBilling: false })).toBe(false) + }) + + it('cocher Prospect efface Livraison et Facturation', () => { + const next = applyProspectExclusivity( + { isProspect: false, isDelivery: true, isBilling: true }, + 'isProspect', + true, + ) + expect(next).toEqual({ isProspect: true, isDelivery: false, isBilling: false }) + }) + + it('cocher Livraison efface Prospect', () => { + const next = applyProspectExclusivity( + { isProspect: true, isDelivery: false, isBilling: false }, + 'isDelivery', + true, + ) + expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: false }) + }) + + it('cocher Facturation efface Prospect mais conserve Livraison', () => { + const next = applyProspectExclusivity( + { isProspect: true, isDelivery: true, isBilling: false }, + 'isBilling', + true, + ) + expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: true }) + }) + + it('decocher un drapeau ne reactive rien d autre', () => { + const next = applyProspectExclusivity( + { isProspect: false, isDelivery: true, isBilling: true }, + 'isBilling', + false, + ) + expect(next).toEqual({ isProspect: false, isDelivery: true, isBilling: false }) + }) +}) + +describe('isBillingEmailRequired (RG-1.11)', () => { + it('obligatoire uniquement si Facturation est coche', () => { + expect(isBillingEmailRequired({ isProspect: false, isDelivery: false, isBilling: true })).toBe(true) + expect(isBillingEmailRequired({ isProspect: false, isDelivery: true, isBilling: false })).toBe(false) + }) +}) + +describe('regles type de reglement (RG-1.12 / RG-1.13)', () => { + it('banque obligatoire si VIREMENT', () => { + expect(isBankRequiredForPaymentType('VIREMENT')).toBe(true) + expect(isBankRequiredForPaymentType('LCR')).toBe(false) + expect(isBankRequiredForPaymentType(null)).toBe(false) + }) + + it('RIB obligatoire si LCR', () => { + expect(isRibRequiredForPaymentType('LCR')).toBe(true) + expect(isRibRequiredForPaymentType('VIREMENT')).toBe(false) + expect(isRibRequiredForPaymentType(null)).toBe(false) + }) +}) diff --git a/frontend/modules/commercial/utils/clientFormRules.ts b/frontend/modules/commercial/utils/clientFormRules.ts new file mode 100644 index 0000000..280248e --- /dev/null +++ b/frontend/modules/commercial/utils/clientFormRules.ts @@ -0,0 +1,158 @@ +/** + * 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) +} + +/** + * 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 +} + +/** 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 +} diff --git a/frontend/shared/composables/useAddressAutocomplete.ts b/frontend/shared/composables/useAddressAutocomplete.ts new file mode 100644 index 0000000..538447e --- /dev/null +++ b/frontend/shared/composables/useAddressAutocomplete.ts @@ -0,0 +1,60 @@ +// STUB ERP-63 — remplacé par l'implémentation BAN d'ERP-66. +// +// Ce fichier appartient fonctionnellement à ERP-66 (#66). ERP-63 n'en livre +// qu'un STUB pour ne pas se bloquer : la vraie implémentation (appels +// api-adresse.data.gouv.fr) viendra remplacer le CORPS des deux méthodes SANS +// changer leur signature ni l'usage côté composant. +// +// Contrat figé par ERP-66 (c'est lui qui fait foi) : +// searchCity(postalCode) -> liste { city, postalCode } +// searchAddress(query, cp?) -> liste { label, street, postalCode, city } +// En cas d'erreur/timeout, la méthode THROW. Le composant catch l'erreur, +// affiche un toast d'avertissement et bascule en saisie libre (MalioInputText). +// +// Comportement du stub : les deux méthodes throw systématiquement → l'onglet +// Adresse part directement en mode dégradé (Ville + Adresse en saisie libre, +// Code postal saisi manuellement). Aucun appel réseau n'est émis ici. + +/** Une suggestion de ville renvoyée à partir d'un code postal. */ +export interface CitySuggestion { + city: string + postalCode: string +} + +/** Une suggestion d'adresse complète (saisie assistée du champ « Adresse »). */ +export interface AddressSuggestion { + label: string + street: string + postalCode: string + city: string +} + +export interface AddressAutocomplete { + searchCity(postalCode: string): Promise + searchAddress(query: string, postalCode?: string): Promise +} + +/** Erreur signalant que le service d'autocomplétion BAN n'est pas disponible. */ +export class AddressAutocompleteUnavailableError extends Error { + constructor() { + // Message technique (non affiché tel quel) : le composant remonte son + // propre libellé i18n. Sert au debug / aux logs uniquement. + super('Address autocomplete (BAN) is not available yet — ERP-66 stub.') + this.name = 'AddressAutocompleteUnavailableError' + } +} + +/** + * STUB : renvoie un composable conforme au contrat ERP-66 dont les méthodes + * échouent toujours, forçant le mode dégradé côté onglet Adresse. + */ +export function useAddressAutocomplete(): AddressAutocomplete { + return { + async searchCity(_postalCode: string): Promise { + throw new AddressAutocompleteUnavailableError() + }, + async searchAddress(_query: string, _postalCode?: string): Promise { + throw new AddressAutocompleteUnavailableError() + }, + } +}