mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-12 01:38:04 +02:00
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:
4 files changed
+187
-2
No files matched your search
@@ -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
|
||||
|
||||
@@ -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: "/" });
|
||||
|
||||
@@ -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
@@ -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"],
|
||||
|
||||
Reference in new issue
Block a user