feat(rental): Sprint C — espace prestataire (signup+dashboard+items+calendrier+résa)
All checks were successful
CI / test (pull_request) Successful in 2m33s

This commit is contained in:
Claude Integration 2026-06-02 08:01:42 +00:00
parent 8d7e9cfdc2
commit 59786e5365
16 changed files with 1509 additions and 9 deletions

View file

@ -5,7 +5,7 @@ import { UserRole } from "@/generated/prisma/enums";
import { hashPassword } from "@/lib/password";
import { prisma } from "@/lib/prisma";
import { recordAudit } from "@/lib/admin/audit";
import { sendSignupWelcome } from "@/lib/email";
import { sendNewRentalProviderRequest, sendSignupWelcome } from "@/lib/email";
import { rateLimitRequest } from "@/lib/rate-limit";
export const runtime = "nodejs";
@ -16,11 +16,14 @@ const schema = z.object({
firstName: z.string().trim().min(1).max(100),
lastName: z.string().trim().min(1).max(100),
phone: z.string().trim().max(40).optional().nullable(),
role: z.enum([UserRole.TOURIST, UserRole.OWNER]).default(UserRole.TOURIST),
role: z
.enum([UserRole.TOURIST, UserRole.OWNER, UserRole.RENTAL_PROVIDER])
.default(UserRole.TOURIST),
providerName: z.string().trim().min(2).max(200).optional(),
providerRivers: z.array(z.string().trim().min(1).max(80)).max(20).optional(),
});
export async function POST(req: Request) {
// 5 inscriptions max par IP par heure.
const rl = rateLimitRequest(req, "signup", 60 * 60 * 1000, 5);
if (!rl.ok) {
return NextResponse.json(
@ -43,6 +46,10 @@ export async function POST(req: Request) {
}
const data = parsed.data;
if (data.role === UserRole.RENTAL_PROVIDER && (!data.providerName || data.providerName.trim().length < 2)) {
return NextResponse.json({ error: "Nom de votre activité requis." }, { status: 400 });
}
const existing = await prisma.user.findUnique({ where: { email: data.email }, select: { id: true } });
if (existing) {
return NextResponse.json({ error: "Un compte existe déjà avec cet email." }, { status: 409 });
@ -62,16 +69,36 @@ export async function POST(req: Request) {
select: { id: true, email: true, role: true },
});
// Pour un RENTAL_PROVIDER : crée le RentalProvider associé en attente d'approbation.
let createdProviderId: string | null = null;
if (user.role === UserRole.RENTAL_PROVIDER && data.providerName) {
const provider = await prisma.rentalProvider.create({
data: {
name: data.providerName,
isSystemD: false,
managedByUserId: user.id,
contactEmail: user.email,
contactPhone: data.phone?.trim() || null,
rivers: data.providerRivers ?? [],
commissionPct: 10, // valeur par défaut, ajustable par admin
active: true,
approved: false,
},
select: { id: true, name: true },
});
createdProviderId = provider.id;
sendNewRentalProviderRequest(provider.name, user.email).catch(() => {});
}
await recordAudit({
scope: "public.signup",
event: "user.create",
target: user.id,
actorEmail: user.email,
details: { role: user.role },
details: { role: user.role, rentalProviderId: createdProviderId },
});
// Best-effort welcome email.
sendSignupWelcome(user.email, data.firstName).catch(() => {});
return NextResponse.json({ ok: true, userId: user.id });
return NextResponse.json({ ok: true, userId: user.id, providerId: createdProviderId });
}

View file

@ -0,0 +1,237 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { auth } from "@/auth";
import { RentalBookingStatus, RentalCategory, UserRole } from "@/generated/prisma/enums";
import { canManageRentalProvider, getCurrentRentalProvider } from "@/lib/rental-access";
import { recordAudit } from "@/lib/admin/audit";
import { prisma } from "@/lib/prisma";
const itemSchema = z.object({
category: z.enum([
RentalCategory.SLEEP,
RentalCategory.NAVIGATION,
RentalCategory.FISHING,
RentalCategory.COOKING,
RentalCategory.SAFETY,
]),
name: z.string().trim().min(2).max(200),
description: z.string().trim().max(5000).nullable().optional(),
imageUrl: z.string().trim().url().max(500).nullable().optional(),
pricePerDay: z.coerce.number().min(0).max(10000),
pricePerWeek: z.coerce.number().min(0).max(50000).nullable().optional(),
deposit: z.coerce.number().min(0).max(10000),
totalQty: z.coerce.number().int().min(1).max(1000),
withMotor: z.boolean(),
fuelIncluded: z.boolean(),
requiresLicense: z.boolean(),
active: z.boolean(),
});
async function requireOwnedProvider(): Promise<{ providerId: string; actorEmail: string | null }> {
const session = await auth();
if (!session?.user?.id) throw new Error("Non authentifié");
const provider = await getCurrentRentalProvider();
if (!provider) throw new Error("Aucun provider associé");
return { providerId: provider.id, actorEmail: session.user.email ?? null };
}
function parseItemFD(fd: FormData) {
const get = (k: string) => {
const v = (fd.get(k) as string | null) ?? "";
return v.trim() === "" ? null : v.trim();
};
return {
category: ((fd.get("category") as string | null) ?? "").trim(),
name: ((fd.get("name") as string | null) ?? "").trim(),
description: get("description"),
imageUrl: get("imageUrl"),
pricePerDay: fd.get("pricePerDay"),
pricePerWeek: get("pricePerWeek"),
deposit: fd.get("deposit") ?? "0",
totalQty: fd.get("totalQty") ?? "1",
withMotor: fd.get("withMotor") === "on",
fuelIncluded: fd.get("fuelIncluded") === "on",
requiresLicense: fd.get("requiresLicense") === "on",
active: fd.get("active") === "on",
};
}
export async function createHostItemAction(fd: FormData) {
const { providerId, actorEmail } = await requireOwnedProvider();
const parsed = itemSchema.safeParse(parseItemFD(fd));
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
}
const created = await prisma.rentalItem.create({ data: { ...parsed.data, providerId } });
await recordAudit({
scope: "host.rental-items",
event: "create",
target: created.id,
actorEmail,
details: { name: created.name, providerId },
});
revalidatePath("/espace-prestataire/items");
redirect(`/espace-prestataire/items/${created.id}`);
}
export async function updateHostItemAction(itemId: string, fd: FormData) {
const { providerId, actorEmail } = await requireOwnedProvider();
const session = await auth();
if (!(await canManageRentalProvider(session!.user.id, session?.user?.role, providerId))) {
return { ok: false as const, error: "Accès refusé" };
}
const existing = await prisma.rentalItem.findUnique({ where: { id: itemId }, select: { providerId: true } });
if (!existing || existing.providerId !== providerId) {
return { ok: false as const, error: "Item introuvable." };
}
const parsed = itemSchema.safeParse(parseItemFD(fd));
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
}
await prisma.rentalItem.update({ where: { id: itemId }, data: parsed.data });
await recordAudit({
scope: "host.rental-items",
event: "update",
target: itemId,
actorEmail,
details: { name: parsed.data.name },
});
revalidatePath("/espace-prestataire/items");
revalidatePath(`/espace-prestataire/items/${itemId}`);
return { ok: true as const };
}
export async function deleteHostItemAction(itemId: string) {
const { providerId, actorEmail } = await requireOwnedProvider();
const existing = await prisma.rentalItem.findUnique({
where: { id: itemId },
select: { providerId: true, _count: { select: { lines: true } } },
});
if (!existing || existing.providerId !== providerId) {
return { ok: false as const, error: "Item introuvable." };
}
if (existing._count.lines > 0) {
return { ok: false as const, error: "Impossible : item référencé par des locations." };
}
await prisma.rentalItem.delete({ where: { id: itemId } });
await recordAudit({
scope: "host.rental-items",
event: "delete",
target: itemId,
actorEmail,
details: {},
});
revalidatePath("/espace-prestataire/items");
redirect("/espace-prestataire/items");
}
const blockSchema = z.object({
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
qty: z.coerce.number().int().min(1).max(1000),
reason: z.enum(["MAINTENANCE", "MANUAL_BLOCK"]),
});
export async function addItemBlockAction(itemId: string, fd: FormData) {
const { providerId, actorEmail } = await requireOwnedProvider();
const existing = await prisma.rentalItem.findUnique({ where: { id: itemId }, select: { providerId: true } });
if (!existing || existing.providerId !== providerId) {
return { ok: false as const, error: "Item introuvable." };
}
const parsed = blockSchema.safeParse({
startDate: fd.get("startDate"),
endDate: fd.get("endDate"),
qty: fd.get("qty"),
reason: fd.get("reason"),
});
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
}
const start = new Date(`${parsed.data.startDate}T00:00:00.000Z`);
const end = new Date(`${parsed.data.endDate}T00:00:00.000Z`);
if (end <= start) return { ok: false as const, error: "Date de fin doit être après début." };
await prisma.rentalItemAvailability.create({
data: {
itemId,
startDate: start,
endDate: end,
qty: parsed.data.qty,
reason: parsed.data.reason,
},
});
await recordAudit({
scope: "host.rental-items",
event: "block.add",
target: itemId,
actorEmail,
details: { ...parsed.data },
});
revalidatePath(`/espace-prestataire/items/${itemId}`);
return { ok: true as const };
}
export async function removeItemBlockAction(blockId: string) {
const { providerId, actorEmail } = await requireOwnedProvider();
const block = await prisma.rentalItemAvailability.findUnique({
where: { id: blockId },
select: { itemId: true, rentalBookingId: true, item: { select: { providerId: true } } },
});
if (!block || block.item.providerId !== providerId) {
return { ok: false as const, error: "Blocage introuvable." };
}
if (block.rentalBookingId) {
return { ok: false as const, error: "Blocage lié à une réservation : annulez la réservation à la place." };
}
await prisma.rentalItemAvailability.delete({ where: { id: blockId } });
await recordAudit({
scope: "host.rental-items",
event: "block.remove",
target: blockId,
actorEmail,
details: { itemId: block.itemId },
});
revalidatePath(`/espace-prestataire/items/${block.itemId}`);
return { ok: true as const };
}
const statusSchema = z.enum([
RentalBookingStatus.PENDING,
RentalBookingStatus.CONFIRMED,
RentalBookingStatus.HANDED_OVER,
RentalBookingStatus.RETURNED,
RentalBookingStatus.CANCELLED,
]);
export async function updateBookingStatusAction(bookingId: string, status: string) {
const { providerId, actorEmail } = await requireOwnedProvider();
const session = await auth();
const role = session?.user?.role;
const parsed = statusSchema.safeParse(status);
if (!parsed.success) return { ok: false as const, error: "Statut invalide." };
const existing = await prisma.rentalBooking.findUnique({
where: { id: bookingId },
select: { providerId: true },
});
if (!existing || (existing.providerId !== providerId && role !== UserRole.ADMIN)) {
return { ok: false as const, error: "Réservation introuvable." };
}
await prisma.rentalBooking.update({
where: { id: bookingId },
data: { status: parsed.data },
});
await recordAudit({
scope: "host.rental-bookings",
event: "status.update",
target: bookingId,
actorEmail,
details: { status: parsed.data },
});
revalidatePath("/espace-prestataire/reservations");
return { ok: true as const };
}

