Merge pull request 'feat(rental): Sprint A — modèle + admin + seed' (#73) from feat/rental-sprint-a into main
Some checks failed
CI / test (push) Failing after 1m49s

This commit is contained in:
tarzzan 2026-06-02 03:26:07 +00:00
commit 46d3c2d3ab
19 changed files with 2000 additions and 6 deletions

View file

@ -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");

View file

@ -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])
}

View file

@ -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<string | null>(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 (
<div className="flex flex-col items-end gap-2">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={toggle}
disabled={pending}
className={
active
? "rounded-md border border-amber-300 bg-amber-50 px-3 py-1.5 text-xs font-semibold text-amber-800 hover:bg-amber-100 disabled:opacity-50"
: "rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
}
>
{active ? "Désactiver" : "Réactiver"}
</button>
{confirmDelete ? (
<div className="flex items-center gap-2 rounded border border-rose-300 bg-rose-50 px-2 py-1">
<span className="text-xs text-rose-900">Supprimer ?</span>
<button
type="button"
onClick={del}
disabled={pending}
className="rounded bg-rose-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-rose-800 disabled:opacity-50"
>
Oui
</button>
<button
type="button"
onClick={() => setConfirmDelete(false)}
disabled={pending}
className="text-[11px] text-zinc-500 hover:text-zinc-900"
>
Annuler
</button>
</div>
) : (
<button
type="button"
onClick={() => 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
</button>
)}
</div>
{error ? <div className="rounded border border-rose-200 bg-rose-50 px-2 py-1 text-xs text-rose-700">{error}</div> : null}
</div>
);
}

View file

@ -0,0 +1,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 (
<div className="mx-auto max-w-4xl space-y-6">
<header className="mt-2 flex flex-wrap items-end justify-between gap-3">
<div>
<Link href="/admin/rental-items" className="text-xs text-zinc-500 hover:text-zinc-900">
Tous les items
</Link>
<h1 className="mt-1 flex items-center gap-3 text-2xl font-semibold text-zinc-900">
{item.name}
<StatusBadge status={item.active ? "ACTIVE" : "INACTIVE"} />
</h1>
<p className="mt-1 text-sm text-zinc-500">
{RENTAL_CATEGORY_LABEL[item.category]} ·{" "}
<Link href={`/admin/rental-providers/${item.provider.id}`} className="text-zinc-900 hover:underline">
{item.provider.name}
</Link>
{item.provider.isSystemD ? " (System D)" : ""}
</p>
</div>
<ItemInlineActions
active={item.active}
toggleActiveAction={toggleActiveThis}
deleteAction={deleteThis}
/>
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<ItemForm
providers={providers}
action={updateThis}
submitLabel="Enregistrer les modifications"
initial={{
providerId: item.providerId,
category: item.category,
name: item.name,
description: item.description,
imageUrl: item.imageUrl,
pricePerDay: item.pricePerDay.toString(),
pricePerWeek: item.pricePerWeek?.toString() ?? null,
deposit: item.deposit.toString(),
totalQty: item.totalQty,
withMotor: item.withMotor,
fuelIncluded: item.fuelIncluded,
requiresLicense: item.requiresLicense,
active: item.active,
}}
/>
</section>
</div>
);
}

View file

@ -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<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
function onSubmit(fd: FormData) {
setError(null);
setSuccess(null);
startTransition(async () => {
const res = await action(fd);
if (res && res.ok === false) setError(res.error);
else if (res && res.ok === true) setSuccess("Enregistré.");
});
}
return (
<form action={onSubmit} className="space-y-4">
<fieldset disabled={pending} className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Prestataire" required>
<select name="providerId" defaultValue={initial.providerId ?? ""} required className={selectCls}>
<option value="" disabled> sélectionner </option>
{providers.map((p) => (
<option key={p.id} value={p.id}>
{p.name}{p.isSystemD ? " (System D)" : ""}
</option>
))}
</select>
</FormField>
<FormField label="Catégorie" required>
<select name="category" defaultValue={initial.category ?? ""} required className={selectCls}>
<option value="" disabled> sélectionner </option>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{RENTAL_CATEGORY_LABEL[c]}</option>
))}
</select>
</FormField>
<FormField label="Nom de l'item" required className="sm:col-span-2">
<input name="name" defaultValue={initial.name ?? ""} required maxLength={200} className={inputCls} placeholder="ex. Hamac coton large, Pirogue 5m avec moteur 15CV" />
</FormField>
<FormField label="Description" className="sm:col-span-2">
<textarea name="description" rows={3} defaultValue={initial.description ?? ""} maxLength={5000} className={textareaCls} />
</FormField>
<FormField label="URL image" hint="Optionnel, URL publique vers photo MinIO.">
<input name="imageUrl" type="url" defaultValue={initial.imageUrl ?? ""} maxLength={500} className={inputCls} />
</FormField>
<FormField label="Stock total (qté)" required>
<input name="totalQty" type="number" min={1} max={1000} defaultValue={initial.totalQty?.toString() ?? "1"} required className={inputCls} />
</FormField>
<FormField label="Prix / jour (€)" required>
<input name="pricePerDay" type="number" min={0} step="0.5" defaultValue={initial.pricePerDay?.toString() ?? ""} required className={inputCls} />
</FormField>
<FormField label="Prix / semaine (€)" hint="Optionnel — tarif dégressif sur 7+ jours.">
<input name="pricePerWeek" type="number" min={0} step="0.5" defaultValue={initial.pricePerWeek?.toString() ?? ""} className={inputCls} />
</FormField>
<FormField label="Caution (€)" hint="Dépôt de garantie (bloqué pendant la location).">
<input name="deposit" type="number" min={0} step="1" defaultValue={initial.deposit?.toString() ?? "0"} className={inputCls} />
</FormField>
<FormField label="Statut">
<label className="flex items-center gap-2 px-1 py-2 text-sm">
<input type="checkbox" name="active" defaultChecked={initial.active ?? true} className="h-4 w-4 rounded border-zinc-300" />
Actif (visible au catalogue)
</label>
</FormField>
</div>
<fieldset className="rounded-lg border border-zinc-200 bg-zinc-50 p-3">
<legend className="px-1 text-xs font-semibold uppercase tracking-wider text-zinc-500">
Spécifications navigation
</legend>
<div className="flex flex-wrap gap-4 pt-1 text-sm">
<label className="flex items-center gap-2">
<input type="checkbox" name="withMotor" defaultChecked={initial.withMotor ?? false} className="h-4 w-4 rounded border-zinc-300" />
Avec moteur
</label>
<label className="flex items-center gap-2">
<input type="checkbox" name="fuelIncluded" defaultChecked={initial.fuelIncluded ?? false} className="h-4 w-4 rounded border-zinc-300" />
Essence incluse
</label>
<label className="flex items-center gap-2">
<input type="checkbox" name="requiresLicense" defaultChecked={initial.requiresLicense ?? false} className="h-4 w-4 rounded border-zinc-300" />
Permis bateau requis
</label>
</div>
</fieldset>
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
) : null}
{success ? (
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
) : null}
<div className="flex items-center justify-end">
<button
type="submit"
className="rounded-md bg-zinc-900 px-5 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
>
{pending ? "Enregistrement…" : submitLabel}
</button>
</div>
</fieldset>
</form>
);
}

View file

@ -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");
}

View file

@ -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 (
<div className="mx-auto max-w-3xl space-y-6">
<header className="mt-2">
<Link href="/admin/rental-items" className="text-xs text-zinc-500 hover:text-zinc-900">
Tous les items
</Link>
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">Nouvel item locable</h1>
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<ItemForm
providers={providers}
action={createRentalItemAction}
submitLabel="Créer l'item"
initial={{ providerId: sp.providerId, active: true, totalQty: 1 }}
/>
</section>
</div>
);
}

View file

