From 4901bb950ebc826de43adeb50b0498ed0157a896 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Tue, 2 Jun 2026 02:46:34 +0000 Subject: [PATCH 01/22] =?UTF-8?q?feat(forms):=204=20crit=C3=A8res=20op?= =?UTF-8?q?=C3=A9rationnels=20dans=20formulaires=20admin=20+=20espace=20h?= =?UTF-8?q?=C3=B4te?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/carbets/[id]/page.tsx | 4 + .../admin/carbets/_components/CarbetForm.tsx | 61 +++++++++++++ src/app/admin/carbets/actions.ts | 16 +++- .../espace-hote/carbets/[carbetId]/page.tsx | 8 ++ .../carbets/_components/carbet-form.tsx | 88 +++++++++++++++++++ src/app/espace-hote/carbets/actions.ts | 53 ++++++++++- 6 files changed, 228 insertions(+), 2 deletions(-) diff --git a/src/app/admin/carbets/[id]/page.tsx b/src/app/admin/carbets/[id]/page.tsx index 7799bef..bf7a972 100644 --- a/src/app/admin/carbets/[id]/page.tsx +++ b/src/app/admin/carbets/[id]/page.tsx @@ -94,6 +94,10 @@ export default async function EditCarbetPage({ params }: PageProps) { capacity: carbet.capacity, nightlyPrice: carbet.nightlyPrice.toString(), accessType: carbet.accessType, + roadAccess: carbet.roadAccess, + electricity: carbet.electricity, + gsmAtCarbet: carbet.gsmAtCarbet, + gsmExitDistanceKm: carbet.gsmExitDistanceKm !== null ? carbet.gsmExitDistanceKm.toString() : null, roadAccessNote: carbet.roadAccessNote, pirogueDurationMin: carbet.pirogueDurationMin, minStayNights: carbet.minStayNights, diff --git a/src/app/admin/carbets/_components/CarbetForm.tsx b/src/app/admin/carbets/_components/CarbetForm.tsx index 260996b..4ddabe8 100644 --- a/src/app/admin/carbets/_components/CarbetForm.tsx +++ b/src/app/admin/carbets/_components/CarbetForm.tsx @@ -20,6 +20,10 @@ export type CarbetFormInitial = { capacity?: number; nightlyPrice?: number | string; accessType?: string; + roadAccess?: string | null; + electricity?: string | null; + gsmAtCarbet?: boolean; + gsmExitDistanceKm?: number | string | null; roadAccessNote?: string | null; pirogueDurationMin?: number | null; minStayNights?: number | null; @@ -189,6 +193,63 @@ export function CarbetForm({ initial = {}, owners, providers, action, submitLabe + {/* Critères opérationnels */} +
+

+ Critères opérationnels +

+

+ Les 4 dealbreakers d'un séjour en carbet guyanais. Indispensable pour les filtres recherche. +

+
+ + + + + + + + + + + + + + + +
+
+ {/* Séjour & tarif */}

Séjour & tarif

diff --git a/src/app/admin/carbets/actions.ts b/src/app/admin/carbets/actions.ts index 9e2fbff..2004bd8 100644 --- a/src/app/admin/carbets/actions.ts +++ b/src/app/admin/carbets/actions.ts @@ -10,7 +10,9 @@ import { prisma } from "@/lib/prisma"; import { AccessType, CarbetStatus, + Electricity, MediaType, + RoadAccess, TransportMode, UserRole, } from "@/generated/prisma/enums"; @@ -29,6 +31,16 @@ const baseCarbetSchema = z.object({ capacity: z.coerce.number().int().min(1).max(100), nightlyPrice: z.coerce.number().min(0).max(100000), accessType: z.enum([AccessType.ROAD_AND_RIVER, AccessType.RIVER_ONLY]), + roadAccess: z + .enum([RoadAccess.NONE, RoadAccess.DRY_SEASON_ONLY, RoadAccess.ALL_YEAR]) + .optional() + .nullable(), + electricity: z + .enum([Electricity.NONE, Electricity.SOLAR, Electricity.GENERATOR_READY, Electricity.EDF]) + .optional() + .nullable(), + gsmAtCarbet: z.preprocess((v) => v === "yes" || v === true, z.boolean()), + gsmExitDistanceKm: z.coerce.number().min(0).max(50).optional().nullable(), roadAccessNote: z.string().trim().max(1000).optional().nullable(), pirogueDurationMin: z.coerce.number().int().min(0).max(1440).optional().nullable(), minStayNights: z.coerce.number().int().min(1).max(365).optional().nullable(), @@ -53,9 +65,11 @@ function parseFromFormData(fd: FormData) { if (typeof v === "string") obj[k] = v; } // Normalise les champs optionnels nullables - ["roadAccessNote", "pirogueDurationMin", "minStayNights", "maxStayNights", "minCapacity", "transportMode", "pirogueProviderId"].forEach( + ["roadAccessNote", "pirogueDurationMin", "minStayNights", "maxStayNights", "minCapacity", "transportMode", "pirogueProviderId", "roadAccess", "electricity", "gsmExitDistanceKm"].forEach( (k) => (obj[k] = normalizeNullable(obj[k] as string | null | undefined)), ); + // gsmAtCarbet : si pas posté, on garde la valeur (sera traité par preprocess Zod) + if (!("gsmAtCarbet" in obj)) obj.gsmAtCarbet = "no"; return obj; } diff --git a/src/app/espace-hote/carbets/[carbetId]/page.tsx b/src/app/espace-hote/carbets/[carbetId]/page.tsx index 2b8b069..39ae0f9 100644 --- a/src/app/espace-hote/carbets/[carbetId]/page.tsx +++ b/src/app/espace-hote/carbets/[carbetId]/page.tsx @@ -32,6 +32,10 @@ export default async function EditCarbetPage({ embarkPoint: true, pirogueDurationMin: true, capacity: true, + roadAccess: true, + electricity: true, + gsmAtCarbet: true, + gsmExitDistanceKm: true, status: true, media: { orderBy: { sortOrder: "asc" }, @@ -54,6 +58,10 @@ export default async function EditCarbetPage({ embarkPoint: carbet.embarkPoint, pirogueDurationMin: String(carbet.pirogueDurationMin), capacity: String(carbet.capacity), + roadAccess: carbet.roadAccess ?? "", + electricity: carbet.electricity ?? "", + gsmAtCarbet: carbet.gsmAtCarbet, + gsmExitDistanceKm: carbet.gsmExitDistanceKm !== null ? carbet.gsmExitDistanceKm.toString() : "", status: carbet.status, amenityKeys: carbet.amenities.map((entry) => entry.amenity.key), }; diff --git a/src/app/espace-hote/carbets/_components/carbet-form.tsx b/src/app/espace-hote/carbets/_components/carbet-form.tsx index ac2d234..3a484c6 100644 --- a/src/app/espace-hote/carbets/_components/carbet-form.tsx +++ b/src/app/espace-hote/carbets/_components/carbet-form.tsx @@ -17,6 +17,10 @@ export type CarbetFormDefaults = { embarkPoint: string; pirogueDurationMin: string; capacity: string; + roadAccess: string; + electricity: string; + gsmAtCarbet: boolean; + gsmExitDistanceKm: string; status: CarbetStatus; amenityKeys: string[]; }; @@ -216,6 +220,90 @@ export function CarbetForm({
+
+
+

+ Critères opérationnels +

+

+ Les 4 dealbreakers d'un séjour en carbet. Ces critères apparaissent + en grand sur votre fiche et alimentent les filtres recherche. +

+
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + +

+ Laissez vide si réseau au carbet +

+ +
+
+
+

Commodités

diff --git a/src/app/espace-hote/carbets/actions.ts b/src/app/espace-hote/carbets/actions.ts index 8964376..ba29ac4 100644 --- a/src/app/espace-hote/carbets/actions.ts +++ b/src/app/espace-hote/carbets/actions.ts @@ -9,7 +9,7 @@ import { prisma } from "@/lib/prisma"; import { ensureUniqueCarbetSlug } from "@/lib/slug"; import { deleteObject } from "@/lib/storage"; import { Prisma } from "@/generated/prisma/client"; -import { CarbetStatus } from "@/generated/prisma/enums"; +import { CarbetStatus, Electricity, RoadAccess } from "@/generated/prisma/enums"; import type { CarbetFormState } from "./form-types"; @@ -22,10 +22,26 @@ type ParsedCarbet = { embarkPoint: string; pirogueDurationMin: number; capacity: number; + roadAccess: RoadAccess | null; + electricity: Electricity | null; + gsmAtCarbet: boolean; + gsmExitDistanceKm: number | null; status: CarbetStatus; amenities: string[]; }; +function isRoadAccess(v: string): v is RoadAccess { + return v === RoadAccess.NONE || v === RoadAccess.DRY_SEASON_ONLY || v === RoadAccess.ALL_YEAR; +} +function isElectricity(v: string): v is Electricity { + return ( + v === Electricity.NONE || + v === Electricity.SOLAR || + v === Electricity.GENERATOR_READY || + v === Electricity.EDF + ); +} + function isCarbetStatus(value: string): value is CarbetStatus { return (Object.values(CarbetStatus) as string[]).includes(value); } @@ -107,6 +123,29 @@ function parseCarbetForm(formData: FormData): { const status = isCarbetStatus(statusRaw) ? statusRaw : CarbetStatus.DRAFT; + // Critères opérationnels + const roadAccessRaw = String(formData.get("roadAccess") ?? "").trim(); + const roadAccess = isRoadAccess(roadAccessRaw) ? roadAccessRaw : null; + + const electricityRaw = String(formData.get("electricity") ?? "").trim(); + const electricity = isElectricity(electricityRaw) ? electricityRaw : null; + + const gsmAtCarbet = String(formData.get("gsmAtCarbet") ?? "no") === "yes"; + + const gsmExitRaw = String(formData.get("gsmExitDistanceKm") ?? "").trim(); + let gsmExitDistanceKm: number | null = null; + if (gsmExitRaw) { + const n = Number(gsmExitRaw); + if (Number.isFinite(n) && n >= 0 && n <= 50) { + gsmExitDistanceKm = n; + } else { + errors.gsmExitDistanceKm = "Distance invalide (0 à 50 km)."; + } + } + + // Cohérence : si GSM au carbet, on ignore la distance + const finalGsmExitDistanceKm = gsmAtCarbet ? null : gsmExitDistanceKm; + return { data: { title, @@ -117,6 +156,10 @@ function parseCarbetForm(formData: FormData): { embarkPoint, pirogueDurationMin, capacity, + roadAccess, + electricity, + gsmAtCarbet, + gsmExitDistanceKm: finalGsmExitDistanceKm, status, amenities, }, @@ -183,6 +226,10 @@ export async function createCarbet( embarkPoint: data.embarkPoint, pirogueDurationMin: data.pirogueDurationMin, capacity: data.capacity, + roadAccess: data.roadAccess, + electricity: data.electricity, + gsmAtCarbet: data.gsmAtCarbet, + gsmExitDistanceKm: data.gsmExitDistanceKm, status: CarbetStatus.DRAFT, }, select: { id: true }, @@ -239,6 +286,10 @@ export async function updateCarbet( embarkPoint: data.embarkPoint, pirogueDurationMin: data.pirogueDurationMin, capacity: data.capacity, + roadAccess: data.roadAccess, + electricity: data.electricity, + gsmAtCarbet: data.gsmAtCarbet, + gsmExitDistanceKm: data.gsmExitDistanceKm, status: data.status, }, }); From e2f3f070faed517c8b63594a5d64817961c69945 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Tue, 2 Jun 2026 03:26:04 +0000 Subject: [PATCH 02/22] =?UTF-8?q?feat(rental):=20Sprint=20A=20=E2=80=94=20?= =?UTF-8?q?mod=C3=A8le=20Prisma=20+=20admin=20CRUD=20+=20seed=2013=20items?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 112 +++++++++++++ prisma/schema.prisma | 143 +++++++++++++++- .../[id]/_components/ItemInlineActions.tsx | 86 ++++++++++ src/app/admin/rental-items/[id]/page.tsx | 83 ++++++++++ .../rental-items/_components/ItemForm.tsx | 141 ++++++++++++++++ src/app/admin/rental-items/actions.ts | 129 +++++++++++++++ src/app/admin/rental-items/new/page.tsx | 31 ++++ src/app/admin/rental-items/page.tsx | 152 ++++++++++++++++++ .../_components/ProviderInlineActions.tsx | 120 ++++++++++++++ src/app/admin/rental-providers/[id]/page.tsx | 136 ++++++++++++++++ .../_components/ProviderForm.tsx | 132 +++++++++++++++ src/app/admin/rental-providers/actions.ts | 150 +++++++++++++++++ src/app/admin/rental-providers/new/page.tsx | 21 +++ src/app/admin/rental-providers/page.tsx | 149 +++++++++++++++++ src/app/admin/rentals/page.tsx | 141 ++++++++++++++++ src/components/admin/Sidebar.tsx | 3 + src/lib/admin/rental-bookings.ts | 60 +++++++ src/lib/admin/rental-items.ts | 111 +++++++++++++ src/lib/admin/rental-providers.ts | 106 ++++++++++++ 19 files changed, 2000 insertions(+), 6 deletions(-) create mode 100644 prisma/migrations/20260603000000_rental_marketplace/migration.sql create mode 100644 src/app/admin/rental-items/[id]/_components/ItemInlineActions.tsx create mode 100644 src/app/admin/rental-items/[id]/page.tsx create mode 100644 src/app/admin/rental-items/_components/ItemForm.tsx create mode 100644 src/app/admin/rental-items/actions.ts create mode 100644 src/app/admin/rental-items/new/page.tsx create mode 100644 src/app/admin/rental-items/page.tsx create mode 100644 src/app/admin/rental-providers/[id]/_components/ProviderInlineActions.tsx create mode 100644 src/app/admin/rental-providers/[id]/page.tsx create mode 100644 src/app/admin/rental-providers/_components/ProviderForm.tsx create mode 100644 src/app/admin/rental-providers/actions.ts create mode 100644 src/app/admin/rental-providers/new/page.tsx create mode 100644 src/app/admin/rental-providers/page.tsx create mode 100644 src/app/admin/rentals/page.tsx create mode 100644 src/lib/admin/rental-bookings.ts create mode 100644 src/lib/admin/rental-items.ts create mode 100644 src/lib/admin/rental-providers.ts diff --git a/prisma/migrations/20260603000000_rental_marketplace/migration.sql b/prisma/migrations/20260603000000_rental_marketplace/migration.sql new file mode 100644 index 0000000..65b4eb1 --- /dev/null +++ b/prisma/migrations/20260603000000_rental_marketplace/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0636340..7580413 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) +} diff --git a/src/app/admin/rental-items/[id]/_components/ItemInlineActions.tsx b/src/app/admin/rental-items/[id]/_components/ItemInlineActions.tsx new file mode 100644 index 0000000..8a6a00f --- /dev/null +++ b/src/app/admin/rental-items/[id]/_components/ItemInlineActions.tsx @@ -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(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 ( +
+
+ + {confirmDelete ? ( +
+ Supprimer ? + + +
+ ) : ( + + )} +
+ {error ?
{error}
: null} +
+ ); +} diff --git a/src/app/admin/rental-items/[id]/page.tsx b/src/app/admin/rental-items/[id]/page.tsx new file mode 100644 index 0000000..8f4dd4a --- /dev/null +++ b/src/app/admin/rental-items/[id]/page.tsx @@ -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 ( +
+
+
+ + ← Tous les items + +

+ {item.name} + +

+

+ {RENTAL_CATEGORY_LABEL[item.category]} ·{" "} + + {item.provider.name} + + {item.provider.isSystemD ? " (System D)" : ""} +

+
+ +
+ +
+ +
+
+ ); +} diff --git a/src/app/admin/rental-items/_components/ItemForm.tsx b/src/app/admin/rental-items/_components/ItemForm.tsx new file mode 100644 index 0000000..523dabc --- /dev/null +++ b/src/app/admin/rental-items/_components/ItemForm.tsx @@ -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(null); + const [success, setSuccess] = useState(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 ( +
+
+
+ + + + + + + + + + +