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
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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue