diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index bbacc1a..b7a4071 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -9,6 +9,7 @@ from ..models.faq import FAQ from ..models.report import Report from ..models.audit_log import AuditLog +from ..services import AdminDashboardService admin_bp = Blueprint("admin", __name__) @@ -134,6 +135,15 @@ def get_stats(): }), 200 +@admin_bp.route("/dashboard", methods=["GET"]) +@jwt_required() +@admin_required +def admin_dashboard(): + current_user_id = get_jwt_identity() + data = AdminDashboardService.get_dashboard(current_user_id=current_user_id) + return jsonify(data), 200 + + # ---- Reports ---- @admin_bp.route("/reports", methods=["GET"]) @jwt_required() diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index 18527ae..29c5298 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -1,12 +1,19 @@ -from .auth_service import AuthService -from .question_service import QuestionService -from .solution_service import SolutionService -from .vote_service import VoteService -from .notification_service import NotificationService -from .faq_service import FAQService -from .websocket_service import WebSocketService - -__all__ = [ - 'AuthService', 'QuestionService', 'SolutionService', - 'VoteService', 'NotificationService', 'FAQService', 'WebSocketService' -] +from .auth_service import AuthService +from .question_service import QuestionService +from .solution_service import SolutionService +from .vote_service import VoteService +from .notification_service import NotificationService +from .faq_service import FAQService +from .websocket_service import WebSocketService +from .admin_dashboard_service import AdminDashboardService + +__all__ = [ + "AuthService", + "QuestionService", + "SolutionService", + "VoteService", + "NotificationService", + "FAQService", + "WebSocketService", + "AdminDashboardService", +] diff --git a/backend/app/services/admin_dashboard_service.py b/backend/app/services/admin_dashboard_service.py new file mode 100644 index 0000000..e66eccf --- /dev/null +++ b/backend/app/services/admin_dashboard_service.py @@ -0,0 +1,218 @@ +from datetime import datetime + +from sqlalchemy import distinct, func + +from .. import db +from ..models import ( + Follow, + Question, + QuestionTag, + Solution, + Tag, + User, + Vote, +) +from .question_service import QuestionService + + +class AdminDashboardService: + @staticmethod + def _format_timeago(value): + if not value: + return None + if isinstance(value, str): + try: + timestamp = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return value + else: + timestamp = value + now = datetime.utcnow() + diff = now - timestamp + minutes = diff.total_seconds() / 60 + if minutes < 1: + return "just now" + if minutes < 60: + return f"{int(minutes)}m ago" + hours = minutes / 60 + if hours < 24: + return f"{int(hours)}h ago" + days = hours / 24 + if days < 7: + return f"{int(days)}d ago" + weeks = days / 7 + if weeks < 4: + return f"{int(weeks)}w ago" + months = days / 30 + if months < 12: + return f"{int(months)}mo ago" + years = days / 365 + return f"{int(years)}y ago" + + @staticmethod + def get_dashboard(current_user_id=None, limit_questions=30): + total_questions = db.session.query(func.count(Question.id)).scalar() or 0 + answered_questions = ( + db.session.query(func.count(distinct(Question.id))) + .join(Solution, Solution.question_id == Question.id) + .scalar() + or 0 + ) + total_answers = db.session.query(func.count(Solution.id)).scalar() or 0 + total_views = db.session.query(func.count(Follow.id)).scalar() or 0 + followed_questions = ( + db.session.query(func.count(distinct(Follow.question_id))).scalar() or 0 + ) + total_votes = db.session.query(func.count(Vote.id)).scalar() or 0 + bounty_questions = ( + db.session.query(func.count(Question.id)) + .filter(Question.problem_type.ilike("%bounty%")) + .scalar() + or 0 + ) + + answer_rate = ( + round((answered_questions / total_questions) * 100, 1) + if total_questions + else 0.0 + ) + + question_query = ( + QuestionService._base_query() + .order_by(Question.created_at.desc()) + .limit(limit_questions) + ) + question_rows = question_query.all() + serialized_questions = [] + featured_count = 0 + bounty_count = 0 + following_count = 0 + + for row in question_rows: + item = QuestionService._serialize_question( + row, include_answers=False, current_user_id=current_user_id + ) + if item.get("is_featured"): + featured_count += 1 + if (item.get("bounty") or 0) > 0: + bounty_count += 1 + if item.get("follows_count"): + following_count += 1 + serialized_questions.append(item) + + tag_rows = ( + db.session.query(Tag.name, func.count(QuestionTag.id).label("count")) + .join(QuestionTag, QuestionTag.tag_id == Tag.id) + .group_by(Tag.id) + .order_by(func.count(QuestionTag.id).desc()) + .limit(6) + .all() + ) + popular_tags = [ + {"name": name, "count": count} + for name, count in tag_rows + if name is not None + ] + + question_counts_sub = ( + db.session.query( + Question.user_id.label("user_id"), + func.count(Question.id).label("question_count"), + ) + .group_by(Question.user_id) + .subquery() + ) + + solution_counts_sub = ( + db.session.query( + Solution.user_id.label("user_id"), + func.count(Solution.id).label("solution_count"), + ) + .group_by(Solution.user_id) + .subquery() + ) + + contributor_rows = ( + db.session.query( + User.id, + User.name, + func.coalesce(question_counts_sub.c.question_count, 0).label( + "questions" + ), + func.coalesce(solution_counts_sub.c.solution_count, 0).label( + "answers" + ), + ) + .outerjoin(question_counts_sub, question_counts_sub.c.user_id == User.id) + .outerjoin(solution_counts_sub, solution_counts_sub.c.user_id == User.id) + .filter(User.role != "admin") + .order_by( + ( + func.coalesce(solution_counts_sub.c.solution_count, 0) * 2 + + func.coalesce(question_counts_sub.c.question_count, 0) + ).desc() + ) + .limit(3) + .all() + ) + + top_contributors = [] + for rank, row in enumerate(contributor_rows, start=1): + reputation = row.answers * 20 + row.questions * 10 + top_contributors.append( + { + "id": row.id, + "name": row.name, + "rank": rank, + "questions": row.questions, + "answers": row.answers, + "reputation": reputation, + } + ) + + recent_rows = ( + QuestionService._base_query() + .order_by(Question.updated_at.desc()) + .limit(5) + .all() + ) + recent_activity = [] + for row in recent_rows: + recent_activity.append( + { + "id": row.id, + "title": row.title, + "answers": len(getattr(row, "solutions", [])), + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + "time_ago": AdminDashboardService._format_timeago(row.updated_at), + } + ) + + metrics = { + "total_questions": total_questions, + "answered_questions": answered_questions, + "unanswered_questions": total_questions - answered_questions, + "answer_rate": answer_rate, + "total_views": total_views, + "tracked_views": followed_questions, + "total_answers": total_answers, + "total_votes": total_votes, + "total_bounty": bounty_questions * 50, + } + + filters = { + "all": total_questions, + "unanswered": total_questions - answered_questions, + "bounty": bounty_count, + "following": following_count, + "featured": featured_count, + } + + return { + "metrics": metrics, + "filters": filters, + "questions": serialized_questions, + "popular_tags": popular_tags, + "top_contributors": top_contributors, + "recent_activity": recent_activity, + } diff --git a/backend/app/services/question_service.py b/backend/app/services/question_service.py index cc7e611..c301e9f 100644 --- a/backend/app/services/question_service.py +++ b/backend/app/services/question_service.py @@ -37,6 +37,18 @@ def _base_query(): def _serialize_question(question, include_answers=False, current_user_id=None): tags = [tag.name for tag in getattr(question, "tags", [])] author = question.author.to_dict() if getattr(question, "author", None) else None + solutions = list(getattr(question, "solutions", [])) + follows = list(getattr(question, "follows", [])) + + vote_total = sum(solution.get_vote_count() for solution in solutions) + upvotes_total = sum(solution.get_upvotes() for solution in solutions) + downvotes_total = sum(solution.get_downvotes() for solution in solutions) + view_count = len(follows) + bounty_value = ( + 50 if question.problem_type and "bounty" in question.problem_type.lower() else 0 + ) + is_solved = len(solutions) > 0 + is_featured = vote_total >= 5 or view_count >= 5 or bounty_value > 0 created_at = question.created_at.isoformat() if question.created_at else None updated_at = question.updated_at.isoformat() if question.updated_at else None @@ -55,7 +67,17 @@ def _serialize_question(question, include_answers=False, current_user_id=None): "authorName": author.get("name") if author else None, "tags": tags, "solutions_count": len(question.solutions), - "follows_count": len(question.follows), + "follows_count": view_count, + "view_count": view_count, + "views": view_count, + "followers_count": view_count, + "vote_total": vote_total, + "vote_count": vote_total, + "upvotes_total": upvotes_total, + "downvotes_total": downvotes_total, + "bounty": bounty_value, + "is_solved": is_solved, + "is_featured": is_featured, } if include_answers: diff --git a/backend/migrations/versions/b76cf71d8685_init.py b/backend/migrations/versions/b76cf71d8685_init.py deleted file mode 100644 index f3fd873..0000000 --- a/backend/migrations/versions/b76cf71d8685_init.py +++ /dev/null @@ -1,171 +0,0 @@ -"""init - -Revision ID: b76cf71d8685 -Revises: -Create Date: 2025-09-29 23:39:03.747636 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = 'b76cf71d8685' -down_revision = None -branch_labels = None -depends_on = None - - -def upgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('tags', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('name', sa.String(length=50), nullable=False), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('name') - ) - op.create_table('users', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('name', sa.String(length=100), nullable=False), - sa.Column('email', sa.String(length=255), nullable=False), - sa.Column('password_hash', sa.String(length=255), nullable=False), - sa.Column('role', sa.String(length=20), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=True), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('email') - ) - op.create_table('audit_logs', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('action', sa.String(length=100), nullable=False), - sa.Column('target', sa.String(length=255), nullable=False), - sa.Column('reason', sa.String(length=255), nullable=True), - sa.Column('admin_id', sa.Integer(), nullable=False), - sa.Column('admin_name', sa.String(length=100), nullable=False), - sa.Column('timestamp', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['admin_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('faqs', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('question', sa.String(length=255), nullable=False), - sa.Column('answer', sa.Text(), nullable=False), - sa.Column('created_by', sa.Integer(), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['created_by'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('notifications', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('type', sa.String(length=20), nullable=False), - sa.Column('reference_id', sa.Integer(), nullable=False), - sa.Column('is_read', sa.Boolean(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('password_reset_tokens', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('token', sa.String(length=255), nullable=False), - sa.Column('expires_at', sa.DateTime(), nullable=False), - sa.Column('used', sa.Boolean(), nullable=True), - sa.Column('used_at', sa.DateTime(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('token') - ) - op.create_table('questions', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('title', sa.String(length=200), nullable=False), - sa.Column('description', sa.Text(), nullable=False), - sa.Column('problem_type', sa.String(length=20), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('reports', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('target_type', sa.String(length=50), nullable=False), - sa.Column('target_id', sa.Integer(), nullable=False), - sa.Column('reason', sa.String(length=255), nullable=False), - sa.Column('status', sa.String(length=20), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('follows', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('question_id', sa.Integer(), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['question_id'], ['questions.id'], ), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('user_id', 'question_id', name='unique_user_question_follow') - ) - op.create_table('question_tags', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('question_id', sa.Integer(), nullable=False), - sa.Column('tag_id', sa.Integer(), nullable=False), - sa.ForeignKeyConstraint(['question_id'], ['questions.id'], ), - sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('question_id', 'tag_id', name='unique_question_tag') - ) - op.create_table('related_questions', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('question_id', sa.Integer(), nullable=False), - sa.Column('related_question_id', sa.Integer(), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['question_id'], ['questions.id'], ), - sa.ForeignKeyConstraint(['related_question_id'], ['questions.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('question_id', 'related_question_id', name='unique_related_question') - ) - op.create_table('solutions', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('question_id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('content', sa.Text(), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['question_id'], ['questions.id'], ), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_table('votes', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('solution_id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('vote_type', sa.String(length=10), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['solution_id'], ['solutions.id'], ), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('user_id', 'solution_id', name='unique_user_solution_vote') - ) - # ### end Alembic commands ### - - -def downgrade(): - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('votes') - op.drop_table('solutions') - op.drop_table('related_questions') - op.drop_table('question_tags') - op.drop_table('follows') - op.drop_table('reports') - op.drop_table('questions') - op.drop_table('password_reset_tokens') - op.drop_table('notifications') - op.drop_table('faqs') - op.drop_table('audit_logs') - op.drop_table('users') - op.drop_table('tags') - # ### end Alembic commands ### diff --git a/frontend/src/components/EnhancedAdminPanel.jsx b/frontend/src/components/EnhancedAdminPanel.jsx index afa40c9..0d2a149 100644 --- a/frontend/src/components/EnhancedAdminPanel.jsx +++ b/frontend/src/components/EnhancedAdminPanel.jsx @@ -1,1653 +1,522 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; import { - Users, - MessageSquare, - Flag, - Activity, - TrendingUp, - Eye, - EyeOff, - Trash2, - CheckCircle, - Search, - Filter, - MoreHorizontal, - FileText, - AlertTriangle, + Award, + ChevronRight, Clock, - UserCheck, - UserX, - Edit, + Eye, + Loader2, + MessageSquare, Plus, - Download, - Calendar, -} from 'lucide-react'; -import api, { problemsApi } from '../services/api'; -import { Button } from './ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; -import { Badge } from './ui/badge'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'; + Search, + Sparkles, + Tag, + ThumbsUp, + TrendingUp, + Users, +} from "lucide-react"; + +import { adminApi } from "../services/api"; +import { Badge } from "./ui/badge"; +import { Button } from "./ui/button"; import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from './ui/table'; + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "./ui/card"; +import { Input } from "./ui/input"; +import { Progress } from "./ui/progress"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from './ui/select'; -import { Input } from './ui/input'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, -} from './ui/dialog'; -import { Textarea } from './ui/textarea'; -import { Label } from './ui/label'; -import { Separator } from './ui/separator'; -import { Progress } from './ui/progress'; -import { Avatar, AvatarFallback } from './ui/avatar'; - -const TIME_RANGE_LABELS = { - '1d': 'Last 24 hours', - '7d': 'Last 7 days', - '30d': 'Last 30 days', - '90d': 'Last 90 days', -}; - -function getStatusBadgeClasses(status) { - switch (status) { - case 'pending': - return 'border-amber-200 bg-amber-100 text-amber-700'; - case 'resolved': - return 'border-emerald-200 bg-emerald-100 text-emerald-700'; - case 'dismissed': - return 'border-slate-200 bg-slate-100 text-slate-600'; - default: - return 'border-slate-200 bg-slate-100 text-slate-600'; - } +} from "./ui/select"; +import { Separator } from "./ui/separator"; +import { Tabs, TabsList, TabsTrigger } from "./ui/tabs"; + +function formatShortNumber(value) { + const formatter = new Intl.NumberFormat("en", { + notation: "compact", + maximumFractionDigits: 1, + }); + return formatter.format(value || 0); } -function getPriorityBadgeClasses(priority) { - switch (priority) { - case 'high': - return 'border-red-200 bg-red-100 text-red-600'; - case 'medium': - return 'border-amber-200 bg-amber-100 text-amber-700'; - case 'low': - return 'border-emerald-200 bg-emerald-100 text-emerald-700'; - default: - return 'border-slate-200 bg-slate-100 text-slate-600'; - } +function formatPercent(value) { + if (value == null || Number.isNaN(value)) return "0%"; + return `${Math.round(value)}%`; } -function formatDateTime(value) { +function formatDate(value) { + if (!value) return "—"; const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return '-'; - } - return date.toLocaleString('en-US', { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', + if (Number.isNaN(date.getTime())) return "—"; + return date.toLocaleDateString("en", { + month: "short", + day: "numeric", + year: "numeric", }); } function formatTimeAgo(value) { + if (!value) return "—"; const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return ''; - } - const diffMinutes = Math.round((Date.now() - date.getTime()) / 60000); - if (diffMinutes < 1) { - return 'just now'; - } - if (diffMinutes < 60) { - return `${diffMinutes}m ago`; - } - if (diffMinutes < 1440) { - return `${Math.floor(diffMinutes / 60)}h ago`; - } - return `${Math.floor(diffMinutes / 1440)}d ago`; -} - -function formatCategoryLabel(value) { - if (!value) { - return 'General'; - } - return value.charAt(0).toUpperCase() + value.slice(1); + if (Number.isNaN(date.getTime())) return "—"; + const diff = Date.now() - date.getTime(); + const minutes = diff / 60000; + if (minutes < 1) return "just now"; + if (minutes < 60) return `${Math.floor(minutes)}m ago`; + const hours = minutes / 60; + if (hours < 24) return `${Math.floor(hours)}h ago`; + const days = hours / 24; + if (days < 7) return `${Math.floor(days)}d ago`; + const weeks = days / 7; + if (weeks < 4) return `${Math.floor(weeks)}w ago`; + const months = days / 30; + if (months < 12) return `${Math.floor(months)}mo ago`; + return `${Math.floor(days / 365)}y ago`; } -function safeParseDate(value) { - if (!value) { - return null; - } - const date = new Date(value); - return Number.isNaN(date.getTime()) ? null : date; -} - -function formatDuration(minutes) { - if (!minutes || !Number.isFinite(minutes) || minutes <= 0) { - return '—'; - } - const totalMinutes = Math.round(minutes); - const hours = Math.floor(totalMinutes / 60); - const mins = totalMinutes % 60; - if (hours && mins) { - return `${hours}h ${mins}m`; - } - if (hours) { - return `${hours}h`; - } - return `${mins}m`; -} - -function formatActionLabel(value) { - if (!value) { - return ''; - } - return value - .toString() - .replace(/[_-]+/g, ' ') - .trim() - .replace(/\b\w/g, (char) => char.toUpperCase()); +function normalizeQuestion(question) { + const tags = Array.isArray(question?.tags) ? question.tags : []; + return { + id: question.id, + title: question.title ?? "(untitled)", + description: question.description ?? question.body ?? "", + created_at: question.created_at ?? question.timestamp, + updated_at: question.updated_at ?? question.timestamp, + tags, + problem_type: question.problem_type, + solutions_count: question.solutions_count ?? 0, + follows_count: question.follows_count ?? question.followers_count ?? 0, + view_count: question.view_count ?? question.views ?? 0, + vote_count: question.vote_total ?? question.vote_count ?? 0, + authorName: question.authorName ?? question.author?.name ?? "Unknown", + authorId: question.authorId ?? question.user_id, + is_solved: Boolean(question.is_solved) || (question.solutions_count ?? 0) > 0, + bounty: question.bounty ?? 0, + is_featured: Boolean(question.is_featured), + is_following: Boolean(question.is_following), + }; } -function normalizeText(value) { - if (typeof value === 'string') { - return value.toLowerCase(); - } - if (value === null || value === undefined) { - return ''; - } - return String(value).toLowerCase(); -} +const METRIC_CARD_CONFIG = [ + { + key: "total_questions", + label: "Total Questions", + icon: Users, + accent: "text-emerald-600 bg-emerald-100", + }, + { + key: "answer_rate", + label: "Answer Rate", + icon: TrendingUp, + accent: "text-rose-600 bg-rose-100", + isPercent: true, + showProgress: true, + }, + { + key: "total_views", + label: "Total Views", + icon: Eye, + accent: "text-indigo-600 bg-indigo-100", + }, + { + key: "total_bounty", + label: "Total Bounty", + icon: Sparkles, + accent: "text-amber-600 bg-amber-100", + }, +]; + +const SORT_OPTIONS = [ + { value: "newest", label: "Newest" }, + { value: "popular", label: "Most Popular" }, + { value: "answers", label: "Most Answers" }, + { value: "bounty", label: "Highest Bounty" }, +]; export function EnhancedAdminPanel({ currentUser }) { - const [activeSection, setActiveSection] = useState('overview'); - const [reports, setReports] = useState([]); - const [auditLogs, setAuditLogs] = useState([]); - const [stats, setStats] = useState(null); - const [adminUsers, setAdminUsers] = useState([]); - const [faqs, setFaqs] = useState([]); + const navigate = useNavigate(); + const [dashboard, setDashboard] = useState(null); + const [questions, setQuestions] = useState([]); + const [activeTab, setActiveTab] = useState("all"); + const [searchTerm, setSearchTerm] = useState(""); + const [sortBy, setSortBy] = useState("newest"); const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - const [selectedTimeRange, setSelectedTimeRange] = useState('7d'); - const [searchTerm, setSearchTerm] = useState(''); - const [selectedReportStatus, setSelectedReportStatus] = useState('all'); - const [showCreateFAQDialog, setShowCreateFAQDialog] = useState(false); - const [newFAQ, setNewFAQ] = useState({ - question: '', - answer: '', - category: 'moderation', - }); - const [trendingTopics, setTrendingTopics] = useState([]); - - useEffect(() => { - let active = true; - - (async () => { - try { - setLoading(true); - setError(''); - - const [statsData, reportsData, auditData, usersData, faqsData] = await Promise.all([ - api.get('/admin/stats').then((res) => res.data), - api.get('/admin/reports').then((res) => res.data), - api.get('/admin/audit').then((res) => res.data), - api - .get('/admin/users', { params: { page: 1, per_page: 10 } }) - .then((res) => res.data?.users ?? []), - api.get('/faqs').then((res) => res.data?.faqs ?? []), - ]); - - if (!active) return; + const [error, setError] = useState(""); - setStats(statsData || null); - const reportsList = Array.isArray(reportsData) - ? reportsData - : reportsData?.reports ?? []; - setReports(reportsList); - setAuditLogs(Array.isArray(auditData) ? auditData : auditData?.logs ?? []); - setAdminUsers(Array.isArray(usersData) ? usersData : []); - setFaqs(Array.isArray(faqsData) ? faqsData : []); - } catch (err) { - if (!active) return; - console.error('Failed to load admin data', err); - setError(err.response?.data?.error || err.message || 'Failed to load admin data'); - } finally { - if (active) { - setLoading(false); - } - } - })(); - - return () => { - active = false; - }; - }, []); + const loadDashboard = async () => { + try { + setLoading(true); + setError(""); + const data = await adminApi.dashboard(); + setDashboard(data); + const list = Array.isArray(data?.questions) ? data.questions : []; + setQuestions(list.map(normalizeQuestion)); + } catch (err) { + console.error("Failed to load admin dashboard", err); + setError(err?.response?.data?.error || err.message || "Failed to load dashboard"); + } finally { + setLoading(false); + } + }; useEffect(() => { - let active = true; - - (async () => { - try { - const data = await problemsApi.list({ page: 1, per_page: 50 }); - if (!active) { - return; - } - - const source = Array.isArray(data?.questions) - ? data.questions - : Array.isArray(data?.items) - ? data.items - : []; - - const tagCounts = new Map(); - source.forEach((item) => { - const tagList = Array.isArray(item?.tags) ? item.tags : []; - tagList.forEach((tag) => { - const value = - typeof tag === 'string' - ? tag.trim().toLowerCase() - : typeof tag?.name === 'string' - ? tag.name.trim().toLowerCase() - : ''; - if (!value) { - return; - } - tagCounts.set(value, (tagCounts.get(value) || 0) + 1); - }); - }); - - const ranked = Array.from(tagCounts.entries()) - .sort((a, b) => b[1] - a[1]) - .slice(0, 5) - .map(([tag, count]) => ({ - tag, - count, - })); - - setTrendingTopics(ranked); - } catch (err) { - if (active) { - console.error('Failed to load trending topics', err); - setTrendingTopics([]); - } - } - })(); - - return () => { - active = false; - }; - }, []); - - const reloadReports = useCallback(async () => { - const data = await api.get('/admin/reports').then((res) => res.data); - const list = Array.isArray(data) ? data : data?.reports ?? []; - setReports(list); - return list; - }, []); - - const reloadAuditLogs = useCallback(async () => { - const data = await api.get('/admin/audit').then((res) => res.data); - const list = Array.isArray(data) ? data : data?.logs ?? []; - setAuditLogs(list); - return list; - }, []); - - const reloadFaqs = useCallback(async () => { - const data = await api.get('/faqs').then((res) => res.data?.faqs ?? []); - const list = Array.isArray(data) ? data : []; - setFaqs(list); - return list; + loadDashboard(); }, []); - const numberFormatter = useMemo(() => new Intl.NumberFormat('en-US'), []); - - const moderationStats = useMemo(() => { - const totalFromStats = (stats?.pendingReports ?? 0) + (stats?.resolvedReports ?? 0); - const totalReports = reports.length || totalFromStats; - const pending = - reports.filter((report) => normalizeText(report?.status) === 'pending').length || - stats?.pendingReports || - 0; - const resolved = - reports.filter((report) => normalizeText(report?.status) === 'resolved').length || - stats?.resolvedReports || - 0; - const dismissed = reports.filter((report) => normalizeText(report?.status) === 'dismissed').length; - const highPriority = reports.filter( - (report) => - String(report.priority).toLowerCase() === 'high' && normalizeText(report?.status) === 'pending' - ).length; - const resolutionRate = totalReports ? Math.round((resolved / totalReports) * 100) : 0; - - return { - totalReports, - pending, - resolved, - dismissed, - highPriority, - resolutionRate, - averageResponseTime: pending ? '—' : '—', + const metrics = dashboard?.metrics ?? {}; + const popularTags = dashboard?.popular_tags ?? []; + const topContributors = dashboard?.top_contributors ?? []; + const recentActivity = dashboard?.recent_activity ?? []; + + const counts = useMemo(() => { + const base = { + all: questions.length, + unanswered: questions.filter((q) => q.solutions_count === 0).length, + bounty: questions.filter((q) => (q.bounty || 0) > 0).length, + following: questions.filter((q) => q.follows_count > 0).length, + featured: questions.filter((q) => q.is_featured).length, }; - }, [reports, stats]); - - const moderationInsights = useMemo(() => { - const now = Date.now(); - const pendingList = reports.filter((report) => normalizeText(report?.status) === 'pending'); - const pendingLast24 = pendingList.filter((report) => { - const date = safeParseDate(report?.timestamp ?? report?.created_at); - return date ? now - date.getTime() <= 24 * 60 * 60 * 1000 : false; - }).length; - const pendingAges = pendingList - .map((report) => { - const date = safeParseDate(report?.timestamp ?? report?.created_at); - return date ? (now - date.getTime()) / 60000 : null; - }) - .filter((value) => value !== null); - const avgPendingMinutes = pendingAges.length - ? pendingAges.reduce((sum, value) => sum + value, 0) / pendingAges.length - : 0; - const resolvedLast7 = auditLogs.filter((log) => { - const action = normalizeText(log?.action); - if (!action || !action.includes('resolve')) { - return false; - } - const date = safeParseDate(log?.timestamp); - return date ? now - date.getTime() <= 7 * 24 * 60 * 60 * 1000 : false; - }).length; - const dismissedLast7 = auditLogs.filter((log) => { - const action = normalizeText(log?.action); - if (!action || !action.includes('dismiss')) { - return false; - } - const date = safeParseDate(log?.timestamp); - return date ? now - date.getTime() <= 7 * 24 * 60 * 60 * 1000 : false; - }).length; - - return [ - { - id: 'mi-queue', - title: 'Pending queue', - value: numberFormatter.format(moderationStats.pending), - change: `${numberFormatter.format(pendingLast24)} new today`, - tone: moderationStats.pending > 0 ? 'attention' : 'positive', - }, - { - id: 'mi-resolution', - title: 'Resolution rate', - value: `${moderationStats.resolutionRate}%`, - change: `${numberFormatter.format(resolvedLast7)} resolved past 7d`, - tone: moderationStats.resolutionRate >= 80 ? 'positive' : 'attention', - }, - { - id: 'mi-age', - title: 'Average pending age', - value: formatDuration(avgPendingMinutes), - change: dismissedLast7 - ? `${numberFormatter.format(dismissedLast7)} dismissed past 7d` - : 'Monitoring queue', - tone: avgPendingMinutes > 240 ? 'attention' : 'positive', - }, - ]; - }, [auditLogs, moderationStats, numberFormatter, reports]); - - const totalQuestions = stats?.totalQuestions ?? 0; - const totalAnswers = stats?.totalAnswers ?? 0; - const totalUsersCount = stats?.totalUsers ?? adminUsers.length; - const answerRate = totalQuestions ? Math.round((totalAnswers / totalQuestions) * 100) : 0; - const unansweredQuestions = Math.max(totalQuestions - totalAnswers, 0); - - const activeContributors = stats?.activeUsers ?? totalUsersCount; - - const statsCards = useMemo( - () => [ - { - id: 'stat-1', - icon: Users, - label: 'Active contributors', - value: numberFormatter.format(activeContributors), - change: 'Active this month', - accentClass: 'bg-emerald-500/10 text-emerald-500', - }, - { - id: 'stat-2', - icon: MessageSquare, - label: 'New questions', - value: numberFormatter.format(totalQuestions), - change: `${answerRate}% answered`, - accentClass: 'bg-sky-500/10 text-sky-600', - }, - { - id: 'stat-3', - icon: Flag, - label: 'Pending reports', - value: numberFormatter.format(moderationStats.pending), - change: `${numberFormatter.format(moderationStats.highPriority)} priority`, - accentClass: 'bg-amber-500/10 text-amber-600', - }, - { - id: 'stat-4', - icon: TrendingUp, - label: 'Resolution rate', - value: `${moderationStats.resolutionRate}%`, - change: 'Target ≥ 90%', - accentClass: 'bg-indigo-500/10 text-indigo-600', - }, - ], - [ - answerRate, - moderationStats.highPriority, - moderationStats.pending, - moderationStats.resolutionRate, - numberFormatter, - totalQuestions, - activeContributors, - ] - ); - - const recentReports = useMemo(() => { - return reports.slice(0, 5).map((report) => { - const status = normalizeText(report?.status) || 'unknown'; - const timestamp = report?.timestamp ?? report?.created_at; - const titleRaw = - report?.targetTitle ?? - report?.target ?? - `${formatCategoryLabel(normalizeText(report?.type) || 'content')} #${report?.target_id ?? ''}`; - const title = typeof titleRaw === 'string' ? titleRaw.trim() : String(titleRaw); - return { - id: report?.id ?? `${title}-${timestamp ?? status}`, - title: title || 'Untitled content', - status, - timestamp, - }; - }); - }, [reports]); - - const complianceChecks = useMemo(() => { - const nowISO = new Date().toISOString(); - const queueStatus = moderationStats.pending > 5 ? 'attention' : 'pass'; - const answerRateStatus = answerRate >= 65 ? 'pass' : 'attention'; - const highPriorityStatus = moderationStats.highPriority > 0 ? 'attention' : 'pass'; - - return [ - { - id: 'check-queue', - label: 'Moderation queue load', - status: queueStatus, - updatedAt: nowISO, - detail: `${numberFormatter.format(moderationStats.pending)} pending`, - }, - { - id: 'check-answers', - label: 'Community answer rate', - status: answerRateStatus, - updatedAt: nowISO, - detail: `${answerRate}% answered`, - }, - { - id: 'check-priority', - label: 'High priority flags', - status: highPriorityStatus, - updatedAt: nowISO, - detail: `${numberFormatter.format(moderationStats.highPriority)} escalated`, - }, - ]; - }, [answerRate, moderationStats, numberFormatter]); + return base; + }, [questions]); + + const filteredQuestions = useMemo(() => { + const term = searchTerm.trim().toLowerCase(); + let data = [...questions]; + + if (term) { + data = data.filter((q) => { + const haystack = [ + q.title, + q.description, + ...(Array.isArray(q.tags) ? q.tags : []), + q.authorName, + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + return haystack.includes(term); + }); + } - const automationSignals = useMemo(() => { - if (!auditLogs.length) { - return []; + if (activeTab === "unanswered") { + data = data.filter((q) => q.solutions_count === 0); + } else if (activeTab === "bounty") { + data = data.filter((q) => (q.bounty || 0) > 0); + } else if (activeTab === "following") { + data = data.filter((q) => q.follows_count > 0); + } else if (activeTab === "featured") { + data = data.filter((q) => q.is_featured); } - const seen = new Set(); - const entries = []; - auditLogs.forEach((log) => { - const action = log?.action; - if (!action || seen.has(action)) { - return; + data.sort((a, b) => { + if (sortBy === "popular") { + const scoreA = (a.view_count || 0) + (a.vote_count || 0) * 2; + const scoreB = (b.view_count || 0) + (b.vote_count || 0) * 2; + return scoreB - scoreA; } - seen.add(action); - entries.push({ - id: log?.id ?? action, - label: formatActionLabel(action), - status: normalizeText(action).includes('dismiss') ? 'paused' : 'active', - timestamp: log?.timestamp, - }); - }); - - return entries.slice(0, 4); - }, [auditLogs]); - - const filteredReports = useMemo(() => { - const search = searchTerm.trim().toLowerCase(); - return reports.filter((report) => { - const haystack = [ - report?.targetTitle, - report?.reason, - report?.description, - report?.reporterName, - report?.reporterEmail, - report?.type, - ].map(normalizeText); - const matchesSearch = !search || haystack.some((value) => value.includes(search)); - const status = normalizeText(report?.status); - const matchesStatus = selectedReportStatus === 'all' || status === selectedReportStatus; - return matchesSearch && matchesStatus; - }); - }, [reports, searchTerm, selectedReportStatus]); - - const timeRangeLabel = TIME_RANGE_LABELS[selectedTimeRange] ?? 'Last 7 days'; - const flaggedUsers = useMemo(() => { - const grouped = new Map(); - reports.forEach((report) => { - const displayName = report.targetTitle || `${report.type ?? report.target_type} #${report.target_id}`; - if (!grouped.has(displayName)) { - grouped.set(displayName, { - id: displayName, - name: displayName, - reason: report.reason, - reports: 0, - lastSeen: report.timestamp || report.created_at, - }); + if (sortBy === "answers") { + return (b.solutions_count || 0) - (a.solutions_count || 0); } - const entry = grouped.get(displayName); - entry.reports += 1; - entry.reason = report.reason; - entry.lastSeen = report.timestamp || report.created_at || entry.lastSeen; - }); - return Array.from(grouped.values()).slice(0, 5); - }, [reports]); - - const teamMembers = useMemo(() => { - if (!Array.isArray(adminUsers) || !adminUsers.length) { - return []; - } - const now = Date.now(); - return adminUsers.map((user) => { - const joinedAt = safeParseDate(user?.created_at); - const updatedAt = safeParseDate(user?.updated_at); - const isActive = updatedAt ? now - updatedAt.getTime() <= 12 * 60 * 60 * 1000 : false; - return { - id: user.id ?? user.email ?? `user-${Math.random().toString(36).slice(2)}`, - name: user.name || user.email || 'Team member', - roleLabel: formatCategoryLabel(normalizeText(user?.role) || 'member'), - email: user.email, - joinedAt, - lastActive: updatedAt, - status: isActive ? 'Active' : 'Away', - }; - }); - }, [adminUsers]); - - const topReporters = useMemo(() => { - const counts = new Map(); - reports.forEach((report) => { - const reporterId = report.reporterId ?? report.user_id ?? report.userId ?? report.userID; - const key = reporterId ?? report.reporterEmail ?? report.reporterName ?? report.user_id ?? report.id; - const name = report.reporterName || report.reporterEmail || (reporterId ? `User #${reporterId}` : 'Community member'); - if (!counts.has(key)) { - counts.set(key, { - id: key, - name, - reports: 0, - }); + if (sortBy === "bounty") { + return (b.bounty || 0) - (a.bounty || 0); } - const entry = counts.get(key); - entry.reports += 1; + const dateA = new Date(a.created_at || 0).getTime(); + const dateB = new Date(b.created_at || 0).getTime(); + return dateB - dateA; }); - return Array.from(counts.values()) - .sort((a, b) => b.reports - a.reports) - .slice(0, 3); - }, [reports]); - - const navigation = [ - { label: 'Overview', value: 'overview', icon: Activity }, - { label: 'Moderation Hub', value: 'moderation', icon: Flag }, - { label: 'Community', value: 'users', icon: Users }, - ]; - - const handleReportAction = async (reportId, action) => { - try { - const endpoint = - action === 'resolve' ? 'resolve' : action === 'dismiss' || action === 'remove' ? 'dismiss' : null; - if (!endpoint) return; - - await api.post(`/admin/reports/${reportId}/${endpoint}`); - await Promise.all([reloadReports(), reloadAuditLogs()]); - } catch (err) { - console.error('Failed to update report', err); - } - }; - const handleUserAction = (userId, action) => { - console.log(`Admin action: ${action} applied to user ${userId}`); - }; - - const handleCreateFAQ = async (event) => { - event.preventDefault(); - if (!newFAQ.question.trim() || !newFAQ.answer.trim()) { - return; - } - - try { - await api.post('/faqs', { - question: newFAQ.question.trim(), - answer: newFAQ.answer.trim(), - }); - await reloadFaqs(); - setNewFAQ({ question: '', answer: '', category: newFAQ.category }); - setShowCreateFAQDialog(false); - } catch (err) { - console.error('Failed to create FAQ', err); - } - }; - - const greeting = currentUser?.name - ? `Welcome back, ${currentUser.name}` - : 'Welcome back, Admin'; + return data; + }, [questions, searchTerm, activeTab, sortBy]); if (loading) { return ( -
{error}
++ Discover answers, monitor community health, and keep the platform thriving with + real-time insights. +
+- Admin Control -
-- Monitor community health, triage urgent reports, and coordinate the moderation team from a single view. +
+ {config.label}
-{displayValue}
Pending queue
-- {numberFormatter.format(moderationStats.pending)} -
-- {moderationStats.highPriority} high priority items + {config.showProgress && ( +
+ {formatShortNumber(metrics.answered_questions ?? 0)} answered ·{" "} + {formatShortNumber(metrics.unanswered_questions ?? 0)} open
Resolution pace
-- {moderationStats.resolutionRate}% -
-- Target ≥ 90% -
+ )} + + + ); + })} + + +Response time
-- {moderationStats.averageResponseTime} -
-- From first flag to human review +
+ {question.description}
- {card.label} -
-- {card.value} -
-{card.change}
-Items awaiting review
-- {numberFormatter.format(moderationStats.pending)} -
-Resolution rate
-- {moderationStats.resolutionRate}% -
-Pending workload
-- {moderationStats.totalReports - ? `${Math.round((moderationStats.pending / moderationStats.totalReports) * 100)}%` - : '0%'} -
-Resolved
-- {numberFormatter.format(moderationStats.resolved)} -
-Dismissed
-- {numberFormatter.format(moderationStats.dismissed)} -
-Avg response
-- {activity.admin_name || 'Admin'} -
-- {activity.action} -
-- {activity.reason || activity.target} -
-No topic insights available yet.
- )} -Coverage across the moderation roster
-{member.name}
-{member.roleLabel}
-{member.email}
-- Last active {formatTimeAgo(member.lastActive) || '—'} -
-- Joined{' '} - {member.joinedAt - ? member.joinedAt.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }) - : '—'} -
-- No admin teammates found yet. -
- )} -Policy coverage and automation audits
-{check.label}
-{check.detail}
-- Updated {formatTimeAgo(check.updatedAt) || 'recently'} -
-No compliance signals yet.
- )} -Latest admin interventions
-{signal.label}
-- {formatTimeAgo(signal.timestamp) || 'recently'} -
-No admin activity recorded yet.
- )} -Prioritise reports with the most impact
-Operational performance signals
-{insight.title}
-- {insight.value} -
-- {insight.change} -
-Accounts needing moderation follow up
-{user.name}
-Answer quality and participation metrics
-Answer rate
-{answerRate}%
-Total questions
-- {numberFormatter.format(totalQuestions)} -
-Total answers
-- {numberFormatter.format(totalAnswers)} -
-Unanswered
-- {numberFormatter.format(unansweredQuestions)} -
-Community members flagging content the most
-{leader.name}
-- {numberFormatter.format(leader.reports)} reports -
-No reports submitted yet.
- )} -Signals that may need messaging
-Visibility dips
-4 posts hidden in the last 12 hours
-Mentor availability
-Coverage holding steady at 92%
-No activity recorded.
+ )} + {recentActivity.map((item) => ( +{item.title}
++ {formatShortNumber(item.answers)} answers · {formatTimeAgo(item.updated_at)} +
+