feat(rental): Sprint C — espace prestataire (signup+dashboard+items+calendrier+résa)
All checks were successful
CI / test (pull_request) Successful in 2m33s
All checks were successful
CI / test (pull_request) Successful in 2m33s
This commit is contained in:
parent
8d7e9cfdc2
commit
59786e5365
16 changed files with 1509 additions and 9 deletions
|
|
@ -5,7 +5,7 @@ import { UserRole } from "@/generated/prisma/enums";
|
|||
import { hashPassword } from "@/lib/password";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { recordAudit } from "@/lib/admin/audit";
|
||||
import { sendSignupWelcome } from "@/lib/email";
|
||||
import { sendNewRentalProviderRequest, sendSignupWelcome } from "@/lib/email";
|
||||
import { rateLimitRequest } from "@/lib/rate-limit";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
|
@ -16,11 +16,14 @@ const schema = z.object({
|
|||
firstName: z.string().trim().min(1).max(100),
|
||||
lastName: z.string().trim().min(1).max(100),
|
||||
phone: z.string().trim().max(40).optional().nullable(),
|
||||
role: z.enum([UserRole.TOURIST, UserRole.OWNER]).default(UserRole.TOURIST),
|
||||
role: z
|
||||
.enum([UserRole.TOURIST, UserRole.OWNER, UserRole.RENTAL_PROVIDER])
|
||||
.default(UserRole.TOURIST),
|
||||
providerName: z.string().trim().min(2).max(200).optional(),
|
||||
providerRivers: z.array(z.string().trim().min(1).max(80)).max(20).optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
// 5 inscriptions max par IP par heure.
|
||||
const rl = rateLimitRequest(req, "signup", 60 * 60 * 1000, 5);
|
||||
if (!rl.ok) {
|
||||
return NextResponse.json(
|
||||
|
|
@ -43,6 +46,10 @@ export async function POST(req: Request) {
|
|||
}
|
||||
const data = parsed.data;
|
||||
|
||||
if (data.role === UserRole.RENTAL_PROVIDER && (!data.providerName || data.providerName.trim().length < 2)) {
|
||||
return NextResponse.json({ error: "Nom de votre activité requis." }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email: data.email }, select: { id: true } });
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: "Un compte existe déjà avec cet email." }, { status: 409 });
|
||||
|
|
@ -62,16 +69,36 @@ export async function POST(req: Request) {
|
|||
select: { id: true, email: true, role: true },
|
||||
});
|
||||
|
||||
// Pour un RENTAL_PROVIDER : crée le RentalProvider associé en attente d'approbation.
|
||||
let createdProviderId: string | null = null;
|
||||
if (user.role === UserRole.RENTAL_PROVIDER && data.providerName) {
|
||||
const provider = await prisma.rentalProvider.create({
|
||||
data: {
|
||||
name: data.providerName,
|
||||
isSystemD: false,
|
||||
managedByUserId: user.id,
|
||||
contactEmail: user.email,
|
||||
contactPhone: data.phone?.trim() || null,
|
||||
rivers: data.providerRivers ?? [],
|
||||
commissionPct: 10, // valeur par défaut, ajustable par admin
|
||||
active: true,
|
||||
approved: false,
|
||||
},
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
createdProviderId = provider.id;
|
||||
sendNewRentalProviderRequest(provider.name, user.email).catch(() => {});
|
||||
}
|
||||
|
||||
await recordAudit({
|
||||
scope: "public.signup",
|
||||
event: "user.create",
|
||||
target: user.id,
|
||||
actorEmail: user.email,
|
||||
details: { role: user.role },
|
||||
details: { role: user.role, rentalProviderId: createdProviderId },
|
||||
});
|
||||
|
||||
// Best-effort welcome email.
|
||||
sendSignupWelcome(user.email, data.firstName).catch(() => {});
|
||||
|
||||
return NextResponse.json({ ok: true, userId: user.id });
|
||||
return NextResponse.json({ ok: true, userId: user.id, providerId: createdProviderId });
|
||||
}
|
||||
|
|
|
|||
237
src/app/espace-prestataire/actions.ts
Normal file
237
src/app/espace-prestataire/actions.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { RentalBookingStatus, RentalCategory, UserRole } from "@/generated/prisma/enums";
|
||||
import { canManageRentalProvider, getCurrentRentalProvider } from "@/lib/rental-access";
|
||||
import { recordAudit } from "@/lib/admin/audit";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const itemSchema = z.object({
|
||||
category: z.enum([
|
||||
RentalCategory.SLEEP,
|
||||
RentalCategory.NAVIGATION,
|
||||
RentalCategory.FISHING,
|
||||
RentalCategory.COOKING,
|
||||
RentalCategory.SAFETY,
|
||||
]),
|
||||
name: z.string().trim().min(2).max(200),
|
||||
description: z.string().trim().max(5000).nullable().optional(),
|
||||
imageUrl: z.string().trim().url().max(500).nullable().optional(),
|
||||
pricePerDay: z.coerce.number().min(0).max(10000),
|
||||
pricePerWeek: z.coerce.number().min(0).max(50000).nullable().optional(),
|
||||
deposit: z.coerce.number().min(0).max(10000),
|
||||
totalQty: z.coerce.number().int().min(1).max(1000),
|
||||
withMotor: z.boolean(),
|
||||
fuelIncluded: z.boolean(),
|
||||
requiresLicense: z.boolean(),
|
||||
active: z.boolean(),
|
||||
});
|
||||
|
||||
async function requireOwnedProvider(): Promise<{ providerId: string; actorEmail: string | null }> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) throw new Error("Non authentifié");
|
||||
const provider = await getCurrentRentalProvider();
|
||||
if (!provider) throw new Error("Aucun provider associé");
|
||||
return { providerId: provider.id, actorEmail: session.user.email ?? null };
|
||||
}
|
||||
|
||||
function parseItemFD(fd: FormData) {
|
||||
const get = (k: string) => {
|
||||
const v = (fd.get(k) as string | null) ?? "";
|
||||
return v.trim() === "" ? null : v.trim();
|
||||
};
|
||||
return {
|
||||
category: ((fd.get("category") as string | null) ?? "").trim(),
|
||||
name: ((fd.get("name") as string | null) ?? "").trim(),
|
||||
description: get("description"),
|
||||
imageUrl: get("imageUrl"),
|
||||
pricePerDay: fd.get("pricePerDay"),
|
||||
pricePerWeek: get("pricePerWeek"),
|
||||
deposit: fd.get("deposit") ?? "0",
|
||||
totalQty: fd.get("totalQty") ?? "1",
|
||||
withMotor: fd.get("withMotor") === "on",
|
||||
fuelIncluded: fd.get("fuelIncluded") === "on",
|
||||
requiresLicense: fd.get("requiresLicense") === "on",
|
||||
active: fd.get("active") === "on",
|
||||
};
|
||||
}
|
||||
|
||||
export async function createHostItemAction(fd: FormData) {
|
||||
const { providerId, actorEmail } = await requireOwnedProvider();
|
||||
const parsed = itemSchema.safeParse(parseItemFD(fd));
|
||||
if (!parsed.success) {
|
||||
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
|
||||
}
|
||||
const created = await prisma.rentalItem.create({ data: { ...parsed.data, providerId } });
|
||||
await recordAudit({
|
||||
scope: "host.rental-items",
|
||||
event: "create",
|
||||
target: created.id,
|
||||
actorEmail,
|
||||
details: { name: created.name, providerId },
|
||||
});
|
||||
revalidatePath("/espace-prestataire/items");
|
||||
redirect(`/espace-prestataire/items/${created.id}`);
|
||||
}
|
||||
|
||||
export async function updateHostItemAction(itemId: string, fd: FormData) {
|
||||
const { providerId, actorEmail } = await requireOwnedProvider();
|
||||
const session = await auth();
|
||||
if (!(await canManageRentalProvider(session!.user.id, session?.user?.role, providerId))) {
|
||||
return { ok: false as const, error: "Accès refusé" };
|
||||
}
|
||||
const existing = await prisma.rentalItem.findUnique({ where: { id: itemId }, select: { providerId: true } });
|
||||
if (!existing || existing.providerId !== providerId) {
|
||||
return { ok: false as const, error: "Item introuvable." };
|
||||
}
|
||||
const parsed = itemSchema.safeParse(parseItemFD(fd));
|
||||
if (!parsed.success) {
|
||||
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
|
||||
}
|
||||
await prisma.rentalItem.update({ where: { id: itemId }, data: parsed.data });
|
||||
await recordAudit({
|
||||
scope: "host.rental-items",
|
||||
event: "update",
|
||||
target: itemId,
|
||||
actorEmail,
|
||||
details: { name: parsed.data.name },
|
||||
});
|
||||
revalidatePath("/espace-prestataire/items");
|
||||
revalidatePath(`/espace-prestataire/items/${itemId}`);
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function deleteHostItemAction(itemId: string) {
|
||||
const { providerId, actorEmail } = await requireOwnedProvider();
|
||||
const existing = await prisma.rentalItem.findUnique({
|
||||
where: { id: itemId },
|
||||
select: { providerId: true, _count: { select: { lines: true } } },
|
||||
});
|
||||
if (!existing || existing.providerId !== providerId) {
|
||||
return { ok: false as const, error: "Item introuvable." };
|
||||
}
|
||||
if (existing._count.lines > 0) {
|
||||
return { ok: false as const, error: "Impossible : item référencé par des locations." };
|
||||
}
|
||||
await prisma.rentalItem.delete({ where: { id: itemId } });
|
||||
await recordAudit({
|
||||
scope: "host.rental-items",
|
||||
event: "delete",
|
||||
target: itemId,
|
||||
actorEmail,
|
||||
details: {},
|
||||
});
|
||||
revalidatePath("/espace-prestataire/items");
|
||||
redirect("/espace-prestataire/items");
|
||||
}
|
||||
|
||||
const blockSchema = z.object({
|
||||
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
qty: z.coerce.number().int().min(1).max(1000),
|
||||
reason: z.enum(["MAINTENANCE", "MANUAL_BLOCK"]),
|
||||
});
|
||||
|
||||
export async function addItemBlockAction(itemId: string, fd: FormData) {
|
||||
const { providerId, actorEmail } = await requireOwnedProvider();
|
||||
const existing = await prisma.rentalItem.findUnique({ where: { id: itemId }, select: { providerId: true } });
|
||||
if (!existing || existing.providerId !== providerId) {
|
||||
return { ok: false as const, error: "Item introuvable." };
|
||||
}
|
||||
const parsed = blockSchema.safeParse({
|
||||
startDate: fd.get("startDate"),
|
||||
endDate: fd.get("endDate"),
|
||||
qty: fd.get("qty"),
|
||||
reason: fd.get("reason"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
|
||||
}
|
||||
const start = new Date(`${parsed.data.startDate}T00:00:00.000Z`);
|
||||
const end = new Date(`${parsed.data.endDate}T00:00:00.000Z`);
|
||||
if (end <= start) return { ok: false as const, error: "Date de fin doit être après début." };
|
||||
|
||||
await prisma.rentalItemAvailability.create({
|
||||
data: {
|
||||
itemId,
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
qty: parsed.data.qty,
|
||||
reason: parsed.data.reason,
|
||||
},
|
||||
});
|
||||
await recordAudit({
|
||||
scope: "host.rental-items",
|
||||
event: "block.add",
|
||||
target: itemId,
|
||||
actorEmail,
|
||||
details: { ...parsed.data },
|
||||
});
|
||||
revalidatePath(`/espace-prestataire/items/${itemId}`);
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function removeItemBlockAction(blockId: string) {
|
||||
const { providerId, actorEmail } = await requireOwnedProvider();
|
||||
const block = await prisma.rentalItemAvailability.findUnique({
|
||||
where: { id: blockId },
|
||||
select: { itemId: true, rentalBookingId: true, item: { select: { providerId: true } } },
|
||||
});
|
||||
if (!block || block.item.providerId !== providerId) {
|
||||
return { ok: false as const, error: "Blocage introuvable." };
|
||||
}
|
||||
if (block.rentalBookingId) {
|
||||
return { ok: false as const, error: "Blocage lié à une réservation : annulez la réservation à la place." };
|
||||
}
|
||||
await prisma.rentalItemAvailability.delete({ where: { id: blockId } });
|
||||
await recordAudit({
|
||||
scope: "host.rental-items",
|
||||
event: "block.remove",
|
||||
target: blockId,
|
||||
actorEmail,
|
||||
details: { itemId: block.itemId },
|
||||
});
|
||||
revalidatePath(`/espace-prestataire/items/${block.itemId}`);
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
const statusSchema = z.enum([
|
||||
RentalBookingStatus.PENDING,
|
||||
RentalBookingStatus.CONFIRMED,
|
||||
RentalBookingStatus.HANDED_OVER,
|
||||
RentalBookingStatus.RETURNED,
|
||||
RentalBookingStatus.CANCELLED,
|
||||
]);
|
||||
|
||||
export async function updateBookingStatusAction(bookingId: string, status: string) {
|
||||
const { providerId, actorEmail } = await requireOwnedProvider();
|
||||
const session = await auth();
|
||||
const role = session?.user?.role;
|
||||
const parsed = statusSchema.safeParse(status);
|
||||
if (!parsed.success) return { ok: false as const, error: "Statut invalide." };
|
||||
|
||||
const existing = await prisma.rentalBooking.findUnique({
|
||||
where: { id: bookingId },
|
||||
select: { providerId: true },
|
||||
});
|
||||
if (!existing || (existing.providerId !== providerId && role !== UserRole.ADMIN)) {
|
||||
return { ok: false as const, error: "Réservation introuvable." };
|
||||
}
|
||||
await prisma.rentalBooking.update({
|
||||
where: { id: bookingId },
|
||||
data: { status: parsed.data },
|
||||
});
|
||||
await recordAudit({
|
||||
scope: "host.rental-bookings",
|
||||
event: "status.update",
|
||||
target: bookingId,
|
||||
actorEmail,
|
||||
details: { status: parsed.data },
|
||||
});
|
||||
revalidatePath("/espace-prestataire/reservations");
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type Block = {
|
||||
id: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
qty: number;
|
||||
reason: string;
|
||||
isBooking: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
blocks: Block[];
|
||||
totalQty: number;
|
||||
addAction: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
|
||||
removeAction: (blockId: string) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
|
||||
};
|
||||
|
||||
const REASON_LABEL: Record<string, string> = {
|
||||
MAINTENANCE: "🔧 Maintenance",
|
||||
MANUAL_BLOCK: "⛔ Blocage personnel",
|
||||
RENTAL_BOOKING: "🛒 Réservation",
|
||||
};
|
||||
|
||||
export function ItemBlocksManager({ blocks, totalQty, addAction, removeAction }: Props) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function onAdd(fd: FormData) {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const res = await addAction(fd);
|
||||
if (res && res.ok === false) setError(res.error);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function onRemove(blockId: string) {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const res = await removeAction(blockId);
|
||||
if (res && res.ok === false) setError(res.error);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<form action={onAdd} className="grid grid-cols-1 gap-2 rounded-md border border-zinc-200 bg-zinc-50 p-3 sm:grid-cols-5">
|
||||
<fieldset disabled={pending} className="contents">
|
||||
<label className="block text-xs">
|
||||
<span className="text-zinc-600">Du</span>
|
||||
<input
|
||||
name="startDate"
|
||||
type="date"
|
||||
required
|
||||
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs">
|
||||
<span className="text-zinc-600">Au</span>
|
||||
<input
|
||||
name="endDate"
|
||||
type="date"
|
||||
required
|
||||
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs">
|
||||
<span className="text-zinc-600">Quantité</span>
|
||||
<input
|
||||
name="qty"
|
||||
type="number"
|
||||
min={1}
|
||||
max={totalQty}
|
||||
defaultValue={totalQty}
|
||||
required
|
||||
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-xs">
|
||||
<span className="text-zinc-600">Raison</span>
|
||||
<select
|
||||
name="reason"
|
||||
defaultValue="MAINTENANCE"
|
||||
className="mt-0.5 w-full rounded-md border border-zinc-300 px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option value="MAINTENANCE">Maintenance</option>
|
||||
<option value="MANUAL_BLOCK">Blocage perso</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
{pending ? "…" : "Ajouter blocage"}
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
|
||||
) : null}
|
||||
|
||||
{blocks.length === 0 ? (
|
||||
<p className="rounded border border-dashed border-zinc-200 px-3 py-6 text-center text-xs text-zinc-500">
|
||||
Aucun blocage manuel. Toutes les dates sont disponibles.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{blocks.map((b) => (
|
||||
<li
|
||||
key={b.id}
|
||||
className={
|
||||
"flex items-center justify-between gap-3 rounded-md border px-3 py-1.5 text-sm " +
|
||||
(b.isBooking ? "border-sky-200 bg-sky-50" : "border-amber-200 bg-amber-50")
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<span className="font-medium text-zinc-900">
|
||||
{b.startDate} → {b.endDate}
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-zinc-600">
|
||||
{b.qty} unité{b.qty > 1 ? "s" : ""} · {REASON_LABEL[b.reason] ?? b.reason}
|
||||
</span>
|
||||
</div>
|
||||
{!b.isBooking ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(b.id)}
|
||||
disabled={pending}
|
||||
className="text-xs font-semibold text-rose-700 hover:text-rose-900 disabled:opacity-50"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-[10px] uppercase tracking-wider text-sky-700">Auto</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
|
||||
type Props = {
|
||||
canDelete: boolean;
|
||||
deleteAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined | void>;
|
||||
};
|
||||
|
||||
export function ItemInlineDelete({ canDelete, deleteAction }: Props) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function run() {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const res = await deleteAction();
|
||||
if (res && (res as { ok?: boolean }).ok === false) {
|
||||
setError((res as { error: string }).error);
|
||||
setConfirm(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!canDelete) {
|
||||
return (
|
||||
<span className="rounded border border-zinc-200 bg-zinc-50 px-2 py-1 text-[11px] text-zinc-500">
|
||||
Suppression impossible — item référencé par des locations
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{confirm ? (
|
||||
<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={run}
|
||||
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={() => setConfirm(false)}
|
||||
disabled={pending}
|
||||
className="text-[11px] text-zinc-500 hover:text-zinc-900"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirm(true)}
|
||||
disabled={pending}
|
||||
className="rounded-md border border-rose-300 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
|
||||
>
|
||||
Supprimer l'item
|
||||
</button>
|
||||
)}
|
||||
{error ? (
|
||||
<div className="rounded border border-rose-200 bg-rose-50 px-2 py-1 text-xs text-rose-700">{error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
src/app/espace-prestataire/items/[itemId]/page.tsx
Normal file
107
src/app/espace-prestataire/items/[itemId]/page.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
|
||||
import { requireRentalProviderSession, getCurrentRentalProvider } from "@/lib/rental-access";
|
||||
import { getHostItem } from "@/lib/rental-host";
|
||||
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
|
||||
|
||||
import { HostItemForm } from "../_components/ItemForm";
|
||||
import { ItemBlocksManager } from "./_components/ItemBlocksManager";
|
||||
import { ItemInlineDelete } from "./_components/ItemInlineDelete";
|
||||
import {
|
||||
addItemBlockAction,
|
||||
deleteHostItemAction,
|
||||
removeItemBlockAction,
|
||||
updateHostItemAction,
|
||||
} from "../../actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PageProps = { params: Promise<{ itemId: string }> };
|
||||
|
||||
export default async function EditHostItemPage({ params }: PageProps) {
|
||||
await requireRentalProviderSession();
|
||||
const provider = await getCurrentRentalProvider();
|
||||
if (!provider) redirect("/admin/rental-providers");
|
||||
const { itemId } = await params;
|
||||
const item = await getHostItem(provider.id, itemId);
|
||||
if (!item) notFound();
|
||||
|
||||
const updateThis = async (fd: FormData) => {
|
||||
"use server";
|
||||
return await updateHostItemAction(itemId, fd);
|
||||
};
|
||||
const deleteThis = async () => {
|
||||
"use server";
|
||||
return await deleteHostItemAction(itemId);
|
||||
};
|
||||
const addBlockThis = async (fd: FormData) => {
|
||||
"use server";
|
||||
return await addItemBlockAction(itemId, fd);
|
||||
};
|
||||
const removeBlockThis = async (blockId: string) => {
|
||||
"use server";
|
||||
return await removeItemBlockAction(blockId);
|
||||
};
|
||||
|
||||
return (
|
||||
<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-prestataire/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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
133
src/app/espace-prestataire/items/_components/ItemForm.tsx
Normal file
133
src/app/espace-prestataire/items/_components/ItemForm.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
|
||||
import { RENTAL_CATEGORY_LABEL, RENTAL_CATEGORIES } from "@/lib/rental-category-labels";
|
||||
|
||||
const inputCls =
|
||||
"mt-0.5 w-full rounded-md border border-zinc-300 px-3 py-2 text-sm focus:border-emerald-500 focus:outline-none";
|
||||
const labelCls = "block text-sm font-medium text-zinc-800";
|
||||
|
||||
type Props = {
|
||||
initial?: {
|
||||
category?: string;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
imageUrl?: string | null;
|
||||
pricePerDay?: string | number;
|
||||
pricePerWeek?: string | number | null;
|
||||
deposit?: string | number;
|
||||
totalQty?: number;
|
||||
withMotor?: boolean;
|
||||
fuelIncluded?: boolean;
|
||||
requiresLicense?: boolean;
|
||||
active?: boolean;
|
||||
};
|
||||
action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
|
||||
submitLabel?: string;
|
||||
};
|
||||
|
||||
export function HostItemForm({ initial = {}, action, submitLabel = "Enregistrer" }: Props) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<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-3 sm:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className={labelCls}>Catégorie</span>
|
||||
<select name="category" defaultValue={initial.category ?? ""} required className={inputCls}>
|
||||
<option value="" disabled>— sélectionner —</option>
|
||||
{RENTAL_CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>{RENTAL_CATEGORY_LABEL[c]}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className={labelCls}>Statut</span>
|
||||
<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>
|
||||
</label>
|
||||
<label className="block sm:col-span-2">
|
||||
<span className={labelCls}>Nom de l'item</span>
|
||||
<input name="name" required maxLength={200} defaultValue={initial.name ?? ""} className={inputCls} placeholder="ex. Hamac coton large" />
|
||||
</label>
|
||||
<label className="block sm:col-span-2">
|
||||
<span className={labelCls}>Description</span>
|
||||
<textarea name="description" rows={3} maxLength={5000} defaultValue={initial.description ?? ""} className={inputCls} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className={labelCls}>URL image</span>
|
||||
<input name="imageUrl" type="url" maxLength={500} defaultValue={initial.imageUrl ?? ""} className={inputCls} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className={labelCls}>Stock total (qté)</span>
|
||||
<input name="totalQty" type="number" min={1} max={1000} defaultValue={initial.totalQty?.toString() ?? "1"} required className={inputCls} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className={labelCls}>Prix / jour (€)</span>
|
||||
<input name="pricePerDay" type="number" min={0} step="0.5" defaultValue={initial.pricePerDay?.toString() ?? ""} required className={inputCls} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className={labelCls}>Prix / semaine (€)</span>
|
||||
<input name="pricePerWeek" type="number" min={0} step="0.5" defaultValue={initial.pricePerWeek?.toString() ?? ""} className={inputCls} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className={labelCls}>Caution (€)</span>
|
||||
<input name="deposit" type="number" min={0} step="1" defaultValue={initial.deposit?.toString() ?? "0"} className={inputCls} />
|
||||
</label>
|
||||
</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
|
||||
</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-emerald-600 px-5 py-2 text-sm font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
{pending ? "Enregistrement…" : submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
23
src/app/espace-prestataire/items/new/page.tsx
Normal file
23
src/app/espace-prestataire/items/new/page.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import Link from "next/link";
|
||||
|
||||
import { requireRentalProviderSession } from "@/lib/rental-access";
|
||||
|
||||
import { HostItemForm } from "../_components/ItemForm";
|
||||
import { createHostItemAction } from "../../actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function NewHostItemPage() {
|
||||
await requireRentalProviderSession();
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-6 py-10">
|
||||
<Link href="/espace-prestataire/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>
|
||||
);
|
||||
}
|
||||
93
src/app/espace-prestataire/items/page.tsx
Normal file
93
src/app/espace-prestataire/items/page.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { requireRentalProviderSession, getCurrentRentalProvider } from "@/lib/rental-access";
|
||||
import { listHostItems } from "@/lib/rental-host";
|
||||
import { RENTAL_CATEGORY_LABEL } from "@/lib/rental-category-labels";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function HostItemsPage() {
|
||||
await requireRentalProviderSession();
|
||||
const provider = await getCurrentRentalProvider();
|
||||
if (!provider) redirect("/admin/rental-providers");
|
||||
|
||||
const items = await listHostItems(provider.id);
|
||||
|
||||
return (
|
||||
<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-prestataire" className="text-xs text-zinc-500 hover:text-zinc-900">
|
||||
← Dashboard
|
||||
</Link>
|
||||
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">Mes items locables</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">{items.length} item{items.length > 1 ? "s" : ""}</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/espace-prestataire/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-prestataire/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-prestataire/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>
|
||||
);
|
||||
}
|
||||
153
src/app/espace-prestataire/page.tsx
Normal file
153
src/app/espace-prestataire/page.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { requireRentalProviderSession, getCurrentRentalProvider } from "@/lib/rental-access";
|
||||
import { getHostRentalKpis } from "@/lib/rental-host";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function fmtEur(amount: string | number): string {
|
||||
const n = Number(amount);
|
||||
return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR" });
|
||||
}
|
||||
|
||||
const dateFmt = new Intl.DateTimeFormat("fr-FR", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
export default async function ProviderDashboardPage() {
|
||||
await requireRentalProviderSession();
|
||||
const provider = await getCurrentRentalProvider();
|
||||
if (!provider) {
|
||||
// Admin sans providerId ciblé : redirect vers liste admin
|
||||
redirect("/admin/rental-providers");
|
||||
}
|
||||
|
||||
const kpis = await getHostRentalKpis(provider.id);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-6 py-10">
|
||||
<header className="mb-6 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold text-zinc-900">Espace prestataire</h1>
|
||||
<p className="mt-1 text-sm text-zinc-600">
|
||||
{provider.name}
|
||||
{provider.isSystemD ? " · Fournisseur officiel Karbé" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link
|
||||
href="/espace-prestataire/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>
|
||||
<Link
|
||||
href="/espace-prestataire/items"
|
||||
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-700 hover:bg-zinc-50"
|
||||
>
|
||||
Mes items
|
||||
</Link>
|
||||
<Link
|
||||
href="/espace-prestataire/reservations"
|
||||
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-700 hover:bg-zinc-50"
|
||||
>
|
||||
Réservations
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{!provider.approved ? (
|
||||
<div className="mb-6 rounded-lg border border-amber-300 bg-amber-50 p-4">
|
||||
<div className="text-xs uppercase tracking-wider text-amber-900">Compte en attente de validation</div>
|
||||
<p className="mt-1 text-sm text-amber-900">
|
||||
Vos items ne sont <strong>pas encore visibles</strong> sur le catalogue public.
|
||||
L'équipe Karbé contactera bientôt {provider.contactEmail ?? "votre email"} pour finaliser
|
||||
votre adhésion. Vous pouvez toutefois préparer vos items dès maintenant.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<Kpi label="CA total" value={fmtEur(kpis.revenueTotal)} />
|
||||
<Kpi label="CA 30 j" value={fmtEur(kpis.revenue30d)} />
|
||||
<Kpi
|
||||
label="À confirmer"
|
||||
value={String(kpis.bookingsPending)}
|
||||
tone={kpis.bookingsPending > 0 ? "warn" : "neutral"}
|
||||
/>
|
||||
<Kpi label="Confirmées à venir" value={String(kpis.bookingsConfirmed)} />
|
||||
<Kpi label="Items au catalogue" value={String(kpis.itemsActive)} />
|
||||
<Kpi label="Items total" value={String(kpis.itemsTotal)} />
|
||||
</section>
|
||||
|
||||
{kpis.nextHandover ? (
|
||||
<section className="mb-6 rounded-lg border border-emerald-300 bg-emerald-50 p-4">
|
||||
<div className="text-xs uppercase tracking-wider text-emerald-700">Prochaine remise</div>
|
||||
<div className="mt-1 text-base font-semibold text-emerald-900">
|
||||
{kpis.nextHandover.tenantName} · {kpis.nextHandover.lineCount} ligne(s)
|
||||
</div>
|
||||
<div className="text-sm text-emerald-800">
|
||||
{dateFmt.format(kpis.nextHandover.startDate)}
|
||||
</div>
|
||||
<Link
|
||||
href={`/espace-prestataire/reservations`}
|
||||
className="mt-2 inline-block text-xs font-semibold text-emerald-900 underline"
|
||||
>
|
||||
Voir le détail →
|
||||
</Link>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">Mon activité</h2>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="rounded border border-zinc-200 bg-white px-3 py-2">
|
||||
<span className="text-zinc-500">Fleuves desservis :</span>{" "}
|
||||
<strong className="text-zinc-900">{provider.rivers.join(", ") || "—"}</strong>
|
||||
</li>
|
||||
<li className="rounded border border-zinc-200 bg-white px-3 py-2">
|
||||
<span className="text-zinc-500">Commission Karbé :</span>{" "}
|
||||
<strong className="text-zinc-900">{Number(provider.commissionPct).toFixed(1)}%</strong>
|
||||
</li>
|
||||
<li className="rounded border border-zinc-200 bg-white px-3 py-2">
|
||||
<span className="text-zinc-500">Statut :</span>{" "}
|
||||
<strong className="text-zinc-900">{provider.active ? "Actif" : "Inactif"}</strong>
|
||||
{" · "}
|
||||
<strong className="text-zinc-900">{provider.approved ? "Approuvé" : "En attente"}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Kpi({
|
||||
label,
|
||||
value,
|
||||
tone = "neutral",
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "neutral" | "warn";
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"rounded-lg border bg-white p-3 shadow-sm " +
|
||||
(tone === "warn" ? "border-amber-300" : "border-zinc-200")
|
||||
}
|
||||
>
|
||||
<div className="text-[11px] uppercase tracking-wider text-zinc-500">{label}</div>
|
||||
<div
|
||||
className={
|
||||
"mt-1 text-xl font-semibold " + (tone === "warn" ? "text-amber-700" : "text-zinc-900")
|
||||
}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { RentalBookingStatus } from "@/generated/prisma/enums";
|
||||
|
||||
import { updateBookingStatusAction } from "../../actions";
|
||||
|
||||
const btnBase =
|
||||
"rounded-md px-3 py-1.5 text-xs font-semibold transition disabled:opacity-50";
|
||||
|
||||
export function BookingDecision({ bookingId, status }: { bookingId: string; status: string }) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmCancel, setConfirmCancel] = useState(false);
|
||||
|
||||
function set(next: string) {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const res = await updateBookingStatusAction(bookingId, next);
|
||||
if (res && res.ok === false) setError(res.error);
|
||||
setConfirmCancel(false);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{status === RentalBookingStatus.PENDING ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => set(RentalBookingStatus.CONFIRMED)}
|
||||
disabled={pending}
|
||||
className={`${btnBase} bg-emerald-600 text-white hover:bg-emerald-700`}
|
||||
>
|
||||
Confirmer
|
||||
</button>
|
||||
) : null}
|
||||
{status === RentalBookingStatus.CONFIRMED ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => set(RentalBookingStatus.HANDED_OVER)}
|
||||
disabled={pending}
|
||||
className={`${btnBase} bg-emerald-600 text-white hover:bg-emerald-700`}
|
||||
>
|
||||
Marquer remis client
|
||||
</button>
|
||||
) : null}
|
||||
{status === RentalBookingStatus.HANDED_OVER ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => set(RentalBookingStatus.RETURNED)}
|
||||
disabled={pending}
|
||||
className={`${btnBase} bg-emerald-600 text-white hover:bg-emerald-700`}
|
||||
>
|
||||
Marquer retourné
|
||||
</button>
|
||||
) : null}
|
||||
{status !== RentalBookingStatus.CANCELLED && status !== RentalBookingStatus.RETURNED ? (
|
||||
confirmCancel ? (
|
||||
<div className="flex items-center gap-1.5 rounded border border-rose-300 bg-rose-50 px-2 py-1">
|
||||
<span className="text-xs text-rose-900">Annuler ?</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => set(RentalBookingStatus.CANCELLED)}
|
||||
disabled={pending}
|
||||
className="rounded bg-rose-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-rose-800 disabled:opacity-50"
|
||||
>
|
||||
Oui
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmCancel(false)}
|
||||
disabled={pending}
|
||||
className="text-[11px] text-zinc-500 hover:text-zinc-900"
|
||||
>
|
||||
Non
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmCancel(true)}
|
||||
disabled={pending}
|
||||
className={`${btnBase} border border-rose-300 bg-white text-rose-700 hover:bg-rose-50`}
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
)
|
||||
) : null}
|
||||
{error ? <span className="text-xs text-rose-700">{error}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
src/app/espace-prestataire/reservations/page.tsx
Normal file
137
src/app/espace-prestataire/reservations/page.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { RentalBookingStatus } from "@/generated/prisma/enums";
|
||||
import { requireRentalProviderSession, getCurrentRentalProvider } from "@/lib/rental-access";
|
||||
import { listHostBookings } from "@/lib/rental-host";
|
||||
import { RENTAL_STATUS_LABEL } from "@/lib/admin/rental-bookings";
|
||||
|
||||
import { BookingDecision } from "./_components/BookingDecision";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const STATUS_VALUES = new Set<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 HostReservationsPage({ searchParams }: PageProps) {
|
||||
await requireRentalProviderSession();
|
||||
const provider = await getCurrentRentalProvider();
|
||||
if (!provider) redirect("/admin/rental-providers");
|
||||
const sp = await searchParams;
|
||||
const status = STATUS_VALUES.has(sp.status ?? "")
|
||||
? (sp.status as RentalBookingStatus)
|
||||
: undefined;
|
||||
|
||||
const bookings = await listHostBookings(provider.id, { status });
|
||||
|
||||
return (
|
||||
<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-prestataire" className="text-xs text-zinc-500 hover:text-zinc-900">
|
||||
← Dashboard
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -10,7 +10,9 @@ export function SignupForm({ next }: Props) {
|
|||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [role, setRole] = useState<"TOURIST" | "OWNER">("TOURIST");
|
||||
const [role, setRole] = useState<"TOURIST" | "OWNER" | "RENTAL_PROVIDER">("TOURIST");
|
||||
const [providerName, setProviderName] = useState("");
|
||||
const [providerRivers, setProviderRivers] = useState("");
|
||||
|
||||
function onSubmit(formData: FormData) {
|
||||
setError(null);
|
||||
|
|
@ -24,12 +26,31 @@ export function SignupForm({ next }: Props) {
|
|||
setError("Le mot de passe doit faire au moins 8 caractères.");
|
||||
return;
|
||||
}
|
||||
if (role === "RENTAL_PROVIDER" && providerName.trim().length < 2) {
|
||||
setError("Le nom de votre activité de loueur est requis.");
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const body: Record<string, unknown> = {
|
||||
email,
|
||||
password,
|
||||
firstName,
|
||||
lastName,
|
||||
phone: phone || null,
|
||||
role,
|
||||
};
|
||||
if (role === "RENTAL_PROVIDER") {
|
||||
body.providerName = providerName.trim();
|
||||
body.providerRivers = providerRivers
|
||||
.split(/[,;\n]/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
}
|
||||
const res = await fetch("/api/signup", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password, firstName, lastName, phone: phone || null, role }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
|
|
@ -91,7 +112,7 @@ export function SignupForm({ next }: Props) {
|
|||
|
||||
<fieldset className="space-y-1">
|
||||
<legend className="text-xs text-zinc-600">Type de compte</legend>
|
||||
<div className="grid grid-cols-2 gap-2 pt-1">
|
||||
<div className="grid grid-cols-1 gap-2 pt-1 sm:grid-cols-3">
|
||||
<label
|
||||
className={
|
||||
"flex cursor-pointer flex-col items-start rounded-md border px-3 py-2 text-sm " +
|
||||
|
|
@ -130,9 +151,59 @@ export function SignupForm({ next }: Props) {
|
|||
<span className="font-semibold text-zinc-900">Hôte</span>
|
||||
<span className="text-[11px] text-zinc-500">Publier un carbet.</span>
|
||||
</label>
|
||||
<label
|
||||
className={
|
||||
"flex cursor-pointer flex-col items-start rounded-md border px-3 py-2 text-sm " +
|
||||
(role === "RENTAL_PROVIDER"
|
||||
? "border-zinc-900 bg-zinc-50 ring-1 ring-zinc-900"
|
||||
: "border-zinc-300 hover:bg-zinc-50")
|
||||
}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="role"
|
||||
value="RENTAL_PROVIDER"
|
||||
checked={role === "RENTAL_PROVIDER"}
|
||||
onChange={() => setRole("RENTAL_PROVIDER")}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="font-semibold text-zinc-900">Loueur matériel</span>
|
||||
<span className="text-[11px] text-zinc-500">Hamac, pirogue, kayak…</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{role === "RENTAL_PROVIDER" ? (
|
||||
<div className="space-y-2 rounded-md border border-emerald-200 bg-emerald-50/30 p-3">
|
||||
<p className="text-[11px] text-emerald-900">
|
||||
Votre compte sera créé en <strong>attente de validation</strong>. Un admin Karbé
|
||||
vous contactera pour confirmer votre activité avant publication de vos items.
|
||||
</p>
|
||||
<label className="block">
|
||||
<span className="text-xs text-zinc-600">Nom de votre activité</span>
|
||||
<input
|
||||
type="text"
|
||||
value={providerName}
|
||||
onChange={(e) => setProviderName(e.target.value)}
|
||||
placeholder="ex. Pirogues du Bas-Oyapock"
|
||||
maxLength={200}
|
||||
className={inputCls + " mt-0.5"}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs text-zinc-600">Fleuves desservis (séparés par virgule)</span>
|
||||
<input
|
||||
type="text"
|
||||
value={providerRivers}
|
||||
onChange={(e) => setProviderRivers(e.target.value)}
|
||||
placeholder="Maroni, Oyapock"
|
||||
maxLength={300}
|
||||
className={inputCls + " mt-0.5"}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export async function SiteHeader() {
|
|||
const u = session?.user;
|
||||
const isAdmin = u?.role === UserRole.ADMIN;
|
||||
const isOwner = u?.role === UserRole.OWNER || isAdmin;
|
||||
const isRentalProvider = u?.role === UserRole.RENTAL_PROVIDER || isAdmin;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 border-b border-zinc-200 bg-white/85 backdrop-blur supports-[backdrop-filter]:bg-white/70">
|
||||
|
|
@ -55,6 +56,11 @@ export async function SiteHeader() {
|
|||
Espace hôte
|
||||
</Link>
|
||||
) : null}
|
||||
{isRentalProvider ? (
|
||||
<Link href="/espace-prestataire" className="hidden text-zinc-700 hover:text-zinc-900 sm:inline">
|
||||
Espace prestataire
|
||||
</Link>
|
||||
) : null}
|
||||
{isAdmin ? (
|
||||
<Link href="/admin" className="hidden rounded-md bg-zinc-900 px-2.5 py-1 text-xs font-semibold text-white hover:bg-zinc-800 sm:inline-block">
|
||||
Admin
|
||||
|
|
|
|||
|
|
@ -186,6 +186,27 @@ export async function sendBookingConfirmed(
|
|||
});
|
||||
}
|
||||
|
||||
export async function sendNewRentalProviderRequest(
|
||||
providerName: string,
|
||||
userEmail: string,
|
||||
): Promise<void> {
|
||||
const adminEmail = process.env.ADMIN_NOTIFICATION_EMAIL ?? "contact@karbe.cosmolan.fr";
|
||||
await sendEmail({
|
||||
to: adminEmail,
|
||||
subject: `Nouvelle demande prestataire matériel — ${providerName}`,
|
||||
html: wrap(
|
||||
"Demande de prestataire à valider",
|
||||
`<p>Une demande d'inscription en tant que prestataire de location matériel vient d'arriver.</p>
|
||||
<ul>
|
||||
<li>Nom : <strong>${providerName}</strong></li>
|
||||
<li>Email contact : ${userEmail}</li>
|
||||
</ul>
|
||||
<p><a href="${SITE_URL}/admin/rental-providers" style="display:inline-block;background:#18181b;color:white;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Valider sur l'admin Karbé</a></p>
|
||||
<p style="font-size:12px;color:#71717a;">Le prestataire reste en attente jusqu'à validation. Ses items ne sont pas publiés tant que <code>approved=false</code>.</p>`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendPasswordReset(
|
||||
to: string,
|
||||
resetUrl: string,
|
||||
|
|
|
|||
54
src/lib/rental-access.ts
Normal file
54
src/lib/rental-access.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import "server-only";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { UserRole } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
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) {
|
||||
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.
|
||||
*/
|
||||
export async function getCurrentRentalProvider(opts: { providerId?: string } = {}) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return null;
|
||||
const role = session.user.role;
|
||||
|
||||
if (role === UserRole.ADMIN && opts.providerId) {
|
||||
return prisma.rentalProvider.findUnique({ where: { id: opts.providerId } });
|
||||
}
|
||||
if (role === UserRole.ADMIN && !opts.providerId) {
|
||||
return null;
|
||||
}
|
||||
// RENTAL_PROVIDER : retourne le provider lié
|
||||
return prisma.rentalProvider.findFirst({
|
||||
where: { managedByUserId: session.user.id },
|
||||
});
|
||||
}
|
||||
|
||||
export async function canManageRentalProvider(
|
||||
userId: string,
|
||||
role: string | undefined,
|
||||
providerId: string,
|
||||
): Promise<boolean> {
|
||||
if (role === UserRole.ADMIN) return true;
|
||||
const provider = await prisma.rentalProvider.findUnique({
|
||||
where: { id: providerId },
|
||||
select: { managedByUserId: true },
|
||||
});
|
||||
return provider?.managedByUserId === userId;
|
||||
}
|
||||
120
src/lib/rental-host.ts
Normal file
120
src/lib/rental-host.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import "server-only";
|
||||
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { RentalBookingStatus } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export type HostRentalKpis = {
|
||||
itemsTotal: number;
|
||||
itemsActive: number;
|
||||
bookingsPending: number;
|
||||
bookingsConfirmed: number;
|
||||
revenueTotal: string;
|
||||
revenue30d: string;
|
||||
nextHandover: {
|
||||
id: string;
|
||||
startDate: Date;
|
||||
tenantName: string;
|
||||
lineCount: number;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export async function getHostRentalKpis(providerId: string): Promise<HostRentalKpis> {
|
||||
const now = new Date();
|
||||
const last30 = new Date(now.getTime() - 30 * 86_400_000);
|
||||
|
||||
const [itemsTotal, itemsActive, bookingsPending, bookingsConfirmed, revenueAll, revenue30, next] =
|
||||
await Promise.all([
|
||||
prisma.rentalItem.count({ where: { providerId } }),
|
||||
prisma.rentalItem.count({ where: { providerId, active: true } }),
|
||||
prisma.rentalBooking.count({
|
||||
where: { providerId, status: RentalBookingStatus.PENDING },
|
||||
}),
|
||||
prisma.rentalBooking.count({
|
||||
where: {
|
||||
providerId,
|
||||
status: { in: [RentalBookingStatus.CONFIRMED, RentalBookingStatus.HANDED_OVER] },
|
||||
startDate: { gte: now },
|
||||
},
|
||||
}),
|
||||
prisma.rentalBooking.aggregate({
|
||||
where: {
|
||||
providerId,
|
||||
status: { in: [RentalBookingStatus.CONFIRMED, RentalBookingStatus.HANDED_OVER, RentalBookingStatus.RETURNED] },
|
||||
},
|
||||
_sum: { amount: true },
|
||||
}),
|
||||
prisma.rentalBooking.aggregate({
|
||||
where: {
|
||||
providerId,
|
||||
status: { in: [RentalBookingStatus.CONFIRMED, RentalBookingStatus.HANDED_OVER, RentalBookingStatus.RETURNED] },
|
||||
createdAt: { gte: last30 },
|
||||
},
|
||||
_sum: { amount: true },
|
||||
}),
|
||||
prisma.rentalBooking.findFirst({
|
||||
where: {
|
||||
providerId,
|
||||
status: RentalBookingStatus.CONFIRMED,
|
||||
startDate: { gte: now },
|
||||
},
|
||||
orderBy: { startDate: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
startDate: true,
|
||||
tenant: { select: { firstName: true, lastName: true } },
|
||||
_count: { select: { lines: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
itemsTotal,
|
||||
itemsActive,
|
||||
bookingsPending,
|
||||
bookingsConfirmed,
|
||||
revenueTotal: (revenueAll._sum.amount ?? 0).toString(),
|
||||
revenue30d: (revenue30._sum.amount ?? 0).toString(),
|
||||
nextHandover: next
|
||||
? {
|
||||
id: next.id,
|
||||
startDate: next.startDate,
|
||||
tenantName: `${next.tenant.firstName} ${next.tenant.lastName}`.trim(),
|
||||
lineCount: next._count.lines,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listHostItems(providerId: string) {
|
||||
return prisma.rentalItem.findMany({
|
||||
where: { providerId },
|
||||
orderBy: [{ category: "asc" }, { name: "asc" }],
|
||||
include: { _count: { select: { lines: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
export async function listHostBookings(providerId: string, filters: { status?: RentalBookingStatus } = {}) {
|
||||
const where: Prisma.RentalBookingWhereInput = { providerId };
|
||||
if (filters.status) where.status = filters.status;
|
||||
return prisma.rentalBooking.findMany({
|
||||
where,
|
||||
orderBy: [{ status: "asc" }, { startDate: "asc" }],
|
||||
take: 200,
|
||||
include: {
|
||||
tenant: { select: { id: true, firstName: true, lastName: true, email: true, phone: true } },
|
||||
lines: { include: { item: { select: { id: true, name: true, category: true } } } },
|
||||
booking: { select: { id: true, carbet: { select: { title: true, slug: true } } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getHostItem(providerId: string, itemId: string) {
|
||||
return prisma.rentalItem.findFirst({
|
||||
where: { id: itemId, providerId },
|
||||
include: {
|
||||
availabilities: { orderBy: { startDate: "asc" } },
|
||||
_count: { select: { lines: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue