Initial commit

This commit is contained in:
poignatov
2025-12-29 20:01:55 +03:00
commit 4f8a793377
63 changed files with 13655 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
node_modules
dist
.git
.gitignore
README.md
.env
.env.local
.DS_Store
*.log
.vscode
.idea

30
play-life-web/.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Environment variables
.env
.env.local
.env.*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

30
play-life-web/Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built files from builder
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

105
play-life-web/README.md Normal file
View File

@@ -0,0 +1,105 @@
# PlayLifeWeb
Веб-приложение для отображения статистики проектов.
## Возможности
- **Текущая неделя**: Отображение статистики на текущий момент с ProgressBar для каждого проекта
- **Полная статистика**: График нарастающей статистики по всем проектам
## Технологии
- React 18
- Vite
- Chart.js (react-chartjs-2)
- Tailwind CSS
- Docker
## Установка и запуск
### Локальная разработка
1. Установите зависимости:
```bash
npm install
```
2. Запустите dev-сервер:
```bash
npm run dev
```
Приложение будет доступно по адресу `http://localhost:3000`
### Сборка для production
```bash
npm run build
```
### Запуск через Docker
1. Создайте файл `.env` в корне проекта (можно скопировать из `env.example`):
```bash
cp env.example .env
```
2. Соберите образ:
```bash
docker-compose build
```
3. Запустите контейнер:
```bash
docker-compose up -d
```
Приложение будет доступно по адресу `http://localhost:3000`
**Примечание:** API запросы автоматически проксируются к бэкенду через nginx. Не требуется настройка URL API.
### Остановка Docker контейнера
```bash
docker-compose down
```
## Структура проекта
```
play-life-web/
├── src/
│ ├── components/
│ │ ├── CurrentWeek.jsx # Компонент текущей недели
│ │ ├── FullStatistics.jsx # Компонент полной статистики
│ │ └── ProjectProgressBar.jsx # Компонент ProgressBar
│ ├── App.jsx # Главный компонент приложения
│ ├── main.jsx # Точка входа
│ └── index.css # Глобальные стили
├── Dockerfile # Docker образ
├── docker-compose.yml # Docker Compose конфигурация
├── nginx.conf # Nginx конфигурация
└── package.json # Зависимости проекта
```
## API Endpoints
Приложение использует относительные пути для API запросов. Проксирование настроено автоматически:
- **Development**: Vite dev server проксирует запросы к `http://localhost:8080`
- **Production**: Nginx проксирует запросы к бэкенд контейнеру
Endpoints, которые используются:
- `/playlife-feed` - данные текущей недели
- `/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b` - полная статистика
- `/projects` - список проектов
- `/project/priority` - обновление приоритетов проектов
- `/api/*` - остальные API endpoints (слова, конфигурации, тесты)
## Особенности реализации
- ProgressBar отображает текущее значение (`total_score`) и выделяет диапазон целей (`min_goal_score` - `max_goal_score`)
- График полной статистики показывает нарастающую сумму баллов по неделям
- Все проекты отображаются на одном графике с разными цветами
- Адаптивный дизайн для различных размеров экранов

View File

@@ -0,0 +1,42 @@
#!/bin/bash
# Добавляем Docker в PATH
export PATH="/Applications/Docker.app/Contents/Resources/bin:$PATH"
echo "Ожидание запуска Docker daemon..."
# Ждем до 60 секунд, пока Docker daemon запустится
for i in {1..60}; do
if docker ps >/dev/null 2>&1; then
echo "Docker daemon запущен!"
break
fi
if [ $i -eq 60 ]; then
echo "Ошибка: Docker daemon не запустился. Пожалуйста, запустите Docker Desktop вручную."
exit 1
fi
sleep 1
done
echo "Сборка Docker образа..."
docker build \
-t play-life-web:latest .
if [ $? -eq 0 ]; then
echo "Образ успешно собран!"
echo "Сохранение образа в play-life-web.tar..."
docker save play-life-web:latest -o play-life-web.tar
if [ $? -eq 0 ]; then
echo "Образ успешно сохранен в play-life-web.tar"
ls -lh play-life-web.tar
else
echo "Ошибка при сохранении образа"
exit 1
fi
else
echo "Ошибка при сборке образа"
exit 1
fi

View File

@@ -0,0 +1,29 @@
#!/bin/bash
# Скрипт для сборки Docker образа и сохранения в .tar файл
IMAGE_NAME="play-life-web"
IMAGE_TAG="latest"
TAR_FILE="play-life-web.tar"
echo "Сборка Docker образа..."
docker build \
-t "$IMAGE_NAME:$IMAGE_TAG" .
if [ $? -eq 0 ]; then
echo "Образ успешно собран!"
echo "Сохранение образа в $TAR_FILE..."
docker save "$IMAGE_NAME:$IMAGE_TAG" -o "$TAR_FILE"
if [ $? -eq 0 ]; then
echo "Образ успешно сохранен в $TAR_FILE"
ls -lh "$TAR_FILE"
else
echo "Ошибка при сохранении образа"
exit 1
fi
else
echo "Ошибка при сборке образа"
exit 1
fi

View File

@@ -0,0 +1,21 @@
version: '3.8'
services:
play-life-web:
build:
context: .
dockerfile: Dockerfile
container_name: play-life-web
ports:
- "${WEB_PORT:-3000}:80"
restart: unless-stopped
networks:
- play-life-network
env_file:
- ../.env
- .env # Локальный .env имеет приоритет
networks:
play-life-network:
driver: bridge

View File

@@ -0,0 +1,6 @@
# API URLs для PlayLifeWeb
# Скопируйте этот файл в .env и укажите свои значения
# Play Life Web Port (по умолчанию: 3000)
WEB_PORT=3000

14
play-life-web/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>PlayLife - Статистика</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

50
play-life-web/nginx.conf Normal file
View File

@@ -0,0 +1,50 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json;
# Proxy API requests to backend
location /api/ {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# Proxy other API endpoints to backend
location ~ ^/(playlife-feed|d2dc349a-0d13-49b2-a8f0-1ab094bfba9b|projects|project/priority|message/post|webhook/|weekly_goals/setup|admin|admin\.html)$ {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# Handle React Router (SPA)
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

2706
play-life-web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
{
"name": "play-life-web",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"chart.js": "^4.4.0",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"vite": "^5.0.8"
}
}

View File

@@ -0,0 +1,7 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

531
play-life-web/src/App.jsx Normal file
View File

@@ -0,0 +1,531 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import CurrentWeek from './components/CurrentWeek'
import FullStatistics from './components/FullStatistics'
import ProjectPriorityManager from './components/ProjectPriorityManager'
import WordList from './components/WordList'
import AddWords from './components/AddWords'
import TestConfigSelection from './components/TestConfigSelection'
import AddConfig from './components/AddConfig'
import TestWords from './components/TestWords'
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
const CURRENT_WEEK_API_URL = '/playlife-feed'
const FULL_STATISTICS_API_URL = '/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b'
function App() {
const [activeTab, setActiveTab] = useState('current')
const [selectedProject, setSelectedProject] = useState(null)
const [loadedTabs, setLoadedTabs] = useState({
current: false,
priorities: false,
full: false,
words: false,
'add-words': false,
'test-config': false,
'add-config': false,
test: false,
})
// Отслеживаем, какие табы уже были загружены (для предотвращения повторных загрузок)
const [tabsInitialized, setTabsInitialized] = useState({
current: false,
priorities: false,
full: false,
words: false,
'add-words': false,
'test-config': false,
'add-config': false,
test: false,
})
// Параметры для навигации между вкладками
const [tabParams, setTabParams] = useState({})
// Кеширование данных
const [currentWeekData, setCurrentWeekData] = useState(null)
const [fullStatisticsData, setFullStatisticsData] = useState(null)
// Состояния загрузки для каждого таба (показываются только при первой загрузке)
const [currentWeekLoading, setCurrentWeekLoading] = useState(false)
const [fullStatisticsLoading, setFullStatisticsLoading] = useState(false)
const [prioritiesLoading, setPrioritiesLoading] = useState(false)
// Состояния фоновой загрузки (не показываются визуально)
const [currentWeekBackgroundLoading, setCurrentWeekBackgroundLoading] = useState(false)
const [fullStatisticsBackgroundLoading, setFullStatisticsBackgroundLoading] = useState(false)
const [prioritiesBackgroundLoading, setPrioritiesBackgroundLoading] = useState(false)
// Ошибки
const [currentWeekError, setCurrentWeekError] = useState(null)
const [fullStatisticsError, setFullStatisticsError] = useState(null)
const [prioritiesError, setPrioritiesError] = useState(null)
// Состояние для кнопки Refresh (если она есть)
const [isRefreshing, setIsRefreshing] = useState(false)
const [prioritiesRefreshTrigger, setPrioritiesRefreshTrigger] = useState(0)
const [testConfigRefreshTrigger, setTestConfigRefreshTrigger] = useState(0)
const [wordsRefreshTrigger, setWordsRefreshTrigger] = useState(0)
// Восстанавливаем последний выбранный таб после перезагрузки
const [isInitialized, setIsInitialized] = useState(false)
useEffect(() => {
if (isInitialized) return
try {
const savedTab = window.localStorage?.getItem('activeTab')
const validTabs = ['current', 'priorities', 'full', 'words', 'add-words', 'test-config', 'add-config', 'test']
if (savedTab && validTabs.includes(savedTab)) {
setActiveTab(savedTab)
setLoadedTabs(prev => ({ ...prev, [savedTab]: true }))
setIsInitialized(true)
} else {
setIsInitialized(true)
}
} catch (err) {
console.warn('Не удалось прочитать активный таб из localStorage', err)
setIsInitialized(true)
}
}, [isInitialized])
const markTabAsLoaded = useCallback((tab) => {
setLoadedTabs(prev => (prev[tab] ? prev : { ...prev, [tab]: true }))
}, [])
const fetchCurrentWeekData = useCallback(async (isBackground = false) => {
try {
if (isBackground) {
setCurrentWeekBackgroundLoading(true)
} else {
setCurrentWeekLoading(true)
}
setCurrentWeekError(null)
console.log('Fetching current week data from:', CURRENT_WEEK_API_URL)
const response = await fetch(CURRENT_WEEK_API_URL)
if (!response.ok) {
throw new Error('Ошибка загрузки данных')
}
const jsonData = await response.json()
// Обрабатываем ответ: приходит массив с одним объектом [{total: ..., projects: [...]}]
let projects = []
let total = null
if (Array.isArray(jsonData) && jsonData.length > 0) {
// Если ответ - массив, проверяем первый элемент
const firstItem = jsonData[0]
if (firstItem && typeof firstItem === 'object') {
// Если первый элемент - объект с полями total и projects
if (firstItem.projects && Array.isArray(firstItem.projects)) {
projects = firstItem.projects
total = firstItem.total !== undefined ? firstItem.total : null
} else {
// Если это просто массив проектов
projects = jsonData
}
} else {
// Если это массив проектов напрямую
projects = jsonData
}
} else if (jsonData && typeof jsonData === 'object' && !Array.isArray(jsonData)) {
// Если ответ - объект напрямую
projects = jsonData.projects || jsonData.data || []
total = jsonData.total !== undefined ? jsonData.total : null
}
setCurrentWeekData({
projects: Array.isArray(projects) ? projects : [],
total: total
})
} catch (err) {
setCurrentWeekError(err.message)
console.error('Ошибка загрузки данных текущей недели:', err)
} finally {
if (isBackground) {
setCurrentWeekBackgroundLoading(false)
} else {
setCurrentWeekLoading(false)
}
}
}, [])
const fetchFullStatisticsData = useCallback(async (isBackground = false) => {
try {
if (isBackground) {
setFullStatisticsBackgroundLoading(true)
} else {
setFullStatisticsLoading(true)
}
setFullStatisticsError(null)
const response = await fetch(FULL_STATISTICS_API_URL)
if (!response.ok) {
throw new Error('Ошибка загрузки данных')
}
const jsonData = await response.json()
setFullStatisticsData(jsonData)
} catch (err) {
setFullStatisticsError(err.message)
console.error('Ошибка загрузки данных полной статистики:', err)
} finally {
if (isBackground) {
setFullStatisticsBackgroundLoading(false)
} else {
setFullStatisticsLoading(false)
}
}
}, [])
// Используем ref для отслеживания инициализации табов (чтобы избежать лишних пересозданий функции)
const tabsInitializedRef = useRef({
current: false,
priorities: false,
full: false,
words: false,
'add-words': false,
'test-config': false,
'add-config': false,
test: false,
})
// Используем ref для отслеживания кеша (чтобы не зависеть от состояния в useCallback)
const cacheRef = useRef({
current: null,
full: null,
})
// Обновляем ref при изменении данных
useEffect(() => {
cacheRef.current.current = currentWeekData
}, [currentWeekData])
useEffect(() => {
cacheRef.current.full = fullStatisticsData
}, [fullStatisticsData])
// Функция для загрузки данных таба
const loadTabData = useCallback((tab, isBackground = false) => {
if (tab === 'current') {
const hasCache = cacheRef.current.current !== null
const isInitialized = tabsInitializedRef.current.current
if (!isInitialized) {
// Первая загрузка таба - загружаем с индикатором
fetchCurrentWeekData(false)
tabsInitializedRef.current.current = true
setTabsInitialized(prev => ({ ...prev, current: true }))
} else if (hasCache && isBackground) {
// Возврат на таб с кешем - фоновая загрузка
fetchCurrentWeekData(true)
}
// Если нет кеша и это не первая загрузка - ничего не делаем (данные уже загружаются)
} else if (tab === 'full') {
const hasCache = cacheRef.current.full !== null
const isInitialized = tabsInitializedRef.current.full
if (!isInitialized) {
// Первая загрузка таба - загружаем с индикатором
fetchFullStatisticsData(false)
tabsInitializedRef.current.full = true
setTabsInitialized(prev => ({ ...prev, full: true }))
} else if (hasCache && isBackground) {
// Возврат на таб с кешем - фоновая загрузка
fetchFullStatisticsData(true)
}
} else if (tab === 'priorities') {
const isInitialized = tabsInitializedRef.current.priorities
if (!isInitialized) {
// Первая загрузка таба
setPrioritiesRefreshTrigger(prev => prev + 1)
tabsInitializedRef.current.priorities = true
setTabsInitialized(prev => ({ ...prev, priorities: true }))
} else if (isBackground) {
// Возврат на таб - фоновая загрузка
setPrioritiesRefreshTrigger(prev => prev + 1)
}
} else if (tab === 'test-config') {
const isInitialized = tabsInitializedRef.current['test-config']
if (!isInitialized) {
// Первая загрузка таба
setTestConfigRefreshTrigger(prev => prev + 1)
tabsInitializedRef.current['test-config'] = true
setTabsInitialized(prev => ({ ...prev, 'test-config': true }))
} else if (isBackground) {
// Возврат на таб - фоновая загрузка
setTestConfigRefreshTrigger(prev => prev + 1)
}
}
}, [fetchCurrentWeekData, fetchFullStatisticsData])
// Функция для обновления всех данных (для кнопки Refresh, если она есть)
const refreshAllData = useCallback(async () => {
setIsRefreshing(true)
setPrioritiesError(null)
setCurrentWeekError(null)
setFullStatisticsError(null)
// Триггерим обновление приоритетов
setPrioritiesRefreshTrigger(prev => prev + 1)
// Загружаем все данные параллельно (не фоново)
await Promise.all([
fetchCurrentWeekData(false),
fetchFullStatisticsData(false),
])
setIsRefreshing(false)
}, [fetchCurrentWeekData, fetchFullStatisticsData])
// Обновляем данные при возвращении экрана в фокус (фоново)
useEffect(() => {
const handleFocus = () => {
if (document.visibilityState === 'visible') {
// Загружаем данные активного таба фоново
loadTabData(activeTab, true)
}
}
window.addEventListener('focus', handleFocus)
document.addEventListener('visibilitychange', handleFocus)
return () => {
window.removeEventListener('focus', handleFocus)
document.removeEventListener('visibilitychange', handleFocus)
}
}, [activeTab, loadTabData])
const handleProjectClick = (projectName) => {
setSelectedProject(projectName)
markTabAsLoaded('full')
setActiveTab('full')
}
const handleTabChange = (tab, params = {}) => {
if (tab === 'full' && activeTab === 'full') {
// При повторном клике на "Полная статистика" сбрасываем выбранный проект
setSelectedProject(null)
} else if (tab !== activeTab) {
markTabAsLoaded(tab)
// Сбрасываем tabParams при переходе с add-config на другой таб
if (activeTab === 'add-config' && tab !== 'add-config') {
setTabParams({})
} else {
setTabParams(params)
}
setActiveTab(tab)
if (tab === 'current') {
setSelectedProject(null)
}
// Обновляем список слов при возврате из экрана добавления слов
if (activeTab === 'add-words' && tab === 'words') {
setWordsRefreshTrigger(prev => prev + 1)
}
// Загрузка данных произойдет в useEffect при изменении activeTab
}
}
// Обработчик навигации для компонентов
const handleNavigate = (tab, params = {}) => {
handleTabChange(tab, params)
}
// Загружаем данные при открытии таба (когда таб становится активным)
const prevActiveTabRef = useRef(null)
const lastLoadedTabRef = useRef(null) // Отслеживаем последний загруженный таб, чтобы избежать двойной загрузки
useEffect(() => {
if (!activeTab || !loadedTabs[activeTab]) return
const isFirstLoad = !tabsInitializedRef.current[activeTab]
const isReturningToTab = prevActiveTabRef.current !== null && prevActiveTabRef.current !== activeTab
// Проверяем, не загружали ли мы уже этот таб в этом рендере
const tabKey = `${activeTab}-${isFirstLoad ? 'first' : 'return'}`
if (lastLoadedTabRef.current === tabKey) {
return // Уже загружали
}
if (isFirstLoad) {
// Первая загрузка таба
lastLoadedTabRef.current = tabKey
loadTabData(activeTab, false)
} else if (isReturningToTab) {
// Возврат на таб - фоновая загрузка
lastLoadedTabRef.current = tabKey
loadTabData(activeTab, true)
}
prevActiveTabRef.current = activeTab
}, [activeTab, loadedTabs, loadTabData])
// Определяем общее состояние загрузки и ошибок для кнопки Refresh
const isAnyLoading = currentWeekLoading || fullStatisticsLoading || prioritiesLoading || isRefreshing
const hasAnyError = currentWeekError || fullStatisticsError || prioritiesError
// Сохраняем выбранный таб, чтобы восстановить его после перезагрузки
useEffect(() => {
try {
window.localStorage?.setItem('activeTab', activeTab)
} catch (err) {
console.warn('Не удалось сохранить активный таб в localStorage', err)
}
}, [activeTab])
// Определяем, нужно ли скрывать нижнюю панель (для fullscreen экранов)
const isFullscreenTab = activeTab === 'test' || activeTab === 'add-words' || activeTab === 'add-config'
return (
<div className="flex flex-col min-h-screen min-h-dvh">
<div className={`flex-1 ${isFullscreenTab ? 'pb-0' : 'pb-20'}`}>
<div className={`max-w-7xl mx-auto ${isFullscreenTab ? 'p-0' : 'p-4 md:p-6'}`}>
{loadedTabs.current && (
<div className={activeTab === 'current' ? 'block' : 'hidden'}>
<CurrentWeek
onProjectClick={handleProjectClick}
data={currentWeekData}
loading={currentWeekLoading}
error={currentWeekError}
onRetry={fetchCurrentWeekData}
allProjectsData={fullStatisticsData}
onNavigate={handleNavigate}
/>
</div>
)}
{loadedTabs.priorities && (
<div className={activeTab === 'priorities' ? 'block' : 'hidden'}>
<ProjectPriorityManager
allProjectsData={fullStatisticsData}
currentWeekData={currentWeekData}
shouldLoad={activeTab === 'priorities' && loadedTabs.priorities}
onLoadingChange={setPrioritiesLoading}
onErrorChange={setPrioritiesError}
refreshTrigger={prioritiesRefreshTrigger}
onNavigate={handleNavigate}
/>
</div>
)}
{loadedTabs.full && (
<div className={activeTab === 'full' ? 'block' : 'hidden'}>
<FullStatistics
selectedProject={selectedProject}
onClearSelection={() => setSelectedProject(null)}
data={fullStatisticsData}
loading={fullStatisticsLoading}
error={fullStatisticsError}
onRetry={fetchFullStatisticsData}
currentWeekData={currentWeekData}
onNavigate={handleNavigate}
/>
</div>
)}
{loadedTabs.words && (
<div className={activeTab === 'words' ? 'block' : 'hidden'}>
<WordList
onNavigate={handleNavigate}
dictionaryId={tabParams.dictionaryId}
isNewDictionary={tabParams.isNewDictionary}
refreshTrigger={wordsRefreshTrigger}
/>
</div>
)}
{loadedTabs['add-words'] && (
<div className={activeTab === 'add-words' ? 'block' : 'hidden'}>
<AddWords
onNavigate={handleNavigate}
dictionaryId={tabParams.dictionaryId}
dictionaryName={tabParams.dictionaryName}
/>
</div>
)}
{loadedTabs['test-config'] && (
<div className={activeTab === 'test-config' ? 'block' : 'hidden'}>
<TestConfigSelection
onNavigate={handleNavigate}
refreshTrigger={testConfigRefreshTrigger}
/>
</div>
)}
{loadedTabs['add-config'] && (
<div className={activeTab === 'add-config' ? 'block' : 'hidden'}>
<AddConfig
key={tabParams.config?.id || 'new'}
onNavigate={handleNavigate}
editingConfig={tabParams.config}
/>
</div>
)}
{loadedTabs.test && (
<div className={activeTab === 'test' ? 'block' : 'hidden'}>
<TestWords
onNavigate={handleNavigate}
wordCount={tabParams.wordCount}
configId={tabParams.configId}
maxCards={tabParams.maxCards}
/>
</div>
)}
</div>
</div>
{!isFullscreenTab && (
<div className="sticky bottom-0 flex bg-white/90 backdrop-blur-md border-t border-white/20 flex-shrink-0 overflow-x-auto shadow-lg z-10 justify-center items-center relative w-full" style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}>
<div className="flex">
<button
onClick={() => handleTabChange('current')}
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
activeTab === 'current'
? 'text-indigo-700 bg-white/50'
: 'text-gray-600 hover:text-indigo-600 hover:bg-white/30'
}`}
title="Неделя"
>
<span className="relative z-10 flex items-center justify-center">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line>
<line x1="8" y1="2" x2="8" y2="6"></line>
<line x1="3" y1="10" x2="21" y2="10"></line>
</svg>
</span>
{activeTab === 'current' && (
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
)}
</button>
<button
onClick={() => handleTabChange('test-config')}
className={`min-w-max whitespace-nowrap px-4 py-4 transition-all duration-300 relative ${
activeTab === 'test-config' || activeTab === 'test'
? 'text-indigo-700 bg-white/50'
: 'text-gray-600 hover:text-indigo-600 hover:bg-white/30'
}`}
title="Тест"
>
<span className="relative z-10 flex items-center justify-center">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path>
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path>
<path d="M8 7h6"></path>
<path d="M8 11h4"></path>
</svg>
</span>
{(activeTab === 'test-config' || activeTab === 'test') && (
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-indigo-500 to-purple-500"></div>
)}
</button>
</div>
</div>
)}
</div>
)
}
export default App

View File

@@ -0,0 +1,222 @@
.add-config {
padding-left: 1rem;
padding-right: 1rem;
}
@media (min-width: 768px) {
.add-config {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
}
.add-config h2 {
margin-top: 2rem;
margin-bottom: 1rem;
color: #2c3e50;
font-size: 2rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #2c3e50;
font-weight: 500;
}
.form-input,
.form-textarea {
width: 100%;
padding: 0.75rem;
border: 2px solid #ddd;
border-radius: 4px;
font-size: 1rem;
transition: border-color 0.2s;
font-family: inherit;
}
.form-textarea {
resize: vertical;
min-height: 100px;
}
.form-input:focus,
.form-textarea:focus {
outline: none;
border-color: #3498db;
}
.submit-button {
background-color: #3498db;
color: white;
border: none;
padding: 0.75rem 2rem;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
width: 100%;
}
.submit-button:hover:not(:disabled) {
background-color: #2980b9;
}
.submit-button:disabled {
background-color: #bdc3c7;
cursor: not-allowed;
}
.message {
margin-top: 1rem;
padding: 1rem;
border-radius: 4px;
font-weight: 500;
}
.message.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.stepper-container {
display: flex;
align-items: center;
gap: 0.5rem;
}
.stepper-button {
background-color: #3498db;
color: white;
border: none;
width: 40px;
height: 40px;
border-radius: 8px;
font-size: 1.5rem;
font-weight: bold;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stepper-button:hover:not(:disabled) {
background-color: #2980b9;
transform: translateY(-1px);
}
.stepper-button:disabled {
background-color: #bdc3c7;
cursor: not-allowed;
opacity: 0.6;
}
.stepper-input {
flex: 1;
padding: 0.75rem;
border: 2px solid #ddd;
border-radius: 4px;
font-size: 1rem;
text-align: center;
transition: border-color 0.2s;
font-family: inherit;
}
.stepper-input:focus {
outline: none;
border-color: #3498db;
}
.close-x-button {
position: fixed;
top: 1rem;
right: 1rem;
background: rgba(255, 255, 255, 0.9);
border: none;
font-size: 1.5rem;
color: #7f8c8d;
cursor: pointer;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s, color 0.2s;
z-index: 1600;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.close-x-button:hover {
background-color: #ffffff;
color: #2c3e50;
}
.dictionaries-hint {
font-size: 0.875rem;
color: #7f8c8d;
margin-bottom: 0.75rem;
font-style: italic;
}
.dictionaries-checkbox-list {
display: flex;
flex-direction: column;
gap: 0;
max-height: 200px;
overflow-y: auto;
padding: 0.5rem;
border: 2px solid #ddd;
border-radius: 4px;
background-color: #f9f9f9;
}
.dictionary-checkbox-label {
display: flex;
align-items: center;
cursor: pointer;
padding: 0.5rem;
border-radius: 4px;
transition: background-color 0.2s;
}
.dictionary-checkbox-label:hover {
background-color: #e8f4f8;
}
.dictionary-checkbox-label input[type="checkbox"] {
width: 18px;
height: 18px;
min-width: 18px;
min-height: 18px;
margin: 0;
margin-right: 0.75rem;
padding: 0;
cursor: pointer;
accent-color: #3498db;
flex-shrink: 0;
align-self: center;
vertical-align: middle;
}
.dictionary-checkbox-label span {
color: #2c3e50;
font-size: 0.95rem;
line-height: 18px;
display: inline-block;
vertical-align: middle;
}

View File

@@ -0,0 +1,344 @@
import React, { useState, useEffect } from 'react'
import './AddConfig.css'
const API_URL = '/api'
function AddConfig({ onNavigate, editingConfig: initialEditingConfig }) {
const [name, setName] = useState('')
const [tryMessage, setTryMessage] = useState('')
const [wordsCount, setWordsCount] = useState('10')
const [maxCards, setMaxCards] = useState('')
const [message, setMessage] = useState('')
const [loading, setLoading] = useState(false)
const [dictionaries, setDictionaries] = useState([])
const [selectedDictionaryIds, setSelectedDictionaryIds] = useState([])
const [loadingDictionaries, setLoadingDictionaries] = useState(false)
// Load dictionaries
useEffect(() => {
const loadDictionaries = async () => {
setLoadingDictionaries(true)
try {
const response = await fetch(`${API_URL}/test-configs-and-dictionaries`)
if (!response.ok) {
throw new Error('Ошибка при загрузке словарей')
}
const data = await response.json()
setDictionaries(Array.isArray(data.dictionaries) ? data.dictionaries : [])
} catch (err) {
console.error('Failed to load dictionaries:', err)
} finally {
setLoadingDictionaries(false)
}
}
loadDictionaries()
}, [])
// Load selected dictionaries when editing
useEffect(() => {
const loadSelectedDictionaries = async () => {
if (initialEditingConfig?.id) {
try {
const response = await fetch(`${API_URL}/configs/${initialEditingConfig.id}/dictionaries`)
if (response.ok) {
const data = await response.json()
setSelectedDictionaryIds(Array.isArray(data.dictionary_ids) ? data.dictionary_ids : [])
}
} catch (err) {
console.error('Failed to load selected dictionaries:', err)
}
} else {
setSelectedDictionaryIds([])
}
}
loadSelectedDictionaries()
}, [initialEditingConfig])
useEffect(() => {
if (initialEditingConfig) {
setName(initialEditingConfig.name)
setTryMessage(initialEditingConfig.try_message)
setWordsCount(String(initialEditingConfig.words_count))
setMaxCards(initialEditingConfig.max_cards ? String(initialEditingConfig.max_cards) : '')
} else {
// Сбрасываем состояние при открытии в режиме добавления
setName('')
setTryMessage('')
setWordsCount('10')
setMaxCards('')
setMessage('')
setSelectedDictionaryIds([])
}
}, [initialEditingConfig])
// Сбрасываем состояние при размонтировании компонента
useEffect(() => {
return () => {
setName('')
setTryMessage('')
setWordsCount('10')
setMaxCards('')
setMessage('')
setLoading(false)
}
}, [])
const handleSubmit = async (e) => {
e.preventDefault()
setMessage('')
setLoading(true)
if (!name.trim()) {
setMessage('Имя обязательно для заполнения.')
setLoading(false)
return
}
try {
const url = initialEditingConfig
? `${API_URL}/configs/${initialEditingConfig.id}`
: `${API_URL}/configs`
const method = initialEditingConfig ? 'PUT' : 'POST'
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: name.trim(),
try_message: tryMessage.trim() || '',
words_count: wordsCount === '' ? 0 : parseInt(wordsCount) || 0,
max_cards: maxCards === '' ? null : parseInt(maxCards) || null,
dictionary_ids: selectedDictionaryIds.length > 0 ? selectedDictionaryIds : undefined,
}),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
const errorMessage = errorData.message || response.statusText || `Ошибка при ${initialEditingConfig ? 'обновлении' : 'создании'} конфигурации`
throw new Error(errorMessage)
}
if (!initialEditingConfig) {
setName('')
setTryMessage('')
setWordsCount('10')
setMaxCards('')
}
// Navigate back immediately
onNavigate?.('test-config')
} catch (error) {
setMessage(`Ошибка: ${error.message}`)
} finally {
setLoading(false)
}
}
const getNumericValue = () => {
return wordsCount === '' ? 0 : parseInt(wordsCount) || 0
}
const getMaxCardsNumericValue = () => {
return maxCards === '' ? 0 : parseInt(maxCards) || 0
}
const handleDecrease = () => {
const numValue = getNumericValue()
if (numValue > 0) {
setWordsCount(String(numValue - 1))
}
}
const handleIncrease = () => {
const numValue = getNumericValue()
setWordsCount(String(numValue + 1))
}
const handleMaxCardsDecrease = () => {
const numValue = getMaxCardsNumericValue()
if (numValue > 0) {
setMaxCards(String(numValue - 1))
} else {
setMaxCards('')
}
}
const handleMaxCardsIncrease = () => {
const numValue = getMaxCardsNumericValue()
const newValue = numValue + 1
setMaxCards(String(newValue))
}
const handleClose = () => {
// Сбрасываем состояние при закрытии
setName('')
setTryMessage('')
setWordsCount('10')
setMaxCards('')
setMessage('')
onNavigate?.('test-config')
}
return (
<div className="add-config">
<button className="close-x-button" onClick={handleClose}>
</button>
<h2>Конфигурация теста</h2>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="name">Имя</label>
<input
id="name"
type="text"
className="form-input"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Название конфига"
required
/>
</div>
<div className="form-group">
<label htmlFor="tryMessage">Сообщение (необязательно)</label>
<textarea
id="tryMessage"
className="form-textarea"
value={tryMessage}
onChange={(e) => setTryMessage(e.target.value)}
placeholder="Сообщение которое будет отправлено в play-life при прохождении теста"
rows={4}
/>
</div>
<div className="form-group">
<label htmlFor="wordsCount">Кол-во слов</label>
<div className="stepper-container">
<button
type="button"
className="stepper-button"
onClick={handleDecrease}
disabled={getNumericValue() <= 0}
>
</button>
<input
id="wordsCount"
type="number"
className="stepper-input"
value={wordsCount}
onChange={(e) => {
const inputValue = e.target.value
if (inputValue === '') {
setWordsCount('')
} else {
const numValue = parseInt(inputValue)
if (!isNaN(numValue) && numValue >= 0) {
setWordsCount(inputValue)
}
}
}}
min="0"
required
/>
<button
type="button"
className="stepper-button"
onClick={handleIncrease}
>
+
</button>
</div>
</div>
<div className="form-group">
<label htmlFor="maxCards">Макс. кол-во карточек (необязательно)</label>
<div className="stepper-container">
<button
type="button"
className="stepper-button"
onClick={handleMaxCardsDecrease}
disabled={getMaxCardsNumericValue() <= 0}
>
</button>
<input
id="maxCards"
type="number"
className="stepper-input"
value={maxCards}
onChange={(e) => {
const inputValue = e.target.value
if (inputValue === '') {
setMaxCards('')
} else {
const numValue = parseInt(inputValue)
if (!isNaN(numValue) && numValue >= 0) {
setMaxCards(inputValue)
}
}
}}
min="0"
/>
<button
type="button"
className="stepper-button"
onClick={handleMaxCardsIncrease}
>
+
</button>
</div>
</div>
<div className="form-group">
<label htmlFor="dictionaries">Словари (необязательно)</label>
<div className="dictionaries-hint">
Если не выбрано ни одного словаря, будут использоваться все словари
</div>
{loadingDictionaries ? (
<div>Загрузка словарей...</div>
) : (
<div className="dictionaries-checkbox-list">
{dictionaries.map((dict) => (
<label key={dict.id} className="dictionary-checkbox-label">
<input
type="checkbox"
checked={selectedDictionaryIds.includes(dict.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedDictionaryIds([...selectedDictionaryIds, dict.id])
} else {
setSelectedDictionaryIds(selectedDictionaryIds.filter(id => id !== dict.id))
}
}}
/>
<span>{dict.name} ({dict.wordsCount})</span>
</label>
))}
</div>
)}
</div>
<button
type="submit"
className="submit-button"
disabled={loading || !name.trim() || getNumericValue() === 0}
>
{loading ? (initialEditingConfig ? 'Обновление...' : 'Создание...') : (initialEditingConfig ? 'Обновить конфигурацию' : 'Создать конфигурацию')}
</button>
</form>
{message && (
<div className={`message ${message.includes('Ошибка') ? 'error' : 'success'}`}>
{message}
</div>
)}
</div>
)
}
export default AddConfig

View File

@@ -0,0 +1,106 @@
.add-words {
padding-left: 1rem;
padding-right: 1rem;
}
@media (min-width: 768px) {
.add-words {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
}
.add-words h2 {
margin-top: 2rem;
margin-bottom: 1rem;
color: #2c3e50;
font-size: 2rem;
}
.description {
margin-bottom: 1.5rem;
color: #666;
font-size: 0.95rem;
}
.markdown-input {
width: 100%;
padding: 1rem;
border: 2px solid #ddd;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 0.9rem;
resize: vertical;
margin-bottom: 1rem;
transition: border-color 0.2s;
}
.markdown-input:focus {
outline: none;
border-color: #3498db;
}
.submit-button {
background-color: #3498db;
color: white;
border: none;
padding: 0.75rem 2rem;
border-radius: 8px;
font-size: 1rem;
cursor: pointer;
transition: background-color 0.2s;
}
.submit-button:hover:not(:disabled) {
background-color: #2980b9;
}
.submit-button:disabled {
background-color: #bdc3c7;
cursor: not-allowed;
}
.message {
margin-top: 1rem;
padding: 1rem;
border-radius: 4px;
font-weight: 500;
}
.message.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.close-x-button {
position: fixed;
top: 1rem;
right: 1rem;
background: rgba(255, 255, 255, 0.9);
border: none;
font-size: 1.5rem;
color: #7f8c8d;
cursor: pointer;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s, color 0.2s;
z-index: 1600;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.close-x-button:hover {
background-color: #ffffff;
color: #2c3e50;
}

View File

@@ -0,0 +1,163 @@
import React, { useState } from 'react'
import './AddWords.css'
const API_URL = '/api'
function AddWords({ onNavigate, dictionaryId, dictionaryName }) {
const [markdownText, setMarkdownText] = useState('')
const [message, setMessage] = useState('')
const [loading, setLoading] = useState(false)
// Hide add button if dictionary name is not set
const canAddWords = dictionaryName && dictionaryName.trim() !== ''
const parseMarkdownTable = (text) => {
const lines = text.split('\n')
const words = []
let foundTable = false
let headerFound = false
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim()
// Skip empty lines
if (!line) continue
// Look for table start (markdown table with |)
if (line.includes('|') && line.includes('Слово')) {
foundTable = true
headerFound = true
continue
}
// Skip separator line (|---|---|)
if (foundTable && line.match(/^\|[\s\-|:]+\|$/)) {
continue
}
// Parse table rows
if (foundTable && headerFound && line.includes('|')) {
const cells = line
.split('|')
.map(cell => (cell || '').trim())
.filter(cell => cell && cell.length > 0)
if (cells.length >= 2) {
// Remove markdown formatting (**bold**, etc.)
const word = cells[0].replace(/\*\*/g, '').trim()
const translation = cells[1].replace(/\*\*/g, '').trim()
if (word && translation) {
words.push({
name: word,
translation: translation,
description: ''
})
}
}
}
}
return words
}
const handleSubmit = async (e) => {
e.preventDefault()
setMessage('')
setLoading(true)
try {
const words = parseMarkdownTable(markdownText)
if (words.length === 0) {
setMessage('Не удалось найти слова в таблице. Убедитесь, что таблица содержит колонки "Слово" и "Перевод".')
setLoading(false)
return
}
// Add dictionary_id to each word if dictionaryId is provided
const wordsWithDictionary = words.map(word => ({
...word,
dictionary_id: dictionaryId !== undefined && dictionaryId !== null ? dictionaryId : undefined
}))
const response = await fetch(`${API_URL}/words`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ words: wordsWithDictionary }),
})
if (!response.ok) {
throw new Error('Ошибка при добавлении слов')
}
const data = await response.json()
const addedCount = data?.added || 0
setMessage(`Успешно добавлено ${addedCount} слов(а)!`)
setMarkdownText('')
} catch (error) {
setMessage(`Ошибка: ${error.message}`)
} finally {
setLoading(false)
}
}
const handleClose = () => {
onNavigate?.('words', dictionaryId !== undefined && dictionaryId !== null ? { dictionaryId } : {})
}
return (
<div className="add-words">
<button className="close-x-button" onClick={handleClose}>
</button>
<h2>Добавить слова</h2>
<p className="description">
Вставьте текст в формате Markdown с таблицей, содержащей колонки "Слово" и "Перевод"
</p>
<form onSubmit={handleSubmit}>
<textarea
className="markdown-input"
value={markdownText}
onChange={(e) => setMarkdownText(e.target.value)}
placeholder={`Вот таблица, содержащая только слова и их перевод:
| Слово (Word) | Перевод |
| --- | --- |
| **Adventure** | Приключение |
| **Challenge** | Вызов, сложная задача |
| **Decision** | Решение |`}
rows={15}
/>
{canAddWords && (
<button
type="submit"
className="submit-button"
disabled={loading || !markdownText.trim()}
>
{loading ? 'Добавление...' : 'Добавить слова'}
</button>
)}
{!canAddWords && (
<div className="message error">
Сначала установите название словаря на экране списка слов
</div>
)}
</form>
{message && (
<div className={`message ${message.includes('Ошибка') ? 'error' : 'success'}`}>
{message}
</div>
)}
</div>
)
}
export default AddWords

View File

@@ -0,0 +1,200 @@
import ProjectProgressBar from './ProjectProgressBar'
import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils'
function CurrentWeek({ onProjectClick, data, loading, error, onRetry, allProjectsData, onNavigate }) {
// Обрабатываем данные: может быть объект с projects и total, или просто массив
const projectsData = data?.projects || (Array.isArray(data) ? data : [])
// Показываем loading только если данных нет и идет загрузка
if (loading && (!data || projectsData.length === 0)) {
return (
<div className="flex justify-center items-center py-16">
<div className="flex flex-col items-center">
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
<div className="text-gray-600 font-medium">Загрузка данных...</div>
</div>
</div>
)
}
if (error && (!data || projectsData.length === 0)) {
return (
<div className="flex flex-col items-center justify-center py-16">
<div className="bg-red-50 border border-red-200 rounded-lg p-6 mb-4 max-w-md">
<div className="text-red-700 font-semibold mb-2">Ошибка загрузки</div>
<div className="text-red-600 text-sm">{error}</div>
</div>
<button
onClick={onRetry}
className="px-6 py-3 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition-all duration-200 shadow-lg hover:shadow-xl font-semibold"
>
Попробовать снова
</button>
</div>
)
}
// Процент выполнения берем только из данных API
const overallProgress = (() => {
// Проверяем различные возможные названия поля
const rawValue = data?.total ?? data?.progress ?? data?.percentage ?? data?.completion ?? data?.goal_progress
const parsedValue = rawValue === undefined || rawValue === null ? null : parseFloat(rawValue)
if (Number.isFinite(parsedValue) && parsedValue >= 0) {
return Math.max(0, parsedValue) // Убрали ограничение на 100, так как может быть больше
}
return null // null означает, что данные не пришли
})()
const hasProgressData = overallProgress !== null
// Логирование для отладки
console.log('CurrentWeek data:', {
data,
dataTotal: data?.total,
dataProgress: data?.progress,
dataPercentage: data?.percentage,
overallProgress,
hasProgressData
})
if (!projectsData || projectsData.length === 0) {
return (
<div className="flex justify-center items-center py-16">
<div className="text-gray-500 text-lg">Нет данных для отображения</div>
</div>
)
}
// Получаем отсортированный список всех проектов для синхронизации цветов
const allProjects = getAllProjectsSorted(allProjectsData, projectsData)
const normalizePriority = (value) => {
if (value === null || value === undefined) return Infinity
const numeric = Number(value)
return Number.isFinite(numeric) ? numeric : Infinity
}
// Сортируем: сначала по priority (1, 2, ...; null в конце), затем по min_goal_score по убыванию
const sortedData = [...projectsData].sort((a, b) => {
const priorityA = normalizePriority(a.priority)
const priorityB = normalizePriority(b.priority)
if (priorityA !== priorityB) {
return priorityA - priorityB
}
const minGoalA = parseFloat(a.min_goal_score) || 0
const minGoalB = parseFloat(b.min_goal_score) || 0
return minGoalB - minGoalA
})
return (
<div>
{/* Информация об общем проценте выполнения целей */}
<div className="mb-3 bg-gradient-to-r from-indigo-50 to-purple-50 rounded-lg p-4 border border-indigo-200">
<div className="flex items-stretch justify-between gap-4">
<div className="min-w-0 flex-1 flex items-center gap-4">
<div className="flex-1">
<div className="text-sm sm:text-base text-gray-600 mb-1">Выполнение целей</div>
<div className="text-2xl sm:text-3xl lg:text-4xl font-bold bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent">
{hasProgressData ? `${overallProgress.toFixed(1)}%` : 'N/A'}
</div>
</div>
{hasProgressData && (
<div className="w-12 h-12 sm:w-16 sm:h-16 relative flex-shrink-0">
<svg className="transform -rotate-90" viewBox="0 0 64 64">
<circle
cx="32"
cy="32"
r="28"
stroke="currentColor"
strokeWidth="6"
fill="none"
className="text-gray-200"
/>
<circle
cx="32"
cy="32"
r="28"
stroke="url(#gradient)"
strokeWidth="6"
fill="none"
strokeDasharray={`${Math.min(overallProgress / 100, 1) * 175.93} 175.93`}
strokeLinecap="round"
/>
{overallProgress >= 100 && (
<g className="transform rotate-90" style={{ transformOrigin: '32px 32px' }}>
<path
d="M 20 32 L 28 40 L 44 24"
stroke="url(#gradient)"
strokeWidth="4"
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
</g>
)}
<defs>
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#4f46e5" />
<stop offset="100%" stopColor="#9333ea" />
</linearGradient>
</defs>
</svg>
</div>
)}
</div>
{onNavigate && (
<div className="flex flex-col flex-shrink-0 gap-1" style={{ minHeight: '64px', height: '100%' }}>
<button
onClick={() => onNavigate('full')}
className="flex-1 flex items-center justify-center px-4 bg-white hover:bg-indigo-50 text-indigo-600 hover:text-indigo-700 rounded-lg border border-indigo-200 hover:border-indigo-300 transition-all duration-200 shadow-sm hover:shadow-md"
title="Статистика"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="20" x2="18" y2="10"></line>
<line x1="12" y1="20" x2="12" y2="4"></line>
<line x1="6" y1="20" x2="6" y2="14"></line>
</svg>
</button>
<button
onClick={() => onNavigate('priorities')}
className="flex-1 flex items-center justify-center px-4 bg-white hover:bg-indigo-50 text-indigo-600 hover:text-indigo-700 rounded-lg border border-indigo-200 hover:border-indigo-300 transition-all duration-200 shadow-sm hover:shadow-md"
title="Приоритеты"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
</svg>
</button>
</div>
)}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{sortedData.map((project, index) => {
const projectColor = getProjectColor(project.project_name, allProjects)
return (
<div key={index}>
<ProjectProgressBar
projectName={project.project_name}
totalScore={parseFloat(project.total_score)}
minGoalScore={parseFloat(project.min_goal_score)}
maxGoalScore={parseFloat(project.max_goal_score)}
onProjectClick={onProjectClick}
projectColor={projectColor}
priority={project.priority}
/>
</div>
)
})}
</div>
</div>
)
}
export default CurrentWeek

View File

@@ -0,0 +1,289 @@
import React from 'react'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
} from 'chart.js'
import { Line } from 'react-chartjs-2'
import WeekProgressChart from './WeekProgressChart'
import { getAllProjectsSorted, getProjectColor, sortProjectsLikeCurrentWeek } from '../utils/projectUtils'
// Экспортируем для обратной совместимости (если используется в других местах)
export { getProjectColorByIndex } from '../utils/projectUtils'
const formatWeekKey = ({ year, week }) => `${year}-W${week.toString().padStart(2, '0')}`
const parseWeekKey = (weekKey) => {
const [yearStr, weekStr] = weekKey.split('-W')
return { year: Number(yearStr), week: Number(weekStr) }
}
const compareWeekKeys = (a, b) => {
const [yearA, weekA] = a.split('-W').map(Number)
const [yearB, weekB] = b.split('-W').map(Number)
if (yearA !== yearB) {
return yearA - yearB
}
return weekA - weekB
}
// Возвращает понедельник ISO-недели
const getDateOfISOWeek = (week, year) => {
const simple = new Date(year, 0, 1 + (week - 1) * 7)
const dayOfWeek = simple.getDay() || 7 // Sunday -> 7
if (dayOfWeek !== 1) {
simple.setDate(simple.getDate() + (1 - dayOfWeek))
}
simple.setHours(0, 0, 0, 0)
return simple
}
const getISOWeekInfo = (date) => {
const target = new Date(date.getTime())
target.setHours(0, 0, 0, 0)
target.setDate(target.getDate() + 4 - (target.getDay() || 7))
const year = target.getFullYear()
const yearStart = new Date(year, 0, 1)
const week = Math.ceil(((target - yearStart) / 86400000 + 1) / 7)
return { year, week }
}
const getPrevWeekKey = (weekKey) => {
const { year, week } = parseWeekKey(weekKey)
const currentWeekDate = getDateOfISOWeek(week, year)
const prevWeekDate = new Date(currentWeekDate.getTime())
prevWeekDate.setDate(prevWeekDate.getDate() - 7)
const prevWeekInfo = getISOWeekInfo(prevWeekDate)
return formatWeekKey(prevWeekInfo)
}
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
)
function FullStatistics({ selectedProject, onClearSelection, data, loading, error, onRetry, currentWeekData, onNavigate }) {
const processData = () => {
if (!data || data.length === 0) return null
// Группируем данные по проектам
const projectsMap = {}
data.forEach(item => {
const projectName = item.project_name
const weekKey = `${item.report_year}-W${item.report_week.toString().padStart(2, '0')}`
if (!projectsMap[projectName]) {
projectsMap[projectName] = {}
}
projectsMap[projectName][weekKey] = parseFloat(item.total_score)
})
// Добавляем дополнительную неделю со значением 0,
// если первая неделя проекта имеет ненулевое значение
Object.values(projectsMap).forEach((weeks) => {
const projectWeeks = Object.keys(weeks)
if (!projectWeeks.length) return
const sortedProjectWeeks = projectWeeks.sort(compareWeekKeys)
const firstWeekKey = sortedProjectWeeks[0]
const firstScore = weeks[firstWeekKey]
if (firstScore !== 0) {
const zeroWeekKey = getPrevWeekKey(firstWeekKey)
weeks[zeroWeekKey] = 0
}
})
// Собираем все уникальные недели и сортируем их по году и неделе
const allWeeks = new Set()
Object.values(projectsMap).forEach(weeks => {
Object.keys(weeks).forEach(week => allWeeks.add(week))
})
const sortedWeeks = Array.from(allWeeks).sort(compareWeekKeys)
// Получаем отсортированный список всех проектов для синхронизации цветов
const allProjectNames = getAllProjectsSorted(data)
// Фильтруем по выбранному проекту, если он указан
let projectNames = allProjectNames.filter(name => projectsMap[name])
// Сортируем проекты так же, как на экране списка проектов (по priority и min_goal_score)
if (currentWeekData) {
projectNames = sortProjectsLikeCurrentWeek(projectNames, currentWeekData)
}
if (selectedProject) {
projectNames = projectNames.filter(name => name === selectedProject)
}
const datasets = projectNames.map((projectName) => {
let cumulativeSum = 0
const values = sortedWeeks.map(week => {
const score = projectsMap[projectName]?.[week] || 0
cumulativeSum += score
return cumulativeSum
})
// Генерируем цвет для линии на основе индекса в полном списке проектов
const color = getProjectColor(projectName, allProjectNames)
return {
label: projectName,
data: values,
borderColor: color,
backgroundColor: color.replace('50%)', '20%)'),
fill: false,
tension: 0.4,
pointRadius: 4,
pointHoverRadius: 6,
}
})
return {
labels: sortedWeeks,
datasets: datasets,
}
}
const chartData = processData()
// Показываем loading только если данных нет и идет загрузка
if (loading && !chartData) {
return (
<div className="flex justify-center items-center py-16">
<div className="flex flex-col items-center">
<div className="w-12 h-12 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin mb-4"></div>
<div className="text-gray-600 font-medium">Загрузка данных...</div>
</div>
</div>
)
}
if (error && !chartData) {
return (
<div className="flex flex-col items-center justify-center py-16">
<div className="bg-red-50 border border-red-200 rounded-lg p-6 mb-4 max-w-md">
<div className="text-red-700 font-semibold mb-2">Ошибка загрузки</div>
<div className="text-red-600 text-sm">{error}</div>
</div>
<button
onClick={onRetry}
className="px-6 py-3 bg-gradient-to-r from-indigo-600 to-purple-600 text-white rounded-lg hover:from-indigo-700 hover:to-purple-700 transition-all duration-200 shadow-lg hover:shadow-xl font-semibold"
>
Попробовать снова
</button>
</div>
)
}
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: {
legend: {
position: 'bottom',
labels: {
usePointStyle: true,
padding: 15,
font: {
size: 12,
},
},
padding: {
top: 20,
},
},
title: {
display: false,
},
tooltip: {
mode: 'index',
intersect: false,
callbacks: {
label: function(context) {
return `${context.dataset.label}: ${context.parsed.y.toFixed(2)}`
}
}
},
},
scales: {
x: {
display: true,
title: {
display: false,
},
grid: {
display: true,
color: 'rgba(0, 0, 0, 0.05)',
},
},
y: {
display: true,
title: {
display: false,
},
beginAtZero: true,
grid: {
display: true,
color: 'rgba(0, 0, 0, 0.05)',
},
},
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false,
},
}
if (!chartData) {
return (
<div className="flex justify-center items-center py-16">
<div className="text-gray-500 text-lg">Нет данных для отображения</div>
</div>
)
}
return (
<div>
{onNavigate && (
<div className="flex justify-end mb-4">
<button
onClick={() => onNavigate('current')}
className="flex items-center justify-center w-10 h-10 rounded-full bg-white hover:bg-gray-100 text-gray-600 hover:text-gray-800 border border-gray-200 hover:border-gray-300 transition-all duration-200 shadow-sm hover:shadow-md"
title="Закрыть"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
)}
<div style={{ height: '550px' }}>
<Line data={chartData} options={chartOptions} />
</div>
<WeekProgressChart data={data} allProjectsSorted={getAllProjectsSorted(data)} currentWeekData={currentWeekData} selectedProject={selectedProject} />
</div>
)
}
export default FullStatistics

View File

@@ -0,0 +1,724 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { createPortal } from 'react-dom'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragOverlay,
useDroppable,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { getAllProjectsSorted, getProjectColor } from '../utils/projectUtils'
// API endpoints (используем относительные пути, проксирование настроено в nginx/vite)
const PROJECTS_API_URL = '/projects'
const PRIORITY_UPDATE_API_URL = '/project/priority'
// Компонент для сортируемого элемента проекта
function SortableProjectItem({ project, index, allProjects, onRemove }) {
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: project.name })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}
const projectColor = getProjectColor(project.name, allProjects)
return (
<div
ref={setNodeRef}
data-id={project.name}
style={{ ...style, touchAction: 'none' }}
className={`bg-white rounded-lg p-3 border-2 border-gray-200 shadow-sm hover:shadow-md transition-all duration-200 ${
isDragging ? 'border-indigo-400' : ''
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 flex-1">
<div
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600"
style={{ touchAction: 'none' }}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<circle cx="7" cy="7" r="1.5" />
<circle cx="13" cy="7" r="1.5" />
<circle cx="7" cy="13" r="1.5" />
<circle cx="13" cy="13" r="1.5" />
</svg>
</div>
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: projectColor }}
></div>
<span className="font-semibold text-gray-800">{project.name}</span>
</div>
{onRemove && (
<button
onClick={() => onRemove(project.name)}
className="ml-2 text-gray-400 hover:text-red-500 transition-colors"
title="Убрать из этого слота"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
</button>
)}
</div>
</div>
)
}
// Компонент для пустого слота (droppable)
function DroppableSlot({ containerId, isEmpty, maxItems, currentCount }) {
const { setNodeRef, isOver } = useDroppable({
id: containerId,
})
return (
<div
ref={setNodeRef}
className={`bg-gray-50 border-2 border-dashed rounded-lg p-4 text-center text-sm transition-colors ${
isOver
? 'border-indigo-400 bg-indigo-50 text-indigo-600'
: 'border-gray-300 text-gray-400'
}`}
>
{isEmpty
? 'Перетащите проект сюда'
: `Можно добавить еще ${maxItems - currentCount} проект${maxItems - currentCount > 1 ? 'а' : ''}`}
</div>
)
}
// Компонент для слота приоритета
function PrioritySlot({ title, projects, allProjects, onRemove, maxItems = null, containerId }) {
return (
<div className="mb-6">
<div className="text-sm font-semibold text-gray-600 mb-2">{title}</div>
<div className="space-y-2 min-h-[60px]">
{projects.length === 0 && (
<DroppableSlot containerId={containerId} isEmpty={true} maxItems={maxItems} currentCount={0} />
)}
{projects.map((project, index) => (
<SortableProjectItem
key={project.name}
project={project}
index={index}
allProjects={allProjects}
onRemove={onRemove}
/>
))}
</div>
</div>
)
}
function ProjectPriorityManager({ allProjectsData, currentWeekData, shouldLoad, onLoadingChange, onErrorChange, refreshTrigger, onNavigate }) {
const [projectsLoading, setProjectsLoading] = useState(false)
const [projectsError, setProjectsError] = useState(null)
const [hasDataCache, setHasDataCache] = useState(false) // Отслеживаем наличие кеша
// Уведомляем родительский компонент об изменении состояния загрузки
useEffect(() => {
if (onLoadingChange) {
onLoadingChange(projectsLoading)
}
}, [projectsLoading, onLoadingChange])
// Уведомляем родительский компонент об изменении ошибок
useEffect(() => {
if (onErrorChange) {
onErrorChange(projectsError)
}
}, [projectsError, onErrorChange])
const [allProjects, setAllProjects] = useState([])
const [maxPriority, setMaxPriority] = useState([])
const [mediumPriority, setMediumPriority] = useState([])
const [lowPriority, setLowPriority] = useState([])
const [activeId, setActiveId] = useState(null)
const scrollContainerRef = useRef(null)
const hasFetchedRef = useRef(false)
const skipNextEffectRef = useRef(false)
const lastRefreshTriggerRef = useRef(0) // Отслеживаем последний обработанный refreshTrigger
const isLoadingRef = useRef(false) // Отслеживаем, идет ли сейчас загрузка
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 10, // Активация только после перемещения на 10px
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
// Получаем резервный список проектов из уже загруженных данных,
// если API для приоритетов недоступен.
const getFallbackProjects = useCallback(() => {
return getAllProjectsSorted(allProjectsData, currentWeekData)
}, [allProjectsData, currentWeekData])
const normalizeProjects = useCallback((projectsArray) => {
const normalizedProjects = projectsArray
.map(item => {
const name = item?.name || item?.project || item?.project_name || item?.title
if (!name) return null
const id = item?.project_id ?? item?.id ?? item?.projectId ?? null
const priorityValue = item?.priority ?? item?.priority_value ?? item?.priority_level ?? null
return { id, name, priority: priorityValue }
})
.filter(Boolean)
const uniqueProjects = []
const seenNames = new Set()
normalizedProjects.forEach(item => {
const key = item.id ?? item.name
if (!seenNames.has(key)) {
seenNames.add(key)
uniqueProjects.push(item)
}
})
const max = []
const medium = []
const low = []
uniqueProjects.forEach(item => {
const projectEntry = { name: item.name, id: item.id }
if (item.priority === 1) {
max.push(projectEntry)
} else if (item.priority === 2) {
medium.push(projectEntry)
} else {
low.push(projectEntry)
}
})
return {
uniqueProjects,
max,
medium,
low,
}
}, [])
const applyProjects = useCallback((projectsArray) => {
const { uniqueProjects, max, medium, low } = normalizeProjects(projectsArray)
setAllProjects(uniqueProjects.map(item => item.name))
setMaxPriority(max)
setMediumPriority(medium)
setLowPriority(low)
}, [normalizeProjects])
const fetchProjects = useCallback(async (isBackground = false) => {
// Предотвращаем параллельные загрузки
if (isLoadingRef.current) {
return
}
try {
isLoadingRef.current = true
// Показываем загрузку только если это не фоновая загрузка
if (!isBackground) {
setProjectsLoading(true)
}
setProjectsError(null)
const response = await fetch(PROJECTS_API_URL)
if (!response.ok) {
throw new Error('Не удалось загрузить проекты')
}
const jsonData = await response.json()
const projectsArray = Array.isArray(jsonData)
? jsonData
: Array.isArray(jsonData?.projects)
? jsonData.projects
: Array.isArray(jsonData?.data)
? jsonData.data
: []
applyProjects(projectsArray)
setHasDataCache(true) // Отмечаем, что данные загружены
} catch (error) {
console.error('Ошибка загрузки проектов:', error)
setProjectsError(error.message || 'Ошибка загрузки проектов')
const fallbackProjects = getFallbackProjects()
if (fallbackProjects.length > 0) {
setAllProjects(fallbackProjects)
setMaxPriority([])
setMediumPriority([])
setLowPriority(fallbackProjects.map(name => ({ name })))
setHasDataCache(true) // Отмечаем, что есть fallback данные
}
} finally {
isLoadingRef.current = false
setProjectsLoading(false)
}
}, [applyProjects, getFallbackProjects])
useEffect(() => {
// Если таб не должен загружаться, не делаем ничего
if (!shouldLoad) {
// Сбрасываем флаг загрузки, если таб стал неактивным
if (hasFetchedRef.current) {
hasFetchedRef.current = false
}
return
}
// Если загрузка уже идет, не запускаем еще одну
if (isLoadingRef.current) {
return
}
// Если refreshTrigger равен 0 и мы еще не загружали - ждем, пока триггер не будет установлен
// Это предотвращает загрузку при первом монтировании, когда shouldLoad становится true,
// но refreshTrigger еще не установлен
if (refreshTrigger === 0 && !hasFetchedRef.current) {
return
}
// Проверяем, был ли этот refreshTrigger уже обработан
if (refreshTrigger === lastRefreshTriggerRef.current && hasFetchedRef.current) {
return // Уже обработали этот триггер
}
// Если уже загружали и нет нового триггера обновления - не загружаем снова
if (hasFetchedRef.current && refreshTrigger === lastRefreshTriggerRef.current) return
// Определяем, есть ли кеш (данные уже загружены)
const hasCache = hasDataCache && (maxPriority.length > 0 || mediumPriority.length > 0 || lowPriority.length > 0)
// Отмечаем, что обрабатываем этот триггер ПЕРЕД загрузкой
lastRefreshTriggerRef.current = refreshTrigger
if (refreshTrigger > 0) {
// Если есть триггер обновления, сбрасываем флаг загрузки
hasFetchedRef.current = false
}
// Устанавливаем флаг загрузки перед вызовом
hasFetchedRef.current = true
// Загружаем: если есть кеш - фоново, если нет - с индикатором
fetchProjects(hasCache)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetchProjects, shouldLoad, refreshTrigger]) // hasDataCache и длины массивов проверяются внутри эффекта, не добавляем в зависимости
const buildAssignments = useCallback(() => {
const map = new Map()
maxPriority.forEach(p => {
map.set(p.id ?? p.name, { id: p.id, priority: 1 })
})
mediumPriority.forEach(p => {
map.set(p.id ?? p.name, { id: p.id, priority: 2 })
})
lowPriority.forEach(p => {
map.set(p.id ?? p.name, { id: p.id, priority: null })
})
return map
}, [lowPriority, maxPriority, mediumPriority])
const prevAssignmentsRef = useRef(new Map())
const initializedAssignmentsRef = useRef(false)
const sendPriorityChanges = useCallback(async (changes) => {
if (!changes.length) return
try {
await fetch(PRIORITY_UPDATE_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(changes),
})
} catch (e) {
console.error('Ошибка отправки изменений приоритета', e)
}
}, [])
useEffect(() => {
const current = buildAssignments()
if (!initializedAssignmentsRef.current) {
prevAssignmentsRef.current = current
initializedAssignmentsRef.current = true
return
}
if (skipNextEffectRef.current) {
skipNextEffectRef.current = false
prevAssignmentsRef.current = current
return
}
const prev = prevAssignmentsRef.current
const allKeys = new Set([...prev.keys(), ...current.keys()])
const changes = []
allKeys.forEach(key => {
const prevItem = prev.get(key)
const currItem = current.get(key)
const prevPriority = prevItem?.priority ?? null
const currPriority = currItem?.priority ?? null
const id = currItem?.id ?? prevItem?.id
if (!id) return
if (prevPriority !== currPriority) {
changes.push({ id, priority: currPriority })
}
})
if (changes.length) {
sendPriorityChanges(changes)
}
prevAssignmentsRef.current = current
}, [buildAssignments, sendPriorityChanges])
const findProjectContainer = (projectName) => {
if (maxPriority.find(p => p.name === projectName)) return 'max'
if (mediumPriority.find(p => p.name === projectName)) return 'medium'
if (lowPriority.find(p => p.name === projectName)) return 'low'
return null
}
const isValidContainer = (containerId) => {
return containerId === 'max' || containerId === 'medium' || containerId === 'low'
}
const handleDragStart = (event) => {
setActiveId(event.active.id)
// Находим скроллируемый контейнер и отключаем его скролл
if (!scrollContainerRef.current) {
// Ищем родительский скроллируемый контейнер через DOM
const activeElement = document.querySelector(`[data-id="${event.active.id}"]`)
if (activeElement) {
const container = activeElement.closest('.overflow-y-auto')
if (container) {
scrollContainerRef.current = container
container.style.overflow = 'hidden'
}
}
} else {
scrollContainerRef.current.style.overflow = 'hidden'
}
}
const handleDragCancel = () => {
setActiveId(null)
// Включаем скролл обратно
if (scrollContainerRef.current) {
scrollContainerRef.current.style.overflow = 'auto'
scrollContainerRef.current = null
}
}
const handleDragEnd = (event) => {
const { active, over } = event
setActiveId(null)
// Включаем скролл обратно
if (scrollContainerRef.current) {
scrollContainerRef.current.style.overflow = 'auto'
scrollContainerRef.current = null
}
if (!over) return
const activeId = active.id
const overId = over.id
const activeContainer = findProjectContainer(activeId)
// Проверяем, является ли overId контейнером или проектом
let overContainer = findProjectContainer(overId)
if (!overContainer && isValidContainer(overId)) {
overContainer = overId
}
if (!activeContainer) return
if (!overContainer) return
// Если перетаскиваем в тот же контейнер
if (activeContainer === overContainer) {
let items
let setItems
if (activeContainer === 'max') {
items = maxPriority
setItems = setMaxPriority
} else if (activeContainer === 'medium') {
items = mediumPriority
setItems = setMediumPriority
} else {
items = lowPriority
setItems = setLowPriority
}
const oldIndex = items.findIndex(p => p.name === activeId)
const newIndex = items.findIndex(p => p.name === overId)
if (oldIndex !== -1 && newIndex !== -1) {
setItems(arrayMove(items, oldIndex, newIndex))
}
return
}
// Перемещаем между контейнерами
const activeProject = [
...maxPriority,
...mediumPriority,
...lowPriority,
].find(p => p.name === activeId)
if (!activeProject) return
// Если контейнеры одинаковые, ничего не делаем (уже обработано выше)
if (activeContainer === overContainer) return
// Удаляем из старого контейнера
if (activeContainer === 'max') {
setMaxPriority(prev => prev.filter(p => p.name !== activeId))
} else if (activeContainer === 'medium') {
setMediumPriority(prev => prev.filter(p => p.name !== activeId))
} else {
setLowPriority(prev => prev.filter(p => p.name !== activeId))
}
// Добавляем в новый контейнер
if (overContainer === 'max') {
// Если контейнер max уже заполнен, заменяем первый элемент
if (maxPriority.length >= 1) {
const oldProject = maxPriority[0]
setMaxPriority([activeProject])
// Старый проект перемещаем в low
setLowPriority(prev => [...prev, oldProject])
} else {
// Контейнер пустой, просто добавляем
setMaxPriority([activeProject])
}
} else if (overContainer === 'medium') {
// Если контейнер medium уже заполнен (2 элемента)
if (mediumPriority.length >= 2) {
// Если перетаскиваем на конкретный проект, заменяем его
const overIndex = mediumPriority.findIndex(p => p.name === overId)
if (overIndex !== -1) {
const oldProject = mediumPriority[overIndex]
const newItems = [...mediumPriority]
newItems[overIndex] = activeProject
setMediumPriority(newItems)
setLowPriority(prev => [...prev, oldProject])
} else {
// Перетаскиваем на пустой слот, заменяем последний
const oldProject = mediumPriority[mediumPriority.length - 1]
setMediumPriority([mediumPriority[0], activeProject])
setLowPriority(prev => [...prev, oldProject])
}
} else {
// Есть место, добавляем в нужную позицию или в конец
const overIndex = mediumPriority.findIndex(p => p.name === overId)
if (overIndex !== -1) {
const newItems = [...mediumPriority]
newItems.splice(overIndex, 0, activeProject)
setMediumPriority(newItems)
} else {
// Перетаскиваем на пустой слот, добавляем в конец
setMediumPriority([...mediumPriority, activeProject])
}
}
} else {
// Для low priority просто добавляем
const overIndex = lowPriority.findIndex(p => p.name === overId)
if (overIndex !== -1) {
// Перетаскиваем на конкретный проект
const newItems = [...lowPriority]
newItems.splice(overIndex, 0, activeProject)
setLowPriority(newItems)
} else {
// Перетаскиваем на пустой слот, добавляем в конец
setLowPriority([...lowPriority, activeProject])
}
}
}
const getProjectKey = (project) => project?.id ?? project?.name
const moveProjectToLow = (project) => {
const projectKey = getProjectKey(project)
if (!projectKey) return
setLowPriority(prev => {
const filtered = prev.filter(p => getProjectKey(p) !== projectKey)
return [...filtered, project]
})
}
const handleRemove = (projectName, container) => {
const sourceList =
container === 'max'
? maxPriority
: container === 'medium'
? mediumPriority
: lowPriority
const project = sourceList.find(p => p.name === projectName)
if (!project) return
const projectId = project.id ?? project.name
if (projectId) {
skipNextEffectRef.current = true
sendPriorityChanges([{ id: projectId, priority: null }])
}
if (container === 'max') {
setMaxPriority(prev => prev.filter(p => p.name !== projectName))
} else if (container === 'medium') {
setMediumPriority(prev => prev.filter(p => p.name !== projectName))
}
moveProjectToLow(project)
}
const allItems = [
...maxPriority.map(p => ({ ...p, container: 'max' })),
...mediumPriority.map(p => ({ ...p, container: 'medium' })),
...lowPriority.map(p => ({ ...p, container: 'low' })),
]
const activeProject = allItems.find(item => item.name === activeId)
return (
<div className="max-w-4xl mx-auto">
{onNavigate && (
<div className="flex justify-end mb-4">
<button
onClick={() => onNavigate('current')}
className="flex items-center justify-center w-10 h-10 rounded-full bg-white hover:bg-gray-100 text-gray-600 hover:text-gray-800 border border-gray-200 hover:border-gray-300 transition-all duration-200 shadow-sm hover:shadow-md"
title="Закрыть"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
)}
{projectsError && (!maxPriority.length && !mediumPriority.length && !lowPriority.length) && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700 shadow-sm">
<div className="font-semibold">Не удалось загрузить проекты</div>
<div className="mt-2 flex flex-wrap items-center justify-between gap-3">
<span className="text-red-600">{projectsError}</span>
<button
onClick={() => fetchProjects()}
className="rounded-md bg-red-600 px-3 py-1 text-white shadow hover:bg-red-700 transition"
>
Повторить
</button>
</div>
</div>
)}
{projectsLoading && (!maxPriority.length && !mediumPriority.length && !lowPriority.length) ? (
<div className="rounded-lg border border-gray-200 bg-white p-4 text-center text-gray-600 shadow-sm">
Загружаем проекты...
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<div className="space-y-6">
<SortableContext items={maxPriority.map(p => p.name)} strategy={verticalListSortingStrategy}>
<PrioritySlot
title="Максимальный приоритет (1 проект)"
projects={maxPriority}
allProjects={allProjects}
onRemove={(name) => handleRemove(name, 'max')}
maxItems={1}
containerId="max"
/>
</SortableContext>
<SortableContext items={mediumPriority.map(p => p.name)} strategy={verticalListSortingStrategy}>
<PrioritySlot
title="Средний приоритет (2 проекта)"
projects={mediumPriority}
allProjects={allProjects}
onRemove={(name) => handleRemove(name, 'medium')}
maxItems={2}
containerId="medium"
/>
</SortableContext>
<SortableContext items={lowPriority.map(p => p.name)} strategy={verticalListSortingStrategy}>
<PrioritySlot
title="Остальные проекты"
projects={lowPriority}
allProjects={allProjects}
containerId="low"
/>
</SortableContext>
{!maxPriority.length && !mediumPriority.length && !lowPriority.length && (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 text-center text-gray-600">
Проекты не найдены
</div>
)}
</div>
{typeof document !== 'undefined'
? createPortal(
<DragOverlay>
{activeProject ? (
<div className="bg-white rounded-lg p-3 border-2 border-indigo-400 shadow-lg opacity-90">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: getProjectColor(activeProject.name, allProjects) }}
></div>
<span className="font-semibold text-gray-800">{activeProject.name}</span>
</div>
</div>
) : null}
</DragOverlay>,
document.body
)
: null}
</DndContext>
)}
</div>
)
}
export default ProjectPriorityManager

