feat(catalog) : M6 — écran consultation produit + onglets conditionnés + édition sans redirection

- Nouvel écran de consultation lecture seule /admin/products/{id} (calque
  client/fournisseur) : clic sur une ligne ouvre la consultation (plus l'édition
  directe), bouton « Modifier » → édition.
- Règle ERP-193 en consultation : champs vides / checkbox non cochées masqués
  (isFilled) ; onglets vides masqués → les coquilles Fournisseurs/Clients
  (placeholder, module Contrat inexistant) ne sont pas rendues en consultation.
- Onglets Fournisseurs/Clients : non affichés à l'ajout (avant validation du
  formulaire principal) ; visibilité conditionnée par l'état (spec C3, « Aucun »
  = OTHER) : Fournisseurs si Achat/Aucun, Clients si Vendu/Aucun.
- Édition : après « Enregistrer » on reste sur l'écran (l'utilisateur garde la
  main, calque client/fournisseur) ; réaffichage des valeurs normalisées serveur
  (RG-6.07) via re-prefill, plus de redirection.
- i18n consultation + tests (consultation, onglets, no-redirect) ; spec écran 8.bis.
This commit is contained in:
2026-06-27 17:18:17 +02:00
parent 58d0c499d4
commit eb94204c55
14 changed files with 464 additions and 37 deletions
+2 -1
View File
@@ -637,7 +637,7 @@ Toute permission `catalog.products.*` doit être posée **simultanément** dans
| **RG-6.07** | back | Normalisation serveur : `code` trim+UPPER, `name` trim (§ 6). |
| **RG-6.08** | back | « Modification » = même formulaire/mêmes règles que « Ajouter » ; bouton « Valider » → « Enregistrer » (docx p.7). Code & contraintes inchangés. |
| **RG-6.09** | back | Liste : exclut par défaut les produits soft-deleted ; pas de Delete exposé (§ 2.7). |
| **RG-6.10** | back | Onglets « Fournisseurs » / « Clients » = **hors périmètre V0** (placeholder), dépendent du module Contrat inexistant (HP-M6-01). |
| **RG-6.10** | back+front | Onglets « Fournisseurs » / « Clients » = **hors périmètre V0** (placeholder), dépendent du module Contrat inexistant (HP-M6-01). **Front** : (a) NON affichés à l'ajout — n'apparaissent qu'après validation du formulaire principal (écran de modification) ; (b) visibilité conditionnée par l'état (cf. C3, « Aucun » = `OTHER`) : « Fournisseurs » si `PURCHASE` ou `OTHER`, « Clients » si `SALE` ou `OTHER`. |
Cohérence inter-champs (RG-6.03 / 6.05) implémentée via `#[Assert\Callback]` portant des messages FR + CHECK Postgres (non-vacuité `states`). RG-6.06 : simple `Assert\Count(min:1)` (référentiel plat, plus de validation de disponibilité par site).
@@ -675,6 +675,7 @@ Cohérence inter-champs (RG-6.03 / 6.05) implémentée via `#[Assert\Callback]`
| 6 | Tests PHPUnit RG-6.01→6.10 + capture contrat JSON | Backend |
| 7 | Page liste `/admin/products` (usePaginatedList) + drawer filtre + export | Frontend |
| 8 | Écran Ajouter (champs conditionnels SALE, selects filtrés catégorie/stockage) | Frontend |
| 8.bis | Écran **Consultation** (lecture seule) `/admin/products/{id}` : clic sur une ligne → consultation (pas l'édition directe), bouton « Modifier » → édition. **Règle ERP-193 (calque client/fournisseur)** : champs vides + checkbox non cochées masqués, et **onglets vides masqués** → les coquilles Fournisseurs/Clients (placeholder, module Contrat inexistant) ne sont **pas affichées en consultation** (elles restent visibles à l'édition). | Frontend |
| 9 | Écran Modification (« Enregistrer ») + onglets **placeholder « en cours de dev »** (Fournisseurs / Clients) | Frontend |
| 10 | i18n + libellé audit (`catalog_product`) | Frontend |
+10 -1
View File
@@ -1063,11 +1063,20 @@
},
"edit": {
"title": "Modifier le produit",
"back": "Retour au catalogue",
"back": "Retour",
"save": "Enregistrer",
"loading": "Chargement du produit…",
"notFound": "Produit introuvable."
},
"consultation": {
"title": "Fiche produit",
"back": "Retour au catalogue",
"loading": "Chargement du produit…",
"notFound": "Produit introuvable."
},
"action": {
"edit": "Modifier"
},
"tab": {
"suppliers": "Fournisseurs",
"clients": "Clients",
@@ -4,24 +4,51 @@
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).
Visibilite conditionnee par l'etat du produit (cf. spec C3, « Aucun » = OTHER) :
- « Fournisseurs » : visible si l'etat contient Achat (PURCHASE) ou Aucun (OTHER) ;
- « Clients » : visible si l'etat contient Vendu (SALE) ou Aucun (OTHER).
Si aucun onglet n'est applicable (etat vide), rien n'est rendu.
-->
<MalioTabList v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<MalioTabList v-if="tabs.length" v-model="activeTab" :tabs="tabs" class="mt-[60px]">
<template #suppliers><ComingSoonPlaceholder :title="t('admin.products.tab.placeholder')" /></template>
<template #clients><ComingSoonPlaceholder :title="t('admin.products.tab.placeholder')" /></template>
</MalioTabList>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
const props = defineProps<{
/** Etats du produit (codes enum PURCHASE / SALE / OTHER) pilotant la visibilite. */
states: string[]
}>()
const { t } = useI18n()
const activeTab = ref('suppliers')
// RG (spec C3) : « Fournisseurs » si Achat ou Aucun ; « Clients » si Vendu ou Aucun.
const showSuppliers = computed(() => props.states.includes('PURCHASE') || props.states.includes('OTHER'))
const showClients = computed(() => props.states.includes('SALE') || props.states.includes('OTHER'))
// 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' },
])
const tabs = computed(() => {
const list: { key: string, label: string, icon: string }[] = []
if (showSuppliers.value) {
list.push({ key: 'suppliers', label: t('admin.products.tab.suppliers'), icon: 'mdi:truck-outline' })
}
if (showClients.value) {
list.push({ key: 'clients', label: t('admin.products.tab.clients'), icon: 'mdi:account-group-outline' })
}
return list
})
const activeTab = ref('suppliers')
// Si l'onglet actif disparait suite a un changement d'etat, retombe sur le premier
// onglet encore disponible (evite un onglet actif fantome).
watch(tabs, (list) => {
if (list.length && !list.some(tab => tab.key === activeTab.value)) {
activeTab.value = list[0].key
}
}, { immediate: true })
</script>
@@ -0,0 +1,64 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { defineComponent, h, nextTick } from 'vue'
import ProductPlaceholderTabs from '../ProductPlaceholderTabs.vue'
// i18n auto-import : retourne la cle telle quelle.
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
// Stub de MalioTabList : expose les `key` des onglets recus (data-tab) pour
// verifier la visibilite conditionnee par l'etat, sans dependre de la lib UI.
const TabListStub = defineComponent({
props: { tabs: { type: Array, default: () => [] }, modelValue: { type: String, default: '' } },
setup(props) {
return () => h(
'div',
{ 'data-testid': 'tablist' },
(props.tabs as { key: string }[]).map(tab => h('span', { 'data-tab': tab.key })),
)
},
})
const PlaceholderStub = defineComponent({ setup() { return () => h('div') } })
function mountTabs(states: string[]) {
return mount(ProductPlaceholderTabs, {
props: { states },
global: { stubs: { MalioTabList: TabListStub, ComingSoonPlaceholder: PlaceholderStub } },
})
}
const tabKeys = (wrapper: ReturnType<typeof mountTabs>): string[] =>
wrapper.findAll('[data-tab]').map(node => node.attributes('data-tab') ?? '')
describe('ProductPlaceholderTabs — visibilite conditionnee par l\'etat', () => {
it('Achat (PURCHASE) : affiche uniquement « Fournisseurs »', () => {
expect(tabKeys(mountTabs(['PURCHASE']))).toEqual(['suppliers'])
})
it('Vendu (SALE) : affiche uniquement « Clients »', () => {
expect(tabKeys(mountTabs(['SALE']))).toEqual(['clients'])
})
it('Aucun (OTHER) : affiche les deux onglets', () => {
expect(tabKeys(mountTabs(['OTHER']))).toEqual(['suppliers', 'clients'])
})
it('Achat + Vendu : affiche les deux onglets', () => {
expect(tabKeys(mountTabs(['PURCHASE', 'SALE']))).toEqual(['suppliers', 'clients'])
})
it('etat vide : ne rend aucun onglet (MalioTabList absent)', () => {
const wrapper = mountTabs([])
expect(wrapper.find('[data-testid="tablist"]').exists()).toBe(false)
})
it('retombe sur le premier onglet visible si l\'actif disparait', async () => {
// OTHER -> suppliers actif par defaut ; passage a SALE retire « Fournisseurs ».
const wrapper = mountTabs(['OTHER'])
await wrapper.setProps({ states: ['SALE'] })
await nextTick()
// Seul « Clients » subsiste : pas d'onglet actif fantome (verifie via le modelValue).
const tablist = wrapper.findComponent(TabListStub)
expect(tablist.props('modelValue')).toBe('clients')
})
})
@@ -245,7 +245,9 @@ describe('useProductForm', () => {
})
it('soumet un PATCH /products/{id} apres prefill (RG-6.08)', async () => {
mockPatch.mockResolvedValueOnce({ id: 34 })
// Le PATCH renvoie le produit normalise : submit re-prefill le form a partir
// de la reponse (l'utilisateur reste sur l'ecran, pas de redirection).
mockPatch.mockResolvedValueOnce({ ...PRODUCT, code: 'BLE-01', name: 'Blé tendre' })
const { prefill, submit } = useProductForm()
await prefill(PRODUCT)
@@ -261,6 +263,21 @@ describe('useProductForm', () => {
expect(mockToastSuccess).toHaveBeenCalled()
})
it('re-affiche les valeurs normalisees du serveur apres un PATCH (RG-6.07, pas de redirection)', async () => {
// Le back normalise code (trim+UPPER) et name (trim) : le form doit refleter
// la reponse serveur, pas la saisie locale.
mockPatch.mockResolvedValueOnce({ ...PRODUCT, code: 'BLE-01', name: 'Blé tendre' })
const { form, prefill, submit } = useProductForm()
await prefill(PRODUCT)
form.code = 'ble-01 '
form.name = ' Blé tendre '
await submit()
expect(form.code).toBe('BLE-01')
expect(form.name).toBe('Blé tendre')
})
it('mappe un 409 doublon de code aussi en edition', async () => {
mockPatch.mockRejectedValueOnce({ response: { status: 409, _data: {} } })
const { errors, prefill, submit } = useProductForm()
@@ -151,8 +151,12 @@ export function useProductForm() {
}
const options = { headers: { Accept: 'application/ld+json' }, toast: false }
if (editing) {
await api.patch(`/products/${productId.value}`, payload, options)
const updated = await api.patch<Product>(`/products/${productId.value}`, payload, options)
toast.success({ title: t('admin.products.toast.updateSuccess') })
// L'utilisateur garde la main (pas de redirection, calque client/
// fournisseur) : on reaffiche les valeurs normalisees renvoyees par le
// serveur (code trim+UPPER, name trim — RG-6.07) directement dans le form.
await prefill(updated)
}
else {
await api.post('/products', payload, options)
@@ -0,0 +1,139 @@
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', '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' }],
storageTypes: [{ '@id': '/api/storage_types/9', id: 9, code: 'TAS', label: 'Tas' }],
createdAt: '', updatedAt: '',
}
const fx = vi.hoisted(() => ({ load: vi.fn() }))
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 ViewPage = (await import('../admin/products/[id]/index.vue')).default
const ButtonStub = defineComponent({
props: { label: { type: String, default: '' } },
emits: ['click'],
setup(props, { emit }) {
return () => h('button', { 'data-label': props.label, onClick: () => emit('click') }, props.label)
},
})
// Input lecture seule : expose le label + la valeur affichee (model-value).
const InputStub = defineComponent({
props: { label: { type: String, default: '' }, modelValue: { default: null } },
setup(props) { return () => h('input', { 'data-label': props.label, 'data-value': String(props.modelValue ?? '') }) },
})
const CheckboxStub = defineComponent({
props: { label: { type: String, default: '' }, modelValue: { type: Boolean, default: false } },
setup(props) { return () => h('input', { 'type': 'checkbox', 'data-label': props.label }) },
})
const TabsStub = defineComponent({ setup() { return () => h('div', { 'data-testid': 'placeholder-tabs' }) } })
const stubs = {
MalioButtonIcon: ButtonStub,
MalioButton: ButtonStub,
MalioInputText: InputStub,
MalioCheckbox: CheckboxStub,
ProductPlaceholderTabs: TabsStub,
}
async function mountPage() {
const wrapper = mount(defineComponent({
components: { ViewPage },
setup: () => () => h(Suspense, null, { default: () => h(ViewPage) }),
}), { global: { stubs } })
await flushPromises()
return wrapper
}
describe('Écran Consultation produit (page /admin/products/{id})', () => {
beforeEach(() => {
fx.load.mockReset().mockResolvedValue(undefined)
mockPush.mockReset()
mockNavigateTo.mockReset()
mockCan.mockReset().mockReturnValue(true)
})
it('charge le produit au montage', async () => {
await mountPage()
expect(fx.load).toHaveBeenCalled()
})
it('redirige vers la liste sans la permission view', async () => {
mockCan.mockReturnValue(false)
await mountPage()
expect(mockNavigateTo).toHaveBeenCalledWith('/admin/products')
})
it('affiche les champs en lecture seule (libelles mappes)', async () => {
const wrapper = await mountPage()
const valueOf = (label: string) =>
wrapper.find(`[data-label="${label}"]`).attributes('data-value')
expect(valueOf('admin.products.form.name')).toBe('Blé tendre')
expect(valueOf('admin.products.form.code')).toBe('BLE-01')
expect(valueOf('admin.products.form.category')).toBe('Céréales')
expect(valueOf('admin.products.form.sites')).toBe('Chatellerault')
expect(valueOf('admin.products.form.storageTypes')).toBe('Tas')
// Etats : libelles i18n joints.
expect(valueOf('admin.products.form.states')).toBe('admin.products.state.PURCHASE, admin.products.state.SALE')
})
it('bouton « Modifier » (manage) → ecran d\'edition', async () => {
const wrapper = await mountPage()
await wrapper.find('[data-label="admin.products.action.edit"]').trigger('click')
expect(mockPush).toHaveBeenCalledWith('/admin/products/34/edit')
})
it('masque « Modifier » sans la permission manage', async () => {
// view OK mais manage refuse.
mockCan.mockImplementation((perm: string) => perm === 'catalog.products.view')
const wrapper = await mountPage()
expect(wrapper.find('[data-label="admin.products.action.edit"]').exists()).toBe(false)
})
it('n\'affiche AUCUN onglet en consultation (coquilles vides masquees, ERP-193)', async () => {
const wrapper = await mountPage()
expect(wrapper.find('[data-testid="placeholder-tabs"]').exists()).toBe(false)
})
it('masque un champ vide / une checkbox non cochee (ERP-193, isFilled)', async () => {
const wrapper = await mountPage()
// containsMolasses = false dans le fixture => case masquee.
expect(wrapper.find('[data-label="admin.products.form.containsMolasses"]').exists()).toBe(false)
// manufactured = true => case affichee.
expect(wrapper.find('[data-label="admin.products.form.manufactured"]').exists()).toBe(true)
})
})
@@ -133,18 +133,19 @@ describe('Écran Modifier un produit (page /admin/products/{id}/edit)', () => {
expect(fx.prefill).toHaveBeenCalledWith(PRODUCT)
})
it('redirige vers la liste sans la permission manage', async () => {
it('redirige vers la consultation sans la permission manage', async () => {
mockCan.mockReturnValue(false)
await mountPage()
expect(mockNavigateTo).toHaveBeenCalledWith('/admin/products')
expect(mockNavigateTo).toHaveBeenCalledWith('/admin/products/34')
})
it('bouton « Enregistrer » : submit (PATCH) puis retour a la liste', async () => {
it('bouton « Enregistrer » : submit (PATCH) SANS redirection (l\'utilisateur garde la main)', 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')
// On reste sur l'ecran d'edition : aucune navigation au succes (calque client/fournisseur).
expect(mockPush).not.toHaveBeenCalled()
})
it('affiche les onglets placeholder (rendu sans appel API)', async () => {
@@ -139,4 +139,9 @@ describe('Écran Ajouter un produit (page /admin/products/new)', () => {
await flushPromises()
expect(mockPush).not.toHaveBeenCalled()
})
it('n\'affiche PAS les onglets Fournisseurs/Clients a l\'ajout (avant validation)', async () => {
const wrapper = await mountPage()
expect(wrapper.find('[data-testid="placeholder-tabs"]').exists()).toBe(false)
})
})
@@ -188,11 +188,11 @@ describe('Catalogue produit (page /admin/products)', () => {
expect(wrapper.find('[data-label="admin.products.add"]').exists()).toBe(false)
})
it('navigue vers l\'édition au clic sur une ligne', async () => {
it('navigue vers la consultation 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')
expect(mockPush).toHaveBeenCalledWith('/admin/products/34')
})
it('navigue vers la création au clic sur « + Ajouter »', async () => {
@@ -102,8 +102,10 @@
/>
</div>
<!-- Onglets Fournisseurs / Clients en placeholder (HP-M6-01). -->
<ProductPlaceholderTabs />
<!-- Onglets Fournisseurs / Clients en placeholder (HP-M6-01). Visibilite
conditionnee par l'etat : Fournisseurs si Achat/Aucun, Clients si
Vendu/Aucun (cf. ProductPlaceholderTabs). -->
<ProductPlaceholderTabs :states="form.states" />
</template>
</div>
</template>
@@ -119,13 +121,14 @@ 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
// Gating de la route : la modification est reservee a `manage` ; sinon retour
// consultation (la lecture seule reste accessible avec `view`).
if (!can('catalog.products.manage')) {
await navigateTo(`/admin/products/${productId}`)
}
const { product, loading, error, load } = useProduct(productId)
const {
@@ -154,17 +157,19 @@ const stateOptions = computed(() =>
PRODUCT_STATES.map(code => ({ value: code, label: t(`admin.products.state.${code}`) })),
)
/** Retour vers le catalogue produit (fleche d'en-tete). */
/** Retour vers la consultation du produit (fleche d'en-tete). */
function goBack(): void {
router.push('/admin/products')
router.push(`/admin/products/${productId}`)
}
/** Soumet la modification (PATCH) ; au succes, retour a la liste. */
/**
* Soumet la modification (PATCH). Au succes : on RESTE sur l'ecran d'edition
* (l'utilisateur garde la main, calque client/fournisseur) — le toast de succes et
* la reaffichage des valeurs normalisees sont geres par `submit()`. La navigation
* reste manuelle (fleche retour -> consultation).
*/
async function onSubmit(): Promise<void> {
const ok = await submit()
if (ok) {
router.push('/admin/products')
}
await submit()
}
onMounted(async () => {
@@ -0,0 +1,155 @@
<template>
<div>
<!-- En-tete : retour catalogue + nom du produit + action « Modifier ». -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
:title="t('admin.products.consultation.back')"
v-bind="{ ariaLabel: t('admin.products.consultation.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ headerTitle }}</h1>
<div class="ml-auto flex items-center gap-12">
<MalioButton
v-if="canManage"
variant="secondary"
icon-name="mdi:pencil-outline"
icon-position="left"
:label="t('admin.products.action.edit')"
@click="goEdit"
/>
</div>
</div>
<!-- Etats de chargement / introuvable. -->
<p v-if="loading" class="mt-12 text-center text-black/60">{{ t('admin.products.consultation.loading') }}</p>
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('admin.products.consultation.notFound') }}</p>
<template v-else-if="product">
<!-- Bloc principal (lecture seule) meme disposition que l'ajout/edition.
Champs non remplis masques (ERP-193, isFilled). -->
<div class="mt-[48px] grid grid-cols-3 xl:grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
v-if="isFilled(statesLabel)"
:model-value="statesLabel"
:label="t('admin.products.form.states')"
disabled
/>
<MalioInputText
v-if="isFilled(sitesLabel)"
:model-value="sitesLabel"
:label="t('admin.products.form.sites')"
disabled
/>
<MalioInputText
v-if="isFilled(product.name)"
:model-value="product.name"
:label="t('admin.products.form.name')"
disabled
/>
<MalioInputText
v-if="isFilled(product.code)"
:model-value="product.code"
:label="t('admin.products.form.code')"
disabled
/>
<MalioInputText
v-if="isFilled(categoryLabel)"
:model-value="categoryLabel"
:label="t('admin.products.form.category')"
disabled
/>
<MalioInputText
v-if="isFilled(storageTypesLabel)"
:model-value="storageTypesLabel"
:label="t('admin.products.form.storageTypes')"
disabled
/>
<!-- RG-6.03 : « Fabriqué » / « Contient de la mélasse » affiches
uniquement si l'etat contient « Vendu » ET la case est cochee. -->
<div v-if="isSale && isFilled(product.manufactured)" class="flex h-12 items-center">
<MalioCheckbox
id="product-view-manufactured"
:label="t('admin.products.form.manufactured')"
:model-value="product.manufactured"
disabled
:reserve-message-space="false"
/>
</div>
<div v-if="isSale && isFilled(product.containsMolasses)" class="flex h-12 items-center">
<MalioCheckbox
id="product-view-molasses"
:label="t('admin.products.form.containsMolasses')"
:model-value="product.containsMolasses"
disabled
:reserve-message-space="false"
/>
</div>
</div>
<!-- Pas d'onglet en consultation (ERP-193) : on masque les onglets vides.
Les onglets Fournisseurs / Clients sont des coquilles non implementees
(placeholder, module Contrat inexistant, HP-M6-01) => aucune donnee a
afficher, donc rien n'est rendu ici. Ils restent visibles a l'edition
(preview + regle d'etat). Quand le module Contrat existera, ce bloc
affichera les onglets effectivement remplis (calque client/fournisseur). -->
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useProduct } from '~/modules/catalog/composables/useProduct'
import { isFilled } from '~/shared/utils/consultationDisplay'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const { can } = usePermissions()
// Gating de la route : la consultation est reservee a `view` (catalogue admin-only).
if (!can('catalog.products.view')) {
await navigateTo('/admin/products')
}
const productId = route.params.id as string
const { product, loading, error, load } = useProduct(productId)
// L'edition est reservee a `manage` ; le bouton « Modifier » suit cette permission.
const canManage = computed(() => can('catalog.products.manage'))
const headerTitle = computed(() => product.value?.name ?? t('admin.products.consultation.title'))
useHead({ title: t('admin.products.consultation.title') })
// RG-6.03 : « Vendu » conditionne l'affichage des booleens fabriqué / mélasse.
const isSale = computed(() => product.value?.states.includes('SALE') ?? false)
// ── Libelles lecture seule (relations embarquees mappees en texte) ───────────
const statesLabel = computed(() =>
(product.value?.states ?? []).map(code => t(`admin.products.state.${code}`)).join(', '),
)
const sitesLabel = computed(() =>
(product.value?.sites ?? []).map(site => site.name).join(', '),
)
const categoryLabel = computed(() => product.value?.category?.name ?? '')
const storageTypesLabel = computed(() =>
(product.value?.storageTypes ?? []).map(type => type.label).join(', '),
)
/** Retour vers le catalogue produit (fleche d'en-tete). */
function goBack(): void {
router.push('/admin/products')
}
/** Bascule vers l'ecran de modification. */
function goEdit(): void {
router.push(`/admin/products/${productId}/edit`)
}
onMounted(load)
</script>
@@ -186,9 +186,9 @@ const columns = [
{ key: 'categoryName', label: t('admin.products.column.category') },
]
/** Clic sur une ligne → ecran d'edition (route imbriquee /admin/products/{id}/edit). */
/** Clic sur une ligne → ecran de consultation (lecture seule) /admin/products/{id}. */
function onRowClick(item: Record<string, unknown>): void {
router.push(`/admin/products/${item.id}/edit`)
router.push(`/admin/products/${item.id}`)
}
function goToCreate(): void {
@@ -97,9 +97,9 @@
/>
</div>
<!-- Onglets Fournisseurs / Clients en placeholder (HP-M6-01) : presents
des l'ajout pour coherence avec l'ecran de modification. -->
<ProductPlaceholderTabs />
<!-- Onglets Fournisseurs / Clients (placeholder, HP-M6-01) : NON affiches a
l'ajout. Ils n'apparaissent qu'apres validation du formulaire principal
(ecran de modification), une fois le produit cree. -->
</div>
</template>