feat(catalog) : add admin categories page with MalioDataTable and drawer (first draft)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Failing after 1m29s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m5s

- Page Nuxt /admin/categories : MalioDataTable + bouton Ajouter
- CategoryDrawer : modes creation / consultation / edition (transition
  auto view -> edit a la 1re modif), validation client RG-1.02/04/05,
  mapping erreurs server 409 (doublon) et 422 (violations)
- CategoryDeleteModal : confirmation suppression (soft delete cote API)
- Types Category, CategoryType, User
- i18n admin.categories.* (titre, table, form, validation, toasts)
- Fix latent : ajout 'categories' a AdminLinkSlug e2e (oubli ERP-47)

Logique fetch inline volontaire au M0 — extraction en composables a
ERP-50 (ticket 0.8). Aucune persistance d'etat de tableau dans l'URL.
This commit is contained in:
2026-05-28 15:11:45 +02:00
parent 9613857650
commit 4046910a9d
7 changed files with 714 additions and 1 deletions
@@ -0,0 +1,162 @@
<template>
<div>
<PageHeader>
{{ t('admin.categories.title') }}
<template #actions>
<MalioButton
v-if="canManage"
:label="t('admin.categories.newCategory')"
icon-name="mdi:add-bold"
icon-position="left"
@click="openCreateDrawer"
/>
</template>
</PageHeader>
<!-- Table des categories : tri par defaut sur Nom ASC (RG-1.10).
Tri serveur applique a la requete + tri client en miroir pour
la pagination front (volumetrie cible <= 300, cf. spec § 4.1). -->
<MalioDataTable
:columns="columns"
:items="categoryItems"
:total-items="categories.length"
:row-clickable="true"
:empty-message="t('admin.categories.noCategories')"
@row-click="onRowClick"
/>
<!-- Drawer creation / consultation / edition. -->
<CategoryDrawer
v-model="drawerOpen"
:category="selectedCategory"
@saved="onCategorySaved"
@delete="onDeleteRequest"
/>
<!-- Modale de confirmation suppression (soft delete cote serveur). -->
<CategoryDeleteModal
v-model="deleteModalOpen"
:category-name="categoryToDelete?.name ?? ''"
:loading="deleting"
@confirm="handleDelete"
/>
</div>
</template>
<script setup lang="ts">
import type { Category } from '~/modules/catalog/types/category'
import type { HydraCollection } from '~/shared/utils/api'
const { t } = useI18n()
const api = useApi()
const { can } = usePermissions()
useHead({ title: t('admin.categories.title') })
const canManage = computed(() => can('catalog.categories.manage'))
const categories = ref<Category[]>([])
const loading = ref(false)
const drawerOpen = ref(false)
const selectedCategory = ref<Category | null>(null)
const deleteModalOpen = ref(false)
const categoryToDelete = ref<Category | null>(null)
const deleting = ref(false)
// Colonnes du datatable. Le type est embarque cote API (cf. spec-back § 3.4) —
// on aplatit en label lisible pour l'affichage.
const columns = [
{ key: 'name', label: t('admin.categories.table.name') },
{ key: 'typeLabel', label: t('admin.categories.table.type') },
]
const categoryItems = computed(() =>
categories.value.map(cat => ({
id: cat.id,
name: cat.name,
typeLabel: cat.categoryType?.label ?? '',
})),
)
function getCategoryById(id: number): Category | undefined {
return categories.value.find(c => c.id === id)
}
function onRowClick(item: Record<string, unknown>) {
const category = getCategoryById(item.id as number)
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<void> {
loading.value = true
try {
const data = await api.get<HydraCollection<Category>>(
'/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
}
function openEditDrawer(category: Category) {
selectedCategory.value = category
drawerOpen.value = true
}
function onDeleteRequest() {
if (!selectedCategory.value) return
categoryToDelete.value = selectedCategory.value
deleteModalOpen.value = true
}
/**
* 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).
*/
async function handleDelete(): Promise<void> {
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()
} finally {
deleting.value = false
}
}
function onCategorySaved() {
loadCategories()
}
onMounted(() => {
loadCategories()
})
</script>