feat(admin): Sprint 3 — Réservations, Utilisateurs, Avis
This commit is contained in:
parent
8f31047b36
commit
d9ee072744
16 changed files with 1632 additions and 0 deletions
120
src/app/admin/users/[id]/_components/UserActions.tsx
Normal file
120
src/app/admin/users/[id]/_components/UserActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
src/app/admin/users/[id]/page.tsx
Normal file
133
src/app/admin/users/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
src/app/admin/users/actions.ts
Normal file
58
src/app/admin/users/actions.ts
Normal 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 };
|
||||
}
|
||||
136
src/app/admin/users/page.tsx
Normal file
136
src/app/admin/users/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue