From bcb93c6b29a2d20176b859726b549cd76ae25910 Mon Sep 17 00:00:00 2001 From: Claude Integration Date: Sun, 31 May 2026 18:21:50 +0000 Subject: [PATCH] =?UTF-8?q?feat(admin):=20shell=20admin=20+=20dashboard=20?= =?UTF-8?q?KPI=20+=20recherche=20=E2=8C=98K=20(Sprint=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layout admin : - src/app/admin/layout.tsx : route protégée requireRole(ADMIN), sidebar + topbar + breadcrumbs, data-admin sur racine pour theme sobre indépendant du theme public - Sidebar : 12 sections groupées (Vue d'ensemble, Catalogue, Activité, Membres, Contenu, Système), highlight de la route courante - TopBar : prompt ⌘K, lien vers site public, email admin - Breadcrumbs : auto depuis pathname - CommandPalette : ⌘K / Ctrl K, navigation ↑↓ + Entrée, recherche live debounced 150ms Dashboard : - 7 KPI cards avec tone neutral/ok/warn/info (réservations semaine, confirmées 30j, revenus reversés, occupation, nouveaux users, carbets publiés, avis à modérer) - Section raccourcis fréquents Theme admin : - globals.css : [data-admin] override le background+font, neutralise les borders sépia/papier teinté du theme aquarelle, garantit lisibilité permanente Recherche globale : - lib/admin/search.ts : query parallèle sur Carbet, User, Booking, ContentPage, PirogueProvider (5 résultats par catégorie, LIKE insensitive) - api/admin/search?q=… route handler avec requireRole KPI : - lib/admin/kpis.ts : 7 métriques live (cache 0), Promise.all, helper formatEur Pas de dépendance externe ajoutée (cmdk, shadcn) — composants custom Tailwind pour rester léger. --- src/app/admin/layout.tsx | 24 +++ src/app/admin/page.tsx | 106 +++++++++++-- src/app/api/admin/search/route.ts | 14 ++ src/app/globals.css | 17 ++ src/components/admin/Breadcrumbs.tsx | 46 ++++++ src/components/admin/CommandPalette.tsx | 177 +++++++++++++++++++++ src/components/admin/KPICard.tsx | 44 ++++++ src/components/admin/Sidebar.tsx | 198 ++++++++++++++++++++++++ src/components/admin/TopBar.tsx | 46 ++++++ src/lib/admin/kpis.ts | 101 ++++++++++++ src/lib/admin/search.ts | 109 +++++++++++++ 11 files changed, 873 insertions(+), 9 deletions(-) create mode 100644 src/app/admin/layout.tsx create mode 100644 src/app/api/admin/search/route.ts create mode 100644 src/components/admin/Breadcrumbs.tsx create mode 100644 src/components/admin/CommandPalette.tsx create mode 100644 src/components/admin/KPICard.tsx create mode 100644 src/components/admin/Sidebar.tsx create mode 100644 src/components/admin/TopBar.tsx create mode 100644 src/lib/admin/kpis.ts create mode 100644 src/lib/admin/search.ts 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}). -

-
+
+
+

Tableau de bord

+

+ Vue d'ensemble de l'activité Karbé. Données live (cache 0). +

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