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
All checks were successful
CI / test (push) Successful in 2m5s
This commit is contained in:
commit
d2dcc698e9
6 changed files with 228 additions and 2 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'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 & tarif</h2>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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'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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue