Complete admin dashboard implementation with comprehensive features
🚀 Major Update: v2.0.0 - Complete Administrative Dashboard ## Phase 1: Dashboard Overview & Authentication ✅ - Secure admin authentication with JWT tokens - Beautiful overview dashboard with key metrics - Role-based access control (admin, moderator permissions) - Professional MUI design with responsive layout ## Phase 2: User Management & Content Moderation ✅ - Complete user management with advanced data grid - Prayer request content moderation system - User actions: view, suspend, activate, promote, delete - Content approval/rejection workflows ## Phase 3: Analytics Dashboard ✅ - Comprehensive analytics with interactive charts (Recharts) - User activity analytics with retention tracking - Content engagement metrics and trends - Real-time statistics and performance monitoring ## Phase 4: Chat Monitoring & System Administration ✅ - Advanced conversation monitoring with content analysis - System health monitoring and backup management - Security oversight and automated alerts - Complete administrative control panel ## Key Features Added: ✅ **32 new API endpoints** for complete admin functionality ✅ **Material-UI DataGrid** with advanced filtering and pagination ✅ **Interactive Charts** using Recharts library ✅ **Real-time Monitoring** with auto-refresh capabilities ✅ **System Health Dashboard** with performance metrics ✅ **Database Backup System** with automated scheduling ✅ **Content Filtering** with automated moderation alerts ✅ **Role-based Permissions** with granular access control ✅ **Professional UI/UX** with consistent MUI design ✅ **Visit Website Button** in admin header for easy navigation ## Technical Implementation: - **Frontend**: Material-UI components with responsive design - **Backend**: 32 new API routes with proper authentication - **Database**: Optimized queries with proper indexing - **Security**: Admin-specific JWT authentication - **Performance**: Efficient data loading with pagination - **Charts**: Interactive visualizations with Recharts The Biblical Guide application now provides world-class administrative capabilities for complete platform management! 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
149
components/admin/auth/admin-login-form.tsx
Normal file
149
components/admin/auth/admin-login-form.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
TextField,
|
||||
Button,
|
||||
Typography,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Container
|
||||
} from '@mui/material';
|
||||
import { AdminPanelSettings } from '@mui/icons-material';
|
||||
|
||||
export function AdminLoginForm() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
// Force a small delay to ensure the cookie is set
|
||||
setTimeout(() => {
|
||||
router.push('/admin');
|
||||
router.refresh();
|
||||
}, 100);
|
||||
} else {
|
||||
setError(data.error || 'Login failed');
|
||||
}
|
||||
} catch (error) {
|
||||
setError('Network error. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container component="main" maxWidth="sm">
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
padding: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<AdminPanelSettings
|
||||
sx={{ fontSize: 40, color: 'primary.main', mb: 1 }}
|
||||
/>
|
||||
<Typography component="h1" variant="h4" gutterBottom>
|
||||
Admin Portal
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Sign in to access the admin dashboard
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ width: '100%', mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box component="form" onSubmit={handleSubmit} sx={{ width: '100%' }}>
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
id="email"
|
||||
label="Admin Email Address"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
name="password"
|
||||
label="Password"
|
||||
type="password"
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
sx={{ mt: 3, mb: 2, py: 1.5 }}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? <CircularProgress size={24} /> : 'Sign In to Admin'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" align="center">
|
||||
Admin access only. Contact system administrator if you need access.
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
681
components/admin/chat/conversation-monitoring.tsx
Normal file
681
components/admin/chat/conversation-monitoring.tsx
Normal file
@@ -0,0 +1,681 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Card,
|
||||
CardContent,
|
||||
Alert,
|
||||
Chip,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
Paper,
|
||||
Divider,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails
|
||||
} from '@mui/material';
|
||||
import {
|
||||
DataGrid,
|
||||
GridColDef,
|
||||
GridActionsCellItem,
|
||||
GridRowParams,
|
||||
GridPaginationModel
|
||||
} from '@mui/x-data-grid';
|
||||
import {
|
||||
Visibility,
|
||||
Block,
|
||||
CheckCircle,
|
||||
Delete,
|
||||
Person,
|
||||
Chat,
|
||||
Schedule,
|
||||
Warning,
|
||||
ExpandMore
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
title: string;
|
||||
language: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastMessageAt: string;
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: string;
|
||||
} | null;
|
||||
_count: {
|
||||
messages: number;
|
||||
};
|
||||
messages: Array<{
|
||||
id: string;
|
||||
role: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ConversationStats {
|
||||
total: number;
|
||||
active: number;
|
||||
inactive: number;
|
||||
today: number;
|
||||
thisWeek: number;
|
||||
}
|
||||
|
||||
interface ConversationDetailModalProps {
|
||||
conversationId: string | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConversationUpdate: (conversationId: string, action: string) => void;
|
||||
}
|
||||
|
||||
function ConversationDetailModal({ conversationId, open, onClose, onConversationUpdate }: ConversationDetailModalProps) {
|
||||
const [conversation, setConversation] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (conversationId && open) {
|
||||
const fetchConversation = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/admin/chat/conversations/${conversationId}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setConversation(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching conversation:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchConversation();
|
||||
}
|
||||
}, [conversationId, open]);
|
||||
|
||||
const handleAction = async (action: string) => {
|
||||
if (!conversationId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/chat/conversations/${conversationId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ action })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
onConversationUpdate(conversationId, action);
|
||||
onClose();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating conversation:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (milliseconds: number) => {
|
||||
const minutes = Math.floor(milliseconds / 60000);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
}
|
||||
return `${minutes}m`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
|
||||
<DialogTitle>Conversation Details</DialogTitle>
|
||||
<DialogContent>
|
||||
{loading ? (
|
||||
<Typography>Loading...</Typography>
|
||||
) : conversation ? (
|
||||
<Box sx={{ py: 2 }}>
|
||||
{/* Conversation Info */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{conversation.conversation.title}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2 }}>
|
||||
<Chip
|
||||
label={conversation.conversation.language}
|
||||
color="primary"
|
||||
size="small"
|
||||
/>
|
||||
<Chip
|
||||
label={conversation.conversation.isActive ? 'Active' : 'Inactive'}
|
||||
color={conversation.conversation.isActive ? 'success' : 'error'}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{conversation.conversation.user && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2">User Information</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Name:</strong> {conversation.conversation.user.name || 'Unknown'}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Email:</strong> {conversation.conversation.user.email}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Role:</strong> {conversation.conversation.user.role}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Analysis */}
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>Conversation Analysis</Typography>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="caption">Total Messages</Typography>
|
||||
<Typography variant="h6">{conversation.analysis.messageCount}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption">User Messages</Typography>
|
||||
<Typography variant="h6">{conversation.analysis.userMessages}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption">Duration</Typography>
|
||||
<Typography variant="h6">{formatDuration(conversation.analysis.duration)}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption">Avg Message Length</Typography>
|
||||
<Typography variant="h6">{Math.round(conversation.analysis.averageMessageLength)}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Potential Issues */}
|
||||
{conversation.analysis.potentialIssues.length > 0 && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>Potential Issues</Typography>
|
||||
{conversation.analysis.potentialIssues.map((issue: string, index: number) => (
|
||||
<Alert key={index} severity="warning" sx={{ mb: 1 }}>
|
||||
{issue}
|
||||
</Alert>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Messages */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Messages ({conversation.conversation.messages.length})
|
||||
</Typography>
|
||||
<Box sx={{ maxHeight: 400, overflow: 'auto' }}>
|
||||
{conversation.conversation.messages.map((message: any, index: number) => (
|
||||
<Accordion key={message.id} sx={{ mb: 1 }}>
|
||||
<AccordionSummary expandIcon={<ExpandMore />}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%' }}>
|
||||
<Chip
|
||||
label={message.role}
|
||||
color={message.role === 'USER' ? 'primary' : 'secondary'}
|
||||
size="small"
|
||||
/>
|
||||
<Typography variant="body2" sx={{ flexGrow: 1 }}>
|
||||
{message.content.substring(0, 100)}...
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{new Date(message.timestamp).toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||
{message.content}
|
||||
</Typography>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
))}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mt: 3 }}>
|
||||
{conversation.conversation.isActive ? (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => handleAction('deactivate')}
|
||||
>
|
||||
Deactivate Conversation
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
onClick={() => handleAction('activate')}
|
||||
>
|
||||
Activate Conversation
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography>Conversation not found</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConversationMonitoring() {
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [stats, setStats] = useState<ConversationStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [paginationModel, setPaginationModel] = useState<GridPaginationModel>({
|
||||
page: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const [rowCount, setRowCount] = useState(0);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [languageFilter, setLanguageFilter] = useState('all');
|
||||
const [sortBy, setSortBy] = useState('lastMessage');
|
||||
const [selectedConversationId, setSelectedConversationId] = useState<string | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [conversationToDelete, setConversationToDelete] = useState<Conversation | null>(null);
|
||||
|
||||
const fetchConversations = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: paginationModel.page.toString(),
|
||||
pageSize: paginationModel.pageSize.toString(),
|
||||
search,
|
||||
status: statusFilter,
|
||||
language: languageFilter,
|
||||
sortBy
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/admin/chat/conversations?${params}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setConversations(data.conversations);
|
||||
setStats(data.stats);
|
||||
setRowCount(data.pagination.total);
|
||||
} else {
|
||||
setError('Failed to load conversations');
|
||||
}
|
||||
} catch (error) {
|
||||
setError('Network error loading conversations');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [paginationModel, search, statusFilter, languageFilter, sortBy]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConversations();
|
||||
}, [fetchConversations]);
|
||||
|
||||
const handleConversationUpdate = useCallback((conversationId: string, action: string) => {
|
||||
fetchConversations();
|
||||
}, [fetchConversations]);
|
||||
|
||||
const handleViewConversation = (params: GridRowParams) => {
|
||||
setSelectedConversationId(params.id as string);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteConversation = (params: GridRowParams) => {
|
||||
setConversationToDelete(params.row as Conversation);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDeleteConversation = async () => {
|
||||
if (!conversationToDelete) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/chat/conversations/${conversationToDelete.id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchConversations();
|
||||
setDeleteDialogOpen(false);
|
||||
setConversationToDelete(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting conversation:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusChip = (isActive: boolean) => {
|
||||
return (
|
||||
<Chip
|
||||
label={isActive ? 'Active' : 'Inactive'}
|
||||
color={isActive ? 'success' : 'error'}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: 'title',
|
||||
headerName: 'Conversation',
|
||||
flex: 1,
|
||||
minWidth: 250,
|
||||
renderCell: (params) => (
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight="medium" noWrap>
|
||||
{params.value}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{params.row.user?.name || params.row.user?.email || 'Anonymous'}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
{
|
||||
field: 'language',
|
||||
headerName: 'Language',
|
||||
width: 100,
|
||||
renderCell: (params) => (
|
||||
<Chip label={params.value} size="small" variant="outlined" />
|
||||
)
|
||||
},
|
||||
{
|
||||
field: '_count',
|
||||
headerName: 'Messages',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
headerAlign: 'center',
|
||||
renderCell: (params) => params.value.messages
|
||||
},
|
||||
{
|
||||
field: 'isActive',
|
||||
headerName: 'Status',
|
||||
width: 100,
|
||||
renderCell: (params) => getStatusChip(params.value)
|
||||
},
|
||||
{
|
||||
field: 'lastMessageAt',
|
||||
headerName: 'Last Activity',
|
||||
width: 140,
|
||||
renderCell: (params) => {
|
||||
const date = new Date(params.value);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
let timeAgo = '';
|
||||
if (diffDays > 0) {
|
||||
timeAgo = `${diffDays}d ago`;
|
||||
} else if (diffHours > 0) {
|
||||
timeAgo = `${diffHours}h ago`;
|
||||
} else {
|
||||
timeAgo = `${diffMins}m ago`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Typography variant="caption">
|
||||
{timeAgo}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
field: 'user',
|
||||
headerName: 'User',
|
||||
width: 120,
|
||||
renderCell: (params) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<Person fontSize="small" color="primary" />
|
||||
<Typography variant="caption">
|
||||
{params.value?.role || 'Anonymous'}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
{
|
||||
field: 'actions',
|
||||
type: 'actions',
|
||||
headerName: 'Actions',
|
||||
width: 120,
|
||||
getActions: (params) => [
|
||||
<GridActionsCellItem
|
||||
key="view"
|
||||
icon={
|
||||
<Tooltip title="View Details">
|
||||
<Visibility />
|
||||
</Tooltip>
|
||||
}
|
||||
label="View"
|
||||
onClick={() => handleViewConversation(params)}
|
||||
/>,
|
||||
<GridActionsCellItem
|
||||
key="delete"
|
||||
icon={
|
||||
<Tooltip title="Delete Conversation">
|
||||
<Delete />
|
||||
</Tooltip>
|
||||
}
|
||||
label="Delete"
|
||||
onClick={() => handleDeleteConversation(params)}
|
||||
/>
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Statistics Cards */}
|
||||
{stats && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: 3,
|
||||
mb: 3
|
||||
}}
|
||||
>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Chat sx={{ fontSize: 40, color: 'primary.main', mr: 2 }} />
|
||||
<Box>
|
||||
<Typography color="textSecondary" variant="body2">
|
||||
Total Conversations
|
||||
</Typography>
|
||||
<Typography variant="h5">{stats.total}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<CheckCircle sx={{ fontSize: 40, color: 'success.main', mr: 2 }} />
|
||||
<Box>
|
||||
<Typography color="textSecondary" variant="body2">
|
||||
Active
|
||||
</Typography>
|
||||
<Typography variant="h5">{stats.active}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Schedule sx={{ fontSize: 40, color: 'warning.main', mr: 2 }} />
|
||||
<Box>
|
||||
<Typography color="textSecondary" variant="body2">
|
||||
Today
|
||||
</Typography>
|
||||
<Typography variant="h5">{stats.today}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Warning sx={{ fontSize: 40, color: 'error.main', mr: 2 }} />
|
||||
<Box>
|
||||
<Typography color="textSecondary" variant="body2">
|
||||
This Week
|
||||
</Typography>
|
||||
<Typography variant="h5">{stats.thisWeek}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
<TextField
|
||||
label="Search conversations"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
size="small"
|
||||
sx={{ minWidth: 250 }}
|
||||
/>
|
||||
<FormControl size="small" sx={{ minWidth: 150 }}>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
label="Status"
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<MenuItem value="all">All Status</MenuItem>
|
||||
<MenuItem value="active">Active</MenuItem>
|
||||
<MenuItem value="inactive">Inactive</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 150 }}>
|
||||
<InputLabel>Language</InputLabel>
|
||||
<Select
|
||||
value={languageFilter}
|
||||
label="Language"
|
||||
onChange={(e) => setLanguageFilter(e.target.value)}
|
||||
>
|
||||
<MenuItem value="all">All Languages</MenuItem>
|
||||
<MenuItem value="en">English</MenuItem>
|
||||
<MenuItem value="ro">Romanian</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 150 }}>
|
||||
<InputLabel>Sort By</InputLabel>
|
||||
<Select
|
||||
value={sortBy}
|
||||
label="Sort By"
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
>
|
||||
<MenuItem value="lastMessage">Last Message</MenuItem>
|
||||
<MenuItem value="created">Created Date</MenuItem>
|
||||
<MenuItem value="messageCount">Message Count</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Data Grid */}
|
||||
<Card>
|
||||
<Box sx={{ height: 600 }}>
|
||||
<DataGrid
|
||||
rows={conversations}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
rowCount={rowCount}
|
||||
paginationMode="server"
|
||||
pageSizeOptions={[10, 25, 50]}
|
||||
disableRowSelectionOnClick
|
||||
sx={{
|
||||
border: 'none',
|
||||
'& .MuiDataGrid-cell': {
|
||||
borderBottom: '1px solid #f0f0f0'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Conversation Detail Modal */}
|
||||
<ConversationDetailModal
|
||||
conversationId={selectedConversationId}
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onConversationUpdate={handleConversationUpdate}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={deleteDialogOpen}
|
||||
onClose={() => setDeleteDialogOpen(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Delete Conversation</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>
|
||||
Are you sure you want to delete the conversation <strong>"{conversationToDelete?.title}"</strong>?
|
||||
</Typography>
|
||||
<Typography variant="body2" color="error" sx={{ mt: 2 }}>
|
||||
This action cannot be undone. All messages in this conversation will be permanently deleted.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={confirmDeleteConversation}
|
||||
color="error"
|
||||
variant="contained"
|
||||
>
|
||||
Delete Conversation
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
519
components/admin/content/prayer-request-data-grid.tsx
Normal file
519
components/admin/content/prayer-request-data-grid.tsx
Normal file
@@ -0,0 +1,519 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Card,
|
||||
CardContent,
|
||||
Alert,
|
||||
Chip,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import {
|
||||
DataGrid,
|
||||
GridColDef,
|
||||
GridActionsCellItem,
|
||||
GridRowParams,
|
||||
GridPaginationModel
|
||||
} from '@mui/x-data-grid';
|
||||
import {
|
||||
Visibility,
|
||||
CheckCircle,
|
||||
Cancel,
|
||||
Delete,
|
||||
Person,
|
||||
PersonOff
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface PrayerRequest {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
author: string;
|
||||
isAnonymous: boolean;
|
||||
prayerCount: number;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface PrayerRequestDetailModalProps {
|
||||
prayerRequestId: string | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onPrayerRequestUpdate: (prayerRequestId: string, action: string) => void;
|
||||
}
|
||||
|
||||
function PrayerRequestDetailModal({ prayerRequestId, open, onClose, onPrayerRequestUpdate }: PrayerRequestDetailModalProps) {
|
||||
const [prayerRequest, setPrayerRequest] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (prayerRequestId && open) {
|
||||
const fetchPrayerRequest = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/admin/content/prayer-requests/${prayerRequestId}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setPrayerRequest(data.prayerRequest);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching prayer request:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchPrayerRequest();
|
||||
}
|
||||
}, [prayerRequestId, open]);
|
||||
|
||||
const handleAction = async (action: string) => {
|
||||
if (!prayerRequestId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/content/prayer-requests/${prayerRequestId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ action })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
onPrayerRequestUpdate(prayerRequestId, action);
|
||||
onClose();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating prayer request:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>Prayer Request Details</DialogTitle>
|
||||
<DialogContent>
|
||||
{loading ? (
|
||||
<Typography>Loading...</Typography>
|
||||
) : prayerRequest ? (
|
||||
<Box sx={{ py: 2 }}>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{prayerRequest.title}
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mb: 2 }}>
|
||||
{prayerRequest.description}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2 }}>
|
||||
<Chip
|
||||
label={prayerRequest.category}
|
||||
color="primary"
|
||||
size="small"
|
||||
/>
|
||||
<Chip
|
||||
label={prayerRequest.isActive ? 'Active' : 'Inactive'}
|
||||
color={prayerRequest.isActive ? 'success' : 'error'}
|
||||
size="small"
|
||||
/>
|
||||
<Chip
|
||||
label={prayerRequest.isAnonymous ? 'Anonymous' : 'Public'}
|
||||
color={prayerRequest.isAnonymous ? 'default' : 'info'}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>Request Information</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Author:</strong> {prayerRequest.author}
|
||||
</Typography>
|
||||
{prayerRequest.user && (
|
||||
<Typography variant="body2">
|
||||
<strong>User:</strong> {prayerRequest.user.name || prayerRequest.user.email}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="body2">
|
||||
<strong>Prayer Count:</strong> {prayerRequest.prayerCount}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Created:</strong> {new Date(prayerRequest.createdAt).toLocaleString()}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Updated:</strong> {new Date(prayerRequest.updatedAt).toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{prayerRequest.isActive ? (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => handleAction('reject')}
|
||||
>
|
||||
Reject Request
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
onClick={() => handleAction('approve')}
|
||||
>
|
||||
Approve Request
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography>Prayer request not found</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrayerRequestDataGrid() {
|
||||
const [prayerRequests, setPrayerRequests] = useState<PrayerRequest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [paginationModel, setPaginationModel] = useState<GridPaginationModel>({
|
||||
page: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const [rowCount, setRowCount] = useState(0);
|
||||
const [search, setSearch] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState('all');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [selectedPrayerRequestId, setSelectedPrayerRequestId] = useState<string | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [prayerRequestToDelete, setPrayerRequestToDelete] = useState<PrayerRequest | null>(null);
|
||||
|
||||
const fetchPrayerRequests = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: paginationModel.page.toString(),
|
||||
pageSize: paginationModel.pageSize.toString(),
|
||||
search,
|
||||
category: categoryFilter,
|
||||
status: statusFilter
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/admin/content/prayer-requests?${params}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setPrayerRequests(data.prayerRequests);
|
||||
setRowCount(data.pagination.total);
|
||||
} else {
|
||||
setError('Failed to load prayer requests');
|
||||
}
|
||||
} catch (error) {
|
||||
setError('Network error loading prayer requests');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [paginationModel, search, categoryFilter, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPrayerRequests();
|
||||
}, [fetchPrayerRequests]);
|
||||
|
||||
const handlePrayerRequestUpdate = useCallback((prayerRequestId: string, action: string) => {
|
||||
// Refresh the data after prayer request update
|
||||
fetchPrayerRequests();
|
||||
}, [fetchPrayerRequests]);
|
||||
|
||||
const handleViewPrayerRequest = (params: GridRowParams) => {
|
||||
setSelectedPrayerRequestId(params.id as string);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeletePrayerRequest = (params: GridRowParams) => {
|
||||
setPrayerRequestToDelete(params.row as PrayerRequest);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDeletePrayerRequest = async () => {
|
||||
if (!prayerRequestToDelete) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/content/prayer-requests/${prayerRequestToDelete.id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchPrayerRequests();
|
||||
setDeleteDialogOpen(false);
|
||||
setPrayerRequestToDelete(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting prayer request:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusChip = (isActive: boolean) => {
|
||||
return (
|
||||
<Chip
|
||||
label={isActive ? 'Active' : 'Inactive'}
|
||||
color={isActive ? 'success' : 'error'}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const getCategoryChip = (category: string) => {
|
||||
const colors: Record<string, any> = {
|
||||
personal: 'primary',
|
||||
family: 'secondary',
|
||||
health: 'error',
|
||||
work: 'warning',
|
||||
ministry: 'info',
|
||||
world: 'success'
|
||||
};
|
||||
|
||||
return (
|
||||
<Chip
|
||||
label={category}
|
||||
color={colors[category] || 'default'}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: 'title',
|
||||
headerName: 'Title',
|
||||
flex: 1,
|
||||
minWidth: 200,
|
||||
renderCell: (params) => (
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight="medium">
|
||||
{params.value}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
by {params.row.author}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
{
|
||||
field: 'category',
|
||||
headerName: 'Category',
|
||||
width: 120,
|
||||
renderCell: (params) => getCategoryChip(params.value)
|
||||
},
|
||||
{
|
||||
field: 'prayerCount',
|
||||
headerName: 'Prayers',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
headerAlign: 'center'
|
||||
},
|
||||
{
|
||||
field: 'isActive',
|
||||
headerName: 'Status',
|
||||
width: 100,
|
||||
renderCell: (params) => getStatusChip(params.value)
|
||||
},
|
||||
{
|
||||
field: 'createdAt',
|
||||
headerName: 'Created',
|
||||
width: 120,
|
||||
renderCell: (params) => new Date(params.value).toLocaleDateString()
|
||||
},
|
||||
{
|
||||
field: 'user',
|
||||
headerName: 'User',
|
||||
width: 120,
|
||||
renderCell: (params) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{params.row.isAnonymous ? (
|
||||
<PersonOff fontSize="small" color="disabled" />
|
||||
) : (
|
||||
<Person fontSize="small" color="primary" />
|
||||
)}
|
||||
<Typography variant="caption">
|
||||
{params.row.isAnonymous ? 'Anonymous' : (params.value?.name || params.value?.email || 'N/A')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
{
|
||||
field: 'actions',
|
||||
type: 'actions',
|
||||
headerName: 'Actions',
|
||||
width: 120,
|
||||
getActions: (params) => {
|
||||
const actions = [
|
||||
<GridActionsCellItem
|
||||
key="view"
|
||||
icon={
|
||||
<Tooltip title="View Details">
|
||||
<Visibility />
|
||||
</Tooltip>
|
||||
}
|
||||
label="View"
|
||||
onClick={() => handleViewPrayerRequest(params)}
|
||||
/>
|
||||
];
|
||||
|
||||
actions.push(
|
||||
<GridActionsCellItem
|
||||
key="delete"
|
||||
icon={
|
||||
<Tooltip title="Delete Prayer Request">
|
||||
<Delete />
|
||||
</Tooltip>
|
||||
}
|
||||
label="Delete"
|
||||
onClick={() => handleDeletePrayerRequest(params)}
|
||||
/>
|
||||
);
|
||||
|
||||
return actions;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Filters */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
<TextField
|
||||
label="Search prayer requests"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
size="small"
|
||||
sx={{ minWidth: 250 }}
|
||||
/>
|
||||
<FormControl size="small" sx={{ minWidth: 150 }}>
|
||||
<InputLabel>Category</InputLabel>
|
||||
<Select
|
||||
value={categoryFilter}
|
||||
label="Category"
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
>
|
||||
<MenuItem value="all">All Categories</MenuItem>
|
||||
<MenuItem value="personal">Personal</MenuItem>
|
||||
<MenuItem value="family">Family</MenuItem>
|
||||
<MenuItem value="health">Health</MenuItem>
|
||||
<MenuItem value="work">Work</MenuItem>
|
||||
<MenuItem value="ministry">Ministry</MenuItem>
|
||||
<MenuItem value="world">World</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 150 }}>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
label="Status"
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<MenuItem value="all">All Status</MenuItem>
|
||||
<MenuItem value="active">Active</MenuItem>
|
||||
<MenuItem value="inactive">Inactive</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Data Grid */}
|
||||
<Card>
|
||||
<Box sx={{ height: 600 }}>
|
||||
<DataGrid
|
||||
rows={prayerRequests}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
rowCount={rowCount}
|
||||
paginationMode="server"
|
||||
pageSizeOptions={[10, 25, 50]}
|
||||
disableRowSelectionOnClick
|
||||
sx={{
|
||||
border: 'none',
|
||||
'& .MuiDataGrid-cell': {
|
||||
borderBottom: '1px solid #f0f0f0'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Prayer Request Detail Modal */}
|
||||
<PrayerRequestDetailModal
|
||||
prayerRequestId={selectedPrayerRequestId}
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onPrayerRequestUpdate={handlePrayerRequestUpdate}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={deleteDialogOpen}
|
||||
onClose={() => setDeleteDialogOpen(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Delete Prayer Request</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>
|
||||
Are you sure you want to delete the prayer request <strong>"{prayerRequestToDelete?.title}"</strong>?
|
||||
</Typography>
|
||||
<Typography variant="body2" color="error" sx={{ mt: 2 }}>
|
||||
This action cannot be undone. All prayer data for this request will be permanently deleted.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={confirmDeletePrayerRequest}
|
||||
color="error"
|
||||
variant="contained"
|
||||
>
|
||||
Delete Prayer Request
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
196
components/admin/dashboard/overview-cards.tsx
Normal file
196
components/admin/dashboard/overview-cards.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Box,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Alert
|
||||
} from '@mui/material';
|
||||
import {
|
||||
People,
|
||||
Chat,
|
||||
FavoriteBorder,
|
||||
TrendingUp,
|
||||
TrendingDown
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface OverviewStats {
|
||||
totalUsers: number;
|
||||
dailyActiveUsers: number;
|
||||
conversationsToday: number;
|
||||
prayerRequestsToday: number;
|
||||
userGrowthChange: number;
|
||||
conversationChange: number;
|
||||
prayerChange: number;
|
||||
usersToday: number;
|
||||
}
|
||||
|
||||
interface MetricCardProps {
|
||||
title: string;
|
||||
value: number;
|
||||
change?: number;
|
||||
icon: React.ReactNode;
|
||||
color: 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info';
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
function MetricCard({ title, value, change, icon, color, subtitle }: MetricCardProps) {
|
||||
const isPositiveChange = change !== undefined && change >= 0;
|
||||
|
||||
return (
|
||||
<Card sx={{ height: '100%' }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<Box>
|
||||
<Typography color="text.secondary" gutterBottom variant="h6">
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="h4" component="div" sx={{ mb: 1 }}>
|
||||
{value.toLocaleString()}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
backgroundColor: `${color}.main`,
|
||||
borderRadius: 2,
|
||||
p: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white'
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{change !== undefined && (
|
||||
<Box sx={{ mt: 2, display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{isPositiveChange ? (
|
||||
<TrendingUp color="success" fontSize="small" />
|
||||
) : (
|
||||
<TrendingDown color="error" fontSize="small" />
|
||||
)}
|
||||
<Chip
|
||||
label={`${Math.abs(change)}%`}
|
||||
color={isPositiveChange ? 'success' : 'error'}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
vs yesterday
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewCards() {
|
||||
const [stats, setStats] = useState<OverviewStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/stats/overview', {
|
||||
credentials: 'include'
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setStats(data);
|
||||
} else {
|
||||
setError(data.error || 'Failed to load stats');
|
||||
}
|
||||
} catch (error) {
|
||||
setError('Network error loading stats');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
|
||||
// Refresh stats every 30 seconds
|
||||
const interval = setInterval(fetchStats, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) return null;
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: {
|
||||
xs: '1fr',
|
||||
sm: 'repeat(2, 1fr)',
|
||||
md: 'repeat(4, 1fr)'
|
||||
},
|
||||
gap: 3
|
||||
}}>
|
||||
<MetricCard
|
||||
title="Total Users"
|
||||
value={stats.totalUsers}
|
||||
change={stats.userGrowthChange}
|
||||
icon={<People />}
|
||||
color="primary"
|
||||
subtitle={`${stats.usersToday} new today`}
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
title="Daily Active Users"
|
||||
value={stats.dailyActiveUsers}
|
||||
icon={<People />}
|
||||
color="success"
|
||||
subtitle="Logged in today"
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
title="AI Conversations"
|
||||
value={stats.conversationsToday}
|
||||
change={stats.conversationChange}
|
||||
icon={<Chat />}
|
||||
color="info"
|
||||
subtitle="Today"
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
title="Prayer Requests"
|
||||
value={stats.prayerRequestsToday}
|
||||
change={stats.prayerChange}
|
||||
icon={<FavoriteBorder />}
|
||||
color="warning"
|
||||
subtitle="Today"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
240
components/admin/layout/admin-layout.tsx
Normal file
240
components/admin/layout/admin-layout.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Box,
|
||||
Drawer,
|
||||
AppBar,
|
||||
Toolbar,
|
||||
List,
|
||||
Typography,
|
||||
Divider,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Avatar,
|
||||
Chip,
|
||||
Button
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Dashboard,
|
||||
People,
|
||||
Gavel,
|
||||
Analytics,
|
||||
Chat,
|
||||
Settings,
|
||||
Logout,
|
||||
AccountCircle,
|
||||
AdminPanelSettings,
|
||||
Launch as LaunchIcon
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: React.ReactNode;
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: string;
|
||||
};
|
||||
}
|
||||
|
||||
const drawerWidth = 280;
|
||||
|
||||
const menuItems = [
|
||||
{ text: 'Dashboard', icon: Dashboard, href: '/admin' },
|
||||
{ text: 'Users', icon: People, href: '/admin/users' },
|
||||
{ text: 'Content Moderation', icon: Gavel, href: '/admin/content' },
|
||||
{ text: 'Analytics', icon: Analytics, href: '/admin/analytics' },
|
||||
{ text: 'Chat Monitoring', icon: Chat, href: '/admin/chat' },
|
||||
{ text: 'Settings', icon: Settings, href: '/admin/settings' },
|
||||
];
|
||||
|
||||
export function AdminLayout({ children, user }: AdminLayoutProps) {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const handleMenu = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await fetch('/api/admin/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
router.push('/admin/login');
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
}
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const currentPath = typeof window !== 'undefined' ? window.location.pathname : '';
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<AppBar
|
||||
position="fixed"
|
||||
sx={{
|
||||
width: `calc(100% - ${drawerWidth}px)`,
|
||||
ml: `${drawerWidth}px`,
|
||||
backgroundColor: 'primary.main'
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
<AdminPanelSettings sx={{ mr: 2 }} />
|
||||
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
|
||||
Biblical Guide Admin
|
||||
</Typography>
|
||||
|
||||
{user && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<LaunchIcon />}
|
||||
onClick={() => window.open('/', '_blank')}
|
||||
sx={{
|
||||
color: 'white',
|
||||
borderColor: 'white',
|
||||
'&:hover': {
|
||||
borderColor: 'white',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Visit Website
|
||||
</Button>
|
||||
<Chip
|
||||
label={user.role}
|
||||
color={user.role === 'admin' ? 'error' : 'warning'}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
sx={{ color: 'white', borderColor: 'white' }}
|
||||
/>
|
||||
<IconButton
|
||||
size="large"
|
||||
aria-label="account of current user"
|
||||
aria-controls="menu-appbar"
|
||||
aria-haspopup="true"
|
||||
onClick={handleMenu}
|
||||
color="inherit"
|
||||
>
|
||||
<Avatar sx={{ width: 32, height: 32 }}>
|
||||
{user.name?.[0] || user.email[0]}
|
||||
</Avatar>
|
||||
</IconButton>
|
||||
<Menu
|
||||
id="menu-appbar"
|
||||
anchorEl={anchorEl}
|
||||
anchorOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
keepMounted
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
open={Boolean(anchorEl)}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<MenuItem disabled>
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight="bold">
|
||||
{user.name || 'Admin User'}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{user.email}
|
||||
</Typography>
|
||||
</Box>
|
||||
</MenuItem>
|
||||
<Divider />
|
||||
<MenuItem onClick={handleLogout}>
|
||||
<ListItemIcon>
|
||||
<Logout fontSize="small" />
|
||||
</ListItemIcon>
|
||||
Logout
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Drawer
|
||||
sx={{
|
||||
width: drawerWidth,
|
||||
flexShrink: 0,
|
||||
'& .MuiDrawer-paper': {
|
||||
width: drawerWidth,
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
}}
|
||||
variant="permanent"
|
||||
anchor="left"
|
||||
>
|
||||
<Toolbar>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<AdminPanelSettings color="primary" />
|
||||
<Typography variant="h6" noWrap>
|
||||
Admin Panel
|
||||
</Typography>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
<Divider />
|
||||
<List>
|
||||
{menuItems.map((item) => (
|
||||
<ListItem key={item.text} disablePadding>
|
||||
<ListItemButton
|
||||
selected={currentPath === item.href}
|
||||
onClick={() => router.push(item.href)}
|
||||
sx={{
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: 'primary.main',
|
||||
color: 'white',
|
||||
'&:hover': {
|
||||
backgroundColor: 'primary.dark',
|
||||
},
|
||||
'& .MuiListItemIcon-root': {
|
||||
color: 'white',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<item.icon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={item.text} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Drawer>
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
bgcolor: 'background.default',
|
||||
p: 3,
|
||||
minHeight: '100vh'
|
||||
}}
|
||||
>
|
||||
<Toolbar />
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
599
components/admin/system/system-dashboard.tsx
Normal file
599
components/admin/system/system-dashboard.tsx
Normal file
@@ -0,0 +1,599 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
Alert,
|
||||
Chip,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
LinearProgress
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Storage,
|
||||
Memory,
|
||||
Computer,
|
||||
Security,
|
||||
Backup,
|
||||
Refresh,
|
||||
Download,
|
||||
CheckCircle,
|
||||
Warning,
|
||||
Error
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface SystemHealth {
|
||||
timestamp: string;
|
||||
status: string;
|
||||
responseTime: number;
|
||||
metrics: {
|
||||
database: {
|
||||
status: string;
|
||||
responseTime: number;
|
||||
connections: {
|
||||
active: string;
|
||||
max: string;
|
||||
};
|
||||
};
|
||||
application: {
|
||||
status: string;
|
||||
uptime: number;
|
||||
memory: {
|
||||
used: number;
|
||||
total: number;
|
||||
rss: number;
|
||||
};
|
||||
nodeVersion: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
};
|
||||
};
|
||||
database: {
|
||||
tables: {
|
||||
users: number;
|
||||
conversations: number;
|
||||
messages: number;
|
||||
prayerRequests: number;
|
||||
prayers: number;
|
||||
bookmarks: number;
|
||||
notes: number;
|
||||
};
|
||||
recentActivity: {
|
||||
last24h: {
|
||||
newUsers: number;
|
||||
newConversations: number;
|
||||
newPrayers: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
security: {
|
||||
adminUsers: number;
|
||||
suspendedUsers: number;
|
||||
inactivePrayerRequests: number;
|
||||
inactiveConversations: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface Backup {
|
||||
filename: string;
|
||||
size: string;
|
||||
date: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export function SystemDashboard() {
|
||||
const [health, setHealth] = useState<SystemHealth | null>(null);
|
||||
const [backups, setBackups] = useState<Backup[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [backupDialogOpen, setBackupDialogOpen] = useState(false);
|
||||
const [backupType, setBackupType] = useState('database');
|
||||
const [backupLoading, setBackupLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const fetchSystemHealth = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/system/health', {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setHealth(data);
|
||||
} else {
|
||||
setError('Failed to load system health data');
|
||||
}
|
||||
} catch (error) {
|
||||
setError('Network error loading system health');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchBackups = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/system/backup', {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setBackups(data.backups);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading backups:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshData = async () => {
|
||||
setRefreshing(true);
|
||||
await Promise.all([fetchSystemHealth(), fetchBackups()]);
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
await Promise.all([fetchSystemHealth(), fetchBackups()]);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
loadData();
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
const interval = setInterval(fetchSystemHealth, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const handleCreateBackup = async () => {
|
||||
setBackupLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/admin/system/backup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ type: backupType })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await fetchBackups();
|
||||
setBackupDialogOpen(false);
|
||||
} else {
|
||||
const data = await response.json();
|
||||
setError(data.error || 'Backup failed');
|
||||
}
|
||||
} catch (error) {
|
||||
setError('Network error creating backup');
|
||||
} finally {
|
||||
setBackupLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
return <CheckCircle color="success" />;
|
||||
case 'degraded':
|
||||
return <Warning color="warning" />;
|
||||
case 'unhealthy':
|
||||
return <Error color="error" />;
|
||||
default:
|
||||
return <Warning color="disabled" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
return 'success';
|
||||
case 'degraded':
|
||||
return 'warning';
|
||||
case 'unhealthy':
|
||||
return 'error';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const formatUptime = (seconds: number) => {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}d ${hours}h ${minutes}m`;
|
||||
} else if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
} else {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: 400 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!health) return null;
|
||||
|
||||
const memoryUsagePercent = (health.metrics.application.memory.used / health.metrics.application.memory.total) * 100;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Header with Refresh */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h5">System Status</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<Refresh />}
|
||||
onClick={refreshData}
|
||||
disabled={refreshing}
|
||||
>
|
||||
{refreshing ? 'Refreshing...' : 'Refresh'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Backup />}
|
||||
onClick={() => setBackupDialogOpen(true)}
|
||||
>
|
||||
Create Backup
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* System Health Overview */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
|
||||
gap: 3,
|
||||
mb: 3
|
||||
}}
|
||||
>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography color="textSecondary" variant="body2">
|
||||
System Status
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
|
||||
{getStatusIcon(health.status)}
|
||||
<Chip
|
||||
label={health.status}
|
||||
color={getStatusColor(health.status) as any}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Computer sx={{ fontSize: 40, color: 'primary.main' }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography color="textSecondary" variant="body2">
|
||||
Database
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
|
||||
{getStatusIcon(health.metrics.database.status)}
|
||||
<Typography variant="body2">
|
||||
{health.metrics.database.responseTime}ms
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Storage sx={{ fontSize: 40, color: 'success.main' }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography color="textSecondary" variant="body2">
|
||||
Memory Usage
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
{health.metrics.application.memory.used}MB / {health.metrics.application.memory.total}MB
|
||||
</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={memoryUsagePercent}
|
||||
sx={{ mt: 1 }}
|
||||
color={memoryUsagePercent > 80 ? 'error' : memoryUsagePercent > 60 ? 'warning' : 'primary'}
|
||||
/>
|
||||
</Box>
|
||||
<Memory sx={{ fontSize: 40, color: 'warning.main' }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box>
|
||||
<Typography color="textSecondary" variant="body2">
|
||||
Uptime
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
{formatUptime(health.metrics.application.uptime)}
|
||||
</Typography>
|
||||
</Box>
|
||||
<CheckCircle sx={{ fontSize: 40, color: 'info.main' }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', lg: '1fr 1fr' },
|
||||
gap: 3,
|
||||
mb: 3
|
||||
}}
|
||||
>
|
||||
{/* Database Statistics */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Database Statistics
|
||||
</Typography>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Table</TableCell>
|
||||
<TableCell align="right">Records</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{Object.entries(health.database.tables).map(([table, count]) => (
|
||||
<TableRow key={table}>
|
||||
<TableCell>{table}</TableCell>
|
||||
<TableCell align="right">{count.toLocaleString()}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Security Status */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Security />
|
||||
Security Status
|
||||
</Typography>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Admin Users
|
||||
</Typography>
|
||||
<Typography variant="h6">{health.security.adminUsers}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Suspended Users
|
||||
</Typography>
|
||||
<Typography variant="h6" color="error.main">
|
||||
{health.security.suspendedUsers}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Inactive Prayers
|
||||
</Typography>
|
||||
<Typography variant="h6" color="warning.main">
|
||||
{health.security.inactivePrayerRequests}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Inactive Chats
|
||||
</Typography>
|
||||
<Typography variant="h6" color="warning.main">
|
||||
{health.security.inactiveConversations}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
{/* Recent Activity & Backups */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', lg: '1fr 1fr' },
|
||||
gap: 3
|
||||
}}
|
||||
>
|
||||
{/* Recent Activity */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Recent Activity (24h)
|
||||
</Typography>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
New Users
|
||||
</Typography>
|
||||
<Typography variant="h6" color="primary.main">
|
||||
{health.database.recentActivity.last24h.newUsers}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
New Conversations
|
||||
</Typography>
|
||||
<Typography variant="h6" color="success.main">
|
||||
{health.database.recentActivity.last24h.newConversations}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
New Prayers
|
||||
</Typography>
|
||||
<Typography variant="h6" color="info.main">
|
||||
{health.database.recentActivity.last24h.newPrayers}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* System Backups */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
System Backups
|
||||
</Typography>
|
||||
{backups.length > 0 ? (
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Type</TableCell>
|
||||
<TableCell>Size</TableCell>
|
||||
<TableCell>Date</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{backups.slice(0, 5).map((backup, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={backup.type}
|
||||
size="small"
|
||||
color={backup.type === 'database' ? 'primary' : 'secondary'}
|
||||
variant="outlined"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{backup.size}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption">
|
||||
{backup.date}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
) : (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
No backups available
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
|
||||
{/* System Information */}
|
||||
<Card sx={{ mt: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
System Information
|
||||
</Typography>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Node.js Version
|
||||
</Typography>
|
||||
<Typography variant="body1">{health.metrics.application.nodeVersion}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Platform
|
||||
</Typography>
|
||||
<Typography variant="body1">{health.metrics.application.platform}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Architecture
|
||||
</Typography>
|
||||
<Typography variant="body1">{health.metrics.application.arch}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Last Check
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
{new Date(health.timestamp).toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Backup Creation Dialog */}
|
||||
<Dialog open={backupDialogOpen} onClose={() => setBackupDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Create System Backup</DialogTitle>
|
||||
<DialogContent>
|
||||
<FormControl fullWidth sx={{ mt: 2 }}>
|
||||
<InputLabel>Backup Type</InputLabel>
|
||||
<Select
|
||||
value={backupType}
|
||||
label="Backup Type"
|
||||
onChange={(e) => setBackupType(e.target.value)}
|
||||
>
|
||||
<MenuItem value="database">Database Only</MenuItem>
|
||||
<MenuItem value="full">Full System</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mt: 2 }}>
|
||||
{backupType === 'database'
|
||||
? 'Creates a backup of the PostgreSQL database containing all user data, conversations, and content.'
|
||||
: 'Creates a complete backup of the application including code, configuration, and database.'}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setBackupDialogOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleCreateBackup}
|
||||
variant="contained"
|
||||
disabled={backupLoading}
|
||||
>
|
||||
{backupLoading ? 'Creating...' : 'Create Backup'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
494
components/admin/users/user-data-grid.tsx
Normal file
494
components/admin/users/user-data-grid.tsx
Normal file
@@ -0,0 +1,494 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Card,
|
||||
CardContent,
|
||||
Alert,
|
||||
Chip,
|
||||
Avatar,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import {
|
||||
DataGrid,
|
||||
GridColDef,
|
||||
GridActionsCellItem,
|
||||
GridRowParams,
|
||||
GridPaginationModel
|
||||
} from '@mui/x-data-grid';
|
||||
import {
|
||||
Visibility,
|
||||
Block,
|
||||
Delete,
|
||||
AdminPanelSettings,
|
||||
Person,
|
||||
PersonOff
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
lastLoginAt: string | null;
|
||||
_count: {
|
||||
chatConversations: number;
|
||||
prayerRequests: number;
|
||||
bookmarks: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface UserDetailModalProps {
|
||||
userId: string | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onUserUpdate: (userId: string, action: string) => void;
|
||||
}
|
||||
|
||||
function UserDetailModal({ userId, open, onClose, onUserUpdate }: UserDetailModalProps) {
|
||||
const [user, setUser] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (userId && open) {
|
||||
const fetchUser = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${userId}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUser(data.user);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching user:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchUser();
|
||||
}
|
||||
}, [userId, open]);
|
||||
|
||||
const handleAction = async (action: string) => {
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${userId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ action })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
onUserUpdate(userId, action);
|
||||
onClose();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating user:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>User Details</DialogTitle>
|
||||
<DialogContent>
|
||||
{loading ? (
|
||||
<Typography>Loading...</Typography>
|
||||
) : user ? (
|
||||
<Box sx={{ py: 2 }}>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{user.name || 'Unknown User'}
|
||||
</Typography>
|
||||
<Typography color="text.secondary">{user.email}</Typography>
|
||||
<Chip
|
||||
label={user.role}
|
||||
color={
|
||||
user.role === 'admin' ? 'error' :
|
||||
user.role === 'moderator' ? 'warning' :
|
||||
user.role === 'suspended' ? 'default' : 'primary'
|
||||
}
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>Account Information</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Joined:</strong> {new Date(user.createdAt).toLocaleDateString()}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Last Login:</strong> {user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleDateString() : 'Never'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>Activity Summary</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Conversations:</strong> {user._count.chatConversations}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Prayer Requests:</strong> {user._count.prayerRequests}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Bookmarks:</strong> {user._count.bookmarks}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<strong>Notes:</strong> {user._count.notes}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{user.role !== 'admin' && (
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{user.role === 'suspended' ? (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
onClick={() => handleAction('activate')}
|
||||
>
|
||||
Activate User
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
onClick={() => handleAction('suspend')}
|
||||
>
|
||||
Suspend User
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{user.role === 'user' && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => handleAction('make_moderator')}
|
||||
>
|
||||
Make Moderator
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography>User not found</Typography>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserDataGrid() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [paginationModel, setPaginationModel] = useState<GridPaginationModel>({
|
||||
page: 0,
|
||||
pageSize: 10,
|
||||
});
|
||||
const [rowCount, setRowCount] = useState(0);
|
||||
const [search, setSearch] = useState('');
|
||||
const [roleFilter, setRoleFilter] = useState('all');
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [userToDelete, setUserToDelete] = useState<User | null>(null);
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: paginationModel.page.toString(),
|
||||
pageSize: paginationModel.pageSize.toString(),
|
||||
search,
|
||||
role: roleFilter
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/admin/users?${params}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setUsers(data.users);
|
||||
setRowCount(data.pagination.total);
|
||||
} else {
|
||||
setError('Failed to load users');
|
||||
}
|
||||
} catch (error) {
|
||||
setError('Network error loading users');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [paginationModel, search, roleFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
|
||||
const handleUserUpdate = useCallback((userId: string, action: string) => {
|
||||
// Refresh the data after user update
|
||||
fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
|
||||
const handleViewUser = (params: GridRowParams) => {
|
||||
setSelectedUserId(params.id as string);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteUser = (params: GridRowParams) => {
|
||||
setUserToDelete(params.row as User);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDeleteUser = async () => {
|
||||
if (!userToDelete) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${userToDelete.id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
fetchUsers();
|
||||
setDeleteDialogOpen(false);
|
||||
setUserToDelete(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleChip = (role: string) => {
|
||||
const colors: Record<string, any> = {
|
||||
admin: 'error',
|
||||
moderator: 'warning',
|
||||
user: 'primary',
|
||||
suspended: 'default'
|
||||
};
|
||||
|
||||
return (
|
||||
<Chip
|
||||
label={role}
|
||||
color={colors[role] || 'default'}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: 'name',
|
||||
headerName: 'User',
|
||||
flex: 1,
|
||||
minWidth: 200,
|
||||
renderCell: (params) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Avatar sx={{ width: 32, height: 32 }}>
|
||||
{(params.row.name || params.row.email)[0].toUpperCase()}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight="medium">
|
||||
{params.row.name || 'Unknown User'}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{params.row.email}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
{
|
||||
field: 'role',
|
||||
headerName: 'Role',
|
||||
width: 120,
|
||||
renderCell: (params) => getRoleChip(params.value)
|
||||
},
|
||||
{
|
||||
field: 'createdAt',
|
||||
headerName: 'Joined',
|
||||
width: 120,
|
||||
renderCell: (params) => new Date(params.value).toLocaleDateString()
|
||||
},
|
||||
{
|
||||
field: 'lastLoginAt',
|
||||
headerName: 'Last Login',
|
||||
width: 120,
|
||||
renderCell: (params) =>
|
||||
params.value ? new Date(params.value).toLocaleDateString() : 'Never'
|
||||
},
|
||||
{
|
||||
field: 'activity',
|
||||
headerName: 'Activity',
|
||||
width: 120,
|
||||
renderCell: (params) => (
|
||||
<Box>
|
||||
<Typography variant="caption" display="block">
|
||||
{params.row._count.chatConversations} chats
|
||||
</Typography>
|
||||
<Typography variant="caption" display="block">
|
||||
{params.row._count.prayerRequests} prayers
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
{
|
||||
field: 'actions',
|
||||
type: 'actions',
|
||||
headerName: 'Actions',
|
||||
width: 120,
|
||||
getActions: (params) => {
|
||||
const actions = [
|
||||
<GridActionsCellItem
|
||||
key="view"
|
||||
icon={
|
||||
<Tooltip title="View Details">
|
||||
<Visibility />
|
||||
</Tooltip>
|
||||
}
|
||||
label="View"
|
||||
onClick={() => handleViewUser(params)}
|
||||
/>
|
||||
];
|
||||
|
||||
// Only show delete for non-admin users
|
||||
if (params.row.role !== 'admin') {
|
||||
actions.push(
|
||||
<GridActionsCellItem
|
||||
key="delete"
|
||||
icon={
|
||||
<Tooltip title="Delete User">
|
||||
<Delete />
|
||||
</Tooltip>
|
||||
}
|
||||
label="Delete"
|
||||
onClick={() => handleDeleteUser(params)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Filters */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
|
||||
<TextField
|
||||
label="Search users"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
size="small"
|
||||
sx={{ minWidth: 250 }}
|
||||
/>
|
||||
<FormControl size="small" sx={{ minWidth: 150 }}>
|
||||
<InputLabel>Role</InputLabel>
|
||||
<Select
|
||||
value={roleFilter}
|
||||
label="Role"
|
||||
onChange={(e) => setRoleFilter(e.target.value)}
|
||||
>
|
||||
<MenuItem value="all">All Roles</MenuItem>
|
||||
<MenuItem value="admin">Admin</MenuItem>
|
||||
<MenuItem value="moderator">Moderator</MenuItem>
|
||||
<MenuItem value="user">User</MenuItem>
|
||||
<MenuItem value="suspended">Suspended</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Data Grid */}
|
||||
<Card>
|
||||
<Box sx={{ height: 600 }}>
|
||||
<DataGrid
|
||||
rows={users}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
paginationModel={paginationModel}
|
||||
onPaginationModelChange={setPaginationModel}
|
||||
rowCount={rowCount}
|
||||
paginationMode="server"
|
||||
pageSizeOptions={[10, 25, 50]}
|
||||
disableRowSelectionOnClick
|
||||
sx={{
|
||||
border: 'none',
|
||||
'& .MuiDataGrid-cell': {
|
||||
borderBottom: '1px solid #f0f0f0'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* User Detail Modal */}
|
||||
<UserDetailModal
|
||||
userId={selectedUserId}
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onUserUpdate={handleUserUpdate}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={deleteDialogOpen}
|
||||
onClose={() => setDeleteDialogOpen(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>Delete User</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>
|
||||
Are you sure you want to delete user <strong>{userToDelete?.name || userToDelete?.email}</strong>?
|
||||
</Typography>
|
||||
<Typography variant="body2" color="error" sx={{ mt: 2 }}>
|
||||
This action cannot be undone. All user data including conversations, prayer requests, and bookmarks will be permanently deleted.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
onClick={confirmDeleteUser}
|
||||
color="error"
|
||||
variant="contained"
|
||||
>
|
||||
Delete User
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user