From f31fb8a32cc41da6d06d94da0ac4f01280217d53 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Tue, 2 Jun 2026 07:49:43 +0000 Subject: [PATCH 01/19] =?UTF-8?q?feat(rental):=20Sprint=20B=20=E2=80=94=20?= =?UTF-8?q?catalogue=20public=20/materiel=20+=20d=C3=A9tail=20item=20+=20d?= =?UTF-8?q?ispo=20+=20nav?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rentals/items/[id]/availability/route.ts | 31 +++ .../_components/AvailabilityPreview.tsx | 56 ++++++ src/app/materiel/[itemId]/page.tsx | 159 +++++++++++++++ .../materiel/_components/rental-filters.tsx | 100 ++++++++++ .../materiel/_components/rental-item-card.tsx | 76 ++++++++ src/app/materiel/page.tsx | 121 ++++++++++++ src/components/SiteHeader.tsx | 4 +- src/lib/rentals-public.ts | 181 ++++++++++++++++++ 8 files changed, 726 insertions(+), 2 deletions(-) create mode 100644 src/app/api/rentals/items/[id]/availability/route.ts create mode 100644 src/app/materiel/[itemId]/_components/AvailabilityPreview.tsx create mode 100644 src/app/materiel/[itemId]/page.tsx create mode 100644 src/app/materiel/_components/rental-filters.tsx create mode 100644 src/app/materiel/_components/rental-item-card.tsx create mode 100644 src/app/materiel/page.tsx create mode 100644 src/lib/rentals-public.ts diff --git a/src/app/api/rentals/items/[id]/availability/route.ts b/src/app/api/rentals/items/[id]/availability/route.ts new file mode 100644 index 0000000..dc3b8b2 --- /dev/null +++ b/src/app/api/rentals/items/[id]/availability/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +import { getItemAvailability } from "@/lib/rentals-public"; +import { parseIsoDate, normalizeUtcDayStart } from "@/lib/booking"; + +export const runtime = "nodejs"; + +export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + const { id } = await ctx.params; + const from = parseIsoDate(req.nextUrl.searchParams.get("from")); + const to = parseIsoDate(req.nextUrl.searchParams.get("to")); + if (!from || !to) { + return NextResponse.json( + { error: "Paramètres from et to (YYYY-MM-DD) requis." }, + { status: 400 }, + ); + } + const start = normalizeUtcDayStart(from); + const end = normalizeUtcDayStart(to); + if (end <= start) { + return NextResponse.json({ error: "to doit être > from." }, { status: 400 }); + } + const calendar = await getItemAvailability(id, start, end); + return NextResponse.json({ + itemId: id, + from: start.toISOString(), + to: end.toISOString(), + calendar, + }); +} diff --git a/src/app/materiel/[itemId]/_components/AvailabilityPreview.tsx b/src/app/materiel/[itemId]/_components/AvailabilityPreview.tsx new file mode 100644 index 0000000..4cdf5d9 --- /dev/null +++ b/src/app/materiel/[itemId]/_components/AvailabilityPreview.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useEffect, useState } from "react"; + +type Day = { + date: string; + availableQty: number; + bookedQty: number; + totalQty: number; +}; + +export function AvailabilityPreview({ itemId }: { itemId: string }) { + const [calendar, setCalendar] = useState(null); + + useEffect(() => { + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + const to = new Date(today.getTime() + 30 * 86_400_000); + const fromStr = today.toISOString().slice(0, 10); + const toStr = to.toISOString().slice(0, 10); + fetch(`/api/rentals/items/${itemId}/availability?from=${fromStr}&to=${toStr}`) + .then((r) => (r.ok ? r.json() : null)) + .then((j) => { + if (j?.calendar) setCalendar(j.calendar); + }) + .catch(() => {}); + }, [itemId]); + + if (!calendar) { + return
; + } + + return ( +
+

+ Disponibilité sur les 30 prochains jours (vert = stock dispo, gris = épuisé) : +

+
+ {calendar.map((d) => { + const ratio = d.availableQty / Math.max(1, d.totalQty); + const tone = + d.availableQty === 0 ? "bg-zinc-300" : + ratio < 0.3 ? "bg-amber-300" : + "bg-emerald-400"; + return ( +
+ ); + })} +
+
+ ); +} diff --git a/src/app/materiel/[itemId]/page.tsx b/src/app/materiel/[itemId]/page.tsx new file mode 100644 index 0000000..b6f6aa1 --- /dev/null +++ b/src/app/materiel/[itemId]/page.tsx @@ -0,0 +1,159 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; + +import { getPublicRentalItem } from "@/lib/rentals-public"; +import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels"; + +import { AvailabilityPreview } from "./_components/AvailabilityPreview"; + +export const dynamic = "force-dynamic"; + +type PageProps = { params: Promise<{ itemId: string }> }; + +export async function generateMetadata({ params }: PageProps): Promise { + const { itemId } = await params; + const item = await getPublicRentalItem(itemId); + if (!item) return { title: "Item introuvable", robots: { index: false } }; + return { + title: `${item.name} — Location matériel`, + description: item.description ?? `Location de ${item.name} via ${item.provider.name}.`, + }; +} + +export default async function RentalItemDetailPage({ params }: PageProps) { + const { itemId } = await params; + const item = await getPublicRentalItem(itemId); + if (!item) notFound(); + + const categoryEmoji = + item.category === "SLEEP" ? "💤" : + item.category === "NAVIGATION" ? "🛶" : + item.category === "FISHING" ? "🎣" : + item.category === "COOKING" ? "🍳" : "🦺"; + + return ( +
+ + ← Tout le matériel + + +
+
+
+

