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,142 @@
"use client";
import { useState, useTransition } from "react";
import Image from "next/image";
import { addMediaAction, removeMediaAction, reorderMediaAction } from "../../actions";
import { FormField, inputCls, selectCls } from "@/components/admin/FormField";
type MediaItem = {
id: string;
type: "PHOTO" | "VIDEO";
s3Key: string;
s3Url: string;
sortOrder: number;
};
export function MediaManager({ carbetId, media: initial }: { carbetId: string; media: MediaItem[] }) {
const [media, setMedia] = useState(initial);
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
async function refresh() {
const r = await fetch(`/api/admin/carbets/${carbetId}/media`);
if (r.ok) setMedia(await r.json());
}
function addByUrl(fd: FormData) {
setError(null);
startTransition(async () => {
const res = await addMediaAction(carbetId, fd);
if (res?.ok === false) {
setError(res.error);
} else {
await refresh();
}
});
}
function remove(mediaId: string) {
startTransition(async () => {
await removeMediaAction(carbetId, mediaId);
await refresh();
});
}
function reorder(mediaId: string, dir: "up" | "down") {
startTransition(async () => {
await reorderMediaAction(carbetId, mediaId, dir);
await refresh();
});
}
return (
<div 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">Médias ({media.length})</h2>
{media.length === 0 ? (
<p className="mb-4 rounded border border-dashed border-zinc-300 bg-zinc-50 p-4 text-sm text-zinc-500">
Aucun média. Ajoute une URL ci-dessous (MinIO, CDN externe, ).
</p>
) : (
<ul className="mb-4 divide-y divide-zinc-100 rounded border border-zinc-200">
{media.map((m, i) => (
<li key={m.id} className="flex items-center gap-3 px-3 py-2">
<span className="font-mono text-xs text-zinc-500">#{i + 1}</span>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={m.s3Url}
alt=""
className="h-12 w-16 rounded object-cover ring-1 ring-zinc-200"
loading="lazy"
/>
<div className="min-w-0 flex-1">
<div className="truncate text-xs text-zinc-700">{m.s3Url}</div>
<div className="text-[11px] text-zinc-500">
{m.type} · <code>{m.s3Key}</code>
</div>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => reorder(m.id, "up")}
disabled={pending || i === 0}
className="rounded border border-zinc-200 px-2 py-1 text-xs hover:bg-zinc-50 disabled:opacity-30"
>
</button>
<button
type="button"
onClick={() => reorder(m.id, "down")}
disabled={pending || i === media.length - 1}
className="rounded border border-zinc-200 px-2 py-1 text-xs hover:bg-zinc-50 disabled:opacity-30"
>
</button>
<button
type="button"
onClick={() => remove(m.id)}
disabled={pending}
className="rounded border border-rose-200 bg-rose-50 px-2 py-1 text-xs text-rose-700 hover:bg-rose-100 disabled:opacity-50"
>
Supprimer
</button>
</div>
</li>
))}
</ul>
)}
<form action={addByUrl} className="space-y-3 rounded border border-zinc-200 bg-zinc-50 p-3">
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">Ajouter un média par URL</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-4">
<FormField label="URL" className="sm:col-span-3">
<input
name="url"
type="url"
required
className={inputCls}
placeholder="https://media.karbe.cosmolan.fr/…"
/>
</FormField>
<FormField label="Type">
<select name="type" defaultValue="PHOTO" className={selectCls}>
<option value="PHOTO">Photo</option>
<option value="VIDEO">Vidéo</option>
</select>
</FormField>
</div>
<input type="hidden" name="s3Key" value={`external/${Date.now()}`} />
{error ? <div className="rounded border border-rose-200 bg-rose-50 px-2 py-1 text-xs text-rose-700">{error}</div> : null}
<div className="flex justify-end">
<button
type="submit"
disabled={pending}
className="rounded-md bg-zinc-900 px-3 py-1.5 text-xs font-semibold text-white hover:bg-zinc-800 disabled:opacity-50"
>
{pending ? "Ajout…" : "Ajouter"}
</button>
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,93 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { CarbetStatus } from "@/generated/prisma/enums";
import { deleteCarbetAction, updateCarbetStatusAction } from "../../actions";
type Status = (typeof CarbetStatus)[keyof typeof CarbetStatus];
export function StatusActions({ id, current }: { id: string; current: Status }) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [confirmArchive, setConfirmArchive] = useState(false);
function setStatus(next: Status) {
startTransition(async () => {
await updateCarbetStatusAction(id, next);
router.refresh();
});
}
function archive() {
startTransition(async () => {
await deleteCarbetAction(id);
});
}
return (
<div className="flex flex-wrap items-center gap-2">
{current === CarbetStatus.DRAFT ? (
<button
type="button"
onClick={() => setStatus(CarbetStatus.PUBLISHED)}
disabled={pending}
className="rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-semibold text-white hover:bg-emerald-700 disabled:opacity-50"
>
Publier
</button>
) : null}
{current === CarbetStatus.PUBLISHED ? (
<button
type="button"
onClick={() => setStatus(CarbetStatus.DRAFT)}
disabled={pending}
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-xs font-semibold text-zinc-700 hover:bg-zinc-50 disabled:opacity-50"
>
Dépublier (brouillon)
</button>
) : null}
{current !== CarbetStatus.ARCHIVED ? (
confirmArchive ? (
<div className="flex items-center gap-2 rounded border border-amber-300 bg-amber-50 px-2 py-1">
<span className="text-xs text-amber-900">Sûr ?</span>
<button
type="button"
onClick={archive}
disabled={pending}
className="rounded bg-amber-700 px-2 py-1 text-[11px] font-semibold text-white hover:bg-amber-800 disabled:opacity-50"
>
Oui, archiver
</button>
<button
type="button"
onClick={() => setConfirmArchive(false)}
disabled={pending}
className="text-[11px] text-zinc-500 hover:text-zinc-900"
>
Annuler
</button>
</div>
) : (
<button
type="button"
onClick={() => setConfirmArchive(true)}
disabled={pending}
className="rounded-md border border-amber-300 bg-amber-50 px-3 py-1.5 text-xs font-semibold text-amber-800 hover:bg-amber-100 disabled:opacity-50"
>
Archiver
</button>
)
) : (
<button
type="button"
onClick={() => setStatus(CarbetStatus.DRAFT)}
disabled={pending}
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-xs font-semibold text-zinc-700 hover:bg-zinc-50 disabled:opacity-50"
>
Réactiver (brouillon)
</button>
)}
</div>
);
}

