feat(analytics): Sprint N — dashboards CE + admin
All checks were successful
CI / test (pull_request) Successful in 2m34s

src/lib/analytics.ts (NEW) — 3 queries server-only :
- getMonthlyRevenueSeries({organizationId?, monthsBack=12}) → 12
  buckets « YYYY-MM » avec carbetRevenue + rentalRevenue + total.
  Scope optionnel par org via memberships (Booking) et
  provider.organizationId (RentalBooking).
- getCarbetsOccupancy({organizationId?, monthsBack=3}) → liste triée
  par occupancyPct avec bookedNights/totalNights pour chaque carbet
  PUBLISHED (filtré par memberships si org).
- getAdminGlobalKpis() → users (total + breakdown par rôle),
  carbetsPublished, bookings/rentals 30j, revenue 30j, top 5 carbets
  + top 5 providers par CA 30j.

src/components/analytics/MonthlyRevenueChart.tsx (NEW) — bar chart
SVG simple (pas de lib externe), stack carbet + rental, grid Y, tooltips
via <title>, légende couleurs. Responsive overflow-x-auto.

/espace-ce/analytics/page.tsx (NEW) :
- 3 KPIs (CA 12 mois total / Carbet / Matériel)
- MonthlyRevenueChart scopé par org
- Liste taux d'occupation carbets 3 derniers mois (barres horizontales)
- Lien ajouté depuis le dashboard /espace-ce

/admin/analytics/page.tsx (NEW) :
- 4 KPIs (utilisateurs, carbets publiés, bookings 30j, CA 30j)
- Breakdown users par rôle (barres horizontales + pourcentages)
- Carte « Activité 30j » avec bookings carbet + locations matériel
- MonthlyRevenueChart global
- Top 5 carbets (CA 30j) + Top 5 prestataires rental (CA 30j)
- Sidebar admin gagne entrée « Analytics » sous « Vue d'ensemble »

Pas de nouvelle dépendance npm — graphiques en SVG natif.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ubuntu 2026-06-03 02:46:01 +00:00
parent 73d24b70f7
commit 0dc560385d
6 changed files with 604 additions and 2 deletions

View file

