Merge pull request 'feat(forms): critères opérationnels dans les formulaires' (#72) from feat/operational-criteria-forms into main
All checks were successful
CI / test (push) Successful in 2m5s

This commit is contained in:
tarzzan 2026-06-02 02:46:36 +00:00
commit d2dcc698e9
6 changed files with 228 additions and 2 deletions

View file

@ -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,

View file

@ -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
</div>
</section>
{/* Critères opérationnels */}
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-1 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Critères opérationnels
</h2>
<p className="mb-4 text-xs text-zinc-500">
Les 4 dealbreakers d&apos;un séjour en carbet guyanais. Indispensable pour les filtres recherche.
</p>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="🛣️ Accès route" hint="Praticabilité de l'accès depuis la route">
<select name="roadAccess" defaultValue={initial.roadAccess ?? ""} className={selectCls}>
<option value=""> non précisé </option>
<option value="ALL_YEAR">🛣 Toute saison</option>
<option value="DRY_SEASON_ONLY">🟠 Saison sèche uniquement</option>
<option value="NONE">🛶 Pirogue uniquement</option>
</select>
</FormField>
<FormField label="⚡ Électricité" hint="Comment est alimenté le carbet ?">
<select name="electricity" defaultValue={initial.electricity ?? ""} className={selectCls}>
<option value=""> non précisé </option>
<option value="EDF"> EDF / raccordé réseau</option>
<option value="GENERATOR_READY">🔌 Préinstallation groupe électrogène</option>
<option value="SOLAR"> Solaire</option>
<option value="NONE">🕯 Aucune électricité</option>
</select>
</FormField>
<FormField label="📶 Réseau GSM au carbet" hint="Téléphone capte directement sur place ?">
<select
name="gsmAtCarbet"
defaultValue={initial.gsmAtCarbet ? "yes" : "no"}
className={selectCls}
>
<option value="yes"> Oui, signal au carbet</option>
<option value="no"> Non, zone sans réseau</option>
</select>
</FormField>
<FormField
label="📵 Distance pour atteindre le réseau (km)"
hint="Si pas de réseau au carbet — sinon laisser vide"
>
<input
name="gsmExitDistanceKm"
type="number"
min={0}
max={50}
step="0.1"
defaultValue={initial.gsmExitDistanceKm?.toString() ?? ""}
placeholder="ex. 1.5"
className={inputCls}
/>
</FormField>
</div>
</section>
{/* Séjour & tarif */}
<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">Séjour &amp; tarif</h2>

View file

@ -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;
}

View file

@ -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),
};

View file

@ -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({
</div>
</section>
<section className="space-y-4 rounded-md border border-emerald-200 bg-emerald-50/30 p-4">
<div>
<h2 className="text-lg font-semibold text-zinc-900">
Critères opérationnels
</h2>
<p className="text-xs text-zinc-600">
Les 4 dealbreakers d&apos;un séjour en carbet. Ces critères apparaissent
en grand sur votre fiche et alimentent les filtres recherche.
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className={labelClass} htmlFor="roadAccess">
🛣 Accès route
</label>
<select
id="roadAccess"
name="roadAccess"
defaultValue={defaults.roadAccess ?? ""}
className={inputClass}
>
<option value=""> non précisé </option>
<option value="ALL_YEAR">🛣 Toute saison</option>
<option value="DRY_SEASON_ONLY">🟠 Saison sèche uniquement</option>
<option value="NONE">🛶 Pirogue uniquement</option>
</select>
<FieldError message={state.errors.roadAccess} />
</div>
<div>
<label className={labelClass} htmlFor="electricity">
Électricité
</label>
<select
id="electricity"
name="electricity"
defaultValue={defaults.electricity ?? ""}
className={inputClass}
>
<option value=""> non précisé </option>
<option value="EDF"> EDF / raccordé réseau</option>
<option value="GENERATOR_READY">🔌 Préinstall groupe électrogène</option>
<option value="SOLAR"> Solaire</option>
<option value="NONE">🕯 Aucune électricité</option>
</select>
<FieldError message={state.errors.electricity} />
</div>
<div>
<label className={labelClass} htmlFor="gsmAtCarbet">
📶 Réseau GSM au carbet
</label>
<select
id="gsmAtCarbet"
name="gsmAtCarbet"
defaultValue={defaults.gsmAtCarbet ? "yes" : "no"}
className={inputClass}
>
<option value="yes"> Oui, signal au carbet</option>
<option value="no"> Non, zone sans réseau</option>
</select>
<FieldError message={state.errors.gsmAtCarbet} />
</div>
<div>
<label className={labelClass} htmlFor="gsmExitDistanceKm">
📵 Distance pour atteindre le réseau (km)
</label>
<input
id="gsmExitDistanceKm"
name="gsmExitDistanceKm"
type="number"
min={0}
max={50}
step="0.1"
defaultValue={defaults.gsmExitDistanceKm ?? ""}
placeholder="ex. 1.5"
className={inputClass}
/>
<p className="mt-1 text-xs text-zinc-500">
Laissez vide si réseau au carbet
</p>
<FieldError message={state.errors.gsmExitDistanceKm} />
</div>
</div>
</section>
<section className="space-y-4">
<h2 className="text-lg font-semibold text-zinc-900">Commodités</h2>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">

View file

@ -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,
},
});