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:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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<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'))
|
||||
@@ -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<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) {
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.01→6.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>
|
||||
@@ -97,6 +97,10 @@
|
||||
@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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user