feat(admin): shell admin + dashboard KPI + recherche ⌘K (Sprint 1)

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.
This commit is contained in:
Claude Integration 2026-05-31 18:21:50 +00:00
parent ffb39a3bf5
commit bcb93c6b29
11 changed files with 873 additions and 9 deletions

24
src/app/admin/layout.tsx Normal file
View file

@ -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 (
<div data-admin className="flex h-screen w-full overflow-hidden bg-zinc-50">
<Sidebar />
<div className="flex min-w-0 flex-1 flex-col">
<TopBar userEmail={session.user.email ?? ""} />
<Breadcrumbs />
<main className="flex-1 overflow-y-auto px-4 pb-12 pt-3">{children}</main>
</div>
<CommandPalette />
</div>
);
}

View file

@ -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 (
<main className="mx-auto max-w-4xl px-6 py-12">
<h1 className="text-3xl font-semibold">Espace administrateur</h1>
<p className="mt-4 text-zinc-700">
Accès autorisé pour {session.user.email} ({session.user.role}).
</p>
</main>
<div className="mx-auto max-w-6xl">
<header className="mb-6 mt-2">
<h1 className="text-2xl font-semibold text-zinc-900">Tableau de bord</h1>
<p className="mt-1 text-sm text-zinc-500">
Vue d&apos;ensemble de l&apos;activité Karbé. Données live (cache 0).
</p>
</header>
<section className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<KPICard
label="Réservations cette semaine"
value={kpis.bookingsThisWeek}
hint="Toutes statuts confondus, démarrage dans la semaine en cours."
tone="info"
/>
<KPICard
label="Réservations confirmées · 30 j"
value={kpis.bookingsConfirmed30d}
hint="CONFIRMED + paiement SUCCEEDED, démarrage J-30."
tone="ok"
/>
<KPICard
label="Revenus reversés · 30 j"
value={formatEur(kpis.revenue30dCents)}
hint="Somme des montants confirmés (reversement loueurs)."
tone="ok"
/>
<KPICard
label="Occupation moyenne · 30 j"
value={`${kpis.occupancyPct} %`}
hint="Nuits réservées / (carbets publiés × 30)."
tone={kpis.occupancyPct > 50 ? "ok" : "neutral"}
/>
<KPICard
label="Nouveaux comptes · 30 j"
value={kpis.newUsers30d}
hint="Inscriptions tous rôles confondus."
tone="info"
/>
<KPICard
label="Carbets publiés"
value={kpis.publishedCarbets}
hint="Catalogue actif (status PUBLISHED)."
tone="neutral"
/>
<KPICard
label="Avis à modérer"
value={kpis.reviewsToModerate}
hint="Aucune réponse de l&apos;hôte enregistrée."
tone={kpis.reviewsToModerate > 5 ? "warn" : "neutral"}
/>
</section>
<section className="mt-10 rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="text-sm font-semibold uppercase tracking-wider text-zinc-500">
Raccourcis fréquents
</h2>
<ul className="mt-3 grid grid-cols-1 gap-2 text-sm sm:grid-cols-2 lg:grid-cols-3">
<li>
<a href="/admin/carbets" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
Gérer les carbets
</a>
</li>
<li>
<a href="/admin/bookings" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
Voir les réservations
</a>
</li>
<li>
<a href="/admin/content-pages" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
Éditer les pages
</a>
</li>
<li>
<a href="/admin/plugins" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
Activer / désactiver des plugins
</a>
</li>
<li>
<a href="/admin/users" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
Modérer les utilisateurs
</a>
</li>
<li>
<a href="/admin/settings" className="block rounded border border-zinc-200 px-3 py-2 hover:border-zinc-300 hover:bg-zinc-50">
Paramètres
</a>
</li>
</ul>
</section>
</div>
);
}