From 90504bba2e5821bebd8657a5969206e9f7d36f61 Mon Sep 17 00:00:00 2001 From: Yves Gugger Date: Tue, 16 Dec 2025 16:51:06 +0100 Subject: [PATCH] Earnings Dashboard: Recharts Integration mit MRR Trend, Tier Breakdown Charts, Customer Growth, Pie Charts und mehr --- backend/app/api/admin.py | 99 +++ frontend/package.json | 3 +- frontend/src/components/admin/EarningsTab.tsx | 563 ++++++++++++++---- frontend/src/lib/api.ts | 23 + 4 files changed, 582 insertions(+), 106 deletions(-) diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index a02b46d..269061b 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -301,6 +301,105 @@ async def get_admin_earnings( } +# ============== Earnings History ============== + +@router.get("/earnings/history") +async def get_admin_earnings_history( + db: Database, + admin: User = Depends(require_admin), + months: int = 12 +): + """ + Get historical earnings data for charts. + + Calculates MRR for each month based on subscription start dates. + """ + tier_prices = { + SubscriptionTier.SCOUT: 0, + SubscriptionTier.TRADER: 9, + SubscriptionTier.TYCOON: 29, + } + + # Get all subscriptions + result = await db.execute(select(Subscription)) + all_subs = result.scalars().all() + + # Generate monthly data for the last N months + monthly_data = [] + now = datetime.utcnow() + + for i in range(months - 1, -1, -1): + # Calculate the start of each month + month_start = datetime(now.year, now.month, 1) - timedelta(days=i * 30) + month_end = month_start + timedelta(days=30) + month_name = month_start.strftime("%b %Y") + + # Calculate MRR for this month + mrr = 0.0 + tier_counts = {"scout": 0, "trader": 0, "tycoon": 0} + new_subs = 0 + churned = 0 + + for sub in all_subs: + # Was this subscription active during this month? + started_before_month_end = sub.started_at <= month_end + cancelled_after_month_start = (sub.cancelled_at is None or sub.cancelled_at >= month_start) + + if started_before_month_end and cancelled_after_month_start: + price = tier_prices.get(sub.tier, 0) + mrr += price + tier_key = sub.tier.value + if tier_key in tier_counts: + tier_counts[tier_key] += 1 + + # New subscriptions in this month + if month_start <= sub.started_at < month_end and sub.tier != SubscriptionTier.SCOUT: + new_subs += 1 + + # Churned in this month + if sub.cancelled_at and month_start <= sub.cancelled_at < month_end: + churned += 1 + + monthly_data.append({ + "month": month_name, + "mrr": round(mrr, 2), + "arr": round(mrr * 12, 2), + "paying_customers": tier_counts["trader"] + tier_counts["tycoon"], + "scout": tier_counts["scout"], + "trader": tier_counts["trader"], + "tycoon": tier_counts["tycoon"], + "new_subscriptions": new_subs, + "churn": churned, + }) + + # Calculate growth metrics + if len(monthly_data) >= 2: + current_mrr = monthly_data[-1]["mrr"] + prev_mrr = monthly_data[-2]["mrr"] if monthly_data[-2]["mrr"] > 0 else 1 + mrr_growth = ((current_mrr - prev_mrr) / prev_mrr) * 100 + else: + mrr_growth = 0 + + # Calculate average revenue per user (ARPU) + current_paying = monthly_data[-1]["paying_customers"] if monthly_data else 0 + current_mrr = monthly_data[-1]["mrr"] if monthly_data else 0 + arpu = current_mrr / current_paying if current_paying > 0 else 0 + + # Calculate LTV (assuming 12 month average retention) + ltv = arpu * 12 + + return { + "monthly_data": monthly_data, + "metrics": { + "mrr_growth_percent": round(mrr_growth, 1), + "arpu": round(arpu, 2), + "ltv": round(ltv, 2), + "total_customers": sum(m["paying_customers"] for m in monthly_data[-1:]), + }, + "timestamp": datetime.utcnow().isoformat(), + } + + # ============== User Management ============== class UpdateUserRequest(BaseModel): diff --git a/frontend/package.json b/frontend/package.json index 8cea8b0..6d9ef9c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,7 +15,8 @@ "lucide-react": "^0.303.0", "zustand": "^4.4.7", "clsx": "^2.1.0", - "sharp": "^0.33.5" + "sharp": "^0.33.5", + "recharts": "^2.15.0" }, "devDependencies": { "@types/node": "^20.10.6", diff --git a/frontend/src/components/admin/EarningsTab.tsx b/frontend/src/components/admin/EarningsTab.tsx index c557e80..78297b7 100644 --- a/frontend/src/components/admin/EarningsTab.tsx +++ b/frontend/src/components/admin/EarningsTab.tsx @@ -5,6 +5,7 @@ import { api } from '@/lib/api' import { DollarSign, TrendingUp, + TrendingDown, Users, Crown, Zap, @@ -13,8 +14,30 @@ import { ArrowUpRight, ArrowDownRight, Coins, + Target, + Activity, + BarChart3, + PieChart as PieChartIcon, } from 'lucide-react' import clsx from 'clsx' +import { + AreaChart, + Area, + BarChart, + Bar, + LineChart, + Line, + PieChart, + Pie, + Cell, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, + ComposedChart, +} from 'recharts' interface EarningsData { mrr: number @@ -32,15 +55,84 @@ interface EarningsData { timestamp: string } +interface HistoryData { + monthly_data: Array<{ + month: string + mrr: number + arr: number + paying_customers: number + scout: number + trader: number + tycoon: number + new_subscriptions: number + churn: number + }> + metrics: { + mrr_growth_percent: number + arpu: number + ltv: number + total_customers: number + } + timestamp: string +} + +// Custom colors matching the design system +const COLORS = { + primary: '#ef4444', // red-500 + accent: '#22c55e', // green-500 + amber: '#f59e0b', // amber-500 + blue: '#3b82f6', // blue-500 + purple: '#8b5cf6', // purple-500 + cyan: '#06b6d4', // cyan-500 + rose: '#f43f5e', // rose-500 +} + +const TIER_COLORS = { + scout: '#6b7280', // gray-500 + trader: '#22c55e', // green-500 + tycoon: '#f59e0b', // amber-500 +} + +// Custom tooltip component +const CustomTooltip = ({ active, payload, label }: any) => { + if (!active || !payload || !payload.length) return null + + return ( +
+

