diff --git a/src/app/admin/bookings/[id]/_components/BookingActions.tsx b/src/app/admin/bookings/[id]/_components/BookingActions.tsx new file mode 100644 index 0000000..b220b27 --- /dev/null +++ b/src/app/admin/bookings/[id]/_components/BookingActions.tsx @@ -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(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 ( +
+
+ Statut résa : + {status === BookingStatus.PENDING ? ( + + ) : null} + {status === BookingStatus.CONFIRMED ? ( + + ) : null} + {status !== BookingStatus.CANCELLED && status !== BookingStatus.COMPLETED ? ( + + ) : null} +
+ +
+ Paiement : + {paymentStatus !== PaymentStatus.SUCCEEDED && paymentStatus !== PaymentStatus.REFUNDED ? ( + + ) : null} + {paymentStatus !== PaymentStatus.FAILED && paymentStatus !== PaymentStatus.REFUNDED ? ( + + ) : null} + {paymentStatus === PaymentStatus.SUCCEEDED ? ( + confirmRefund ? ( +
+ Rembourser & annuler ? + + +
+ ) : ( + + ) + ) : null} +
+ + {error ? ( +
{error}
+ ) : null} +
+ ); +} diff --git a/src/app/admin/bookings/[id]/page.tsx b/src/app/admin/bookings/[id]/page.tsx new file mode 100644 index 0000000..185de60 --- /dev/null +++ b/src/app/admin/bookings/[id]/page.tsx @@ -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 ( +
+
+ + ← Toutes les réservations + +

+ Réservation {booking.id.slice(0, 12)} + + +

+

+ Créée le {dateTimeFmt.format(booking.createdAt)} · MAJ {dateTimeFmt.format(booking.updatedAt)} +

+
+ +
+

Actions

+ +
+ +
+
+

Séjour

+
+ + + 1 ? "s" : ""}`} /> + + +
+
+ +
+

Carbet

+
+ + {booking.carbet.title} + + } + /> + /{booking.carbet.slug}} /> + + + {booking.carbet.owner.firstName} {booking.carbet.owner.lastName} + + } + /> +
+
+ +
+

Locataire

+
+ + {booking.tenant.firstName} {booking.tenant.lastName} + + } + /> + + {booking.tenant.phone ? : null} + +
+
+ +
+

Avis

+ {booking.review ? ( +

+ Note {booking.review.rating}/5 · déposé le {dateFmt.format(booking.review.createdAt)} ·{" "} + + Voir l'avis + +

+ ) : ( +

Pas encore d'avis pour cette réservation.

+ )} +
+
+
+ ); +} + +function Row({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/src/app/admin/bookings/actions.ts b/src/app/admin/bookings/actions.ts new file mode 100644 index 0000000..79c4a4a --- /dev/null +++ b/src/app/admin/bookings/actions.ts @@ -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([ + BookingStatus.PENDING, + BookingStatus.CONFIRMED, + BookingStatus.CANCELLED, + BookingStatus.COMPLETED, +]); +const ALLOWED_PAYMENT = new Set([ + 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 }; +} diff --git a/src/app/admin/bookings/page.tsx b/src/app/admin/bookings/page.tsx new file mode 100644 index 0000000..c938c17 --- /dev/null +++ b/src/app/admin/bookings/page.tsx @@ -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([ + BookingStatus.PENDING, + BookingStatus.CONFIRMED, + BookingStatus.CANCELLED, + BookingStatus.COMPLETED, +]); +const PAYMENT_VALUES = new Set([ + 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 ( +
+
+
+

Réservations

+

+ {bookings.length} résultat{bookings.length > 1 ? "s" : ""} + {bookings.length === 200 ? " (limite atteinte — affinez les filtres)" : ""} +

+
+
+ +
+ + + + + + + {(filters.q || filters.status || filters.paymentStatus || filters.from || filters.to) ? ( + + Réinit. + + ) : null} +
+ +
+ + + + + + + + + + + + + + + + {bookings.length === 0 ? ( + + + + ) : null} + {bookings.map((b) => ( + + + + + + + + + + + + ))} + +
IDCarbetLocataireSéjourPers.MontantStatutPaiementCréé
+ Aucune réservation ne correspond aux filtres. +
+ + {b.id.slice(0, 10)}… + + + + {b.carbet.title} + +
+ /{b.carbet.slug} +
+
+ {b.tenant.firstName} {b.tenant.lastName} +
{b.tenant.email}
+
+ {dateFmt.format(b.startDate)} → {dateFmt.format(b.endDate)} + {b.guestCount} + {Number(b.amount).toFixed(2)} {b.currency} + + {dateFmt.format(b.createdAt)} +
+
+
+ ); +} diff --git a/src/app/admin/reviews/[id]/_components/ReviewForm.tsx b/src/app/admin/reviews/[id]/_components/ReviewForm.tsx new file mode 100644 index 0000000..a7b1662 --- /dev/null +++ b/src/app/admin/reviews/[id]/_components/ReviewForm.tsx @@ -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(null); + const [success, setSuccess] = useState(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 ( +
+
+
+ + +
+ +
+ +