diff --git a/docs/specs/M1-clients/spec-front.md b/docs/specs/M1-clients/spec-front.md index b476095..88a19ab 100644 --- a/docs/specs/M1-clients/spec-front.md +++ b/docs/specs/M1-clients/spec-front.md @@ -258,7 +258,8 @@ Le composant `Code postal` + `Ville` + `Adresse` est branché sur **api-adresse. - Composable dédié `useAddressAutocomplete()` (à créer en M1). - Appel HTTP **direct depuis le front** (CORS OK), pas de proxy back. -- Pattern : à la saisie du code postal (5 chiffres), GET `https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville. Sur saisie d'adresse : `?q={addr}&postcode={cp}&type=housenumber` → suggestions adresse. +- Pattern : à la saisie du code postal (5 chiffres), GET `https://api-adresse.data.gouv.fr/search/?q={cp}&type=municipality` → alimente le select Ville. Sur saisie d'adresse : `?q={addr}&postcode={cp}` (sans filtre `type`) → suggestions adresse. + - ⚠ **Ne pas forcer `type=housenumber`** sur la recherche d'adresse (corrigé en ERP-66) : la BAN ne renvoie un résultat de ce type qu'une fois un numéro saisi, donc une recherche par nom de rue (« boulevard du port ») renverrait **0 résultat** pendant toute la frappe. Sans filtre `type`, la BAN classe rues + numéros par pertinence — comportement d'autocomplétion attendu. - Cas dégradé : si l'API ne répond pas (offline, timeout), le champ Ville devient un `` libre éditable + toast d'avertissement. Validation serveur acceptera la saisie libre. ## Points laissés ouverts par la V0 (résolus côté back) diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 4fa1c99..c09f4e9 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -10,7 +10,11 @@ "confirm": "Confirmer", "yes": "Oui", "no": "Non", - "actions": "Actions" + "actions": "Actions", + "comingSoon": { + "title": "En cours de dev", + "subtitle": "Cette fonctionnalité arrive bientôt." + } }, "sidebar": { "administration": { @@ -95,8 +99,6 @@ "back": "Retour au répertoire", "loading": "Chargement du client…", "notFound": "Client introuvable.", - "emptyContacts": "Aucun contact enregistré.", - "emptyAddresses": "Aucune adresse enregistrée.", "confirmArchive": { "title": "Archiver le client", "message": "Ce client n'apparaîtra plus dans le répertoire actif. Confirmer l'archivage ?" @@ -111,8 +113,6 @@ "back": "Retour au répertoire", "loading": "Chargement du client…", "notFound": "Client introuvable.", - "emptyContacts": "Aucun contact enregistré.", - "emptyAddresses": "Aucune adresse enregistrée.", "save": "Valider" }, "validation": { diff --git a/frontend/modules/commercial/components/ClientAddressBlock.vue b/frontend/modules/commercial/components/ClientAddressBlock.vue index 3539dbf..d19ad95 100644 --- a/frontend/modules/commercial/components/ClientAddressBlock.vue +++ b/frontend/modules/commercial/components/ClientAddressBlock.vue @@ -201,7 +201,8 @@ const model = computed(() => props.modelValue) const degraded = ref(false) // Villes proposees par la BAN (alimentees a la saisie du code postal). const banCityOptions = ref([]) -const addressOptions = ref([]) +// Adresses proposees par la BAN (alimentees a la saisie d'adresse). +const banAddressOptions = ref([]) // Options ville effectives : on garantit que la ville courante figure toujours // dans la liste, sinon MalioSelect (qui resout le libelle depuis ses options) @@ -214,6 +215,20 @@ const cityOptions = computed(() => { } return banCityOptions.value }) + +// Meme garantie que cityOptions pour le champ Adresse : la rue courante doit +// toujours figurer dans les options, sinon MalioInputAutocomplete (qui resout +// l'affichage depuis ses options) laisse le champ VIDE des que la liste de +// suggestions BAN est vide — typiquement juste apres validation (remontage) ou +// a l'edition d'une adresse existante (1.12), alors que la valeur est bien +// persistee. On reinjecte donc la rue liee si la BAN ne l'a pas (re)proposee. +const addressOptions = computed(() => { + const current = props.modelValue.street + if (current && !banAddressOptions.value.some(o => o.value === current)) { + return [{ value: current, label: current }, ...banAddressOptions.value] + } + return banAddressOptions.value +}) const addressLoading = ref(false) // Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select. let lastAddressSuggestions: AddressSuggestion[] = [] @@ -280,7 +295,7 @@ async function onAddressSearch(query: string): Promise { const postalCode = (model.value.postalCode ?? '').replace(/\D/g, '') || undefined const suggestions = await autocomplete.searchAddress(query, postalCode) lastAddressSuggestions = suggestions - addressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label })) + banAddressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label })) } catch { enterDegraded() diff --git a/frontend/modules/commercial/components/TabPlaceholderBlank.vue b/frontend/modules/commercial/components/TabPlaceholderBlank.vue deleted file mode 100644 index 5375cb6..0000000 --- a/frontend/modules/commercial/components/TabPlaceholderBlank.vue +++ /dev/null @@ -1,14 +0,0 @@ - - - diff --git a/frontend/modules/commercial/components/__tests__/ClientAddressBlock.spec.ts b/frontend/modules/commercial/components/__tests__/ClientAddressBlock.spec.ts new file mode 100644 index 0000000..1ec6587 --- /dev/null +++ b/frontend/modules/commercial/components/__tests__/ClientAddressBlock.spec.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { defineComponent, h, ref, computed } from 'vue' +import { emptyAddress } from '~/modules/commercial/types/clientForm' +import ClientAddressBlock from '../ClientAddressBlock.vue' + +// Le composable BAN est mocke : aucun appel reseau, aucune suggestion chargee. +// On reproduit ainsi l'etat « adresse persistee, mais liste de suggestions +// vide » (remontage apres validation / edition d'une adresse existante). +vi.mock('~/shared/composables/useAddressAutocomplete', () => ({ + useAddressAutocomplete: () => ({ + searchCity: vi.fn(), + searchAddress: vi.fn(), + }), +})) + +// Auto-imports Nuxt/Vue utilises sans import explicite par le composant. +vi.stubGlobal('useI18n', () => ({ t: (key: string) => key })) +vi.stubGlobal('ref', ref) +vi.stubGlobal('computed', computed) + +// Stub de MalioInputAutocomplete : expose les `value` des options recues, pour +// verifier que la rue courante figure bien dans la liste (sinon le composant +// Malio ne peut pas resoudre/afficher la valeur liee -> champ vide). +const MalioInputAutocompleteStub = defineComponent({ + name: 'MalioInputAutocomplete', + props: { + modelValue: { type: [String, Number, null], default: undefined }, + options: { type: Array as () => { value: string | number, label: string }[], default: () => [] }, + loading: { type: Boolean, default: false }, + minSearchLength: { type: Number, default: 0 }, + label: { type: String, default: '' }, + readonly: { type: Boolean, default: false }, + }, + emits: ['update:modelValue', 'search', 'select'], + setup(props) { + return () => h('div', { + 'data-testid': 'addr-autocomplete', + 'data-options': JSON.stringify(props.options.map(o => o.value)), + }) + }, +}) + +function mountBlock(street: string | null) { + return mount(ClientAddressBlock, { + props: { + modelValue: { ...emptyAddress(), street }, + title: 'Adresse', + categoryOptions: [], + siteOptions: [], + contactOptions: [], + countryOptions: [], + }, + global: { + stubs: { + MalioButtonIcon: true, + MalioCheckbox: true, + MalioSelect: true, + MalioSelectCheckbox: true, + MalioInputText: true, + MalioInputAutocomplete: MalioInputAutocompleteStub, + }, + }, + }) +} + +describe('ClientAddressBlock — affichage de l\'adresse persistee', () => { + it('inclut la rue courante dans les options de l\'autocomplete meme sans recherche BAN', () => { + const wrapper = mountBlock('8 Boulevard du Port') + + const el = wrapper.find('[data-testid="addr-autocomplete"]') + const values = JSON.parse(el.attributes('data-options') ?? '[]') + + expect(values).toContain('8 Boulevard du Port') + }) +}) diff --git a/frontend/modules/commercial/composables/useClientReferentials.ts b/frontend/modules/commercial/composables/useClientReferentials.ts index 0f5bb8f..6721ee2 100644 --- a/frontend/modules/commercial/composables/useClientReferentials.ts +++ b/frontend/modules/commercial/composables/useClientReferentials.ts @@ -45,6 +45,7 @@ interface CategoryMember extends HydraMember { interface SiteMember extends HydraMember { name: string + postalCode: string } interface ReferentialMember extends HydraMember { @@ -101,7 +102,10 @@ export function useClientReferentials() { fetchAll('/categories') .then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code })) }), fetchAll('/sites') - .then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: s.name })) }), + // Libelle = numero de departement (2 premiers chiffres du code + // postal du site), ex: 86100 -> « 86 ». Le code postal est deja + // expose par /sites (groupe site:read) — aucune colonne a ajouter. + .then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }), fetchAll('/tva_modes') .then((tva) => { tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label })) }), fetchAll('/payment_delays') diff --git a/frontend/modules/commercial/pages/clients/[id]/edit.vue b/frontend/modules/commercial/pages/clients/[id]/edit.vue index e1f64e5..06a2f77 100644 --- a/frontend/modules/commercial/pages/clients/[id]/edit.vue +++ b/frontend/modules/commercial/pages/clients/[id]/edit.vue @@ -179,9 +179,6 @@ @update:model-value="(v) => contacts[index] = v" @remove="askRemoveContact(index)" /> -

