[ERP-49] Créer la page Gestion des catégories (datatable + drawer) (#22)
Auto Tag Develop / tag (push) Successful in 7s
Auto Tag Develop / tag (push) Successful in 7s
## Contexte Ticket Lesstime : [#49](https://lesstime.malio.fr/tasks/460) — premier ticket front du M0 (Gestion des catégories). Suit la chaîne back ERP-43..48 mergée sur develop. ## Contenu first draft (Claude Code) - Page Nuxt `/admin/categories` (`MalioDataTable` + bouton `+ Ajouter`) - Composant `<CategoryDrawer>` : modes création / consultation / édition, transition auto view → edit à la première modification, validation client miroir RG-1.02 (name requis) / RG-1.04 (longueur 2-120) / RG-1.05 (type requis), mapping erreurs 409 (doublon) et 422 (violations) - Composant `<CategoryDeleteModal>` : confirmation suppression (soft delete RG-1.12) - Types TS `Category`, `CategoryType`, `User` - i18n `admin.categories.*` ajouté dans `fr.json` - Fix latent en passant : ajout de `'categories'` à `AdminLinkSlug` du Page Object e2e (oublié lors d'ERP-47 quand l'item sidebar a été ajouté) ## Décisions marquantes - Logique `fetch` inline dans `categories.vue` (sera extraite en composables `useCategoriesAdmin` + `useCategoryForm` au ticket ERP-50 / 0.8) - Drawer dans composant séparé pour réutilisabilité - Aucun état de tableau persisté dans l'URL (règle ABSOLUE n°6) - Tous les composants formulaires sont `Malio*` (`MalioDataTable`, `MalioInputText`, `MalioSelect`, `MalioButton`, `MalioDrawer`) ## Polish à venir (Tristan) Tristan testera en navigateur et peaufinera : UX, classes Tailwind, animations, icônes, wording de toasts. Les commits de polish suivront sur la même branche. ## Tests - `npx nuxi typecheck` : net 0 nouvelle erreur (mêmes erreurs pré-existantes que sur `develop`, infrastructure auto-import) + 1 latente corrigée (AdminLinkSlug) - `make nuxt-test` : 43/43 passent (0 régression) - Tests manuels navigateur : voir cahier de test du ticket Lesstime #49 ## Note pre-commit hook Le hook a remonté un échec PHPUnit pré-existant sur `develop` (`CategoryDeleteTest::testPatchOnSoftDeletedReturns404` → 401 au lieu de 404, JWT non initialisé en test runner). Aucun PHP touché dans cette MR. Commit avec `--no-verify` autorisé par Tristan. ## Reviewer suggéré Matthieu (back ↔ front + permissions). --------- Co-authored-by: Matthieu <mtholot19@gmail.com> Reviewed-on: #22 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #22.
This commit is contained in:
@@ -73,12 +73,20 @@ jobs:
|
|||||||
run: vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --allow-risky=yes --dry-run --diff
|
run: vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --allow-risky=yes --dry-run --diff
|
||||||
|
|
||||||
- name: Bootstrap test database
|
- name: Bootstrap test database
|
||||||
|
# Aligne sur la cible `test-db-setup` du makefile : apres
|
||||||
|
# `schema:update --force`, on RECREE manuellement l'index unique
|
||||||
|
# partiel `uq_category_name_type_active` car Doctrine ORM ne sait
|
||||||
|
# pas exprimer les index fonctionnels partiels (LOWER(name) + WHERE
|
||||||
|
# deleted_at IS NULL) et `schema:update` les considere comme
|
||||||
|
# orphelins et les DROP — collisions non detectees, tests d'unicite
|
||||||
|
# qui attendent 409 recoivent 201.
|
||||||
run: |
|
run: |
|
||||||
php bin/console doctrine:database:create --env=test --if-not-exists --no-interaction
|
php bin/console doctrine:database:create --env=test --if-not-exists --no-interaction
|
||||||
php bin/console doctrine:migrations:migrate --env=test --no-interaction
|
php bin/console doctrine:migrations:migrate --env=test --no-interaction
|
||||||
php bin/console doctrine:schema:update --env=test --force --no-interaction
|
php bin/console doctrine:schema:update --env=test --force --no-interaction
|
||||||
php bin/console doctrine:fixtures:load --env=test --no-interaction
|
php bin/console doctrine:fixtures:load --env=test --no-interaction
|
||||||
php bin/console app:sync-permissions --env=test --no-interaction
|
php bin/console app:sync-permissions --env=test --no-interaction
|
||||||
|
php bin/console --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_category_name_type_active ON category (LOWER(name), category_type_id) WHERE deleted_at IS NULL"
|
||||||
|
|
||||||
- name: Run PHPUnit
|
- name: Run PHPUnit
|
||||||
run: php -d memory_limit=512M vendor/bin/phpunit
|
run: php -d memory_limit=512M vendor/bin/phpunit
|
||||||
|
|||||||
@@ -230,6 +230,39 @@
|
|||||||
"updated": "Site mis à jour avec succès",
|
"updated": "Site mis à jour avec succès",
|
||||||
"deleted": "Site supprimé avec succès"
|
"deleted": "Site supprimé avec succès"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"title": "Gestion des catégories",
|
||||||
|
"newCategory": "Ajouter",
|
||||||
|
"editCategory": "Modifier la catégorie",
|
||||||
|
"createCategory": "Créer une catégorie",
|
||||||
|
"viewCategory": "Détail de la catégorie",
|
||||||
|
"noCategories": "Aucune catégorie pour l'instant.",
|
||||||
|
"table": {
|
||||||
|
"name": "Nom",
|
||||||
|
"type": "Type"
|
||||||
|
},
|
||||||
|
"form": {
|
||||||
|
"name": "Nom",
|
||||||
|
"type": "Type de catégorie",
|
||||||
|
"typePlaceholder": "Sélectionner un type"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"nameRequired": "Le nom est obligatoire.",
|
||||||
|
"nameLength": "Le nom doit faire entre 2 et 120 caractères.",
|
||||||
|
"typeRequired": "Le type de catégorie est obligatoire."
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"title": "Supprimer la catégorie",
|
||||||
|
"message": "Êtes-vous sûr de vouloir supprimer la catégorie \"{name}\" ? Cette action est irréversible."
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"created": "Catégorie créée avec succès",
|
||||||
|
"updated": "Catégorie mise à jour avec succès",
|
||||||
|
"deleted": "Catégorie supprimée avec succès",
|
||||||
|
"duplicate": "Une catégorie nommée « {name} » existe déjà pour ce type.",
|
||||||
|
"typesLoadFailed": "Impossible de charger les types de catégorie. Réessayez."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<template>
|
||||||
|
<MalioModal
|
||||||
|
:model-value="modelValue"
|
||||||
|
modal-class="max-w-md"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<h3 class="text-lg font-semibold text-neutral-900">
|
||||||
|
{{ t('admin.categories.delete.title') }}
|
||||||
|
</h3>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p class="text-sm text-neutral-600">
|
||||||
|
{{ t('admin.categories.delete.message', { name: categoryName }) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
:label="t('common.cancel')"
|
||||||
|
variant="secondary"
|
||||||
|
@click="emit('update:modelValue', false)"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
:label="t('common.delete')"
|
||||||
|
variant="danger"
|
||||||
|
icon-name="mdi:delete-outline"
|
||||||
|
icon-position="left"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="emit('confirm')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MalioModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
categoryName: string
|
||||||
|
loading: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
confirm: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
<template>
|
||||||
|
<MalioDrawer
|
||||||
|
:model-value="modelValue"
|
||||||
|
drawer-class="w-full max-w-lg"
|
||||||
|
header-class="border-b border-black"
|
||||||
|
footer-class="justify-between border-t border-black p-6"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-2xl font-bold">
|
||||||
|
{{ headerLabel }}
|
||||||
|
</h2>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<form class="flex flex-col gap-4 py-4" @submit.prevent="handleSave">
|
||||||
|
<!-- Nom (RG-1.02 obligatoire / RG-1.04 longueur 2-120 apres trim).
|
||||||
|
Erreur miroir client + erreurs server-side (422) mappees sur ce champ. -->
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.name"
|
||||||
|
:label="t('admin.categories.form.name')"
|
||||||
|
input-class="w-full"
|
||||||
|
:max-length="120"
|
||||||
|
:error="errors.name"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Type (RG-1.05 obligatoire). MalioSelect porte la valeur en
|
||||||
|
number (categoryType id) ; conversion en IRI au moment du save. -->
|
||||||
|
<MalioSelect
|
||||||
|
v-model="form.categoryTypeId"
|
||||||
|
:options="categoryTypeOptions"
|
||||||
|
:label="t('admin.categories.form.type')"
|
||||||
|
:empty-option-label="t('admin.categories.form.typePlaceholder')"
|
||||||
|
:error="errors.categoryType"
|
||||||
|
:disabled="loadingTypes"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Erreur transverse (typiquement reseau / 5xx) — separe des
|
||||||
|
erreurs de validation par champ. -->
|
||||||
|
<p v-if="errors._global" class="text-sm text-red-600">
|
||||||
|
{{ errors._global }}
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Footer fixe : depuis 1.7.1 le slot #footer est un frere du body
|
||||||
|
scrollable (shrink-0), donc reellement fige sans sticky. -->
|
||||||
|
<template #footer>
|
||||||
|
<MalioButton
|
||||||
|
v-if="canShowDelete"
|
||||||
|
:label="t('common.delete')"
|
||||||
|
variant="danger"
|
||||||
|
icon-name="mdi:delete-outline"
|
||||||
|
icon-position="left"
|
||||||
|
button-class="w-[150px]"
|
||||||
|
@click="emit('delete')"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
v-else
|
||||||
|
:label="t('common.cancel')"
|
||||||
|
variant="tertiary"
|
||||||
|
button-class="w-[150px]"
|
||||||
|
@click="emit('update:modelValue', false)"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
v-if="canShowSave"
|
||||||
|
:label="t('common.save')"
|
||||||
|
variant="primary"
|
||||||
|
button-class="w-[150px]"
|
||||||
|
:disabled="saving || loadingTypes"
|
||||||
|
@click="handleSave"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</MalioDrawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Category, CategoryType } from '~/modules/catalog/types/category'
|
||||||
|
import type { HydraCollection } from '~/shared/utils/api'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const api = useApi()
|
||||||
|
const { can } = usePermissions()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
category: Category | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
saved: []
|
||||||
|
delete: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mode du drawer :
|
||||||
|
* - 'create' : pas de category prop, formulaire vide, POST au save.
|
||||||
|
* - 'view' : category prop set, formulaire pre-rempli, save MASQUE
|
||||||
|
* jusqu'a ce que l'utilisateur modifie un champ.
|
||||||
|
* - 'edit' : category prop set et formulaire « dirty » (au moins un
|
||||||
|
* champ different de l'original), PATCH au save.
|
||||||
|
*
|
||||||
|
* La bascule view → edit est automatique des qu'un champ change (cf. watch
|
||||||
|
* sur form). Le label du header suit le mode courant.
|
||||||
|
*/
|
||||||
|
type DrawerMode = 'create' | 'view' | 'edit'
|
||||||
|
|
||||||
|
const saving = ref(false)
|
||||||
|
const loadingTypes = ref(false)
|
||||||
|
const categoryTypes = ref<CategoryType[]>([])
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
name: '',
|
||||||
|
categoryTypeId: null as number | null,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Snapshot des valeurs initiales pour detecter le « dirty » (view → edit).
|
||||||
|
const initial = ref({
|
||||||
|
name: '',
|
||||||
|
categoryTypeId: null as number | null,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Erreurs par champ + erreur transverse globale. Pattern propre pour mapper
|
||||||
|
// les violations 422 sur les MalioInputText / MalioSelect.
|
||||||
|
const errors = ref<{
|
||||||
|
name: string
|
||||||
|
categoryType: string
|
||||||
|
_global: string
|
||||||
|
}>({
|
||||||
|
name: '',
|
||||||
|
categoryType: '',
|
||||||
|
_global: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const isCreateMode = computed(() => props.category === null)
|
||||||
|
|
||||||
|
const isDirty = computed(
|
||||||
|
() =>
|
||||||
|
form.value.name !== initial.value.name
|
||||||
|
|| form.value.categoryTypeId !== initial.value.categoryTypeId,
|
||||||
|
)
|
||||||
|
|
||||||
|
const mode = computed<DrawerMode>(() => {
|
||||||
|
if (isCreateMode.value) return 'create'
|
||||||
|
return isDirty.value ? 'edit' : 'view'
|
||||||
|
})
|
||||||
|
|
||||||
|
const headerLabel = computed(() => {
|
||||||
|
if (mode.value === 'create') return t('admin.categories.createCategory')
|
||||||
|
if (mode.value === 'edit') return t('admin.categories.editCategory')
|
||||||
|
return t('admin.categories.viewCategory')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Le bouton Supprimer n'est visible qu'en consultation/edition d'une categorie
|
||||||
|
// existante et seulement pour les users ayant la permission manage. En mode
|
||||||
|
// creation on affiche un bouton Annuler a la place.
|
||||||
|
const canShowDelete = computed(
|
||||||
|
() => !isCreateMode.value && can('catalog.categories.manage'),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Save : visible en creation, ou en edition (apres modification d'un champ).
|
||||||
|
// Masque en view tant que rien n'a change.
|
||||||
|
const canShowSave = computed(
|
||||||
|
() => mode.value === 'create' || mode.value === 'edit',
|
||||||
|
)
|
||||||
|
|
||||||
|
const categoryTypeOptions = computed(() =>
|
||||||
|
categoryTypes.value.map(ct => ({
|
||||||
|
label: ct.label,
|
||||||
|
value: ct.id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charge le referentiel CategoryType. Appele a chaque ouverture du drawer
|
||||||
|
* (pas seulement au mount) pour rester a jour si un type est ajoute en
|
||||||
|
* arriere-plan. Volontairement sans toast en cas d'echec : on affiche un
|
||||||
|
* message inline via `errors._global` pour ne pas spammer.
|
||||||
|
*/
|
||||||
|
async function loadCategoryTypes(): Promise<void> {
|
||||||
|
loadingTypes.value = true
|
||||||
|
try {
|
||||||
|
const data = await api.get<HydraCollection<CategoryType>>(
|
||||||
|
'/category_types',
|
||||||
|
{ itemsPerPage: 999 },
|
||||||
|
{ toast: false },
|
||||||
|
)
|
||||||
|
categoryTypes.value = data.member ?? []
|
||||||
|
} catch {
|
||||||
|
categoryTypes.value = []
|
||||||
|
errors.value._global = t('admin.categories.toast.typesLoadFailed')
|
||||||
|
} finally {
|
||||||
|
loadingTypes.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-initialise le formulaire a partir de la prop `category`. Aussi appele
|
||||||
|
* a l'ouverture du drawer pour repartir d'un etat propre.
|
||||||
|
*/
|
||||||
|
function resetForm(): void {
|
||||||
|
errors.value = { name: '', categoryType: '', _global: '' }
|
||||||
|
if (props.category) {
|
||||||
|
form.value.name = props.category.name
|
||||||
|
form.value.categoryTypeId = props.category.categoryType.id
|
||||||
|
initial.value.name = props.category.name
|
||||||
|
initial.value.categoryTypeId = props.category.categoryType.id
|
||||||
|
} else {
|
||||||
|
form.value.name = ''
|
||||||
|
form.value.categoryTypeId = null
|
||||||
|
initial.value.name = ''
|
||||||
|
initial.value.categoryTypeId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-initialiser quand la categorie selectionnee change (clic sur une autre
|
||||||
|
// ligne sans fermer le drawer entre-temps).
|
||||||
|
watch(() => props.category, resetForm, { immediate: true })
|
||||||
|
|
||||||
|
// A chaque ouverture du drawer : reset + chargement frais des types. Pas
|
||||||
|
// d'optimisation cache au M0 — le referentiel est petit et statique.
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(open) => {
|
||||||
|
if (open) {
|
||||||
|
resetForm()
|
||||||
|
loadCategoryTypes()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation client-side miroir des RG back. Renvoie true si tout passe et
|
||||||
|
* peuple `errors` sinon. Le serveur valide aussi (defense en profondeur) ;
|
||||||
|
* la validation client sert juste a eviter l'aller-retour evitable.
|
||||||
|
*/
|
||||||
|
function validate(): boolean {
|
||||||
|
errors.value = { name: '', categoryType: '', _global: '' }
|
||||||
|
const trimmedName = form.value.name.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 (form.value.categoryTypeId === null) {
|
||||||
|
errors.value.categoryType = t('admin.categories.validation.typeRequired')
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.value.name === '' && errors.value.categoryType === ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mappe une reponse 422 d'API Platform sur le state `errors`. API Platform 4
|
||||||
|
* retourne soit `violations: [{ propertyPath, message }]` soit
|
||||||
|
* `hydra:violations` selon la negociation de format.
|
||||||
|
*/
|
||||||
|
function mapServerViolations(data: unknown): boolean {
|
||||||
|
if (!data || typeof data !== 'object') return false
|
||||||
|
const record = data as Record<string, unknown>
|
||||||
|
const rawViolations = record.violations ?? record['hydra:violations']
|
||||||
|
if (!Array.isArray(rawViolations)) return false
|
||||||
|
|
||||||
|
let mapped = false
|
||||||
|
for (const v of rawViolations) {
|
||||||
|
if (!v || typeof v !== 'object') continue
|
||||||
|
const violation = v as Record<string, unknown>
|
||||||
|
const path = String(violation.propertyPath ?? '')
|
||||||
|
const message = String(violation.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 HTTP au format API Platform / Hydra.
|
||||||
|
*/
|
||||||
|
function extractErrorMessage(data: unknown): string {
|
||||||
|
if (!data || typeof data !== 'object') return ''
|
||||||
|
const record = data as Record<string, unknown>
|
||||||
|
return (
|
||||||
|
(record['hydra:description'] as string)
|
||||||
|
?? (record.detail as string)
|
||||||
|
?? (record.description as string)
|
||||||
|
?? ''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sauvegarde la categorie (POST en mode create, PATCH en mode edit).
|
||||||
|
* Trim cote client (miroir RG-1.03), conversion ID → IRI pour categoryType,
|
||||||
|
* mapping des erreurs server.
|
||||||
|
*/
|
||||||
|
async function handleSave(): Promise<void> {
|
||||||
|
if (!validate()) return
|
||||||
|
saving.value = true
|
||||||
|
errors.value._global = ''
|
||||||
|
|
||||||
|
// Trim cote client (miroir RG-1.03). Le serveur retrim de toute facon.
|
||||||
|
const payload = {
|
||||||
|
name: form.value.name.trim(),
|
||||||
|
categoryType: `/api/category_types/${form.value.categoryTypeId}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (mode.value === 'create') {
|
||||||
|
await api.post('/categories', payload, {
|
||||||
|
toastSuccessMessage: t('admin.categories.toast.created'),
|
||||||
|
toast: false, // gestion fine des erreurs ci-dessous
|
||||||
|
})
|
||||||
|
} else if (mode.value === 'edit' && props.category) {
|
||||||
|
await api.patch(`/categories/${props.category.id}`, payload, {
|
||||||
|
toastSuccessMessage: t('admin.categories.toast.updated'),
|
||||||
|
toast: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Succes : toast manuel (car on a desactive le toast du composable
|
||||||
|
// pour gerer finement les erreurs) + propagation au parent.
|
||||||
|
useToast().success({
|
||||||
|
title: 'Succès',
|
||||||
|
message:
|
||||||
|
mode.value === 'create'
|
||||||
|
? t('admin.categories.toast.created')
|
||||||
|
: t('admin.categories.toast.updated'),
|
||||||
|
})
|
||||||
|
emit('saved')
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { status?: number, _data?: unknown } }
|
||||||
|
const status = error?.response?.status
|
||||||
|
const data = error?.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: payload.name,
|
||||||
|
})
|
||||||
|
errors.value.name = duplicateMessage
|
||||||
|
useToast().error({
|
||||||
|
title: 'Erreur',
|
||||||
|
message: duplicateMessage,
|
||||||
|
})
|
||||||
|
} else if (status === 422 && mapServerViolations(data)) {
|
||||||
|
// Violations mappees sur les champs concernes — pas de toast,
|
||||||
|
// l'utilisateur voit l'erreur directement sous le champ.
|
||||||
|
} else {
|
||||||
|
const extracted = extractErrorMessage(data)
|
||||||
|
errors.value._global = extracted || 'Une erreur est survenue.'
|
||||||
|
useToast().error({
|
||||||
|
title: 'Erreur',
|
||||||
|
message: errors.value._global,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export default defineNuxtConfig({})
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<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. Affichage exhaustif (volumetrie cible
|
||||||
|
<= 300, cf. spec § 4.1) — tri 100% serveur via CategoryProvider
|
||||||
|
(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. -->
|
||||||
|
|
||||||
|
<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 (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<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>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Types front du module Catalog (M0 — Gestion des categories).
|
||||||
|
*
|
||||||
|
* Contrats API consommes :
|
||||||
|
* - GET /api/categories → HydraCollection<Category>
|
||||||
|
* - GET /api/categories/{id} → Category
|
||||||
|
* - POST /api/categories → body { name, categoryType: IRI }
|
||||||
|
* - PATCH /api/categories/{id} → body partiel { name?, categoryType?: IRI }
|
||||||
|
* - DELETE /api/categories/{id} → 204 (soft delete via CategoryProcessor)
|
||||||
|
* - GET /api/category_types → HydraCollection<CategoryType>
|
||||||
|
*
|
||||||
|
* Notes :
|
||||||
|
* - Les IRI sont envoyes en POST/PATCH (ex. "/api/category_types/3").
|
||||||
|
* - `categoryType` est embarque (groupe Serializer `category:read` sur les
|
||||||
|
* proprietes de CategoryType, cf. spec-back § 3.4).
|
||||||
|
* - `createdBy` / `updatedBy` peuvent etre `null` (hors contexte HTTP,
|
||||||
|
* ON DELETE SET NULL en BDD). Affichage : libelle "Systeme" si null.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference legere d'un user, telle qu'embarquee dans Category.createdBy /
|
||||||
|
* updatedBy. Volontairement minimaliste : on n'a besoin que de l'identifiant
|
||||||
|
* et de l'username pour l'affichage courant.
|
||||||
|
*/
|
||||||
|
export interface User {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference du referentiel CategoryType (lecture seule au M0).
|
||||||
|
*/
|
||||||
|
export interface CategoryType {
|
||||||
|
id: number
|
||||||
|
code: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categorie metier — telle qu'elle est lue depuis l'API. L'entite porte le
|
||||||
|
* pattern Timestampable+Blamable (cf. spec-back § 2.8).
|
||||||
|
*/
|
||||||
|
export interface Category {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
categoryType: CategoryType
|
||||||
|
/** Soft delete : null = active, valeur = supprimee logiquement le {date}. */
|
||||||
|
deletedAt: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
createdBy: User | null
|
||||||
|
updatedBy: User | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload accepte en POST /api/categories. `categoryType` est envoye en
|
||||||
|
* IRI Hydra (ex. `/api/category_types/3`).
|
||||||
|
*/
|
||||||
|
export interface CategoryCreateInput {
|
||||||
|
name: string
|
||||||
|
categoryType: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payload accepte en PATCH /api/categories/{id}. Tous les champs sont
|
||||||
|
* optionnels (modification partielle).
|
||||||
|
*/
|
||||||
|
export interface CategoryUpdateInput {
|
||||||
|
name?: string
|
||||||
|
categoryType?: string
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Locator, Page } from '@playwright/test'
|
import type { Locator, Page } from '@playwright/test'
|
||||||
|
|
||||||
export type AdminLinkSlug = 'users' | 'roles' | 'sites' | 'audit-log'
|
export type AdminLinkSlug = 'users' | 'roles' | 'sites' | 'categories' | 'audit-log'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Page Object de la sidebar (MalioSidebar), scope sur les items "admin".
|
* Page Object de la sidebar (MalioSidebar), scope sur les items "admin".
|
||||||
|
|||||||
Reference in New Issue
Block a user