Merge pull request 'feat(rental): Sprint A — modèle + admin + seed' (#73) from feat/rental-sprint-a into main
Some checks failed
CI / test (push) Failing after 1m49s
Some checks failed
CI / test (push) Failing after 1m49s
This commit is contained in:
commit
46d3c2d3ab
19 changed files with 2000 additions and 6 deletions
|
|
@ -0,0 +1,112 @@
|
|||
-- UserRole : ajouter RENTAL_PROVIDER
|
||||
ALTER TYPE "UserRole" ADD VALUE IF NOT EXISTS 'RENTAL_PROVIDER';
|
||||
|
||||
-- Enums dédiés
|
||||
CREATE TYPE "RentalCategory" AS ENUM ('SLEEP', 'NAVIGATION', 'FISHING', 'COOKING', 'SAFETY');
|
||||
CREATE TYPE "RentalBookingStatus" AS ENUM ('PENDING', 'CONFIRMED', 'HANDED_OVER', 'RETURNED', 'CANCELLED');
|
||||
|
||||
-- RentalProvider
|
||||
CREATE TABLE "RentalProvider" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"isSystemD" BOOLEAN NOT NULL DEFAULT false,
|
||||
"managedByUserId" TEXT,
|
||||
"contactEmail" TEXT,
|
||||
"contactPhone" TEXT,
|
||||
"rivers" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"description" TEXT,
|
||||
"commissionPct" DECIMAL(5,2) NOT NULL DEFAULT 0,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"approved" BOOLEAN NOT NULL DEFAULT false,
|
||||
"approvedAt" TIMESTAMP(3),
|
||||
"approvedBy" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "RentalProvider_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "RentalProvider_managedByUserId_fkey" FOREIGN KEY ("managedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
CREATE INDEX "RentalProvider_active_approved_idx" ON "RentalProvider"("active", "approved");
|
||||
CREATE INDEX "RentalProvider_managedByUserId_idx" ON "RentalProvider"("managedByUserId");
|
||||
|
||||
-- RentalItem
|
||||
CREATE TABLE "RentalItem" (
|
||||
"id" TEXT NOT NULL,
|
||||
"providerId" TEXT NOT NULL,
|
||||
"category" "RentalCategory" NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"imageUrl" TEXT,
|
||||
"pricePerDay" DECIMAL(8,2) NOT NULL,
|
||||
"pricePerWeek" DECIMAL(8,2),
|
||||
"deposit" DECIMAL(8,2) NOT NULL DEFAULT 0,
|
||||
"totalQty" INTEGER NOT NULL DEFAULT 1,
|
||||
"withMotor" BOOLEAN NOT NULL DEFAULT false,
|
||||
"fuelIncluded" BOOLEAN NOT NULL DEFAULT false,
|
||||
"requiresLicense" BOOLEAN NOT NULL DEFAULT false,
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "RentalItem_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "RentalItem_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "RentalProvider"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
CREATE INDEX "RentalItem_providerId_idx" ON "RentalItem"("providerId");
|
||||
CREATE INDEX "RentalItem_category_active_idx" ON "RentalItem"("category", "active");
|
||||
|
||||
-- RentalItemAvailability
|
||||
CREATE TABLE "RentalItemAvailability" (
|
||||
"id" TEXT NOT NULL,
|
||||
"itemId" TEXT NOT NULL,
|
||||
"startDate" TIMESTAMP(3) NOT NULL,
|
||||
"endDate" TIMESTAMP(3) NOT NULL,
|
||||
"qty" INTEGER NOT NULL,
|
||||
"reason" TEXT NOT NULL,
|
||||
"rentalBookingId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "RentalItemAvailability_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "RentalItemAvailability_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "RentalItem"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
CREATE INDEX "RentalItemAvailability_itemId_startDate_endDate_idx" ON "RentalItemAvailability"("itemId", "startDate", "endDate");
|
||||
CREATE INDEX "RentalItemAvailability_rentalBookingId_idx" ON "RentalItemAvailability"("rentalBookingId");
|
||||
|
||||
-- RentalBooking
|
||||
CREATE TABLE "RentalBooking" (
|
||||
"id" TEXT NOT NULL,
|
||||
"bookingId" TEXT,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"providerId" TEXT NOT NULL,
|
||||
"startDate" TIMESTAMP(3) NOT NULL,
|
||||
"endDate" TIMESTAMP(3) NOT NULL,
|
||||
"status" "RentalBookingStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"paymentStatus" "PaymentStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"itemsTotal" DECIMAL(10,2) NOT NULL,
|
||||
"depositTotal" DECIMAL(10,2) NOT NULL,
|
||||
"commissionAmount" DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
"amount" DECIMAL(10,2) NOT NULL,
|
||||
"currency" TEXT NOT NULL DEFAULT 'EUR',
|
||||
"stripeSessionId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "RentalBooking_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "RentalBooking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "RentalBooking_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "RentalBooking_providerId_fkey" FOREIGN KEY ("providerId") REFERENCES "RentalProvider"("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
CREATE INDEX "RentalBooking_tenantId_status_idx" ON "RentalBooking"("tenantId", "status");
|
||||
CREATE INDEX "RentalBooking_providerId_status_idx" ON "RentalBooking"("providerId", "status");
|
||||
CREATE INDEX "RentalBooking_bookingId_idx" ON "RentalBooking"("bookingId");
|
||||
CREATE INDEX "RentalBooking_startDate_endDate_idx" ON "RentalBooking"("startDate", "endDate");
|
||||
|
||||
-- RentalLine
|
||||
CREATE TABLE "RentalLine" (
|
||||
"id" TEXT NOT NULL,
|
||||
"rentalBookingId" TEXT NOT NULL,
|
||||
"itemId" TEXT NOT NULL,
|
||||
"qty" INTEGER NOT NULL,
|
||||
"pricePerDay" DECIMAL(8,2) NOT NULL,
|
||||
"deposit" DECIMAL(8,2) NOT NULL DEFAULT 0,
|
||||
"lineTotal" DECIMAL(10,2) NOT NULL,
|
||||
CONSTRAINT "RentalLine_pkey" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "RentalLine_rentalBookingId_fkey" FOREIGN KEY ("rentalBookingId") REFERENCES "RentalBooking"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "RentalLine_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "RentalItem"("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
CREATE INDEX "RentalLine_rentalBookingId_idx" ON "RentalLine"("rentalBookingId");
|
||||
|
|
@ -13,6 +13,7 @@ enum UserRole {
|
|||
CE_MEMBER
|
||||
TOURIST
|
||||
ADMIN
|
||||
RENTAL_PROVIDER
|
||||
}
|
||||
|
||||
enum CarbetStatus {
|
||||
|
|
@ -97,11 +98,13 @@ model User {
|
|||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
|
||||
carbets Carbet[] @relation("CarbetOwner")
|
||||
bookings Booking[] @relation("BookingTenant")
|
||||
reviews Review[] @relation("ReviewAuthor")
|
||||
subscriptions Subscription[]
|
||||
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
|
||||
carbets Carbet[] @relation("CarbetOwner")
|
||||
bookings Booking[] @relation("BookingTenant")
|
||||
reviews Review[] @relation("ReviewAuthor")
|
||||
subscriptions Subscription[]
|
||||
rentalProviders RentalProvider[]
|
||||
rentalBookings RentalBooking[] @relation("RentalBookingTenant")
|
||||
|
||||
@@index([organizationId])
|
||||
@@index([role])
|
||||
|
|
@ -249,7 +252,8 @@ model Booking {
|
|||
carbet Carbet @relation(fields: [carbetId], references: [id], onDelete: Restrict)
|
||||
tenant User @relation("BookingTenant", fields: [tenantId], references: [id], onDelete: Restrict)
|
||||
|
||||
review Review?
|
||||
review Review?
|
||||
rentalBookings RentalBooking[]
|
||||
|
||||
@@index([carbetId])
|
||||
@@index([tenantId])
|
||||
|
|
@ -399,3 +403,130 @@ enum Electricity {
|
|||
GENERATOR_READY
|
||||
EDF
|
||||
}
|
||||
|
||||
enum RentalCategory {
|
||||
SLEEP
|
||||
NAVIGATION
|
||||
FISHING
|
||||
COOKING
|
||||
SAFETY
|
||||
}
|
||||
|
||||
enum RentalBookingStatus {
|
||||
PENDING
|
||||
CONFIRMED
|
||||
HANDED_OVER
|
||||
RETURNED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
model RentalProvider {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
isSystemD Boolean @default(false)
|
||||
managedByUserId String?
|
||||
contactEmail String?
|
||||
contactPhone String?
|
||||
rivers String[] @default([])
|
||||
description String?
|
||||
commissionPct Decimal @db.Decimal(5, 2) @default(0)
|
||||
active Boolean @default(true)
|
||||
approved Boolean @default(false)
|
||||
approvedAt DateTime?
|
||||
approvedBy String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
manager User? @relation(fields: [managedByUserId], references: [id], onDelete: SetNull)
|
||||
items RentalItem[]
|
||||
rentalBookings RentalBooking[]
|
||||
|
||||
@@index([active, approved])
|
||||
@@index([managedByUserId])
|
||||
}
|
||||
|
||||
model RentalItem {
|
||||
id String @id @default(cuid())
|
||||
providerId String
|
||||
category RentalCategory
|
||||
name String
|
||||
description String?
|
||||
imageUrl String?
|
||||
pricePerDay Decimal @db.Decimal(8, 2)
|
||||
pricePerWeek Decimal? @db.Decimal(8, 2)
|
||||
deposit Decimal @db.Decimal(8, 2) @default(0)
|
||||
totalQty Int @default(1)
|
||||
withMotor Boolean @default(false)
|
||||
fuelIncluded Boolean @default(false)
|
||||
requiresLicense Boolean @default(false)
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
provider RentalProvider @relation(fields: [providerId], references: [id], onDelete: Cascade)
|
||||
availabilities RentalItemAvailability[]
|
||||
lines RentalLine[]
|
||||
|
||||
@@index([providerId])
|
||||
@@index([category, active])
|
||||
}
|
||||
|
||||
model RentalItemAvailability {
|
||||
id String @id @default(cuid())
|
||||
itemId String
|
||||
startDate DateTime
|
||||
endDate DateTime
|
||||
qty Int
|
||||
reason String
|
||||
rentalBookingId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
item RentalItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([itemId, startDate, endDate])
|
||||
@@index([rentalBookingId])
|
||||
}
|
||||
|
||||
model RentalBooking {
|
||||
id String @id @default(cuid())
|
||||
bookingId String?
|
||||
tenantId String
|
||||
providerId String
|
||||
startDate DateTime
|
||||
endDate DateTime
|
||||
status RentalBookingStatus @default(PENDING)
|
||||
paymentStatus PaymentStatus @default(PENDING)
|
||||
itemsTotal Decimal @db.Decimal(10, 2)
|
||||
depositTotal Decimal @db.Decimal(10, 2)
|
||||
commissionAmount Decimal @db.Decimal(10, 2) @default(0)
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
currency String @default("EUR")
|
||||
stripeSessionId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
booking Booking? @relation(fields: [bookingId], references: [id], onDelete: SetNull)
|
||||
tenant User @relation("RentalBookingTenant", fields: [tenantId], references: [id], onDelete: Restrict)
|
||||
provider RentalProvider @relation(fields: [providerId], references: [id], onDelete: Restrict)
|
||||
lines RentalLine[]
|
||||
|
||||
@@index([tenantId, status])
|
||||
@@index([providerId, status])
|
||||
@@index([bookingId])
|
||||
@@index([startDate, endDate])
|
||||
}
|
||||
|
||||
model RentalLine {
|
||||
id String @id @default(cuid())
|
||||
rentalBookingId String
|
||||
itemId String
|
||||
qty Int
|
||||
pricePerDay Decimal @db.Decimal(8, 2)
|
||||
deposit Decimal @db.Decimal(8, 2) @default(0)
|
||||
lineTotal Decimal @db.Decimal(10, 2)
|
||||
|
||||
rentalBooking RentalBooking @relation(fields: [rentalBookingId], references: [id], onDelete: Cascade)
|
||||
item RentalItem @relation(fields: [itemId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([rentalBookingId])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type Props = {
|
||||
active: boolean;
|
||||
toggleActiveAction: (active: boolean) => Promise<{ ok: true } | { ok: false; error: string } | undefined>;
|
||||
deleteAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined | void>;
|
||||
};
|
||||
|
||||
export function ItemInlineActions({ active, toggleActiveAction, deleteAction }: Props) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function toggle() {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const res = await toggleActiveAction(!active);
|
||||
if (res && (res as { ok?: boolean }).ok === false) setError((res as { error: string }).error);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
function del() {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const res = await deleteAction();
|
||||
if (res && (res as { ok?: boolean }).ok === false) {
|
||||
setError((res as { error: string }).error);
|
||||
setConfirmDelete(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
disabled={pending}
|
||||
className={
|
||||
active
|
||||
? "rounded-md border border-amber-300 bg-amber-50 px-3 py-1.5 text-xs font-semibold text-amber-800 hover:bg-amber-100 disabled:opacity-50"
|
||||
: "rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
}
|
||||
>
|
||||
{active ? "Désactiver" : "Réactiver"}
|
||||
</button>
|
||||
{confirmDelete ? (
|
||||
<div className="flex items-center gap-2 rounded border border-rose-300 bg-rose-50 px-2 py-1">
|
||||
<span className="text-xs text-rose-900">Supprimer ?</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={del}
|
||||
disabled={pending}
|
||||
className="rounded bg-rose-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-rose-800 disabled:opacity-50"
|
||||
>
|
||||
Oui
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(false)}
|
||||
disabled={pending}
|
||||
className="text-[11px] text-zinc-500 hover:text-zinc-900"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
disabled={pending}
|
||||
className="rounded-md border border-rose-300 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error ? <div className="rounded border border-rose-200 bg-rose-50 px-2 py-1 text-xs text-rose-700">{error}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
src/app/admin/rental-items/[id]/page.tsx
Normal file
83
src/app/admin/rental-items/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
||||
import { getRentalItemForAdmin, listProvidersForSelect, RENTAL_CATEGORY_LABEL } from "@/lib/admin/rental-items";
|
||||
|
||||
import { ItemForm } from "../_components/ItemForm";
|
||||
import { ItemInlineActions } from "./_components/ItemInlineActions";
|
||||
import { deleteRentalItemAction, toggleRentalItemActiveAction, updateRentalItemAction } from "../actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PageProps = { params: Promise<{ id: string }> };
|
||||
|
||||
export default async function EditRentalItemPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const [item, providers] = await Promise.all([getRentalItemForAdmin(id), listProvidersForSelect()]);
|
||||
if (!item) notFound();
|
||||
|
||||
const updateThis = async (fd: FormData) => {
|
||||
"use server";
|
||||
return await updateRentalItemAction(id, fd);
|
||||
};
|
||||
const toggleActiveThis = async (active: boolean) => {
|
||||
"use server";
|
||||
return await toggleRentalItemActiveAction(id, active);
|
||||
};
|
||||
const deleteThis = async () => {
|
||||
"use server";
|
||||
return await deleteRentalItemAction(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
<header className="mt-2 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<Link href="/admin/rental-items" className="text-xs text-zinc-500 hover:text-zinc-900">
|
||||
← Tous les items
|
||||
</Link>
|
||||
<h1 className="mt-1 flex items-center gap-3 text-2xl font-semibold text-zinc-900">
|
||||
{item.name}
|
||||
<StatusBadge status={item.active ? "ACTIVE" : "INACTIVE"} />
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
{RENTAL_CATEGORY_LABEL[item.category]} ·{" "}
|
||||
<Link href={`/admin/rental-providers/${item.provider.id}`} className="text-zinc-900 hover:underline">
|
||||
{item.provider.name}
|
||||
</Link>
|
||||
{item.provider.isSystemD ? " (System D)" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<ItemInlineActions
|
||||
active={item.active}
|
||||
toggleActiveAction={toggleActiveThis}
|
||||
deleteAction={deleteThis}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||
<ItemForm
|
||||
providers={providers}
|
||||
action={updateThis}
|
||||
submitLabel="Enregistrer les modifications"
|
||||
initial={{
|
||||
providerId: item.providerId,
|
||||
category: item.category,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
imageUrl: item.imageUrl,
|
||||
pricePerDay: item.pricePerDay.toString(),
|
||||
pricePerWeek: item.pricePerWeek?.toString() ?? null,
|
||||
deposit: item.deposit.toString(),
|
||||
totalQty: item.totalQty,
|
||||
withMotor: item.withMotor,
|
||||
fuelIncluded: item.fuelIncluded,
|
||||
requiresLicense: item.requiresLicense,
|
||||
active: item.active,
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
src/app/admin/rental-items/_components/ItemForm.tsx
Normal file
141
src/app/admin/rental-items/_components/ItemForm.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { FormField, inputCls, selectCls, textareaCls } from "@/components/admin/FormField";
|
||||
import { RENTAL_CATEGORY_LABEL } from "@/lib/admin/rental-items";
|
||||
import { RentalCategory } from "@/generated/prisma/enums";
|
||||
|
||||
type Props = {
|
||||
providers: { id: string; name: string; isSystemD: boolean }[];
|
||||
initial?: {
|
||||
providerId?: string;
|
||||
category?: string;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
imageUrl?: string | null;
|
||||
pricePerDay?: string | number;
|
||||
pricePerWeek?: string | number | null;
|
||||
deposit?: string | number;
|
||||
totalQty?: number;
|
||||
withMotor?: boolean;
|
||||
fuelIncluded?: boolean;
|
||||
requiresLicense?: boolean;
|
||||
active?: boolean;
|
||||
};
|
||||
action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
|
||||
submitLabel?: string;
|
||||
};
|
||||
|
||||
const CATEGORIES: RentalCategory[] = [
|
||||
RentalCategory.SLEEP,
|
||||
RentalCategory.NAVIGATION,
|
||||
RentalCategory.FISHING,
|
||||
RentalCategory.COOKING,
|
||||
RentalCategory.SAFETY,
|
||||
];
|
||||
|
||||
export function ItemForm({ providers, initial = {}, action, submitLabel = "Enregistrer" }: Props) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
function onSubmit(fd: FormData) {
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
startTransition(async () => {
|
||||
const res = await action(fd);
|
||||
if (res && res.ok === false) setError(res.error);
|
||||
else if (res && res.ok === true) setSuccess("Enregistré.");
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={onSubmit} className="space-y-4">
|
||||
<fieldset disabled={pending} className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField label="Prestataire" required>
|
||||
<select name="providerId" defaultValue={initial.providerId ?? ""} required className={selectCls}>
|
||||
<option value="" disabled>— sélectionner —</option>
|
||||
{providers.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}{p.isSystemD ? " (System D)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Catégorie" required>
|
||||
<select name="category" defaultValue={initial.category ?? ""} required className={selectCls}>
|
||||
<option value="" disabled>— sélectionner —</option>
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>{RENTAL_CATEGORY_LABEL[c]}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
<FormField label="Nom de l'item" required className="sm:col-span-2">
|
||||
<input name="name" defaultValue={initial.name ?? ""} required maxLength={200} className={inputCls} placeholder="ex. Hamac coton large, Pirogue 5m avec moteur 15CV" />
|
||||
</FormField>
|
||||
<FormField label="Description" className="sm:col-span-2">
|
||||
<textarea name="description" rows={3} defaultValue={initial.description ?? ""} maxLength={5000} className={textareaCls} />
|
||||
</FormField>
|
||||
<FormField label="URL image" hint="Optionnel, URL publique vers photo MinIO.">
|
||||
<input name="imageUrl" type="url" defaultValue={initial.imageUrl ?? ""} maxLength={500} className={inputCls} />
|
||||
</FormField>
|
||||
<FormField label="Stock total (qté)" required>
|
||||
<input name="totalQty" type="number" min={1} max={1000} defaultValue={initial.totalQty?.toString() ?? "1"} required className={inputCls} />
|
||||
</FormField>
|
||||
<FormField label="Prix / jour (€)" required>
|
||||
<input name="pricePerDay" type="number" min={0} step="0.5" defaultValue={initial.pricePerDay?.toString() ?? ""} required className={inputCls} />
|
||||
</FormField>
|
||||
<FormField label="Prix / semaine (€)" hint="Optionnel — tarif dégressif sur 7+ jours.">
|
||||
<input name="pricePerWeek" type="number" min={0} step="0.5" defaultValue={initial.pricePerWeek?.toString() ?? ""} className={inputCls} />
|
||||
</FormField>
|
||||
<FormField label="Caution (€)" hint="Dépôt de garantie (bloqué pendant la location).">
|
||||
<input name="deposit" type="number" min={0} step="1" defaultValue={initial.deposit?.toString() ?? "0"} className={inputCls} />
|
||||
</FormField>
|
||||
<FormField label="Statut">
|
||||
<label className="flex items-center gap-2 px-1 py-2 text-sm">
|
||||
<input type="checkbox" name="active" defaultChecked={initial.active ?? true} className="h-4 w-4 rounded border-zinc-300" />
|
||||
Actif (visible au catalogue)
|
||||
</label>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<fieldset className="rounded-lg border border-zinc-200 bg-zinc-50 p-3">
|
||||
<legend className="px-1 text-xs font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Spécifications navigation
|
||||
</legend>
|
||||
<div className="flex flex-wrap gap-4 pt-1 text-sm">
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" name="withMotor" defaultChecked={initial.withMotor ?? false} className="h-4 w-4 rounded border-zinc-300" />
|
||||
Avec moteur
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" name="fuelIncluded" defaultChecked={initial.fuelIncluded ?? false} className="h-4 w-4 rounded border-zinc-300" />
|
||||
Essence incluse
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" name="requiresLicense" defaultChecked={initial.requiresLicense ?? false} className="h-4 w-4 rounded border-zinc-300" />
|
||||
Permis bateau requis
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
|
||||
) : null}
|
||||
{success ? (
|
||||
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-zinc-900 px-5 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
{pending ? "Enregistrement…" : submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
129
src/app/admin/rental-items/actions.ts
Normal file
129
src/app/admin/rental-items/actions.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { RentalCategory, UserRole } from "@/generated/prisma/enums";
|
||||
import { requireRole } from "@/lib/authorization";
|
||||
import { recordAudit } from "@/lib/admin/audit";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const itemSchema = z.object({
|
||||
providerId: z.string().min(1),
|
||||
category: z.enum([
|
||||
RentalCategory.SLEEP,
|
||||
RentalCategory.NAVIGATION,
|
||||
RentalCategory.FISHING,
|
||||
RentalCategory.COOKING,
|
||||
RentalCategory.SAFETY,
|
||||
]),
|
||||
name: z.string().trim().min(2).max(200),
|
||||
description: z.string().trim().max(5000).nullable().optional(),
|
||||
imageUrl: z.string().trim().url().max(500).nullable().optional(),
|
||||
pricePerDay: z.coerce.number().min(0).max(10000),
|
||||
pricePerWeek: z.coerce.number().min(0).max(50000).nullable().optional(),
|
||||
deposit: z.coerce.number().min(0).max(10000),
|
||||
totalQty: z.coerce.number().int().min(1).max(1000),
|
||||
withMotor: z.boolean(),
|
||||
fuelIncluded: z.boolean(),
|
||||
requiresLicense: z.boolean(),
|
||||
active: z.boolean(),
|
||||
});
|
||||
|
||||
function parseFD(fd: FormData) {
|
||||
const get = (k: string) => {
|
||||
const v = (fd.get(k) as string | null) ?? "";
|
||||
return v.trim() === "" ? null : v.trim();
|
||||
};
|
||||
return {
|
||||
providerId: ((fd.get("providerId") as string | null) ?? "").trim(),
|
||||
category: ((fd.get("category") as string | null) ?? "").trim(),
|
||||
name: ((fd.get("name") as string | null) ?? "").trim(),
|
||||
description: get("description"),
|
||||
imageUrl: get("imageUrl"),
|
||||
pricePerDay: fd.get("pricePerDay"),
|
||||
pricePerWeek: get("pricePerWeek"),
|
||||
deposit: fd.get("deposit") ?? "0",
|
||||
totalQty: fd.get("totalQty") ?? "1",
|
||||
withMotor: fd.get("withMotor") === "on",
|
||||
fuelIncluded: fd.get("fuelIncluded") === "on",
|
||||
requiresLicense: fd.get("requiresLicense") === "on",
|
||||
active: fd.get("active") === "on",
|
||||
};
|
||||
}
|
||||
|
||||
export async function createRentalItemAction(fd: FormData) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const parsed = itemSchema.safeParse(parseFD(fd));
|
||||
if (!parsed.success) {
|
||||
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
|
||||
}
|
||||
const session = await auth();
|
||||
const created = await prisma.rentalItem.create({ data: parsed.data });
|
||||
await recordAudit({
|
||||
scope: "admin.rental-items",
|
||||
event: "create",
|
||||
target: created.id,
|
||||
actorEmail: session?.user?.email ?? null,
|
||||
details: { name: created.name, providerId: created.providerId },
|
||||
});
|
||||
revalidatePath("/admin/rental-items");
|
||||
redirect(`/admin/rental-items/${created.id}`);
|
||||
}
|
||||
|
||||
export async function updateRentalItemAction(id: string, fd: FormData) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const parsed = itemSchema.safeParse(parseFD(fd));
|
||||
if (!parsed.success) {
|
||||
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
|
||||
}
|
||||
const session = await auth();
|
||||
await prisma.rentalItem.update({ where: { id }, data: parsed.data });
|
||||
await recordAudit({
|
||||
scope: "admin.rental-items",
|
||||
event: "update",
|
||||
target: id,
|
||||
actorEmail: session?.user?.email ?? null,
|
||||
details: { name: parsed.data.name },
|
||||
});
|
||||
revalidatePath("/admin/rental-items");
|
||||
revalidatePath(`/admin/rental-items/${id}`);
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function toggleRentalItemActiveAction(id: string, active: boolean) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const session = await auth();
|
||||
await prisma.rentalItem.update({ where: { id }, data: { active } });
|
||||
await recordAudit({
|
||||
scope: "admin.rental-items",
|
||||
event: "active.update",
|
||||
target: id,
|
||||
actorEmail: session?.user?.email ?? null,
|
||||
details: { active },
|
||||
});
|
||||
revalidatePath("/admin/rental-items");
|
||||
revalidatePath(`/admin/rental-items/${id}`);
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function deleteRentalItemAction(id: string) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const session = await auth();
|
||||
const linesCount = await prisma.rentalLine.count({ where: { itemId: id } });
|
||||
if (linesCount > 0) {
|
||||
return { ok: false as const, error: `Impossible : ${linesCount} ligne(s) de réservation pointe(nt) sur cet item.` };
|
||||
}
|
||||
await prisma.rentalItem.delete({ where: { id } });
|
||||
await recordAudit({
|
||||
scope: "admin.rental-items",
|
||||
event: "delete",
|
||||
target: id,
|
||||
actorEmail: session?.user?.email ?? null,
|
||||
details: {},
|
||||
});
|
||||
revalidatePath("/admin/rental-items");
|
||||
redirect("/admin/rental-items");
|
||||
}
|
||||
31
src/app/admin/rental-items/new/page.tsx
Normal file
31
src/app/admin/rental-items/new/page.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import Link from "next/link";
|
||||
import { ItemForm } from "../_components/ItemForm";
|
||||
import { createRentalItemAction } from "../actions";
|
||||
import { listProvidersForSelect } from "@/lib/admin/rental-items";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PageProps = { searchParams: Promise<{ providerId?: string }> };
|
||||
|
||||
export default async function NewRentalItemPage({ searchParams }: PageProps) {
|
||||
const sp = await searchParams;
|
||||
const providers = await listProvidersForSelect();
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
<header className="mt-2">
|
||||
<Link href="/admin/rental-items" className="text-xs text-zinc-500 hover:text-zinc-900">
|
||||
← Tous les items
|
||||
</Link>
|
||||
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">Nouvel item locable</h1>
|
||||
</header>
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||
<ItemForm
|
||||
providers={providers}
|
||||
action={createRentalItemAction}
|
||||
submitLabel="Créer l'item"
|
||||
initial={{ providerId: sp.providerId, active: true, totalQty: 1 }}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
src/app/admin/rental-items/page.tsx
Normal file
152
src/app/admin/rental-items/page.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import Link from "next/link";
|
||||
import { RentalCategory } from "@/generated/prisma/enums";
|
||||
import {
|
||||
RENTAL_CATEGORY_LABEL,
|
||||
isRentalCategory,
|
||||
listProvidersForSelect,
|
||||
listRentalItemsAdmin,
|
||||
} from "@/lib/admin/rental-items";
|
||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<{
|
||||
q?: string;
|
||||
category?: string;
|
||||
providerId?: string;
|
||||
active?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function RentalItemsAdminPage({ searchParams }: PageProps) {
|
||||
const sp = await searchParams;
|
||||
const filters = {
|
||||
q: sp.q?.trim() || undefined,
|
||||
category: sp.category && isRentalCategory(sp.category) ? sp.category : undefined,
|
||||
providerId: sp.providerId || undefined,
|
||||
active: sp.active === "yes" || sp.active === "no" ? (sp.active as "yes" | "no") : undefined,
|
||||
};
|
||||
const [rows, providers] = await Promise.all([listRentalItemsAdmin(filters), listProvidersForSelect()]);
|
||||
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<header className="mb-5 mt-2 flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-zinc-900">Catalogue d'items locables</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
{rows.length} item{rows.length > 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/rental-items/new"
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-semibold text-white hover:bg-zinc-800"
|
||||
>
|
||||
+ Nouvel item
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
defaultValue={filters.q ?? ""}
|
||||
placeholder="Recherche nom, description…"
|
||||
className="flex-1 min-w-[220px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
||||
/>
|
||||
<select
|
||||
name="category"
|
||||
defaultValue={filters.category ?? ""}
|
||||
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
||||
>
|
||||
<option value="">Toutes catégories</option>
|
||||
{Object.values(RentalCategory).map((c) => (
|
||||
<option key={c} value={c}>{RENTAL_CATEGORY_LABEL[c]}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
name="providerId"
|
||||
defaultValue={filters.providerId ?? ""}
|
||||
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
||||
>
|
||||
<option value="">Tous prestataires</option>
|
||||
{providers.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
name="active"
|
||||
defaultValue={filters.active ?? ""}
|
||||
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
||||
>
|
||||
<option value="">Actifs + inactifs</option>
|
||||
<option value="yes">Actifs</option>
|
||||
<option value="no">Inactifs</option>
|
||||
</select>
|
||||
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
|
||||
Filtrer
|
||||
</button>
|
||||
{(filters.q || filters.category || filters.providerId || filters.active) ? (
|
||||
<Link href="/admin/rental-items" className="text-sm text-zinc-500 hover:text-zinc-900">Réinit.</Link>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-semibold">Nom</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">Catégorie</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">Prestataire</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">€ / jour</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Stock</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Caution</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">État</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">MAJ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100">
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-sm text-zinc-500">
|
||||
Aucun item.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{rows.map((i) => (
|
||||
<tr key={i.id} className="hover:bg-zinc-50">
|
||||
<td className="px-4 py-2">
|
||||
<Link href={`/admin/rental-items/${i.id}`} className="font-medium text-zinc-900 hover:underline">
|
||||
{i.name}
|
||||
</Link>
|
||||
<div className="text-[11px] text-zinc-500">
|
||||
{i.withMotor ? "⚙️ moteur · " : ""}
|
||||
{i.requiresLicense ? "🪪 permis · " : ""}
|
||||
{i.fuelIncluded ? "⛽ essence · " : ""}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-zinc-700">{RENTAL_CATEGORY_LABEL[i.category]}</td>
|
||||
<td className="px-4 py-2">
|
||||
<Link href={`/admin/rental-providers/${i.providerId}`} className="text-zinc-900 hover:underline">
|
||||
{i.providerName}
|
||||
</Link>
|
||||
{i.providerIsSystemD ? (
|
||||
<span className="ml-1 rounded-full bg-emerald-100 px-1 py-0 text-[9px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
|
||||
SD
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">{Number(i.pricePerDay).toFixed(0)}</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">{i.totalQty}</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">{Number(i.deposit).toFixed(0)}</td>
|
||||
<td className="px-4 py-2"><StatusBadge status={i.active ? "ACTIVE" : "INACTIVE"} /></td>
|
||||
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">{dateFmt.format(i.updatedAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type Props = {
|
||||
approved: boolean;
|
||||
active: boolean;
|
||||
itemsCount: number;
|
||||
approveAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined>;
|
||||
toggleActiveAction: (active: boolean) => Promise<{ ok: true } | { ok: false; error: string } | undefined>;
|
||||
deleteAction: () => Promise<{ ok: true } | { ok: false; error: string } | undefined | void>;
|
||||
};
|
||||
|
||||
export function ProviderInlineActions({
|
||||
approved,
|
||||
active,
|
||||
itemsCount,
|
||||
approveAction,
|
||||
toggleActiveAction,
|
||||
deleteAction,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function approve() {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const res = await approveAction();
|
||||
if (res && (res as { ok?: boolean }).ok === false) setError((res as { error: string }).error);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
function toggle() {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const res = await toggleActiveAction(!active);
|
||||
if (res && (res as { ok?: boolean }).ok === false) setError((res as { error: string }).error);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
function del() {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
const res = await deleteAction();
|
||||
if (res && (res as { ok?: boolean }).ok === false) {
|
||||
setError((res as { error: string }).error);
|
||||
setConfirmDelete(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!approved ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={approve}
|
||||
disabled={pending}
|
||||
className="rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
✓ Approuver
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
disabled={pending}
|
||||
className={
|
||||
active
|
||||
? "rounded-md border border-amber-300 bg-amber-50 px-3 py-1.5 text-xs font-semibold text-amber-800 hover:bg-amber-100 disabled:opacity-50"
|
||||
: "rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
}
|
||||
>
|
||||
{active ? "Désactiver" : "Réactiver"}
|
||||
</button>
|
||||
{itemsCount === 0 ? (
|
||||
confirmDelete ? (
|
||||
<div className="flex items-center gap-2 rounded border border-rose-300 bg-rose-50 px-2 py-1">
|
||||
<span className="text-xs text-rose-900">Supprimer ?</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={del}
|
||||
disabled={pending}
|
||||
className="rounded bg-rose-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-rose-800 disabled:opacity-50"
|
||||
>
|
||||
Oui
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(false)}
|
||||
disabled={pending}
|
||||
className="text-[11px] text-zinc-500 hover:text-zinc-900"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
disabled={pending}
|
||||
className="rounded-md border border-rose-300 bg-rose-50 px-3 py-1.5 text-xs font-semibold text-rose-700 hover:bg-rose-100 disabled:opacity-50"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<span className="rounded border border-zinc-200 bg-zinc-50 px-2 py-1 text-[11px] text-zinc-500">
|
||||
{itemsCount} item(s) — supprimez-les d'abord
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{error ? <div className="rounded border border-rose-200 bg-rose-50 px-2 py-1 text-xs text-rose-700">{error}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
src/app/admin/rental-providers/[id]/page.tsx
Normal file
136
src/app/admin/rental-providers/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
||||
import { getRentalProviderForAdmin } from "@/lib/admin/rental-providers";
|
||||
import { RENTAL_CATEGORY_LABEL } from "@/lib/admin/rental-items";
|
||||
|
||||
import { ProviderForm } from "../_components/ProviderForm";
|
||||
import { ProviderInlineActions } from "./_components/ProviderInlineActions";
|
||||
import {
|
||||
approveRentalProviderAction,
|
||||
deleteRentalProviderAction,
|
||||
toggleRentalProviderActiveAction,
|
||||
updateRentalProviderAction,
|
||||
} from "../actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PageProps = { params: Promise<{ id: string }> };
|
||||
|
||||
export default async function EditRentalProviderPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const p = await getRentalProviderForAdmin(id);
|
||||
if (!p) notFound();
|
||||
|
||||
const updateThis = async (fd: FormData) => {
|
||||
"use server";
|
||||
return await updateRentalProviderAction(id, fd);
|
||||
};
|
||||
const approveThis = async () => {
|
||||
"use server";
|
||||
return await approveRentalProviderAction(id);
|
||||
};
|
||||
const toggleActiveThis = async (active: boolean) => {
|
||||
"use server";
|
||||
return await toggleRentalProviderActiveAction(id, active);
|
||||
};
|
||||
const deleteThis = async () => {
|
||||
"use server";
|
||||
return await deleteRentalProviderAction(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-6">
|
||||
<header className="mt-2 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<Link href="/admin/rental-providers" className="text-xs text-zinc-500 hover:text-zinc-900">
|
||||
← Tous les prestataires
|
||||
</Link>
|
||||
<h1 className="mt-1 flex items-center gap-3 text-2xl font-semibold text-zinc-900">
|
||||
{p.name}
|
||||
{p.isSystemD ? (
|
||||
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
|
||||
System D
|
||||
</span>
|
||||
) : null}
|
||||
<StatusBadge status={p.active ? "ACTIVE" : "INACTIVE"} />
|
||||
{p.approved ? (
|
||||
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
|
||||
Approuvé
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-800 ring-1 ring-inset ring-amber-300">
|
||||
En attente
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
Fleuves : {p.rivers.join(", ") || "—"} · {p._count.items} item(s) · {p._count.rentalBookings} réservation(s) · Commission {Number(p.commissionPct).toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<ProviderInlineActions
|
||||
approved={p.approved}
|
||||
active={p.active}
|
||||
itemsCount={p._count.items}
|
||||
approveAction={approveThis}
|
||||
toggleActiveAction={toggleActiveThis}
|
||||
deleteAction={deleteThis}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wider text-zinc-500">Informations</h2>
|
||||
<ProviderForm
|
||||
action={updateThis}
|
||||
submitLabel="Enregistrer"
|
||||
initial={{
|
||||
name: p.name,
|
||||
isSystemD: p.isSystemD,
|
||||
contactEmail: p.contactEmail,
|
||||
contactPhone: p.contactPhone,
|
||||
rivers: p.rivers,
|
||||
description: p.description,
|
||||
commissionPct: p.commissionPct.toString(),
|
||||
active: p.active,
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||
<h2 className="mb-4 flex items-center justify-between text-sm font-semibold uppercase tracking-wider text-zinc-500">
|
||||
<span>Items ({p.items.length})</span>
|
||||
<Link href={`/admin/rental-items?providerId=${p.id}`} className="text-xs normal-case tracking-normal text-zinc-700 underline hover:text-zinc-900">
|
||||
Voir tous les items
|
||||
</Link>
|
||||
</h2>
|
||||
{p.items.length === 0 ? (
|
||||
<p className="text-sm text-zinc-500">
|
||||
Pas encore d'item.{" "}
|
||||
<Link href={`/admin/rental-items/new?providerId=${p.id}`} className="text-zinc-900 underline">
|
||||
Créer un premier item
|
||||
</Link>
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-zinc-100">
|
||||
{p.items.map((i) => (
|
||||
<li key={i.id} className="flex items-center justify-between gap-3 py-2 text-sm">
|
||||
<Link href={`/admin/rental-items/${i.id}`} className="text-zinc-900 hover:underline">
|
||||
{i.name}
|
||||
<span className="ml-2 text-[11px] text-zinc-500">
|
||||
{RENTAL_CATEGORY_LABEL[i.category]}
|
||||
</span>
|
||||
</Link>
|
||||
<span className="flex items-center gap-3">
|
||||
<span className="font-mono text-xs text-zinc-700">{Number(i.pricePerDay).toFixed(0)} €/j</span>
|
||||
<span className="text-[11px] text-zinc-500">qty {i.totalQty}</span>
|
||||
<StatusBadge status={i.active ? "ACTIVE" : "INACTIVE"} />
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
src/app/admin/rental-providers/_components/ProviderForm.tsx
Normal file
132
src/app/admin/rental-providers/_components/ProviderForm.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { FormField, inputCls, textareaCls } from "@/components/admin/FormField";
|
||||
|
||||
type Props = {
|
||||
initial?: {
|
||||
name?: string;
|
||||
isSystemD?: boolean;
|
||||
contactEmail?: string | null;
|
||||
contactPhone?: string | null;
|
||||
rivers?: string[];
|
||||
description?: string | null;
|
||||
commissionPct?: number | string;
|
||||
active?: boolean;
|
||||
};
|
||||
action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
|
||||
submitLabel?: string;
|
||||
};
|
||||
|
||||
export function ProviderForm({ initial = {}, action, submitLabel = "Enregistrer" }: Props) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
function onSubmit(fd: FormData) {
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
startTransition(async () => {
|
||||
const res = await action(fd);
|
||||
if (res && res.ok === false) setError(res.error);
|
||||
else if (res && res.ok === true) setSuccess("Enregistré.");
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={onSubmit} className="space-y-4">
|
||||
<fieldset disabled={pending} className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<FormField label="Nom du prestataire" required>
|
||||
<input name="name" defaultValue={initial.name ?? ""} required maxLength={200} className={inputCls} />
|
||||
</FormField>
|
||||
<FormField label="Type">
|
||||
<label className="flex items-center gap-2 px-1 py-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="isSystemD"
|
||||
defaultChecked={initial.isSystemD ?? false}
|
||||
className="h-4 w-4 rounded border-zinc-300"
|
||||
/>
|
||||
Fournisseur officiel System D (0 % commission)
|
||||
</label>
|
||||
</FormField>
|
||||
<FormField label="Email contact">
|
||||
<input
|
||||
name="contactEmail"
|
||||
type="email"
|
||||
defaultValue={initial.contactEmail ?? ""}
|
||||
maxLength={200}
|
||||
className={inputCls}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Téléphone contact">
|
||||
<input
|
||||
name="contactPhone"
|
||||
defaultValue={initial.contactPhone ?? ""}
|
||||
maxLength={50}
|
||||
className={inputCls}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Commission (%)" hint="0 pour System D, 5-15 % pour les prestataires externes.">
|
||||
<input
|
||||
name="commissionPct"
|
||||
type="number"
|
||||
min={0}
|
||||
max={50}
|
||||
step="0.5"
|
||||
defaultValue={initial.commissionPct?.toString() ?? "10"}
|
||||
className={inputCls}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Statut">
|
||||
<label className="flex items-center gap-2 px-1 py-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="active"
|
||||
defaultChecked={initial.active ?? true}
|
||||
className="h-4 w-4 rounded border-zinc-300"
|
||||
/>
|
||||
Actif
|
||||
</label>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField label="Fleuves desservis" required hint="Séparer par virgule, point-virgule ou retour à la ligne.">
|
||||
<input
|
||||
name="rivers"
|
||||
defaultValue={(initial.rivers ?? []).join(", ")}
|
||||
placeholder="Maroni, Approuague, Oyapock"
|
||||
className={inputCls}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Description" hint="Présentation, points forts, conditions particulières.">
|
||||
<textarea
|
||||
name="description"
|
||||
rows={4}
|
||||
defaultValue={initial.description ?? ""}
|
||||
maxLength={5000}
|
||||
className={textareaCls}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
|
||||
) : null}
|
||||
{success ? (
|
||||
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-zinc-900 px-5 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
|
||||
>
|
||||
{pending ? "Enregistrement…" : submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
150
src/app/admin/rental-providers/actions.ts
Normal file
150
src/app/admin/rental-providers/actions.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { UserRole } from "@/generated/prisma/enums";
|
||||
import { requireRole } from "@/lib/authorization";
|
||||
import { recordAudit } from "@/lib/admin/audit";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const providerSchema = z.object({
|
||||
name: z.string().trim().min(2).max(200),
|
||||
isSystemD: z.boolean(),
|
||||
managedByUserId: z.string().nullable().optional(),
|
||||
contactEmail: z.string().trim().email().max(200).nullable().optional(),
|
||||
contactPhone: z.string().trim().max(50).nullable().optional(),
|
||||
rivers: z.array(z.string().trim().min(1).max(80)).max(20),
|
||||
description: z.string().trim().max(5000).nullable().optional(),
|
||||
commissionPct: z.coerce.number().min(0).max(50),
|
||||
active: z.boolean(),
|
||||
});
|
||||
|
||||
function parseFD(fd: FormData) {
|
||||
const riversRaw = (fd.get("rivers") as string | null) ?? "";
|
||||
const rivers = riversRaw
|
||||
.split(/[,;\n]/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
const get = (k: string) => {
|
||||
const v = (fd.get(k) as string | null) ?? "";
|
||||
return v.trim() === "" ? null : v.trim();
|
||||
};
|
||||
return {
|
||||
name: ((fd.get("name") as string | null) ?? "").trim(),
|
||||
isSystemD: fd.get("isSystemD") === "on",
|
||||
managedByUserId: get("managedByUserId"),
|
||||
contactEmail: get("contactEmail"),
|
||||
contactPhone: get("contactPhone"),
|
||||
rivers,
|
||||
description: get("description"),
|
||||
commissionPct: fd.get("commissionPct"),
|
||||
active: fd.get("active") === "on",
|
||||
};
|
||||
}
|
||||
|
||||
export async function createRentalProviderAction(fd: FormData) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const parsed = providerSchema.safeParse(parseFD(fd));
|
||||
if (!parsed.success) {
|
||||
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
|
||||
}
|
||||
const session = await auth();
|
||||
const created = await prisma.rentalProvider.create({
|
||||
data: {
|
||||
...parsed.data,
|
||||
approved: true, // créé par admin → approuvé d'office
|
||||
approvedAt: new Date(),
|
||||
approvedBy: session?.user?.email ?? null,
|
||||
},
|
||||
});
|
||||
await recordAudit({
|
||||
scope: "admin.rental-providers",
|
||||
event: "create",
|
||||
target: created.id,
|
||||
actorEmail: session?.user?.email ?? null,
|
||||
details: { name: created.name, isSystemD: created.isSystemD },
|
||||
});
|
||||
revalidatePath("/admin/rental-providers");
|
||||
redirect(`/admin/rental-providers/${created.id}`);
|
||||
}
|
||||
|
||||
export async function updateRentalProviderAction(id: string, fd: FormData) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const parsed = providerSchema.safeParse(parseFD(fd));
|
||||
if (!parsed.success) {
|
||||
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
|
||||
}
|
||||
const session = await auth();
|
||||
await prisma.rentalProvider.update({ where: { id }, data: parsed.data });
|
||||
await recordAudit({
|
||||
scope: "admin.rental-providers",
|
||||
event: "update",
|
||||
target: id,
|
||||
actorEmail: session?.user?.email ?? null,
|
||||
details: { name: parsed.data.name },
|
||||
});
|
||||
revalidatePath("/admin/rental-providers");
|
||||
revalidatePath(`/admin/rental-providers/${id}`);
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function approveRentalProviderAction(id: string) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const session = await auth();
|
||||
await prisma.rentalProvider.update({
|
||||
where: { id },
|
||||
data: {
|
||||
approved: true,
|
||||
approvedAt: new Date(),
|
||||
approvedBy: session?.user?.email ?? null,
|
||||
},
|
||||
});
|
||||
await recordAudit({
|
||||
scope: "admin.rental-providers",
|
||||
event: "approve",
|
||||
target: id,
|
||||
actorEmail: session?.user?.email ?? null,
|
||||
details: {},
|
||||
});
|
||||
revalidatePath("/admin/rental-providers");
|
||||
revalidatePath(`/admin/rental-providers/${id}`);
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function toggleRentalProviderActiveAction(id: string, active: boolean) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const session = await auth();
|
||||
await prisma.rentalProvider.update({ where: { id }, data: { active } });
|
||||
await recordAudit({
|
||||
scope: "admin.rental-providers",
|
||||
event: "active.update",
|
||||
target: id,
|
||||
actorEmail: session?.user?.email ?? null,
|
||||
details: { active },
|
||||
});
|
||||
revalidatePath("/admin/rental-providers");
|
||||
revalidatePath(`/admin/rental-providers/${id}`);
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
export async function deleteRentalProviderAction(id: string) {
|
||||
await requireRole([UserRole.ADMIN]);
|
||||
const session = await auth();
|
||||
const itemsCount = await prisma.rentalItem.count({ where: { providerId: id } });
|
||||
if (itemsCount > 0) {
|
||||
return { ok: false as const, error: `Impossible : ${itemsCount} item(s) attaché(s).` };
|
||||
}
|
||||
await prisma.rentalProvider.delete({ where: { id } });
|
||||
await recordAudit({
|
||||
scope: "admin.rental-providers",
|
||||
event: "delete",
|
||||
target: id,
|
||||
actorEmail: session?.user?.email ?? null,
|
||||
details: {},
|
||||
});
|
||||
revalidatePath("/admin/rental-providers");
|
||||
redirect("/admin/rental-providers");
|
||||
}
|
||||
21
src/app/admin/rental-providers/new/page.tsx
Normal file
21
src/app/admin/rental-providers/new/page.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import Link from "next/link";
|
||||
import { ProviderForm } from "../_components/ProviderForm";
|
||||
import { createRentalProviderAction } from "../actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function NewRentalProviderPage() {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
<header className="mt-2">
|
||||
<Link href="/admin/rental-providers" className="text-xs text-zinc-500 hover:text-zinc-900">
|
||||
← Tous les prestataires
|
||||
</Link>
|
||||
<h1 className="mt-1 text-2xl font-semibold text-zinc-900">Nouveau prestataire location</h1>
|
||||
</header>
|
||||
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
|
||||
<ProviderForm action={createRentalProviderAction} submitLabel="Créer le prestataire" />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
149
src/app/admin/rental-providers/page.tsx
Normal file
149
src/app/admin/rental-providers/page.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import Link from "next/link";
|
||||
import { listRentalProvidersAdmin, listRentalProviderRivers } from "@/lib/admin/rental-providers";
|
||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<{
|
||||
q?: string;
|
||||
approved?: string;
|
||||
active?: string;
|
||||
river?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function RentalProvidersAdminPage({ searchParams }: PageProps) {
|
||||
const sp = await searchParams;
|
||||
const filters = {
|
||||
q: sp.q?.trim() || undefined,
|
||||
approved: sp.approved === "yes" || sp.approved === "no" ? (sp.approved as "yes" | "no") : undefined,
|
||||
active: sp.active === "yes" || sp.active === "no" ? (sp.active as "yes" | "no") : undefined,
|
||||
river: sp.river || undefined,
|
||||
};
|
||||
const [rows, rivers] = await Promise.all([listRentalProvidersAdmin(filters), listRentalProviderRivers()]);
|
||||
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<header className="mb-5 mt-2 flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-zinc-900">Prestataires location matériel</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
{rows.length} résultat{rows.length > 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/rental-providers/new"
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-semibold text-white hover:bg-zinc-800"
|
||||
>
|
||||
+ Nouveau prestataire
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
defaultValue={filters.q ?? ""}
|
||||
placeholder="Recherche nom, email, description…"
|
||||
className="flex-1 min-w-[220px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
||||
/>
|
||||
<select
|
||||
name="approved"
|
||||
defaultValue={filters.approved ?? ""}
|
||||
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
||||
>
|
||||
<option value="">Tous statuts approbation</option>
|
||||
<option value="yes">Approuvés</option>
|
||||
<option value="no">En attente</option>
|
||||
</select>
|
||||
<select
|
||||
name="active"
|
||||
defaultValue={filters.active ?? ""}
|
||||
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
||||
>
|
||||
<option value="">Actifs + inactifs</option>
|
||||
<option value="yes">Actifs</option>
|
||||
<option value="no">Inactifs</option>
|
||||
</select>
|
||||
<select
|
||||
name="river"
|
||||
defaultValue={filters.river ?? ""}
|
||||
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
||||
>
|
||||
<option value="">Tous fleuves</option>
|
||||
{rivers.map((r) => (
|
||||
<option key={r} value={r}>{r}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
|
||||
Filtrer
|
||||
</button>
|
||||
{(filters.q || filters.approved || filters.active || filters.river) ? (
|
||||
<Link href="/admin/rental-providers" className="text-sm text-zinc-500 hover:text-zinc-900">
|
||||
Réinit.
|
||||
</Link>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-semibold">Nom</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">Fleuves</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Items</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Comm.</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">Approbation</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">État</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">MAJ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100">
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-sm text-zinc-500">
|
||||
Aucun prestataire ne correspond aux filtres.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{rows.map((p) => (
|
||||
<tr key={p.id} className="hover:bg-zinc-50">
|
||||
<td className="px-4 py-2">
|
||||
<Link href={`/admin/rental-providers/${p.id}`} className="font-medium text-zinc-900 hover:underline">
|
||||
{p.name}
|
||||
</Link>
|
||||
{p.isSystemD ? (
|
||||
<span className="ml-2 rounded-full bg-emerald-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
|
||||
System D
|
||||
</span>
|
||||
) : null}
|
||||
<div className="text-[11px] text-zinc-500">{p.contactEmail ?? "—"}</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-zinc-700">
|
||||
{p.rivers.length === 0 ? <span className="text-zinc-400">—</span> : p.rivers.join(", ")}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">{p.itemsCount}</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-zinc-700">{Number(p.commissionPct).toFixed(1)}%</td>
|
||||
<td className="px-4 py-2">
|
||||
{p.approved ? (
|
||||
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-800 ring-1 ring-inset ring-emerald-300">
|
||||
Approuvé
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-800 ring-1 ring-inset ring-amber-300">
|
||||
En attente
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2"><StatusBadge status={p.active ? "ACTIVE" : "INACTIVE"} /></td>
|
||||
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">{dateFmt.format(p.updatedAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
src/app/admin/rentals/page.tsx
Normal file
141
src/app/admin/rentals/page.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import Link from "next/link";
|
||||
import { PaymentStatus, RentalBookingStatus } from "@/generated/prisma/enums";
|
||||
import { listRentalBookingsAdmin, RENTAL_STATUS_LABEL } from "@/lib/admin/rental-bookings";
|
||||
import { StatusBadge } from "@/components/admin/StatusBadge";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<{
|
||||
q?: string;
|
||||
status?: string;
|
||||
paymentStatus?: string;
|
||||
providerId?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const RENTAL_STATUS_VALUES = new Set<string>([
|
||||
RentalBookingStatus.PENDING,
|
||||
RentalBookingStatus.CONFIRMED,
|
||||
RentalBookingStatus.HANDED_OVER,
|
||||
RentalBookingStatus.RETURNED,
|
||||
RentalBookingStatus.CANCELLED,
|
||||
]);
|
||||
|
||||
const PAYMENT_VALUES = new Set<string>([
|
||||
PaymentStatus.PENDING,
|
||||
PaymentStatus.AUTHORIZED,
|
||||
PaymentStatus.SUCCEEDED,
|
||||
PaymentStatus.FAILED,
|
||||
PaymentStatus.REFUNDED,
|
||||
]);
|
||||
|
||||
export default async function RentalsAdminPage({ searchParams }: PageProps) {
|
||||
const sp = await searchParams;
|
||||
const filters = {
|
||||
q: sp.q?.trim() || undefined,
|
||||
status: RENTAL_STATUS_VALUES.has(sp.status ?? "") ? (sp.status as RentalBookingStatus) : undefined,
|
||||
paymentStatus: PAYMENT_VALUES.has(sp.paymentStatus ?? "") ? (sp.paymentStatus as PaymentStatus) : undefined,
|
||||
providerId: sp.providerId || undefined,
|
||||
};
|
||||
const rows = await listRentalBookingsAdmin(filters);
|
||||
const dateFmt = new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short", year: "2-digit" });
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<header className="mb-5 mt-2">
|
||||
<h1 className="text-2xl font-semibold text-zinc-900">Réservations matériel</h1>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
{rows.length} résultat{rows.length > 1 ? "s" : ""}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
defaultValue={filters.q ?? ""}
|
||||
placeholder="Recherche ID, email locataire, prestataire…"
|
||||
className="flex-1 min-w-[200px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
|
||||
/>
|
||||
<select name="status" defaultValue={filters.status ?? ""} className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none">
|
||||
<option value="">Tous statuts</option>
|
||||
{Object.values(RentalBookingStatus).map((s) => (
|
||||
<option key={s} value={s}>{RENTAL_STATUS_LABEL[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
<select name="paymentStatus" defaultValue={filters.paymentStatus ?? ""} className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none">
|
||||
<option value="">Tous paiements</option>
|
||||
{Object.values(PaymentStatus).map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
|
||||
Filtrer
|
||||
</button>
|
||||
{(filters.q || filters.status || filters.paymentStatus) ? (
|
||||
<Link href="/admin/rentals" className="text-sm text-zinc-500 hover:text-zinc-900">Réinit.</Link>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-semibold">ID</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">Locataire</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">Prestataire</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">Items</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">Période</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Montant</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">Statut</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">Paiement</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100">
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-8 text-center text-sm text-zinc-500">
|
||||
Aucune réservation matériel.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id} className="hover:bg-zinc-50">
|
||||
<td className="px-4 py-2 font-mono text-[11px] text-zinc-700">{r.id.slice(0, 10)}…</td>
|
||||
<td className="px-4 py-2 text-zinc-700">
|
||||
{r.tenant.firstName} {r.tenant.lastName}
|
||||
<div className="text-[11px] text-zinc-500">{r.tenant.email}</div>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<Link href={`/admin/rental-providers/${r.provider.id}`} className="text-zinc-900 hover:underline">
|
||||
{r.provider.name}
|
||||
</Link>
|
||||
{r.provider.isSystemD ? <span className="ml-1 text-[9px] font-semibold text-emerald-700">SD</span> : null}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-zinc-700">
|
||||
{r.lines.length} ligne{r.lines.length > 1 ? "s" : ""}
|
||||
<div className="text-[11px] text-zinc-500 truncate max-w-[200px]">
|
||||
{r.lines.map((l) => `${l.qty}× ${l.item.name}`).join(", ")}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-zinc-700">
|
||||
{dateFmt.format(r.startDate)} → {dateFmt.format(r.endDate)}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right font-mono text-zinc-900">
|
||||
{Number(r.amount).toFixed(2)} {r.currency}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<StatusBadge status={r.status} />
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<StatusBadge status={r.paymentStatus} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -115,6 +115,8 @@ const GROUPS: NavGroup[] = [
|
|||
items: [
|
||||
{ href: "/admin/carbets", label: "Carbets", icon: ICONS.carbets },
|
||||
{ href: "/admin/pirogue-providers", label: "Prestataires pirogue", icon: ICONS.pirogue },
|
||||
{ href: "/admin/rental-providers", label: "Prestataires matériel", icon: ICONS.pirogue },
|
||||
{ href: "/admin/rental-items", label: "Items locables", icon: ICONS.media },
|
||||
{ href: "/admin/media", label: "Médias", icon: ICONS.media },
|
||||
],
|
||||
},
|
||||
|
|
@ -122,6 +124,7 @@ const GROUPS: NavGroup[] = [
|
|||
label: "Activité",
|
||||
items: [
|
||||
{ href: "/admin/bookings", label: "Réservations", icon: ICONS.bookings },
|
||||
{ href: "/admin/rentals", label: "Locations matériel", icon: ICONS.bookings },
|
||||
{ href: "/admin/reviews", label: "Avis & modération", icon: ICONS.reviews },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
60
src/lib/admin/rental-bookings.ts
Normal file
60
src/lib/admin/rental-bookings.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import "server-only";
|
||||
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { RentalBookingStatus, PaymentStatus } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export type AdminRentalBookingFilters = {
|
||||
q?: string;
|
||||
status?: RentalBookingStatus;
|
||||
paymentStatus?: PaymentStatus;
|
||||
providerId?: string;
|
||||
};
|
||||
|
||||
export const RENTAL_STATUS_LABEL: Record<RentalBookingStatus, string> = {
|
||||
PENDING: "En attente",
|
||||
CONFIRMED: "Confirmée",
|
||||
HANDED_OVER: "Remis client",
|
||||
RETURNED: "Retourné",
|
||||
CANCELLED: "Annulée",
|
||||
};
|
||||
|
||||
export async function listRentalBookingsAdmin(
|
||||
filters: AdminRentalBookingFilters = {},
|
||||
) {
|
||||
const where: Prisma.RentalBookingWhereInput = {};
|
||||
if (filters.q) {
|
||||
where.OR = [
|
||||
{ id: { contains: filters.q, mode: "insensitive" } },
|
||||
{ tenant: { email: { contains: filters.q, mode: "insensitive" } } },
|
||||
{ provider: { name: { contains: filters.q, mode: "insensitive" } } },
|
||||
];
|
||||
}
|
||||
if (filters.status) where.status = filters.status;
|
||||
if (filters.paymentStatus) where.paymentStatus = filters.paymentStatus;
|
||||
if (filters.providerId) where.providerId = filters.providerId;
|
||||
|
||||
return prisma.rentalBooking.findMany({
|
||||
where,
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
take: 200,
|
||||
include: {
|
||||
tenant: { select: { id: true, firstName: true, lastName: true, email: true } },
|
||||
provider: { select: { id: true, name: true, isSystemD: true } },
|
||||
booking: { select: { id: true, carbet: { select: { title: true, slug: true } } } },
|
||||
lines: { include: { item: { select: { name: true, category: true } } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getRentalBookingForAdmin(id: string) {
|
||||
return prisma.rentalBooking.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
tenant: { select: { id: true, firstName: true, lastName: true, email: true, phone: true } },
|
||||
provider: { select: { id: true, name: true, isSystemD: true, contactEmail: true, contactPhone: true } },
|
||||
booking: { select: { id: true, carbet: { select: { id: true, title: true, slug: true } } } },
|
||||
lines: { include: { item: { select: { id: true, name: true, category: true, imageUrl: true } } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
111
src/lib/admin/rental-items.ts
Normal file
111
src/lib/admin/rental-items.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import "server-only";
|
||||
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { RentalCategory } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const RENTAL_CATEGORY_LABEL: Record<RentalCategory, string> = {
|
||||
SLEEP: "💤 Couchage",
|
||||
NAVIGATION: "🛶 Navigation",
|
||||
FISHING: "🎣 Pêche",
|
||||
COOKING: "🍳 Cuisine",
|
||||
SAFETY: "🦺 Sécurité",
|
||||
};
|
||||
|
||||
export type AdminRentalItemFilters = {
|
||||
q?: string;
|
||||
category?: RentalCategory;
|
||||
providerId?: string;
|
||||
active?: "yes" | "no";
|
||||
};
|
||||
|
||||
export type AdminRentalItemListItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
category: RentalCategory;
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
providerIsSystemD: boolean;
|
||||
pricePerDay: string;
|
||||
pricePerWeek: string | null;
|
||||
deposit: string;
|
||||
totalQty: number;
|
||||
withMotor: boolean;
|
||||
fuelIncluded: boolean;
|
||||
requiresLicense: boolean;
|
||||
active: boolean;
|
||||
imageUrl: string | null;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
const CATEGORY_VALUES: RentalCategory[] = [
|
||||
RentalCategory.SLEEP,
|
||||
RentalCategory.NAVIGATION,
|
||||
RentalCategory.FISHING,
|
||||
RentalCategory.COOKING,
|
||||
RentalCategory.SAFETY,
|
||||
];
|
||||
|
||||
export function isRentalCategory(v: string): v is RentalCategory {
|
||||
return (CATEGORY_VALUES as string[]).includes(v);
|
||||
}
|
||||
|
||||
export async function listRentalItemsAdmin(
|
||||
filters: AdminRentalItemFilters = {},
|
||||
): Promise<AdminRentalItemListItem[]> {
|
||||
const where: Prisma.RentalItemWhereInput = {};
|
||||
if (filters.q) {
|
||||
where.OR = [
|
||||
{ name: { contains: filters.q, mode: "insensitive" } },
|
||||
{ description: { contains: filters.q, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
if (filters.category) where.category = filters.category;
|
||||
if (filters.providerId) where.providerId = filters.providerId;
|
||||
if (filters.active === "yes") where.active = true;
|
||||
if (filters.active === "no") where.active = false;
|
||||
|
||||
const rows = await prisma.rentalItem.findMany({
|
||||
where,
|
||||
orderBy: [{ category: "asc" }, { name: "asc" }],
|
||||
take: 300,
|
||||
include: {
|
||||
provider: { select: { name: true, isSystemD: true } },
|
||||
},
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
category: r.category,
|
||||
providerId: r.providerId,
|
||||
providerName: r.provider.name,
|
||||
providerIsSystemD: r.provider.isSystemD,
|
||||
pricePerDay: r.pricePerDay.toString(),
|
||||
pricePerWeek: r.pricePerWeek?.toString() ?? null,
|
||||
deposit: r.deposit.toString(),
|
||||
totalQty: r.totalQty,
|
||||
withMotor: r.withMotor,
|
||||
fuelIncluded: r.fuelIncluded,
|
||||
requiresLicense: r.requiresLicense,
|
||||
active: r.active,
|
||||
imageUrl: r.imageUrl,
|
||||
updatedAt: r.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getRentalItemForAdmin(id: string) {
|
||||
return prisma.rentalItem.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
provider: { select: { id: true, name: true, isSystemD: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function listProvidersForSelect() {
|
||||
return prisma.rentalProvider.findMany({
|
||||
where: { active: true },
|
||||
orderBy: [{ isSystemD: "desc" }, { name: "asc" }],
|
||||
select: { id: true, name: true, isSystemD: true, approved: true },
|
||||
});
|
||||
}
|
||||
106
src/lib/admin/rental-providers.ts
Normal file
106
src/lib/admin/rental-providers.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import "server-only";
|
||||
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export type AdminRentalProviderFilters = {
|
||||
q?: string;
|
||||
approved?: "yes" | "no";
|
||||
active?: "yes" | "no";
|
||||
river?: string;
|
||||
};
|
||||
|
||||
export type AdminRentalProviderListItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
isSystemD: boolean;
|
||||
contactEmail: string | null;
|
||||
contactPhone: string | null;
|
||||
rivers: string[];
|
||||
commissionPct: string;
|
||||
active: boolean;
|
||||
approved: boolean;
|
||||
itemsCount: number;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export async function listRentalProvidersAdmin(
|
||||
filters: AdminRentalProviderFilters = {},
|
||||
): Promise<AdminRentalProviderListItem[]> {
|
||||
const where: Prisma.RentalProviderWhereInput = {};
|
||||
if (filters.q) {
|
||||
where.OR = [
|
||||
{ name: { contains: filters.q, mode: "insensitive" } },
|
||||
{ contactEmail: { contains: filters.q, mode: "insensitive" } },
|
||||
{ description: { contains: filters.q, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
if (filters.approved === "yes") where.approved = true;
|
||||
if (filters.approved === "no") where.approved = false;
|
||||
if (filters.active === "yes") where.active = true;
|
||||
if (filters.active === "no") where.active = false;
|
||||
if (filters.river) where.rivers = { has: filters.river };
|
||||
|
||||
const rows = await prisma.rentalProvider.findMany({
|
||||
where,
|
||||
orderBy: [{ approved: "asc" }, { isSystemD: "desc" }, { name: "asc" }],
|
||||
take: 200,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
isSystemD: true,
|
||||
contactEmail: true,
|
||||
contactPhone: true,
|
||||
rivers: true,
|
||||
commissionPct: true,
|
||||
active: true,
|
||||
approved: true,
|
||||
updatedAt: true,
|
||||
_count: { select: { items: true } },
|
||||
},
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
isSystemD: r.isSystemD,
|
||||
contactEmail: r.contactEmail,
|
||||
contactPhone: r.contactPhone,
|
||||
rivers: r.rivers,
|
||||
commissionPct: r.commissionPct.toString(),
|
||||
active: r.active,
|
||||
approved: r.approved,
|
||||
itemsCount: r._count.items,
|
||||
updatedAt: r.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getRentalProviderForAdmin(id: string) {
|
||||
return prisma.rentalProvider.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
manager: { select: { id: true, firstName: true, lastName: true, email: true } },
|
||||
items: {
|
||||
orderBy: [{ category: "asc" }, { name: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
category: true,
|
||||
pricePerDay: true,
|
||||
totalQty: true,
|
||||
active: true,
|
||||
},
|
||||
},
|
||||
_count: { select: { items: true, rentalBookings: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function listRentalProviderRivers(): Promise<string[]> {
|
||||
const rows = await prisma.rentalProvider.findMany({
|
||||
where: { active: true, approved: true },
|
||||
select: { rivers: true },
|
||||
});
|
||||
const set = new Set<string>();
|
||||
for (const r of rows) for (const x of r.rivers) set.add(x);
|
||||
return Array.from(set).sort();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue