karbe/src/app/admin/analytics/page.tsx
Ubuntu 0dc560385d
All checks were successful
CI / test (pull_request) Successful in 2m34s
feat(analytics): Sprint N — dashboards CE + admin
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>
2026-06-03 02:46:01 +00:00

169 lines
6.9 KiB
TypeScript

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