feat(ce): Sprint J — matériel rental côté CE
All checks were successful
CI / test (pull_request) Successful in 2m41s

src/lib/rental-access.ts CE-aware :
- requireRentalProviderSession accepte CE_MANAGER (en plus de
  RENTAL_PROVIDER et ADMIN).
- getCurrentRentalProvider : CE_MANAGER → findFirst par
  organizationId ; RENTAL_PROVIDER → par managedByUserId.
- getCurrentRentalProviderForCe(organizationId) helper explicite.
- canManageRentalProvider gagne un userOrgId? optionnel : vrai si
  manager nominal OU CE_MANAGER + provider.organizationId === userOrgId.
- Callers existants (5 sites : actions.ts + 4 routes API rental)
  passent désormais session.user.organizationId.

Actions /espace-prestataire/actions.ts role-aware :
- requireOwnedProvider() dérive basePath selon le rôle :
  CE_MANAGER → /espace-ce/materiel ; sinon → /espace-prestataire.
- Tous les redirect/revalidatePath utilisent basePath, donc
  createHostItemAction, updateHostItemAction, deleteHostItemAction,
  addItemBlockAction, removeItemBlockAction, updateBookingStatusAction
  emmènent le user vers son espace contextuel après chaque opération.

/espace-ce/materiel/page.tsx — onboarding :
- Plugin gear-rental disabled → message d'info.
- Pas de provider activé → CTA « Activer la location matériel pour
  <org> » (bouton bloqué si org pending, message bannière).
- Provider existant → dashboard avec KPIs (items actifs, résa pending,
  confirmées à venir, revenu 30j) + 2 ActionCards Items + Réservations.

actions.ts (CE) :
- activateRentalProviderForCeAction → crée RentalProvider(organizationId,
  name="Matériel <org>", managedByUserId=session.user.id, approved=true)
  + audit + redirect /espace-ce/materiel.

Pages CE clonées (réutilisent les composants, actions, helpers
existants — zéro duplication de logique métier) :
- /espace-ce/materiel/items/page.tsx (liste)
- /espace-ce/materiel/items/new/page.tsx (HostItemForm)
- /espace-ce/materiel/items/[itemId]/page.tsx (MediaUploader +
  HostItemForm + ItemBlocksManager + ItemInlineDelete)
- /espace-ce/materiel/reservations/page.tsx (BookingDecision)

Tous importent depuis /espace-prestataire/{actions, items, reservations}
pour rester DRY. Les breadcrumbs et links sont adaptés au contexte CE.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ubuntu 2026-06-02 23:47:57 +00:00
parent 03b740dfff
commit caa3d5214f
12 changed files with 714 additions and 25 deletions

View file

@ -23,6 +23,7 @@ export async function DELETE(_req: Request, ctx: { params: Promise<{ id: string
session.user.id,
session.user.role,
media.item.providerId,
session.user.organizationId,
);
if (!allowed) return NextResponse.json({ error: "Accès refusé" }, { status: 403 });

View file

@ -34,6 +34,7 @@ export async function POST(req: Request) {
session.user.id,
session.user.role,
item.providerId,
session.user.organizationId,
);
if (!allowed) return NextResponse.json({ error: "Accès refusé" }, { status: 403 });

View file

@ -43,6 +43,7 @@ export async function POST(req: Request) {
session.user.id,
session.user.role,
item.providerId,
session.user.organizationId,
);
if (!allowed) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });

View file

