Enhance localization support by integrating i18next for translations across various components and pages. Update UI elements to utilize translated strings for improved user experience in both English and Russian. Additionally, refactor the Toaster component to support a context-based approach for toast notifications.

This commit is contained in:
Primakov Alexandr Alexandrovich 2025-11-04 10:25:12 +03:00
parent daa44521b9
commit 44a7ac2bfd
19 changed files with 892 additions and 293 deletions

View File

@ -1,3 +1,192 @@
{ {
"challenge.title": "Challenge" "challenge.title": "Challenge",
"challenge.admin.common.success": "Success",
"challenge.admin.common.error": "Error",
"challenge.admin.common.cancel": "Cancel",
"challenge.admin.common.loading.tasks": "Loading tasks...",
"challenge.admin.common.not.found": "Nothing found",
"challenge.admin.common.validation.error": "Validation error",
"challenge.admin.tasks.updated": "Task updated",
"challenge.admin.tasks.created": "Task created",
"challenge.admin.tasks.validation.fill.required.fields": "Fill in required fields",
"challenge.admin.tasks.save.error": "Failed to save task",
"challenge.admin.tasks.loading": "Loading task...",
"challenge.admin.tasks.load.error": "Failed to load task",
"challenge.admin.tasks.edit.title": "Edit task",
"challenge.admin.tasks.create.title": "Create task",
"challenge.admin.tasks.field.title": "Task title",
"challenge.admin.tasks.field.title.placeholder": "Enter task title",
"challenge.admin.tasks.field.title.helper": "Maximum 255 characters",
"challenge.admin.tasks.field.description": "Description (Markdown)",
"challenge.admin.tasks.field.description.placeholder": "# Task title\n\nTask description in Markdown format...",
"challenge.admin.tasks.field.description.helper": "Use Markdown to format text",
"challenge.admin.tasks.tab.editor": "Editor",
"challenge.admin.tasks.tab.preview": "Preview",
"challenge.admin.tasks.preview.empty": "Preview will appear here...",
"challenge.admin.tasks.field.hidden.instructions": "🔒 Hidden instructions for LLM",
"challenge.admin.tasks.field.hidden.instructions.description": "These instructions will be passed to the LLM when checking student solutions. Students will not see them.",
"challenge.admin.tasks.field.hidden.instructions.placeholder": "Example: Check that algorithm complexity is O(n log n). Code should handle edge cases...",
"challenge.admin.tasks.field.hidden.instructions.helper": "Optional. Use for fine-tuning LLM verification.",
"challenge.admin.tasks.meta.created": "Created:",
"challenge.admin.tasks.meta.author": "Author:",
"challenge.admin.tasks.meta.updated": "Updated:",
"challenge.admin.tasks.button.save": "Save changes",
"challenge.admin.tasks.button.create": "Create task",
"challenge.admin.tasks.list.title": "Tasks",
"challenge.admin.tasks.list.create.button": "+ Create Task",
"challenge.admin.tasks.list.search.placeholder": "Search by name...",
"challenge.admin.tasks.list.empty.title": "No tasks",
"challenge.admin.tasks.list.empty.description": "Create your first task to get started",
"challenge.admin.tasks.list.empty.action": "Create task",
"challenge.admin.tasks.list.search.empty": "Nothing found for \"{query}\"",
"challenge.admin.tasks.list.table.title": "Title",
"challenge.admin.tasks.list.table.creator": "Creator",
"challenge.admin.tasks.list.table.created": "Created date",
"challenge.admin.tasks.list.table.hidden.instructions": "Hidden instructions",
"challenge.admin.tasks.list.table.actions": "Actions",
"challenge.admin.tasks.list.badge.has.instructions": "🔒 Yes",
"challenge.admin.tasks.list.button.edit": "Edit",
"challenge.admin.tasks.list.button.delete": "Delete",
"challenge.admin.tasks.deleted": "Task deleted",
"challenge.admin.tasks.delete.error": "Failed to delete task",
"challenge.admin.tasks.list.loading": "Loading tasks...",
"challenge.admin.tasks.list.load.error": "Failed to load tasks list",
"challenge.admin.tasks.delete.confirm.title": "Delete task",
"challenge.admin.tasks.delete.confirm.message": "Are you sure you want to delete task \"{title}\"? This action cannot be undone.",
"challenge.admin.tasks.delete.confirm.button": "Delete",
"challenge.admin.chains.updated": "Chain updated",
"challenge.admin.chains.created": "Chain created",
"challenge.admin.chains.validation.enter.name": "Enter chain name",
"challenge.admin.chains.validation.add.task": "Add at least one task",
"challenge.admin.chains.save.error": "Failed to save chain",
"challenge.admin.chains.loading": "Loading chain...",
"challenge.admin.chains.load.error": "Failed to load chain",
"challenge.admin.chains.tasks.load.error": "Failed to load task list",
"challenge.admin.chains.edit.title": "Edit chain",
"challenge.admin.chains.create.title": "Create chain",
"challenge.admin.chains.field.name": "Chain name",
"challenge.admin.chains.field.name.placeholder": "Enter chain name",
"challenge.admin.chains.selected.tasks": "Tasks in chain",
"challenge.admin.chains.selected.tasks.empty": "Add tasks from the list below",
"challenge.admin.chains.available.tasks": "Available tasks",
"challenge.admin.chains.search.placeholder": "Search tasks...",
"challenge.admin.chains.all.tasks.added": "All tasks already added",
"challenge.admin.chains.button.add": "+ Add",
"challenge.admin.chains.button.save": "Save changes",
"challenge.admin.chains.button.create": "Create chain",
"challenge.admin.chains.list.title": "Task Chains",
"challenge.admin.chains.list.create.button": "+ Create Chain",
"challenge.admin.chains.list.search.placeholder": "Search by name...",
"challenge.admin.chains.list.empty.title": "No chains",
"challenge.admin.chains.list.empty.description": "Create your first task chain",
"challenge.admin.chains.list.empty.action": "Create chain",
"challenge.admin.chains.list.search.empty": "Nothing found for \"{query}\"",
"challenge.admin.chains.list.table.name": "Name",
"challenge.admin.chains.list.table.tasks.count": "Number of tasks",
"challenge.admin.chains.list.table.created": "Created date",
"challenge.admin.chains.list.table.actions": "Actions",
"challenge.admin.chains.list.badge.tasks": "tasks",
"challenge.admin.chains.list.button.edit": "Edit",
"challenge.admin.chains.list.button.delete": "Delete",
"challenge.admin.chains.deleted": "Chain deleted",
"challenge.admin.chains.delete.error": "Failed to delete chain",
"challenge.admin.chains.list.loading": "Loading chains...",
"challenge.admin.chains.list.load.error": "Failed to load chains list",
"challenge.admin.chains.delete.confirm.title": "Delete chain",
"challenge.admin.chains.delete.confirm.message": "Are you sure you want to delete chain \"{name}\"? This action cannot be undone.",
"challenge.admin.chains.delete.confirm.button": "Delete",
"challenge.admin.dashboard.title": "Dashboard",
"challenge.admin.dashboard.loading": "Loading statistics...",
"challenge.admin.dashboard.load.error": "Failed to load system statistics",
"challenge.admin.dashboard.stats.users": "Total users",
"challenge.admin.dashboard.stats.tasks": "Total tasks",
"challenge.admin.dashboard.stats.chains": "Total chains",
"challenge.admin.dashboard.stats.submissions": "Total checks",
"challenge.admin.dashboard.submissions.title": "Check statistics",
"challenge.admin.dashboard.submissions.accepted": "Accepted",
"challenge.admin.dashboard.submissions.rejected": "Rejected",
"challenge.admin.dashboard.submissions.pending": "Pending",
"challenge.admin.dashboard.submissions.in.progress": "In progress",
"challenge.admin.dashboard.queue.title": "Queue status",
"challenge.admin.dashboard.queue.processing": "Processing",
"challenge.admin.dashboard.queue.waiting": "Waiting in queue",
"challenge.admin.dashboard.queue.total": "Total in queue",
"challenge.admin.dashboard.queue.utilization": "Queue utilization:",
"challenge.admin.dashboard.check.time.title": "Average check time",
"challenge.admin.dashboard.check.time.value": "{{time}} sec",
"challenge.admin.dashboard.check.time.description": "Time from solution submission to result",
"challenge.admin.users.title": "Users",
"challenge.admin.users.loading": "Loading users...",
"challenge.admin.users.load.error": "Failed to load users list",
"challenge.admin.users.search.placeholder": "Search by nickname...",
"challenge.admin.users.empty.title": "No users",
"challenge.admin.users.empty.description": "Users will appear after registration",
"challenge.admin.users.search.empty": "Nothing found for \"{query}\"",
"challenge.admin.users.table.nickname": "Nickname",
"challenge.admin.users.table.id": "ID",
"challenge.admin.users.table.registered": "Registration date",
"challenge.admin.users.table.actions": "Actions",
"challenge.admin.users.button.stats": "Statistics",
"challenge.admin.users.stats.title": "User statistics",
"challenge.admin.users.stats.loading": "Loading statistics...",
"challenge.admin.users.stats.no.data": "No data",
"challenge.admin.users.stats.completed": "Completed",
"challenge.admin.users.stats.total.submissions": "Total attempts",
"challenge.admin.users.stats.in.progress": "In progress",
"challenge.admin.users.stats.needs.revision": "Needs revision",
"challenge.admin.users.stats.chains.progress": "Chain progress",
"challenge.admin.users.stats.tasks": "Tasks",
"challenge.admin.users.stats.status.completed": "Completed",
"challenge.admin.users.stats.status.needs_revision": "Revision",
"challenge.admin.users.stats.status.in_progress": "In progress",
"challenge.admin.users.stats.status.not_started": "Not started",
"challenge.admin.users.stats.attempts": "Attempts:",
"challenge.admin.users.stats.avg.check.time": "Average check time",
"challenge.admin.users.stats.close": "Close",
"challenge.admin.submissions.title": "Solution attempts",
"challenge.admin.submissions.loading": "Loading attempts...",
"challenge.admin.submissions.load.error": "Failed to load attempts list",
"challenge.admin.submissions.search.placeholder": "Search by user or task...",
"challenge.admin.submissions.filter.status": "Status",
"challenge.admin.submissions.status.all": "All statuses",
"challenge.admin.submissions.status.accepted": "Accepted",
"challenge.admin.submissions.status.needs_revision": "Needs revision",
"challenge.admin.submissions.status.in_progress": "Checking",
"challenge.admin.submissions.status.pending": "Pending",
"challenge.admin.submissions.empty.title": "No attempts",
"challenge.admin.submissions.empty.description": "Attempts will appear after solution submissions",
"challenge.admin.submissions.search.empty.title": "Nothing found",
"challenge.admin.submissions.search.empty.description": "Try changing filters",
"challenge.admin.submissions.table.user": "User",
"challenge.admin.submissions.table.task": "Task",
"challenge.admin.submissions.table.status": "Status",
"challenge.admin.submissions.table.attempt": "Attempt",
"challenge.admin.submissions.table.submitted": "Submitted date",
"challenge.admin.submissions.table.check.time": "Check time",
"challenge.admin.submissions.table.actions": "Actions",
"challenge.admin.submissions.button.details": "Details",
"challenge.admin.submissions.check.time": "{{time}} sec",
"challenge.admin.submissions.details.title": "Attempt details",
"challenge.admin.submissions.details.user": "User",
"challenge.admin.submissions.details.status": "Status",
"challenge.admin.submissions.details.submitted": "Submitted:",
"challenge.admin.submissions.details.checked": "Checked:",
"challenge.admin.submissions.details.check.time": "Check time:",
"challenge.admin.submissions.details.task": "Task:",
"challenge.admin.submissions.details.solution": "User solution:",
"challenge.admin.submissions.details.feedback": "LLM feedback:",
"challenge.admin.submissions.details.close": "Close",
"challenge.admin.layout.title": "Challenge Admin",
"challenge.admin.layout.nav.dashboard": "Dashboard",
"challenge.admin.layout.nav.tasks": "Tasks",
"challenge.admin.layout.nav.chains": "Chains",
"challenge.admin.layout.nav.users": "Users",
"challenge.admin.layout.nav.submissions": "Attempts",
"challenge.admin.layout.button.player": "Open Player",
"challenge.admin.layout.button.logout": "Logout",
"challenge.admin.common.loading.default": "Loading...",
"challenge.admin.common.error.default": "An error occurred while loading data",
"challenge.admin.common.retry": "Try again",
"challenge.admin.common.confirm": "Confirm",
"challenge.admin.common.close": "Close"
} }

View File

