feat(admin): édition de membre via dashboard
Nouvelle page /admin/members/:slug/edit avec :
- Preview avatar live (initiales/URL mis à jour à la saisie)
- Form complet : display_name, slug, initials, kind, bio, email, website, avatar
- Envoie un diff au PATCH (champs vides -> null, pas chaîne vide)
- Si le slug change, redirige automatiquement vers la nouvelle URL
- Encart "Projets attachés" en lecture (avec lien vers chaque fiche)
pour rappeler l'impact d'une suppression ou d'un changement de slug
- Bouton Supprimer en header avec confirm qui mentionne le nb de projets
- Banner vert "Modifications enregistrées" sur succès
- Gestion erreurs : 422 -> fieldErrors, 401, 409 (slug collision), réseau
Backend
- Aucune nouvelle route : PATCH /api/members/:slug existait déjà
- Le contrôleur gère déjà slug auto-unique, initials, kind enum, etc.
UI
- AdminMembersPage : nouveau lien "Éditer" par ligne, à côté de Supprimer
- Router : route nommée admin-member-edit avec guard admin
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,299 @@
|
|||||||
|
<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 { Member } from '@/types/api'
|
||||||
|
import AdminSubNav from '@/components/AdminSubNav.vue'
|
||||||
|
import Avatar from '@/components/Avatar.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 member = ref<Member | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const loadError = ref<string | null>(null)
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
display_name: '',
|
||||||
|
slug: '',
|
||||||
|
initials: '',
|
||||||
|
kind: 'person' as Member['kind'],
|
||||||
|
bio: '',
|
||||||
|
avatar_url: '',
|
||||||
|
website_url: '',
|
||||||
|
contact_email: '',
|
||||||
|
})
|
||||||
|
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 m = await api.get<Member>(`/api/members/${encodeURIComponent(props.slug)}`)
|
||||||
|
member.value = m
|
||||||
|
form.value = {
|
||||||
|
display_name: m.display_name,
|
||||||
|
slug: m.slug,
|
||||||
|
initials: m.initials ?? '',
|
||||||
|
kind: m.kind,
|
||||||
|
bio: m.bio ?? '',
|
||||||
|
avatar_url: m.avatar_url ?? '',
|
||||||
|
website_url: m.website_url ?? '',
|
||||||
|
contact_email: m.contact_email ?? '',
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
loadError.value = e instanceof ApiError ? (e.payload.message ?? e.code) : 'Erreur réseau'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(load)
|
||||||
|
|
||||||
|
const slugChanged = computed(() => member.value !== null && form.value.slug !== member.value.slug)
|
||||||
|
|
||||||
|
// Liste de projets sur lesquels ce membre est attaché — affichage informatif
|
||||||
|
// pour rappeler à l'admin l'impact d'une suppression ou d'un changement de slug.
|
||||||
|
const memberProjects = computed(() => member.value?.projects ?? [])
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
fieldErrors.value = {}
|
||||||
|
generalError.value = null
|
||||||
|
justSaved.value = false
|
||||||
|
if (!isAuthenticated.value) {
|
||||||
|
generalError.value = "Tu dois être connecté en admin."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!member.value) return
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
// Construit le diff : on n'envoie que ce qui a changé.
|
||||||
|
const diff: Record<string, unknown> = {}
|
||||||
|
const m = member.value
|
||||||
|
if (form.value.display_name.trim() !== m.display_name) diff.display_name = form.value.display_name.trim()
|
||||||
|
if (slugChanged.value) diff.slug = form.value.slug.trim()
|
||||||
|
if (form.value.initials !== (m.initials ?? '')) diff.initials = form.value.initials.trim() || null
|
||||||
|
if (form.value.kind !== m.kind) diff.kind = form.value.kind
|
||||||
|
if (form.value.bio !== (m.bio ?? '')) diff.bio = form.value.bio || null
|
||||||
|
if (form.value.avatar_url !== (m.avatar_url ?? '')) diff.avatar_url = form.value.avatar_url || null
|
||||||
|
if (form.value.website_url !== (m.website_url ?? '')) diff.website_url = form.value.website_url || null
|
||||||
|
if (form.value.contact_email !== (m.contact_email ?? '')) diff.contact_email = form.value.contact_email || null
|
||||||
|
|
||||||
|
if (Object.keys(diff).length === 0) {
|
||||||
|
generalError.value = 'Aucune modification à enregistrer.'
|
||||||
|
submitting.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await api.patch<Member>(`/api/members/${encodeURIComponent(m.slug)}`, diff)
|
||||||
|
member.value = updated
|
||||||
|
// Si le slug a changé, l'URL courante devient invalide. On reroute discrètement.
|
||||||
|
if (updated.slug !== props.slug) {
|
||||||
|
router.replace({ name: 'admin-member-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 (!member.value) return
|
||||||
|
const projectCount = memberProjects.value.length
|
||||||
|
const warning = projectCount > 0
|
||||||
|
? `\n${projectCount} projet${projectCount > 1 ? 's' : ''} en cours seront affectés (le membre sera retiré).`
|
||||||
|
: ''
|
||||||
|
if (!confirm(`Supprimer définitivement « ${member.value.display_name} » ?${warning}`)) return
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/members/${encodeURIComponent(member.value.slug)}`)
|
||||||
|
router.push({ name: 'admin-members' })
|
||||||
|
} 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'un membre de l'annuaire.</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="member" class="mt-6 space-y-4">
|
||||||
|
<RouterLink :to="{ name: 'admin-members' }" 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 à l'annuaire
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<form @submit.prevent="submit" class="rounded-xl border border-donut-border bg-donut-surface p-6 space-y-5">
|
||||||
|
<header class="flex items-center justify-between gap-4 pb-4 border-b border-donut-border">
|
||||||
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
|
<Avatar
|
||||||
|
:name="form.display_name || member.display_name"
|
||||||
|
:initials="form.initials || member.initials"
|
||||||
|
:avatar-url="form.avatar_url || member.avatar_url"
|
||||||
|
:size="44"
|
||||||
|
/>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h2 class="text-base font-semibold text-donut-text truncate">{{ member.display_name }}</h2>
|
||||||
|
<p class="text-sm text-donut-muted">
|
||||||
|
{{ memberProjects.length }} projet{{ memberProjects.length > 1 ? 's' : '' }} attaché{{ memberProjects.length > 1 ? 's' : '' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-xs font-medium text-red-500 hover:text-red-700"
|
||||||
|
@click="destroy"
|
||||||
|
>Supprimer</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-donut-text mb-1">Nom affiché *</label>
|
||||||
|
<input
|
||||||
|
v-model="form.display_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.display_name ? 'border-red-400' : ''"
|
||||||
|
/>
|
||||||
|
<p v-if="fieldErrors.display_name" class="text-xs text-red-600 mt-1">Nom requis.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-[1fr,140px,160px] 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="96"
|
||||||
|
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. Modifier le casse les liens externes existants.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-donut-text mb-1">Initiales</label>
|
||||||
|
<input
|
||||||
|
v-model="form.initials" type="text" maxlength="8"
|
||||||
|
placeholder="Auto"
|
||||||
|
class="w-full bg-white border border-donut-border rounded-lg px-3 py-2.5 text-sm uppercase outline-none focus:border-donut-text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-donut-text mb-1">Type</label>
|
||||||
|
<select
|
||||||
|
v-model="form.kind"
|
||||||
|
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 value="person">Personne</option>
|
||||||
|
<option value="collective">Collectif</option>
|
||||||
|
<option value="org">Organisation</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-donut-text mb-1">Bio</label>
|
||||||
|
<textarea
|
||||||
|
v-model="form.bio" rows="3" maxlength="5000"
|
||||||
|
placeholder="Une courte présentation du membre (optionnel)."
|
||||||
|
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">Email</label>
|
||||||
|
<input
|
||||||
|
v-model="form.contact_email" type="email" maxlength="255"
|
||||||
|
placeholder="contact@example.org"
|
||||||
|
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.contact_email ? 'border-red-400' : ''"
|
||||||
|
/>
|
||||||
|
<p v-if="fieldErrors.contact_email" class="text-xs text-red-600 mt-1">Email invalide.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-donut-text mb-1">Site web</label>
|
||||||
|
<input
|
||||||
|
v-model="form.website_url" type="url" maxlength="512"
|
||||||
|
placeholder="https://…"
|
||||||
|
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.website_url ? 'border-red-400' : ''"
|
||||||
|
/>
|
||||||
|
<p v-if="fieldErrors.website_url" class="text-xs text-red-600 mt-1">URL invalide.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-donut-text mb-1">Avatar (URL)</label>
|
||||||
|
<input
|
||||||
|
v-model="form.avatar_url" type="url" maxlength="512"
|
||||||
|
placeholder="https://…"
|
||||||
|
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.avatar_url ? 'border-red-400' : ''"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-donut-muted mt-1">Une URL d'image. Si vide, on génère l'avatar depuis les initiales.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Projets attachés (lecture seule, lien vers chaque fiche) -->
|
||||||
|
<div v-if="memberProjects.length > 0" class="rounded-lg border border-donut-border bg-gray-50 p-4">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-wider text-donut-muted mb-2">
|
||||||
|
Projets attachés ({{ memberProjects.length }})
|
||||||
|
</h3>
|
||||||
|
<ul class="space-y-1.5">
|
||||||
|
<li v-for="p in memberProjects" :key="p.id" class="flex items-center gap-2 text-sm">
|
||||||
|
<span class="w-2 h-2 rounded-full shrink-0" :style="{ background: p.category.color_hex }" />
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'project-detail', params: { slug: p.slug } }"
|
||||||
|
class="text-donut-text hover:underline truncate"
|
||||||
|
>{{ p.title }}</RouterLink>
|
||||||
|
<span class="ml-auto text-xs text-donut-muted">{{ p.role }} · {{ p.status }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</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-members' }"
|
||||||
|
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>
|
||||||
@@ -112,11 +112,17 @@ const KIND_LABEL = {
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-donut-muted">{{ formatDate(m.created_at) }}</td>
|
<td class="px-4 py-3 text-donut-muted">{{ formatDate(m.created_at) }}</td>
|
||||||
<td class="px-4 py-3 text-right">
|
<td class="px-4 py-3 text-right">
|
||||||
<button
|
<div class="inline-flex items-center gap-3">
|
||||||
type="button"
|
<RouterLink
|
||||||
class="text-red-500 hover:text-red-700 text-xs font-medium"
|
:to="{ name: 'admin-member-edit', params: { slug: m.slug } }"
|
||||||
@click="destroy(m)"
|
class="text-donut-text hover:underline text-xs font-medium"
|
||||||
>Supprimer</button>
|
>Éditer</RouterLink>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-red-500 hover:text-red-700 text-xs font-medium"
|
||||||
|
@click="destroy(m)"
|
||||||
|
>Supprimer</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{ path: 'admin/projects', name: 'admin-projects', component: () => import('@/pages/admin/AdminProjectsPage.vue'), beforeEnter: requireAdmin },
|
{ path: 'admin/projects', name: 'admin-projects', component: () => import('@/pages/admin/AdminProjectsPage.vue'), beforeEnter: requireAdmin },
|
||||||
{ path: 'admin/projects/:slug/edit', name: 'admin-project-edit', component: () => import('@/pages/admin/AdminProjectEditPage.vue'), props: true, beforeEnter: requireAdmin },
|
{ path: 'admin/projects/:slug/edit', name: 'admin-project-edit', component: () => import('@/pages/admin/AdminProjectEditPage.vue'), props: true, beforeEnter: requireAdmin },
|
||||||
{ 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/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/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