From 0dc560385ddef4d7137290e65bac779624087eed Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Wed, 3 Jun 2026 02:46:01 +0000 Subject: [PATCH] =?UTF-8?q?feat(analytics):=20Sprint=20N=20=E2=80=94=20das?= =?UTF-8?q?hboards=20CE=20+=20admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 , 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> --- src/app/admin/analytics/page.tsx | 169 ++++++++++++++ src/app/espace-ce/analytics/page.tsx | 95 ++++++++ src/app/espace-ce/page.tsx | 6 +- src/components/admin/Sidebar.tsx | 5 +- .../analytics/MonthlyRevenueChart.tsx | 113 +++++++++ src/lib/analytics.ts | 218 ++++++++++++++++++ 6 files changed, 604 insertions(+), 2 deletions(-) create mode 100644 src/app/admin/analytics/page.tsx create mode 100644 src/app/espace-ce/analytics/page.tsx create mode 100644 src/components/analytics/MonthlyRevenueChart.tsx create mode 100644 src/lib/analytics.ts diff --git a/src/app/admin/analytics/page.tsx b/src/app/admin/analytics/page.tsx new file mode 100644 index 0000000..0ec2427 --- /dev/null +++ b/src/app/admin/analytics/page.tsx @@ -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'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> + ); +} diff --git a/src/app/espace-ce/analytics/page.tsx b/src/app/espace-ce/analytics/page.tsx new file mode 100644 index 0000000..c9fc2be --- /dev/null +++ b/src/app/espace-ce/analytics/page.tsx @@ -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'affaires des 12 derniers mois et taux d'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'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'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> + ); +} diff --git a/src/app/espace-ce/page.tsx b/src/app/espace-ce/page.tsx index 0a5251d..a730184 100644 --- a/src/app/espace-ce/page.tsx +++ b/src/app/espace-ce/page.tsx @@ -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> diff --git a/src/components/admin/Sidebar.tsx b/src/components/admin/Sidebar.tsx index ad30de2..ddd52ad 100644 --- a/src/components/admin/Sidebar.tsx +++ b/src/components/admin/Sidebar.tsx @@ -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", diff --git a/src/components/analytics/MonthlyRevenueChart.tsx b/src/components/analytics/MonthlyRevenueChart.tsx new file mode 100644 index 0000000..b65f6b9 --- /dev/null +++ b/src/components/analytics/MonthlyRevenueChart.tsx @@ -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)} + + + ) : null} + {/* Rental revenue (top de la stack) */} + {rentalH > 0 ? ( + + + Matériel {d.month} : {fmtEur(d.rentalRevenue)} + + + ) : null} + + {shortMonth(d.month)} + + + ); + })} + +
+ + Carbet + + + Matériel rental + +
+ + ); +} diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts new file mode 100644 index 0000000..697e5d9 --- /dev/null +++ b/src/lib/analytics.ts @@ -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 { + 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(); + 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 { + 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; + 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 { + 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 = {}; + let usersTotal = 0; + for (const row of usersByRoleRows) { + usersByRole[row.role] = row._count._all; + usersTotal += row._count._all; + } + + const carbetAgg = new Map(); + 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(); + 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, + }; +} -- 2.49.1