Compare commits

...

3 Commits

Author SHA1 Message Date
tristan 64c3b9b6ec 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)
2026-06-25 18:01:33 +02:00
tristan ce0e274743 feat(catalog) : M6 — écran Ajouter un produit /admin/products/new (ERP-205)
Formulaire principal de création produit (admin-only) : état, sites, nom,
code, catégorie (type PRODUIT), types de stockage, booléens conditionnels.

- RG-6.03 : « Fabriqué » / « Contient de la mélasse » visibles uniquement si l'état contient « Vendu »
- RG-6.06 : cascade Site → Type de stockage (rechargement + purge des types indisponibles) dans useProductForm
- RG-6.01 : POST /products (toast:false) ; 422 mappées inline (useFormErrors), 409 doublon de code → setError + toast
- bouton « Valider » toujours actif, validation autoritaire serveur (ERP-101)
- composables useSiteOptions / useCategoryOptions / useStorageTypeOptions (?pagination=false)
- i18n admin.products.form ; 15 tests Vitest (useProductForm + page)
2026-06-25 17:52:02 +02:00
tristan f12a378126 feat(catalog) : M6 — page liste produits /admin/products (ERP-204)
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m59s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 5m35s
Ecran d'entree du catalogue produit (admin-only) : liste paginee
(usePaginatedList), drawer de filtres (categorie/etat/sites), export
XLSX et navigation vers creation/edition.

