342 lines
14 KiB
TypeScript
342 lines
14 KiB
TypeScript
"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'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>
|
||
<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>
|
||
);
|
||
}
|