Fix games list
This commit is contained in:
@@ -32,7 +32,6 @@ import {
|
||||
AdminDashboardPage,
|
||||
AdminUsersPage,
|
||||
AdminMarathonsPage,
|
||||
AdminDisputesPage,
|
||||
AdminLogsPage,
|
||||
AdminBroadcastPage,
|
||||
AdminContentPage,
|
||||
@@ -209,7 +208,6 @@ function App() {
|
||||
<Route index element={<AdminDashboardPage />} />
|
||||
<Route path="users" element={<AdminUsersPage />} />
|
||||
<Route path="marathons" element={<AdminMarathonsPage />} />
|
||||
<Route path="disputes" element={<AdminDisputesPage />} />
|
||||
<Route path="logs" element={<AdminLogsPage />} />
|
||||
<Route path="broadcast" element={<AdminBroadcastPage />} />
|
||||
<Route path="content" element={<AdminContentPage />} />
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { adminApi } from '@/api'
|
||||
import type { AdminDispute } from '@/types'
|
||||
import { GlassCard, NeonButton } from '@/components/ui'
|
||||
import { useToast } from '@/store/toast'
|
||||
import {
|
||||
AlertTriangle, Loader2, CheckCircle, XCircle, Clock,
|
||||
ThumbsUp, ThumbsDown, User, Trophy, ExternalLink
|
||||
} from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export function AdminDisputesPage() {
|
||||
const toast = useToast()
|
||||
const [disputes, setDisputes] = useState<AdminDispute[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [filter, setFilter] = useState<'pending' | 'open' | 'all'>('pending')
|
||||
const [resolvingId, setResolvingId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadDisputes()
|
||||
}, [filter])
|
||||
|
||||
const loadDisputes = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const data = await adminApi.listDisputes(filter)
|
||||
setDisputes(data)
|
||||
} catch (err) {
|
||||
toast.error('Не удалось загрузить оспаривания')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResolve = async (disputeId: number, isValid: boolean) => {
|
||||
setResolvingId(disputeId)
|
||||
try {
|
||||
await adminApi.resolveDispute(disputeId, isValid)
|
||||
toast.success(isValid ? 'Пруф подтверждён' : 'Пруф отклонён')
|
||||
await loadDisputes()
|
||||
} catch (err) {
|
||||
toast.error('Не удалось разрешить диспут')
|
||||
} finally {
|
||||
setResolvingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
const getTimeRemaining = (expiresAt: string) => {
|
||||
const now = new Date()
|
||||
const expires = new Date(expiresAt)
|
||||
const diff = expires.getTime() - now.getTime()
|
||||
|
||||
if (diff <= 0) return 'Истекло'
|
||||
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60))
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
|
||||
|
||||
return `${hours}ч ${minutes}м`
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'open':
|
||||
return (
|
||||
<span className="px-2 py-1 bg-blue-500/20 text-blue-400 rounded text-xs font-medium flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
Голосование
|
||||
</span>
|
||||
)
|
||||
case 'pending_admin':
|
||||
return (
|
||||
<span className="px-2 py-1 bg-orange-500/20 text-orange-400 rounded text-xs font-medium flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
Ожидает решения
|
||||
</span>
|
||||
)
|
||||
case 'valid':
|
||||
return (
|
||||
<span className="px-2 py-1 bg-green-500/20 text-green-400 rounded text-xs font-medium flex items-center gap-1">
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
Валидно
|
||||
</span>
|
||||
)
|
||||
case 'invalid':
|
||||
return (
|
||||
<span className="px-2 py-1 bg-red-500/20 text-red-400 rounded text-xs font-medium flex items-center gap-1">
|
||||
<XCircle className="w-3 h-3" />
|
||||
Невалидно
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const pendingCount = disputes.filter(d => d.status === 'pending_admin').length
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-yellow-400 to-orange-400">
|
||||
Оспаривания
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-1">
|
||||
Управление диспутами и проверка пруфов
|
||||
</p>
|
||||
</div>
|
||||
{pendingCount > 0 && (
|
||||
<div className="px-4 py-2 bg-orange-500/20 border border-orange-500/30 rounded-xl">
|
||||
<span className="text-orange-400 font-semibold">{pendingCount}</span>
|
||||
<span className="text-gray-400 ml-2">ожида{pendingCount === 1 ? 'ет' : 'ют'} решения</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-all ${
|
||||
filter === 'pending'
|
||||
? 'bg-orange-500/20 text-orange-400 border border-orange-500/30'
|
||||
: 'bg-dark-700 text-gray-400 border border-dark-600 hover:border-dark-500'
|
||||
}`}
|
||||
onClick={() => setFilter('pending')}
|
||||
>
|
||||
Ожидают решения
|
||||
</button>
|
||||
<button
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-all ${
|
||||
filter === 'open'
|
||||
? 'bg-blue-500/20 text-blue-400 border border-blue-500/30'
|
||||
: 'bg-dark-700 text-gray-400 border border-dark-600 hover:border-dark-500'
|
||||
}`}
|
||||
onClick={() => setFilter('open')}
|
||||
>
|
||||
Голосование
|
||||
</button>
|
||||
<button
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-all ${
|
||||
filter === 'all'
|
||||
? 'bg-accent-500/20 text-accent-400 border border-accent-500/30'
|
||||
: 'bg-dark-700 text-gray-400 border border-dark-600 hover:border-dark-500'
|
||||
}`}
|
||||
onClick={() => setFilter('all')}
|
||||
>
|
||||
Все
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-accent-500" />
|
||||
</div>
|
||||
) : disputes.length === 0 ? (
|
||||
<GlassCard className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-green-500/10 border border-green-500/30 flex items-center justify-center">
|
||||
<CheckCircle className="w-8 h-8 text-green-400" />
|
||||
</div>
|
||||
<p className="text-gray-400">
|
||||
{filter === 'pending' ? 'Нет оспариваний, ожидающих решения' :
|
||||
filter === 'open' ? 'Нет оспариваний в стадии голосования' :
|
||||
'Нет оспариваний'}
|
||||
</p>
|
||||
</GlassCard>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{disputes.map((dispute) => (
|
||||
<GlassCard
|
||||
key={dispute.id}
|
||||
className={
|
||||
dispute.status === 'pending_admin' ? 'border-orange-500/30' :
|
||||
dispute.status === 'open' ? 'border-blue-500/30' : ''
|
||||
}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
{/* Left side - Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-10 h-10 rounded-xl bg-yellow-500/20 flex items-center justify-center shrink-0">
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-400" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-white font-semibold truncate">
|
||||
{dispute.challenge_title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<Trophy className="w-3 h-3" />
|
||||
<span className="truncate">{dispute.marathon_title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Participants */}
|
||||
<div className="flex flex-wrap gap-4 mb-3 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<User className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-gray-400">Автор:</span>
|
||||
<span className="text-white">{dispute.participant_nickname}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<AlertTriangle className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-gray-400">Оспорил:</span>
|
||||
<span className="text-white">{dispute.raised_by_nickname}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reason */}
|
||||
<div className="p-3 bg-dark-700/50 rounded-lg border border-dark-600 mb-3">
|
||||
<p className="text-sm text-gray-400 mb-1">Причина:</p>
|
||||
<p className="text-white text-sm">{dispute.reason}</p>
|
||||
</div>
|
||||
|
||||
{/* Votes & Time */}
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 text-green-400">
|
||||
<ThumbsUp className="w-4 h-4" />
|
||||
<span className="font-medium">{dispute.votes_valid}</span>
|
||||
</div>
|
||||
<span className="text-gray-600">/</span>
|
||||
<div className="flex items-center gap-1 text-red-400">
|
||||
<ThumbsDown className="w-4 h-4" />
|
||||
<span className="font-medium">{dispute.votes_invalid}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-gray-600">•</span>
|
||||
<span className="text-gray-400">{formatDate(dispute.created_at)}</span>
|
||||
{dispute.status === 'open' && (
|
||||
<>
|
||||
<span className="text-gray-600">•</span>
|
||||
<span className="text-yellow-400 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{getTimeRemaining(dispute.expires_at)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Status & Actions */}
|
||||
<div className="flex flex-col items-end gap-3 shrink-0">
|
||||
{getStatusBadge(dispute.status)}
|
||||
|
||||
{/* Link to assignment */}
|
||||
{dispute.assignment_id && (
|
||||
<Link
|
||||
to={`/assignments/${dispute.assignment_id}`}
|
||||
className="text-xs text-accent-400 hover:text-accent-300 flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
Открыть
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Resolution buttons - show for open and pending_admin */}
|
||||
{(dispute.status === 'open' || dispute.status === 'pending_admin') && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Vote recommendation for pending disputes */}
|
||||
{dispute.status === 'pending_admin' && (
|
||||
<div className="text-xs text-gray-400 text-right mb-1">
|
||||
Рекомендация: {dispute.votes_invalid > dispute.votes_valid ? (
|
||||
<span className="text-red-400">невалидно</span>
|
||||
) : (
|
||||
<span className="text-green-400">валидно</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<NeonButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-green-500/50 text-green-400 hover:bg-green-500/10"
|
||||
onClick={() => handleResolve(dispute.id, true)}
|
||||
isLoading={resolvingId === dispute.id}
|
||||
disabled={resolvingId !== null}
|
||||
icon={<CheckCircle className="w-4 h-4" />}
|
||||
>
|
||||
Валидно
|
||||
</NeonButton>
|
||||
<NeonButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-red-500/50 text-red-400 hover:bg-red-500/10"
|
||||
onClick={() => handleResolve(dispute.id, false)}
|
||||
isLoading={resolvingId === dispute.id}
|
||||
disabled={resolvingId !== null}
|
||||
icon={<XCircle className="w-4 h-4" />}
|
||||
>
|
||||
Невалидно
|
||||
</NeonButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,15 +12,13 @@ import {
|
||||
Shield,
|
||||
MessageCircle,
|
||||
Sparkles,
|
||||
Lock,
|
||||
AlertTriangle
|
||||
Lock
|
||||
} from 'lucide-react'
|
||||
|
||||
const navItems = [
|
||||
{ to: '/admin', icon: LayoutDashboard, label: 'Дашборд', end: true },
|
||||
{ to: '/admin/users', icon: Users, label: 'Пользователи' },
|
||||
{ to: '/admin/marathons', icon: Trophy, label: 'Марафоны' },
|
||||
{ to: '/admin/disputes', icon: AlertTriangle, label: 'Оспаривания' },
|
||||
{ to: '/admin/logs', icon: ScrollText, label: 'Логи' },
|
||||
{ to: '/admin/broadcast', icon: Send, label: 'Рассылка' },
|
||||
{ to: '/admin/content', icon: FileText, label: 'Контент' },
|
||||
|
||||
@@ -2,7 +2,6 @@ export { AdminLayout } from './AdminLayout'
|
||||
export { AdminDashboardPage } from './AdminDashboardPage'
|
||||
export { AdminUsersPage } from './AdminUsersPage'
|
||||
export { AdminMarathonsPage } from './AdminMarathonsPage'
|
||||
export { AdminDisputesPage } from './AdminDisputesPage'
|
||||
export { AdminLogsPage } from './AdminLogsPage'
|
||||
export { AdminBroadcastPage } from './AdminBroadcastPage'
|
||||
export { AdminContentPage } from './AdminContentPage'
|
||||
|
||||
Reference in New Issue
Block a user