@ -1,3 +1,191 @@
{ {
"challenge.title": "Challenge" "challenge.admin.common.success": "Успешно",
"challenge.admin.common.error": "Ошибка",
"challenge.admin.common.cancel": "Отмена",
"challenge.admin.common.loading.tasks": "Загрузка заданий...",
"challenge.admin.common.not.found": "Ничего не найдено",
"challenge.admin.common.validation.error": "Ошибка валидации",
"challenge.admin.tasks.updated": "Задание обновлено",
"challenge.admin.tasks.created": "Задание создано",
"challenge.admin.tasks.validation.fill.required.fields": "Заполните обязательные поля",
"challenge.admin.tasks.save.error": "Не удалось сохранить задание",
"challenge.admin.tasks.loading": "Загрузка задания...",
"challenge.admin.tasks.load.error": "Не удалось загрузить задание",
"challenge.admin.tasks.edit.title": "Редактировать задание",
"challenge.admin.tasks.create.title": "Создать задание",
"challenge.admin.tasks.field.title": "Название задания",
"challenge.admin.tasks.field.title.placeholder": "Введите название задания",
"challenge.admin.tasks.field.title.helper": "Максимум 255 символов",
"challenge.admin.tasks.field.description": "Описание (Markdown)",
"challenge.admin.tasks.field.description.placeholder": "# Заголовок задания\n\nОписание задания в формате Markdown...",
"challenge.admin.tasks.field.description.helper": "Используйте Markdown для форматирования текста",
"challenge.admin.tasks.tab.editor": "Редактор",
"challenge.admin.tasks.tab.preview": "Превью",
"challenge.admin.tasks.preview.empty": "Предпросмотр появится здесь...",
"challenge.admin.tasks.field.hidden.instructions": "🔒 Скрытые инструкции для LLM",
"challenge.admin.tasks.field.hidden.instructions.description": "Эти инструкции будут переданы LLM при проверке решений студентов. Студенты их не увидят.",
"challenge.admin.tasks.field.hidden.instructions.placeholder": "Например: Проверь, что сложность алгоритма O(n log n). Код должен обрабатывать edge cases...",
"challenge.admin.tasks.field.hidden.instructions.helper": "Опционально. Используйте для тонкой настройки проверки LLM.",
"challenge.admin.tasks.meta.created": "Создано:",
"challenge.admin.tasks.meta.author": "Автор:",
"challenge.admin.tasks.meta.updated": "Обновлено:",
"challenge.admin.tasks.button.save": "Сохранить изменения",
"challenge.admin.tasks.button.create": "Создать задание",
"challenge.admin.tasks.list.title": "Задания",
"challenge.admin.tasks.list.create.button": "+ Создать задание",
"challenge.admin.tasks.list.search.placeholder": "Поиск по названию...",
"challenge.admin.tasks.list.empty.title": "Нет заданий",
"challenge.admin.tasks.list.empty.description": "Создайте первое задание для начала работы",
"challenge.admin.tasks.list.empty.action": "Создать задание",
"challenge.admin.tasks.list.search.empty": "По запросу \"{query}\" ничего не найдено",
"challenge.admin.tasks.list.table.title": "Название",
"challenge.admin.tasks.list.table.creator": "Создатель",
"challenge.admin.tasks.list.table.created": "Дата создания",
"challenge.admin.tasks.list.table.hidden.instructions": "Скрытые инструкции",
"challenge.admin.tasks.list.table.actions": "Действия",
"challenge.admin.tasks.list.badge.has.instructions": "🔒 Есть",
"challenge.admin.tasks.list.button.edit": "Редактировать",
"challenge.admin.tasks.list.button.delete": "Удалить",
"challenge.admin.tasks.deleted": "Задание удалено",
"challenge.admin.tasks.delete.error": "Не удалось удалить задание",
"challenge.admin.tasks.list.loading": "Загрузка заданий...",
"challenge.admin.tasks.list.load.error": "Не удалось загрузить список заданий",
"challenge.admin.tasks.delete.confirm.title": "Удалить задание",
"challenge.admin.tasks.delete.confirm.message": "Вы уверены, что хотите удалить задание \"{title}\"? Это действие нельзя отменить.",
"challenge.admin.tasks.delete.confirm.button": "Удалить",
"challenge.admin.chains.updated": "Цепочка обновлена",
"challenge.admin.chains.created": "Цепочка создана",
"challenge.admin.chains.validation.enter.name": "Введите название цепочки",
"challenge.admin.chains.validation.add.task": "Добавьте хотя бы одно задание",
"challenge.admin.chains.save.error": "Не удалось сохранить цепочку",
"challenge.admin.chains.loading": "Загрузка цепочки...",
"challenge.admin.chains.load.error": "Не удалось загрузить цепочку",
"challenge.admin.chains.tasks.load.error": "Не удалось загрузить список заданий",
"challenge.admin.chains.edit.title": "Редактировать цепочку",
"challenge.admin.chains.create.title": "Создать цепочку",
"challenge.admin.chains.field.name": "Название цепочки",
"challenge.admin.chains.field.name.placeholder": "Введите название цепочки",
"challenge.admin.chains.selected.tasks": "Задания в цепочке",
"challenge.admin.chains.selected.tasks.empty": "Добавьте задания из списка ниже",
"challenge.admin.chains.available.tasks": "Доступные задания",
"challenge.admin.chains.search.placeholder": "Поиск заданий...",
"challenge.admin.chains.all.tasks.added": "Все задания уже добавлены",
"challenge.admin.chains.button.add": "+ Добавить",
"challenge.admin.chains.button.save": "Сохранить изменения",
"challenge.admin.chains.button.create": "Создать цепочку",
"challenge.admin.chains.list.title": "Цепочки заданий",
"challenge.admin.chains.list.create.button": "+ Создать цепочку",
"challenge.admin.chains.list.search.placeholder": "Поиск по названию...",
"challenge.admin.chains.list.empty.title": "Нет цепочек",
"challenge.admin.chains.list.empty.description": "Создайте первую цепочку заданий",
"challenge.admin.chains.list.empty.action": "Создать цепочку",
"challenge.admin.chains.list.search.empty": "По запросу \"{query}\" ничего не найдено",
"challenge.admin.chains.list.table.name": "Название",
"challenge.admin.chains.list.table.tasks.count": "Количество заданий",
"challenge.admin.chains.list.table.created": "Дата создания",
"challenge.admin.chains.list.table.actions": "Действия",
"challenge.admin.chains.list.badge.tasks": "заданий",
"challenge.admin.chains.list.button.edit": "Редактировать",
"challenge.admin.chains.list.button.delete": "Удалить",
"challenge.admin.chains.deleted": "Цепочка удалена",
"challenge.admin.chains.delete.error": "Не удалось удалить цепочку",
"challenge.admin.chains.list.loading": "Загрузка цепочек...",
"challenge.admin.chains.list.load.error": "Не удалось загрузить список цепочек",
"challenge.admin.chains.delete.confirm.title": "Удалить цепочку",
"challenge.admin.chains.delete.confirm.message": "Вы уверены, что хотите удалить цепочку \"{name}\"? Это действие нельзя отменить.",
"challenge.admin.chains.delete.confirm.button": "Удалить",
"challenge.admin.dashboard.title": "Dashboard",
"challenge.admin.dashboard.loading": "Загрузка статистики...",
"challenge.admin.dashboard.load.error": "Не удалось загрузить статистику системы",
"challenge.admin.dashboard.stats.users": "Всего пользователей",
"challenge.admin.dashboard.stats.tasks": "Всего заданий",
"challenge.admin.dashboard.stats.chains": "Всего цепочек",
"challenge.admin.dashboard.stats.submissions": "Всего проверок",
"challenge.admin.dashboard.submissions.title": "Статистика проверок",
"challenge.admin.dashboard.submissions.accepted": "Принято",
"challenge.admin.dashboard.submissions.rejected": "Отклонено",
"challenge.admin.dashboard.submissions.pending": "Ожидают",
"challenge.admin.dashboard.submissions.in.progress": "В процессе",
"challenge.admin.dashboard.queue.title": "Статус очереди",
"challenge.admin.dashboard.queue.processing": "В обработке",
"challenge.admin.dashboard.queue.waiting": "Ожидают в очереди",
"challenge.admin.dashboard.queue.total": "Всего в очереди",
"challenge.admin.dashboard.queue.utilization": "Загруженность очереди:",
"challenge.admin.dashboard.check.time.title": "Среднее время проверки",
"challenge.admin.dashboard.check.time.value": "{{time}} сек",
"challenge.admin.dashboard.check.time.description": "Время от отправки решения до получения результата",
"challenge.admin.users.title": "Пользователи",
"challenge.admin.users.loading": "Загрузка пользователей...",
"challenge.admin.users.load.error": "Не удалось загрузить список пользователей",
"challenge.admin.users.search.placeholder": "Поиск по nickname...",
"challenge.admin.users.empty.title": "Нет пользователей",
"challenge.admin.users.empty.description": "Пользователи появятся после регистрации",
"challenge.admin.users.search.empty": "По запросу \"{query}\" ничего не найдено",
"challenge.admin.users.table.nickname": "Nickname",
"challenge.admin.users.table.id": "ID",
"challenge.admin.users.table.registered": "Дата регистрации",
"challenge.admin.users.table.actions": "Действия",
"challenge.admin.users.button.stats": "Статистика",
"challenge.admin.users.stats.title": "Статистика пользователя",
"challenge.admin.users.stats.loading": "Загрузка статистики...",
"challenge.admin.users.stats.no.data": "Нет данных",
"challenge.admin.users.stats.completed": "Выполнено",
"challenge.admin.users.stats.total.submissions": "Всего попыток",
"challenge.admin.users.stats.in.progress": "В процессе",
"challenge.admin.users.stats.needs.revision": "Требует доработки",
"challenge.admin.users.stats.chains.progress": "Прогресс по цепочкам",
"challenge.admin.users.stats.tasks": "Задания",
"challenge.admin.users.stats.status.completed": "Завершено",
"challenge.admin.users.stats.status.needs_revision": "Доработка",
"challenge.admin.users.stats.status.in_progress": "В процессе",
"challenge.admin.users.stats.status.not_started": "Не начато",
"challenge.admin.users.stats.attempts": "Попыток:",
"challenge.admin.users.stats.avg.check.time": "Среднее время проверки",
"challenge.admin.users.stats.close": "Закрыть",
"challenge.admin.submissions.title": "Попытки решений",
"challenge.admin.submissions.loading": "Загрузка попыток...",
"challenge.admin.submissions.load.error": "Не удалось загрузить список попыток",
"challenge.admin.submissions.search.placeholder": "Поиск по пользователю или заданию...",
"challenge.admin.submissions.filter.status": "Статус",
"challenge.admin.submissions.status.all": "Все статусы",
"challenge.admin.submissions.status.accepted": "Принято",
"challenge.admin.submissions.status.needs_revision": "Доработка",
"challenge.admin.submissions.status.in_progress": "Проверяется",
"challenge.admin.submissions.status.pending": "Ожидает",
"challenge.admin.submissions.empty.title": "Нет попыток",
"challenge.admin.submissions.empty.description": "Попытки появятся после отправки решений",
"challenge.admin.submissions.search.empty.title": "Ничего не найдено",
"challenge.admin.submissions.search.empty.description": "Попробуйте изменить фильтры",
"challenge.admin.submissions.table.user": "Пользователь",
"challenge.admin.submissions.table.task": "Задание",
"challenge.admin.submissions.table.status": "Статус",
"challenge.admin.submissions.table.attempt": "Попытка",
"challenge.admin.submissions.table.submitted": "Дата отправки",
"challenge.admin.submissions.table.check.time": "Время проверки",
"challenge.admin.submissions.table.actions": "Действия",
"challenge.admin.submissions.button.details": "Детали",
"challenge.admin.submissions.check.time": "{{time}} сек",
"challenge.admin.submissions.details.title": "Детали попытки",
"challenge.admin.submissions.details.user": "Пользователь",
"challenge.admin.submissions.details.status": "Статус",
"challenge.admin.submissions.details.submitted": "Отправлено:",
"challenge.admin.submissions.details.checked": "Проверено:",
"challenge.admin.submissions.details.check.time": "Время проверки:",
"challenge.admin.submissions.details.task": "Задание:",
"challenge.admin.submissions.details.solution": "Решение пользователя:",
"challenge.admin.submissions.details.feedback": "Обратная связь от LLM:",
"challenge.admin.submissions.details.close": "Закрыть",
"challenge.admin.layout.title": "Challenge Admin",
"challenge.admin.layout.nav.dashboard": "Dashboard",
"challenge.admin.layout.nav.tasks": "Задания",
"challenge.admin.layout.nav.chains": "Цепочки",
"challenge.admin.layout.nav.users": "Пользователи",
"challenge.admin.layout.nav.submissions": "Попытки",
"challenge.admin.layout.button.player": "Открыть проигрыватель",
"challenge.admin.layout.button.logout": "Выйти",
"challenge.admin.common.loading.default": "Загрузка...",
"challenge.admin.common.error.default": "Произошла ошибка при загрузке данных",
"challenge.admin.common.retry": "Попробовать снова",
"challenge.admin.common.confirm": "Подтвердить",
"challenge.admin.common.close": "Закрыть"
} }

View File

