feat(ce): Sprint J — matériel rental côté CE
All checks were successful
CI / test (push) Successful in 2m19s
All checks were successful
CI / test (push) Successful in 2m19s
This commit is contained in:
commit
ab1bbb5484
12 changed files with 714 additions and 25 deletions
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
66
src/app/espace-ce/materiel/actions.ts
Normal file
66
src/app/espace-ce/materiel/actions.ts
Normal 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");
|
||||
}
|
||||
122
src/app/espace-ce/materiel/items/[itemId]/page.tsx
Normal file
122
src/app/espace-ce/materiel/items/[itemId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
src/app/espace-ce/materiel/items/new/page.tsx
Normal file
27
src/app/espace-ce/materiel/items/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
src/app/espace-ce/materiel/items/page.tsx
Normal file
109
src/app/espace-ce/materiel/items/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
152
src/app/espace-ce/materiel/page.tsx
Normal file
152
src/app/espace-ce/materiel/page.tsx
Normal 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'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'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>
|
||||
);
|
||||
}
|
||||
150
src/app/espace-ce/materiel/reservations/page.tsx
Normal file
150
src/app/espace-ce/materiel/reservations/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 été 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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue