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:
parent
3ec7a3ff10
commit
9aa0771001
11 changed files with 1202 additions and 0 deletions
142
src/app/admin/carbets/[id]/_components/MediaManager.tsx
Normal file
142
src/app/admin/carbets/[id]/_components/MediaManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
src/app/admin/carbets/[id]/_components/StatusActions.tsx
Normal file
93
src/app/admin/carbets/[id]/_components/StatusActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
src/app/admin/carbets/[id]/page.tsx
Normal file
103
src/app/admin/carbets/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
269
src/app/admin/carbets/_components/CarbetForm.tsx
Normal file
269
src/app/admin/carbets/_components/CarbetForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
219
src/app/admin/carbets/actions.ts
Normal file
219
src/app/admin/carbets/actions.ts
Normal 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(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
20
src/app/admin/carbets/new/page.tsx
Normal file
20
src/app/admin/carbets/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
146
src/app/admin/carbets/page.tsx
Normal file
146
src/app/admin/carbets/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/app/api/admin/carbets/[id]/media/route.ts
Normal file
17
src/app/api/admin/carbets/[id]/media/route.ts
Normal 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);
|
||||
}
|
||||
32
src/components/admin/FormField.tsx
Normal file
32
src/components/admin/FormField.tsx
Normal 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";
|
||||
31
src/components/admin/StatusBadge.tsx
Normal file
31
src/components/admin/StatusBadge.tsx
Normal 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
130
src/lib/admin/carbets.ts
Normal 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é" },
|
||||
];
|
||||
Loading…
Add table
Add a link
Reference in a new issue