Add registration_activity chart and enhance admin statistics

- Introduced `RegistrationActivityChart` to display user registration trends over 14 days.
- Enhanced `AdminStatsResponse` with `registration_activity`, providing improved insights for admin users.
- Updated demo data to include realistic registration activity and organization details.
- Refactored admin page to use updated statistics data model and improved query handling.
- Fixed inconsistent timezone handling in statistical analytics and demo user timestamps.
This commit is contained in:
Felipe Cardoso
2025-11-24 17:42:43 +01:00
parent 9f655913b1
commit a05def5906
9 changed files with 664 additions and 226 deletions

View File

@@ -7,7 +7,7 @@ for managing the application.
""" """
import logging import logging
from datetime import datetime, timedelta from datetime import UTC, datetime, timedelta
from enum import Enum from enum import Enum
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
@@ -94,6 +94,11 @@ class OrgDistributionData(BaseModel):
value: int value: int
class RegistrationActivityData(BaseModel):
date: str
registrations: int
class UserStatusData(BaseModel): class UserStatusData(BaseModel):
name: str name: str
value: int value: int
@@ -102,9 +107,63 @@ class UserStatusData(BaseModel):
class AdminStatsResponse(BaseModel): class AdminStatsResponse(BaseModel):
user_growth: list[UserGrowthData] user_growth: list[UserGrowthData]
organization_distribution: list[OrgDistributionData] organization_distribution: list[OrgDistributionData]
registration_activity: list[RegistrationActivityData]
user_status: list[UserStatusData] user_status: list[UserStatusData]
def _generate_demo_stats() -> AdminStatsResponse:
"""Generate demo statistics for empty databases."""
from random import randint
# Demo user growth (last 30 days)
user_growth = []
total = 10
for i in range(29, -1, -1):
date = datetime.now(UTC) - timedelta(days=i)
total += randint(0, 3)
user_growth.append(
UserGrowthData(
date=date.strftime("%b %d"),
total_users=total,
active_users=int(total * 0.85),
)
)
# Demo organization distribution
org_dist = [
OrgDistributionData(name="Engineering", value=12),
OrgDistributionData(name="Product", value=8),
OrgDistributionData(name="Sales", value=15),
OrgDistributionData(name="Marketing", value=6),
OrgDistributionData(name="Support", value=5),
OrgDistributionData(name="Operations", value=4),
]
# Demo registration activity (last 14 days)
registration_activity = []
for i in range(13, -1, -1):
date = datetime.now(UTC) - timedelta(days=i)
registration_activity.append(
RegistrationActivityData(
date=date.strftime("%b %d"),
registrations=randint(0, 5),
)
)
# Demo user status
user_status = [
UserStatusData(name="Active", value=45),
UserStatusData(name="Inactive", value=5),
]
return AdminStatsResponse(
user_growth=user_growth,
organization_distribution=org_dist,
registration_activity=registration_activity,
user_status=user_status,
)
@router.get( @router.get(
"/stats", "/stats",
response_model=AdminStatsResponse, response_model=AdminStatsResponse,
@@ -116,75 +175,88 @@ async def admin_get_stats(
admin: User = Depends(require_superuser), admin: User = Depends(require_superuser),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
) -> Any: ) -> Any:
"""Get admin dashboard statistics.""" """Get admin dashboard statistics with real data from database."""
# 1. User Growth (Last 30 days) from app.core.config import settings
# Note: This is a simplified implementation. For production, consider a dedicated stats table or materialized view.
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
# Get all users created in last 30 days # Check if we have any data
query = ( total_users_query = select(func.count()).select_from(User)
select(User).where(User.created_at >= thirty_days_ago).order_by(User.created_at) total_users = (await db.execute(total_users_query)).scalar() or 0
)
result = await db.execute(query)
recent_users = result.scalars().all()
# Get total count before 30 days # If database is essentially empty (only admin user), return demo data
count_query = ( if total_users <= 1 and settings.DEMO_MODE:
select(func.count()).select_from(User).where(User.created_at < thirty_days_ago) logger.info("Returning demo stats data (empty database in demo mode)")
) return _generate_demo_stats()
count_result = await db.execute(count_query)
base_count = count_result.scalar() or 0
# Aggregate by day # 1. User Growth (Last 30 days) - Improved calculation
datetime.now(UTC) - timedelta(days=30)
# Get all users with their creation dates
all_users_query = select(User).order_by(User.created_at)
result = await db.execute(all_users_query)
all_users = result.scalars().all()
# Build cumulative counts per day
user_growth = [] user_growth = []
current_total = base_count
# Create a map of date -> count
daily_counts = {}
for user in recent_users:
date_str = user.created_at.strftime("%b %d")
if date_str not in daily_counts:
daily_counts[date_str] = {"total": 0, "active": 0}
daily_counts[date_str]["total"] += 1
if user.is_active:
daily_counts[date_str]["active"] += 1
# Fill in the last 30 days
for i in range(29, -1, -1): for i in range(29, -1, -1):
date = datetime.utcnow() - timedelta(days=i) date = datetime.now(UTC) - timedelta(days=i)
date_str = date.strftime("%b %d") date_start = date.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=UTC)
date_end = date_start + timedelta(days=1)
day_data = daily_counts.get(date_str, {"total": 0, "active": 0}) # Count all users created before end of this day
current_total += day_data["total"] # Make comparison timezone-aware
total_users_on_date = sum(
1 for u in all_users
if u.created_at and u.created_at.replace(tzinfo=UTC) < date_end
)
# Count active users created before end of this day
active_users_on_date = sum(
1 for u in all_users
if u.created_at and u.created_at.replace(tzinfo=UTC) < date_end and u.is_active
)
# For active users, we'd ideally track history, but for now let's approximate
# by just counting current active users created up to this point
# This is a simplification
user_growth.append( user_growth.append(
UserGrowthData( UserGrowthData(
date=date_str, date=date.strftime("%b %d"),
total_users=current_total, total_users=total_users_on_date,
active_users=int( active_users=active_users_on_date,
current_total * 0.8
), # Mocking active ratio for demo visual appeal if real data lacks history
) )
) )
# 2. Organization Distribution # 2. Organization Distribution - Top 6 organizations by member count
# Get top 5 organizations by member count
org_query = ( org_query = (
select(Organization.name, func.count(UserOrganization.user_id).label("count")) select(Organization.name, func.count(UserOrganization.user_id).label("count"))
.join(UserOrganization, Organization.id == UserOrganization.organization_id) .join(UserOrganization, Organization.id == UserOrganization.organization_id)
.group_by(Organization.name) .group_by(Organization.name)
.order_by(func.count(UserOrganization.user_id).desc()) .order_by(func.count(UserOrganization.user_id).desc())
.limit(5) .limit(6)
) )
result = await db.execute(org_query) result = await db.execute(org_query)
org_dist = [ org_dist = [
OrgDistributionData(name=row.name, value=row.count) for row in result.all() OrgDistributionData(name=row.name, value=row.count) for row in result.all()
] ]
# 3. User Status # 3. User Registration Activity (Last 14 days) - NEW
registration_activity = []
for i in range(13, -1, -1):
date = datetime.now(UTC) - timedelta(days=i)
date_start = date.replace(hour=0, minute=0, second=0, microsecond=0, tzinfo=UTC)
date_end = date_start + timedelta(days=1)
# Count users created on this specific day
# Make comparison timezone-aware
day_registrations = sum(
1 for u in all_users
if u.created_at and date_start <= u.created_at.replace(tzinfo=UTC) < date_end
)
registration_activity.append(
RegistrationActivityData(
date=date.strftime("%b %d"),
registrations=day_registrations,
)
)
# 4. User Status - Active vs Inactive
active_query = select(func.count()).select_from(User).where(User.is_active) active_query = select(func.count()).select_from(User).where(User.is_active)
inactive_query = ( inactive_query = (
select(func.count()).select_from(User).where(User.is_active.is_(False)) select(func.count()).select_from(User).where(User.is_active.is_(False))
@@ -193,6 +265,8 @@ async def admin_get_stats(
active_count = (await db.execute(active_query)).scalar() or 0 active_count = (await db.execute(active_query)).scalar() or 0
inactive_count = (await db.execute(inactive_query)).scalar() or 0 inactive_count = (await db.execute(inactive_query)).scalar() or 0
logger.info(f"User status counts - Active: {active_count}, Inactive: {inactive_count}")
user_status = [ user_status = [
UserStatusData(name="Active", value=active_count), UserStatusData(name="Active", value=active_count),
UserStatusData(name="Inactive", value=inactive_count), UserStatusData(name="Inactive", value=inactive_count),
@@ -201,6 +275,7 @@ async def admin_get_stats(
return AdminStatsResponse( return AdminStatsResponse(
user_growth=user_growth, user_growth=user_growth,
organization_distribution=org_dist, organization_distribution=org_dist,
registration_activity=registration_activity,
user_status=user_status, user_status=user_status,
) )

View File

@@ -24,6 +24,11 @@
"name": "Umbrella Corporation", "name": "Umbrella Corporation",
"slug": "umbrella", "slug": "umbrella",
"description": "Our business is life itself." "description": "Our business is life itself."
},
{
"name": "Massive Dynamic",
"slug": "massive-dynamic",
"description": "What don't we do?"
} }
], ],
"users": [ "users": [
@@ -57,6 +62,16 @@
"role": "member", "role": "member",
"is_active": false "is_active": false
}, },
{
"email": "diana@acme.com",
"password": "Demo123!",
"first_name": "Diana",
"last_name": "Prince",
"is_superuser": false,
"organization_slug": "acme-corp",
"role": "member",
"is_active": true
},
{ {
"email": "carol@globex.com", "email": "carol@globex.com",
"password": "Demo123!", "password": "Demo123!",
@@ -77,6 +92,26 @@
"role": "member", "role": "member",
"is_active": true "is_active": true
}, },
{
"email": "ellen@globex.com",
"password": "Demo123!",
"first_name": "Ellen",
"last_name": "Ripley",
"is_superuser": false,
"organization_slug": "globex",
"role": "member",
"is_active": true
},
{
"email": "fred@globex.com",
"password": "Demo123!",
"first_name": "Fred",
"last_name": "Flintstone",
"is_superuser": false,
"organization_slug": "globex",
"role": "member",
"is_active": true
},
{ {
"email": "dave@soylent.com", "email": "dave@soylent.com",
"password": "Demo123!", "password": "Demo123!",
@@ -87,6 +122,26 @@
"role": "member", "role": "member",
"is_active": true "is_active": true
}, },
{
"email": "gina@soylent.com",
"password": "Demo123!",
"first_name": "Gina",
"last_name": "Torres",
"is_superuser": false,
"organization_slug": "soylent",
"role": "member",
"is_active": true
},
{
"email": "harry@soylent.com",
"password": "Demo123!",
"first_name": "Harry",
"last_name": "Potter",
"is_superuser": false,
"organization_slug": "soylent",
"role": "admin",
"is_active": true
},
{ {
"email": "eve@initech.com", "email": "eve@initech.com",
"password": "Demo123!", "password": "Demo123!",
@@ -97,6 +152,26 @@
"role": "admin", "role": "admin",
"is_active": true "is_active": true
}, },
{
"email": "iris@initech.com",
"password": "Demo123!",
"first_name": "Iris",
"last_name": "West",
"is_superuser": false,
"organization_slug": "initech",
"role": "member",
"is_active": true
},
{
"email": "jack@initech.com",
"password": "Demo123!",
"first_name": "Jack",
"last_name": "Sparrow",
"is_superuser": false,
"organization_slug": "initech",
"role": "member",
"is_active": false
},
{ {
"email": "frank@umbrella.com", "email": "frank@umbrella.com",
"password": "Demo123!", "password": "Demo123!",
@@ -117,6 +192,76 @@
"role": "member", "role": "member",
"is_active": false "is_active": false
}, },
{
"email": "kate@umbrella.com",
"password": "Demo123!",
"first_name": "Kate",
"last_name": "Bishop",
"is_superuser": false,
"organization_slug": "umbrella",
"role": "member",
"is_active": true
},
{
"email": "leo@massive.com",
"password": "Demo123!",
"first_name": "Leo",
"last_name": "Messi",
"is_superuser": false,
"organization_slug": "massive-dynamic",
"role": "owner",
"is_active": true
},
{
"email": "mary@massive.com",
"password": "Demo123!",
"first_name": "Mary",
"last_name": "Jane",
"is_superuser": false,
"organization_slug": "massive-dynamic",
"role": "member",
"is_active": true
},
{
"email": "nathan@massive.com",
"password": "Demo123!",
"first_name": "Nathan",
"last_name": "Drake",
"is_superuser": false,
"organization_slug": "massive-dynamic",
"role": "member",
"is_active": true
},
{
"email": "olivia@massive.com",
"password": "Demo123!",
"first_name": "Olivia",
"last_name": "Dunham",
"is_superuser": false,
"organization_slug": "massive-dynamic",
"role": "admin",
"is_active": true
},
{
"email": "peter@massive.com",
"password": "Demo123!",
"first_name": "Peter",
"last_name": "Parker",
"is_superuser": false,
"organization_slug": "massive-dynamic",
"role": "member",
"is_active": true
},
{
"email": "quinn@massive.com",
"password": "Demo123!",
"first_name": "Quinn",
"last_name": "Mallory",
"is_superuser": false,
"organization_slug": "massive-dynamic",
"role": "member",
"is_active": true
},
{ {
"email": "grace@example.com", "email": "grace@example.com",
"password": "Demo123!", "password": "Demo123!",
@@ -146,6 +291,66 @@
"organization_slug": null, "organization_slug": null,
"role": null, "role": null,
"is_active": false "is_active": false
},
{
"email": "rachel@example.com",
"password": "Demo123!",
"first_name": "Rachel",
"last_name": "Green",
"is_superuser": false,
"organization_slug": null,
"role": null,
"is_active": true
},
{
"email": "sam@example.com",
"password": "Demo123!",
"first_name": "Sam",
"last_name": "Wilson",
"is_superuser": false,
"organization_slug": null,
"role": null,
"is_active": true
},
{
"email": "tony@example.com",
"password": "Demo123!",
"first_name": "Tony",
"last_name": "Stark",
"is_superuser": false,
"organization_slug": null,
"role": null,
"is_active": true
},
{
"email": "una@example.com",
"password": "Demo123!",
"first_name": "Una",
"last_name": "Chin-Riley",
"is_superuser": false,
"organization_slug": null,
"role": null,
"is_active": false
},
{
"email": "victor@example.com",
"password": "Demo123!",
"first_name": "Victor",
"last_name": "Von Doom",
"is_superuser": false,
"organization_slug": null,
"role": null,
"is_active": true
},
{
"email": "wanda@example.com",
"password": "Demo123!",
"first_name": "Wanda",
"last_name": "Maximoff",
"is_superuser": false,
"organization_slug": null,
"role": null,
"is_active": true
} }
] ]
} }

