mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-22 06:38:05 +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",
|
"Validez les offres soumises par les adhérents",
|
||||||
],
|
],
|
||||||
"/backend/administrateurs": ["Administrateurs", "Gérer les accès à l'administration"],
|
"/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"],
|
"/backend/espace": ["Mon espace adhérent", "Publiez et suivez vos promotions"],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -116,6 +117,11 @@ export function BackendShell({
|
|||||||
</span>
|
</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
)}
|
)}
|
||||||
|
{can(user.role, "manageCategories") && (
|
||||||
|
<NavLink href="/backend/categories" active={pathname === "/backend/categories"}>
|
||||||
|
{dotDiamond}Catégories
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
{can(user.role, "manageAdmins") && (
|
{can(user.role, "manageAdmins") && (
|
||||||
<NavLink href="/backend/administrateurs" active={pathname === "/backend/administrateurs"}>
|
<NavLink href="/backend/administrateurs" active={pathname === "/backend/administrateurs"}>
|
||||||
{dot}Administrateurs
|
{dot}Administrateurs
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { auth, signOut } from "@/auth";
|
import { auth, signOut } from "@/auth";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import {
|
import {
|
||||||
activityLog,
|
activityLog,
|
||||||
|
categories,
|
||||||
members,
|
members,
|
||||||
promotions,
|
promotions,
|
||||||
users,
|
users,
|
||||||
@@ -14,6 +15,27 @@ import {
|
|||||||
import { can, LABEL_TO_ROLE } from "@/lib/rbac";
|
import { can, LABEL_TO_ROLE } from "@/lib/rbac";
|
||||||
import type { AppRole } from "@/types/next-auth";
|
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") {
|
async function logActivity(message: string, dot = "#2C6FB3") {
|
||||||
await db.insert(activityLog).values({ message, dot });
|
await db.insert(activityLog).values({ message, dot });
|
||||||
}
|
}
|
||||||
@@ -183,6 +205,65 @@ export async function removeAdmin(formData: FormData) {
|
|||||||
revalidatePath("/backend/administrateurs");
|
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 ----
|
// ---- Sign out ----
|
||||||
export async function doSignOut() {
|
export async function doSignOut() {
|
||||||
await signOut({ redirectTo: "/" });
|
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"
|
| "manageMembers"
|
||||||
| "moderatePromos"
|
| "moderatePromos"
|
||||||
| "manageAdmins"
|
| "manageAdmins"
|
||||||
|
| "manageCategories"
|
||||||
| "memberSpace";
|
| "memberSpace";
|
||||||
|
|
||||||
const CAPABILITIES: Record<AppRole, Capability[]> = {
|
const CAPABILITIES: Record<AppRole, Capability[]> = {
|
||||||
admin: ["viewDashboard", "manageMembers", "moderatePromos", "manageAdmins", "memberSpace"],
|
admin: [
|
||||||
|
"viewDashboard",
|
||||||
|
"manageMembers",
|
||||||
|
"moderatePromos",
|
||||||
|
"manageAdmins",
|
||||||
|
"manageCategories",
|
||||||
|
"memberSpace",
|
||||||
|
],
|
||||||
moderator: ["viewDashboard", "manageMembers", "moderatePromos"],
|
moderator: ["viewDashboard", "manageMembers", "moderatePromos"],
|
||||||
editor: ["viewDashboard", "manageMembers"],
|
editor: ["viewDashboard", "manageMembers"],
|
||||||
member: ["memberSpace"],
|
member: ["memberSpace"],
|
||||||
|
|||||||
Reference in new issue
Block a user