Merge pull request 'feat: espace hôte dashboard + lightbox galerie' (#59) from feat/host-dashboard-and-lightbox into main
Some checks failed
CI / test (push) Has been cancelled
Some checks failed
CI / test (push) Has been cancelled
This commit is contained in:
commit
d1a1bb04de
4 changed files with 527 additions and 50 deletions
|
|
@ -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<number | null>(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 (
|
||||
<div className="flex aspect-[16/9] w-full items-center justify-center rounded-lg bg-zinc-100 text-sm text-zinc-400">
|
||||
|
|
@ -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 (
|
||||
<div className="space-y-3">
|
||||
<figure className="overflow-hidden rounded-lg bg-zinc-100">
|
||||
{cover.type === MediaType.VIDEO ? (
|
||||
<video
|
||||
src={cover.url}
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
className="aspect-[16/9] w-full bg-black object-contain"
|
||||
/>
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={cover.url}
|
||||
alt={`Photo principale de ${title}`}
|
||||
className="aspect-[16/9] w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</figure>
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActive(0)}
|
||||
className="block w-full overflow-hidden rounded-lg bg-zinc-100 transition hover:opacity-95"
|
||||
aria-label="Ouvrir la photo principale en grand"
|
||||
>
|
||||
{cover.type === MediaType.VIDEO ? (
|
||||
<video
|
||||
src={cover.url}
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
className="aspect-[16/9] w-full bg-black object-contain"
|
||||
/>
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={cover.url}
|
||||
alt={`Photo principale de ${title}`}
|
||||
className="aspect-[16/9] w-full cursor-zoom-in object-cover"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{rest.length > 0 ? (
|
||||
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{rest.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
className="overflow-hidden rounded-md bg-zinc-100"
|
||||
>
|
||||
{item.type === MediaType.VIDEO ? (
|
||||
<video
|
||||
src={item.url}
|
||||
preload="metadata"
|
||||
controls
|
||||
playsInline
|
||||
className="aspect-square w-full bg-black object-contain"
|
||||
/>
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={item.url}
|
||||
alt={`Média de ${title}`}
|
||||
loading="lazy"
|
||||
className="aspect-square w-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{rest.length > 0 ? (
|
||||
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{rest.map((item, idx) => (
|
||||
<li key={item.id} className="overflow-hidden rounded-md bg-zinc-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActive(idx + 1)}
|
||||
className="block w-full"
|
||||
aria-label="Ouvrir en grand"
|
||||
>
|
||||
{item.type === MediaType.VIDEO ? (
|
||||
<video
|
||||
src={item.url}
|
||||
preload="metadata"
|
||||
controls
|
||||
playsInline
|
||||
className="aspect-square w-full bg-black object-contain"
|
||||
/>
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={item.url}
|
||||
alt={`Média de ${title}`}
|
||||
loading="lazy"
|
||||
className="aspect-square w-full cursor-zoom-in object-cover transition hover:scale-105"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{current ? (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 backdrop-blur-sm"
|
||||
onClick={close}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Galerie photo"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
className="absolute right-4 top-4 rounded-full bg-white/10 p-2 text-white hover:bg-white/20"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M6 6 L18 18 M6 18 L18 6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{media.length > 1 ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
prev();
|
||||
}}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white hover:bg-white/20"
|
||||
aria-label="Précédent"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M15 6 L9 12 L15 18" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
next();
|
||||
}}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white hover:bg-white/20"
|
||||
aria-label="Suivant"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M9 6 L15 12 L9 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="max-h-[88vh] max-w-[92vw]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{current.type === MediaType.VIDEO ? (
|
||||
<video
|
||||
src={current.url}
|
||||
controls
|
||||
autoPlay
|
||||
playsInline
|
||||
className="max-h-[88vh] max-w-[92vw] object-contain"
|
||||
/>
|
||||
) : (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={current.url}
|
||||
alt={`Photo ${active! + 1} sur ${media.length} de ${title}`}
|
||||
className="max-h-[88vh] max-w-[92vw] object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full bg-white/10 px-3 py-1 text-xs text-white">
|
||||
{active! + 1} / {media.length}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
77
src/app/espace-hote/_components/BookingDecision.tsx
Normal file
77
src/app/espace-hote/_components/BookingDecision.tsx
Normal file
|
|
@ -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<string | null>(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 (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{confirmReject ? (
|
||||
<div className="flex items-center gap-1.5 rounded border border-rose-300 bg-rose-50 px-2 py-1">
|
||||
<span className="text-xs text-rose-900">Refuser ?</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reject}
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmReject(false)}
|
||||
disabled={pending}
|
||||
className="text-[11px] text-zinc-500 hover:text-zinc-900"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={accept}
|
||||
disabled={pending}
|
||||
className="rounded bg-emerald-600 px-2.5 py-1 text-[11px] font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
Confirmer
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmReject(true)}
|
||||
disabled={pending}
|
||||
className="rounded border border-rose-300 bg-white px-2.5 py-1 text-[11px] font-semibold text-rose-700 hover:bg-rose-50 disabled:opacity-50"
|
||||
>
|
||||
Refuser
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{error ? <span className="text-[11px] text-rose-700">{error}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
src/app/espace-hote/actions.ts
Normal file
75
src/app/espace-hote/actions.ts
Normal file
|
|
@ -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 };
|
||||
}
|
||||
203
src/lib/host-dashboard.ts
Normal file
203
src/lib/host-dashboard.ts
Normal file
|
|
@ -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<HostKpis> {
|
||||
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<HostRecentBooking[]> {
|
||||
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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue