From 64c3b9b6ecbc73601dec1482790ae14840005b01 Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 25 Jun 2026 18:01:33 +0200 Subject: [PATCH] =?UTF-8?q?feat(catalog)=20:=20M6=20=E2=80=94=20=C3=A9cran?= =?UTF-8?q?=20Modification=20produit=20+=20onglets=20placeholder=20(ERP-20?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Écran de modification (ajout pré-rempli, bouton « Enregistrer ») et pose des onglets Fournisseurs/Clients en placeholder « en cours de développement ». - route /admin/products/{id}/edit : useProduct(id) charge le détail, prefill du formulaire principal - RG-6.08 : useProductForm en mode édition → PATCH /products/{id} (merge-patch), bouton « Enregistrer » - unicité du code re-validée serveur en édition (409 doublon mappé inline) - onglets Fournisseurs + Clients : ComingSoonPlaceholder, aucun appel API ni champ (HP-M6-01 / RG-6.10) - mêmes onglets placeholder posés sur l'écran Ajouter (cohérence) - i18n admin.products.edit / tab ; 11 tests Vitest (prefill + PATCH + placeholder) --- frontend/i18n/locales/fr.json | 14 +- .../components/ProductPlaceholderTabs.vue | 27 +++ .../__tests__/useProductForm.spec.ts | 69 ++++++- .../modules/catalog/composables/useProduct.ts | 41 ++++ .../catalog/composables/useProductForm.ts | 53 ++++- .../pages/__tests__/productEdit.spec.ts | 154 +++++++++++++++ .../pages/__tests__/productNew.spec.ts | 4 + .../pages/admin/products/[id]/edit.vue | 182 ++++++++++++++++++ .../catalog/pages/admin/products/new.vue | 4 + frontend/modules/catalog/types/product.ts | 6 + 10 files changed, 544 insertions(+), 10 deletions(-) create mode 100644 frontend/modules/catalog/components/ProductPlaceholderTabs.vue create mode 100644 frontend/modules/catalog/composables/useProduct.ts create mode 100644 frontend/modules/catalog/pages/__tests__/productEdit.spec.ts create mode 100644 frontend/modules/catalog/pages/admin/products/[id]/edit.vue diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 3792eb1..22ecdaf 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -1061,10 +1061,22 @@ "containsMolasses": "Contient de la mélasse", "duplicateCode": "Un produit portant ce code existe déjà." }, + "edit": { + "title": "Modifier le produit", + "back": "Retour au catalogue", + "save": "Enregistrer", + "loading": "Chargement du produit…", + "notFound": "Produit introuvable." + }, + "tab": { + "suppliers": "Fournisseurs", + "clients": "Clients" + }, "toast": { "error": "Une erreur est survenue. Réessayez.", "exportError": "L'export du catalogue produit a échoué. Réessayez.", - "createSuccess": "Produit créé avec succès" + "createSuccess": "Produit créé avec succès", + "updateSuccess": "Produit mis à jour avec succès" } } } diff --git a/frontend/modules/catalog/components/ProductPlaceholderTabs.vue b/frontend/modules/catalog/components/ProductPlaceholderTabs.vue new file mode 100644 index 0000000..b448da9 --- /dev/null +++ b/frontend/modules/catalog/components/ProductPlaceholderTabs.vue @@ -0,0 +1,27 @@ + + + diff --git a/frontend/modules/catalog/composables/__tests__/useProductForm.spec.ts b/frontend/modules/catalog/composables/__tests__/useProductForm.spec.ts index a7c3856..3d82cd8 100644 --- a/frontend/modules/catalog/composables/__tests__/useProductForm.spec.ts +++ b/frontend/modules/catalog/composables/__tests__/useProductForm.spec.ts @@ -6,6 +6,7 @@ import { useProductForm } from '../useProductForm' // Stubs des auto-imports Nuxt consommes par le composable + ses dependances. const mockGet = vi.hoisted(() => vi.fn()) const mockPost = vi.hoisted(() => vi.fn()) +const mockPatch = vi.hoisted(() => vi.fn()) const mockToastSuccess = vi.hoisted(() => vi.fn()) const mockToastError = vi.hoisted(() => vi.fn()) @@ -13,7 +14,7 @@ vi.stubGlobal('useApi', () => ({ get: mockGet, post: mockPost, put: vi.fn(), - patch: vi.fn(), + patch: mockPatch, delete: vi.fn(), })) vi.stubGlobal('useToast', () => ({ @@ -51,6 +52,7 @@ describe('useProductForm', () => { beforeEach(() => { mockGet.mockReset() mockPost.mockReset() + mockPatch.mockReset() mockToastSuccess.mockReset() mockToastError.mockReset() @@ -217,4 +219,69 @@ describe('useProductForm', () => { expect(mockToastError).not.toHaveBeenCalled() }) }) + + describe('RG-6.08 — mode edition (prefill + PATCH)', () => { + // Produit charge (memes cles que la reponse reelle § 4.0.bis : @id sur les relations). + const PRODUCT = { + id: 34, + code: 'BLE-01', + name: 'Blé tendre', + states: ['PURCHASE', 'SALE'], + manufactured: true, + containsMolasses: false, + category: { '@id': '/api/categories/12', id: 12, name: 'Céréales', code: 'CEREALES' }, + sites: [{ '@id': '/api/sites/1', id: 1, name: 'Chatellerault', code: '86', postalCode: '86100', city: 'C', color: '#000', fullAddress: 'x' }], + storageTypes: [{ '@id': '/api/storage_types/9', id: 9, code: 'TAS', label: 'Tas' }], + createdAt: '', updatedAt: '', + } + + it('pre-remplit le formulaire depuis le produit (relations en IRI) + charge le stockage', async () => { + const { form, prefill, storageTypeOptions } = useProductForm() + await prefill(PRODUCT) + + expect(form.code).toBe('BLE-01') + expect(form.name).toBe('Blé tendre') + expect(form.states).toEqual(['PURCHASE', 'SALE']) + expect(form.categoryIri).toBe('/api/categories/12') + expect(form.siteIris).toEqual(['/api/sites/1']) + expect(form.storageTypeIris).toEqual(['/api/storage_types/9']) + expect(form.manufactured).toBe(true) + // Cascade : options de stockage chargees pour le site du produit. + expect(mockGet).toHaveBeenCalledWith( + '/storage_types', + expect.objectContaining({ 'siteId[]': ['1'] }), + expect.any(Object), + ) + expect(storageTypeOptions.value.map(o => o.value)).toContain('/api/storage_types/9') + }) + + it('soumet un PATCH /products/{id} apres prefill (RG-6.08)', async () => { + mockPatch.mockResolvedValueOnce({ id: 34 }) + const { prefill, submit } = useProductForm() + await prefill(PRODUCT) + + const ok = await submit() + + expect(ok).toBe(true) + expect(mockPost).not.toHaveBeenCalled() + expect(mockPatch).toHaveBeenCalledWith( + '/products/34', + expect.objectContaining({ code: 'BLE-01', name: 'Blé tendre' }), + expect.objectContaining({ toast: false }), + ) + expect(mockToastSuccess).toHaveBeenCalled() + }) + + it('mappe un 409 doublon de code aussi en edition', async () => { + mockPatch.mockRejectedValueOnce({ response: { status: 409, _data: {} } }) + const { errors, prefill, submit } = useProductForm() + await prefill(PRODUCT) + + const ok = await submit() + + expect(ok).toBe(false) + expect(errors.code).toBe('admin.products.form.duplicateCode') + expect(mockToastError).toHaveBeenCalled() + }) + }) }) diff --git a/frontend/modules/catalog/composables/useProduct.ts b/frontend/modules/catalog/composables/useProduct.ts new file mode 100644 index 0000000..1a8d090 --- /dev/null +++ b/frontend/modules/catalog/composables/useProduct.ts @@ -0,0 +1,41 @@ +import { ref } from 'vue' +import type { Product } from '~/modules/catalog/types/product' + +/** + * Chargement d'un produit unique (ecran « Modification produit », M6 — ERP-206). + * Lit le detail via `GET /api/products/{id}` — meme structure que la ligne de + * liste (category / sites / storageTypes embarques, § 4.0.bis). + * + * L'en-tete `Accept: application/ld+json` est impose pour obtenir le payload + * Hydra complet (IRI `@id` des relations, necessaires au pre-remplissage des + * selects). Etat 100 % local a l'instance (refs) — aucune persistance URL. + */ +export function useProduct(id: number | string) { + const api = useApi() + + const product = ref(null) + const loading = ref(false) + const error = ref(false) + + /** Charge le detail du produit. En cas d'echec : `error = true`, `product = null`. */ + async function load(): Promise { + loading.value = true + error.value = false + try { + product.value = await api.get( + `/products/${id}`, + {}, + { headers: { Accept: 'application/ld+json' }, toast: false }, + ) + } + catch { + error.value = true + product.value = null + } + finally { + loading.value = false + } + } + + return { product, loading, error, load } +} diff --git a/frontend/modules/catalog/composables/useProductForm.ts b/frontend/modules/catalog/composables/useProductForm.ts index 14e3c31..ec4dc7e 100644 --- a/frontend/modules/catalog/composables/useProductForm.ts +++ b/frontend/modules/catalog/composables/useProductForm.ts @@ -15,6 +15,7 @@ import { useCategoryOptions, useStorageTypeOptions, } from '~/modules/catalog/composables/useProductOptions' +import type { Product } from '~/modules/catalog/types/product' /** Etats produit (miroir de l'enum back Product::STATE_*). */ export const PRODUCT_STATES = ['PURCHASE', 'SALE', 'OTHER'] as const @@ -51,6 +52,10 @@ export function useProductForm() { const submitting = ref(false) + // Id du produit edite (null = creation). Pilote l'URL/methode du submit (RG-6.08 : + // « Modification » = meme formulaire/regles que « Ajouter », bouton « Enregistrer »). + const productId = ref(null) + // RG-6.03 : « Fabriqué » / « Contient de la mélasse » saisissables uniquement // si l'etat contient « Vendu » (SALE). const isSale = computed(() => form.states.includes('SALE')) @@ -104,9 +109,34 @@ export function useProductForm() { } /** - * Soumet la creation. Retourne true au succes (la page redirige), false sinon. - * 422 → mapping inline par champ (useFormErrors) ; 409 doublon de code → - * erreur inline sur `code` + toast explicite (RG-6.01). + * Pre-remplit le formulaire depuis un produit charge (mode edition, RG-6.08). + * Les relations sont reprises via leur IRI `@id` (= valeur d'option des selects). + * Charge au passage les options de Type de stockage pour les sites du produit, + * afin que le multi-select affiche les libelles et conserve la selection. + */ + async function prefill(product: Product): Promise { + productId.value = product.id + form.code = product.code + form.name = product.name + form.states = [...product.states] + form.categoryIri = product.category?.['@id'] ?? null + form.siteIris = product.sites.map(s => s['@id']) + form.manufactured = product.manufactured + form.containsMolasses = product.containsMolasses + + const siteIds = form.siteIris + .map(iriToId) + .filter((id): id is number => id !== null) + await storage.load(siteIds) + form.storageTypeIris = product.storageTypes.map(st => st['@id']) + } + + /** + * Soumet le formulaire. Retourne true au succes (la page redirige), false sinon. + * Creation → `POST /products` ; edition (productId non nul, RG-6.08) → + * `PATCH /products/{id}` (mode merge-patch gere par useApi). 422 → mapping + * inline par champ (useFormErrors) ; 409 doublon de code → erreur inline sur + * `code` + toast explicite (RG-6.01, unicite re-validee aussi en edition). */ async function submit(): Promise { if (submitting.value) { @@ -114,6 +144,7 @@ export function useProductForm() { } submitting.value = true formErrors.clearErrors() + const editing = productId.value !== null try { const payload = { code: form.code || null, @@ -127,11 +158,15 @@ export function useProductForm() { sites: form.siteIris, storageTypes: form.storageTypeIris, } - await api.post('/products', payload, { - headers: { Accept: 'application/ld+json' }, - toast: false, - }) - toast.success({ title: t('admin.products.toast.createSuccess') }) + const options = { headers: { Accept: 'application/ld+json' }, toast: false } + if (editing) { + await api.patch(`/products/${productId.value}`, payload, options) + toast.success({ title: t('admin.products.toast.updateSuccess') }) + } + else { + await api.post('/products', payload, options) + toast.success({ title: t('admin.products.toast.createSuccess') }) + } return true } catch (error) { @@ -154,6 +189,7 @@ export function useProductForm() { return { form, + productId, errors: formErrors.errors, submitting, isSale, @@ -165,6 +201,7 @@ export function useProductForm() { setStorageTypes, setSites, loadReferentials, + prefill, submit, } } diff --git a/frontend/modules/catalog/pages/__tests__/productEdit.spec.ts b/frontend/modules/catalog/pages/__tests__/productEdit.spec.ts new file mode 100644 index 0000000..2f10e4b --- /dev/null +++ b/frontend/modules/catalog/pages/__tests__/productEdit.spec.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { defineComponent, h, Suspense } from 'vue' + +// Produit charge simule (cles de la reponse reelle § 4.0.bis). +const PRODUCT = { + id: 34, + code: 'BLE-01', + name: 'Blé tendre', + states: ['PURCHASE'], + manufactured: false, + containsMolasses: false, + category: { '@id': '/api/categories/12', id: 12, name: 'Céréales', code: 'CEREALES' }, + sites: [{ '@id': '/api/sites/1', id: 1, name: 'Chatellerault' }], + storageTypes: [{ '@id': '/api/storage_types/9', id: 9, code: 'TAS', label: 'Tas' }], + createdAt: '', updatedAt: '', +} + +// Holders crees dans les factories (vue initialise au moment de l'import page). +const fx = vi.hoisted(() => ({ + isSale: null as unknown as { value: boolean }, + submit: vi.fn(), + prefill: vi.fn(), + loadReferentials: vi.fn(), + load: vi.fn(), +})) + +vi.mock('~/modules/catalog/composables/useProductForm', async () => { + const { ref, reactive } = await import('vue') + fx.isSale = ref(false) + return { + PRODUCT_STATES: ['PURCHASE', 'SALE', 'OTHER'], + useProductForm: () => ({ + form: reactive({ + code: null, name: null, states: [], siteIris: [], + categoryIri: null, storageTypeIris: [], manufactured: false, containsMolasses: false, + }), + errors: reactive({}), + submitting: ref(false), + isSale: fx.isSale, + siteOptions: ref([]), + categoryOptions: ref([]), + storageTypeOptions: ref([]), + setStates: vi.fn(), + setCategory: vi.fn(), + setStorageTypes: vi.fn(), + setSites: vi.fn(), + loadReferentials: fx.loadReferentials, + prefill: fx.prefill, + submit: fx.submit, + }), + } +}) + +vi.mock('~/modules/catalog/composables/useProduct', async () => { + const { ref } = await import('vue') + return { + useProduct: () => ({ + product: ref(PRODUCT), + loading: ref(false), + error: ref(false), + load: fx.load, + }), + } +}) + +// ── Auto-imports Nuxt stubbes globalement ─────────────────────────────────── +const mockPush = vi.hoisted(() => vi.fn()) +const mockNavigateTo = vi.hoisted(() => vi.fn()) +const mockCan = vi.hoisted(() => vi.fn()) + +vi.stubGlobal('useI18n', () => ({ t: (key: string) => key })) +vi.stubGlobal('useHead', () => undefined) +vi.stubGlobal('useRoute', () => ({ params: { id: '34' } })) +vi.stubGlobal('useRouter', () => ({ push: mockPush })) +vi.stubGlobal('usePermissions', () => ({ can: mockCan })) +vi.stubGlobal('navigateTo', mockNavigateTo) + +const EditPage = (await import('../admin/products/[id]/edit.vue')).default + +const ButtonStub = defineComponent({ + props: { label: { type: String, default: '' }, disabled: { type: Boolean, default: false } }, + emits: ['click'], + setup(props, { emit }) { + return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label) + }, +}) +const InputStub = defineComponent({ + props: { label: { type: String, default: '' }, modelValue: { default: null } }, + setup(props) { return () => h('input', { 'data-label': props.label }) }, +}) +const CheckboxStub = defineComponent({ + props: { label: { type: String, default: '' } }, + setup(props) { return () => h('input', { 'type': 'checkbox', 'data-label': props.label }) }, +}) +// Placeholder : rendu sans aucun appel API (juste un marqueur). +const TabsStub = defineComponent({ setup() { return () => h('div', { 'data-testid': 'placeholder-tabs' }) } }) + +const stubs = { + MalioButtonIcon: ButtonStub, + MalioButton: ButtonStub, + MalioInputText: InputStub, + MalioSelect: InputStub, + MalioSelectCheckbox: InputStub, + MalioCheckbox: CheckboxStub, + ProductPlaceholderTabs: TabsStub, +} + +async function mountPage() { + const wrapper = mount(defineComponent({ + components: { EditPage }, + setup: () => () => h(Suspense, null, { default: () => h(EditPage) }), + }), { global: { stubs } }) + await flushPromises() + return wrapper +} + +describe('Écran Modifier un produit (page /admin/products/{id}/edit)', () => { + beforeEach(() => { + fx.submit.mockReset().mockResolvedValue(true) + fx.prefill.mockReset().mockResolvedValue(undefined) + fx.loadReferentials.mockReset().mockResolvedValue(undefined) + fx.load.mockReset().mockResolvedValue(undefined) + mockPush.mockReset() + mockNavigateTo.mockReset() + mockCan.mockReset().mockReturnValue(true) + fx.isSale.value = false + }) + + it('charge le produit et pre-remplit le formulaire au montage', async () => { + await mountPage() + expect(fx.load).toHaveBeenCalled() + expect(fx.prefill).toHaveBeenCalledWith(PRODUCT) + }) + + it('redirige vers la liste sans la permission manage', async () => { + mockCan.mockReturnValue(false) + await mountPage() + expect(mockNavigateTo).toHaveBeenCalledWith('/admin/products') + }) + + it('bouton « Enregistrer » : submit (PATCH) puis retour a la liste', async () => { + const wrapper = await mountPage() + await wrapper.find('[data-label="admin.products.edit.save"]').trigger('click') + await flushPromises() + expect(fx.submit).toHaveBeenCalled() + expect(mockPush).toHaveBeenCalledWith('/admin/products') + }) + + it('affiche les onglets placeholder (rendu sans appel API)', async () => { + const wrapper = await mountPage() + expect(wrapper.find('[data-testid="placeholder-tabs"]').exists()).toBe(true) + }) +}) diff --git a/frontend/modules/catalog/pages/__tests__/productNew.spec.ts b/frontend/modules/catalog/pages/__tests__/productNew.spec.ts index 4160f8c..60ffa63 100644 --- a/frontend/modules/catalog/pages/__tests__/productNew.spec.ts +++ b/frontend/modules/catalog/pages/__tests__/productNew.spec.ts @@ -67,6 +67,9 @@ const CheckboxStub = defineComponent({ setup(props) { return () => h('input', { 'type': 'checkbox', 'data-label': props.label }) }, }) +// Placeholder (onglets Fournisseurs/Clients) : marqueur sans appel API. +const TabsStub = defineComponent({ setup() { return () => h('div', { 'data-testid': 'placeholder-tabs' }) } }) + const stubs = { MalioButtonIcon: ButtonStub, MalioButton: ButtonStub, @@ -74,6 +77,7 @@ const stubs = { MalioSelect: InputStub, MalioSelectCheckbox: InputStub, MalioCheckbox: CheckboxStub, + ProductPlaceholderTabs: TabsStub, } async function mountPage() { diff --git a/frontend/modules/catalog/pages/admin/products/[id]/edit.vue b/frontend/modules/catalog/pages/admin/products/[id]/edit.vue new file mode 100644 index 0000000..02a20c2 --- /dev/null +++ b/frontend/modules/catalog/pages/admin/products/[id]/edit.vue @@ -0,0 +1,182 @@ + + + diff --git a/frontend/modules/catalog/pages/admin/products/new.vue b/frontend/modules/catalog/pages/admin/products/new.vue index e33a10e..7d5f545 100644 --- a/frontend/modules/catalog/pages/admin/products/new.vue +++ b/frontend/modules/catalog/pages/admin/products/new.vue @@ -97,6 +97,10 @@ @click="onSubmit" /> + + + diff --git a/frontend/modules/catalog/types/product.ts b/frontend/modules/catalog/types/product.ts index 7a5e2e3..32d0536 100644 --- a/frontend/modules/catalog/types/product.ts +++ b/frontend/modules/catalog/types/product.ts @@ -22,6 +22,8 @@ export interface ProductCategoryType { /** Categorie embarquee dans un produit (lecture seule, sous-ensemble utile au front). */ export interface ProductCategory { + /** IRI Hydra, ex. `/api/categories/12` — utilise pour pre-selectionner le select en edition. */ + '@id': string id: number name: string code: string @@ -30,6 +32,8 @@ export interface ProductCategory { /** Site de disponibilite embarque dans un produit (groupe `site:read`). */ export interface ProductSite { + /** IRI Hydra, ex. `/api/sites/1` — utilise pour pre-selectionner le multi-select en edition. */ + '@id': string id: number name: string code: string @@ -41,6 +45,8 @@ export interface ProductSite { /** Type de stockage embarque dans un produit (referentiel borne, § 2.4). */ export interface ProductStorageType { + /** IRI Hydra, ex. `/api/storage_types/9` — utilise pour pre-selectionner le multi-select en edition. */ + '@id': string id: number code: string label: string