View File

@@ -0,0 +1,158 @@
function ProjectProgressBar({ projectName, totalScore, minGoalScore, maxGoalScore, onProjectClick, projectColor, priority }) {
// Вычисляем максимальное значение для шкалы (берем максимум из maxGoalScore и totalScore + 20%)
const maxScale = Math.max(maxGoalScore, totalScore * 1.2, 1)
// Процентные значения
const totalScorePercent = (totalScore / maxScale) * 100
const minGoalPercent = (minGoalScore / maxScale) * 100
const maxGoalPercent = (maxGoalScore / maxScale) * 100
const goalRangePercent = maxGoalPercent - minGoalPercent
const normalizedPriority = (() => {
if (priority === null || priority === undefined) return null
const numeric = Number(priority)
return Number.isFinite(numeric) ? numeric : null
})()
const priorityBonus = (() => {
if (normalizedPriority === 1) return 50
if (normalizedPriority === 2) return 35
return 20
})()
const goalProgress = (() => {
const safeTotal = Number.isFinite(totalScore) ? totalScore : 0
const safeMinGoal = Number.isFinite(minGoalScore) ? minGoalScore : 0
const safeMaxGoal = Number.isFinite(maxGoalScore) ? maxGoalScore : 0
// Если нет валидного minGoal, возвращаем прогресс относительно maxGoal либо 0
if (safeMinGoal <= 0) {
if (safeMaxGoal > 0) {
return Math.max(0, Math.min((safeTotal / safeMaxGoal) * 100, 100))
}
return 0
}
// До достижения minGoal растем линейно от 0 до 100%
const baseProgress = Math.max(0, Math.min((safeTotal / safeMinGoal) * 100, 100))
// Если maxGoal не задан корректно или еще не достигнут minGoal, показываем базовый прогресс
if (safeTotal < safeMinGoal || safeMaxGoal <= safeMinGoal) {
return baseProgress
}
// Между minGoal и maxGoal добавляем бонус в зависимости от приоритета
const extraRange = safeMaxGoal - safeMinGoal
const extraRatio = Math.min(1, Math.max(0, (safeTotal - safeMinGoal) / extraRange))
const extraProgress = extraRatio * priorityBonus
// Выше maxGoal прогресс не растет
return Math.min(100 + priorityBonus, 100 + extraProgress)
})()
const isGoalReached = totalScore >= minGoalScore
const isGoalExceeded = totalScore >= maxGoalScore
const priorityBorderStyle =
normalizedPriority === 1
? { borderColor: '#d4af37' }
: normalizedPriority === 2
? { borderColor: '#c0c0c0' }
: {}
const cardBorderClasses =
normalizedPriority === 1 || normalizedPriority === 2
? 'border-2'
: 'border border-gray-200/50 hover:border-indigo-300'
const cardBaseClasses =
'bg-gradient-to-br from-white to-gray-50 rounded-lg p-3 shadow-sm hover:shadow-md transition-all duration-300 cursor-pointer'
const handleClick = () => {
if (onProjectClick) {
onProjectClick(projectName)
}
}
return (
<div
onClick={handleClick}
className={`${cardBaseClasses} ${cardBorderClasses}`}
style={priorityBorderStyle}
>
<div className="flex justify-between items-center mb-2">
<div className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: projectColor }}
></div>
<h3 className="text-lg font-semibold text-gray-800">{projectName}</h3>
</div>
<div className="text-right">
<div className="text-lg font-bold bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent">
{totalScore.toFixed(1)}
</div>
<div className="text-xs text-gray-500">
из {minGoalScore.toFixed(1)} ({goalProgress.toFixed(0)}%)
</div>
</div>
</div>
<div className="relative h-6 bg-gray-200 rounded-full overflow-hidden shadow-inner">
{/* Диапазон цели (min_goal_score до max_goal_score) */}
{minGoalScore > 0 && maxGoalScore > 0 && (
<div
className="absolute h-full bg-gradient-to-r from-amber-200 via-yellow-300 to-amber-200 opacity-70 border-l border-r border-amber-400"
style={{
left: `${minGoalPercent}%`,
width: `${goalRangePercent}%`,
}}
title={`Цель: ${minGoalScore.toFixed(2)} - ${maxGoalScore.toFixed(2)}`}
/>
)}
{/* Текущее значение (total_score) */}
<div
className={`absolute h-full transition-all duration-700 ease-out ${
isGoalExceeded
? 'bg-gradient-to-r from-green-500 to-emerald-500'
: isGoalReached
? 'bg-gradient-to-r from-yellow-500 to-amber-500'
: 'bg-gradient-to-r from-indigo-500 to-purple-500'
} shadow-sm`}
style={{
width: `${totalScorePercent}%`,
}}
>
<div className="absolute inset-0 bg-white/20 animate-pulse"></div>
</div>
{/* Индикатор текущего значения */}
{totalScorePercent > 0 && (
<div
className="absolute top-0 h-full w-0.5 bg-white shadow-md"
style={{
left: `${totalScorePercent}%`,
}}
/>
)}
</div>
<div className="flex justify-between items-center text-xs mt-1.5">
<span className="text-gray-400">0</span>
{minGoalScore > 0 && (
<div className="flex items-center gap-1.5">
<div className="w-1.5 h-1.5 rounded-full bg-amber-400"></div>
<span className="text-amber-700 font-medium text-xs">
Цель: {minGoalScore.toFixed(1)} - {maxGoalScore.toFixed(1)}
</span>
</div>
)}
<span className="text-gray-400">{maxScale.toFixed(1)}</span>
</div>
</div>
)
}
export default ProjectProgressBar

