From 9aa07710012a97e6a2b69f2f68d82c8a79e9c013 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Sun, 31 May 2026 19:51:33 +0000 Subject: [PATCH] =?UTF-8?q?feat(admin):=20CRUD=20complet=20carbets=20+=20g?= =?UTF-8?q?estion=20m=C3=A9dias=20(Sprint=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../carbets/[id]/_components/MediaManager.tsx | 142 +++++++++ .../[id]/_components/StatusActions.tsx | 93 ++++++ src/app/admin/carbets/[id]/page.tsx | 103 +++++++ .../admin/carbets/_components/CarbetForm.tsx | 269 ++++++++++++++++++ src/app/admin/carbets/actions.ts | 219 ++++++++++++++ src/app/admin/carbets/new/page.tsx | 20 ++ src/app/admin/carbets/page.tsx | 146 ++++++++++ src/app/api/admin/carbets/[id]/media/route.ts | 17 ++ src/components/admin/FormField.tsx | 32 +++ src/components/admin/StatusBadge.tsx | 31 ++ src/lib/admin/carbets.ts | 130 +++++++++ 11 files changed, 1202 insertions(+) create mode 100644 src/app/admin/carbets/[id]/_components/MediaManager.tsx create mode 100644 src/app/admin/carbets/[id]/_components/StatusActions.tsx create mode 100644 src/app/admin/carbets/[id]/page.tsx create mode 100644 src/app/admin/carbets/_components/CarbetForm.tsx create mode 100644 src/app/admin/carbets/actions.ts create mode 100644 src/app/admin/carbets/new/page.tsx create mode 100644 src/app/admin/carbets/page.tsx create mode 100644 src/app/api/admin/carbets/[id]/media/route.ts create mode 100644 src/components/admin/FormField.tsx create mode 100644 src/components/admin/StatusBadge.tsx create mode 100644 src/lib/admin/carbets.ts diff --git a/src/app/admin/carbets/[id]/_components/MediaManager.tsx b/src/app/admin/carbets/[id]/_components/MediaManager.tsx new file mode 100644 index 0000000..47947da --- /dev/null +++ b/src/app/admin/carbets/[id]/_components/MediaManager.tsx @@ -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(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 ( +
+

Médias ({media.length})

+ + {media.length === 0 ? ( +

+ Aucun média. Ajoute une URL ci-dessous (MinIO, CDN externe, …). +

+ ) : ( +
    + {media.map((m, i) => ( +
  • + #{i + 1} + {/* eslint-disable-next-line @next/next/no-img-element */} + +
    +
    {m.s3Url}
    +
    + {m.type} · {m.s3Key} +
    +
    +
    + + + +
    +
  • + ))} +
+ )} + +
+

Ajouter un média par URL

+
+ + + + + + +
+ + {error ?
{error}
: null} +
+ +
+
+
+ ); +} diff --git a/src/app/admin/carbets/[id]/_components/StatusActions.tsx b/src/app/admin/carbets/[id]/_components/StatusActions.tsx new file mode 100644 index 0000000..7d585ef --- /dev/null +++ b/src/app/admin/carbets/[id]/_components/StatusActions.tsx @@ -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 ( +
+ {current === CarbetStatus.DRAFT ? ( + + ) : null} + {current === CarbetStatus.PUBLISHED ? ( + + ) : null} + {current !== CarbetStatus.ARCHIVED ? ( + confirmArchive ? ( +
+ Sûr ? + + +
+ ) : ( + + ) + ) : ( + + )} +
+ ); +} diff --git a/src/app/admin/carbets/[id]/page.tsx b/src/app/admin/carbets/[id]/page.tsx new file mode 100644 index 0000000..5e8f635 --- /dev/null +++ b/src/app/admin/carbets/[id]/page.tsx @@ -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 ( +
+
+
+ + ← Tous les carbets + +

+ {carbet.title} + +

+

+ /{carbet.slug} · {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)} +

+
+
+ + {carbet.status === "PUBLISHED" ? ( + + ↗ Voir la fiche publique + + ) : null} +
+
+ + ({ + id: m.id, + type: m.type, + s3Key: m.s3Key, + s3Url: m.s3Url, + sortOrder: m.sortOrder, + }))} + /> + + +
+ ); +} diff --git a/src/app/admin/carbets/_components/CarbetForm.tsx b/src/app/admin/carbets/_components/CarbetForm.tsx new file mode 100644 index 0000000..11d8460 --- /dev/null +++ b/src/app/admin/carbets/_components/CarbetForm.tsx @@ -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(null); + const [success, setSuccess] = useState(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 ( +
+
+ {/* Identité */} +
+

Identité

+
+ + + + + + + + + + +