Admin Dashboard Frontend (Phases 1-4) #3
24
src/App.jsx
24
src/App.jsx
@ -9,6 +9,8 @@ import Login from './pages/Login';
|
||||
import UserList from './pages/admin/UserList';
|
||||
import UserDetail from './pages/admin/UserDetail';
|
||||
import ActivityStream from './pages/admin/ActivityStream';
|
||||
import AdminOverview from './pages/admin/AdminOverview';
|
||||
import UsageStats from './pages/admin/UsageStats';
|
||||
|
||||
const ApartmentDashboard = () => {
|
||||
// Router
|
||||
@ -572,6 +574,12 @@ const ApartmentDashboard = () => {
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</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">
|
||||
<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
|
||||
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'}`}
|
||||
@ -584,6 +592,12 @@ const ApartmentDashboard = () => {
|
||||
>
|
||||
Activity
|
||||
</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>
|
||||
)}
|
||||
@ -1301,6 +1315,11 @@ const ApartmentDashboard = () => {
|
||||
} />
|
||||
|
||||
{/* Admin Routes */}
|
||||
<Route path="/admin" element={
|
||||
<AdminRoute>
|
||||
<AdminOverview />
|
||||
</AdminRoute>
|
||||
} />
|
||||
<Route path="/admin/users" element={
|
||||
<AdminRoute>
|
||||
<UserList />
|
||||
@ -1316,6 +1335,11 @@ const ApartmentDashboard = () => {
|
||||
<ActivityStream />
|
||||
</AdminRoute>
|
||||
} />
|
||||
<Route path="/admin/stats" element={
|
||||
<AdminRoute>
|
||||
<UsageStats />
|
||||
</AdminRoute>
|
||||
} />
|
||||
|
||||
{/* Catch-all redirect to overview */}
|
||||
<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