View File

@@ -0,0 +1,347 @@
.config-selection {
padding-top: 0;
padding-left: 1rem;
padding-right: 1rem;
}
@media (min-width: 768px) {
.config-selection {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
}
.add-config-button {
background: transparent;
border: 2px dashed #3498db;
border-radius: 12px;
padding: 1.5rem 1rem;
transition: transform 0.2s, box-shadow 0.2s, background-color 0.2s;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
cursor: pointer;
min-height: 180px;
position: relative;
}
.add-config-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.3);
background-color: rgba(52, 152, 219, 0.05);
border-color: #2980b9;
}
.add-config-icon {
font-size: 3rem;
font-weight: bold;
color: #3498db;
margin-bottom: auto;
display: flex;
align-items: center;
justify-content: center;
flex: 1;
line-height: 1;
}
.add-config-text {
font-size: 1rem;
font-weight: 500;
color: #3498db;
text-align: center;
margin-top: auto;
padding-top: 0.5rem;
}
.loading, .error-message {
text-align: center;
padding: 2rem;
color: #666;
}
.error-message {
color: #e74c3c;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 4px;
}
.empty-state {
text-align: center;
padding: 3rem;
color: #666;
}
.configs-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
}
.config-card {
background: #3498db;
border-radius: 12px;
padding: 1.5rem 1rem;
transition: transform 0.2s, box-shadow 0.2s;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
cursor: pointer;
min-height: 180px;
position: relative;
}
.card-menu-button {
position: absolute;
top: 0.5rem;
right: 0;
background: transparent;
border: none;
border-radius: 6px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 1.75rem;
color: white;
font-weight: bold;
transition: all 0.2s;
z-index: 10;
padding: 0;
line-height: 1;
}
.card-menu-button:hover {
opacity: 0.8;
transform: scale(1.1);
}
.config-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(52, 152, 219, 0.4);
}
.config-words-count {
font-size: 2.5rem;
font-weight: bold;
color: white;
margin-bottom: auto;
display: flex;
align-items: center;
justify-content: center;
flex: 1;
}
.config-max-cards {
font-size: 1.5rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
margin-top: -1rem;
margin-bottom: auto;
}
.config-name {
font-size: 1rem;
font-weight: 500;
color: white;
text-align: center;
margin-top: auto;
padding-top: 0.5rem;
}
.config-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.config-modal {
background: white;
border-radius: 12px;
padding: 0;
max-width: 400px;
width: 90%;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
animation: modalSlideIn 0.2s ease-out;
}
@keyframes modalSlideIn {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.config-modal-header {
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem 1.5rem 0.5rem 1.5rem;
position: relative;
}
.config-modal-header h3 {
margin: 0;
color: #2c3e50;
font-size: 1.75rem;
text-align: center;
}
.config-modal-close:hover {
background-color: #f0f0f0;
}
.config-modal-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.5rem 1.5rem 1.5rem 1.5rem;
}
.config-modal-edit,
.config-modal-delete {
width: 100%;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.config-modal-edit {
background-color: #3498db;
color: white;
}
.config-modal-edit:hover {
background-color: #2980b9;
transform: translateY(-1px);
}
.config-modal-delete {
background-color: #e74c3c;
color: white;
}
.config-modal-delete:hover {
background-color: #c0392b;
transform: translateY(-1px);
}
.section-divider {
margin: 0.5rem 0 1rem 0;
padding-bottom: 0.75rem;
border-bottom: 2px solid #e0e0e0;
}
.section-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #2c3e50;
}
.dictionaries-section {
margin-top: 2rem;
}
.dictionary-card {
background: white;
border: 1px solid #ddd;
border-radius: 12px;
padding: 1.5rem 1rem;
transition: transform 0.2s, box-shadow 0.2s;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
cursor: pointer;
min-height: 180px;
position: relative;
}
.dictionary-card .card-menu-button {
background: transparent;
color: #2c3e50;
}
.dictionary-card .card-menu-button:hover {
opacity: 0.7;
}
.dictionary-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.dictionary-words-count {
font-size: 2.5rem;
font-weight: bold;
color: #2c3e50;
margin-bottom: auto;
display: flex;
align-items: center;
justify-content: center;
flex: 1;
}
.dictionary-name {
font-size: 1rem;
font-weight: 500;
color: #2c3e50;
text-align: center;
margin-top: auto;
padding-top: 0.5rem;
}
.add-dictionary-button {
background: transparent;
border: 2px dashed #2c3e50;
border-radius: 12px;
padding: 1.5rem 1rem;
transition: transform 0.2s, box-shadow 0.2s, background-color 0.2s;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
cursor: pointer;
min-height: 180px;
position: relative;
}
.add-dictionary-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(44, 62, 80, 0.2);
background-color: rgba(44, 62, 80, 0.05);
border-color: #1a252f;
}
.add-dictionary-button .add-config-icon {
color: #000000;
}
.add-dictionary-button .add-config-text {
color: #000000;
}

