feat(admin): création de catégorie via dashboard
Nouvelle page /admin/categories/new, jumeau de l'écran d'édition :
- Form complet : name, slug (auto), description, color_hex, icon,
dimension, sort_order
- Preview live identique à l'éditeur (top border + folder tinté + badge)
- Slug auto-généré depuis le nom (normalisation NFD + lowercase + dashes)
tant que l'utilisateur n'a pas touché manuellement au champ. Flag
slugDirty mis à true sur premier @input du slug -> on arrête l'auto.
- Validation color_hex /^#RRGGBB$/ côté client + normalisation upper-case
- Body construit en omettant les champs vides -> l'API applique ses
défauts (color_hex='#888888', dimension='transversal', sort_order=0)
- Sur 201 : redirige vers /admin/categories/:slug/edit pour que l'admin
voie tout de suite sa nouvelle catégorie et puisse ajuster
- 422/409/401 gérés comme sur les autres écrans admin
UI
- AdminCategoriesPage : CTA "Nouvelle catégorie" en haut à droite
- Router : route nommée admin-category-new avec guard admin
Backend
- Aucune nouvelle route : POST /api/categories existait déjà
- Le contrôleur valide name (required, max 128), dimension enum, color
hex format, slug auto-unique
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -60,10 +60,22 @@ async function destroy(c: Category) {
|
|||||||
</header>
|
</header>
|
||||||
<AdminSubNav />
|
<AdminSubNav />
|
||||||
|
|
||||||
<LoadingState v-if="loading" class="mt-8" />
|
<div class="mt-6 flex items-center justify-end">
|
||||||
<ErrorState v-else-if="error" :message="error" :retry="load" class="mt-8" />
|
<RouterLink
|
||||||
|
:to="{ name: 'admin-category-new' }"
|
||||||
|
class="flex items-center gap-1.5 bg-donut-text text-white rounded-lg px-3.5 py-2 text-sm font-medium hover:bg-black transition-colors"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||||
|
<path d="M12 5v14M5 12h14" />
|
||||||
|
</svg>
|
||||||
|
Nouvelle catégorie
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else class="mt-6 rounded-xl border border-donut-border bg-donut-surface overflow-hidden">
|
<LoadingState v-if="loading" class="mt-4" />
|
||||||
|
<ErrorState v-else-if="error" :message="error" :retry="load" class="mt-4" />
|
||||||
|
|
||||||
|
<div v-else class="mt-4 rounded-xl border border-donut-border bg-donut-surface overflow-hidden">
|
||||||
<table class="w-full text-sm">
|
<table class="w-full text-sm">
|
||||||
<thead class="bg-gray-50 border-b border-donut-border text-xs uppercase tracking-wider text-donut-muted">
|
<thead class="bg-gray-50 border-b border-donut-border text-xs uppercase tracking-wider text-donut-muted">
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { RouterLink, useRouter } from 'vue-router'
|
||||||
|
import { api, ApiError } from '@/api/client'
|
||||||
|
import { useAuth } from '@/composables/useAuth'
|
||||||
|
import type { Category, Dimension } from '@/types/api'
|
||||||
|
import AdminSubNav from '@/components/AdminSubNav.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
name: '',
|
||||||
|
slug: '',
|
||||||
|
description: '',
|
||||||
|
color_hex: '#3B82F6',
|
||||||
|
icon: '',
|
||||||
|
dimension: 'transversal' as Dimension,
|
||||||
|
sort_order: 100,
|
||||||
|
})
|
||||||
|
const submitting = ref(false)
|
||||||
|
const fieldErrors = ref<Record<string, string>>({})
|
||||||
|
const generalError = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Slug auto-suggéré depuis le nom tant que l'utilisateur n'a pas touché au champ slug
|
||||||
|
// manuellement. Évite d'avoir à le saisir mais on garde la main si besoin.
|
||||||
|
const slugDirty = ref(false)
|
||||||
|
function slugify(s: string): string {
|
||||||
|
return s
|
||||||
|
.normalize('NFD').replace(/[̀-ͯ]/g, '') // strip diacritics
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 64)
|
||||||
|
}
|
||||||
|
watch(() => form.value.name, (n) => {
|
||||||
|
if (!slugDirty.value) form.value.slug = slugify(n)
|
||||||
|
})
|
||||||
|
|
||||||
|
const colorValid = computed(() => /^#[0-9A-Fa-f]{6}$/.test(form.value.color_hex))
|
||||||
|
|
||||||
|
const DIMENSION_OPTIONS: Array<{ value: Dimension; label: string }> = [
|
||||||
|
{ value: 'social', label: 'Plancher social' },
|
||||||
|
{ value: 'ecologique', label: 'Plafond écologique' },
|
||||||
|
{ value: 'transversal', label: 'Transversal' },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
fieldErrors.value = {}
|
||||||
|
generalError.value = null
|
||||||
|
if (!isAuthenticated.value) {
|
||||||
|
generalError.value = "Tu dois être connecté en admin."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.value.name.trim()) {
|
||||||
|
fieldErrors.value.name = 'required'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!colorValid.value) {
|
||||||
|
fieldErrors.value.color_hex = 'invalid_color_hex'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
// On envoie uniquement les champs renseignés. L'API a ses propres défauts
|
||||||
|
// (color_hex='#888888', dimension='transversal', sort_order=0) si on omet.
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
name: form.value.name.trim(),
|
||||||
|
color_hex: form.value.color_hex.toUpperCase(),
|
||||||
|
dimension: form.value.dimension,
|
||||||
|
sort_order: form.value.sort_order,
|
||||||
|
}
|
||||||
|
if (form.value.slug.trim()) body.slug = form.value.slug.trim()
|
||||||
|
if (form.value.description.trim()) body.description = form.value.description.trim()
|
||||||
|
if (form.value.icon.trim()) body.icon = form.value.icon.trim()
|
||||||
|
|
||||||
|
const created = await api.post<Category>('/api/categories', body)
|
||||||
|
// Redirige vers l'écran d'édition de la nouvelle catégorie — l'admin voit
|
||||||
|
// tout de suite la preview en haut et peut ajuster la couleur/description.
|
||||||
|
router.push({ name: 'admin-category-edit', params: { slug: created.slug } })
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) {
|
||||||
|
if (e.status === 422) fieldErrors.value = e.fields
|
||||||
|
else if (e.status === 401) generalError.value = 'Session admin expirée. Reconnecte-toi.'
|
||||||
|
else if (e.status === 409) generalError.value = e.payload.message ?? 'Conflit (slug déjà pris ?)'
|
||||||
|
else generalError.value = e.payload.message ?? e.code
|
||||||
|
} else {
|
||||||
|
generalError.value = 'Erreur réseau'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="px-10 py-8 max-w-4xl">
|
||||||
|
<header class="mb-6">
|
||||||
|
<h1 class="text-3xl font-semibold text-donut-text">Administration</h1>
|
||||||
|
<p class="text-donut-muted mt-1">Création d'une nouvelle catégorie thématique.</p>
|
||||||
|
</header>
|
||||||
|
<AdminSubNav />
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-4">
|
||||||
|
<RouterLink :to="{ name: 'admin-categories' }" class="inline-flex items-center gap-1.5 text-sm text-donut-muted hover:text-donut-text">
|
||||||
|
<svg viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="m12 19-7-7 7-7M5 12h14"/>
|
||||||
|
</svg>
|
||||||
|
Retour aux catégories
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<!-- Preview live identique à l'écran d'édition -->
|
||||||
|
<div
|
||||||
|
class="rounded-xl border border-donut-border bg-donut-surface overflow-hidden"
|
||||||
|
:style="{ borderTopWidth: '4px', borderTopStyle: 'solid', borderTopColor: colorValid ? form.color_hex : '#D1D5DB' }"
|
||||||
|
>
|
||||||
|
<div class="p-5 flex items-start gap-4">
|
||||||
|
<div
|
||||||
|
class="w-11 h-11 rounded-lg flex items-center justify-center shrink-0"
|
||||||
|
:style="{ background: (colorValid ? form.color_hex : '#888888') + '1A', color: colorValid ? form.color_hex : '#6B7280' }"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3 class="text-lg font-semibold text-donut-text">{{ form.name || '(sans nom)' }}</h3>
|
||||||
|
<p v-if="form.description" class="text-sm text-donut-muted mt-1">{{ form.description }}</p>
|
||||||
|
<div class="mt-3 flex items-center justify-between gap-2">
|
||||||
|
<span class="inline-flex items-center rounded-full bg-orange-50 text-status-active px-2.5 py-1 text-xs font-medium">
|
||||||
|
0 projet
|
||||||
|
</span>
|
||||||
|
<span class="text-[10px] text-donut-muted uppercase tracking-wider">
|
||||||
|
{{ DIMENSION_OPTIONS.find(d => d.value === form.dimension)?.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="submit" class="rounded-xl border border-donut-border bg-donut-surface p-6 space-y-5">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-donut-text mb-1">Nom *</label>
|
||||||
|
<input
|
||||||
|
v-model="form.name" type="text" maxlength="128" autofocus
|
||||||
|
placeholder="Ex: Mobilité douce"
|
||||||
|
class="w-full bg-white border border-donut-border rounded-lg px-3 py-2.5 text-sm outline-none focus:border-donut-text"
|
||||||
|
:class="fieldErrors.name ? 'border-red-400' : ''"
|
||||||
|
/>
|
||||||
|
<p v-if="fieldErrors.name" class="text-xs text-red-600 mt-1">Nom requis.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-[1fr,140px] gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-donut-text mb-1">Slug</label>
|
||||||
|
<input
|
||||||
|
v-model="form.slug" type="text" maxlength="64"
|
||||||
|
placeholder="auto-généré depuis le nom"
|
||||||
|
class="w-full bg-white border border-donut-border rounded-lg px-3 py-2.5 text-sm font-mono outline-none focus:border-donut-text"
|
||||||
|
:class="fieldErrors.slug ? 'border-red-400' : ''"
|
||||||
|
@input="slugDirty = true"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-donut-muted mt-1">Identifiant URL (auto si vide).</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-donut-text mb-1">Ordre</label>
|
||||||
|
<input
|
||||||
|
v-model.number="form.sort_order" type="number" min="0"
|
||||||
|
class="w-full bg-white border border-donut-border rounded-lg px-3 py-2.5 text-sm outline-none focus:border-donut-text"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-donut-muted mt-1">Tri (asc).</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-donut-text mb-1">Description</label>
|
||||||
|
<textarea
|
||||||
|
v-model="form.description" rows="3" maxlength="5000"
|
||||||
|
placeholder="À quoi ressemblent les projets de cette catégorie ?"
|
||||||
|
class="w-full bg-white border border-donut-border rounded-lg px-3 py-2.5 text-sm outline-none focus:border-donut-text resize-y"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-donut-text mb-1">Couleur</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="form.color_hex" type="color"
|
||||||
|
class="h-10 w-12 rounded border border-donut-border cursor-pointer"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="form.color_hex" type="text" maxlength="7" placeholder="#RRGGBB"
|
||||||
|
class="flex-1 bg-white border border-donut-border rounded-lg px-3 py-2.5 text-sm font-mono uppercase outline-none focus:border-donut-text"
|
||||||
|
:class="fieldErrors.color_hex || !colorValid ? 'border-red-400' : ''"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="!colorValid" class="text-xs text-red-600 mt-1">Format attendu : #RRGGBB</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-donut-text mb-1">Dimension Donut</label>
|
||||||
|
<select
|
||||||
|
v-model="form.dimension"
|
||||||
|
class="w-full bg-white border border-donut-border rounded-lg px-3 py-2.5 text-sm outline-none focus:border-donut-text"
|
||||||
|
>
|
||||||
|
<option v-for="d in DIMENSION_OPTIONS" :key="d.value" :value="d.value">{{ d.label }}</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-donut-muted mt-1">
|
||||||
|
Détermine la bande de positionnement des projets dans la viz Donut.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-donut-text mb-1">Icône (nom)</label>
|
||||||
|
<input
|
||||||
|
v-model="form.icon" type="text" maxlength="32"
|
||||||
|
placeholder="database / users / leaf / map / graduation-cap…"
|
||||||
|
class="w-full bg-white border border-donut-border rounded-lg px-3 py-2.5 text-sm font-mono outline-none focus:border-donut-text"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-donut-muted mt-1">
|
||||||
|
Nom d'icône (style Lucide). Champ libre — le rendu visuel sera ajouté ultérieurement.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="generalError" class="rounded-lg border border-red-200 bg-red-50 text-red-700 px-3 py-2 text-sm">
|
||||||
|
{{ generalError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end gap-2 pt-2 border-t border-donut-border">
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'admin-categories' }"
|
||||||
|
class="px-4 py-2 rounded-lg text-sm font-medium border border-donut-border hover:bg-gray-50"
|
||||||
|
>Annuler</RouterLink>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="submitting"
|
||||||
|
class="px-4 py-2 rounded-lg bg-donut-text text-white text-sm font-medium hover:bg-black disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ submitting ? 'Création…' : 'Créer la catégorie' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -40,6 +40,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{ path: 'admin/members', name: 'admin-members', component: () => import('@/pages/admin/AdminMembersPage.vue'), beforeEnter: requireAdmin },
|
{ path: 'admin/members', name: 'admin-members', component: () => import('@/pages/admin/AdminMembersPage.vue'), beforeEnter: requireAdmin },
|
||||||
{ path: 'admin/members/:slug/edit', name: 'admin-member-edit', component: () => import('@/pages/admin/AdminMemberEditPage.vue'), props: true, beforeEnter: requireAdmin },
|
{ path: 'admin/members/:slug/edit', name: 'admin-member-edit', component: () => import('@/pages/admin/AdminMemberEditPage.vue'), props: true, beforeEnter: requireAdmin },
|
||||||
{ path: 'admin/categories', name: 'admin-categories', component: () => import('@/pages/admin/AdminCategoriesPage.vue'), beforeEnter: requireAdmin },
|
{ path: 'admin/categories', name: 'admin-categories', component: () => import('@/pages/admin/AdminCategoriesPage.vue'), beforeEnter: requireAdmin },
|
||||||
|
{ path: 'admin/categories/new', name: 'admin-category-new', component: () => import('@/pages/admin/AdminCategoryNewPage.vue'), beforeEnter: requireAdmin },
|
||||||
{ path: 'admin/categories/:slug/edit', name: 'admin-category-edit', component: () => import('@/pages/admin/AdminCategoryEditPage.vue'), props: true, beforeEnter: requireAdmin },
|
{ path: 'admin/categories/:slug/edit', name: 'admin-category-edit', component: () => import('@/pages/admin/AdminCategoryEditPage.vue'), props: true, beforeEnter: requireAdmin },
|
||||||
|
|
||||||
{ path: 'admin/login', name: 'admin-login', component: () => import('@/pages/AdminLoginPage.vue'), meta: { hideForAuthed: true } },
|
{ path: 'admin/login', name: 'admin-login', component: () => import('@/pages/AdminLoginPage.vue'), meta: { hideForAuthed: true } },
|
||||||
|
|||||||
Reference in New Issue
Block a user