Added admin panel.

This commit is contained in:
2026-01-11 18:50:26 -06:00
parent e50b2f31d3
commit a97912188e
109 changed files with 6651 additions and 249 deletions

View File

@ -1,5 +1,24 @@
import { apiClient } from './client'
import type { SportEvent } from '@/types/sport-event'
import type {
AdminDashboardStats,
AuditLogListResponse,
WipePreview,
WipeRequest,
WipeResponse,
SeedRequest,
SeedResponse,
SimulationStatus,
SimulationConfig,
SimulationStartResponse,
SimulationStopResponse,
AdminUserListResponse,
AdminUserDetail,
UserUpdateRequest,
UserStatusRequest,
BalanceAdjustRequest,
BalanceAdjustResponse,
} from '@/types/admin'
export interface AdminSettings {
id: number
@ -28,6 +47,13 @@ export interface CreateEventData {
}
export const adminApi = {
// Dashboard
getDashboard: async (): Promise<AdminDashboardStats> => {
const response = await apiClient.get<AdminDashboardStats>('/api/v1/admin/dashboard')
return response.data
},
// Settings
getSettings: async (): Promise<AdminSettings> => {
const response = await apiClient.get<AdminSettings>('/api/v1/admin/settings')
return response.data
@ -38,6 +64,7 @@ export const adminApi = {
return response.data
},
// Events
createEvent: async (eventData: CreateEventData): Promise<SportEvent> => {
const response = await apiClient.post<SportEvent>('/api/v1/admin/events', eventData)
return response.data
@ -57,4 +84,111 @@ export const adminApi = {
const response = await apiClient.delete<{ message: string }>(`/api/v1/admin/events/${eventId}`)
return response.data
},
// Audit Logs
getAuditLogs: async (params?: {
page?: number
page_size?: number
action?: string
admin_id?: number
target_type?: string
}): Promise<AuditLogListResponse> => {
const queryParams = new URLSearchParams()
if (params?.page) queryParams.append('page', params.page.toString())
if (params?.page_size) queryParams.append('page_size', params.page_size.toString())
if (params?.action) queryParams.append('action', params.action)
if (params?.admin_id) queryParams.append('admin_id', params.admin_id.toString())
if (params?.target_type) queryParams.append('target_type', params.target_type)
const url = `/api/v1/admin/audit-logs${queryParams.toString() ? `?${queryParams}` : ''}`
const response = await apiClient.get<AuditLogListResponse>(url)
return response.data
},
// Data Wiper
getWipePreview: async (): Promise<WipePreview> => {
const response = await apiClient.get<WipePreview>('/api/v1/admin/data/wipe/preview')
return response.data
},
executeWipe: async (request: WipeRequest): Promise<WipeResponse> => {
const response = await apiClient.post<WipeResponse>('/api/v1/admin/data/wipe', request)
return response.data
},
// Data Seeder
seedDatabase: async (request: SeedRequest): Promise<SeedResponse> => {
const response = await apiClient.post<SeedResponse>('/api/v1/admin/data/seed', request)
return response.data
},
// Simulation
getSimulationStatus: async (): Promise<SimulationStatus> => {
const response = await apiClient.get<SimulationStatus>('/api/v1/admin/simulation/status')
return response.data
},
startSimulation: async (config?: SimulationConfig): Promise<SimulationStartResponse> => {
const response = await apiClient.post<SimulationStartResponse>('/api/v1/admin/simulation/start', {
config,
})
return response.data
},
stopSimulation: async (): Promise<SimulationStopResponse> => {
const response = await apiClient.post<SimulationStopResponse>('/api/v1/admin/simulation/stop')
return response.data
},
// User Management
getUsers: async (params?: {
page?: number
page_size?: number
search?: string
status_filter?: string
is_admin?: boolean
}): Promise<AdminUserListResponse> => {
const queryParams = new URLSearchParams()
if (params?.page) queryParams.append('page', params.page.toString())
if (params?.page_size) queryParams.append('page_size', params.page_size.toString())
if (params?.search) queryParams.append('search', params.search)
if (params?.status_filter) queryParams.append('status_filter', params.status_filter)
if (params?.is_admin !== undefined) queryParams.append('is_admin', params.is_admin.toString())
const url = `/api/v1/admin/users${queryParams.toString() ? `?${queryParams}` : ''}`
const response = await apiClient.get<AdminUserListResponse>(url)
return response.data
},
getUser: async (userId: number): Promise<AdminUserDetail> => {
const response = await apiClient.get<AdminUserDetail>(`/api/v1/admin/users/${userId}`)
return response.data
},
updateUser: async (
userId: number,
data: UserUpdateRequest
): Promise<{ message: string; changes: Record<string, unknown> }> => {
const response = await apiClient.patch<{ message: string; changes: Record<string, unknown> }>(
`/api/v1/admin/users/${userId}`,
data
)
return response.data
},
changeUserStatus: async (userId: number, data: UserStatusRequest): Promise<{ message: string }> => {
const response = await apiClient.patch<{ message: string }>(
`/api/v1/admin/users/${userId}/status`,
data
)
return response.data
},
adjustUserBalance: async (userId: number, data: BalanceAdjustRequest): Promise<BalanceAdjustResponse> => {
const response = await apiClient.post<BalanceAdjustResponse>(
`/api/v1/admin/users/${userId}/balance`,
data
)
return response.data
},
}

View File

@ -0,0 +1,173 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { adminApi } from '@/api/admin'
import { Loading } from '@/components/common/Loading'
import { Button } from '@/components/common/Button'
import { ClipboardList, ChevronLeft, ChevronRight, Filter, Clock } from 'lucide-react'
import { format } from 'date-fns'
const ACTION_LABELS: Record<string, { label: string; color: string }> = {
DATA_WIPE: { label: 'Data Wipe', color: 'bg-red-100 text-red-800' },
DATA_SEED: { label: 'Data Seed', color: 'bg-blue-100 text-blue-800' },
SIMULATION_START: { label: 'Simulation Start', color: 'bg-green-100 text-green-800' },
SIMULATION_STOP: { label: 'Simulation Stop', color: 'bg-yellow-100 text-yellow-800' },
USER_STATUS_CHANGE: { label: 'User Status', color: 'bg-purple-100 text-purple-800' },
USER_BALANCE_ADJUST: { label: 'Balance Adjust', color: 'bg-indigo-100 text-indigo-800' },
USER_ADMIN_GRANT: { label: 'Admin Grant', color: 'bg-blue-100 text-blue-800' },
USER_ADMIN_REVOKE: { label: 'Admin Revoke', color: 'bg-orange-100 text-orange-800' },
USER_UPDATE: { label: 'User Update', color: 'bg-gray-100 text-gray-800' },
SETTINGS_UPDATE: { label: 'Settings Update', color: 'bg-cyan-100 text-cyan-800' },
EVENT_CREATE: { label: 'Event Create', color: 'bg-green-100 text-green-800' },
EVENT_UPDATE: { label: 'Event Update', color: 'bg-blue-100 text-blue-800' },
EVENT_DELETE: { label: 'Event Delete', color: 'bg-red-100 text-red-800' },
}
export const AdminAuditLog = () => {
const [page, setPage] = useState(1)
const [actionFilter, setActionFilter] = useState('')
const [expandedLog, setExpandedLog] = useState<number | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['admin-audit-logs', page, actionFilter],
queryFn: () =>
adminApi.getAuditLogs({
page,
page_size: 25,
action: actionFilter || undefined,
}),
})
if (isLoading) {
return <Loading />
}
const totalPages = data ? Math.ceil(data.total / data.page_size) : 1
return (
<div className="space-y-6">
{/* Header */}
<div className="bg-white rounded-lg shadow p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<ClipboardList className="text-gray-400" size={24} />
<div>
<h2 className="text-xl font-semibold text-gray-900">Audit Log</h2>
<p className="text-sm text-gray-500">Track all admin actions on the platform</p>
</div>
</div>
<div className="flex items-center gap-2">
<Filter size={18} className="text-gray-400" />
<select
value={actionFilter}
onChange={(e) => {
setActionFilter(e.target.value)
setPage(1)
}}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
>
<option value="">All Actions</option>
{Object.entries(ACTION_LABELS).map(([value, { label }]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
</div>
</div>
{/* Logs List */}
<div className="bg-white rounded-lg shadow overflow-hidden">
{data?.logs.length === 0 ? (
<div className="p-8 text-center text-gray-500">No audit logs found</div>
) : (
<div className="divide-y">
{data?.logs.map((log) => {
const actionInfo = ACTION_LABELS[log.action] || {
label: log.action,
color: 'bg-gray-100 text-gray-800',
}
const isExpanded = expandedLog === log.id
let details: Record<string, any> | null = null
try {
details = log.details ? JSON.parse(log.details) : null
} catch {
details = null
}
return (
<div
key={log.id}
className={`p-4 hover:bg-gray-50 cursor-pointer transition-colors ${
isExpanded ? 'bg-gray-50' : ''
}`}
onClick={() => setExpandedLog(isExpanded ? null : log.id)}
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-4">
<div
className={`px-2 py-1 rounded text-xs font-medium ${actionInfo.color}`}
>
{actionInfo.label}
</div>
<div>
<p className="text-gray-900">{log.description}</p>
<div className="flex items-center gap-4 mt-1 text-sm text-gray-500">
<span>By: {log.admin_username}</span>
{log.target_type && (
<span>
Target: {log.target_type} #{log.target_id}
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2 text-sm text-gray-500">
<Clock size={14} />
<span>{format(new Date(log.created_at), 'MMM d, yyyy h:mm a')}</span>
</div>
</div>
{/* Expanded Details */}
{isExpanded && details && (
<div className="mt-4 p-3 bg-gray-100 rounded-lg">
<p className="text-sm font-medium text-gray-600 mb-2">Details:</p>
<pre className="text-xs text-gray-700 overflow-x-auto">
{JSON.stringify(details, null, 2)}
</pre>
{log.ip_address && (
<p className="mt-2 text-xs text-gray-500">IP: {log.ip_address}</p>
)}
</div>
)}
</div>
)
})}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t bg-gray-50">
<p className="text-sm text-gray-600">
Page {page} of {totalPages} ({data?.total || 0} total logs)
</p>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => setPage(page - 1)} disabled={page === 1}>
<ChevronLeft size={18} />
</Button>
<Button
variant="secondary"
onClick={() => setPage(page + 1)}
disabled={page >= totalPages}
>
<ChevronRight size={18} />
</Button>
</div>
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,282 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { adminApi } from '@/api/admin'
import { Button } from '@/components/common/Button'
import { Loading } from '@/components/common/Loading'
import { AlertTriangle, Database, Trash2, RefreshCw } from 'lucide-react'
import toast from 'react-hot-toast'
import type { SeedRequest } from '@/types/admin'
export const AdminDataTools = () => {
const queryClient = useQueryClient()
const [showWipeModal, setShowWipeModal] = useState(false)
const [confirmPhrase, setConfirmPhrase] = useState('')
const [preserveAdmins, setPreserveAdmins] = useState(true)
const [preserveEvents, setPreserveEvents] = useState(false)
const [seedConfig, setSeedConfig] = useState<SeedRequest>({
num_users: 10,
num_events: 5,
num_bets_per_event: 3,
starting_balance: 1000,
create_admin: true,
})
const { data: wipePreview, isLoading: isLoadingPreview, refetch: refetchPreview } = useQuery({
queryKey: ['wipe-preview'],
queryFn: adminApi.getWipePreview,
})
const wipeMutation = useMutation({
mutationFn: adminApi.executeWipe,
onSuccess: (data) => {
toast.success(data.message)
setShowWipeModal(false)
setConfirmPhrase('')
queryClient.invalidateQueries()
refetchPreview()
},
onError: (error: any) => {
toast.error(error.response?.data?.detail || 'Wipe failed')
},
})
const seedMutation = useMutation({
mutationFn: adminApi.seedDatabase,
onSuccess: (data) => {
toast.success(data.message)
if (data.test_admin) {
toast.success(`Test admin created: ${data.test_admin.username} / ${data.test_admin.password}`, {
duration: 10000,
})
}
queryClient.invalidateQueries()
refetchPreview()
},
onError: (error: any) => {
toast.error(error.response?.data?.detail || 'Seed failed')
},
})
const handleWipe = () => {
wipeMutation.mutate({
confirmation_phrase: confirmPhrase,
preserve_admin_users: preserveAdmins,
preserve_events: preserveEvents,
})
}
const handleSeed = () => {
seedMutation.mutate(seedConfig)
}
if (isLoadingPreview) {
return <Loading />
}
return (
<div className="space-y-6">
{/* Wipe Preview */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center gap-3 mb-4">
<Trash2 className="text-red-500" size={24} />
<h2 className="text-xl font-semibold text-gray-900">Data Wiper</h2>
</div>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
<div className="flex items-start gap-3">
<AlertTriangle className="text-red-500 flex-shrink-0 mt-0.5" size={20} />
<div>
<p className="text-red-800 font-medium">Danger Zone</p>
<p className="text-red-600 text-sm">
This action will permanently delete data from the database. Admin users and settings are preserved by default.
</p>
</div>
</div>
</div>
{wipePreview && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-gray-600 text-sm">Users</p>
<p className="text-2xl font-bold text-gray-900">{wipePreview.users_count}</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-gray-600 text-sm">Spread Bets</p>
<p className="text-2xl font-bold text-gray-900">{wipePreview.spread_bets_count}</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-gray-600 text-sm">Events</p>
<p className="text-2xl font-bold text-gray-900">{wipePreview.events_count}</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-gray-600 text-sm">Transactions</p>
<p className="text-2xl font-bold text-gray-900">{wipePreview.transactions_count}</p>
</div>
</div>
)}
{wipePreview && !wipePreview.can_wipe && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-4">
<p className="text-yellow-800">
Cooldown active. Please wait {wipePreview.cooldown_remaining_seconds} seconds before wiping again.
</p>
</div>
)}
<Button
variant="secondary"
onClick={() => setShowWipeModal(true)}
disabled={!wipePreview?.can_wipe}
className="bg-red-600 hover:bg-red-700 text-white border-red-600"
>
<Trash2 size={18} className="mr-2" />
Wipe Database
</Button>
</div>
{/* Wipe Confirmation Modal */}
{showWipeModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 p-6">
<h3 className="text-xl font-bold text-red-600 mb-4">Confirm Database Wipe</h3>
<div className="space-y-4 mb-6">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={preserveAdmins}
onChange={(e) => setPreserveAdmins(e.target.checked)}
className="rounded"
/>
<span className="text-gray-700">Preserve admin users</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={preserveEvents}
onChange={(e) => setPreserveEvents(e.target.checked)}
className="rounded"
/>
<span className="text-gray-700">Preserve events (delete bets only)</span>
</label>
<div>
<label className="block text-gray-700 font-medium mb-2">
Type <span className="font-mono bg-gray-100 px-2 py-1 rounded">CONFIRM WIPE</span> to proceed:
</label>
<input
type="text"
value={confirmPhrase}
onChange={(e) => setConfirmPhrase(e.target.value)}
placeholder="CONFIRM WIPE"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
/>
</div>
</div>
<div className="flex gap-3">
<Button
variant="secondary"
onClick={() => {
setShowWipeModal(false)
setConfirmPhrase('')
}}
className="flex-1"
>
Cancel
</Button>
<Button
onClick={handleWipe}
disabled={confirmPhrase !== 'CONFIRM WIPE' || wipeMutation.isPending}
className="flex-1 bg-red-600 hover:bg-red-700"
>
{wipeMutation.isPending ? 'Wiping...' : 'Wipe Data'}
</Button>
</div>
</div>
</div>
)}
{/* Seeder */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center gap-3 mb-4">
<Database className="text-blue-500" size={24} />
<h2 className="text-xl font-semibold text-gray-900">Data Seeder</h2>
</div>
<p className="text-gray-600 mb-4">
Populate the database with test data for development and testing.
</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
<div>
<label className="block text-gray-700 text-sm font-medium mb-1">Number of Users</label>
<input
type="number"
value={seedConfig.num_users}
onChange={(e) => setSeedConfig({ ...seedConfig, num_users: parseInt(e.target.value) || 0 })}
min={1}
max={100}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label className="block text-gray-700 text-sm font-medium mb-1">Number of Events</label>
<input
type="number"
value={seedConfig.num_events}
onChange={(e) => setSeedConfig({ ...seedConfig, num_events: parseInt(e.target.value) || 0 })}
min={0}
max={50}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label className="block text-gray-700 text-sm font-medium mb-1">Bets per Event</label>
<input
type="number"
value={seedConfig.num_bets_per_event}
onChange={(e) => setSeedConfig({ ...seedConfig, num_bets_per_event: parseInt(e.target.value) || 0 })}
min={0}
max={20}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label className="block text-gray-700 text-sm font-medium mb-1">Starting Balance</label>
<input
type="number"
value={seedConfig.starting_balance}
onChange={(e) => setSeedConfig({ ...seedConfig, starting_balance: parseInt(e.target.value) || 0 })}
min={100}
max={10000}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={seedConfig.create_admin}
onChange={(e) => setSeedConfig({ ...seedConfig, create_admin: e.target.checked })}
className="rounded"
/>
<span className="text-gray-700">Create test admin</span>
</label>
</div>
</div>
<Button onClick={handleSeed} disabled={seedMutation.isPending}>
<RefreshCw size={18} className={`mr-2 ${seedMutation.isPending ? 'animate-spin' : ''}`} />
{seedMutation.isPending ? 'Seeding...' : 'Seed Database'}
</Button>
</div>
</div>
)
}

View File

@ -0,0 +1,226 @@
import { useState, useEffect } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { adminApi } from '@/api/admin'
import { Button } from '@/components/common/Button'
import { Loading } from '@/components/common/Loading'
import { Play, Square, Settings, Activity } from 'lucide-react'
import toast from 'react-hot-toast'
import type { SimulationConfig } from '@/types/admin'
export const AdminSimulation = () => {
const queryClient = useQueryClient()
const [showConfig, setShowConfig] = useState(false)
const [config, setConfig] = useState<SimulationConfig>({
delay_seconds: 2.0,
actions_per_iteration: 3,
create_users: true,
create_bets: true,
take_bets: true,
add_comments: true,
cancel_bets: true,
})
const { data: status, isLoading, refetch } = useQuery({
queryKey: ['simulation-status'],
queryFn: adminApi.getSimulationStatus,
refetchInterval: (query) => (query.state.data?.is_running ? 2000 : false),
})
// Update config when status loads
useEffect(() => {
if (status?.config) {
setConfig(status.config)
}
}, [status?.config])
const startMutation = useMutation({
mutationFn: () => adminApi.startSimulation(config),
onSuccess: (data) => {
toast.success(data.message)
queryClient.invalidateQueries({ queryKey: ['simulation-status'] })
queryClient.invalidateQueries({ queryKey: ['admin-dashboard'] })
},
onError: (error: any) => {
toast.error(error.response?.data?.detail || 'Failed to start simulation')
},
})
const stopMutation = useMutation({
mutationFn: adminApi.stopSimulation,
onSuccess: (data) => {
toast.success(`${data.message} (${data.total_iterations} iterations, ${data.ran_for_seconds.toFixed(1)}s)`)
queryClient.invalidateQueries({ queryKey: ['simulation-status'] })
queryClient.invalidateQueries({ queryKey: ['admin-dashboard'] })
},
onError: (error: any) => {
toast.error(error.response?.data?.detail || 'Failed to stop simulation')
},
})
if (isLoading) {
return <Loading />
}
return (
<div className="space-y-6">
{/* Status Card */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<Activity className={status?.is_running ? 'text-green-500 animate-pulse' : 'text-gray-400'} size={24} />
<h2 className="text-xl font-semibold text-gray-900">Activity Simulation</h2>
</div>
<span
className={`px-3 py-1 rounded-full text-sm font-medium ${
status?.is_running ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'
}`}
>
{status?.is_running ? 'Running' : 'Stopped'}
</span>
</div>
<p className="text-gray-600 mb-6">
Simulate random user activity including creating users, placing bets, matching bets, and adding comments.
</p>
{status?.is_running && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-gray-600 text-sm">Started By</p>
<p className="font-semibold text-gray-900">{status.started_by}</p>
</div>
<div>
<p className="text-gray-600 text-sm">Iterations</p>
<p className="font-semibold text-gray-900">{status.iterations_completed}</p>
</div>
<div>
<p className="text-gray-600 text-sm">Started At</p>
<p className="font-semibold text-gray-900">
{status.started_at ? new Date(status.started_at).toLocaleTimeString() : '-'}
</p>
</div>
<div>
<p className="text-gray-600 text-sm">Last Activity</p>
<p className="font-semibold text-gray-900 truncate">{status.last_activity || '-'}</p>
</div>
</div>
</div>
)}
<div className="flex gap-3">
{status?.is_running ? (
<Button
onClick={() => stopMutation.mutate()}
disabled={stopMutation.isPending}
className="bg-red-600 hover:bg-red-700"
>
<Square size={18} className="mr-2" />
{stopMutation.isPending ? 'Stopping...' : 'Stop Simulation'}
</Button>
) : (
<>
<Button onClick={() => startMutation.mutate()} disabled={startMutation.isPending}>
<Play size={18} className="mr-2" />
{startMutation.isPending ? 'Starting...' : 'Start Simulation'}
</Button>
<Button variant="secondary" onClick={() => setShowConfig(!showConfig)}>
<Settings size={18} className="mr-2" />
Configure
</Button>
</>
)}
</div>
</div>
{/* Configuration */}
{showConfig && !status?.is_running && (
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Simulation Configuration</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
<div>
<label className="block text-gray-700 text-sm font-medium mb-1">Delay (seconds)</label>
<input
type="number"
value={config.delay_seconds}
onChange={(e) => setConfig({ ...config, delay_seconds: parseFloat(e.target.value) || 0.5 })}
min={0.5}
max={30}
step={0.5}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label className="block text-gray-700 text-sm font-medium mb-1">Actions per Iteration</label>
<input
type="number"
value={config.actions_per_iteration}
onChange={(e) => setConfig({ ...config, actions_per_iteration: parseInt(e.target.value) || 1 })}
min={1}
max={10}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
</div>
<div className="space-y-3">
<p className="text-gray-700 font-medium">Enabled Actions:</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={config.create_users}
onChange={(e) => setConfig({ ...config, create_users: e.target.checked })}
className="rounded"
/>
<span className="text-gray-700">Create Users</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={config.create_bets}
onChange={(e) => setConfig({ ...config, create_bets: e.target.checked })}
className="rounded"
/>
<span className="text-gray-700">Create Bets</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={config.take_bets}
onChange={(e) => setConfig({ ...config, take_bets: e.target.checked })}
className="rounded"
/>
<span className="text-gray-700">Take/Match Bets</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={config.add_comments}
onChange={(e) => setConfig({ ...config, add_comments: e.target.checked })}
className="rounded"
/>
<span className="text-gray-700">Add Comments</span>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={config.cancel_bets}
onChange={(e) => setConfig({ ...config, cancel_bets: e.target.checked })}
className="rounded"
/>
<span className="text-gray-700">Cancel Bets</span>
</label>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,333 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { adminApi } from '@/api/admin'
import { Button } from '@/components/common/Button'
import { Loading } from '@/components/common/Loading'
import { Search, User, Shield, Ban, DollarSign, Edit, ChevronLeft, ChevronRight } from 'lucide-react'
import toast from 'react-hot-toast'
import type { AdminUser, BalanceAdjustRequest } from '@/types/admin'
export const AdminUsers = () => {
const queryClient = useQueryClient()
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [searchInput, setSearchInput] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('')
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null)
const [showBalanceModal, setShowBalanceModal] = useState(false)
const [balanceAmount, setBalanceAmount] = useState('')
const [balanceReason, setBalanceReason] = useState('')
const { data, isLoading } = useQuery({
queryKey: ['admin-users', page, search, statusFilter],
queryFn: () =>
adminApi.getUsers({
page,
page_size: 20,
search: search || undefined,
status_filter: statusFilter || undefined,
}),
})
const statusMutation = useMutation({
mutationFn: ({ userId, status }: { userId: number; status: 'active' | 'suspended' }) =>
adminApi.changeUserStatus(userId, { status }),
onSuccess: () => {
toast.success('User status updated')
queryClient.invalidateQueries({ queryKey: ['admin-users'] })
setSelectedUser(null)
},
onError: (error: any) => {
toast.error(error.response?.data?.detail || 'Failed to update status')
},
})
const adminMutation = useMutation({
mutationFn: ({ userId, isAdmin }: { userId: number; isAdmin: boolean }) =>
adminApi.updateUser(userId, { is_admin: isAdmin }),
onSuccess: () => {
toast.success('Admin privileges updated')
queryClient.invalidateQueries({ queryKey: ['admin-users'] })
},
onError: (error: any) => {
toast.error(error.response?.data?.detail || 'Failed to update privileges')
},
})
const balanceMutation = useMutation({
mutationFn: ({ userId, data }: { userId: number; data: BalanceAdjustRequest }) =>
adminApi.adjustUserBalance(userId, data),
onSuccess: (data) => {
toast.success(`Balance adjusted: $${data.previous_balance}$${data.new_balance}`)
queryClient.invalidateQueries({ queryKey: ['admin-users'] })
setShowBalanceModal(false)
setSelectedUser(null)
setBalanceAmount('')
setBalanceReason('')
},
onError: (error: any) => {
toast.error(error.response?.data?.detail || 'Failed to adjust balance')
},
})
const handleSearch = () => {
setSearch(searchInput)
setPage(1)
}
const handleBalanceSubmit = () => {
if (!selectedUser || !balanceAmount || !balanceReason) return
balanceMutation.mutate({
userId: selectedUser.id,
data: {
amount: parseFloat(balanceAmount),
reason: balanceReason,
},
})
}
if (isLoading) {
return <Loading />
}
const totalPages = data ? Math.ceil(data.total / data.page_size) : 1
return (
<div className="space-y-6">
{/* Search and Filters */}
<div className="bg-white rounded-lg shadow p-4">
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1 flex gap-2">
<input
type="text"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="Search by username, email, or name..."
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg"
/>
<Button onClick={handleSearch}>
<Search size={18} />
</Button>
</div>
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value)
setPage(1)
}}
className="px-3 py-2 border border-gray-300 rounded-lg"
>
<option value="">All Status</option>
<option value="active">Active</option>
<option value="suspended">Suspended</option>
</select>
</div>
</div>
{/* Users Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b">
<tr>
<th className="text-left px-4 py-3 text-sm font-semibold text-gray-600">User</th>
<th className="text-left px-4 py-3 text-sm font-semibold text-gray-600">Status</th>
<th className="text-left px-4 py-3 text-sm font-semibold text-gray-600">Balance</th>
<th className="text-left px-4 py-3 text-sm font-semibold text-gray-600">Stats</th>
<th className="text-left px-4 py-3 text-sm font-semibold text-gray-600">Joined</th>
<th className="text-right px-4 py-3 text-sm font-semibold text-gray-600">Actions</th>
</tr>
</thead>
<tbody className="divide-y">
{data?.users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center">
<User size={20} className="text-gray-500" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">{user.username}</span>
{user.is_admin && (
<Shield size={14} className="text-blue-500" title="Admin" />
)}
</div>
<p className="text-sm text-gray-500">{user.email}</p>
</div>
</div>
</td>
<td className="px-4 py-3">
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${
user.status === 'active'
? 'bg-green-100 text-green-800'
: user.status === 'suspended'
? 'bg-red-100 text-red-800'
: 'bg-yellow-100 text-yellow-800'
}`}
>
{user.status}
</span>
</td>
<td className="px-4 py-3">
<div>
<p className="font-medium text-gray-900">${Number(user.balance).toFixed(2)}</p>
{Number(user.escrow) > 0 && (
<p className="text-xs text-gray-500">+ ${Number(user.escrow).toFixed(2)} escrow</p>
)}
</div>
</td>
<td className="px-4 py-3">
<div className="text-sm">
<p className="text-gray-900">
{user.wins}W / {user.losses}L
</p>
<p className="text-gray-500">{(user.win_rate * 100).toFixed(0)}% win rate</p>
</div>
</td>
<td className="px-4 py-3 text-sm text-gray-500">
{new Date(user.created_at).toLocaleDateString()}
</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-2">
<button
onClick={() => {
setSelectedUser(user)
setShowBalanceModal(true)
}}
className="p-1.5 text-gray-500 hover:text-green-600 hover:bg-green-50 rounded"
title="Adjust Balance"
>
<DollarSign size={18} />
</button>
<button
onClick={() =>
statusMutation.mutate({
userId: user.id,
status: user.status === 'active' ? 'suspended' : 'active',
})
}
className={`p-1.5 rounded ${
user.status === 'active'
? 'text-gray-500 hover:text-red-600 hover:bg-red-50'
: 'text-gray-500 hover:text-green-600 hover:bg-green-50'
}`}
title={user.status === 'active' ? 'Suspend User' : 'Activate User'}
>
<Ban size={18} />
</button>
<button
onClick={() =>
adminMutation.mutate({
userId: user.id,
isAdmin: !user.is_admin,
})
}
className={`p-1.5 rounded ${
user.is_admin
? 'text-blue-500 hover:text-gray-600 hover:bg-gray-50'
: 'text-gray-500 hover:text-blue-600 hover:bg-blue-50'
}`}
title={user.is_admin ? 'Remove Admin' : 'Make Admin'}
>
<Shield size={18} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t bg-gray-50">
<p className="text-sm text-gray-600">
Showing {(page - 1) * 20 + 1} to {Math.min(page * 20, data?.total || 0)} of {data?.total || 0} users
</p>
<div className="flex gap-2">
<Button
variant="secondary"
onClick={() => setPage(page - 1)}
disabled={page === 1}
>
<ChevronLeft size={18} />
</Button>
<Button
variant="secondary"
onClick={() => setPage(page + 1)}
disabled={page >= totalPages}
>
<ChevronRight size={18} />
</Button>
</div>
</div>
)}
</div>
{/* Balance Adjustment Modal */}
{showBalanceModal && selectedUser && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 p-6">
<h3 className="text-xl font-bold text-gray-900 mb-4">Adjust Balance</h3>
<div className="mb-4 p-3 bg-gray-50 rounded-lg">
<p className="text-sm text-gray-600">User: <span className="font-medium">{selectedUser.username}</span></p>
<p className="text-sm text-gray-600">Current Balance: <span className="font-medium">${Number(selectedUser.balance).toFixed(2)}</span></p>
</div>
<div className="space-y-4 mb-6">
<div>
<label className="block text-gray-700 font-medium mb-1">Amount (+ to add, - to subtract)</label>
<input
type="number"
value={balanceAmount}
onChange={(e) => setBalanceAmount(e.target.value)}
placeholder="e.g., 100 or -50"
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
<div>
<label className="block text-gray-700 font-medium mb-1">Reason</label>
<textarea
value={balanceReason}
onChange={(e) => setBalanceReason(e.target.value)}
placeholder="Reason for adjustment..."
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg"
/>
</div>
</div>
<div className="flex gap-3">
<Button
variant="secondary"
onClick={() => {
setShowBalanceModal(false)
setSelectedUser(null)
setBalanceAmount('')
setBalanceReason('')
}}
className="flex-1"
>
Cancel
</Button>
<Button
onClick={handleBalanceSubmit}
disabled={!balanceAmount || !balanceReason || balanceMutation.isPending}
className="flex-1"
>
{balanceMutation.isPending ? 'Adjusting...' : 'Adjust Balance'}
</Button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -12,7 +12,8 @@ import {
Rocket,
Share2,
Settings,
Receipt
Receipt,
Shield
} from 'lucide-react'
import { useWeb3Wallet } from '@/blockchain/hooks/useWeb3Wallet'
import { Button } from '@/components/common/Button'
@ -153,6 +154,16 @@ function ProfileDropdown() {
<Wallet size={16} />
Wallet
</Link>
{user.is_admin && (
<Link
to="/admin"
onClick={() => setIsOpen(false)}
className="flex items-center gap-3 px-4 py-2 text-blue-600 hover:bg-blue-50 transition-colors"
>
<Shield size={16} />
Admin Panel
</Link>
)}
</div>
{/* Web3 Wallet section */}

View File

@ -1,15 +1,271 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useQuery } from '@tanstack/react-query'
import { Layout } from '@/components/layout/Layout'
import { Loading } from '@/components/common/Loading'
import { adminApi } from '@/api/admin'
import { AdminDataTools } from '@/components/admin/AdminDataTools'
import { AdminSimulation } from '@/components/admin/AdminSimulation'
import { AdminUsers } from '@/components/admin/AdminUsers'
import { AdminAuditLog } from '@/components/admin/AdminAuditLog'
import {
LayoutDashboard,
Users,
Calendar,
Database,
Activity,
ClipboardList,
Settings,
TrendingUp,
DollarSign,
Percent,
} from 'lucide-react'
type TabType = 'dashboard' | 'users' | 'events' | 'data' | 'simulation' | 'audit'
export const Admin = () => {
const [activeTab, setActiveTab] = useState<TabType>('dashboard')
const { data: dashboard, isLoading: isDashboardLoading } = useQuery({
queryKey: ['admin-dashboard'],
queryFn: adminApi.getDashboard,
})
const { data: settings } = useQuery({
queryKey: ['admin-settings'],
queryFn: adminApi.getSettings,
})
const tabs: { id: TabType; label: string; icon: React.ReactNode }[] = [
{ id: 'dashboard', label: 'Dashboard', icon: <LayoutDashboard size={18} /> },
{ id: 'users', label: 'Users', icon: <Users size={18} /> },
{ id: 'events', label: 'Events', icon: <Calendar size={18} /> },
{ id: 'data', label: 'Data Tools', icon: <Database size={18} /> },
{ id: 'simulation', label: 'Simulation', icon: <Activity size={18} /> },
{ id: 'audit', label: 'Audit Log', icon: <ClipboardList size={18} /> },
]
return (
<Layout>
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Admin Panel</h1>
<p className="text-gray-600 mt-1">Manage the H2H platform</p>
</div>
{dashboard?.simulation_running && (
<div className="flex items-center gap-2 px-4 py-2 bg-green-100 text-green-800 rounded-lg">
<Activity size={18} className="animate-pulse" />
<span className="font-medium">Simulation Running</span>
</div>
)}
</div>
{/* Tabs */}
<div className="bg-white rounded-lg shadow">
<div className="flex border-b overflow-x-auto">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-6 py-4 text-sm font-medium whitespace-nowrap transition-colors ${
activeTab === tab.id
? 'text-blue-600 border-b-2 border-blue-600 bg-blue-50/50'
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
}`}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
</div>
{/* Tab Content */}
<div>
{activeTab === 'dashboard' && (
<DashboardTab dashboard={dashboard} settings={settings} isLoading={isDashboardLoading} />
)}
{activeTab === 'users' && <AdminUsers />}
{activeTab === 'events' && <EventsTab />}
{activeTab === 'data' && <AdminDataTools />}
{activeTab === 'simulation' && <AdminSimulation />}
{activeTab === 'audit' && <AdminAuditLog />}
</div>
</div>
</Layout>
)
}
// Dashboard Tab Component
const DashboardTab = ({
dashboard,
settings,
isLoading,
}: {
dashboard: any
settings: any
isLoading: boolean
}) => {
if (isLoading) {
return <Loading />
}
return (
<div className="space-y-6">
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
title="Total Users"
value={dashboard?.total_users || 0}
subtitle={`${dashboard?.active_users || 0} active`}
icon={<Users className="text-blue-500" size={24} />}
/>
<StatCard
title="Total Events"
value={dashboard?.total_events || 0}
subtitle={`${dashboard?.upcoming_events || 0} upcoming`}
icon={<Calendar className="text-green-500" size={24} />}
/>
<StatCard
title="Total Bets"
value={dashboard?.total_bets || 0}
subtitle={`${dashboard?.open_bets || 0} open, ${dashboard?.matched_bets || 0} matched`}
icon={<TrendingUp className="text-purple-500" size={24} />}
/>
<StatCard
title="Total Volume"
value={`$${(dashboard?.total_volume || 0).toLocaleString()}`}
subtitle={`$${(dashboard?.escrow_locked || 0).toLocaleString()} in escrow`}
icon={<DollarSign className="text-yellow-500" size={24} />}
/>
</div>
{/* Settings */}
{settings && (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center gap-3 mb-4">
<Settings className="text-gray-400" size={24} />
<h2 className="text-xl font-semibold text-gray-900">Platform Settings</h2>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gray-50 rounded-lg p-3">
<div className="flex items-center gap-2 text-gray-600 text-sm mb-1">
<Percent size={14} />
<span>House Commission</span>
</div>
<p className="text-xl font-bold text-gray-900">{settings.default_house_commission_percent}%</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-gray-600 text-sm mb-1">Min Bet</p>
<p className="text-xl font-bold text-gray-900">${settings.default_min_bet_amount}</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-gray-600 text-sm mb-1">Max Bet</p>
<p className="text-xl font-bold text-gray-900">${settings.default_max_bet_amount}</p>
</div>
<div className="bg-gray-50 rounded-lg p-3">
<p className="text-gray-600 text-sm mb-1">Spread Range</p>
<p className="text-xl font-bold text-gray-900">
{settings.default_min_spread} to {settings.default_max_spread}
</p>
</div>
</div>
</div>
)}
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">User Status</h3>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-gray-600">Active</span>
<span className="font-semibold text-green-600">{dashboard?.active_users || 0}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Suspended</span>
<span className="font-semibold text-red-600">{dashboard?.suspended_users || 0}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Admins</span>
<span className="font-semibold text-blue-600">{dashboard?.admin_users || 0}</span>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Event Status</h3>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-gray-600">Upcoming</span>
<span className="font-semibold text-blue-600">{dashboard?.upcoming_events || 0}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Live</span>
<span className="font-semibold text-green-600">{dashboard?.live_events || 0}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Total</span>
<span className="font-semibold text-gray-900">{dashboard?.total_events || 0}</span>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Bet Status</h3>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-gray-600">Open</span>
<span className="font-semibold text-yellow-600">{dashboard?.open_bets || 0}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Matched</span>
<span className="font-semibold text-green-600">{dashboard?.matched_bets || 0}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-600">Total</span>
<span className="font-semibold text-gray-900">{dashboard?.total_bets || 0}</span>
</div>
</div>
</div>
</div>
</div>
)
}
// Stat Card Component
const StatCard = ({
title,
value,
subtitle,
icon,
}: {
title: string
value: string | number
subtitle: string
icon: React.ReactNode
}) => (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-2">
<span className="text-gray-600 text-sm">{title}</span>
{icon}
</div>
<p className="text-2xl font-bold text-gray-900">{value}</p>
<p className="text-sm text-gray-500 mt-1">{subtitle}</p>
</div>
)
// Events Tab Component (keeping existing functionality)
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Button } from '@/components/common/Button'
import { Input } from '@/components/common/Input'
import { Loading } from '@/components/common/Loading'
import { adminApi, type CreateEventData } from '@/api/admin'
import { SportType } from '@/types/sport-event'
import { toast } from 'react-hot-toast'
import { Plus, Trash2 } from 'lucide-react'
import type { CreateEventData } from '@/api/admin'
export const Admin = () => {
const EventsTab = () => {
const queryClient = useQueryClient()
const [showCreateForm, setShowCreateForm] = useState(false)
const [formData, setFormData] = useState<CreateEventData>({
@ -22,11 +278,6 @@ export const Admin = () => {
league: '',
})
const { data: settings } = useQuery({
queryKey: ['admin-settings'],
queryFn: adminApi.getSettings,
})
const { data: events, isLoading } = useQuery({
queryKey: ['admin-events'],
queryFn: () => adminApi.getEvents(),
@ -38,6 +289,7 @@ export const Admin = () => {
toast.success('Event created successfully!')
queryClient.invalidateQueries({ queryKey: ['admin-events'] })
queryClient.invalidateQueries({ queryKey: ['sport-events'] })
queryClient.invalidateQueries({ queryKey: ['admin-dashboard'] })
setShowCreateForm(false)
setFormData({
sport: SportType.FOOTBALL,
@ -60,6 +312,7 @@ export const Admin = () => {
toast.success('Event deleted successfully!')
queryClient.invalidateQueries({ queryKey: ['admin-events'] })
queryClient.invalidateQueries({ queryKey: ['sport-events'] })
queryClient.invalidateQueries({ queryKey: ['admin-dashboard'] })
},
onError: (error: any) => {
toast.error(error.response?.data?.detail || 'Failed to delete event')
@ -78,207 +331,168 @@ export const Admin = () => {
}
return (
<Layout>
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold text-gray-900">Admin Panel</h1>
<p className="text-gray-600 mt-2">Manage sporting events and platform settings</p>
</div>
<Button onClick={() => setShowCreateForm(!showCreateForm)}>
<Plus size={20} className="mr-2" />
{showCreateForm ? 'Cancel' : 'Create Event'}
</Button>
<div className="space-y-6">
{/* Create Button */}
<div className="flex justify-end">
<Button onClick={() => setShowCreateForm(!showCreateForm)}>
<Plus size={20} className="mr-2" />
{showCreateForm ? 'Cancel' : 'Create Event'}
</Button>
</div>
{/* Create Form */}
{showCreateForm && (
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Create New Event</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Sport</label>
<select
value={formData.sport}
onChange={(e) => setFormData({ ...formData, sport: e.target.value as SportType })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
>
{Object.values(SportType).map((sport) => (
<option key={sport} value={sport}>
{sport.charAt(0).toUpperCase() + sport.slice(1)}
</option>
))}
</select>
</div>
<Input
label="League"
value={formData.league || ''}
onChange={(e) => setFormData({ ...formData, league: e.target.value })}
placeholder="e.g., NFL, NBA, NCAA"
/>
<Input
label="Home Team"
value={formData.home_team}
onChange={(e) => setFormData({ ...formData, home_team: e.target.value })}
required
/>
<Input
label="Away Team"
value={formData.away_team}
onChange={(e) => setFormData({ ...formData, away_team: e.target.value })}
required
/>
<Input
label="Official Spread"
type="number"
step="0.5"
value={formData.official_spread}
onChange={(e) => setFormData({ ...formData, official_spread: parseFloat(e.target.value) })}
required
/>
<Input
label="Game Time"
type="datetime-local"
value={formData.game_time}
onChange={(e) => setFormData({ ...formData, game_time: e.target.value })}
required
/>
<Input
label="Venue"
value={formData.venue || ''}
onChange={(e) => setFormData({ ...formData, venue: e.target.value })}
placeholder="Stadium/Arena name"
/>
</div>
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="secondary"
onClick={() => setShowCreateForm(false)}
disabled={createEventMutation.isPending}
className="flex-1"
>
Cancel
</Button>
<Button type="submit" disabled={createEventMutation.isPending} className="flex-1">
{createEventMutation.isPending ? 'Creating...' : 'Create Event'}
</Button>
</div>
</form>
</div>
)}
{/* Events List */}
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold">All Events</h2>
</div>
{settings && (
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Platform Settings</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<p className="text-gray-600">House Commission</p>
<p className="font-semibold">{settings.default_house_commission_percent}%</p>
</div>
<div>
<p className="text-gray-600">Min Bet</p>
<p className="font-semibold">${settings.default_min_bet_amount}</p>
</div>
<div>
<p className="text-gray-600">Max Bet</p>
<p className="font-semibold">${settings.default_max_bet_amount}</p>
</div>
<div>
<p className="text-gray-600">Spread Range</p>
<p className="font-semibold">
{settings.default_min_spread} to {settings.default_max_spread}
</p>
</div>
</div>
{isLoading ? (
<div className="p-6">
<Loading />
</div>
)}
{showCreateForm && (
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Create New Event</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Sport
</label>
<select
value={formData.sport}
onChange={(e) =>
setFormData({ ...formData, sport: e.target.value as SportType })
}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-transparent"
>
{Object.values(SportType).map((sport) => (
<option key={sport} value={sport}>
{sport.charAt(0).toUpperCase() + sport.slice(1)}
</option>
))}
</select>
</div>
<Input
label="League"
value={formData.league || ''}
onChange={(e) => setFormData({ ...formData, league: e.target.value })}
placeholder="e.g., NFL, NBA, NCAA"
/>
<Input
label="Home Team"
value={formData.home_team}
onChange={(e) => setFormData({ ...formData, home_team: e.target.value })}
required
/>
<Input
label="Away Team"
value={formData.away_team}
onChange={(e) => setFormData({ ...formData, away_team: e.target.value })}
required
/>
<Input
label="Official Spread"
type="number"
step="0.5"
value={formData.official_spread}
onChange={(e) =>
setFormData({ ...formData, official_spread: parseFloat(e.target.value) })
}
required
/>
<Input
label="Game Time"
type="datetime-local"
value={formData.game_time}
onChange={(e) => setFormData({ ...formData, game_time: e.target.value })}
required
/>
<Input
label="Venue"
value={formData.venue || ''}
onChange={(e) => setFormData({ ...formData, venue: e.target.value })}
placeholder="Stadium/Arena name"
/>
</div>
<div className="flex gap-3 pt-4">
<Button
type="button"
variant="secondary"
onClick={() => setShowCreateForm(false)}
disabled={createEventMutation.isPending}
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
disabled={createEventMutation.isPending}
className="flex-1"
>
{createEventMutation.isPending ? 'Creating...' : 'Create Event'}
</Button>
</div>
</form>
</div>
)}
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200">
<h2 className="text-xl font-semibold">All Events</h2>
</div>
{isLoading ? (
<div className="p-6">
<Loading />
</div>
) : !events || events.length === 0 ? (
<div className="p-6 text-center text-gray-600">No events created yet</div>
) : (
<div className="divide-y divide-gray-200">
{events.map((event) => (
<div key={event.id} className="p-6 hover:bg-gray-50 transition-colors">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs font-semibold uppercase">
{event.sport}
</span>
<span className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs">
{event.status}
</span>
</div>
<h3 className="font-bold text-lg text-gray-900">
{event.home_team} vs {event.away_team}
</h3>
<div className="mt-2 grid grid-cols-2 md:grid-cols-4 gap-2 text-sm text-gray-600">
<div>
<span className="font-medium">Spread:</span> {event.home_team}{' '}
{event.official_spread > 0 ? '+' : ''}
{event.official_spread}
</div>
<div>
<span className="font-medium">Time:</span>{' '}
{new Date(event.game_time).toLocaleString()}
</div>
{event.venue && (
<div>
<span className="font-medium">Venue:</span> {event.venue}
</div>
)}
{event.league && (
<div>
<span className="font-medium">League:</span> {event.league}
</div>
)}
</div>
) : !events || events.length === 0 ? (
<div className="p-6 text-center text-gray-600">No events created yet</div>
) : (
<div className="divide-y divide-gray-200">
{events.map((event) => (
<div key={event.id} className="p-6 hover:bg-gray-50 transition-colors">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs font-semibold uppercase">
{event.sport}
</span>
<span className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs">
{event.status}
</span>
</div>
<Button
variant="secondary"
onClick={() => handleDelete(event.id)}
disabled={deleteEventMutation.isPending}
className="ml-4"
>
<Trash2 size={18} />
</Button>
<h3 className="font-bold text-lg text-gray-900">
{event.home_team} vs {event.away_team}
</h3>
<div className="mt-2 grid grid-cols-2 md:grid-cols-4 gap-2 text-sm text-gray-600">
<div>
<span className="font-medium">Spread:</span> {event.home_team}{' '}
{event.official_spread > 0 ? '+' : ''}
{event.official_spread}
</div>
<div>
<span className="font-medium">Time:</span>{' '}
{new Date(event.game_time).toLocaleString()}
</div>
{event.venue && (
<div>
<span className="font-medium">Venue:</span> {event.venue}
</div>
)}
{event.league && (
<div>
<span className="font-medium">League:</span> {event.league}
</div>
)}
</div>
</div>
<Button
variant="secondary"
onClick={() => handleDelete(event.id)}
disabled={deleteEventMutation.isPending}
className="ml-4"
>
<Trash2 size={18} />
</Button>
</div>
))}
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
</Layout>
</div>
)
}

178
frontend/src/types/admin.ts Normal file
View File

@ -0,0 +1,178 @@
// Admin Panel Types
export interface AdminDashboardStats {
total_users: number
active_users: number
suspended_users: number
admin_users: number
total_events: number
upcoming_events: number
live_events: number
total_bets: number
open_bets: number
matched_bets: number
total_volume: number
escrow_locked: number
simulation_running: boolean
}
export interface AuditLog {
id: number
admin_id: number
admin_username: string
action: string
target_type: string | null
target_id: number | null
description: string
details: string | null
ip_address: string | null
created_at: string
}
export interface AuditLogListResponse {
logs: AuditLog[]
total: number
page: number
page_size: number
}
export interface WipePreview {
users_count: number
wallets_count: number
transactions_count: number
bets_count: number
spread_bets_count: number
events_count: number
event_comments_count: number
match_comments_count: number
admin_settings_preserved: boolean
admin_users_preserved: boolean
can_wipe: boolean
cooldown_remaining_seconds: number
last_wipe_at: string | null
}
export interface WipeRequest {
confirmation_phrase: string
preserve_admin_users: boolean
preserve_events: boolean
}
export interface WipeResponse {
success: boolean
message: string
deleted_counts: Record<string, number>
preserved_counts: Record<string, number>
executed_at: string
executed_by: string
}
export interface SeedRequest {
num_users: number
num_events: number
num_bets_per_event: number
starting_balance: number
create_admin: boolean
}
export interface SeedResponse {
success: boolean
message: string
created_counts: Record<string, number>
test_admin: {
username: string
email: string
password: string
} | null
}
export interface SimulationConfig {
delay_seconds: number
actions_per_iteration: number
create_users: boolean
create_bets: boolean
take_bets: boolean
add_comments: boolean
cancel_bets: boolean
}
export interface SimulationStatus {
is_running: boolean
started_at: string | null
started_by: string | null
iterations_completed: number
config: SimulationConfig | null
last_activity: string | null
}
export interface SimulationStartResponse {
success: boolean
message: string
status: SimulationStatus
}
export interface SimulationStopResponse {
success: boolean
message: string
total_iterations: number
ran_for_seconds: number
}
export interface AdminUser {
id: number
email: string
username: string
display_name: string | null
is_admin: boolean
status: 'active' | 'suspended' | 'pending_verification'
balance: number
escrow: number
total_bets: number
wins: number
losses: number
win_rate: number
created_at: string
}
export interface AdminUserListResponse {
users: AdminUser[]
total: number
page: number
page_size: number
}
export interface AdminUserDetail extends AdminUser {
avatar_url: string | null
bio: string | null
updated_at: string
open_bets_count: number
matched_bets_count: number
transaction_count: number
}
export interface UserUpdateRequest {
display_name?: string
email?: string
is_admin?: boolean
}
export interface UserStatusRequest {
status: 'active' | 'suspended'
reason?: string
}
export interface BalanceAdjustRequest {
amount: number
reason: string
}
export interface BalanceAdjustResponse {
success: boolean
user_id: number
username: string
previous_balance: number
adjustment: number
new_balance: number
reason: string
transaction_id: number
}