View File

@@ -0,0 +1,278 @@
import React, { useState, useEffect, useRef } from 'react'
import './TestConfigSelection.css'
const API_URL = '/api'
function TestConfigSelection({ onNavigate, refreshTrigger = 0 }) {
const [configs, setConfigs] = useState([])
const [dictionaries, setDictionaries] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [selectedConfig, setSelectedConfig] = useState(null)
const [selectedDictionary, setSelectedDictionary] = useState(null)
const [longPressTimer, setLongPressTimer] = useState(null)
const isInitializedRef = useRef(false)
const configsRef = useRef([])
const dictionariesRef = useRef([])
// Обновляем ref при изменении состояния
useEffect(() => {
configsRef.current = configs
}, [configs])
useEffect(() => {
dictionariesRef.current = dictionaries
}, [dictionaries])
useEffect(() => {
fetchTestConfigsAndDictionaries()
}, [refreshTrigger])
const fetchTestConfigsAndDictionaries = async () => {
try {
// Показываем загрузку только при первой инициализации или если нет данных для отображения
const isFirstLoad = !isInitializedRef.current
const hasData = !isFirstLoad && (configsRef.current.length > 0 || dictionariesRef.current.length > 0)
if (!hasData) {
setLoading(true)
}
const response = await fetch(`${API_URL}/test-configs-and-dictionaries`)
if (!response.ok) {
throw new Error('Ошибка при загрузке конфигураций и словарей')
}
const data = await response.json()
setConfigs(Array.isArray(data.configs) ? data.configs : [])
setDictionaries(Array.isArray(data.dictionaries) ? data.dictionaries : [])
setError('')
isInitializedRef.current = true
} catch (err) {
setError(err.message)
setConfigs([])
setDictionaries([])
isInitializedRef.current = true
} finally {
setLoading(false)
}
}
const handleConfigSelect = (config) => {
onNavigate?.('test', {
wordCount: config.words_count,
configId: config.id,
maxCards: config.max_cards || null
})
}
const handleDictionarySelect = (dict) => {
// For now, navigate to words list
// In the future, we might want to filter by dictionary_id
onNavigate?.('words', { dictionaryId: dict.id })
}
const handleConfigMenuClick = (config, e) => {
e.stopPropagation()
setSelectedConfig(config)
}
const handleDictionaryMenuClick = (dict, e) => {
e.stopPropagation()
setSelectedDictionary(dict)
}
const handleEdit = () => {
if (selectedConfig) {
onNavigate?.('add-config', { config: selectedConfig })
setSelectedConfig(null)
}
}
const handleDictionaryDelete = async () => {
if (!selectedDictionary) return
try {
const response = await fetch(`${API_URL}/dictionaries/${selectedDictionary.id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
const errorText = await response.text()
console.error('Delete error:', response.status, errorText)
throw new Error(`Ошибка при удалении словаря: ${response.status}`)
}
setSelectedDictionary(null)
// Refresh dictionaries list
await fetchTestConfigsAndDictionaries()
} catch (err) {
console.error('Delete failed:', err)
setError(err.message)
setSelectedDictionary(null)
}
}
const handleDelete = async () => {
if (!selectedConfig) return
try {
const response = await fetch(`${API_URL}/configs/${selectedConfig.id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
const errorText = await response.text()
console.error('Delete error:', response.status, errorText)
throw new Error(`Ошибка при удалении конфигурации: ${response.status}`)
}
setSelectedConfig(null)
// Refresh configs and dictionaries list
await fetchTestConfigsAndDictionaries()
} catch (err) {
console.error('Delete failed:', err)
setError(err.message)
setSelectedConfig(null)
}
}
const closeModal = () => {
setSelectedConfig(null)
}
// Показываем загрузку только при первой инициализации и если нет данных для отображения
const shouldShowLoading = loading && !isInitializedRef.current && configs.length === 0 && dictionaries.length === 0
if (shouldShowLoading) {
return (
<div className="config-selection">
<div className="loading">Загрузка...</div>
</div>
)
}
if (error) {
return (
<div className="config-selection">
<div className="error-message">{error}</div>
</div>
)
}
return (
<div className="config-selection">
{/* Секция тестов */}
<div className="section-divider">
<h2 className="section-title">Тесты</h2>
</div>
<div className="configs-grid">
{configs.map((config) => (
<div
key={config.id}
className="config-card"
onClick={() => handleConfigSelect(config)}
>
<button
onClick={(e) => handleConfigMenuClick(config, e)}
className="card-menu-button"
title="Меню"
>
</button>
<div className="config-words-count">
{config.words_count}
</div>
{config.max_cards && (
<div className="config-max-cards">
{config.max_cards}
</div>
)}
<div className="config-name">{config.name}</div>
</div>
))}
<button onClick={() => onNavigate?.('add-config')} className="add-config-button">
<div className="add-config-icon">+</div>
<div className="add-config-text">Добавить</div>
</button>
</div>
{/* Секция словарей */}
<div className="dictionaries-section">
<div className="section-divider">
<h2 className="section-title">Словари</h2>
</div>
<div className="configs-grid">
{dictionaries.map((dict) => (
<div
key={dict.id}
className="dictionary-card"
onClick={() => handleDictionarySelect(dict)}
>
<button
onClick={(e) => handleDictionaryMenuClick(dict, e)}
className="card-menu-button"
title="Меню"
>
</button>
<div className="dictionary-words-count">
{dict.wordsCount}
</div>
<div className="dictionary-name">{dict.name}</div>
</div>
))}
<button
onClick={() => onNavigate?.('words', { isNewDictionary: true })}
className="add-dictionary-button"
>
<div className="add-config-icon">+</div>
<div className="add-config-text">Добавить</div>
</button>
</div>
</div>
{selectedConfig && (
<div className="config-modal-overlay" onClick={closeModal}>
<div className="config-modal" onClick={(e) => e.stopPropagation()}>
<div className="config-modal-header">
<h3>{selectedConfig.name}</h3>
</div>
<div className="config-modal-actions">
<button className="config-modal-edit" onClick={handleEdit}>
Редактировать
</button>
<button className="config-modal-delete" onClick={handleDelete}>
Удалить
</button>
</div>
</div>
</div>
)}
{selectedDictionary && (
<div className="config-modal-overlay" onClick={() => setSelectedDictionary(null)}>
<div className="config-modal" onClick={(e) => e.stopPropagation()}>
<div className="config-modal-header">
<h3>{selectedDictionary.name}</h3>
</div>
<div className="config-modal-actions">
<button className="config-modal-delete" onClick={handleDictionaryDelete}>
Удалить
</button>
</div>
</div>
</div>
)}
</div>
)
}
export default TestConfigSelection

View File

@@ -0,0 +1,485 @@
.test-container {
min-height: 400px;
}
.test-container-fullscreen {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #f5f5f5;
z-index: 1500;
overflow: hidden;
padding: 0;
display: flex;
flex-direction: column;
}
.test-close-x-button {
position: fixed;
top: 1rem;
right: 1rem;
background: rgba(255, 255, 255, 0.9);
border: none;
font-size: 1.5rem;
color: #7f8c8d;
cursor: pointer;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s, color 0.2s;
z-index: 1600;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.test-close-x-button:hover {
background-color: #ffffff;
color: #2c3e50;
}
.test-duration-selection {
text-align: center;
}
.test-duration-selection h2 {
color: #2c3e50;
margin-bottom: 2rem;
}
.test-error {
background-color: #f8d7da;
color: #721c24;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.duration-buttons {
display: flex;
flex-direction: column;
gap: 1rem;
}
.duration-button {
background-color: #3498db;
color: white;
border: none;
padding: 1.5rem;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
text-align: center;
}
.duration-button:hover:not(:disabled) {
background-color: #2980b9;
transform: translateY(-2px);
}
.duration-button:disabled {
background-color: #bdc3c7;
cursor: not-allowed;
}
.duration-label {
font-size: 1.25rem;
font-weight: bold;
margin-bottom: 0.5rem;
}
.duration-count {
font-size: 0.9rem;
opacity: 0.9;
}
.test-loading {
margin-top: 2rem;
text-align: center;
color: #666;
}
.test-screen {
min-height: 100vh;
padding-top: 3rem;
}
.test-progress {
margin-top: 1rem;
text-align: center;
}
.progress-text {
font-size: 1.5rem;
color: #2c3e50;
font-weight: 500;
}
.test-card-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 300px;
perspective: 1000px;
}
.test-card {
width: 100%;
max-width: 400px;
height: 500px;
position: relative;
transform-style: preserve-3d;
transition: transform 0.6s;
cursor: pointer;
}
.test-card.flipped {
transform: rotateY(180deg);
}
.test-card-front,
.test-card-back {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
display: flex;
align-items: center;
justify-content: center;
}
.test-card-front {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.test-card-back {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
transform: rotateY(180deg);
}
.test-card-content {
padding: 2rem;
text-align: center;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: relative;
}
.test-word {
font-size: 3rem;
font-weight: bold;
margin-bottom: 0;
}
.test-translation {
font-size: 2.5rem;
margin-bottom: 0;
}
.test-card-hint {
font-size: 0.9rem;
opacity: 0.8;
margin-top: 2rem;
}
.test-card-actions {
display: flex;
gap: 1rem;
width: 100%;
justify-content: center;
margin-top: auto;
padding-top: 2rem;
position: absolute;
bottom: 2rem;
left: 0;
right: 0;
}
.test-action-button {
padding: 1.25rem 2.5rem;
border: none;
border-radius: 8px;
font-size: 1.25rem;
font-weight: bold;
cursor: pointer;
transition: transform 0.1s, opacity 0.2s;
min-width: 160px;
}
.test-action-button:hover {
transform: scale(1.05);
}
.test-action-button:active {
transform: scale(0.95);
}
.success-button {
background-color: #27ae60;
color: white;
}
.success-button:hover {
background-color: #229954;
}
.failure-button {
background-color: #e74c3c;
color: white;
}
.failure-button:hover {
background-color: #c0392b;
}
.test-results {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
padding: 0;
box-sizing: border-box;
position: relative;
overflow: hidden;
}
.results-stats {
flex: 1;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
overflow-y: auto;
overflow-x: hidden;
padding: 4rem 1rem 1rem 1rem;
box-sizing: border-box;
width: 100%;
min-height: 0;
}
.result-item {
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem;
background: #fafafa;
transition: transform 0.2s, box-shadow 0.2s;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1.5rem;
}
.result-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.result-item:last-child {
margin-bottom: 2rem;
}
.result-word-content {
flex: 1;
}
.result-word-header {
margin-bottom: 0.75rem;
}
.result-word {
font-size: 1.25rem;
font-weight: bold;
color: #2c3e50;
margin: 0;
}
.result-translation {
font-size: 1rem;
color: #34495e;
font-weight: 500;
margin-bottom: 0.5rem;
}
.result-stats {
display: flex;
align-items: center;
font-size: 1.5rem;
font-weight: bold;
white-space: nowrap;
}
.result-stat-success {
color: #3498db;
}
.result-stat-separator {
color: #7f8c8d;
margin: 0 0.25rem;
}
.result-stat-failure {
color: #e74c3c;
}
.test-preview {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
padding: 0;
box-sizing: border-box;
position: relative;
overflow: hidden;
}
.preview-stats {
flex: 1;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
align-items: start;
overflow-y: auto;
overflow-x: hidden;
padding: 4rem 1rem 1rem 1rem;
box-sizing: border-box;
width: 100%;
min-height: 0;
}
.preview-item {
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem;
background: #fafafa;
transition: transform 0.2s, box-shadow 0.2s;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1.5rem;
align-self: start;
}
.preview-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.preview-word-content {
flex: 1;
}
.preview-word-header {
margin-bottom: 0.75rem;
}
.preview-word {
font-size: 1.25rem;
font-weight: bold;
color: #2c3e50;
margin: 0;
}
.preview-translation {
font-size: 1rem;
color: #34495e;
font-weight: 500;
margin-bottom: 0.5rem;
}
.preview-stats-numbers {
display: flex;
align-items: center;
font-size: 1.5rem;
font-weight: bold;
white-space: nowrap;
}
.preview-stat-success {
color: #3498db;
}
.preview-stat-separator {
color: #7f8c8d;
margin: 0 0.25rem;
}
.preview-stat-failure {
color: #e74c3c;
}
.preview-actions {
display: flex;
justify-content: center;
padding: 1rem;
flex-shrink: 0;
background-color: #f5f5f5;
border-top: 1px solid #e0e0e0;
position: relative;
z-index: 10;
}
.test-start-button {
padding: 1.25rem 3rem;
background-color: #27ae60;
color: white;
border: none;
border-radius: 8px;
font-size: 1.5rem;
font-weight: bold;
cursor: pointer;
transition: transform 0.1s, background-color 0.2s;
min-width: 200px;
}
.test-start-button:hover {
background-color: #229954;
transform: scale(1.05);
}
.test-start-button:active {
transform: scale(0.95);
}
.results-actions {
display: flex;
justify-content: center;
padding: 1rem;
flex-shrink: 0;
background-color: #f5f5f5;
border-top: 1px solid #e0e0e0;
position: relative;
z-index: 10;
}
.test-finish-button {
padding: 1.25rem 3rem;
background-color: #3498db;
color: white;
border: none;
border-radius: 8px;
font-size: 1.5rem;
font-weight: bold;
cursor: pointer;
transition: transform 0.1s, background-color 0.2s;
min-width: 200px;
}
.test-finish-button:hover {
background-color: #2980b9;
transform: scale(1.05);
}
.test-finish-button:active {
transform: scale(0.95);
}

View File

@@ -0,0 +1,490 @@
import React, { useState, useEffect, useRef } from 'react'
import './TestWords.css'
const API_URL = '/api'
const DEFAULT_TEST_WORD_COUNT = 10
function TestWords({ onNavigate, wordCount: initialWordCount, configId: initialConfigId, maxCards: initialMaxCards }) {
const wordCount = initialWordCount || DEFAULT_TEST_WORD_COUNT
const configId = initialConfigId || null
const maxCards = initialMaxCards || null
const [words, setWords] = useState([]) // Начальный пул всех слов (для статистики)
const [testWords, setTestWords] = useState([]) // Пул слов для показа
const [currentIndex, setCurrentIndex] = useState(0)
const [flippedCards, setFlippedCards] = useState(new Set())
const [wordStats, setWordStats] = useState({}) // Локальная статистика
const [cardsShown, setCardsShown] = useState(0) // Левый счётчик: кол-во показанных карточек
const [totalAnswers, setTotalAnswers] = useState(0) // Кол-во полученных ответов
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [showPreview, setShowPreview] = useState(false) // Показывать ли экран предпросмотра
const [showResults, setShowResults] = useState(false) // Показывать ли экран результатов
const isFinishingRef = useRef(false)
const wordStatsRef = useRef({})
const processingRef = useRef(false)
// Загрузка слов при монтировании
useEffect(() => {
setWords([])
setTestWords([])
setCurrentIndex(0)
setFlippedCards(new Set())
setWordStats({})
wordStatsRef.current = {}
setCardsShown(0)
setTotalAnswers(0)
setError('')
setShowPreview(false) // Сбрасываем экран предпросмотра
setShowResults(false) // Сбрасываем экран результатов при загрузке нового теста
isFinishingRef.current = false
processingRef.current = false
setLoading(true)
const loadWords = async () => {
try {
if (configId === null) {
throw new Error('config_id обязателен для запуска теста')
}
const url = `${API_URL}/test/words?config_id=${configId}`
const response = await fetch(url)
if (!response.ok) {
throw new Error('Ошибка при загрузке слов')
}
const data = await response.json()
if (!Array.isArray(data) || data.length === 0) {
throw new Error('Недостаточно слов для теста')
}
// Инициализируем статистику из данных бэкенда
const stats = {}
data.forEach(word => {
stats[word.id] = {
success: word.success || 0,
failure: word.failure || 0,
lastSuccessAt: word.last_success_at || null,
lastFailureAt: word.last_failure_at || null
}
})
setWords(data)
// Формируем пул слов: каждое слово добавляется n раз, затем пул перемешивается
// n = max(1, floor(0.7 * maxCards / количество_слов))
const wordsCount = data.length
const cardsCount = maxCards !== null && maxCards > 0 ? maxCards : wordsCount
const n = Math.max(1, Math.floor(0.7 * cardsCount / wordsCount))
// Создаем пул, где каждое слово повторяется n раз
const wordPool = []
for (let i = 0; i < n; i++) {
wordPool.push(...data)
}
// Перемешиваем пул случайным образом
for (let i = wordPool.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[wordPool[i], wordPool[j]] = [wordPool[j], wordPool[i]]
}
setTestWords(wordPool)
setWordStats(stats)
wordStatsRef.current = stats
// Показываем экран предпросмотра
setShowPreview(true)
// Показываем первую карточку и увеличиваем левый счётчик (будет использовано после начала теста)
setCardsShown(0)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
loadWords()
}, [wordCount, configId])
const getCurrentWord = () => {
if (currentIndex < testWords.length && testWords.length > 0) {
return testWords[currentIndex]
}
return null
}
// Правый счётчик: кол-во полученных ответов + кол-во слов в пуле (не больше maxCards)
const getRightCounter = () => {
const total = totalAnswers + testWords.length
if (maxCards !== null && maxCards > 0) {
return Math.min(total, maxCards)
}
return total
}
const handleCardFlip = (wordId) => {
setFlippedCards(prev => new Set(prev).add(wordId))
}
// Завершение теста
const finishTest = async () => {
if (isFinishingRef.current) return
isFinishingRef.current = true
// Сразу показываем экран результатов, чтобы предотвратить показ новых карточек
setShowResults(true)
// Отправляем статистику на бэкенд
try {
// Получаем актуальные данные из состояния
const currentStats = wordStatsRef.current
// Отправляем все слова, которые были в тесте, с их текущими значениями
// Бэкенд сам обновит только измененные поля
const updates = words.map(word => {
const stats = currentStats[word.id] || {
success: word.success || 0,
failure: word.failure || 0,
lastSuccessAt: word.last_success_at || null,
lastFailureAt: word.last_failure_at || null
}
return {
id: word.id,
success: stats.success || 0,
failure: stats.failure || 0,
last_success_at: stats.lastSuccessAt || null,
last_failure_at: stats.lastFailureAt || null
}
})
if (updates.length === 0) {
console.log('No words to send - empty test')
return
}
const requestBody = { words: updates }
if (configId !== null) {
requestBody.config_id = configId
}
console.log('Sending test progress to backend:', {
wordsCount: updates.length,
configId: configId,
requestBody
})
const response = await fetch(`${API_URL}/test/progress`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Server responded with status ${response.status}: ${errorText}`)
}
const responseData = await response.json().catch(() => ({}))
console.log('Test progress saved successfully:', responseData)
} catch (err) {
console.error('Failed to save progress:', err)
// Можно показать уведомление пользователю, но не блокируем показ результатов
}
}
// Проверка условий завершения и показ следующей карточки
const showNextCardOrFinish = (newTestWords, currentCardsShown) => {
// Проверяем, не завершился ли тест (на случай если finishTest уже был вызван)
if (isFinishingRef.current || showResults) {
return
}
// Условие 1: Достигли максимума карточек
if (maxCards !== null && maxCards > 0 && currentCardsShown >= maxCards) {
finishTest()
return
}
// Условие 2: Пул слов пуст
if (newTestWords.length === 0) {
finishTest()
return
}
// Показываем следующую карточку и увеличиваем левый счётчик
// Но сначала проверяем, не достигнем ли мы максимума после увеличения
const nextCardsShown = currentCardsShown + 1
if (maxCards !== null && maxCards > 0 && nextCardsShown > maxCards) {
finishTest()
return
}
setCardsShown(nextCardsShown)
}
const handleSuccess = (wordId) => {
if (processingRef.current || isFinishingRef.current || showResults) return
processingRef.current = true
const word = words.find(w => w.id === wordId)
if (!word) {
processingRef.current = false
return
}
const now = new Date().toISOString()
// Обновляем статистику: success + 1, lastSuccessAt = now
const currentWordStats = wordStatsRef.current[wordId] || { success: 0, failure: 0 }
const updatedStats = {
...wordStatsRef.current,
[wordId]: {
success: (currentWordStats.success || 0) + 1,
failure: currentWordStats.failure || 0,
lastSuccessAt: now,
lastFailureAt: currentWordStats.lastFailureAt || null
}
}
wordStatsRef.current = updatedStats
setWordStats(updatedStats)
// Увеличиваем счётчик ответов
const newTotalAnswers = totalAnswers + 1
setTotalAnswers(newTotalAnswers)
// Убираем только один экземпляр слова из пула (по текущему индексу)
const newTestWords = [...testWords]
newTestWords.splice(currentIndex, 1)
// Обновляем индекс: если удалили последний элемент, переходим к предыдущему
let newIndex = currentIndex
if (newTestWords.length === 0) {
newIndex = 0
} else if (currentIndex >= newTestWords.length) {
newIndex = newTestWords.length - 1
}
// Обновляем состояние
setTestWords(newTestWords)
setCurrentIndex(newIndex)
setFlippedCards(new Set())
// Проверяем условия завершения или показываем следующую карточку
showNextCardOrFinish(newTestWords, cardsShown)
// Если тест завершился, не сбрасываем processingRef
if (isFinishingRef.current) {
return
}
processingRef.current = false
}
const handleFailure = (wordId) => {
if (processingRef.current || isFinishingRef.current || showResults) return
processingRef.current = true
const word = words.find(w => w.id === wordId)
if (!word) {
processingRef.current = false
return
}
const now = new Date().toISOString()
// Обновляем статистику: failure + 1, lastFailureAt = now
const currentWordStats = wordStatsRef.current[wordId] || { success: 0, failure: 0 }
const updatedStats = {
...wordStatsRef.current,
[wordId]: {
success: currentWordStats.success || 0,
failure: (currentWordStats.failure || 0) + 1,
lastSuccessAt: currentWordStats.lastSuccessAt || null,
lastFailureAt: now
}
}
wordStatsRef.current = updatedStats
setWordStats(updatedStats)
// Увеличиваем счётчик ответов
const newTotalAnswers = totalAnswers + 1
setTotalAnswers(newTotalAnswers)
// Слово остаётся в пуле, переходим к следующему
let newIndex = currentIndex + 1
if (newIndex >= testWords.length) {
newIndex = 0
}
setCurrentIndex(newIndex)
setFlippedCards(new Set())
// Проверяем условия завершения или показываем следующую карточку
// При failure пул не изменяется
showNextCardOrFinish(testWords, cardsShown)
// Если тест завершился, не сбрасываем processingRef
if (isFinishingRef.current) {
return
}
processingRef.current = false
}
const handleClose = () => {
onNavigate?.('test-config')
}
const handleStartTest = () => {
setShowPreview(false)
// Показываем первую карточку и увеличиваем левый счётчик
setCardsShown(1)
}
const handleFinish = () => {
onNavigate?.('test-config')
}
const getRandomSide = (word) => {
return word.id % 2 === 0 ? 'word' : 'translation'
}
return (
<div className="test-container test-container-fullscreen">
<button className="test-close-x-button" onClick={handleClose}>
</button>
{showPreview ? (
<div className="test-preview">
<div className="preview-stats">
{words.map((word) => {
const stats = wordStats[word.id] || { success: 0, failure: 0 }
return (
<div key={word.id} className="preview-item">
<div className="preview-word-content">
<div className="preview-word-header">
<h3 className="preview-word">{word.name}</h3>
</div>
<div className="preview-translation">{word.translation}</div>
</div>
<div className="preview-stats-numbers">
<span className="preview-stat-success">{stats.success}</span>
<span className="preview-stat-separator"> | </span>
<span className="preview-stat-failure">{stats.failure}</span>
</div>
</div>
)
})}
</div>
<div className="preview-actions">
<button className="test-start-button" onClick={handleStartTest}>
Начать
</button>
</div>
</div>
) : showResults ? (
<div className="test-results">
<div className="results-stats">
{words.map((word) => {
const stats = wordStats[word.id] || { success: 0, failure: 0 }
return (
<div key={word.id} className="result-item">
<div className="result-word-content">
<div className="result-word-header">
<h3 className="result-word">{word.name}</h3>
</div>
<div className="result-translation">{word.translation}</div>
</div>
<div className="result-stats">
<span className="result-stat-success">{stats.success}</span>
<span className="result-stat-separator"> | </span>
<span className="result-stat-failure">{stats.failure}</span>
</div>
</div>
)
})}
</div>
<div className="results-actions">
<button className="test-finish-button" onClick={handleFinish}>
Закончить
</button>
</div>
</div>
) : (
<div className="test-screen">
{loading && (
<div className="test-loading">Загрузка слов...</div>
)}
{error && (
<div className="test-error">{error}</div>
)}
{!loading && !error && !isFinishingRef.current && getCurrentWord() && (() => {
const word = getCurrentWord()
const isFlipped = flippedCards.has(word.id)
const showSide = getRandomSide(word)
return (
<div className="test-card-container" key={word.id}>
<div
className={`test-card ${isFlipped ? 'flipped' : ''}`}
onClick={() => !isFlipped && handleCardFlip(word.id)}
>
<div className="test-card-front">
<div className="test-card-content">
{showSide === 'word' ? (
<div className="test-word">{word.name}</div>
) : (
<div className="test-translation">{word.translation}</div>
)}
</div>
</div>
<div className="test-card-back">
<div className="test-card-content">
{showSide === 'word' ? (
<div className="test-translation">{word.translation}</div>
) : (
<div className="test-word">{word.name}</div>
)}
<div className="test-card-actions">
<button
className="test-action-button success-button"
onClick={(e) => {
e.stopPropagation()
handleSuccess(word.id)
}}
>
Знаю
</button>
<button
className="test-action-button failure-button"
onClick={(e) => {
e.stopPropagation()
handleFailure(word.id)
}}
>
Не знаю
</button>
</div>
</div>
</div>
</div>
</div>
)
})()}
{!loading && !error && (
<div className="test-progress">
<div className="progress-text">
{cardsShown} / {getRightCounter()}
</div>
</div>
)}
</div>
)}
</div>
)
}
export default TestWords

View File

@@ -0,0 +1,160 @@
import React from 'react'
import { getProjectColor, sortProjectsLikeCurrentWeek } from '../utils/projectUtils'
const formatWeekKey = ({ year, week }) => `${year}-W${week.toString().padStart(2, '0')}`
const parseWeekKey = (weekKey) => {
const [yearStr, weekStr] = weekKey.split('-W')
return { year: Number(yearStr), week: Number(weekStr) }
}
const compareWeekKeys = (a, b) => {
const [yearA, weekA] = a.split('-W').map(Number)
const [yearB, weekB] = b.split('-W').map(Number)
if (yearA !== yearB) {
return yearA - yearB
}
return weekA - weekB
}
function WeekProgressChart({ data, allProjectsSorted, currentWeekData, selectedProject }) {
if (!data || data.length === 0) {
return null
}
// Группируем данные по неделям
const weeksMap = {}
data.forEach(item => {
// Фильтруем по выбранному проекту, если он указан
if (selectedProject && item.project_name !== selectedProject) {
return
}
const weekKey = `${item.report_year}-W${item.report_week.toString().padStart(2, '0')}`
if (!weeksMap[weekKey]) {
weeksMap[weekKey] = []
}
weeksMap[weekKey].push({
projectName: item.project_name,
score: parseFloat(item.total_score) || 0
})
})
// Получаем все уникальные недели и сортируем их (новые сверху)
const allWeeks = Object.keys(weeksMap).sort((a, b) => -compareWeekKeys(a, b))
// Берем первые 4 недели (самые актуальные)
const last4Weeks = allWeeks.slice(0, 4)
// Используем переданный отсортированный список проектов или получаем из данных
const allProjects = allProjectsSorted || (() => {
const allProjectsSet = new Set()
data.forEach(item => {
allProjectsSet.add(item.project_name)
})
return Array.from(allProjectsSet).sort()
})()
// Обрабатываем данные для каждой недели
const weeksData = last4Weeks.map(weekKey => {
const weekProjects = weeksMap[weekKey]
const totalScore = weekProjects.reduce((sum, p) => sum + p.score, 0)
// Используем абсолютные значения (баллы)
// Сортируем проекты так же, как в полной статистике (по priority и min_goal_score)
const projectsWithData = weekProjects.map(project => {
const color = getProjectColor(project.projectName, allProjects)
return {
...project,
color
}
})
// Применяем ту же сортировку, что и в FullStatistics
const projectNames = projectsWithData.map(p => p.projectName)
const sortedProjectNames = currentWeekData
? sortProjectsLikeCurrentWeek(projectNames, currentWeekData)
: projectNames
// Пересобираем projectsWithData в правильном порядке
const sortedProjectsWithData = sortedProjectNames.map(name => {
return projectsWithData.find(p => p.projectName === name)
}).filter(Boolean)
const { year, week } = parseWeekKey(weekKey)
return {
weekKey,
year,
week,
projects: sortedProjectsWithData,
totalScore
}
})
// Находим максимальное значение среди всех недель для единой шкалы сравнения
const maxTotalScore = Math.max(...weeksData.map(w => w.totalScore), 1)
if (weeksData.length === 0) {
return null
}
return (
<div className="mt-8">
<h2 className="text-xl font-semibold text-gray-800 mb-4">Последние 4 недели</h2>
<div className="space-y-3">
{weeksData.map((weekData) => (
<div key={weekData.weekKey} className="flex items-center gap-3">
<div className="min-w-[100px] text-sm font-medium text-gray-700">
Неделя {weekData.week}
</div>
<div className="flex-1 relative h-6 bg-gray-200 rounded-full overflow-hidden shadow-inner">
{weekData.totalScore === 0 ? (
<div className="absolute inset-0 flex items-center justify-center text-gray-400 text-xs">
Нет данных
</div>
) : (
<>
{weekData.projects.map((project, index) => {
// Вычисляем позицию и ширину для каждого сегмента на основе абсолютных значений
let left = 0
for (let i = 0; i < index; i++) {
left += weekData.projects[i].score
}
const widthPercent = (project.score / maxTotalScore) * 100
const leftPercent = (left / maxTotalScore) * 100
return (
<div
key={project.projectName}
className="absolute h-full transition-all duration-300 hover:opacity-90"
style={{
left: `${leftPercent}%`,
width: `${widthPercent}%`,
backgroundColor: project.color,
}}
title={`${project.projectName}: ${project.score.toFixed(1)} баллов`}
/>
)
})}
</>
)}
</div>
<div className="min-w-[60px] text-right text-sm text-gray-600 font-medium">
{weekData.totalScore > 0 ? `${weekData.totalScore.toFixed(1)}` : '-'}
</div>
</div>
))}
</div>
</div>
)
}
export default WeekProgressChart

View File

@@ -0,0 +1,248 @@
.word-list {
position: relative;
}
.close-x-button {
position: fixed;
top: 1rem;
right: 1rem;
background: rgba(255, 255, 255, 0.9);
border: none;
font-size: 1.5rem;
color: #7f8c8d;
cursor: pointer;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s, color 0.2s;
z-index: 1600;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.close-x-button:hover {
background-color: #ffffff;
color: #2c3e50;
}
.word-list h2 {
color: #2c3e50;
}
.add-button {
background-color: transparent;
color: #3498db;
border: 2px solid #3498db;
padding: 1rem;
border-radius: 8px;
cursor: pointer;
font-size: 1.1rem;
font-weight: 500;
transition: all 0.2s ease;
min-height: 60px;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
box-sizing: border-box;
margin: 0 0 1rem 0;
}
.add-button:hover {
background-color: #3498db;
color: white;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(52, 152, 219, 0.3);
}
.loading, .error-message {
text-align: center;
padding: 2rem;
color: #666;
}
.error-message {
color: #e74c3c;
background-color: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 4px;
}
.empty-state {
text-align: center;
padding: 3rem;
color: #666;
}
.words-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.word-card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem;
background: #fafafa;
transition: transform 0.2s, box-shadow 0.2s;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1.5rem;
}
.word-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.word-content {
flex: 1;
}
.word-header {
margin-bottom: 0.75rem;
}
.word-name {
font-size: 1.25rem;
font-weight: bold;
color: #2c3e50;
margin: 0;
}
.word-translation {
font-size: 1rem;
color: #34495e;
font-weight: 500;
margin-bottom: 0.5rem;
}
.word-description {
font-size: 0.9rem;
color: #7f8c8d;
font-style: italic;
}
.word-stats {
display: flex;
align-items: center;
font-size: 1.5rem;
font-weight: bold;
white-space: nowrap;
}
.stat-success {
color: #3498db;
}
.stat-separator {
color: #7f8c8d;
margin: 0 0.25rem;
}
.stat-failure {
color: #e74c3c;
}
.dictionary-name-input-container {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
padding-right: 60px; /* Space for close button */
margin-top: 0.5rem;
}
.dictionary-name-input {
flex: 1;
padding: 0.5rem 0.75rem;
border: none;
border-bottom: 2px solid #e0e0e0;
border-radius: 0;
font-size: 1.25rem;
font-weight: 600;
color: #2c3e50;
transition: all 0.2s;
font-family: inherit;
background-color: transparent;
}
.dictionary-name-input:focus {
outline: none;
border-bottom-color: #3498db;
border-bottom-width: 3px;
}
.dictionary-name-input::placeholder {
color: #95a5a6;
font-weight: 400;
}
.dictionary-name-save-button {
background-color: #27ae60;
color: white;
border: none;
width: 36px;
height: 36px;
border-radius: 8px;
font-size: 1.25rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 2px 6px rgba(39, 174, 96, 0.25);
font-weight: bold;
}
.dictionary-name-save-button:hover:not(:disabled) {
background-color: #229954;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(39, 174, 96, 0.4);
}
.dictionary-name-save-button:active:not(:disabled) {
transform: translateY(0);
}
.dictionary-name-save-button:disabled {
background-color: #95a5a6;
cursor: not-allowed;
opacity: 0.6;
box-shadow: none;
}
.menu-button {
position: fixed;
top: 1rem;
right: 4rem;
background: rgba(255, 255, 255, 0.9);
border: none;
border-radius: 8px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 1.5rem;
color: #2c3e50;
font-weight: bold;
transition: all 0.2s;
z-index: 1500;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
padding: 0;
line-height: 1;
}
.menu-button:hover {
background-color: #ffffff;
color: #3498db;
transform: scale(1.1);
}

View File

@@ -0,0 +1,246 @@
import React, { useState, useEffect } from 'react'
import './WordList.css'
const API_URL = '/api'
function WordList({ onNavigate, dictionaryId, isNewDictionary, refreshTrigger = 0 }) {
const [words, setWords] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [dictionary, setDictionary] = useState(null)
const [dictionaryName, setDictionaryName] = useState('')
const [originalDictionaryName, setOriginalDictionaryName] = useState('')
const [isSavingName, setIsSavingName] = useState(false)
const [currentDictionaryId, setCurrentDictionaryId] = useState(dictionaryId)
const [isNewDict, setIsNewDict] = useState(isNewDictionary)
useEffect(() => {
setCurrentDictionaryId(dictionaryId)
setIsNewDict(isNewDictionary)
if (isNewDictionary) {
setLoading(false)
setDictionary(null)
setDictionaryName('')
setOriginalDictionaryName('')
setWords([])
} else if (dictionaryId !== undefined && dictionaryId !== null) {
fetchDictionary()
fetchWords()
} else {
setLoading(false)
setWords([])
}
}, [dictionaryId, isNewDictionary, refreshTrigger])
const fetchDictionary = async () => {
try {
const response = await fetch(`${API_URL}/dictionaries`)
if (!response.ok) {
throw new Error('Ошибка при загрузке словарей')
}
const dictionaries = await response.json()
const dict = dictionaries.find(d => d.id === dictionaryId)
if (dict) {
setDictionary(dict)
setDictionaryName(dict.name)
setOriginalDictionaryName(dict.name)
}
} catch (err) {
console.error('Error fetching dictionary:', err)
}
}
const fetchWords = async () => {
if (isNewDictionary || dictionaryId === undefined || dictionaryId === null) {
setWords([])
setLoading(false)
return
}
await fetchWordsForDictionary(dictionaryId)
}
const fetchWordsForDictionary = async (dictId) => {
try {
setLoading(true)
const url = `${API_URL}/words?dictionary_id=${dictId}`
const response = await fetch(url)
if (!response.ok) {
throw new Error('Ошибка при загрузке слов')
}
const data = await response.json()
setWords(Array.isArray(data) ? data : [])
setError('')
} catch (err) {
setError(err.message)
setWords([])
} finally {
setLoading(false)
}
}
const handleNameChange = (e) => {
setDictionaryName(e.target.value)
}
const handleNameSave = async () => {
if (!dictionaryName.trim()) {
return
}
setIsSavingName(true)
try {
if (isNewDictionary) {
// Create new dictionary
const response = await fetch(`${API_URL}/dictionaries`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: dictionaryName.trim() }),
})
if (!response.ok) {
throw new Error('Ошибка при создании словаря')
}
const newDict = await response.json()
const newDictionaryId = newDict.id
// Update local state immediately
setOriginalDictionaryName(newDict.name)
setDictionaryName(newDict.name)
setDictionary(newDict)
setCurrentDictionaryId(newDictionaryId)
setIsNewDict(false)
// Fetch words for the new dictionary
await fetchWordsForDictionary(newDictionaryId)
// Update navigation to use the new dictionary ID and remove isNewDictionary flag
onNavigate?.('words', { dictionaryId: newDictionaryId, isNewDictionary: false })
} else if (dictionaryId !== undefined && dictionaryId !== null) {
// Update existing dictionary
const response = await fetch(`${API_URL}/dictionaries/${dictionaryId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: dictionaryName.trim() }),
})
if (!response.ok) {
throw new Error('Ошибка при обновлении словаря')
}
setOriginalDictionaryName(dictionaryName.trim())
if (dictionary) {
setDictionary({ ...dictionary, name: dictionaryName.trim() })
}
}
} catch (err) {
setError(err.message)
} finally {
setIsSavingName(false)
}
}
const showSaveButton = dictionaryName.trim() !== '' && dictionaryName.trim() !== originalDictionaryName
if (loading) {
return (
<div className="word-list">
<div className="loading">Загрузка...</div>
</div>
)
}
if (error) {
return (
<div className="word-list">
<div className="error-message">{error}</div>
</div>
)
}
return (
<div className="word-list">
<button
onClick={() => onNavigate?.('test-config')}
className="close-x-button"
title="Закрыть"
>
</button>
{/* Dictionary name input */}
<div className="dictionary-name-input-container">
<input
type="text"
className="dictionary-name-input"
value={dictionaryName}
onChange={handleNameChange}
placeholder="Введите название словаря"
/>
{showSaveButton && (
<button
className="dictionary-name-save-button"
onClick={handleNameSave}
disabled={isSavingName}
title="Сохранить название"
>
</button>
)}
</div>
{/* Show add button and words list:
- If dictionary exists (has dictionaryId), show regardless of name
- If new dictionary (no dictionaryId), show only if name is set */}
{((currentDictionaryId !== undefined && currentDictionaryId !== null && !isNewDict) || (isNewDict && dictionaryName.trim())) && (
<>
{(!words || words.length === 0) ? (
<>
<button onClick={() => onNavigate?.('add-words', { dictionaryId: currentDictionaryId, dictionaryName })} className="add-button">
Добавить
</button>
<div className="empty-state">
<p>Слов пока нет. Добавьте слова через экран "Добавить слова".</p>
</div>
</>
) : (
<>
<button onClick={() => onNavigate?.('add-words', { dictionaryId: currentDictionaryId, dictionaryName })} className="add-button">
Добавить
</button>
<div className="words-grid">
{words.map((word) => (
<div key={word.id} className="word-card">
<div className="word-content">
<div className="word-header">
<h3 className="word-name">{word.name}</h3>
</div>
<div className="word-translation">{word.translation}</div>
{word.description && (
<div className="word-description">{word.description}</div>
)}
</div>
<div className="word-stats">
<span className="stat-success">{word.success || 0}</span>
<span className="stat-separator"> | </span>
<span className="stat-failure">{word.failure || 0}</span>
</div>
</div>
))}
</div>
</>
)}
</>
)}
</div>
)
}
export default WordList

View File

@@ -0,0 +1,48 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
margin: 0;
padding: 0;
height: 100%;
}
body {
margin: 0;
padding: 0;
min-height: 100%;
min-height: 100dvh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-overflow-scrolling: touch;
}
#root {
min-height: 100vh;
min-height: 100dvh; /* Dynamic viewport height для мобильных устройств */
background: #f3f4f6;
background-attachment: fixed;
display: flex;
flex-direction: column;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fadeIn 0.5s ease-out;
}

