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:
2026-01-22 13:25:51 -07:00
parent 40727c53e1
commit 338e9e3a76
6 changed files with 530 additions and 34 deletions

View File

@ -11,6 +11,7 @@ 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 AdminOverview from './pages/admin/AdminOverview';
import UsageStats from './pages/admin/UsageStats'; import UsageStats from './pages/admin/UsageStats';
import AdminSettings from './pages/admin/AdminSettings';
const ApartmentDashboard = () => { const ApartmentDashboard = () => {
// Router // Router
@ -598,6 +599,12 @@ const ApartmentDashboard = () => {
> >
Statistics Statistics
</Link> </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>
</div> </div>
)} )}
@ -1340,6 +1347,11 @@ const ApartmentDashboard = () => {
<UsageStats /> <UsageStats />
</AdminRoute> </AdminRoute>
} /> } />
<Route path="/admin/settings" element={
<AdminRoute>
<AdminSettings />
</AdminRoute>
} />
{/* Catch-all redirect to overview */} {/* Catch-all redirect to overview */}
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />

View File

@ -387,7 +387,7 @@ const ActivityStream = () => {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* 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> <div>
<h1 className="text-2xl font-bold text-gray-900">Activity Stream</h1> <h1 className="text-2xl font-bold text-gray-900">Activity Stream</h1>
<p className="text-gray-600"> <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" 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" /> <Download className="w-4 h-4" />
<span>Export</span> <span className="hidden sm:inline">Export</span>
</button> </button>
{/* Refresh 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" 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' : ''}`} /> <RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
<span>Refresh</span> <span className="hidden sm:inline">Refresh</span>
</button> </button>
</div> </div>
</div> </div>
@ -528,9 +528,28 @@ const ActivityStream = () => {
{/* Activity List */} {/* Activity List */}
<div className="bg-white rounded-lg shadow-md overflow-hidden"> <div className="bg-white rounded-lg shadow-md overflow-hidden">
{loading && activities.length === 0 ? ( {loading && activities.length === 0 ? (
<div className="flex items-center justify-center py-12"> /* Skeleton Loader */
<RefreshCw className="w-8 h-8 animate-spin text-violet-600" /> <div className="divide-y divide-gray-100">
<span className="ml-3 text-gray-600">Loading activities...</span> {[...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> </div>
) : activities.length === 0 ? ( ) : activities.length === 0 ? (
<div className="text-center py-12"> <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> <h3 className="text-lg font-semibold text-gray-900 mb-2">No Activity Found</h3>
<p className="text-gray-600"> <p className="text-gray-600">
{hasActiveFilters {hasActiveFilters
? 'Try adjusting your filters to see more results.' ? 'No activity recorded in this period. Try adjusting your filters.'
: 'No activity has been recorded yet.'} : 'No activity has been recorded yet. User activity will appear here once users start interacting with the application.'}
</p> </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>
) : ( ) : (
<div className="divide-y divide-gray-100"> <div className="divide-y divide-gray-100">
@ -598,8 +625,8 @@ const ActivityStream = () => {
{/* Pagination */} {/* Pagination */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 border-t"> <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"> <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 Showing {((page - 1) * limit) + 1} to {Math.min(page * limit, totalActivities)} of {totalActivities} activities
</div> </div>
<div className="flex items-center space-x-2"> <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" 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" /> <ChevronLeft className="w-4 h-4" />
<span className="ml-1">Previous</span> <span className="ml-1 hidden sm:inline">Previous</span>
</button> </button>
<span className="px-3 py-1 text-sm"> <span className="px-3 py-1 text-sm">
Page {page} of {totalPages} {page} / {totalPages}
</span> </span>
<button <button
onClick={() => setPage(Math.min(totalPages, page + 1))} onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page === totalPages || loading} 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" 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" /> <ChevronRight className="w-4 h-4" />
</button> </button>
</div> </div>

View File

@ -326,8 +326,22 @@ const AdminOverview = () => {
</div> </div>
{loading ? ( {loading ? (
<div className="h-64 flex items-center justify-center"> /* Chart Skeleton */
<RefreshCw className="w-8 h-8 animate-spin text-violet-600" /> <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> </div>
) : activeUsersData.length > 0 ? ( ) : activeUsersData.length > 0 ? (
<ResponsiveContainer width="100%" height={250}> <ResponsiveContainer width="100%" height={250}>

View 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;

View File

@ -339,8 +339,22 @@ const UsageStats = () => {
</div> </div>
{loading ? ( {loading ? (
<div className="h-80 flex items-center justify-center"> /* Chart Skeleton */
<RefreshCw className="w-8 h-8 animate-spin text-violet-600" /> <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> </div>
) : activeUsersData.length > 0 ? ( ) : activeUsersData.length > 0 ? (
<ResponsiveContainer width="100%" height={320}> <ResponsiveContainer width="100%" height={320}>
@ -411,8 +425,11 @@ const UsageStats = () => {
</div> </div>
{loading ? ( {loading ? (
<div className="h-64 flex items-center justify-center"> /* Pie Chart Skeleton */
<RefreshCw className="w-8 h-8 animate-spin text-violet-600" /> <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> </div>
) : actionsData.length > 0 ? ( ) : actionsData.length > 0 ? (
<ResponsiveContainer width="100%" height={280}> <ResponsiveContainer width="100%" height={280}>
@ -457,8 +474,17 @@ const UsageStats = () => {
</div> </div>
{loading ? ( {loading ? (
<div className="h-64 flex items-center justify-center"> /* Bar Chart Skeleton */
<RefreshCw className="w-8 h-8 animate-spin text-violet-600" /> <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> </div>
) : actionsData.length > 0 ? ( ) : actionsData.length > 0 ? (
<ResponsiveContainer width="100%" height={280}> <ResponsiveContainer width="100%" height={280}>
@ -504,8 +530,24 @@ const UsageStats = () => {
</div> </div>
{loading ? ( {loading ? (
<div className="h-64 flex items-center justify-center"> /* Heatmap Skeleton */
<RefreshCw className="w-8 h-8 animate-spin text-violet-600" /> <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> </div>
) : heatmapData ? ( ) : heatmapData ? (
<div className="space-y-6"> <div className="space-y-6">

View File

@ -155,7 +155,7 @@ const UserList = () => {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* 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> <div>
<h1 className="text-2xl font-bold text-gray-900">User Management</h1> <h1 className="text-2xl font-bold text-gray-900">User Management</h1>
<p className="text-gray-600"> <p className="text-gray-600">
@ -165,7 +165,7 @@ const UserList = () => {
<button <button
onClick={fetchUsers} onClick={fetchUsers}
disabled={loading} 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' : ''}`} /> <RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
<span>Refresh</span> <span>Refresh</span>
@ -208,9 +208,51 @@ const UserList = () => {
{/* Users Table */} {/* Users Table */}
<div className="bg-white rounded-lg shadow-md overflow-hidden"> <div className="bg-white rounded-lg shadow-md overflow-hidden">
{loading && users.length === 0 ? ( {loading && users.length === 0 ? (
<div className="flex items-center justify-center py-12"> /* Skeleton Loader */
<RefreshCw className="w-8 h-8 animate-spin text-violet-600" /> <div className="overflow-x-auto">
<span className="ml-3 text-gray-600">Loading users...</span> <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> </div>
) : users.length === 0 ? ( ) : users.length === 0 ? (
<div className="text-center py-12"> <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> <h3 className="text-lg font-semibold text-gray-900 mb-2">No Users Found</h3>
<p className="text-gray-600"> <p className="text-gray-600">
{debouncedSearch || statusFilter !== 'all' {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.'} : 'No users have signed up yet.'}
</p> </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>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@ -347,8 +401,8 @@ const UserList = () => {
{/* Pagination */} {/* Pagination */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 bg-gray-50 border-t"> <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"> <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 Showing {((page - 1) * limit) + 1} to {Math.min(page * limit, totalUsers)} of {totalUsers} users
</div> </div>
<div className="flex items-center space-x-2"> <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" 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" /> <ChevronLeft className="w-4 h-4" />
<span className="ml-1">Previous</span> <span className="ml-1 hidden sm:inline">Previous</span>
</button> </button>
<span className="px-3 py-1 text-sm"> <span className="px-3 py-1 text-sm">
Page {page} of {totalPages} {page} / {totalPages}
</span> </span>
<button <button
onClick={() => setPage(Math.min(totalPages, page + 1))} onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page === totalPages || loading} 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" 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" /> <ChevronRight className="w-4 h-4" />
</button> </button>
</div> </div>