Files
game-marathon/frontend/src/pages/admin/AdminMarathonsPage.tsx
2025-12-19 02:07:25 +07:00

243 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback } from 'react'
import { adminApi } from '@/api'
import type { AdminMarathon } from '@/types'
import { useToast } from '@/store/toast'
import { useConfirm } from '@/store/confirm'
import { NeonButton } from '@/components/ui'
import { Search, Trash2, StopCircle, ChevronLeft, ChevronRight, Trophy, Clock, CheckCircle, Loader2 } from 'lucide-react'
const STATUS_CONFIG: Record<string, { label: string; icon: typeof Clock; className: string }> = {
preparing: {
label: 'Подготовка',
icon: Loader2,
className: 'bg-amber-500/20 text-amber-400 border border-amber-500/30'
},
active: {
label: 'Активный',
icon: Clock,
className: 'bg-green-500/20 text-green-400 border border-green-500/30'
},
finished: {
label: 'Завершён',
icon: CheckCircle,
className: 'bg-dark-600/50 text-gray-400 border border-dark-500'
},
}
function formatDate(dateStr: string | null) {
if (!dateStr) return '—'
return new Date(dateStr).toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
export function AdminMarathonsPage() {
const [marathons, setMarathons] = useState<AdminMarathon[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [page, setPage] = useState(0)
const toast = useToast()
const confirm = useConfirm()
const LIMIT = 20
const loadMarathons = useCallback(async () => {
setLoading(true)
try {
const data = await adminApi.listMarathons(page * LIMIT, LIMIT, search || undefined)
setMarathons(data)
} catch (err) {
console.error('Failed to load marathons:', err)
toast.error('Ошибка загрузки марафонов')
} finally {
setLoading(false)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, search])
useEffect(() => {
loadMarathons()
}, [loadMarathons])
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
setPage(0)
loadMarathons()
}
const handleDelete = async (marathon: AdminMarathon) => {
const confirmed = await confirm({
title: 'Удалить марафон',
message: `Вы уверены, что хотите удалить марафон "${marathon.title}"? Это действие необратимо.`,
confirmText: 'Удалить',
variant: 'danger',
})
if (!confirmed) return
try {
await adminApi.deleteMarathon(marathon.id)
setMarathons(marathons.filter(m => m.id !== marathon.id))
toast.success('Марафон удалён')
} catch (err) {
console.error('Failed to delete marathon:', err)
toast.error('Ошибка удаления')
}
}
const handleForceFinish = async (marathon: AdminMarathon) => {
const confirmed = await confirm({
title: 'Завершить марафон',
message: `Принудительно завершить марафон "${marathon.title}"? Участники получат уведомление.`,
confirmText: 'Завершить',
variant: 'warning',
})
if (!confirmed) return
try {
await adminApi.forceFinishMarathon(marathon.id)
setMarathons(marathons.map(m =>
m.id === marathon.id ? { ...m, status: 'finished' } : m
))
toast.success('Марафон завершён')
} catch (err) {
console.error('Failed to finish marathon:', err)
toast.error('Ошибка завершения')
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-accent-500/20 border border-accent-500/30">
<Trophy className="w-6 h-6 text-accent-400" />
</div>
<h1 className="text-2xl font-bold text-white">Марафоны</h1>
</div>
{/* Search */}
<form onSubmit={handleSearch} className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
<input
type="text"
placeholder="Поиск по названию..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl pl-10 pr-4 py-2.5 text-white placeholder:text-gray-500 focus:outline-none focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/20 transition-all"
/>
</div>
<NeonButton type="submit" color="purple">
Найти
</NeonButton>
</form>
{/* Marathons Table */}
<div className="glass rounded-xl border border-dark-600 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-dark-700/50 border-b border-dark-600">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">ID</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Название</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Создатель</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Статус</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Участники</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Игры</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Даты</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-400">Действия</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-600">
{loading ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-accent-500 border-t-transparent mx-auto" />
</td>
</tr>
) : marathons.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-gray-400">
Марафоны не найдены
</td>
</tr>
) : (
marathons.map((marathon) => {
const statusConfig = STATUS_CONFIG[marathon.status] || STATUS_CONFIG.finished
const StatusIcon = statusConfig.icon
return (
<tr key={marathon.id} className="hover:bg-dark-700/30 transition-colors">
<td className="px-4 py-3 text-sm text-gray-400 font-mono">{marathon.id}</td>
<td className="px-4 py-3 text-sm text-white font-medium">{marathon.title}</td>
<td className="px-4 py-3 text-sm text-gray-300">{marathon.creator.nickname}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-lg ${statusConfig.className}`}>
<StatusIcon className={`w-3 h-3 ${marathon.status === 'preparing' ? 'animate-spin' : ''}`} />
{statusConfig.label}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-400">{marathon.participants_count}</td>
<td className="px-4 py-3 text-sm text-gray-400">{marathon.games_count}</td>
<td className="px-4 py-3 text-sm text-gray-400">
<span className="text-gray-500">{formatDate(marathon.start_date)}</span>
<span className="text-gray-600 mx-1"></span>
<span className="text-gray-500">{formatDate(marathon.end_date)}</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
{marathon.status !== 'finished' && (
<button
onClick={() => handleForceFinish(marathon)}
className="p-2 text-orange-400 hover:bg-orange-500/20 rounded-lg transition-colors"
title="Завершить марафон"
>
<StopCircle className="w-4 h-4" />
</button>
)}
<button
onClick={() => handleDelete(marathon)}
className="p-2 text-red-400 hover:bg-red-500/20 rounded-lg transition-colors"
title="Удалить"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between px-4 py-3 border-t border-dark-600">
<button
onClick={() => setPage(Math.max(0, page - 1))}
disabled={page === 0}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
>
<ChevronLeft className="w-4 h-4" />
Назад
</button>
<span className="text-sm text-gray-500">
Страница <span className="text-white font-medium">{page + 1}</span>
</span>
<button
onClick={() => setPage(page + 1)}
disabled={marathons.length < LIMIT}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-400 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed transition-colors rounded-lg hover:bg-dark-600/50"
>
Вперед
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
</div>
)
}