feat(admin): Sprint 4 — Organisations CE + Prestataires pirogue (CRUD)

This commit is contained in:
Claude Integration 2026-05-31 21:36:22 +00:00
parent 19b4ff8293
commit 99f3bbdc71
14 changed files with 1141 additions and 0 deletions

View file

@ -0,0 +1,71 @@
"use client";
import { useState, useTransition } from "react";
type Props = {
action: () => Promise<{ ok: false; error: string } | { ok: true } | undefined | void>;
memberCount: number;
};
export function DeleteOrgButton({ action, memberCount }: Props) {
const [pending, startTransition] = useTransition();
const [confirm, setConfirm] = useState(false);
const [error, setError] = useState<string | null>(null);
function run() {
setError(null);
startTransition(async () => {
const res = await action();
if (res && (res as { ok?: boolean }).ok === false) {
setError((res as { error: string }).error);
setConfirm(false);
}
});
}
if (memberCount > 0) {
return (
<span className="rounded border border-zinc-200 bg-zinc-50 px-2 py-1 text-xs text-zinc-500">
Suppression impossible {memberCount} membre{memberCount > 1 ? "s" : ""} rattaché{memberCount > 1 ? "s" : ""}
</span>
);
}
return (
<div className="flex flex-col items-end gap-1">
{confirm ? (
<div className="flex items-center gap-2 rounded border border-rose-300 bg-rose-50 px-2 py-1">
<span className="text-xs text-rose-900">Supprimer définitivement ?</span>
<button
type="button"
onClick={run}
disabled={pending}
className="rounded bg-rose-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-rose-800 disabled:opacity-50"
>
Oui, supprimer
</button>
<button
type="button"
onClick={() => setConfirm(false)}
disabled={pending}
className="text-[11px] text-zinc-500 hover:text-zinc-900"
>
Annuler
</button>
</div>
) : (
<button
type="button"
onClick={() => setConfirm(true)}
disabled={pending}
className="rounded-md border border-rose-300 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
>
Supprimer l&apos;organisation
</button>
)}
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-2 py-1 text-xs text-rose-700">{error}</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,90 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { getOrganizationForAdmin } from "@/lib/admin/organizations";
import { OrgForm } from "../_components/OrgForm";
import { StatusBadge } from "@/components/admin/StatusBadge";
import { deleteOrganizationAction, updateOrganizationAction } from "../actions";
import { DeleteOrgButton } from "./_components/DeleteOrgButton";
export const dynamic = "force-dynamic";
const ROLE_LABEL: Record<string, string> = {
OWNER: "Propriétaire",
CE_MANAGER: "CE — Manager",
CE_MEMBER: "CE — Membre",
TOURIST: "Touriste",
ADMIN: "Admin",
};
type PageProps = { params: Promise<{ id: string }> };
export default async function EditOrgPage({ params }: PageProps) {
const { id } = await params;
const org = await getOrganizationForAdmin(id);
if (!org) notFound();
const updateThis = async (fd: FormData) => {
"use server";
return await updateOrganizationAction(id, fd);
};
const deleteThis = async () => {
"use server";
return await deleteOrganizationAction(id);
};
return (
<div className="mx-auto max-w-5xl space-y-6">
<header className="mt-2 flex flex-wrap items-end justify-between gap-3">
<div>
<Link href="/admin/organizations" className="text-xs text-zinc-500 hover:text-zinc-900">
Toutes les organisations
</Link>
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">{org.name}</h1>
<p className="mt-1 text-sm text-zinc-500">
<code>/{org.slug}</code> · {org.members.length} membre{org.members.length > 1 ? "s" : ""}
</p>
</div>
<DeleteOrgButton action={deleteThis} memberCount={org.members.length} />
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Identité</h2>
<OrgForm
action={updateThis}
submitLabel="Enregistrer les modifications"
initial={{ name: org.name, slug: org.slug, description: org.description }}
/>
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Membres ({org.members.length})
</h2>
{org.members.length === 0 ? (
<p className="text-sm text-zinc-500">
Aucun membre. Rattachez un utilisateur via{" "}
<Link href="/admin/users" className="text-zinc-900 hover:underline">
la page Utilisateurs
</Link>
.
</p>
) : (
<ul className="divide-y divide-zinc-100">
{org.members.map((m) => (
<li key={m.id} className="flex items-center justify-between gap-3 py-2 text-sm">
<Link href={`/admin/users/${m.id}`} className="text-zinc-900 hover:underline">
{m.firstName} {m.lastName}
<span className="ml-2 text-[11px] text-zinc-500">{m.email}</span>
</Link>
<span className="flex items-center gap-2">
<span className="text-xs text-zinc-600">{ROLE_LABEL[m.role] ?? m.role}</span>
<StatusBadge status={m.isActive ? "ACTIVE" : "INACTIVE"} />
</span>
</li>
))}
</ul>
)}
</section>
</div>
);
}

View file

@ -0,0 +1,77 @@
"use client";
import { useState, useTransition } from "react";
import { FormField, inputCls, textareaCls } from "@/components/admin/FormField";
type Props = {
initial?: {
name?: string;
slug?: string;
description?: string | null;
};
action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
submitLabel?: string;
};
export function OrgForm({ initial = {}, action, submitLabel = "Enregistrer" }: Props) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
function onSubmit(formData: FormData) {
setError(null);
setSuccess(null);
startTransition(async () => {
const res = await action(formData);
if (res && res.ok === false) setError(res.error);
else if (res && res.ok === true) setSuccess("Organisation enregistrée.");
});
}
return (
<form action={onSubmit} className="space-y-4">
<fieldset disabled={pending} className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Nom" required>
<input name="name" defaultValue={initial.name ?? ""} required maxLength={200} className={inputCls} />
</FormField>
<FormField label="Slug" required hint="URL : /organizations/<slug>">
<input
name="slug"
defaultValue={initial.slug ?? ""}
required
pattern="^[a-z0-9](?:[a-z0-9-]{0,80}[a-z0-9])?$"
placeholder="ex. ce-airbus-kourou"
className={inputCls}
/>
</FormField>
</div>
<FormField label="Description" hint="Brève présentation interne (max 5000 caractères).">
<textarea
name="description"
rows={5}
defaultValue={initial.description ?? ""}
maxLength={5000}
className={textareaCls}
/>
</FormField>
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
) : null}
{success ? (
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
) : null}
<div className="flex items-center justify-end gap-2">
<button
type="submit"
className="rounded-md bg-zinc-900 px-5 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
>
{pending ? "Enregistrement…" : submitLabel}
</button>
</div>
</fieldset>
</form>
);
}

View file

@ -0,0 +1,88 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { auth } from "@/auth";
import { UserRole } from "@/generated/prisma/enums";
import { requireRole } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
async function audit(event: string, target: string, actor: string | null, details: unknown) {
console.log(JSON.stringify({ scope: "admin.organizations", event, target, actor, details, at: new Date().toISOString() }));
}
const slugRe = /^[a-z0-9](?:[a-z0-9-]{0,80}[a-z0-9])?$/;
const orgSchema = z.object({
name: z.string().trim().min(2).max(200),
slug: z.string().trim().regex(slugRe, "Slug invalide (a-z, 0-9, -)"),
description: z.string().trim().max(5000).optional().nullable(),
});
function parseFD(fd: FormData) {
return {
name: (fd.get("name") as string | null) ?? "",
slug: (fd.get("slug") as string | null) ?? "",
description: ((fd.get("description") as string | null) ?? "") || null,
};
}
export async function createOrganizationAction(fd: FormData) {
await requireRole([UserRole.ADMIN]);
const parsed = orgSchema.safeParse(parseFD(fd));
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
}
const session = await auth();
try {
const created = await prisma.organization.create({
data: { name: parsed.data.name, slug: parsed.data.slug, description: parsed.data.description ?? null },
});
await audit("organization.create", created.id, session?.user?.email ?? null, { slug: created.slug });
revalidatePath("/admin/organizations");
} catch (e) {
if (e instanceof Error && e.message.includes("Unique")) {
return { ok: false as const, error: "Ce slug existe déjà." };
}
return { ok: false as const, error: "Erreur lors de la création." };
}
redirect("/admin/organizations");
}
export async function updateOrganizationAction(id: string, fd: FormData) {
await requireRole([UserRole.ADMIN]);
const parsed = orgSchema.safeParse(parseFD(fd));
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
}
const session = await auth();
try {
await prisma.organization.update({
where: { id },
data: { name: parsed.data.name, slug: parsed.data.slug, description: parsed.data.description ?? null },
});
} catch (e) {
if (e instanceof Error && e.message.includes("Unique")) {
return { ok: false as const, error: "Ce slug est déjà pris." };
}
return { ok: false as const, error: "Erreur lors de la mise à jour." };
}
await audit("organization.update", id, session?.user?.email ?? null, { slug: parsed.data.slug });
revalidatePath("/admin/organizations");
revalidatePath(`/admin/organizations/${id}`);
return { ok: true as const };
}
export async function deleteOrganizationAction(id: string) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
const count = await prisma.user.count({ where: { organizationId: id } });
if (count > 0) {
return { ok: false as const, error: `Impossible : ${count} membre(s) encore rattaché(s).` };
}
await prisma.organization.delete({ where: { id } });
await audit("organization.delete", id, session?.user?.email ?? null, {});
revalidatePath("/admin/organizations");
redirect("/admin/organizations");
}

View file

@ -0,0 +1,21 @@
import Link from "next/link";
import { OrgForm } from "../_components/OrgForm";
import { createOrganizationAction } from "../actions";
export const dynamic = "force-dynamic";
export default function NewOrgPage() {
return (
<div className="mx-auto max-w-3xl space-y-6">
<header className="mt-2">
<Link href="/admin/organizations" className="text-xs text-zinc-500 hover:text-zinc-900">
Toutes les organisations
</Link>
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">Nouvelle organisation</h1>
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<OrgForm action={createOrganizationAction} submitLabel="Créer l'organisation" />
</section>
</div>
);
}

View file

@ -0,0 +1,89 @@
import Link from "next/link";
import { listOrganizationsAdmin } from "@/lib/admin/organizations";
export const dynamic = "force-dynamic";
type PageProps = {
searchParams: Promise<{ q?: string }>;
};
export default async function OrgsAdminPage({ searchParams }: PageProps) {
const sp = await searchParams;
const filters = { q: sp.q?.trim() || undefined };
const orgs = await listOrganizationsAdmin(filters);
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
return (
<div className="mx-auto max-w-7xl">
<header className="mb-5 mt-2 flex items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold text-zinc-900">Organisations CE</h1>
<p className="mt-1 text-sm text-zinc-500">
{orgs.length} résultat{orgs.length > 1 ? "s" : ""}
</p>
</div>
<Link
href="/admin/organizations/new"
className="inline-flex items-center gap-1.5 rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-semibold text-white hover:bg-zinc-800"
>
+ Nouvelle organisation
</Link>
</header>
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
<input
type="text"
name="q"
defaultValue={filters.q ?? ""}
placeholder="Recherche nom, slug, description…"
className="flex-1 min-w-[220px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
/>
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
Filtrer
</button>
{filters.q ? (
<Link href="/admin/organizations" className="text-sm text-zinc-500 hover:text-zinc-900">
Réinit.
</Link>
) : null}
</form>
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
<table className="w-full text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-4 py-2 text-left font-semibold">Nom</th>
<th className="px-4 py-2 text-left font-semibold">Slug</th>
<th className="px-4 py-2 text-right font-semibold">Membres</th>
<th className="px-4 py-2 text-right font-semibold">Créée</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{orgs.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-sm text-zinc-500">
Aucune organisation.
</td>
</tr>
) : null}
{orgs.map((o) => (
<tr key={o.id} className="hover:bg-zinc-50">
<td className="px-4 py-2">
<Link href={`/admin/organizations/${o.id}`} className="font-medium text-zinc-900 hover:underline">
{o.name}
</Link>
{o.description ? (
<div className="text-[11px] text-zinc-500">{o.description.slice(0, 80)}{o.description.length > 80 ? "…" : ""}</div>
) : null}
</td>
<td className="px-4 py-2 text-zinc-700"><code>/{o.slug}</code></td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{o.membersCount}</td>
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">{dateFmt.format(o.createdAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}