View file

@ -0,0 +1,103 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import {
getCarbetForEdit,
listOwners,
listPirogueProviders,
} from "@/lib/admin/carbets";
import { CarbetForm } from "../_components/CarbetForm";
import { StatusBadge } from "@/components/admin/StatusBadge";
import { MediaManager } from "./_components/MediaManager";
import { StatusActions } from "./_components/StatusActions";
import { updateCarbetAction } from "../actions";
export const dynamic = "force-dynamic";
type PageProps = { params: Promise<{ id: string }> };
export default async function EditCarbetPage({ params }: PageProps) {
const { id } = await params;
const [carbet, owners, providers] = await Promise.all([
getCarbetForEdit(id),
listOwners(),
listPirogueProviders(),
]);
if (!carbet) notFound();
const updateThis = async (fd: FormData) => {
"use server";
return await updateCarbetAction(id, fd);
};
return (
<div className="mx-auto max-w-5xl space-y-6">
<header className="mt-2 flex flex-wrap items-end justify-between gap-3">
<div>
<Link href="/admin/carbets" className="text-xs text-zinc-500 hover:text-zinc-900">
Tous les carbets
</Link>
<h1 className="mt-1 flex items-center gap-3 text-2xl font-semibold text-zinc-900">
{carbet.title}
<StatusBadge status={carbet.status} />
</h1>
<p className="mt-1 text-sm text-zinc-500">
<code>/{carbet.slug}</code> · {carbet._count.bookings} résa
{carbet._count.bookings > 1 ? "s" : ""} · {carbet._count.reviews} avis ·
mis à jour {new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "long", year: "numeric" }).format(carbet.updatedAt)}
</p>
</div>
<div className="flex flex-col items-end gap-2">
<StatusActions id={carbet.id} current={carbet.status} />
{carbet.status === "PUBLISHED" ? (
<a
href={`/carbets/${carbet.slug}`}
target="_blank"
rel="noreferrer"
className="text-xs text-zinc-500 hover:text-zinc-900"
>
Voir la fiche publique
</a>
) : null}
</div>
</header>
<MediaManager
carbetId={carbet.id}
media={carbet.media.map((m) => ({
id: m.id,
type: m.type,
s3Key: m.s3Key,
s3Url: m.s3Url,
sortOrder: m.sortOrder,
}))}
/>
<CarbetForm
owners={owners}
providers={providers}
action={updateThis}
submitLabel="Enregistrer les modifications"
initial={{
ownerId: carbet.owner.id,
title: carbet.title,
slug: carbet.slug,
description: carbet.description,
river: carbet.river,
embarkPoint: carbet.embarkPoint,
latitude: carbet.latitude.toString(),
longitude: carbet.longitude.toString(),
capacity: carbet.capacity,
accessType: carbet.accessType,
roadAccessNote: carbet.roadAccessNote,
pirogueDurationMin: carbet.pirogueDurationMin,
minStayNights: carbet.minStayNights,
maxStayNights: carbet.maxStayNights,
minCapacity: carbet.minCapacity,
transportMode: carbet.transportMode,
pirogueProviderId: carbet.pirogueProvider?.id ?? null,
status: carbet.status,
}}
/>
</div>
);
}

View file

@ -0,0 +1,269 @@
"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/carbets";
export type CarbetFormInitial = {
ownerId?: string;
title?: string;
slug?: string;
description?: string;
river?: string;
embarkPoint?: string;
latitude?: number | string;
longitude?: number | string;
capacity?: number;
accessType?: string;
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>
{/* Séjour */}
<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</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="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>
);
}

View file

@ -0,0 +1,219 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { auth } from "@/auth";
import { requireRole } from "@/lib/authorization";
import { prisma } from "@/lib/prisma";
import {
AccessType,
CarbetStatus,
MediaType,
TransportMode,
UserRole,
} from "@/generated/prisma/enums";
const slugRe = /^[a-z0-9](?:[a-z0-9-]{0,80}[a-z0-9])?$/;
const baseCarbetSchema = z.object({
ownerId: z.string().min(1, "Propriétaire requis"),
title: z.string().trim().min(1).max(200),
slug: z.string().trim().regex(slugRe, "Slug invalide (a-z, 0-9, -)"),
description: z.string().trim().min(10).max(20000),
river: z.string().trim().min(2).max(100),
embarkPoint: z.string().trim().min(2).max(200),
latitude: z.coerce.number().min(-90).max(90),
longitude: z.coerce.number().min(-180).max(180),
capacity: z.coerce.number().int().min(1).max(100),
accessType: z.enum([AccessType.ROAD_AND_RIVER, AccessType.RIVER_ONLY]),
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(),
maxStayNights: z.coerce.number().int().min(1).max(365).optional().nullable(),
minCapacity: z.coerce.number().int().min(1).max(100).optional().nullable(),
transportMode: z
.enum([TransportMode.OWNER_PROVIDES, TransportMode.SELF_ARRANGE, TransportMode.PARTNER_PROVIDER])
.optional()
.nullable(),
pirogueProviderId: z.string().optional().nullable(),
status: z.enum([CarbetStatus.DRAFT, CarbetStatus.PUBLISHED, CarbetStatus.ARCHIVED]).default(CarbetStatus.DRAFT),
});
function normalizeNullable<T>(v: T | "" | undefined | null): T | null {
if (v === undefined || v === null || v === "") return null;
return v;
}
function parseFromFormData(fd: FormData) {
const obj: Record<string, unknown> = {};
for (const [k, v] of fd.entries()) {
if (typeof v === "string") obj[k] = v;
}
// Normalise les champs optionnels nullables
["roadAccessNote", "pirogueDurationMin", "minStayNights", "maxStayNights", "minCapacity", "transportMode", "pirogueProviderId"].forEach(
(k) => (obj[k] = normalizeNullable(obj[k] as string | null | undefined)),
);
return obj;
}
export async function createCarbetAction(fd: FormData) {
await requireRole([UserRole.ADMIN]);
const parsed = baseCarbetSchema.safeParse(parseFromFormData(fd));
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
}
const session = await auth();
try {
const created = await prisma.carbet.create({
data: {
...parsed.data,
lastBookedAt: null,
},
});
await audit("carbet.create", created.id, session?.user?.email ?? null, {
slug: created.slug,
status: created.status,
});
revalidatePath("/admin/carbets");
redirect(`/admin/carbets/${created.id}`);
} catch (e) {
if (e instanceof Error && e.message.includes("Unique constraint")) {
return { ok: false as const, error: "Slug déjà utilisé" };
}
throw e;
}
}
export async function updateCarbetAction(id: string, fd: FormData) {
await requireRole([UserRole.ADMIN]);
const parsed = baseCarbetSchema.safeParse(parseFromFormData(fd));
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(" · ") };
}
const session = await auth();
try {
const updated = await prisma.carbet.update({
where: { id },
data: parsed.data,
});
await audit("carbet.update", updated.id, session?.user?.email ?? null, {
slug: updated.slug,
status: updated.status,
});
revalidatePath("/admin/carbets");
revalidatePath(`/admin/carbets/${id}`);
revalidatePath(`/carbets/${updated.slug}`);
return { ok: true as const };
} catch (e) {
if (e instanceof Error && e.message.includes("Unique constraint")) {
return { ok: false as const, error: "Slug déjà utilisé" };
}
throw e;
}
}
export async function updateCarbetStatusAction(id: string, status: CarbetStatus) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
await prisma.carbet.update({ where: { id }, data: { status } });
await audit("carbet.status", id, session?.user?.email ?? null, { status });
revalidatePath("/admin/carbets");
revalidatePath(`/admin/carbets/${id}`);
return { ok: true as const };
}
export async function deleteCarbetAction(id: string) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
// Soft : on archive plutôt que supprimer (bookings/reviews FK Restrict).
const archived = await prisma.carbet.update({
where: { id },
data: { status: CarbetStatus.ARCHIVED },
});
await audit("carbet.archive", id, session?.user?.email ?? null, { slug: archived.slug });
revalidatePath("/admin/carbets");
redirect("/admin/carbets");
}
const mediaSchema = z.object({
url: z.string().url().max(2000),
type: z.enum([MediaType.PHOTO, MediaType.VIDEO]).default(MediaType.PHOTO),
s3Key: z.string().max(500).optional(),
});
export async function addMediaAction(carbetId: string, fd: FormData) {
await requireRole([UserRole.ADMIN]);
const parsed = mediaSchema.safeParse({
url: fd.get("url"),
type: fd.get("type") ?? "PHOTO",
s3Key: fd.get("s3Key") ?? undefined,
});
if (!parsed.success) {
return { ok: false as const, error: parsed.error.issues.map((i) => i.message).join(" · ") };
}
const existing = await prisma.media.count({ where: { carbetId } });
const session = await auth();
const m = await prisma.media.create({
data: {
carbetId,
type: parsed.data.type,
s3Url: parsed.data.url,
s3Key: parsed.data.s3Key ?? `external/${Date.now()}`,
sortOrder: existing,
},
});
await audit("media.create", m.id, session?.user?.email ?? null, { carbetId, url: parsed.data.url });
revalidatePath(`/admin/carbets/${carbetId}`);
return { ok: true as const };
}
export async function removeMediaAction(carbetId: string, mediaId: string) {
await requireRole([UserRole.ADMIN]);
const session = await auth();
await prisma.media.delete({ where: { id: mediaId } });
await audit("media.delete", mediaId, session?.user?.email ?? null, { carbetId });
revalidatePath(`/admin/carbets/${carbetId}`);
return { ok: true as const };
}
export async function reorderMediaAction(carbetId: string, mediaId: string, direction: "up" | "down") {
await requireRole([UserRole.ADMIN]);
const all = await prisma.media.findMany({
where: { carbetId },
orderBy: { sortOrder: "asc" },
});
const idx = all.findIndex((m) => m.id === mediaId);
if (idx === -1) return { ok: false as const };
const swap = direction === "up" ? idx - 1 : idx + 1;
if (swap < 0 || swap >= all.length) return { ok: false as const };
const a = all[idx];
const b = all[swap];
await prisma.$transaction([
prisma.media.update({ where: { id: a.id }, data: { sortOrder: b.sortOrder } }),
prisma.media.update({ where: { id: b.id }, data: { sortOrder: a.sortOrder } }),
]);
revalidatePath(`/admin/carbets/${carbetId}`);
return { ok: true as const };
}
/**
* Audit léger : log dans la console (Sprint 5 ajoutera une table AuditLog).
* Pour l'instant on a au moins une trace dans les logs du container.
*/
async function audit(
action: string,
entityId: string,
actor: string | null,
payload: Record<string, unknown>,
) {
console.log(
JSON.stringify({
audit: action,
actor,
entityId,
payload,
at: new Date().toISOString(),
}),
);
}

View file

@ -0,0 +1,20 @@
import { listOwners, listPirogueProviders } from "@/lib/admin/carbets";
import { CarbetForm } from "../_components/CarbetForm";
import { createCarbetAction } from "../actions";
export const dynamic = "force-dynamic";
export default async function NewCarbetPage() {
const [owners, providers] = await Promise.all([listOwners(), listPirogueProviders()]);
return (
<div className="mx-auto max-w-4xl">
<header className="mb-5 mt-2">
<h1 className="text-2xl font-semibold text-zinc-900">Nouveau carbet</h1>
<p className="mt-1 text-sm text-zinc-500">
Crée un brouillon. Tu pourras le publier ensuite depuis sa fiche.
</p>
</header>
<CarbetForm owners={owners} providers={providers} action={createCarbetAction} submitLabel="Créer le carbet" />
</div>
);
}

View file

@ -0,0 +1,146 @@
import Link from "next/link";
import { AccessType, CarbetStatus } from "@/generated/prisma/enums";
import { listCarbetsAdmin, listDistinctRivers } from "@/lib/admin/carbets";
import { StatusBadge } from "@/components/admin/StatusBadge";
export const dynamic = "force-dynamic";
type PageProps = {
searchParams: Promise<{
q?: string;
river?: string;
status?: string;
accessType?: string;
}>;
};
const STATUS_VALUES = new Set<string>([CarbetStatus.DRAFT, CarbetStatus.PUBLISHED, CarbetStatus.ARCHIVED]);
const ACCESS_VALUES = new Set<string>([AccessType.ROAD_AND_RIVER, AccessType.RIVER_ONLY]);
export default async function CarbetsAdminPage({ searchParams }: PageProps) {
const sp = await searchParams;
const filters = {
q: sp.q?.trim() || undefined,
river: sp.river || undefined,
status: STATUS_VALUES.has(sp.status ?? "") ? (sp.status as CarbetStatus) : undefined,
accessType: ACCESS_VALUES.has(sp.accessType ?? "") ? (sp.accessType as AccessType) : undefined,
};
const [carbets, rivers] = await Promise.all([listCarbetsAdmin(filters), listDistinctRivers()]);
return (
<div className="mx-auto max-w-7xl">
<header className="mb-5 mt-2 flex items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold text-zinc-900">Carbets</h1>
<p className="mt-1 text-sm text-zinc-500">
{carbets.length} résultat{carbets.length > 1 ? "s" : ""} · brouillons, publiés et archivés
</p>
</div>
<Link
href="/admin/carbets/new"
className="inline-flex items-center gap-1.5 rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-semibold text-white hover:bg-zinc-800"
>
+ Nouveau carbet
</Link>
</header>
<form className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-zinc-200 bg-white p-3" method="get">
<input
type="text"
name="q"
defaultValue={filters.q ?? ""}
placeholder="Recherche par titre, slug, fleuve…"
className="flex-1 min-w-[180px] rounded-md border border-zinc-300 px-3 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
/>
<select
name="river"
defaultValue={filters.river ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Tous les fleuves</option>
{rivers.map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
<select
name="status"
defaultValue={filters.status ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Tous statuts</option>
<option value={CarbetStatus.DRAFT}>Brouillon</option>
<option value={CarbetStatus.PUBLISHED}>Publié</option>
<option value={CarbetStatus.ARCHIVED}>Archivé</option>
</select>
<select
name="accessType"
defaultValue={filters.accessType ?? ""}
className="rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-sm focus:border-zinc-900 focus:outline-none"
>
<option value="">Tous accès</option>
<option value={AccessType.ROAD_AND_RIVER}>🛣 Route + fleuve</option>
<option value={AccessType.RIVER_ONLY}>🛶 Expédition</option>
</select>
<button type="submit" className="rounded-md bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800">
Filtrer
</button>
{(filters.q || filters.river || filters.status || filters.accessType) ? (
<Link href="/admin/carbets" className="text-sm text-zinc-500 hover:text-zinc-900">
Réinit.
</Link>
) : null}
</form>
<div className="overflow-hidden rounded-lg border border-zinc-200 bg-white">
<table className="w-full text-sm">
<thead className="border-b border-zinc-200 bg-zinc-50 text-xs uppercase tracking-wider text-zinc-500">
<tr>
<th className="px-4 py-2 text-left font-semibold">Titre</th>
<th className="px-4 py-2 text-left font-semibold">Fleuve</th>
<th className="px-4 py-2 text-left font-semibold">Accès</th>
<th className="px-4 py-2 text-right font-semibold">Cap.</th>
<th className="px-4 py-2 text-right font-semibold">Médias</th>
<th className="px-4 py-2 text-right font-semibold">Résas</th>
<th className="px-4 py-2 text-left font-semibold">Propriétaire</th>
<th className="px-4 py-2 text-left font-semibold">Statut</th>
<th className="px-4 py-2 text-right font-semibold">MAJ</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100">
{carbets.length === 0 ? (
<tr>
<td colSpan={9} className="px-4 py-8 text-center text-sm text-zinc-500">
Aucun carbet ne correspond aux filtres.
</td>
</tr>
) : null}
{carbets.map((c) => (
<tr key={c.id} className="hover:bg-zinc-50">
<td className="px-4 py-2">
<Link href={`/admin/carbets/${c.id}`} className="font-medium text-zinc-900 hover:underline">
{c.title}
</Link>
<div className="text-[11px] text-zinc-500">
<code>/{c.slug}</code>
</div>
</td>
<td className="px-4 py-2 text-zinc-700">{c.river}</td>
<td className="px-4 py-2 text-zinc-700">
{c.accessType === AccessType.RIVER_ONLY ? "🛶 Fleuve" : "🛣️ Route+fleuve"}
</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{c.capacity}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{c.mediaCount}</td>
<td className="px-4 py-2 text-right font-mono text-zinc-700">{c.bookingsCount}</td>
<td className="px-4 py-2 text-zinc-700">{c.ownerName}</td>
<td className="px-4 py-2"><StatusBadge status={c.status} /></td>
<td className="px-4 py-2 text-right text-[11px] text-zinc-500">
{new Intl.DateTimeFormat("fr-FR", { day: "2-digit", month: "short" }).format(c.updatedAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { requireRole } from "@/lib/authorization";
import { UserRole } from "@/generated/prisma/enums";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> }) {
await requireRole([UserRole.ADMIN]);
const { id } = await ctx.params;
const media = await prisma.media.findMany({
where: { carbetId: id },
orderBy: { sortOrder: "asc" },
select: { id: true, type: true, s3Key: true, s3Url: true, sortOrder: true },
});
return NextResponse.json(media);
}

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

130
src/lib/admin/carbets.ts Normal file
View file

@ -0,0 +1,130 @@
/**
* Helpers admin Carbets listing avec filtres, lookup pour formulaires.
*/
import "server-only";
import { prisma } from "@/lib/prisma";
import { AccessType, CarbetStatus, TransportMode, UserRole } from "@/generated/prisma/enums";
export type AdminCarbetListItem = {
id: string;
slug: string;
title: string;
river: string;
capacity: number;
status: CarbetStatus;
accessType: AccessType;
ownerName: string;
ownerEmail: string;
mediaCount: number;
bookingsCount: number;
updatedAt: Date;
};
export type AdminCarbetFilters = {
q?: string;
river?: string;
status?: CarbetStatus;
accessType?: AccessType;
};
export async function listCarbetsAdmin(filters: AdminCarbetFilters = {}): Promise<AdminCarbetListItem[]> {
const where: Parameters<typeof prisma.carbet.findMany>[0]["where"] = {};
if (filters.q) {
where.OR = [
{ title: { contains: filters.q, mode: "insensitive" } },
{ slug: { contains: filters.q, mode: "insensitive" } },
{ river: { contains: filters.q, mode: "insensitive" } },
];
}
if (filters.river) where.river = filters.river;
if (filters.status) where.status = filters.status;
if (filters.accessType) where.accessType = filters.accessType;
const rows = await prisma.carbet.findMany({
where,
orderBy: { updatedAt: "desc" },
take: 100,
select: {
id: true,
slug: true,
title: true,
river: true,
capacity: true,
status: true,
accessType: true,
updatedAt: true,
owner: { select: { firstName: true, lastName: true, email: true } },
_count: { select: { media: true, bookings: true } },
},
});
return rows.map((r) => ({
id: r.id,
slug: r.slug,
title: r.title,
river: r.river,
capacity: r.capacity,
status: r.status,
accessType: r.accessType,
ownerName: `${r.owner.firstName} ${r.owner.lastName}`.trim() || r.owner.email,
ownerEmail: r.owner.email,
mediaCount: r._count.media,
bookingsCount: r._count.bookings,
updatedAt: r.updatedAt,
}));
}
export async function listDistinctRivers(): Promise<string[]> {
const rows = await prisma.carbet.findMany({
distinct: ["river"],
orderBy: { river: "asc" },
select: { river: true },
});
return rows.map((r) => r.river);
}
export async function listOwners() {
return await prisma.user.findMany({
where: { role: UserRole.OWNER, isActive: true },
orderBy: [{ firstName: "asc" }, { lastName: "asc" }],
select: { id: true, firstName: true, lastName: true, email: true },
});
}
export async function listPirogueProviders() {
return await prisma.pirogueProvider.findMany({
where: { active: true },
orderBy: { name: "asc" },
select: { id: true, name: true, rivers: true },
});
}
export async function getCarbetForEdit(id: string) {
return await prisma.carbet.findUnique({
where: { id },
include: {
owner: { select: { id: true, firstName: true, lastName: true, email: true } },
pirogueProvider: { select: { id: true, name: true } },
media: { orderBy: { sortOrder: "asc" } },
_count: { select: { bookings: true, reviews: true } },
},
});
}
export const ACCESS_TYPE_OPTIONS: { value: AccessType; label: string }[] = [
{ value: AccessType.ROAD_AND_RIVER, label: "🛣️ Route + fleuve" },
{ value: AccessType.RIVER_ONLY, label: "🛶 Expédition fleuve" },
];
export const TRANSPORT_MODE_OPTIONS: { value: TransportMode; label: string }[] = [
{ value: TransportMode.SELF_ARRANGE, label: "🗺️ À organiser par le voyageur" },
{ value: TransportMode.OWNER_PROVIDES, label: "👤 Le loueur fournit" },
{ value: TransportMode.PARTNER_PROVIDER, label: "🤝 Partenaire référencé" },
];
export const STATUS_OPTIONS: { value: CarbetStatus; label: string }[] = [
{ value: CarbetStatus.DRAFT, label: "Brouillon" },
{ value: CarbetStatus.PUBLISHED, label: "Publié" },
{ value: CarbetStatus.ARCHIVED, label: "Archivé" },
];