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

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