@ -0,0 +1,169 @@
import Link from "next/link";
import { MonthlyRevenueChart } from "@/components/analytics/MonthlyRevenueChart";
import { getAdminGlobalKpis, getMonthlyRevenueSeries } from "@/lib/analytics";
export const dynamic = "force-dynamic";
export const metadata = { title: "Analytics globaux — Karbé admin" };
const ROLE_LABEL: Record<string, string> = {
ADMIN: "Admin",
OWNER: "Hôte",
RENTAL_PROVIDER: "Loueur matériel",
CE_MANAGER: "CE Manager",
CE_MEMBER: "CE Membre",
TOURIST: "Voyageur",
};
function fmtEur(n: number): string {
return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 });
}
export default async function AdminAnalyticsPage() {
const [kpis, series] = await Promise.all([
getAdminGlobalKpis(),
getMonthlyRevenueSeries({ monthsBack: 12 }),
]);
return (
<div className="mx-auto max-w-6xl space-y-6">
<header className="mt-2">
<h1 className="text-2xl font-semibold text-zinc-900">Analytics globaux</h1>
<p className="mt-1 text-sm text-zinc-500">
Vue d&apos;ensemble plateforme : utilisateurs, activité 30 derniers jours, top performers.
</p>
</header>
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<KpiCard label="Utilisateurs" value={kpis.usersTotal} />
<KpiCard label="Carbets publiés" value={kpis.carbetsPublished} />
<KpiCard label="Bookings 30j" value={kpis.bookings30d} />
<KpiCard label="CA 30j" value={fmtEur(kpis.revenue30d)} />
</section>
<section className="grid gap-4 lg:grid-cols-2">
<div className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Utilisateurs par rôle
</h2>
{kpis.usersTotal === 0 ? (
<p className="text-sm text-zinc-500">Aucun utilisateur.</p>
) : (
<ul className="space-y-1.5 text-sm">
{Object.entries(kpis.usersByRole)
.sort((a, b) => b[1] - a[1])
.map(([role, count]) => {
const pct = Math.round((count / kpis.usersTotal) * 100);
return (
<li key={role}>
<div className="flex items-baseline justify-between">
<span className="text-zinc-700">{ROLE_LABEL[role] ?? role}</span>
<span className="font-mono text-xs text-zinc-700">
{count} ({pct}%)
</span>
</div>
<div className="mt-0.5 h-1.5 overflow-hidden rounded-full bg-zinc-100">
<div
className="h-full bg-emerald-500"
style={{ width: `${pct}%` }}
/>
</div>
</li>
);
})}
</ul>
)}
</div>
<div className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Activité 30 derniers jours
</h2>
<ul className="space-y-2 text-sm">
<li className="flex items-baseline justify-between">
<span className="text-zinc-700">Bookings carbet</span>
<span className="font-mono font-semibold text-zinc-900">{kpis.bookings30d}</span>
</li>
<li className="flex items-baseline justify-between">
<span className="text-zinc-700">Locations matériel</span>
<span className="font-mono font-semibold text-zinc-900">{kpis.rentals30d}</span>
</li>
<li className="flex items-baseline justify-between border-t border-zinc-100 pt-2">
<span className="font-semibold text-zinc-900">Total CA 30j</span>
<span className="font-mono font-semibold text-emerald-700">
{fmtEur(kpis.revenue30d)}
</span>
</li>
</ul>
</div>
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Chiffre d&apos;affaires mensuel
</h2>
<MonthlyRevenueChart data={series} />
</section>
<section className="grid gap-4 lg:grid-cols-2">
<div className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Top carbets (30j)
</h2>
{kpis.topCarbets.length === 0 ? (
<p className="text-sm text-zinc-500">Aucune réservation sur les 30 derniers jours.</p>
) : (
<ul className="space-y-2 text-sm">
{kpis.topCarbets.map((c, i) => (
<li key={c.carbetId} className="flex items-baseline justify-between">
<span>
<span className="mr-2 text-xs text-zinc-500">#{i + 1}</span>
<Link href={`/admin/carbets/${c.carbetId}`} className="text-zinc-900 hover:underline">
{c.title}
</Link>
</span>
<span className="font-mono text-zinc-700">{fmtEur(c.revenue)}</span>
</li>
))}
</ul>
)}
</div>
<div className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Top prestataires rental (30j)
</h2>
{kpis.topProviders.length === 0 ? (
<p className="text-sm text-zinc-500">Aucune location sur les 30 derniers jours.</p>
) : (
<ul className="space-y-2 text-sm">
{kpis.topProviders.map((p, i) => (
<li key={p.providerId} className="flex items-baseline justify-between">
<span>
<span className="mr-2 text-xs text-zinc-500">#{i + 1}</span>
<Link
href={`/admin/rental-providers/${p.providerId}`}
className="text-zinc-900 hover:underline"
>
{p.name}
</Link>
</span>
<span className="font-mono text-zinc-700">{fmtEur(p.revenue)}</span>
</li>
))}
</ul>
)}
</div>
</section>
</div>
);
}
function KpiCard({ label, value }: { label: string; value: string | number }) {
return (
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3 shadow-sm">
<div className="text-[10px] uppercase tracking-wider text-zinc-500">{label}</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900 font-mono">{value}</div>
</div>
);
}

View file

