diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index bb8c74c..4bef7b2 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -5,9 +5,15 @@ from app.extensions import limiter from app.services.account_service import get_user_by_id, is_user_admin, set_user_role, update_user_plan from app.services.admin_service import ( get_admin_overview, + get_admin_ratings_detail, + get_admin_system_health, + get_admin_tool_analytics, + get_admin_user_registration_stats, + get_plan_interest_summary, list_admin_contacts, list_admin_users, mark_admin_contact_read, + record_plan_interest_click, ) from app.services.ai_cost_service import get_monthly_spend from app.utils.auth import get_current_user_id @@ -155,3 +161,89 @@ def ai_cost_dashboard(): spend = get_monthly_spend() return jsonify(spend), 200 + + +@admin_bp.route("/ratings", methods=["GET"]) +@limiter.limit("60/hour") +def admin_ratings_route(): + """Return detailed ratings and reviews for admin inspection.""" + auth_error = _require_admin_session() + if auth_error: + return auth_error + + try: + page = max(1, int(request.args.get("page", 1))) + except ValueError: + page = 1 + + try: + per_page = max(1, min(int(request.args.get("per_page", 20)), 100)) + except ValueError: + per_page = 20 + + tool_filter = request.args.get("tool", "").strip() + + return jsonify(get_admin_ratings_detail(page=page, per_page=per_page, tool_filter=tool_filter)), 200 + + +@admin_bp.route("/tool-analytics", methods=["GET"]) +@limiter.limit("60/hour") +def admin_tool_analytics_route(): + """Return detailed per-tool usage analytics.""" + auth_error = _require_admin_session() + if auth_error: + return auth_error + + return jsonify(get_admin_tool_analytics()), 200 + + +@admin_bp.route("/user-stats", methods=["GET"]) +@limiter.limit("60/hour") +def admin_user_stats_route(): + """Return user registration trends and breakdown.""" + auth_error = _require_admin_session() + if auth_error: + return auth_error + + return jsonify(get_admin_user_registration_stats()), 200 + + +@admin_bp.route("/plan-interest", methods=["GET"]) +@limiter.limit("60/hour") +def admin_plan_interest_route(): + """Return paid plan click interest summary.""" + auth_error = _require_admin_session() + if auth_error: + return auth_error + + return jsonify(get_plan_interest_summary()), 200 + + +@admin_bp.route("/system-health", methods=["GET"]) +@limiter.limit("60/hour") +def admin_system_health_route(): + """Return system health indicators.""" + auth_error = _require_admin_session() + if auth_error: + return auth_error + + return jsonify(get_admin_system_health()), 200 + + +@admin_bp.route("/plan-interest/record", methods=["POST"]) +@limiter.limit("30/hour") +def record_plan_interest_route(): + """Record a click on a paid plan button — public endpoint.""" + data = request.get_json(silent=True) or {} + plan = str(data.get("plan", "pro")).strip().lower() + billing = str(data.get("billing", "monthly")).strip().lower() + + if plan not in ("pro",): + plan = "pro" + if billing not in ("monthly", "yearly"): + billing = "monthly" + + user_id = get_current_user_id() + record_plan_interest_click(user_id=user_id, plan=plan, billing=billing) + + return jsonify({"message": "Interest recorded."}), 200 diff --git a/backend/app/services/admin_service.py b/backend/app/services/admin_service.py index b0a2548..a90b813 100644 --- a/backend/app/services/admin_service.py +++ b/backend/app/services/admin_service.py @@ -285,4 +285,382 @@ def list_admin_contacts(page: int = 1, per_page: int = 20) -> dict: def mark_admin_contact_read(message_id: int) -> bool: - return mark_read(message_id) \ No newline at end of file + return mark_read(message_id) + + +# --------------------------------------------------------------------------- +# Enhanced admin analytics +# --------------------------------------------------------------------------- + + +def _ensure_plan_interest_table(): + """Create plan_interest_clicks table if it does not exist.""" + with _connect() as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS plan_interest_clicks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + plan TEXT NOT NULL, + billing TEXT NOT NULL DEFAULT 'monthly', + created_at TEXT NOT NULL + ) + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_plan_interest_created ON plan_interest_clicks(created_at)" + ) + + +def record_plan_interest_click(user_id: int | None, plan: str, billing: str = "monthly") -> None: + """Record a click on a pricing plan button.""" + _ensure_plan_interest_table() + now = datetime.now(timezone.utc).isoformat() + with _connect() as conn: + conn.execute( + "INSERT INTO plan_interest_clicks (user_id, plan, billing, created_at) VALUES (?, ?, ?, ?)", + (user_id, plan, billing, now), + ) + + +def get_plan_interest_summary() -> dict: + """Return summary of paid plan button clicks.""" + _ensure_plan_interest_table() + cutoff_7d = (datetime.now(timezone.utc) - timedelta(days=7)).isoformat() + cutoff_30d = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat() + + with _connect() as conn: + total_row = conn.execute( + """ + SELECT + COUNT(*) AS total_clicks, + COUNT(DISTINCT user_id) AS unique_users, + COALESCE(SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END), 0) AS clicks_last_7d, + COALESCE(SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END), 0) AS clicks_last_30d + FROM plan_interest_clicks + """, + (cutoff_7d, cutoff_30d), + ).fetchone() + + by_plan_rows = conn.execute( + """ + SELECT plan, billing, COUNT(*) AS clicks + FROM plan_interest_clicks + GROUP BY plan, billing + ORDER BY clicks DESC + """ + ).fetchall() + + recent_rows = conn.execute( + """ + SELECT + plan_interest_clicks.id, + plan_interest_clicks.user_id, + plan_interest_clicks.plan, + plan_interest_clicks.billing, + plan_interest_clicks.created_at, + users.email + FROM plan_interest_clicks + LEFT JOIN users ON users.id = plan_interest_clicks.user_id + ORDER BY plan_interest_clicks.created_at DESC + LIMIT 20 + """ + ).fetchall() + + return { + "total_clicks": int(total_row["total_clicks"]) if total_row else 0, + "unique_users": int(total_row["unique_users"]) if total_row else 0, + "clicks_last_7d": int(total_row["clicks_last_7d"]) if total_row else 0, + "clicks_last_30d": int(total_row["clicks_last_30d"]) if total_row else 0, + "by_plan": [ + {"plan": row["plan"], "billing": row["billing"], "clicks": int(row["clicks"])} + for row in by_plan_rows + ], + "recent": [ + { + "id": row["id"], + "user_id": row["user_id"], + "email": row["email"], + "plan": row["plan"], + "billing": row["billing"], + "created_at": row["created_at"], + } + for row in recent_rows + ], + } + + +def get_admin_ratings_detail(page: int = 1, per_page: int = 20, tool_filter: str = "") -> dict: + """Return detailed ratings list with feedback for the admin dashboard.""" + safe_page = max(1, page) + safe_per_page = max(1, min(per_page, 100)) + offset = (safe_page - 1) * safe_per_page + + with _connect() as conn: + # Total count + count_sql = "SELECT COUNT(*) AS total FROM tool_ratings" + count_params: list[object] = [] + if tool_filter: + count_sql += " WHERE tool = ?" + count_params.append(tool_filter) + + total_row = conn.execute(count_sql, tuple(count_params)).fetchone() + + # Paginated ratings + sql = """ + SELECT id, tool, rating, feedback, tag, fingerprint, created_at + FROM tool_ratings + """ + params: list[object] = [] + if tool_filter: + sql += " WHERE tool = ?" + params.append(tool_filter) + sql += " ORDER BY created_at DESC LIMIT ? OFFSET ?" + params.extend([safe_per_page, offset]) + + rows = conn.execute(sql, tuple(params)).fetchall() + + # Per-tool summary + summary_rows = conn.execute( + """ + SELECT + tool, + COUNT(*) AS count, + COALESCE(AVG(rating), 0) AS average, + COALESCE(SUM(CASE WHEN rating >= 4 THEN 1 ELSE 0 END), 0) AS positive, + COALESCE(SUM(CASE WHEN rating <= 2 THEN 1 ELSE 0 END), 0) AS negative + FROM tool_ratings + GROUP BY tool + ORDER BY count DESC + """ + ).fetchall() + + return { + "items": [ + { + "id": row["id"], + "tool": row["tool"], + "rating": int(row["rating"]), + "feedback": row["feedback"] or "", + "tag": row["tag"] or "", + "created_at": row["created_at"], + } + for row in rows + ], + "page": safe_page, + "per_page": safe_per_page, + "total": int(total_row["total"]) if total_row else 0, + "tool_summaries": [ + { + "tool": row["tool"], + "count": int(row["count"]), + "average": round(row["average"], 1), + "positive": int(row["positive"]), + "negative": int(row["negative"]), + } + for row in summary_rows + ], + } + + +def get_admin_tool_analytics() -> dict: + """Return detailed per-tool usage analytics for the admin dashboard.""" + cutoff_24h = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat() + cutoff_7d = (datetime.now(timezone.utc) - timedelta(days=7)).isoformat() + cutoff_30d = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat() + + with _connect() as conn: + # Per-tool detailed stats + tool_rows = conn.execute( + """ + SELECT + tool, + COUNT(*) AS total_runs, + COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0) AS completed, + COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0) AS failed, + COALESCE(SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END), 0) AS runs_24h, + COALESCE(SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END), 0) AS runs_7d, + COALESCE(SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END), 0) AS runs_30d, + COUNT(DISTINCT user_id) AS unique_users + FROM file_history + GROUP BY tool + ORDER BY total_runs DESC + """, + (cutoff_24h, cutoff_7d, cutoff_30d), + ).fetchall() + + # Daily usage for the last 30 days + daily_rows = conn.execute( + """ + SELECT + DATE(created_at) AS day, + COUNT(*) AS total, + COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0) AS completed, + COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0) AS failed + FROM file_history + WHERE created_at >= ? + GROUP BY DATE(created_at) + ORDER BY day ASC + """, + (cutoff_30d,), + ).fetchall() + + # Most common errors + error_rows = conn.execute( + """ + SELECT + tool, + metadata_json, + COUNT(*) AS occurrences + FROM file_history + WHERE status = 'failed' AND created_at >= ? + GROUP BY tool, metadata_json + ORDER BY occurrences DESC + LIMIT 15 + """, + (cutoff_30d,), + ).fetchall() + + return { + "tools": [ + { + "tool": row["tool"], + "total_runs": int(row["total_runs"]), + "completed": int(row["completed"]), + "failed": int(row["failed"]), + "success_rate": round((int(row["completed"]) / int(row["total_runs"])) * 100, 1) if int(row["total_runs"]) > 0 else 0, + "runs_24h": int(row["runs_24h"]), + "runs_7d": int(row["runs_7d"]), + "runs_30d": int(row["runs_30d"]), + "unique_users": int(row["unique_users"]), + } + for row in tool_rows + ], + "daily_usage": [ + { + "day": row["day"], + "total": int(row["total"]), + "completed": int(row["completed"]), + "failed": int(row["failed"]), + } + for row in daily_rows + ], + "common_errors": [ + { + "tool": row["tool"], + "error": _parse_metadata(row["metadata_json"]).get("error", "Unknown error"), + "occurrences": int(row["occurrences"]), + } + for row in error_rows + ], + } + + +def get_admin_user_registration_stats() -> dict: + """Return user registration trends and breakdown.""" + cutoff_7d = (datetime.now(timezone.utc) - timedelta(days=7)).isoformat() + cutoff_30d = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat() + + with _connect() as conn: + totals_row = conn.execute( + """ + SELECT + COUNT(*) AS total, + COALESCE(SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END), 0) AS last_7d, + COALESCE(SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END), 0) AS last_30d, + COALESCE(SUM(CASE WHEN plan = 'pro' THEN 1 ELSE 0 END), 0) AS pro_count, + COALESCE(SUM(CASE WHEN plan = 'free' THEN 1 ELSE 0 END), 0) AS free_count + FROM users + """, + (cutoff_7d, cutoff_30d), + ).fetchone() + + # Daily registrations for the last 30 days + daily_rows = conn.execute( + """ + SELECT DATE(created_at) AS day, COUNT(*) AS registrations + FROM users + WHERE created_at >= ? + GROUP BY DATE(created_at) + ORDER BY day ASC + """, + (cutoff_30d,), + ).fetchall() + + # Most active users (by task count) + active_rows = conn.execute( + """ + SELECT + users.id, + users.email, + users.plan, + users.created_at, + COUNT(file_history.id) AS total_tasks + FROM users + JOIN file_history ON file_history.user_id = users.id + GROUP BY users.id + ORDER BY total_tasks DESC + LIMIT 10 + """ + ).fetchall() + + return { + "total_users": int(totals_row["total"]) if totals_row else 0, + "new_last_7d": int(totals_row["last_7d"]) if totals_row else 0, + "new_last_30d": int(totals_row["last_30d"]) if totals_row else 0, + "pro_users": int(totals_row["pro_count"]) if totals_row else 0, + "free_users": int(totals_row["free_count"]) if totals_row else 0, + "daily_registrations": [ + {"day": row["day"], "count": int(row["registrations"])} + for row in daily_rows + ], + "most_active_users": [ + { + "id": row["id"], + "email": row["email"], + "plan": row["plan"], + "created_at": row["created_at"], + "total_tasks": int(row["total_tasks"]), + } + for row in active_rows + ], + } + + +def get_admin_system_health() -> dict: + """Return system health indicators for the admin dashboard.""" + from app.services.openrouter_config_service import get_openrouter_settings + + ai_cost_summary = get_monthly_spend() + settings = get_openrouter_settings() + + with _connect() as conn: + # Recent error rate (last 1h) + cutoff_1h = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat() + error_row = conn.execute( + """ + SELECT + COUNT(*) AS total, + COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0) AS failed + FROM file_history + WHERE created_at >= ? + """, + (cutoff_1h,), + ).fetchone() + + # DB size + db_path = current_app.config["DATABASE_PATH"] + db_size_mb = round(os.path.getsize(db_path) / (1024 * 1024), 2) if os.path.exists(db_path) else 0 + + error_total = int(error_row["total"]) if error_row else 0 + error_failed = int(error_row["failed"]) if error_row else 0 + + return { + "ai_configured": bool(settings.api_key), + "ai_model": settings.model, + "ai_budget_used_percent": ai_cost_summary["budget_used_percent"], + "error_rate_1h": round((error_failed / error_total) * 100, 1) if error_total > 0 else 0, + "tasks_last_1h": error_total, + "failures_last_1h": error_failed, + "database_size_mb": db_size_mb, + } \ No newline at end of file diff --git a/backend/app/services/pdf_ai_service.py b/backend/app/services/pdf_ai_service.py index 7bcaecb..5e05b74 100644 --- a/backend/app/services/pdf_ai_service.py +++ b/backend/app/services/pdf_ai_service.py @@ -55,8 +55,9 @@ def _call_openrouter( settings = get_openrouter_settings() if not settings.api_key: + logger.error("OPENROUTER_API_KEY is not set or is a placeholder value.") raise PdfAiError( - "AI service is not configured. Set OPENROUTER_API_KEY in the application configuration." + "AI features are temporarily unavailable. Our team has been notified." ) messages = [ @@ -79,9 +80,40 @@ def _call_openrouter( }, timeout=60, ) + + if response.status_code == 401: + logger.error("OpenRouter API key is invalid or expired (401).") + raise PdfAiError( + "AI features are temporarily unavailable due to a configuration issue. Our team has been notified." + ) + + if response.status_code == 402: + logger.error("OpenRouter account has insufficient credits (402).") + raise PdfAiError( + "AI processing credits have been exhausted. Please try again later." + ) + + if response.status_code == 429: + logger.warning("OpenRouter rate limit reached (429).") + raise PdfAiError( + "AI service is experiencing high demand. Please wait a moment and try again." + ) + + if response.status_code >= 500: + logger.error("OpenRouter server error (%s).", response.status_code) + raise PdfAiError( + "AI service provider is experiencing issues. Please try again shortly." + ) + response.raise_for_status() data = response.json() + # Handle model-level errors returned inside a 200 response + if data.get("error"): + error_msg = data["error"].get("message", "") if isinstance(data["error"], dict) else str(data["error"]) + logger.error("OpenRouter returned an error payload: %s", error_msg) + raise PdfAiError("AI service encountered an issue. Please try again.") + reply = ( data.get("choices", [{}])[0] .get("message", {}) @@ -107,10 +139,15 @@ def _call_openrouter( return reply + except PdfAiError: + raise except requests.exceptions.Timeout: raise PdfAiError("AI service timed out. Please try again.") + except requests.exceptions.ConnectionError: + logger.error("Cannot connect to OpenRouter API at %s", settings.base_url) + raise PdfAiError("AI service is unreachable. Please try again shortly.") except requests.exceptions.RequestException as e: - logger.error(f"OpenRouter API error: {e}") + logger.error("OpenRouter API error: %s", e) raise PdfAiError("AI service is temporarily unavailable.") diff --git a/backend/app/services/site_assistant_service.py b/backend/app/services/site_assistant_service.py index 71a66f4..c80e9c1 100644 --- a/backend/app/services/site_assistant_service.py +++ b/backend/app/services/site_assistant_service.py @@ -246,7 +246,8 @@ def stream_site_assistant_chat( check_ai_budget() settings = get_openrouter_settings() if not settings.api_key: - raise RuntimeError("OPENROUTER_API_KEY is not configured for the application.") + logger.error("OPENROUTER_API_KEY is not set — assistant AI unavailable.") + raise RuntimeError("AI assistant is temporarily unavailable. Please try again later.") response_model = settings.model messages = _build_ai_messages( @@ -436,7 +437,8 @@ def _request_ai_reply( settings = get_openrouter_settings() if not settings.api_key: - raise RuntimeError("OPENROUTER_API_KEY is not configured for the application.") + logger.error("OPENROUTER_API_KEY is not set — assistant AI unavailable.") + raise RuntimeError("AI assistant is temporarily unavailable. Please try again later.") messages = _build_ai_messages( message=message, diff --git a/backend/celerybeat-schedule b/backend/celerybeat-schedule index d090a9f..4f922dd 100644 Binary files a/backend/celerybeat-schedule and b/backend/celerybeat-schedule differ diff --git a/backend/data/dociva.db b/backend/data/dociva.db index df51379..cadc5ab 100644 Binary files a/backend/data/dociva.db and b/backend/data/dociva.db differ diff --git a/frontend/src/pages/InternalAdminPage.tsx b/frontend/src/pages/InternalAdminPage.tsx index 4052f07..415d6de 100644 --- a/frontend/src/pages/InternalAdminPage.tsx +++ b/frontend/src/pages/InternalAdminPage.tsx @@ -2,29 +2,331 @@ import { useEffect, useMemo, useState, type FormEvent } from 'react'; import { Helmet } from 'react-helmet-async'; import { Link } from 'react-router-dom'; import { + Activity, AlertTriangle, BarChart3, + Clock, + Database, + DollarSign, + Globe, + Heart, Inbox, LogOut, + MessageSquare, RefreshCcw, Search, ShieldCheck, + Star, + TrendingUp, Users, Zap, } from 'lucide-react'; import { + getAdminPlanInterest, + getAdminRatingsDetail, + getAdminSystemHealth, + getAdminToolAnalytics, + getAdminUserStats, getInternalAdminContacts, getInternalAdminOverview, listInternalAdminUsers, markInternalAdminContactRead, updateInternalAdminUserRole, updateInternalAdminUserPlan, + type AdminPlanInterest, + type AdminRatingsDetail, + type AdminSystemHealth, + type AdminToolAnalytics, + type AdminUserStats, type InternalAdminContact, type InternalAdminOverview, type InternalAdminUser, } from '@/services/api'; import { useAuthStore } from '@/stores/authStore'; +type AdminTab = 'overview' | 'users' | 'tools' | 'ratings' | 'contacts' | 'system'; +type Lang = 'ar' | 'en'; + +const TRANSLATIONS: Record> = { + en: { + // Page & header + pageTitle: 'Internal Admin | Dociva', + internalOps: 'Internal operations', + controlRoom: 'Admin control room', + controlRoomDesc: 'Monitor project health, user activity, tool performance, ratings, and system status.', + roleLabel: 'Role:', + refresh: 'Refresh', + signOut: 'Sign out', + // Auth + checkingSession: 'Checking admin session...', + adminSignIn: 'Admin sign in', + adminSignInDesc: 'Use an allowlisted internal account to access the admin control room.', + passwordPlaceholder: 'Password', + signInBtn: 'Sign in as admin', + noAdminAccessMsg: 'This account does not have internal admin access.', + noAdminPermission: 'No admin permission', + notAdminDesc: 'You are signed in as {email}, but this account does not have admin access.', + backToAccount: 'Back to account', + // Tabs + tabOverview: 'Overview', + tabUsers: 'Users', + tabTools: 'Tool Analytics', + tabRatings: 'Ratings & Reviews', + tabContacts: 'Inbox', + tabSystem: 'System Health', + // Overview cards + totalUsers: 'Total users', + filesProcessed: 'Files processed', + inLast24h: 'in the last 24h', + proFreeCaption: '{pro} pro / {free} free', + successRate: 'Success rate', + failuresTracked: 'failures tracked', + unreadContacts: 'Unread contacts', + totalInboxItems: 'total inbox items', + aiSpend: 'AI spend', + ofBudget: '{pct}% of {budget} budget', + averageRating: 'Average rating', + ratingsCollected: 'ratings collected', + upgradeClicks: 'Upgrade clicks', + last7dSuffix: '{clicks} last 7 days / {unique} unique users', + aiStatus: 'AI status', + active: 'Active', + notConfigured: 'Not configured', + modelLabel: 'Model: {model}', + checkKey: 'Check OPENROUTER_API_KEY', + // Overview sections + topTools: 'Top tools', + totalRunsSuffix: 'total runs', + failedBadge: 'failed', + recentFailures: 'Recent failures', + unknownFile: 'Unknown file', + processingFailed: 'Processing failed without a structured error message.', + noToolActivity: 'No tool activity yet.', + noRecentFailures: 'No recent failures.', + latestRegistrations: 'Latest registrations', + emailCol: 'Email', + planCol: 'Plan', + tasksCol: 'Tasks', + joinedCol: 'Joined', + // Users tab + newLast7d: 'New (7 days)', + newLast30d: 'New (30 days)', + proUsers: 'Pro users', + dailyReg30d: 'Daily registrations (30 days)', + userManagement: 'User management', + searchEmailPlaceholder: 'Search user email', + searchBtn: 'Search', + roleCol: 'Role', + apiKeysCol: 'API keys', + actionsCol: 'Actions', + allowlisted: 'Allowlisted', + tasksSummary: '{ok} ok / {fail} fail', + createdLabel: 'Created {date}', + mostActiveUsers: 'Most active users', + totalTasksCol: 'Total tasks', + loadingText: 'Loading...', + // Tools tab + toolUsageBreakdown: 'Tool usage breakdown', + toolUsageDesc: 'Detailed usage, success rates, and user counts per tool.', + toolCol: 'Tool', + totalCol: 'Total', + successCol: 'Success', + rateCol: 'Rate', + usersCol: 'Users', + dailyProcessing30d: 'Daily processing (30 days)', + completedLabel: 'Completed', + failedLabel: 'Failed', + mostCommonErrors30d: 'Most common errors (30 days)', + // Ratings tab + ratingSummaryByTool: 'Rating summary by tool', + ratingsCountSuffix: 'ratings', + positiveLabel: 'positive', + negativeLabel: 'negative', + allReviews: 'All reviews', + reviewsFilterLabel: 'All reviews — {tool}', + totalRatingsLabel: '{n} total ratings', + clearFilter: 'Clear filter', + previous: 'Previous', + next: 'Next', + pageOf: 'Page {p} of {total}', + noRatingsFound: 'No ratings found.', + // Contacts tab + contactInbox: 'Contact inbox', + unreadOfTotal: '{unread} unread of {total} total messages.', + noSubject: 'No subject', + markAsRead: 'Mark as read', + readLabel: 'Read', + noContactMessages: 'No contact messages found.', + // System tab + aiServiceLabel: 'AI service', + offlineLabel: 'Offline', + errorRate1h: 'Error rate (1h)', + failedOfTotal: '{fail} failed / {total} total', + aiBudgetUsed: 'AI budget used', + databaseSize: 'Database size', + paidPlanInterest: 'Paid plan interest', + paidPlanInterestDesc: 'Users who clicked upgrade buttons on the pricing page.', + totalClicksLabel: 'Total clicks', + uniqueUsersLabel: 'Unique users', + last7DaysLabel: 'Last 7 days', + last30DaysLabel: 'Last 30 days', + byPlanBilling: 'By plan & billing', + recentInterest: 'Recent interest', + userCol: 'User', + billingCol: 'Billing', + dateCol: 'Date', + anonymousLabel: 'Anonymous', + clicksSuffix: 'clicks', + // Buttons + btnFree: 'Free', + btnPro: 'Pro', + btnUser: 'User', + btnAdmin: 'Admin', + }, + ar: { + // Page & header + pageTitle: 'لوحة الإدارة | Dociva', + internalOps: 'العمليات الداخلية', + controlRoom: 'غرفة التحكم', + controlRoomDesc: 'مراقبة صحة المشروع ونشاط المستخدمين وأداء الأدوات والتقييمات وحالة النظام.', + roleLabel: 'الدور:', + refresh: 'تحديث', + signOut: 'تسجيل الخروج', + // Auth + checkingSession: 'جارٍ التحقق من جلسة المشرف...', + adminSignIn: 'دخول المشرف', + adminSignInDesc: 'استخدم حسابًا داخليًا مصرّحًا للوصول إلى لوحة التحكم.', + passwordPlaceholder: 'كلمة المرور', + signInBtn: 'دخول كمشرف', + noAdminAccessMsg: 'هذا الحساب لا يملك صلاحيات المشرف.', + noAdminPermission: 'لا توجد صلاحية مشرف', + notAdminDesc: 'أنت مسجّل الدخول بـ {email}، لكن هذا الحساب لا يملك صلاحيات الإدارة.', + backToAccount: 'العودة للحساب', + // Tabs + tabOverview: 'نظرة عامة', + tabUsers: 'المستخدمون', + tabTools: 'تحليلات الأدوات', + tabRatings: 'التقييمات والمراجعات', + tabContacts: 'صندوق الوارد', + tabSystem: 'صحة النظام', + // Overview cards + totalUsers: 'إجمالي المستخدمين', + filesProcessed: 'الملفات المعالجة', + inLast24h: 'في آخر 24 ساعة', + proFreeCaption: '{pro} pro / {free} مجاني', + successRate: 'معدل النجاح', + failuresTracked: 'أخطاء مرصودة', + unreadContacts: 'رسائل غير مقروءة', + totalInboxItems: 'إجمالي الرسائل', + aiSpend: 'إنفاق الذكاء الاصطناعي', + ofBudget: '{pct}% من ميزانية {budget}', + averageRating: 'متوسط التقييم', + ratingsCollected: 'تقييم مجمّع', + upgradeClicks: 'نقرات الترقية', + last7dSuffix: '{clicks} في آخر 7 أيام / {unique} مستخدم فريد', + aiStatus: 'حالة الذكاء الاصطناعي', + active: 'نشط', + notConfigured: 'غير مهيأ', + modelLabel: 'النموذج: {model}', + checkKey: 'تحقق من OPENROUTER_API_KEY', + // Overview sections + topTools: 'أكثر الأدوات استخدامًا', + totalRunsSuffix: 'تشغيلة', + failedBadge: 'فشل', + recentFailures: 'الأخطاء الأخيرة', + unknownFile: 'ملف غير معروف', + processingFailed: 'فشلت المعالجة دون رسالة خطأ محددة.', + noToolActivity: 'لا يوجد نشاط للأدوات بعد.', + noRecentFailures: 'لا توجد أخطاء حديثة.', + latestRegistrations: 'أحدث التسجيلات', + emailCol: 'البريد الإلكتروني', + planCol: 'الخطة', + tasksCol: 'المهام', + joinedCol: 'تاريخ الانضمام', + // Users tab + newLast7d: 'جدد (7 أيام)', + newLast30d: 'جدد (30 يومًا)', + proUsers: 'مستخدمو Pro', + dailyReg30d: 'التسجيلات اليومية (30 يومًا)', + userManagement: 'إدارة المستخدمين', + searchEmailPlaceholder: 'بحث بالبريد الإلكتروني', + searchBtn: 'بحث', + roleCol: 'الدور', + apiKeysCol: 'مفاتيح API', + actionsCol: 'الإجراءات', + allowlisted: 'قائمة مسموحة', + tasksSummary: '{ok} ناجح / {fail} فاشل', + createdLabel: 'أُنشئ {date}', + mostActiveUsers: 'الأكثر نشاطًا', + totalTasksCol: 'إجمالي المهام', + loadingText: 'جارٍ التحميل...', + // Tools tab + toolUsageBreakdown: 'تفصيل استخدام الأدوات', + toolUsageDesc: 'تفاصيل الاستخدام ومعدلات النجاح وعدد المستخدمين لكل أداة.', + toolCol: 'الأداة', + totalCol: 'الإجمالي', + successCol: 'نجاح', + rateCol: 'النسبة', + usersCol: 'المستخدمون', + dailyProcessing30d: 'المعالجة اليومية (30 يومًا)', + completedLabel: 'مكتمل', + failedLabel: 'فاشل', + mostCommonErrors30d: 'أكثر الأخطاء شيوعًا (30 يومًا)', + // Ratings tab + ratingSummaryByTool: 'ملخص التقييمات حسب الأداة', + ratingsCountSuffix: 'تقييم', + positiveLabel: 'إيجابي', + negativeLabel: 'سلبي', + allReviews: 'جميع المراجعات', + reviewsFilterLabel: 'جميع المراجعات — {tool}', + totalRatingsLabel: '{n} تقييم إجمالي', + clearFilter: 'حذف الفلتر', + previous: 'السابق', + next: 'التالي', + pageOf: 'صفحة {p} من {total}', + noRatingsFound: 'لم يتم العثور على تقييمات.', + // Contacts tab + contactInbox: 'صندوق رسائل التواصل', + unreadOfTotal: '{unread} غير مقروء من أصل {total} رسالة.', + noSubject: 'بدون موضوع', + markAsRead: 'تعيين كمقروء', + readLabel: 'مقروء', + noContactMessages: 'لا توجد رسائل تواصل.', + // System tab + aiServiceLabel: 'خدمة الذكاء الاصطناعي', + offlineLabel: 'غير متاح', + errorRate1h: 'معدل الخطأ (ساعة)', + failedOfTotal: '{fail} فاشل / {total} إجمالي', + aiBudgetUsed: 'الميزانية المستهلكة', + databaseSize: 'حجم قاعدة البيانات', + paidPlanInterest: 'الاهتمام بالخطط المدفوعة', + paidPlanInterestDesc: 'المستخدمون الذين نقروا على زر الترقية في صفحة الأسعار.', + totalClicksLabel: 'إجمالي النقرات', + uniqueUsersLabel: 'مستخدمون فريدون', + last7DaysLabel: 'آخر 7 أيام', + last30DaysLabel: 'آخر 30 يومًا', + byPlanBilling: 'حسب الخطة والدورة', + recentInterest: 'الاهتمام الأخير', + userCol: 'المستخدم', + billingCol: 'الدورة', + dateCol: 'التاريخ', + anonymousLabel: 'مجهول', + clicksSuffix: 'نقرة', + // Buttons + btnFree: 'مجاني', + btnPro: 'Pro', + btnUser: 'مستخدم', + btnAdmin: 'مشرف', + }, +}; + +function tr(template: string, vars: Record = {}): string { + return Object.entries(vars).reduce( + (s, [k, v]) => s.replace(`{${k}}`, String(v)), + template, + ); +} + function formatMoney(value: number) { return new Intl.NumberFormat('en-US', { style: 'currency', @@ -33,6 +335,19 @@ function formatMoney(value: number) { }).format(value); } +function StarDisplay({ rating }: { rating: number }) { + return ( + + {[1, 2, 3, 4, 5].map((s) => ( + + ))} + + ); +} + export default function InternalAdminPage() { const user = useAuthStore((state) => state.user); const initialized = useAuthStore((state) => state.initialized); @@ -42,11 +357,43 @@ export default function InternalAdminPage() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); + const [activeTab, setActiveTab] = useState('overview'); + + // Overview state const [overview, setOverview] = useState(null); + const [systemHealth, setSystemHealth] = useState(null); + + // Users state const [users, setUsers] = useState([]); - const [contacts, setContacts] = useState([]); - const [contactMeta, setContactMeta] = useState({ total: 0, unread: 0, page: 1, perPage: 12 }); const [userQuery, setUserQuery] = useState(''); + const [userStats, setUserStats] = useState(null); + + // Tools state + const [toolAnalytics, setToolAnalytics] = useState(null); + + // Ratings state + const [ratingsDetail, setRatingsDetail] = useState(null); + const [ratingsPage, setRatingsPage] = useState(1); + const [ratingsToolFilter, setRatingsToolFilter] = useState(''); + + // Contacts state + const [contacts, setContacts] = useState([]); + const [contactMeta, setContactMeta] = useState({ total: 0, unread: 0, page: 1, perPage: 20 }); + + // Plan interest state + const [planInterest, setPlanInterest] = useState(null); + + // Language + const [lang, setLang] = useState(() => (localStorage.getItem('admin-lang') as Lang) ?? 'en'); + const isRtl = lang === 'ar'; + function t(key: string): string { return TRANSLATIONS[lang][key] ?? key; } + function toggleLang() { + const next: Lang = lang === 'en' ? 'ar' : 'en'; + setLang(next); + localStorage.setItem('admin-lang', next); + } + + // UI state const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [loginError, setLoginError] = useState(null); @@ -56,93 +403,79 @@ export default function InternalAdminPage() { const isAdmin = user?.role === 'admin'; - const metricCards = useMemo(() => { - if (!overview) { - return []; - } - - return [ - { - key: 'users', - title: 'Total users', - value: overview.users.total.toLocaleString(), - caption: `${overview.users.pro} pro / ${overview.users.free} free`, - icon: Users, - }, - { - key: 'processing', - title: 'Files processed', - value: overview.processing.total_files_processed.toLocaleString(), - caption: `${overview.processing.files_last_24h} in the last 24h`, - icon: BarChart3, - }, - { - key: 'success', - title: 'Success rate', - value: `${overview.processing.success_rate}%`, - caption: `${overview.processing.failed_files} failures tracked`, - icon: ShieldCheck, - }, - { - key: 'contacts', - title: 'Unread contacts', - value: overview.contacts.unread_messages.toLocaleString(), - caption: `${overview.contacts.total_messages} total inbox items`, - icon: Inbox, - }, - { - key: 'ai-cost', - title: 'AI spend', - value: formatMoney(overview.ai_cost.total_usd), - caption: `${overview.ai_cost.percent_used}% of ${formatMoney(overview.ai_cost.budget_usd)} budget`, - icon: Zap, - }, - { - key: 'ratings', - title: 'Average rating', - value: overview.ratings.average_rating.toFixed(1), - caption: `${overview.ratings.rating_count} ratings collected`, - icon: RefreshCcw, - }, - ]; - }, [overview]); + const tabs: { key: AdminTab; label: string; icon: typeof BarChart3 }[] = [ + { key: 'overview', label: t('tabOverview'), icon: BarChart3 }, + { key: 'users', label: t('tabUsers'), icon: Users }, + { key: 'tools', label: t('tabTools'), icon: Activity }, + { key: 'ratings', label: t('tabRatings'), icon: Star }, + { key: 'contacts', label: t('tabContacts'), icon: Inbox }, + { key: 'system', label: t('tabSystem'), icon: ShieldCheck }, + ]; useEffect(() => { - if (!isAdmin) { - setOverview(null); - setUsers([]); - setContacts([]); - return; - } + if (!isAdmin) return; + void loadTab(activeTab); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAdmin, activeTab]); - void loadDashboard(userQuery); - }, [isAdmin]); - - async function loadDashboard(query = '') { + async function loadTab(tab: AdminTab) { setLoading(true); setError(null); - try { - const [overviewData, usersData, contactsData] = await Promise.all([ - getInternalAdminOverview(), - listInternalAdminUsers(query), - getInternalAdminContacts(1, 12), - ]); - - setOverview(overviewData); - setUsers(usersData); - setContacts(contactsData.items); - setContactMeta({ - total: contactsData.total, - unread: contactsData.unread, - page: contactsData.page, - perPage: contactsData.per_page, - }); - } catch (loadError) { - setError(loadError instanceof Error ? loadError.message : 'Unable to load internal admin dashboard.'); - setOverview(null); - setUsers([]); - setContacts([]); + switch (tab) { + case 'overview': { + const [ov, health, pi] = await Promise.all([ + getInternalAdminOverview(), + getAdminSystemHealth(), + getAdminPlanInterest(), + ]); + setOverview(ov); + setSystemHealth(health); + setPlanInterest(pi); + break; + } + case 'users': { + const [usersData, stats] = await Promise.all([ + listInternalAdminUsers(userQuery), + getAdminUserStats(), + ]); + setUsers(usersData); + setUserStats(stats); + break; + } + case 'tools': { + const analytics = await getAdminToolAnalytics(); + setToolAnalytics(analytics); + break; + } + case 'ratings': { + const ratings = await getAdminRatingsDetail(ratingsPage, 20, ratingsToolFilter); + setRatingsDetail(ratings); + break; + } + case 'contacts': { + const contactsData = await getInternalAdminContacts(contactMeta.page, contactMeta.perPage); + setContacts(contactsData.items); + setContactMeta({ + total: contactsData.total, + unread: contactsData.unread, + page: contactsData.page, + perPage: contactsData.per_page, + }); + break; + } + case 'system': { + const [health, pi] = await Promise.all([ + getAdminSystemHealth(), + getAdminPlanInterest(), + ]); + setSystemHealth(health); + setPlanInterest(pi); + break; + } + } + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load dashboard data.'); } finally { setLoading(false); } @@ -152,118 +485,956 @@ export default function InternalAdminPage() { event.preventDefault(); setLoginError(null); setError(null); - try { const authenticatedUser = await login(email, password); if (authenticatedUser.role !== 'admin') { setLoginError('This account does not have internal admin access.'); } setPassword(''); - } catch (loginAttemptError) { - setLoginError(loginAttemptError instanceof Error ? loginAttemptError.message : 'Unable to sign in.'); + } catch (e) { + setLoginError(e instanceof Error ? e.message : 'Unable to sign in.'); } } - async function handleRefresh() { - if (!isAdmin) { - return; - } - await loadDashboard(userQuery); - } - - async function handleSearch(event: FormEvent) { - event.preventDefault(); - if (!isAdmin) { - return; - } - await loadDashboard(userQuery); - } - async function handlePlanChange(userId: number, plan: 'free' | 'pro') { - if (!isAdmin) { - return; - } - + if (!isAdmin) return; setUpdatingUserId(userId); setError(null); try { await updateInternalAdminUserPlan(userId, plan); - await loadDashboard(userQuery); - } catch (updateError) { - setError(updateError instanceof Error ? updateError.message : 'Unable to update plan.'); + await loadTab('users'); + } catch (e) { + setError(e instanceof Error ? e.message : 'Unable to update plan.'); } finally { setUpdatingUserId(null); } } - async function handleMarkRead(messageId: number) { - if (!isAdmin) { - return; - } - - setMarkingMessageId(messageId); - setError(null); - try { - await markInternalAdminContactRead(messageId); - await loadDashboard(userQuery); - } catch (markError) { - setError(markError instanceof Error ? markError.message : 'Unable to update contact message.'); - } finally { - setMarkingMessageId(null); - } - } - async function handleRoleChange(userId: number, role: 'user' | 'admin') { - if (!isAdmin) { - return; - } - + if (!isAdmin) return; setUpdatingRoleUserId(userId); setError(null); try { await updateInternalAdminUserRole(userId, role); - await loadDashboard(userQuery); - } catch (updateError) { - setError(updateError instanceof Error ? updateError.message : 'Unable to update role.'); + await loadTab('users'); + } catch (e) { + setError(e instanceof Error ? e.message : 'Unable to update role.'); } finally { setUpdatingRoleUserId(null); } } + async function handleMarkRead(messageId: number) { + if (!isAdmin) return; + setMarkingMessageId(messageId); + setError(null); + try { + await markInternalAdminContactRead(messageId); + await loadTab('contacts'); + } catch (e) { + setError(e instanceof Error ? e.message : 'Unable to update contact message.'); + } finally { + setMarkingMessageId(null); + } + } + async function handleLogout() { setError(null); setLoginError(null); await logout(); } + // ====================== RENDER HELPERS ====================== + + const overviewCards = useMemo(() => { + if (!overview) return []; + return [ + { + key: 'users', + title: t('totalUsers'), + value: overview.users.total.toLocaleString(), + caption: tr(t('proFreeCaption'), { pro: overview.users.pro, free: overview.users.free }), + icon: Users, + }, + { + key: 'processing', + title: t('filesProcessed'), + value: overview.processing.total_files_processed.toLocaleString(), + caption: `${overview.processing.files_last_24h} ${t('inLast24h')}`, + icon: BarChart3, + }, + { + key: 'success', + title: t('successRate'), + value: `${overview.processing.success_rate}%`, + caption: `${overview.processing.failed_files} ${t('failuresTracked')}`, + icon: ShieldCheck, + }, + { + key: 'contacts', + title: t('unreadContacts'), + value: overview.contacts.unread_messages.toLocaleString(), + caption: `${overview.contacts.total_messages} ${t('totalInboxItems')}`, + icon: Inbox, + }, + { + key: 'ai-cost', + title: t('aiSpend'), + value: formatMoney(overview.ai_cost.total_usd), + caption: tr(t('ofBudget'), { pct: overview.ai_cost.percent_used, budget: formatMoney(overview.ai_cost.budget_usd) }), + icon: Zap, + }, + { + key: 'ratings', + title: t('averageRating'), + value: overview.ratings.average_rating.toFixed(1), + caption: `${overview.ratings.rating_count} ${t('ratingsCollected')}`, + icon: Star, + }, + ...(planInterest + ? [ + { + key: 'plan-interest', + title: t('upgradeClicks'), + value: planInterest.total_clicks.toLocaleString(), + caption: tr(t('last7dSuffix'), { clicks: planInterest.clicks_last_7d, unique: planInterest.unique_users }), + icon: DollarSign, + }, + ] + : []), + ...(systemHealth + ? [ + { + key: 'ai-status', + title: t('aiStatus'), + value: systemHealth.ai_configured ? t('active') : t('notConfigured'), + caption: systemHealth.ai_configured ? tr(t('modelLabel'), { model: systemHealth.ai_model }) : t('checkKey'), + icon: Activity, + }, + ] + : []), + ]; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [overview, planInterest, systemHealth, lang]); + + function renderOverviewTab() { + return ( + <> + {/* Metric cards */} +
+ {overviewCards.map((card) => { + const Icon = card.icon; + return ( +
+
+
+

{card.title}

+

{card.value}

+

{card.caption}

+
+
+ +
+
+
+ ); + })} +
+ + {/* Top tools + recent failures */} +
+
+

{t('topTools')}

+
+ {overview?.top_tools.length ? ( + overview.top_tools.map((tool) => ( +
+
+
+

{tool.tool}

+

{tool.total_runs} {t('totalRunsSuffix')}

+
+ + {tool.failed_runs} {t('failedBadge')} + +
+
+ )) + ) : ( +

{t('noToolActivity')}

+ )} +
+
+ +
+

{t('recentFailures')}

+
+ {overview?.recent_failures.length ? ( + overview.recent_failures.map((failure) => ( +
+
+
+

{failure.tool}

+

+ {failure.original_filename || t('unknownFile')} + {failure.email ? ` / ${failure.email}` : ''} +

+
+ {failure.created_at} +
+

+ {typeof failure.metadata.error === 'string' + ? failure.metadata.error + : t('processingFailed')} +

+
+ )) + ) : ( +

{t('noRecentFailures')}

+ )} +
+
+
+ + {/* Recent users */} + {overview?.recent_users.length ? ( +
+

{t('latestRegistrations')}

+
+ + + + + + + + + + + {overview.recent_users.map((u) => ( + + + + + + + ))} + +
{t('emailCol')}{t('planCol')}{t('tasksCol')}{t('joinedCol')}
{u.email}{u.plan}{u.total_tasks}{u.created_at}
+
+
+ ) : null} + + ); + } + + function renderUsersTab() { + return ( + <> + {/* Registration stats */} + {userStats && ( +
+ {[ + { key: 'total', title: t('totalUsers'), value: userStats.total_users }, + { key: 'new7', title: t('newLast7d'), value: userStats.new_last_7d }, + { key: 'new30', title: t('newLast30d'), value: userStats.new_last_30d }, + { key: 'pro', title: t('proUsers'), value: userStats.pro_users }, + ].map((s) => ( +
+

{s.title}

+

{s.value.toLocaleString()}

+
+ ))} +
+ )} + + {/* Registration chart (simple bar representation) */} + {userStats && userStats.daily_registrations.length > 0 && ( +
+

{t('dailyReg30d')}

+
+ {(() => { + const maxCount = Math.max(...userStats.daily_registrations.map((d) => d.count), 1); + return userStats.daily_registrations.map((d) => ( +
+
+ {d.count} +
+ )); + })()} +
+
+ )} + + {/* User management table */} +
+
+

{t('userManagement')}

+
) => { + e.preventDefault(); + void loadTab('users'); + }} + className="flex w-full max-w-md items-center gap-2" + > +
+ + setUserQuery(e.target.value)} + placeholder={t('searchEmailPlaceholder')} + className="w-full rounded-2xl border border-slate-300 bg-white py-2.5 ps-10 pe-4 text-sm text-slate-900 outline-none transition focus:border-primary-500 focus:ring-2 focus:ring-primary-200 dark:border-slate-600 dark:bg-slate-950 dark:text-slate-100 dark:focus:ring-primary-500/30" + /> +
+ +
+
+ +
+ + + + + + + + + + + + + {users.map((u) => ( + + + + + + + + + ))} + +
{t('emailCol')}{t('roleCol')}{t('planCol')}{t('tasksCol')}{t('apiKeysCol')}{t('actionsCol')}
+
{u.email}
+
{tr(t('createdLabel'), { date: u.created_at })}
+
+
+ {u.role} + {u.is_allowlisted_admin && ( + {t('allowlisted')} + )} +
+
{u.plan}{tr(t('tasksSummary'), { ok: u.completed_tasks, fail: u.failed_tasks })}{u.active_api_keys} +
+ + + + +
+
+
+
+ + {/* Most active users */} + {userStats && userStats.most_active_users.length > 0 && ( +
+

{t('mostActiveUsers')}

+
+ + + + + + + + + + + {userStats.most_active_users.map((u) => ( + + + + + + + ))} + +
{t('emailCol')}{t('planCol')}{t('totalTasksCol')}{t('joinedCol')}
{u.email}{u.plan}{u.total_tasks.toLocaleString()}{u.created_at}
+
+
+ )} + + ); + } + + function renderToolsTab() { + if (!toolAnalytics) return

{t('loadingText')}

; + return ( + <> + {/* Per-tool analytics */} +
+

{t('toolUsageBreakdown')}

+

{t('toolUsageDesc')}

+
+ + + + + + + + + + + + + + + {toolAnalytics.tools.map((t_) => ( + + + + + + + + + + + ))} + +
{t('toolCol')}{t('totalCol')}{t('successCol')}{t('rateCol')}24h7d30d{t('usersCol')}
{t_.tool}{t_.total_runs.toLocaleString()} + {t_.completed.toLocaleString()} + {' / '} + {t_.failed.toLocaleString()} + + = 90 + ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300' + : t_.success_rate >= 70 + ? 'bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-300' + : 'bg-rose-100 text-rose-700 dark:bg-rose-500/10 dark:text-rose-300' + }`} + > + {t_.success_rate}% + + {t_.runs_24h}{t_.runs_7d}{t_.runs_30d}{t_.unique_users}
+
+
+ + {/* Daily usage chart */} + {toolAnalytics.daily_usage.length > 0 && ( +
+

{t('dailyProcessing30d')}

+
+ {(() => { + const maxVal = Math.max(...toolAnalytics.daily_usage.map((d) => d.total), 1); + return toolAnalytics.daily_usage.map((d) => ( +
+
+
+
+
+ {d.total} +
+ )); + })()} +
+
+ + {t('completedLabel')} + + + {t('failedLabel')} + +
+
+ )} + + {/* Common errors */} + {toolAnalytics.common_errors.length > 0 && ( +
+

{t('mostCommonErrors30d')}

+
+ {toolAnalytics.common_errors.map((err, i) => ( +
+
+
+

{err.tool}

+

{err.error}

+
+ + {err.occurrences}x + +
+
+ ))} +
+
+ )} + + ); + } + + function renderRatingsTab() { + return ( + <> + {/* Per-tool summaries */} + {ratingsDetail && ratingsDetail.tool_summaries.length > 0 && ( +
+

{t('ratingSummaryByTool')}

+
+ {ratingsDetail.tool_summaries.map((ts) => ( +
{ + setRatingsToolFilter(ratingsToolFilter === ts.tool ? '' : ts.tool); + setRatingsPage(1); + void getAdminRatingsDetail(1, 20, ratingsToolFilter === ts.tool ? '' : ts.tool).then(setRatingsDetail); + }} + > +
+
+

{ts.tool}

+

{ts.count} {t('ratingsCountSuffix')}

+
+
+

{ts.average}

+
+ {ts.positive} {t('positiveLabel')} + {ts.negative} {t('negativeLabel')} +
+
+
+
+ ))} +
+
+ )} + + {/* Individual ratings */} +
+
+
+

+ {ratingsToolFilter ? tr(t('reviewsFilterLabel'), { tool: ratingsToolFilter }) : t('allReviews')} +

+

+ {tr(t('totalRatingsLabel'), { n: ratingsDetail?.total ?? 0 })} + {ratingsToolFilter && ( + + )} +

+
+
+ +
+ {ratingsDetail?.items.length ? ( + ratingsDetail.items.map((r) => ( +
+
+
+
+ {r.tool} + +
+ {r.feedback && ( +

+ + {r.feedback} +

+ )} + {r.tag && ( + + {r.tag} + + )} +
+ {r.created_at} +
+
+ )) + ) : ( +

{t('noRatingsFound')}

+ )} +
+ + {/* Pagination */} + {ratingsDetail && ratingsDetail.total > ratingsDetail.per_page && ( +
+ + + {tr(t('pageOf'), { p: ratingsPage, total: Math.ceil(ratingsDetail.total / ratingsDetail.per_page) })} + + +
+ )} +
+ + ); + } + + function renderContactsTab() { + return ( +
+
+
+

{t('contactInbox')}

+

+ {tr(t('unreadOfTotal'), { unread: contactMeta.unread, total: contactMeta.total })} +

+
+
+ +
+ {contacts.length ? ( + contacts.map((c) => ( +
+
+
+

{c.subject || t('noSubject')}

+

+ {c.name} / {c.email} / {c.category} +

+
+ {c.created_at} +
+

{c.message}

+ {!c.is_read ? ( + + ) : ( + + {t('readLabel')} + + )} +
+ )) + ) : ( +

{t('noContactMessages')}

+ )} +
+ + {/* Contact pagination */} + {contactMeta.total > contactMeta.perPage && ( +
+ + + {tr(t('pageOf'), { p: contactMeta.page, total: Math.ceil(contactMeta.total / contactMeta.perPage) })} + + +
+ )} +
+ ); + } + + function renderSystemTab() { + return ( + <> + {/* System health cards */} + {systemHealth && ( +
+
+
+
+

{t('aiServiceLabel')}

+

+ {systemHealth.ai_configured ? t('active') : t('offlineLabel')} +

+

{systemHealth.ai_model}

+
+ +
+
+ +
+
+
+

{t('errorRate1h')}

+

20 ? 'text-rose-600' : systemHealth.error_rate_1h > 5 ? 'text-amber-600' : 'text-emerald-600'}`}> + {systemHealth.error_rate_1h}% +

+

+ {tr(t('failedOfTotal'), { fail: systemHealth.failures_last_1h, total: systemHealth.tasks_last_1h })} +

+
+ +
+
+ +
+
+
+

{t('aiBudgetUsed')}

+

80 ? 'text-rose-600' : 'text-slate-900 dark:text-white'}`}> + {systemHealth.ai_budget_used_percent}% +

+
+ +
+
+ +
+
+
+

{t('databaseSize')}

+

{systemHealth.database_size_mb} MB

+
+ +
+
+
+ )} + + {/* Plan interest */} + {planInterest && ( +
+
+ +
+

{t('paidPlanInterest')}

+

{t('paidPlanInterestDesc')}

+
+
+ +
+ {[ + { label: t('totalClicksLabel'), value: planInterest.total_clicks }, + { label: t('uniqueUsersLabel'), value: planInterest.unique_users }, + { label: t('last7DaysLabel'), value: planInterest.clicks_last_7d }, + { label: t('last30DaysLabel'), value: planInterest.clicks_last_30d }, + ].map((s) => ( +
+

{s.value.toLocaleString()}

+

{s.label}

+
+ ))} +
+ + {planInterest.by_plan.length > 0 && ( +
+

{t('byPlanBilling')}

+
+ {planInterest.by_plan.map((bp, i) => ( + + {bp.plan} ({bp.billing}): {bp.clicks} {t('clicksSuffix')} + + ))} +
+
+ )} + + {planInterest.recent.length > 0 && ( +
+

{t('recentInterest')}

+
+ + + + + + + + + + + {planInterest.recent.map((r) => ( + + + + + + + ))} + +
{t('userCol')}{t('planCol')}{t('billingCol')}{t('dateCol')}
{r.email ?? t('anonymousLabel')}{r.plan}{r.billing}{r.created_at}
+
+
+ )} +
+ )} + + ); + } + + // ====================== MAIN RENDER ====================== + return ( -
+
- Internal Admin | Dociva + {t('pageTitle')} + {/* Header */}

- Internal operations + {t('internalOps')}

-

- Admin control room -

+

{t('controlRoom')}

- This area now uses the normal app session plus admin permissions. Only signed-in allowlisted admins can - inspect operations, edit plans, and process the support inbox. + {t('controlRoomDesc')}

- {user ? ( -
- {user.email} - Role: {user.role} -
- ) : null} +
+ {/* Language toggle */} + + + {user && ( +
+ {user.email} + {t('roleLabel')} {user.role} +
+ )} + {isAdmin && ( +
+ + +
+ )} +
@@ -274,26 +1445,25 @@ export default function InternalAdminPage() {
)} + {/* Auth state */} {!initialized || authLoading ? (
- Checking admin session... + {t('checkingSession')}
) : !user ? (
-

Admin sign in

+

{t('adminSignIn')}

- Use an allowlisted internal account to start a normal authenticated session. Admin access is decided by - server-side permissions, not a client-side secret. + {t('adminSignInDesc')}

-
setEmail(event.target.value)} + onChange={(e) => setEmail(e.target.value)} placeholder="admin@example.com" className="rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm outline-none transition focus:border-primary-500 focus:ring-2 focus:ring-primary-200 dark:border-slate-600 dark:bg-slate-950 dark:text-slate-100 dark:focus:ring-primary-500/30" /> @@ -301,8 +1471,8 @@ export default function InternalAdminPage() { type="password" autoComplete="current-password" value={password} - onChange={(event) => setPassword(event.target.value)} - placeholder="Password" + onChange={(e) => setPassword(e.target.value)} + placeholder={t('passwordPlaceholder')} className="rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm outline-none transition focus:border-primary-500 focus:ring-2 focus:ring-primary-200 dark:border-slate-600 dark:bg-slate-950 dark:text-slate-100 dark:focus:ring-primary-500/30" /> {loginError && ( @@ -314,23 +1484,22 @@ export default function InternalAdminPage() { type="submit" className="rounded-2xl bg-primary-600 px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-primary-700" > - Sign in as admin + {t('signInBtn')}
) : !isAdmin ? (
-

No admin permission

+

{t('noAdminPermission')}

- You are signed in as {user.email}, but this account is not in the internal admin allowlist and does not - carry the admin role. + {tr(t('notAdminDesc'), { email: user.email })}

- Back to account + {t('backToAccount')}
) : ( <> -
- {metricCards.map((card) => { - const Icon = card.icon; - + {/* Tab navigation */} +
+ -
-
-
-
-
-

Users and monetization

-

- Review plan mix, API adoption, and failed task concentration before support tickets pile up. -

-
-
-
- - setUserQuery(event.target.value)} - placeholder="Search user email" - className="w-full rounded-2xl border border-slate-300 bg-white py-2.5 pl-10 pr-4 text-sm text-slate-900 outline-none transition focus:border-primary-500 focus:ring-2 focus:ring-primary-200 dark:border-slate-600 dark:bg-slate-950 dark:text-slate-100 dark:focus:ring-primary-500/30" - /> -
- -
-
- -
- - - - - - - - - - - - - {users.map((user) => ( - - - - - - - - - ))} - -
UserRolePlanTasksAPI keysAction
-
{user.email}
-
Created {user.created_at}
-
-
- {user.role} - {user.is_allowlisted_admin ? ( - Bootstrap allowlist - ) : null} -
-
{user.plan}{user.completed_tasks} complete / {user.failed_tasks} failed{user.active_api_keys} -
- - - - -
-
-
-
- -
-
-
-

Recent failures

-

- These entries help isolate tool instability and prioritize support follow-up. -

-
- -
- -
- {overview?.recent_failures.length ? overview.recent_failures.map((failure) => ( -
-
-
-

{failure.tool}

-

- {failure.original_filename || 'Unknown file'} - {failure.email ? ` / ${failure.email}` : ''} -

-
- {failure.created_at} -
-

- {typeof failure.metadata.error === 'string' ? failure.metadata.error : 'Processing failed without a structured error message.'} -

-
- )) : ( -

No recent failures.

- )} -
-
-
- -
-
-

Top tools

-
- {overview?.top_tools.length ? overview.top_tools.map((tool) => ( -
-
-
-

{tool.tool}

-

{tool.total_runs} total runs

-
- - {tool.failed_runs} failed - -
-
- )) : ( -

No tool activity yet.

- )} -
-
- -
-
-
-

Contact inbox

-

- {contactMeta.unread} unread of {contactMeta.total} total messages. -

-
- - Page {contactMeta.page} - -
- -
- {contacts.length ? contacts.map((contact) => ( -
-
-
-

{contact.subject || 'No subject'}

-

- {contact.name} / {contact.email} / {contact.category} -

-
- {contact.created_at} -
-

{contact.message}

- {!contact.is_read ? ( - - ) : ( - - Read - - )} -
- )) : ( -

No contact messages found.

- )} -
-
-
-
+ {/* Tab content */} +
+ {activeTab === 'overview' && renderOverviewTab()} + {activeTab === 'users' && renderUsersTab()} + {activeTab === 'tools' && renderToolsTab()} + {activeTab === 'ratings' && renderRatingsTab()} + {activeTab === 'contacts' && renderContactsTab()} + {activeTab === 'system' && renderSystemTab()} +
)}
); -} \ No newline at end of file +} + diff --git a/frontend/src/pages/PricingPage.tsx b/frontend/src/pages/PricingPage.tsx index eeb3465..d1161dd 100644 --- a/frontend/src/pages/PricingPage.tsx +++ b/frontend/src/pages/PricingPage.tsx @@ -37,6 +37,13 @@ export default function PricingPage() { const [loading, setLoading] = useState(false); async function handleUpgrade(billing: 'monthly' | 'yearly') { + // Track interest in paid plan + try { + await api.post('/internal/admin/plan-interest/record', { plan: 'pro', billing }); + } catch { + // Non-critical — don't block the flow + } + if (!user) { window.location.href = '/account?redirect=pricing'; return; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 160674c..a2cedb5 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -135,11 +135,30 @@ api.interceptors.request.use( (error) => Promise.reject(error) ); -// Response interceptor for error handling +// Response interceptor — auto-retry once on CSRF failure api.interceptors.response.use( (response) => response, - (error) => { + async (error) => { if (error.response) { + // Auto-retry on CSRF token mismatch (session expired, cookie lost, etc.) + const originalRequest = error.config; + if ( + !originalRequest._csrfRetried && + isCsrfFailure( + error.response.status, + typeof error.response.data === 'string' + ? error.response.data + : JSON.stringify(error.response.data ?? '') + ) + ) { + originalRequest._csrfRetried = true; + const freshToken = await ensureCsrfToken(true); + if (freshToken) { + setRequestHeader(originalRequest, CSRF_HEADER_NAME, freshToken); + } + return api(originalRequest); + } + if (error.response.status === 429) { return Promise.reject(new Error('Too many requests. Please wait a moment and try again.')); } @@ -714,6 +733,133 @@ export async function updateInternalAdminUserRole( return response.data.user; } +// --- Enhanced Admin Analytics --- + +export interface AdminRatingItem { + id: number; + tool: string; + rating: number; + feedback: string; + tag: string; + created_at: string; +} + +export interface AdminToolSummary { + tool: string; + count: number; + average: number; + positive: number; + negative: number; +} + +export interface AdminRatingsDetail { + items: AdminRatingItem[]; + page: number; + per_page: number; + total: number; + tool_summaries: AdminToolSummary[]; +} + +export interface AdminToolAnalyticsItem { + tool: string; + total_runs: number; + completed: number; + failed: number; + success_rate: number; + runs_24h: number; + runs_7d: number; + runs_30d: number; + unique_users: number; +} + +export interface AdminDailyUsage { + day: string; + total: number; + completed: number; + failed: number; +} + +export interface AdminCommonError { + tool: string; + error: string; + occurrences: number; +} + +export interface AdminToolAnalytics { + tools: AdminToolAnalyticsItem[]; + daily_usage: AdminDailyUsage[]; + common_errors: AdminCommonError[]; +} + +export interface AdminUserStats { + total_users: number; + new_last_7d: number; + new_last_30d: number; + pro_users: number; + free_users: number; + daily_registrations: Array<{ day: string; count: number }>; + most_active_users: Array<{ + id: number; + email: string; + plan: string; + created_at: string; + total_tasks: number; + }>; +} + +export interface AdminPlanInterest { + total_clicks: number; + unique_users: number; + clicks_last_7d: number; + clicks_last_30d: number; + by_plan: Array<{ plan: string; billing: string; clicks: number }>; + recent: Array<{ + id: number; + user_id: number | null; + email: string | null; + plan: string; + billing: string; + created_at: string; + }>; +} + +export interface AdminSystemHealth { + ai_configured: boolean; + ai_model: string; + ai_budget_used_percent: number; + error_rate_1h: number; + tasks_last_1h: number; + failures_last_1h: number; + database_size_mb: number; +} + +export async function getAdminRatingsDetail(page = 1, perPage = 20, tool = ''): Promise { + const response = await api.get('/internal/admin/ratings', { + params: { page, per_page: perPage, ...(tool ? { tool } : {}) }, + }); + return response.data; +} + +export async function getAdminToolAnalytics(): Promise { + const response = await api.get('/internal/admin/tool-analytics'); + return response.data; +} + +export async function getAdminUserStats(): Promise { + const response = await api.get('/internal/admin/user-stats'); + return response.data; +} + +export async function getAdminPlanInterest(): Promise { + const response = await api.get('/internal/admin/plan-interest'); + return response.data; +} + +export async function getAdminSystemHealth(): Promise { + const response = await api.get('/internal/admin/system-health'); + return response.data; +} + // --- Account / Usage / API Keys --- export interface UsageSummary {