Add admin panel
This commit is contained in:
242
frontend/src/pages/admin/AdminMarathonsPage.tsx
Normal file
242
frontend/src/pages/admin/AdminMarathonsPage.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user