Files
Starseed/frontend/modules/catalog/composables/useStorageForm.ts
T
tristan e3e1f9738c feat(catalog) : M7 — écran Modification d'un stockage /admin/storages/{id}/edit (ERP-218)
- Route /admin/storages/{id}/edit, gate catalog.storages.manage, détail via useStorage (GET /api/storages/{id})
- Formulaire factorisé create/edit dans useStorageForm : prefill + bouton « Enregistrer » → PATCH /api/storages/{id} (RG-7.08)
- Mêmes champs/validations que l'ajout (RG-7.01→7.06), erreurs 422 inline par champ
- 409 doublon (site, type, numéro, exclut le courant côté back) → inline sous Numéro + toast
- Pas d'onglets (HP-M7-06) ; libellés i18n edit.* + toast.updateSuccess
- Tests Vitest useStorageForm mode édition (prefill + PATCH + 409)
2026-06-30 11:58:02 +02:00

173 lines
7.0 KiB
TypeScript

/**
* Composable du formulaire de creation d'un stockage (M7 — ERP-217).
*
* Porte l'etat du formulaire principal (a plat, PAS d'onglets — HP-M7-06), les
* referentiels des selects et la soumission `POST /api/storages` avec mapping des
* erreurs 422/409 inline (useFormErrors, ERP-101). Reference : ecran « Ajouter un
* produit » (M6) / ecran Client.
*
* Referentiel des types de stockage : PLAT (RG-6.06 / decision back). Le concept
* type<->site a ete retire en M6 (jointure storage_type_site droppee, migration
* Version20260626100000) et `StorageType` n'a plus de relation Site ; le provider
* ignore tout filtre `?siteId[]`. La cascade Site->Type de RG-7.03 n'est donc PAS
* portee (decision produit du 30/06 : referentiel plat, fidele au back ; RG-7.03 a
* reclarifier cote spec). On charge donc TOUS les types une fois, Site et Type
* independants.
*
* Etat 100 % local a l'instance.
*/
import { reactive, ref } from 'vue'
import {
useSiteOptions,
useStorageTypeOptions,
} 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). */
export const STORAGE_STATES = ['RECEPTION', 'PRODUCTION', 'TRIAGE'] as const
export function useStorageForm() {
const api = useApi()
const { t } = useI18n()
const toast = useToast()
const formErrors = useFormErrors()
const sites = useSiteOptions()
const storageTypes = useStorageTypeOptions()
// ── Etat du formulaire ───────────────────────────────────────────────────
// Les relations (site, storageType) sont stockees en IRI (envoyees telles
// quelles au POST) ; `states` porte les codes enum.
const form = reactive({
siteIri: null as string | null,
storageTypeIri: null as string | null,
numero: null as string | null,
states: [] as string[],
})
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). */
function setSite(iri: string | null): void {
form.siteIri = iri
}
/** Met a jour le type de stockage (select simple, referentiel plat). */
function setStorageType(iri: string | null): void {
form.storageTypeIri = iri
}
/** Met a jour les etats (multi-select, >= 1, RG-7.04). */
function setStates(states: string[]): void {
form.states = states
}
/**
* Charge les referentiels (sites + TOUS les types de stockage). Resilient :
* un referentiel en echec reste vide sans casser l'autre. Pas de cascade par
* site (referentiel plat, cf. docblock).
*/
async function loadReferentials(): Promise<void> {
await Promise.allSettled([sites.load(), storageTypes.load()])
}
/**
* Pre-remplit le formulaire depuis un stockage charge (mode edition, RG-7.08).
* Les relations sont reprises via leur IRI `@id` (= valeur d'option des selects).
* Le referentiel de types est plat (charge par loadReferentials) : prefill se
* 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> {
if (submitting.value) {
return false
}
submitting.value = true
formErrors.clearErrors()
const editing = storageId.value !== null
try {
const payload: Record<string, unknown> = {
// Chaine vide (jamais null) : le setter back setNumero attend un
// `string` non-nullable -> envoyer null leverait une erreur de type
// (denormalisation) qui court-circuiterait les autres violations.
// Avec '', la contrainte NotBlank renvoie un message propre par champ.
numero: form.numero ?? '',
states: form.states,
}
// `site` / `storageType` attendent un IRI (string) : envoyer null
// declencherait une erreur de denormalisation API Platform qui
// court-circuiterait TOUTES les autres violations. On omet la cle quand
// la relation n'est pas choisie -> la contrainte NotNull renvoie un
// message propre, et les autres champs sont valides dans la meme 422.
if (form.siteIri) {
payload.site = form.siteIri
}
if (form.storageTypeIri) {
payload.storageType = form.storageTypeIri
}
const options = { headers: { Accept: 'application/ld+json' }, toast: false }
if (editing) {
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
}
catch (error) {
const status = (error as { response?: { status?: number } })?.response?.status
if (status === 409) {
// Doublon (site, type, numero) RG-7.01 : inline sur `numero` + toast.
const message = t('admin.storages.form.duplicateNumero')
formErrors.setError('numero', message)
toast.error({ title: t('admin.storages.toast.error'), message })
}
else {
formErrors.handleApiError(error, { fallbackMessage: t('admin.storages.toast.error') })
}
return false
}
finally {
submitting.value = false
}
}
return {
form,
storageId,
errors: formErrors.errors,
submitting,
siteOptions: sites.options,
storageTypeOptions: storageTypes.options,
setSite,
setStorageType,
setStates,
loadReferentials,
prefill,
submit,
}
}