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)
This commit is contained in:
2026-06-25 17:52:02 +02:00
parent f12a378126
commit ce0e274743
6 changed files with 796 additions and 2 deletions
@@ -0,0 +1,157 @@
<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>
</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>