From dc2b07507fe23290815c6c6f1ad61f567ad8cbb8 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Tue, 2 Jun 2026 02:26:02 +0000 Subject: [PATCH 01/23] =?UTF-8?q?feat:=204=20crit=C3=A8res=20op=C3=A9ratio?= =?UTF-8?q?nnels=20(route/capacit=C3=A9/=C3=A9lectricit=C3=A9/GSM)=20+=20p?= =?UTF-8?q?resets=20profils=20+=20badges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 15 +++ prisma/schema.prisma | 18 +++ src/app/carbets/[slug]/page.tsx | 15 +++ src/app/carbets/_components/carbet-card.tsx | 14 +- .../carbets/_components/search-filters.tsx | 110 +++++++++++++++- .../carbets/_components/search-profiles.tsx | 29 +++++ src/app/carbets/page.tsx | 2 + src/components/OperationalBadges.tsx | 120 ++++++++++++++++++ src/lib/carbet-public.ts | 12 ++ src/lib/carbet-search.ts | 88 ++++++++++++- src/lib/search-profiles.ts | 79 ++++++++++++ 11 files changed, 494 insertions(+), 8 deletions(-) create mode 100644 prisma/migrations/20260602030000_operational_criteria/migration.sql create mode 100644 src/app/carbets/_components/search-profiles.tsx create mode 100644 src/components/OperationalBadges.tsx create mode 100644 src/lib/search-profiles.ts diff --git a/prisma/migrations/20260602030000_operational_criteria/migration.sql b/prisma/migrations/20260602030000_operational_criteria/migration.sql new file mode 100644 index 0000000..5bdca5f --- /dev/null +++ b/prisma/migrations/20260602030000_operational_criteria/migration.sql @@ -0,0 +1,15 @@ +CREATE TYPE "RoadAccess" AS ENUM ('NONE', 'DRY_SEASON_ONLY', 'ALL_YEAR'); +CREATE TYPE "Electricity" AS ENUM ('NONE', 'SOLAR', 'GENERATOR_READY', 'EDF'); + +ALTER TABLE "Carbet" ADD COLUMN "roadAccess" "RoadAccess"; +ALTER TABLE "Carbet" ADD COLUMN "electricity" "Electricity"; +ALTER TABLE "Carbet" ADD COLUMN "gsmAtCarbet" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "Carbet" ADD COLUMN "gsmExitDistanceKm" DECIMAL(4,2); + +-- Seed des 6 carbets démo avec valeurs réalistes +UPDATE "Carbet" SET "roadAccess" = 'NONE', "electricity" = 'SOLAR', "gsmAtCarbet" = false, "gsmExitDistanceKm" = 1.5 WHERE id = 'demo-carbet-awara'; +UPDATE "Carbet" SET "roadAccess" = 'ALL_YEAR', "electricity" = 'EDF', "gsmAtCarbet" = true WHERE id = 'demo-carbet-kourou'; +UPDATE "Carbet" SET "roadAccess" = 'ALL_YEAR', "electricity" = 'EDF', "gsmAtCarbet" = true WHERE id = 'demo-carbet-mahury'; +UPDATE "Carbet" SET "roadAccess" = 'NONE', "electricity" = 'GENERATOR_READY', "gsmAtCarbet" = false, "gsmExitDistanceKm" = 4.0 WHERE id = 'demo-carbet-maripa'; +UPDATE "Carbet" SET "roadAccess" = 'DRY_SEASON_ONLY', "electricity" = 'SOLAR', "gsmAtCarbet" = false, "gsmExitDistanceKm" = 0.5 WHERE id = 'demo-carbet-paripou'; +UPDATE "Carbet" SET "roadAccess" = 'ALL_YEAR', "electricity" = 'EDF', "gsmAtCarbet" = true WHERE id = 'demo-carbet-wapa'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 83d75c2..0636340 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -124,6 +124,11 @@ model Carbet { // Détails d'accès route pour ROAD_AND_RIVER (GPS, distance, type de piste). roadAccessNote String? capacity Int + // 4 critères opérationnels dealbreakers (dispo en filtres + badges UI) + roadAccess RoadAccess? + electricity Electricity? + gsmAtCarbet Boolean @default(false) + gsmExitDistanceKm Decimal? @db.Decimal(4, 2) // Prix par nuit pour le carbet entier (toute capacité). En euros. nightlyPrice Decimal @db.Decimal(10, 2) @default(0) // Contraintes séjour (plugin min-stay). null = pas de contrainte. @@ -381,3 +386,16 @@ model Favorite { @@index([userId]) @@index([carbetId]) } + +enum RoadAccess { + NONE + DRY_SEASON_ONLY + ALL_YEAR +} + +enum Electricity { + NONE + SOLAR + GENERATOR_READY + EDF +} diff --git a/src/app/carbets/[slug]/page.tsx b/src/app/carbets/[slug]/page.tsx index f37adae..ae53374 100644 --- a/src/app/carbets/[slug]/page.tsx +++ b/src/app/carbets/[slug]/page.tsx @@ -20,6 +20,7 @@ import { CarbetMap } from "../_components/carbet-map"; import { ReviewsSection } from "../_components/reviews-section"; import { StarRating } from "../_components/star-rating"; import { AccessTypeBadge } from "@/components/AccessTypeBadge"; +import { OperationalBadges } from "@/components/OperationalBadges"; import { StayConstraints } from "@/components/StayConstraints"; import { PirogueTransportBlock } from "@/components/PirogueTransportBlock"; @@ -131,6 +132,20 @@ export default async function PublicCarbetPage({ params }: PageProps) { +
+

+ Critères opérationnels +

+ +
+
diff --git a/src/app/carbets/_components/carbet-card.tsx b/src/app/carbets/_components/carbet-card.tsx index c11003a..f32cc8e 100644 --- a/src/app/carbets/_components/carbet-card.tsx +++ b/src/app/carbets/_components/carbet-card.tsx @@ -5,6 +5,7 @@ import { formatPirogueDuration, truncate } from "@/lib/format"; import { formatAverageRating } from "@/lib/reviews"; import { buildSrcSet } from "@/lib/image-variants"; import { AccessTypeBadge } from "@/components/AccessTypeBadge"; +import { OperationalBadges } from "@/components/OperationalBadges"; import { StayConstraints } from "@/components/StayConstraints"; import { StarRating } from "./star-rating"; @@ -41,9 +42,18 @@ export function CarbetCard({ carbet }: { carbet: CarbetSearchResult }) {

- Fleuve {carbet.river} · {carbet.capacity} voyageur - {carbet.capacity > 1 ? "s" : ""} + Fleuve {carbet.river}

+
+ +
+ + @@ -87,6 +101,98 @@ export function SearchFilters({ filters, rivers }: SearchFiltersProps) { /> +
+ Accès route +
+ {[ + { value: RoadAccess.ALL_YEAR, label: "🛣️ Route toute saison" }, + { value: RoadAccess.DRY_SEASON_ONLY, label: "🟠 Route saison sèche" }, + { value: RoadAccess.NONE, label: "🛶 Pirogue uniquement" }, + ].map((opt) => { + const checked = (filters.roadAccess ?? []).includes(opt.value); + return ( + + ); + })} +
+
+ +
+ Électricité +
+ {[ + { value: Electricity.EDF, label: "⚡ EDF / raccordé" }, + { value: Electricity.GENERATOR_READY, label: "🔌 Préinstall groupe" }, + { value: Electricity.SOLAR, label: "☀️ Solaire" }, + { value: Electricity.NONE, label: "🕯️ Aucune" }, + ].map((opt) => { + const checked = (filters.electricity ?? []).includes(opt.value); + return ( + + ); + })} +
+
+ + +
Équipements souhaités
diff --git a/src/app/carbets/_components/search-profiles.tsx b/src/app/carbets/_components/search-profiles.tsx new file mode 100644 index 0000000..cd11732 --- /dev/null +++ b/src/app/carbets/_components/search-profiles.tsx @@ -0,0 +1,29 @@ +"use client"; + +import Link from "next/link"; + +import { SEARCH_PROFILES, buildProfileUrl } from "@/lib/search-profiles"; + +export function SearchProfiles() { + return ( +
+
+ Profils de séjour +
+
    + {SEARCH_PROFILES.map((p) => ( +
  • + + {p.emoji} + {p.label} + +
  • + ))} +
+
+ ); +} diff --git a/src/app/carbets/page.tsx b/src/app/carbets/page.tsx index b700fed..512c79f 100644 --- a/src/app/carbets/page.tsx +++ b/src/app/carbets/page.tsx @@ -10,6 +10,7 @@ import { import { CarbetCard } from "./_components/carbet-card"; import { CatalogMap } from "./_components/catalog-map"; import { SearchFilters } from "./_components/search-filters"; +import { SearchProfiles } from "./_components/search-profiles"; export const metadata: Metadata = { title: "Rechercher un carbet", @@ -57,6 +58,7 @@ export default async function CarbetsSearchPage({

+
diff --git a/src/components/OperationalBadges.tsx b/src/components/OperationalBadges.tsx new file mode 100644 index 0000000..e2f3ea0 --- /dev/null +++ b/src/components/OperationalBadges.tsx @@ -0,0 +1,120 @@ +/** + * Badges opérationnels Karbé : 4 critères dealbreakers affichés en compact + * sur les cards catalog + en gros sur la fiche carbet. + * + * - Route (NONE / DRY_SEASON_ONLY / ALL_YEAR) + * - Capacité (X voyageurs max) + * - Électricité (NONE / SOLAR / GENERATOR_READY / EDF) + * - GSM (au carbet OUI / à X km / zone blanche) + */ + +import { Electricity, RoadAccess } from "@/generated/prisma/enums"; + +type Props = { + roadAccess: RoadAccess | null; + capacity: number; + electricity: Electricity | null; + gsmAtCarbet: boolean; + gsmExitDistanceKm: number | null; + /** "compact" pour les cards, "full" pour la fiche détail. */ + variant?: "compact" | "full"; +}; + +type Badge = { + emoji: string; + label: string; + tone: "good" | "neutral" | "warn"; +}; + +function roadBadge(r: RoadAccess | null): Badge { + if (r === RoadAccess.ALL_YEAR) return { emoji: "🛣️", label: "Route toute saison", tone: "good" }; + if (r === RoadAccess.DRY_SEASON_ONLY) return { emoji: "🛣️", label: "Route saison sèche", tone: "warn" }; + if (r === RoadAccess.NONE) return { emoji: "🛶", label: "Pirogue uniquement", tone: "neutral" }; + return { emoji: "🛣️", label: "Accès non précisé", tone: "neutral" }; +} + +function capacityBadge(c: number): Badge { + return { emoji: "👥", label: `${c} voyageur${c > 1 ? "s" : ""}`, tone: "neutral" }; +} + +function electricityBadge(e: Electricity | null): Badge { + if (e === Electricity.EDF) return { emoji: "⚡", label: "EDF / raccordé", tone: "good" }; + if (e === Electricity.GENERATOR_READY) return { emoji: "🔌", label: "Préinstall groupe", tone: "good" }; + if (e === Electricity.SOLAR) return { emoji: "☀️", label: "Solaire", tone: "neutral" }; + if (e === Electricity.NONE) return { emoji: "🕯️", label: "Aucune électricité", tone: "warn" }; + return { emoji: "⚡", label: "Électricité non précisée", tone: "neutral" }; +} + +function gsmBadge(atCarbet: boolean, exitKm: number | null): Badge { + if (atCarbet) return { emoji: "📶", label: "Réseau au carbet", tone: "good" }; + if (exitKm !== null) { + const tone: Badge["tone"] = exitKm <= 1 ? "neutral" : "warn"; + return { emoji: "📵", label: `Réseau à ${exitKm.toFixed(exitKm < 1 ? 1 : 0)} km`, tone }; + } + return { emoji: "📵", label: "Zone blanche", tone: "warn" }; +} + +const TONE_CLASSES_COMPACT: Record = { + good: "bg-emerald-50 text-emerald-800 ring-emerald-200", + neutral: "bg-zinc-100 text-zinc-700 ring-zinc-200", + warn: "bg-amber-50 text-amber-800 ring-amber-200", +}; + +const TONE_CLASSES_FULL: Record = { + good: "bg-emerald-50 text-emerald-900 ring-emerald-300 border-emerald-200", + neutral: "bg-white text-zinc-900 ring-zinc-300 border-zinc-200", + warn: "bg-amber-50 text-amber-900 ring-amber-300 border-amber-200", +}; + +export function OperationalBadges({ + roadAccess, + capacity, + electricity, + gsmAtCarbet, + gsmExitDistanceKm, + variant = "compact", +}: Props) { + const badges: Badge[] = [ + roadBadge(roadAccess), + capacityBadge(capacity), + electricityBadge(electricity), + gsmBadge(gsmAtCarbet, gsmExitDistanceKm), + ]; + + if (variant === "compact") { + return ( +
    + {badges.map((b, i) => ( +
  • + {b.emoji} + {b.label} +
  • + ))} +
+ ); + } + + // full : grille 2×2 pour la fiche + return ( +
    + {badges.map((b, i) => ( +
  • + {b.emoji} + {b.label} +
  • + ))} +
+ ); +} diff --git a/src/lib/carbet-public.ts b/src/lib/carbet-public.ts index 61af5c4..c09b2fb 100644 --- a/src/lib/carbet-public.ts +++ b/src/lib/carbet-public.ts @@ -28,6 +28,10 @@ export type PublicCarbetDetail = { roadAccessNote: string | null; capacity: number; nightlyPrice: string; + roadAccess: import("@/generated/prisma/enums").RoadAccess | null; + electricity: import("@/generated/prisma/enums").Electricity | null; + gsmAtCarbet: boolean; + gsmExitDistanceKm: number | null; minStayNights: number | null; maxStayNights: number | null; minCapacity: number | null; @@ -62,6 +66,10 @@ export const getPublicCarbet = cache( roadAccessNote: true, capacity: true, nightlyPrice: true, + roadAccess: true, + electricity: true, + gsmAtCarbet: true, + gsmExitDistanceKm: true, minStayNights: true, maxStayNights: true, minCapacity: true, @@ -113,6 +121,10 @@ export const getPublicCarbet = cache( roadAccessNote: carbet.roadAccessNote, capacity: carbet.capacity, nightlyPrice: carbet.nightlyPrice.toString(), + roadAccess: carbet.roadAccess, + electricity: carbet.electricity, + gsmAtCarbet: carbet.gsmAtCarbet, + gsmExitDistanceKm: carbet.gsmExitDistanceKm !== null ? Number(carbet.gsmExitDistanceKm) : null, minStayNights: carbet.minStayNights, maxStayNights: carbet.maxStayNights, minCapacity: carbet.minCapacity, diff --git a/src/lib/carbet-search.ts b/src/lib/carbet-search.ts index b2cb041..cd53126 100644 --- a/src/lib/carbet-search.ts +++ b/src/lib/carbet-search.ts @@ -5,6 +5,8 @@ import { AvailabilityBlockReason, AvailabilityScope, CarbetStatus, + Electricity, + RoadAccess, } from "@/generated/prisma/enums"; import { getCarbetReviewStatsMany } from "@/lib/reviews-server"; @@ -13,11 +15,16 @@ export type CarbetSearchFilters = { startDate?: Date; endDate?: Date; capacity?: number; - // Filtre plugin access-type : si "river-only" exclu, on garde uniquement - // ROAD_AND_RIVER. Si "all" ou non spécifié, tout passe. + capacityMax?: number; accessibility?: "road-only" | "all"; priceMax?: number; amenities?: string[]; + /** Niveaux d'accès route acceptés (multi). */ + roadAccess?: RoadAccess[]; + /** Niveaux d'électricité acceptés (multi). */ + electricity?: Electricity[]; + /** Distance max en km pour atteindre le réseau GSM. 0 = exige le réseau au carbet. */ + gsmMaxKm?: number; }; export type RawSearchParams = { @@ -71,6 +78,45 @@ export function parseSearchFilters( filters.accessibility = accessibility; } + const capacityMaxRaw = pickString(searchParams.capacityMax); + if (capacityMaxRaw) { + const cmax = Number(capacityMaxRaw); + if (Number.isInteger(cmax) && cmax > 0 && cmax <= 100) filters.capacityMax = cmax; + } + + const roadRaw = searchParams.roadAccess; + if (roadRaw) { + const arr = Array.isArray(roadRaw) ? roadRaw : [roadRaw]; + const keys = arr + .flatMap((s) => s.split(",")) + .map((s) => s.trim()) + .filter((s): s is RoadAccess => + s === RoadAccess.NONE || s === RoadAccess.DRY_SEASON_ONLY || s === RoadAccess.ALL_YEAR, + ); + if (keys.length > 0) filters.roadAccess = Array.from(new Set(keys)); + } + + const elecRaw = searchParams.electricity; + if (elecRaw) { + const arr = Array.isArray(elecRaw) ? elecRaw : [elecRaw]; + const keys = arr + .flatMap((s) => s.split(",")) + .map((s) => s.trim()) + .filter((s): s is Electricity => + s === Electricity.NONE || + s === Electricity.SOLAR || + s === Electricity.GENERATOR_READY || + s === Electricity.EDF, + ); + if (keys.length > 0) filters.electricity = Array.from(new Set(keys)); + } + + const gsmMaxRaw = pickString(searchParams.gsmMaxKm); + if (gsmMaxRaw) { + const km = Number(gsmMaxRaw); + if (Number.isFinite(km) && km >= 0 && km <= 50) filters.gsmMaxKm = km; + } + const priceMaxRaw = pickString(searchParams.priceMax); if (priceMaxRaw) { const priceMax = Number(priceMaxRaw); @@ -113,6 +159,10 @@ export type CarbetSearchResult = { nightlyPrice: string; latitude: number; longitude: number; + roadAccess: RoadAccess | null; + electricity: Electricity | null; + gsmAtCarbet: boolean; + gsmExitDistanceKm: number | null; }; // Build the Prisma where-clause for a public carbet search. A carbet is only @@ -127,8 +177,30 @@ function buildWhere(filters: CarbetSearchFilters): Prisma.CarbetWhereInput { where.river = { contains: filters.river, mode: "insensitive" }; } - if (filters.capacity) { - where.capacity = { gte: filters.capacity }; + if (filters.capacity || filters.capacityMax) { + where.capacity = {}; + if (filters.capacity) where.capacity.gte = filters.capacity; + if (filters.capacityMax) where.capacity.lte = filters.capacityMax; + } + + if (filters.roadAccess && filters.roadAccess.length > 0) { + where.roadAccess = { in: filters.roadAccess }; + } + + if (filters.electricity && filters.electricity.length > 0) { + where.electricity = { in: filters.electricity }; + } + + if (filters.gsmMaxKm !== undefined) { + if (filters.gsmMaxKm === 0) { + where.gsmAtCarbet = true; + } else { + where.OR = [ + ...(where.OR ?? []), + { gsmAtCarbet: true }, + { gsmExitDistanceKm: { lte: filters.gsmMaxKm } }, + ]; + } } if (filters.accessibility === "road-only") { @@ -182,6 +254,10 @@ export async function searchCarbets( maxStayNights: true, minCapacity: true, description: true, + roadAccess: true, + electricity: true, + gsmAtCarbet: true, + gsmExitDistanceKm: true, nightlyPrice: true, latitude: true, longitude: true, @@ -222,6 +298,10 @@ export async function searchCarbets( nightlyPrice: carbet.nightlyPrice.toString(), latitude: Number(carbet.latitude), longitude: Number(carbet.longitude), + roadAccess: carbet.roadAccess, + electricity: carbet.electricity, + gsmAtCarbet: carbet.gsmAtCarbet, + gsmExitDistanceKm: carbet.gsmExitDistanceKm !== null ? Number(carbet.gsmExitDistanceKm) : null, }; }); } diff --git a/src/lib/search-profiles.ts b/src/lib/search-profiles.ts new file mode 100644 index 0000000..cff37da --- /dev/null +++ b/src/lib/search-profiles.ts @@ -0,0 +1,79 @@ +/** + * Profils de séjour prédéfinis — chips au-dessus des facettes. + * Chaque profil pose un set de query params qui pré-cochent les filtres. + */ + +import { Electricity, RoadAccess } from "@/generated/prisma/enums"; + +export type SearchProfile = { + id: string; + emoji: string; + label: string; + description: string; + params: Record; +}; + +export const SEARCH_PROFILES: SearchProfile[] = [ + { + id: "deconnexion", + emoji: "🌿", + label: "Déconnexion totale", + description: "Zone blanche, pas d'électricité, accès pirogue, 2-4 personnes.", + params: { + roadAccess: RoadAccess.NONE, + electricity: `${Electricity.NONE},${Electricity.SOLAR}`, + capacityMax: "4", + }, + }, + { + id: "teletravail", + emoji: "💻", + label: "Télétravail nature", + description: "Route, EDF, 4G au carbet — bureau au bord du fleuve.", + params: { + roadAccess: RoadAccess.ALL_YEAR, + electricity: Electricity.EDF, + gsmMaxKm: "0", + }, + }, + { + id: "famille-weekend", + emoji: "🏝️", + label: "Famille week-end", + description: "Route toute saison, électricité, capacité 4-8.", + params: { + roadAccess: RoadAccess.ALL_YEAR, + electricity: `${Electricity.EDF},${Electricity.GENERATOR_READY}`, + capacity: "4", + capacityMax: "8", + }, + }, + { + id: "astreinte", + emoji: "📞", + label: "Astreinte sereine", + description: "Réseau accessible (au max 1 km), EDF, route saison sèche min.", + params: { + gsmMaxKm: "1", + electricity: `${Electricity.EDF},${Electricity.GENERATOR_READY}`, + roadAccess: `${RoadAccess.DRY_SEASON_ONLY},${RoadAccess.ALL_YEAR}`, + }, + }, + { + id: "aventure", + emoji: "🛶", + label: "Aventure expédition", + description: "Accès pirogue uniquement, petit groupe 2-4.", + params: { + roadAccess: RoadAccess.NONE, + capacityMax: "4", + }, + }, +]; + +export function buildProfileUrl(profileId: string): string { + const profile = SEARCH_PROFILES.find((p) => p.id === profileId); + if (!profile) return "/carbets"; + const search = new URLSearchParams(profile.params); + return `/carbets?${search.toString()}`; +} From 4901bb950ebc826de43adeb50b0498ed0157a896 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Tue, 2 Jun 2026 02:46:34 +0000 Subject: [PATCH 02/23] =?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 03/23] =?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 ( +
+
+
+ + + + + + + + + + +