feat(admin): Sprint 3 — Réservations, Utilisateurs, Avis

This commit is contained in:
Claude Integration 2026-05-31 21:20:46 +00:00
parent 8f31047b36
commit d9ee072744
16 changed files with 1632 additions and 0 deletions

View file

@ -0,0 +1,120 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { UserRole } from "@/generated/prisma/enums";
import { toggleUserActiveAction, updateUserRoleAction } from "../../actions";
const ROLE_OPTIONS: { value: string; label: string }[] = [
{ value: UserRole.OWNER, label: "Propriétaire" },
{ value: UserRole.CE_MANAGER, label: "CE — Manager" },
{ value: UserRole.CE_MEMBER, label: "CE — Membre" },
{ value: UserRole.TOURIST, label: "Touriste" },
{ value: UserRole.ADMIN, label: "Admin" },
];
export function UserActions({
id,
role,
isActive,
}: {
id: string;
role: string;
isActive: boolean;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [selectedRole, setSelectedRole] = useState(role);
const [confirmDeactivate, setConfirmDeactivate] = useState(false);
function changeRole(next: string) {
setError(null);
setSelectedRole(next);
startTransition(async () => {
const res = await updateUserRoleAction(id, next);
if (res && res.ok === false) {
setError(res.error);
setSelectedRole(role);
}
router.refresh();
});
}
function toggleActive(next: boolean) {
setError(null);
startTransition(async () => {
const res = await toggleUserActiveAction(id, next);
if (res && res.ok === false) setError(res.error);
setConfirmDeactivate(false);
router.refresh();
});
}
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<label className="text-[11px] uppercase tracking-wider text-zinc-500">Rôle</label>
<select
value={selectedRole}
disabled={pending}
onChange={(e) => changeRole(e.target.value)}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none disabled:opacity-50"
>
{ROLE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] uppercase tracking-wider text-zinc-500">État du compte</span>
{isActive ? (
confirmDeactivate ? (
<div className="flex items-center gap-2 rounded border border-amber-300 bg-amber-50 px-2 py-1">
<span className="text-xs text-amber-900">Désactiver ce compte ?</span>
<button
type="button"
onClick={() => toggleActive(false)}
disabled={pending}
className="rounded bg-amber-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-amber-800 disabled:opacity-50"
>
Oui, désactiver
</button>
<button
type="button"
onClick={() => setConfirmDeactivate(false)}
disabled={pending}
className="text-[11px] text-zinc-500 hover:text-zinc-900"
>
Annuler
</button>
</div>
) : (
<button
type="button"
onClick={() => setConfirmDeactivate(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"
>
Désactiver
</button>
)
) : (
<button
type="button"
onClick={() => toggleActive(true)}
disabled={pending}
className="rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
>
Réactiver
</button>
)}
</div>
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,133 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { getUserForAdmin } from "@/lib/admin/users";
import { StatusBadge } from "@/components/admin/StatusBadge";
import { UserActions } from "./_components/UserActions";
export const dynamic = "force-dynamic";
type PageProps = { params: Promise<{ id: string }> };
const ROLE_LABEL: Record<string, string> = {
OWNER: "Propriétaire",
CE_MANAGER: "CE — Manager",
CE_MEMBER: "CE — Membre",
TOURIST: "Touriste",
ADMIN: "Admin",
};
export default async function UserDetailPage({ params }: PageProps) {
const { id } = await params;
const user = await getUserForAdmin(id);
if (!user) notFound();
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" });
const dateShortFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
return (
<div className="mx-auto max-w-5xl space-y-6">
<header className="mt-2">
<Link href="/admin/users" className="text-xs text-zinc-500 hover:text-zinc-900">
Tous les utilisateurs
</Link>
<h1 className="mt-1 flex items-center gap-3 text-2xl font-semibold text-zinc-900">
{user.firstName} {user.lastName}
<StatusBadge status={user.isActive ? "ACTIVE" : "INACTIVE"} />
</h1>
<p className="mt-1 text-sm text-zinc-500">
{user.email} · {ROLE_LABEL[user.role] ?? user.role} · inscrit le {dateFmt.format(user.createdAt)}
</p>
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">Actions</h2>
<UserActions id={user.id} role={user.role} isActive={user.isActive} />
</section>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">Identité</h2>
<dl className="space-y-2 text-sm">
<Row label="Email" value={user.email} />
{user.phone ? <Row label="Téléphone" value={user.phone} /> : null}
<Row label="Rôle" value={ROLE_LABEL[user.role] ?? user.role} />
<Row label="Actif" value={user.isActive ? "Oui" : "Non"} />
{user.organization ? (
<Row
label="Organisation"
value={
<Link href={`/admin/organizations/${user.organization.id}`} className="text-zinc-900 hover:underline">
{user.organization.name}
</Link>
}
/>
) : null}
</dl>
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">Statistiques</h2>
<dl className="space-y-2 text-sm">
<Row label="Carbets" value={String(user._count.carbets)} />
<Row label="Réservations" value={String(user._count.bookings)} />
<Row label="Avis publiés" value={String(user._count.reviews)} />
<Row label="Abonnements" value={String(user._count.subscriptions)} />
</dl>
</section>
</div>
{user.carbets.length > 0 ? (
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">Carbets du propriétaire</h2>
<ul className="space-y-1.5">
{user.carbets.map((c) => (
<li key={c.id} className="flex items-center justify-between text-sm">
<Link href={`/admin/carbets/${c.id}`} className="text-zinc-900 hover:underline">
{c.title} <code className="text-[11px] text-zinc-500">/{c.slug}</code>
</Link>
<span className="flex items-center gap-2">
<StatusBadge status={c.status} />
<span className="text-[11px] text-zinc-500">{dateShortFmt.format(c.updatedAt)}</span>
</span>
</li>
))}
</ul>
</section>
) : null}
{user.bookings.length > 0 ? (
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">Dernières réservations</h2>
<ul className="space-y-1.5">
{user.bookings.map((b) => (
<li key={b.id} className="flex items-center justify-between gap-3 text-sm">
<Link href={`/admin/bookings/${b.id}`} className="text-zinc-900 hover:underline">
{b.carbet.title}
<span className="ml-2 text-[11px] text-zinc-500">
{dateShortFmt.format(b.startDate)} {dateShortFmt.format(b.endDate)}
</span>
</Link>
<span className="flex items-center gap-2">
<span className="font-mono text-[11px] text-zinc-700">
{Number(b.amount).toFixed(2)} {b.currency}
</span>
<StatusBadge status={b.status} />
<StatusBadge status={b.paymentStatus} />
</span>
</li>
))}
</ul>
</section>
) : null}
</div>
);
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-baseline justify-between gap-3 border-b border-zinc-100 pb-1.5 last:border-b-0 last:pb-0">
<dt className="text-[11px] uppercase tracking-wider text-zinc-500">{label}</dt>
<dd className="text-sm text-zinc-900">{value}</dd>
</div>
);
}

View file

@ -0,0 +1,58 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/auth";
import { UserRole } from "@/generated/prisma/enums";
import { requireRole } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
const ROLE_VALUES = new Set<string>([
UserRole.OWNER,
UserRole.CE_MANAGER,
UserRole.CE_MEMBER,
UserRole.TOURIST,
UserRole.ADMIN,
]);
async function audit(event: string, target: string, actor: string | null, details: unknown) {
console.log(JSON.stringify({ scope: "admin.users", event, target, actor, details, at: new Date().toISOString() }));
}
export async function updateUserRoleAction(id: string, role: string) {
await requireRole([UserRole.ADMIN]);
if (!ROLE_VALUES.has(role)) {
return { ok: false as const, error: "Rôle invalide" };
}
const session = await auth();
if (role !== UserRole.ADMIN) {
const adminCount = await prisma.user.count({ where: { role: UserRole.ADMIN, isActive: true } });
const current = await prisma.user.findUnique({ where: { id }, select: { role: true } });
if (current?.role === UserRole.ADMIN && adminCount <= 1) {
return { ok: false as const, error: "Impossible de retirer le dernier admin actif." };
}
}
await prisma.user.update({ where: { id }, data: { role: role as UserRole } });
await audit("user.role.update", id, session?.user?.email ?? null, { role });
revalidatePath("/admin/users");
revalidatePath(`/admin/users/${id}`);
return { ok: true as const };
}
export async function toggleUserActiveAction(id: string, active: boolean) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
if (!active) {
const target = await prisma.user.findUnique({ where: { id }, select: { role: true, isActive: true } });
if (target?.role === UserRole.ADMIN) {
const adminCount = await prisma.user.count({ where: { role: UserRole.ADMIN, isActive: true } });
if (adminCount <= 1) {
return { ok: false as const, error: "Impossible de désactiver le dernier admin." };
}
}
}
await prisma.user.update({ where: { id }, data: { isActive: active } });
await audit("user.active.update", id, session?.user?.email ?? null, { active });
revalidatePath("/admin/users");
revalidatePath(`/admin/users/${id}`);
return { ok: true as const };
}

View file

@ -0,0 +1,136 @@
import Link from "next/link";
import { UserRole } from "@/generated/prisma/enums";
import { listUsersAdmin } from "@/lib/admin/users";
import { StatusBadge } from "@/components/admin/StatusBadge";
export const dynamic = "force-dynamic";
type PageProps = {
searchParams: Promise<{
q?: string;
role?: string;
active?: string;
}>;
};
const ROLE_VALUES = new Set<string>([
UserRole.OWNER,
UserRole.CE_MANAGER,
UserRole.CE_MEMBER,
UserRole.TOURIST,
UserRole.ADMIN,
]);
const ROLE_LABEL: Record<string, string> = {
OWNER: "Propriétaire",
CE_MANAGER: "CE — Manager",
CE_MEMBER: "CE — Membre",
TOURIST: "Touriste",
ADMIN: "Admin",
};
export default async function UsersAdminPage({ searchParams }: PageProps) {
const sp = await searchParams;
const filters = {
q: sp.q?.trim() || undefined,
role: ROLE_VALUES.has(sp.role ?? "") ? (sp.role as UserRole) : undefined,
active: sp.active === "yes" || sp.active === "no" ? (sp.active as "yes" | "no") : undefined,
};
const users = await listUsersAdmin(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">Utilisateurs</h1>
<p className="mt-1 text-sm text-zinc-500">
{users.length} résultat{users.length > 1 ? "s" : ""}
{users.length === 300 ? " (limite atteinte — affinez les filtres)" : ""}
</p>
</div>
</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 email, nom, téléphone…"
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"
/>
<select
name="role"
defaultValue={filters.role ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Tous rôles</option>
{Object.entries(ROLE_LABEL).map(([v, l]) => (
<option key={v} value={v}>{l}</option>
))}
</select>
<select
name="active"
defaultValue={filters.active ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Actifs + inactifs</option>
<option value="yes">Actifs</option>
<option value="no">Inactifs</option>
</select>
<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 || filters.role || filters.active) ? (
<Link href="/admin/users" 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">Email</th>
<th className="px-4 py-2 text-left font-semibold">Rôle</th>
<th className="px-4 py-2 text-right font-semibold">Carbets</th>
<th className="px-4 py-2 text-right font-semibold">Résas</th>
<th className="px-4 py-2 text-right font-semibold">Avis</th>
<th className="px-4 py-2 text-left font-semibold">État</th>
<th className="px-4 py-2 text-right font-semibold">Inscrit</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{users.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-sm text-zinc-500">
Aucun utilisateur ne correspond aux filtres.
</td>
</tr>
) : null}
{users.map((u) => (
<tr key={u.id} className="hover:bg-zinc-50">
<td className="px-4 py-2">
<Link href={`/admin/users/${u.id}`} className="font-medium text-zinc-900 hover:underline">
{u.firstName} {u.lastName}
</Link>
</td>
<td className="px-4 py-2 text-zinc-700">{u.email}</td>
<td className="px-4 py-2 text-zinc-700">{ROLE_LABEL[u.role] ?? u.role}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{u.carbetsCount}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{u.bookingsCount}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{u.reviewsCount}</td>
<td className="px-4 py-2"><StatusBadge status={u.isActive ? "ACTIVE" : "INACTIVE"} /></td>
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">
{dateFmt.format(u.createdAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}