View File

@@ -0,0 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,104 @@
// Утилиты для работы с проектами - обеспечивают единую сортировку и цвета
// Функция для генерации цвета проекта на основе его индекса в отсортированном списке
export function getProjectColorByIndex(index) {
const hue = (index * 137.508) % 360 // Золотой угол для равномерного распределения цветов
return `hsl(${hue}, 70%, 50%)`
}
/**
* Получает отсортированный список всех проектов для определения цветов
* Сортировка: по алфавиту (стабильный порядок для цветов)
*
* @param {Array} allProjectsData - данные полной статистики (массив объектов с project_name)
* @param {Array} currentWeekData - данные текущей недели (массив объектов с project_name)
* @returns {Array} отсортированный массив названий проектов
*/
export function getAllProjectsSorted(allProjectsData, currentWeekData = null) {
const projectsSet = new Set()
// Собираем проекты из полной статистики (приоритетный источник)
if (allProjectsData && allProjectsData.length > 0) {
allProjectsData.forEach(item => {
projectsSet.add(item.project_name)
})
}
// Если данных полной статистики нет, используем проекты из текущей недели
if (projectsSet.size === 0 && currentWeekData) {
const projects = Array.isArray(currentWeekData)
? currentWeekData
: (currentWeekData?.projects || [])
projects.forEach(item => {
projectsSet.add(item.project_name)
})
}
return Array.from(projectsSet).sort()
}
/**
* Получает цвет проекта на основе его названия
*
* @param {string} projectName - название проекта
* @param {Array} allProjectsSorted - отсортированный список всех проектов
* @returns {string} цвет в формате HSL
*/
export function getProjectColor(projectName, allProjectsSorted) {
const projectIndex = allProjectsSorted.indexOf(projectName)
return projectIndex >= 0 ? getProjectColorByIndex(projectIndex) : '#9CA3AF'
}
/**
* Нормализует значение priority для сортировки
*/
function normalizePriority(value) {
if (value === null || value === undefined) return Infinity
const numeric = Number(value)
return Number.isFinite(numeric) ? numeric : Infinity
}
/**
* Сортирует проекты так же, как на экране списка проектов:
* сначала по priority (1, 2, ...; null в конце), затем по min_goal_score по убыванию
*
* @param {Array} projectNames - массив названий проектов для сортировки
* @param {Array} currentWeekData - данные текущей недели с информацией о priority и min_goal_score
* @returns {Array} отсортированный массив названий проектов
*/
export function sortProjectsLikeCurrentWeek(projectNames, currentWeekData) {
if (!currentWeekData || projectNames.length === 0) {
return projectNames
}
// Получаем данные проектов из currentWeekData
const projectsData = currentWeekData?.projects || (Array.isArray(currentWeekData) ? currentWeekData : [])
// Создаем Map для быстрого доступа к данным проекта
const projectDataMap = new Map()
projectsData.forEach(project => {
if (project.project_name) {
projectDataMap.set(project.project_name, {
priority: project.priority,
min_goal_score: parseFloat(project.min_goal_score) || 0
})
}
})
// Сортируем проекты
return [...projectNames].sort((a, b) => {
const dataA = projectDataMap.get(a) || { priority: null, min_goal_score: 0 }
const dataB = projectDataMap.get(b) || { priority: null, min_goal_score: 0 }
const priorityA = normalizePriority(dataA.priority)
const priorityB = normalizePriority(dataB.priority)
if (priorityA !== priorityB) {
return priorityA - priorityB
}
return dataB.min_goal_score - dataA.min_goal_score
})
}

View File

@@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -0,0 +1,51 @@
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'
export default defineConfig(({ mode }) => {
// Загружаем переменные окружения из корня проекта
// Сначала пробуем корневой .env, затем локальный
const rootEnv = loadEnv(mode, resolve(process.cwd(), '..'), '')
const localEnv = loadEnv(mode, process.cwd(), '')
// Объединяем переменные (локальные имеют приоритет)
const env = { ...rootEnv, ...localEnv, ...process.env }
return {
plugins: [react()],
server: {
host: '0.0.0.0',
port: parseInt(env.VITE_PORT || '3000', 10),
proxy: {
// Proxy API requests to backend
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
secure: false,
},
// Proxy other API endpoints
'/playlife-feed': {
target: 'http://localhost:8080',
changeOrigin: true,
secure: false,
},
'/d2dc349a-0d13-49b2-a8f0-1ab094bfba9b': {
target: 'http://localhost:8080',
changeOrigin: true,
secure: false,
},
'/projects': {
target: 'http://localhost:8080',
changeOrigin: true,
secure: false,
},
'/project': {
target: 'http://localhost:8080',
changeOrigin: true,
secure: false,
},
}
}
}
})