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
156
src/app/admin/bookings/[id]/_components/BookingActions.tsx
Normal file
156
src/app/admin/bookings/[id]/_components/BookingActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
src/app/admin/bookings/[id]/page.tsx
Normal file
121
src/app/admin/bookings/[id]/page.tsx
Normal 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'avis
|
||||
</Link>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-zinc-500">Pas encore d'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>
|
||||
);
|
||||
}
|
||||
73
src/app/admin/bookings/actions.ts
Normal file
73
src/app/admin/bookings/actions.ts
Normal 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 };
|
||||
}
|
||||
184
src/app/admin/bookings/page.tsx
Normal file
184
src/app/admin/bookings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
src/app/admin/reviews/[id]/_components/ReviewForm.tsx
Normal file
134
src/app/admin/reviews/[id]/_components/ReviewForm.tsx
Normal 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'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'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>
|
||||
);
|
||||
}
|
||||
52
src/app/admin/reviews/[id]/page.tsx
Normal file
52
src/app/admin/reviews/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
src/app/admin/reviews/actions.ts
Normal file
59
src/app/admin/reviews/actions.ts
Normal 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 };
|
||||
}
|
||||
134
src/app/admin/reviews/page.tsx
Normal file
134
src/app/admin/reviews/page.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
100
src/lib/admin/bookings.ts
Normal 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
69
src/lib/admin/reviews.ts
Normal 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
91
src/lib/admin/users.ts
Normal 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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue