Merge pull request 'feat(admin): shell + dashboard + ⌘K (Sprint 1)' (#40) from feat/admin-shell-foundation into main

This commit is contained in:
tarzzan 2026-05-31 18:22:08 +00:00
commit 3ec7a3ff10
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>
);
}

View file

@ -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 });
}

View file

@ -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;

View file

@ -0,0 +1,46 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
const LABELS: Record<string, string> = {
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 (
<nav className="flex items-center gap-1 px-4 pt-3 text-xs text-zinc-500">
{parts.map((p, i) => {
const href = "/" + parts.slice(0, i + 1).join("/");
const isLast = i === parts.length - 1;
const label = LABELS[p] ?? decodeURIComponent(p);
return (
<span key={href} className="flex items-center gap-1">
{i > 0 ? <span className="text-zinc-300">/</span> : null}
{isLast ? (
<span className="text-zinc-700">{label}</span>
) : (
<Link href={href} className="hover:text-zinc-900">{label}</Link>
)}
</span>
);
})}
</nav>
);
}

View file

@ -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<string, string> = {
carbet: "Carbet",
user: "Utilisateur",
booking: "Réservation",
page: "Page",
provider: "Prestataire",
};
const TYPE_ACCENT: Record<string, string> = {
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<SearchHit[]>([]);
const [selected, setSelected] = useState(0);
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const abortRef = useRef<AbortController | null>(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<HTMLInputElement>) {
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 (
<div
className="fixed inset-0 z-50 flex items-start justify-center bg-zinc-900/30 px-4 pt-[12vh] backdrop-blur-sm"
onClick={() => setOpen(false)}
>
<div
className="w-full max-w-xl overflow-hidden rounded-lg border border-zinc-200 bg-white shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-zinc-200 px-3 py-2">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-zinc-400">
<circle cx="11" cy="11" r="7" />
<path d="M21 21 L17 17" />
</svg>
<input
ref={inputRef}
type="text"
value={query}
placeholder="Rechercher un carbet, un user, une résa…"
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onListKey}
className="flex-1 bg-transparent text-sm text-zinc-900 placeholder-zinc-400 focus:outline-none"
/>
<kbd className="rounded border border-zinc-300 bg-zinc-100 px-1.5 py-0.5 text-[10px] text-zinc-500">
ESC
</kbd>
</div>
<div className="max-h-96 overflow-y-auto py-1">
{loading ? (
<div className="px-4 py-3 text-sm text-zinc-500"></div>
) : query.length >= 2 && hits.length === 0 ? (
<div className="px-4 py-3 text-sm text-zinc-500">Aucun résultat.</div>
) : hits.length === 0 ? (
<div className="px-4 py-3 text-sm text-zinc-500">
Tape au moins 2 caractères. Navigation : / Entrée.
</div>
) : (
<ul>
{hits.map((h, i) => (
<li key={`${h.type}-${h.id}`}>
<Link
href={h.href}
onClick={() => 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"
}`}
>
<span className="flex items-center gap-2 truncate">
<span
className={`rounded px-1.5 py-0.5 text-[10px] font-semibold ${TYPE_ACCENT[h.type]}`}
>
{TYPE_LABEL[h.type]}
</span>
<span className="truncate text-zinc-900">{h.title}</span>
</span>
{h.subtitle ? (
<span className="truncate text-xs text-zinc-500">{h.subtitle}</span>
) : null}
</Link>
</li>
))}
</ul>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,44 @@
import type { ReactNode } from "react";
type Tone = "neutral" | "ok" | "warn" | "info";
const toneStyles: Record<Tone, string> = {
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<Tone, string> = {
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 (
<div className={`rounded-lg border ${toneStyles[tone]} p-5 shadow-sm`}>
<div className="flex items-start justify-between gap-2">
<span className="text-xs font-medium uppercase tracking-wider text-zinc-500">{label}</span>
{icon ? <span className="text-zinc-400">{icon}</span> : null}
</div>
<div className={`mt-2 font-mono text-3xl font-semibold tracking-tight ${toneText[tone]}`}>
{value}
</div>
{hint ? <div className="mt-1 text-xs text-zinc-500">{hint}</div> : null}
</div>
);
}

View file

@ -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: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
),
carbets: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 21 L12 6 L21 21 Z" />
<path d="M9 21 V13 H15 V21" />
</svg>
),
bookings: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="5" width="18" height="16" rx="2" />
<path d="M3 10 H21" />
<path d="M8 3 V7" />
<path d="M16 3 V7" />
</svg>
),
users: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="9" cy="8" r="3" />
<path d="M3 21 Q3 14 9 14 Q15 14 15 21" />
<circle cx="17" cy="9" r="2.5" />
<path d="M14 16 Q17 13 21 16" />
</svg>
),
organizations: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="4" y="6" width="16" height="14" rx="1" />
<path d="M4 10 H20" />
<path d="M9 10 V20" />
<path d="M15 10 V20" />
</svg>
),
pirogue: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M3 14 Q12 22 21 14" />
<path d="M5 12 H19" />
<path d="M9 12 V8" />
<path d="M15 12 V8" />
</svg>
),
reviews: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2 L14.5 8.5 L21 9 L16 14 L17.5 21 L12 17.5 L6.5 21 L8 14 L3 9 L9.5 8.5 Z" />
</svg>
),
media: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="9" cy="9" r="2" />
<path d="M3 17 L9 11 L21 19" />
</svg>
),
pages: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 3 H15 L19 7 V21 H5 Z" />
<path d="M14 3 V8 H19" />
<path d="M9 13 H15" />
<path d="M9 17 H13" />
</svg>
),
plugins: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<path d="M17 14 V21 M14 17 H20" />
</svg>
),
settings: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="3" />
<path d="M12 1 V5 M12 19 V23 M4.2 4.2 L7 7 M17 17 L19.8 19.8 M1 12 H5 M19 12 H23 M4.2 19.8 L7 17 M17 7 L19.8 4.2" />
</svg>
),
audit: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M4 4 H20 V20 H4 Z" />
<path d="M8 9 H16 M8 13 H16 M8 17 H12" />
</svg>
),
};
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 (
<nav className="flex h-full w-60 shrink-0 flex-col gap-1 border-r border-zinc-200 bg-[#f7f5f0] py-4">
<Link
href="/admin"
className="mx-3 mb-3 flex items-center gap-2 rounded px-2 py-1.5 text-sm font-semibold text-zinc-900"
>
<span className="inline-block h-2.5 w-2.5 rounded-full bg-[#8c3d18]" />
Karbé · Admin
</Link>
{GROUPS.map((group) => (
<section key={group.label} className="mb-2 px-3">
<h3 className="mb-1 px-2 text-[10px] font-semibold uppercase tracking-wider text-zinc-500">
{group.label}
</h3>
<ul className="space-y-0.5">
{group.items.map((item) => {
const active = pathname === item.href || (item.href !== "/admin" && pathname.startsWith(item.href));
return (
<li key={item.href}>
<Link
href={item.href}
className={`flex items-center justify-between rounded px-2 py-1.5 text-sm transition ${
active
? "bg-white text-zinc-900 shadow-sm ring-1 ring-zinc-200"
: "text-zinc-600 hover:bg-white/60 hover:text-zinc-900"
}`}
>
<span className="flex items-center gap-2">
<span className={active ? "text-[#8c3d18]" : "text-zinc-400"}>{item.icon}</span>
{item.label}
</span>
{item.badge !== undefined ? (
<span className="rounded-full bg-zinc-200 px-1.5 py-0.5 text-[10px] font-medium text-zinc-700">
{item.badge}
</span>
) : null}
</Link>
</li>
);
})}
</ul>
</section>
))}
</nav>
);
}

View file

@ -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 (
<div className="flex h-12 shrink-0 items-center justify-between gap-3 border-b border-zinc-200 bg-white px-4">
<div className="flex items-center gap-2 text-xs text-zinc-500">
<span>Cmd&nbsp;K pour rechercher</span>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => {
const ev = new KeyboardEvent("keydown", {
key: "k",
metaKey: isMac,
ctrlKey: !isMac,
bubbles: true,
});
window.dispatchEvent(ev);
}}
className="hidden items-center gap-2 rounded border border-zinc-200 bg-zinc-50 px-2.5 py-1 text-xs text-zinc-600 hover:border-zinc-300 hover:bg-white sm:inline-flex"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="7" />
<path d="M21 21 L17 17" />
</svg>
Rechercher
<kbd className="rounded bg-zinc-200 px-1 text-[10px] text-zinc-600">
{isMac ? "⌘K" : "Ctrl K"}
</kbd>
</button>
<a href="/" className="text-xs text-zinc-500 hover:text-zinc-900" target="_blank" rel="noreferrer">
Voir le site
</a>
<span className="text-xs text-zinc-500">{userEmail}</span>
</div>
</div>
);
}

101
src/lib/admin/kpis.ts Normal file
View file

@ -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<AdminKpis> {
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);
}

109
src/lib/admin/search.ts Normal file
View file

@ -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<SearchHit[]> {
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;
}