diff --git a/frontend/modules/catalog/components/CategoryDrawer.vue b/frontend/modules/catalog/components/CategoryDrawer.vue
index 73991f0..d780808 100644
--- a/frontend/modules/catalog/components/CategoryDrawer.vue
+++ b/frontend/modules/catalog/components/CategoryDrawer.vue
@@ -16,29 +16,30 @@
+ number (categoryType id) ; conversion en IRI au moment du save
+ par le composable useCategoryForm. -->
-
- {{ errors._global }}
+
+ {{ form.errors.value._global }}
@@ -66,7 +67,7 @@
:label="t('common.save')"
variant="primary"
button-class="w-[150px]"
- :disabled="saving || loadingTypes"
+ :disabled="form.submitting.value || loadingTypes"
@click="handleSave"
/>
@@ -74,12 +75,14 @@
diff --git a/frontend/modules/catalog/composables/useCategoriesAdmin.ts b/frontend/modules/catalog/composables/useCategoriesAdmin.ts
new file mode 100644
index 0000000..181326c
--- /dev/null
+++ b/frontend/modules/catalog/composables/useCategoriesAdmin.ts
@@ -0,0 +1,134 @@
+/**
+ * Composable d'administration des categories (M0 — Gestion des categories).
+ *
+ * Centralise le chargement et le state des deux ressources lues par la page
+ * `/admin/categories` : la liste des categories et le referentiel
+ * CategoryType (utilise dans le select du drawer).
+ *
+ * State singleton au niveau module (meme convention que `useSidebar` /
+ * `useModules` / `useAuditLog`) : reset automatique au logout via
+ * `onAuthSessionCleared` (cf. CLAUDE.md regle frontend.md : « composables
+ * avec state singleton doivent etre reinitialises au logout »), et reset
+ * explicite expose via `resetCategoriesAdmin()` appele depuis
+ * `modules/core/pages/logout.vue`.
+ */
+import { ref } from 'vue'
+import type { Category, CategoryType } from '~/modules/catalog/types/category'
+import type { HydraCollection } from '~/shared/utils/api'
+import { onAuthSessionCleared } from '~/shared/stores/auth'
+
+/**
+ * Dette M0 : pas de pagination serveur sur les ressources Catalog (volumetrie
+ * cible ≤ 300). On force une page geante via `itemsPerPage` pour recuperer
+ * toute la liste en un coup. A basculer en pagination serveur quand la
+ * volumetrie reelle depassera ce plafond — meme pattern que sites.vue.
+ */
+const HYDRA_NO_PAGINATION = 999
+
+// State singleton — partage entre tous les composants qui appellent le
+// composable dans la meme session. Les refs sont declarees au niveau module
+// (pas dans la fonction `useCategoriesAdmin()`) pour eviter qu'une nouvelle
+// instance soit creee a chaque appel.
+const categories = ref([])
+const types = ref([])
+const loading = ref(false)
+const loadingTypes = ref(false)
+const error = ref(null)
+
+function resetCategoriesAdminState(): void {
+ categories.value = []
+ types.value = []
+ loading.value = false
+ loadingTypes.value = false
+ error.value = null
+}
+
+// Auto-enregistrement singleton : purge le state sur 401/clearSession pour
+// eviter qu'un user suivant (connecte sur le meme onglet) voie l'etat de
+// l'ancien. Le logout volontaire (page logout.vue) appelle directement
+// `resetCategoriesAdmin()` ci-dessous.
+onAuthSessionCleared(resetCategoriesAdminState)
+
+export function useCategoriesAdmin() {
+ const api = useApi()
+
+ /**
+ * Charge la liste des categories. Le serveur exclut les soft-deleted par
+ * defaut (RG-1.08) et trie par name ASC (RG-1.10). Pas de pagination
+ * serveur (volumetrie ≤ 300, pagination front via MalioDataTable).
+ *
+ * `includeDeleted=true` permet a un user avec `catalog.categories.manage`
+ * de voir les soft-deleted (RG-1.09) — au M0 la page n'utilise pas cette
+ * option mais on l'expose pour la suite (corbeille future).
+ *
+ * Swallow volontaire : un 403 (user sans permission view) ne doit pas
+ * toaster — la sidebar masque deja l'entree pour ces users, on tombe sur
+ * la page seulement par URL directe et on affiche un tableau vide propre.
+ */
+ async function fetchAll(includeDeleted = false): Promise {
+ loading.value = true
+ error.value = null
+ try {
+ const query: Record = { itemsPerPage: HYDRA_NO_PAGINATION }
+ if (includeDeleted) {
+ query.includeDeleted = 'true'
+ }
+ const data = await api.get>(
+ '/categories',
+ query,
+ { toast: false },
+ )
+ categories.value = data.member ?? []
+ } catch (e) {
+ categories.value = []
+ error.value = (e as Error)?.message ?? 'Erreur de chargement'
+ } finally {
+ loading.value = false
+ }
+ }
+
+ /**
+ * Charge le referentiel CategoryType (lecture seule, RG-1.06). Appele a
+ * l'ouverture de la page admin pour que le select du drawer ait deja les
+ * options pretes au moment de la creation/edition.
+ *
+ * Toast desactive : on stocke l'erreur dans `error` plutot que de
+ * spammer un toast — le drawer affichera l'erreur inline s'il y a lieu.
+ */
+ async function fetchTypes(): Promise {
+ loadingTypes.value = true
+ try {
+ const data = await api.get>(
+ '/category_types',
+ { itemsPerPage: HYDRA_NO_PAGINATION },
+ { toast: false },
+ )
+ types.value = data.member ?? []
+ } catch (e) {
+ types.value = []
+ error.value = (e as Error)?.message ?? 'Erreur de chargement des types'
+ } finally {
+ loadingTypes.value = false
+ }
+ }
+
+ /**
+ * Reset explicite — appele depuis `logout.vue` apres `auth.logout()` pour
+ * garantir que la prochaine session reparte sur un state propre meme si
+ * `clearSession()` n'a pas ete declenche (cas logout volontaire).
+ */
+ function resetCategoriesAdmin(): void {
+ resetCategoriesAdminState()
+ }
+
+ return {
+ categories,
+ types,
+ loading,
+ loadingTypes,
+ error,
+ fetchAll,
+ fetchTypes,
+ resetCategoriesAdmin,
+ }
+}
diff --git a/frontend/modules/catalog/composables/useCategoryForm.ts b/frontend/modules/catalog/composables/useCategoryForm.ts
new file mode 100644
index 0000000..36e928e
--- /dev/null
+++ b/frontend/modules/catalog/composables/useCategoryForm.ts
@@ -0,0 +1,319 @@
+/**
+ * Composable de formulaire categorie (M0 — Gestion des categories).
+ *
+ * Centralise la logique de validation client + appels API (POST / PATCH /
+ * DELETE) du drawer de creation/edition. Contrairement a
+ * `useCategoriesAdmin` qui porte un state singleton partage entre composants,
+ * ce composable est instancie par formulaire (les refs vivent dans la
+ * fonction `useCategoryForm()`) — chaque drawer ouvert a son propre state
+ * isole.
+ *
+ * Validations client en miroir des regles back (RG-1.02 / RG-1.04 / RG-1.05) :
+ * elles servent juste a eviter l'aller-retour reseau evitable. Le serveur
+ * revalide toujours (defense en profondeur).
+ *
+ * Mapping erreurs API :
+ * - 409 (RG-1.07 doublon) → toast + erreur sur le champ `name`
+ * - 422 (violations API Platform) → mapping sur les champs concernes
+ * - autre → erreur globale `_global` + toast generique
+ */
+import { computed, ref } from 'vue'
+import type { Category } from '~/modules/catalog/types/category'
+import { extractApiErrorMessage, extractApiViolations } from '~/shared/utils/api'
+
+/**
+ * Erreur HTTP capturee par ofetch. On expose juste les champs utilises ici
+ * (status et payload data) pour eviter de typer toute la lib.
+ */
+interface ApiFetchError {
+ response?: {
+ status?: number
+ _data?: unknown
+ }
+}
+
+export function useCategoryForm() {
+ const api = useApi()
+ const { t } = useI18n()
+ const toast = useToast()
+
+ // State local du formulaire — pas singleton, chaque appel a useCategoryForm
+ // cree son propre state (cohérent avec le pattern « un drawer = un form »).
+ const name = ref('')
+ const categoryTypeId = ref(null)
+
+ // Snapshot des valeurs initiales : sert a calculer `isDirty` pour le
+ // pattern view → edit du drawer (le bouton Enregistrer reste masque tant
+ // que rien n'a change en mode consultation).
+ const initialName = ref('')
+ const initialCategoryTypeId = ref(null)
+
+ const errors = ref<{
+ name: string
+ categoryType: string
+ _global: string
+ }>({
+ name: '',
+ categoryType: '',
+ _global: '',
+ })
+
+ const submitting = ref(false)
+
+ const isDirty = computed(
+ () =>
+ name.value !== initialName.value
+ || categoryTypeId.value !== initialCategoryTypeId.value,
+ )
+
+ /**
+ * Pre-remplit le formulaire a partir d'une categorie existante (mode
+ * consultation/edition) ou vide (mode creation). Reinitialise les
+ * erreurs et le snapshot initial pour repartir d'un etat propre.
+ */
+ function loadFrom(category: Category | null): void {
+ errors.value = { name: '', categoryType: '', _global: '' }
+ if (category) {
+ name.value = category.name
+ categoryTypeId.value = category.categoryType.id
+ initialName.value = category.name
+ initialCategoryTypeId.value = category.categoryType.id
+ } else {
+ name.value = ''
+ categoryTypeId.value = null
+ initialName.value = ''
+ initialCategoryTypeId.value = null
+ }
+ }
+
+ /**
+ * Validation client miroir des RG back. Renvoie true si tout passe et
+ * peuple `errors` sinon. Le trim est applique cote client (miroir RG-1.03)
+ * mais le serveur retrim de toute facon — pas de risque de divergence.
+ */
+ function validate(): boolean {
+ errors.value = { name: '', categoryType: '', _global: '' }
+ const trimmedName = name.value.trim()
+
+ // RG-1.02 — name obligatoire (vide / whitespace-only).
+ if (trimmedName === '') {
+ errors.value.name = t('admin.categories.validation.nameRequired')
+ } else if (trimmedName.length < 2 || trimmedName.length > 120) {
+ // RG-1.04 — longueur 2-120 apres trim.
+ errors.value.name = t('admin.categories.validation.nameLength')
+ }
+
+ // RG-1.05 — categoryType obligatoire.
+ if (categoryTypeId.value === null) {
+ errors.value.categoryType = t('admin.categories.validation.typeRequired')
+ }
+
+ return errors.value.name === '' && errors.value.categoryType === ''
+ }
+
+ /**
+ * Construit le payload POST a partir du state. Le `categoryType` est
+ * envoye en IRI Hydra (`/api/category_types/{id}`) — convention API
+ * Platform pour referencer une ressource liee. Retourne un object literal
+ * compatible avec `AnyObject` de `useApi()` (un type nomme strict comme
+ * `CategoryCreateInput` ne serait pas assignable a `Record`
+ * en TS strict).
+ */
+ function buildCreatePayload(): Record {
+ return {
+ name: name.value.trim(),
+ categoryType: `/api/category_types/${categoryTypeId.value}`,
+ }
+ }
+
+ /**
+ * Mappe les violations 422 d'API Platform sur les champs du formulaire.
+ * Renvoie true des qu'au moins une violation a ete posee — false sinon
+ * (payload sans violations exploitables, ou tous les `propertyPath` hors
+ * du mapping connu). L'extraction Hydra (`violations` / `hydra:violations`)
+ * est centralisee dans `shared/utils/api.ts` pour rester reutilisable
+ * sur les futurs drawers de formulaire.
+ */
+ function mapServerViolations(data: unknown): boolean {
+ const violations = extractApiViolations(data)
+ if (violations.length === 0) return false
+ let mapped = false
+ for (const v of violations) {
+ if (v.propertyPath === 'name') {
+ errors.value.name = v.message
+ mapped = true
+ } else if (v.propertyPath === 'categoryType') {
+ errors.value.categoryType = v.message
+ mapped = true
+ }
+ }
+ return mapped
+ }
+
+ /**
+ * Traite une erreur API : mappe selon le status, declenche les toasts
+ * appropries. Centralise la logique entre create/update.
+ *
+ * - 409 (RG-1.07) : doublon — toast + errors.name avec libelle qui inclut
+ * le nom soumis.
+ * - 422 : tentative de mapping fin via les violations API Platform — si au
+ * moins une violation est mappee, pas de toast (erreur affichee inline
+ * sous le champ concerne).
+ * - autre : message global + toast generique. Le toast natif d'useApi
+ * est desactive (`toast: false`) pour permettre ce mapping fin ; il faut
+ * donc en re-emettre un manuellement ici, sinon une 500 reste silencieuse.
+ *
+ * Retourne true si l'erreur a ete reconnue et traitee (409/422 mappes),
+ * false sinon (fallback generique).
+ */
+ function handleApiError(e: unknown, attemptedName: string): boolean {
+ const status = (e as ApiFetchError)?.response?.status
+ const data = (e as ApiFetchError)?.response?._data
+
+ if (status === 409) {
+ const duplicateMessage = t('admin.categories.toast.duplicate', {
+ name: attemptedName,
+ })
+ errors.value.name = duplicateMessage
+ toast.error({
+ title: 'Erreur',
+ message: duplicateMessage,
+ })
+ return true
+ }
+
+ if (status === 422 && mapServerViolations(data)) {
+ return true
+ }
+
+ const extracted = extractApiErrorMessage(data)
+ errors.value._global = extracted || 'Une erreur est survenue.'
+ toast.error({
+ title: 'Erreur',
+ message: errors.value._global,
+ })
+ return false
+ }
+
+ /**
+ * POST /api/categories. Renvoie la categorie creee, ou `null` si la
+ * validation client a echoue ou si le serveur a renvoye une erreur. Le
+ * caller (drawer) decide quoi faire en fonction (fermer ou rester ouvert).
+ */
+ async function submitCreate(): Promise {
+ if (!validate()) return null
+ submitting.value = true
+ errors.value._global = ''
+ const payload = buildCreatePayload()
+ try {
+ const created = await api.post('/categories', payload, {
+ toast: false,
+ })
+ toast.success({
+ title: 'Succès',
+ message: t('admin.categories.toast.created'),
+ })
+ return created
+ } catch (e) {
+ handleApiError(e, String(payload.name))
+ return null
+ } finally {
+ submitting.value = false
+ }
+ }
+
+ /**
+ * PATCH /api/categories/{id}. Envoie uniquement les champs modifies pour
+ * coller a la semantique merge-patch (Content-Type pose par useApi).
+ * Renvoie la categorie mise a jour, ou `null` en cas d'echec.
+ */
+ async function submitUpdate(id: number): Promise {
+ if (!validate()) return null
+ submitting.value = true
+ errors.value._global = ''
+ const payload: Record = {}
+ if (name.value !== initialName.value) {
+ payload.name = name.value.trim()
+ }
+ if (categoryTypeId.value !== initialCategoryTypeId.value) {
+ payload.categoryType = `/api/category_types/${categoryTypeId.value}`
+ }
+ // Garde-fou : un PATCH sans changement ne sert a rien. Theoriquement
+ // empeche par le drawer (bouton Enregistrer masque si !isDirty) mais
+ // on protege le composable contre un appel direct mal utilise.
+ if (Object.keys(payload).length === 0) {
+ submitting.value = false
+ return null
+ }
+ try {
+ const updated = await api.patch(`/categories/${id}`, payload, {
+ toast: false,
+ })
+ toast.success({
+ title: 'Succès',
+ message: t('admin.categories.toast.updated'),
+ })
+ return updated
+ } catch (e) {
+ const attemptedName = typeof payload.name === 'string'
+ ? payload.name
+ : name.value.trim()
+ handleApiError(e, attemptedName)
+ return null
+ } finally {
+ submitting.value = false
+ }
+ }
+
+ /**
+ * DELETE /api/categories/{id} → soft delete (RG-1.12). Le serveur pose
+ * `deleted_at = now()` et retourne 204. Renvoie true en cas de succes,
+ * false sinon (avec toast erreur deja affiche).
+ */
+ async function submitDelete(id: number): Promise {
+ submitting.value = true
+ errors.value._global = ''
+ try {
+ await api.delete(`/categories/${id}`, {}, { toast: false })
+ toast.success({
+ title: 'Succès',
+ message: t('admin.categories.toast.deleted'),
+ })
+ return true
+ } catch (e) {
+ handleApiError(e, name.value)
+ return false
+ } finally {
+ submitting.value = false
+ }
+ }
+
+ /**
+ * Reset complet du formulaire — utilise par le drawer apres save ou
+ * fermeture pour ne pas garder de donnees stale entre deux ouvertures.
+ */
+ function reset(): void {
+ name.value = ''
+ categoryTypeId.value = null
+ initialName.value = ''
+ initialCategoryTypeId.value = null
+ errors.value = { name: '', categoryType: '', _global: '' }
+ submitting.value = false
+ }
+
+ return {
+ // State
+ name,
+ categoryTypeId,
+ errors,
+ submitting,
+ isDirty,
+ // Methods
+ loadFrom,
+ validate,
+ submitCreate,
+ submitUpdate,
+ submitDelete,
+ reset,
+ }
+}
diff --git a/frontend/modules/catalog/pages/admin/categories.vue b/frontend/modules/catalog/pages/admin/categories.vue
index 482bce8..2acd257 100644
--- a/frontend/modules/catalog/pages/admin/categories.vue
+++ b/frontend/modules/catalog/pages/admin/categories.vue
@@ -18,7 +18,6 @@
(name ASC, RG-1.10). La barre de pagination du MalioDataTable
reste cosmetique tant qu'aucun slice client n'est cable : a
traiter cote @malio/layer-ui le jour ou la volumetrie monte. -->
-
import type { Category } from '~/modules/catalog/types/category'
-import type { HydraCollection } from '~/shared/utils/api'
const { t } = useI18n()
-const api = useApi()
const { can } = usePermissions()
+const { categories, fetchAll, fetchTypes } = useCategoriesAdmin()
+const { submitDelete } = useCategoryForm()
useHead({ title: t('admin.categories.title') })
const canManage = computed(() => can('catalog.categories.manage'))
-const categories = ref([])
-const loading = ref(false)
const drawerOpen = ref(false)
const selectedCategory = ref(null)
const deleteModalOpen = ref(false)
@@ -90,35 +87,6 @@ function onRowClick(item: Record) {
if (category) openEditDrawer(category)
}
-/**
- * Charge la liste des categories. Le serveur exclut les soft-deleted par
- * defaut (RG-1.08) et trie par name ASC (RG-1.10). Pas de pagination
- * serveur (volumetrie cible <= 300) ni de slice client — toute la liste
- * est rendue d'un coup ; la barre du MalioDataTable est donc cosmetique
- * jusqu'a la mise a jour layer-ui (ticket ERP-70).
- *
- * Logique inline volontaire au M0 (decision prompt ERP-49) : extraction
- * en composable `useCategoriesAdmin` au ticket 0.8 (ERP-50).
- */
-async function loadCategories(): Promise {
- loading.value = true
- try {
- const data = await api.get>(
- '/categories',
- { itemsPerPage: 999 },
- { toast: false },
- )
- categories.value = data.member ?? []
- } catch {
- // Reset sur echec pour ne pas afficher de donnees stale. Pas de
- // toast : un user sans permission view recoit 403 et voit une
- // liste vide propre — le mecanisme de gating se fait cote sidebar.
- categories.value = []
- } finally {
- loading.value = false
- }
-}
-
function openCreateDrawer() {
selectedCategory.value = null
drawerOpen.value = true
@@ -136,32 +104,36 @@ function onDeleteRequest() {
}
/**
- * DELETE /api/categories/{id} → soft delete (RG-1.12). Le serveur pose
- * `deleted_at = now()` et retourne 204. Refresh de la liste a la fin
- * pour retirer la ligne (l'index unique partiel autorise une recreation
- * ulterieure avec le meme couple (name, type) — RG-1.07).
+ * Soft delete via le composable de form (qui gere toast + erreur). Refresh
+ * de la liste a la fin pour retirer la ligne. L'index unique partiel
+ * autorise une recreation ulterieure avec le meme couple (name, type) —
+ * RG-1.07.
*/
async function handleDelete(): Promise {
if (!categoryToDelete.value) return
deleting.value = true
try {
- await api.delete(`/categories/${categoryToDelete.value.id}`, {}, {
- toastSuccessMessage: t('admin.categories.toast.deleted'),
- })
- deleteModalOpen.value = false
- categoryToDelete.value = null
- drawerOpen.value = false
- await loadCategories()
+ const ok = await submitDelete(categoryToDelete.value.id)
+ if (ok) {
+ deleteModalOpen.value = false
+ categoryToDelete.value = null
+ drawerOpen.value = false
+ await fetchAll()
+ }
} finally {
deleting.value = false
}
}
function onCategorySaved() {
- loadCategories()
+ fetchAll()
}
+// Chargement initial des deux ressources (liste + referentiel des types).
+// Le referentiel est pre-charge ici (et pas dans le drawer) pour que le
+// select soit pret au moment ou l'utilisateur clique sur « + Ajouter ».
onMounted(() => {
- loadCategories()
+ fetchAll()
+ fetchTypes()
})
diff --git a/frontend/modules/core/pages/logout.vue b/frontend/modules/core/pages/logout.vue
index c25c9ad..21ff0a0 100644
--- a/frontend/modules/core/pages/logout.vue
+++ b/frontend/modules/core/pages/logout.vue
@@ -12,6 +12,7 @@ const { resetSidebar } = useSidebar()
const { resetModules } = useModules()
const { resetCurrentSite } = useCurrentSite()
const { resetAuditLog } = useAuditLog()
+const { resetCategoriesAdmin } = useCategoriesAdmin()
onMounted(async () => {
try {
@@ -27,6 +28,7 @@ onMounted(async () => {
resetModules()
resetCurrentSite()
resetAuditLog()
+ resetCategoriesAdmin()
await navigateTo('/login')
}
})
diff --git a/frontend/shared/composables/useApi.ts b/frontend/shared/composables/useApi.ts
index 344a6f5..b51aaa0 100644
--- a/frontend/shared/composables/useApi.ts
+++ b/frontend/shared/composables/useApi.ts
@@ -1,5 +1,6 @@
import type { FetchOptions , FetchError } from 'ofetch'
import { $fetch } from 'ofetch'
+import { extractApiErrorMessage } from '~/shared/utils/api'
export type AnyObject = Record
@@ -41,24 +42,8 @@ export function useApi(): ApiClient {
function extractErrorMessage(error: unknown, responseData?: unknown): string {
const data = responseData ?? (error as FetchError)?.data
-
- if (typeof data === 'string') {
- return data
- }
-
- if (data && typeof data === 'object') {
- const record = data as Record
- return (
- (record['hydra:description'] as string) ||
- (record.detail as string) ||
- (record.message as string) ||
- (record.error as string) ||
- (record.title as string) ||
- (record['hydra:title'] as string) ||
- ''
- )
- }
-
+ const msg = extractApiErrorMessage(data)
+ if (msg) return msg
return (error as FetchError)?.message ?? 'Erreur inconnue.'
}
diff --git a/frontend/shared/utils/api.ts b/frontend/shared/utils/api.ts
index 8e57001..b8f24f6 100644
--- a/frontend/shared/utils/api.ts
+++ b/frontend/shared/utils/api.ts
@@ -31,3 +31,62 @@ export interface HydraCollection {
export function extractHydraMembers(collection: HydraCollection): T[] {
return collection.member ?? []
}
+
+/**
+ * Une violation de contrainte API Platform (reponse 422). Le `propertyPath`
+ * pointe le champ concerne, `message` est le libelle a afficher.
+ */
+export interface ApiViolation {
+ propertyPath: string
+ message: string
+}
+
+/**
+ * Extrait les violations d'un payload d'erreur 422 d'API Platform 4. Supporte
+ * les deux formats de negociation (`violations` ou `hydra:violations`) et
+ * renvoie un tableau vide si le payload n'en contient pas d'exploitables.
+ *
+ * Utilise par useCategoryForm et tout futur composable de formulaire qui
+ * doit mapper les violations serveur sur ses champs.
+ */
+export function extractApiViolations(data: unknown): ApiViolation[] {
+ if (!data || typeof data !== 'object') return []
+ const record = data as Record
+ const raw = record.violations ?? record['hydra:violations']
+ if (!Array.isArray(raw)) return []
+ const out: ApiViolation[] = []
+ for (const v of raw) {
+ if (!v || typeof v !== 'object') continue
+ const obj = v as Record
+ out.push({
+ propertyPath: String(obj.propertyPath ?? ''),
+ message: String(obj.message ?? ''),
+ })
+ }
+ return out
+}
+
+/**
+ * Extrait un message d'erreur lisible depuis un payload Hydra / JSON
+ * d'erreur API Platform. Essaie les champs courants dans l'ordre :
+ * `hydra:description` → `detail` → `description` → `message` → `error` →
+ * `title` → `hydra:title`. Renvoie '' si rien d'exploitable.
+ *
+ * Si `data` est une string, la renvoie telle quelle (cas des erreurs
+ * Symfony en text/plain ou des messages bruts).
+ */
+export function extractApiErrorMessage(data: unknown): string {
+ if (typeof data === 'string') return data
+ if (!data || typeof data !== 'object') return ''
+ const record = data as Record
+ return (
+ (record['hydra:description'] as string)
+ ?? (record.detail as string)
+ ?? (record.description as string)
+ ?? (record.message as string)
+ ?? (record.error as string)
+ ?? (record.title as string)
+ ?? (record['hydra:title'] as string)
+ ?? ''
+ )
+}