Implement Phase 3 usage statistics frontend
- Add AdminOverview page with stats cards and recent activity - Add UsageStats page with charts and heatmap - Active users chart with daily/weekly/monthly toggle - Action breakdown with pie and bar charts - Peak times heatmap (24h x 7d) - Update admin navigation with Overview and Statistics links
This commit is contained in:
24
src/App.jsx
24
src/App.jsx
@ -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 />} />
|
||||||
|
|||||||
504
src/pages/admin/AdminOverview.jsx
Normal file
504
src/pages/admin/AdminOverview.jsx
Normal 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;
|
||||||
629
src/pages/admin/UsageStats.jsx
Normal file
629
src/pages/admin/UsageStats.jsx
Normal 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;
|
||||||
Reference in New Issue
Block a user