@ -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 (
<div className="mx-auto max-w-7xl">
<header className="mb-5 mt-2 flex items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold text-zinc-900">Catalogue d&apos;items locables</h1>
<p className="mt-1 text-sm text-zinc-500">
{rows.length} item{rows.length > 1 ? "s" : ""}
</p>
</div>
<Link
href="/admin/rental-items/new"
className="inline-flex items-center gap-1.5 rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-semibold text-white hover:bg-zinc-800"
>
+ Nouvel item
</Link>
</header>
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
<input
type="text"
name="q"
defaultValue={filters.q ?? ""}
placeholder="Recherche nom, description…"
className="flex-1 min-w-[220px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
/>
<select
name="category"
defaultValue={filters.category ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Toutes catégories</option>
{Object.values(RentalCategory).map((c) => (
<option key={c} value={c}>{RENTAL_CATEGORY_LABEL[c]}</option>
))}
</select>
<select
name="providerId"
defaultValue={filters.providerId ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Tous prestataires</option>
{providers.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<select
name="active"
defaultValue={filters.active ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Actifs + inactifs</option>
<option value="yes">Actifs</option>
<option value="no">Inactifs</option>
</select>
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
Filtrer
</button>
{(filters.q || filters.category || filters.providerId || filters.active) ? (
<Link href="/admin/rental-items" className="text-sm text-zinc-500 hover:text-zinc-900">Réinit.</Link>
) : null}
</form>
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
<table className="w-full text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-4 py-2 text-left font-semibold">Nom</th>
<th className="px-4 py-2 text-left font-semibold">Catégorie</th>
<th className="px-4 py-2 text-left font-semibold">Prestataire</th>
<th className="px-4 py-2 text-right font-semibold"> / jour</th>
<th className="px-4 py-2 text-right font-semibold">Stock</th>
<th className="px-4 py-2 text-right font-semibold">Caution</th>
<th className="px-4 py-2 text-left font-semibold">État</th>
<th className="px-4 py-2 text-right font-semibold">MAJ</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{rows.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-sm text-zinc-500">
Aucun item.
</td>
</tr>
) : null}
{rows.map((i) => (
<tr key={i.id} className="hover:bg-zinc-50">
<td className="px-4 py-2">
<Link href={`/admin/rental-items/${i.id}`} className="font-medium text-zinc-900 hover:underline">
{i.name}
</Link>
<div className="text-[11px] text-zinc-500">
{i.withMotor ? "⚙️ moteur · " : ""}
{i.requiresLicense ? "🪪 permis · " : ""}
{i.fuelIncluded ? "⛽ essence · " : ""}
</div>
</td>
<td className="px-4 py-2 text-zinc-700">{RENTAL_CATEGORY_LABEL[i.category]}</td>
<td className="px-4 py-2">
<Link href={`/admin/rental-providers/${i.providerId}`} className="text-zinc-900 hover:underline">
{i.providerName}
</Link>
{i.providerIsSystemD ? (
<span className="ml-1 rounded-full bg-emerald-100 px-1 py-0 text-[9px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
SD
</span>
) : null}
</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{Number(i.pricePerDay).toFixed(0)}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{i.totalQty}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{Number(i.deposit).toFixed(0)}</td>
<td className="px-4 py-2"><StatusBadge status={i.active ? "ACTIVE" : "INACTIVE"} /></td>
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">{dateFmt.format(i.updatedAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -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<string | null>(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 (
<div className="flex flex-col items-end gap-2">
<div className="flex flex-wrap items-center gap-2">
{!approved ? (
<button
type="button"
onClick={approve}
disabled={pending}
className="rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
>
Approuver
</button>
) : null}
<button
type="button"
onClick={toggle}
disabled={pending}
className={
active
? "rounded-md border border-amber-300 bg-amber-50 px-3 py-1.5 text-xs font-semibold text-amber-800 hover:bg-amber-100 disabled:opacity-50"
: "rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
}
>
{active ? "Désactiver" : "Réactiver"}
</button>
{itemsCount === 0 ? (
confirmDelete ? (
<div className="flex items-center gap-2 rounded border border-rose-300 bg-rose-50 px-2 py-1">
<span className="text-xs text-rose-900">Supprimer ?</span>
<button
type="button"
onClick={del}
disabled={pending}
className="rounded bg-rose-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-rose-800 disabled:opacity-50"
>
Oui
</button>
<button
type="button"
onClick={() => setConfirmDelete(false)}
disabled={pending}
className="text-[11px] text-zinc-500 hover:text-zinc-900"
>
Annuler
</button>
</div>
) : (
<button
type="button"
onClick={() => 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
</button>
)
) : (
<span className="rounded border border-zinc-200 bg-zinc-50 px-2 py-1 text-[11px] text-zinc-500">
{itemsCount} item(s) supprimez-les d&apos;abord
</span>
)}
</div>
{error ? <div className="rounded border border-rose-200 bg-rose-50 px-2 py-1 text-xs text-rose-700">{error}</div> : null}
</div>
);
}

View file

@ -0,0 +1,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 (
<div className="mx-auto max-w-5xl space-y-6">
<header className="mt-2 flex flex-wrap items-end justify-between gap-3">
<div>
<Link href="/admin/rental-providers" className="text-xs text-zinc-500 hover:text-zinc-900">
Tous les prestataires
</Link>
<h1 className="mt-1 flex items-center gap-3 text-2xl font-semibold text-zinc-900">
{p.name}
{p.isSystemD ? (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
System D
</span>
) : null}
<StatusBadge status={p.active ? "ACTIVE" : "INACTIVE"} />
{p.approved ? (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
Approuvé
</span>
) : (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-800 ring-1 ring-inset ring-amber-300">
En attente
</span>
)}
</h1>
<p className="mt-1 text-sm text-zinc-500">
Fleuves : {p.rivers.join(", ") || "—"} · {p._count.items} item(s) · {p._count.rentalBookings} réservation(s) · Commission {Number(p.commissionPct).toFixed(1)}%
</p>
</div>
<ProviderInlineActions
approved={p.approved}
active={p.active}
itemsCount={p._count.items}
approveAction={approveThis}
toggleActiveAction={toggleActiveThis}
deleteAction={deleteThis}
/>
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Informations</h2>
<ProviderForm
action={updateThis}
submitLabel="Enregistrer"
initial={{
name: p.name,
isSystemD: p.isSystemD,
contactEmail: p.contactEmail,
contactPhone: p.contactPhone,
rivers: p.rivers,
description: p.description,
commissionPct: p.commissionPct.toString(),
active: p.active,
}}
/>
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-4 flex items-center justify-between text-sm font-semibold uppercase tracking-wider text-zinc-500">
<span>Items ({p.items.length})</span>
<Link href={`/admin/rental-items?providerId=${p.id}`} className="text-xs normal-case tracking-normal text-zinc-700 underline hover:text-zinc-900">
Voir tous les items
</Link>
</h2>
{p.items.length === 0 ? (
<p className="text-sm text-zinc-500">
Pas encore d&apos;item.{" "}
<Link href={`/admin/rental-items/new?providerId=${p.id}`} className="text-zinc-900 underline">
Créer un premier item
</Link>
</p>
) : (
<ul className="divide-y divide-zinc-100">
{p.items.map((i) => (
<li key={i.id} className="flex items-center justify-between gap-3 py-2 text-sm">
<Link href={`/admin/rental-items/${i.id}`} className="text-zinc-900 hover:underline">
{i.name}
<span className="ml-2 text-[11px] text-zinc-500">
{RENTAL_CATEGORY_LABEL[i.category]}
</span>
</Link>
<span className="flex items-center gap-3">
<span className="font-mono text-xs text-zinc-700">{Number(i.pricePerDay).toFixed(0)} /j</span>
<span className="text-[11px] text-zinc-500">qty {i.totalQty}</span>
<StatusBadge status={i.active ? "ACTIVE" : "INACTIVE"} />
</span>
</li>
))}
</ul>
)}
</section>
</div>
);
}

View file

@ -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<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
function onSubmit(fd: FormData) {
setError(null);
setSuccess(null);
startTransition(async () => {
const res = await action(fd);
if (res && res.ok === false) setError(res.error);
else if (res && res.ok === true) setSuccess("Enregistré.");
});
}
return (
<form action={onSubmit} className="space-y-4">
<fieldset disabled={pending} className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Nom du prestataire" required>
<input name="name" defaultValue={initial.name ?? ""} required maxLength={200} className={inputCls} />
</FormField>
<FormField label="Type">
<label className="flex items-center gap-2 px-1 py-2 text-sm">
<input
type="checkbox"
name="isSystemD"
defaultChecked={initial.isSystemD ?? false}
className="h-4 w-4 rounded border-zinc-300"
/>
Fournisseur officiel System D (0 % commission)
</label>
</FormField>
<FormField label="Email contact">
<input
name="contactEmail"
type="email"
defaultValue={initial.contactEmail ?? ""}
maxLength={200}
className={inputCls}
/>
</FormField>
<FormField label="Téléphone contact">
<input
name="contactPhone"
defaultValue={initial.contactPhone ?? ""}
maxLength={50}
className={inputCls}
/>
</FormField>
<FormField label="Commission (%)" hint="0 pour System D, 5-15 % pour les prestataires externes.">
<input
name="commissionPct"
type="number"
min={0}
max={50}
step="0.5"
defaultValue={initial.commissionPct?.toString() ?? "10"}
className={inputCls}
/>
</FormField>
<FormField label="Statut">
<label className="flex items-center gap-2 px-1 py-2 text-sm">
<input
type="checkbox"
name="active"
defaultChecked={initial.active ?? true}
className="h-4 w-4 rounded border-zinc-300"
/>
Actif
</label>
</FormField>
</div>
<FormField label="Fleuves desservis" required hint="Séparer par virgule, point-virgule ou retour à la ligne.">
<input
name="rivers"
defaultValue={(initial.rivers ?? []).join(", ")}
placeholder="Maroni, Approuague, Oyapock"
className={inputCls}
/>
</FormField>
<FormField label="Description" hint="Présentation, points forts, conditions particulières.">
<textarea
name="description"
rows={4}
defaultValue={initial.description ?? ""}
maxLength={5000}
className={textareaCls}
/>
</FormField>
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
) : null}
{success ? (
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
) : null}
<div className="flex items-center justify-end gap-2">
<button
type="submit"
className="rounded-md bg-zinc-900 px-5 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
>
{pending ? "Enregistrement…" : submitLabel}
</button>
</div>
</fieldset>
</form>
);
}

View file

@ -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");
}

View file

@ -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 (
<div className="mx-auto max-w-3xl space-y-6">
<header className="mt-2">
<Link href="/admin/rental-providers" className="text-xs text-zinc-500 hover:text-zinc-900">
Tous les prestataires
</Link>
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">Nouveau prestataire location</h1>
</header>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<ProviderForm action={createRentalProviderAction} submitLabel="Créer le prestataire" />
</section>
</div>
);
}