@ -0,0 +1,95 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { MonthlyRevenueChart } from "@/components/analytics/MonthlyRevenueChart";
import { getCarbetsOccupancy, getMonthlyRevenueSeries } from "@/lib/analytics";
import { getCurrentCeOrganization } from "@/lib/ce-access";
export const dynamic = "force-dynamic";
export const metadata = { title: "Analytics CE — Karbé" };
function fmtEur(n: number): string {
return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 });
}
export default async function CeAnalyticsPage() {
const org = await getCurrentCeOrganization();
if (!org) redirect("/admin/organizations");
const [series, occupancy] = await Promise.all([
getMonthlyRevenueSeries({ organizationId: org.id, monthsBack: 12 }),
getCarbetsOccupancy({ organizationId: org.id, monthsBack: 3 }),
]);
const total12m = series.reduce((s, p) => s + p.total, 0);
const totalCarbet12m = series.reduce((s, p) => s + p.carbetRevenue, 0);
const totalRental12m = series.reduce((s, p) => s + p.rentalRevenue, 0);
return (
<main className="mx-auto max-w-5xl px-6 py-10 space-y-6">
<header>
<Link href="/espace-ce" className="text-xs text-zinc-500 hover:text-zinc-900">
Tableau de bord CE
</Link>
<h1 className="mt-1 text-3xl font-semibold text-zinc-900">
Analytics {org.name}
</h1>
<p className="mt-1 text-sm text-zinc-600">
Chiffre d&apos;affaires des 12 derniers mois et taux d&apos;occupation des carbets co-gérés.
</p>
</header>
<section className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<KpiCard label="CA 12 mois" value={fmtEur(total12m)} />
<KpiCard label="dont Carbet" value={fmtEur(totalCarbet12m)} />
<KpiCard label="dont Matériel" value={fmtEur(totalRental12m)} />
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Chiffre d&apos;affaires mensuel
</h2>
<MonthlyRevenueChart data={series} />
</section>
<section className="rounded-lg border border-zinc-200 bg-white p-5 shadow-sm">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-zinc-500">
Taux d&apos;occupation des carbets (3 derniers mois)
</h2>
{occupancy.length === 0 ? (
<p className="text-sm text-zinc-500">Pas encore de carbet publié.</p>
) : (
<ul className="space-y-2">
{occupancy.map((c) => (
<li key={c.carbetId}>
<div className="flex items-baseline justify-between text-sm">
<Link href={`/carbets/${c.slug}`} className="font-medium text-zinc-900 hover:underline">
{c.title}
</Link>
<span className="font-mono text-zinc-700">
{c.occupancyPct} % ({c.bookedNights}/{c.totalNights} nuits)
</span>
</div>
<div className="mt-1 h-2 overflow-hidden rounded-full bg-zinc-100">
<div
className="h-full bg-emerald-500"
style={{ width: `${Math.min(100, c.occupancyPct)}%` }}
/>
</div>
</li>
))}
</ul>
)}
</section>
</main>
);
}
function KpiCard({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-zinc-200 bg-white px-4 py-3 shadow-sm">
<div className="text-[10px] uppercase tracking-wider text-zinc-500">{label}</div>
<div className="mt-1 text-xl font-semibold text-zinc-900 font-mono">{value}</div>
</div>
);
}

View file

