diff --git a/frontend/modules/catalog/components/CategoryDrawer.vue b/frontend/modules/catalog/components/CategoryDrawer.vue
index 87d3788..96f85ae 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..2e9b430
--- /dev/null
+++ b/frontend/modules/catalog/composables/useCategoriesAdmin.ts
@@ -0,0 +1,126 @@
+/**
+ * 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'
+
+// 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: 999 }
+ 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: 999 },
+ { 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..06f94eb
--- /dev/null
+++ b/frontend/modules/catalog/composables/useCategoryForm.ts
@@ -0,0 +1,342 @@
+/**
+ * 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'
+
+/**
+ * Forme des violations renvoyees par API Platform 4 en 422. La cle peut etre
+ * `violations` ou `hydra:violations` selon la negociation de format — on
+ * tente les deux.
+ */
+interface ApiViolation {
+ propertyPath?: string
+ message?: string
+}
+
+/**
+ * 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 une reponse 422 API Platform sur le state `errors`. API Platform 4
+ * retourne `violations: [{ propertyPath, message }]` (ou
+ * `hydra:violations` selon negociation). On ne mappe que les chemins
+ * connus (`name`, `categoryType`) ; le reste fallback en erreur globale.
+ */
+ function mapServerViolations(data: unknown): boolean {
+ if (!data || typeof data !== 'object') return false
+ const record = data as Record
+ const rawViolations = record.violations ?? record['hydra:violations']
+ if (!Array.isArray(rawViolations)) return false
+
+ let mapped = false
+ for (const v of rawViolations as ApiViolation[]) {
+ if (!v || typeof v !== 'object') continue
+ const path = String(v.propertyPath ?? '')
+ const message = String(v.message ?? '')
+ if (path === 'name') {
+ errors.value.name = message
+ mapped = true
+ } else if (path === 'categoryType') {
+ errors.value.categoryType = message
+ mapped = true
+ }
+ }
+ return mapped
+ }
+
+ /**
+ * Extrait un message d'erreur lisible depuis un payload Hydra (champs
+ * `hydra:description`, `detail`, `description`).
+ */
+ function extractErrorMessage(data: unknown): string {
+ 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)
+ ?? ''
+ )
+ }
+
+ /**
+ * Traite une erreur API : mappe selon le status, declenche les toasts
+ * appropries. Centralise la logique entre create/update.
+ *
+ * Retourne true si l'erreur a ete reconnue et traitee, false sinon
+ * (utile pour les tests).
+ */
+ function handleApiError(e: unknown, attemptedName: string): boolean {
+ const status = (e as ApiFetchError)?.response?.status
+ const data = (e as ApiFetchError)?.response?._data
+
+ if (status === 409) {
+ // RG-1.07 — doublon (name, categoryType). Toast custom +
+ // erreur mappee sur le champ name (origine du conflit).
+ 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)) {
+ // Violations mappees sur les champs — pas de toast, l'utilisateur
+ // voit l'erreur directement sous le champ concerne.
+ return true
+ }
+
+ const extracted = extractErrorMessage(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 077d77a..15934ba 100644
--- a/frontend/modules/catalog/pages/admin/categories.vue
+++ b/frontend/modules/catalog/pages/admin/categories.vue
@@ -14,8 +14,8 @@
+ Tri serveur applique a la requete + pagination front via
+ MalioDataTable (volumetrie cible <= 300, cf. spec § 4.1). -->
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)
@@ -87,33 +85,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 (RG : volumetrie ≤ 300, pagination front via MalioDataTable).
- *
- * 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
@@ -131,32 +102,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')
}
})