+ {RENTAL_CATEGORY_LABEL[item.category]} +

+

{item.name}

+

+ Loué par {item.provider.name} + {item.provider.isSystemD ? ( + + Fournisseur Karbé + + ) : null} +

+
+ +
+ {item.imageUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {item.name} + ) : ( +
+ {categoryEmoji} +
+ )} +
+ + {item.description ? ( +
+

Description

+

{item.description}

+
+ ) : null} + +
+

Caractéristiques

+
    +
  • +
    Stock disponible
    +
    {item.totalQty} unités
    +
  • + {Number(item.deposit) > 0 ? ( +
  • +
    Caution
    +
    {Number(item.deposit).toFixed(0)} €
    +
  • + ) : null} + {item.withMotor ? ( +
  • ⚙️ Avec moteur
  • + ) : null} + {item.fuelIncluded ? ( +
  • ⛽ Essence incluse
  • + ) : null} + {item.requiresLicense ? ( +
  • 🪪 Permis bateau requis
  • + ) : null} +
+
+ +
+

Disponibilité

+
+ +
+
+
+ + +
+
+ ); +} diff --git a/src/app/materiel/_components/rental-filters.tsx b/src/app/materiel/_components/rental-filters.tsx new file mode 100644 index 0000000..90dc76e --- /dev/null +++ b/src/app/materiel/_components/rental-filters.tsx @@ -0,0 +1,100 @@ +import Link from "next/link"; + +import { RentalCategory } from "@/generated/prisma/enums"; +import { RENTAL_CATEGORIES, RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels"; + +type Props = { + filters: { + q?: string; + category?: RentalCategory; + providerId?: string; + river?: string; + }; + rivers: string[]; + providers: { id: string; name: string; isSystemD: boolean }[]; +}; + +export function RentalFilters({ filters, rivers, providers }: Props) { + return ( +
+
+ + + +
+ +
+ Catégorie +
+ {RENTAL_CATEGORIES.map((c) => { + const checked = filters.category === c; + return ( + + ); + })} +
+
+ +
+ + + Réinit. + +
+
+ ); +} diff --git a/src/app/materiel/_components/rental-item-card.tsx b/src/app/materiel/_components/rental-item-card.tsx new file mode 100644 index 0000000..179750b --- /dev/null +++ b/src/app/materiel/_components/rental-item-card.tsx @@ -0,0 +1,76 @@ +import Link from "next/link"; + +import type { PublicRentalItem } from "@/lib/rentals-public"; +import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels"; + +export function RentalItemCard({ item }: { item: PublicRentalItem }) { + return ( + +
+ {item.imageUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {item.name} + ) : ( +
+ {item.category === "SLEEP" ? "💤" : + item.category === "NAVIGATION" ? "🛶" : + item.category === "FISHING" ? "🎣" : + item.category === "COOKING" ? "🍳" : "🦺"} +
+ )} + + {RENTAL_CATEGORY_LABEL[item.category]} + + {item.provider.isSystemD ? ( + + Karbé + + ) : null} +
+
+

