feat(catalog) : M7 — écran Modification d'un stockage /admin/storages/{id}/edit (ERP-218) #170
@@ -1127,10 +1127,18 @@
|
|||||||
"states": "État du type de stockage",
|
"states": "État du type de stockage",
|
||||||
"duplicateNumero": "Un stockage avec ce site, ce type et ce numéro existe déjà."
|
"duplicateNumero": "Un stockage avec ce site, ce type et ce numéro existe déjà."
|
||||||
},
|
},
|
||||||
|
"edit": {
|
||||||
|
"title": "Modifier le stockage",
|
||||||
|
"back": "Retour à la liste",
|
||||||
|
"save": "Enregistrer",
|
||||||
|
"loading": "Chargement du stockage…",
|
||||||
|
"notFound": "Stockage introuvable."
|
||||||
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"error": "Une erreur est survenue. Réessayez.",
|
"error": "Une erreur est survenue. Réessayez.",
|
||||||
"exportError": "L'export du répertoire stockage a échoué. Réessayez.",
|
"exportError": "L'export du répertoire stockage a échoué. Réessayez.",
|
||||||
"createSuccess": "Stockage créé avec succès"
|
"createSuccess": "Stockage créé avec succès",
|
||||||
|
"updateSuccess": "Stockage mis à jour avec succès"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useStorageForm } from '../useStorageForm'
|
|||||||
// Stubs des auto-imports Nuxt consommes par le composable + ses dependances.
|
// Stubs des auto-imports Nuxt consommes par le composable + ses dependances.
|
||||||
const mockGet = vi.hoisted(() => vi.fn())
|
const mockGet = vi.hoisted(() => vi.fn())
|
||||||
const mockPost = vi.hoisted(() => vi.fn())
|
const mockPost = vi.hoisted(() => vi.fn())
|
||||||
|
const mockPatch = vi.hoisted(() => vi.fn())
|
||||||
const mockToastSuccess = vi.hoisted(() => vi.fn())
|
const mockToastSuccess = vi.hoisted(() => vi.fn())
|
||||||
const mockToastError = vi.hoisted(() => vi.fn())
|
const mockToastError = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ vi.stubGlobal('useApi', () => ({
|
|||||||
get: mockGet,
|
get: mockGet,
|
||||||
post: mockPost,
|
post: mockPost,
|
||||||
put: vi.fn(),
|
put: vi.fn(),
|
||||||
patch: vi.fn(),
|
patch: mockPatch,
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
}))
|
}))
|
||||||
vi.stubGlobal('useToast', () => ({
|
vi.stubGlobal('useToast', () => ({
|
||||||
@@ -39,6 +40,7 @@ describe('useStorageForm', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockGet.mockReset()
|
mockGet.mockReset()
|
||||||
mockPost.mockReset()
|
mockPost.mockReset()
|
||||||
|
mockPatch.mockReset()
|
||||||
mockToastSuccess.mockReset()
|
mockToastSuccess.mockReset()
|
||||||
mockToastError.mockReset()
|
mockToastError.mockReset()
|
||||||
|
|
||||||
@@ -186,4 +188,62 @@ describe('useStorageForm', () => {
|
|||||||
expect(errors.states).toBe('Sélectionnez au moins un état.')
|
expect(errors.states).toBe('Sélectionnez au moins un état.')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('RG-7.08 — mode edition (prefill + PATCH)', () => {
|
||||||
|
// Stockage charge (memes cles que la reponse reelle § 4.0.bis : @id sur les relations).
|
||||||
|
const STORAGE = {
|
||||||
|
id: 42,
|
||||||
|
numero: '12',
|
||||||
|
states: ['RECEPTION', 'PRODUCTION'],
|
||||||
|
displayName: 'Cellule 12',
|
||||||
|
site: { '@id': '/api/sites/1', id: 1, name: 'Chatellerault', code: '86' },
|
||||||
|
storageType: { '@id': '/api/storage_types/9', id: 9, code: 'CELLULE', label: 'Cellule' },
|
||||||
|
createdAt: '', updatedAt: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
it('pre-remplit le formulaire depuis le stockage (relations en IRI)', () => {
|
||||||
|
const { form, storageId, prefill } = useStorageForm()
|
||||||
|
prefill(STORAGE)
|
||||||
|
|
||||||
|
expect(storageId.value).toBe(42)
|
||||||
|
expect(form.siteIri).toBe('/api/sites/1')
|
||||||
|
expect(form.storageTypeIri).toBe('/api/storage_types/9')
|
||||||
|
expect(form.numero).toBe('12')
|
||||||
|
expect(form.states).toEqual(['RECEPTION', 'PRODUCTION'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('soumet un PATCH /storages/{id} apres prefill (RG-7.08)', async () => {
|
||||||
|
mockPatch.mockResolvedValueOnce({ ...STORAGE })
|
||||||
|
const { prefill, submit } = useStorageForm()
|
||||||
|
prefill(STORAGE)
|
||||||
|
|
||||||
|
const ok = await submit()
|
||||||
|
|
||||||
|
expect(ok).toBe(true)
|
||||||
|
expect(mockPost).not.toHaveBeenCalled()
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith(
|
||||||
|
'/storages/42',
|
||||||
|
{
|
||||||
|
numero: '12',
|
||||||
|
states: ['RECEPTION', 'PRODUCTION'],
|
||||||
|
site: '/api/sites/1',
|
||||||
|
storageType: '/api/storage_types/9',
|
||||||
|
},
|
||||||
|
expect.objectContaining({ toast: false }),
|
||||||
|
)
|
||||||
|
expect(mockToastSuccess).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mappe un 409 doublon sur errors.numero aussi en edition (exclut le courant cote back)', async () => {
|
||||||
|
mockPatch.mockRejectedValueOnce({ response: { status: 409, _data: {} } })
|
||||||
|
const { errors, prefill, submit } = useStorageForm()
|
||||||
|
prefill(STORAGE)
|
||||||
|
|
||||||
|
const ok = await submit()
|
||||||
|
|
||||||
|
expect(ok).toBe(false)
|
||||||
|
expect(errors.numero).toBe('admin.storages.form.duplicateNumero')
|
||||||
|
expect(mockToastError).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import type { Storage } from '~/modules/catalog/types/storage'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chargement d'un stockage unique (ecran « Modification stockage », M7 — ERP-218).
|
||||||
|
* Lit le detail via `GET /api/storages/{id}` — meme structure que la ligne de liste
|
||||||
|
* (site / storageType embarques + displayName, § 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 useStorage(id: number | string) {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
const storage = ref<Storage | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref(false)
|
||||||
|
|
||||||
|
/** Charge le detail du stockage. En cas d'echec : `error = true`, `storage = null`. */
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
error.value = false
|
||||||
|
try {
|
||||||
|
storage.value = await api.get<Storage>(
|
||||||
|
`/storages/${id}`,
|
||||||
|
{},
|
||||||
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
error.value = true
|
||||||
|
storage.value = null
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { storage, loading, error, load }
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
useSiteOptions,
|
useSiteOptions,
|
||||||
useStorageTypeOptions,
|
useStorageTypeOptions,
|
||||||
} from '~/modules/catalog/composables/useProductOptions'
|
} from '~/modules/catalog/composables/useProductOptions'
|
||||||
|
import type { Storage } from '~/modules/catalog/types/storage'
|
||||||
|
|
||||||
/** Etats d'un stockage (miroir de l'enum back Storage::STATE_*, RG-7.04). */
|
/** Etats d'un stockage (miroir de l'enum back Storage::STATE_*, RG-7.04). */
|
||||||
export const STORAGE_STATES = ['RECEPTION', 'PRODUCTION', 'TRIAGE'] as const
|
export const STORAGE_STATES = ['RECEPTION', 'PRODUCTION', 'TRIAGE'] as const
|
||||||
@@ -46,6 +47,11 @@ export function useStorageForm() {
|
|||||||
|
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
// Id du stockage edite (null = creation). Pilote l'URL/methode du submit
|
||||||
|
// (RG-7.08 : « Modification » = meme formulaire/regles que « Ajouter »,
|
||||||
|
// bouton « Enregistrer » → PATCH).
|
||||||
|
const storageId = ref<number | null>(null)
|
||||||
|
|
||||||
/** Met a jour le site (select simple, RG-7.02). */
|
/** Met a jour le site (select simple, RG-7.02). */
|
||||||
function setSite(iri: string | null): void {
|
function setSite(iri: string | null): void {
|
||||||
form.siteIri = iri
|
form.siteIri = iri
|
||||||
@@ -71,10 +77,26 @@ export function useStorageForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Soumet la creation. Retourne true au succes (la page redirige), false sinon.
|
* Pre-remplit le formulaire depuis un stockage charge (mode edition, RG-7.08).
|
||||||
* 422 → mapping inline par champ (useFormErrors, `{ toast: false }`) ; 409
|
* Les relations sont reprises via leur IRI `@id` (= valeur d'option des selects).
|
||||||
* doublon du triplet (site, type, numero, RG-7.01) → erreur inline sur `numero`
|
* Le referentiel de types est plat (charge par loadReferentials) : prefill se
|
||||||
* (propertyPath exploitable cote back) + toast explicite.
|
* contente de mapper la selection courante (pas de cascade par site).
|
||||||
|
*/
|
||||||
|
function prefill(storage: Storage): void {
|
||||||
|
storageId.value = storage.id
|
||||||
|
form.siteIri = storage.site?.['@id'] ?? null
|
||||||
|
form.storageTypeIri = storage.storageType?.['@id'] ?? null
|
||||||
|
form.numero = storage.numero
|
||||||
|
form.states = [...storage.states]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soumet le formulaire. Retourne true au succes (la page redirige), false sinon.
|
||||||
|
* Creation → `POST /storages` ; edition (storageId non nul, RG-7.08) →
|
||||||
|
* `PATCH /storages/{id}` (mode merge-patch gere par useApi). 422 → mapping inline
|
||||||
|
* par champ (useFormErrors, `{ toast: false }`) ; 409 doublon du triplet (site,
|
||||||
|
* type, numero, RG-7.01 — le back exclut le stockage courant en PATCH) → erreur
|
||||||
|
* inline sur `numero` (propertyPath exploitable cote back) + toast explicite.
|
||||||
*/
|
*/
|
||||||
async function submit(): Promise<boolean> {
|
async function submit(): Promise<boolean> {
|
||||||
if (submitting.value) {
|
if (submitting.value) {
|
||||||
@@ -82,6 +104,7 @@ export function useStorageForm() {
|
|||||||
}
|
}
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
formErrors.clearErrors()
|
formErrors.clearErrors()
|
||||||
|
const editing = storageId.value !== null
|
||||||
try {
|
try {
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
// Chaine vide (jamais null) : le setter back setNumero attend un
|
// Chaine vide (jamais null) : le setter back setNumero attend un
|
||||||
@@ -104,8 +127,14 @@ export function useStorageForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const options = { headers: { Accept: 'application/ld+json' }, toast: false }
|
const options = { headers: { Accept: 'application/ld+json' }, toast: false }
|
||||||
await api.post('/storages', payload, options)
|
if (editing) {
|
||||||
toast.success({ title: t('admin.storages.toast.createSuccess') })
|
await api.patch(`/storages/${storageId.value}`, payload, options)
|
||||||
|
toast.success({ title: t('admin.storages.toast.updateSuccess') })
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await api.post('/storages', payload, options)
|
||||||
|
toast.success({ title: t('admin.storages.toast.createSuccess') })
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
@@ -128,6 +157,7 @@ export function useStorageForm() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
form,
|
form,
|
||||||
|
storageId,
|
||||||
errors: formErrors.errors,
|
errors: formErrors.errors,
|
||||||
submitting,
|
submitting,
|
||||||
siteOptions: sites.options,
|
siteOptions: sites.options,
|
||||||
@@ -136,6 +166,7 @@ export function useStorageForm() {
|
|||||||
setStorageType,
|
setStorageType,
|
||||||
setStates,
|
setStates,
|
||||||
loadReferentials,
|
loadReferentials,
|
||||||
|
prefill,
|
||||||
submit,
|
submit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- En-tete : retour vers la liste + nom du stockage. -->
|
||||||
|
<div class="flex items-center gap-3 pt-11">
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:arrow-left-bold"
|
||||||
|
icon-size="24"
|
||||||
|
variant="ghost"
|
||||||
|
:title="t('admin.storages.edit.back')"
|
||||||
|
v-bind="{ ariaLabel: t('admin.storages.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.storages.edit.loading') }}</p>
|
||||||
|
<p v-else-if="error" class="mt-12 text-center text-m-danger">{{ t('admin.storages.edit.notFound') }}</p>
|
||||||
|
|
||||||
|
<template v-else-if="storage">
|
||||||
|
<!-- ── Formulaire principal pre-rempli (a plat, PAS d'onglets — HP-M7-06),
|
||||||
|
memes champs/regles que l'ajout (RG-7.01→7.06). Bouton
|
||||||
|
« Enregistrer » → PATCH (RG-7.08). -->
|
||||||
|
<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.edit.save')"
|
||||||
|
:disabled="submitting"
|
||||||
|
@click="onSubmit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Onglets de la maquette : HORS perimetre HP-M7-06 (idem ajout). -->
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
|
import { useStorageForm, STORAGE_STATES } from '~/modules/catalog/composables/useStorageForm'
|
||||||
|
import { useStorage } from '~/modules/catalog/composables/useStorage'
|
||||||
|
import { FREE_TEXT_MASK } from '~/shared/utils/textSanitize'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { can } = usePermissions()
|
||||||
|
|
||||||
|
const storageId = route.params.id as string
|
||||||
|
|
||||||
|
// Gating de la route : la modification est reservee a `manage` (admin-only) ; sinon
|
||||||
|
// retour a la liste (pas d'ecran de consultation au M7).
|
||||||
|
if (!can('catalog.storages.manage')) {
|
||||||
|
await navigateTo('/admin/storages')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { storage, loading, error, load } = useStorage(storageId)
|
||||||
|
|
||||||
|
const {
|
||||||
|
form,
|
||||||
|
errors,
|
||||||
|
submitting,
|
||||||
|
siteOptions,
|
||||||
|
storageTypeOptions,
|
||||||
|
setSite,
|
||||||
|
setStorageType,
|
||||||
|
setStates,
|
||||||
|
loadReferentials,
|
||||||
|
prefill,
|
||||||
|
submit,
|
||||||
|
} = useStorageForm()
|
||||||
|
|
||||||
|
// Titre : libelle d'affichage du stockage (RG-7.05) sinon titre generique.
|
||||||
|
const headerTitle = computed(() => storage.value?.displayName ?? t('admin.storages.edit.title'))
|
||||||
|
|
||||||
|
useHead({ title: headerTitle })
|
||||||
|
|
||||||
|
// 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 modification (PATCH) ; au succes, retour a la liste. */
|
||||||
|
async function onSubmit(): Promise<void> {
|
||||||
|
const ok = await submit()
|
||||||
|
if (ok) {
|
||||||
|
router.push('/admin/storages')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// Referentiels (selects) + detail du stockage charges en parallele.
|
||||||
|
await Promise.all([
|
||||||
|
loadReferentials().catch(() => {}),
|
||||||
|
load(),
|
||||||
|
])
|
||||||
|
// Pre-remplissage une fois le stockage charge (echec de chargement => message).
|
||||||
|
if (storage.value) {
|
||||||
|
prefill(storage.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user