View File

@@ -9,7 +9,7 @@ import asyncio
import json import json
import logging import logging
import random import random
from datetime import datetime, timedelta from datetime import UTC, datetime, timedelta
from pathlib import Path from pathlib import Path
from sqlalchemy import select, text from sqlalchemy import select, text
@@ -153,23 +153,24 @@ async def load_demo_data(session):
# Randomize created_at for demo data (last 30 days) # Randomize created_at for demo data (last 30 days)
# This makes the charts look more realistic # This makes the charts look more realistic
days_ago = random.randint(0, 30) # noqa: S311 days_ago = random.randint(0, 30) # noqa: S311
random_time = datetime.utcnow() - timedelta(days=days_ago) random_time = datetime.now(UTC) - timedelta(days=days_ago)
# Add some random hours/minutes variation # Add some random hours/minutes variation
random_time = random_time.replace( random_time = random_time.replace(
hour=random.randint(0, 23), # noqa: S311 hour=random.randint(0, 23), # noqa: S311
minute=random.randint(0, 59), # noqa: S311 minute=random.randint(0, 59), # noqa: S311
) )
# Update the timestamp directly in the database # Update the timestamp and is_active directly in the database
# We do this to ensure the values are persisted correctly
await session.execute( await session.execute(
text( text(
"UPDATE users SET created_at = :created_at WHERE id = :user_id" "UPDATE users SET created_at = :created_at, is_active = :is_active WHERE id = :user_id"
), ),
{"created_at": random_time, "user_id": user.id}, {"created_at": random_time, "is_active": user_data.get("is_active", True), "user_id": user.id},
) )
logger.info( logger.info(
f"Created demo user: {user.email} (created {days_ago} days ago)" f"Created demo user: {user.email} (created {days_ago} days ago, active={user_data.get('is_active', True)})"
) )
# Add to organization if specified # Add to organization if specified

View File

@@ -11,7 +11,7 @@ import { DashboardStats } from '@/components/admin';
import { import {
UserGrowthChart, UserGrowthChart,
OrganizationDistributionChart, OrganizationDistributionChart,
SessionActivityChart, RegistrationActivityChart,
UserStatusChart, UserStatusChart,
} from '@/components/charts'; } from '@/components/charts';
import { Users, Building2, Settings } from 'lucide-react'; import { Users, Building2, Settings } from 'lucide-react';
@@ -19,16 +19,40 @@ import { useQuery } from '@tanstack/react-query';
import { getAdminStats } from '@/lib/api/admin'; import { getAdminStats } from '@/lib/api/admin';
export default function AdminPage() { export default function AdminPage() {
console.log('[AdminPage] Component rendering');
const { const {
data: stats, data: stats,
isLoading, isLoading,
error, error,
status,
fetchStatus,
} = useQuery({ } = useQuery({
queryKey: ['admin', 'stats'], queryKey: ['admin', 'analytics'], // Changed from 'stats' to avoid collision with useAdminStats hook
queryFn: async () => { queryFn: async () => {
const response = await getAdminStats(); console.log('[AdminPage] QueryFn executing - fetching stats...');
return response.data; try {
const response = await getAdminStats();
console.log('[AdminPage] Stats response received:', response);
return response.data;
} catch (err) {
console.error('[AdminPage] Error fetching stats:', err);
throw err;
}
}, },
enabled: true, // Explicitly enable the query
retry: 1,
staleTime: 60000, // Cache for 1 minute
});
console.log('[AdminPage] Current state:', {
isLoading,
hasError: Boolean(error),
error: error?.message,
hasData: Boolean(stats),
dataKeys: stats ? Object.keys(stats) : null,
status,
fetchStatus,
}); });
return ( return (
@@ -94,7 +118,11 @@ export default function AdminPage() {
loading={isLoading} loading={isLoading}
error={error ? (error as Error).message : null} error={error ? (error as Error).message : null}
/> />
<SessionActivityChart /> <RegistrationActivityChart
data={stats?.registration_activity}
loading={isLoading}
error={error ? (error as Error).message : null}
/>
<OrganizationDistributionChart <OrganizationDistributionChart
data={stats?.organization_distribution} data={stats?.organization_distribution}
loading={isLoading} loading={isLoading}

View File

@@ -14,39 +14,56 @@ import {
Tooltip, Tooltip,
ResponsiveContainer, ResponsiveContainer,
Legend, Legend,
Rectangle,
} from 'recharts'; } from 'recharts';
import { ChartCard } from './ChartCard'; import { ChartCard } from './ChartCard';
import { CHART_PALETTES } from '@/lib/chart-colors'; import { CHART_PALETTES } from '@/lib/chart-colors';
export interface OrganizationDistributionData { export interface OrgDistributionData {
name: string; name: string;
value: number; value: number;
} }
interface OrganizationDistributionChartProps { interface OrganizationDistributionChartProps {
data?: OrganizationDistributionData[]; data?: OrgDistributionData[];
loading?: boolean; loading?: boolean;
error?: string | null; error?: string | null;
} }
// Generate mock data for development/demo // Custom tooltip with proper theme colors
function generateMockData(): OrganizationDistributionData[] { const CustomTooltip = ({ active, payload }: any) => {
return [ if (active && payload && payload.length) {
{ name: 'Engineering', value: 45 }, return (
{ name: 'Marketing', value: 28 }, <div
{ name: 'Sales', value: 35 }, style={{
{ name: 'Operations', value: 22 }, backgroundColor: 'hsl(var(--popover) / 0.95)',
{ name: 'HR', value: 15 }, border: '1px solid hsl(var(--border))',
{ name: 'Finance', value: 18 }, borderRadius: '8px',
]; padding: '10px 14px',
} boxShadow: '0 2px 2px rgba(0, 0, 0, 0.5)',
backdropFilter: 'blur(8px)',
}}
>
<p style={{ color: 'hsl(var(--popover-foreground))', margin: 0, fontSize: '14px', fontWeight: 600 }}>
{payload[0].payload.name}
</p>
<p style={{ color: 'hsl(var(--muted-foreground))', margin: '4px 0 0 0', fontSize: '13px' }}>
Members: <span style={{ fontWeight: 600, color: 'hsl(var(--popover-foreground))' }}>{payload[0].value}</span>
</p>
</div>
);
}
return null;
};
export function OrganizationDistributionChart({ export function OrganizationDistributionChart({
data, data,
loading, loading,
error, error,
}: OrganizationDistributionChartProps) { }: OrganizationDistributionChartProps) {
const chartData = data || generateMockData(); // Show empty chart if no data available
const rawData = data || [];
const hasData = rawData.length > 0 && rawData.some((d) => d.value > 0);
return ( return (
<ChartCard <ChartCard
@@ -55,42 +72,33 @@ export function OrganizationDistributionChart({
loading={loading} loading={loading}
error={error} error={error}
> >
<ResponsiveContainer width="100%" height={300}> {!hasData && !loading && !error ? (
<BarChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}> <div className="flex items-center justify-center h-[300px] text-muted-foreground">
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> <p>No organization data available</p>
<XAxis </div>
dataKey="name" ) : (
stroke="hsl(var(--border))" <ResponsiveContainer width="100%" height={300}>
tick={{ fill: 'hsl(var(--muted-foreground))', fontSize: 12 }} <BarChart data={rawData} margin={{ top: 5, right: 30, left: 20, bottom: 80 }}>
tickLine={{ stroke: 'hsl(var(--border))' }} <CartesianGrid strokeDasharray="3 3" style={{ stroke: 'var(--muted)', opacity: 0.2 }} />
/> <XAxis
<YAxis dataKey="name"
stroke="hsl(var(--border))" angle={-45}
tick={{ fill: 'hsl(var(--muted-foreground))', fontSize: 12 }} textAnchor="end"
tickLine={{ stroke: 'hsl(var(--border))' }} style={{ fill: 'var(--muted-foreground)', fontSize: '12px' }}
/> />
<Tooltip <YAxis
contentStyle={{ style={{ fill: 'var(--muted-foreground)', fontSize: '12px' }}
backgroundColor: 'hsl(var(--popover))', />
border: '1px solid hsl(var(--border))', <Tooltip content={<CustomTooltip />} />
borderRadius: '6px', <Bar
color: 'hsl(var(--popover-foreground))', dataKey="value"
}} fill={CHART_PALETTES.bar[0]}
labelStyle={{ color: 'hsl(var(--popover-foreground))' }} radius={[4, 4, 0, 0]}
/> activeBar={{ fill: CHART_PALETTES.bar[0] }}
<Legend />
wrapperStyle={{ </BarChart>
paddingTop: '20px', </ResponsiveContainer>
}} )}
/>
<Bar
dataKey="value"
name="Total Members"
fill={CHART_PALETTES.bar[0]}
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</ChartCard> </ChartCard>
); );
} }

View File

@@ -0,0 +1,113 @@
/**
* RegistrationActivityChart Component
* Displays user registration activity over time using an area chart
*/
'use client';
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from 'recharts';
import { ChartCard } from './ChartCard';
import { CHART_PALETTES } from '@/lib/chart-colors';
export interface RegistrationActivityData {
date: string;
registrations: number;
}
interface RegistrationActivityChartProps {
data?: RegistrationActivityData[];
loading?: boolean;
error?: string | null;
}
// Custom tooltip with proper theme colors
const CustomTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
return (
<div
style={{
backgroundColor: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
padding: '8px 12px',
}}
>
<p style={{ color: 'hsl(var(--popover-foreground))', margin: 0, fontSize: '13px', fontWeight: 600 }}>
{payload[0].payload.date}
</p>
<p style={{ color: 'hsl(var(--popover-foreground))', margin: '4px 0 0 0', fontSize: '12px' }}>
New Registrations: {payload[0].value}
</p>
</div>
);
}
return null;
};
export function RegistrationActivityChart({
data,
loading,
error,
}: RegistrationActivityChartProps) {
// Show empty chart if no data available
const chartData = data || [];
const hasData = chartData.length > 0 && chartData.some((d) => d.registrations > 0);
return (
<ChartCard
title="User Registration Activity"
description="New user registrations over the last 14 days"
loading={loading}
error={error}
>
{!hasData && !loading && !error ? (
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
<p>No registration data available</p>
</div>
) : (
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<defs>
<linearGradient id="colorRegistrations" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={CHART_PALETTES.area[0]} stopOpacity={0.8} />
<stop offset="95%" stopColor={CHART_PALETTES.area[0]} stopOpacity={0.1} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" style={{ stroke: 'var(--muted)', opacity: 0.2 }} />
<XAxis
dataKey="date"
style={{ fill: 'var(--muted-foreground)', fontSize: '12px' }}
/>
<YAxis
style={{ fill: 'var(--muted-foreground)', fontSize: '12px' }}
/>
<Tooltip content={<CustomTooltip />} />
<Legend
wrapperStyle={{
paddingTop: '20px',
}}
/>
<Area
type="monotone"
dataKey="registrations"
name="New Registrations"
stroke={CHART_PALETTES.area[0]}
strokeWidth={2}
fillOpacity={1}
fill="url(#colorRegistrations)"
/>
</AreaChart>
</ResponsiveContainer>
)}
</ChartCard>
);
}

View File

@@ -5,19 +5,18 @@
'use client'; 'use client';
import { ChartCard } from './ChartCard';
import { CHART_PALETTES } from '@/lib/chart-colors';
import { import {
ResponsiveContainer,
LineChart, LineChart,
Line, Line,
XAxis, XAxis,
YAxis, YAxis,
CartesianGrid, CartesianGrid,
Tooltip, Tooltip,
ResponsiveContainer,
Legend, Legend,
} from 'recharts'; } from 'recharts';
import { ChartCard } from './ChartCard';
import { format, subDays } from 'date-fns';
import { CHART_PALETTES } from '@/lib/chart-colors';
export interface UserGrowthData { export interface UserGrowthData {
date: string; date: string;
@@ -25,32 +24,46 @@ export interface UserGrowthData {
active_users: number; active_users: number;
} }
interface UserGrowthChartProps { export interface UserGrowthChartProps {
data?: UserGrowthData[]; data?: UserGrowthData[];
loading?: boolean; loading?: boolean;
error?: string | null; error?: string | null;
} }
// Generate mock data for development/demo // Custom tooltip with proper theme colors
function generateMockData(): UserGrowthData[] { const CustomTooltip = ({ active, payload }: any) => {
const data: UserGrowthData[] = []; if (active && payload && payload.length) {
const today = new Date(); return (
<div
for (let i = 29; i >= 0; i--) { style={{
const date = subDays(today, i); backgroundColor: 'hsl(var(--popover))',
const baseUsers = 100 + i * 3; border: '1px solid hsl(var(--border))',
data.push({ borderRadius: '6px',
date: format(date, 'MMM d'), padding: '8px 12px',
total_users: baseUsers + Math.floor(Math.random() * 10), }}
active_users: Math.floor(baseUsers * 0.8) + Math.floor(Math.random() * 5), >
}); <p style={{ color: 'hsl(var(--popover-foreground))', margin: 0, fontSize: '13px', fontWeight: 600 }}>
{payload[0].payload.date}
</p>
<p style={{ color: 'hsl(var(--popover-foreground))', margin: '4px 0 0 0', fontSize: '12px' }}>
Total Users: {payload[0].value}
</p>
{payload[1] && (
<p style={{ color: 'hsl(var(--popover-foreground))', margin: '2px 0 0 0', fontSize: '12px' }}>
Active Users: {payload[1].value}
</p>
)}
</div>
);
} }
return null;
return data; };
}
export function UserGrowthChart({ data, loading, error }: UserGrowthChartProps) { export function UserGrowthChart({ data, loading, error }: UserGrowthChartProps) {
const chartData = data || generateMockData(); // Show empty chart if no data available
const rawData = data || [];
const hasData =
rawData.length > 0 && rawData.some((d) => d.total_users > 0 || d.active_users > 0);
return ( return (
<ChartCard <ChartCard
@@ -59,54 +72,51 @@ export function UserGrowthChart({ data, loading, error }: UserGrowthChartProps)
loading={loading} loading={loading}
error={error} error={error}
> >
<ResponsiveContainer width="100%" height={300}> {!hasData && !loading && !error ? (
<LineChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}> <div className="flex items-center justify-center h-[300px] text-muted-foreground">
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> <p>No user growth data available</p>
<XAxis </div>
dataKey="date" ) : (
stroke="hsl(var(--border))" <ResponsiveContainer width="100%" height={300}>
tick={{ fill: 'hsl(var(--muted-foreground))', fontSize: 12 }} <LineChart data={rawData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
tickLine={{ stroke: 'hsl(var(--border))' }} <CartesianGrid strokeDasharray="3 3" style={{ stroke: 'var(--muted)', opacity: 0.2 }} />
/> <XAxis dataKey="date" style={{ fill: 'var(--muted-foreground)', fontSize: '12px' }} />
<YAxis <YAxis
stroke="hsl(var(--border))" style={{ fill: 'var(--muted-foreground)', fontSize: '12px' }}
tick={{ fill: 'hsl(var(--muted-foreground))', fontSize: 12 }} label={{
tickLine={{ stroke: 'hsl(var(--border))' }} value: 'Users',
/> angle: -90,
<Tooltip position: 'insideLeft',
contentStyle={{ style: { fill: 'var(--muted-foreground)', textAnchor: 'middle' },
backgroundColor: 'hsl(var(--popover))', }}
border: '1px solid hsl(var(--border))', />
borderRadius: '6px', <Tooltip content={<CustomTooltip />} />
color: 'hsl(var(--popover-foreground))', <Legend
}} wrapperStyle={{
labelStyle={{ color: 'hsl(var(--popover-foreground))' }} paddingTop: '20px',
/> }}
<Legend />
wrapperStyle={{ <Line
paddingTop: '20px', type="monotone"
}} dataKey="total_users"
/> name="Total Users"
<Line stroke={CHART_PALETTES.line[0]}
type="monotone" strokeWidth={2}
dataKey="total_users" dot={false}
name="Total Users" activeDot={{ r: 6 }}
stroke={CHART_PALETTES.line[0]} />
strokeWidth={2} <Line
dot={false} type="monotone"
activeDot={{ r: 6 }} dataKey="active_users"
/> name="Active Users"
<Line stroke={CHART_PALETTES.line[1]}
type="monotone" strokeWidth={2}
dataKey="active_users" dot={false}
name="Active Users" activeDot={{ r: 6 }}
stroke={CHART_PALETTES.line[1]} />
strokeWidth={2} </LineChart>
dot={false} </ResponsiveContainer>
activeDot={{ r: 6 }} )}
/>
</LineChart>
</ResponsiveContainer>
</ChartCard> </ChartCard>
); );
} }

View File

@@ -21,16 +21,6 @@ interface UserStatusChartProps {
error?: string | null; error?: string | null;
} }
// Generate mock data for development/demo
function generateMockData(): UserStatusData[] {
return [
{ name: 'Active', value: 142, color: CHART_PALETTES.pie[0] },
{ name: 'Inactive', value: 28, color: CHART_PALETTES.pie[1] },
{ name: 'Pending', value: 15, color: CHART_PALETTES.pie[2] },
{ name: 'Suspended', value: 5, color: CHART_PALETTES.pie[3] },
];
}
// Custom label component to show percentages // Custom label component to show percentages
const renderLabel = (entry: { percent: number; name: string }) => { const renderLabel = (entry: { percent: number; name: string }) => {
const percent = (entry.percent * 100).toFixed(0); const percent = (entry.percent * 100).toFixed(0);
@@ -38,7 +28,9 @@ const renderLabel = (entry: { percent: number; name: string }) => {
}; };
export function UserStatusChart({ data, loading, error }: UserStatusChartProps) { export function UserStatusChart({ data, loading, error }: UserStatusChartProps) {
const rawData = data || generateMockData(); // Show empty chart if no data available
const rawData = data || [];
const hasData = rawData.length > 0 && rawData.some((d) => d.value > 0);
// Assign colors if missing // Assign colors if missing
const chartData = rawData.map((item, index) => ({ const chartData = rawData.map((item, index) => ({
@@ -53,41 +45,47 @@ export function UserStatusChart({ data, loading, error }: UserStatusChartProps)
loading={loading} loading={loading}
error={error} error={error}
> >
<ResponsiveContainer width="100%" height={300}> {!hasData && !loading && !error ? (
<PieChart> <div className="flex items-center justify-center h-[300px] text-muted-foreground">
<Pie <p>No user status data available</p>
data={chartData} </div>
cx="50%" ) : (
cy="50%" <ResponsiveContainer width="100%" height={300}>
labelLine={false} <PieChart>
label={renderLabel} <Pie
outerRadius={80} data={chartData}
fill="#8884d8" cx="50%"
dataKey="value" cy="50%"
> labelLine={false}
{chartData.map((entry, index) => ( label={renderLabel}
<Cell key={`cell-${index}`} fill={entry.color} /> outerRadius={80}
))} fill="#8884d8"
</Pie> dataKey="value"
<Tooltip >
contentStyle={{ {chartData.map((entry, index) => (
backgroundColor: 'hsl(var(--popover))', <Cell key={`cell-${index}`} fill={entry.color} />
border: '1px solid hsl(var(--border))', ))}
borderRadius: '6px', </Pie>
color: 'hsl(var(--popover-foreground))', <Tooltip
}} contentStyle={{
labelStyle={{ color: 'hsl(var(--popover-foreground))' }} backgroundColor: 'hsl(var(--popover))',
/> border: '1px solid hsl(var(--border))',
<Legend borderRadius: '6px',
verticalAlign="bottom" color: 'hsl(var(--popover-foreground))',
height={36} }}
wrapperStyle={{ labelStyle={{ color: 'hsl(var(--popover-foreground))' }}
paddingTop: '20px', />
color: 'hsl(var(--foreground))', <Legend
}} verticalAlign="bottom"
/> height={36}
</PieChart> wrapperStyle={{
</ResponsiveContainer> paddingTop: '20px',
color: 'hsl(var(--foreground))',
}}
/>
</PieChart>
</ResponsiveContainer>
)}
</ChartCard> </ChartCard>
); );
} }

View File

@@ -7,7 +7,7 @@ export { UserGrowthChart } from './UserGrowthChart';
export type { UserGrowthData } from './UserGrowthChart'; export type { UserGrowthData } from './UserGrowthChart';
export { OrganizationDistributionChart } from './OrganizationDistributionChart'; export { OrganizationDistributionChart } from './OrganizationDistributionChart';
export type { OrganizationDistributionData } from './OrganizationDistributionChart'; export type { OrganizationDistributionData } from './OrganizationDistributionChart';
export { SessionActivityChart } from './SessionActivityChart'; export { RegistrationActivityChart } from './RegistrationActivityChart';
export type { SessionActivityData } from './SessionActivityChart'; export type { RegistrationActivityData } from './RegistrationActivityChart';
export { UserStatusChart } from './UserStatusChart'; export { UserStatusChart } from './UserStatusChart';
export type { UserStatusData } from './UserStatusChart'; export type { UserStatusData } from './UserStatusChart';