Implement Phase 4 settings and polish frontend
- Add AdminSettings page with retention period configuration - Add warning dialog when reducing retention period - Add skeleton loaders to all admin pages (tables, charts, activities) - Add empty states with helpful messages and clear filter buttons - Improve mobile responsiveness across all admin views - Update admin navigation with Settings link
This commit is contained in:
12
src/App.jsx
12
src/App.jsx
@ -11,6 +11,7 @@ import UserDetail from './pages/admin/UserDetail';
|
||||
import ActivityStream from './pages/admin/ActivityStream';
|
||||
import AdminOverview from './pages/admin/AdminOverview';
|
||||
import UsageStats from './pages/admin/UsageStats';
|
||||
import AdminSettings from './pages/admin/AdminSettings';
|
||||
|
||||
const ApartmentDashboard = () => {
|
||||
// Router
|
||||
@ -598,6 +599,12 @@ const ApartmentDashboard = () => {
|
||||
>
|
||||
Statistics
|
||||
</Link>
|
||||
<Link
|
||||
to="/admin/settings"
|
||||
className={`block px-4 py-2 text-sm hover:bg-violet-50 ${location.pathname === '/admin/settings' ? 'bg-violet-100 text-violet-700' : 'text-gray-700'}`}
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -1340,6 +1347,11 @@ const ApartmentDashboard = () => {
|
||||
<UsageStats />
|
||||
</AdminRoute>
|
||||
} />
|
||||
<Route path="/admin/settings" element={
|
||||
<AdminRoute>
|
||||
<AdminSettings />
|
||||
</AdminRoute>
|
||||
} />
|
||||
|
||||
{/* Catch-all redirect to overview */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@ -387,7 +387,7 @@ const ActivityStream = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Activity Stream</h1>
|
||||
<p className="text-gray-600">
|
||||
@ -402,7 +402,7 @@ const ActivityStream = () => {
|
||||
className="flex items-center space-x-2 px-4 py-2 border border-gray-300 rounded-md bg-white hover:bg-gray-50"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span>Export</span>
|
||||
<span className="hidden sm:inline">Export</span>
|
||||
</button>
|
||||
|
||||
{/* Refresh Button */}
|
||||
@ -412,7 +412,7 @@ const ActivityStream = () => {
|
||||
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>
|
||||
<span className="hidden sm:inline">Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -528,9 +528,28 @@ const ActivityStream = () => {
|
||||
{/* Activity List */}
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
{loading && activities.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-violet-600" />
|
||||
<span className="ml-3 text-gray-600">Loading activities...</span>
|
||||
/* Skeleton Loader */
|
||||
<div className="divide-y divide-gray-100">
|
||||
{[...Array(8)].map((_, index) => (
|
||||
<div key={index} className="p-4">
|
||||
<div className="flex items-start space-x-4 animate-pulse">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-200"></div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-4 bg-gray-200 rounded w-24"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-32"></div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-3 bg-gray-200 rounded w-4"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-40"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-20"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : activities.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
@ -538,9 +557,17 @@ const ActivityStream = () => {
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">No Activity Found</h3>
|
||||
<p className="text-gray-600">
|
||||
{hasActiveFilters
|
||||
? 'Try adjusting your filters to see more results.'
|
||||
: 'No activity has been recorded yet.'}
|
||||
? 'No activity recorded in this period. Try adjusting your filters.'
|
||||
: 'No activity has been recorded yet. User activity will appear here once users start interacting with the application.'}
|
||||
</p>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="mt-4 px-4 py-2 text-violet-600 hover:text-violet-700 hover:bg-violet-50 rounded-md"
|
||||
>
|
||||
Clear all filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
@ -598,8 +625,8 @@ const ActivityStream = () => {
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 border-t">
|
||||
<div className="text-sm text-gray-600">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 px-4 py-3 bg-gray-50 border-t">
|
||||
<div className="text-sm text-gray-600 text-center sm:text-left">
|
||||
Showing {((page - 1) * limit) + 1} to {Math.min(page * limit, totalActivities)} of {totalActivities} activities
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
@ -609,17 +636,17 @@ const ActivityStream = () => {
|
||||
className="flex items-center px-3 py-1 border border-gray-300 rounded-md bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
<span className="ml-1">Previous</span>
|
||||
<span className="ml-1 hidden sm:inline">Previous</span>
|
||||
</button>
|
||||
<span className="px-3 py-1 text-sm">
|
||||
Page {page} of {totalPages}
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(Math.min(totalPages, page + 1))}
|
||||
disabled={page === totalPages || loading}
|
||||
className="flex items-center px-3 py-1 border border-gray-300 rounded-md bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="mr-1">Next</span>
|
||||
<span className="mr-1 hidden sm:inline">Next</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -326,8 +326,22 @@ const AdminOverview = () => {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="h-64 flex items-center justify-center">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-violet-600" />
|
||||
/* Chart Skeleton */
|
||||
<div className="h-64 animate-pulse">
|
||||
<div className="flex items-end h-52 space-x-2 px-4">
|
||||
{[...Array(7)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 bg-gray-200 rounded-t"
|
||||
style={{ height: `${Math.random() * 60 + 20}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between mt-3 px-4">
|
||||
{[...Array(7)].map((_, i) => (
|
||||
<div key={i} className="h-3 bg-gray-200 rounded w-8" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : activeUsersData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
|
||||
347
src/pages/admin/AdminSettings.jsx
Normal file
347
src/pages/admin/AdminSettings.jsx
Normal file
@ -0,0 +1,347 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Settings, AlertTriangle, RefreshCw, Save, Clock } from 'lucide-react';
|
||||
|
||||
const API_BASE = 'https://apartments.maverickapplications.com/api';
|
||||
|
||||
const AdminSettings = () => {
|
||||
// State
|
||||
const [settings, setSettings] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [saveError, setSaveError] = useState(null);
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [retentionDays, setRetentionDays] = useState(90);
|
||||
const [originalRetentionDays, setOriginalRetentionDays] = useState(90);
|
||||
|
||||
// Warning dialog state
|
||||
const [showWarningDialog, setShowWarningDialog] = useState(false);
|
||||
|
||||
// Validation
|
||||
const MIN_RETENTION = 30;
|
||||
const MAX_RETENTION = 365;
|
||||
|
||||
const isValidRetention = retentionDays >= MIN_RETENTION && retentionDays <= MAX_RETENTION;
|
||||
const hasChanges = retentionDays !== originalRetentionDays;
|
||||
const isReducingRetention = retentionDays < originalRetentionDays;
|
||||
|
||||
// Fetch settings
|
||||
const fetchSettings = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/admin/settings`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch settings');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setSettings(data);
|
||||
setRetentionDays(data.activityRetentionDays || 90);
|
||||
setOriginalRetentionDays(data.activityRetentionDays || 90);
|
||||
} catch (err) {
|
||||
console.error('Error fetching settings:', err);
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch on mount
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, [fetchSettings]);
|
||||
|
||||
// Clear success message after 3 seconds
|
||||
useEffect(() => {
|
||||
if (saveSuccess) {
|
||||
const timer = setTimeout(() => setSaveSuccess(false), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [saveSuccess]);
|
||||
|
||||
// Handle save
|
||||
const handleSave = async () => {
|
||||
// If reducing retention, show warning first
|
||||
if (isReducingRetention && !showWarningDialog) {
|
||||
setShowWarningDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/admin/settings`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
activityRetentionDays: retentionDays
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Failed to save settings');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setSettings(data.settings);
|
||||
setOriginalRetentionDays(retentionDays);
|
||||
setSaveSuccess(true);
|
||||
setShowWarningDialog(false);
|
||||
} catch (err) {
|
||||
console.error('Error saving settings:', err);
|
||||
setSaveError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle confirm from warning dialog
|
||||
const handleConfirmChange = () => {
|
||||
handleSave();
|
||||
};
|
||||
|
||||
// Handle cancel from warning dialog
|
||||
const handleCancelChange = () => {
|
||||
setShowWarningDialog(false);
|
||||
};
|
||||
|
||||
// Reset to original value
|
||||
const handleReset = () => {
|
||||
setRetentionDays(originalRetentionDays);
|
||||
setSaveError(null);
|
||||
};
|
||||
|
||||
// Skeleton loader
|
||||
const SettingsSkeleton = () => (
|
||||
<div className="space-y-6 animate-pulse">
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="h-6 bg-gray-200 rounded w-48 mb-4"></div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="h-4 bg-gray-200 rounded w-32 mb-2"></div>
|
||||
<div className="h-10 bg-gray-200 rounded w-24"></div>
|
||||
</div>
|
||||
<div className="h-4 bg-gray-200 rounded w-full max-w-md"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4 max-w-md"></div>
|
||||
<div className="h-10 bg-gray-200 rounded w-32 mt-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
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={fetchSettings}
|
||||
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 Settings</h1>
|
||||
<p className="text-gray-600">
|
||||
Configure application settings and data retention policies
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchSettings}
|
||||
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>
|
||||
|
||||
{/* Settings Content */}
|
||||
{loading ? (
|
||||
<SettingsSkeleton />
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center space-x-2 mb-6">
|
||||
<Clock className="w-5 h-5 text-violet-600" />
|
||||
<h2 className="text-lg font-semibold text-gray-900">Activity Log Retention</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 max-w-lg">
|
||||
{/* Retention Period Input */}
|
||||
<div>
|
||||
<label htmlFor="retention-days" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Retention Period (days)
|
||||
</label>
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
id="retention-days"
|
||||
type="number"
|
||||
min={MIN_RETENTION}
|
||||
max={MAX_RETENTION}
|
||||
value={retentionDays}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
if (!isNaN(value)) {
|
||||
setRetentionDays(value);
|
||||
}
|
||||
}}
|
||||
className={`w-24 px-3 py-2 border rounded-md focus:ring-2 focus:ring-violet-500 focus:border-violet-500 ${
|
||||
!isValidRetention ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm text-gray-500">
|
||||
({MIN_RETENTION}-{MAX_RETENTION} days)
|
||||
</span>
|
||||
</div>
|
||||
{!isValidRetention && (
|
||||
<p className="mt-1 text-sm text-red-600">
|
||||
Retention period must be between {MIN_RETENTION} and {MAX_RETENTION} days
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-gray-600">
|
||||
Activity logs older than this will be automatically deleted. This helps manage database size while maintaining an audit trail for recent activity.
|
||||
</p>
|
||||
|
||||
{/* Current Setting Info */}
|
||||
{settings && (
|
||||
<div className="bg-gray-50 rounded-md p-3">
|
||||
<p className="text-sm text-gray-600">
|
||||
<span className="font-medium">Current setting:</span> {originalRetentionDays} days
|
||||
</p>
|
||||
{settings.activityLogLevel && (
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
<span className="font-medium">Log level:</span> {settings.activityLogLevel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Save Error */}
|
||||
{saveError && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-3">
|
||||
<p className="text-sm text-red-700">Error: {saveError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Message */}
|
||||
{saveSuccess && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-md p-3">
|
||||
<p className="text-sm text-green-700">Settings saved successfully</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center space-x-3 pt-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || !isValidRetention || saving}
|
||||
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 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
<span>Saving...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4" />
|
||||
<span>Save Changes</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{hasChanges && (
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning Dialog */}
|
||||
{showWarningDialog && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-lg max-w-md w-full p-6">
|
||||
<div className="flex items-start space-x-3 mb-4">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertTriangle className="w-6 h-6 text-amber-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
Warning: Reducing Retention Period
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
<p className="text-gray-600">
|
||||
Reducing the retention period from{' '}
|
||||
<span className="font-semibold">{originalRetentionDays} days</span> to{' '}
|
||||
<span className="font-semibold">{retentionDays} days</span> will permanently delete
|
||||
activity logs older than {retentionDays} days.
|
||||
</p>
|
||||
<p className="text-red-600 font-medium">
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={handleCancelChange}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmChange}
|
||||
disabled={saving}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-amber-600 text-white rounded-md hover:bg-amber-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||
<span>Saving...</span>
|
||||
</>
|
||||
) : (
|
||||
<span>Confirm Change</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminSettings;
|
||||
@ -339,8 +339,22 @@ const UsageStats = () => {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="h-80 flex items-center justify-center">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-violet-600" />
|
||||
/* Chart Skeleton */
|
||||
<div className="h-80 animate-pulse">
|
||||
<div className="flex items-end h-64 space-x-2 px-8">
|
||||
{[...Array(12)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 bg-gray-200 rounded-t"
|
||||
style={{ height: `${Math.random() * 60 + 20}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between mt-4 px-8">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="h-3 bg-gray-200 rounded w-12" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : activeUsersData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
@ -411,8 +425,11 @@ const UsageStats = () => {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="h-64 flex items-center justify-center">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-violet-600" />
|
||||
/* Pie Chart Skeleton */
|
||||
<div className="h-64 flex items-center justify-center animate-pulse">
|
||||
<div className="w-48 h-48 rounded-full bg-gray-200 relative">
|
||||
<div className="absolute inset-4 rounded-full bg-white"></div>
|
||||
</div>
|
||||
</div>
|
||||
) : actionsData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
@ -457,8 +474,17 @@ const UsageStats = () => {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="h-64 flex items-center justify-center">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-violet-600" />
|
||||
/* Bar Chart Skeleton */
|
||||
<div className="h-64 space-y-3 py-4 animate-pulse">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="flex items-center space-x-3">
|
||||
<div className="h-4 bg-gray-200 rounded w-20" />
|
||||
<div
|
||||
className="h-6 bg-gray-200 rounded"
|
||||
style={{ width: `${Math.random() * 50 + 20}%` }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : actionsData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
@ -504,8 +530,24 @@ const UsageStats = () => {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="h-64 flex items-center justify-center">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-violet-600" />
|
||||
/* Heatmap Skeleton */
|
||||
<div className="space-y-6 animate-pulse">
|
||||
<div>
|
||||
<div className="h-4 bg-gray-200 rounded w-40 mb-3" />
|
||||
<div className="flex space-x-1">
|
||||
{[...Array(24)].map((_, i) => (
|
||||
<div key={i} className="w-6 h-16 bg-gray-200 rounded" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="h-4 bg-gray-200 rounded w-40 mb-3" />
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{[...Array(7)].map((_, i) => (
|
||||
<div key={i} className="h-16 bg-gray-200 rounded" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : heatmapData ? (
|
||||
<div className="space-y-6">
|
||||
|
||||
@ -155,7 +155,7 @@ const UserList = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">User Management</h1>
|
||||
<p className="text-gray-600">
|
||||
@ -165,7 +165,7 @@ const UserList = () => {
|
||||
<button
|
||||
onClick={fetchUsers}
|
||||
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"
|
||||
className="flex items-center justify-center space-x-2 px-4 py-2 bg-violet-600 text-white rounded-md hover:bg-violet-700 disabled:opacity-50 w-full sm:w-auto"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
<span>Refresh</span>
|
||||
@ -208,9 +208,51 @@ const UserList = () => {
|
||||
{/* Users Table */}
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
{loading && users.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-violet-600" />
|
||||
<span className="ml-3 text-gray-600">Loading users...</span>
|
||||
/* Skeleton Loader */
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-max">
|
||||
<thead className="bg-gray-50 border-b">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4 font-semibold text-gray-700">User</th>
|
||||
<th className="text-left py-3 px-4 font-semibold text-gray-700">Email</th>
|
||||
<th className="text-center py-3 px-4 font-semibold text-gray-700">Role</th>
|
||||
<th className="text-center py-3 px-4 font-semibold text-gray-700">Status</th>
|
||||
<th className="text-center py-3 px-4 font-semibold text-gray-700">Logins</th>
|
||||
<th className="text-left py-3 px-4 font-semibold text-gray-700">Last Login</th>
|
||||
<th className="text-left py-3 px-4 font-semibold text-gray-700">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...Array(5)].map((_, index) => (
|
||||
<tr key={index} className={`border-b ${index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}`}>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center space-x-3 animate-pulse">
|
||||
<div className="w-8 h-8 rounded-full bg-gray-200"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-24"></div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="h-4 bg-gray-200 rounded w-40 animate-pulse"></div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-center">
|
||||
<div className="h-5 bg-gray-200 rounded-full w-12 mx-auto animate-pulse"></div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-center">
|
||||
<div className="h-5 bg-gray-200 rounded-full w-16 mx-auto animate-pulse"></div>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-center">
|
||||
<div className="h-4 bg-gray-200 rounded w-8 mx-auto animate-pulse"></div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="h-4 bg-gray-200 rounded w-32 animate-pulse"></div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="h-4 bg-gray-200 rounded w-24 animate-pulse"></div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
@ -218,9 +260,21 @@ const UserList = () => {
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">No Users Found</h3>
|
||||
<p className="text-gray-600">
|
||||
{debouncedSearch || statusFilter !== 'all'
|
||||
? 'Try adjusting your filters to see more results.'
|
||||
? 'No users found matching your search. Try adjusting your filters.'
|
||||
: 'No users have signed up yet.'}
|
||||
</p>
|
||||
{(debouncedSearch || statusFilter !== 'all') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setStatusFilter('all');
|
||||
setPage(1);
|
||||
}}
|
||||
className="mt-4 px-4 py-2 text-violet-600 hover:text-violet-700 hover:bg-violet-50 rounded-md"
|
||||
>
|
||||
Clear all filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
@ -347,8 +401,8 @@ const UserList = () => {
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 border-t">
|
||||
<div className="text-sm text-gray-600">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 px-4 py-3 bg-gray-50 border-t">
|
||||
<div className="text-sm text-gray-600 text-center sm:text-left">
|
||||
Showing {((page - 1) * limit) + 1} to {Math.min(page * limit, totalUsers)} of {totalUsers} users
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
@ -358,17 +412,17 @@ const UserList = () => {
|
||||
className="flex items-center px-3 py-1 border border-gray-300 rounded-md bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
<span className="ml-1">Previous</span>
|
||||
<span className="ml-1 hidden sm:inline">Previous</span>
|
||||
</button>
|
||||
<span className="px-3 py-1 text-sm">
|
||||
Page {page} of {totalPages}
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(Math.min(totalPages, page + 1))}
|
||||
disabled={page === totalPages || loading}
|
||||
className="flex items-center px-3 py-1 border border-gray-300 rounded-md bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="mr-1">Next</span>
|
||||
<span className="mr-1 hidden sm:inline">Next</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user