Добавлен Skip with Exile, модерация марафонов и выдача предметов

## Skip with Exile (новый расходник)
- Новая модель ExiledGame для хранения изгнанных игр
- Расходник skip_exile: пропуск без штрафа + игра исключается из пула навсегда
- Фильтрация изгнанных игр при выдаче заданий
- UI кнопка в PlayPage для использования skip_exile

## Модерация марафонов (для организаторов)
- Эндпоинты: skip-assignment, exiled-games, restore-exiled-game
- UI в LeaderboardPage: кнопка скипа у каждого участника
- Выбор типа скипа (обычный/с изгнанием) + причина
- Telegram уведомления о модерации

## Админская выдача предметов
- Эндпоинты: admin grant/remove items, get user inventory
- Новая страница AdminGrantItemPage (как магазин)
- Telegram уведомление при получении подарка

## Исправления миграций
- Миграции 029/030 теперь идемпотентны (проверка существования таблиц)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-01-10 23:01:23 +03:00
parent cf0df928b1
commit f78eacb1a5
24 changed files with 2194 additions and 14 deletions

View File

@@ -494,6 +494,35 @@ export function PlayPage() {
}
}
const handleUseSkipExile = async () => {
if (!currentAssignment || !id) return
const confirmed = await confirm({
title: 'Скип с изгнанием?',
message: 'Задание будет пропущено без штрафа, а игра навсегда удалена из вашего пула.',
confirmText: 'Использовать',
cancelText: 'Отмена',
variant: 'warning',
})
if (!confirmed) return
setIsUsingConsumable('skip_exile')
try {
await shopApi.useConsumable({
item_code: 'skip_exile',
marathon_id: parseInt(id),
assignment_id: currentAssignment.id,
})
toast.success('Задание пропущено, игра изгнана из пула!')
await loadData()
useShopStore.getState().loadBalance()
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } } }
toast.error(error.response?.data?.detail || 'Не удалось использовать Skip с изгнанием')
} finally {
setIsUsingConsumable(null)
}
}
const handleUseBoost = async () => {
if (!id) return
setIsUsingConsumable('boost')
@@ -826,6 +855,28 @@ export function PlayPage() {
</NeonButton>
</div>
{/* Skip with Exile */}
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<XCircle className="w-4 h-4 text-red-400" />
<span className="text-white font-medium">Skip + Изгнание</span>
</div>
<span className="text-gray-400 text-sm">{consumablesStatus.skip_exiles_available} шт.</span>
</div>
<p className="text-gray-500 text-xs mb-2">Скип + убрать игру из пула</p>
<NeonButton
size="sm"
variant="outline"
onClick={handleUseSkipExile}
disabled={consumablesStatus.skip_exiles_available === 0 || !currentAssignment || isUsingConsumable !== null}
isLoading={isUsingConsumable === 'skip_exile'}
className="w-full"
>
Использовать
</NeonButton>
</div>
{/* Boost */}
<div className="p-3 bg-dark-700/50 rounded-xl border border-dark-600">
<div className="flex items-center justify-between mb-2">