diff --git a/src/app/espace-hote/carbets/actions.ts b/src/app/espace-hote/carbets/actions.ts
index 8964376..ba29ac4 100644
--- a/src/app/espace-hote/carbets/actions.ts
+++ b/src/app/espace-hote/carbets/actions.ts
@@ -9,7 +9,7 @@ import { prisma } from "@/lib/prisma";
import { ensureUniqueCarbetSlug } from "@/lib/slug";
import { deleteObject } from "@/lib/storage";
import { Prisma } from "@/generated/prisma/client";
-import { CarbetStatus } from "@/generated/prisma/enums";
+import { CarbetStatus, Electricity, RoadAccess } from "@/generated/prisma/enums";
import type { CarbetFormState } from "./form-types";
@@ -22,10 +22,26 @@ type ParsedCarbet = {
embarkPoint: string;
pirogueDurationMin: number;
capacity: number;
+ roadAccess: RoadAccess | null;
+ electricity: Electricity | null;
+ gsmAtCarbet: boolean;
+ gsmExitDistanceKm: number | null;
status: CarbetStatus;
amenities: string[];
};
+function isRoadAccess(v: string): v is RoadAccess {
+ return v === RoadAccess.NONE || v === RoadAccess.DRY_SEASON_ONLY || v === RoadAccess.ALL_YEAR;
+}
+function isElectricity(v: string): v is Electricity {
+ return (
+ v === Electricity.NONE ||
+ v === Electricity.SOLAR ||
+ v === Electricity.GENERATOR_READY ||
+ v === Electricity.EDF
+ );
+}
+
function isCarbetStatus(value: string): value is CarbetStatus {
return (Object.values(CarbetStatus) as string[]).includes(value);
}
@@ -107,6 +123,29 @@ function parseCarbetForm(formData: FormData): {
const status = isCarbetStatus(statusRaw) ? statusRaw : CarbetStatus.DRAFT;
+ // Critères opérationnels
+ const roadAccessRaw = String(formData.get("roadAccess") ?? "").trim();
+ const roadAccess = isRoadAccess(roadAccessRaw) ? roadAccessRaw : null;
+
+ const electricityRaw = String(formData.get("electricity") ?? "").trim();
+ const electricity = isElectricity(electricityRaw) ? electricityRaw : null;
+
+ const gsmAtCarbet = String(formData.get("gsmAtCarbet") ?? "no") === "yes";
+
+ const gsmExitRaw = String(formData.get("gsmExitDistanceKm") ?? "").trim();
+ let gsmExitDistanceKm: number | null = null;
+ if (gsmExitRaw) {
+ const n = Number(gsmExitRaw);
+ if (Number.isFinite(n) && n >= 0 && n <= 50) {
+ gsmExitDistanceKm = n;
+ } else {
+ errors.gsmExitDistanceKm = "Distance invalide (0 à 50 km).";
+ }
+ }
+
+ // Cohérence : si GSM au carbet, on ignore la distance
+ const finalGsmExitDistanceKm = gsmAtCarbet ? null : gsmExitDistanceKm;
+
return {
data: {
title,
@@ -117,6 +156,10 @@ function parseCarbetForm(formData: FormData): {
embarkPoint,
pirogueDurationMin,
capacity,
+ roadAccess,
+ electricity,
+ gsmAtCarbet,
+ gsmExitDistanceKm: finalGsmExitDistanceKm,
status,
amenities,
},
@@ -183,6 +226,10 @@ export async function createCarbet(
embarkPoint: data.embarkPoint,
pirogueDurationMin: data.pirogueDurationMin,
capacity: data.capacity,
+ roadAccess: data.roadAccess,
+ electricity: data.electricity,
+ gsmAtCarbet: data.gsmAtCarbet,
+ gsmExitDistanceKm: data.gsmExitDistanceKm,
status: CarbetStatus.DRAFT,
},
select: { id: true },
@@ -239,6 +286,10 @@ export async function updateCarbet(
embarkPoint: data.embarkPoint,
pirogueDurationMin: data.pirogueDurationMin,
capacity: data.capacity,
+ roadAccess: data.roadAccess,
+ electricity: data.electricity,
+ gsmAtCarbet: data.gsmAtCarbet,
+ gsmExitDistanceKm: data.gsmExitDistanceKm,
status: data.status,
},
});
From e2f3f070faed517c8b63594a5d64817961c69945 Mon Sep 17 00:00:00 2001
From: Claude Integration
Date: Tue, 2 Jun 2026 03:26:04 +0000
Subject: [PATCH 12/32] =?UTF-8?q?feat(rental):=20Sprint=20A=20=E2=80=94=20?=
=?UTF-8?q?mod=C3=A8le=20Prisma=20+=20admin=20CRUD=20+=20seed=2013=20items?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../migration.sql | 112 +++++++++++++
prisma/schema.prisma | 143 +++++++++++++++-
.../[id]/_components/ItemInlineActions.tsx | 86 ++++++++++
src/app/admin/rental-items/[id]/page.tsx | 83 ++++++++++
.../rental-items/_components/ItemForm.tsx | 141 ++++++++++++++++
src/app/admin/rental-items/actions.ts | 129 +++++++++++++++
src/app/admin/rental-items/new/page.tsx | 31 ++++
src/app/admin/rental-items/page.tsx | 152 ++++++++++++++++++
.../_components/ProviderInlineActions.tsx | 120 ++++++++++++++
src/app/admin/rental-providers/[id]/page.tsx | 136 ++++++++++++++++
.../_components/ProviderForm.tsx | 132 +++++++++++++++
src/app/admin/rental-providers/actions.ts | 150 +++++++++++++++++
src/app/admin/rental-providers/new/page.tsx | 21 +++
src/app/admin/rental-providers/page.tsx | 149 +++++++++++++++++
src/app/admin/rentals/page.tsx | 141 ++++++++++++++++
src/components/admin/Sidebar.tsx | 3 +
src/lib/admin/rental-bookings.ts | 60 +++++++
src/lib/admin/rental-items.ts | 111 +++++++++++++
src/lib/admin/rental-providers.ts | 106 ++++++++++++
19 files changed, 2000 insertions(+), 6 deletions(-)
create mode 100644 prisma/migrations/20260603000000_rental_marketplace/migration.sql
create mode 100644 src/app/admin/rental-items/[id]/_components/ItemInlineActions.tsx
create mode 100644 src/app/admin/rental-items/[id]/page.tsx
create mode 100644 src/app/admin/rental-items/_components/ItemForm.tsx
create mode 100644 src/app/admin/rental-items/actions.ts
create mode 100644 src/app/admin/rental-items/new/page.tsx
create mode 100644 src/app/admin/rental-items/page.tsx
create mode 100644 src/app/admin/rental-providers/[id]/_components/ProviderInlineActions.tsx
create mode 100644 src/app/admin/rental-providers/[id]/page.tsx
create mode 100644 src/app/admin/rental-providers/_components/ProviderForm.tsx
create mode 100644 src/app/admin/rental-providers/actions.ts
create mode 100644 src/app/admin/rental-providers/new/page.tsx
create mode 100644 src/app/admin/rental-providers/page.tsx
create mode 100644 src/app/admin/rentals/page.tsx
create mode 100644 src/lib/admin/rental-bookings.ts
create mode 100644 src/lib/admin/rental-items.ts
create mode 100644 src/lib/admin/rental-providers.ts
diff --git a/prisma/migrations/20260603000000_rental_marketplace/migration.sql b/prisma/migrations/20260603000000_rental_marketplace/migration.sql
new file mode 100644
index 0000000..65b4eb1
--- /dev/null
+++ b/prisma/migrations/20260603000000_rental_marketplace/migration.sql
@@ -0,0 +1,112 @@
+-- UserRole : ajouter RENTAL_PROVIDER
+ALTER TYPE "UserRole" ADD VALUE IF NOT EXISTS 'RENTAL_PROVIDER';
+
+-- Enums dédiés
+CREATE TYPE "RentalCategory" AS ENUM ('SLEEP', 'NAVIGATION', 'FISHING', 'COOKING', 'SAFETY');
+CREATE TYPE "RentalBookingStatus" AS ENUM ('PENDING', 'CONFIRMED', 'HANDED_OVER', 'RETURNED', 'CANCELLED');
+
+-- RentalProvider
+CREATE TABLE "RentalProvider" (
+ "id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "isSystemD" BOOLEAN NOT NULL DEFAULT false,
+ "managedByUserId" TEXT,
+ "contactEmail" TEXT,
+ "contactPhone" TEXT,
+ "rivers" TEXT[] DEFAULT ARRAY[]::TEXT[],
+ "description" TEXT,
+ "commissionPct" DECIMAL(5,2) NOT NULL DEFAULT 0,
+ "active" BOOLEAN NOT NULL DEFAULT true,
+ "approved" BOOLEAN NOT NULL DEFAULT false,
+ "approvedAt" TIMESTAMP(3),
+ "approvedBy" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ CONSTRAINT "RentalProvider_pkey" PRIMARY KEY ("id"),
+ CONSTRAINT "RentalProvider_managedByUserId_fkey" FOREIGN KEY ("managedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE
+);
+CREATE INDEX "RentalProvider_active_approved_idx" ON "RentalProvider"("active", "approved");
+CREATE INDEX "RentalProvider_managedByUserId_idx" ON "RentalProvider"("managedByUserId");
+
+-- RentalItem
+CREATE TABLE "RentalItem" (
+ "id" TEXT NOT NULL,
+ "providerId" TEXT NOT NULL,
+ "category" "RentalCategory" NOT NULL,
+ "name" TEXT NOT NULL,
+ "description" TEXT,
+ "imageUrl" TEXT,
+ "pricePerDay" DECIMAL(8,2) NOT NULL,
+ "pricePerWeek" DECIMAL(8,2),
+ "deposit" DECIMAL(8,2) NOT NULL DEFAULT 0,
+ "totalQty" INTEGER NOT NULL DEFAULT 1,
+ "withMotor" BOOLEAN NOT NULL DEFAULT false,
+ "fuelIncluded" BOOLEAN NOT NULL DEFAULT false,
+ "requiresLicense" BOOLEAN NOT NULL DEFAULT false,
+ "active" BOOLEAN NOT NULL DEFAULT true,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ CONSTRAINT "RentalItem_pkey" PRIMARY KEY ("id"),
+ CONSTRAINT "RentalItem_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "RentalProvider"("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+CREATE INDEX "RentalItem_providerId_idx" ON "RentalItem"("providerId");
+CREATE INDEX "RentalItem_category_active_idx" ON "RentalItem"("category", "active");
+
+-- RentalItemAvailability
+CREATE TABLE "RentalItemAvailability" (
+ "id" TEXT NOT NULL,
+ "itemId" TEXT NOT NULL,
+ "startDate" TIMESTAMP(3) NOT NULL,
+ "endDate" TIMESTAMP(3) NOT NULL,
+ "qty" INTEGER NOT NULL,
+ "reason" TEXT NOT NULL,
+ "rentalBookingId" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "RentalItemAvailability_pkey" PRIMARY KEY ("id"),
+ CONSTRAINT "RentalItemAvailability_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "RentalItem"("id") ON DELETE CASCADE ON UPDATE CASCADE
+);
+CREATE INDEX "RentalItemAvailability_itemId_startDate_endDate_idx" ON "RentalItemAvailability"("itemId", "startDate", "endDate");
+CREATE INDEX "RentalItemAvailability_rentalBookingId_idx" ON "RentalItemAvailability"("rentalBookingId");
+
+-- RentalBooking
+CREATE TABLE "RentalBooking" (
+ "id" TEXT NOT NULL,
+ "bookingId" TEXT,
+ "tenantId" TEXT NOT NULL,
+ "providerId" TEXT NOT NULL,
+ "startDate" TIMESTAMP(3) NOT NULL,
+ "endDate" TIMESTAMP(3) NOT NULL,
+ "status" "RentalBookingStatus" NOT NULL DEFAULT 'PENDING',
+ "paymentStatus" "PaymentStatus" NOT NULL DEFAULT 'PENDING',
+ "itemsTotal" DECIMAL(10,2) NOT NULL,
+ "depositTotal" DECIMAL(10,2) NOT NULL,
+ "commissionAmount" DECIMAL(10,2) NOT NULL DEFAULT 0,
+ "amount" DECIMAL(10,2) NOT NULL,
+ "currency" TEXT NOT NULL DEFAULT 'EUR',
+ "stripeSessionId" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+ CONSTRAINT "RentalBooking_pkey" PRIMARY KEY ("id"),
+ CONSTRAINT "RentalBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE,
+ CONSTRAINT "RentalBooking_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
+ CONSTRAINT "RentalBooking_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "RentalProvider"("id") ON DELETE RESTRICT ON UPDATE CASCADE
+);
+CREATE INDEX "RentalBooking_tenantId_status_idx" ON "RentalBooking"("tenantId", "status");
+CREATE INDEX "RentalBooking_providerId_status_idx" ON "RentalBooking"("providerId", "status");
+CREATE INDEX "RentalBooking_bookingId_idx" ON "RentalBooking"("bookingId");
+CREATE INDEX "RentalBooking_startDate_endDate_idx" ON "RentalBooking"("startDate", "endDate");
+
+-- RentalLine
+CREATE TABLE "RentalLine" (
+ "id" TEXT NOT NULL,
+ "rentalBookingId" TEXT NOT NULL,
+ "itemId" TEXT NOT NULL,
+ "qty" INTEGER NOT NULL,
+ "pricePerDay" DECIMAL(8,2) NOT NULL,
+ "deposit" DECIMAL(8,2) NOT NULL DEFAULT 0,
+ "lineTotal" DECIMAL(10,2) NOT NULL,
+ CONSTRAINT "RentalLine_pkey" PRIMARY KEY ("id"),
+ CONSTRAINT "RentalLine_rentalBookingId_fkey" FOREIGN KEY ("rentalBookingId") REFERENCES "RentalBooking"("id") ON DELETE CASCADE ON UPDATE CASCADE,
+ CONSTRAINT "RentalLine_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "RentalItem"("id") ON DELETE RESTRICT ON UPDATE CASCADE
+);
+CREATE INDEX "RentalLine_rentalBookingId_idx" ON "RentalLine"("rentalBookingId");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 0636340..7580413 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -13,6 +13,7 @@ enum UserRole {
CE_MEMBER
TOURIST
ADMIN
+ RENTAL_PROVIDER
}
enum CarbetStatus {
@@ -97,11 +98,13 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
- organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
- carbets Carbet[] @relation("CarbetOwner")
- bookings Booking[] @relation("BookingTenant")
- reviews Review[] @relation("ReviewAuthor")
- subscriptions Subscription[]
+ organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
+ carbets Carbet[] @relation("CarbetOwner")
+ bookings Booking[] @relation("BookingTenant")
+ reviews Review[] @relation("ReviewAuthor")
+ subscriptions Subscription[]
+ rentalProviders RentalProvider[]
+ rentalBookings RentalBooking[] @relation("RentalBookingTenant")
@@index([organizationId])
@@index([role])
@@ -249,7 +252,8 @@ model Booking {
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Restrict)
tenant User @relation("BookingTenant", fields: [tenantId], references: [id], onDelete: Restrict)
- review Review?
+ review Review?
+ rentalBookings RentalBooking[]
@@index([carbetId])
@@index([tenantId])
@@ -399,3 +403,130 @@ enum Electricity {
GENERATOR_READY
EDF
}
+
+enum RentalCategory {
+ SLEEP
+ NAVIGATION
+ FISHING
+ COOKING
+ SAFETY
+}
+
+enum RentalBookingStatus {
+ PENDING
+ CONFIRMED
+ HANDED_OVER
+ RETURNED
+ CANCELLED
+}
+
+model RentalProvider {
+ id String @id @default(cuid())
+ name String
+ isSystemD Boolean @default(false)
+ managedByUserId String?
+ contactEmail String?
+ contactPhone String?
+ rivers String[] @default([])
+ description String?
+ commissionPct Decimal @db.Decimal(5, 2) @default(0)
+ active Boolean @default(true)
+ approved Boolean @default(false)
+ approvedAt DateTime?
+ approvedBy String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ manager User? @relation(fields: [managedByUserId], references: [id], onDelete: SetNull)
+ items RentalItem[]
+ rentalBookings RentalBooking[]
+
+ @@index([active, approved])
+ @@index([managedByUserId])
+}
+
+model RentalItem {
+ id String @id @default(cuid())
+ providerId String
+ category RentalCategory
+ name String
+ description String?
+ imageUrl String?
+ pricePerDay Decimal @db.Decimal(8, 2)
+ pricePerWeek Decimal? @db.Decimal(8, 2)
+ deposit Decimal @db.Decimal(8, 2) @default(0)
+ totalQty Int @default(1)
+ withMotor Boolean @default(false)
+ fuelIncluded Boolean @default(false)
+ requiresLicense Boolean @default(false)
+ active Boolean @default(true)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ provider RentalProvider @relation(fields: [providerId], references: [id], onDelete: Cascade)
+ availabilities RentalItemAvailability[]
+ lines RentalLine[]
+
+ @@index([providerId])
+ @@index([category, active])
+}
+
+model RentalItemAvailability {
+ id String @id @default(cuid())
+ itemId String
+ startDate DateTime
+ endDate DateTime
+ qty Int
+ reason String
+ rentalBookingId String?
+ createdAt DateTime @default(now())
+
+ item RentalItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
+
+ @@index([itemId, startDate, endDate])
+ @@index([rentalBookingId])
+}
+
+model RentalBooking {
+ id String @id @default(cuid())
+ bookingId String?
+ tenantId String
+ providerId String
+ startDate DateTime
+ endDate DateTime
+ status RentalBookingStatus @default(PENDING)
+ paymentStatus PaymentStatus @default(PENDING)
+ itemsTotal Decimal @db.Decimal(10, 2)
+ depositTotal Decimal @db.Decimal(10, 2)
+ commissionAmount Decimal @db.Decimal(10, 2) @default(0)
+ amount Decimal @db.Decimal(10, 2)
+ currency String @default("EUR")
+ stripeSessionId String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ booking Booking? @relation(fields: [bookingId], references: [id], onDelete: SetNull)
+ tenant User @relation("RentalBookingTenant", fields: [tenantId], references: [id], onDelete: Restrict)
+ provider RentalProvider @relation(fields: [providerId], references: [id], onDelete: Restrict)
+ lines RentalLine[]
+
+ @@index([tenantId, status])
+ @@index([providerId, status])
+ @@index([bookingId])
+ @@index([startDate, endDate])
+}
+
+model RentalLine {
+ id String @id @default(cuid())
+ rentalBookingId String
+ itemId String
+ qty Int
+ pricePerDay Decimal @db.Decimal(8, 2)
+ deposit Decimal @db.Decimal(8, 2) @default(0)
+ lineTotal Decimal @db.Decimal(10, 2)
+
+ rentalBooking RentalBooking @relation(fields: [rentalBookingId], references: [id], onDelete: Cascade)
+ item RentalItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
+
+ @@index([rentalBookingId])
+}
diff --git a/src/app/admin/rental-items/[id]/_components/ItemInlineActions.tsx b/src/app/admin/rental-items/[id]/_components/ItemInlineActions.tsx
new file mode 100644
index 0000000..8a6a00f
--- /dev/null
+++ b/src/app/admin/rental-items/[id]/_components/ItemInlineActions.tsx
@@ -0,0 +1,86 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+
+type Props = {
+ active: boolean;
+ toggleActiveAction: (active: boolean) => Promise<{ ok: true } | { ok: false; error: string } | undefined>;
+ deleteAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined | void>;
+};
+
+export function ItemInlineActions({ active, toggleActiveAction, deleteAction }: Props) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [confirmDelete, setConfirmDelete] = useState(false);
+ const [error, setError] = useState(null);
+
+ function toggle() {
+ setError(null);
+ startTransition(async () => {
+ const res = await toggleActiveAction(!active);
+ if (res && (res as { ok?: boolean }).ok === false) setError((res as { error: string }).error);
+ router.refresh();
+ });
+ }
+ function del() {
+ setError(null);
+ startTransition(async () => {
+ const res = await deleteAction();
+ if (res && (res as { ok?: boolean }).ok === false) {
+ setError((res as { error: string }).error);
+ setConfirmDelete(false);
+ }
+ });
+ }
+
+ return (
+
+
+
+ {active ? "Désactiver" : "Réactiver"}
+
+ {confirmDelete ? (
+
+ Supprimer ?
+
+ Oui
+
+ setConfirmDelete(false)}
+ disabled={pending}
+ className="text-[11px] text-zinc-500 hover:text-zinc-900"
+ >
+ Annuler
+
+
+ ) : (
+
setConfirmDelete(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
+
+ )}
+
+ {error ?
{error}
: null}
+
+ );
+}
diff --git a/src/app/admin/rental-items/[id]/page.tsx b/src/app/admin/rental-items/[id]/page.tsx
new file mode 100644
index 0000000..8f4dd4a
--- /dev/null
+++ b/src/app/admin/rental-items/[id]/page.tsx
@@ -0,0 +1,83 @@
+import { notFound } from "next/navigation";
+import Link from "next/link";
+
+import { StatusBadge } from "@/components/admin/StatusBadge";
+import { getRentalItemForAdmin, listProvidersForSelect, RENTAL_CATEGORY_LABEL } from "@/lib/admin/rental-items";
+
+import { ItemForm } from "../_components/ItemForm";
+import { ItemInlineActions } from "./_components/ItemInlineActions";
+import { deleteRentalItemAction, toggleRentalItemActiveAction, updateRentalItemAction } from "../actions";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = { params: Promise<{ id: string }> };
+
+export default async function EditRentalItemPage({ params }: PageProps) {
+ const { id } = await params;
+ const [item, providers] = await Promise.all([getRentalItemForAdmin(id), listProvidersForSelect()]);
+ if (!item) notFound();
+
+ const updateThis = async (fd: FormData) => {
+ "use server";
+ return await updateRentalItemAction(id, fd);
+ };
+ const toggleActiveThis = async (active: boolean) => {
+ "use server";
+ return await toggleRentalItemActiveAction(id, active);
+ };
+ const deleteThis = async () => {
+ "use server";
+ return await deleteRentalItemAction(id);
+ };
+
+ return (
+
+ );
+}
diff --git a/src/app/admin/rental-items/_components/ItemForm.tsx b/src/app/admin/rental-items/_components/ItemForm.tsx
new file mode 100644
index 0000000..523dabc
--- /dev/null
+++ b/src/app/admin/rental-items/_components/ItemForm.tsx
@@ -0,0 +1,141 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { FormField, inputCls, selectCls, textareaCls } from "@/components/admin/FormField";
+import { RENTAL_CATEGORY_LABEL } from "@/lib/admin/rental-items";
+import { RentalCategory } from "@/generated/prisma/enums";
+
+type Props = {
+ providers: { id: string; name: string; isSystemD: boolean }[];
+ initial?: {
+ providerId?: string;
+ 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;
+};
+
+const CATEGORIES: RentalCategory[] = [
+ RentalCategory.SLEEP,
+ RentalCategory.NAVIGATION,
+ RentalCategory.FISHING,
+ RentalCategory.COOKING,
+ RentalCategory.SAFETY,
+];
+
+export function ItemForm({ providers, 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 (
+
+ );
+}
diff --git a/src/app/admin/rental-items/actions.ts b/src/app/admin/rental-items/actions.ts
new file mode 100644
index 0000000..c5eaad2
--- /dev/null
+++ b/src/app/admin/rental-items/actions.ts
@@ -0,0 +1,129 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { redirect } from "next/navigation";
+import { z } from "zod";
+
+import { auth } from "@/auth";
+import { RentalCategory, UserRole } from "@/generated/prisma/enums";
+import { requireRole } from "@/lib/authorization";
+import { recordAudit } from "@/lib/admin/audit";
+import { prisma } from "@/lib/prisma";
+
+const itemSchema = z.object({
+ providerId: z.string().min(1),
+ 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(),
+});
+
+function parseFD(fd: FormData) {
+ const get = (k: string) => {
+ const v = (fd.get(k) as string | null) ?? "";
+ return v.trim() === "" ? null : v.trim();
+ };
+ return {
+ providerId: ((fd.get("providerId") as string | null) ?? "").trim(),
+ 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 createRentalItemAction(fd: FormData) {
+ await requireRole([UserRole.ADMIN]);
+ const parsed = itemSchema.safeParse(parseFD(fd));
+ if (!parsed.success) {
+ return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
+ }
+ const session = await auth();
+ const created = await prisma.rentalItem.create({ data: parsed.data });
+ await recordAudit({
+ scope: "admin.rental-items",
+ event: "create",
+ target: created.id,
+ actorEmail: session?.user?.email ?? null,
+ details: { name: created.name, providerId: created.providerId },
+ });
+ revalidatePath("/admin/rental-items");
+ redirect(`/admin/rental-items/${created.id}`);
+}
+
+export async function updateRentalItemAction(id: string, fd: FormData) {
+ await requireRole([UserRole.ADMIN]);
+ const parsed = itemSchema.safeParse(parseFD(fd));
+ if (!parsed.success) {
+ return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
+ }
+ const session = await auth();
+ await prisma.rentalItem.update({ where: { id }, data: parsed.data });
+ await recordAudit({
+ scope: "admin.rental-items",
+ event: "update",
+ target: id,
+ actorEmail: session?.user?.email ?? null,
+ details: { name: parsed.data.name },
+ });
+ revalidatePath("/admin/rental-items");
+ revalidatePath(`/admin/rental-items/${id}`);
+ return { ok: true as const };
+}
+
+export async function toggleRentalItemActiveAction(id: string, active: boolean) {
+ await requireRole([UserRole.ADMIN]);
+ const session = await auth();
+ await prisma.rentalItem.update({ where: { id }, data: { active } });
+ await recordAudit({
+ scope: "admin.rental-items",
+ event: "active.update",
+ target: id,
+ actorEmail: session?.user?.email ?? null,
+ details: { active },
+ });
+ revalidatePath("/admin/rental-items");
+ revalidatePath(`/admin/rental-items/${id}`);
+ return { ok: true as const };
+}
+
+export async function deleteRentalItemAction(id: string) {
+ await requireRole([UserRole.ADMIN]);
+ const session = await auth();
+ const linesCount = await prisma.rentalLine.count({ where: { itemId: id } });
+ if (linesCount > 0) {
+ return { ok: false as const, error: `Impossible : ${linesCount} ligne(s) de réservation pointe(nt) sur cet item.` };
+ }
+ await prisma.rentalItem.delete({ where: { id } });
+ await recordAudit({
+ scope: "admin.rental-items",
+ event: "delete",
+ target: id,
+ actorEmail: session?.user?.email ?? null,
+ details: {},
+ });
+ revalidatePath("/admin/rental-items");
+ redirect("/admin/rental-items");
+}
diff --git a/src/app/admin/rental-items/new/page.tsx b/src/app/admin/rental-items/new/page.tsx
new file mode 100644
index 0000000..fec17d0
--- /dev/null
+++ b/src/app/admin/rental-items/new/page.tsx
@@ -0,0 +1,31 @@
+import Link from "next/link";
+import { ItemForm } from "../_components/ItemForm";
+import { createRentalItemAction } from "../actions";
+import { listProvidersForSelect } from "@/lib/admin/rental-items";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = { searchParams: Promise<{ providerId?: string }> };
+
+export default async function NewRentalItemPage({ searchParams }: PageProps) {
+ const sp = await searchParams;
+ const providers = await listProvidersForSelect();
+ return (
+
+
+
+ ← Tous les items
+
+ Nouvel item locable
+
+
+
+ );
+}
diff --git a/src/app/admin/rental-items/page.tsx b/src/app/admin/rental-items/page.tsx
new file mode 100644
index 0000000..d67a556
--- /dev/null
+++ b/src/app/admin/rental-items/page.tsx
@@ -0,0 +1,152 @@
+import Link from "next/link";
+import { RentalCategory } from "@/generated/prisma/enums";
+import {
+ RENTAL_CATEGORY_LABEL,
+ isRentalCategory,
+ listProvidersForSelect,
+ listRentalItemsAdmin,
+} from "@/lib/admin/rental-items";
+import { StatusBadge } from "@/components/admin/StatusBadge";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = {
+ searchParams: Promise<{
+ q?: string;
+ category?: string;
+ providerId?: string;
+ active?: string;
+ }>;
+};
+
+export default async function RentalItemsAdminPage({ searchParams }: PageProps) {
+ const sp = await searchParams;
+ const filters = {
+ q: sp.q?.trim() || undefined,
+ category: sp.category && isRentalCategory(sp.category) ? sp.category : undefined,
+ providerId: sp.providerId || undefined,
+ active: sp.active === "yes" || sp.active === "no" ? (sp.active as "yes" | "no") : undefined,
+ };
+ const [rows, providers] = await Promise.all([listRentalItemsAdmin(filters), listProvidersForSelect()]);
+ const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
+
+ return (
+
+
+
+
+
+
+ Toutes catégories
+ {Object.values(RentalCategory).map((c) => (
+ {RENTAL_CATEGORY_LABEL[c]}
+ ))}
+
+
+ Tous prestataires
+ {providers.map((p) => (
+ {p.name}
+ ))}
+
+
+ Actifs + inactifs
+ Actifs
+ Inactifs
+
+
+ Filtrer
+
+ {(filters.q || filters.category || filters.providerId || filters.active) ? (
+ Réinit.
+ ) : null}
+
+
+
+
+
+
+ Nom
+ Catégorie
+ Prestataire
+ € / jour
+ Stock
+ Caution
+ État
+ MAJ
+
+
+
+ {rows.length === 0 ? (
+
+
+ Aucun item.
+
+
+ ) : null}
+ {rows.map((i) => (
+
+
+
+ {i.name}
+
+
+ {i.withMotor ? "⚙️ moteur · " : ""}
+ {i.requiresLicense ? "🪪 permis · " : ""}
+ {i.fuelIncluded ? "⛽ essence · " : ""}
+
+
+ {RENTAL_CATEGORY_LABEL[i.category]}
+
+
+ {i.providerName}
+
+ {i.providerIsSystemD ? (
+
+ SD
+
+ ) : null}
+
+ {Number(i.pricePerDay).toFixed(0)}
+ {i.totalQty}
+ {Number(i.deposit).toFixed(0)}
+
+ {dateFmt.format(i.updatedAt)}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/app/admin/rental-providers/[id]/_components/ProviderInlineActions.tsx b/src/app/admin/rental-providers/[id]/_components/ProviderInlineActions.tsx
new file mode 100644
index 0000000..1839fae
--- /dev/null
+++ b/src/app/admin/rental-providers/[id]/_components/ProviderInlineActions.tsx
@@ -0,0 +1,120 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { useRouter } from "next/navigation";
+
+type Props = {
+ approved: boolean;
+ active: boolean;
+ itemsCount: number;
+ approveAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined>;
+ toggleActiveAction: (active: boolean) => Promise<{ ok: true } | { ok: false; error: string } | undefined>;
+ deleteAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined | void>;
+};
+
+export function ProviderInlineActions({
+ approved,
+ active,
+ itemsCount,
+ approveAction,
+ toggleActiveAction,
+ deleteAction,
+}: Props) {
+ const router = useRouter();
+ const [pending, startTransition] = useTransition();
+ const [confirmDelete, setConfirmDelete] = useState(false);
+ const [error, setError] = useState(null);
+
+ function approve() {
+ setError(null);
+ startTransition(async () => {
+ const res = await approveAction();
+ if (res && (res as { ok?: boolean }).ok === false) setError((res as { error: string }).error);
+ router.refresh();
+ });
+ }
+ function toggle() {
+ setError(null);
+ startTransition(async () => {
+ const res = await toggleActiveAction(!active);
+ if (res && (res as { ok?: boolean }).ok === false) setError((res as { error: string }).error);
+ router.refresh();
+ });
+ }
+ function del() {
+ setError(null);
+ startTransition(async () => {
+ const res = await deleteAction();
+ if (res && (res as { ok?: boolean }).ok === false) {
+ setError((res as { error: string }).error);
+ setConfirmDelete(false);
+ }
+ });
+ }
+
+ return (
+
+
+ {!approved ? (
+
+ ✓ Approuver
+
+ ) : null}
+
+ {active ? "Désactiver" : "Réactiver"}
+
+ {itemsCount === 0 ? (
+ confirmDelete ? (
+
+ Supprimer ?
+
+ Oui
+
+ setConfirmDelete(false)}
+ disabled={pending}
+ className="text-[11px] text-zinc-500 hover:text-zinc-900"
+ >
+ Annuler
+
+
+ ) : (
+
setConfirmDelete(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
+
+ )
+ ) : (
+
+ {itemsCount} item(s) — supprimez-les d'abord
+
+ )}
+
+ {error ?
{error}
: null}
+
+ );
+}
diff --git a/src/app/admin/rental-providers/[id]/page.tsx b/src/app/admin/rental-providers/[id]/page.tsx
new file mode 100644
index 0000000..5358bd1
--- /dev/null
+++ b/src/app/admin/rental-providers/[id]/page.tsx
@@ -0,0 +1,136 @@
+import { notFound } from "next/navigation";
+import Link from "next/link";
+
+import { StatusBadge } from "@/components/admin/StatusBadge";
+import { getRentalProviderForAdmin } from "@/lib/admin/rental-providers";
+import { RENTAL_CATEGORY_LABEL } from "@/lib/admin/rental-items";
+
+import { ProviderForm } from "../_components/ProviderForm";
+import { ProviderInlineActions } from "./_components/ProviderInlineActions";
+import {
+ approveRentalProviderAction,
+ deleteRentalProviderAction,
+ toggleRentalProviderActiveAction,
+ updateRentalProviderAction,
+} from "../actions";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = { params: Promise<{ id: string }> };
+
+export default async function EditRentalProviderPage({ params }: PageProps) {
+ const { id } = await params;
+ const p = await getRentalProviderForAdmin(id);
+ if (!p) notFound();
+
+ const updateThis = async (fd: FormData) => {
+ "use server";
+ return await updateRentalProviderAction(id, fd);
+ };
+ const approveThis = async () => {
+ "use server";
+ return await approveRentalProviderAction(id);
+ };
+ const toggleActiveThis = async (active: boolean) => {
+ "use server";
+ return await toggleRentalProviderActiveAction(id, active);
+ };
+ const deleteThis = async () => {
+ "use server";
+ return await deleteRentalProviderAction(id);
+ };
+
+ return (
+
+
+
+
+
+
+
+ Items ({p.items.length})
+
+ Voir tous les items
+
+
+ {p.items.length === 0 ? (
+
+ Pas encore d'item.{" "}
+
+ Créer un premier item
+
+
+ ) : (
+
+ {p.items.map((i) => (
+
+
+ {i.name}
+
+ {RENTAL_CATEGORY_LABEL[i.category]}
+
+
+
+ {Number(i.pricePerDay).toFixed(0)} €/j
+ qty {i.totalQty}
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/app/admin/rental-providers/_components/ProviderForm.tsx b/src/app/admin/rental-providers/_components/ProviderForm.tsx
new file mode 100644
index 0000000..baf84a9
--- /dev/null
+++ b/src/app/admin/rental-providers/_components/ProviderForm.tsx
@@ -0,0 +1,132 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { FormField, inputCls, textareaCls } from "@/components/admin/FormField";
+
+type Props = {
+ initial?: {
+ name?: string;
+ isSystemD?: boolean;
+ contactEmail?: string | null;
+ contactPhone?: string | null;
+ rivers?: string[];
+ description?: string | null;
+ commissionPct?: number | string;
+ active?: boolean;
+ };
+ action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
+ submitLabel?: string;
+};
+
+export function ProviderForm({ 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 (
+
+
+
+
+
+
+
+
+
+ Fournisseur officiel System D (0 % commission)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Actif
+
+
+
+
+
+
+
+
+
+
+
+
+ {error ? (
+ {error}
+ ) : null}
+ {success ? (
+ {success}
+ ) : null}
+
+
+
+ {pending ? "Enregistrement…" : submitLabel}
+
+
+
+
+ );
+}
diff --git a/src/app/admin/rental-providers/actions.ts b/src/app/admin/rental-providers/actions.ts
new file mode 100644
index 0000000..3561471
--- /dev/null
+++ b/src/app/admin/rental-providers/actions.ts
@@ -0,0 +1,150 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { redirect } from "next/navigation";
+import { z } from "zod";
+
+import { auth } from "@/auth";
+import { UserRole } from "@/generated/prisma/enums";
+import { requireRole } from "@/lib/authorization";
+import { recordAudit } from "@/lib/admin/audit";
+import { prisma } from "@/lib/prisma";
+
+const providerSchema = z.object({
+ name: z.string().trim().min(2).max(200),
+ isSystemD: z.boolean(),
+ managedByUserId: z.string().nullable().optional(),
+ contactEmail: z.string().trim().email().max(200).nullable().optional(),
+ contactPhone: z.string().trim().max(50).nullable().optional(),
+ rivers: z.array(z.string().trim().min(1).max(80)).max(20),
+ description: z.string().trim().max(5000).nullable().optional(),
+ commissionPct: z.coerce.number().min(0).max(50),
+ active: z.boolean(),
+});
+
+function parseFD(fd: FormData) {
+ const riversRaw = (fd.get("rivers") as string | null) ?? "";
+ const rivers = riversRaw
+ .split(/[,;\n]/)
+ .map((s) => s.trim())
+ .filter((s) => s.length > 0);
+ const get = (k: string) => {
+ const v = (fd.get(k) as string | null) ?? "";
+ return v.trim() === "" ? null : v.trim();
+ };
+ return {
+ name: ((fd.get("name") as string | null) ?? "").trim(),
+ isSystemD: fd.get("isSystemD") === "on",
+ managedByUserId: get("managedByUserId"),
+ contactEmail: get("contactEmail"),
+ contactPhone: get("contactPhone"),
+ rivers,
+ description: get("description"),
+ commissionPct: fd.get("commissionPct"),
+ active: fd.get("active") === "on",
+ };
+}
+
+export async function createRentalProviderAction(fd: FormData) {
+ await requireRole([UserRole.ADMIN]);
+ const parsed = providerSchema.safeParse(parseFD(fd));
+ if (!parsed.success) {
+ return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
+ }
+ const session = await auth();
+ const created = await prisma.rentalProvider.create({
+ data: {
+ ...parsed.data,
+ approved: true, // créé par admin → approuvé d'office
+ approvedAt: new Date(),
+ approvedBy: session?.user?.email ?? null,
+ },
+ });
+ await recordAudit({
+ scope: "admin.rental-providers",
+ event: "create",
+ target: created.id,
+ actorEmail: session?.user?.email ?? null,
+ details: { name: created.name, isSystemD: created.isSystemD },
+ });
+ revalidatePath("/admin/rental-providers");
+ redirect(`/admin/rental-providers/${created.id}`);
+}
+
+export async function updateRentalProviderAction(id: string, fd: FormData) {
+ await requireRole([UserRole.ADMIN]);
+ const parsed = providerSchema.safeParse(parseFD(fd));
+ if (!parsed.success) {
+ return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
+ }
+ const session = await auth();
+ await prisma.rentalProvider.update({ where: { id }, data: parsed.data });
+ await recordAudit({
+ scope: "admin.rental-providers",
+ event: "update",
+ target: id,
+ actorEmail: session?.user?.email ?? null,
+ details: { name: parsed.data.name },
+ });
+ revalidatePath("/admin/rental-providers");
+ revalidatePath(`/admin/rental-providers/${id}`);
+ return { ok: true as const };
+}
+
+export async function approveRentalProviderAction(id: string) {
+ await requireRole([UserRole.ADMIN]);
+ const session = await auth();
+ await prisma.rentalProvider.update({
+ where: { id },
+ data: {
+ approved: true,
+ approvedAt: new Date(),
+ approvedBy: session?.user?.email ?? null,
+ },
+ });
+ await recordAudit({
+ scope: "admin.rental-providers",
+ event: "approve",
+ target: id,
+ actorEmail: session?.user?.email ?? null,
+ details: {},
+ });
+ revalidatePath("/admin/rental-providers");
+ revalidatePath(`/admin/rental-providers/${id}`);
+ return { ok: true as const };
+}
+
+export async function toggleRentalProviderActiveAction(id: string, active: boolean) {
+ await requireRole([UserRole.ADMIN]);
+ const session = await auth();
+ await prisma.rentalProvider.update({ where: { id }, data: { active } });
+ await recordAudit({
+ scope: "admin.rental-providers",
+ event: "active.update",
+ target: id,
+ actorEmail: session?.user?.email ?? null,
+ details: { active },
+ });
+ revalidatePath("/admin/rental-providers");
+ revalidatePath(`/admin/rental-providers/${id}`);
+ return { ok: true as const };
+}
+
+export async function deleteRentalProviderAction(id: string) {
+ await requireRole([UserRole.ADMIN]);
+ const session = await auth();
+ const itemsCount = await prisma.rentalItem.count({ where: { providerId: id } });
+ if (itemsCount > 0) {
+ return { ok: false as const, error: `Impossible : ${itemsCount} item(s) attaché(s).` };
+ }
+ await prisma.rentalProvider.delete({ where: { id } });
+ await recordAudit({
+ scope: "admin.rental-providers",
+ event: "delete",
+ target: id,
+ actorEmail: session?.user?.email ?? null,
+ details: {},
+ });
+ revalidatePath("/admin/rental-providers");
+ redirect("/admin/rental-providers");
+}
diff --git a/src/app/admin/rental-providers/new/page.tsx b/src/app/admin/rental-providers/new/page.tsx
new file mode 100644
index 0000000..c836fea
--- /dev/null
+++ b/src/app/admin/rental-providers/new/page.tsx
@@ -0,0 +1,21 @@
+import Link from "next/link";
+import { ProviderForm } from "../_components/ProviderForm";
+import { createRentalProviderAction } from "../actions";
+
+export const dynamic = "force-dynamic";
+
+export default function NewRentalProviderPage() {
+ return (
+
+
+
+ ← Tous les prestataires
+
+ Nouveau prestataire location
+
+
+
+ );
+}
diff --git a/src/app/admin/rental-providers/page.tsx b/src/app/admin/rental-providers/page.tsx
new file mode 100644
index 0000000..d2548e3
--- /dev/null
+++ b/src/app/admin/rental-providers/page.tsx
@@ -0,0 +1,149 @@
+import Link from "next/link";
+import { listRentalProvidersAdmin, listRentalProviderRivers } from "@/lib/admin/rental-providers";
+import { StatusBadge } from "@/components/admin/StatusBadge";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = {
+ searchParams: Promise<{
+ q?: string;
+ approved?: string;
+ active?: string;
+ river?: string;
+ }>;
+};
+
+export default async function RentalProvidersAdminPage({ searchParams }: PageProps) {
+ const sp = await searchParams;
+ const filters = {
+ q: sp.q?.trim() || undefined,
+ approved: sp.approved === "yes" || sp.approved === "no" ? (sp.approved as "yes" | "no") : undefined,
+ active: sp.active === "yes" || sp.active === "no" ? (sp.active as "yes" | "no") : undefined,
+ river: sp.river || undefined,
+ };
+ const [rows, rivers] = await Promise.all([listRentalProvidersAdmin(filters), listRentalProviderRivers()]);
+ const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
+
+ return (
+
+
+
+
+
+
+ Tous statuts approbation
+ Approuvés
+ En attente
+
+
+ Actifs + inactifs
+ Actifs
+ Inactifs
+
+
+ Tous fleuves
+ {rivers.map((r) => (
+ {r}
+ ))}
+
+
+ Filtrer
+
+ {(filters.q || filters.approved || filters.active || filters.river) ? (
+
+ Réinit.
+
+ ) : null}
+
+
+
+
+
+
+ Nom
+ Fleuves
+ Items
+ Comm.
+ Approbation
+ État
+ MAJ
+
+
+
+ {rows.length === 0 ? (
+
+
+ Aucun prestataire ne correspond aux filtres.
+
+
+ ) : null}
+ {rows.map((p) => (
+
+
+
+ {p.name}
+
+ {p.isSystemD ? (
+
+ System D
+
+ ) : null}
+ {p.contactEmail ?? "—"}
+
+
+ {p.rivers.length === 0 ? — : p.rivers.join(", ")}
+
+ {p.itemsCount}
+ {Number(p.commissionPct).toFixed(1)}%
+
+ {p.approved ? (
+
+ Approuvé
+
+ ) : (
+
+ En attente
+
+ )}
+
+
+ {dateFmt.format(p.updatedAt)}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/app/admin/rentals/page.tsx b/src/app/admin/rentals/page.tsx
new file mode 100644
index 0000000..34ddb12
--- /dev/null
+++ b/src/app/admin/rentals/page.tsx
@@ -0,0 +1,141 @@
+import Link from "next/link";
+import { PaymentStatus, RentalBookingStatus } from "@/generated/prisma/enums";
+import { listRentalBookingsAdmin, RENTAL_STATUS_LABEL } from "@/lib/admin/rental-bookings";
+import { StatusBadge } from "@/components/admin/StatusBadge";
+
+export const dynamic = "force-dynamic";
+
+type PageProps = {
+ searchParams: Promise<{
+ q?: string;
+ status?: string;
+ paymentStatus?: string;
+ providerId?: string;
+ }>;
+};
+
+const RENTAL_STATUS_VALUES = new Set([
+ RentalBookingStatus.PENDING,
+ RentalBookingStatus.CONFIRMED,
+ RentalBookingStatus.HANDED_OVER,
+ RentalBookingStatus.RETURNED,
+ RentalBookingStatus.CANCELLED,
+]);
+
+const PAYMENT_VALUES = new Set([
+ PaymentStatus.PENDING,
+ PaymentStatus.AUTHORIZED,
+ PaymentStatus.SUCCEEDED,
+ PaymentStatus.FAILED,
+ PaymentStatus.REFUNDED,
+]);
+
+export default async function RentalsAdminPage({ searchParams }: PageProps) {
+ const sp = await searchParams;
+ const filters = {
+ q: sp.q?.trim() || undefined,
+ status: RENTAL_STATUS_VALUES.has(sp.status ?? "") ? (sp.status as RentalBookingStatus) : undefined,
+ paymentStatus: PAYMENT_VALUES.has(sp.paymentStatus ?? "") ? (sp.paymentStatus as PaymentStatus) : undefined,
+ providerId: sp.providerId || undefined,
+ };
+ const rows = await listRentalBookingsAdmin(filters);
+ const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
+
+ return (
+
+
+
+
+
+
+ Tous statuts
+ {Object.values(RentalBookingStatus).map((s) => (
+ {RENTAL_STATUS_LABEL[s]}
+ ))}
+
+
+ Tous paiements
+ {Object.values(PaymentStatus).map((s) => (
+ {s}
+ ))}
+
+
+ Filtrer
+
+ {(filters.q || filters.status || filters.paymentStatus) ? (
+ Réinit.
+ ) : null}
+
+
+
+
+
+
+ ID
+ Locataire
+ Prestataire
+ Items
+ Période
+ Montant
+ Statut
+ Paiement
+
+
+
+ {rows.length === 0 ? (
+
+
+ Aucune réservation matériel.
+
+
+ ) : null}
+ {rows.map((r) => (
+
+ {r.id.slice(0, 10)}…
+
+ {r.tenant.firstName} {r.tenant.lastName}
+ {r.tenant.email}
+
+
+
+ {r.provider.name}
+
+ {r.provider.isSystemD ? SD : null}
+
+
+ {r.lines.length} ligne{r.lines.length > 1 ? "s" : ""}
+
+ {r.lines.map((l) => `${l.qty}× ${l.item.name}`).join(", ")}
+
+
+
+ {dateFmt.format(r.startDate)} → {dateFmt.format(r.endDate)}
+
+
+ {Number(r.amount).toFixed(2)} {r.currency}
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/components/admin/Sidebar.tsx b/src/components/admin/Sidebar.tsx
index e10428c..ad30de2 100644
--- a/src/components/admin/Sidebar.tsx
+++ b/src/components/admin/Sidebar.tsx
@@ -115,6 +115,8 @@ const GROUPS: NavGroup[] = [
items: [
{ href: "/admin/carbets", label: "Carbets", icon: ICONS.carbets },
{ href: "/admin/pirogue-providers", label: "Prestataires pirogue", icon: ICONS.pirogue },
+ { href: "/admin/rental-providers", label: "Prestataires matériel", icon: ICONS.pirogue },
+ { href: "/admin/rental-items", label: "Items locables", icon: ICONS.media },
{ href: "/admin/media", label: "Médias", icon: ICONS.media },
],
},
@@ -122,6 +124,7 @@ const GROUPS: NavGroup[] = [
label: "Activité",
items: [
{ href: "/admin/bookings", label: "Réservations", icon: ICONS.bookings },
+ { href: "/admin/rentals", label: "Locations matériel", icon: ICONS.bookings },
{ href: "/admin/reviews", label: "Avis & modération", icon: ICONS.reviews },
],
},
diff --git a/src/lib/admin/rental-bookings.ts b/src/lib/admin/rental-bookings.ts
new file mode 100644
index 0000000..21b35e5
--- /dev/null
+++ b/src/lib/admin/rental-bookings.ts
@@ -0,0 +1,60 @@
+import "server-only";
+
+import { Prisma } from "@/generated/prisma/client";
+import { RentalBookingStatus, PaymentStatus } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+
+export type AdminRentalBookingFilters = {
+ q?: string;
+ status?: RentalBookingStatus;
+ paymentStatus?: PaymentStatus;
+ providerId?: string;
+};
+
+export const RENTAL_STATUS_LABEL: Record = {
+ PENDING: "En attente",
+ CONFIRMED: "Confirmée",
+ HANDED_OVER: "Remis client",
+ RETURNED: "Retourné",
+ CANCELLED: "Annulée",
+};
+
+export async function listRentalBookingsAdmin(
+ filters: AdminRentalBookingFilters = {},
+) {
+ const where: Prisma.RentalBookingWhereInput = {};
+ if (filters.q) {
+ where.OR = [
+ { id: { contains: filters.q, mode: "insensitive" } },
+ { tenant: { email: { contains: filters.q, mode: "insensitive" } } },
+ { provider: { name: { contains: filters.q, mode: "insensitive" } } },
+ ];
+ }
+ if (filters.status) where.status = filters.status;
+ if (filters.paymentStatus) where.paymentStatus = filters.paymentStatus;
+ if (filters.providerId) where.providerId = filters.providerId;
+
+ return prisma.rentalBooking.findMany({
+ where,
+ orderBy: [{ createdAt: "desc" }],
+ take: 200,
+ include: {
+ tenant: { select: { id: true, firstName: true, lastName: true, email: true } },
+ provider: { select: { id: true, name: true, isSystemD: true } },
+ booking: { select: { id: true, carbet: { select: { title: true, slug: true } } } },
+ lines: { include: { item: { select: { name: true, category: true } } } },
+ },
+ });
+}
+
+export async function getRentalBookingForAdmin(id: string) {
+ return prisma.rentalBooking.findUnique({
+ where: { id },
+ include: {
+ tenant: { select: { id: true, firstName: true, lastName: true, email: true, phone: true } },
+ provider: { select: { id: true, name: true, isSystemD: true, contactEmail: true, contactPhone: true } },
+ booking: { select: { id: true, carbet: { select: { id: true, title: true, slug: true } } } },
+ lines: { include: { item: { select: { id: true, name: true, category: true, imageUrl: true } } } },
+ },
+ });
+}
diff --git a/src/lib/admin/rental-items.ts b/src/lib/admin/rental-items.ts
new file mode 100644
index 0000000..d5c1bb0
--- /dev/null
+++ b/src/lib/admin/rental-items.ts
@@ -0,0 +1,111 @@
+import "server-only";
+
+import { Prisma } from "@/generated/prisma/client";
+import { RentalCategory } from "@/generated/prisma/enums";
+import { prisma } from "@/lib/prisma";
+
+export const RENTAL_CATEGORY_LABEL: Record = {
+ SLEEP: "💤 Couchage",
+ NAVIGATION: "🛶 Navigation",
+ FISHING: "🎣 Pêche",
+ COOKING: "🍳 Cuisine",
+ SAFETY: "🦺 Sécurité",
+};
+
+export type AdminRentalItemFilters = {
+ q?: string;
+ category?: RentalCategory;
+ providerId?: string;
+ active?: "yes" | "no";
+};
+
+export type AdminRentalItemListItem = {
+ id: string;
+ name: string;
+ category: RentalCategory;
+ providerId: string;
+ providerName: string;
+ providerIsSystemD: boolean;
+ pricePerDay: string;
+ pricePerWeek: string | null;
+ deposit: string;
+ totalQty: number;
+ withMotor: boolean;
+ fuelIncluded: boolean;
+ requiresLicense: boolean;
+ active: boolean;
+ imageUrl: string | null;
+ updatedAt: Date;
+};
+
+const CATEGORY_VALUES: RentalCategory[] = [
+ RentalCategory.SLEEP,
+ RentalCategory.NAVIGATION,
+ RentalCategory.FISHING,
+ RentalCategory.COOKING,
+ RentalCategory.SAFETY,
+];
+
+export function isRentalCategory(v: string): v is RentalCategory {
+ return (CATEGORY_VALUES as string[]).includes(v);
+}
+
+export async function listRentalItemsAdmin(
+ filters: AdminRentalItemFilters = {},
+): Promise {
+ const where: Prisma.RentalItemWhereInput = {};
+ 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.active === "yes") where.active = true;
+ if (filters.active === "no") where.active = false;
+
+ const rows = await prisma.rentalItem.findMany({
+ where,
+ orderBy: [{ category: "asc" }, { name: "asc" }],
+ take: 300,
+ include: {
+ provider: { select: { name: true, isSystemD: true } },
+ },
+ });
+ return rows.map((r) => ({
+ id: r.id,
+ name: r.name,
+ category: r.category,
+ providerId: r.providerId,
+ providerName: r.provider.name,
+ providerIsSystemD: r.provider.isSystemD,
+ 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,
+ active: r.active,
+ imageUrl: r.imageUrl,
+ updatedAt: r.updatedAt,
+ }));
+}
+
+export async function getRentalItemForAdmin(id: string) {
+ return prisma.rentalItem.findUnique({
+ where: { id },
+ include: {
+ provider: { select: { id: true, name: true, isSystemD: true } },
+ },
+ });
+}
+
+export async function listProvidersForSelect() {
+ return prisma.rentalProvider.findMany({
+ where: { active: true },
+ orderBy: [{ isSystemD: "desc" }, { name: "asc" }],
+ select: { id: true, name: true, isSystemD: true, approved: true },
+ });
+}
diff --git a/src/lib/admin/rental-providers.ts b/src/lib/admin/rental-providers.ts
new file mode 100644
index 0000000..b1e4d97
--- /dev/null
+++ b/src/lib/admin/rental-providers.ts
@@ -0,0 +1,106 @@
+import "server-only";
+
+import { Prisma } from "@/generated/prisma/client";
+import { prisma } from "@/lib/prisma";
+
+export type AdminRentalProviderFilters = {
+ q?: string;
+ approved?: "yes" | "no";
+ active?: "yes" | "no";
+ river?: string;
+};
+
+export type AdminRentalProviderListItem = {
+ id: string;
+ name: string;
+ isSystemD: boolean;
+ contactEmail: string | null;
+ contactPhone: string | null;
+ rivers: string[];
+ commissionPct: string;
+ active: boolean;
+ approved: boolean;
+ itemsCount: number;
+ updatedAt: Date;
+};
+
+export async function listRentalProvidersAdmin(
+ filters: AdminRentalProviderFilters = {},
+): Promise {
+ const where: Prisma.RentalProviderWhereInput = {};
+ if (filters.q) {
+ where.OR = [
+ { name: { contains: filters.q, mode: "insensitive" } },
+ { contactEmail: { contains: filters.q, mode: "insensitive" } },
+ { description: { contains: filters.q, mode: "insensitive" } },
+ ];
+ }
+ if (filters.approved === "yes") where.approved = true;
+ if (filters.approved === "no") where.approved = false;
+ if (filters.active === "yes") where.active = true;
+ if (filters.active === "no") where.active = false;
+ if (filters.river) where.rivers = { has: filters.river };
+
+ const rows = await prisma.rentalProvider.findMany({
+ where,
+ orderBy: [{ approved: "asc" }, { isSystemD: "desc" }, { name: "asc" }],
+ take: 200,
+ select: {
+ id: true,
+ name: true,
+ isSystemD: true,
+ contactEmail: true,
+ contactPhone: true,
+ rivers: true,
+ commissionPct: true,
+ active: true,
+ approved: true,
+ updatedAt: true,
+ _count: { select: { items: true } },
+ },
+ });
+ return rows.map((r) => ({
+ id: r.id,
+ name: r.name,
+ isSystemD: r.isSystemD,
+ contactEmail: r.contactEmail,
+ contactPhone: r.contactPhone,
+ rivers: r.rivers,
+ commissionPct: r.commissionPct.toString(),
+ active: r.active,
+ approved: r.approved,
+ itemsCount: r._count.items,
+ updatedAt: r.updatedAt,
+ }));
+}
+
+export async function getRentalProviderForAdmin(id: string) {
+ return prisma.rentalProvider.findUnique({
+ where: { id },
+ include: {
+ manager: { select: { id: true, firstName: true, lastName: true, email: true } },
+ items: {
+ orderBy: [{ category: "asc" }, { name: "asc" }],
+ select: {
+ id: true,
+ name: true,
+ category: true,
+ pricePerDay: true,
+ totalQty: true,
+ active: true,
+ },
+ },
+ _count: { select: { items: true, rentalBookings: true } },
+ },
+ });
+}
+
+export async function listRentalProviderRivers(): 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();
+}
From 90cc7a94af423e5f75fdd6cd39764c0758bcf779 Mon Sep 17 00:00:00 2001
From: Claude Integration
Date: Tue, 2 Jun 2026 03:31:22 +0000
Subject: [PATCH 13/32] fix(rental): extract category labels en fichier neutre
(importable client)
---
.../rental-items/_components/ItemForm.tsx | 13 ++----------
src/lib/admin/rental-items.ts | 20 +-----------------
src/lib/rental-category-labels.ts | 21 +++++++++++++++++++
3 files changed, 24 insertions(+), 30 deletions(-)
create mode 100644 src/lib/rental-category-labels.ts
diff --git a/src/app/admin/rental-items/_components/ItemForm.tsx b/src/app/admin/rental-items/_components/ItemForm.tsx
index 523dabc..27ad4b2 100644
--- a/src/app/admin/rental-items/_components/ItemForm.tsx
+++ b/src/app/admin/rental-items/_components/ItemForm.tsx
@@ -2,8 +2,7 @@
import { useState, useTransition } from "react";
import { FormField, inputCls, selectCls, textareaCls } from "@/components/admin/FormField";
-import { RENTAL_CATEGORY_LABEL } from "@/lib/admin/rental-items";
-import { RentalCategory } from "@/generated/prisma/enums";
+import { RENTAL_CATEGORY_LABEL, RENTAL_CATEGORIES } from "@/lib/rental-category-labels";
type Props = {
providers: { id: string; name: string; isSystemD: boolean }[];
@@ -26,14 +25,6 @@ type Props = {
submitLabel?: string;
};
-const CATEGORIES: RentalCategory[] = [
- RentalCategory.SLEEP,
- RentalCategory.NAVIGATION,
- RentalCategory.FISHING,
- RentalCategory.COOKING,
- RentalCategory.SAFETY,
-];
-
export function ItemForm({ providers, initial = {}, action, submitLabel = "Enregistrer" }: Props) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState(null);
@@ -66,7 +57,7 @@ export function ItemForm({ providers, initial = {}, action, submitLabel = "Enreg
— sélectionner —
- {CATEGORIES.map((c) => (
+ {RENTAL_CATEGORIES.map((c) => (
{RENTAL_CATEGORY_LABEL[c]}
))}
diff --git a/src/lib/admin/rental-items.ts b/src/lib/admin/rental-items.ts
index d5c1bb0..01dd655 100644
--- a/src/lib/admin/rental-items.ts
+++ b/src/lib/admin/rental-items.ts
@@ -4,13 +4,7 @@ import { Prisma } from "@/generated/prisma/client";
import { RentalCategory } from "@/generated/prisma/enums";
import { prisma } from "@/lib/prisma";
-export const RENTAL_CATEGORY_LABEL: Record = {
- SLEEP: "💤 Couchage",
- NAVIGATION: "🛶 Navigation",
- FISHING: "🎣 Pêche",
- COOKING: "🍳 Cuisine",
- SAFETY: "🦺 Sécurité",
-};
+export { RENTAL_CATEGORY_LABEL, RENTAL_CATEGORIES, isRentalCategory } from "@/lib/rental-category-labels";
export type AdminRentalItemFilters = {
q?: string;
@@ -38,18 +32,6 @@ export type AdminRentalItemListItem = {
updatedAt: Date;
};
-const CATEGORY_VALUES: RentalCategory[] = [
- RentalCategory.SLEEP,
- RentalCategory.NAVIGATION,
- RentalCategory.FISHING,
- RentalCategory.COOKING,
- RentalCategory.SAFETY,
-];
-
-export function isRentalCategory(v: string): v is RentalCategory {
- return (CATEGORY_VALUES as string[]).includes(v);
-}
-
export async function listRentalItemsAdmin(
filters: AdminRentalItemFilters = {},
): Promise {
diff --git a/src/lib/rental-category-labels.ts b/src/lib/rental-category-labels.ts
new file mode 100644
index 0000000..63f14a1
--- /dev/null
+++ b/src/lib/rental-category-labels.ts
@@ -0,0 +1,21 @@
+import { RentalCategory } from "@/generated/prisma/enums";
+
+export const RENTAL_CATEGORY_LABEL: Record = {
+ SLEEP: "💤 Couchage",
+ NAVIGATION: "🛶 Navigation",
+ FISHING: "🎣 Pêche",
+ COOKING: "🍳 Cuisine",
+ SAFETY: "🦺 Sécurité",
+};
+
+export const RENTAL_CATEGORIES: RentalCategory[] = [
+ RentalCategory.SLEEP,
+ RentalCategory.NAVIGATION,
+ RentalCategory.FISHING,
+ RentalCategory.COOKING,
+ RentalCategory.SAFETY,
+];
+
+export function isRentalCategory(v: string): v is RentalCategory {
+ return (RENTAL_CATEGORIES as string[]).includes(v);
+}
From f31fb8a32cc41da6d06d94da0ac4f01280217d53 Mon Sep 17 00:00:00 2001
From: Claude Integration
Date: Tue, 2 Jun 2026 07:49:43 +0000
Subject: [PATCH 14/32] =?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
+
+ ) : (
+
+ {categoryEmoji}
+
+ )}
+
+
+ {item.description ? (
+
+ Description
+ {item.description}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+ {Number(item.pricePerDay).toFixed(0)} €
+
+ / jour
+
+
+ {item.pricePerWeek ? (
+
+ Forfait semaine : {Number(item.pricePerWeek).toFixed(0)} € (≥ 7 jours)
+
+ ) : null}
+
+
+
+ 🛒 La fonction « Ajouter au panier » arrive avec le Sprint D.
+ Pour réserver maintenant, contactez directement le prestataire.
+
+
+
+
{item.provider.name}
+ {item.provider.isSystemD ? (
+
Fournisseur officiel Karbé (0% commission).
+ ) : null}
+ {item.provider.description ? (
+
{item.provider.description}
+ ) : null}
+
+ {item.provider.contactEmail ? (
+
📧 {item.provider.contactEmail}
+ ) : null}
+ {item.provider.contactPhone ? (
+
📞 {item.provider.contactPhone}
+ ) : null}
+
+ Fleuves desservis : {item.provider.rivers.join(", ") || "—"}
+
+
+
+
+
+
+ );
+}
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 (
+
+
+
+ Recherche
+
+
+
+ Fleuve
+
+ Tous fleuves
+ {rivers.map((r) => (
+ {r}
+ ))}
+
+
+
+ Prestataire
+
+ Tous prestataires
+ {providers.map((p) => (
+
+ {p.name}{p.isSystemD ? " (Karbé)" : ""}
+
+ ))}
+
+
+
+
+
+ Catégorie
+
+ {RENTAL_CATEGORIES.map((c) => {
+ const checked = filters.category === c;
+ return (
+
+
+ {RENTAL_CATEGORY_LABEL[c]}
+
+ );
+ })}
+
+
+
+
+
+ Filtrer
+
+
+ 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.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 15/32] =?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 (
+
+ );
+}
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 ?
+
+ Oui
+
+ setConfirm(false)}
+ disabled={pending}
+ className="text-[11px] text-zinc-500 hover:text-zinc-900"
+ >
+ Annuler
+
+
+ ) : (
+
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'item
+
+ )}
+ {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 (
+
+
+
+
+
+
+
+ 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 (
+
+
+
+
+ Catégorie
+
+ — sélectionner —
+ {RENTAL_CATEGORIES.map((c) => (
+ {RENTAL_CATEGORY_LABEL[c]}
+ ))}
+
+
+
+ Statut
+
+
+ Actif (visible au catalogue)
+
+
+
+ Nom de l'item
+
+
+
+ Description
+
+
+
+ URL image
+
+
+
+ Stock total (qté)
+
+
+
+ Prix / jour (€)
+
+
+
+ Prix / semaine (€)
+
+
+
+ Caution (€)
+
+
+
+
+
+
+ Spécifications
+
+
+
+
+ Avec moteur
+
+
+
+ Essence incluse
+
+
+
+ Permis bateau requis
+
+
+
+
+ {error ? (
+ {error}
+ ) : null}
+ {success ? (
+ {success}
+ ) : null}
+
+
+
+ {pending ? "Enregistrement…" : submitLabel}
+
+
+
+
+ );
+}
diff --git a/src/app/espace-prestataire/items/new/page.tsx b/src/app/espace-prestataire/items/new/page.tsx
new file mode 100644
index 0000000..5431bdf
--- /dev/null
+++ b/src/app/espace-prestataire/items/new/page.tsx
@@ -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 (
+
+
+ ← Mes items
+
+ Nouvel item
+
+
+ );
+}
diff --git a/src/app/espace-prestataire/items/page.tsx b/src/app/espace-prestataire/items/page.tsx
new file mode 100644
index 0000000..355a5ae
--- /dev/null
+++ b/src/app/espace-prestataire/items/page.tsx
@@ -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 (
+
+
+
+ {items.length === 0 ? (
+
+ Pas encore d'item.{" "}
+
+ Créer mon premier item
+
+
+ ) : (
+
+
+
+
+ Nom
+ Catégorie
+ €/j
+ Stock
+ Caution
+ Résa
+ État
+
+
+
+ {items.map((i) => (
+
+
+
+ {i.name}
+
+
+ {i.withMotor ? "⚙️ moteur · " : ""}
+ {i.requiresLicense ? "🪪 permis · " : ""}
+ {i.fuelIncluded ? "⛽ essence " : ""}
+
+
+ {RENTAL_CATEGORY_LABEL[i.category]}
+ {Number(i.pricePerDay).toFixed(0)}
+ {i.totalQty}
+ {Number(i.deposit).toFixed(0)}
+ {i._count.lines}
+
+ {i.active ? (
+
+ Actif
+
+ ) : (
+
+ Inactif
+
+ )}
+
+
+ ))}
+
+
+
+ )}
+
+ );
+}
diff --git a/src/app/espace-prestataire/page.tsx b/src/app/espace-prestataire/page.tsx
new file mode 100644
index 0000000..620aa63
--- /dev/null
+++ b/src/app/espace-prestataire/page.tsx
@@ -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 (
+
+
+
+ {!provider.approved ? (
+
+
Compte en attente de validation
+
+ Vos items ne sont pas encore visibles sur le catalogue public.
+ L'équipe Karbé contactera bientôt {provider.contactEmail ?? "votre email"} pour finaliser
+ votre adhésion. Vous pouvez toutefois préparer vos items dès maintenant.
+
+
+ ) : null}
+
+
+
+
+ 0 ? "warn" : "neutral"}
+ />
+
+
+
+
+
+ {kpis.nextHandover ? (
+
+ Prochaine remise
+
+ {kpis.nextHandover.tenantName} · {kpis.nextHandover.lineCount} ligne(s)
+
+
+ {dateFmt.format(kpis.nextHandover.startDate)}
+
+
+ Voir le détail →
+
+
+ ) : null}
+
+
+ Mon activité
+
+
+ Fleuves desservis : {" "}
+ {provider.rivers.join(", ") || "—"}
+
+
+ Commission Karbé : {" "}
+ {Number(provider.commissionPct).toFixed(1)}%
+
+
+ Statut : {" "}
+ {provider.active ? "Actif" : "Inactif"}
+ {" · "}
+ {provider.approved ? "Approuvé" : "En attente"}
+
+
+
+
+ );
+}
+
+function Kpi({
+ label,
+ value,
+ tone = "neutral",
+}: {
+ label: string;
+ value: string;
+ tone?: "neutral" | "warn";
+}) {
+ return (
+
+
{label}
+
+ {value}
+
+
+ );
+}
diff --git a/src/app/espace-prestataire/reservations/_components/BookingDecision.tsx b/src/app/espace-prestataire/reservations/_components/BookingDecision.tsx
new file mode 100644
index 0000000..2d6fa77
--- /dev/null
+++ b/src/app/espace-prestataire/reservations/_components/BookingDecision.tsx
@@ -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(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 (
+
+ {status === RentalBookingStatus.PENDING ? (
+
set(RentalBookingStatus.CONFIRMED)}
+ disabled={pending}
+ className={`${btnBase} bg-emerald-600 text-white hover:bg-emerald-700`}
+ >
+ Confirmer
+
+ ) : null}
+ {status === RentalBookingStatus.CONFIRMED ? (
+
set(RentalBookingStatus.HANDED_OVER)}
+ disabled={pending}
+ className={`${btnBase} bg-emerald-600 text-white hover:bg-emerald-700`}
+ >
+ Marquer remis client
+
+ ) : null}
+ {status === RentalBookingStatus.HANDED_OVER ? (
+
set(RentalBookingStatus.RETURNED)}
+ disabled={pending}
+ className={`${btnBase} bg-emerald-600 text-white hover:bg-emerald-700`}
+ >
+ Marquer retourné
+
+ ) : null}
+ {status !== RentalBookingStatus.CANCELLED && status !== RentalBookingStatus.RETURNED ? (
+ confirmCancel ? (
+
+ Annuler ?
+ 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
+
+ setConfirmCancel(false)}
+ disabled={pending}
+ className="text-[11px] text-zinc-500 hover:text-zinc-900"
+ >
+ Non
+
+
+ ) : (
+
setConfirmCancel(true)}
+ disabled={pending}
+ className={`${btnBase} border border-rose-300 bg-white text-rose-700 hover:bg-rose-50`}
+ >
+ Annuler
+
+ )
+ ) : null}
+ {error ?
{error} : null}
+
+ );
+}
diff --git a/src/app/espace-prestataire/reservations/page.tsx b/src/app/espace-prestataire/reservations/page.tsx
new file mode 100644
index 0000000..b66f063
--- /dev/null
+++ b/src/app/espace-prestataire/reservations/page.tsx
@@ -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([
+ 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 (
+
+
+
+ {bookings.length === 0 ? (
+
+ Aucune réservation matériel.
+
+ ) : (
+
+ {bookings.map((b) => (
+
+
+
+
+ {b.tenant.firstName} {b.tenant.lastName}
+
+
+ {b.tenant.email}
+ {b.tenant.phone ? ` · ${b.tenant.phone}` : ""}
+
+ {b.booking ? (
+
+ 🏠 Lié à la résa carbet :{" "}
+
+ {b.booking.carbet.title}
+
+
+ ) : (
+
Location standalone (sans carbet)
+ )}
+
+
+
+ {dateFmt.format(b.startDate)} → {dateFmt.format(b.endDate)}
+
+
+ {Number(b.amount).toFixed(2)} {b.currency}
+
+
+
+
+
+ {b.lines.map((l) => (
+
+
+ {l.qty}× {l.item.name}
+
+
+ {Number(l.lineTotal).toFixed(2)} €
+
+
+ ))}
+
+
+
+
+
+ {RENTAL_STATUS_LABEL[b.status]}
+
+
+ {b.paymentStatus}
+
+
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/app/inscription/_components/SignupForm.tsx b/src/app/inscription/_components/SignupForm.tsx
index 2ffd914..6f8f7bd 100644
--- a/src/app/inscription/_components/SignupForm.tsx
+++ b/src/app/inscription/_components/SignupForm.tsx
@@ -10,7 +10,9 @@ export function SignupForm({ next }: Props) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [error, setError] = useState(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 = {
+ 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) {
Type de compte
-
+
Hôte
Publier un carbet.
+
+ setRole("RENTAL_PROVIDER")}
+ className="sr-only"
+ />
+ Loueur matériel
+ Hamac, pirogue, kayak…
+
+ {role === "RENTAL_PROVIDER" ? (
+
+ ) : null}
+
{error ? (
{error}
) : null}
diff --git a/src/components/SiteHeader.tsx b/src/components/SiteHeader.tsx
index 564c466..f823a9e 100644
--- a/src/components/SiteHeader.tsx
+++ b/src/components/SiteHeader.tsx
@@ -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 (
+
+
-
-
- {children}
+
+
+
+ {children}
+