+ {item.name} +

+

{item.provider.name}

+

{item.description ?? ""}

+
+ {item.withMotor ? ( + ⚙️ moteur + ) : null} + {item.requiresLicense ? ( + 🪪 permis + ) : null} + {item.fuelIncluded ? ( + ⛽ essence + ) : null} + {Number(item.deposit) > 0 ? ( + + Caution {Number(item.deposit).toFixed(0)} € + + ) : null} +
+
+ + + {Number(item.pricePerDay).toFixed(0)} € + + / jour + + {item.pricePerWeek ? ( + + {Number(item.pricePerWeek).toFixed(0)} € / semaine + + ) : null} +
+
+ + ); +} diff --git a/src/app/materiel/page.tsx b/src/app/materiel/page.tsx new file mode 100644 index 0000000..f18f2ac --- /dev/null +++ b/src/app/materiel/page.tsx @@ -0,0 +1,121 @@ +import type { Metadata } from "next"; + +import { RentalCategory } from "@/generated/prisma/enums"; +import { isRentalCategory } from "@/lib/rental-category-labels"; +import { + listPublicProviders, + listPublicRentalItems, + listPublicRivers, +} from "@/lib/rentals-public"; + +import { RentalFilters } from "./_components/rental-filters"; +import { RentalItemCard } from "./_components/rental-item-card"; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: "Louer du matériel", + description: + "Hamac, moustiquaire, pirogue, kayak, barque, gilet, réchaud… Toutes les locations de matériel pour réussir votre séjour en carbet guyanais, fournies par l'association System D et des prestataires locaux validés.", +}; + +type PageProps = { + searchParams: Promise<{ + q?: string; + category?: string; + providerId?: string; + river?: string; + }>; +}; + +export default async function MaterialPage({ searchParams }: PageProps) { + const sp = await searchParams; + const filters = { + q: sp.q?.trim() || undefined, + category: sp.category && isRentalCategory(sp.category) ? (sp.category as RentalCategory) : undefined, + providerId: sp.providerId || undefined, + river: sp.river || undefined, + }; + const [items, providers, rivers] = await Promise.all([ + listPublicRentalItems(filters), + listPublicProviders(), + listPublicRivers(), + ]); + + return ( +
+
+

+ Matériel à louer +

+

+ Hamac, moustiquaire, pirogue, kayak, barque, réchaud, gilet de sauvetage… + Tout le matériel pour réussir votre séjour, mis à disposition par + l'association System D ou par des prestataires + locaux validés. +

+
+ + + +
+

+ {items.length} item{items.length > 1 ? "s" : ""} disponible + {items.length > 1 ? "s" : ""} +

+ {items.length === 0 ? ( +
+ Aucun item ne correspond à votre recherche. Essayez d'élargir + les filtres. +
+ ) : ( +
    + {items.map((item) => ( +
  • + +
  • + ))} +
+ )} +
+ + {providers.length > 0 ? ( +
+

+ Nos prestataires partenaires +

+

+ {providers.length} prestataire{providers.length > 1 ? "s" : ""} valid + {providers.length > 1 ? "és" : "é"} sur Karbé. +

+
    + {providers.map((p) => ( +
  • +
    +

    {p.name}

    + {p.isSystemD ? ( + + Karbé + + ) : null} +
    +

    + Fleuves : {p.rivers.join(", ") || "—"} · {p.itemsCount} item + {p.itemsCount > 1 ? "s" : ""} +

    + {p.description ? ( +

    + {p.description} +

    + ) : null} +
  • + ))} +
+
+ ) : null} +
+ ); +} diff --git a/src/components/SiteHeader.tsx b/src/components/SiteHeader.tsx index 08ffe77..564c466 100644 --- a/src/components/SiteHeader.tsx +++ b/src/components/SiteHeader.tsx @@ -33,8 +33,8 @@ export async function SiteHeader() { Catalogue - - Comment ça marche + + Matériel diff --git a/src/lib/rentals-public.ts b/src/lib/rentals-public.ts new file mode 100644 index 0000000..af08f4a --- /dev/null +++ b/src/lib/rentals-public.ts @@ -0,0 +1,181 @@ +import "server-only"; + +import { Prisma } from "@/generated/prisma/client"; +import { RentalCategory } from "@/generated/prisma/enums"; +import { prisma } from "@/lib/prisma"; + +export type PublicRentalFilters = { + q?: string; + category?: RentalCategory; + providerId?: string; + river?: string; +}; + +export type PublicRentalItem = { + id: string; + name: string; + description: string | null; + category: RentalCategory; + imageUrl: string | null; + pricePerDay: string; + pricePerWeek: string | null; + deposit: string; + totalQty: number; + withMotor: boolean; + fuelIncluded: boolean; + requiresLicense: boolean; + provider: { + id: string; + name: string; + isSystemD: boolean; + rivers: string[]; + }; +}; + +export async function listPublicRentalItems( + filters: PublicRentalFilters = {}, +): Promise { + const where: Prisma.RentalItemWhereInput = { + active: true, + provider: { active: true, approved: true }, + }; + if (filters.q) { + where.OR = [ + { name: { contains: filters.q, mode: "insensitive" } }, + { description: { contains: filters.q, mode: "insensitive" } }, + ]; + } + if (filters.category) where.category = filters.category; + if (filters.providerId) where.providerId = filters.providerId; + if (filters.river) { + where.provider = { active: true, approved: true, rivers: { has: filters.river } }; + } + + const rows = await prisma.rentalItem.findMany({ + where, + orderBy: [{ category: "asc" }, { name: "asc" }], + take: 200, + include: { + provider: { select: { id: true, name: true, isSystemD: true, rivers: true } }, + }, + }); + return rows.map((r) => ({ + id: r.id, + name: r.name, + description: r.description, + category: r.category, + imageUrl: r.imageUrl, + pricePerDay: r.pricePerDay.toString(), + pricePerWeek: r.pricePerWeek?.toString() ?? null, + deposit: r.deposit.toString(), + totalQty: r.totalQty, + withMotor: r.withMotor, + fuelIncluded: r.fuelIncluded, + requiresLicense: r.requiresLicense, + provider: r.provider, + })); +} + +export async function getPublicRentalItem(id: string) { + return prisma.rentalItem.findFirst({ + where: { id, active: true, provider: { active: true, approved: true } }, + include: { + provider: { + select: { + id: true, + name: true, + isSystemD: true, + rivers: true, + description: true, + contactEmail: true, + contactPhone: true, + }, + }, + }, + }); +} + +export type PublicProvider = { + id: string; + name: string; + isSystemD: boolean; + rivers: string[]; + itemsCount: number; + description: string | null; +}; + +export async function listPublicProviders(): Promise { + const rows = await prisma.rentalProvider.findMany({ + where: { active: true, approved: true }, + orderBy: [{ isSystemD: "desc" }, { name: "asc" }], + select: { + id: true, + name: true, + isSystemD: true, + rivers: true, + description: true, + _count: { select: { items: { where: { active: true } } } }, + }, + }); + return rows.map((r) => ({ + id: r.id, + name: r.name, + isSystemD: r.isSystemD, + rivers: r.rivers, + description: r.description, + itemsCount: r._count.items, + })); +} + +export async function listPublicRivers(): Promise { + const rows = await prisma.rentalProvider.findMany({ + where: { active: true, approved: true }, + select: { rivers: true }, + }); + const set = new Set(); + for (const r of rows) for (const x of r.rivers) set.add(x); + return Array.from(set).sort(); +} + +/** + * Calcule la disponibilité d'un item sur une plage : pour chaque jour, qty + * réservée (somme des RentalItemAvailability qui couvrent ce jour) vs + * totalQty. Renvoie la qty disponible jour par jour. + */ +export async function getItemAvailability( + itemId: string, + from: Date, + to: Date, +): Promise<{ date: string; availableQty: number; bookedQty: number; totalQty: number }[]> { + const item = await prisma.rentalItem.findUnique({ + where: { id: itemId }, + select: { totalQty: true }, + }); + if (!item) return []; + + const blocks = await prisma.rentalItemAvailability.findMany({ + where: { + itemId, + startDate: { lt: to }, + endDate: { gt: from }, + }, + select: { startDate: true, endDate: true, qty: true }, + }); + + const days: { date: string; availableQty: number; bookedQty: number; totalQty: number }[] = []; + const DAY_MS = 86_400_000; + for (let t = from.getTime(); t < to.getTime(); t += DAY_MS) { + const dayStart = new Date(t); + const dayEnd = new Date(t + DAY_MS); + const booked = blocks + .filter((b) => b.startDate < dayEnd && b.endDate > dayStart) + .reduce((acc, b) => acc + b.qty, 0); + days.push({ + date: dayStart.toISOString().slice(0, 10), + bookedQty: booked, + availableQty: Math.max(0, item.totalQty - booked), + totalQty: item.totalQty, + }); + } + return days; +} From 59786e536565009a4bc412ddb8faa26770ca90e7 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Tue, 2 Jun 2026 08:01:42 +0000 Subject: [PATCH 02/19] =?UTF-8?q?feat(rental):=20Sprint=20C=20=E2=80=94=20?= =?UTF-8?q?espace=20prestataire=20(signup+dashboard+items+calendrier+r?= =?UTF-8?q?=C3=A9sa)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/signup/route.ts | 39 ++- src/app/espace-prestataire/actions.ts | 237 ++++++++++++++++++ .../_components/ItemBlocksManager.tsx | 151 +++++++++++ .../[itemId]/_components/ItemInlineDelete.tsx | 71 ++++++ .../items/[itemId]/page.tsx | 107 ++++++++ .../items/_components/ItemForm.tsx | 133 ++++++++++ src/app/espace-prestataire/items/new/page.tsx | 23 ++ src/app/espace-prestataire/items/page.tsx | 93 +++++++ src/app/espace-prestataire/page.tsx | 153 +++++++++++ .../_components/BookingDecision.tsx | 96 +++++++ .../espace-prestataire/reservations/page.tsx | 137 ++++++++++ .../inscription/_components/SignupForm.tsx | 77 +++++- src/components/SiteHeader.tsx | 6 + src/lib/email.ts | 21 ++ src/lib/rental-access.ts | 54 ++++ src/lib/rental-host.ts | 120 +++++++++ 16 files changed, 1509 insertions(+), 9 deletions(-) create mode 100644 src/app/espace-prestataire/actions.ts create mode 100644 src/app/espace-prestataire/items/[itemId]/_components/ItemBlocksManager.tsx create mode 100644 src/app/espace-prestataire/items/[itemId]/_components/ItemInlineDelete.tsx create mode 100644 src/app/espace-prestataire/items/[itemId]/page.tsx create mode 100644 src/app/espace-prestataire/items/_components/ItemForm.tsx create mode 100644 src/app/espace-prestataire/items/new/page.tsx create mode 100644 src/app/espace-prestataire/items/page.tsx create mode 100644 src/app/espace-prestataire/page.tsx create mode 100644 src/app/espace-prestataire/reservations/_components/BookingDecision.tsx create mode 100644 src/app/espace-prestataire/reservations/page.tsx create mode 100644 src/lib/rental-access.ts create mode 100644 src/lib/rental-host.ts diff --git a/src/app/api/signup/route.ts b/src/app/api/signup/route.ts index 1ded993..8953cf7 100644 --- a/src/app/api/signup/route.ts +++ b/src/app/api/signup/route.ts @@ -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 }); } diff --git a/src/app/espace-prestataire/actions.ts b/src/app/espace-prestataire/actions.ts new file mode 100644 index 0000000..2474e91 --- /dev/null +++ b/src/app/espace-prestataire/actions.ts @@ -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 }; +} diff --git a/src/app/espace-prestataire/items/[itemId]/_components/ItemBlocksManager.tsx b/src/app/espace-prestataire/items/[itemId]/_components/ItemBlocksManager.tsx new file mode 100644 index 0000000..e83e53b --- /dev/null +++ b/src/app/espace-prestataire/items/[itemId]/_components/ItemBlocksManager.tsx @@ -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 = { + 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(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 ( +
+
+
+ + + + +
+ +
+
+
+ + {error ? ( +
{error}
+ ) : null} + + {blocks.length === 0 ? ( +

+ Aucun blocage manuel. Toutes les dates sont disponibles. +

+ ) : ( +
    + {blocks.map((b) => ( +
  • +
    + + {b.startDate} → {b.endDate} + + + {b.qty} unité{b.qty > 1 ? "s" : ""} · {REASON_LABEL[b.reason] ?? b.reason} + +
    + {!b.isBooking ? ( + + ) : ( + Auto + )} +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/app/espace-prestataire/items/[itemId]/_components/ItemInlineDelete.tsx b/src/app/espace-prestataire/items/[itemId]/_components/ItemInlineDelete.tsx new file mode 100644 index 0000000..bc81c8b --- /dev/null +++ b/src/app/espace-prestataire/items/[itemId]/_components/ItemInlineDelete.tsx @@ -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(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 ( + + Suppression impossible — item référencé par des locations + + ); + } + + return ( +
+ {confirm ? ( +
+ Supprimer ? + + +
+ ) : ( + + )} + {error ? ( +
{error}
+ ) : null} +
+ ); +} diff --git a/src/app/espace-prestataire/items/[itemId]/page.tsx b/src/app/espace-prestataire/items/[itemId]/page.tsx new file mode 100644 index 0000000..699a8b0 --- /dev/null +++ b/src/app/espace-prestataire/items/[itemId]/page.tsx @@ -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 ( +
+
+
+ + ← Mes items + +

{item.name}

+

+ {RENTAL_CATEGORY_LABEL[item.category]} · Stock : {item.totalQty} · {item._count.lines} location(s) historique +

+
+ +
+ +
+ +
+ +
+

+ Calendrier de disponibilité +

+

+ Bloquez ici des dates pour maintenance, indisponibilité personnelle, etc. Les réservations + confirmées sont gérées automatiquement. +

+ ({ + 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} + /> +
+
+ ); +} diff --git a/src/app/espace-prestataire/items/_components/ItemForm.tsx b/src/app/espace-prestataire/items/_components/ItemForm.tsx new file mode 100644 index 0000000..c0033ad --- /dev/null +++ b/src/app/espace-prestataire/items/_components/ItemForm.tsx @@ -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(null); + const [success, setSuccess] = useState(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 ( +
+
+
+ + + +