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,156 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { BookingStatus, PaymentStatus } from "@/generated/prisma/enums";
import {
refundBookingAction,
updateBookingPaymentAction,
updateBookingStatusAction,
} from "../../actions";
type Status = (typeof BookingStatus)[keyof typeof BookingStatus];
type Payment = (typeof PaymentStatus)[keyof typeof PaymentStatus];
const btnBase =
"rounded-md px-3 py-1.5 text-xs font-semibold transition disabled:opacity-50";
export function BookingActions({
id,
status,
paymentStatus,
}: {
id: string;
status: Status;
paymentStatus: Payment;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [confirmRefund, setConfirmRefund] = useState(false);
function setStatus(next: Status) {
setError(null);
startTransition(async () => {
const res = await updateBookingStatusAction(id, next);
if (res && res.ok === false) setError(res.error);
router.refresh();
});
}
function setPayment(next: Payment) {
setError(null);
startTransition(async () => {
const res = await updateBookingPaymentAction(id, next);
if (res && res.ok === false) setError(res.error);
router.refresh();
});
}
function refund() {
setError(null);
startTransition(async () => {
await refundBookingAction(id);
setConfirmRefund(false);
router.refresh();
});
}
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] uppercase tracking-wider text-zinc-500">Statut résa :</span>
{status === BookingStatus.PENDING ? (
<button
type="button"
disabled={pending}
onClick={() => setStatus(BookingStatus.CONFIRMED)}
className={`${btnBase} bg-emerald-600 text-white hover:bg-emerald-700`}
>
Confirmer
</button>
) : null}
{status === BookingStatus.CONFIRMED ? (
<button
type="button"
disabled={pending}
onClick={() => setStatus(BookingStatus.COMPLETED)}
className={`${btnBase} border border-zinc-300 bg-white text-zinc-700 hover:bg-zinc-50`}
>
Marquer terminé
</button>
) : null}
{status !== BookingStatus.CANCELLED && status !== BookingStatus.COMPLETED ? (
<button
type="button"
disabled={pending}
onClick={() => setStatus(BookingStatus.CANCELLED)}
className={`${btnBase} border border-rose-300 bg-rose-50 text-rose-700 hover:bg-rose-100`}
>
Annuler
</button>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] uppercase tracking-wider text-zinc-500">Paiement :</span>
{paymentStatus !== PaymentStatus.SUCCEEDED && paymentStatus !== PaymentStatus.REFUNDED ? (
<button
type="button"
disabled={pending}
onClick={() => setPayment(PaymentStatus.SUCCEEDED)}
className={`${btnBase} bg-emerald-600 text-white hover:bg-emerald-700`}
>
Marquer payé
</button>
) : null}
{paymentStatus !== PaymentStatus.FAILED && paymentStatus !== PaymentStatus.REFUNDED ? (
<button
type="button"
disabled={pending}
onClick={() => setPayment(PaymentStatus.FAILED)}
className={`${btnBase} border border-rose-300 bg-rose-50 text-rose-700 hover:bg-rose-100`}
>
Marquer échec
</button>
) : null}
{paymentStatus === PaymentStatus.SUCCEEDED ? (
confirmRefund ? (
<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">Rembourser & annuler ?</span>
<button
type="button"
onClick={refund}
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
</button>
<button
type="button"
onClick={() => setConfirmRefund(false)}
disabled={pending}
className="text-[11px] text-zinc-500 hover:text-zinc-900"
>
Annuler
</button>
</div>
) : (
<button
type="button"
onClick={() => setConfirmRefund(true)}
disabled={pending}
className={`${btnBase} border border-amber-300 bg-amber-50 text-amber-800 hover:bg-amber-100`}
>
Rembourser
</button>
)
) : null}
</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,121 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { getBookingForAdmin } from "@/lib/admin/bookings";
import { StatusBadge } from "@/components/admin/StatusBadge";
import { BookingActions } from "./_components/BookingActions";
export const dynamic = "force-dynamic";
type PageProps = { params: Promise<{ id: string }> };
export default async function BookingDetailPage({ params }: PageProps) {
const { id } = await params;
const booking = await getBookingForAdmin(id);
if (!booking) notFound();
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" });
const dateTimeFmt = new Intl.DateTimeFormat("fr-FR", {
day: "2-digit", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit",
});
const nights = Math.max(1, Math.round((booking.endDate.getTime() - booking.startDate.getTime()) / 86400000));
return (
<div className="mx-auto max-w-5xl space-y-6">
<header className="mt-2">
<Link href="/admin/bookings" className="text-xs text-zinc-500 hover:text-zinc-900">
Toutes les réservations
</Link>
<h1 className="mt-1 flex items-center gap-3 text-2xl font-semibold text-zinc-900">
Réservation <code className="text-base text-zinc-500">{booking.id.slice(0, 12)}</code>
<StatusBadge status={booking.status} />
<StatusBadge status={booking.paymentStatus} />
</h1>
<p className="mt-1 text-sm text-zinc-500">
Créée le {dateTimeFmt.format(booking.createdAt)} · MAJ {dateTimeFmt.format(booking.updatedAt)}
</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>
<BookingActions id={booking.id} status={booking.status} paymentStatus={booking.paymentStatus} />
</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">Séjour</h2>
<dl className="space-y-2 text-sm">
<Row label="Du" value={dateFmt.format(booking.startDate)} />
<Row label="Au" value={dateFmt.format(booking.endDate)} />
<Row label="Durée" value={`${nights} nuit${nights > 1 ? "s" : ""}`} />
<Row label="Voyageurs" value={String(booking.guestCount)} />
<Row label="Montant" value={`${Number(booking.amount).toFixed(2)} ${booking.currency}`} />
</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">Carbet</h2>
<dl className="space-y-2 text-sm">
<Row
label="Titre"
value={
<Link href={`/admin/carbets/${booking.carbet.id}`} className="font-medium text-zinc-900 hover:underline">
{booking.carbet.title}
</Link>
}
/>
<Row label="Slug" value={<code>/{booking.carbet.slug}</code>} />
<Row label="Fleuve" value={booking.carbet.river} />
<Row
label="Propriétaire"
value={
<Link href={`/admin/users/${booking.carbet.owner.id}`} className="text-zinc-900 hover:underline">
{booking.carbet.owner.firstName} {booking.carbet.owner.lastName}
</Link>
}
/>
</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">Locataire</h2>
<dl className="space-y-2 text-sm">
<Row
label="Nom"
value={
<Link href={`/admin/users/${booking.tenant.id}`} className="text-zinc-900 hover:underline">
{booking.tenant.firstName} {booking.tenant.lastName}
</Link>
}
/>
<Row label="Email" value={booking.tenant.email} />
{booking.tenant.phone ? <Row label="Téléphone" value={booking.tenant.phone} /> : null}
<Row label="Rôle" value={booking.tenant.role} />
</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">Avis</h2>
{booking.review ? (
<p className="text-sm text-zinc-700">
Note <strong>{booking.review.rating}/5</strong> · déposé le {dateFmt.format(booking.review.createdAt)} ·{" "}
<Link href={`/admin/reviews?q=${booking.review.id}`} className="text-zinc-900 hover:underline">
Voir l&apos;avis
</Link>
</p>
) : (
<p className="text-sm text-zinc-500">Pas encore d&apos;avis pour cette réservation.</p>
)}
</section>
</div>
</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,73 @@
"use server";
import { revalidatePath } from "next/cache";
import { auth } from "@/auth";
import { BookingStatus, PaymentStatus, 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.bookings", event, target, actor, details, at: new Date().toISOString() }));
}
const ALLOWED_STATUS = new Set<string>([
BookingStatus.PENDING,
BookingStatus.CONFIRMED,
BookingStatus.CANCELLED,
BookingStatus.COMPLETED,
]);
const ALLOWED_PAYMENT = new Set<string>([
PaymentStatus.PENDING,
PaymentStatus.AUTHORIZED,
PaymentStatus.SUCCEEDED,
PaymentStatus.FAILED,
PaymentStatus.REFUNDED,
]);
export async function updateBookingStatusAction(id: string, status: string) {
await requireRole([UserRole.ADMIN]);
if (!ALLOWED_STATUS.has(status)) {
return { ok: false as const, error: "Statut invalide" };
}
const session = await auth();
await prisma.booking.update({
where: { id },
data: { status: status as BookingStatus },
});
await audit("booking.status.update", id, session?.user?.email ?? null, { status });
revalidatePath("/admin/bookings");
revalidatePath(`/admin/bookings/${id}`);
return { ok: true as const };
}
export async function updateBookingPaymentAction(id: string, paymentStatus: string) {
await requireRole([UserRole.ADMIN]);
if (!ALLOWED_PAYMENT.has(paymentStatus)) {
return { ok: false as const, error: "Statut de paiement invalide" };
}
const session = await auth();
await prisma.booking.update({
where: { id },
data: { paymentStatus: paymentStatus as PaymentStatus },
});
await audit("booking.payment.update", id, session?.user?.email ?? null, { paymentStatus });
revalidatePath("/admin/bookings");
revalidatePath(`/admin/bookings/${id}`);
return { ok: true as const };
}
export async function refundBookingAction(id: string) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
await prisma.booking.update({
where: { id },
data: {
paymentStatus: PaymentStatus.REFUNDED,
status: BookingStatus.CANCELLED,
},
});
await audit("booking.refund", id, session?.user?.email ?? null, {});
revalidatePath("/admin/bookings");
revalidatePath(`/admin/bookings/${id}`);
return { ok: true as const };
}

View file

@ -0,0 +1,184 @@
import Link from "next/link";
import { BookingStatus, PaymentStatus } from "@/generated/prisma/enums";
import { listBookingsAdmin } from "@/lib/admin/bookings";
import { StatusBadge } from "@/components/admin/StatusBadge";
export const dynamic = "force-dynamic";
type PageProps = {
searchParams: Promise<{
q?: string;
status?: string;
paymentStatus?: string;
from?: string;
to?: string;
}>;
};
const STATUS_VALUES = new Set<string>([
BookingStatus.PENDING,
BookingStatus.CONFIRMED,
BookingStatus.CANCELLED,
BookingStatus.COMPLETED,
]);
const PAYMENT_VALUES = new Set<string>([
PaymentStatus.PENDING,
PaymentStatus.AUTHORIZED,
PaymentStatus.SUCCEEDED,
PaymentStatus.FAILED,
PaymentStatus.REFUNDED,
]);
function parseDate(v?: string): Date | undefined {
if (!v) return undefined;
const d = new Date(v);
return isNaN(d.getTime()) ? undefined : d;
}
export default async function BookingsAdminPage({ searchParams }: PageProps) {
const sp = await searchParams;
const filters = {
q: sp.q?.trim() || undefined,
status: STATUS_VALUES.has(sp.status ?? "") ? (sp.status as BookingStatus) : undefined,
paymentStatus: PAYMENT_VALUES.has(sp.paymentStatus ?? "")
? (sp.paymentStatus as PaymentStatus)
: undefined,
from: parseDate(sp.from),
to: parseDate(sp.to),
};
const bookings = await listBookingsAdmin(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">Réservations</h1>
<p className="mt-1 text-sm text-zinc-500">
{bookings.length} résultat{bookings.length > 1 ? "s" : ""}
{bookings.length === 200 ? " (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 ID, locataire, carbet…"
className="flex-1 min-w-[200px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
/>
<select
name="status"
defaultValue={filters.status ?? ""}
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 statuts</option>
<option value={BookingStatus.PENDING}>En attente</option>
<option value={BookingStatus.CONFIRMED}>Confirmé</option>
<option value={BookingStatus.CANCELLED}>Annulé</option>
<option value={BookingStatus.COMPLETED}>Terminé</option>
</select>
<select
name="paymentStatus"
defaultValue={filters.paymentStatus ?? ""}
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 paiements</option>
<option value={PaymentStatus.PENDING}>En attente</option>
<option value={PaymentStatus.AUTHORIZED}>Autorisé</option>
<option value={PaymentStatus.SUCCEEDED}>Payé</option>
<option value={PaymentStatus.FAILED}>Échec</option>
<option value={PaymentStatus.REFUNDED}>Remboursé</option>
</select>
<label className="flex items-center gap-1 text-xs text-zinc-500">
Du
<input
type="date"
name="from"
defaultValue={sp.from ?? ""}
className="rounded-md border border-zinc-300 px-2 py-1 text-sm"
/>
</label>
<label className="flex items-center gap-1 text-xs text-zinc-500">
au
<input
type="date"
name="to"
defaultValue={sp.to ?? ""}
className="rounded-md border border-zinc-300 px-2 py-1 text-sm"
/>
</label>
<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.status || filters.paymentStatus || filters.from || filters.to) ? (
<Link href="/admin/bookings" 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">ID</th>
<th className="px-4 py-2 text-left font-semibold">Carbet</th>
<th className="px-4 py-2 text-left font-semibold">Locataire</th>
<th className="px-4 py-2 text-left font-semibold">Séjour</th>
<th className="px-4 py-2 text-right font-semibold">Pers.</th>
<th className="px-4 py-2 text-right font-semibold">Montant</th>
<th className="px-4 py-2 text-left font-semibold">Statut</th>
<th className="px-4 py-2 text-left font-semibold">Paiement</th>
<th className="px-4 py-2 text-right font-semibold">Créé</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{bookings.length === 0 ? (
<tr>
<td colSpan={9} className="px-4 py-8 text-center text-sm text-zinc-500">
Aucune réservation ne correspond aux filtres.
</td>
</tr>
) : null}
{bookings.map((b) => (
<tr key={b.id} className="hover:bg-zinc-50">
<td className="px-4 py-2">
<Link href={`/admin/bookings/${b.id}`} className="font-mono text-[11px] text-zinc-900 hover:underline">
{b.id.slice(0, 10)}
</Link>
</td>
<td className="px-4 py-2">
<Link href={`/admin/carbets/${b.carbet.id}`} className="font-medium text-zinc-900 hover:underline">
{b.carbet.title}
</Link>
<div className="text-[11px] text-zinc-500">
<code>/{b.carbet.slug}</code>
</div>
</td>
<td className="px-4 py-2 text-zinc-700">
{b.tenant.firstName} {b.tenant.lastName}
<div className="text-[11px] text-zinc-500">{b.tenant.email}</div>
</td>
<td className="px-4 py-2 text-zinc-700">
{dateFmt.format(b.startDate)} {dateFmt.format(b.endDate)}
</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{b.guestCount}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-900">
{Number(b.amount).toFixed(2)} {b.currency}
</td>
<td className="px-4 py-2"><StatusBadge status={b.status} /></td>
<td className="px-4 py-2"><StatusBadge status={b.paymentStatus} /></td>
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">
{dateFmt.format(b.createdAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,134 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { deleteReviewAction, updateReviewAction } from "../../actions";
import { inputCls, textareaCls } from "@/components/admin/FormField";
type Props = {
id: string;
initial: {
rating: number;
comment: string | null;
hostResponse: string | null;
};
};
export function ReviewForm({ id, initial }: Props) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
function onSubmit(formData: FormData) {
setError(null);
setSuccess(null);
startTransition(async () => {
const res = await updateReviewAction(id, formData);
if (res && res.ok === false) {
setError(res.error);
} else {
setSuccess("Avis enregistré.");
router.refresh();
}
});
}
function onDelete() {
setError(null);
startTransition(async () => {
await deleteReviewAction(id);
router.push("/admin/reviews");
});
}
return (
<form action={onSubmit} className="space-y-4">
<fieldset disabled={pending} className="space-y-4">
<div className="flex items-center gap-3">
<label className="text-[11px] uppercase tracking-wider text-zinc-500">Note</label>
<select name="rating" defaultValue={String(initial.rating)} className={inputCls + " w-24"}>
{[1, 2, 3, 4, 5].map((n) => (
<option key={n} value={String(n)}>{n} </option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-[11px] uppercase tracking-wider text-zinc-500">
Commentaire du voyageur
</label>
<textarea
name="comment"
rows={5}
defaultValue={initial.comment ?? ""}
maxLength={5000}
className={textareaCls}
placeholder="(vide pour supprimer le commentaire)"
/>
</div>
<div>
<label className="mb-1 block text-[11px] uppercase tracking-wider text-zinc-500">
Réponse de l&apos;hôte
</label>
<textarea
name="hostResponse"
rows={4}
defaultValue={initial.hostResponse ?? ""}
maxLength={5000}
className={textareaCls}
placeholder="(vide pour supprimer la réponse)"
/>
</div>
{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-between gap-2">
{confirmDelete ? (
<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={onDelete}
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={() => setConfirmDelete(false)}
disabled={pending}
className="text-[11px] text-zinc-500 hover:text-zinc-900"
>
Annuler
</button>
</div>
) : (
<button
type="button"
onClick={() => setConfirmDelete(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;avis
</button>
)}
<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…" : "Enregistrer"}
</button>
</div>
</fieldset>
</form>
);
}

View file

@ -0,0 +1,52 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { getReviewForAdmin } from "@/lib/admin/reviews";
import { ReviewForm } from "./_components/ReviewForm";
export const dynamic = "force-dynamic";
type PageProps = { params: Promise<{ id: string }> };
export default async function ReviewDetailPage({ params }: PageProps) {
const { id } = await params;
const review = await getReviewForAdmin(id);
if (!review) notFound();
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" });
return (
<div className="mx-auto max-w-3xl space-y-6">
<header className="mt-2">
<Link href="/admin/reviews" className="text-xs text-zinc-500 hover:text-zinc-900">
Tous les avis
</Link>
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">
Avis de {review.author.firstName} {review.author.lastName}
</h1>
<p className="mt-1 text-sm text-zinc-500">
Sur{" "}
<Link href={`/admin/carbets/${review.carbet.id}`} className="text-zinc-900 hover:underline">
{review.carbet.title}
</Link>{" "}
· réservation{" "}
<Link href={`/admin/bookings/${review.booking.id}`} className="font-mono text-zinc-900 hover:underline">
{review.booking.id.slice(0, 12)}
</Link>{" "}
· publié le {dateFmt.format(review.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">Modération</h2>
<ReviewForm
id={review.id}
initial={{
rating: review.rating,
comment: review.comment,
hostResponse: review.hostResponse,
}}
/>
</section>
</div>
);
}

View file

@ -0,0 +1,59 @@
"use server";
import { revalidatePath } from "next/cache";
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.reviews", event, target, actor, details, at: new Date().toISOString() }));
}
const updateSchema = z.object({
rating: z.coerce.number().int().min(1).max(5),
comment: z.string().trim().max(5000).optional().nullable(),
hostResponse: z.string().trim().max(5000).optional().nullable(),
});
export async function updateReviewAction(id: string, fd: FormData) {
await requireRole([UserRole.ADMIN]);
const obj = Object.fromEntries(fd.entries());
const parsed = updateSchema.safeParse({
rating: obj.rating,
comment: obj.comment === "" ? null : obj.comment,
hostResponse: obj.hostResponse === "" ? null : obj.hostResponse,
});
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
}
const session = await auth();
const current = await prisma.review.findUnique({ where: { id }, select: { hostResponse: true, hostRespondedAt: true } });
const hostRespondedAt =
parsed.data.hostResponse && parsed.data.hostResponse !== current?.hostResponse
? new Date()
: current?.hostRespondedAt ?? null;
await prisma.review.update({
where: { id },
data: {
rating: parsed.data.rating,
comment: parsed.data.comment ?? null,
hostResponse: parsed.data.hostResponse ?? null,
hostRespondedAt: parsed.data.hostResponse ? hostRespondedAt : null,
},
});
await audit("review.update", id, session?.user?.email ?? null, { rating: parsed.data.rating });
revalidatePath("/admin/reviews");
revalidatePath(`/admin/reviews/${id}`);
return { ok: true as const };
}
export async function deleteReviewAction(id: string) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
await prisma.review.delete({ where: { id } });
await audit("review.delete", id, session?.user?.email ?? null, {});
revalidatePath("/admin/reviews");
return { ok: true as const };
}

View file

@ -0,0 +1,134 @@
import Link from "next/link";
import { listReviewsAdmin } from "@/lib/admin/reviews";
export const dynamic = "force-dynamic";
type PageProps = {
searchParams: Promise<{
q?: string;
rating?: string;
withResponse?: string;
}>;
};
function Stars({ rating }: { rating: number }) {
return (
<span className="font-mono text-sm">
<span className="text-amber-500">{"★".repeat(rating)}</span>
<span className="text-zinc-300">{"★".repeat(5 - rating)}</span>
</span>
);
}
export default async function ReviewsAdminPage({ searchParams }: PageProps) {
const sp = await searchParams;
const rating = sp.rating && /^[1-5]$/.test(sp.rating) ? Number(sp.rating) : undefined;
const withResponse = sp.withResponse === "yes" || sp.withResponse === "no" ? (sp.withResponse as "yes" | "no") : undefined;
const filters = {
q: sp.q?.trim() || undefined,
rating,
withResponse,
};
const reviews = await listReviewsAdmin(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">Avis &amp; modération</h1>
<p className="mt-1 text-sm text-zinc-500">
{reviews.length} résultat{reviews.length > 1 ? "s" : ""}
{reviews.length === 200 ? " (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 commentaire, auteur, carbet…"
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="rating"
defaultValue={sp.rating ?? ""}
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="">Toutes notes</option>
{[5, 4, 3, 2, 1].map((r) => (
<option key={r} value={String(r)}>{r} étoile{r > 1 ? "s" : ""}</option>
))}
</select>
<select
name="withResponse"
defaultValue={filters.withResponse ?? ""}
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="">Avec ou sans réponse</option>
<option value="yes">Avec réponse hôte</option>
<option value="no">Sans réponse hôte</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.rating || filters.withResponse) ? (
<Link href="/admin/reviews" className="text-sm text-zinc-500 hover:text-zinc-900">
Réinit.
</Link>
) : null}
</form>
<div className="space-y-3">
{reviews.length === 0 ? (
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-8 text-center text-sm text-zinc-500">
Aucun avis ne correspond aux filtres.
</div>
) : null}
{reviews.map((r) => (
<article key={r.id} className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm">
<header className="mb-2 flex flex-wrap items-baseline justify-between gap-2">
<div className="flex items-center gap-3">
<Stars rating={r.rating} />
<Link href={`/admin/reviews/${r.id}`} className="text-sm font-semibold text-zinc-900 hover:underline">
{r.author.firstName} {r.author.lastName}
</Link>
<span className="text-[11px] text-zinc-500">{r.author.email}</span>
</div>
<div className="flex items-center gap-2 text-[11px] text-zinc-500">
<Link href={`/admin/carbets/${r.carbet.id}`} className="hover:text-zinc-900 hover:underline">
{r.carbet.title}
</Link>
· <Link href={`/admin/bookings/${r.booking.id}`} className="font-mono hover:text-zinc-900 hover:underline">
résa {r.booking.id.slice(0, 8)}
</Link>
· {dateFmt.format(r.createdAt)}
</div>
</header>
{r.comment ? (
<p className="whitespace-pre-line text-sm text-zinc-800">{r.comment}</p>
) : (
<p className="text-sm italic text-zinc-400">Pas de commentaire.</p>
)}
{r.hostResponse ? (
<div className="mt-2 rounded border border-emerald-100 bg-emerald-50 px-3 py-2 text-sm text-emerald-900">
<div className="text-[11px] font-semibold uppercase tracking-wider text-emerald-700">Réponse hôte</div>
<p className="whitespace-pre-line">{r.hostResponse}</p>
</div>
) : null}
<div className="mt-2 flex items-center justify-end">
<Link
href={`/admin/reviews/${r.id}`}
className="text-xs font-semibold text-zinc-700 hover:text-zinc-900 hover:underline"
>
Modérer
</Link>
</div>
</article>
))}
</div>
</div>
);
}

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>
);
}

View file

@ -6,6 +6,12 @@ const TONES = {
confirmed: "bg-emerald-100 text-emerald-800 ring-emerald-300",
cancelled: "bg-rose-100 text-rose-700 ring-rose-300",
completed: "bg-zinc-100 text-zinc-700 ring-zinc-300",
authorized: "bg-indigo-100 text-indigo-800 ring-indigo-300",
succeeded: "bg-emerald-100 text-emerald-800 ring-emerald-300",
failed: "bg-rose-100 text-rose-700 ring-rose-300",
refunded: "bg-amber-100 text-amber-800 ring-amber-300",
active: "bg-emerald-100 text-emerald-800 ring-emerald-300",
inactive: "bg-zinc-100 text-zinc-500 ring-zinc-300",
} as const;
const LABELS: Record<string, string> = {
@ -16,6 +22,12 @@ const LABELS: Record<string, string> = {
CONFIRMED: "Confirmé",
CANCELLED: "Annulé",
COMPLETED: "Terminé",
AUTHORIZED: "Autorisé",
SUCCEEDED: "Payé",
FAILED: "Échec",
REFUNDED: "Remboursé",
ACTIVE: "Actif",
INACTIVE: "Inactif",
};
export function StatusBadge({ status }: { status: string }) {

100
src/lib/admin/bookings.ts Normal file
View file

@ -0,0 +1,100 @@
import "server-only";
import { Prisma } from "@/generated/prisma/client";
import { BookingStatus, PaymentStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/prisma";
export type AdminBookingFilters = {
q?: string;
status?: BookingStatus;
paymentStatus?: PaymentStatus;
carbetId?: string;
tenantId?: string;
from?: Date;
to?: Date;
};
export type AdminBookingListItem = {
id: string;
startDate: Date;
endDate: Date;
guestCount: number;
status: BookingStatus;
paymentStatus: PaymentStatus;
amount: string;
currency: string;
createdAt: Date;
carbet: { id: string; title: string; slug: string };
tenant: { id: string; firstName: string; lastName: string; email: string };
};
export async function listBookingsAdmin(
filters: AdminBookingFilters = {},
): Promise<AdminBookingListItem[]> {
const where: Prisma.BookingWhereInput = {};
if (filters.q) {
where.OR = [
{ id: { contains: filters.q, mode: "insensitive" } },
{ tenant: { email: { contains: filters.q, mode: "insensitive" } } },
{ tenant: { firstName: { contains: filters.q, mode: "insensitive" } } },
{ tenant: { lastName: { contains: filters.q, mode: "insensitive" } } },
{ carbet: { title: { contains: filters.q, mode: "insensitive" } } },
{ carbet: { slug: { contains: filters.q, mode: "insensitive" } } },
];
}
if (filters.status) where.status = filters.status;
if (filters.paymentStatus) where.paymentStatus = filters.paymentStatus;
if (filters.carbetId) where.carbetId = filters.carbetId;
if (filters.tenantId) where.tenantId = filters.tenantId;
if (filters.from || filters.to) {
where.startDate = {};
if (filters.from) where.startDate.gte = filters.from;
if (filters.to) where.startDate.lte = filters.to;
}
const rows = await prisma.booking.findMany({
where,
orderBy: [{ createdAt: "desc" }],
take: 200,
select: {
id: true,
startDate: true,
endDate: true,
guestCount: true,
status: true,
paymentStatus: true,
amount: true,
currency: true,
createdAt: true,
carbet: { select: { id: true, title: true, slug: true } },
tenant: { select: { id: true, firstName: true, lastName: true, email: true } },
},
});
return rows.map((r) => ({
...r,
amount: r.amount.toString(),
}));
}
export async function getBookingForAdmin(id: string) {
return prisma.booking.findUnique({
where: { id },
include: {
carbet: {
select: {
id: true,
title: true,
slug: true,
river: true,
owner: { select: { id: true, firstName: true, lastName: true, email: true } },
},
},
tenant: {
select: { id: true, firstName: true, lastName: true, email: true, phone: true, role: true },
},
review: { select: { id: true, rating: true, createdAt: true } },
},
});
}

69
src/lib/admin/reviews.ts Normal file
View file

@ -0,0 +1,69 @@
import "server-only";
import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/prisma";
export type AdminReviewFilters = {
q?: string;
carbetId?: string;
rating?: number;
withResponse?: "yes" | "no";
};
export type AdminReviewListItem = {
id: string;
rating: number;
comment: string | null;
hostResponse: string | null;
hostRespondedAt: Date | null;
createdAt: Date;
carbet: { id: string; title: string; slug: string };
author: { id: string; firstName: string; lastName: string; email: string };
booking: { id: string };
};
export async function listReviewsAdmin(filters: AdminReviewFilters = {}): Promise<AdminReviewListItem[]> {
const where: Prisma.ReviewWhereInput = {};
if (filters.q) {
where.OR = [
{ id: { contains: filters.q, mode: "insensitive" } },
{ comment: { contains: filters.q, mode: "insensitive" } },
{ author: { email: { contains: filters.q, mode: "insensitive" } } },
{ author: { firstName: { contains: filters.q, mode: "insensitive" } } },
{ author: { lastName: { contains: filters.q, mode: "insensitive" } } },
{ carbet: { title: { contains: filters.q, mode: "insensitive" } } },
];
}
if (filters.carbetId) where.carbetId = filters.carbetId;
if (filters.rating) where.rating = filters.rating;
if (filters.withResponse === "yes") where.hostResponse = { not: null };
if (filters.withResponse === "no") where.hostResponse = null;
return prisma.review.findMany({
where,
orderBy: [{ createdAt: "desc" }],
take: 200,
select: {
id: true,
rating: true,
comment: true,
hostResponse: true,
hostRespondedAt: true,
createdAt: true,
booking: { select: { id: true } },
carbet: { select: { id: true, title: true, slug: true } },
author: { select: { id: true, firstName: true, lastName: true, email: true } },
},
});
}
export async function getReviewForAdmin(id: string) {
return prisma.review.findUnique({
where: { id },
include: {
booking: { select: { id: true, startDate: true, endDate: true, amount: true, currency: true } },
carbet: { select: { id: true, title: true, slug: true } },
author: { select: { id: true, firstName: true, lastName: true, email: true } },
},
});
}

91
src/lib/admin/users.ts Normal file
View file

@ -0,0 +1,91 @@
import "server-only";
import { Prisma } from "@/generated/prisma/client";
import { UserRole } from "@/generated/prisma/enums";
import { prisma } from "@/lib/prisma";
export type AdminUserFilters = {
q?: string;
role?: UserRole;
active?: "yes" | "no";
};
export type AdminUserListItem = {
id: string;
email: string;
firstName: string;
lastName: string;
role: UserRole;
isActive: boolean;
createdAt: Date;
carbetsCount: number;
bookingsCount: number;
reviewsCount: number;
};
export async function listUsersAdmin(filters: AdminUserFilters = {}): Promise<AdminUserListItem[]> {
const where: Prisma.UserWhereInput = {};
if (filters.q) {
where.OR = [
{ email: { contains: filters.q, mode: "insensitive" } },
{ firstName: { contains: filters.q, mode: "insensitive" } },
{ lastName: { contains: filters.q, mode: "insensitive" } },
{ phone: { contains: filters.q, mode: "insensitive" } },
];
}
if (filters.role) where.role = filters.role;
if (filters.active === "yes") where.isActive = true;
if (filters.active === "no") where.isActive = false;
const rows = await prisma.user.findMany({
where,
orderBy: [{ createdAt: "desc" }],
take: 300,
select: {
id: true,
email: true,
firstName: true,
lastName: true,
role: true,
isActive: true,
createdAt: true,
_count: { select: { carbets: true, bookings: true, reviews: true } },
},
});
return rows.map((u) => ({
id: u.id,
email: u.email,
firstName: u.firstName,
lastName: u.lastName,
role: u.role,
isActive: u.isActive,
createdAt: u.createdAt,
carbetsCount: u._count.carbets,
bookingsCount: u._count.bookings,
reviewsCount: u._count.reviews,
}));
}
export async function getUserForAdmin(id: string) {
return prisma.user.findUnique({
where: { id },
include: {
organization: { select: { id: true, name: true } },
_count: { select: { carbets: true, bookings: true, reviews: true, subscriptions: true } },
bookings: {
take: 10,
orderBy: { createdAt: "desc" },
select: {
id: true, status: true, paymentStatus: true, startDate: true, endDate: true, amount: true, currency: true,
carbet: { select: { id: true, title: true } },
},
},
carbets: {
take: 10,
orderBy: { updatedAt: "desc" },
select: { id: true, title: true, slug: true, status: true, updatedAt: true },
},
},
});
}