View file

@ -0,0 +1,151 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
type Block = {
id: string;
startDate: string;
endDate: string;
qty: number;
reason: string;
isBooking: boolean;
};
type Props = {
blocks: Block[];
totalQty: number;
addAction: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
removeAction: (blockId: string) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
};
const REASON_LABEL: Record<string, string> = {
MAINTENANCE: "🔧 Maintenance",
MANUAL_BLOCK: "⛔ Blocage personnel",
RENTAL_BOOKING: "🛒 Réservation",
};
export function ItemBlocksManager({ blocks, totalQty, addAction, removeAction }: Props) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
function onAdd(fd: FormData) {
setError(null);
startTransition(async () => {
const res = await addAction(fd);
if (res && res.ok === false) setError(res.error);
router.refresh();
});
}
function onRemove(blockId: string) {
setError(null);
startTransition(async () => {
const res = await removeAction(blockId);
if (res && res.ok === false) setError(res.error);
router.refresh();
});
}
return (
<div className="space-y-4">
<form action={onAdd} className="grid grid-cols-1 gap-2 rounded-md border border-zinc-200 bg-zinc-50 p-3 sm:grid-cols-5">
<fieldset disabled={pending} className="contents">
<label className="block text-xs">
<span className="text-zinc-600">Du</span>
<input
name="startDate"
type="date"
required
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5 text-sm"
/>
</label>
<label className="block text-xs">
<span className="text-zinc-600">Au</span>
<input
name="endDate"
type="date"
required
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5 text-sm"
/>
</label>
<label className="block text-xs">
<span className="text-zinc-600">Quantité</span>
<input
name="qty"
type="number"
min={1}
max={totalQty}
defaultValue={totalQty}
required
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5 text-sm"
/>
</label>
<label className="block text-xs">
<span className="text-zinc-600">Raison</span>
<select
name="reason"
defaultValue="MAINTENANCE"
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5 text-sm"
>
<option value="MAINTENANCE">Maintenance</option>
<option value="MANUAL_BLOCK">Blocage perso</option>
</select>
</label>
<div className="flex items-end">
<button
type="submit"
className="w-full rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{pending ? "…" : "Ajouter blocage"}
</button>
</div>
</fieldset>
</form>
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
) : null}
{blocks.length === 0 ? (
<p className="rounded border border-dashed border-zinc-200 px-3 py-6 text-center text-xs text-zinc-500">
Aucun blocage manuel. Toutes les dates sont disponibles.
</p>
) : (
<ul className="space-y-1.5">
{blocks.map((b) => (
<li
key={b.id}
className={
"flex items-center justify-between gap-3 rounded-md border px-3 py-1.5 text-sm " +
(b.isBooking ? "border-sky-200 bg-sky-50" : "border-amber-200 bg-amber-50")
}
>
<div>
<span className="font-medium text-zinc-900">
{b.startDate} {b.endDate}
</span>
<span className="ml-2 text-xs text-zinc-600">
{b.qty} unité{b.qty > 1 ? "s" : ""} · {REASON_LABEL[b.reason] ?? b.reason}
</span>
</div>
{!b.isBooking ? (
<button
type="button"
onClick={() => onRemove(b.id)}
disabled={pending}
className="text-xs font-semibold text-rose-700 hover:text-rose-900 disabled:opacity-50"
>
Supprimer
</button>
) : (
<span className="text-[10px] uppercase tracking-wider text-sky-700">Auto</span>
)}
</li>
))}
</ul>
)}
</div>
);
}

