Gestion des catégories dans le back-office (admin)

Nouvelle page /backend/categories : lister, ajouter (slug + couleur auto),
renommer et supprimer des catégories. Capacité RBAC manageCategories (admin) ;
lien ajouté à la barre latérale. La suppression détache les adhérents concernés
(transaction) pour éviter toute violation de clé étrangère.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XwfhYHzC9hQWPDnQ2p53GL
This commit is contained in:
Claude committed 2026-06-26 21:58:46 +00:00
1 parent 4ab05c5d71
commit 856fbc1c90
4 files changed
+187 -2

No files matched your search

+6
View File
@@ -15,6 +15,7 @@ const TITLES: Record<string, [string, string]> = {
"Validez les offres soumises par les adhérents",
],
"/backend/administrateurs": ["Administrateurs", "Gérer les accès à l'administration"],
"/backend/categories": ["Catégories", "Gérer les métiers de l'annuaire"],
"/backend/espace": ["Mon espace adhérent", "Publiez et suivez vos promotions"],
};
@@ -116,6 +117,11 @@ export function BackendShell({
</span>
</NavLink>
)}
{can(user.role, "manageCategories") && (
<NavLink href="/backend/categories" active={pathname === "/backend/categories"}>
{dotDiamond}Catégories
</NavLink>
)}
{can(user.role, "manageAdmins") && (
<NavLink href="/backend/administrateurs" active={pathname === "/backend/administrateurs"}>
{dot}Administrateurs
+82 -1
View File
@@ -1,12 +1,13 @@
"use server";
import { revalidatePath } from "next/cache";
import { eq } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import bcrypt from "bcryptjs";
import { auth, signOut } from "@/auth";
import { db } from "@/db";
import {
activityLog,
categories,
members,
promotions,
users,
@@ -14,6 +15,27 @@ import {
import { can, LABEL_TO_ROLE } from "@/lib/rbac";
import type { AppRole } from "@/types/next-auth";
const CATEGORY_PALETTE = [
{ accent: "#E0A63C", tint: "#f6efdc" },
{ accent: "#6FB0C6", tint: "#e7f0f3" },
{ accent: "#9a6638", tint: "#f4ebda" },
{ accent: "#2C6FB3", tint: "#eaf0f6" },
{ accent: "#5a7a5a", tint: "#eef0ec" },
{ accent: "#7a6f9c", tint: "#efe9f3" },
{ accent: "#c98a2e", tint: "#f7efe0" },
{ accent: "#3f8aa3", tint: "#e6eff2" },
];
function slugify(input: string): string {
return input
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 70);
}
async function logActivity(message: string, dot = "#2C6FB3") {
await db.insert(activityLog).values({ message, dot });
}
@@ -183,6 +205,65 @@ export async function removeAdmin(formData: FormData) {
revalidatePath("/backend/administrateurs");
}
// ---- Categories ----
export async function addCategory(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageCategories")) throw new Error("Accès refusé");
const label = String(formData.get("label") ?? "").trim();
if (!label) return;
let base = slugify(label) || "categorie";
let slug = base;
let n = 2;
while ((await db.select({ id: categories.id }).from(categories).where(eq(categories.slug, slug))).length > 0) {
slug = `${base}-${n++}`;
}
const existing = await db.select({ id: categories.id }).from(categories);
const palette = CATEGORY_PALETTE[existing.length % CATEGORY_PALETTE.length];
const [{ max } = { max: 0 }] = await db
.select({ max: sql<number>`coalesce(max(${categories.sort}), 0)` })
.from(categories);
await db.insert(categories).values({
slug,
label,
accent: palette.accent,
tint: palette.tint,
sort: Number(max) + 1,
});
revalidatePath("/backend/categories");
revalidatePath("/annuaire");
}
export async function renameCategory(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageCategories")) throw new Error("Accès refusé");
const id = Number(formData.get("id"));
const label = String(formData.get("label") ?? "").trim();
if (!id || !label) return;
await db.update(categories).set({ label }).where(eq(categories.id, id));
revalidatePath("/backend/categories");
revalidatePath("/annuaire");
}
export async function deleteCategory(formData: FormData) {
const { role } = await requireRole();
if (!can(role, "manageCategories")) throw new Error("Accès refusé");
const id = Number(formData.get("id"));
if (!id) return;
// Detach members from this category, then delete it (avoids FK violation).
await db.transaction(async (tx) => {
await tx.update(members).set({ categoryId: null }).where(eq(members.categoryId, id));
await tx.delete(categories).where(eq(categories.id, id));
});
revalidatePath("/backend/categories");
revalidatePath("/backend/adherents");
revalidatePath("/annuaire");
}
// ---- Sign out ----
export async function doSignOut() {
await signOut({ redirectTo: "/" });
+90
View File
@@ -0,0 +1,90 @@
import { redirect } from "next/navigation";
import { asc, count, eq } from "drizzle-orm";
import { auth } from "@/auth";
import { db } from "@/db";
import { categories, members } from "@/db/schema";
import { can } from "@/lib/rbac";
import { addCategory, deleteCategory, renameCategory } from "../actions";
export const dynamic = "force-dynamic";
export default async function CategoriesPage() {
const session = await auth();
if (!can(session?.user.role, "manageCategories")) {
redirect("/backend");
}
const rows = await db
.select({
id: categories.id,
slug: categories.slug,
label: categories.label,
accent: categories.accent,
tint: categories.tint,
memberCount: count(members.id),
})
.from(categories)
.leftJoin(members, eq(members.categoryId, categories.id))
.groupBy(categories.id)
.orderBy(asc(categories.sort));
return (
<div className="grid admins-split">
{/* list */}
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, overflow: "hidden" }}>
<div className="font-display" style={{ padding: "16px 22px", borderBottom: "1px solid #f0e8d6", fontWeight: 700, fontSize: 16, color: "#26201a" }}>
Catégories ({rows.length})
</div>
{rows.map((c) => (
<div key={c.id} style={{ display: "flex", alignItems: "center", gap: 12, padding: "12px 22px", borderBottom: "1px solid #f4eede", flexWrap: "wrap" }}>
<span style={{ width: 28, height: 28, borderRadius: 8, background: c.tint, display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
<span className="sparkle" style={{ width: 12, height: 12, background: c.accent }} />
</span>
<form action={renameCategory} style={{ display: "flex", alignItems: "center", gap: 8, flex: 1, minWidth: 220 }}>
<input type="hidden" name="id" value={c.id} />
<input name="label" defaultValue={c.label} className="field" style={{ padding: "8px 11px", flex: 1 }} />
<button type="submit" style={{ border: "1px solid #d8cdb4", background: "#faf7ef", color: "#3c3322", fontWeight: 700, fontSize: 12.5, padding: "8px 12px", borderRadius: 9, cursor: "pointer", whiteSpace: "nowrap" }}>
Renommer
</button>
</form>
<span style={{ fontSize: 12, color: "#a99c82", whiteSpace: "nowrap" }}>
{c.memberCount} adhérent{c.memberCount > 1 ? "s" : ""}
</span>
<form action={deleteCategory}>
<input type="hidden" name="id" value={c.id} />
<button type="submit" style={{ border: "none", background: "transparent", color: "#d8472b", fontWeight: 700, fontSize: 12.5, cursor: "pointer" }}>
Supprimer
</button>
</form>
</div>
))}
</div>
{/* add */}
<div style={{ background: "#fff", border: "1px solid #e6dcc6", borderRadius: 16, padding: 22 }}>
<h3 className="font-display" style={{ fontWeight: 700, fontSize: 16, margin: "0 0 16px", color: "#26201a" }}>
Ajouter une catégorie
</h3>
<form action={addCategory}>
<label className="field-label">Nom de la catégorie</label>
<input name="label" required placeholder="ex : Cordonnerie" className="field" style={{ marginBottom: 16 }} />
<button
type="submit"
className="font-display"
style={{ width: "100%", border: "none", background: "#13324F", color: "#fff", fontWeight: 700, fontSize: 14.5, padding: 13, borderRadius: 11, cursor: "pointer" }}
>
Ajouter
</button>
</form>
<p style={{ fontSize: 11.5, color: "#a99c82", marginTop: 12, lineHeight: 1.5 }}>
Une couleur est attribuée automatiquement. Supprimer une catégorie détache
les adhérents concernés (ils restent, sans catégorie).
</p>
</div>
</div>
);
}
+9 -1
View File
@@ -19,10 +19,18 @@ type Capability =
| "manageMembers"
| "moderatePromos"
| "manageAdmins"
| "manageCategories"
| "memberSpace";
const CAPABILITIES: Record<AppRole, Capability[]> = {
admin: ["viewDashboard", "manageMembers", "moderatePromos", "manageAdmins", "memberSpace"],
admin: [
"viewDashboard",
"manageMembers",
"moderatePromos",
"manageAdmins",
"manageCategories",
"memberSpace",
],
moderator: ["viewDashboard", "manageMembers", "moderatePromos"],
editor: ["viewDashboard", "manageMembers"],
member: ["memberSpace"],