View file

@ -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 (
<div className="mx-auto max-w-7xl">
<header className="mb-5 mt-2 flex items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold text-zinc-900">Prestataires location matériel</h1>
<p className="mt-1 text-sm text-zinc-500">
{rows.length} résultat{rows.length > 1 ? "s" : ""}
</p>
</div>
<Link
href="/admin/rental-providers/new"
className="inline-flex items-center gap-1.5 rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-semibold text-white hover:bg-zinc-800"
>
+ Nouveau prestataire
</Link>
</header>
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
<input
type="text"
name="q"
defaultValue={filters.q ?? ""}
placeholder="Recherche nom, email, description…"
className="flex-1 min-w-[220px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
/>
<select
name="approved"
defaultValue={filters.approved ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Tous statuts approbation</option>
<option value="yes">Approuvés</option>
<option value="no">En attente</option>
</select>
<select
name="active"
defaultValue={filters.active ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Actifs + inactifs</option>
<option value="yes">Actifs</option>
<option value="no">Inactifs</option>
</select>
<select
name="river"
defaultValue={filters.river ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Tous fleuves</option>
{rivers.map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
Filtrer
</button>
{(filters.q || filters.approved || filters.active || filters.river) ? (
<Link href="/admin/rental-providers" className="text-sm text-zinc-500 hover:text-zinc-900">
Réinit.
</Link>
) : null}
</form>
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
<table className="w-full text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-4 py-2 text-left font-semibold">Nom</th>
<th className="px-4 py-2 text-left font-semibold">Fleuves</th>
<th className="px-4 py-2 text-right font-semibold">Items</th>
<th className="px-4 py-2 text-right font-semibold">Comm.</th>
<th className="px-4 py-2 text-left font-semibold">Approbation</th>
<th className="px-4 py-2 text-left font-semibold">État</th>
<th className="px-4 py-2 text-right font-semibold">MAJ</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{rows.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-sm text-zinc-500">
Aucun prestataire ne correspond aux filtres.
</td>
</tr>
) : null}
{rows.map((p) => (
<tr key={p.id} className="hover:bg-zinc-50">
<td className="px-4 py-2">
<Link href={`/admin/rental-providers/${p.id}`} className="font-medium text-zinc-900 hover:underline">
{p.name}
</Link>
{p.isSystemD ? (
<span className="ml-2 rounded-full bg-emerald-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
System D
</span>
) : null}
<div className="text-[11px] text-zinc-500">{p.contactEmail ?? "—"}</div>
</td>
<td className="px-4 py-2 text-zinc-700">
{p.rivers.length === 0 ? <span className="text-zinc-400"></span> : p.rivers.join(", ")}
</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{p.itemsCount}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{Number(p.commissionPct).toFixed(1)}%</td>
<td className="px-4 py-2">
{p.approved ? (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
Approuvé
</span>
) : (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-800 ring-1 ring-inset ring-amber-300">
En attente
</span>
)}
</td>
<td className="px-4 py-2"><StatusBadge status={p.active ? "ACTIVE" : "INACTIVE"} /></td>
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">{dateFmt.format(p.updatedAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -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<string>([
RentalBookingStatus.PENDING,
RentalBookingStatus.CONFIRMED,
RentalBookingStatus.HANDED_OVER,
RentalBookingStatus.RETURNED,
RentalBookingStatus.CANCELLED,
]);
const PAYMENT_VALUES = new Set<string>([
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 (
<div className="mx-auto max-w-7xl">
<header className="mb-5 mt-2">
<h1 className="text-2xl font-semibold text-zinc-900">Réservations matériel</h1>
<p className="mt-1 text-sm text-zinc-500">
{rows.length} résultat{rows.length > 1 ? "s" : ""}
</p>
</header>
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
<input
type="text"
name="q"
defaultValue={filters.q ?? ""}
placeholder="Recherche ID, email locataire, prestataire…"
className="flex-1 min-w-[200px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
/>
<select name="status" defaultValue={filters.status ?? ""} className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none">
<option value="">Tous statuts</option>
{Object.values(RentalBookingStatus).map((s) => (
<option key={s} value={s}>{RENTAL_STATUS_LABEL[s]}</option>
))}
</select>
<select name="paymentStatus" defaultValue={filters.paymentStatus ?? ""} className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none">
<option value="">Tous paiements</option>
{Object.values(PaymentStatus).map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
Filtrer
</button>
{(filters.q || filters.status || filters.paymentStatus) ? (
<Link href="/admin/rentals" className="text-sm text-zinc-500 hover:text-zinc-900">Réinit.</Link>
) : null}
</form>
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
<table className="w-full text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-4 py-2 text-left font-semibold">ID</th>
<th className="px-4 py-2 text-left font-semibold">Locataire</th>
<th className="px-4 py-2 text-left font-semibold">Prestataire</th>
<th className="px-4 py-2 text-left font-semibold">Items</th>
<th className="px-4 py-2 text-left font-semibold">Période</th>
<th className="px-4 py-2 text-right font-semibold">Montant</th>
<th className="px-4 py-2 text-left font-semibold">Statut</th>
<th className="px-4 py-2 text-left font-semibold">Paiement</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{rows.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-sm text-zinc-500">
Aucune réservation matériel.
</td>
</tr>
) : null}
{rows.map((r) => (
<tr key={r.id} className="hover:bg-zinc-50">
<td className="px-4 py-2 font-mono text-[11px] text-zinc-700">{r.id.slice(0, 10)}</td>
<td className="px-4 py-2 text-zinc-700">
{r.tenant.firstName} {r.tenant.lastName}
<div className="text-[11px] text-zinc-500">{r.tenant.email}</div>
</td>
<td className="px-4 py-2">
<Link href={`/admin/rental-providers/${r.provider.id}`} className="text-zinc-900 hover:underline">
{r.provider.name}
</Link>
{r.provider.isSystemD ? <span className="ml-1 text-[9px] font-semibold text-emerald-700">SD</span> : null}
</td>
<td className="px-4 py-2 text-zinc-700">
{r.lines.length} ligne{r.lines.length > 1 ? "s" : ""}
<div className="text-[11px] text-zinc-500 truncate max-w-[200px]">
{r.lines.map((l) => `${l.qty}× ${l.item.name}`).join(", ")}
</div>
</td>
<td className="px-4 py-2 text-zinc-700">
{dateFmt.format(r.startDate)} {dateFmt.format(r.endDate)}
</td>
<td className="px-4 py-2 text-right font-mono text-zinc-900">
{Number(r.amount).toFixed(2)} {r.currency}
</td>
<td className="px-4 py-2">
<StatusBadge status={r.status} />
</td>
<td className="px-4 py-2">
<StatusBadge status={r.paymentStatus} />
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -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 },
],
},

View file

@ -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<RentalBookingStatus, string> = {
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 } } } },
},
});
}

View file

@ -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<RentalCategory, string> = {
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<AdminRentalItemListItem[]> {
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 },
});
}

View file

@ -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<AdminRentalProviderListItem[]> {
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<string[]> {
const rows = await prisma.rentalProvider.findMany({
where: { active: true, approved: true },
select: { rivers: true },
});
const set = new Set<string>();
for (const r of rows) for (const x of r.rivers) set.add(x);
return Array.from(set).sort();
}