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>
169 lines
6.9 KiB
TypeScript
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'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'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>
|
|
);
|
|
}
|