243 lines
10 KiB
TypeScript
243 lines
10 KiB
TypeScript
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>
|
||
)
|
||
}
|