feat(admin): CRUD complet carbets + gestion médias (Sprint 2)

Server actions (src/app/admin/carbets/actions.ts) avec validation Zod :
- createCarbetAction → INSERT + audit + redirect /admin/carbets/[id]
- updateCarbetAction → UPDATE + revalidate page publique
- updateCarbetStatusAction → DRAFT/PUBLISHED/ARCHIVED
- deleteCarbetAction → soft archive (bookings/reviews FK Restrict)
- addMediaAction(carbetId, fd) → INSERT Media + sortOrder
- removeMediaAction, reorderMediaAction (transactionnel up/down)

Helpers (src/lib/admin/carbets.ts) :
- listCarbetsAdmin avec filtres (q/river/status/accessType)
- listDistinctRivers, listOwners, listPirogueProviders
- getCarbetForEdit (include owner, provider, media, _count bookings/reviews)
- Options enum pour les selects (ACCESS_TYPE, TRANSPORT_MODE, STATUS)

Pages :
- /admin/carbets : liste tableau dense avec recherche/filtres GET, status badge,
  liens vers édition, count médias/résas
- /admin/carbets/new : page création avec CarbetForm
- /admin/carbets/[id] : header titre+badge+actions, MediaManager, CarbetForm
  d'édition. Lien public si PUBLISHED.

Composants admin réutilisables :
- StatusBadge (DRAFT/PUBLISHED/ARCHIVED + statuts Booking)
- FormField + inputCls/selectCls/textareaCls
- CarbetForm (client, 5 sections : identité, localisation, accès, séjour,
  publication) avec useTransition + erreur + succès inline
- MediaManager (client, liste + reorder ↑↓ + suppression + ajout par URL)
- StatusActions (client, publier/dépublier/archiver/réactiver avec confirm)

API :
- GET /api/admin/carbets/[id]/media pour refresh client après mutation

Audit léger en log console (JSON structuré) — Sprint 5 ajoutera la table.
This commit is contained in:
Claude Integration 2026-05-31 19:51:33 +00:00
parent 3ec7a3ff10
commit 9aa0771001
11 changed files with 1202 additions and 0 deletions

View file

@ -0,0 +1,32 @@
import type { ReactNode } from "react";
type Props = {
label: string;
htmlFor?: string;
hint?: string;
error?: string;
required?: boolean;
children: ReactNode;
className?: string;
};
export function FormField({ label, htmlFor, hint, error, required, children, className = "" }: Props) {
return (
<label className={`block ${className}`} htmlFor={htmlFor}>
<span className="mb-1 flex items-center gap-1 text-xs font-medium uppercase tracking-wider text-zinc-600">
{label}
{required ? <span className="text-rose-500">*</span> : null}
</span>
{children}
{hint && !error ? <span className="mt-1 block text-xs text-zinc-500">{hint}</span> : null}
{error ? <span className="mt-1 block text-xs text-rose-600">{error}</span> : null}
</label>
);
}
export const inputCls =
"w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 placeholder:text-zinc-400 focus:border-zinc-900 focus:outline-none focus:ring-1 focus:ring-zinc-900 disabled:opacity-50";
export const selectCls = inputCls + " cursor-pointer";
export const textareaCls = inputCls + " font-mono leading-relaxed";

View file

@ -0,0 +1,31 @@
const TONES = {
draft: "bg-zinc-100 text-zinc-700 ring-zinc-300",
published: "bg-emerald-100 text-emerald-800 ring-emerald-300",
archived: "bg-amber-100 text-amber-800 ring-amber-300",
pending: "bg-sky-100 text-sky-800 ring-sky-300",
confirmed: "bg-emerald-100 text-emerald-800 ring-emerald-300",
cancelled: "bg-rose-100 text-rose-700 ring-rose-300",
completed: "bg-zinc-100 text-zinc-700 ring-zinc-300",
} as const;
const LABELS: Record<string, string> = {
DRAFT: "Brouillon",
PUBLISHED: "Publié",
ARCHIVED: "Archivé",
PENDING: "En attente",
CONFIRMED: "Confirmé",
CANCELLED: "Annulé",
COMPLETED: "Terminé",
};
export function StatusBadge({ status }: { status: string }) {
const key = status.toLowerCase() as keyof typeof TONES;
const tone = TONES[key] ?? TONES.draft;
return (
<span
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider ring-1 ring-inset ${tone}`}
>
{LABELS[status] ?? status}
</span>
);
}