diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx
new file mode 100644
index 0000000..e853a28
--- /dev/null
+++ b/src/app/admin/layout.tsx
@@ -0,0 +1,24 @@
+import type { ReactNode } from "react";
+import { requireRole } from "@/lib/authorization";
+import { UserRole } from "@/generated/prisma/enums";
+import { Sidebar } from "@/components/admin/Sidebar";
+import { TopBar } from "@/components/admin/TopBar";
+import { Breadcrumbs } from "@/components/admin/Breadcrumbs";
+import { CommandPalette } from "@/components/admin/CommandPalette";
+
+export const dynamic = "force-dynamic";
+
+export default async function AdminLayout({ children }: { children: ReactNode }) {
+ const session = await requireRole([UserRole.ADMIN]);
+ return (
+
+
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index 731159d..3249e89 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -1,14 +1,102 @@
-import { requireRole } from "@/lib/authorization";
+import { formatEur, getAdminKpis } from "@/lib/admin/kpis";
+import { KPICard } from "@/components/admin/KPICard";
-export default async function AdminPage() {
- const session = await requireRole(["ADMIN"]);
+export const dynamic = "force-dynamic";
+
+export default async function AdminDashboard() {
+ const kpis = await getAdminKpis();
return (
-
- Espace administrateur
-
- Accès autorisé pour {session.user.email} ({session.user.role}).
-
-
+
+
+
+
+
+
+
+ 50 ? "ok" : "neutral"}
+ />
+
+
+ 5 ? "warn" : "neutral"}
+ />
+
+
+
+
+ Raccourcis fréquents
+
+
+
+
);
}
diff --git a/src/app/api/admin/search/route.ts b/src/app/api/admin/search/route.ts
new file mode 100644
index 0000000..55f6523
--- /dev/null
+++ b/src/app/api/admin/search/route.ts
@@ -0,0 +1,14 @@
+import { NextResponse } from "next/server";
+import { requireRole } from "@/lib/authorization";
+import { UserRole } from "@/generated/prisma/enums";
+import { adminSearch } from "@/lib/admin/search";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(req: Request) {
+ await requireRole([UserRole.ADMIN]);
+ const url = new URL(req.url);
+ const q = url.searchParams.get("q") ?? "";
+ const hits = await adminSearch(q);
+ return NextResponse.json({ hits });
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index 63f3cb9..77cad1f 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -70,6 +70,23 @@ body[data-theme="aquarelle"] [class*="border-gray-"] {
border-color: rgba(140, 61, 24, 0.25);
}
+/* === Theme Admin (route /admin/...) === */
+/* Indépendant des themes publics. Sobre, gris/blanc, accent ocre Karbé,
+ typographie sans-serif neutre. Pas de texture grain. Lisible en
+ permanence peu importe le toggle Aquarelle/Guyane côté site public. */
+[data-admin] {
+ --background: #fafafa;
+ --foreground: #18181b;
+ font-family: var(--font-geist-sans), system-ui, sans-serif;
+ background-image: none !important;
+}
+[data-admin] [class*="border-zinc-"],
+[data-admin] [class*="border-gray-"] {
+ /* Restaure des borders neutres dans l'admin si theme aquarelle est actif
+ côté body (qui les surcharge en sépia). */
+ border-color: #e4e4e7;
+}
+
@media (prefers-color-scheme: dark) {
:root:not([data-theme="guyane"]):not([data-theme="aquarelle"]) {
--background: #0a0a0a;
diff --git a/src/components/admin/Breadcrumbs.tsx b/src/components/admin/Breadcrumbs.tsx
new file mode 100644
index 0000000..1206bf7
--- /dev/null
+++ b/src/components/admin/Breadcrumbs.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+
+const LABELS: Record = {
+ admin: "Admin",
+ carbets: "Carbets",
+ bookings: "Réservations",
+ reviews: "Avis",
+ users: "Utilisateurs",
+ organizations: "Organisations",
+ "pirogue-providers": "Prestataires",
+ media: "Médias",
+ "content-pages": "Pages",
+ plugins: "Plugins",
+ settings: "Paramètres",
+ audit: "Audit log",
+};
+
+export function Breadcrumbs() {
+ const pathname = usePathname();
+ if (!pathname.startsWith("/admin")) return null;
+ const parts = pathname.split("/").filter(Boolean);
+ // skip if just /admin
+ if (parts.length <= 1) return null;
+ return (
+
+ );
+}
diff --git a/src/components/admin/CommandPalette.tsx b/src/components/admin/CommandPalette.tsx
new file mode 100644
index 0000000..16f82dd
--- /dev/null
+++ b/src/components/admin/CommandPalette.tsx
@@ -0,0 +1,177 @@
+"use client";
+
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { useCallback, useEffect, useRef, useState } from "react";
+import type { SearchHit } from "@/lib/admin/search";
+
+const TYPE_LABEL: Record = {
+ carbet: "Carbet",
+ user: "Utilisateur",
+ booking: "Réservation",
+ page: "Page",
+ provider: "Prestataire",
+};
+const TYPE_ACCENT: Record = {
+ carbet: "bg-emerald-100 text-emerald-800",
+ user: "bg-sky-100 text-sky-800",
+ booking: "bg-amber-100 text-amber-800",
+ page: "bg-violet-100 text-violet-800",
+ provider: "bg-rose-100 text-rose-800",
+};
+
+/**
+ * Palette ⌘K minimaliste, sans dépendance externe. Server search via
+ * /api/admin/search?q=…, navigation au clavier (↑/↓/Enter/Esc).
+ */
+export function CommandPalette() {
+ const router = useRouter();
+ const [open, setOpen] = useState(false);
+ const [query, setQuery] = useState("");
+ const [hits, setHits] = useState([]);
+ const [selected, setSelected] = useState(0);
+ const [loading, setLoading] = useState(false);
+ const inputRef = useRef(null);
+ const abortRef = useRef(null);
+
+ // Ouvre la palette sur ⌘K / Ctrl+K. Esc ferme.
+ useEffect(() => {
+ function onKey(e: KeyboardEvent) {
+ const cmd = e.metaKey || e.ctrlKey;
+ if (cmd && e.key.toLowerCase() === "k") {
+ e.preventDefault();
+ setOpen((v) => !v);
+ } else if (e.key === "Escape") {
+ setOpen(false);
+ }
+ }
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, []);
+
+ useEffect(() => {
+ if (open) {
+ setQuery("");
+ setHits([]);
+ setSelected(0);
+ setTimeout(() => inputRef.current?.focus(), 50);
+ }
+ }, [open]);
+
+ const runSearch = useCallback(async (q: string) => {
+ if (q.trim().length < 2) {
+ setHits([]);
+ return;
+ }
+ abortRef.current?.abort();
+ const ac = new AbortController();
+ abortRef.current = ac;
+ setLoading(true);
+ try {
+ const r = await fetch(`/api/admin/search?q=${encodeURIComponent(q)}`, { signal: ac.signal });
+ if (r.ok) {
+ const j = await r.json();
+ setHits(j.hits ?? []);
+ setSelected(0);
+ }
+ } catch {
+ // aborted ou erreur silencieuse
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ const id = setTimeout(() => runSearch(query), 150);
+ return () => clearTimeout(id);
+ }, [query, runSearch]);
+
+ function onListKey(e: React.KeyboardEvent) {
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ setSelected((s) => Math.min(s + 1, hits.length - 1));
+ } else if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setSelected((s) => Math.max(s - 1, 0));
+ } else if (e.key === "Enter") {
+ e.preventDefault();
+ const hit = hits[selected];
+ if (hit) {
+ setOpen(false);
+ router.push(hit.href);
+ }
+ }
+ }
+
+ if (!open) return null;
+
+ return (
+ setOpen(false)}
+ >
+
e.stopPropagation()}
+ >
+
+
+
setQuery(e.target.value)}
+ onKeyDown={onListKey}
+ className="flex-1 bg-transparent text-sm text-zinc-900 placeholder-zinc-400 focus:outline-none"
+ />
+
+ ESC
+
+
+
+
+ {loading ? (
+
…
+ ) : query.length >= 2 && hits.length === 0 ? (
+
Aucun résultat.
+ ) : hits.length === 0 ? (
+
+ Tape au moins 2 caractères. Navigation : ↑ ↓ / Entrée.
+
+ ) : (
+
+ {hits.map((h, i) => (
+ -
+ setOpen(false)}
+ onMouseEnter={() => setSelected(i)}
+ className={`flex items-center justify-between gap-3 px-3 py-2 text-sm ${
+ i === selected ? "bg-zinc-100" : "hover:bg-zinc-50"
+ }`}
+ >
+
+
+ {TYPE_LABEL[h.type]}
+
+ {h.title}
+
+ {h.subtitle ? (
+ {h.subtitle}
+ ) : null}
+
+
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/admin/KPICard.tsx b/src/components/admin/KPICard.tsx
new file mode 100644
index 0000000..0b83f29
--- /dev/null
+++ b/src/components/admin/KPICard.tsx
@@ -0,0 +1,44 @@
+import type { ReactNode } from "react";
+
+type Tone = "neutral" | "ok" | "warn" | "info";
+
+const toneStyles: Record = {
+ neutral: "border-zinc-200 bg-white",
+ ok: "border-emerald-200 bg-emerald-50",
+ warn: "border-amber-200 bg-amber-50",
+ info: "border-sky-200 bg-sky-50",
+};
+
+const toneText: Record = {
+ neutral: "text-zinc-900",
+ ok: "text-emerald-900",
+ warn: "text-amber-900",
+ info: "text-sky-900",
+};
+
+export function KPICard({
+ label,
+ value,
+ hint,
+ tone = "neutral",
+ icon,
+}: {
+ label: string;
+ value: string | number;
+ hint?: string;
+ tone?: Tone;
+ icon?: ReactNode;
+}) {
+ return (
+
+
+ {label}
+ {icon ? {icon} : null}
+
+
+ {value}
+
+ {hint ?
{hint}
: null}
+
+ );
+}
diff --git a/src/components/admin/Sidebar.tsx b/src/components/admin/Sidebar.tsx
new file mode 100644
index 0000000..05e9695
--- /dev/null
+++ b/src/components/admin/Sidebar.tsx
@@ -0,0 +1,198 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import type { ReactNode } from "react";
+
+type NavItem = {
+ href: string;
+ label: string;
+ icon: ReactNode;
+ badge?: number;
+};
+
+type NavGroup = {
+ label: string;
+ items: NavItem[];
+};
+
+const ICONS = {
+ dashboard: (
+
+ ),
+ carbets: (
+
+ ),
+ bookings: (
+
+ ),
+ users: (
+
+ ),
+ organizations: (
+
+ ),
+ pirogue: (
+
+ ),
+ reviews: (
+
+ ),
+ media: (
+
+ ),
+ pages: (
+
+ ),
+ plugins: (
+
+ ),
+ settings: (
+
+ ),
+ audit: (
+
+ ),
+};
+
+const GROUPS: NavGroup[] = [
+ {
+ label: "Vue d'ensemble",
+ items: [{ href: "/admin", label: "Dashboard", icon: ICONS.dashboard }],
+ },
+ {
+ label: "Catalogue",
+ items: [
+ { href: "/admin/carbets", label: "Carbets", icon: ICONS.carbets },
+ { href: "/admin/pirogue-providers", label: "Prestataires pirogue", icon: ICONS.pirogue },
+ { href: "/admin/media", label: "Médias", icon: ICONS.media },
+ ],
+ },
+ {
+ label: "Activité",
+ items: [
+ { href: "/admin/bookings", label: "Réservations", icon: ICONS.bookings },
+ { href: "/admin/reviews", label: "Avis & modération", icon: ICONS.reviews },
+ ],
+ },
+ {
+ label: "Membres",
+ items: [
+ { href: "/admin/users", label: "Utilisateurs", icon: ICONS.users },
+ { href: "/admin/organizations", label: "Organisations CE", icon: ICONS.organizations },
+ ],
+ },
+ {
+ label: "Contenu",
+ items: [{ href: "/admin/content-pages", label: "Pages éditoriales", icon: ICONS.pages }],
+ },
+ {
+ label: "Système",
+ items: [
+ { href: "/admin/plugins", label: "Plugins", icon: ICONS.plugins },
+ { href: "/admin/settings", label: "Paramètres", icon: ICONS.settings },
+ { href: "/admin/audit", label: "Audit log", icon: ICONS.audit },
+ ],
+ },
+];
+
+export function Sidebar() {
+ const pathname = usePathname();
+
+ return (
+
+ );
+}
diff --git a/src/components/admin/TopBar.tsx b/src/components/admin/TopBar.tsx
new file mode 100644
index 0000000..e06f7c0
--- /dev/null
+++ b/src/components/admin/TopBar.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+export function TopBar({ userEmail }: { userEmail: string }) {
+ const [isMac, setIsMac] = useState(false);
+ useEffect(() => {
+ setIsMac(navigator.userAgent.includes("Mac"));
+ }, []);
+
+ return (
+
+
+ Cmd K pour rechercher
+
+
+
+
+ ↗ Voir le site
+
+
{userEmail}
+
+
+ );
+}
diff --git a/src/lib/admin/kpis.ts b/src/lib/admin/kpis.ts
new file mode 100644
index 0000000..6e593d8
--- /dev/null
+++ b/src/lib/admin/kpis.ts
@@ -0,0 +1,101 @@
+/**
+ * KPIs du dashboard admin Karbé.
+ * Toutes les queries sont scoppées à la company (mono-tenant pour l'instant)
+ * et calculent des chiffres simples mais utiles : activité récente, état du
+ * catalogue, modération à faire.
+ */
+
+import "server-only";
+import { prisma } from "@/lib/prisma";
+import { BookingStatus, CarbetStatus, PaymentStatus } from "@/generated/prisma/enums";
+
+export type AdminKpis = {
+ bookingsThisWeek: number;
+ bookingsConfirmed30d: number;
+ revenue30dCents: number;
+ occupancyPct: number; // 0..100
+ newUsers30d: number;
+ publishedCarbets: number;
+ reviewsToModerate: number;
+};
+
+function startOfWeek(d = new Date()): Date {
+ const x = new Date(d);
+ const day = (x.getDay() + 6) % 7; // 0 = lundi
+ x.setHours(0, 0, 0, 0);
+ x.setDate(x.getDate() - day);
+ return x;
+}
+
+function daysAgo(n: number): Date {
+ const x = new Date();
+ x.setHours(0, 0, 0, 0);
+ x.setDate(x.getDate() - n);
+ return x;
+}
+
+export async function getAdminKpis(): Promise {
+ const weekStart = startOfWeek();
+ const monthStart = daysAgo(30);
+
+ const [
+ bookingsThisWeek,
+ bookingsConfirmed30dList,
+ newUsers30d,
+ publishedCarbets,
+ reviewsToModerate,
+ ] = await Promise.all([
+ prisma.booking.count({
+ where: { startDate: { gte: weekStart } },
+ }),
+ prisma.booking.findMany({
+ where: {
+ status: BookingStatus.CONFIRMED,
+ paymentStatus: PaymentStatus.SUCCEEDED,
+ startDate: { gte: monthStart },
+ },
+ select: { amount: true, startDate: true, endDate: true },
+ }),
+ prisma.user.count({
+ where: { createdAt: { gte: monthStart } },
+ }),
+ prisma.carbet.count({
+ where: { status: CarbetStatus.PUBLISHED },
+ }),
+ prisma.review.count({
+ where: { hostResponse: null },
+ }),
+ ]);
+
+ const revenue30dCents = bookingsConfirmed30dList.reduce(
+ (acc, b) => acc + Math.round(Number(b.amount) * 100),
+ 0,
+ );
+
+ // Occupation = total bookings * jours moyens / (publishedCarbets * 30)
+ // Approximation simple, on raffine en sprint 3.
+ const bookedNights = bookingsConfirmed30dList.reduce((acc, b) => {
+ const diffMs = b.endDate.getTime() - b.startDate.getTime();
+ return acc + Math.max(0, Math.round(diffMs / (1000 * 60 * 60 * 24)));
+ }, 0);
+ const occupancyDen = publishedCarbets * 30;
+ const occupancyPct = occupancyDen > 0 ? Math.min(100, Math.round((bookedNights * 100) / occupancyDen)) : 0;
+
+ return {
+ bookingsThisWeek,
+ bookingsConfirmed30d: bookingsConfirmed30dList.length,
+ revenue30dCents,
+ occupancyPct,
+ newUsers30d,
+ publishedCarbets,
+ reviewsToModerate,
+ };
+}
+
+export function formatEur(cents: number): string {
+ return new Intl.NumberFormat("fr-FR", {
+ style: "currency",
+ currency: "EUR",
+ maximumFractionDigits: 0,
+ }).format(cents / 100);
+}
diff --git a/src/lib/admin/search.ts b/src/lib/admin/search.ts
new file mode 100644
index 0000000..12b85cb
--- /dev/null
+++ b/src/lib/admin/search.ts
@@ -0,0 +1,109 @@
+/**
+ * Recherche globale ⌘K — server function.
+ *
+ * Recherche transversale sur carbets / utilisateurs / réservations /
+ * pages éditoriales / prestataires pirogue. Renvoie au max 5 résultats
+ * par catégorie pour garder la palette lisible.
+ */
+
+import "server-only";
+import { prisma } from "@/lib/prisma";
+
+export type SearchHit = {
+ type: "carbet" | "user" | "booking" | "page" | "provider";
+ id: string;
+ title: string;
+ subtitle?: string;
+ href: string;
+};
+
+export async function adminSearch(query: string): Promise {
+ const q = query.trim();
+ if (q.length < 2) return [];
+ const ci = { contains: q, mode: "insensitive" as const };
+
+ const [carbets, users, bookings, pages, providers] = await Promise.all([
+ prisma.carbet.findMany({
+ where: {
+ OR: [{ slug: ci }, { title: ci }, { river: ci }],
+ },
+ take: 5,
+ select: { id: true, slug: true, title: true, river: true, status: true },
+ }),
+ prisma.user.findMany({
+ where: {
+ OR: [{ email: ci }, { firstName: ci }, { lastName: ci }],
+ },
+ take: 5,
+ select: { id: true, email: true, firstName: true, lastName: true, role: true },
+ }),
+ prisma.booking.findMany({
+ where: { id: ci },
+ take: 5,
+ select: { id: true, status: true, startDate: true, endDate: true },
+ }),
+ prisma.contentPage.findMany({
+ where: {
+ OR: [{ slug: ci }, { title: ci }],
+ lang: "fr",
+ },
+ take: 5,
+ select: { slug: true, title: true, category: true, lang: true },
+ }),
+ prisma.pirogueProvider.findMany({
+ where: { OR: [{ name: ci }] },
+ take: 5,
+ select: { id: true, name: true, rivers: true },
+ }),
+ ]);
+
+ const hits: SearchHit[] = [];
+
+ for (const c of carbets) {
+ hits.push({
+ type: "carbet",
+ id: c.id,
+ title: c.title,
+ subtitle: `${c.river} · ${c.status}`,
+ href: `/admin/carbets/${c.id}`,
+ });
+ }
+ for (const u of users) {
+ hits.push({
+ type: "user",
+ id: u.id,
+ title: `${u.firstName} ${u.lastName}`.trim() || u.email,
+ subtitle: `${u.email} · ${u.role}`,
+ href: `/admin/users/${u.id}`,
+ });
+ }
+ for (const b of bookings) {
+ hits.push({
+ type: "booking",
+ id: b.id,
+ title: `Réservation ${b.id.slice(0, 8)}`,
+ subtitle: `${b.status} · ${b.startDate.toISOString().slice(0, 10)} → ${b.endDate.toISOString().slice(0, 10)}`,
+ href: `/admin/bookings/${b.id}`,
+ });
+ }
+ for (const p of pages) {
+ hits.push({
+ type: "page",
+ id: p.slug,
+ title: p.title,
+ subtitle: `/${p.slug} · ${p.category} · ${p.lang}`,
+ href: `/admin/content-pages/${encodeURIComponent(p.slug)}`,
+ });
+ }
+ for (const p of providers) {
+ hits.push({
+ type: "provider",
+ id: p.id,
+ title: p.name,
+ subtitle: p.rivers.join(" · "),
+ href: `/admin/pirogue-providers/${p.id}`,
+ });
+ }
+
+ return hits;
+}