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:
2025-09-23 12:01:34 +00:00
parent ee99e93ec2
commit 39b6899315
48 changed files with 8525 additions and 5198 deletions

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

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

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

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

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

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

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