Add user stats page and refactor user navigation; replace modal with dedicated page for user statistics, enhancing routing and UI consistency. Update localization for new status keys in both English and Russian.
This commit is contained in:
116
CLAUDE.MD
Normal file
116
CLAUDE.MD
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
## Overview
|
||||||
|
|
||||||
|
This document summarizes the recent changes around submissions/users pages and records guardrails to avoid similar issues in the future.
|
||||||
|
|
||||||
|
We:
|
||||||
|
- Reworked submissions and user stats UIs to use real routes/pages instead of modals.
|
||||||
|
- Added compact progress overview for participants.
|
||||||
|
- Introduced deep-linked details pages for submissions and users.
|
||||||
|
- Fixed Chakra UI dialog misuse and type/translation issues.
|
||||||
|
|
||||||
|
## Routing & Page Structure
|
||||||
|
|
||||||
|
### Do
|
||||||
|
- **Define all routes centrally** in `src/__data__/urls.ts` and `src/dashboard.ts`:
|
||||||
|
- Add both the **URL builder** (e.g. `submissionDetails(userId, submissionId)`) and the **`:param` path**.
|
||||||
|
- Wrap pages in `PageWrapper` in `dashboard.tsx`.
|
||||||
|
- **Use real pages for complex views** (details, stats) instead of large modals:
|
||||||
|
- Submissions details: `SubmissionDetailsPage` with URL `/submissions/:userId/:submissionId`.
|
||||||
|
- User stats: `UserStatsPage` with URL `/users/:userId`.
|
||||||
|
- **Pass IDs via URL**, not only component state:
|
||||||
|
- Use route params for `userId`, `submissionId`, etc.
|
||||||
|
- For “return and keep selection”, encode it as a query param (e.g. `?userId=...`) and read it on the list page.
|
||||||
|
|
||||||
|
### Don’t
|
||||||
|
- **Don’t hardcode paths in components** (e.g. `'/submissions/...'`); always use `URLs.*` helpers.
|
||||||
|
- **Don’t rely solely on local React state for deep links**:
|
||||||
|
- If a view must be shareable/bookmarkable or restorable on reload, it must be addressable by URL.
|
||||||
|
|
||||||
|
## Chakra UI Dialogs & Layout
|
||||||
|
|
||||||
|
### Do
|
||||||
|
- **Use dialog subcomponents only inside a dialog root**:
|
||||||
|
- If you use `DialogBody`, `DialogContent`, etc., they must be wrapped in `<DialogRoot>`.
|
||||||
|
- For **standalone pages**, use plain layout components:
|
||||||
|
- `Box`, `Heading`, `VStack`, `Grid`, `Progress`, etc.
|
||||||
|
- No `Dialog*` components on normal routed pages.
|
||||||
|
|
||||||
|
### Don’t
|
||||||
|
- **Don’t import or use `DialogBody`, `DialogContent`, `DialogHeader`, etc. on regular pages**:
|
||||||
|
- This causes `useDialogStyles returned 'undefined'` runtime errors.
|
||||||
|
- **Don’t mix modal patterns and page patterns**:
|
||||||
|
- Either a true modal (`DialogRoot` + `DialogContent`) over an existing page,
|
||||||
|
- Or a full page route with normal layout — not both at the same time.
|
||||||
|
|
||||||
|
## Data Safety & Types
|
||||||
|
|
||||||
|
### Do
|
||||||
|
- Assume backend fields can be **either object or ID string**, per `ChallengeSubmission` types:
|
||||||
|
- Example safe access in submissions:
|
||||||
|
- Guard before reading `user.nickname` or `task.title`.
|
||||||
|
- Derive strings like:
|
||||||
|
- `const nickname = typeof rawUser === 'object' && 'nickname' in rawUser ? rawUser.nickname ?? '' : typeof rawUser === 'string' ? rawUser : ''`.
|
||||||
|
- Normalize strings before calling `.toLowerCase()`:
|
||||||
|
- `const normalized = (value ?? '').toLowerCase()`.
|
||||||
|
- When filtering/searching, **never call string methods on possibly `undefined` or non-object values**.
|
||||||
|
|
||||||
|
### Don’t
|
||||||
|
- **Don’t cast blindly** (`as ChallengeUser`) and then access `.nickname` or `.title` without guards.
|
||||||
|
- **Don’t call `.toLowerCase()` directly on untrusted values** from API or union-typed fields.
|
||||||
|
|
||||||
|
## “Back” Navigation & State Restoration
|
||||||
|
|
||||||
|
### Do
|
||||||
|
- For **details pages that should restore list state**:
|
||||||
|
- Encode the necessary selection into the URL when navigating _to_ details.
|
||||||
|
- Example: `SubmissionDetailsPage` returns to `URLs.submissions` with `?userId=...`, and `SubmissionsPage` reads `userId` from `useSearchParams` to preselect the user.
|
||||||
|
- Prefer **semantic back actions** over bare `navigate(-1)` when the previous page/state is known:
|
||||||
|
- Use `navigate(URLs.submissions + '?userId=...')` or `navigate(URLs.users)` when appropriate.
|
||||||
|
|
||||||
|
### Don’t
|
||||||
|
- **Don’t rely on `navigate(-1)`** when:
|
||||||
|
- The previous page might not be the canonical list page,
|
||||||
|
- You need a specific state (e.g. selected user) restored.
|
||||||
|
|
||||||
|
## i18n / Locales
|
||||||
|
|
||||||
|
### Do
|
||||||
|
- **Keep `ru.json` and `en.json` in sync** for any new keys:
|
||||||
|
- When adding a key under `challenge.admin.*` in one file, add the corresponding entry in the other.
|
||||||
|
- For **status enums**, ensure all possible values have translations:
|
||||||
|
- `challenge.admin.users.stats.status.*` must cover all values of `taskStat.status`.
|
||||||
|
- `challenge.admin.submissions.status.*` must cover all submission statuses.
|
||||||
|
- Use **consistent key naming patterns**:
|
||||||
|
- Example: `challenge.admin.users.stats.status.accepted`, `...status.needs_revision`, etc.
|
||||||
|
|
||||||
|
### Don’t
|
||||||
|
- **Don’t introduce new `t('...')` keys in code without adding them to both locale files**.
|
||||||
|
- **Don’t reuse unrelated keys** just to avoid adding translations — create clear, specific keys.
|
||||||
|
|
||||||
|
## UI Patterns for High-Density Overviews
|
||||||
|
|
||||||
|
### Do
|
||||||
|
- For high-density screens (e.g. 100 participants at once):
|
||||||
|
- Use **compact cards or rows** with:
|
||||||
|
- Truncated names (`truncate`),
|
||||||
|
- Thin `Progress` bars,
|
||||||
|
- Minimal text (percentage + small counters).
|
||||||
|
- Sort by progress to surface lagging participants.
|
||||||
|
|
||||||
|
### Don’t
|
||||||
|
- **Don’t use wide tables** when many rows must fit on one screen; prefer grids or narrow rows with fixed-width text columns and flexible progress area.
|
||||||
|
|
||||||
|
## When Adding New Features
|
||||||
|
|
||||||
|
Before merging:
|
||||||
|
- **Check routing**:
|
||||||
|
- New URL added to `URLs`.
|
||||||
|
- Route wired in `dashboard.tsx`.
|
||||||
|
- **Check data safety**:
|
||||||
|
- No unchecked property access on union/nullable types.
|
||||||
|
- **Check i18n**:
|
||||||
|
- New keys exist in both `ru.json` and `en.json`.
|
||||||
|
- **Check Chakra usage**:
|
||||||
|
- No `Dialog*` components outside a proper `<DialogRoot>` _or_ on standalone pages.
|
||||||
|
|
||||||
|
|
||||||
@@ -137,9 +137,12 @@
|
|||||||
"challenge.admin.users.stats.chains.progress": "Chain progress",
|
"challenge.admin.users.stats.chains.progress": "Chain progress",
|
||||||
"challenge.admin.users.stats.tasks": "Tasks",
|
"challenge.admin.users.stats.tasks": "Tasks",
|
||||||
"challenge.admin.users.stats.status.completed": "Completed",
|
"challenge.admin.users.stats.status.completed": "Completed",
|
||||||
|
"challenge.admin.users.stats.status.accepted": "Accepted",
|
||||||
"challenge.admin.users.stats.status.needs_revision": "Revision",
|
"challenge.admin.users.stats.status.needs_revision": "Revision",
|
||||||
"challenge.admin.users.stats.status.in_progress": "In progress",
|
"challenge.admin.users.stats.status.in_progress": "In progress",
|
||||||
|
"challenge.admin.users.stats.status.pending": "Pending",
|
||||||
"challenge.admin.users.stats.status.not_started": "Not started",
|
"challenge.admin.users.stats.status.not_started": "Not started",
|
||||||
|
"challenge.admin.users.stats.status.not_attempted": "Not attempted",
|
||||||
"challenge.admin.users.stats.attempts": "Attempts:",
|
"challenge.admin.users.stats.attempts": "Attempts:",
|
||||||
"challenge.admin.users.stats.avg.check.time": "Average check time",
|
"challenge.admin.users.stats.avg.check.time": "Average check time",
|
||||||
"challenge.admin.users.stats.close": "Close",
|
"challenge.admin.users.stats.close": "Close",
|
||||||
|
|||||||
@@ -136,9 +136,12 @@
|
|||||||
"challenge.admin.users.stats.chains.progress": "Прогресс по цепочкам",
|
"challenge.admin.users.stats.chains.progress": "Прогресс по цепочкам",
|
||||||
"challenge.admin.users.stats.tasks": "Задания",
|
"challenge.admin.users.stats.tasks": "Задания",
|
||||||
"challenge.admin.users.stats.status.completed": "Завершено",
|
"challenge.admin.users.stats.status.completed": "Завершено",
|
||||||
|
"challenge.admin.users.stats.status.accepted": "Принято",
|
||||||
"challenge.admin.users.stats.status.needs_revision": "Доработка",
|
"challenge.admin.users.stats.status.needs_revision": "Доработка",
|
||||||
"challenge.admin.users.stats.status.in_progress": "В процессе",
|
"challenge.admin.users.stats.status.in_progress": "В процессе",
|
||||||
|
"challenge.admin.users.stats.status.pending": "Ожидает",
|
||||||
"challenge.admin.users.stats.status.not_started": "Не начато",
|
"challenge.admin.users.stats.status.not_started": "Не начато",
|
||||||
|
"challenge.admin.users.stats.status.not_attempted": "Не пытался",
|
||||||
"challenge.admin.users.stats.attempts": "Попыток:",
|
"challenge.admin.users.stats.attempts": "Попыток:",
|
||||||
"challenge.admin.users.stats.avg.check.time": "Среднее время проверки",
|
"challenge.admin.users.stats.avg.check.time": "Среднее время проверки",
|
||||||
"challenge.admin.users.stats.close": "Закрыть",
|
"challenge.admin.users.stats.close": "Закрыть",
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export const URLs = {
|
|||||||
|
|
||||||
// Users
|
// Users
|
||||||
users: makeUrl('/users'),
|
users: makeUrl('/users'),
|
||||||
|
userStats: (userId: string) => makeUrl(`/users/${userId}`),
|
||||||
|
userStatsPath: makeUrl('/users/:userId'),
|
||||||
|
|
||||||
// Submissions
|
// Submissions
|
||||||
submissions: makeUrl('/submissions'),
|
submissions: makeUrl('/submissions'),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { TaskFormPage } from './pages/tasks/TaskFormPage'
|
|||||||
import { ChainsListPage } from './pages/chains/ChainsListPage'
|
import { ChainsListPage } from './pages/chains/ChainsListPage'
|
||||||
import { ChainFormPage } from './pages/chains/ChainFormPage'
|
import { ChainFormPage } from './pages/chains/ChainFormPage'
|
||||||
import { UsersPage } from './pages/users/UsersPage'
|
import { UsersPage } from './pages/users/UsersPage'
|
||||||
|
import { UserStatsPage } from './pages/users/UserStatsPage'
|
||||||
import { SubmissionsPage } from './pages/submissions/SubmissionsPage'
|
import { SubmissionsPage } from './pages/submissions/SubmissionsPage'
|
||||||
import { SubmissionDetailsPage } from './pages/submissions/SubmissionDetailsPage'
|
import { SubmissionDetailsPage } from './pages/submissions/SubmissionDetailsPage'
|
||||||
import { URLs } from './__data__/urls'
|
import { URLs } from './__data__/urls'
|
||||||
@@ -111,6 +112,14 @@ export const Dashboard = () => {
|
|||||||
</PageWrapper>
|
</PageWrapper>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path={URLs.userStatsPath}
|
||||||
|
element={
|
||||||
|
<PageWrapper>
|
||||||
|
<UserStatsPage />
|
||||||
|
</PageWrapper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Submissions */}
|
{/* Submissions */}
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
196
src/pages/users/UserStatsPage.tsx
Normal file
196
src/pages/users/UserStatsPage.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { Box, Heading, Text, Button, Grid, VStack, HStack, Badge, Progress } from '@chakra-ui/react'
|
||||||
|
import { useGetUserStatsQuery } from '../../__data__/api/api'
|
||||||
|
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||||
|
import { ErrorAlert } from '../../components/ErrorAlert'
|
||||||
|
import { URLs } from '../../__data__/urls'
|
||||||
|
|
||||||
|
interface RouteParams {
|
||||||
|
userId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UserStatsPage: React.FC = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { userId } = useParams<RouteParams>()
|
||||||
|
|
||||||
|
const { data: stats, isLoading, error, refetch } = useGetUserStatsQuery(userId!, {
|
||||||
|
skip: !userId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
navigate(URLs.users)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Button variant="ghost" onClick={handleBack} mb={4}>
|
||||||
|
← {t('challenge.admin.common.close')}
|
||||||
|
</Button>
|
||||||
|
<ErrorAlert message={t('challenge.admin.users.stats.no.data')} onRetry={handleBack} />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <LoadingSpinner message={t('challenge.admin.users.stats.loading')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Button variant="ghost" onClick={handleBack} mb={4}>
|
||||||
|
← {t('challenge.admin.common.close')}
|
||||||
|
</Button>
|
||||||
|
<ErrorAlert message={t('challenge.admin.users.load.error')} onRetry={refetch} />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stats) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Button variant="ghost" onClick={handleBack} mb={4}>
|
||||||
|
← {t('challenge.admin.common.close')}
|
||||||
|
</Button>
|
||||||
|
<Text color="gray.600">{t('challenge.admin.users.stats.no.data')}</Text>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<HStack mb={6}>
|
||||||
|
<Button variant="ghost" onClick={handleBack}>
|
||||||
|
← {t('challenge.admin.common.close')}
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<Heading mb={6}>{t('challenge.admin.users.stats.title')}</Heading>
|
||||||
|
|
||||||
|
<VStack gap={6} align="stretch">
|
||||||
|
{/* Overview */}
|
||||||
|
<Grid templateColumns="repeat(auto-fit, minmax(150px, 1fr))" gap={4}>
|
||||||
|
<Box>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
{t('challenge.admin.users.stats.completed')}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="2xl" fontWeight="bold" color="green.600">
|
||||||
|
{stats.completedTasks}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
{t('challenge.admin.users.stats.total.submissions')}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="2xl" fontWeight="bold" color="blue.600">
|
||||||
|
{stats.totalSubmissions}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
{t('challenge.admin.users.stats.in.progress')}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
|
||||||
|
{stats.inProgressTasks}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
{t('challenge.admin.users.stats.needs.revision')}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="2xl" fontWeight="bold" color="red.600">
|
||||||
|
{stats.needsRevisionTasks}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Chains Progress */}
|
||||||
|
{stats.chainStats.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Text fontWeight="bold" mb={3}>
|
||||||
|
{t('challenge.admin.users.stats.chains.progress')}
|
||||||
|
</Text>
|
||||||
|
<VStack gap={3} align="stretch">
|
||||||
|
{stats.chainStats.map((chain) => (
|
||||||
|
<Box key={chain.chainId}>
|
||||||
|
<HStack justify="space-between" mb={1}>
|
||||||
|
<Text fontSize="sm" fontWeight="medium">
|
||||||
|
{chain.chainName}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="sm" color="gray.600">
|
||||||
|
{chain.completedTasks} / {chain.totalTasks}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
<Progress.Root value={chain.progress} colorPalette="teal" size="sm">
|
||||||
|
<Progress.Track>
|
||||||
|
<Progress.Range />
|
||||||
|
</Progress.Track>
|
||||||
|
</Progress.Root>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Task Stats */}
|
||||||
|
{stats.taskStats.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
<Text fontWeight="bold" mb={3}>
|
||||||
|
{t('challenge.admin.users.stats.tasks')}
|
||||||
|
</Text>
|
||||||
|
<VStack gap={2} align="stretch" maxH="300px" overflowY="auto">
|
||||||
|
{stats.taskStats.map((taskStat) => {
|
||||||
|
const getBadgeColor = () => {
|
||||||
|
if (taskStat.status === 'completed') return 'green'
|
||||||
|
if (taskStat.status === 'needs_revision') return 'red'
|
||||||
|
return 'gray'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={taskStat.taskId}
|
||||||
|
p={3}
|
||||||
|
bg="gray.50"
|
||||||
|
borderRadius="md"
|
||||||
|
borderWidth="1px"
|
||||||
|
borderColor="gray.200"
|
||||||
|
>
|
||||||
|
<HStack justify="space-between" mb={1}>
|
||||||
|
<Text fontSize="sm" fontWeight="medium">
|
||||||
|
{taskStat.taskTitle}
|
||||||
|
</Text>
|
||||||
|
<Badge colorPalette={getBadgeColor()}>
|
||||||
|
{t(`challenge.admin.users.stats.status.${taskStat.status}`)}
|
||||||
|
</Badge>
|
||||||
|
</HStack>
|
||||||
|
<Text fontSize="xs" color="gray.600">
|
||||||
|
{t('challenge.admin.users.stats.attempts')} {taskStat.totalAttempts}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Average Check Time */}
|
||||||
|
<Box p={3} bg="purple.50" borderRadius="md">
|
||||||
|
<Text fontSize="sm" color="gray.700" mb={1}>
|
||||||
|
{t('challenge.admin.users.stats.avg.check.time')}
|
||||||
|
</Text>
|
||||||
|
<Text fontSize="lg" fontWeight="bold" color="purple.700">
|
||||||
|
{t('challenge.admin.dashboard.check.time.value', {
|
||||||
|
time: (stats.averageCheckTimeMs / 1000).toFixed(2),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,38 +1,21 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import { useNavigate } from 'react-router-dom'
|
||||||
Box,
|
import { Box, Heading, Table, Input, Text, Button } from '@chakra-ui/react'
|
||||||
Heading,
|
import { useGetSystemStatsV2Query } from '../../__data__/api/api'
|
||||||
Table,
|
|
||||||
Input,
|
|
||||||
Text,
|
|
||||||
Button,
|
|
||||||
DialogRoot,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogBody,
|
|
||||||
DialogFooter,
|
|
||||||
DialogActionTrigger,
|
|
||||||
Grid,
|
|
||||||
VStack,
|
|
||||||
HStack,
|
|
||||||
Badge,
|
|
||||||
Progress,
|
|
||||||
} from '@chakra-ui/react'
|
|
||||||
import { useGetSystemStatsV2Query, useGetUserStatsQuery } from '../../__data__/api/api'
|
|
||||||
import type { ActiveParticipant } from '../../types/challenge'
|
import type { ActiveParticipant } from '../../types/challenge'
|
||||||
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 { URLs } from '../../__data__/urls'
|
||||||
|
|
||||||
export const UsersPage: React.FC = () => {
|
export const UsersPage: React.FC = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const navigate = useNavigate()
|
||||||
const { data: stats, isLoading, error, refetch } = useGetSystemStatsV2Query(undefined, {
|
const { data: stats, isLoading, error, refetch } = useGetSystemStatsV2Query(undefined, {
|
||||||
pollingInterval: 10000,
|
pollingInterval: 10000,
|
||||||
})
|
})
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <LoadingSpinner message={t('challenge.admin.users.loading')} />
|
return <LoadingSpinner message={t('challenge.admin.users.loading')} />
|
||||||
@@ -108,7 +91,7 @@ export const UsersPage: React.FC = () => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
colorPalette="teal"
|
colorPalette="teal"
|
||||||
onClick={() => setSelectedUserId(user.userId)}
|
onClick={() => navigate(URLs.userStats(user.userId))}
|
||||||
>
|
>
|
||||||
{t('challenge.admin.users.button.stats')}
|
{t('challenge.admin.users.button.stats')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -120,167 +103,6 @@ export const UsersPage: React.FC = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* User Stats Modal */}
|
|
||||||
<UserStatsModal
|
|
||||||
userId={selectedUserId}
|
|
||||||
isOpen={!!selectedUserId}
|
|
||||||
onClose={() => setSelectedUserId(null)}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserStatsModalProps {
|
|
||||||
userId: string | null
|
|
||||||
isOpen: boolean
|
|
||||||
onClose: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const UserStatsModal: React.FC<UserStatsModalProps> = ({ userId, isOpen, onClose }) => {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const { data: stats, isLoading } = useGetUserStatsQuery(userId!, {
|
|
||||||
skip: !userId,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DialogRoot open={isOpen} onOpenChange={(e) => !e.open && onClose()} size="xl">
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{t('challenge.admin.users.stats.title')}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogBody>
|
|
||||||
{isLoading ? (
|
|
||||||
<LoadingSpinner message={t('challenge.admin.users.stats.loading')} />
|
|
||||||
) : !stats ? (
|
|
||||||
<Text color="gray.600">{t('challenge.admin.users.stats.no.data')}</Text>
|
|
||||||
) : (
|
|
||||||
<VStack gap={6} align="stretch">
|
|
||||||
{/* Overview */}
|
|
||||||
<Grid templateColumns="repeat(auto-fit, minmax(150px, 1fr))" gap={4}>
|
|
||||||
<Box>
|
|
||||||
<Text fontSize="sm" color="gray.600">
|
|
||||||
{t('challenge.admin.users.stats.completed')}
|
|
||||||
</Text>
|
|
||||||
<Text fontSize="2xl" fontWeight="bold" color="green.600">
|
|
||||||
{stats.completedTasks}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Text fontSize="sm" color="gray.600">
|
|
||||||
{t('challenge.admin.users.stats.total.submissions')}
|
|
||||||
</Text>
|
|
||||||
<Text fontSize="2xl" fontWeight="bold" color="blue.600">
|
|
||||||
{stats.totalSubmissions}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Text fontSize="sm" color="gray.600">
|
|
||||||
{t('challenge.admin.users.stats.in.progress')}
|
|
||||||
</Text>
|
|
||||||
<Text fontSize="2xl" fontWeight="bold" color="orange.600">
|
|
||||||
{stats.inProgressTasks}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Text fontSize="sm" color="gray.600">
|
|
||||||
{t('challenge.admin.users.stats.needs.revision')}
|
|
||||||
</Text>
|
|
||||||
<Text fontSize="2xl" fontWeight="bold" color="red.600">
|
|
||||||
{stats.needsRevisionTasks}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{/* Chains Progress */}
|
|
||||||
{stats.chainStats.length > 0 && (
|
|
||||||
<Box>
|
|
||||||
<Text fontWeight="bold" mb={3}>
|
|
||||||
{t('challenge.admin.users.stats.chains.progress')}
|
|
||||||
</Text>
|
|
||||||
<VStack gap={3} align="stretch">
|
|
||||||
{stats.chainStats.map((chain) => (
|
|
||||||
<Box key={chain.chainId}>
|
|
||||||
<HStack justify="space-between" mb={1}>
|
|
||||||
<Text fontSize="sm" fontWeight="medium">
|
|
||||||
{chain.chainName}
|
|
||||||
</Text>
|
|
||||||
<Text fontSize="sm" color="gray.600">
|
|
||||||
{chain.completedTasks} / {chain.totalTasks}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
<Progress.Root value={chain.progress} colorPalette="teal" size="sm">
|
|
||||||
<Progress.Track>
|
|
||||||
<Progress.Range />
|
|
||||||
</Progress.Track>
|
|
||||||
</Progress.Root>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</VStack>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Task Stats */}
|
|
||||||
{stats.taskStats.length > 0 && (
|
|
||||||
<Box>
|
|
||||||
<Text fontWeight="bold" mb={3}>
|
|
||||||
{t('challenge.admin.users.stats.tasks')}
|
|
||||||
</Text>
|
|
||||||
<VStack gap={2} align="stretch" maxH="300px" overflowY="auto">
|
|
||||||
{stats.taskStats.map((taskStat) => {
|
|
||||||
const getBadgeColor = () => {
|
|
||||||
if (taskStat.status === 'completed') return 'green'
|
|
||||||
if (taskStat.status === 'needs_revision') return 'red'
|
|
||||||
return 'gray'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
key={taskStat.taskId}
|
|
||||||
p={3}
|
|
||||||
bg="gray.50"
|
|
||||||
borderRadius="md"
|
|
||||||
borderWidth="1px"
|
|
||||||
borderColor="gray.200"
|
|
||||||
>
|
|
||||||
<HStack justify="space-between" mb={1}>
|
|
||||||
<Text fontSize="sm" fontWeight="medium">
|
|
||||||
{taskStat.taskTitle}
|
|
||||||
</Text>
|
|
||||||
<Badge colorPalette={getBadgeColor()}>
|
|
||||||
{t(`challenge.admin.users.stats.status.${taskStat.status}`)}
|
|
||||||
</Badge>
|
|
||||||
</HStack>
|
|
||||||
<Text fontSize="xs" color="gray.600">
|
|
||||||
{t('challenge.admin.users.stats.attempts')} {taskStat.totalAttempts}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</VStack>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Average Check Time */}
|
|
||||||
<Box p={3} bg="purple.50" borderRadius="md">
|
|
||||||
<Text fontSize="sm" color="gray.700" mb={1}>
|
|
||||||
{t('challenge.admin.users.stats.avg.check.time')}
|
|
||||||
</Text>
|
|
||||||
<Text fontSize="lg" fontWeight="bold" color="purple.700">
|
|
||||||
{t('challenge.admin.dashboard.check.time.value', { time: (stats.averageCheckTimeMs / 1000).toFixed(2) })}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</VStack>
|
|
||||||
)}
|
|
||||||
</DialogBody>
|
|
||||||
<DialogFooter>
|
|
||||||
<DialogActionTrigger asChild>
|
|
||||||
<Button variant="outline" onClick={onClose}>
|
|
||||||
Закрыть
|
|
||||||
</Button>
|
|
||||||
</DialogActionTrigger>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</DialogRoot>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user