@ -45,6 +45,7 @@ export async function POST(req: Request) {
session.user.id,
session.user.role,
item.providerId,
session.user.organizationId,
);
if (!allowed) {
return NextResponse.json({ error: "Accès refusé" }, { status: 403 });

View file

@ -0,0 +1,66 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { UserRole } from "@/generated/prisma/enums";
import { recordAudit } from "@/lib/admin/audit";
import { getCurrentCeOrganization } from "@/lib/ce-access";
import { prisma } from "@/lib/prisma";
/**
* Active la location matériel pour un CE : crée le RentalProvider lié à son
* organizationId. Approuvé automatiquement si l'org elle-même est approuvée.
* - Si un provider existe déjà pour cette org : redirige sans rien créer.
* - Bloque si l'org n'est pas validée (la création doit attendre l'approval).
*/
export async function activateRentalProviderForCeAction(): Promise<void> {
const session = await auth();
if (!session?.user?.id) redirect("/connexion?next=/espace-ce/materiel");
if (session.user.role !== UserRole.CE_MANAGER && session.user.role !== UserRole.ADMIN) {
redirect("/");
}
const org = await getCurrentCeOrganization();
if (!org) redirect("/espace-ce");
if (!org.approved) {
// L'org doit être validée avant activation. La page affichera la bannière.
redirect("/espace-ce/materiel?activateError=pending");
}
const existing = await prisma.rentalProvider.findFirst({
where: { organizationId: org.id },
select: { id: true },
});
if (existing) {
redirect("/espace-ce/materiel");
}
const created = await prisma.rentalProvider.create({
data: {
name: `Matériel — ${org.name}`,
isSystemD: false,
managedByUserId: session.user.id,
organizationId: org.id,
contactEmail: org.contactEmail,
rivers: [],
commissionPct: 10,
active: true,
approved: true,
approvedAt: new Date(),
approvedBy: session.user.email ?? "system",
},
select: { id: true, name: true },
});
await recordAudit({
scope: "ce",
event: "ce.rental_provider.activate",
target: created.id,
actorEmail: session.user.email ?? null,
details: { organizationId: org.id, name: created.name },
});
revalidatePath("/espace-ce/materiel");
redirect("/espace-ce/materiel");
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,152 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getCurrentCeOrganization } from "@/lib/ce-access";
import { isPluginEnabled } from "@/lib/plugins/server";
import {
getCurrentRentalProviderForCe,
} from "@/lib/rental-access";
import { getHostRentalKpis } from "@/lib/rental-host";
import { activateRentalProviderForCeAction } from "./actions";
export const dynamic = "force-dynamic";
export const metadata = { title: "Matériel CE — Karbé" };
function fmtEur(amount: string | number): string {
return Number(amount).toLocaleString("fr-FR", { style: "currency", currency: "EUR" });
}
export default async function CeMaterielPage() {
// Soft dependency : si le plugin gear-rental est off, on masque /espace-ce/materiel
// (le bouton du dashboard a déjà été désactivé côté UX).
if (!(await isPluginEnabled("gear-rental"))) {
return (
<main className="mx-auto max-w-3xl px-6 py-12">
<h1 className="text-3xl font-semibold text-zinc-900">Matériel rental</h1>
<p className="mt-4 rounded-md border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-700">
La marketplace location matériel n&apos;est pas activée. Activez le plugin
<code className="ml-1 rounded bg-zinc-200 px-1.5 py-0.5 text-xs">gear-rental</code>{" "}
dans <Link href="/admin/plugins" className="underline">/admin/plugins</Link>.
</p>
</main>
);
}
const org = await getCurrentCeOrganization();
if (!org) redirect("/admin/organizations");
const provider = await getCurrentRentalProviderForCe(org.id);
// Onboarding : pas encore de provider activé
if (!provider) {
return (
<main className="mx-auto max-w-3xl px-6 py-12">
<Link href="/espace-ce" className="text-xs text-zinc-500 hover:text-zinc-900">
Tableau de bord CE
</Link>
<h1 className="mt-1 text-3xl font-semibold text-zinc-900">Matériel rental</h1>
<p className="mt-2 text-sm text-zinc-600">
Activez la location matériel pour proposer hamacs, kayaks, pirogues, etc. à vos
membres et au public touriste. Le provider sera créé au nom de votre CE.
</p>
{!org.approved ? (
<p className="mt-6 rounded-md border border-amber-200 bg-amber-50/60 px-4 py-3 text-sm text-amber-900">
🕒 Votre organisation est en attente de validation. La location matériel sera
activable dès qu&apos;un admin Karbé aura validé votre CE.
</p>
) : (
<form action={activateRentalProviderForCeAction} className="mt-8">
<button
type="submit"
className="rounded-md bg-emerald-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-emerald-700"
>
Activer la location matériel pour {org.name}
</button>
<p className="mt-2 text-xs text-zinc-500">
Vous pourrez ensuite ajouter vos items (hamac, pirogue, kayak). Commission
par défaut : 10 % (ajustable par un admin Karbé).
</p>
</form>
)}
</main>
);
}
// Provider existant : dashboard + KPIs
const kpis = await getHostRentalKpis(provider.id);
return (
<main className="mx-auto max-w-5xl px-6 py-10 space-y-6">
<header>
<Link href="/espace-ce" className="text-xs text-zinc-500 hover:text-zinc-900">
Tableau de bord CE
</Link>
<h1 className="mt-1 text-3xl font-semibold text-zinc-900">
Matériel rental {provider.name}
</h1>
<p className="mt-1 text-sm text-zinc-500">
Commission Karbé : {Number(provider.commissionPct).toFixed(1)} % · Géré par {org.name}
</p>
</header>
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<KpiCard label="Items actifs" value={kpis.itemsActive} />
<KpiCard label="Réservations en attente" value={kpis.bookingsPending} />
<KpiCard label="Confirmées à venir" value={kpis.bookingsConfirmed} />
<KpiCard label="Revenu 30j" value={fmtEur(kpis.revenue30d)} />
</section>
<section className="grid gap-3 sm:grid-cols-2">
<ActionCard
href="/espace-ce/materiel/items"
title="Mes items"
description={
kpis.itemsActive > 0
? `${kpis.itemsActive} item${kpis.itemsActive > 1 ? "s" : ""} en location.`
: "Ajoutez votre premier item (hamac, kayak, pirogue…)."
}
/>
<ActionCard
href="/espace-ce/materiel/reservations"
title="Réservations"
description={
kpis.bookingsPending > 0
? `${kpis.bookingsPending} demande${kpis.bookingsPending > 1 ? "s" : ""} à préparer.`
: "Suivez vos réservations en cours, à préparer et terminées."
}
/>
</section>
</main>
);
}
function KpiCard({ label, value }: { label: string; value: string | number }) {
return (
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3 shadow-sm">
<div className="text-[10px] uppercase tracking-wider text-zinc-500">{label}</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900 font-mono">{value}</div>
</div>
);
}
function ActionCard({
href,
title,
description,
}: {
href: string;
title: string;
description: string;
}) {
return (
<Link
href={href}
className="rounded-lg border border-zinc-200 bg-white px-5 py-4 shadow-sm transition hover:border-zinc-400 hover:shadow"
>
<h3 className="text-base font-semibold text-zinc-900">{title}</h3>
<p className="mt-1 text-sm text-zinc-600">{description}</p>
</Link>
);
}

View file

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

View file

@ -31,12 +31,25 @@ const itemSchema = z.object({
active: z.boolean(),
});
async function requireOwnedProvider(): Promise<{ providerId: string; actorEmail: string | null }> {
async function requireOwnedProvider(): Promise<{
providerId: string;
actorEmail: string | null;
basePath: string;
}> {
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 };
// Un CE_MANAGER reste sous /espace-ce/materiel ; un RENTAL_PROVIDER/ADMIN
// reste sous /espace-prestataire. Les actions sont mutualisées et redirigent
// vers l'espace contextuel du user.
const basePath =
session.user.role === UserRole.CE_MANAGER ? "/espace-ce/materiel" : "/espace-prestataire";
return {
providerId: provider.id,
actorEmail: session.user.email ?? null,
basePath,
};
}
function parseItemFD(fd: FormData) {
@ -61,7 +74,7 @@ function parseItemFD(fd: FormData) {
}
export async function createHostItemAction(fd: FormData) {
const { providerId, actorEmail } = await requireOwnedProvider();
const { providerId, actorEmail, basePath } = 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(" · ") };
@ -74,14 +87,14 @@ export async function createHostItemAction(fd: FormData) {
actorEmail,
details: { name: created.name, providerId },
});
revalidatePath("/espace-prestataire/items");
redirect(`/espace-prestataire/items/${created.id}`);
revalidatePath(`${basePath}/items`);
redirect(`${basePath}/items/${created.id}`);
}
export async function updateHostItemAction(itemId: string, fd: FormData) {
const { providerId, actorEmail } = await requireOwnedProvider();
const { providerId, actorEmail, basePath } = await requireOwnedProvider();
const session = await auth();
if (!(await canManageRentalProvider(session!.user.id, session?.user?.role, providerId))) {
if (!(await canManageRentalProvider(session!.user.id, session?.user?.role, providerId, session?.user?.organizationId))) {
return { ok: false as const, error: "Accès refusé" };
}
const existing = await prisma.rentalItem.findUnique({ where: { id: itemId }, select: { providerId: true } });
@ -100,13 +113,13 @@ export async function updateHostItemAction(itemId: string, fd: FormData) {
actorEmail,
details: { name: parsed.data.name },
});
revalidatePath("/espace-prestataire/items");
revalidatePath(`/espace-prestataire/items/${itemId}`);
revalidatePath(`${basePath}/items`);
revalidatePath(`${basePath}/items/${itemId}`);
return { ok: true as const };
}
export async function deleteHostItemAction(itemId: string) {
const { providerId, actorEmail } = await requireOwnedProvider();
const { providerId, actorEmail, basePath } = await requireOwnedProvider();
const existing = await prisma.rentalItem.findUnique({
where: { id: itemId },
select: { providerId: true, _count: { select: { lines: true } } },
@ -125,8 +138,8 @@ export async function deleteHostItemAction(itemId: string) {
actorEmail,
details: {},
});
revalidatePath("/espace-prestataire/items");
redirect("/espace-prestataire/items");
revalidatePath(`${basePath}/items`);
redirect(`${basePath}/items`);
}
const blockSchema = z.object({
@ -137,7 +150,7 @@ const blockSchema = z.object({
});
export async function addItemBlockAction(itemId: string, fd: FormData) {
const { providerId, actorEmail } = await requireOwnedProvider();
const { providerId, actorEmail, basePath } = 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." };
@ -171,12 +184,12 @@ export async function addItemBlockAction(itemId: string, fd: FormData) {
actorEmail,
details: { ...parsed.data },
});
revalidatePath(`/espace-prestataire/items/${itemId}`);
revalidatePath(`${basePath}/items/${itemId}`);
return { ok: true as const };
}
export async function removeItemBlockAction(blockId: string) {
const { providerId, actorEmail } = await requireOwnedProvider();
const { providerId, actorEmail, basePath } = await requireOwnedProvider();
const block = await prisma.rentalItemAvailability.findUnique({
where: { id: blockId },
select: { itemId: true, rentalBookingId: true, item: { select: { providerId: true } } },
@ -195,7 +208,7 @@ export async function removeItemBlockAction(blockId: string) {
actorEmail,
details: { itemId: block.itemId },
});
revalidatePath(`/espace-prestataire/items/${block.itemId}`);
revalidatePath(`${basePath}/items/${block.itemId}`);
return { ok: true as const };
}
@ -208,7 +221,7 @@ const statusSchema = z.enum([
]);
export async function updateBookingStatusAction(bookingId: string, status: string) {
const { providerId, actorEmail } = await requireOwnedProvider();
const { providerId, actorEmail, basePath } = await requireOwnedProvider();
const session = await auth();
const role = session?.user?.role;
const parsed = statusSchema.safeParse(status);
@ -232,6 +245,6 @@ export async function updateBookingStatusAction(bookingId: string, status: strin
actorEmail,
details: { status: parsed.data },
});
revalidatePath("/espace-prestataire/reservations");
revalidatePath(`${basePath}/reservations`);
return { ok: true as const };
}

View file

@ -6,22 +6,36 @@ import { auth } from "@/auth";
import { UserRole } from "@/generated/prisma/enums";
import { prisma } from "@/lib/prisma";
/**
* Garde-fou pour /espace-prestataire ET /espace-ce/materiel : accepte RENTAL_PROVIDER,
* CE_MANAGER, ADMIN. Chacun voit ensuite SON provider :
* - RENTAL_PROVIDER via managedByUserId
* - CE_MANAGER via organizationId
* - ADMIN null si pas de providerId fourni (force le choix)
*/
export async function requireRentalProviderSession() {
const session = await auth();
if (!session?.user?.id) {
redirect("/connexion?next=/espace-prestataire");
}
const role = session.user.role;
if (role !== UserRole.RENTAL_PROVIDER && role !== UserRole.ADMIN) {
if (
role !== UserRole.RENTAL_PROVIDER &&
role !== UserRole.CE_MANAGER &&
role !== UserRole.ADMIN
) {
redirect("/");
}
return session;
}
/**
* Récupère le RentalProvider géré par l'utilisateur.
* Si ADMIN, on retourne soit le provider explicitement ciblé (param `providerId`),
* soit null pour forcer le choix.
* Récupère le RentalProvider courant selon le rôle.
*
* - ADMIN avec `providerId` : retourne ce provider.
* - ADMIN sans `providerId` : retourne null (force le choix admin).
* - RENTAL_PROVIDER : retourne `findFirst({ managedByUserId })`.
* - CE_MANAGER : retourne `findFirst({ organizationId: session.user.organizationId })`.
*/
export async function getCurrentRentalProvider(opts: { providerId?: string } = {}) {
const session = await auth();
@ -34,21 +48,53 @@ export async function getCurrentRentalProvider(opts: { providerId?: string } = {
if (role === UserRole.ADMIN && !opts.providerId) {
return null;
}
// RENTAL_PROVIDER : retourne le provider lié
if (role === UserRole.CE_MANAGER && session.user.organizationId) {
return prisma.rentalProvider.findFirst({
where: { organizationId: session.user.organizationId },
});
}
// RENTAL_PROVIDER
return prisma.rentalProvider.findFirst({
where: { managedByUserId: session.user.id },
});
}
/**
* Variante explicite pour le contexte CE : retourne le provider lié à `organizationId`
* (ou null s'il n'a pas encore é activé). Utile dans /espace-ce/materiel pour
* détecter l'onboarding nécessaire.
*/
export async function getCurrentRentalProviderForCe(organizationId: string) {
return prisma.rentalProvider.findFirst({
where: { organizationId },
});
}
/**
* Vrai si :
* - ADMIN
* - le user est le manager nominal du provider (RENTAL_PROVIDER)
* - le user est CE_MANAGER ET son organizationId == provider.organizationId
*/
export async function canManageRentalProvider(
userId: string,
role: string | undefined,
providerId: string,
userOrgId?: string | null,
): Promise<boolean> {
if (role === UserRole.ADMIN) return true;
const provider = await prisma.rentalProvider.findUnique({
where: { id: providerId },
select: { managedByUserId: true },
select: { managedByUserId: true, organizationId: true },
});
return provider?.managedByUserId === userId;
if (!provider) return false;
if (provider.managedByUserId === userId) return true;
if (
role === UserRole.CE_MANAGER &&
userOrgId &&
provider.organizationId === userOrgId
) {
return true;
}
return false;
}