feat(admin): édition de catégorie via dashboard
Nouvelle page /admin/categories/:slug/edit, même pattern que members :
- Preview live de la carte catégorie (top border colorée + folder icon
tinté + badge "X projets" + label dimension)
- Form complet : name, slug, description, color_hex, icon, dimension, sort_order
- Color picker natif <input type="color"> + champ HEX texte synchronisés,
validation /^#[0-9A-Fa-f]{6}$/ côté client
- Diff PATCH (champs vides -> null), upper-case normalisé pour color_hex
- Si le slug change -> router.replace vers la nouvelle URL
- Bouton Supprimer en header désactivé si projects_count > 0 (FK RESTRICT)
- 422 -> fieldErrors, 409 -> slug collision, 401 -> session expirée
- projects_count récupéré direct depuis GET /api/categories/:slug
(la réponse contient ce champ pour la route détail, cf. controller PHP)
UI
- AdminCategoriesPage : nouveau lien "Éditer" par ligne du tableau
- Router : route nommée admin-category-edit avec guard admin
Backend
- Aucune nouvelle route : PATCH /api/categories/:slug existait déjà
- Validation côté serveur : in dimension enum, colorHex format,
integer sort_order >= 0, slug auto-unique avec suffixe -2/-3...
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { api, ApiError } from '@/api/client'
|
||||
import type { Category, Stats, Wrapped } from '@/types/api'
|
||||
import AdminSubNav from '@/components/AdminSubNav.vue'
|
||||
@@ -94,13 +95,19 @@ async function destroy(c: Category) {
|
||||
<td class="px-4 py-3 text-right tabular-nums">{{ c.sort_order }}</td>
|
||||
<td class="px-4 py-3 text-right tabular-nums">{{ counts[c.slug] ?? 0 }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button
|
||||
type="button"
|
||||
class="text-red-500 hover:text-red-700 text-xs font-medium disabled:opacity-30"
|
||||
:disabled="(counts[c.slug] ?? 0) > 0"
|
||||
:title="(counts[c.slug] ?? 0) > 0 ? 'Catégorie utilisée par des projets' : 'Supprimer'"
|
||||
@click="destroy(c)"
|
||||
>Supprimer</button>
|
||||
<div class="inline-flex items-center gap-3">
|
||||
<RouterLink
|
||||
:to="{ name: 'admin-category-edit', params: { slug: c.slug } }"
|
||||
class="text-donut-text hover:underline text-xs font-medium"
|
||||
>Éditer</RouterLink>
|
||||
<button
|
||||
type="button"
|
||||
class="text-red-500 hover:text-red-700 text-xs font-medium disabled:opacity-30"
|
||||
:disabled="(counts[c.slug] ?? 0) > 0"
|
||||
:title="(counts[c.slug] ?? 0) > 0 ? 'Catégorie utilisée par des projets' : 'Supprimer'"
|
||||
@click="destroy(c)"
|
||||
>Supprimer</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } 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'
|
||||
import LoadingState from '@/components/LoadingState.vue'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
|
||||
const props = defineProps<{ slug: string }>()
|
||||
const router = useRouter()
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
const category = ref<Category | null>(null)
|
||||
const loading = ref(true)
|
||||
const loadError = ref<string | null>(null)
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
color_hex: '#888888',
|
||||
icon: '',
|
||||
dimension: 'transversal' as Dimension,
|
||||
sort_order: 0,
|
||||
})
|
||||
const submitting = ref(false)
|
||||
const fieldErrors = ref<Record<string, string>>({})
|
||||
const generalError = ref<string | null>(null)
|
||||
const justSaved = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const c = await api.get<Category>(`/api/categories/${encodeURIComponent(props.slug)}`)
|
||||
category.value = c
|
||||
form.value = {
|
||||
name: c.name,
|
||||
slug: c.slug,
|
||||
description: c.description ?? '',
|
||||
color_hex: c.color_hex,
|
||||
icon: c.icon ?? '',
|
||||
dimension: c.dimension,
|
||||
sort_order: c.sort_order,
|
||||
}
|
||||
} catch (e) {
|
||||
loadError.value = e instanceof ApiError ? (e.payload.message ?? e.code) : 'Erreur réseau'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
onMounted(load)
|
||||
|
||||
const slugChanged = computed(() => category.value !== null && form.value.slug !== category.value.slug)
|
||||
|
||||
// `projects_count` est rempli par GET /api/categories/:slug (cf. controller PHP).
|
||||
const projectsCount = computed(() => category.value?.projects_count ?? 0)
|
||||
|
||||
// Validation HEX côté client pour feedback immédiat (le serveur revalide).
|
||||
const colorValid = computed(() => /^#[0-9A-Fa-f]{6}$/.test(form.value.color_hex))
|
||||
|
||||
const DIMENSION_OPTIONS: Array<{ value: Dimension; label: string; color: string }> = [
|
||||
{ value: 'social', label: 'Plancher social', color: '#F97316' },
|
||||
{ value: 'ecologique', label: 'Plafond écologique', color: '#22C55E' },
|
||||
{ value: 'transversal', label: 'Transversal', color: '#6B7280' },
|
||||
]
|
||||
|
||||
async function submit() {
|
||||
fieldErrors.value = {}
|
||||
generalError.value = null
|
||||
justSaved.value = false
|
||||
if (!isAuthenticated.value) {
|
||||
generalError.value = "Tu dois être connecté en admin."
|
||||
return
|
||||
}
|
||||
if (!category.value) return
|
||||
if (!colorValid.value) {
|
||||
fieldErrors.value.color_hex = 'invalid_color_hex'
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const diff: Record<string, unknown> = {}
|
||||
const c = category.value
|
||||
if (form.value.name.trim() !== c.name) diff.name = form.value.name.trim()
|
||||
if (slugChanged.value) diff.slug = form.value.slug.trim()
|
||||
if (form.value.description !== (c.description ?? '')) diff.description = form.value.description || null
|
||||
if (form.value.color_hex.toUpperCase() !== c.color_hex.toUpperCase()) diff.color_hex = form.value.color_hex.toUpperCase()
|
||||
if (form.value.icon !== (c.icon ?? '')) diff.icon = form.value.icon || null
|
||||
if (form.value.dimension !== c.dimension) diff.dimension = form.value.dimension
|
||||
if (form.value.sort_order !== c.sort_order) diff.sort_order = form.value.sort_order
|
||||
|
||||
if (Object.keys(diff).length === 0) {
|
||||
generalError.value = 'Aucune modification à enregistrer.'
|
||||
submitting.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const updated = await api.patch<Category>(`/api/categories/${encodeURIComponent(c.slug)}`, diff)
|
||||
category.value = updated
|
||||
if (updated.slug !== props.slug) {
|
||||
router.replace({ name: 'admin-category-edit', params: { slug: updated.slug } })
|
||||
}
|
||||
justSaved.value = true
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
async function destroy() {
|
||||
if (!category.value) return
|
||||
if (projectsCount.value > 0) {
|
||||
alert(`Impossible : ${projectsCount.value} projet(s) utilise(nt) cette catégorie.\nDéplace-les ou archive-les d'abord.`)
|
||||
return
|
||||
}
|
||||
if (!confirm(`Supprimer définitivement « ${category.value.name} » ?`)) return
|
||||
try {
|
||||
await api.delete(`/api/categories/${encodeURIComponent(category.value.slug)}`)
|
||||
router.push({ name: 'admin-categories' })
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) generalError.value = e.payload.message ?? e.code
|
||||
else generalError.value = 'Erreur réseau'
|
||||
}
|
||||
}
|
||||
</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">Édition d'une catégorie thématique.</p>
|
||||
</header>
|
||||
<AdminSubNav />
|
||||
|
||||
<LoadingState v-if="loading" class="mt-8" />
|
||||
<ErrorState v-else-if="loadError" :message="loadError" :retry="load" class="mt-8" />
|
||||
|
||||
<div v-else-if="category" 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 en haut : carte de catégorie comme rendue dans /categories -->
|
||||
<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">
|
||||
{{ projectsCount }} projet{{ projectsCount > 1 ? 's' : '' }}
|
||||
</span>
|
||||
<span class="text-[10px] text-donut-muted uppercase tracking-wider">
|
||||
{{ DIMENSION_OPTIONS.find(d => d.value === form.dimension)?.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-medium text-red-500 hover:text-red-700 self-start"
|
||||
:disabled="projectsCount > 0"
|
||||
:title="projectsCount > 0 ? 'Catégorie utilisée par des projets' : 'Supprimer'"
|
||||
@click="destroy"
|
||||
>Supprimer</button>
|
||||
</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"
|
||||
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"
|
||||
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' : ''"
|
||||
/>
|
||||
<p class="text-xs text-donut-muted mt-1">Identifiant URL. Casse les filtres existants.</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"
|
||||
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 où les projets de cette catégorie se placent 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 / home / map / leaf / 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 v-if="justSaved" class="rounded-lg border border-green-200 bg-green-50 text-green-700 px-3 py-2 text-sm">
|
||||
Modifications enregistrées.
|
||||
</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 ? 'Enregistrement…' : 'Enregistrer' }}
|
||||
</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/: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/: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: ':pathMatch(.*)*', name: 'not-found', component: () => import('@/pages/NotFoundPage.vue') },
|
||||
|
||||
Reference in New Issue
Block a user