{label}

+ {payload.map((entry: any, index: number) => ( +
+
+ {entry.name}: + + {entry.name.includes('$') || entry.dataKey === 'mrr' || entry.dataKey === 'arr' + ? `$${entry.value.toLocaleString()}` + : entry.value} + +
+ ))} +
+ ) +} + export function EarningsTab() { const [data, setData] = useState(null) + const [history, setHistory] = useState(null) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) + const [activeChart, setActiveChart] = useState<'mrr' | 'customers' | 'tiers'>('mrr') const loadData = async () => { try { - const earnings = await api.getAdminEarnings() + const [earnings, earningsHistory] = await Promise.all([ + api.getAdminEarnings(), + api.getAdminEarningsHistory(12), + ]) setData(earnings) + setHistory(earningsHistory) } catch (err) { console.error('Failed to load earnings:', err) } finally { @@ -61,12 +153,12 @@ export function EarningsTab() { if (loading) { return (
- +
) } - if (!data) { + if (!data || !history) { return (
@@ -75,12 +167,28 @@ export function EarningsTab() { ) } + // Prepare pie chart data + const pieData = [ + { name: 'Tycoon', value: data.tier_breakdown.tycoon.count, color: TIER_COLORS.tycoon }, + { name: 'Trader', value: data.tier_breakdown.trader.count, color: TIER_COLORS.trader }, + { name: 'Scout', value: data.tier_breakdown.scout.count, color: TIER_COLORS.scout }, + ].filter(d => d.value > 0) + + // Revenue pie data + const revenuePieData = [ + { name: 'Tycoon', value: data.tier_breakdown.tycoon.revenue, color: TIER_COLORS.tycoon }, + { name: 'Trader', value: data.tier_breakdown.trader.revenue, color: TIER_COLORS.trader }, + ].filter(d => d.value > 0) + return (
- {/* Header Actions */} + {/* Header */}
-

Revenue Dashboard

+

+ + Revenue Dashboard +

Last updated: {new Date(data.timestamp).toLocaleString()}

@@ -95,144 +203,380 @@ export function EarningsTab() {
- {/* Main Revenue Cards */} -
+ {/* Main KPI Cards */} +
{/* MRR */} -
-
+
+
-
-
- -
- Monthly Recurring +
+ + MRR
-
+
${data.mrr.toLocaleString()}
-
MRR
+ {history.metrics.mrr_growth_percent !== 0 && ( +
0 ? "text-green-400" : "text-rose-400" + )}> + {history.metrics.mrr_growth_percent > 0 ? ( + + ) : ( + + )} + {Math.abs(history.metrics.mrr_growth_percent)}% vs last month +
+ )}
{/* ARR */} -
-
+
+
-
-
- -
- Annual Recurring +
+ + ARR
-
+
${data.arr.toLocaleString()}
-
ARR
+
+ Projected annual +
- {/* Paying Customers */} -
-
-
- -
- Paying Customers + {/* Customers */} +
+
+ + Paying
-
+
{data.paying_customers}
-
- - - +{data.new_subscriptions.week} this week - +
+ + +{data.new_subscriptions.week} this week
- {/* Yield Revenue */} -
-
-
- -
- Yield Revenue + {/* ARPU */} +
+
+ + ARPU
-
- ${data.yield_revenue_month.toFixed(0)} +
+ ${history.metrics.arpu.toFixed(2)} +
+
+ LTV: ${history.metrics.ltv.toFixed(0)}
-
This month (30%)
- {/* Tier Breakdown */} + {/* MRR Chart */}
-
-

Subscription Breakdown

+
+
+ +

Revenue Trend

+
+
+ {(['mrr', 'customers', 'tiers'] as const).map((chart) => ( + + ))} +
-
- {/* Tycoon */} -
-
-
- +
+ + {activeChart === 'mrr' ? ( + + + + + + + + + + + + + + `$${value}`} + /> + } /> + + + ) : activeChart === 'customers' ? ( + + + + + } /> + + + + + ) : ( + + + + + } /> + {value}} + /> + + + + + )} + +
+
+ + {/* Bottom Section: Tier Breakdown + Pie Charts */} +
+ {/* Tier Breakdown */} +
+
+