@ -3,7 +3,7 @@ import { BrowserRouter } from 'react-router-dom'
import { Dashboard } from './dashboard' import { Dashboard } from './dashboard'
import { Provider } from './theme' import { Provider } from './theme'
import { Provider as ReduxProvider } from 'react-redux' import { Provider as ReduxProvider } from 'react-redux'
import { Toaster } from './components/ui/toaster' import { ToasterProvider, Toaster } from './components/ui/toaster'
import type { PropsWithChildren } from 'react' import type { PropsWithChildren } from 'react'
const App = ({ store }: PropsWithChildren<{ store?: any }>) => { const App = ({ store }: PropsWithChildren<{ store?: any }>) => {
@ -14,10 +14,12 @@ const App = ({ store }: PropsWithChildren<{ store?: any }>) => {
return ( return (
<ReduxProvider store={store}> <ReduxProvider store={store}>
<Provider> <Provider>
<ToasterProvider>
<BrowserRouter> <BrowserRouter>
<Dashboard /> <Dashboard />
</BrowserRouter> </BrowserRouter>
<Toaster /> <Toaster />
</ToasterProvider>
</Provider> </Provider>
</ReduxProvider> </ReduxProvider>
) )

View File

@ -1,4 +1,5 @@
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next'
import { import {
DialogRoot, DialogRoot,
DialogContent, DialogContent,
@ -27,10 +28,14 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
onConfirm, onConfirm,
title, title,
message, message,
confirmLabel = 'Подтвердить', confirmLabel,
cancelLabel = 'Отмена', cancelLabel,
isLoading = false, isLoading = false,
}) => { }) => {
const { t } = useTranslation()
const confirm = confirmLabel || t('challenge.admin.common.confirm')
const cancel = cancelLabel || t('challenge.admin.common.cancel')
return ( return (
<DialogRoot open={isOpen} onOpenChange={(e) => !e.open && onClose()}> <DialogRoot open={isOpen} onOpenChange={(e) => !e.open && onClose()}>
<DialogContent> <DialogContent>
@ -43,15 +48,15 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
<DialogFooter> <DialogFooter>
<DialogActionTrigger asChild> <DialogActionTrigger asChild>
<Button variant="outline" onClick={onClose} disabled={isLoading}> <Button variant="outline" onClick={onClose} disabled={isLoading}>
{cancelLabel} {cancel}
</Button> </Button>
</DialogActionTrigger> </DialogActionTrigger>
<Button <Button
colorPalette="red" colorPalette="red"
onClick={onConfirm} onClick={onConfirm}
loading={isLoading} disabled={isLoading}
> >
{confirmLabel} {confirm}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@ -1,4 +1,5 @@
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next'
import { Box, Text, Button } from '@chakra-ui/react' import { Box, Text, Button } from '@chakra-ui/react'
interface ErrorAlertProps { interface ErrorAlertProps {
@ -7,9 +8,11 @@ interface ErrorAlertProps {
} }
export const ErrorAlert: React.FC<ErrorAlertProps> = ({ export const ErrorAlert: React.FC<ErrorAlertProps> = ({
message = 'Произошла ошибка при загрузке данных', message,
onRetry, onRetry,
}) => { }) => {
const { t } = useTranslation()
return ( return (
<Box <Box
bg="red.50" bg="red.50"
@ -20,11 +23,11 @@ export const ErrorAlert: React.FC<ErrorAlertProps> = ({
textAlign="center" textAlign="center"
> >
<Text color="red.700" fontWeight="medium" mb={4}> <Text color="red.700" fontWeight="medium" mb={4}>
{message} {message || t('challenge.admin.common.error.default')}
</Text> </Text>
{onRetry && ( {onRetry && (
<Button colorPalette="red" size="sm" onClick={onRetry}> <Button colorPalette="red" size="sm" onClick={onRetry}>
Попробовать снова {t('challenge.admin.common.retry')}
</Button> </Button>
)} )}
</Box> </Box>

View File

@ -1,4 +1,5 @@
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next'
import { Link, useLocation, useNavigate } from 'react-router-dom' import { Link, useLocation, useNavigate } from 'react-router-dom'
import { Box, Container, Flex, HStack, VStack, Button, Text } from '@chakra-ui/react' import { Box, Container, Flex, HStack, VStack, Button, Text } from '@chakra-ui/react'
import { useAppSelector } from '../__data__/store' import { useAppSelector } from '../__data__/store'
@ -10,6 +11,7 @@ interface LayoutProps {
} }
export const Layout: React.FC<LayoutProps> = ({ children }) => { export const Layout: React.FC<LayoutProps> = ({ children }) => {
const { t, i18n } = useTranslation()
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const user = useAppSelector((state) => state.user) const user = useAppSelector((state) => state.user)
@ -22,16 +24,20 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
navigate(URLs.challengePlayer) navigate(URLs.challengePlayer)
} }
const handleChangeLanguage = (lang: string) => {
i18n.changeLanguage(lang)
}
const isActive = (path: string) => { const isActive = (path: string) => {
return location.pathname === path return location.pathname === path
} }
const navItems = [ const navItems = [
{ label: 'Dashboard', path: URLs.dashboard }, { label: t('challenge.admin.layout.nav.dashboard'), path: URLs.dashboard },
{ label: 'Задания', path: URLs.tasks }, { label: t('challenge.admin.layout.nav.tasks'), path: URLs.tasks },
{ label: 'Цепочки', path: URLs.chains }, { label: t('challenge.admin.layout.nav.chains'), path: URLs.chains },
{ label: 'Пользователи', path: URLs.users }, { label: t('challenge.admin.layout.nav.users'), path: URLs.users },
{ label: 'Попытки', path: URLs.submissions }, { label: t('challenge.admin.layout.nav.submissions'), path: URLs.submissions },
] ]
return ( return (
@ -41,16 +47,38 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
<Container maxW="container.xl"> <Container maxW="container.xl">
<Flex h="16" alignItems="center" justifyContent="space-between"> <Flex h="16" alignItems="center" justifyContent="space-between">
<Text fontSize="xl" fontWeight="bold" color="teal.600"> <Text fontSize="xl" fontWeight="bold" color="teal.600">
Challenge Admin {t('challenge.admin.layout.title')}
</Text> </Text>
<HStack gap={4}> <HStack gap={4}>
{/* Language Switcher */}
<HStack gap={1} bg="gray.100" borderRadius="md" p={1}>
<Button
size="xs"
variant={i18n.language === 'ru' ? 'solid' : 'ghost'}
colorPalette={i18n.language === 'ru' ? 'teal' : 'gray'}
onClick={() => handleChangeLanguage('ru')}
minW="40px"
>
RU
</Button>
<Button
size="xs"
variant={i18n.language === 'en' ? 'solid' : 'ghost'}
colorPalette={i18n.language === 'en' ? 'teal' : 'gray'}
onClick={() => handleChangeLanguage('en')}
minW="40px"
>
EN
</Button>
</HStack>
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
onClick={handleNavigateToPlayer} onClick={handleNavigateToPlayer}
> >
Открыть проигрыватель {t('challenge.admin.layout.button.player')}
</Button> </Button>
{user && ( {user && (
@ -64,7 +92,7 @@ export const Layout: React.FC<LayoutProps> = ({ children }) => {
variant="ghost" variant="ghost"
onClick={handleLogout} onClick={handleLogout}
> >
Выйти {t('challenge.admin.layout.button.logout')}
</Button> </Button>
</HStack> </HStack>
)} )}

View File

@ -1,16 +1,19 @@
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next'
import { Flex, Spinner, Text, VStack } from '@chakra-ui/react' import { Flex, Spinner, Text, VStack } from '@chakra-ui/react'
interface LoadingSpinnerProps { interface LoadingSpinnerProps {
message?: string message?: string
} }
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({ message = 'Загрузка...' }) => { export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({ message }) => {
const { t } = useTranslation()
return ( return (
<Flex justify="center" align="center" minH="400px"> <Flex justify="center" align="center" minH="400px">
<VStack gap={4}> <VStack gap={4}>
<Spinner size="xl" color="teal.500" /> <Spinner size="xl" color="teal.500" />
<Text color="gray.600">{message}</Text> <Text color="gray.600">{message || t('challenge.admin.common.loading.default')}</Text>
</VStack> </VStack>
</Flex> </Flex>
) )

View File

@ -1,4 +1,5 @@
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next'
import { Badge } from '@chakra-ui/react' import { Badge } from '@chakra-ui/react'
import type { SubmissionStatus } from '../types/challenge' import type { SubmissionStatus } from '../types/challenge'
@ -7,6 +8,8 @@ interface StatusBadgeProps {
} }
export const StatusBadge: React.FC<StatusBadgeProps> = ({ status }) => { export const StatusBadge: React.FC<StatusBadgeProps> = ({ status }) => {
const { t } = useTranslation()
const getColorPalette = () => { const getColorPalette = () => {
switch (status) { switch (status) {
case 'accepted': case 'accepted':
@ -22,24 +25,9 @@ export const StatusBadge: React.FC<StatusBadgeProps> = ({ status }) => {
} }
} }
const getLabel = () => {
switch (status) {
case 'accepted':
return 'Принято'
case 'needs_revision':
return 'Доработка'
case 'in_progress':
return 'Проверяется'
case 'pending':
return 'Ожидает'
default:
return status
}
}
return ( return (
<Badge colorPalette={getColorPalette()} variant="subtle"> <Badge colorPalette={getColorPalette()} variant="subtle">
{getLabel()} {t(`challenge.admin.submissions.status.${status}`)}
</Badge> </Badge>
) )
} }

View File

@ -1,12 +1,198 @@
import React from 'react' import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react'
import { createToaster, Toaster as ChakraToaster } from '@chakra-ui/react' import { createPortal } from 'react-dom'
import { Box, Text, HStack, IconButton } from '@chakra-ui/react'
export const toaster = createToaster({ type ToastType = 'success' | 'error' | 'info' | 'warning'
placement: 'top-end',
duration: 3000,
})
export const Toaster = () => { interface Toast {
return <ChakraToaster toaster={toaster} /> id: string
title: string
description?: string
type: ToastType
duration?: number
} }
interface ToasterContextValue {
create: (toast: Omit<Toast, 'id'>) => void
}
const ToasterContext = createContext<ToasterContextValue | null>(null)
export const useToaster = () => {
const context = useContext(ToasterContext)
if (!context) {
throw new Error('useToaster must be used within ToasterProvider')
}
return context
}
const getToastColor = (type: ToastType) => {
switch (type) {
case 'success':
return { bg: 'green.500', color: 'white' }
case 'error':
return { bg: 'red.500', color: 'white' }
case 'warning':
return { bg: 'orange.500', color: 'white' }
case 'info':
return { bg: 'blue.500', color: 'white' }
}
}
const getToastIcon = (type: ToastType) => {
switch (type) {
case 'success':
return '✓'
case 'error':
return '✕'
case 'warning':
return '⚠'
case 'info':
return ''
}
}
export const ToasterProvider = ({ children }: { children: ReactNode }) => {
const [toasts, setToasts] = useState<Toast[]>([])
const create = useCallback((toast: Omit<Toast, 'id'>) => {
const id = Math.random().toString(36).substring(2, 9)
const newToast = { ...toast, id }
setToasts((prev) => [...prev, newToast])
const duration = toast.duration ?? 3000
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, duration)
}, [])
const remove = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
return (
<ToasterContext.Provider value={{ create }}>
{children}
{typeof window !== 'undefined' &&
createPortal(
<Box
position="fixed"
top="20px"
right="20px"
zIndex={9999}
display="flex"
flexDirection="column"
gap={3}
>
{toasts.map((toast) => {
const colors = getToastColor(toast.type)
return (
<Box
key={toast.id}
bg={colors.bg}
color={colors.color}
p={4}
borderRadius="md"
boxShadow="lg"
minW="300px"
maxW="400px"
animation="slideIn 0.3s ease-out"
>
<HStack justify="space-between" align="start">
<HStack gap={3} flex={1}>
<Text fontSize="xl" fontWeight="bold">
{getToastIcon(toast.type)}
</Text>
<Box flex={1}>
<Text fontWeight="bold" fontSize="sm">
{toast.title}
</Text>
{toast.description && (
<Text fontSize="sm" mt={1}>
{toast.description}
</Text>
)}
</Box>
</HStack>
<IconButton
aria-label="Close"
size="xs"
variant="ghost"
color={colors.color}
onClick={() => remove(toast.id)}
_hover={{ bg: 'whiteAlpha.300' }}
>
</IconButton>
</HStack>
</Box>
)
})}
</Box>,
document.body
)}
</ToasterContext.Provider>
)
}
// Singleton instance для совместимости со старым API
class ToasterSingleton {
private static instance: ToasterSingleton
private createFn: ((toast: Omit<Toast, 'id'>) => void) | null = null
static getInstance(): ToasterSingleton {
if (!ToasterSingleton.instance) {
ToasterSingleton.instance = new ToasterSingleton()
}
return ToasterSingleton.instance
}
setCreateFn(fn: (toast: Omit<Toast, 'id'>) => void) {
this.createFn = fn
}
create(toast: Omit<Toast, 'id'>) {
if (this.createFn) {
this.createFn(toast)
} else {
console.error('Toaster not initialized')
}
}
}
export const toaster = ToasterSingleton.getInstance()
// Компонент для инициализации singleton
export const ToasterInitializer = () => {
const { create } = useToaster()
React.useEffect(() => {
toaster.setCreateFn(create)
}, [create])
return null
}
// Старый компонент Toaster для совместимости
export const Toaster = () => {
return (
<>
<ToasterInitializer />
<style>
{`
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
`}
</style>
</>
)
}

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { import {
Box, Box,
Heading, Heading,
@ -29,6 +30,7 @@ export const ChainFormPage: React.FC = () => {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const isEdit = !!id const isEdit = !!id
const { t } = useTranslation()
const { data: chain, isLoading: isLoadingChain, error: loadError } = useGetChainQuery(id!, { const { data: chain, isLoading: isLoadingChain, error: loadError } = useGetChainQuery(id!, {
skip: !id, skip: !id,
@ -53,8 +55,8 @@ export const ChainFormPage: React.FC = () => {
if (!name.trim()) { if (!name.trim()) {
toaster.create({ toaster.create({
title: 'Ошибка валидации', title: t('challenge.admin.common.validation.error'),
description: 'Введите название цепочки', description: t('challenge.admin.chains.validation.enter.name'),
type: 'error', type: 'error',
}) })
return return
@ -62,8 +64,8 @@ export const ChainFormPage: React.FC = () => {
if (selectedTasks.length === 0) { if (selectedTasks.length === 0) {
toaster.create({ toaster.create({
title: 'Ошибка валидации', title: t('challenge.admin.common.validation.error'),
description: 'Добавьте хотя бы одно задание', description: t('challenge.admin.chains.validation.add.task'),
type: 'error', type: 'error',
}) })
return return
@ -81,8 +83,8 @@ export const ChainFormPage: React.FC = () => {
}, },
}).unwrap() }).unwrap()
toaster.create({ toaster.create({
title: 'Успешно', title: t('challenge.admin.common.success'),
description: 'Цепочка обновлена', description: t('challenge.admin.chains.updated'),
type: 'success', type: 'success',
}) })
} else { } else {
@ -91,16 +93,24 @@ export const ChainFormPage: React.FC = () => {
tasks: taskIds, tasks: taskIds,
}).unwrap() }).unwrap()
toaster.create({ toaster.create({
title: 'Успешно', title: t('challenge.admin.common.success'),
description: 'Цепочка создана', description: t('challenge.admin.chains.created'),
type: 'success', type: 'success',
}) })
} }
navigate(URLs.chains) navigate(URLs.chains)
} catch (err: any) { } catch (err: unknown) {
const errorMessage =
(err && typeof err === 'object' && 'data' in err &&
err.data && typeof err.data === 'object' && 'error' in err.data &&
err.data.error && typeof err.data.error === 'object' && 'message' in err.data.error &&
typeof err.data.error.message === 'string')
? err.data.error.message
: t('challenge.admin.chains.save.error')
toaster.create({ toaster.create({
title: 'Ошибка', title: t('challenge.admin.common.error'),
description: err?.data?.error?.message || 'Не удалось сохранить цепочку', description: errorMessage,
type: 'error', type: 'error',
}) })
} }
@ -131,19 +141,19 @@ export const ChainFormPage: React.FC = () => {
} }
if (isEdit && isLoadingChain) { if (isEdit && isLoadingChain) {
return <LoadingSpinner message="Загрузка цепочки..." /> return <LoadingSpinner message={t('challenge.admin.chains.loading')} />
} }
if (isEdit && loadError) { if (isEdit && loadError) {
return <ErrorAlert message="Не удалось загрузить цепочку" /> return <ErrorAlert message={t('challenge.admin.chains.load.error')} />
} }
if (isLoadingTasks) { if (isLoadingTasks) {
return <LoadingSpinner message="Загрузка заданий..." /> return <LoadingSpinner message={t('challenge.admin.common.loading.tasks')} />
} }
if (!allTasks) { if (!allTasks) {
return <ErrorAlert message="Не удалось загрузить список заданий" /> return <ErrorAlert message={t('challenge.admin.chains.tasks.load.error')} />
} }
const isLoading = isCreating || isUpdating const isLoading = isCreating || isUpdating
@ -156,7 +166,7 @@ export const ChainFormPage: React.FC = () => {
return ( return (
<Box> <Box>
<Heading mb={6}>{isEdit ? 'Редактировать цепочку' : 'Создать цепочку'}</Heading> <Heading mb={6}>{isEdit ? t('challenge.admin.chains.edit.title') : t('challenge.admin.chains.create.title')}</Heading>
<Box <Box
as="form" as="form"
@ -171,11 +181,11 @@ export const ChainFormPage: React.FC = () => {
<VStack gap={6} align="stretch"> <VStack gap={6} align="stretch">
{/* Name */} {/* Name */}
<Field.Root required> <Field.Root required>
<Field.Label>Название цепочки</Field.Label> <Field.Label>{t('challenge.admin.chains.field.name')}</Field.Label>
<Input <Input
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="Введите название цепочки" placeholder={t('challenge.admin.chains.field.name.placeholder')}
maxLength={255} maxLength={255}
disabled={isLoading} disabled={isLoading}
/> />
@ -184,7 +194,7 @@ export const ChainFormPage: React.FC = () => {
{/* Selected Tasks */} {/* Selected Tasks */}
<Box> <Box>
<Text fontWeight="bold" mb={3}> <Text fontWeight="bold" mb={3}>
Задания в цепочке ({selectedTasks.length}) {t('challenge.admin.chains.selected.tasks')} ({selectedTasks.length})
</Text> </Text>
{selectedTasks.length === 0 ? ( {selectedTasks.length === 0 ? (
<Box <Box
@ -195,7 +205,7 @@ export const ChainFormPage: React.FC = () => {
borderRadius="md" borderRadius="md"
textAlign="center" textAlign="center"
> >
<Text color="gray.500">Добавьте задания из списка ниже</Text> <Text color="gray.500">{t('challenge.admin.chains.selected.tasks.empty')}</Text>
</Box> </Box>
) : ( ) : (
<VStack gap={2} align="stretch"> <VStack gap={2} align="stretch">
@ -255,10 +265,10 @@ export const ChainFormPage: React.FC = () => {
{/* Available Tasks */} {/* Available Tasks */}
<Box> <Box>
<Text fontWeight="bold" mb={3}> <Text fontWeight="bold" mb={3}>
Доступные задания {t('challenge.admin.chains.available.tasks')}
</Text> </Text>
<Input <Input
placeholder="Поиск заданий..." placeholder={t('challenge.admin.chains.search.placeholder')}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
mb={3} mb={3}
@ -273,8 +283,8 @@ export const ChainFormPage: React.FC = () => {
> >
<Text color="gray.500"> <Text color="gray.500">
{allTasks.length === selectedTasks.length {allTasks.length === selectedTasks.length
? 'Все задания уже добавлены' ? t('challenge.admin.chains.all.tasks.added')
: 'Ничего не найдено'} : t('challenge.admin.common.not.found')}
</Text> </Text>
</Box> </Box>
) : ( ) : (
@ -295,7 +305,7 @@ export const ChainFormPage: React.FC = () => {
> >
<Text>{task.title}</Text> <Text>{task.title}</Text>
<Button size="sm" colorPalette="teal" variant="ghost"> <Button size="sm" colorPalette="teal" variant="ghost">
+ Добавить {t('challenge.admin.chains.button.add')}
</Button> </Button>
</Flex> </Flex>
))} ))}
@ -306,10 +316,10 @@ export const ChainFormPage: React.FC = () => {
{/* Actions */} {/* Actions */}
<HStack gap={3} justify="flex-end"> <HStack gap={3} justify="flex-end">
<Button variant="outline" onClick={() => navigate(URLs.chains)} disabled={isLoading}> <Button variant="outline" onClick={() => navigate(URLs.chains)} disabled={isLoading}>
Отмена {t('challenge.admin.common.cancel')}
</Button> </Button>
<Button type="submit" colorPalette="teal" loading={isLoading}> <Button type="submit" colorPalette="teal" disabled={isLoading}>
{isEdit ? 'Сохранить изменения' : 'Создать цепочку'} {isEdit ? t('challenge.admin.chains.button.save') : t('challenge.admin.chains.button.create')}
</Button> </Button>
</HStack> </HStack>
</VStack> </VStack>

View File

@ -1,5 +1,6 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { import {
Box, Box,
Heading, Heading,
@ -22,6 +23,7 @@ import { toaster } from '../../components/ui/toaster'
export const ChainsListPage: React.FC = () => { export const ChainsListPage: React.FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
const { t } = useTranslation()
const { data: chains, isLoading, error, refetch } = useGetChainsQuery() const { data: chains, isLoading, error, refetch } = useGetChainsQuery()
const [deleteChain, { isLoading: isDeleting }] = useDeleteChainMutation() const [deleteChain, { isLoading: isDeleting }] = useDeleteChainMutation()
@ -34,26 +36,26 @@ export const ChainsListPage: React.FC = () => {
try { try {
await deleteChain(chainToDelete.id).unwrap() await deleteChain(chainToDelete.id).unwrap()
toaster.create({ toaster.create({
title: 'Успешно', title: t('challenge.admin.common.success'),
description: 'Цепочка удалена', description: t('challenge.admin.chains.deleted'),
type: 'success', type: 'success',
}) })
setChainToDelete(null) setChainToDelete(null)
} catch (err) { } catch (err) {
toaster.create({ toaster.create({
title: 'Ошибка', title: t('challenge.admin.common.error'),
description: 'Не удалось удалить цепочку', description: t('challenge.admin.chains.delete.error'),
type: 'error', type: 'error',
}) })
} }
} }
if (isLoading) { if (isLoading) {
return <LoadingSpinner message="Загрузка цепочек..." /> return <LoadingSpinner message={t('challenge.admin.chains.list.loading')} />
} }
if (error || !chains) { if (error || !chains) {
return <ErrorAlert message="Не удалось загрузить список цепочек" onRetry={refetch} /> return <ErrorAlert message={t('challenge.admin.chains.list.load.error')} onRetry={refetch} />
} }
const filteredChains = chains.filter((chain) => const filteredChains = chains.filter((chain) =>
@ -71,16 +73,16 @@ export const ChainsListPage: React.FC = () => {
return ( return (
<Box> <Box>
<Flex justify="space-between" align="center" mb={6}> <Flex justify="space-between" align="center" mb={6}>
<Heading>Цепочки заданий</Heading> <Heading>{t('challenge.admin.chains.list.title')}</Heading>
<Button colorPalette="teal" onClick={() => navigate(URLs.chainNew)}> <Button colorPalette="teal" onClick={() => navigate(URLs.chainNew)}>
+ Создать цепочку {t('challenge.admin.chains.list.create.button')}
</Button> </Button>
</Flex> </Flex>
{chains.length > 0 && ( {chains.length > 0 && (
<Box mb={4}> <Box mb={4}>
<Input <Input
placeholder="Поиск по названию..." placeholder={t('challenge.admin.chains.list.search.placeholder')}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
maxW="400px" maxW="400px"
@ -90,25 +92,25 @@ export const ChainsListPage: React.FC = () => {
{filteredChains.length === 0 && chains.length === 0 ? ( {filteredChains.length === 0 && chains.length === 0 ? (
<EmptyState <EmptyState
title="Нет цепочек" title={t('challenge.admin.chains.list.empty.title')}
description="Создайте первую цепочку заданий" description={t('challenge.admin.chains.list.empty.description')}
actionLabel="Создать цепочку" actionLabel={t('challenge.admin.chains.list.empty.action')}
onAction={() => navigate(URLs.chainNew)} onAction={() => navigate(URLs.chainNew)}
/> />
) : filteredChains.length === 0 ? ( ) : filteredChains.length === 0 ? (
<EmptyState <EmptyState
title="Ничего не найдено" title={t('challenge.admin.common.not.found')}
description={`По запросу "${searchQuery}" ничего не найдено`} description={t('challenge.admin.chains.list.search.empty', { query: searchQuery })}
/> />
) : ( ) : (
<Box bg="white" borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" overflowX="auto"> <Box bg="white" borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" overflowX="auto">
<Table.Root size="sm"> <Table.Root size="sm">
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.ColumnHeader>Название</Table.ColumnHeader> <Table.ColumnHeader>{t('challenge.admin.chains.list.table.name')}</Table.ColumnHeader>
<Table.ColumnHeader>Количество заданий</Table.ColumnHeader> <Table.ColumnHeader>{t('challenge.admin.chains.list.table.tasks.count')}</Table.ColumnHeader>
<Table.ColumnHeader>Дата создания</Table.ColumnHeader> <Table.ColumnHeader>{t('challenge.admin.chains.list.table.created')}</Table.ColumnHeader>
<Table.ColumnHeader textAlign="right">Действия</Table.ColumnHeader> <Table.ColumnHeader textAlign="right">{t('challenge.admin.chains.list.table.actions')}</Table.ColumnHeader>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
@ -117,7 +119,7 @@ export const ChainsListPage: React.FC = () => {
<Table.Cell fontWeight="medium">{chain.name}</Table.Cell> <Table.Cell fontWeight="medium">{chain.name}</Table.Cell>
<Table.Cell> <Table.Cell>
<Badge colorPalette="teal" variant="subtle"> <Badge colorPalette="teal" variant="subtle">
{chain.tasks.length} заданий {chain.tasks.length} {t('challenge.admin.chains.list.badge.tasks')}
</Badge> </Badge>
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
@ -132,7 +134,7 @@ export const ChainsListPage: React.FC = () => {
variant="ghost" variant="ghost"
onClick={() => navigate(URLs.chainEdit(chain.id))} onClick={() => navigate(URLs.chainEdit(chain.id))}
> >
Редактировать {t('challenge.admin.chains.list.button.edit')}
</Button> </Button>
<Button <Button
size="sm" size="sm"
@ -140,7 +142,7 @@ export const ChainsListPage: React.FC = () => {
colorPalette="red" colorPalette="red"
onClick={() => setChainToDelete(chain)} onClick={() => setChainToDelete(chain)}
> >
Удалить {t('challenge.admin.chains.list.button.delete')}
</Button> </Button>
</HStack> </HStack>
</Table.Cell> </Table.Cell>
@ -155,9 +157,9 @@ export const ChainsListPage: React.FC = () => {
isOpen={!!chainToDelete} isOpen={!!chainToDelete}
onClose={() => setChainToDelete(null)} onClose={() => setChainToDelete(null)}
onConfirm={handleDeleteChain} onConfirm={handleDeleteChain}
title="Удалить цепочку" title={t('challenge.admin.chains.delete.confirm.title')}
message={`Вы уверены, что хотите удалить цепочку "${chainToDelete?.name}"? Это действие нельзя отменить.`} message={t('challenge.admin.chains.delete.confirm.message', { name: chainToDelete?.name })}
confirmLabel="Удалить" confirmLabel={t('challenge.admin.chains.delete.confirm.button')}
isLoading={isDeleting} isLoading={isDeleting}
/> />
</Box> </Box>

View File

@ -1,4 +1,5 @@
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next'
import { Box, Heading, Grid, Text, VStack, HStack, Badge, Progress } from '@chakra-ui/react' import { Box, Heading, Grid, Text, VStack, HStack, Badge, Progress } from '@chakra-ui/react'
import { useGetSystemStatsQuery } from '../../__data__/api/api' import { useGetSystemStatsQuery } from '../../__data__/api/api'
import { StatCard } from '../../components/StatCard' import { StatCard } from '../../components/StatCard'
@ -6,16 +7,17 @@ import { LoadingSpinner } from '../../components/LoadingSpinner'
import { ErrorAlert } from '../../components/ErrorAlert' import { ErrorAlert } from '../../components/ErrorAlert'
export const DashboardPage: React.FC = () => { export const DashboardPage: React.FC = () => {
const { t } = useTranslation()
const { data: stats, isLoading, error, refetch } = useGetSystemStatsQuery(undefined, { const { data: stats, isLoading, error, refetch } = useGetSystemStatsQuery(undefined, {
pollingInterval: 10000, // Обновление каждые 10 секунд pollingInterval: 10000, // Обновление каждые 10 секунд
}) })
if (isLoading) { if (isLoading) {
return <LoadingSpinner message="Загрузка статистики..." /> return <LoadingSpinner message={t('challenge.admin.dashboard.loading')} />
} }
if (error || !stats) { if (error || !stats) {
return <ErrorAlert message="Не удалось загрузить статистику системы" onRetry={refetch} /> return <ErrorAlert message={t('challenge.admin.dashboard.load.error')} onRetry={refetch} />
} }
const acceptanceRate = stats.submissions.total > 0 const acceptanceRate = stats.submissions.total > 0
@ -32,25 +34,25 @@ export const DashboardPage: React.FC = () => {
return ( return (
<Box> <Box>
<Heading mb={6}>Dashboard</Heading> <Heading mb={6}>{t('challenge.admin.dashboard.title')}</Heading>
{/* Main Stats */} {/* Main Stats */}
<Grid templateColumns="repeat(auto-fit, minmax(250px, 1fr))" gap={6} mb={8}> <Grid templateColumns="repeat(auto-fit, minmax(250px, 1fr))" gap={6} mb={8}>
<StatCard label="Всего пользователей" value={stats.users} colorScheme="blue" /> <StatCard label={t('challenge.admin.dashboard.stats.users')} value={stats.users} colorScheme="blue" />
<StatCard label="Всего заданий" value={stats.tasks} colorScheme="teal" /> <StatCard label={t('challenge.admin.dashboard.stats.tasks')} value={stats.tasks} colorScheme="teal" />
<StatCard label="Всего цепочек" value={stats.chains} colorScheme="purple" /> <StatCard label={t('challenge.admin.dashboard.stats.chains')} value={stats.chains} colorScheme="purple" />
<StatCard label="Всего проверок" value={stats.submissions.total} colorScheme="orange" /> <StatCard label={t('challenge.admin.dashboard.stats.submissions')} value={stats.submissions.total} colorScheme="orange" />
</Grid> </Grid>
{/* Submissions Stats */} {/* Submissions Stats */}
<Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" mb={8}> <Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" mb={8}>
<Heading size="md" mb={4}> <Heading size="md" mb={4}>
Статистика проверок {t('challenge.admin.dashboard.submissions.title')}
</Heading> </Heading>
<Grid templateColumns="repeat(auto-fit, minmax(200px, 1fr))" gap={6}> <Grid templateColumns="repeat(auto-fit, minmax(200px, 1fr))" gap={6}>
<VStack align="start" gap={2}> <VStack align="start" gap={2}>
<Text fontSize="sm" color="gray.600"> <Text fontSize="sm" color="gray.600">
Принято {t('challenge.admin.dashboard.submissions.accepted')}
</Text> </Text>
<HStack> <HStack>
<Text fontSize="2xl" fontWeight="bold" color="green.600"> <Text fontSize="2xl" fontWeight="bold" color="green.600">
@ -62,7 +64,7 @@ export const DashboardPage: React.FC = () => {
<VStack align="start" gap={2}> <VStack align="start" gap={2}>
<Text fontSize="sm" color="gray.600"> <Text fontSize="sm" color="gray.600">
Отклонено {t('challenge.admin.dashboard.submissions.rejected')}
</Text> </Text>
<HStack> <HStack>
<Text fontSize="2xl" fontWeight="bold" color="red.600"> <Text fontSize="2xl" fontWeight="bold" color="red.600">
@ -74,7 +76,7 @@ export const DashboardPage: React.FC = () => {
<VStack align="start" gap={2}> <VStack align="start" gap={2}>
<Text fontSize="sm" color="gray.600"> <Text fontSize="sm" color="gray.600">
Ожидают {t('challenge.admin.dashboard.submissions.pending')}
</Text> </Text>
<Text fontSize="2xl" fontWeight="bold" color="yellow.600"> <Text fontSize="2xl" fontWeight="bold" color="yellow.600">
{stats.submissions.pending} {stats.submissions.pending}
@ -83,7 +85,7 @@ export const DashboardPage: React.FC = () => {
<VStack align="start" gap={2}> <VStack align="start" gap={2}>
<Text fontSize="sm" color="gray.600"> <Text fontSize="sm" color="gray.600">
В процессе {t('challenge.admin.dashboard.submissions.in.progress')}
</Text> </Text>
<Text fontSize="2xl" fontWeight="bold" color="blue.600"> <Text fontSize="2xl" fontWeight="bold" color="blue.600">
{stats.submissions.inProgress} {stats.submissions.inProgress}
@ -95,13 +97,13 @@ export const DashboardPage: React.FC = () => {
{/* Queue Stats */} {/* Queue Stats */}
<Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" mb={8}> <Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" mb={8}>
<Heading size="md" mb={4}> <Heading size="md" mb={4}>
Статус очереди {t('challenge.admin.dashboard.queue.title')}
</Heading> </Heading>
<Grid templateColumns="repeat(auto-fit, minmax(250px, 1fr))" gap={6} mb={4}> <Grid templateColumns="repeat(auto-fit, minmax(250px, 1fr))" gap={6} mb={4}>
<VStack align="start" gap={2}> <VStack align="start" gap={2}>
<Text fontSize="sm" color="gray.600"> <Text fontSize="sm" color="gray.600">
В обработке {t('challenge.admin.dashboard.queue.processing')}
</Text> </Text>
<HStack align="baseline"> <HStack align="baseline">
<Text fontSize="2xl" fontWeight="bold" color="teal.600"> <Text fontSize="2xl" fontWeight="bold" color="teal.600">
@ -115,7 +117,7 @@ export const DashboardPage: React.FC = () => {
<VStack align="start" gap={2}> <VStack align="start" gap={2}>
<Text fontSize="sm" color="gray.600"> <Text fontSize="sm" color="gray.600">
Ожидают в очереди {t('challenge.admin.dashboard.queue.waiting')}
</Text> </Text>
<Text fontSize="2xl" fontWeight="bold" color="orange.600"> <Text fontSize="2xl" fontWeight="bold" color="orange.600">
{stats.queue.waiting} {stats.queue.waiting}
@ -124,7 +126,7 @@ export const DashboardPage: React.FC = () => {
<VStack align="start" gap={2}> <VStack align="start" gap={2}>
<Text fontSize="sm" color="gray.600"> <Text fontSize="sm" color="gray.600">
Всего в очереди {t('challenge.admin.dashboard.queue.total')}
</Text> </Text>
<Text fontSize="2xl" fontWeight="bold" color="blue.600"> <Text fontSize="2xl" fontWeight="bold" color="blue.600">
{stats.queue.queueLength} {stats.queue.queueLength}
@ -134,7 +136,7 @@ export const DashboardPage: React.FC = () => {
<Box> <Box>
<Text fontSize="sm" color="gray.600" mb={2}> <Text fontSize="sm" color="gray.600" mb={2}>
Загруженность очереди: {queueUtilization}% {t('challenge.admin.dashboard.queue.utilization')} {queueUtilization}%
</Text> </Text>
<Progress.Root value={Number(queueUtilization)} colorPalette="teal" size="sm" borderRadius="full"> <Progress.Root value={Number(queueUtilization)} colorPalette="teal" size="sm" borderRadius="full">
<Progress.Track> <Progress.Track>
@ -147,13 +149,13 @@ export const DashboardPage: React.FC = () => {
{/* Average Check Time */} {/* Average Check Time */}
<Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200"> <Box bg="white" p={6} borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200">
<Heading size="md" mb={2}> <Heading size="md" mb={2}>
Среднее время проверки {t('challenge.admin.dashboard.check.time.title')}
</Heading> </Heading>
<Text fontSize="3xl" fontWeight="bold" color="purple.600"> <Text fontSize="3xl" fontWeight="bold" color="purple.600">
{(stats.averageCheckTimeMs / 1000).toFixed(2)} сек {t('challenge.admin.dashboard.check.time.value', { time: (stats.averageCheckTimeMs / 1000).toFixed(2) })}
</Text> </Text>
<Text fontSize="sm" color="gray.600" mt={2}> <Text fontSize="sm" color="gray.600" mt={2}>
Время от отправки решения до получения результата {t('challenge.admin.dashboard.check.time.description')}
</Text> </Text>
</Box> </Box>
</Box> </Box>

View File

@ -1,4 +0,0 @@
import { lazy } from 'react'
export const MainPage = lazy(() => import(/* webpackChunkName: 'main' */'./main'))

View File

@ -1,2 +0,0 @@
export { MainPage as default } from './main'

View File

@ -1,11 +0,0 @@
import React from 'react'
export const MainPage = () => {
return (
<div>
<h1>Главная страница проекта challenge-admin-pl</h1>
<p>Это базовая страница с React Router</p>
</div>
)
}

View File

@ -1,4 +1,5 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { import {
Box, Box,
Heading, Heading,
@ -27,6 +28,7 @@ import { StatusBadge } from '../../components/StatusBadge'
import type { ChallengeSubmission, SubmissionStatus, ChallengeTask, ChallengeUser } from '../../types/challenge' import type { ChallengeSubmission, SubmissionStatus, ChallengeTask, ChallengeUser } from '../../types/challenge'
export const SubmissionsPage: React.FC = () => { export const SubmissionsPage: React.FC = () => {
const { t } = useTranslation()
const { data: submissions, isLoading, error, refetch } = useGetAllSubmissionsQuery() const { data: submissions, isLoading, error, refetch } = useGetAllSubmissionsQuery()
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
@ -34,11 +36,11 @@ export const SubmissionsPage: React.FC = () => {
const [selectedSubmission, setSelectedSubmission] = useState<ChallengeSubmission | null>(null) const [selectedSubmission, setSelectedSubmission] = useState<ChallengeSubmission | null>(null)
if (isLoading) { if (isLoading) {
return <LoadingSpinner message="Загрузка попыток..." /> return <LoadingSpinner message={t('challenge.admin.submissions.loading')} />
} }
if (error || !submissions) { if (error || !submissions) {
return <ErrorAlert message="Не удалось загрузить список попыток" onRetry={refetch} /> return <ErrorAlert message={t('challenge.admin.submissions.load.error')} onRetry={refetch} />
} }
const filteredSubmissions = submissions.filter((submission) => { const filteredSubmissions = submissions.filter((submission) => {
@ -69,28 +71,28 @@ export const SubmissionsPage: React.FC = () => {
const submitted = new Date(submission.submittedAt).getTime() const submitted = new Date(submission.submittedAt).getTime()
const checked = new Date(submission.checkedAt).getTime() const checked = new Date(submission.checkedAt).getTime()
const diff = Math.round((checked - submitted) / 1000) const diff = Math.round((checked - submitted) / 1000)
return `${diff} сек` return t('challenge.admin.submissions.check.time', { time: diff })
} }
const statusOptions = createListCollection({ const statusOptions = createListCollection({
items: [ items: [
{ label: 'Все статусы', value: 'all' }, { label: t('challenge.admin.submissions.status.all'), value: 'all' },
{ label: 'Принято', value: 'accepted' }, { label: t('challenge.admin.submissions.status.accepted'), value: 'accepted' },
{ label: 'Доработка', value: 'needs_revision' }, { label: t('challenge.admin.submissions.status.needs.revision'), value: 'needs_revision' },
{ label: 'Проверяется', value: 'in_progress' }, { label: t('challenge.admin.submissions.status.in.progress'), value: 'in_progress' },
{ label: 'Ожидает', value: 'pending' }, { label: t('challenge.admin.submissions.status.pending'), value: 'pending' },
], ],
}) })
return ( return (
<Box> <Box>
<Heading mb={6}>Попытки решений</Heading> <Heading mb={6}>{t('challenge.admin.submissions.title')}</Heading>
{/* Filters */} {/* Filters */}
{submissions.length > 0 && ( {submissions.length > 0 && (
<HStack mb={4} gap={4}> <HStack mb={4} gap={4}>
<Input <Input
placeholder="Поиск по пользователю или заданию..." placeholder={t('challenge.admin.submissions.search.placeholder')}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
maxW="400px" maxW="400px"
@ -102,7 +104,7 @@ export const SubmissionsPage: React.FC = () => {
maxW="200px" maxW="200px"
> >
<Select.Trigger> <Select.Trigger>
<Select.ValueText placeholder="Статус" /> <Select.ValueText placeholder={t('challenge.admin.submissions.filter.status')} />
</Select.Trigger> </Select.Trigger>
<Select.Content> <Select.Content>
{statusOptions.items.map((option) => ( {statusOptions.items.map((option) => (
@ -116,21 +118,21 @@ export const SubmissionsPage: React.FC = () => {
)} )}
{filteredSubmissions.length === 0 && submissions.length === 0 ? ( {filteredSubmissions.length === 0 && submissions.length === 0 ? (
<EmptyState title="Нет попыток" description="Попытки появятся после отправки решений" /> <EmptyState title={t('challenge.admin.submissions.empty.title')} description={t('challenge.admin.submissions.empty.description')} />
) : filteredSubmissions.length === 0 ? ( ) : filteredSubmissions.length === 0 ? (
<EmptyState title="Ничего не найдено" description="Попробуйте изменить фильтры" /> <EmptyState title={t('challenge.admin.submissions.search.empty.title')} description={t('challenge.admin.submissions.search.empty.description')} />
) : ( ) : (
<Box bg="white" borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" overflowX="auto"> <Box bg="white" borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" overflowX="auto">
<Table.Root size="sm"> <Table.Root size="sm">
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.ColumnHeader>Пользователь</Table.ColumnHeader> <Table.ColumnHeader>{t('challenge.admin.submissions.table.user')}</Table.ColumnHeader>
<Table.ColumnHeader>Задание</Table.ColumnHeader> <Table.ColumnHeader>{t('challenge.admin.submissions.table.task')}</Table.ColumnHeader>
<Table.ColumnHeader>Статус</Table.ColumnHeader> <Table.ColumnHeader>{t('challenge.admin.submissions.table.status')}</Table.ColumnHeader>
<Table.ColumnHeader>Попытка</Table.ColumnHeader> <Table.ColumnHeader>{t('challenge.admin.submissions.table.attempt')}</Table.ColumnHeader>
<Table.ColumnHeader>Дата отправки</Table.ColumnHeader> <Table.ColumnHeader>{t('challenge.admin.submissions.table.submitted')}</Table.ColumnHeader>
<Table.ColumnHeader>Время проверки</Table.ColumnHeader> <Table.ColumnHeader>{t('challenge.admin.submissions.table.check.time')}</Table.ColumnHeader>
<Table.ColumnHeader textAlign="right">Действия</Table.ColumnHeader> <Table.ColumnHeader textAlign="right">{t('challenge.admin.submissions.table.actions')}</Table.ColumnHeader>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
@ -167,7 +169,7 @@ export const SubmissionsPage: React.FC = () => {
colorPalette="teal" colorPalette="teal"
onClick={() => setSelectedSubmission(submission)} onClick={() => setSelectedSubmission(submission)}
> >
Детали {t('challenge.admin.submissions.button.details')}
</Button> </Button>
</Table.Cell> </Table.Cell>
</Table.Row> </Table.Row>
@ -199,6 +201,8 @@ const SubmissionDetailsModal: React.FC<SubmissionDetailsModalProps> = ({
isOpen, isOpen,
onClose, onClose,
}) => { }) => {
const { t } = useTranslation()
if (!submission) return null if (!submission) return null
const user = submission.user as ChallengeUser const user = submission.user as ChallengeUser
@ -215,7 +219,7 @@ const SubmissionDetailsModal: React.FC<SubmissionDetailsModalProps> = ({
}) })
} }
const getCheckTime = () => { const getCheckTimeValue = () => {
if (!submission.checkedAt) return null if (!submission.checkedAt) return null
const submitted = new Date(submission.submittedAt).getTime() const submitted = new Date(submission.submittedAt).getTime()
const checked = new Date(submission.checkedAt).getTime() const checked = new Date(submission.checkedAt).getTime()
@ -226,7 +230,7 @@ const SubmissionDetailsModal: React.FC<SubmissionDetailsModalProps> = ({
<DialogRoot open={isOpen} onOpenChange={(e) => !e.open && onClose()} size="xl"> <DialogRoot open={isOpen} onOpenChange={(e) => !e.open && onClose()} size="xl">
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Детали попытки #{submission.attemptNumber}</DialogTitle> <DialogTitle>{t('challenge.admin.submissions.details.title')} #{submission.attemptNumber}</DialogTitle>
</DialogHeader> </DialogHeader>
<DialogBody> <DialogBody>
<VStack gap={6} align="stretch"> <VStack gap={6} align="stretch">
@ -235,13 +239,13 @@ const SubmissionDetailsModal: React.FC<SubmissionDetailsModalProps> = ({
<HStack mb={4} justify="space-between"> <HStack mb={4} justify="space-between">
<Box> <Box>
<Text fontSize="sm" color="gray.600" mb={1}> <Text fontSize="sm" color="gray.600" mb={1}>
Пользователь {t('challenge.admin.submissions.details.user')}
</Text> </Text>
<Text fontWeight="bold">{user.nickname}</Text> <Text fontWeight="bold">{user.nickname}</Text>
</Box> </Box>
<Box> <Box>
<Text fontSize="sm" color="gray.600" mb={1}> <Text fontSize="sm" color="gray.600" mb={1}>
Статус {t('challenge.admin.submissions.details.status')}
</Text> </Text>
<StatusBadge status={submission.status} /> <StatusBadge status={submission.status} />
</Box> </Box>
@ -249,15 +253,15 @@ const SubmissionDetailsModal: React.FC<SubmissionDetailsModalProps> = ({
<VStack align="stretch" gap={2}> <VStack align="stretch" gap={2}>
<Text fontSize="sm" color="gray.600"> <Text fontSize="sm" color="gray.600">
<strong>Отправлено:</strong> {formatDate(submission.submittedAt)} <strong>{t('challenge.admin.submissions.details.submitted')}</strong> {formatDate(submission.submittedAt)}
</Text> </Text>
{submission.checkedAt && ( {submission.checkedAt && (
<> <>
<Text fontSize="sm" color="gray.600"> <Text fontSize="sm" color="gray.600">
<strong>Проверено:</strong> {formatDate(submission.checkedAt)} <strong>{t('challenge.admin.submissions.details.checked')}</strong> {formatDate(submission.checkedAt)}
</Text> </Text>
<Text fontSize="sm" color="gray.600"> <Text fontSize="sm" color="gray.600">
<strong>Время проверки:</strong> {getCheckTime()} сек <strong>{t('challenge.admin.submissions.details.check.time')}</strong> {t('challenge.admin.submissions.check.time', { time: getCheckTimeValue() })}
</Text> </Text>
</> </>
)} )}
@ -267,7 +271,7 @@ const SubmissionDetailsModal: React.FC<SubmissionDetailsModalProps> = ({
{/* Task */} {/* Task */}
<Box> <Box>
<Text fontWeight="bold" mb={2}> <Text fontWeight="bold" mb={2}>
Задание: {task.title} {t('challenge.admin.submissions.details.task')} {task.title}
</Text> </Text>
<Box <Box
p={4} p={4}
@ -285,7 +289,7 @@ const SubmissionDetailsModal: React.FC<SubmissionDetailsModalProps> = ({
{/* Solution */} {/* Solution */}
<Box> <Box>
<Text fontWeight="bold" mb={2}> <Text fontWeight="bold" mb={2}>
Решение пользователя: {t('challenge.admin.submissions.details.solution')}
</Text> </Text>
<Box <Box
p={4} p={4}
@ -311,7 +315,7 @@ const SubmissionDetailsModal: React.FC<SubmissionDetailsModalProps> = ({
{submission.feedback && ( {submission.feedback && (
<Box> <Box>
<Text fontWeight="bold" mb={2}> <Text fontWeight="bold" mb={2}>
Обратная связь от LLM: {t('challenge.admin.submissions.details.feedback')}
</Text> </Text>
<Box <Box
p={4} p={4}
@ -329,7 +333,7 @@ const SubmissionDetailsModal: React.FC<SubmissionDetailsModalProps> = ({
<DialogFooter> <DialogFooter>
<DialogActionTrigger asChild> <DialogActionTrigger asChild>
<Button variant="outline" onClick={onClose}> <Button variant="outline" onClick={onClose}>
Закрыть {t('challenge.admin.submissions.details.close')}
</Button> </Button>
</DialogActionTrigger> </DialogActionTrigger>
</DialogFooter> </DialogFooter>

View File

@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { import {
Box, Box,
Heading, Heading,
@ -9,8 +10,6 @@ import {
VStack, VStack,
HStack, HStack,
Text, Text,
Flex,
Stack,
Field, Field,
Tabs, Tabs,
} from '@chakra-ui/react' } from '@chakra-ui/react'
@ -29,7 +28,7 @@ export const TaskFormPage: React.FC = () => {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const isEdit = !!id const isEdit = !!id
const { t } = useTranslation()
const { data: task, isLoading: isLoadingTask, error: loadError } = useGetTaskQuery(id!, { const { data: task, isLoading: isLoadingTask, error: loadError } = useGetTaskQuery(id!, {
skip: !id, skip: !id,
}) })
@ -54,8 +53,8 @@ export const TaskFormPage: React.FC = () => {
if (!title.trim() || !description.trim()) { if (!title.trim() || !description.trim()) {
toaster.create({ toaster.create({
title: 'Ошибка валидации', title: t('challenge.admin.common.validation.error'),
description: 'Заполните обязательные поля', description: t('challenge.admin.tasks.validation.fill.required.fields'),
type: 'error', type: 'error',
}) })
return return
@ -72,8 +71,8 @@ export const TaskFormPage: React.FC = () => {
}, },
}).unwrap() }).unwrap()
toaster.create({ toaster.create({
title: 'Успешно', title: t('challenge.admin.common.success'),
description: 'Задание обновлено', description: t('challenge.admin.tasks.updated'),
type: 'success', type: 'success',
}) })
} else { } else {
@ -83,34 +82,42 @@ export const TaskFormPage: React.FC = () => {
hiddenInstructions: hiddenInstructions.trim() || undefined, hiddenInstructions: hiddenInstructions.trim() || undefined,
}).unwrap() }).unwrap()
toaster.create({ toaster.create({
title: 'Успешно', title: t('challenge.admin.common.success'),
description: 'Задание создано', description: t('challenge.admin.tasks.created'),
type: 'success', type: 'success',
}) })
} }
navigate(URLs.tasks) navigate(URLs.tasks)
} catch (err: any) { } catch (err: unknown) {
const errorMessage =
(err && typeof err === 'object' && 'data' in err &&
err.data && typeof err.data === 'object' && 'error' in err.data &&
err.data.error && typeof err.data.error === 'object' && 'message' in err.data.error &&
typeof err.data.error.message === 'string')
? err.data.error.message
: t('challenge.admin.tasks.save.error')
toaster.create({ toaster.create({
title: 'Ошибка', title: t('challenge.admin.common.error'),
description: err?.data?.error?.message || 'Не удалось сохранить задание', description: errorMessage,
type: 'error', type: 'error',
}) })
} }
} }
if (isEdit && isLoadingTask) { if (isEdit && isLoadingTask) {
return <LoadingSpinner message="Загрузка задания..." /> return <LoadingSpinner message={t('challenge.admin.tasks.loading')} />
} }
if (isEdit && loadError) { if (isEdit && loadError) {
return <ErrorAlert message="Не удалось загрузить задание" /> return <ErrorAlert message={t('challenge.admin.tasks.load.error')} />
} }
const isLoading = isCreating || isUpdating const isLoading = isCreating || isUpdating
return ( return (
<Box> <Box>
<Heading mb={6}>{isEdit ? 'Редактировать задание' : 'Создать задание'}</Heading> <Heading mb={6}>{isEdit ? t('challenge.admin.tasks.edit.title') : t('challenge.admin.tasks.create.title')}</Heading>
<Box <Box
as="form" as="form"
@ -125,33 +132,33 @@ export const TaskFormPage: React.FC = () => {
<VStack gap={6} align="stretch"> <VStack gap={6} align="stretch">
{/* Title */} {/* Title */}
<Field.Root required> <Field.Root required>
<Field.Label>Название задания</Field.Label> <Field.Label>{t('challenge.admin.tasks.field.title')}</Field.Label>
<Input <Input
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="Введите название задания" placeholder={t('challenge.admin.tasks.field.title.placeholder')}
maxLength={255} maxLength={255}
disabled={isLoading} disabled={isLoading}
/> />
<Field.HelperText>Максимум 255 символов</Field.HelperText> <Field.HelperText>{t('challenge.admin.tasks.field.title.helper')}</Field.HelperText>
</Field.Root> </Field.Root>
{/* Description with Markdown */} {/* Description with Markdown */}
<Field.Root required> <Field.Root required>
<Field.Label>Описание (Markdown)</Field.Label> <Field.Label>{t('challenge.admin.tasks.field.description')}</Field.Label>
<Tabs.Root <Tabs.Root
value={showDescPreview ? 'preview' : 'editor'} value={showDescPreview ? 'preview' : 'editor'}
onValueChange={(e) => setShowDescPreview(e.value === 'preview')} onValueChange={(e) => setShowDescPreview(e.value === 'preview')}
> >
<Tabs.List> <Tabs.List>
<Tabs.Trigger value="editor">Редактор</Tabs.Trigger> <Tabs.Trigger value="editor">{t('challenge.admin.tasks.tab.editor')}</Tabs.Trigger>
<Tabs.Trigger value="preview">Превью</Tabs.Trigger> <Tabs.Trigger value="preview">{t('challenge.admin.tasks.tab.preview')}</Tabs.Trigger>
</Tabs.List> </Tabs.List>
<Tabs.Content value="editor" pt={4}> <Tabs.Content value="editor" pt={4}>
<Textarea <Textarea
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
placeholder="# Заголовок задания&#10;&#10;Описание задания в формате Markdown..." placeholder={t('challenge.admin.tasks.field.description.placeholder')}
rows={15} rows={15}
fontFamily="monospace" fontFamily="monospace"
disabled={isLoading} disabled={isLoading}
@ -172,13 +179,13 @@ export const TaskFormPage: React.FC = () => {
</Box> </Box>
) : ( ) : (
<Text color="gray.400" fontStyle="italic"> <Text color="gray.400" fontStyle="italic">
Предпросмотр появится здесь... {t('challenge.admin.tasks.preview.empty')}
</Text> </Text>
)} )}
</Box> </Box>
</Tabs.Content> </Tabs.Content>
</Tabs.Root> </Tabs.Root>
<Field.HelperText>Используйте Markdown для форматирования текста</Field.HelperText> <Field.HelperText>{t('challenge.admin.tasks.field.description.helper')}</Field.HelperText>
</Field.Root> </Field.Root>
{/* Hidden Instructions */} {/* Hidden Instructions */}
@ -186,22 +193,21 @@ export const TaskFormPage: React.FC = () => {
<Box bg="purple.50" p={4} borderRadius="md" borderWidth="1px" borderColor="purple.200"> <Box bg="purple.50" p={4} borderRadius="md" borderWidth="1px" borderColor="purple.200">
<HStack mb={2}> <HStack mb={2}>
<Text fontWeight="bold" color="purple.800"> <Text fontWeight="bold" color="purple.800">
🔒 Скрытые инструкции для LLM {t('challenge.admin.tasks.field.hidden.instructions')}
</Text> </Text>
</HStack> </HStack>
<Text fontSize="sm" color="purple.700" mb={3}> <Text fontSize="sm" color="purple.700" mb={3}>
Эти инструкции будут переданы LLM при проверке решений студентов. Студенты их не {t('challenge.admin.tasks.field.hidden.instructions.description')}
увидят.
</Text> </Text>
<Textarea <Textarea
value={hiddenInstructions} value={hiddenInstructions}
onChange={(e) => setHiddenInstructions(e.target.value)} onChange={(e) => setHiddenInstructions(e.target.value)}
placeholder="Например: Проверь, что сложность алгоритма O(n log n). Код должен обрабатывать edge cases..." placeholder={t('challenge.admin.tasks.field.hidden.instructions.placeholder')}
rows={6} rows={6}
disabled={isLoading} disabled={isLoading}
/> />
<Field.HelperText> <Field.HelperText>
Опционально. Используйте для тонкой настройки проверки LLM. {t('challenge.admin.tasks.field.hidden.instructions.helper')}
</Field.HelperText> </Field.HelperText>
</Box> </Box>
</Field.Root> </Field.Root>
@ -210,17 +216,17 @@ export const TaskFormPage: React.FC = () => {
{isEdit && task && ( {isEdit && task && (
<Box p={4} bg="gray.50" borderRadius="md"> <Box p={4} bg="gray.50" borderRadius="md">
<Text fontSize="sm" color="gray.600"> <Text fontSize="sm" color="gray.600">
<strong>Создано:</strong>{' '} <strong>{t('challenge.admin.tasks.meta.created')}</strong>{' '}
{new Date(task.createdAt).toLocaleString('ru-RU')} {new Date(task.createdAt).toLocaleString('ru-RU')}
</Text> </Text>
{task.creator && ( {task.creator && (
<Text fontSize="sm" color="gray.600"> <Text fontSize="sm" color="gray.600">
<strong>Автор:</strong> {task.creator.preferred_username} <strong>{t('challenge.admin.tasks.meta.author')}</strong> {task.creator.preferred_username}
</Text> </Text>
)} )}
{task.updatedAt !== task.createdAt && ( {task.updatedAt !== task.createdAt && (
<Text fontSize="sm" color="gray.600"> <Text fontSize="sm" color="gray.600">
<strong>Обновлено:</strong>{' '} <strong>{t('challenge.admin.tasks.meta.updated')}</strong>{' '}
{new Date(task.updatedAt).toLocaleString('ru-RU')} {new Date(task.updatedAt).toLocaleString('ru-RU')}
</Text> </Text>
)} )}
@ -230,10 +236,10 @@ export const TaskFormPage: React.FC = () => {
{/* Actions */} {/* Actions */}
<HStack gap={3} justify="flex-end"> <HStack gap={3} justify="flex-end">
<Button variant="outline" onClick={() => navigate(URLs.tasks)} disabled={isLoading}> <Button variant="outline" onClick={() => navigate(URLs.tasks)} disabled={isLoading}>
Отмена {t('challenge.admin.common.cancel')}
</Button> </Button>
<Button type="submit" colorPalette="teal" loading={isLoading}> <Button type="submit" colorPalette="teal" disabled={isLoading}>
{isEdit ? 'Сохранить изменения' : 'Создать задание'} {isEdit ? t('challenge.admin.tasks.button.save') : t('challenge.admin.tasks.button.create')}
</Button> </Button>
</HStack> </HStack>
</VStack> </VStack>

View File

@ -1,5 +1,6 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { import {
Box, Box,
Heading, Heading,
@ -9,9 +10,7 @@ import {
Input, Input,
HStack, HStack,
Text, Text,
IconButton,
Badge, Badge,
createListCollection,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { useGetTasksQuery, useDeleteTaskMutation } from '../../__data__/api/api' import { useGetTasksQuery, useDeleteTaskMutation } from '../../__data__/api/api'
import { URLs } from '../../__data__/urls' import { URLs } from '../../__data__/urls'
@ -24,6 +23,7 @@ import { toaster } from '../../components/ui/toaster'
export const TasksListPage: React.FC = () => { export const TasksListPage: React.FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
const { t } = useTranslation()
const { data: tasks, isLoading, error, refetch } = useGetTasksQuery() const { data: tasks, isLoading, error, refetch } = useGetTasksQuery()
const [deleteTask, { isLoading: isDeleting }] = useDeleteTaskMutation() const [deleteTask, { isLoading: isDeleting }] = useDeleteTaskMutation()
@ -36,26 +36,26 @@ export const TasksListPage: React.FC = () => {
try { try {
await deleteTask(taskToDelete.id).unwrap() await deleteTask(taskToDelete.id).unwrap()
toaster.create({ toaster.create({
title: 'Успешно', title: t('challenge.admin.common.success'),
description: 'Задание удалено', description: t('challenge.admin.tasks.deleted'),
type: 'success', type: 'success',
}) })
setTaskToDelete(null) setTaskToDelete(null)
} catch (err) { } catch (_err) {
toaster.create({ toaster.create({
title: 'Ошибка', title: t('challenge.admin.common.error'),
description: 'Не удалось удалить задание', description: t('challenge.admin.tasks.delete.error'),
type: 'error', type: 'error',
}) })
} }
} }
if (isLoading) { if (isLoading) {
return <LoadingSpinner message="Загрузка заданий..." /> return <LoadingSpinner message={t('challenge.admin.tasks.list.loading')} />
} }
if (error || !tasks) { if (error || !tasks) {
return <ErrorAlert message="Не удалось загрузить список заданий" onRetry={refetch} /> return <ErrorAlert message={t('challenge.admin.tasks.list.load.error')} onRetry={refetch} />
} }
const filteredTasks = tasks.filter((task) => const filteredTasks = tasks.filter((task) =>
@ -73,16 +73,16 @@ export const TasksListPage: React.FC = () => {
return ( return (
<Box> <Box>
<Flex justify="space-between" align="center" mb={6}> <Flex justify="space-between" align="center" mb={6}>
<Heading>Задания</Heading> <Heading>{t('challenge.admin.tasks.list.title')}</Heading>
<Button colorPalette="teal" onClick={() => navigate(URLs.taskNew)}> <Button colorPalette="teal" onClick={() => navigate(URLs.taskNew)}>
+ Создать задание {t('challenge.admin.tasks.list.create.button')}
</Button> </Button>
</Flex> </Flex>
{tasks.length > 0 && ( {tasks.length > 0 && (
<Box mb={4}> <Box mb={4}>
<Input <Input
placeholder="Поиск по названию..." placeholder={t('challenge.admin.tasks.list.search.placeholder')}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
maxW="400px" maxW="400px"
@ -92,26 +92,26 @@ export const TasksListPage: React.FC = () => {
{filteredTasks.length === 0 && tasks.length === 0 ? ( {filteredTasks.length === 0 && tasks.length === 0 ? (
<EmptyState <EmptyState
title="Нет заданий" title={t('challenge.admin.tasks.list.empty.title')}
description="Создайте первое задание для начала работы" description={t('challenge.admin.tasks.list.empty.description')}
actionLabel="Создать задание" actionLabel={t('challenge.admin.tasks.list.empty.action')}
onAction={() => navigate(URLs.taskNew)} onAction={() => navigate(URLs.taskNew)}
/> />
) : filteredTasks.length === 0 ? ( ) : filteredTasks.length === 0 ? (
<EmptyState <EmptyState
title="Ничего не найдено" title={t('challenge.admin.common.not.found')}
description={`По запросу "${searchQuery}" ничего не найдено`} description={t('challenge.admin.tasks.list.search.empty', { query: searchQuery })}
/> />
) : ( ) : (
<Box bg="white" borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" overflowX="auto"> <Box bg="white" borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" overflowX="auto">
<Table.Root size="sm"> <Table.Root size="sm">
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.ColumnHeader>Название</Table.ColumnHeader> <Table.ColumnHeader>{t('challenge.admin.tasks.list.table.title')}</Table.ColumnHeader>
<Table.ColumnHeader>Создатель</Table.ColumnHeader> <Table.ColumnHeader>{t('challenge.admin.tasks.list.table.creator')}</Table.ColumnHeader>
<Table.ColumnHeader>Дата создания</Table.ColumnHeader> <Table.ColumnHeader>{t('challenge.admin.tasks.list.table.created')}</Table.ColumnHeader>
<Table.ColumnHeader>Скрытые инструкции</Table.ColumnHeader> <Table.ColumnHeader>{t('challenge.admin.tasks.list.table.hidden.instructions')}</Table.ColumnHeader>
<Table.ColumnHeader textAlign="right">Действия</Table.ColumnHeader> <Table.ColumnHeader textAlign="right">{t('challenge.admin.tasks.list.table.actions')}</Table.ColumnHeader>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
@ -131,7 +131,7 @@ export const TasksListPage: React.FC = () => {
<Table.Cell> <Table.Cell>
{task.hiddenInstructions ? ( {task.hiddenInstructions ? (
<Badge colorPalette="purple" variant="subtle"> <Badge colorPalette="purple" variant="subtle">
🔒 Есть {t('challenge.admin.tasks.list.badge.has.instructions')}
</Badge> </Badge>
) : ( ) : (
<Text fontSize="sm" color="gray.400"> <Text fontSize="sm" color="gray.400">
@ -146,7 +146,7 @@ export const TasksListPage: React.FC = () => {
variant="ghost" variant="ghost"
onClick={() => navigate(URLs.taskEdit(task.id))} onClick={() => navigate(URLs.taskEdit(task.id))}
> >
Редактировать {t('challenge.admin.tasks.list.button.edit')}
</Button> </Button>
<Button <Button
size="sm" size="sm"
@ -154,7 +154,7 @@ export const TasksListPage: React.FC = () => {
colorPalette="red" colorPalette="red"
onClick={() => setTaskToDelete(task)} onClick={() => setTaskToDelete(task)}
> >
Удалить {t('challenge.admin.tasks.list.button.delete')}
</Button> </Button>
</HStack> </HStack>
</Table.Cell> </Table.Cell>
@ -169,9 +169,9 @@ export const TasksListPage: React.FC = () => {
isOpen={!!taskToDelete} isOpen={!!taskToDelete}
onClose={() => setTaskToDelete(null)} onClose={() => setTaskToDelete(null)}
onConfirm={handleDeleteTask} onConfirm={handleDeleteTask}
title="Удалить задание" title={t('challenge.admin.tasks.delete.confirm.title')}
message={`Вы уверены, что хотите удалить задание "${taskToDelete?.title}"? Это действие нельзя отменить.`} message={t('challenge.admin.tasks.delete.confirm.message', { title: taskToDelete?.title })}
confirmLabel="Удалить" confirmLabel={t('challenge.admin.tasks.delete.confirm.button')}
isLoading={isDeleting} isLoading={isDeleting}
/> />
</Box> </Box>

View File

@ -1,4 +1,5 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { import {
Box, Box,
Heading, Heading,
@ -23,19 +24,19 @@ import { useGetUsersQuery, useGetUserStatsQuery } from '../../__data__/api/api'
import { LoadingSpinner } from '../../components/LoadingSpinner' import { LoadingSpinner } from '../../components/LoadingSpinner'
import { ErrorAlert } from '../../components/ErrorAlert' import { ErrorAlert } from '../../components/ErrorAlert'
import { EmptyState } from '../../components/EmptyState' import { EmptyState } from '../../components/EmptyState'
import type { ChallengeUser } from '../../types/challenge'
export const UsersPage: React.FC = () => { export const UsersPage: React.FC = () => {
const { t } = useTranslation()
const { data: users, isLoading, error, refetch } = useGetUsersQuery() const { data: users, isLoading, error, refetch } = useGetUsersQuery()
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [selectedUserId, setSelectedUserId] = useState<string | null>(null) const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
if (isLoading) { if (isLoading) {
return <LoadingSpinner message="Загрузка пользователей..." /> return <LoadingSpinner message={t('challenge.admin.users.loading')} />
} }
if (error || !users) { if (error || !users) {
return <ErrorAlert message="Не удалось загрузить список пользователей" onRetry={refetch} /> return <ErrorAlert message={t('challenge.admin.users.load.error')} onRetry={refetch} />
} }
const filteredUsers = users.filter((user) => const filteredUsers = users.filter((user) =>
@ -52,12 +53,12 @@ export const UsersPage: React.FC = () => {
return ( return (
<Box> <Box>
<Heading mb={6}>Пользователи</Heading> <Heading mb={6}>{t('challenge.admin.users.title')}</Heading>
{users.length > 0 && ( {users.length > 0 && (
<Box mb={4}> <Box mb={4}>
<Input <Input
placeholder="Поиск по nickname..." placeholder={t('challenge.admin.users.search.placeholder')}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
maxW="400px" maxW="400px"
@ -66,21 +67,21 @@ export const UsersPage: React.FC = () => {
)} )}
{filteredUsers.length === 0 && users.length === 0 ? ( {filteredUsers.length === 0 && users.length === 0 ? (
<EmptyState title="Нет пользователей" description="Пользователи появятся после регистрации" /> <EmptyState title={t('challenge.admin.users.empty.title')} description={t('challenge.admin.users.empty.description')} />
) : filteredUsers.length === 0 ? ( ) : filteredUsers.length === 0 ? (
<EmptyState <EmptyState
title="Ничего не найдено" title={t('challenge.admin.common.not.found')}
description={`По запросу "${searchQuery}" ничего не найдено`} description={t('challenge.admin.users.search.empty', { query: searchQuery })}
/> />
) : ( ) : (
<Box bg="white" borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" overflowX="auto"> <Box bg="white" borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" overflowX="auto">
<Table.Root size="sm"> <Table.Root size="sm">
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.ColumnHeader>Nickname</Table.ColumnHeader> <Table.ColumnHeader>{t('challenge.admin.users.table.nickname')}</Table.ColumnHeader>
<Table.ColumnHeader>ID</Table.ColumnHeader> <Table.ColumnHeader>{t('challenge.admin.users.table.id')}</Table.ColumnHeader>
<Table.ColumnHeader>Дата регистрации</Table.ColumnHeader> <Table.ColumnHeader>{t('challenge.admin.users.table.registered')}</Table.ColumnHeader>
<Table.ColumnHeader textAlign="right">Действия</Table.ColumnHeader> <Table.ColumnHeader textAlign="right">{t('challenge.admin.users.table.actions')}</Table.ColumnHeader>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
@ -104,7 +105,7 @@ export const UsersPage: React.FC = () => {
colorPalette="teal" colorPalette="teal"
onClick={() => setSelectedUserId(user.id)} onClick={() => setSelectedUserId(user.id)}
> >
Статистика {t('challenge.admin.users.button.stats')}
</Button> </Button>
</Table.Cell> </Table.Cell>
</Table.Row> </Table.Row>
@ -131,6 +132,7 @@ interface UserStatsModalProps {
} }
const UserStatsModal: React.FC<UserStatsModalProps> = ({ userId, isOpen, onClose }) => { const UserStatsModal: React.FC<UserStatsModalProps> = ({ userId, isOpen, onClose }) => {
const { t } = useTranslation()
const { data: stats, isLoading } = useGetUserStatsQuery(userId!, { const { data: stats, isLoading } = useGetUserStatsQuery(userId!, {
skip: !userId, skip: !userId,
}) })
@ -139,20 +141,20 @@ const UserStatsModal: React.FC<UserStatsModalProps> = ({ userId, isOpen, onClose
<DialogRoot open={isOpen} onOpenChange={(e) => !e.open && onClose()} size="xl"> <DialogRoot open={isOpen} onOpenChange={(e) => !e.open && onClose()} size="xl">
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Статистика пользователя</DialogTitle> <DialogTitle>{t('challenge.admin.users.stats.title')}</DialogTitle>
</DialogHeader> </DialogHeader>
<DialogBody> <DialogBody>
{isLoading ? ( {isLoading ? (
<LoadingSpinner message="Загрузка статистики..." /> <LoadingSpinner message={t('challenge.admin.users.stats.loading')} />
) : !stats ? ( ) : !stats ? (
<Text color="gray.600">Нет данных</Text> <Text color="gray.600">{t('challenge.admin.users.stats.no.data')}</Text>
) : ( ) : (
<VStack gap={6} align="stretch"> <VStack gap={6} align="stretch">
{/* Overview */} {/* Overview */}
<Grid templateColumns="repeat(auto-fit, minmax(150px, 1fr))" gap={4}> <Grid templateColumns="repeat(auto-fit, minmax(150px, 1fr))" gap={4}>
<Box> <Box>
<Text fontSize="sm" color="gray.600"> <Text fontSize="sm" color="gray.600">
Выполнено {t('challenge.admin.users.stats.completed')}
</Text> </Text>
<Text fontSize="2xl" fontWeight="bold" color="green.600"> <Text fontSize="2xl" fontWeight="bold" color="green.600">
{stats.completedTasks} {stats.completedTasks}
@ -160,7 +162,7 @@ const UserStatsModal: React.FC<UserStatsModalProps> = ({ userId, isOpen, onClose
</Box> </Box>
<Box> <Box>
<Text fontSize="sm" color="gray.600"> <Text fontSize="sm" color="gray.600">
Всего попыток {t('challenge.admin.users.stats.total.submissions')}
</Text> </Text>
<Text fontSize="2xl" fontWeight="bold" color="blue.600"> <Text fontSize="2xl" fontWeight="bold" color="blue.600">
{stats.totalSubmissions} {stats.totalSubmissions}
@ -168,7 +170,7 @@ const UserStatsModal: React.FC<UserStatsModalProps> = ({ userId, isOpen, onClose
</Box> </Box>
<Box> <Box>
<Text fontSize="sm" color="gray.600"> <Text fontSize="sm" color="gray.600">
В процессе {t('challenge.admin.users.stats.in.progress')}
</Text> </Text>
<Text fontSize="2xl" fontWeight="bold" color="orange.600"> <Text fontSize="2xl" fontWeight="bold" color="orange.600">
{stats.inProgressTasks} {stats.inProgressTasks}
@ -176,7 +178,7 @@ const UserStatsModal: React.FC<UserStatsModalProps> = ({ userId, isOpen, onClose
</Box> </Box>
<Box> <Box>
<Text fontSize="sm" color="gray.600"> <Text fontSize="sm" color="gray.600">
Требует доработки {t('challenge.admin.users.stats.needs.revision')}
</Text> </Text>
<Text fontSize="2xl" fontWeight="bold" color="red.600"> <Text fontSize="2xl" fontWeight="bold" color="red.600">
{stats.needsRevisionTasks} {stats.needsRevisionTasks}
@ -188,7 +190,7 @@ const UserStatsModal: React.FC<UserStatsModalProps> = ({ userId, isOpen, onClose
{stats.chainStats.length > 0 && ( {stats.chainStats.length > 0 && (
<Box> <Box>
<Text fontWeight="bold" mb={3}> <Text fontWeight="bold" mb={3}>
Прогресс по цепочкам {t('challenge.admin.users.stats.chains.progress')}
</Text> </Text>
<VStack gap={3} align="stretch"> <VStack gap={3} align="stretch">
{stats.chainStats.map((chain) => ( {stats.chainStats.map((chain) => (
@ -201,7 +203,11 @@ const UserStatsModal: React.FC<UserStatsModalProps> = ({ userId, isOpen, onClose
{chain.completedTasks} / {chain.totalTasks} {chain.completedTasks} / {chain.totalTasks}
</Text> </Text>
</HStack> </HStack>
<Progress value={chain.progress} colorPalette="teal" size="sm" /> <Progress.Root value={chain.progress} colorPalette="teal" size="sm">
<Progress.Track>
<Progress.Range />
</Progress.Track>
</Progress.Root>
</Box> </Box>
))} ))}
</VStack> </VStack>
@ -212,10 +218,17 @@ const UserStatsModal: React.FC<UserStatsModalProps> = ({ userId, isOpen, onClose
{stats.taskStats.length > 0 && ( {stats.taskStats.length > 0 && (
<Box> <Box>
<Text fontWeight="bold" mb={3}> <Text fontWeight="bold" mb={3}>
Задания {t('challenge.admin.users.stats.tasks')}
</Text> </Text>
<VStack gap={2} align="stretch" maxH="300px" overflowY="auto"> <VStack gap={2} align="stretch" maxH="300px" overflowY="auto">
{stats.taskStats.map((taskStat) => ( {stats.taskStats.map((taskStat) => {
const getBadgeColor = () => {
if (taskStat.status === 'completed') return 'green'
if (taskStat.status === 'needs_revision') return 'red'
return 'gray'
}
return (
<Box <Box
key={taskStat.taskId} key={taskStat.taskId}
p={3} p={3}
@ -228,29 +241,16 @@ const UserStatsModal: React.FC<UserStatsModalProps> = ({ userId, isOpen, onClose
<Text fontSize="sm" fontWeight="medium"> <Text fontSize="sm" fontWeight="medium">
{taskStat.taskTitle} {taskStat.taskTitle}
</Text> </Text>
<Badge <Badge colorPalette={getBadgeColor()}>
colorPalette={ {t(`challenge.admin.users.stats.status.${taskStat.status}`)}
taskStat.status === 'completed'
? 'green'
: taskStat.status === 'needs_revision'
? 'red'
: 'gray'
}
>
{taskStat.status === 'completed'
? 'Завершено'
: taskStat.status === 'needs_revision'
? 'Доработка'
: taskStat.status === 'in_progress'
? 'В процессе'
: 'Не начато'}
</Badge> </Badge>
</HStack> </HStack>
<Text fontSize="xs" color="gray.600"> <Text fontSize="xs" color="gray.600">
Попыток: {taskStat.totalAttempts} {t('challenge.admin.users.stats.attempts')} {taskStat.totalAttempts}
</Text> </Text>
</Box> </Box>
))} )
})}
</VStack> </VStack>
</Box> </Box>
)} )}
@ -258,10 +258,10 @@ const UserStatsModal: React.FC<UserStatsModalProps> = ({ userId, isOpen, onClose
{/* Average Check Time */} {/* Average Check Time */}
<Box p={3} bg="purple.50" borderRadius="md"> <Box p={3} bg="purple.50" borderRadius="md">
<Text fontSize="sm" color="gray.700" mb={1}> <Text fontSize="sm" color="gray.700" mb={1}>
Среднее время проверки {t('challenge.admin.users.stats.avg.check.time')}
</Text> </Text>
<Text fontSize="lg" fontWeight="bold" color="purple.700"> <Text fontSize="lg" fontWeight="bold" color="purple.700">
{(stats.averageCheckTimeMs / 1000).toFixed(2)} сек {t('challenge.admin.dashboard.check.time.value', { time: (stats.averageCheckTimeMs / 1000).toFixed(2) })}
</Text> </Text>
</Box> </Box>
</VStack> </VStack>