Fix games list

This commit is contained in:
2026-01-04 03:17:17 +07:00
parent 475e2cf4cd
commit 18ffff5473
5 changed files with 47 additions and 321 deletions

View File

@@ -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 />} />

View File

@@ -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>
)
}

View File

@@ -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: 'Контент' },

View File

@@ -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'