Subscription Breakdown

+
+
+ {/* Tycoon */} +
+
+
+ +
+
+

Tycoon

+

$29/month · Premium

+
-
-

Tycoon

-

$29/month

+
+

{data.tier_breakdown.tycoon.count}

+

${data.tier_breakdown.tycoon.revenue}/mo

+
+
+
-
-

{data.tier_breakdown.tycoon.count}

-

${data.tier_breakdown.tycoon.revenue}/mo

+ + {/* Trader */} +
+
+
+ +
+
+

Trader

+

$9/month · Standard

+
+
+
+

{data.tier_breakdown.trader.count}

+

${data.tier_breakdown.trader.revenue}/mo

+
+
+
+
+
+ + {/* Scout */} +
+
+
+ +
+
+

Scout

+

Free tier

+
+
+
+

{data.tier_breakdown.scout.count}

+

$0/mo

+
+
+
+
+
+
+
+ + {/* Pie Charts */} +
+ {/* Customer Distribution */} +
+
+ + Customers +
+
+ + + + {pieData.map((entry, index) => ( + + ))} + + { + if (!active || !payload || !payload.length) return null + const data = payload[0].payload + return ( +
+

+ {data.name}: {data.value} +

+
+ ) + }} + /> +
+
+
+
+ {pieData.map((entry, index) => ( +
+
+ {entry.name} +
+ ))}
- {/* Trader */} -
-
-
- -
-
-

Trader

-

$9/month

-
+ {/* Revenue Distribution */} +
+
+ + Revenue Split
-
-

{data.tier_breakdown.trader.count}

-

${data.tier_breakdown.trader.revenue}/mo

+
+ + + + {revenuePieData.map((entry, index) => ( + + ))} + + { + if (!active || !payload || !payload.length) return null + const data = payload[0].payload + return ( +
+

+ {data.name}: ${data.value} +

+
+ ) + }} + /> +
+
-
- - {/* Scout */} -
-
-
- -
-
-

Scout

-

Free

-
-
-
-

{data.tier_breakdown.scout.count}

-

$0/mo

+
+ {revenuePieData.map((entry, index) => ( +
+
+ {entry.name} +
+ ))}
- {/* Growth & Churn */} -
+ {/* Growth Metrics */} +
{/* New This Week */}
- + New This Week
-

{data.new_subscriptions.week}

+

{data.new_subscriptions.week}

paid subscriptions

@@ -249,24 +593,34 @@ export function EarningsTab() { {/* Churn */}
- - Churned This Month + + Churned

{data.churn.month}

-

cancelled

+

this month

+
+ + {/* Yield Revenue */} +
+
+ + Yield Revenue +
+

${data.yield_revenue_month.toFixed(0)}

+

platform cut (30%)

{/* Total Revenue Summary */} -
+
-

Total Monthly Revenue

-

Subscriptions + Yield (30%)

+

Total Monthly Revenue

+

Subscriptions + Yield Platform Cut

-

${data.total_revenue_month.toLocaleString()}

-

+

${data.total_revenue_month.toLocaleString()}

+

${(data.total_revenue_month * 12).toLocaleString()} projected ARR

@@ -275,4 +629,3 @@ export function EarningsTab() {
) } - diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c693c06..2af6653 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1286,6 +1286,29 @@ class AdminApiClient extends ApiClient { }>('/admin/earnings') } + async getAdminEarningsHistory(months: number = 12) { + return this.request<{ + monthly_data: Array<{ + month: string + mrr: number + arr: number + paying_customers: number + scout: number + trader: number + tycoon: number + new_subscriptions: number + churn: number + }> + metrics: { + mrr_growth_percent: number + arpu: number + ltv: number + total_customers: number + } + timestamp: string + }>(`/admin/earnings/history?months=${months}`) + } + // Admin Users async getAdminUsers(limit: number = 50, offset: number = 0, search?: string) { const params = new URLSearchParams({ limit: String(limit), offset: String(offset) })