View file

@ -0,0 +1,71 @@
"use client";
import { useState, useTransition } from "react";
type Props = {
canDelete: boolean;
deleteAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined | void>;
};
export function ItemInlineDelete({ canDelete, deleteAction }: Props) {
const [pending, startTransition] = useTransition();
const [confirm, setConfirm] = useState(false);
const [error, setError] = useState<string | null>(null);
function run() {
setError(null);
startTransition(async () => {
const res = await deleteAction();
if (res && (res as { ok?: boolean }).ok === false) {
setError((res as { error: string }).error);
setConfirm(false);
}
});
}
if (!canDelete) {
return (
<span className="rounded border border-zinc-200 bg-zinc-50 px-2 py-1 text-[11px] text-zinc-500">
Suppression impossible item référencé par des locations
</span>
);
}
return (
<div className="flex flex-col items-end gap-1">
{confirm ? (
<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 ?</span>
<button
type="button"
onClick={run}
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={() => setConfirm(false)}
disabled={pending}
className="text-[11px] text-zinc-500 hover:text-zinc-900"
>
Annuler
</button>
</div>
) : (
<button
type="button"
onClick={() => setConfirm(true)}
disabled={pending}
className="rounded-md border border-rose-300 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
>
Supprimer l&apos;item
</button>
)}
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-2 py-1 text-xs text-rose-700">{error}</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,107 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { requireRentalProviderSession, getCurrentRentalProvider } from "@/lib/rental-access";
import { getHostItem } from "@/lib/rental-host";
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
import { HostItemForm } from "../_components/ItemForm";
import { ItemBlocksManager } from "./_components/ItemBlocksManager";
import { ItemInlineDelete } from "./_components/ItemInlineDelete";
import {
addItemBlockAction,
deleteHostItemAction,
removeItemBlockAction,
updateHostItemAction,
} from "../../actions";
export const dynamic = "force-dynamic";
type PageProps = { params: Promise<{ itemId: string }> };
export default async function EditHostItemPage({ params }: PageProps) {
await requireRentalProviderSession();
const provider = await getCurrentRentalProvider();
if (!provider) redirect("/admin/rental-providers");
const { itemId } = await params;
const item = await getHostItem(provider.id, itemId);
if (!item) notFound();
const updateThis = async (fd: FormData) => {
"use server";
return await updateHostItemAction(itemId, fd);
};
const deleteThis = async () => {
"use server";
return await deleteHostItemAction(itemId);
};
const addBlockThis = async (fd: FormData) => {
"use server";
return await addItemBlockAction(itemId, fd);
};
const removeBlockThis = async (blockId: string) => {
"use server";
return await removeItemBlockAction(blockId);
};
return (
<main className="mx-auto max-w-4xl px-6 py-10 space-y-6">
<header className="flex flex-wrap items-end justify-between gap-3">
<div>
<Link href="/espace-prestataire/items" className="text-xs text-zinc-500 hover:text-zinc-900">
Mes items
</Link>
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">{item.name}</h1>
<p className="mt-1 text-sm text-zinc-500">
{RENTAL_CATEGORY_LABEL[item.category]} · Stock : {item.totalQty} · {item._count.lines} location(s) historique
</p>
</div>
<ItemInlineDelete deleteAction={deleteThis} canDelete={item._count.lines === 0} />
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<HostItemForm
action={updateThis}
submitLabel="Enregistrer les modifications"
initial={{
category: item.category,
name: item.name,
description: item.description,
imageUrl: item.imageUrl,
pricePerDay: item.pricePerDay.toString(),
pricePerWeek: item.pricePerWeek?.toString() ?? null,
deposit: item.deposit.toString(),
totalQty: item.totalQty,
withMotor: item.withMotor,
fuelIncluded: item.fuelIncluded,
requiresLicense: item.requiresLicense,
active: item.active,
}}
/>
</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">
Calendrier de disponibilité
</h2>
<p className="mb-3 text-xs text-zinc-600">
Bloquez ici des dates pour maintenance, indisponibilité personnelle, etc. Les réservations
confirmées sont gérées automatiquement.
</p>
<ItemBlocksManager
blocks={item.availabilities.map((a) => ({
id: a.id,
startDate: a.startDate.toISOString().slice(0, 10),
endDate: a.endDate.toISOString().slice(0, 10),
qty: a.qty,
reason: a.reason,
isBooking: Boolean(a.rentalBookingId),
}))}
addAction={addBlockThis}
removeAction={removeBlockThis}
totalQty={item.totalQty}
/>
</section>
</main>
);
}

View file

@ -0,0 +1,133 @@
"use client";
import { useState, useTransition } from "react";
import { RENTAL_CATEGORY_LABEL, RENTAL_CATEGORIES } from "@/lib/rental-category-labels";
const inputCls =
"mt-0.5 w-full rounded-md border border-zinc-300 px-3 py-2 text-sm focus:border-emerald-500 focus:outline-none";
const labelCls = "block text-sm font-medium text-zinc-800";
type Props = {
initial?: {
category?: string;
name?: string;
description?: string | null;
imageUrl?: string | null;
pricePerDay?: string | number;
pricePerWeek?: string | number | null;
deposit?: string | number;
totalQty?: number;
withMotor?: boolean;
fuelIncluded?: boolean;
requiresLicense?: boolean;
active?: boolean;
};
action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
submitLabel?: string;
};
export function HostItemForm({ initial = {}, action, submitLabel = "Enregistrer" }: Props) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
function onSubmit(fd: FormData) {
setError(null);
setSuccess(null);
startTransition(async () => {
const res = await action(fd);
if (res && res.ok === false) setError(res.error);
else if (res && res.ok === true) setSuccess("Enregistré.");
});
}
return (
<form action={onSubmit} className="space-y-4">
<fieldset disabled={pending} className="space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="block">
<span className={labelCls}>Catégorie</span>
<select name="category" defaultValue={initial.category ?? ""} required className={inputCls}>
<option value="" disabled> sélectionner </option>
{RENTAL_CATEGORIES.map((c) => (
<option key={c} value={c}>{RENTAL_CATEGORY_LABEL[c]}</option>
))}
</select>
</label>
<label className="block">
<span className={labelCls}>Statut</span>
<label className="flex items-center gap-2 px-1 py-2 text-sm">
<input type="checkbox" name="active" defaultChecked={initial.active ?? true} className="h-4 w-4 rounded border-zinc-300" />
Actif (visible au catalogue)
</label>
</label>
<label className="block sm:col-span-2">
<span className={labelCls}>Nom de l&apos;item</span>
<input name="name" required maxLength={200} defaultValue={initial.name ?? ""} className={inputCls} placeholder="ex. Hamac coton large" />
</label>
<label className="block sm:col-span-2">
<span className={labelCls}>Description</span>
<textarea name="description" rows={3} maxLength={5000} defaultValue={initial.description ?? ""} className={inputCls} />
</label>
<label className="block">
<span className={labelCls}>URL image</span>
<input name="imageUrl" type="url" maxLength={500} defaultValue={initial.imageUrl ?? ""} className={inputCls} />
</label>
<label className="block">
<span className={labelCls}>Stock total (qté)</span>
<input name="totalQty" type="number" min={1} max={1000} defaultValue={initial.totalQty?.toString() ?? "1"} required className={inputCls} />
</label>
<label className="block">
<span className={labelCls}>Prix / jour ()</span>
<input name="pricePerDay" type="number" min={0} step="0.5" defaultValue={initial.pricePerDay?.toString() ?? ""} required className={inputCls} />
</label>
<label className="block">
<span className={labelCls}>Prix / semaine ()</span>
<input name="pricePerWeek" type="number" min={0} step="0.5" defaultValue={initial.pricePerWeek?.toString() ?? ""} className={inputCls} />
</label>
<label className="block">
<span className={labelCls}>Caution ()</span>
<input name="deposit" type="number" min={0} step="1" defaultValue={initial.deposit?.toString() ?? "0"} className={inputCls} />
</label>
</div>
<fieldset className="rounded-lg border border-zinc-200 bg-zinc-50 p-3">
<legend className="px-1 text-xs font-semibold uppercase tracking-wider text-zinc-500">
Spécifications
</legend>
<div className="flex flex-wrap gap-4 pt-1 text-sm">
<label className="flex items-center gap-2">
<input type="checkbox" name="withMotor" defaultChecked={initial.withMotor ?? false} className="h-4 w-4 rounded border-zinc-300" />
Avec moteur
</label>
<label className="flex items-center gap-2">
<input type="checkbox" name="fuelIncluded" defaultChecked={initial.fuelIncluded ?? false} className="h-4 w-4 rounded border-zinc-300" />
Essence incluse
</label>
<label className="flex items-center gap-2">
<input type="checkbox" name="requiresLicense" defaultChecked={initial.requiresLicense ?? false} className="h-4 w-4 rounded border-zinc-300" />
Permis bateau requis
</label>
</div>
</fieldset>
{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-end">
<button
type="submit"
className="rounded-md bg-emerald-600 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
>
{pending ? "Enregistrement…" : submitLabel}
</button>
</div>
</fieldset>
</form>
);
}

View file

@ -0,0 +1,23 @@
import Link from "next/link";
import { requireRentalProviderSession } from "@/lib/rental-access";
import { HostItemForm } from "../_components/ItemForm";
import { createHostItemAction } from "../../actions";
export const dynamic = "force-dynamic";
export default async function NewHostItemPage() {
await requireRentalProviderSession();
return (
<main className="mx-auto max-w-3xl px-6 py-10">
<Link href="/espace-prestataire/items" className="text-xs text-zinc-500 hover:text-zinc-900">
Mes items
</Link>
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">Nouvel item</h1>
<section className="mt-5 rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<HostItemForm action={createHostItemAction} submitLabel="Créer l'item" initial={{ active: true, totalQty: 1 }} />
</section>
</main>
);
}

View file

@ -0,0 +1,93 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { requireRentalProviderSession, getCurrentRentalProvider } from "@/lib/rental-access";
import { listHostItems } from "@/lib/rental-host";
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
export const dynamic = "force-dynamic";
export default async function HostItemsPage() {
await requireRentalProviderSession();
const provider = await getCurrentRentalProvider();
if (!provider) redirect("/admin/rental-providers");
const items = await listHostItems(provider.id);
return (
<main className="mx-auto max-w-6xl px-6 py-10">
<header className="mb-5 flex flex-wrap items-end justify-between gap-3">
<div>
<Link href="/espace-prestataire" className="text-xs text-zinc-500 hover:text-zinc-900">
Dashboard
</Link>
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">Mes items locables</h1>
<p className="mt-1 text-sm text-zinc-500">{items.length} item{items.length > 1 ? "s" : ""}</p>
</div>
<Link
href="/espace-prestataire/items/new"
className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-700"
>
+ Nouvel item
</Link>
</header>
{items.length === 0 ? (
<div className="rounded-lg border border-dashed border-zinc-300 px-6 py-12 text-center text-sm text-zinc-500">
Pas encore d&apos;item.{" "}
<Link href="/espace-prestataire/items/new" className="text-emerald-700 underline">
Créer mon premier item
</Link>
</div>
) : (
<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">Catégorie</th>
<th className="px-4 py-2 text-right font-semibold">/j</th>
<th className="px-4 py-2 text-right font-semibold">Stock</th>
<th className="px-4 py-2 text-right font-semibold">Caution</th>
<th className="px-4 py-2 text-right font-semibold">Résa</th>
<th className="px-4 py-2 text-left font-semibold">État</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{items.map((i) => (
<tr key={i.id} className="hover:bg-zinc-50">
<td className="px-4 py-2">
<Link href={`/espace-prestataire/items/${i.id}`} className="font-medium text-zinc-900 hover:underline">
{i.name}
</Link>
<div className="text-[11px] text-zinc-500">
{i.withMotor ? "⚙️ moteur · " : ""}
{i.requiresLicense ? "🪪 permis · " : ""}
{i.fuelIncluded ? "⛽ essence " : ""}
</div>
</td>
<td className="px-4 py-2 text-zinc-700">{RENTAL_CATEGORY_LABEL[i.category]}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{Number(i.pricePerDay).toFixed(0)}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{i.totalQty}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{Number(i.deposit).toFixed(0)}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{i._count.lines}</td>
<td className="px-4 py-2">
{i.active ? (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
Actif
</span>
) : (
<span className="rounded-full bg-zinc-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-zinc-500 ring-1 ring-inset ring-zinc-300">
Inactif
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</main>
);
}

View file

@ -0,0 +1,153 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { requireRentalProviderSession, getCurrentRentalProvider } from "@/lib/rental-access";
import { getHostRentalKpis } from "@/lib/rental-host";
export const dynamic = "force-dynamic";
function fmtEur(amount: string | number): string {
const n = Number(amount);
return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR" });
}
const dateFmt = new Intl.DateTimeFormat("fr-FR", {
day: "2-digit",
month: "long",
year: "numeric",
});
export default async function ProviderDashboardPage() {
await requireRentalProviderSession();
const provider = await getCurrentRentalProvider();
if (!provider) {
// Admin sans providerId ciblé : redirect vers liste admin
redirect("/admin/rental-providers");
}
const kpis = await getHostRentalKpis(provider.id);
return (
<main className="mx-auto max-w-6xl px-6 py-10">
<header className="mb-6 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-3xl font-semibold text-zinc-900">Espace prestataire</h1>
<p className="mt-1 text-sm text-zinc-600">
{provider.name}
{provider.isSystemD ? " · Fournisseur officiel Karbé" : ""}
</p>
</div>
<div className="flex gap-2">
<Link
href="/espace-prestataire/items/new"
className="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-emerald-700"
>
+ Nouvel item
</Link>
<Link
href="/espace-prestataire/items"
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-700 hover:bg-zinc-50"
>
Mes items
</Link>
<Link
href="/espace-prestataire/reservations"
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-700 hover:bg-zinc-50"
>
Réservations
</Link>
</div>
</header>
{!provider.approved ? (
<div className="mb-6 rounded-lg border border-amber-300 bg-amber-50 p-4">
<div className="text-xs uppercase tracking-wider text-amber-900">Compte en attente de validation</div>
<p className="mt-1 text-sm text-amber-900">
Vos items ne sont <strong>pas encore visibles</strong> sur le catalogue public.
L&apos;équipe Karbé contactera bientôt {provider.contactEmail ?? "votre email"} pour finaliser
votre adhésion. Vous pouvez toutefois préparer vos items dès maintenant.
</p>
</div>
) : null}
<section className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<Kpi label="CA total" value={fmtEur(kpis.revenueTotal)} />
<Kpi label="CA 30 j" value={fmtEur(kpis.revenue30d)} />
<Kpi
label="À confirmer"
value={String(kpis.bookingsPending)}
tone={kpis.bookingsPending > 0 ? "warn" : "neutral"}
/>
<Kpi label="Confirmées à venir" value={String(kpis.bookingsConfirmed)} />
<Kpi label="Items au catalogue" value={String(kpis.itemsActive)} />
<Kpi label="Items total" value={String(kpis.itemsTotal)} />
</section>
{kpis.nextHandover ? (
<section className="mb-6 rounded-lg border border-emerald-300 bg-emerald-50 p-4">
<div className="text-xs uppercase tracking-wider text-emerald-700">Prochaine remise</div>
<div className="mt-1 text-base font-semibold text-emerald-900">
{kpis.nextHandover.tenantName} · {kpis.nextHandover.lineCount} ligne(s)
</div>
<div className="text-sm text-emerald-800">
{dateFmt.format(kpis.nextHandover.startDate)}
</div>
<Link
href={`/espace-prestataire/reservations`}
className="mt-2 inline-block text-xs font-semibold text-emerald-900 underline"
>
Voir le détail
</Link>
</section>
) : null}
<section>
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">Mon activité</h2>
<ul className="space-y-2 text-sm">
<li className="rounded border border-zinc-200 bg-white px-3 py-2">
<span className="text-zinc-500">Fleuves desservis :</span>{" "}
<strong className="text-zinc-900">{provider.rivers.join(", ") || "—"}</strong>
</li>
<li className="rounded border border-zinc-200 bg-white px-3 py-2">
<span className="text-zinc-500">Commission Karbé :</span>{" "}
<strong className="text-zinc-900">{Number(provider.commissionPct).toFixed(1)}%</strong>
</li>
<li className="rounded border border-zinc-200 bg-white px-3 py-2">
<span className="text-zinc-500">Statut :</span>{" "}
<strong className="text-zinc-900">{provider.active ? "Actif" : "Inactif"}</strong>
{" · "}
<strong className="text-zinc-900">{provider.approved ? "Approuvé" : "En attente"}</strong>
</li>
</ul>
</section>
</main>
);
}
function Kpi({
label,
value,
tone = "neutral",
}: {
label: string;
value: string;
tone?: "neutral" | "warn";
}) {
return (
<div
className={
"rounded-lg border bg-white p-3 shadow-sm " +
(tone === "warn" ? "border-amber-300" : "border-zinc-200")
}
>
<div className="text-[11px] uppercase tracking-wider text-zinc-500">{label}</div>
<div
className={
"mt-1 text-xl font-semibold " + (tone === "warn" ? "text-amber-700" : "text-zinc-900")
}
>
{value}
</div>
</div>
);
}

View file

@ -0,0 +1,96 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { RentalBookingStatus } from "@/generated/prisma/enums";
import { updateBookingStatusAction } from "../../actions";
const btnBase =
"rounded-md px-3 py-1.5 text-xs font-semibold transition disabled:opacity-50";
export function BookingDecision({ bookingId, status }: { bookingId: string; status: string }) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [confirmCancel, setConfirmCancel] = useState(false);
function set(next: string) {
setError(null);
startTransition(async () => {
const res = await updateBookingStatusAction(bookingId, next);
if (res && res.ok === false) setError(res.error);
setConfirmCancel(false);
router.refresh();
});
}
return (
<div className="flex flex-wrap items-center gap-2">
{status === RentalBookingStatus.PENDING ? (
<button
type="button"
onClick={() => set(RentalBookingStatus.CONFIRMED)}
disabled={pending}
className={`${btnBase} bg-emerald-600 text-white hover:bg-emerald-700`}
>
Confirmer
</button>
) : null}
{status === RentalBookingStatus.CONFIRMED ? (
<button
type="button"
onClick={() => set(RentalBookingStatus.HANDED_OVER)}
disabled={pending}
className={`${btnBase} bg-emerald-600 text-white hover:bg-emerald-700`}
>
Marquer remis client
</button>
) : null}
{status === RentalBookingStatus.HANDED_OVER ? (
<button
type="button"
onClick={() => set(RentalBookingStatus.RETURNED)}
disabled={pending}
className={`${btnBase} bg-emerald-600 text-white hover:bg-emerald-700`}
>
Marquer retourné
</button>
) : null}
{status !== RentalBookingStatus.CANCELLED && status !== RentalBookingStatus.RETURNED ? (
confirmCancel ? (
<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">Annuler ?</span>
<button
type="button"
onClick={() => set(RentalBookingStatus.CANCELLED)}
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={() => setConfirmCancel(false)}
disabled={pending}
className="text-[11px] text-zinc-500 hover:text-zinc-900"
>
Non
</button>
</div>
) : (
<button
type="button"
onClick={() => setConfirmCancel(true)}
disabled={pending}
className={`${btnBase} border border-rose-300 bg-white text-rose-700 hover:bg-rose-50`}
>
Annuler
</button>
)
) : null}
{error ? <span className="text-xs text-rose-700">{error}</span> : null}
</div>
);
}

View file

@ -0,0 +1,137 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { RentalBookingStatus } from "@/generated/prisma/enums";
import { requireRentalProviderSession, getCurrentRentalProvider } from "@/lib/rental-access";
import { listHostBookings } from "@/lib/rental-host";
import { RENTAL_STATUS_LABEL } from "@/lib/admin/rental-bookings";
import { BookingDecision } from "./_components/BookingDecision";
export const dynamic = "force-dynamic";
const STATUS_VALUES = new Set<string>([
RentalBookingStatus.PENDING,
RentalBookingStatus.CONFIRMED,
RentalBookingStatus.HANDED_OVER,
RentalBookingStatus.RETURNED,
RentalBookingStatus.CANCELLED,
]);
type PageProps = {
searchParams: Promise<{ status?: string }>;
};
const dateFmt = new Intl.DateTimeFormat("fr-FR", {
day: "2-digit",
month: "short",
year: "2-digit",
});
export default async function HostReservationsPage({ searchParams }: PageProps) {
await requireRentalProviderSession();
const provider = await getCurrentRentalProvider();
if (!provider) redirect("/admin/rental-providers");
const sp = await searchParams;
const status = STATUS_VALUES.has(sp.status ?? "")
? (sp.status as RentalBookingStatus)
: undefined;
const bookings = await listHostBookings(provider.id, { status });
return (
<main className="mx-auto max-w-6xl px-6 py-10">
<header className="mb-5 flex flex-wrap items-end justify-between gap-3">
<div>
<Link href="/espace-prestataire" className="text-xs text-zinc-500 hover:text-zinc-900">
Dashboard
</Link>
<h1 className="mt-1 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" : ""}</p>
</div>
<form method="get" className="flex items-center gap-2 text-sm">
<select
name="status"
defaultValue={status ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-emerald-500 focus:outline-none"
>
<option value="">Tous statuts</option>
{Object.values(RentalBookingStatus).map((s) => (
<option key={s} value={s}>{RENTAL_STATUS_LABEL[s]}</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>
</form>
</header>
{bookings.length === 0 ? (
<div className="rounded-lg border border-dashed border-zinc-300 px-6 py-12 text-center text-sm text-zinc-500">
Aucune réservation matériel.
</div>
) : (
<ul className="space-y-3">
{bookings.map((b) => (
<li key={b.id} className="rounded-lg border border-zinc-200 bg-white p-4 shadow-sm">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div>
<h2 className="text-base font-semibold text-zinc-900">
{b.tenant.firstName} {b.tenant.lastName}
</h2>
<p className="text-xs text-zinc-500">
{b.tenant.email}
{b.tenant.phone ? ` · ${b.tenant.phone}` : ""}
</p>
{b.booking ? (
<p className="mt-0.5 text-xs text-emerald-700">
🏠 Lié à la résa carbet :{" "}
<Link href={`/reservations/${b.booking.id}`} className="underline">
{b.booking.carbet.title}
</Link>
</p>
) : (
<p className="mt-0.5 text-xs text-zinc-500">Location standalone (sans carbet)</p>
)}
</div>
<div className="text-right">
<div className="text-xs text-zinc-500">
{dateFmt.format(b.startDate)} {dateFmt.format(b.endDate)}
</div>
<div className="font-mono text-base font-semibold text-zinc-900">
{Number(b.amount).toFixed(2)} {b.currency}
</div>
</div>
</div>
<ul className="mt-2 space-y-1 border-t border-zinc-100 pt-2 text-sm text-zinc-700">
{b.lines.map((l) => (
<li key={l.id} className="flex items-center justify-between">
<span>
{l.qty}× <strong>{l.item.name}</strong>
</span>
<span className="font-mono text-xs text-zinc-600">
{Number(l.lineTotal).toFixed(2)}
</span>
</li>
))}
</ul>
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 border-t border-zinc-100 pt-2">
<div className="flex flex-wrap items-center gap-2 text-xs">
<span className="rounded-full bg-zinc-100 px-2 py-0.5 font-semibold uppercase tracking-wider text-zinc-700 ring-1 ring-inset ring-zinc-300">
{RENTAL_STATUS_LABEL[b.status]}
</span>
<span className="rounded-full bg-zinc-100 px-2 py-0.5 font-semibold uppercase tracking-wider text-zinc-700 ring-1 ring-inset ring-zinc-300">
{b.paymentStatus}
</span>
</div>
<BookingDecision bookingId={b.id} status={b.status} />
</div>
</li>
))}
</ul>
)}
</main>
);
}

View file

@ -10,7 +10,9 @@ export function SignupForm({ next }: Props) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [role, setRole] = useState<"TOURIST" | "OWNER">("TOURIST");
const [role, setRole] = useState<"TOURIST" | "OWNER" | "RENTAL_PROVIDER">("TOURIST");
const [providerName, setProviderName] = useState("");
const [providerRivers, setProviderRivers] = useState("");
function onSubmit(formData: FormData) {
setError(null);
@ -24,12 +26,31 @@ export function SignupForm({ next }: Props) {
setError("Le mot de passe doit faire au moins 8 caractères.");
return;
}
if (role === "RENTAL_PROVIDER" && providerName.trim().length < 2) {
setError("Le nom de votre activité de loueur est requis.");
return;
}
startTransition(async () => {
const body: Record<string, unknown> = {
email,
password,
firstName,
lastName,
phone: phone || null,
role,
};
if (role === "RENTAL_PROVIDER") {
body.providerName = providerName.trim();
body.providerRivers = providerRivers
.split(/[,;\n]/)
.map((s) => s.trim())
.filter((s) => s.length > 0);
}
const res = await fetch("/api/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, firstName, lastName, phone: phone || null, role }),
body: JSON.stringify(body),
});
const json = await res.json().catch(() => ({}));
if (!res.ok) {
@ -91,7 +112,7 @@ export function SignupForm({ next }: Props) {
<fieldset className="space-y-1">
<legend className="text-xs text-zinc-600">Type de compte</legend>
<div className="grid grid-cols-2 gap-2 pt-1">
<div className="grid grid-cols-1 gap-2 pt-1 sm:grid-cols-3">
<label
className={
"flex cursor-pointer flex-col items-start rounded-md border px-3 py-2 text-sm " +
@ -130,9 +151,59 @@ export function SignupForm({ next }: Props) {
<span className="font-semibold text-zinc-900">Hôte</span>
<span className="text-[11px] text-zinc-500">Publier un carbet.</span>
</label>
<label
className={
"flex cursor-pointer flex-col items-start rounded-md border px-3 py-2 text-sm " +
(role === "RENTAL_PROVIDER"
? "border-zinc-900 bg-zinc-50 ring-1 ring-zinc-900"
: "border-zinc-300 hover:bg-zinc-50")
}
>
<input
type="radio"
name="role"
value="RENTAL_PROVIDER"
checked={role === "RENTAL_PROVIDER"}
onChange={() => setRole("RENTAL_PROVIDER")}
className="sr-only"
/>
<span className="font-semibold text-zinc-900">Loueur matériel</span>
<span className="text-[11px] text-zinc-500">Hamac, pirogue, kayak</span>
</label>
</div>
</fieldset>
{role === "RENTAL_PROVIDER" ? (
<div className="space-y-2 rounded-md border border-emerald-200 bg-emerald-50/30 p-3">
<p className="text-[11px] text-emerald-900">
Votre compte sera créé en <strong>attente de validation</strong>. Un admin Karbé
vous contactera pour confirmer votre activité avant publication de vos items.
</p>
<label className="block">
<span className="text-xs text-zinc-600">Nom de votre activité</span>
<input
type="text"
value={providerName}
onChange={(e) => setProviderName(e.target.value)}
placeholder="ex. Pirogues du Bas-Oyapock"
maxLength={200}
className={inputCls + " mt-0.5"}
/>
</label>
<label className="block">
<span className="text-xs text-zinc-600">Fleuves desservis (séparés par virgule)</span>
<input
type="text"
value={providerRivers}
onChange={(e) => setProviderRivers(e.target.value)}
placeholder="Maroni, Oyapock"
maxLength={300}
className={inputCls + " mt-0.5"}
/>
</label>
</div>
) : null}
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
) : null}

View file

@ -15,6 +15,7 @@ export async function SiteHeader() {
const u = session?.user;
const isAdmin = u?.role === UserRole.ADMIN;
const isOwner = u?.role === UserRole.OWNER || isAdmin;
const isRentalProvider = u?.role === UserRole.RENTAL_PROVIDER || isAdmin;
return (
<header className="sticky top-0 z-30 border-b border-zinc-200 bg-white/85 backdrop-blur supports-[backdrop-filter]:bg-white/70">
@ -55,6 +56,11 @@ export async function SiteHeader() {
Espace hôte
</Link>
) : null}
{isRentalProvider ? (
<Link href="/espace-prestataire" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
Espace prestataire
</Link>
) : null}
{isAdmin ? (
<Link href="/admin" className="hidden rounded-md bg-zinc-900 px-2.5 py-1 text-xs font-semibold text-white hover:bg-zinc-800 sm:inline-block">
Admin

View file

@ -186,6 +186,27 @@ export async function sendBookingConfirmed(
});
}
export async function sendNewRentalProviderRequest(
providerName: string,
userEmail: string,
): Promise<void> {
const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL ?? "contact@karbe.cosmolan.fr";
await sendEmail({
to: adminEmail,
subject: `Nouvelle demande prestataire matériel — ${providerName}`,
html: wrap(
"Demande de prestataire à valider",
`<p>Une demande d'inscription en tant que prestataire de location matériel vient d'arriver.</p>
<ul>
<li>Nom : <strong>${providerName}</strong></li>
<li>Email contact : ${userEmail}</li>
</ul>
<p><a href="${SITE_URL}/admin/rental-providers" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Valider sur l'admin Karbé</a></p>
<p style="font-size:12px;color:#71717a;">Le prestataire reste en attente jusqu'à validation. Ses items ne sont pas publiés tant que <code>approved=false</code>.</p>`,
),
});
}
export async function sendPasswordReset(
to: string,
resetUrl: string,

54
src/lib/rental-access.ts Normal file
View file

@ -0,0 +1,54 @@
import "server-only";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { UserRole } from "@/generated/prisma/enums";
import { prisma } from "@/lib/prisma";
export async function requireRentalProviderSession() {
const session = await auth();
if (!session?.user?.id) {
redirect("/connexion?next=/espace-prestataire");
}
const role = session.user.role;
if (role !== UserRole.RENTAL_PROVIDER && role !== UserRole.ADMIN) {
redirect("/");
}
return session;
}
/**
* Récupère le RentalProvider géré par l'utilisateur.
* Si ADMIN, on retourne soit le provider explicitement ciblé (param `providerId`),
* soit null pour forcer le choix.
*/
export async function getCurrentRentalProvider(opts: { providerId?: string } = {}) {
const session = await auth();
if (!session?.user?.id) return null;
const role = session.user.role;
if (role === UserRole.ADMIN && opts.providerId) {
return prisma.rentalProvider.findUnique({ where: { id: opts.providerId } });
}
if (role === UserRole.ADMIN && !opts.providerId) {
return null;
}
// RENTAL_PROVIDER : retourne le provider lié
return prisma.rentalProvider.findFirst({
where: { managedByUserId: session.user.id },
});
}
export async function canManageRentalProvider(
userId: string,
role: string | undefined,
providerId: string,
): Promise<boolean> {
if (role === UserRole.ADMIN) return true;
const provider = await prisma.rentalProvider.findUnique({
where: { id: providerId },
select: { managedByUserId: true },
});
return provider?.managedByUserId === userId;
}

120
src/lib/rental-host.ts Normal file
View file

@ -0,0 +1,120 @@
import "server-only";
import { Prisma } from "@/generated/prisma/client";
import { RentalBookingStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/prisma";
export type HostRentalKpis = {
itemsTotal: number;
itemsActive: number;
bookingsPending: number;
bookingsConfirmed: number;
revenueTotal: string;
revenue30d: string;
nextHandover: {
id: string;
startDate: Date;
tenantName: string;
lineCount: number;
} | null;
};
export async function getHostRentalKpis(providerId: string): Promise<HostRentalKpis> {
const now = new Date();
const last30 = new Date(now.getTime() - 30 * 86_400_000);
const [itemsTotal, itemsActive, bookingsPending, bookingsConfirmed, revenueAll, revenue30, next] =
await Promise.all([
prisma.rentalItem.count({ where: { providerId } }),
prisma.rentalItem.count({ where: { providerId, active: true } }),
prisma.rentalBooking.count({
where: { providerId, status: RentalBookingStatus.PENDING },
}),
prisma.rentalBooking.count({
where: {
providerId,
status: { in: [RentalBookingStatus.CONFIRMED, RentalBookingStatus.HANDED_OVER] },
startDate: { gte: now },
},
}),
prisma.rentalBooking.aggregate({
where: {
providerId,
status: { in: [RentalBookingStatus.CONFIRMED, RentalBookingStatus.HANDED_OVER, RentalBookingStatus.RETURNED] },
},
_sum: { amount: true },
}),
prisma.rentalBooking.aggregate({
where: {
providerId,
status: { in: [RentalBookingStatus.CONFIRMED, RentalBookingStatus.HANDED_OVER, RentalBookingStatus.RETURNED] },
createdAt: { gte: last30 },
},
_sum: { amount: true },
}),
prisma.rentalBooking.findFirst({
where: {
providerId,
status: RentalBookingStatus.CONFIRMED,
startDate: { gte: now },
},
orderBy: { startDate: "asc" },
select: {
id: true,
startDate: true,
tenant: { select: { firstName: true, lastName: true } },
_count: { select: { lines: true } },
},
}),
]);
return {
itemsTotal,
itemsActive,
bookingsPending,
bookingsConfirmed,
revenueTotal: (revenueAll._sum.amount ?? 0).toString(),
revenue30d: (revenue30._sum.amount ?? 0).toString(),
nextHandover: next
? {
id: next.id,
startDate: next.startDate,
tenantName: `${next.tenant.firstName} ${next.tenant.lastName}`.trim(),
lineCount: next._count.lines,
}
: null,
};
}
export async function listHostItems(providerId: string) {
return prisma.rentalItem.findMany({
where: { providerId },
orderBy: [{ category: "asc" }, { name: "asc" }],
include: { _count: { select: { lines: true } } },
});
}
export async function listHostBookings(providerId: string, filters: { status?: RentalBookingStatus } = {}) {
const where: Prisma.RentalBookingWhereInput = { providerId };
if (filters.status) where.status = filters.status;
return prisma.rentalBooking.findMany({
where,
orderBy: [{ status: "asc" }, { startDate: "asc" }],
take: 200,
include: {
tenant: { select: { id: true, firstName: true, lastName: true, email: true, phone: true } },
lines: { include: { item: { select: { id: true, name: true, category: true } } } },
booking: { select: { id: true, carbet: { select: { title: true, slug: true } } } },
},
});
}
export async function getHostItem(providerId: string, itemId: string) {
return prisma.rentalItem.findFirst({
where: { id: itemId, providerId },
include: {
availabilities: { orderBy: { startDate: "asc" } },
_count: { select: { lines: true } },
},
});
}