diff --git a/src/app/backend/BackendShell.tsx b/src/app/backend/BackendShell.tsx index 4362dfc..5e3ef7a 100644 --- a/src/app/backend/BackendShell.tsx +++ b/src/app/backend/BackendShell.tsx @@ -15,6 +15,7 @@ const TITLES: Record = { "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({ )} + {can(user.role, "manageCategories") && ( + + {dotDiamond}Catégories + + )} {can(user.role, "manageAdmins") && ( {dot}Administrateurs diff --git a/src/app/backend/actions.ts b/src/app/backend/actions.ts index 9265507..bdffa90 100644 --- a/src/app/backend/actions.ts +++ b/src/app/backend/actions.ts @@ -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`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: "/" }); diff --git a/src/app/backend/categories/page.tsx b/src/app/backend/categories/page.tsx new file mode 100644 index 0000000..a7afdb9 --- /dev/null +++ b/src/app/backend/categories/page.tsx @@ -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 ( +
+ {/* list */} +
+
+ Catégories ({rows.length}) +
+ + {rows.map((c) => ( +
+ + + + +
+ + + +
+ + + {c.memberCount} adhérent{c.memberCount > 1 ? "s" : ""} + + +
+ + +
+
+ ))} +
+ + {/* add */} +
+

+ Ajouter une catégorie +

+
+ + + +
+

+ Une couleur est attribuée automatiquement. Supprimer une catégorie détache + les adhérents concernés (ils restent, sans catégorie). +

+
+
+ ); +} diff --git a/src/lib/rbac.ts b/src/lib/rbac.ts index 1c03b99..f44fa41 100644 --- a/src/lib/rbac.ts +++ b/src/lib/rbac.ts @@ -19,10 +19,18 @@ type Capability = | "manageMembers" | "moderatePromos" | "manageAdmins" + | "manageCategories" | "memberSpace"; const CAPABILITIES: Record = { - admin: ["viewDashboard", "manageMembers", "moderatePromos", "manageAdmins", "memberSpace"], + admin: [ + "viewDashboard", + "manageMembers", + "moderatePromos", + "manageAdmins", + "manageCategories", + "memberSpace", + ], moderator: ["viewDashboard", "manageMembers", "moderatePromos"], editor: ["viewDashboard", "manageMembers"], member: ["memberSpace"],