Added admin panel.
This commit is contained in:
@ -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
|
||||
},
|
||||
}
|
||||
|
||||
173
frontend/src/components/admin/AdminAuditLog.tsx
Normal file
173
frontend/src/components/admin/AdminAuditLog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
282
frontend/src/components/admin/AdminDataTools.tsx
Normal file
282
frontend/src/components/admin/AdminDataTools.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
226
frontend/src/components/admin/AdminSimulation.tsx
Normal file
226
frontend/src/components/admin/AdminSimulation.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
333
frontend/src/components/admin/AdminUsers.tsx
Normal file
333
frontend/src/components/admin/AdminUsers.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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 */}
|
||||
|
||||
@ -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
178
frontend/src/types/admin.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user