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 = { + 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 ( +
+
+

Analytics globaux

+

+ Vue d'ensemble plateforme : utilisateurs, activité 30 derniers jours, top performers. +

+
+ +
+ + + + +
+ +
+
+

+ Utilisateurs par rôle +

+ {kpis.usersTotal === 0 ? ( +

Aucun utilisateur.

+ ) : ( +
    + {Object.entries(kpis.usersByRole) + .sort((a, b) => b[1] - a[1]) + .map(([role, count]) => { + const pct = Math.round((count / kpis.usersTotal) * 100); + return ( +
  • +
    + {ROLE_LABEL[role] ?? role} + + {count} ({pct}%) + +
    +
    +
    +
    +
  • + ); + })} +
+ )} +
+ +
+

+ Activité 30 derniers jours +

+
    +
  • + Bookings carbet + {kpis.bookings30d} +
  • +
  • + Locations matériel + {kpis.rentals30d} +
  • +
  • + Total CA 30j + + {fmtEur(kpis.revenue30d)} + +
  • +
+
+
+ +
+

+ Chiffre d'affaires mensuel +

+ +
+ +
+
+

+ Top carbets (30j) +

+ {kpis.topCarbets.length === 0 ? ( +

Aucune réservation sur les 30 derniers jours.

+ ) : ( +
    + {kpis.topCarbets.map((c, i) => ( +
  • + + #{i + 1} + + {c.title} + + + {fmtEur(c.revenue)} +
  • + ))} +
+ )} +
+ +
+

+ Top prestataires rental (30j) +

+ {kpis.topProviders.length === 0 ? ( +

Aucune location sur les 30 derniers jours.

+ ) : ( +
    + {kpis.topProviders.map((p, i) => ( +
  • + + #{i + 1} + + {p.name} + + + {fmtEur(p.revenue)} +
  • + ))} +
+ )} +
+
+
+ ); +} + +function KpiCard({ label, value }: { label: string; value: string | number }) { + return ( +
+
{label}
+
{value}
+
+ ); +} 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 ( +
+
+ + ← Tableau de bord CE + +

+ Analytics — {org.name} +

+

+ Chiffre d'affaires des 12 derniers mois et taux d'occupation des carbets co-gérés. +

+
+ +
+ + + +
+ +
+

+ Chiffre d'affaires mensuel +

+ +
+ +
+

+ Taux d'occupation des carbets (3 derniers mois) +

+ {occupancy.length === 0 ? ( +

Pas encore de carbet publié.

+ ) : ( +
    + {occupancy.map((c) => ( +
  • +
    + + {c.title} + + + {c.occupancyPct} % ({c.bookedNights}/{c.totalNights} nuits) + +
    +
    +
    +
    +
  • + ))} +
+ )} +
+
+ ); +} + +function KpiCard({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} 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() {

- Gérez aussi vos{" "} + Voir aussi vos{" "} + + analytics CA & occupation + {" "} + et gérez vos{" "} membres et invitations CE 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 ( +

+ + {/* Y-axis grid */} + {[0, 0.25, 0.5, 0.75, 1].map((p) => { + const y = padTop + usableHeight * (1 - p); + return ( + + + + {fmtEur(max * p)} + + + ); + })} + + {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 ( + + {/* Carbet revenue (bas) */} + {carbetH > 0 ? ( + + + 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, + }; +}