- colonnes Nom / Numero (code) / Categorie (category.name), tri name ASC serveur
- filtres mappes sur les query params du provider (categoryId, state, siteId[])
- etat du tableau 100% local (jamais dans l'URL)
- type Product calque sur le contrat JSON capture (ERP-203)
- i18n admin.products ; 11 tests Vitest
2026-06-25 17:39:41 +02:00
13 changed files with 2074 additions and 0 deletions
+58
View File
@@ -1020,6 +1020,64 @@
"duplicate": "Une catégorie nommée « {name} » existe déjà.",
"typesLoadFailed": "Impossible de charger les types de catégorie. Réessayez."
}
},
"products": {
"title": "Catalogue produit",
"add": "Ajouter",
"export": "Exporter",
"empty": "Aucun produit pour l'instant.",
"column": {
"name": "Nom",
"code": "Numéro",
"category": "Catégorie"
},
"state": {
"PURCHASE": "Achat",
"SALE": "Vendu",
"OTHER": "Autre"
},
"filters": {
"title": "Filtres",
"search": "Recherche",
"category": "Catégorie",
"categoryAll": "Toutes les catégories",
"state": "État",
"stateAll": "Tous les états",
"site": "Sites",
"apply": "Voir les résultats",
"reset": "Réinitialiser"
},
"form": {
"title": "Ajouter un produit",
"back": "Retour au catalogue",
"submit": "Valider",
"states": "État du produit",
"sites": "Site",
"name": "Nom du produit",
"code": "Code produit",
"category": "Catégorie produit",
"storageTypes": "Type de stockage",
"manufactured": "Fabriqué",
"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",
"updateSuccess": "Produit mis à jour avec succès"
}
}
}
}
@@ -0,0 +1,27 @@
<template>
<!--
Onglets « Fournisseurs » / « Clients » de la fiche produit HORS PERIMETRE
V0 (HP-M6-01, RG-6.10) : ils dependent d'un module Contrat inexistant.
Rendu en placeholder « en cours de développement » (meme composant que les
onglets non-dev des fiches M1→M4). AUCUN appel API, AUCUN champ saisissable.
Affiches sans condition d'etat (a raffiner avec le module Contrat).
-->
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<template #suppliers><ComingSoonPlaceholder /></template>
<template #clients><ComingSoonPlaceholder /></template>
</MalioTabList>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
const { t } = useI18n()
const activeTab = ref('suppliers')
// Icone (Iconify) par onglet, alignee sur la convention des fiches existantes.
const tabs = computed(() => [
{ key: 'suppliers', label: t('admin.products.tab.suppliers'), icon: 'mdi:truck-outline' },
{ key: 'clients', label: t('admin.products.tab.clients'), icon: 'mdi:account-group-outline' },
])
</script>
@@ -0,0 +1,287 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { nextTick } from 'vue'
import { useFormErrors } from '~/shared/composables/useFormErrors'
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())
vi.stubGlobal('useApi', () => ({
get: mockGet,
post: mockPost,
put: vi.fn(),
patch: mockPatch,
delete: vi.fn(),
}))
vi.stubGlobal('useToast', () => ({
success: mockToastSuccess,
error: mockToastError,
}))
// useFormErrors est un auto-import Nuxt : on expose l'implementation reelle
// (elle consomme useToast/useI18n deja stubbes) pour tester l'integration 422/409.
vi.stubGlobal('useFormErrors', useFormErrors)
vi.stubGlobal('useI18n', () => ({
t: (key: string, params?: Record<string, unknown>) =>
params ? `${key}::${JSON.stringify(params)}` : key,
}))
/** Reponse Hydra des types de stockage selon les sites demandes. */
function storageMembersForSites(siteIds: string[]): { member: Array<{ '@id': string, label: string }> } {
// Site 1 → types 9 et 5 ; site 2 → type 7. Permet de tester la cascade.
const byId: Record<string, Array<{ '@id': string, label: string }>> = {
'1': [
{ '@id': '/api/storage_types/9', label: 'Tas' },
{ '@id': '/api/storage_types/5', label: 'Cellule' },
],
'2': [{ '@id': '/api/storage_types/7', label: 'Cuve mélasse' }],
}
const seen = new Map<string, { '@id': string, label: string }>()
for (const id of siteIds) {
for (const m of byId[id] ?? []) {
seen.set(m['@id'], m)
}
}
return { member: [...seen.values()] }
}
describe('useProductForm', () => {
beforeEach(() => {
mockGet.mockReset()
mockPost.mockReset()
mockPatch.mockReset()
mockToastSuccess.mockReset()
mockToastError.mockReset()
// Routage des GET par url (referentiels + cascade stockage).
mockGet.mockImplementation((url: string, query: Record<string, unknown> = {}) => {
if (url === '/sites') {
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault' }] })
}
if (url === '/categories') {
return Promise.resolve({ member: [{ '@id': '/api/categories/12', name: 'Céréales' }] })
}
if (url === '/storage_types') {
const raw = (query['siteId[]'] ?? []) as string[]
return Promise.resolve(storageMembersForSites(raw))
}
return Promise.resolve({ member: [] })
})
})
describe('RG-6.03 — champs conditionnels « Vendu »', () => {
it('isSale est vrai uniquement si states contient SALE', () => {
const { form, isSale } = useProductForm()
expect(isSale.value).toBe(false)
form.states = ['PURCHASE']
expect(isSale.value).toBe(false)
form.states = ['PURCHASE', 'SALE']
expect(isSale.value).toBe(true)
})
it('remet manufactured / containsMolasses a false quand SALE est retire', async () => {
const { form, isSale } = useProductForm()
form.states = ['SALE']
form.manufactured = true
form.containsMolasses = true
await nextTick()
expect(isSale.value).toBe(true)
form.states = ['PURCHASE']
await nextTick()
expect(form.manufactured).toBe(false)
expect(form.containsMolasses).toBe(false)
})
})
describe('RG-6.06 — cascade Site → Type de stockage', () => {
it('charge les types de stockage filtres par les sites selectionnes', async () => {
const { storageTypeOptions, setSites } = useProductForm()
await setSites(['/api/sites/1'])
expect(mockGet).toHaveBeenCalledWith(
'/storage_types',
expect.objectContaining({ 'siteId[]': ['1'], pagination: 'false' }),
expect.any(Object),
)
expect(storageTypeOptions.value.map(o => o.value)).toEqual([
'/api/storage_types/9',
'/api/storage_types/5',
])
})
it('retire de la selection les types devenus indisponibles', async () => {
const { form, setStorageTypes, setSites } = useProductForm()
// Selection initiale sur le site 1 (types 9 et 5).
await setSites(['/api/sites/1'])
setStorageTypes(['/api/storage_types/9', '/api/storage_types/5'])
// Bascule vers le site 2 (type 7 seul) : 9 et 5 ne sont plus dispo.
await setSites(['/api/sites/2'])
expect(form.storageTypeIris).toEqual([])
})
it('vide options + selection quand plus aucun site n\'est selectionne', async () => {
const { form, storageTypeOptions, setStorageTypes, setSites } = useProductForm()
await setSites(['/api/sites/1'])
setStorageTypes(['/api/storage_types/9'])
await setSites([])
expect(storageTypeOptions.value).toEqual([])
expect(form.storageTypeIris).toEqual([])
// Pas d'appel /storage_types inutile sans site.
expect(mockGet).not.toHaveBeenCalledWith('/storage_types', expect.objectContaining({ 'siteId[]': [] }), expect.any(Object))
})
})
describe('submit — POST /products', () => {
function fillValidForm(form: ReturnType<typeof useProductForm>['form']): void {
form.code = 'ble-01'
form.name = 'Blé tendre'
form.states = ['PURCHASE', 'SALE']
form.siteIris = ['/api/sites/1']
form.categoryIri = '/api/categories/12'
form.storageTypeIris = ['/api/storage_types/9']
form.manufactured = true
form.containsMolasses = false
}
it('poste le payload (relations en IRI) et retourne true au succes', async () => {
mockPost.mockResolvedValueOnce({ id: 34 })
const { form, submit } = useProductForm()
fillValidForm(form)
const ok = await submit()
expect(ok).toBe(true)
expect(mockPost).toHaveBeenCalledWith(
'/products',
{
code: 'ble-01',
name: 'Blé tendre',
states: ['PURCHASE', 'SALE'],
manufactured: true,
containsMolasses: false,
category: '/api/categories/12',
sites: ['/api/sites/1'],
storageTypes: ['/api/storage_types/9'],
},
expect.objectContaining({ toast: false }),
)
expect(mockToastSuccess).toHaveBeenCalled()
})
it('force manufactured / containsMolasses a false hors « Vendu » (RG-6.03)', async () => {
mockPost.mockResolvedValueOnce({ id: 35 })
const { form, submit } = useProductForm()
fillValidForm(form)
// L'utilisateur retire « Vendu » apres avoir coche les booleens.
form.states = ['PURCHASE']
await submit()
const payload = mockPost.mock.calls[0][1]
expect(payload.manufactured).toBe(false)
expect(payload.containsMolasses).toBe(false)
})
it('mappe un 409 doublon de code sur errors.code + toast explicite', async () => {
mockPost.mockRejectedValueOnce({ response: { status: 409, _data: {} } })
const { form, errors, submit } = useProductForm()
fillValidForm(form)
const ok = await submit()
expect(ok).toBe(false)
expect(errors.code).toBe('admin.products.form.duplicateCode')
expect(mockToastError).toHaveBeenCalled()
})
it('mappe une 422 inline par champ (errors.code) sans toast', async () => {
mockPost.mockRejectedValueOnce({
response: {
status: 422,
_data: { violations: [{ propertyPath: 'code', message: 'Le code produit est obligatoire.' }] },
},
})
const { form, errors, submit } = useProductForm()
fillValidForm(form)
form.code = null
const ok = await submit()
expect(ok).toBe(false)
expect(errors.code).toBe('Le code produit est obligatoire.')
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()
})
})
})
@@ -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<Product | null>(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<void> {
loading.value = true
error.value = false
try {
product.value = await api.get<Product>(
`/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 }
}
@@ -0,0 +1,207 @@
/**
* Composable du formulaire de creation produit (M6 — ERP-205).
*
* Porte l'etat du formulaire principal, les referentiels des selects, les regles
* de gestion front (champs conditionnels RG-6.03, cascade site→stockage RG-6.06)
* et la soumission `POST /api/products` avec mapping des erreurs 422/409 inline
* (useFormErrors). Reference : ecran « Ajouter un client » / « Ajouter un
* prestataire » (formulaire principal).
*
* Etat 100 % local a l'instance.
*/
import { computed, reactive, ref, watch } from 'vue'
import {
useSiteOptions,
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
/** Extrait l'id numerique d'un IRI Hydra (`/api/sites/1` → 1), sinon null. */
function iriToId(iri: string): number | null {
const tail = iri.split('/').pop()
return tail !== undefined && /^\d+$/.test(tail) ? Number(tail) : null
}
export function useProductForm() {
const api = useApi()
const { t } = useI18n()
const toast = useToast()
const formErrors = useFormErrors()
const sites = useSiteOptions()
const categories = useCategoryOptions({ typeCode: 'PRODUIT' })
const storage = useStorageTypeOptions()
// ── Etat du formulaire ───────────────────────────────────────────────────
// Les relations sont stockees en IRI (envoyees telles quelles au POST) ;
// `states` porte les codes enum ; les booleens conditionnels RG-6.03 a part.
const form = reactive({
code: null as string | null,
name: null as string | null,
states: [] as string[],
siteIris: [] as string[],
categoryIri: null as string | null,
storageTypeIris: [] as string[],
manufactured: false,
containsMolasses: false,
})
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<number | null>(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'))
// Quand l'etat ne contient plus SALE, on remet les booleens a false : le back
// les forcerait de toute facon (RG-6.03), on evite de soumettre une valeur
// fantome saisie avant de retirer « Vendu ».
watch(isSale, (sale) => {
if (!sale) {
form.manufactured = false
form.containsMolasses = false
}
})
/** Met a jour les etats (multi-select). */
function setStates(states: string[]): void {
form.states = states
}
/** Met a jour la categorie (select simple). */
function setCategory(iri: string | null): void {
form.categoryIri = iri
}
/** Met a jour les types de stockage (multi-select). */
function setStorageTypes(iris: string[]): void {
form.storageTypeIris = iris
}
/**
* RG-6.06 (cascade) : a chaque changement de Site, recharge les options de Type
* de stockage filtrees par les sites choisis et retire de la selection les
* types devenus indisponibles.
*/
async function setSites(iris: string[]): Promise<void> {
form.siteIris = iris
const siteIds = iris
.map(iriToId)
.filter((id): id is number => id !== null)
await storage.load(siteIds)
const available = new Set(storage.options.value.map(o => o.value))
form.storageTypeIris = form.storageTypeIris.filter(iri => available.has(iri))
}
/** Charge les referentiels initiaux (sites + categories). Resilient. */
async function loadReferentials(): Promise<void> {
await Promise.allSettled([sites.load(), categories.load()])
// Les types de stockage se chargent a la 1re selection de sites (cascade).
}
/**
* 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<void> {
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<boolean> {
if (submitting.value) {
return false
}
submitting.value = true
formErrors.clearErrors()
const editing = productId.value !== null
try {
const payload = {
code: form.code || null,
name: form.name || null,
states: form.states,
// RG-6.03 : booleens forces a false hors « Vendu » (le back les
// re-force, on garde le payload coherent).
manufactured: isSale.value ? form.manufactured : false,
containsMolasses: isSale.value ? form.containsMolasses : false,
category: form.categoryIri,
sites: form.siteIris,
storageTypes: form.storageTypeIris,
}
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) {
const status = (error as { response?: { status?: number } })?.response?.status
if (status === 409) {
// Doublon de code (RG-6.01) : inline sur le champ + toast explicite.
const message = t('admin.products.form.duplicateCode')
formErrors.setError('code', message)
toast.error({ title: t('admin.products.toast.error'), message })
}
else {
formErrors.handleApiError(error, { fallbackMessage: t('admin.products.toast.error') })
}
return false
}
finally {
submitting.value = false
}
}
return {
form,
productId,
errors: formErrors.errors,
submitting,
isSale,
siteOptions: sites.options,
categoryOptions: categories.options,
storageTypeOptions: storage.options,
setStates,
setCategory,
setStorageTypes,
setSites,
loadReferentials,
prefill,
submit,
}
}
@@ -0,0 +1,94 @@
/**
* Composables d'options des selects du formulaire produit (M6 — ERP-205).
*
* Chaque referentiel est borne (quelques dizaines d'entrees) : on le recupere en
* entier via l'echappatoire `?pagination=false`, 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 ManyToOne / ManyToMany).
*
* Etat 100 % local a l'instance (refs) — aucune persistance URL. Chaque appel cree
* sa propre instance ; le formulaire en consomme une via `useProductForm`.
*/
import { ref } from 'vue'
/** Option generique au format attendu par MalioSelect / MalioSelectCheckbox. */
export interface RefOption {
value: string
label: string
}
/** Membre Hydra minimal commun aux referentiels consommes ici. */
interface HydraMember {
'@id': string
name?: string
label?: string
}
const LD_JSON_HEADERS = { Accept: 'application/ld+json' }
/**
* Recupere une collection complete (pagination desactivee) et la projette en
* options `{ value: IRI, label }`. Resilient : l'appelant gere l'echec (liste vide).
*/
async function fetchOptions(
url: string,
query: Record<string, string | string[]>,
toLabel: (member: HydraMember) => string,
): Promise<RefOption[]> {
const res = await useApi().get<{ member?: HydraMember[] }>(
url,
{ pagination: 'false', ...query },
{ headers: LD_JSON_HEADERS, toast: false },
)
return (res.member ?? []).map(m => ({ value: m['@id'], label: toLabel(m) }))
}
/** Sites de disponibilite (libelle = nom du site). */
export function useSiteOptions() {
const options = ref<RefOption[]>([])
async function load(): Promise<void> {
options.value = await fetchOptions('/sites', {}, s => s.name ?? '')
}
return { options, load }
}
/**
* Categories produit. Filtrees au type voulu (`?typeCode=PRODUIT` pour le produit,
* RG-6.05) cote serveur — le provider Category supporte deja `typeCode`.
*/
export function useCategoryOptions(params: { typeCode: string }) {
const options = ref<RefOption[]>([])
async function load(): Promise<void> {
options.value = await fetchOptions('/categories', { typeCode: params.typeCode }, c => c.name ?? '')
}
return { options, load }
}
/**
* Types de stockage (libelle = `label`). Filtres par les sites selectionnes
* (`?siteId[]=…`, RG-6.06) : on ne charge que les types disponibles sur AU MOINS
* UN des sites passes. Sans site, la liste est videe (le multi-select depend des
* sites).
*/
export function useStorageTypeOptions() {
const options = ref<RefOption[]>([])
async function load(siteIds: number[]): Promise<void> {
if (siteIds.length === 0) {
options.value = []
return
}
options.value = await fetchOptions(
'/storage_types',
{ 'siteId[]': siteIds.map(String) },
s => s.label ?? '',
)
}
return { options, load }
}
@@ -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)
})
})
@@ -0,0 +1,142 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, h, Suspense } from 'vue'
// ── Mock du composable form (sa logique est testee a part : useProductForm.spec).
// Ici on teste le WIRING de la page : rendu conditionnel RG-6.03 + submit→redirect.
// Les refs partagees sont creees DANS la factory (vue est initialise au moment ou
// la page est importee) et exposees via un holder hoiste pour pilotage par test.
const fx = vi.hoisted(() => ({
isSale: null as unknown as { value: boolean },
submit: vi.fn(),
loadReferentials: 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,
submit: fx.submit,
}),
}
})
// ── 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('useRouter', () => ({ push: mockPush }))
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
vi.stubGlobal('navigateTo', mockNavigateTo)
const NewPage = (await import('../admin/products/new.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: '' }, modelValue: { type: Boolean, default: false } },
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,
MalioInputText: InputStub,
MalioSelect: InputStub,
MalioSelectCheckbox: InputStub,
MalioCheckbox: CheckboxStub,
ProductPlaceholderTabs: TabsStub,
}
async function mountPage() {
const wrapper = mount(defineComponent({
components: { NewPage },
setup: () => () => h(Suspense, null, { default: () => h(NewPage) }),
}), { global: { stubs } })
await flushPromises()
return wrapper
}
describe('Écran Ajouter un produit (page /admin/products/new)', () => {
beforeEach(() => {
fx.submit.mockReset().mockResolvedValue(true)
fx.loadReferentials.mockReset().mockResolvedValue(undefined)
mockPush.mockReset()
mockNavigateTo.mockReset()
mockCan.mockReset().mockReturnValue(true)
fx.isSale.value = false
})
it('redirige vers la liste sans la permission manage', async () => {
mockCan.mockReturnValue(false)
await mountPage()
expect(mockNavigateTo).toHaveBeenCalledWith('/admin/products')
})
it('charge les referentiels au montage', async () => {
await mountPage()
expect(fx.loadReferentials).toHaveBeenCalled()
})
it('RG-6.03 : masque Fabriqué / mélasse tant que l\'etat ne contient pas « Vendu »', async () => {
fx.isSale.value = false
const wrapper = await mountPage()
expect(wrapper.find('[data-label="admin.products.form.manufactured"]').exists()).toBe(false)
expect(wrapper.find('[data-label="admin.products.form.containsMolasses"]').exists()).toBe(false)
})
it('RG-6.03 : affiche Fabriqué / mélasse quand l\'etat contient « Vendu »', async () => {
fx.isSale.value = true
const wrapper = await mountPage()
expect(wrapper.find('[data-label="admin.products.form.manufactured"]').exists()).toBe(true)
expect(wrapper.find('[data-label="admin.products.form.containsMolasses"]').exists()).toBe(true)
})
it('« Valider » : submit puis retour a la liste au succes', async () => {
const wrapper = await mountPage()
await wrapper.find('[data-label="admin.products.form.submit"]').trigger('click')
await flushPromises()
expect(fx.submit).toHaveBeenCalled()
expect(mockPush).toHaveBeenCalledWith('/admin/products')
})
it('ne redirige pas si submit echoue (erreurs inline)', async () => {
fx.submit.mockResolvedValueOnce(false)
const wrapper = await mountPage()
await wrapper.find('[data-label="admin.products.form.submit"]').trigger('click')
await flushPromises()
expect(mockPush).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,272 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { defineComponent, h, ref } from 'vue'
// ── Auto-imports Nuxt stubbes globalement ───────────────────────────────────
// La page ne les importe pas (auto-import) : on les expose en globals pour le
// runtime de test (happy-dom). Meme philosophie que les specs M1→M5.
const mockPush = vi.hoisted(() => vi.fn())
const mockApiGet = vi.hoisted(() => vi.fn())
const mockCan = vi.hoisted(() => vi.fn())
const mockSetFilters = vi.hoisted(() => vi.fn())
const mockFetch = vi.hoisted(() => vi.fn())
const mockToastError = vi.hoisted(() => vi.fn())
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('useHead', () => undefined)
vi.stubGlobal('useApi', () => ({ get: mockApiGet }))
vi.stubGlobal('useRouter', () => ({ push: mockPush }))
vi.stubGlobal('useToast', () => ({ error: mockToastError, success: vi.fn() }))
vi.stubGlobal('usePermissions', () => ({ can: mockCan }))
// usePaginatedList est l'auto-import pilotant la liste : on controle items +
// setFilters + fetch. La ligne reproduit le contrat JSON reel (§ 4.0.bis).
vi.stubGlobal('usePaginatedList', () => ({
items: ref<Array<Record<string, unknown>>>([
{
id: 34,
code: 'BLE-TENDRE-01',
name: 'Blé tendre',
states: ['PURCHASE', 'SALE'],
manufactured: true,
containsMolasses: true,
category: { id: 12, name: 'Céréales', code: 'CEREALES' },
sites: [{ id: 1, name: 'Chatellerault', code: '86' }],
storageTypes: [{ id: 9, code: 'TAS', label: 'Tas' }],
},
]),
totalItems: ref(1),
currentPage: ref(1),
itemsPerPage: ref(10),
itemsPerPageOptions: ref([10, 25, 50]),
fetch: mockFetch,
goToPage: vi.fn(),
setItemsPerPage: vi.fn(),
setFilters: mockSetFilters,
}))
// happy-dom n'implemente pas createObjectURL : on ajoute les methodes statiques
// sur la classe URL existante (sans la remplacer — sinon `new URL()` casse).
globalThis.URL.createObjectURL = vi.fn(() => 'blob:fake')
globalThis.URL.revokeObjectURL = vi.fn()
// Import APRES les stubs (la page resout les auto-imports au top-level du module).
const ProductsIndex = (await import('../admin/products.vue')).default
// ── Stubs de composants ──────────────────────────────────────────────────────
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 DataTableStub = defineComponent({
props: { items: { type: Array, default: () => [] } },
emits: ['row-click', 'update:page', 'update:per-page'],
setup(props, { emit }) {
return () => h('div', { 'data-testid': 'datatable' },
(props.items as Array<Record<string, unknown>>).map(it =>
h('tr', {
'data-row-id': it.id,
'data-name': it.name,
'data-code': it.code,
'data-category': it.categoryName,
'onClick': () => emit('row-click', it),
}),
),
)
},
})
const DrawerStub = defineComponent({
props: { modelValue: { type: Boolean, default: false } },
setup(_, { slots }) {
return () => h('div', {}, [slots.header?.(), slots.default?.(), slots.footer?.()])
},
})
const SlotStub = defineComponent({ setup(_, { slots }) { return () => h('div', {}, slots.default?.()) } })
const PageHeaderStub = defineComponent({
setup(_, { slots }) { return () => h('div', {}, [slots.default?.(), slots.actions?.()]) },
})
const CheckboxStub = defineComponent({
props: { id: { type: String, default: '' }, modelValue: { type: Boolean, default: false } },
emits: ['update:model-value'],
setup(props, { emit }) {
return () => h('input', {
'type': 'checkbox',
'data-id': props.id,
'onChange': (e: Event) => emit('update:model-value', (e.target as HTMLInputElement).checked),
})
},
})
const SelectStub = defineComponent({
props: {
modelValue: { type: [String, Number, null] as unknown as () => string | number | null, default: null },
options: { type: Array, default: () => [] },
emptyOptionLabel: { type: String, default: '' },
},
emits: ['update:model-value'],
setup(props, { emit }) {
return () => h('select', {
'data-empty-label': props.emptyOptionLabel,
'onChange': (e: Event) => emit('update:model-value', (e.target as HTMLSelectElement).value),
}, (props.options as Array<{ value: string | number, label: string }>).map(o =>
h('option', { value: o.value }, o.label),
))
},
})
const InputTextStub = defineComponent({ setup() { return () => h('input') } })
function mountPage() {
return mount(ProductsIndex, {
global: {
stubs: {
PageHeader: PageHeaderStub,
MalioButton: ButtonStub,
MalioDataTable: DataTableStub,
MalioDrawer: DrawerStub,
MalioAccordion: SlotStub,
MalioAccordionItem: SlotStub,
MalioInputText: InputTextStub,
MalioSelect: SelectStub,
MalioCheckbox: CheckboxStub,
},
},
})
}
describe('Catalogue produit (page /admin/products)', () => {
beforeEach(() => {
mockPush.mockReset()
mockApiGet.mockReset().mockImplementation((url: string) => {
if (url === '/categories') {
return Promise.resolve({ member: [{ '@id': '/api/categories/12', id: 12, name: 'Céréales' }] })
}
if (url === '/sites') {
return Promise.resolve({ member: [{ id: 1, name: 'Chatellerault' }] })
}
return Promise.resolve({ member: [] })
})
mockCan.mockReset().mockReturnValue(true)
mockSetFilters.mockReset()
mockFetch.mockReset()
mockToastError.mockReset()
})
it('charge la liste au montage', async () => {
mountPage()
await flushPromises()
expect(mockFetch).toHaveBeenCalled()
})
it('mappe les colonnes Nom / Numéro / Catégorie sur le JSON réel (§ 4.0.bis)', async () => {
const wrapper = mountPage()
await flushPromises()
const row = wrapper.find('tr[data-row-id="34"]')
expect(row.attributes('data-name')).toBe('Blé tendre')
expect(row.attributes('data-code')).toBe('BLE-TENDRE-01')
expect(row.attributes('data-category')).toBe('Céréales')
})
it('affiche « + Ajouter » uniquement avec la permission manage', async () => {
mockCan.mockImplementation((perm: string) => perm === 'catalog.products.manage')
const wrapper = mountPage()
await flushPromises()
expect(wrapper.find('[data-label="admin.products.add"]').exists()).toBe(true)
})
it('masque « + Ajouter » sans la permission manage (view seul)', async () => {
mockCan.mockImplementation((perm: string) => perm === 'catalog.products.view')
const wrapper = mountPage()
await flushPromises()
expect(wrapper.find('[data-label="admin.products.add"]').exists()).toBe(false)
})
it('navigue vers l\'édition au clic sur une ligne', async () => {
const wrapper = mountPage()
await flushPromises()
await wrapper.find('tr[data-row-id="34"]').trigger('click')
expect(mockPush).toHaveBeenCalledWith('/admin/products/34/edit')
})
it('navigue vers la création au clic sur « + Ajouter »', async () => {
const wrapper = mountPage()
await flushPromises()
await wrapper.find('[data-label="admin.products.add"]').trigger('click')
expect(mockPush).toHaveBeenCalledWith('/admin/products/new')
})
it('appelle l\'export XLSX sur /products/export.xlsx en blob', async () => {
const wrapper = mountPage()
await flushPromises()
await wrapper.find('[data-label="admin.products.export"]').trigger('click')
await flushPromises()
expect(mockApiGet).toHaveBeenCalledWith(
'/products/export.xlsx',
expect.any(Object),
expect.objectContaining({ responseType: 'blob', toast: false }),
)
})
it('répercute les sites cochés dans setFilters (filtre multi, clé siteId[])', async () => {
const wrapper = mountPage()
await flushPromises()
await wrapper.find('input[data-id="filter-site-1"]').setValue(true)
await wrapper.find('[data-label="admin.products.filters.apply"]').trigger('click')
expect(mockSetFilters).toHaveBeenLastCalledWith(
{ 'siteId[]': ['1'] },
{ replace: true },
)
// Etat 100 % local (regle n°6) : aucune navigation/query string declenchee.
expect(mockPush).not.toHaveBeenCalled()
})
it('répercute l\'état sélectionné dans setFilters (param state)', async () => {
const wrapper = mountPage()
await flushPromises()
await wrapper.find('select[data-empty-label="admin.products.filters.stateAll"]').setValue('SALE')
await wrapper.find('[data-label="admin.products.filters.apply"]').trigger('click')
expect(mockSetFilters).toHaveBeenLastCalledWith(
{ state: 'SALE' },
{ replace: true },
)
})
it('répercute la catégorie sélectionnée dans setFilters (param categoryId)', async () => {
const wrapper = mountPage()
await flushPromises()
await wrapper.find('select[data-empty-label="admin.products.filters.categoryAll"]').setValue('12')
await wrapper.find('[data-label="admin.products.filters.apply"]').trigger('click')
expect(mockSetFilters).toHaveBeenLastCalledWith(
{ categoryId: '12' },
{ replace: true },
)
})
it('badge filtres actifs + Réinitialiser vide l\'état appliqué', async () => {
const wrapper = mountPage()
await flushPromises()
await wrapper.find('input[data-id="filter-site-1"]').setValue(true)
await wrapper.find('[data-label="admin.products.filters.apply"]').trigger('click')
// Le libelle du bouton Filtrer porte le compteur (1 filtre actif).
expect(wrapper.find('[data-label="admin.products.filters.title (1)"]').exists()).toBe(true)
// Réinitialiser → query propre (setFilters avec objet vide).
await wrapper.find('[data-label="admin.products.filters.reset"]').trigger('click')
expect(mockSetFilters).toHaveBeenLastCalledWith({}, { replace: true })
})
})
@@ -0,0 +1,377 @@
<template>
<div>
<PageHeader>
{{ t('admin.products.title') }}
<template #actions>
<!-- gap-8 = 32px d'espacement entre Filtrer et Ajouter (meme
design que le Repertoire transporteurs / la Gestion des categories). -->
<div class="flex items-center gap-8">
<!-- Bouton Filtrer a GAUCHE d'Ajouter. Le compteur reflete les filtres actifs. -->
<MalioButton
v-if="canView"
variant="tertiary"
:label="filterButtonLabel"
icon-name="mdi:tune"
icon-position="left"
icon-size="24"
@click="openFilters"
/>
<MalioButton
v-if="canManage"
variant="secondary"
:label="t('admin.products.add')"
icon-name="mdi:add-bold"
icon-position="left"
@click="goToCreate"
/>
</div>
</template>
</PageHeader>
<!-- Datatable branchee sur usePaginatedList : pagination serveur, tri
name ASC par defaut (cote back, § 4.1). Colonnes Nom / Numero /
Categorie (docx p.3). -->
<MalioDataTable
:columns="columns"
:items="rows"
:total-items="totalItems"
:page="currentPage"
:per-page="itemsPerPage"
:per-page-options="itemsPerPageOptions"
row-clickable
:empty-message="t('admin.products.empty')"
@row-click="onRowClick"
@update:page="goToPage"
@update:per-page="setItemsPerPage"
/>
<div class="flex justify-center mt-4">
<MalioButton
v-if="canView"
variant="primary"
:label="t('admin.products.export')"
:disabled="exporting"
@click="exportXlsx"
/>
</div>
<!-- Drawer de filtres : etat BROUILLON, applique uniquement au clic sur
« Voir les résultats ». Meme pattern que les repertoires M1M5.
Etat 100 % local, jamais dans l'URL (regle ABSOLUE n°6). -->
<MalioDrawer
v-model="filterDrawerOpen"
drawer-class="max-w-[450px]"
body-class="p-0"
footer-class="justify-between border-t border-black p-6"
>
<template #header>
<h2 class="text-[24px] font-bold uppercase">{{ t('admin.products.filters.title') }}</h2>
</template>
<MalioAccordion>
<!-- Recherche : code + nom (param `search`, partiel insensible a la casse). -->
<MalioAccordionItem :title="t('admin.products.filters.search')" value="search">
<MalioInputText
v-model="draftSearch"
icon-name="mdi:magnify"
/>
</MalioAccordionItem>
<!-- Categorie : select simple (param `categoryId`). Referentiel borne
aux categories de type PRODUIT (RG-6.05). -->
<MalioAccordionItem :title="t('admin.products.filters.category')" value="category">
<MalioSelect
:model-value="draftCategoryId"
:options="categoryOptions"
:empty-option-label="t('admin.products.filters.categoryAll')"
@update:model-value="(v: string | number | null) => draftCategoryId = v === null || v === '' ? null : Number(v)"
/>
</MalioAccordionItem>
<!-- Etat : select simple (param `state`, enum PURCHASE / SALE / OTHER). -->
<MalioAccordionItem :title="t('admin.products.filters.state')" value="state">
<MalioSelect
:model-value="draftState"
:options="stateOptions"
:empty-option-label="t('admin.products.filters.stateAll')"
@update:model-value="(v: string | number | null) => draftState = v === null || v === '' ? null : String(v)"
/>
</MalioAccordionItem>
<!-- Site(s) : cases a cocher (multi, param `siteId[]`). Un produit
remonte s'il est disponible sur AU MOINS UN des sites coches. -->
<MalioAccordionItem :title="t('admin.products.filters.site')" value="site">
<div class="flex flex-col">
<MalioCheckbox
v-for="opt in siteOptions"
:id="`filter-site-${opt.value}`"
:key="opt.value"
:label="opt.label"
:model-value="draftSiteIds.includes(opt.value)"
@update:model-value="(val: boolean) => toggleSite(opt.value, val)"
/>
</div>
</MalioAccordionItem>
</MalioAccordion>
<template #footer>
<MalioButton
variant="tertiary"
:label="t('admin.products.filters.reset')"
button-class="w-m-btn-action"
@click="resetFilters"
/>
<MalioButton
variant="primary"
:label="t('admin.products.filters.apply')"
button-class="w-[170px]"
@click="applyFilters"
/>
</template>
</MalioDrawer>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import type { Product } from '~/modules/catalog/types/product'
interface FilterOption {
value: number
label: string
}
const { t } = useI18n()
const api = useApi()
const router = useRouter()
const toast = useToast()
const { can } = usePermissions()
useHead({ title: t('admin.products.title') })
// Catalogue produit admin-only (docx p.3) : « + Ajouter » reserve a `manage`.
// « Filtrer » / « Exporter » suivent `view` (gate page-level). L'item sidebar
// est deja masque cote back pour les roles sans `view` (RBAC § 5.2).
const canManage = computed(() => can('catalog.products.manage'))
const canView = computed(() => can('catalog.products.view'))
// Pagination serveur via le composable partage. Le ProductProvider applique
// deja name ASC (§ 4.1) — pas de defaultSort cote front tant qu'aucun
// OrderFilter n'est expose.
const {
items: products,
totalItems,
currentPage,
itemsPerPage,
itemsPerPageOptions,
fetch: loadProducts,
goToPage,
setItemsPerPage,
setFilters,
} = usePaginatedList<Product>({ url: '/products' })
// Mappe les produits en objets « plats » pour MalioDataTable (items typees
// Record<string, unknown>[]) : un objet litteral porte une signature d'index
// implicite, contrairement a l'interface Product. Meme pattern que M1→M5.
const rows = computed(() => products.value.map(product => ({
id: product.id,
name: product.name,
code: product.code,
categoryName: product.category?.name ?? '',
})))
const columns = [
{ key: 'name', label: t('admin.products.column.name') },
{ key: 'code', label: t('admin.products.column.code') },
{ key: 'categoryName', label: t('admin.products.column.category') },
]
/** Clic sur une ligne → ecran d'edition (route imbriquee /admin/products/{id}/edit). */
function onRowClick(item: Record<string, unknown>): void {
router.push(`/admin/products/${item.id}/edit`)
}
function goToCreate(): void {
router.push('/admin/products/new')
}
// ── Referentiels des filtres ─────────────────────────────────────────────────
// Charges une fois (pagination desactivee, referentiels bornes). Categories
// filtrees au type PRODUIT (RG-6.05) ; sites = tous les sites actifs.
const categoryOptions = ref<FilterOption[]>([])
const siteOptions = ref<FilterOption[]>([])
// Etats produit (miroir de l'enum back Product::STATE_*). Le libelle est resolu
// par i18n. Select simple cote filtre (`?state=` n'accepte qu'une valeur).
const PRODUCT_STATES = ['PURCHASE', 'SALE', 'OTHER'] as const
const stateOptions = computed(() =>
PRODUCT_STATES.map(code => ({ value: code, label: t(`admin.products.state.${code}`) })),
)
interface HydraMember { '@id': string, id: number, name?: string, postalCode?: string }
/** Recupere une collection complete (pagination desactivee) en Hydra. */
async function fetchAll<T extends HydraMember>(
url: string,
query: Record<string, string> = {},
): Promise<T[]> {
const res = await api.get<{ member?: T[] }>(
url,
{ pagination: 'false', ...query },
{ headers: { Accept: 'application/ld+json' }, toast: false },
)
return res.member ?? []
}
/**
* Charge les referentiels des filtres en parallele et de maniere resiliente :
* un referentiel en echec (403/500) reste vide sans casser l'autre.
*/
async function loadFilterReferentials(): Promise<void> {
await Promise.allSettled([
fetchAll('/categories', { typeCode: 'PRODUIT' })
.then((cats) => { categoryOptions.value = cats.map(c => ({ value: c.id, label: c.name ?? '' })) }),
fetchAll('/sites')
.then((sitesList) => { siteOptions.value = sitesList.map(s => ({ value: s.id, label: s.name ?? '' })) }),
])
}
// ── Filtres (drawer) ─────────────────────────────────────────────────────────
// Deux niveaux d'etat (pattern repertoires M1→M5) :
// - APPLIED : pilote la liste/l'export + le compteur du bouton. Modifie
// uniquement au clic « Voir les résultats » / « Réinitialiser ».
// - DRAFT : edite librement dans le drawer ; recopie vers applied a la validation.
const filterDrawerOpen = ref(false)
const draftSearch = ref('')
const draftCategoryId = ref<number | null>(null)
const draftState = ref<string | null>(null)
const draftSiteIds = ref<number[]>([])
const appliedSearch = ref('')
const appliedCategoryId = ref<number | null>(null)
const appliedState = ref<string | null>(null)
const appliedSiteIds = ref<number[]>([])
const activeFilterCount = computed(() => {
let count = 0
if (appliedSearch.value.trim() !== '') count++
if (appliedCategoryId.value !== null) count++
if (appliedState.value !== null) count++
if (appliedSiteIds.value.length > 0) count++
return count
})
const filterButtonLabel = computed(() => {
const base = t('admin.products.filters.title')
return activeFilterCount.value > 0 ? `${base} (${activeFilterCount.value})` : base
})
// Recopie l'etat applique vers le brouillon puis ouvre le drawer : la reouverture
// reflete les filtres actifs.
function openFilters(): void {
draftSearch.value = appliedSearch.value
draftCategoryId.value = appliedCategoryId.value
draftState.value = appliedState.value
draftSiteIds.value = [...appliedSiteIds.value]
filterDrawerOpen.value = true
}
/** Coche / decoche un site dans le brouillon (filtre multi). */
function toggleSite(id: number, selected: boolean): void {
draftSiteIds.value = selected
? [...draftSiteIds.value, id]
: draftSiteIds.value.filter(s => s !== id)
}
/**
* Construit le payload de filtres serveur a partir de l'etat applique. Cle
* `siteId[]` pour que PHP la parse en tableau (OR cote back). Les filtres vides
* sont omis pour une query propre.
*/
function buildFilterPayload(): Record<string, string | string[]> {
const payload: Record<string, string | string[]> = {}
if (appliedSearch.value.trim() !== '') payload.search = appliedSearch.value.trim()
if (appliedCategoryId.value !== null) payload.categoryId = String(appliedCategoryId.value)
if (appliedState.value !== null) payload.state = appliedState.value
if (appliedSiteIds.value.length > 0) payload['siteId[]'] = appliedSiteIds.value.map(String)
return payload
}
// « Voir les résultats » : recopie brouillon → applied, pousse les filtres
// (retombe en page 1 via usePaginatedList) et ferme le drawer.
function applyFilters(): void {
appliedSearch.value = draftSearch.value.trim()
appliedCategoryId.value = draftCategoryId.value
appliedState.value = draftState.value
appliedSiteIds.value = [...draftSiteIds.value]
setFilters(buildFilterPayload(), { replace: true })
filterDrawerOpen.value = false
}
// « Réinitialiser » : vide brouillon ET applied, recharge la liste complete.
// Le drawer reste ouvert pour montrer le formulaire vide.
function resetFilters(): void {
draftSearch.value = ''
draftCategoryId.value = null
draftState.value = null
draftSiteIds.value = []
appliedSearch.value = ''
appliedCategoryId.value = null
appliedState.value = null
appliedSiteIds.value = []
setFilters({}, { replace: true })
}
// ── Export XLSX ──────────────────────────────────────────────────────────────
// Memes filtres que la vue : l'export reflete exactement ce que l'utilisateur voit.
const exporting = ref(false)
async function exportXlsx(): Promise<void> {
if (exporting.value) {
return
}
exporting.value = true
try {
// useApi type ses options en JSON ; l'export renvoie un binaire, donc on
// force responseType:'blob' (transmis tel quel a ofetch au runtime). Cast
// contenu faute d'overload blob sur le client partage (meme pattern M2→M5).
const blob = await api.get<Blob>('/products/export.xlsx', buildFilterPayload(), {
responseType: 'blob',
toast: false,
} as unknown as Parameters<typeof api.get>[2])
triggerDownload(blob, 'catalogue-produits.xlsx')
}
catch {
toast.error({
title: t('admin.products.toast.error'),
message: t('admin.products.toast.exportError'),
})
}
finally {
exporting.value = false
}
}
/** Declenche le telechargement d'un blob via un lien temporaire. */
function triggerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}
onMounted(() => {
loadProducts()
loadFilterReferentials()
})
</script>
@@ -0,0 +1,182 @@
<template>
<div>
<!-- En-tete : retour vers le catalogue + nom du produit. -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
:title="t('admin.products.edit.back')"
v-bind="{ ariaLabel: t('admin.products.edit.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
</div>
<!-- Etats de chargement / introuvable. -->
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('admin.products.edit.loading') }}</p>
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('admin.products.edit.notFound') }}</p>
<template v-else-if="product">
<!-- Formulaire principal pre-rempli (mêmes champs/regles que l'ajout,
RG-6.016.07). Bouton « Enregistrer » PATCH (RG-6.08). -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<!-- Etat du produit : multi-select obligatoire (>= 1, RG-6.02). -->
<MalioSelectCheckbox
:model-value="form.states"
:options="stateOptions"
:label="t('admin.products.form.states')"
:display-tag="true"
:required="true"
:error="errors.states"
@update:model-value="(v: (string | number)[]) => setStates(v.map(String))"
/>
<!-- Sites de disponibilite : multi-select obligatoire (>= 1, RG-6.04).
Pilote la cascade Type de stockage (RG-6.06). -->
<MalioSelectCheckbox
:model-value="form.siteIris"
:options="siteOptions"
:label="t('admin.products.form.sites')"
:display-tag="true"
:required="true"
:error="errors.sites"
@update:model-value="(v: (string | number)[]) => setSites(v.map(String))"
/>
<MalioInputText
v-model="form.name"
:mask="FREE_TEXT_MASK"
:label="t('admin.products.form.name')"
:required="true"
:error="errors.name"
/>
<!-- Code modifiable techniquement ; l'unicite reste re-validee serveur (RG-6.01). -->
<MalioInputText
v-model="form.code"
:mask="CODE_ALNUM_MASK"
:label="t('admin.products.form.code')"
:required="true"
:error="errors.code"
/>
<!-- Categorie produit : select simple obligatoire, filtre type PRODUIT (RG-6.05). -->
<MalioSelect
:model-value="form.categoryIri"
:options="categoryOptions"
:label="t('admin.products.form.category')"
empty-option-label=""
:required="true"
:error="errors.category"
@update:model-value="(v: string | number | null) => setCategory(v === null || v === '' ? null : String(v))"
/>
<!-- Type de stockage : multi-select obligatoire (>= 1), options filtrees
par les sites selectionnes (RG-6.06). -->
<MalioSelectCheckbox
:model-value="form.storageTypeIris"
:options="storageTypeOptions"
:label="t('admin.products.form.storageTypes')"
:display-tag="true"
:required="true"
:error="errors.storageTypes"
@update:model-value="(v: (string | number)[]) => setStorageTypes(v.map(String))"
/>
<!-- RG-6.03 : « Fabriqué » + « Contient de la mélasse » visibles
uniquement si l'Etat contient « Vendu ». -->
<MalioCheckbox
v-if="isSale"
v-model="form.manufactured"
:label="t('admin.products.form.manufactured')"
group-class="self-center"
/>
<MalioCheckbox
v-if="isSale"
v-model="form.containsMolasses"
:label="t('admin.products.form.containsMolasses')"
group-class="self-center"
/>
</div>
<div class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('admin.products.edit.save')"
:disabled="submitting"
@click="onSubmit"
/>
</div>
<!-- Onglets Fournisseurs / Clients en placeholder (HP-M6-01). -->
<ProductPlaceholderTabs />
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useProductForm, PRODUCT_STATES } from '~/modules/catalog/composables/useProductForm'
import { useProduct } from '~/modules/catalog/composables/useProduct'
import { CODE_ALNUM_MASK, FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const { can } = usePermissions()
// Gating de la route : la modification est reservee a `manage` (catalogue admin-only).
if (!can('catalog.products.manage')) {
await navigateTo('/admin/products')
}
const productId = route.params.id as string
const { product, loading, error, load } = useProduct(productId)
const {
form,
errors,
submitting,
isSale,
siteOptions,
categoryOptions,
storageTypeOptions,
setStates,
setCategory,
setStorageTypes,
setSites,
loadReferentials,
prefill,
submit,
} = useProductForm()
const headerTitle = computed(() => product.value?.name ?? t('admin.products.edit.title'))
useHead({ title: headerTitle })
// Options de l'etat : libelles i18n (la valeur d'option = code enum).
const stateOptions = computed(() =>
PRODUCT_STATES.map(code => ({ value: code, label: t(`admin.products.state.${code}`) })),
)
/** Retour vers le catalogue produit (fleche d'en-tete). */
function goBack(): void {
router.push('/admin/products')
}
/** Soumet la modification (PATCH) ; au succes, retour a la liste. */
async function onSubmit(): Promise<void> {
const ok = await submit()
if (ok) {
router.push('/admin/products')
}
}
onMounted(async () => {
// Referentiels (selects) + detail du produit charges en parallele.
await Promise.all([
loadReferentials().catch(() => {}),
load(),
])
// Pre-remplissage une fois le produit charge (echec de chargement => message).
if (product.value) {
await prefill(product.value)
}
})
</script>
@@ -0,0 +1,161 @@
<template>
<div>
<!-- En-tete : retour vers le catalogue + titre. -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
:title="t('admin.products.form.back')"
v-bind="{ ariaLabel: t('admin.products.form.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('admin.products.form.title') }}</h1>
</div>
<!-- Formulaire principal de creation
Bouton « Valider » TOUJOURS actif (ERP-101) : la validation
autoritaire est serveur, les erreurs 422 reviennent inline. -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<!-- Etat du produit : multi-select obligatoire (>= 1, RG-6.02). -->
<MalioSelectCheckbox
:model-value="form.states"
:options="stateOptions"
:label="t('admin.products.form.states')"
:display-tag="true"
:required="true"
:error="errors.states"
@update:model-value="(v: (string | number)[]) => setStates(v.map(String))"
/>
<!-- Sites de disponibilite : multi-select obligatoire (>= 1, RG-6.04).
Pilote la cascade Type de stockage (RG-6.06). -->
<MalioSelectCheckbox
:model-value="form.siteIris"
:options="siteOptions"
:label="t('admin.products.form.sites')"
:display-tag="true"
:required="true"
:error="errors.sites"
@update:model-value="(v: (string | number)[]) => setSites(v.map(String))"
/>
<MalioInputText
v-model="form.name"
:mask="FREE_TEXT_MASK"
:label="t('admin.products.form.name')"
:required="true"
:error="errors.name"
/>
<MalioInputText
v-model="form.code"
:mask="CODE_ALNUM_MASK"
:label="t('admin.products.form.code')"
:required="true"
:error="errors.code"
/>
<!-- Categorie produit : select simple obligatoire, filtre type PRODUIT (RG-6.05). -->
<MalioSelect
:model-value="form.categoryIri"
:options="categoryOptions"
:label="t('admin.products.form.category')"
empty-option-label=""
:required="true"
:error="errors.category"
@update:model-value="(v: string | number | null) => setCategory(v === null || v === '' ? null : String(v))"
/>
<!-- Type de stockage : multi-select obligatoire (>= 1), options filtrees
par les sites selectionnes (RG-6.06). -->
<MalioSelectCheckbox
:model-value="form.storageTypeIris"
:options="storageTypeOptions"
:label="t('admin.products.form.storageTypes')"
:display-tag="true"
:required="true"
:error="errors.storageTypes"
@update:model-value="(v: (string | number)[]) => setStorageTypes(v.map(String))"
/>
<!-- RG-6.03 : « Fabriqué » + « Contient de la mélasse » visibles
uniquement si l'Etat contient « Vendu ». -->
<MalioCheckbox
v-if="isSale"
v-model="form.manufactured"
:label="t('admin.products.form.manufactured')"
group-class="self-center"
/>
<MalioCheckbox
v-if="isSale"
v-model="form.containsMolasses"
:label="t('admin.products.form.containsMolasses')"
group-class="self-center"
/>
</div>
<div class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('admin.products.form.submit')"
:disabled="submitting"
@click="onSubmit"
/>
</div>
<!-- Onglets Fournisseurs / Clients en placeholder (HP-M6-01) : presents
des l'ajout pour coherence avec l'ecran de modification. -->
<ProductPlaceholderTabs />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useProductForm, PRODUCT_STATES } from '~/modules/catalog/composables/useProductForm'
import { CODE_ALNUM_MASK, FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
const { t } = useI18n()
const router = useRouter()
const { can } = usePermissions()
useHead({ title: t('admin.products.form.title') })
// Gating de la route : la creation est reservee a `manage` (catalogue admin-only).
if (!can('catalog.products.manage')) {
await navigateTo('/admin/products')
}
const {
form,
errors,
submitting,
isSale,
siteOptions,
categoryOptions,
storageTypeOptions,
setStates,
setCategory,
setStorageTypes,
setSites,
loadReferentials,
submit,
} = useProductForm()
// Options de l'etat : libelles i18n (la valeur d'option = code enum).
const stateOptions = computed(() =>
PRODUCT_STATES.map(code => ({ value: code, label: t(`admin.products.state.${code}`) })),
)
/** Retour vers le catalogue produit (fleche d'en-tete). */
function goBack(): void {
router.push('/admin/products')
}
/** Soumet la creation ; au succes, retour a la liste. */
async function onSubmit(): Promise<void> {
const ok = await submit()
if (ok) {
router.push('/admin/products')
}
}
onMounted(() => {
// Echec du chargement des referentiels non bloquant : les selects restent vides.
loadReferentials().catch(() => {})
})
</script>
+72
View File
@@ -0,0 +1,72 @@
/**
* Types front du module Catalog (M6 — Catalogue produit).
*
* Contrats API consommes :
* - GET /api/products → HydraCollection<Product>
* - GET /api/products/{id} → Product
* - GET /api/products/export.xlsx → binaire XLSX (export complet, filtres actifs)
*
* Notes (cf. spec-back § 4.0.bis, contrat JSON capture en ERP-203) :
* - `category` est embarque (objet, pas IRI) ; idem `sites` / `storageTypes`
* (tableaux d'objets bornes). On n'a besoin que de `category.name` en liste.
* - `states` est un tableau de chaines (PURCHASE / SALE / OTHER).
* - `skip_null_values` actif cote back : ne pas presumer la presence des nulls.
*/
/** Type de categorie embarque dans `category.categoryTypes` (RG-6.05). */
export interface ProductCategoryType {
id: number
code: string
label: string
}
/** 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
categoryTypes?: ProductCategoryType[]
}
/** 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
postalCode: string
city: string
color: string
fullAddress: string
}
/** 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
}
/**
* Produit metier — tel qu'il est lu depuis l'API. L'entite porte le pattern
* Timestampable+Blamable (cf. spec-back § 2.8).
*/
export interface Product {
id: number
code: string
name: string
/** Etats : sous-ensemble de PURCHASE / SALE / OTHER (RG-6.02). */
states: string[]
manufactured: boolean
containsMolasses: boolean
category: ProductCategory | null
sites: ProductSite[]
storageTypes: ProductStorageType[]
createdAt: string
updatedAt: string
}