@ -85,7 +85,11 @@ export default async function CeDashboardPage() {
</section>
<p className="text-xs text-zinc-500">
Gérez aussi vos{" "}
Voir aussi vos{" "}
<Link href="/espace-ce/analytics" className="text-zinc-700 underline hover:text-zinc-900">
analytics CA & occupation
</Link>{" "}
et gérez vos{" "}
<Link href="/espace-ce/membres" className="text-zinc-700 underline hover:text-zinc-900">
membres et invitations CE
</Link>

View file

@ -108,7 +108,10 @@ const ICONS = {
const GROUPS: NavGroup[] = [
{
label: "Vue d'ensemble",
items: [{ href: "/admin", label: "Dashboard", icon: ICONS.dashboard }],
items: [
{ href: "/admin", label: "Dashboard", icon: ICONS.dashboard },
{ href: "/admin/analytics", label: "Analytics", icon: ICONS.dashboard },
],
},
{
label: "Catalogue",

View file

@ -0,0 +1,113 @@
/**
* Bar chart SVG simple pas de lib externe. Stack carbetRevenue + rentalRevenue.
* Affiche les 12 derniers mois en barres verticales.
*/
type Point = {
month: string;
carbetRevenue: number;
rentalRevenue: number;
total: number;
};
const MONTH_LABEL = ["jan", "fév", "mar", "avr", "mai", "jun", "jul", "aoû", "sep", "oct", "nov", "déc"];
function shortMonth(ym: string): string {
const [, m] = ym.split("-");
return MONTH_LABEL[parseInt(m, 10) - 1] ?? ym;
}
function fmtEur(n: number): string {
return n.toLocaleString("fr-FR", { style: "currency", currency: "EUR", maximumFractionDigits: 0 });
}
export function MonthlyRevenueChart({ data }: { data: Point[] }) {
const max = Math.max(1, ...data.map((d) => d.total));
const width = Math.max(360, data.length * 40);
const height = 180;
const padBottom = 24;
const padTop = 8;
const barWidth = width / data.length - 8;
const usableHeight = height - padTop - padBottom;
return (
<div className="overflow-x-auto">
<svg
viewBox={`0 0 ${width} ${height}`}
className="w-full max-w-full"
role="img"
aria-label="Chiffre d'affaires mensuel"
>
{/* Y-axis grid */}
{[0, 0.25, 0.5, 0.75, 1].map((p) => {
const y = padTop + usableHeight * (1 - p);
return (
<g key={p}>
<line x1={36} x2={width} y1={y} y2={y} stroke="#e4e4e7" strokeWidth={1} strokeDasharray="2 4" />
<text x={4} y={y + 3} fontSize={9} fill="#71717a">
{fmtEur(max * p)}
</text>
</g>
);
})}
{data.map((d, i) => {
const x = 40 + i * (width / data.length) + 4;
const carbetH = (d.carbetRevenue / max) * usableHeight;
const rentalH = (d.rentalRevenue / max) * usableHeight;
const baseY = padTop + usableHeight;
return (
<g key={d.month}>
{/* Carbet revenue (bas) */}
{carbetH > 0 ? (
<rect
x={x}
y={baseY - carbetH}
width={barWidth}
height={carbetH}
fill="#059669"
rx={2}
>
<title>
Carbet {d.month} : {fmtEur(d.carbetRevenue)}
</title>
</rect>
) : null}
{/* Rental revenue (top de la stack) */}
{rentalH > 0 ? (
<rect
x={x}
y={baseY - carbetH - rentalH}
width={barWidth}
height={rentalH}
fill="#f59e0b"
rx={2}
>
<title>
Matériel {d.month} : {fmtEur(d.rentalRevenue)}
</title>
</rect>
) : null}
<text
x={x + barWidth / 2}
y={height - 6}
fontSize={10}
textAnchor="middle"
fill="#71717a"
>
{shortMonth(d.month)}
</text>
</g>
);
})}
</svg>
<div className="mt-2 flex flex-wrap items-center gap-3 text-[11px] text-zinc-600">
<span className="flex items-center gap-1">
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-emerald-600" /> Carbet
</span>
<span className="flex items-center gap-1">
<span className="inline-block h-2.5 w-2.5 rounded-sm bg-amber-500" /> Matériel rental
</span>
</div>
</div>
);
}

218
src/lib/analytics.ts Normal file
View file

@ -0,0 +1,218 @@
import "server-only";
import {
BookingStatus,
RentalBookingStatus,
} from "@/generated/prisma/enums";
import { prisma } from "@/lib/prisma";
const MONTH_MS = 30 * 24 * 60 * 60 * 1000;
export type MonthlyRevenuePoint = {
/** "YYYY-MM" */
month: string;
carbetRevenue: number;
rentalRevenue: number;
total: number;
};
/**
* CA mensuel sur les 12 derniers mois calendaires.
* Scope optionnel par organisation CE filtre via Carbet.organizations (memberships)
* et RentalProvider.organizationId.
*/
export async function getMonthlyRevenueSeries(opts: {
organizationId?: string;
monthsBack?: number;
} = {}): Promise<MonthlyRevenuePoint[]> {
const monthsBack = opts.monthsBack ?? 12;
const since = new Date();
since.setMonth(since.getMonth() - monthsBack);
since.setDate(1);
since.setHours(0, 0, 0, 0);
const carbetWhere = {
status: BookingStatus.CONFIRMED,
createdAt: { gte: since },
...(opts.organizationId
? { carbet: { organizations: { some: { organizationId: opts.organizationId } } } }
: {}),
};
const rentalWhere = {
status: { in: [RentalBookingStatus.CONFIRMED, RentalBookingStatus.HANDED_OVER, RentalBookingStatus.RETURNED] },
createdAt: { gte: since },
...(opts.organizationId ? { provider: { organizationId: opts.organizationId } } : {}),
};
const [bookings, rentals] = await Promise.all([
prisma.booking.findMany({
where: carbetWhere,
select: { amount: true, createdAt: true },
}),
prisma.rentalBooking.findMany({
where: rentalWhere,
select: { amount: true, createdAt: true },
}),
]);
const map = new Map<string, MonthlyRevenuePoint>();
for (let i = 0; i < monthsBack; i++) {
const d = new Date();
d.setMonth(d.getMonth() - (monthsBack - 1 - i));
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
map.set(key, { month: key, carbetRevenue: 0, rentalRevenue: 0, total: 0 });
}
for (const b of bookings) {
const key = `${b.createdAt.getFullYear()}-${String(b.createdAt.getMonth() + 1).padStart(2, "0")}`;
const p = map.get(key);
if (p) p.carbetRevenue += Number(b.amount);
}
for (const r of rentals) {
const key = `${r.createdAt.getFullYear()}-${String(r.createdAt.getMonth() + 1).padStart(2, "0")}`;
const p = map.get(key);
if (p) p.rentalRevenue += Number(r.amount);
}
for (const p of map.values()) p.total = p.carbetRevenue + p.rentalRevenue;
return Array.from(map.values());
}
export type CarbetOccupancy = {
carbetId: string;
title: string;
slug: string;
totalNights: number;
bookedNights: number;
occupancyPct: number;
};
/**
* Taux d'occupation des carbets sur la fenêtre `monthsBack` (par défaut 3).
* Calcule (nuits réservées CONFIRMED fenêtre) / (totalNights de la fenêtre) en %.
*/
export async function getCarbetsOccupancy(opts: {
organizationId?: string;
monthsBack?: number;
} = {}): Promise<CarbetOccupancy[]> {
const monthsBack = opts.monthsBack ?? 3;
const since = new Date(Date.now() - monthsBack * MONTH_MS);
const now = new Date();
const totalNights = Math.max(1, Math.floor((now.getTime() - since.getTime()) / 86_400_000));
const carbets = await prisma.carbet.findMany({
where: {
status: "PUBLISHED",
...(opts.organizationId
? { organizations: { some: { organizationId: opts.organizationId } } }
: {}),
},
select: {
id: true,
title: true,
slug: true,
bookings: {
where: {
status: BookingStatus.CONFIRMED,
startDate: { lt: now },
endDate: { gt: since },
},
select: { startDate: true, endDate: true },
},
},
});
return carbets
.map((c) => {
const booked = c.bookings.reduce((sum, b) => {
const start = Math.max(b.startDate.getTime(), since.getTime());
const end = Math.min(b.endDate.getTime(), now.getTime());
return sum + Math.max(0, Math.floor((end - start) / 86_400_000));
}, 0);
const occupancyPct = Math.round((booked / totalNights) * 1000) / 10;
return {
carbetId: c.id,
title: c.title,
slug: c.slug,
totalNights,
bookedNights: booked,
occupancyPct,
};
})
.sort((a, b) => b.occupancyPct - a.occupancyPct);
}
export type AdminGlobalKpis = {
usersTotal: number;
usersByRole: Record<string, number>;
carbetsPublished: number;
bookings30d: number;
rentals30d: number;
revenue30d: number;
topCarbets: { carbetId: string; title: string; slug: string; revenue: number }[];
topProviders: { providerId: string; name: string; revenue: number }[];
};
export async function getAdminGlobalKpis(): Promise<AdminGlobalKpis> {
const since = new Date(Date.now() - 30 * 86_400_000);
const [usersByRoleRows, carbetsPublished, bookings30d, rentals30d] = await Promise.all([
prisma.user.groupBy({
by: ["role"],
_count: { _all: true },
}),
prisma.carbet.count({ where: { status: "PUBLISHED" } }),
prisma.booking.findMany({
where: { status: BookingStatus.CONFIRMED, createdAt: { gte: since } },
select: { amount: true, carbetId: true, carbet: { select: { title: true, slug: true } } },
}),
prisma.rentalBooking.findMany({
where: {
status: { in: [RentalBookingStatus.CONFIRMED, RentalBookingStatus.HANDED_OVER, RentalBookingStatus.RETURNED] },
createdAt: { gte: since },
},
select: { amount: true, providerId: true, provider: { select: { name: true } } },
}),
]);
const usersByRole: Record<string, number> = {};
let usersTotal = 0;
for (const row of usersByRoleRows) {
usersByRole[row.role] = row._count._all;
usersTotal += row._count._all;
}
const carbetAgg = new Map<string, { title: string; slug: string; revenue: number }>();
for (const b of bookings30d) {
const v = carbetAgg.get(b.carbetId) ?? { title: b.carbet.title, slug: b.carbet.slug, revenue: 0 };
v.revenue += Number(b.amount);
carbetAgg.set(b.carbetId, v);
}
const topCarbets = Array.from(carbetAgg.entries())
.map(([carbetId, v]) => ({ carbetId, ...v }))
.sort((a, b) => b.revenue - a.revenue)
.slice(0, 5);
const providerAgg = new Map<string, { name: string; revenue: number }>();
for (const r of rentals30d) {
const v = providerAgg.get(r.providerId) ?? { name: r.provider.name, revenue: 0 };
v.revenue += Number(r.amount);
providerAgg.set(r.providerId, v);
}
const topProviders = Array.from(providerAgg.entries())
.map(([providerId, v]) => ({ providerId, ...v }))
.sort((a, b) => b.revenue - a.revenue)
.slice(0, 5);
const bookingsRevenue = bookings30d.reduce((s, b) => s + Number(b.amount), 0);
const rentalsRevenue = rentals30d.reduce((s, r) => s + Number(r.amount), 0);
return {
usersTotal,
usersByRole,
carbetsPublished,
bookings30d: bookings30d.length,
rentals30d: rentals30d.length,
revenue30d: bookingsRevenue + rentalsRevenue,
topCarbets,
topProviders,
};
}