karbe/src/app/admin/carbets/_components/CarbetForm.tsx
Claude Integration 4901bb950e
All checks were successful
CI / test (pull_request) Successful in 2m16s
feat(forms): 4 critères opérationnels dans formulaires admin + espace hôte
2026-06-02 02:46:34 +00:00

342 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState, useTransition } from "react";
import { FormField, inputCls, selectCls, textareaCls } from "@/components/admin/FormField";
import {
ACCESS_TYPE_OPTIONS,
STATUS_OPTIONS,
TRANSPORT_MODE_OPTIONS,
} from "@/lib/admin/carbet-options";
export type CarbetFormInitial = {
ownerId?: string;
title?: string;
slug?: string;
description?: string;
river?: string;
embarkPoint?: string;
latitude?: number | string;
longitude?: number | string;
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;
maxStayNights?: number | null;
minCapacity?: number | null;
transportMode?: string | null;
pirogueProviderId?: string | null;
status?: string;
};
type Props = {
initial?: CarbetFormInitial;
owners: { id: string; firstName: string; lastName: string; email: string }[];
providers: { id: string; name: string; rivers: string[] }[];
action: (fd: FormData) => Promise<{ ok: false; error: string } | { ok: true } | undefined>;
submitLabel?: string;
};
export function CarbetForm({ initial = {}, owners, providers, action, submitLabel = "Enregistrer" }: Props) {
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
function onSubmit(formData: FormData) {
setError(null);
setSuccess(null);
startTransition(async () => {
const res = await action(formData);
if (res && res.ok === false) {
setError(res.error);
} else if (res && res.ok === true) {
setSuccess("Carbet enregistré.");
}
});
}
return (
<form action={onSubmit} className="space-y-6">
<fieldset disabled={pending} className="space-y-6">
{/* Identité */}
<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">Identité</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Titre" required>
<input name="title" defaultValue={initial.title ?? ""} className={inputCls} required maxLength={200} />
</FormField>
<FormField label="Slug" required hint="URL publique : /carbets/<slug>">
<input
name="slug"
defaultValue={initial.slug ?? ""}
className={inputCls}
required
pattern="^[a-z0-9](?:[a-z0-9-]{0,80}[a-z0-9])?$"
placeholder="ex. karbe-awara-maroni"
/>
</FormField>
<FormField label="Propriétaire" required className="sm:col-span-2">
<select name="ownerId" defaultValue={initial.ownerId ?? ""} className={selectCls} required>
<option value="" disabled> sélectionner un propriétaire </option>
{owners.map((o) => (
<option key={o.id} value={o.id}>
{o.firstName} {o.lastName} ({o.email})
</option>
))}
</select>
</FormField>
<FormField label="Description" required className="sm:col-span-2" hint="Markdown léger autorisé.">
<textarea
name="description"
rows={6}
defaultValue={initial.description ?? ""}
className={textareaCls}
required
maxLength={20000}
/>
</FormField>
</div>
</section>
{/* Localisation */}
<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">Localisation</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Fleuve" required>
<input name="river" defaultValue={initial.river ?? ""} className={inputCls} required maxLength={100} placeholder="Maroni" />
</FormField>
<FormField label="Point d'embarquement" required>
<input
name="embarkPoint"
defaultValue={initial.embarkPoint ?? ""}
className={inputCls}
required
maxLength={200}
/>
</FormField>
<FormField label="Latitude" required hint="Décimal (-90 à 90)">
<input
name="latitude"
type="number"
step="0.000001"
defaultValue={initial.latitude?.toString() ?? ""}
className={inputCls}
required
/>
</FormField>
<FormField label="Longitude" required hint="Décimal (-180 à 180)">
<input
name="longitude"
type="number"
step="0.000001"
defaultValue={initial.longitude?.toString() ?? ""}
className={inputCls}
required
/>
</FormField>
</div>
</section>
{/* Accès */}
<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">Accès & transport</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Type d'accès" required>
<select name="accessType" defaultValue={initial.accessType ?? "ROAD_AND_RIVER"} className={selectCls} required>
{ACCESS_TYPE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</FormField>
<FormField label="Durée pirogue (min)" hint="Optionnel — vide si accès route uniquement">
<input
name="pirogueDurationMin"
type="number"
min={0}
max={1440}
defaultValue={initial.pirogueDurationMin?.toString() ?? ""}
className={inputCls}
/>
</FormField>
<FormField label="Note d'accès route" className="sm:col-span-2" hint="GPS, type de piste, distance dernière ville…">
<textarea
name="roadAccessNote"
rows={2}
defaultValue={initial.roadAccessNote ?? ""}
className={textareaCls}
maxLength={1000}
/>
</FormField>
<FormField label="Mode de transport pirogue">
<select name="transportMode" defaultValue={initial.transportMode ?? ""} className={selectCls}>
<option value=""> non spécifié </option>
{TRANSPORT_MODE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</FormField>
<FormField label="Prestataire pirogue partenaire">
<select name="pirogueProviderId" defaultValue={initial.pirogueProviderId ?? ""} className={selectCls}>
<option value=""> aucun </option>
{providers.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.rivers.join(", ")})
</option>
))}
</select>
</FormField>
</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>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
<FormField label="Capacité" required hint="Voyageurs max">
<input
name="capacity"
type="number"
min={1}
max={100}
defaultValue={initial.capacity?.toString() ?? ""}
className={inputCls}
required
/>
</FormField>
<FormField label="Prix / nuit (€)" required hint="Pour le carbet entier.">
<input
name="nightlyPrice"
type="number"
min={0}
step="0.01"
defaultValue={initial.nightlyPrice?.toString() ?? ""}
className={inputCls}
required
/>
</FormField>
<FormField label="Capacité min recommandée" hint="Facultatif">
<input
name="minCapacity"
type="number"
min={1}
max={100}
defaultValue={initial.minCapacity?.toString() ?? ""}
className={inputCls}
/>
</FormField>
<FormField label="Nuits min" hint="Facultatif">
<input
name="minStayNights"
type="number"
min={1}
max={365}
defaultValue={initial.minStayNights?.toString() ?? ""}
className={inputCls}
/>
</FormField>
<FormField label="Nuits max" hint="Facultatif">
<input
name="maxStayNights"
type="number"
min={1}
max={365}
defaultValue={initial.maxStayNights?.toString() ?? ""}
className={inputCls}
/>
</FormField>
</div>
</section>
{/* Publication */}
<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">Publication</h2>
<FormField label="Statut" hint="Brouillon n'apparaît pas sur le site public. Archivé reste en base mais non listé.">
<select name="status" defaultValue={initial.status ?? "DRAFT"} className={selectCls}>
{STATUS_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</FormField>
</section>
{error ? (
<div className="rounded border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
) : null}
{success ? (
<div className="rounded border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{success}</div>
) : null}
<div className="flex items-center justify-end gap-2">
<button
type="submit"
className="rounded-md bg-zinc-900 px-5 py-2 text-sm font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
>
{pending ? "Enregistrement…" : submitLabel}
</button>
</div>
</fieldset>
</form>
);
}