feat(analytics): Sprint N — dashboards CE + admin
All checks were successful
CI / test (pull_request) Successful in 2m34s
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:
parent
73d24b70f7
commit
0dc560385d
6 changed files with 604 additions and 2 deletions
169
src/app/admin/analytics/page.tsx
Normal file
169
src/app/admin/analytics/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
95
src/app/espace-ce/analytics/page.tsx
Normal file
95
src/app/espace-ce/analytics/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
113
src/components/analytics/MonthlyRevenueChart.tsx
Normal file
113
src/components/analytics/MonthlyRevenueChart.tsx
Normal 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
218
src/lib/analytics.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue