feat(catalog) : M6 — écran Modification produit + onglets placeholder (ERP-206)

É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)
This commit is contained in:
2026-06-25 18:01:33 +02:00
parent ce0e274743
commit 64c3b9b6ec
10 changed files with 544 additions and 10 deletions
@@ -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()
})
})
})