Add admin moderation and analytics dashboards
Moderation Dashboard: - API with admin-only access for content moderation - View/filter reports by status (open, resolved, ignored) - View/resolve contract disputes - Manage flagged users (warn, ban, unban) - Resolution notes for audit trail - Stats for open reports, disputes, resolved today Analytics Dashboard: - Comprehensive platform metrics API - User stats: total, new, active, creators - Opportunity and application metrics - Contract tracking and completion rates - Revenue tracking by period - Daily signup trend visualization - Top performing opportunities ranking - Period selector (7, 30, 90, 365 days) Both dashboards have proper admin authorization checks.
This commit is contained in:
parent
ebf62ec80e
commit
88e364f4c5
5 changed files with 1406 additions and 0 deletions
187
api/admin/analytics.ts
Normal file
187
api/admin/analytics.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import { supabase } from "../_supabase.js";
|
||||
|
||||
export default async (req: Request) => {
|
||||
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
||||
if (!token) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
const { data: userData } = await supabase.auth.getUser(token);
|
||||
if (!userData.user) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const { data: profile } = await supabase
|
||||
.from("profiles")
|
||||
.select("role")
|
||||
.eq("id", userData.user.id)
|
||||
.single();
|
||||
|
||||
if (!profile || profile.role !== "admin") {
|
||||
return new Response(JSON.stringify({ error: "Forbidden" }), { status: 403, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const period = url.searchParams.get("period") || "30"; // days
|
||||
|
||||
try {
|
||||
if (req.method === "GET") {
|
||||
const daysAgo = new Date();
|
||||
daysAgo.setDate(daysAgo.getDate() - parseInt(period));
|
||||
|
||||
// Get total users and growth
|
||||
const { count: totalUsers } = await supabase
|
||||
.from("profiles")
|
||||
.select("*", { count: "exact", head: true });
|
||||
|
||||
const { count: newUsersThisPeriod } = await supabase
|
||||
.from("profiles")
|
||||
.select("*", { count: "exact", head: true })
|
||||
.gte("created_at", daysAgo.toISOString());
|
||||
|
||||
// Get active users (logged in within period)
|
||||
const { count: activeUsers } = await supabase
|
||||
.from("profiles")
|
||||
.select("*", { count: "exact", head: true })
|
||||
.gte("last_login_at", daysAgo.toISOString());
|
||||
|
||||
// Get opportunities stats
|
||||
const { count: totalOpportunities } = await supabase
|
||||
.from("aethex_opportunities")
|
||||
.select("*", { count: "exact", head: true });
|
||||
|
||||
const { count: openOpportunities } = await supabase
|
||||
.from("aethex_opportunities")
|
||||
.select("*", { count: "exact", head: true })
|
||||
.eq("status", "open");
|
||||
|
||||
const { count: newOpportunities } = await supabase
|
||||
.from("aethex_opportunities")
|
||||
.select("*", { count: "exact", head: true })
|
||||
.gte("created_at", daysAgo.toISOString());
|
||||
|
||||
// Get applications stats
|
||||
const { count: totalApplications } = await supabase
|
||||
.from("aethex_applications")
|
||||
.select("*", { count: "exact", head: true });
|
||||
|
||||
const { count: newApplications } = await supabase
|
||||
.from("aethex_applications")
|
||||
.select("*", { count: "exact", head: true })
|
||||
.gte("created_at", daysAgo.toISOString());
|
||||
|
||||
// Get contracts stats
|
||||
const { count: totalContracts } = await supabase
|
||||
.from("nexus_contracts")
|
||||
.select("*", { count: "exact", head: true });
|
||||
|
||||
const { count: activeContracts } = await supabase
|
||||
.from("nexus_contracts")
|
||||
.select("*", { count: "exact", head: true })
|
||||
.eq("status", "active");
|
||||
|
||||
// Get community stats
|
||||
const { count: totalPosts } = await supabase
|
||||
.from("community_posts")
|
||||
.select("*", { count: "exact", head: true });
|
||||
|
||||
const { count: newPosts } = await supabase
|
||||
.from("community_posts")
|
||||
.select("*", { count: "exact", head: true })
|
||||
.gte("created_at", daysAgo.toISOString());
|
||||
|
||||
// Get creator stats
|
||||
const { count: totalCreators } = await supabase
|
||||
.from("aethex_creators")
|
||||
.select("*", { count: "exact", head: true });
|
||||
|
||||
// Get daily signups for trend (last 30 days)
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
const { data: signupTrend } = await supabase
|
||||
.from("profiles")
|
||||
.select("created_at")
|
||||
.gte("created_at", thirtyDaysAgo.toISOString())
|
||||
.order("created_at");
|
||||
|
||||
// Group signups by date
|
||||
const signupsByDate: Record<string, number> = {};
|
||||
signupTrend?.forEach((user) => {
|
||||
const date = new Date(user.created_at).toISOString().split("T")[0];
|
||||
signupsByDate[date] = (signupsByDate[date] || 0) + 1;
|
||||
});
|
||||
|
||||
const dailySignups = Object.entries(signupsByDate).map(([date, count]) => ({
|
||||
date,
|
||||
count
|
||||
}));
|
||||
|
||||
// Revenue data (if available)
|
||||
const { data: revenueData } = await supabase
|
||||
.from("nexus_payments")
|
||||
.select("amount, created_at")
|
||||
.eq("status", "completed")
|
||||
.gte("created_at", daysAgo.toISOString());
|
||||
|
||||
const totalRevenue = revenueData?.reduce((sum, p) => sum + (p.amount || 0), 0) || 0;
|
||||
|
||||
// Top performing opportunities
|
||||
const { data: topOpportunities } = await supabase
|
||||
.from("aethex_opportunities")
|
||||
.select(`
|
||||
id,
|
||||
title,
|
||||
aethex_applications(count)
|
||||
`)
|
||||
.eq("status", "open")
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(5);
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
users: {
|
||||
total: totalUsers || 0,
|
||||
new: newUsersThisPeriod || 0,
|
||||
active: activeUsers || 0,
|
||||
creators: totalCreators || 0
|
||||
},
|
||||
opportunities: {
|
||||
total: totalOpportunities || 0,
|
||||
open: openOpportunities || 0,
|
||||
new: newOpportunities || 0
|
||||
},
|
||||
applications: {
|
||||
total: totalApplications || 0,
|
||||
new: newApplications || 0
|
||||
},
|
||||
contracts: {
|
||||
total: totalContracts || 0,
|
||||
active: activeContracts || 0
|
||||
},
|
||||
community: {
|
||||
posts: totalPosts || 0,
|
||||
newPosts: newPosts || 0
|
||||
},
|
||||
revenue: {
|
||||
total: totalRevenue,
|
||||
period: `${period} days`
|
||||
},
|
||||
trends: {
|
||||
dailySignups,
|
||||
topOpportunities: topOpportunities?.map(o => ({
|
||||
id: o.id,
|
||||
title: o.title,
|
||||
applications: o.aethex_applications?.[0]?.count || 0
|
||||
})) || []
|
||||
},
|
||||
period: parseInt(period)
|
||||
}), { headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { "Content-Type": "application/json" } });
|
||||
} catch (err: any) {
|
||||
console.error("Analytics API error:", err);
|
||||
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
};
|
||||
245
api/admin/moderation.ts
Normal file
245
api/admin/moderation.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import { supabase } from "../_supabase.js";
|
||||
|
||||
export default async (req: Request) => {
|
||||
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
||||
if (!token) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
const { data: userData } = await supabase.auth.getUser(token);
|
||||
if (!userData.user) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const { data: profile } = await supabase
|
||||
.from("profiles")
|
||||
.select("role")
|
||||
.eq("id", userData.user.id)
|
||||
.single();
|
||||
|
||||
if (!profile || profile.role !== "admin") {
|
||||
return new Response(JSON.stringify({ error: "Forbidden" }), { status: 403, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
|
||||
try {
|
||||
// GET - Fetch reports and stats
|
||||
if (req.method === "GET") {
|
||||
const status = url.searchParams.get("status") || "open";
|
||||
const type = url.searchParams.get("type"); // report, dispute, user
|
||||
|
||||
// Get content reports
|
||||
let reportsQuery = supabase
|
||||
.from("moderation_reports")
|
||||
.select(`
|
||||
*,
|
||||
reporter:profiles!moderation_reports_reporter_id_fkey(id, full_name, email, avatar_url)
|
||||
`)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(50);
|
||||
|
||||
if (status !== "all") {
|
||||
reportsQuery = reportsQuery.eq("status", status);
|
||||
}
|
||||
if (type && type !== "all") {
|
||||
reportsQuery = reportsQuery.eq("target_type", type);
|
||||
}
|
||||
|
||||
const { data: reports, error: reportsError } = await reportsQuery;
|
||||
if (reportsError) console.error("Reports error:", reportsError);
|
||||
|
||||
// Get disputes
|
||||
let disputesQuery = supabase
|
||||
.from("nexus_disputes")
|
||||
.select(`
|
||||
*,
|
||||
reporter:profiles!nexus_disputes_reported_by_fkey(id, full_name, email)
|
||||
`)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(50);
|
||||
|
||||
if (status !== "all") {
|
||||
disputesQuery = disputesQuery.eq("status", status);
|
||||
}
|
||||
|
||||
const { data: disputes, error: disputesError } = await disputesQuery;
|
||||
if (disputesError) console.error("Disputes error:", disputesError);
|
||||
|
||||
// Get flagged users (users with warnings/bans)
|
||||
const { data: flaggedUsers } = await supabase
|
||||
.from("profiles")
|
||||
.select("id, full_name, email, avatar_url, is_banned, warning_count, created_at")
|
||||
.or("is_banned.eq.true,warning_count.gt.0")
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(50);
|
||||
|
||||
// Calculate stats
|
||||
const { count: openReports } = await supabase
|
||||
.from("moderation_reports")
|
||||
.select("*", { count: "exact", head: true })
|
||||
.eq("status", "open");
|
||||
|
||||
const { count: openDisputes } = await supabase
|
||||
.from("nexus_disputes")
|
||||
.select("*", { count: "exact", head: true })
|
||||
.eq("status", "open");
|
||||
|
||||
const { count: resolvedToday } = await supabase
|
||||
.from("moderation_reports")
|
||||
.select("*", { count: "exact", head: true })
|
||||
.eq("status", "resolved")
|
||||
.gte("updated_at", new Date(new Date().setHours(0, 0, 0, 0)).toISOString());
|
||||
|
||||
const stats = {
|
||||
openReports: openReports || 0,
|
||||
openDisputes: openDisputes || 0,
|
||||
resolvedToday: resolvedToday || 0,
|
||||
flaggedUsers: flaggedUsers?.length || 0
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
reports: reports || [],
|
||||
disputes: disputes || [],
|
||||
flaggedUsers: flaggedUsers || [],
|
||||
stats
|
||||
}), { headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
// POST - Take moderation action
|
||||
if (req.method === "POST") {
|
||||
const body = await req.json();
|
||||
|
||||
// Resolve/ignore report
|
||||
if (body.action === "update_report") {
|
||||
const { report_id, status, resolution_notes } = body;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("moderation_reports")
|
||||
.update({
|
||||
status,
|
||||
resolution_notes,
|
||||
resolved_by: userData.user.id,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq("id", report_id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return new Response(JSON.stringify({ report: data }), { headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
// Resolve dispute
|
||||
if (body.action === "update_dispute") {
|
||||
const { dispute_id, status, resolution_notes } = body;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("nexus_disputes")
|
||||
.update({
|
||||
status,
|
||||
resolution_notes,
|
||||
resolved_by: userData.user.id,
|
||||
resolved_at: new Date().toISOString()
|
||||
})
|
||||
.eq("id", dispute_id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return new Response(JSON.stringify({ dispute: data }), { headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
// Ban/warn user
|
||||
if (body.action === "moderate_user") {
|
||||
const { user_id, action_type, reason } = body;
|
||||
|
||||
if (action_type === "ban") {
|
||||
const { data, error } = await supabase
|
||||
.from("profiles")
|
||||
.update({
|
||||
is_banned: true,
|
||||
ban_reason: reason,
|
||||
banned_at: new Date().toISOString(),
|
||||
banned_by: userData.user.id
|
||||
})
|
||||
.eq("id", user_id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return new Response(JSON.stringify({ user: data, action: "banned" }), { headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
if (action_type === "warn") {
|
||||
const { data: currentUser } = await supabase
|
||||
.from("profiles")
|
||||
.select("warning_count")
|
||||
.eq("id", user_id)
|
||||
.single();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("profiles")
|
||||
.update({
|
||||
warning_count: (currentUser?.warning_count || 0) + 1,
|
||||
last_warning_at: new Date().toISOString(),
|
||||
last_warning_reason: reason
|
||||
})
|
||||
.eq("id", user_id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return new Response(JSON.stringify({ user: data, action: "warned" }), { headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
if (action_type === "unban") {
|
||||
const { data, error } = await supabase
|
||||
.from("profiles")
|
||||
.update({
|
||||
is_banned: false,
|
||||
ban_reason: null,
|
||||
unbanned_at: new Date().toISOString(),
|
||||
unbanned_by: userData.user.id
|
||||
})
|
||||
.eq("id", user_id)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw error;
|
||||
return new Response(JSON.stringify({ user: data, action: "unbanned" }), { headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
}
|
||||
|
||||
// Delete content
|
||||
if (body.action === "delete_content") {
|
||||
const { content_type, content_id } = body;
|
||||
|
||||
const tableMap: Record<string, string> = {
|
||||
post: "community_posts",
|
||||
comment: "community_comments",
|
||||
project: "projects",
|
||||
opportunity: "aethex_opportunities"
|
||||
};
|
||||
|
||||
const table = tableMap[content_type];
|
||||
if (!table) {
|
||||
return new Response(JSON.stringify({ error: "Invalid content type" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
const { error } = await supabase.from(table).delete().eq("id", content_id);
|
||||
if (error) throw error;
|
||||
|
||||
return new Response(JSON.stringify({ success: true, deleted: content_type }), { headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ error: "Invalid action" }), { status: 400, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ error: "Method not allowed" }), { status: 405, headers: { "Content-Type": "application/json" } });
|
||||
} catch (err: any) {
|
||||
console.error("Moderation API error:", err);
|
||||
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
};
|
||||
|
|
@ -167,6 +167,8 @@ import CandidateInterviews from "./pages/candidate/CandidateInterviews";
|
|||
import CandidateOffers from "./pages/candidate/CandidateOffers";
|
||||
import StaffOKRs from "./pages/staff/StaffOKRs";
|
||||
import StaffTimeTracking from "./pages/staff/StaffTimeTracking";
|
||||
import AdminModeration from "./pages/admin/AdminModeration";
|
||||
import AdminAnalytics from "./pages/admin/AdminAnalytics";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
|
|
@ -248,6 +250,22 @@ const App = () => (
|
|||
<Route path="/admin" element={<Admin />} />
|
||||
<Route path="/admin/feed" element={<AdminFeed />} />
|
||||
<Route path="/admin/docs-sync" element={<DocsSync />} />
|
||||
<Route
|
||||
path="/admin/moderation"
|
||||
element={
|
||||
<RequireAccess>
|
||||
<AdminModeration />
|
||||
</RequireAccess>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/analytics"
|
||||
element={
|
||||
<RequireAccess>
|
||||
<AdminAnalytics />
|
||||
</RequireAccess>
|
||||
}
|
||||
/>
|
||||
<Route path="/arms" element={<Arms />} />
|
||||
<Route path="/feed" element={<Navigate to="/community/feed" replace />} />
|
||||
<Route path="/teams" element={<Teams />} />
|
||||
|
|
|
|||
362
client/pages/admin/AdminAnalytics.tsx
Normal file
362
client/pages/admin/AdminAnalytics.tsx
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import Layout from "@/components/Layout";
|
||||
import SEO from "@/components/SEO";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
BarChart3,
|
||||
Users,
|
||||
Briefcase,
|
||||
FileText,
|
||||
DollarSign,
|
||||
TrendingUp,
|
||||
Activity,
|
||||
MessageSquare,
|
||||
Loader2,
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { aethexToast } from "@/components/ui/aethex-toast";
|
||||
|
||||
interface Analytics {
|
||||
users: {
|
||||
total: number;
|
||||
new: number;
|
||||
active: number;
|
||||
creators: number;
|
||||
};
|
||||
opportunities: {
|
||||
total: number;
|
||||
open: number;
|
||||
new: number;
|
||||
};
|
||||
applications: {
|
||||
total: number;
|
||||
new: number;
|
||||
};
|
||||
contracts: {
|
||||
total: number;
|
||||
active: number;
|
||||
};
|
||||
community: {
|
||||
posts: number;
|
||||
newPosts: number;
|
||||
};
|
||||
revenue: {
|
||||
total: number;
|
||||
period: string;
|
||||
};
|
||||
trends: {
|
||||
dailySignups: Array<{ date: string; count: number }>;
|
||||
topOpportunities: Array<{ id: string; title: string; applications: number }>;
|
||||
};
|
||||
period: number;
|
||||
}
|
||||
|
||||
export default function AdminAnalytics() {
|
||||
const { session } = useAuth();
|
||||
const [analytics, setAnalytics] = useState<Analytics | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [period, setPeriod] = useState("30");
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.access_token) {
|
||||
fetchAnalytics();
|
||||
}
|
||||
}, [session?.access_token, period]);
|
||||
|
||||
const fetchAnalytics = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/analytics?period=${period}`, {
|
||||
headers: { Authorization: `Bearer ${session?.access_token}` },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setAnalytics(data);
|
||||
} else {
|
||||
aethexToast.error(data.error || "Failed to load analytics");
|
||||
}
|
||||
} catch (err) {
|
||||
aethexToast.error("Failed to load analytics");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const maxSignups = analytics?.trends.dailySignups
|
||||
? Math.max(...analytics.trends.dailySignups.map(d => d.count), 1)
|
||||
: 1;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-cyan-400" />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<SEO title="Analytics Dashboard" description="Platform analytics and insights" />
|
||||
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
|
||||
<div className="absolute inset-0 opacity-30">
|
||||
<div className="absolute top-20 left-10 w-96 h-96 bg-cyan-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
|
||||
<div className="absolute bottom-20 right-10 w-96 h-96 bg-blue-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="container mx-auto max-w-7xl px-4 py-16">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 rounded-lg bg-cyan-500/20 border border-cyan-500/30">
|
||||
<BarChart3 className="h-6 w-6 text-cyan-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-cyan-100">Analytics</h1>
|
||||
<p className="text-cyan-200/70">Platform insights and metrics</p>
|
||||
</div>
|
||||
</div>
|
||||
<Select value={period} onValueChange={setPeriod}>
|
||||
<SelectTrigger className="w-40 bg-slate-800 border-slate-700 text-slate-100">
|
||||
<SelectValue placeholder="Period" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7">Last 7 days</SelectItem>
|
||||
<SelectItem value="30">Last 30 days</SelectItem>
|
||||
<SelectItem value="90">Last 90 days</SelectItem>
|
||||
<SelectItem value="365">Last year</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Overview Stats */}
|
||||
<div className="grid md:grid-cols-4 gap-4 mb-8">
|
||||
<Card className="bg-cyan-950/30 border-cyan-500/30">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-cyan-200/70">Total Users</p>
|
||||
<p className="text-3xl font-bold text-cyan-100">{analytics?.users.total.toLocaleString()}</p>
|
||||
<div className="flex items-center gap-1 mt-1 text-green-400 text-sm">
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
+{analytics?.users.new} this period
|
||||
</div>
|
||||
</div>
|
||||
<Users className="h-8 w-8 text-cyan-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-cyan-950/30 border-cyan-500/30">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-cyan-200/70">Active Users</p>
|
||||
<p className="text-3xl font-bold text-cyan-100">{analytics?.users.active.toLocaleString()}</p>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
{analytics?.users.total ? Math.round((analytics.users.active / analytics.users.total) * 100) : 0}% of total
|
||||
</p>
|
||||
</div>
|
||||
<Activity className="h-8 w-8 text-cyan-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-cyan-950/30 border-cyan-500/30">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-cyan-200/70">Opportunities</p>
|
||||
<p className="text-3xl font-bold text-cyan-100">{analytics?.opportunities.open}</p>
|
||||
<div className="flex items-center gap-1 mt-1 text-green-400 text-sm">
|
||||
<ArrowUpRight className="h-4 w-4" />
|
||||
+{analytics?.opportunities.new} new
|
||||
</div>
|
||||
</div>
|
||||
<Briefcase className="h-8 w-8 text-cyan-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-cyan-950/30 border-cyan-500/30">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-cyan-200/70">Revenue</p>
|
||||
<p className="text-3xl font-bold text-cyan-100">{formatCurrency(analytics?.revenue.total || 0)}</p>
|
||||
<p className="text-sm text-slate-400 mt-1">Last {period} days</p>
|
||||
</div>
|
||||
<DollarSign className="h-8 w-8 text-cyan-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Detailed Stats Grid */}
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
{/* Applications */}
|
||||
<Card className="bg-slate-800/50 border-slate-700/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-slate-100 flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-cyan-400" />
|
||||
Applications
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Total</span>
|
||||
<span className="text-xl font-bold text-cyan-100">{analytics?.applications.total}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">This Period</span>
|
||||
<span className="text-xl font-bold text-green-400">+{analytics?.applications.new}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Avg per Opportunity</span>
|
||||
<span className="text-xl font-bold text-cyan-100">
|
||||
{analytics?.opportunities.total
|
||||
? (analytics.applications.total / analytics.opportunities.total).toFixed(1)
|
||||
: 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Contracts */}
|
||||
<Card className="bg-slate-800/50 border-slate-700/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-slate-100 flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5 text-cyan-400" />
|
||||
Contracts
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Total</span>
|
||||
<span className="text-xl font-bold text-cyan-100">{analytics?.contracts.total}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Active</span>
|
||||
<span className="text-xl font-bold text-green-400">{analytics?.contracts.active}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Completion Rate</span>
|
||||
<span className="text-xl font-bold text-cyan-100">
|
||||
{analytics?.contracts.total
|
||||
? Math.round(((analytics.contracts.total - analytics.contracts.active) / analytics.contracts.total) * 100)
|
||||
: 0}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Community */}
|
||||
<Card className="bg-slate-800/50 border-slate-700/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-slate-100 flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5 text-cyan-400" />
|
||||
Community
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Total Posts</span>
|
||||
<span className="text-xl font-bold text-cyan-100">{analytics?.community.posts}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">New Posts</span>
|
||||
<span className="text-xl font-bold text-green-400">+{analytics?.community.newPosts}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400">Creators</span>
|
||||
<span className="text-xl font-bold text-cyan-100">{analytics?.users.creators}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Charts Row */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{/* Signup Trend */}
|
||||
<Card className="bg-slate-800/50 border-slate-700/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-slate-100">Daily Signups</CardTitle>
|
||||
<CardDescription className="text-slate-400">User registrations over the last 30 days</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-48 flex items-end gap-1">
|
||||
{analytics?.trends.dailySignups.slice(-30).map((day, i) => (
|
||||
<div
|
||||
key={day.date}
|
||||
className="flex-1 bg-cyan-500/30 hover:bg-cyan-500/50 transition-colors rounded-t"
|
||||
style={{ height: `${(day.count / maxSignups) * 100}%`, minHeight: "4px" }}
|
||||
title={`${day.date}: ${day.count} signups`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between mt-2 text-xs text-slate-500">
|
||||
<span>30 days ago</span>
|
||||
<span>Today</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Top Opportunities */}
|
||||
<Card className="bg-slate-800/50 border-slate-700/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-slate-100">Top Opportunities</CardTitle>
|
||||
<CardDescription className="text-slate-400">By number of applications</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{analytics?.trends.topOpportunities.map((opp, i) => (
|
||||
<div key={opp.id} className="flex items-center gap-4">
|
||||
<span className="text-lg font-bold text-cyan-400 w-6">#{i + 1}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-slate-200 truncate">{opp.title}</p>
|
||||
<p className="text-sm text-slate-500">{opp.applications} applications</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(!analytics?.trends.topOpportunities || analytics.trends.topOpportunities.length === 0) && (
|
||||
<p className="text-slate-500 text-center py-4">No opportunities yet</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
594
client/pages/admin/AdminModeration.tsx
Normal file
594
client/pages/admin/AdminModeration.tsx
Normal file
|
|
@ -0,0 +1,594 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import Layout from "@/components/Layout";
|
||||
import SEO from "@/components/SEO";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs";
|
||||
import {
|
||||
Shield,
|
||||
AlertTriangle,
|
||||
Flag,
|
||||
UserX,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Eye,
|
||||
Ban,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import { aethexToast } from "@/components/ui/aethex-toast";
|
||||
|
||||
interface Report {
|
||||
id: string;
|
||||
reporter_id: string;
|
||||
target_type: string;
|
||||
target_id: string;
|
||||
reason: string;
|
||||
details?: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
reporter?: {
|
||||
id: string;
|
||||
full_name: string;
|
||||
email: string;
|
||||
avatar_url?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Dispute {
|
||||
id: string;
|
||||
contract_id: string;
|
||||
reason: string;
|
||||
status: string;
|
||||
resolution_notes?: string;
|
||||
created_at: string;
|
||||
reporter?: {
|
||||
id: string;
|
||||
full_name: string;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface FlaggedUser {
|
||||
id: string;
|
||||
full_name: string;
|
||||
email: string;
|
||||
avatar_url?: string;
|
||||
is_banned: boolean;
|
||||
warning_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
openReports: number;
|
||||
openDisputes: number;
|
||||
resolvedToday: number;
|
||||
flaggedUsers: number;
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "open":
|
||||
return "bg-red-500/20 text-red-300 border-red-500/30";
|
||||
case "resolved":
|
||||
return "bg-green-500/20 text-green-300 border-green-500/30";
|
||||
case "ignored":
|
||||
return "bg-slate-500/20 text-slate-300 border-slate-500/30";
|
||||
default:
|
||||
return "bg-slate-500/20 text-slate-300";
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case "post":
|
||||
return "bg-blue-500/20 text-blue-300";
|
||||
case "comment":
|
||||
return "bg-purple-500/20 text-purple-300";
|
||||
case "user":
|
||||
return "bg-amber-500/20 text-amber-300";
|
||||
case "project":
|
||||
return "bg-cyan-500/20 text-cyan-300";
|
||||
default:
|
||||
return "bg-slate-500/20 text-slate-300";
|
||||
}
|
||||
};
|
||||
|
||||
export default function AdminModeration() {
|
||||
const { session } = useAuth();
|
||||
const [reports, setReports] = useState<Report[]>([]);
|
||||
const [disputes, setDisputes] = useState<Dispute[]>([]);
|
||||
const [flaggedUsers, setFlaggedUsers] = useState<FlaggedUser[]>([]);
|
||||
const [stats, setStats] = useState<Stats>({ openReports: 0, openDisputes: 0, resolvedToday: 0, flaggedUsers: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [statusFilter, setStatusFilter] = useState("open");
|
||||
const [selectedReport, setSelectedReport] = useState<Report | null>(null);
|
||||
const [selectedDispute, setSelectedDispute] = useState<Dispute | null>(null);
|
||||
const [selectedUser, setSelectedUser] = useState<FlaggedUser | null>(null);
|
||||
const [resolution, setResolution] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.access_token) {
|
||||
fetchModeration();
|
||||
}
|
||||
}, [session?.access_token, statusFilter]);
|
||||
|
||||
const fetchModeration = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/moderation?status=${statusFilter}`, {
|
||||
headers: { Authorization: `Bearer ${session?.access_token}` },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setReports(data.reports || []);
|
||||
setDisputes(data.disputes || []);
|
||||
setFlaggedUsers(data.flaggedUsers || []);
|
||||
setStats(data.stats || { openReports: 0, openDisputes: 0, resolvedToday: 0, flaggedUsers: 0 });
|
||||
} else {
|
||||
aethexToast.error(data.error || "Failed to load moderation data");
|
||||
}
|
||||
} catch (err) {
|
||||
aethexToast.error("Failed to load moderation data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateReport = async (reportId: string, status: string) => {
|
||||
try {
|
||||
const res = await fetch("/api/admin/moderation", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${session?.access_token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: "update_report",
|
||||
report_id: reportId,
|
||||
status,
|
||||
resolution_notes: resolution
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
aethexToast.success(`Report ${status}`);
|
||||
setSelectedReport(null);
|
||||
setResolution("");
|
||||
fetchModeration();
|
||||
}
|
||||
} catch (err) {
|
||||
aethexToast.error("Failed to update report");
|
||||
}
|
||||
};
|
||||
|
||||
const updateDispute = async (disputeId: string, status: string) => {
|
||||
try {
|
||||
const res = await fetch("/api/admin/moderation", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${session?.access_token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: "update_dispute",
|
||||
dispute_id: disputeId,
|
||||
status,
|
||||
resolution_notes: resolution
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
aethexToast.success(`Dispute ${status}`);
|
||||
setSelectedDispute(null);
|
||||
setResolution("");
|
||||
fetchModeration();
|
||||
}
|
||||
} catch (err) {
|
||||
aethexToast.error("Failed to update dispute");
|
||||
}
|
||||
};
|
||||
|
||||
const moderateUser = async (userId: string, actionType: string) => {
|
||||
const reason = prompt(`Enter reason for ${actionType}:`);
|
||||
if (!reason && actionType !== "unban") return;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/admin/moderation", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${session?.access_token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: "moderate_user",
|
||||
user_id: userId,
|
||||
action_type: actionType,
|
||||
reason
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
aethexToast.success(`User ${actionType}ned successfully`);
|
||||
setSelectedUser(null);
|
||||
fetchModeration();
|
||||
}
|
||||
} catch (err) {
|
||||
aethexToast.error(`Failed to ${actionType} user`);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 flex items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-red-400" />
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<SEO title="Moderation Dashboard" description="Admin content moderation" />
|
||||
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
|
||||
<div className="absolute inset-0 opacity-30">
|
||||
<div className="absolute top-20 left-10 w-96 h-96 bg-red-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
|
||||
<div className="absolute bottom-20 right-10 w-96 h-96 bg-orange-600 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="container mx-auto max-w-7xl px-4 py-16">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 rounded-lg bg-red-500/20 border border-red-500/30">
|
||||
<Shield className="h-6 w-6 text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-red-100">Moderation</h1>
|
||||
<p className="text-red-200/70">Content moderation and user management</p>
|
||||
</div>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-40 bg-slate-800 border-slate-700 text-slate-100">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="open">Open</SelectItem>
|
||||
<SelectItem value="resolved">Resolved</SelectItem>
|
||||
<SelectItem value="ignored">Ignored</SelectItem>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid md:grid-cols-4 gap-4 mb-8">
|
||||
<Card className="bg-red-950/30 border-red-500/30">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-red-200/70">Open Reports</p>
|
||||
<p className="text-3xl font-bold text-red-100">{stats.openReports}</p>
|
||||
</div>
|
||||
<Flag className="h-8 w-8 text-red-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-red-950/30 border-red-500/30">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-red-200/70">Open Disputes</p>
|
||||
<p className="text-3xl font-bold text-red-100">{stats.openDisputes}</p>
|
||||
</div>
|
||||
<AlertTriangle className="h-8 w-8 text-red-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-red-950/30 border-red-500/30">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-red-200/70">Resolved Today</p>
|
||||
<p className="text-3xl font-bold text-red-100">{stats.resolvedToday}</p>
|
||||
</div>
|
||||
<CheckCircle className="h-8 w-8 text-red-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-red-950/30 border-red-500/30">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-red-200/70">Flagged Users</p>
|
||||
<p className="text-3xl font-bold text-red-100">{stats.flaggedUsers}</p>
|
||||
</div>
|
||||
<UserX className="h-8 w-8 text-red-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="reports" className="space-y-6">
|
||||
<TabsList className="bg-slate-800">
|
||||
<TabsTrigger value="reports">Reports ({reports.length})</TabsTrigger>
|
||||
<TabsTrigger value="disputes">Disputes ({disputes.length})</TabsTrigger>
|
||||
<TabsTrigger value="users">Flagged Users ({flaggedUsers.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Reports Tab */}
|
||||
<TabsContent value="reports" className="space-y-4">
|
||||
{reports.map((report) => (
|
||||
<Card key={report.id} className="bg-slate-800/50 border-slate-700/50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge className={`border ${getStatusColor(report.status)}`}>
|
||||
{report.status}
|
||||
</Badge>
|
||||
<Badge className={getTypeColor(report.target_type)}>
|
||||
{report.target_type}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-slate-200 font-medium mb-1">{report.reason}</p>
|
||||
{report.details && (
|
||||
<p className="text-sm text-slate-400 mb-2">{report.details}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-xs text-slate-500">
|
||||
<span>By: {report.reporter?.full_name || report.reporter?.email || "Unknown"}</span>
|
||||
<span>{new Date(report.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
{report.status === "open" && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
onClick={() => setSelectedReport(report)}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-1" />
|
||||
Resolve
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => updateReport(report.id, "ignored")}
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-1" />
|
||||
Ignore
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{reports.length === 0 && (
|
||||
<div className="text-center py-12 text-slate-400">No reports found</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Disputes Tab */}
|
||||
<TabsContent value="disputes" className="space-y-4">
|
||||
{disputes.map((dispute) => (
|
||||
<Card key={dispute.id} className="bg-slate-800/50 border-slate-700/50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge className={`border ${getStatusColor(dispute.status)}`}>
|
||||
{dispute.status}
|
||||
</Badge>
|
||||
<Badge className="bg-purple-500/20 text-purple-300">
|
||||
Contract Dispute
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-slate-200 font-medium mb-1">{dispute.reason}</p>
|
||||
<div className="flex items-center gap-4 text-xs text-slate-500">
|
||||
<span>Contract: {dispute.contract_id?.slice(0, 8)}...</span>
|
||||
<span>By: {dispute.reporter?.full_name || dispute.reporter?.email || "Unknown"}</span>
|
||||
<span>{new Date(dispute.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
{dispute.status === "open" && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
onClick={() => setSelectedDispute(dispute)}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
Review
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{disputes.length === 0 && (
|
||||
<div className="text-center py-12 text-slate-400">No disputes found</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Flagged Users Tab */}
|
||||
<TabsContent value="users" className="space-y-4">
|
||||
{flaggedUsers.map((user) => (
|
||||
<Card key={user.id} className="bg-slate-800/50 border-slate-700/50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-full bg-slate-700 flex items-center justify-center">
|
||||
{user.avatar_url ? (
|
||||
<img src={user.avatar_url} alt="" className="w-10 h-10 rounded-full" />
|
||||
) : (
|
||||
<span className="text-slate-400">{user.full_name?.[0] || "?"}</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-200 font-medium">{user.full_name || "Unknown"}</p>
|
||||
<p className="text-sm text-slate-400">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{user.is_banned && (
|
||||
<Badge className="bg-red-500/20 text-red-300 border-red-500/30">
|
||||
Banned
|
||||
</Badge>
|
||||
)}
|
||||
{user.warning_count > 0 && (
|
||||
<Badge className="bg-amber-500/20 text-amber-300 border-amber-500/30">
|
||||
{user.warning_count} Warnings
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{user.is_banned ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => moderateUser(user.id, "unban")}
|
||||
>
|
||||
Unban
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-amber-500/30 text-amber-300"
|
||||
onClick={() => moderateUser(user.id, "warn")}
|
||||
>
|
||||
<AlertCircle className="h-4 w-4 mr-1" />
|
||||
Warn
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
onClick={() => moderateUser(user.id, "ban")}
|
||||
>
|
||||
<Ban className="h-4 w-4 mr-1" />
|
||||
Ban
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{flaggedUsers.length === 0 && (
|
||||
<div className="text-center py-12 text-slate-400">No flagged users</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resolve Report Dialog */}
|
||||
<Dialog open={!!selectedReport} onOpenChange={() => setSelectedReport(null)}>
|
||||
<DialogContent className="bg-slate-800 border-slate-700">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-red-100">Resolve Report</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedReport && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-slate-700/50 rounded">
|
||||
<p className="text-sm text-slate-400 mb-1">Reason</p>
|
||||
<p className="text-slate-200">{selectedReport.reason}</p>
|
||||
{selectedReport.details && (
|
||||
<>
|
||||
<p className="text-sm text-slate-400 mb-1 mt-3">Details</p>
|
||||
<p className="text-slate-300 text-sm">{selectedReport.details}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder="Resolution notes (optional)"
|
||||
value={resolution}
|
||||
onChange={(e) => setResolution(e.target.value)}
|
||||
className="bg-slate-700 border-slate-600 text-slate-100"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setSelectedReport(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
onClick={() => updateReport(selectedReport.id, "resolved")}
|
||||
>
|
||||
Resolve
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Review Dispute Dialog */}
|
||||
<Dialog open={!!selectedDispute} onOpenChange={() => setSelectedDispute(null)}>
|
||||
<DialogContent className="bg-slate-800 border-slate-700">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-red-100">Review Dispute</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedDispute && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-slate-700/50 rounded">
|
||||
<p className="text-sm text-slate-400 mb-1">Contract</p>
|
||||
<p className="text-slate-200 font-mono text-sm">{selectedDispute.contract_id}</p>
|
||||
<p className="text-sm text-slate-400 mb-1 mt-3">Dispute Reason</p>
|
||||
<p className="text-slate-200">{selectedDispute.reason}</p>
|
||||
</div>
|
||||
<Textarea
|
||||
placeholder="Resolution notes"
|
||||
value={resolution}
|
||||
onChange={(e) => setResolution(e.target.value)}
|
||||
className="bg-slate-700 border-slate-600 text-slate-100"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setSelectedDispute(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
onClick={() => updateDispute(selectedDispute.id, "resolved")}
|
||||
>
|
||||
Resolve
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue