From 1e6acf29b9cd906917ca72c1aab1e1ae825b491a Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Mon, 1 Jun 2026 16:16:25 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20dashboard=20espace=20h=C3=B4te=20(KPIs?= =?UTF-8?q?=20+=20r=C3=A9sa=20pending=20+=20carbets=20+=20activit=C3=A9)?= =?UTF-8?q?=20+=20lightbox=20galerie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../carbets/_components/carbet-gallery.tsx | 222 ++++++++++++++---- .../_components/BookingDecision.tsx | 77 ++++++ src/app/espace-hote/actions.ts | 75 ++++++ src/lib/host-dashboard.ts | 203 ++++++++++++++++ 4 files changed, 527 insertions(+), 50 deletions(-) create mode 100644 src/app/espace-hote/_components/BookingDecision.tsx create mode 100644 src/app/espace-hote/actions.ts create mode 100644 src/lib/host-dashboard.ts diff --git a/src/app/carbets/_components/carbet-gallery.tsx b/src/app/carbets/_components/carbet-gallery.tsx index 807adda..a5c7ca1 100644 --- a/src/app/carbets/_components/carbet-gallery.tsx +++ b/src/app/carbets/_components/carbet-gallery.tsx @@ -1,3 +1,7 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + import type { PublicCarbetMedia } from "@/lib/carbet-public"; import { MediaType } from "@/generated/prisma/enums"; @@ -6,9 +10,36 @@ type Props = { media: PublicCarbetMedia[]; }; -// SSR-friendly gallery: shows a cover (photo or video) plus a strip of -// secondary media. No client component — all native HTML controls. +/** + * Galerie publique : grille de vignettes ; clic = lightbox plein écran avec + * navigation prev/next, fermeture par Esc ou clic backdrop. Pas de dep externe. + */ export function CarbetGallery({ title, media }: Props) { + const [active, setActive] = useState(null); + + const close = useCallback(() => setActive(null), []); + const next = useCallback(() => { + setActive((i) => (i === null ? null : (i + 1) % media.length)); + }, [media.length]); + const prev = useCallback(() => { + setActive((i) => (i === null ? null : (i - 1 + media.length) % media.length)); + }, [media.length]); + + useEffect(() => { + if (active === null) return; + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") close(); + else if (e.key === "ArrowRight") next(); + else if (e.key === "ArrowLeft") prev(); + } + window.addEventListener("keydown", onKey); + document.body.style.overflow = "hidden"; + return () => { + window.removeEventListener("keydown", onKey); + document.body.style.overflow = ""; + }; + }, [active, close, next, prev]); + if (media.length === 0) { return (
@@ -17,57 +48,148 @@ export function CarbetGallery({ title, media }: Props) { ); } - const [cover, ...rest] = media; + const cover = media[0]; + const rest = media.slice(1); + const current = active === null ? null : media[active]; return ( -
-
- {cover.type === MediaType.VIDEO ? ( -
+ <> +
+ - {rest.length > 0 ? ( -
    - {rest.map((item) => ( -
  • - {item.type === MediaType.VIDEO ? ( -
  • - ))} -
+ {rest.length > 0 ? ( +
    + {rest.map((item, idx) => ( +
  • + +
  • + ))} +
+ ) : null} +
+ + {current ? ( +
+ + + {media.length > 1 ? ( + <> + + + + ) : null} + +
e.stopPropagation()} + > + {current.type === MediaType.VIDEO ? ( +
+ +
+ {active! + 1} / {media.length} +
+
) : null} -
+ ); } diff --git a/src/app/espace-hote/_components/BookingDecision.tsx b/src/app/espace-hote/_components/BookingDecision.tsx new file mode 100644 index 0000000..9380ea2 --- /dev/null +++ b/src/app/espace-hote/_components/BookingDecision.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; + +import { confirmBookingAsHost, rejectBookingAsHost } from "../actions"; + +export function BookingDecision({ bookingId }: { bookingId: string }) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [confirmReject, setConfirmReject] = useState(false); + const [error, setError] = useState(null); + + function accept() { + setError(null); + startTransition(async () => { + const res = await confirmBookingAsHost(bookingId); + if (res && res.ok === false) setError(res.error); + router.refresh(); + }); + } + function reject() { + setError(null); + startTransition(async () => { + const res = await rejectBookingAsHost(bookingId); + if (res && res.ok === false) setError(res.error); + setConfirmReject(false); + router.refresh(); + }); + } + + return ( +
+ {confirmReject ? ( +
+ Refuser ? + + +
+ ) : ( + <> + + + + )} + {error ? {error} : null} +
+ ); +} diff --git a/src/app/espace-hote/actions.ts b/src/app/espace-hote/actions.ts new file mode 100644 index 0000000..fa9208c --- /dev/null +++ b/src/app/espace-hote/actions.ts @@ -0,0 +1,75 @@ +"use server"; + +import { revalidatePath } from "next/cache"; + +import { auth } from "@/auth"; +import { BookingStatus, UserRole } from "@/generated/prisma/enums"; +import { prisma } from "@/lib/prisma"; +import { recordAudit } from "@/lib/admin/audit"; +import { sendBookingConfirmed } from "@/lib/email"; + +async function requireBookingOwnership(bookingId: string) { + const session = await auth(); + if (!session?.user?.id) throw new Error("Non authentifié"); + const booking = await prisma.booking.findUnique({ + where: { id: bookingId }, + include: { + carbet: { select: { ownerId: true, title: true } }, + tenant: { select: { email: true, firstName: true } }, + }, + }); + if (!booking) throw new Error("Réservation introuvable"); + const isAdmin = session.user.role === UserRole.ADMIN; + if (!isAdmin && booking.carbet.ownerId !== session.user.id) { + throw new Error("Accès refusé"); + } + return { session, booking }; +} + +export async function confirmBookingAsHost(bookingId: string) { + const { session, booking } = await requireBookingOwnership(bookingId); + if (booking.status !== BookingStatus.PENDING) { + return { ok: false as const, error: "Cette réservation ne peut plus être confirmée." }; + } + const updated = await prisma.booking.update({ + where: { id: bookingId }, + data: { status: BookingStatus.CONFIRMED }, + }); + await recordAudit({ + scope: "host.bookings", + event: "confirm", + target: bookingId, + actorEmail: session.user.email ?? null, + details: {}, + }); + sendBookingConfirmed( + booking.tenant.email, + booking.tenant.firstName, + bookingId, + booking.carbet.title, + updated.startDate, + updated.endDate, + ).catch(() => {}); + revalidatePath("/espace-hote"); + return { ok: true as const }; +} + +export async function rejectBookingAsHost(bookingId: string) { + const { session, booking } = await requireBookingOwnership(bookingId); + if (booking.status !== BookingStatus.PENDING) { + return { ok: false as const, error: "Cette réservation ne peut plus être refusée." }; + } + await prisma.booking.update({ + where: { id: bookingId }, + data: { status: BookingStatus.CANCELLED }, + }); + await recordAudit({ + scope: "host.bookings", + event: "reject", + target: bookingId, + actorEmail: session.user.email ?? null, + details: {}, + }); + revalidatePath("/espace-hote"); + return { ok: true as const }; +} diff --git a/src/lib/host-dashboard.ts b/src/lib/host-dashboard.ts new file mode 100644 index 0000000..586ff65 --- /dev/null +++ b/src/lib/host-dashboard.ts @@ -0,0 +1,203 @@ +import "server-only"; + +import { BookingStatus, PaymentStatus, UserRole } from "@/generated/prisma/enums"; +import { prisma } from "@/lib/prisma"; + +export type HostKpis = { + revenueTotal: string; + revenue30d: string; + revenue365d: string; + bookingsPending: number; + bookingsConfirmedUpcoming: number; + bookingsTotal: number; + carbetsCount: number; + carbetsPublished: number; + occupancyRate30d: number; // 0..1 + nextArrival: { id: string; carbetTitle: string; startDate: Date; tenantName: string } | null; +}; + +type Scope = { ownerId: string; isAdmin: boolean }; + +function scopeWhere(scope: Scope) { + return scope.isAdmin ? {} : { carbet: { ownerId: scope.ownerId } }; +} + +function carbetWhere(scope: Scope) { + return scope.isAdmin ? {} : { ownerId: scope.ownerId }; +} + +export async function getHostKpis(scope: Scope): Promise { + const now = new Date(); + const last30 = new Date(now.getTime() - 30 * 86_400_000); + const last365 = new Date(now.getTime() - 365 * 86_400_000); + + const [revAll, rev30, rev365, pending, upcomingConfirmed, total, carbetsTotal, carbetsPub, nextArrival] = + await Promise.all([ + prisma.booking.aggregate({ + where: { + ...scopeWhere(scope), + status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED] }, + paymentStatus: { in: [PaymentStatus.SUCCEEDED, PaymentStatus.AUTHORIZED] }, + }, + _sum: { amount: true }, + }), + prisma.booking.aggregate({ + where: { + ...scopeWhere(scope), + status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED] }, + paymentStatus: { in: [PaymentStatus.SUCCEEDED, PaymentStatus.AUTHORIZED] }, + createdAt: { gte: last30 }, + }, + _sum: { amount: true }, + }), + prisma.booking.aggregate({ + where: { + ...scopeWhere(scope), + status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED] }, + paymentStatus: { in: [PaymentStatus.SUCCEEDED, PaymentStatus.AUTHORIZED] }, + createdAt: { gte: last365 }, + }, + _sum: { amount: true }, + }), + prisma.booking.count({ + where: { ...scopeWhere(scope), status: BookingStatus.PENDING }, + }), + prisma.booking.count({ + where: { + ...scopeWhere(scope), + status: BookingStatus.CONFIRMED, + startDate: { gte: now }, + }, + }), + prisma.booking.count({ where: scopeWhere(scope) }), + prisma.carbet.count({ where: carbetWhere(scope) }), + prisma.carbet.count({ where: { ...carbetWhere(scope), status: "PUBLISHED" } }), + prisma.booking.findFirst({ + where: { + ...scopeWhere(scope), + status: BookingStatus.CONFIRMED, + startDate: { gte: now }, + }, + orderBy: { startDate: "asc" }, + select: { + id: true, + startDate: true, + carbet: { select: { title: true } }, + tenant: { select: { firstName: true, lastName: true } }, + }, + }), + ]); + + // Taux d'occupation 30j : nuits réservées / (carbets publiés × 30) + const occupiedNights = await prisma.booking.findMany({ + where: { + ...scopeWhere(scope), + status: { in: [BookingStatus.CONFIRMED, BookingStatus.COMPLETED] }, + startDate: { lt: now }, + endDate: { gte: last30 }, + }, + select: { startDate: true, endDate: true }, + }); + let totalNightsOccupied = 0; + for (const b of occupiedNights) { + const s = Math.max(b.startDate.getTime(), last30.getTime()); + const e = Math.min(b.endDate.getTime(), now.getTime()); + if (e > s) totalNightsOccupied += Math.floor((e - s) / 86_400_000); + } + const denom = Math.max(1, carbetsPub * 30); + const occupancyRate30d = Math.min(1, totalNightsOccupied / denom); + + return { + revenueTotal: (revAll._sum.amount ?? 0).toString(), + revenue30d: (rev30._sum.amount ?? 0).toString(), + revenue365d: (rev365._sum.amount ?? 0).toString(), + bookingsPending: pending, + bookingsConfirmedUpcoming: upcomingConfirmed, + bookingsTotal: total, + carbetsCount: carbetsTotal, + carbetsPublished: carbetsPub, + occupancyRate30d, + nextArrival: nextArrival + ? { + id: nextArrival.id, + carbetTitle: nextArrival.carbet.title, + startDate: nextArrival.startDate, + tenantName: `${nextArrival.tenant.firstName} ${nextArrival.tenant.lastName}`.trim(), + } + : null, + }; +} + +export type HostRecentBooking = { + id: string; + carbetId: string; + carbetTitle: string; + carbetSlug: string; + tenantName: string; + startDate: Date; + endDate: Date; + guestCount: number; + status: BookingStatus; + paymentStatus: PaymentStatus; + amount: string; + currency: string; +}; + +export async function listHostRecentBookings( + scope: Scope, + limit = 10, +): Promise { + const rows = await prisma.booking.findMany({ + where: scopeWhere(scope), + orderBy: [{ status: "asc" }, { createdAt: "desc" }], + take: limit, + select: { + id: true, + startDate: true, + endDate: true, + guestCount: true, + status: true, + paymentStatus: true, + amount: true, + currency: true, + carbet: { select: { id: true, title: true, slug: true } }, + tenant: { select: { firstName: true, lastName: true } }, + }, + }); + return rows.map((r) => ({ + id: r.id, + carbetId: r.carbet.id, + carbetTitle: r.carbet.title, + carbetSlug: r.carbet.slug, + tenantName: `${r.tenant.firstName} ${r.tenant.lastName}`.trim(), + startDate: r.startDate, + endDate: r.endDate, + guestCount: r.guestCount, + status: r.status, + paymentStatus: r.paymentStatus, + amount: r.amount.toString(), + currency: r.currency, + })); +} + +export async function listHostCarbets(scope: Scope) { + const rows = await prisma.carbet.findMany({ + where: carbetWhere(scope), + orderBy: [{ updatedAt: "desc" }], + select: { + id: true, + slug: true, + title: true, + status: true, + nightlyPrice: true, + capacity: true, + river: true, + _count: { select: { bookings: true, reviews: true, media: true } }, + }, + }); + return rows.map((r) => ({ ...r, nightlyPrice: r.nightlyPrice.toString() })); +} + +export function isScopeAdmin(role: UserRole | string | undefined): boolean { + return role === UserRole.ADMIN; +}