+ {banning &&
setBanning(null)} />}
+
+
+
Contributor Management
+ {data && {data.total} total}
+
+
+ {/* Filters */}
+
+ { setSearch(e.target.value); setPage(1); }}
+ className="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-[#9945FF]/50 w-56"
+ data-testid="contributor-search"
+ />
+
+
+
+ {/* Table */}
+ {isLoading && (
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+ )}
+
+ {error && {(error as Error).message}
}
+
+ {data && data.items.length === 0 && (
+ No contributors found
+ )}
+
+ {data && data.items.length > 0 && (
+
+
+
+
+ | Username |
+ Tier |
+ Rep Score |
+ Quality |
+ Completed |
+ Earnings |
+ Status |
+ Actions |
+
+
+
+ {data.items.map(c => (
+
+ |
+
+ @{c.username}
+
+ |
+ {c.tier} |
+ {c.reputation_score.toFixed(1)} |
+
+ = 70 ? 'text-[#14F195]' : (c.quality_score ?? 0) >= 40 ? 'text-yellow-400' : 'text-gray-400'}`}>
+ {(c.quality_score ?? 0).toFixed(0)}
+
+ |
+ {c.total_bounties_completed} |
+
+ {c.total_earnings.toLocaleString()}
+ |
+
+ {c.is_banned ? (
+
+ Banned
+
+ ) : (
+
+ Active
+
+ )}
+ |
+
+ {c.is_banned ? (
+
+ ) : (
+
+ )}
+ |
+
+ ))}
+
+
+
+ )}
+
+ {totalPages > 1 && (
+
+
+ {page} / {totalPages}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/admin/FinancialPanel.tsx b/frontend/src/components/admin/FinancialPanel.tsx
new file mode 100644
index 00000000..59a055f2
--- /dev/null
+++ b/frontend/src/components/admin/FinancialPanel.tsx
@@ -0,0 +1,131 @@
+/** Financial overview panel — token distribution and payout history. */
+import { useState } from 'react';
+import { useFinancialOverview, usePayoutHistory } from '../../hooks/useAdminData';
+
+function fmt(n: number) {
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
+ return n.toLocaleString(undefined, { maximumFractionDigits: 2 });
+}
+
+export function FinancialPanel() {
+ const [page, setPage] = useState(1);
+ const { data: overview, isLoading: ovLoading } = useFinancialOverview();
+ const { data: payouts, isLoading: payLoading } = usePayoutHistory(page, 20);
+
+ const totalPages = payouts ? Math.ceil(payouts.total / 20) : 1;
+
+ return (
+
+
Financial Overview
+
+ {/* Summary cards */}
+ {ovLoading && (
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
+ )}
+
+ {overview && (
+
+ {[
+ { label: 'Total $FNDRY Distributed', value: fmt(overview.total_fndry_distributed), accent: 'text-[#14F195]' },
+ { label: 'Paid Bounties', value: overview.total_paid_bounties },
+ { label: 'Avg Reward', value: fmt(overview.avg_reward), accent: 'text-[#9945FF]' },
+ { label: 'Highest Reward', value: fmt(overview.highest_reward) },
+ { label: 'Pending Payouts', value: overview.pending_payout_count, accent: overview.pending_payout_count > 0 ? 'text-yellow-400' : 'text-white' },
+ { label: 'Pending Amount', value: fmt(overview.pending_payout_amount), accent: 'text-yellow-400' },
+ ].map(({ label, value, accent = 'text-white' }) => (
+
+ ))}
+
+ )}
+
+ {/* Payout history table */}
+
+
Payout History
+
+ {payLoading && (
+
+ {Array.from({ length: 5 }).map((_, i) => (
+
+ ))}
+
+ )}
+
+ {payouts && payouts.items.length === 0 && (
+
No payouts yet
+ )}
+
+ {payouts && payouts.items.length > 0 && (
+
+
+
+
+ | Bounty |
+ Winner |
+ Amount |
+ Status |
+ Date |
+
+
+
+ {payouts.items.map((p, i) => (
+
+ |
+
+ {p.bounty_title}
+
+ |
+ {p.winner || '—'} |
+
+ {fmt(p.amount)}
+ |
+
+
+ {p.status}
+
+ |
+
+ {p.completed_at ? new Date(p.completed_at).toLocaleDateString() : '—'}
+ |
+
+ ))}
+
+
+
+ )}
+
+ {totalPages > 1 && (
+
+
+ {page} / {totalPages}
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/admin/OverviewPanel.tsx b/frontend/src/components/admin/OverviewPanel.tsx
new file mode 100644
index 00000000..076d148e
--- /dev/null
+++ b/frontend/src/components/admin/OverviewPanel.tsx
@@ -0,0 +1,114 @@
+/** Overview panel — 6-stat card grid + uptime. */
+import { useAdminOverview } from '../../hooks/useAdminData';
+
+interface StatCardProps {
+ label: string;
+ value: string | number;
+ sub?: string;
+ accent?: string;
+}
+
+function StatCard({ label, value, sub, accent = 'text-white' }: StatCardProps) {
+ return (
+
+
Platform Overview
+
+ {isLoading && (
+
+ {Array.from({ length: 6 }).map((_, i) => (
+
+ ))}
+
+ )}
+
+ {error && (
+
+ Failed to load overview: {(error as Error).message}
+
+ )}
+
+ {data && (
+ <>
+
+
+ 0 ? 'text-yellow-400' : 'text-white'}
+ />
+
+
+ 0 ? 'text-yellow-400' : 'text-white'}
+ />
+
+
+
+ {/* Tier breakdown */}
+
+
Bounty breakdown
+
+ {[
+ { label: 'Open', val: data.open_bounties, color: 'text-[#14F195]' },
+ { label: 'Completed', val: data.completed_bounties, color: 'text-[#9945FF]' },
+ { label: 'Cancelled', val: data.cancelled_bounties, color: 'text-gray-500' },
+ ].map(({ label, val, color }) => (
+
+ ))}
+
+
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/components/admin/ReviewPipeline.tsx b/frontend/src/components/admin/ReviewPipeline.tsx
new file mode 100644
index 00000000..5da79c70
--- /dev/null
+++ b/frontend/src/components/admin/ReviewPipeline.tsx
@@ -0,0 +1,124 @@
+/** Review pipeline panel — active reviews, pass/fail metrics. */
+import { useReviewPipeline } from '../../hooks/useAdminData';
+
+function ScoreBar({ score }: { score: number }) {
+ const pct = Math.min(100, Math.max(0, score * 10));
+ const color =
+ score >= 7 ? 'bg-[#14F195]' :
+ score >= 5 ? 'bg-yellow-400' : 'bg-red-400';
+ return (
+
+
Review Pipeline
+
+ {isLoading && (
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+ )}
+
+ {error &&
{(error as Error).message}
}
+
+ {data && (
+ <>
+ {/* Aggregate metrics */}
+
+
+
{data.total_active}
+
Pending
+
+
+
+ {(data.pass_rate * 100).toFixed(1)}%
+
+
Pass Rate
+
+
+
{data.avg_score.toFixed(2)}
+
Avg Score
+
+
+
+ {/* Active reviews table */}
+ {data.active.length === 0 ? (
+
+ ) : (
+
+
+
+
+ | Bounty |
+ Submitted by |
+ PR |
+ AI Score |
+ Threshold |
+ Submitted |
+
+
+
+ {data.active.map(r => (
+
+ |
+
+ {r.bounty_title}
+
+ |
+ {r.submitted_by} |
+
+ {r.pr_url ? (
+
+ {r.pr_url.replace('https://github.com/', '')}
+
+ ) : (
+ —
+ )}
+ |
+
+
+ |
+
+ {r.meets_threshold ? (
+ ✓ Pass
+ ) : (
+ ✗ Fail
+ )}
+ |
+
+ {new Date(r.submitted_at).toLocaleDateString()}
+ |
+
+ ))}
+
+
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/components/admin/SystemHealth.tsx b/frontend/src/components/admin/SystemHealth.tsx
new file mode 100644
index 00000000..2c721525
--- /dev/null
+++ b/frontend/src/components/admin/SystemHealth.tsx
@@ -0,0 +1,103 @@
+/** System health panel — service status, uptime, queue depth, WS connections. */
+import { useSystemHealth } from '../../hooks/useAdminData';
+
+function ServiceBadge({ name, status }: { name: string; status: string }) {
+ const ok = status === 'connected' || status === 'healthy';
+ return (
+
+
+
System Health
+ {dataUpdatedAt > 0 && (
+
+ Updated {new Date(dataUpdatedAt).toLocaleTimeString()}
+
+ )}
+
+
+ {isLoading && (
+
+ {Array.from({ length: 4 }).map((_, i) => (
+
+ ))}
+
+ )}
+
+ {error &&
{(error as Error).message}
}
+
+ {data && (
+ <>
+ {/* Overall status banner */}
+
+
+ {data.status}
+ {new Date(data.timestamp).toLocaleString()}
+
+
+ {/* Services */}
+
+
Services
+ {Object.entries(data.services).map(([name, status]) => (
+
+ ))}
+
+
+ {/* Metrics */}
+
+
+
+ 0 ? 'pending reviews' : 'queue clear'}
+ />
+
+
+
+
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/hooks/useAdminData.ts b/frontend/src/hooks/useAdminData.ts
new file mode 100644
index 00000000..c76ebaed
--- /dev/null
+++ b/frontend/src/hooks/useAdminData.ts
@@ -0,0 +1,253 @@
+/**
+ * React Query hooks for admin dashboard data.
+ * All requests include a Bearer token from sessionStorage.
+ * @module hooks/useAdminData
+ */
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import type {
+ AdminOverview,
+ BountyListAdminResponse,
+ BountyAdminUpdate,
+ BountyAdminCreate,
+ ContributorListAdminResponse,
+ TierHistoryResponse,
+ ReviewPipelineResponse,
+ FinancialOverview,
+ PayoutHistoryResponse,
+ SystemHealthResponse,
+ AuditLogResponse,
+} from '../types/admin';
+
+// ---------------------------------------------------------------------------
+// Auth helpers
+// ---------------------------------------------------------------------------
+
+const STORAGE_KEY = 'sf_admin_token';
+
+export function getAdminToken(): string {
+ return sessionStorage.getItem(STORAGE_KEY) ?? '';
+}
+
+export function setAdminToken(token: string): void {
+ if (token) {
+ sessionStorage.setItem(STORAGE_KEY, token);
+ } else {
+ sessionStorage.removeItem(STORAGE_KEY);
+ }
+}
+
+export function clearAdminToken(): void {
+ sessionStorage.removeItem(STORAGE_KEY);
+}
+
+// ---------------------------------------------------------------------------
+// Base fetch helper
+// ---------------------------------------------------------------------------
+
+const API_BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:8000';
+
+async function adminFetch