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:
parent
ffb39a3bf5
commit
bcb93c6b29
11 changed files with 873 additions and 9 deletions
101
src/lib/admin/kpis.ts
Normal file
101
src/lib/admin/kpis.ts
Normal 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
109
src/lib/admin/search.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue