Admin Dashboard Frontend (Phases 1-4) #3

Merged
stephen merged 7 commits from admin into main 2026-01-22 14:12:59 -07:00
13 changed files with 3334 additions and 117 deletions
Showing only changes of commit 40727c53e1 - Show all commits

View File

@ -9,6 +9,8 @@ import Login from './pages/Login';
import UserList from './pages/admin/UserList'; import UserList from './pages/admin/UserList';
import UserDetail from './pages/admin/UserDetail'; import UserDetail from './pages/admin/UserDetail';
import ActivityStream from './pages/admin/ActivityStream'; import ActivityStream from './pages/admin/ActivityStream';
import AdminOverview from './pages/admin/AdminOverview';
import UsageStats from './pages/admin/UsageStats';
const ApartmentDashboard = () => { const ApartmentDashboard = () => {
// Router // Router
@ -572,6 +574,12 @@ const ApartmentDashboard = () => {
<ChevronDown className="w-4 h-4" /> <ChevronDown className="w-4 h-4" />
</button> </button>
<div className="absolute left-0 mt-1 w-48 bg-white rounded-md shadow-lg border border-gray-200 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-150 z-50"> <div className="absolute left-0 mt-1 w-48 bg-white rounded-md shadow-lg border border-gray-200 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-150 z-50">
<Link
to="/admin"
className={`block px-4 py-2 text-sm hover:bg-violet-50 ${location.pathname === '/admin' ? 'bg-violet-100 text-violet-700' : 'text-gray-700'}`}
>
Overview
</Link>
<Link <Link
to="/admin/users" to="/admin/users"
className={`block px-4 py-2 text-sm hover:bg-violet-50 ${location.pathname === '/admin/users' ? 'bg-violet-100 text-violet-700' : 'text-gray-700'}`} className={`block px-4 py-2 text-sm hover:bg-violet-50 ${location.pathname === '/admin/users' ? 'bg-violet-100 text-violet-700' : 'text-gray-700'}`}
@ -584,6 +592,12 @@ const ApartmentDashboard = () => {
> >
Activity Activity
</Link> </Link>
<Link
to="/admin/stats"
className={`block px-4 py-2 text-sm hover:bg-violet-50 ${location.pathname === '/admin/stats' ? 'bg-violet-100 text-violet-700' : 'text-gray-700'}`}
>
Statistics
</Link>
</div> </div>
</div> </div>
)} )}
@ -1301,6 +1315,11 @@ const ApartmentDashboard = () => {
} /> } />
{/* Admin Routes */} {/* Admin Routes */}
<Route path="/admin" element={
<AdminRoute>
<AdminOverview />
</AdminRoute>
} />
<Route path="/admin/users" element={ <Route path="/admin/users" element={
<AdminRoute> <AdminRoute>
<UserList /> <UserList />
@ -1316,6 +1335,11 @@ const ApartmentDashboard = () => {
<ActivityStream /> <ActivityStream />
</AdminRoute> </AdminRoute>
} /> } />
<Route path="/admin/stats" element={
<AdminRoute>
<UsageStats />
</AdminRoute>
} />
{/* Catch-all redirect to overview */} {/* Catch-all redirect to overview */}
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />

View File

@ -0,0 +1,504 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer
} from 'recharts';
import {
Users,
UserPlus,
Activity,
TrendingUp,
RefreshCw,
Calendar,
LogIn,
Eye,
ChevronRight
} from 'lucide-react';
const API_BASE = 'https://apartments.maverickapplications.com/api';
// Stats card component
const StatsCard = ({ title, value, subtitle, icon: Icon, color = 'violet', loading = false }) => {
const colorClasses = {
violet: 'bg-violet-100 text-violet-600',
green: 'bg-green-100 text-green-600',
blue: 'bg-blue-100 text-blue-600',
orange: 'bg-orange-100 text-orange-600',
red: 'bg-red-100 text-red-600'
};
return (
<div className="bg-white rounded-lg shadow-md p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">{title}</p>
{loading ? (
<div className="h-8 w-16 bg-gray-200 animate-pulse rounded mt-1" />
) : (
<p className="text-2xl font-bold text-gray-900">{value}</p>
)}
{subtitle && <p className="text-sm text-gray-500 mt-1">{subtitle}</p>}
</div>
<div className={`p-3 rounded-full ${colorClasses[color]}`}>
<Icon className="w-6 h-6" />
</div>
</div>
</div>
);
};
// Action type icons and labels for recent activity
const ACTION_CONFIG = {
LOGIN: { icon: LogIn, label: 'logged in', color: 'text-green-600' },
LOGOUT: { icon: LogIn, label: 'logged out', color: 'text-gray-600' },
PAGE_VIEW: { icon: Eye, label: 'viewed', color: 'text-blue-600' },
NAVIGATION: { icon: Activity, label: 'navigated', color: 'text-violet-600' }
};
const AdminOverview = () => {
const navigate = useNavigate();
// State
const [overview, setOverview] = useState(null);
const [activeUsersData, setActiveUsersData] = useState([]);
const [recentActivity, setRecentActivity] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Fetch overview stats
const fetchOverview = useCallback(async () => {
try {
const response = await fetch(`${API_BASE}/admin/stats/overview`, {
credentials: 'include'
});
if (!response.ok) {
throw new Error('Failed to fetch overview stats');
}
const data = await response.json();
setOverview(data.data);
} catch (err) {
console.error('Error fetching overview:', err);
throw err;
}
}, []);
// Fetch 7-day active users
const fetchActiveUsers = useCallback(async () => {
try {
const response = await fetch(`${API_BASE}/admin/stats/active-users?period=daily&days=7`, {
credentials: 'include'
});
if (!response.ok) {
throw new Error('Failed to fetch active users data');
}
const data = await response.json();
// Sort by date ascending for chart display
const sortedData = (data.data || []).sort((a, b) =>
new Date(a.date) - new Date(b.date)
);
setActiveUsersData(sortedData);
} catch (err) {
console.error('Error fetching active users:', err);
throw err;
}
}, []);
// Fetch recent activity (last 10)
const fetchRecentActivity = useCallback(async () => {
try {
const response = await fetch(`${API_BASE}/admin/activity?limit=10`, {
credentials: 'include'
});
if (!response.ok) {
throw new Error('Failed to fetch recent activity');
}
const data = await response.json();
setRecentActivity(data.activities || []);
} catch (err) {
console.error('Error fetching recent activity:', err);
throw err;
}
}, []);
// Fetch all data
const fetchAllData = useCallback(async () => {
setLoading(true);
setError(null);
try {
await Promise.all([
fetchOverview(),
fetchActiveUsers(),
fetchRecentActivity()
]);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [fetchOverview, fetchActiveUsers, fetchRecentActivity]);
// Fetch on mount
useEffect(() => {
fetchAllData();
}, [fetchAllData]);
// Format relative time
const formatRelativeTime = (dateStr) => {
if (!dateStr) return '';
const date = new Date(dateStr);
const now = new Date();
const diffMs = now - date;
const diffSeconds = Math.floor(diffMs / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSeconds < 60) {
return 'Just now';
} else if (diffMinutes < 60) {
return `${diffMinutes}m ago`;
} else if (diffHours < 24) {
return `${diffHours}h ago`;
} else if (diffDays < 7) {
return `${diffDays}d ago`;
} else {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
});
}
};
// Format date for chart
const formatChartDate = (dateStr) => {
if (!dateStr) return '';
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
});
};
// Get action description
const getActionDescription = (activity) => {
switch (activity.action) {
case 'PAGE_VIEW':
return `viewed ${activity.metadata?.path || 'a page'}`;
case 'NAVIGATION':
return `navigated to ${activity.metadata?.to || 'unknown'}`;
case 'LOGIN':
return 'logged in';
case 'LOGOUT':
return 'logged out';
default:
return activity.action.toLowerCase().replace(/_/g, ' ');
}
};
// Navigate to user detail
const handleUserClick = (userId, e) => {
e.stopPropagation();
navigate(`/admin/users/${userId}`);
};
if (error) {
return (
<div className="space-y-6">
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
<p className="text-red-700 mb-4">Error: {error}</p>
<button
onClick={fetchAllData}
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
>
Try Again
</button>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Admin Dashboard</h1>
<p className="text-gray-600">Overview of user activity and statistics</p>
</div>
<button
onClick={fetchAllData}
disabled={loading}
className="flex items-center space-x-2 px-4 py-2 bg-violet-600 text-white rounded-md hover:bg-violet-700 disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
<span>Refresh</span>
</button>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatsCard
title="Total Users"
value={overview?.totalUsers || 0}
subtitle={`${overview?.activeUsers || 0} active`}
icon={Users}
color="violet"
loading={loading}
/>
<StatsCard
title="Active Today (DAU)"
value={overview?.dau || 0}
icon={Activity}
color="green"
loading={loading}
/>
<StatsCard
title="Active This Week (WAU)"
value={overview?.wau || 0}
icon={TrendingUp}
color="blue"
loading={loading}
/>
<StatsCard
title="New This Week"
value={overview?.newUsersThisWeek || 0}
subtitle={`${overview?.newUsersToday || 0} today`}
icon={UserPlus}
color="orange"
loading={loading}
/>
</div>
{/* Second Row Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<StatsCard
title="Monthly Active (MAU)"
value={overview?.mau || 0}
icon={Calendar}
color="violet"
loading={loading}
/>
<StatsCard
title="Total Logins"
value={overview?.totalLogins?.toLocaleString() || 0}
subtitle={`Avg ${overview?.avgLoginsPerUser?.toFixed(1) || 0} per user`}
icon={LogIn}
color="green"
loading={loading}
/>
<StatsCard
title="DAU/MAU Ratio"
value={overview?.mau > 0 ? `${((overview?.dau / overview?.mau) * 100).toFixed(1)}%` : '0%'}
subtitle="Engagement indicator"
icon={TrendingUp}
color="blue"
loading={loading}
/>
</div>
{/* Charts and Activity Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 7-Day Active Users Chart */}
<div className="bg-white rounded-lg shadow-md p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">Active Users (7 Days)</h2>
<button
onClick={() => navigate('/admin/stats')}
className="text-sm text-violet-600 hover:text-violet-700 flex items-center"
>
View More
<ChevronRight className="w-4 h-4 ml-1" />
</button>
</div>
{loading ? (
<div className="h-64 flex items-center justify-center">
<RefreshCw className="w-8 h-8 animate-spin text-violet-600" />
</div>
) : activeUsersData.length > 0 ? (
<ResponsiveContainer width="100%" height={250}>
<LineChart data={activeUsersData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="date"
tickFormatter={formatChartDate}
tick={{ fontSize: 12 }}
/>
<YAxis
allowDecimals={false}
tick={{ fontSize: 12 }}
/>
<Tooltip
formatter={(value) => [value, 'Active Users']}
labelFormatter={formatChartDate}
/>
<Line
type="monotone"
dataKey="count"
stroke="#7c3aed"
strokeWidth={2}
dot={{ fill: '#7c3aed', strokeWidth: 2 }}
activeDot={{ r: 6 }}
/>
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-64 flex items-center justify-center text-gray-500">
No activity data available
</div>
)}
</div>
{/* Recent Activity Widget */}
<div className="bg-white rounded-lg shadow-md p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">Recent Activity</h2>
<button
onClick={() => navigate('/admin/activity')}
className="text-sm text-violet-600 hover:text-violet-700 flex items-center"
>
View All
<ChevronRight className="w-4 h-4 ml-1" />
</button>
</div>
{loading ? (
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-center space-x-3">
<div className="w-8 h-8 bg-gray-200 rounded-full animate-pulse" />
<div className="flex-1">
<div className="h-4 bg-gray-200 rounded animate-pulse w-3/4" />
<div className="h-3 bg-gray-200 rounded animate-pulse w-1/2 mt-1" />
</div>
</div>
))}
</div>
) : recentActivity.length > 0 ? (
<div className="space-y-3 max-h-[280px] overflow-y-auto">
{recentActivity.map((activity) => {
const config = ACTION_CONFIG[activity.action] || {
icon: Activity,
color: 'text-gray-600'
};
const Icon = config.icon;
return (
<div
key={activity._id}
className="flex items-start space-x-3 p-2 hover:bg-gray-50 rounded-lg transition-colors"
>
{/* Avatar */}
<button
onClick={(e) => handleUserClick(activity.userId, e)}
className="flex-shrink-0"
>
{activity.userPicture ? (
<img
src={activity.userPicture}
alt={activity.userName}
className="w-8 h-8 rounded-full"
/>
) : (
<div className="w-8 h-8 rounded-full bg-gray-300 flex items-center justify-center">
<span className="text-xs font-medium text-gray-600">
{activity.userName?.charAt(0)?.toUpperCase() || '?'}
</span>
</div>
)}
</button>
{/* Content */}
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-900 truncate">
<button
onClick={(e) => handleUserClick(activity.userId, e)}
className="font-medium hover:text-violet-600"
>
{activity.userName || 'Unknown'}
</button>
{' '}
<span className="text-gray-600">
{getActionDescription(activity)}
</span>
</p>
<p className="text-xs text-gray-500 flex items-center mt-0.5">
<Icon className={`w-3 h-3 mr-1 ${config.color}`} />
{formatRelativeTime(activity.timestamp)}
</p>
</div>
</div>
);
})}
</div>
) : (
<div className="h-64 flex items-center justify-center text-gray-500">
<div className="text-center">
<Activity className="w-12 h-12 text-gray-300 mx-auto mb-2" />
<p>No recent activity</p>
</div>
</div>
)}
</div>
</div>
{/* Quick Links */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<button
onClick={() => navigate('/admin/users')}
className="bg-white rounded-lg shadow-md p-4 hover:shadow-lg transition-shadow flex items-center justify-between"
>
<div className="flex items-center space-x-3">
<div className="p-2 bg-violet-100 rounded-lg">
<Users className="w-5 h-5 text-violet-600" />
</div>
<span className="font-medium text-gray-900">Manage Users</span>
</div>
<ChevronRight className="w-5 h-5 text-gray-400" />
</button>
<button
onClick={() => navigate('/admin/activity')}
className="bg-white rounded-lg shadow-md p-4 hover:shadow-lg transition-shadow flex items-center justify-between"
>
<div className="flex items-center space-x-3">
<div className="p-2 bg-blue-100 rounded-lg">
<Activity className="w-5 h-5 text-blue-600" />
</div>
<span className="font-medium text-gray-900">Activity Stream</span>
</div>
<ChevronRight className="w-5 h-5 text-gray-400" />
</button>
<button
onClick={() => navigate('/admin/stats')}
className="bg-white rounded-lg shadow-md p-4 hover:shadow-lg transition-shadow flex items-center justify-between"
>
<div className="flex items-center space-x-3">
<div className="p-2 bg-green-100 rounded-lg">
<TrendingUp className="w-5 h-5 text-green-600" />
</div>
<span className="font-medium text-gray-900">Full Statistics</span>
</div>
<ChevronRight className="w-5 h-5 text-gray-400" />
</button>
</div>
</div>
);
};
export default AdminOverview;

View File

@ -0,0 +1,629 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
LineChart,
Line,
BarChart,
Bar,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend
} from 'recharts';
import {
TrendingUp,
RefreshCw,
ArrowLeft,
Calendar,
Activity,
Clock,
Users
} from 'lucide-react';
const API_BASE = 'https://apartments.maverickapplications.com/api';
// Colors for charts
const CHART_COLORS = ['#7c3aed', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#6366f1', '#ec4899'];
// Heatmap color scale (light to dark violet)
const HEATMAP_COLORS = [
'#f5f3ff', // 0 - lightest
'#ede9fe', // 1
'#ddd6fe', // 2
'#c4b5fd', // 3
'#a78bfa', // 4
'#8b5cf6', // 5
'#7c3aed', // 6
'#6d28d9', // 7
'#5b21b6', // 8
'#4c1d95' // 9 - darkest
];
// Day names for heatmap
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const UsageStats = () => {
const navigate = useNavigate();
// State
const [activeUsersData, setActiveUsersData] = useState([]);
const [actionsData, setActionsData] = useState([]);
const [peakTimesData, setPeakTimesData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Period toggle for active users chart
const [period, setPeriod] = useState('daily');
const [days, setDays] = useState(30);
// Date range for actions
const [actionStartDate, setActionStartDate] = useState(() => {
const date = new Date();
date.setDate(date.getDate() - 30);
return date.toISOString().split('T')[0];
});
const [actionEndDate, setActionEndDate] = useState(() => {
return new Date().toISOString().split('T')[0];
});
// Fetch active users data
const fetchActiveUsers = useCallback(async () => {
try {
const response = await fetch(
`${API_BASE}/admin/stats/active-users?period=${period}&days=${days}`,
{ credentials: 'include' }
);
if (!response.ok) {
throw new Error('Failed to fetch active users data');
}
const data = await response.json();
// Sort by date ascending for chart display
const sortedData = (data.data || []).sort((a, b) =>
new Date(a.date) - new Date(b.date)
);
setActiveUsersData(sortedData);
} catch (err) {
console.error('Error fetching active users:', err);
throw err;
}
}, [period, days]);
// Fetch actions breakdown
const fetchActions = useCallback(async () => {
try {
const params = new URLSearchParams();
if (actionStartDate) {
params.append('startDate', new Date(actionStartDate).toISOString());
}
if (actionEndDate) {
const end = new Date(actionEndDate);
end.setHours(23, 59, 59, 999);
params.append('endDate', end.toISOString());
}
const response = await fetch(
`${API_BASE}/admin/stats/actions?${params}`,
{ credentials: 'include' }
);
if (!response.ok) {
throw new Error('Failed to fetch actions data');
}
const data = await response.json();
setActionsData(data.data?.actions || []);
} catch (err) {
console.error('Error fetching actions:', err);
throw err;
}
}, [actionStartDate, actionEndDate]);
// Fetch peak times
const fetchPeakTimes = useCallback(async () => {
try {
const response = await fetch(`${API_BASE}/admin/stats/peak-times`, {
credentials: 'include'
});
if (!response.ok) {
throw new Error('Failed to fetch peak times data');
}
const data = await response.json();
setPeakTimesData(data.data);
} catch (err) {
console.error('Error fetching peak times:', err);
throw err;
}
}, []);
// Fetch all data
const fetchAllData = useCallback(async () => {
setLoading(true);
setError(null);
try {
await Promise.all([
fetchActiveUsers(),
fetchActions(),
fetchPeakTimes()
]);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [fetchActiveUsers, fetchActions, fetchPeakTimes]);
// Fetch on mount
useEffect(() => {
fetchAllData();
}, [fetchAllData]);
// Re-fetch active users when period changes
useEffect(() => {
fetchActiveUsers();
}, [fetchActiveUsers]);
// Re-fetch actions when date range changes
useEffect(() => {
fetchActions();
}, [fetchActions]);
// Format date for chart
const formatChartDate = (dateStr) => {
if (!dateStr) return '';
const date = new Date(dateStr);
if (period === 'monthly') {
return date.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
} else if (period === 'weekly') {
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
};
// Format hour for display
const formatHour = (hour) => {
if (hour === 0) return '12am';
if (hour === 12) return '12pm';
return hour < 12 ? `${hour}am` : `${hour - 12}pm`;
};
// Get color for heatmap cell based on value
const getHeatmapColor = (value, maxValue) => {
if (!value || maxValue === 0) return HEATMAP_COLORS[0];
const intensity = Math.min(Math.floor((value / maxValue) * 10), 9);
return HEATMAP_COLORS[intensity];
};
// Build heatmap data (24 hours x 7 days)
const buildHeatmapData = () => {
if (!peakTimesData?.hourly || !peakTimesData?.daily) return null;
// Find max value for color scaling
const hourlyMax = Math.max(...peakTimesData.hourly.map(h => h.avgActivity || 0));
const dailyMax = Math.max(...peakTimesData.daily.map(d => d.avgActivity || 0));
const maxValue = Math.max(hourlyMax, dailyMax);
// Create a simple heatmap structure
// For now, we'll show hourly distribution across the week
// The backend returns separate hourly and daily averages
return {
hourly: peakTimesData.hourly,
daily: peakTimesData.daily,
maxValue
};
};
const heatmapData = buildHeatmapData();
// Custom tooltip for pie chart
const CustomPieTooltip = ({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0];
const total = actionsData.reduce((sum, d) => sum + (d.count || 0), 0);
const percentage = total > 0 ? ((data.value / total) * 100).toFixed(1) : 0;
return (
<div className="bg-white p-3 border rounded-lg shadow-lg">
<p className="font-medium">{data.payload.action}</p>
<p className="text-sm text-gray-600">{data.value.toLocaleString()} ({percentage}%)</p>
</div>
);
}
return null;
};
if (error) {
return (
<div className="space-y-6">
<button
onClick={() => navigate('/admin')}
className="flex items-center text-violet-600 hover:text-violet-700"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Dashboard
</button>
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
<p className="text-red-700 mb-4">Error: {error}</p>
<button
onClick={fetchAllData}
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
>
Try Again
</button>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<button
onClick={() => navigate('/admin')}
className="flex items-center text-violet-600 hover:text-violet-700 mb-2"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Dashboard
</button>
<h1 className="text-2xl font-bold text-gray-900">Usage Statistics</h1>
<p className="text-gray-600">Detailed analytics and usage patterns</p>
</div>
<button
onClick={fetchAllData}
disabled={loading}
className="flex items-center space-x-2 px-4 py-2 bg-violet-600 text-white rounded-md hover:bg-violet-700 disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
<span>Refresh</span>
</button>
</div>
{/* Active Users Chart */}
<div className="bg-white rounded-lg shadow-md p-6">
<div className="flex flex-wrap items-center justify-between gap-4 mb-4">
<div className="flex items-center space-x-2">
<TrendingUp className="w-5 h-5 text-violet-600" />
<h2 className="text-lg font-semibold text-gray-900">Active Users Over Time</h2>
</div>
<div className="flex flex-wrap items-center gap-4">
{/* Period Toggle */}
<div className="flex items-center space-x-1 bg-gray-100 rounded-lg p-1">
{['daily', 'weekly', 'monthly'].map((p) => (
<button
key={p}
onClick={() => {
setPeriod(p);
// Adjust days based on period
if (p === 'daily') setDays(30);
else if (p === 'weekly') setDays(12);
else setDays(12);
}}
className={`px-3 py-1 rounded-md text-sm font-medium transition-colors ${
period === p
? 'bg-white text-violet-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900'
}`}
>
{p.charAt(0).toUpperCase() + p.slice(1)}
</button>
))}
</div>
{/* Days selector */}
<div className="flex items-center space-x-2">
<Calendar className="w-4 h-4 text-gray-500" />
<select
value={days}
onChange={(e) => setDays(parseInt(e.target.value))}
className="border border-gray-300 rounded-md px-2 py-1 text-sm focus:ring-2 focus:ring-violet-500"
>
<option value={7}>Last 7 {period === 'daily' ? 'days' : period === 'weekly' ? 'weeks' : 'months'}</option>
<option value={14}>Last 14 {period === 'daily' ? 'days' : period === 'weekly' ? 'weeks' : 'months'}</option>
<option value={30}>Last 30 {period === 'daily' ? 'days' : period === 'weekly' ? 'weeks' : 'months'}</option>
<option value={90}>Last 90 {period === 'daily' ? 'days' : period === 'weekly' ? 'weeks' : 'months'}</option>
</select>
</div>
</div>
</div>
{loading ? (
<div className="h-80 flex items-center justify-center">
<RefreshCw className="w-8 h-8 animate-spin text-violet-600" />
</div>
) : activeUsersData.length > 0 ? (
<ResponsiveContainer width="100%" height={320}>
<LineChart data={activeUsersData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="date"
tickFormatter={formatChartDate}
tick={{ fontSize: 12 }}
interval="preserveStartEnd"
/>
<YAxis
allowDecimals={false}
tick={{ fontSize: 12 }}
/>
<Tooltip
formatter={(value) => [value, 'Active Users']}
labelFormatter={formatChartDate}
/>
<Legend />
<Line
type="monotone"
dataKey="count"
name="Active Users"
stroke="#7c3aed"
strokeWidth={2}
dot={{ fill: '#7c3aed', strokeWidth: 2, r: 3 }}
activeDot={{ r: 6 }}
/>
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-80 flex items-center justify-center text-gray-500">
<div className="text-center">
<Users className="w-12 h-12 text-gray-300 mx-auto mb-2" />
<p>No active user data available</p>
</div>
</div>
)}
</div>
{/* Action Breakdown Section */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Pie Chart */}
<div className="bg-white rounded-lg shadow-md p-6">
<div className="flex flex-wrap items-center justify-between gap-4 mb-4">
<div className="flex items-center space-x-2">
<Activity className="w-5 h-5 text-blue-600" />
<h2 className="text-lg font-semibold text-gray-900">Action Breakdown</h2>
</div>
{/* Date Range */}
<div className="flex items-center space-x-2 text-sm">
<input
type="date"
value={actionStartDate}
onChange={(e) => setActionStartDate(e.target.value)}
className="border border-gray-300 rounded-md px-2 py-1 focus:ring-2 focus:ring-violet-500"
/>
<span className="text-gray-500">to</span>
<input
type="date"
value={actionEndDate}
onChange={(e) => setActionEndDate(e.target.value)}
className="border border-gray-300 rounded-md px-2 py-1 focus:ring-2 focus:ring-violet-500"
/>
</div>
</div>
{loading ? (
<div className="h-64 flex items-center justify-center">
<RefreshCw className="w-8 h-8 animate-spin text-violet-600" />
</div>
) : actionsData.length > 0 ? (
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie
data={actionsData}
dataKey="count"
nameKey="action"
cx="50%"
cy="50%"
outerRadius={100}
label={({ action, percent }) =>
`${action} (${(percent * 100).toFixed(0)}%)`
}
labelLine={{ stroke: '#64748b', strokeWidth: 1 }}
>
{actionsData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={CHART_COLORS[index % CHART_COLORS.length]}
/>
))}
</Pie>
<Tooltip content={<CustomPieTooltip />} />
</PieChart>
</ResponsiveContainer>
) : (
<div className="h-64 flex items-center justify-center text-gray-500">
<div className="text-center">
<Activity className="w-12 h-12 text-gray-300 mx-auto mb-2" />
<p>No action data available</p>
</div>
</div>
)}
</div>
{/* Bar Chart */}
<div className="bg-white rounded-lg shadow-md p-6">
<div className="flex items-center space-x-2 mb-4">
<Activity className="w-5 h-5 text-green-600" />
<h2 className="text-lg font-semibold text-gray-900">Actions by Type</h2>
</div>
{loading ? (
<div className="h-64 flex items-center justify-center">
<RefreshCw className="w-8 h-8 animate-spin text-violet-600" />
</div>
) : actionsData.length > 0 ? (
<ResponsiveContainer width="100%" height={280}>
<BarChart data={actionsData} layout="vertical">
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" tick={{ fontSize: 12 }} />
<YAxis
type="category"
dataKey="action"
tick={{ fontSize: 12 }}
width={100}
/>
<Tooltip
formatter={(value) => [value.toLocaleString(), 'Count']}
/>
<Bar dataKey="count" radius={[0, 4, 4, 0]}>
{actionsData.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={CHART_COLORS[index % CHART_COLORS.length]}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-64 flex items-center justify-center text-gray-500">
<div className="text-center">
<Activity className="w-12 h-12 text-gray-300 mx-auto mb-2" />
<p>No action data available</p>
</div>
</div>
)}
</div>
</div>
{/* Peak Times Heatmap */}
<div className="bg-white rounded-lg shadow-md p-6">
<div className="flex items-center space-x-2 mb-4">
<Clock className="w-5 h-5 text-orange-600" />
<h2 className="text-lg font-semibold text-gray-900">Peak Usage Times</h2>
<span className="text-sm text-gray-500">(Based on last 30 days)</span>
</div>
{loading ? (
<div className="h-64 flex items-center justify-center">
<RefreshCw className="w-8 h-8 animate-spin text-violet-600" />
</div>
) : heatmapData ? (
<div className="space-y-6">
{/* Hourly Distribution */}
<div>
<h3 className="text-sm font-medium text-gray-700 mb-3">Hourly Activity Distribution</h3>
<div className="overflow-x-auto">
<div className="inline-flex items-end space-x-1 min-w-full">
{heatmapData.hourly.map((hourData, index) => {
const height = heatmapData.maxValue > 0
? Math.max(20, (hourData.avgActivity / heatmapData.maxValue) * 100)
: 20;
return (
<div key={index} className="flex flex-col items-center">
<div
className="w-6 rounded-t transition-all duration-200 hover:opacity-80"
style={{
height: `${height}px`,
backgroundColor: getHeatmapColor(hourData.avgActivity, heatmapData.maxValue)
}}
title={`${formatHour(hourData.hour)}: ${hourData.avgActivity?.toFixed(1) || 0} avg activities`}
/>
<span className="text-xs text-gray-500 mt-1">
{index % 3 === 0 ? formatHour(hourData.hour) : ''}
</span>
</div>
);
})}
</div>
</div>
</div>
{/* Daily Distribution */}
<div>
<h3 className="text-sm font-medium text-gray-700 mb-3">Daily Activity Distribution</h3>
<div className="grid grid-cols-7 gap-2">
{heatmapData.daily.map((dayData, index) => {
const dayIndex = DAY_NAMES.indexOf(dayData.day);
const displayDay = dayIndex !== -1 ? DAY_NAMES[dayIndex] : dayData.day;
return (
<div
key={index}
className="p-4 rounded-lg text-center transition-all duration-200 hover:opacity-80"
style={{
backgroundColor: getHeatmapColor(dayData.avgActivity, heatmapData.maxValue)
}}
title={`${displayDay}: ${dayData.avgActivity?.toFixed(1) || 0} avg activities`}
>
<p className="text-xs font-medium text-gray-700">{displayDay}</p>
<p className="text-lg font-bold text-gray-900">
{dayData.avgActivity?.toFixed(0) || 0}
</p>
</div>
);
})}
</div>
</div>
{/* Color Legend */}
<div className="flex items-center justify-end space-x-2">
<span className="text-xs text-gray-500">Less</span>
<div className="flex space-x-0.5">
{HEATMAP_COLORS.map((color, i) => (
<div
key={i}
className="w-4 h-4 rounded-sm"
style={{ backgroundColor: color }}
/>
))}
</div>
<span className="text-xs text-gray-500">More</span>
</div>
</div>
) : (
<div className="h-64 flex items-center justify-center text-gray-500">
<div className="text-center">
<Clock className="w-12 h-12 text-gray-300 mx-auto mb-2" />
<p>No peak times data available</p>
</div>
</div>
)}
</div>
{/* Summary Stats */}
{actionsData.length > 0 && (
<div className="bg-gray-100 rounded-lg p-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
<div>
<p className="text-sm text-gray-600">Total Actions</p>
<p className="text-lg font-bold text-gray-900">
{actionsData.reduce((sum, a) => sum + (a.count || 0), 0).toLocaleString()}
</p>
</div>
<div>
<p className="text-sm text-gray-600">Most Common</p>
<p className="text-lg font-bold text-gray-900">
{actionsData.length > 0
? actionsData.reduce((max, a) => a.count > max.count ? a : max, actionsData[0]).action
: 'N/A'}
</p>
</div>
<div>
<p className="text-sm text-gray-600">Action Types</p>
<p className="text-lg font-bold text-gray-900">{actionsData.length}</p>
</div>
<div>
<p className="text-sm text-gray-600">Date Range</p>
<p className="text-lg font-bold text-gray-900">
{actionStartDate && actionEndDate
? `${new Date(actionStartDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} - ${new Date(actionEndDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`
: 'All time'}
</p>
</div>
</div>
</div>
)}
</div>
);
};
export default UsageStats;