feat(catalog) : M7 — écran Ajouter un stockage /admin/storages/new (ERP-217)

- Formulaire de création à plat (pas d'onglets, HP-M7-06), gate catalog.storages.manage
- Champs Site, Type de stockage, Numéro, État (multi ≥1) en composants Malio, validation inline 422 par champ via useFormErrors
- 409 doublon (site, type, numéro) RG-7.01 → erreur inline sous Numéro + toast explicite
- Composable useStorageForm (POST /storages, payload relations en IRI), libellés i18n
- Référentiel des types PLAT : pas de cascade Site→Type (RG-7.03 non portée côté back, StorageType sans relation Site — à reclarifier spec)
- Tests Vitest de useStorageForm (référentiel plat, submit, 409/422)
This commit is contained in:
2026-06-30 11:27:57 +02:00
parent fd6b7e4c79
commit aa7cda48b3
4 changed files with 469 additions and 1 deletions
@@ -0,0 +1,127 @@
<template>
<div>
<!-- En-tete : retour vers la liste + titre. -->
<div class="flex items-center gap-3 pt-11">
<MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
:title="t('admin.storages.form.back')"
v-bind="{ ariaLabel: t('admin.storages.form.back') }"
@click="goBack"
/>
<h1 class="text-[30px] font-semibold text-m-primary">{{ t('admin.storages.form.title') }}</h1>
</div>
<!-- Formulaire principal de creation (a plat, PAS d'onglets — HP-M7-06)
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">
<!-- Site : select simple obligatoire (RG-7.02). -->
<MalioSelect
:model-value="form.siteIri"
:options="siteOptions"
:label="t('admin.storages.form.site')"
empty-option-label=""
:required="true"
:error="errors.site"
@update:model-value="(v: string | number | null) => setSite(v === null || v === '' ? null : String(v))"
/>
<!-- Type de stockage : select simple obligatoire. Referentiel plat :
tous les types (pas de cascade par site, RG-7.03 non portee back). -->
<MalioSelect
:model-value="form.storageTypeIri"
:options="storageTypeOptions"
:label="t('admin.storages.form.storageType')"
empty-option-label=""
:required="true"
:error="errors.storageType"
@update:model-value="(v: string | number | null) => setStorageType(v === null || v === '' ? null : String(v))"
/>
<!-- Numero : texte libre obligatoire (RG-7.01, normalise trim cote serveur). -->
<MalioInputText
v-model="form.numero"
:mask="FREE_TEXT_MASK"
:label="t('admin.storages.form.numero')"
:required="true"
:error="errors.numero"
/>
<!-- Etat du type de stockage : multi-select obligatoire (>= 1, RG-7.04). -->
<MalioSelectCheckbox
:model-value="form.states"
:options="stateOptions"
:max-tags="3"
:label="t('admin.storages.form.states')"
:display-tag="true"
:required="true"
:error="errors.states"
@update:model-value="(v: (string | number)[]) => setStates(v.map(String))"
/>
</div>
<div class="mt-12 flex justify-center">
<MalioButton
variant="primary"
:label="t('admin.storages.form.submit')"
:disabled="submitting"
@click="onSubmit"
/>
</div>
<!-- Onglets de la maquette (Clients / Règles / Etiquette / Comptabilité) :
HORS perimetre HP-M7-06 — aucune barre d'onglets a l'ajout. -->
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useStorageForm, STORAGE_STATES } from '~/modules/catalog/composables/useStorageForm'
import { FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
const { t } = useI18n()
const router = useRouter()
const { can } = usePermissions()
useHead({ title: t('admin.storages.form.title') })
// Gating de la route : la creation est reservee a `manage` (repertoire admin-only).
if (!can('catalog.storages.manage')) {
await navigateTo('/admin/storages')
}
const {
form,
errors,
submitting,
siteOptions,
storageTypeOptions,
setSite,
setStorageType,
setStates,
loadReferentials,
submit,
} = useStorageForm()
// Options de l'etat : libelles i18n (la valeur d'option = code enum).
const stateOptions = computed(() =>
STORAGE_STATES.map(code => ({ value: code, label: t(`admin.storages.state.${code}`) })),
)
/** Retour vers la liste des stockages (fleche d'en-tete). */
function goBack(): void {
router.push('/admin/storages')
}
/** Soumet la creation ; au succes, retour a la liste. */
async function onSubmit(): Promise<void> {
const ok = await submit()
if (ok) {
router.push('/admin/storages')
}
}
onMounted(() => {
// Echec du chargement des referentiels non bloquant : les selects restent vides.
loadReferentials().catch(() => {})
})
</script>