- {{ t('commercial.clients.edit.emptyContacts') }} -

-

- {{ t('commercial.clients.edit.emptyAddresses') }} -

-
+
-
+
- - - - + + + + @@ -495,6 +489,11 @@ function hydrate(detail: ClientDetail): void { contacts.value = (detail.contacts ?? []).map(mapContactToDraft) addresses.value = (detail.addresses ?? []).map(mapAddressToDraft) ribs.value = (detail.ribs ?? []).map(mapRibToDraft) + // Chaque bloc reste visible meme vide : si une collection est vide, on amorce + // un bloc vierge (non persiste tant qu'incomplet — cf. submit*/canValidate*). + if (contacts.value.length === 0) contacts.value.push(emptyContact()) + if (addresses.value.length === 0) addresses.value.push(emptyAddress()) + if (ribs.value.length === 0) ribs.value.push(emptyRib()) // Charge les listes distributeur / courtier si une relation est deja posee. if (main.relationType === 'distributeur') referentials.loadDistributors().catch(() => {}) if (main.relationType === 'courtier') referentials.loadBrokers().catch(() => {}) @@ -694,6 +693,8 @@ function askRemoveContact(index: number): void { const removed = contacts.value[index] if (removed?.id != null) removedContactIds.value.push(removed.id) contacts.value.splice(index, 1) + // Garde au moins un bloc visible (cf. amorce a l'hydratation). + if (contacts.value.length === 0) contacts.value.push(emptyContact()) }) } @@ -742,7 +743,9 @@ const canValidateAddresses = computed(() => addresses.value.length > 0 && addresses.value.every((a) => { const filledBillingEmail = a.billingEmail !== null && a.billingEmail.trim() !== '' - return a.siteIris.length >= 1 && (!isBillingEmailRequired(a) || filledBillingEmail) + return a.siteIris.length >= 1 + && a.categoryIris.length >= 1 + && (!isBillingEmailRequired(a) || filledBillingEmail) }), ) @@ -755,6 +758,8 @@ function askRemoveAddress(index: number): void { const removed = addresses.value[index] if (removed?.id != null) removedAddressIds.value.push(removed.id) addresses.value.splice(index, 1) + // Garde au moins un bloc visible (cf. amorce a l'hydratation). + if (addresses.value.length === 0) addresses.value.push(emptyAddress()) }) } @@ -833,6 +838,8 @@ function askRemoveRib(index: number): void { const removed = ribs.value[index] if (removed?.id != null) removedRibIds.value.push(removed.id) ribs.value.splice(index, 1) + // Garde au moins un bloc RIB visible (cf. amorce a l'hydratation). + if (ribs.value.length === 0) ribs.value.push(emptyRib()) }) } diff --git a/frontend/modules/commercial/pages/clients/[id]/index.vue b/frontend/modules/commercial/pages/clients/[id]/index.vue index 52ea3cd..1951700 100644 --- a/frontend/modules/commercial/pages/clients/[id]/index.vue +++ b/frontend/modules/commercial/pages/clients/[id]/index.vue @@ -159,9 +159,6 @@ :title="t('commercial.clients.form.contact.title', { n: index + 1 })" readonly /> -

- {{ t('commercial.clients.consultation.emptyContacts') }} -

@@ -174,14 +171,11 @@ :model-value="view.draft" :title="t('commercial.clients.form.address.title', { n: index + 1 })" :category-options="view.categoryOptions" - :site-options="view.siteOptions" + :site-options="allSiteOptions" :contact-options="contactOptions" :country-options="countryOptions" readonly /> -

- {{ t('commercial.clients.consultation.emptyAddresses') }} -

@@ -189,7 +183,7 @@ @@ -320,6 +314,7 @@ import { type SelectOption, } from '~/modules/commercial/utils/clientConsultation' import { formatPhoneFR } from '~/shared/utils/phone' +import { emptyAddress, emptyContact, emptyRib } from '~/modules/commercial/types/clientForm' // Masques d'affichage (purement visuels, la donnee reste celle du serveur). const PHONE_MASK = '## ## ## ## ##' @@ -330,6 +325,7 @@ const route = useRoute() const router = useRouter() const toast = useToast() const { can, canAny } = usePermissions() +const authStore = useAuthStore() // Gating de la route : la consultation exige `view`. Usine (sans view) est // redirige vers le repertoire (lui-meme protege). Cf. matrice § 2.7. @@ -372,10 +368,21 @@ const information = computed(() => ({ directorName: client.value?.directorName ?? null, })) -const contacts = computed(() => (client.value?.contacts ?? []).map(mapContactToDraft)) +// Chaque bloc reste visible meme vide en consultation : si la collection est +// vide, on affiche un bloc vierge en lecture seule (pas de message « Aucun … »). +const contacts = computed(() => { + const list = (client.value?.contacts ?? []).map(mapContactToDraft) + return list.length ? list : [emptyContact()] +}) // Vue par adresse : brouillon + options (sites/categories) propres a l'adresse. -const addressViews = computed(() => (client.value?.addresses ?? []).map(mapAddressView)) -const ribs = computed(() => (client.value?.ribs ?? []).map(mapRibToDraft)) +const addressViews = computed(() => { + const views = (client.value?.addresses ?? []).map(mapAddressView) + return views.length ? views : [{ draft: emptyAddress(), siteOptions: [], categoryOptions: [] }] +}) +const ribs = computed(() => { + const list = (client.value?.ribs ?? []).map(mapRibToDraft) + return list.length ? list : [emptyRib()] +}) // Draft comptable (tout null si l'utilisateur n'a pas accounting.view). const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as ClientDetail))) @@ -385,6 +392,18 @@ const accounting = computed(() => mapAccountingDraft(client.value ?? ({} as Clie const mainCategoryOptions = computed(() => categoryOptionsOf(client.value?.categories)) const contactOptions = computed(() => contactOptionsOf(client.value?.contacts)) +// Liste COMPLETE des sites disponibles, issue de /api/me (groupe me:read — donc +// pas de 403 pour les roles metier, contrairement a GET /sites). Libelle = numero +// de departement (2 premiers chiffres du code postal). Permet d'afficher TOUJOURS +// toutes les cases « Sites » (86 / 17 / 82) dans le bloc adresse, meme celles non +// rattachees a l'adresse consultee (les rattachees restent cochees via siteIris). +const allSiteOptions = computed(() => + (authStore.user?.sites ?? []).map(s => ({ + value: `/api/sites/${s.id}`, + label: (s.postalCode ?? '').slice(0, 2), + })), +) + const relationOptions = computed(() => [ { value: 'distributeur', label: t('commercial.clients.form.main.relationDistributor') }, { value: 'courtier', label: t('commercial.clients.form.main.relationBroker') }, diff --git a/frontend/modules/commercial/pages/clients/new.vue b/frontend/modules/commercial/pages/clients/new.vue index f651225..7bbdbe6 100644 --- a/frontend/modules/commercial/pages/clients/new.vue +++ b/frontend/modules/commercial/pages/clients/new.vue @@ -233,7 +233,7 @@