From d0e9f489834c5d0ae0917db73bdb043528257207 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 15 Jun 2026 08:59:39 +0000 Subject: [PATCH] feat(front) : page ajout prestataire + formulaire principal (ERP-141) (#103) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empilée sur ERP-140 (#102). ## Périmètre ERP-141 Écran `/providers/new` — création par onglets + formulaire principal (POST). - **Page** `modules/technique/pages/providers/new.vue` : en-tête + retour, formulaire principal (Nom, Catégorie, Site), barre d'onglets **Contact · Adresse · Comptabilité** (pas d'onglet Information ; Rapports/Échanges absents en création). Contenu des onglets = placeholders « À venir » (ERP-142→144). - **`useProviderForm()`** : POST principal (groupe `provider:write:main`, IRIs catégories/sites), pré-check front RG-3.03 (≥1 site) / RG-3.09 (≥1 catégorie), 409 doublon (RG-3.10) inline, 422 mapping par champ via `useFormErrors`, orchestration des onglets (verrouillage + bascule auto sur Contact au succès), `patchProvider` (PATCH partiel mode strict pour les onglets à venir). - **`useProviderReferentials()`** : catégories type PRESTATAIRE + sites (`?pagination=false`, Hydra). - i18n `technique.providers.form/tab/toast`. ## Conformité - `useApi()` uniquement, composants `Malio*`, aucun texte FR en dur, bouton « Valider » toujours actif + erreurs sous les champs (ERP-101). ## Vérifications - Vitest : 402/402 (dont 9 nouveaux tests `useProviderForm`). - ESLint : OK. - `nuxi typecheck` : 0 erreur sur les fichiers source du ticket. - Golden path navigateur : page rendue, catégories filtrées PRESTATAIRE, sélecteur site, onglets désactivés avant validation, erreurs inline RG-3.03/3.09. Reviewed-on: https://gitea.malio.fr/MALIO-DEV/Starseed/pulls/103 Co-authored-by: tristan Co-committed-by: tristan --- frontend/i18n/locales/fr.json | 23 +- .../__tests__/useProviderForm.test.ts | 192 ++++++++++++++++ .../technique/composables/useProviderForm.ts | 207 ++++++++++++++++++ .../composables/useProviderReferentials.ts | 85 +++++++ .../modules/technique/pages/providers/new.vue | 127 +++++++++++ .../modules/technique/types/providerForm.ts | 41 ++++ 6 files changed, 674 insertions(+), 1 deletion(-) create mode 100644 frontend/modules/technique/composables/__tests__/useProviderForm.test.ts create mode 100644 frontend/modules/technique/composables/useProviderForm.ts create mode 100644 frontend/modules/technique/composables/useProviderReferentials.ts create mode 100644 frontend/modules/technique/pages/providers/new.vue create mode 100644 frontend/modules/technique/types/providerForm.ts diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 174a84a..bd7348b 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -388,9 +388,30 @@ "apply": "Voir les résultats", "reset": "Réinitialiser" }, + "tab": { + "contact": "Contact", + "address": "Adresse", + "accounting": "Comptabilité" + }, + "form": { + "title": "Ajouter un prestataire", + "back": "Précédent", + "submit": "Valider", + "duplicateCompany": "Un prestataire portant ce nom de société existe déjà.", + "main": { + "companyName": "Nom du prestataire (Entreprise)", + "categories": "Catégorie", + "sites": "Site" + }, + "errors": { + "siteRequired": "Sélectionnez au moins un site.", + "categoryRequired": "Sélectionnez au moins une catégorie." + } + }, "toast": { "error": "Une erreur est survenue. Réessayez.", - "exportError": "L'export du répertoire prestataires a échoué. Réessayez." + "exportError": "L'export du répertoire prestataires a échoué. Réessayez.", + "createSuccess": "Prestataire créé avec succès" } } }, diff --git a/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts b/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts new file mode 100644 index 0000000..9a732c5 --- /dev/null +++ b/frontend/modules/technique/composables/__tests__/useProviderForm.test.ts @@ -0,0 +1,192 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * Tests du workflow « Ajouter un prestataire » (M3 Technique, ERP-141). + * + * `useProviderForm` porte le formulaire principal (Nom + Categorie + Site) et + * l'orchestration des onglets de creation. On verifie ici le CONTRAT propre a la + * creation : + * - RG-3.03 (front) : au moins un site requis ; RG-3.09 : au moins une categorie + * -> POST bloque, erreurs inline, aucun appel reseau. + * - POST /providers (groupe provider:write:main) : payload IRIs + Accept ld+json + * + toast:false ; au succes, verrouillage + bascule sur l'onglet Contact + + * reaffichage du nom normalise. + * - 409 doublon (RG-3.10) -> erreur inline dediee sur companyName. + * - 422 -> mapping inline par champ (propertyPath). + * - Onglets : « Comptabilite » present uniquement avec accounting.view ; + * completeTab deverrouille/avance et signale le dernier onglet. + */ + +const mockPost = vi.hoisted(() => vi.fn()) +const mockPatch = vi.hoisted(() => vi.fn()) +// Permission accounting.view pilotable par test (presence de l'onglet Comptabilite). +const permState = vi.hoisted(() => ({ accountingView: false })) + +vi.stubGlobal('useApi', () => ({ + get: vi.fn(), + post: mockPost, + put: vi.fn(), + patch: mockPatch, + delete: vi.fn(), +})) +vi.stubGlobal('useI18n', () => ({ t: (key: string) => key })) +vi.stubGlobal('useToast', () => ({ + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), +})) +vi.stubGlobal('usePermissions', () => ({ + can: (perm: string) => perm === 'technique.providers.accounting.view' ? permState.accountingView : true, +})) + +const { useProviderForm, buildProviderCreateTabKeys } = await import('../useProviderForm') + +const SITE_86 = '/api/sites/1' +const CAT_MAINT = '/api/categories/7' + +describe('useProviderForm', () => { + beforeEach(() => { + mockPost.mockReset() + mockPatch.mockReset() + permState.accountingView = false + }) + + it('RG-3.03/RG-3.09 (front) : bloque le POST si aucun site / aucune categorie', async () => { + const form = useProviderForm() + form.main.companyName = 'Maintenance Pro' + + const created = await form.submitMain() + + expect(created).toBe(false) + expect(mockPost).not.toHaveBeenCalled() + expect(form.mainErrors.errors.sites).toBe('technique.providers.form.errors.siteRequired') + expect(form.mainErrors.errors.categories).toBe('technique.providers.form.errors.categoryRequired') + expect(form.mainLocked.value).toBe(false) + }) + + it('RG-3.03 (front) : un site present sans categorie n\'erre que sur categories', async () => { + const form = useProviderForm() + form.main.companyName = 'Maintenance Pro' + form.main.siteIris = [SITE_86] + + await form.submitMain() + + expect(mockPost).not.toHaveBeenCalled() + expect(form.mainErrors.errors.sites).toBeUndefined() + expect(form.mainErrors.errors.categories).toBe('technique.providers.form.errors.categoryRequired') + }) + + it('POST /providers avec IRIs + Accept ld+json, verrouille et bascule sur Contact', async () => { + mockPost.mockResolvedValueOnce({ id: 42, companyName: 'MAINTENANCE PRO' }) + const form = useProviderForm() + form.main.companyName = 'Maintenance Pro' + form.main.categoryIris = [CAT_MAINT] + form.main.siteIris = [SITE_86] + + const created = await form.submitMain() + + expect(created).toBe(true) + expect(mockPost).toHaveBeenCalledTimes(1) + const [url, body, opts] = mockPost.mock.calls[0] ?? [] + expect(url).toBe('/providers') + expect(body).toEqual({ + companyName: 'Maintenance Pro', + categories: [CAT_MAINT], + sites: [SITE_86], + }) + expect(opts).toMatchObject({ toast: false, headers: { Accept: 'application/ld+json' } }) + + expect(form.providerId.value).toBe(42) + // RG-3.11 : reaffiche le nom normalise (UPPERCASE) renvoye par le serveur. + expect(form.main.companyName).toBe('MAINTENANCE PRO') + expect(form.mainLocked.value).toBe(true) + expect(form.activeTab.value).toBe('contact') + expect(form.unlockedIndex.value).toBe(0) + }) + + it('omet companyName vide du payload (laisse la 422 NotBlank back mordre)', async () => { + mockPost.mockResolvedValueOnce({ id: 1, companyName: null }) + const form = useProviderForm() + form.main.companyName = ' ' + form.main.categoryIris = [CAT_MAINT] + form.main.siteIris = [SITE_86] + + await form.submitMain() + + const body = (mockPost.mock.calls[0] ?? [])[1] as Record + expect(body).not.toHaveProperty('companyName') + expect(body).toEqual({ categories: [CAT_MAINT], sites: [SITE_86] }) + }) + + it('409 doublon (RG-3.10) : erreur inline dediee sur companyName, pas de verrouillage', async () => { + mockPost.mockRejectedValueOnce({ response: { status: 409 } }) + const form = useProviderForm() + form.main.companyName = 'Doublon' + form.main.categoryIris = [CAT_MAINT] + form.main.siteIris = [SITE_86] + + const created = await form.submitMain() + + expect(created).toBe(false) + expect(form.mainErrors.errors.companyName).toBe('technique.providers.form.duplicateCompany') + expect(form.mainLocked.value).toBe(false) + }) + + it('422 : mappe les violations serveur inline par champ', async () => { + mockPost.mockRejectedValueOnce({ + response: { + status: 422, + _data: { violations: [{ propertyPath: 'sites', message: 'Au moins un site est requis.' }] }, + }, + }) + const form = useProviderForm() + form.main.companyName = 'X' + form.main.categoryIris = [CAT_MAINT] + form.main.siteIris = [SITE_86] + + const created = await form.submitMain() + + expect(created).toBe(false) + expect(form.mainErrors.errors.sites).toBe('Au moins un site est requis.') + }) + + it('onglet Comptabilite : absent sans accounting.view, present avec', () => { + expect(buildProviderCreateTabKeys(false)).toEqual(['contact', 'address']) + expect(buildProviderCreateTabKeys(true)).toEqual(['contact', 'address', 'accounting']) + + permState.accountingView = true + const form = useProviderForm() + expect(form.tabKeys.value).toEqual(['contact', 'address', 'accounting']) + }) + + it('completeTab : deverrouille/avance, et signale le dernier onglet du flux', () => { + const form = useProviderForm() + + // Contact -> Adresse (pas le dernier). + expect(form.completeTab('contact')).toBe(false) + expect(form.isValidated('contact')).toBe(true) + expect(form.activeTab.value).toBe('address') + expect(form.unlockedIndex.value).toBe(1) + + // Adresse = dernier onglet remplissable (sans accounting.view) -> true. + expect(form.completeTab('address')).toBe(true) + expect(form.isValidated('address')).toBe(true) + }) + + it('patchProvider : PATCH /providers/{id} en mode strict, no-op avant creation', async () => { + const form = useProviderForm() + + await form.patchProvider({ siren: '123456789' }) + expect(mockPatch).not.toHaveBeenCalled() + + mockPost.mockResolvedValueOnce({ id: 9, companyName: 'ACME' }) + form.main.companyName = 'Acme' + form.main.categoryIris = [CAT_MAINT] + form.main.siteIris = [SITE_86] + await form.submitMain() + + await form.patchProvider({ siren: '123456789' }) + expect(mockPatch).toHaveBeenCalledWith('/providers/9', { siren: '123456789' }, { toast: false }) + }) +}) diff --git a/frontend/modules/technique/composables/useProviderForm.ts b/frontend/modules/technique/composables/useProviderForm.ts new file mode 100644 index 0000000..de9afc3 --- /dev/null +++ b/frontend/modules/technique/composables/useProviderForm.ts @@ -0,0 +1,207 @@ +import { computed, reactive, ref } from 'vue' +import { useFormErrors } from '~/shared/composables/useFormErrors' +import { + emptyProviderMain, + type ProviderMainDraft, + type ProviderMainResponse, +} from '~/modules/technique/types/providerForm' + +/** + * Workflow de l'ecran « Ajouter un prestataire » (M3 Technique, ERP-141) — + * miroir conceptuel de la logique de creation fournisseur (M2), extraite ici en + * composable. + * + * Particularites M3 (cf. spec-front § « Ecran Ajouter ») : + * - PAS d'onglet « Information » : le formulaire principal est minimal (Nom + + * Categorie + Site). + * - Selecteur de site SUR le formulaire principal (RG-3.03, relation directe + * `provider.sites`). + * - Creation incrementale par onglets (Contact · Adresse · Comptabilite) : + * POST principal puis PATCH partiels par groupe de serialisation + * (`provider:write:*`, mode strict — spec-back § 2.10). Le contenu des onglets + * arrive aux tickets ERP-142 → 144 ; ce composable pose le POST principal et + * l'orchestration des onglets. + * + * Etat 100 % local a l'instance (refs/reactive) — aucune persistance URL. + */ + +/** + * Cles des onglets du FLUX DE CREATION. Pas d'onglet « Information » au M3 ; + * « Rapports » / « Echanges » n'apparaissent qu'en consultation/modification. + * L'onglet « Comptabilite » n'est present que pour les roles qui peuvent le voir + * (`technique.providers.accounting.view` — Admin, Compta). + */ +export function buildProviderCreateTabKeys(canAccountingView: boolean): string[] { + return canAccountingView + ? ['contact', 'address', 'accounting'] + : ['contact', 'address'] +} + +export function useProviderForm() { + const api = useApi() + const { t } = useI18n() + const toast = useToast() + const { can } = usePermissions() + + // Erreurs de validation par champ (ERP-101) du formulaire principal. + const mainErrors = useFormErrors() + + // ── Etat du prestataire cree ──────────────────────────────────────────── + const providerId = ref(null) + const mainLocked = ref(false) + const mainSubmitting = ref(false) + const tabSubmitting = ref(false) + + // ── Formulaire principal ────────────────────────────────────────────────── + const main = reactive(emptyProviderMain()) + + // ── Onglets : ordre + gating progressif ─────────────────────────────────── + const canAccountingView = computed(() => can('technique.providers.accounting.view')) + const tabKeys = computed(() => buildProviderCreateTabKeys(canAccountingView.value)) + + // Index du dernier onglet deverrouille (-1 tant que le prestataire n'est pas cree). + const unlockedIndex = ref(-1) + const activeTab = ref('contact') + // Onglets valides (passent en lecture seule). + const validated = reactive>({}) + + function isValidated(key: string): boolean { + return validated[key] === true + } + + function tabIndex(key: string): number { + return tabKeys.value.indexOf(key) + } + + /** + * Validation FRONT du formulaire principal : RG-3.03 (>= 1 site) et RG-3.09 + * (>= 1 categorie). Pose les erreurs inline et retourne false si invalide. + * Le back reste la couche autoritaire (ERP-101) ; ce pre-check evite un + * aller-retour inutile et porte la garantie RG-3.03 cote front. + */ + function validateMainFront(): boolean { + let valid = true + if (main.siteIris.length === 0) { + mainErrors.setError('sites', t('technique.providers.form.errors.siteRequired')) + valid = false + } + if (main.categoryIris.length === 0) { + mainErrors.setError('categories', t('technique.providers.form.errors.categoryRequired')) + valid = false + } + return valid + } + + /** + * Payload du POST principal (groupe `provider:write:main`). `companyName` est + * omis s'il est vide afin que la 422 porte la violation NotBlank (RG-3.11) sur + * le champ plutot qu'une erreur de type. Les relations M2M partent en IRI. + */ + function buildMainPayload(): Record { + const payload: Record = { + categories: [...main.categoryIris], + sites: [...main.siteIris], + } + if (main.companyName?.trim()) { + payload.companyName = main.companyName + } + return payload + } + + /** + * POST /providers (groupe `provider:write:main`). Pre-check front RG-3.03/3.09, + * puis creation. Au succes : verrouille le bloc principal, deverrouille le 1er + * onglet et bascule sur « Contact ». Retourne true si cree, false sinon. + */ + async function submitMain(): Promise { + if (mainSubmitting.value) return false + mainErrors.clearErrors() + if (!validateMainFront()) return false + + mainSubmitting.value = true + try { + const created = await api.post('/providers', buildMainPayload(), { + headers: { Accept: 'application/ld+json' }, + toast: false, + }) + + providerId.value = created.id + // Reaffiche la valeur normalisee renvoyee par le serveur (UPPERCASE, RG-3.11). + main.companyName = created.companyName ?? main.companyName + + mainLocked.value = true + unlockedIndex.value = 0 + activeTab.value = tabKeys.value[0] ?? 'contact' + toast.success({ title: t('technique.providers.toast.createSuccess') }) + return true + } + catch (error) { + // 409 = doublon de nom (RG-3.10) → erreur inline dediee + toast ; + // 422 → mapping inline par champ ; autre → toast de fallback (ERP-101). + const status = (error as { response?: { status?: number } })?.response?.status + if (status === 409) { + const message = t('technique.providers.form.duplicateCompany') + mainErrors.setError('companyName', message) + toast.error({ title: t('technique.providers.toast.error'), message }) + } + else { + mainErrors.handleApiError(error, { fallbackMessage: t('technique.providers.toast.error') }) + } + return false + } + finally { + mainSubmitting.value = false + } + } + + /** + * PATCH partiel du prestataire (mode strict : un seul groupe de serialisation + * par appel — spec-back § 2.10). Sert l'onglet Comptabilite a champs scalaires + * (ERP-144) ; les onglets Contact/Adresse passent par leurs sous-ressources + * (POST/PATCH par ligne, ERP-142/143). No-op tant que le prestataire n'existe pas. + */ + async function patchProvider(payload: Record): Promise { + if (providerId.value === null) return + await api.patch(`/providers/${providerId.value}`, payload, { toast: false }) + } + + /** + * Marque un onglet valide (passe en lecture seule), deverrouille et avance a + * l'onglet suivant. Retourne true si c'etait le dernier onglet du flux + * (creation terminee), false sinon. + */ + function completeTab(key: string): boolean { + validated[key] = true + const index = tabIndex(key) + const next = tabKeys.value[index + 1] + if (next === undefined) { + return true + } + unlockedIndex.value = Math.max(unlockedIndex.value, index + 1) + activeTab.value = next + return false + } + + return { + // etat + main, + providerId, + mainLocked, + mainSubmitting, + tabSubmitting, + mainErrors, + // onglets + canAccountingView, + tabKeys, + activeTab, + unlockedIndex, + validated, + isValidated, + // actions + validateMainFront, + buildMainPayload, + submitMain, + patchProvider, + completeTab, + } +} diff --git a/frontend/modules/technique/composables/useProviderReferentials.ts b/frontend/modules/technique/composables/useProviderReferentials.ts new file mode 100644 index 0000000..e299805 --- /dev/null +++ b/frontend/modules/technique/composables/useProviderReferentials.ts @@ -0,0 +1,85 @@ +import { ref } from 'vue' + +/** + * Charge les referentiels (listes courtes) alimentant les selects du formulaire + * principal de l'ecran « Ajouter un prestataire » (M3 Technique, ERP-141) : + * categories (type PRESTATAIRE) et sites (86 / 17 / 82). + * + * Miroir reduit de `useSupplierReferentials` (M2) : a ce stade (formulaire + * principal) seuls categories + sites sont necessaires. Les referentiels + * comptables (modes de TVA, delais/types de reglement, banques) seront charges + * par l'onglet Comptabilite (ERP-144). + * + * Toutes les collections sont recuperees en entier via l'echappatoire prevue + * `?pagination=false` (referentiels de quelques entrees), avec l'en-tete + * `Accept: application/ld+json` impose par API Platform 4 pour obtenir l'enveloppe + * Hydra (`member`). La valeur d'option est l'IRI Hydra (`@id`), renvoyee telle + * quelle dans le payload POST (relations M2M). + * + * Chargement RESILIENT (Promise.allSettled) : chaque referentiel est isole ; un + * echec (permission manquante, reseau) laisse simplement la liste vide. + * + * Etat 100 % local a l'instance (refs) — aucune persistance URL. + */ + +/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox. */ +export interface RefOption { + value: string + label: string +} + +interface HydraMember { + '@id': string +} + +interface CategoryMember extends HydraMember { + code: string + name: string +} + +interface SiteMember extends HydraMember { + name: string + postalCode: string +} + +const LD_JSON_HEADERS = { Accept: 'application/ld+json' } + +export function useProviderReferentials() { + const api = useApi() + + const categories = ref([]) + const sites = 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 du formulaire principal (categories + sites). */ + async function loadMain(): Promise { + await Promise.allSettled([ + // RG-3.09 : un prestataire ne porte que des categories de type + // PRESTATAIRE -> filtre cote API. Libelle affiche = `name`. + fetchAll('/categories', { typeCode: 'PRESTATAIRE' }) + .then((cats) => { categories.value = cats.map(c => ({ value: c['@id'], label: c.name })) }), + // Sites (RG-3.03) : libelle = numero de departement (2 premiers chiffres + // du code postal du site), ex: 86100 -> « 86 », 17400 -> « 17 ». + fetchAll('/sites') + .then((sitesList) => { sites.value = sitesList.map(s => ({ value: s['@id'], label: (s.postalCode ?? '').slice(0, 2) })) }), + ]) + } + + return { + categories, + sites, + loadMain, + } +} diff --git a/frontend/modules/technique/pages/providers/new.vue b/frontend/modules/technique/pages/providers/new.vue new file mode 100644 index 0000000..4d5813a --- /dev/null +++ b/frontend/modules/technique/pages/providers/new.vue @@ -0,0 +1,127 @@ + + + diff --git a/frontend/modules/technique/types/providerForm.ts b/frontend/modules/technique/types/providerForm.ts new file mode 100644 index 0000000..2e602fc --- /dev/null +++ b/frontend/modules/technique/types/providerForm.ts @@ -0,0 +1,41 @@ +/** + * Types « brouillon » de l'ecran « Ajouter un prestataire » (M3 Technique). + * + * Miroir reduit de `types/supplierForm.ts` (M2) : le M3 n'a PAS d'onglet + * Information, et porte en plus un selecteur de site SUR le formulaire principal + * (RG-3.03 — relation directe `provider.sites`, distincte des sites d'adresse). + * + * Ces interfaces decrivent l'etat LOCAL du formulaire (refs Vue), distinct des + * DTO de l'API : la page de creation (ERP-141) et — a venir — les blocs d'onglet + * Contact / Adresse / Comptabilite (ERP-142 → 144) les partagent. + * + * Les relations M2M (categories, sites) sont portees par leurs IRI Hydra (`@id`), + * envoyees telles quelles dans le payload POST (cf. contrat back ERP-139 : + * `categories: ['/api/categories/{id}']`, `sites: ['/api/sites/{id}']`). + */ + +/** Etat « plat » du formulaire principal (groupe `provider:write:main`). */ +export interface ProviderMainDraft { + /** Nom de l'entreprise prestataire. UPPERCASE serveur (RG-3.11), unicite RG-3.10. */ + companyName: string | null + /** IRI des categories rattachees (M2M, type PRESTATAIRE — RG-3.09 ; >= 1). */ + categoryIris: string[] + /** IRI des sites rattaches DIRECTEMENT au prestataire (M2M `provider_site`, RG-3.03 ; >= 1). */ + siteIris: string[] +} + +/** Fabrique un formulaire principal vierge. */ +export function emptyProviderMain(): ProviderMainDraft { + return { + companyName: null, + categoryIris: [], + siteIris: [], + } +} + +/** Reponse minimale du POST /providers exploitee par l'ecran de creation. */ +export interface ProviderMainResponse { + id: number + /** Nom renvoye normalise (UPPERCASE) par le serveur, reaffiche en lecture seule. */ + companyName: string | null +}