PROBLEM:
- /admin redirects to / (home page)
- Admin pages were wrapping themselves in <AdminLayout>
- This caused double-nested layout (header, nav, footer twice)
ROOT CAUSE:
Admin pages (AdminDashboardPage, AdminUsersPage, etc.) were individually wrapping
their content in <AdminLayout> component. But the App.tsx routing already renders
<AdminLayout> as the parent element for all /admin routes, and AdminLayout uses
<Outlet /> to render child routes.
This created a nested structure:
AppLayout -> AdminLayout (from routing) -> AdminLayout (from page) -> Page Content
SOLUTION:
- Removed <AdminLayout> wrappers from all admin pages
- AdminLayout now always uses <Outlet /> instead of {children || <Outlet />}
- Removed unused 'children' parameter from AdminLayout
- Admin pages now render directly in the Outlet of AdminLayout
CHANGES:
- AdminDashboardPage.tsx: Removed AdminLayout import and all <AdminLayout> tags
- AdminUsersPage.tsx: Removed AdminLayout import and all <AdminLayout> tags
- AdminUserDetailPage.tsx: Removed AdminLayout import and all <AdminLayout> tags
- AdminReportsPage.tsx: Removed AdminLayout import and all <AdminLayout> tags
- AdminStoryGeneratorPage.tsx: Removed AdminLayout import and all <AdminLayout> tags
- AdminLayout.tsx: Removed unused children parameter, always use <Outlet />
Now /admin will correctly render the AdminLayout with nested admin pages.
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
342 lines
13 KiB
TypeScript
342 lines
13 KiB
TypeScript
/**
|
|
* Admin Reports page
|
|
* View and export user progress reports
|
|
*/
|
|
|
|
import { useEffect, useState, useCallback } from 'react';
|
|
import { useAuth } from '@/stores/authStore';
|
|
import { getAllUserReports, getFullUserReport, exportReportsAsCsv } from '@/lib/api/admin';
|
|
import type { UserProgressReport, FullUserReport } from '@/types/api/admin';
|
|
|
|
export function AdminReportsPage() {
|
|
const { token } = useAuth();
|
|
const [reports, setReports] = useState<UserProgressReport[]>([]);
|
|
const [selectedUser, setSelectedUser] = useState<FullUserReport | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [reportLoading, setReportLoading] = useState<number | null>(null);
|
|
const [exporting, setExporting] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const fetchReports = useCallback(async () => {
|
|
if (!token) return;
|
|
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
const data = await getAllUserReports(token);
|
|
setReports(data);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load reports');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [token]);
|
|
|
|
useEffect(() => {
|
|
fetchReports();
|
|
}, [fetchReports]);
|
|
|
|
const fetchUserReport = async (userId: number) => {
|
|
if (!token) return;
|
|
|
|
try {
|
|
setReportLoading(userId);
|
|
setError(null);
|
|
const data = await getFullUserReport(token, userId);
|
|
setSelectedUser(data);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load user report');
|
|
} finally {
|
|
setReportLoading(null);
|
|
}
|
|
};
|
|
|
|
const handleExportCsv = async () => {
|
|
if (!token) return;
|
|
|
|
try {
|
|
setExporting(true);
|
|
setError(null);
|
|
const { csvContent } = await exportReportsAsCsv(token);
|
|
|
|
// Download CSV file
|
|
const blob = new Blob([csvContent], { type: 'text/csv' });
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `user-reports-${new Date().toISOString().split('T')[0]}.csv`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
window.URL.revokeObjectURL(url);
|
|
document.body.removeChild(a);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to export reports');
|
|
} finally {
|
|
setExporting(false);
|
|
}
|
|
};
|
|
|
|
const formatDate = (dateString: string): string => {
|
|
const date = new Date(dateString);
|
|
return date.toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
});
|
|
};
|
|
|
|
const calculateAverageScore = (score: number): string => {
|
|
return score > 0 ? score.toFixed(1) : 'N/A';
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
|
|
<div className="loading-overlay">
|
|
<div className="spinner"></div>
|
|
<p>Loading reports...</p>
|
|
</div>
|
|
|
|
);
|
|
}
|
|
|
|
return (
|
|
|
|
<div className="admin-reports">
|
|
<header className="reports-header">
|
|
<h1>User Progress Reports</h1>
|
|
<p className="reports-subtitle">
|
|
View and export progress reports for all users
|
|
</p>
|
|
<div className="reports-actions">
|
|
<button
|
|
onClick={fetchReports}
|
|
className="btn btn-secondary"
|
|
disabled={loading}
|
|
>
|
|
Refresh Reports
|
|
</button>
|
|
<button
|
|
onClick={handleExportCsv}
|
|
className="btn btn-primary"
|
|
disabled={exporting || reports.length === 0}
|
|
>
|
|
{exporting ? 'Exporting...' : 'Export All as CSV'}
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
{error && (
|
|
<div className="error-message admin-error">{error}</div>
|
|
)}
|
|
|
|
<div className="reports-container">
|
|
{/* Reports Summary Table */}
|
|
<div className="reports-summary">
|
|
<h2>All User Reports</h2>
|
|
<div className="reports-table-container">
|
|
<table className="reports-table">
|
|
<thead>
|
|
<tr>
|
|
<th>User</th>
|
|
<th>Email</th>
|
|
<th>Level</th>
|
|
<th>Lessons Completed</th>
|
|
<th>Quizzes Completed</th>
|
|
<th>Avg. Score</th>
|
|
<th>Points</th>
|
|
<th>Streak</th>
|
|
<th>Last Activity</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{reports.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={10} className="no-reports">
|
|
No user reports available
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
reports.map((report) => (
|
|
<tr key={report.userId}>
|
|
<td>{report.username}</td>
|
|
<td>{report.email}</td>
|
|
<td>{report.currentLevel}</td>
|
|
<td>{report.totalLessonsCompleted}</td>
|
|
<td>{report.totalQuizzesCompleted}</td>
|
|
<td>{calculateAverageScore(report.averageQuizScore)}</td>
|
|
<td>{report.totalPoints}</td>
|
|
<td>{report.currentStreak}</td>
|
|
<td>{formatDate(report.lastActivityDate)}</td>
|
|
<td>
|
|
<button
|
|
onClick={() => fetchUserReport(report.userId)}
|
|
className="btn btn-small"
|
|
disabled={reportLoading === report.userId}
|
|
>
|
|
{reportLoading === report.userId ? 'Loading...' : 'View Details'}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* User Detail Modal */}
|
|
{selectedUser && (
|
|
<div className="modal-overlay" onClick={() => setSelectedUser(null)}>
|
|
<div className="modal modal-large" onClick={(e) => e.stopPropagation()}>
|
|
<div className="modal-header">
|
|
<h2>User Progress Report</h2>
|
|
<button
|
|
onClick={() => setSelectedUser(null)}
|
|
className="modal-close"
|
|
>
|
|
×
|
|
</button>
|
|
</div>
|
|
|
|
<div className="modal-content">
|
|
{/* User Overview */}
|
|
<div className="report-section">
|
|
<h3>User Overview</h3>
|
|
<div className="user-overview-grid">
|
|
<div className="overview-item">
|
|
<span className="label">Username:</span>
|
|
<span className="value">{selectedUser.progressReport.username}</span>
|
|
</div>
|
|
<div className="overview-item">
|
|
<span className="label">Email:</span>
|
|
<span className="value">{selectedUser.progressReport.email}</span>
|
|
</div>
|
|
<div className="overview-item">
|
|
<span className="label">Current Level:</span>
|
|
<span className="value">{selectedUser.progressReport.currentLevel}</span>
|
|
</div>
|
|
<div className="overview-item">
|
|
<span className="label">Total Points:</span>
|
|
<span className="value">{selectedUser.progressReport.totalPoints}</span>
|
|
</div>
|
|
<div className="overview-item">
|
|
<span className="label">Streak:</span>
|
|
<span className="value">{selectedUser.progressReport.currentStreak} days</span>
|
|
</div>
|
|
<div className="overview-item">
|
|
<span className="label">Avg. Quiz Score:</span>
|
|
<span className="value">
|
|
{calculateAverageScore(selectedUser.progressReport.averageQuizScore)}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Progress Summary */}
|
|
<div className="report-section">
|
|
<h3>Progress Summary</h3>
|
|
<div className="progress-summary-grid">
|
|
<div className="progress-item">
|
|
<h4>Lessons Completed</h4>
|
|
<p>{selectedUser.progressReport.totalLessonsCompleted}</p>
|
|
</div>
|
|
<div className="progress-item">
|
|
<h4>Quizzes Completed</h4>
|
|
<p>{selectedUser.progressReport.totalQuizzesCompleted}</p>
|
|
</div>
|
|
<div className="progress-item">
|
|
<h4>Last Activity</h4>
|
|
<p>{formatDate(selectedUser.progressReport.lastActivityDate)}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Lesson Progress */}
|
|
{selectedUser.lessonProgress.length > 0 && (
|
|
<div className="report-section">
|
|
<h3>Lesson Progress</h3>
|
|
<div className="lesson-progress-table">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Lesson</th>
|
|
<th>Level</th>
|
|
<th>Status</th>
|
|
<th>Completed</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{selectedUser.lessonProgress.map((lesson) => (
|
|
<tr key={lesson.lessonId}>
|
|
<td>{lesson.lessonTitle}</td>
|
|
<td>{lesson.levelCode}</td>
|
|
<td>
|
|
<span className={`status-badge ${lesson.isCompleted ? 'completed' : 'pending'}`}>
|
|
{lesson.isCompleted ? 'Completed' : 'Pending'}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
{lesson.completedAt ? formatDate(lesson.completedAt) : 'N/A'}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Quiz Results */}
|
|
{selectedUser.quizResults.length > 0 && (
|
|
<div className="report-section">
|
|
<h3>Quiz Results</h3>
|
|
<div className="quiz-results-table">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Quiz</th>
|
|
<th>Score</th>
|
|
<th>Passing Score</th>
|
|
<th>Status</th>
|
|
<th>Date</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{selectedUser.quizResults.map((result) => (
|
|
<tr key={result.quizId}>
|
|
<td>{result.quizTitle}</td>
|
|
<td>{result.score}%</td>
|
|
<td>{result.passingScore}%</td>
|
|
<td>
|
|
<span className={`status-badge ${result.passed ? 'passed' : 'failed'}`}>
|
|
{result.passed ? 'Passed' : 'Failed'}
|
|
</span>
|
|
</td>
|
|
<td>{formatDate(result.attemptDate)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="modal-footer">
|
|
<button
|
|
onClick={() => setSelectedUser(null)}
|
|
className="btn btn-secondary"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
);
|
|
}
|