Initial commit
This commit is contained in:
12
play-life-web/.dockerignore
Normal file
12
play-life-web/.dockerignore
Normal 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
30
play-life-web/.gitignore
vendored
Normal 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
30
play-life-web/Dockerfile
Normal 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
105
play-life-web/README.md
Normal 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`)
|
||||
- График полной статистики показывает нарастающую сумму баллов по неделям
|
||||
- Все проекты отображаются на одном графике с разными цветами
|
||||
- Адаптивный дизайн для различных размеров экранов
|
||||
|
||||
42
play-life-web/build-and-save.sh
Normal file
42
play-life-web/build-and-save.sh
Normal 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
|
||||
|
||||
|
||||
|
||||
29
play-life-web/build-docker-image.sh
Normal file
29
play-life-web/build-docker-image.sh
Normal 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
|
||||
|
||||
21
play-life-web/docker-compose.yml
Normal file
21
play-life-web/docker-compose.yml
Normal 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
|
||||
|
||||
6
play-life-web/env.example
Normal file
6
play-life-web/env.example
Normal 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
14
play-life-web/index.html
Normal 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
50
play-life-web/nginx.conf
Normal 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
2706
play-life-web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
play-life-web/package.json
Normal file
28
play-life-web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
play-life-web/postcss.config.js
Normal file
7
play-life-web/postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
531
play-life-web/src/App.jsx
Normal file
531
play-life-web/src/App.jsx
Normal 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
|
||||
|
||||
|
||||
222
play-life-web/src/components/AddConfig.css
Normal file
222
play-life-web/src/components/AddConfig.css
Normal 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;
|
||||
}
|
||||
|
||||
344
play-life-web/src/components/AddConfig.jsx
Normal file
344
play-life-web/src/components/AddConfig.jsx
Normal 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
|
||||
|
||||
106
play-life-web/src/components/AddWords.css
Normal file
106
play-life-web/src/components/AddWords.css
Normal 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;
|
||||
}
|
||||
|
||||
163
play-life-web/src/components/AddWords.jsx
Normal file
163
play-life-web/src/components/AddWords.jsx
Normal 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
|
||||
|
||||
200
play-life-web/src/components/CurrentWeek.jsx
Normal file
200
play-life-web/src/components/CurrentWeek.jsx
Normal 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
|
||||
|
||||
289
play-life-web/src/components/FullStatistics.jsx
Normal file
289
play-life-web/src/components/FullStatistics.jsx
Normal 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
|
||||
|
||||
724
play-life-web/src/components/ProjectPriorityManager.jsx
Normal file
724
play-life-web/src/components/ProjectPriorityManager.jsx
Normal 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
|
||||
|
||||
158
play-life-web/src/components/ProjectProgressBar.jsx
Normal file
158
play-life-web/src/components/ProjectProgressBar.jsx
Normal 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
|
||||
|
||||
347
play-life-web/src/components/TestConfigSelection.css
Normal file
347
play-life-web/src/components/TestConfigSelection.css
Normal 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;
|
||||
}
|
||||
|
||||
278
play-life-web/src/components/TestConfigSelection.jsx
Normal file
278
play-life-web/src/components/TestConfigSelection.jsx
Normal 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
|
||||
|
||||
485
play-life-web/src/components/TestWords.css
Normal file
485
play-life-web/src/components/TestWords.css
Normal 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);
|
||||
}
|
||||
|
||||
490
play-life-web/src/components/TestWords.jsx
Normal file
490
play-life-web/src/components/TestWords.jsx
Normal 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
|
||||
160
play-life-web/src/components/WeekProgressChart.jsx
Normal file
160
play-life-web/src/components/WeekProgressChart.jsx
Normal 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
|
||||
|
||||
248
play-life-web/src/components/WordList.css
Normal file
248
play-life-web/src/components/WordList.css
Normal 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);
|
||||
}
|
||||
|
||||
246
play-life-web/src/components/WordList.jsx
Normal file
246
play-life-web/src/components/WordList.jsx
Normal 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
|
||||
|
||||
48
play-life-web/src/index.css
Normal file
48
play-life-web/src/index.css
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
11
play-life-web/src/main.jsx
Normal file
11
play-life-web/src/main.jsx
Normal 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>,
|
||||
)
|
||||
|
||||
104
play-life-web/src/utils/projectUtils.js
Normal file
104
play-life-web/src/utils/projectUtils.js
Normal 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
|
||||
})
|
||||
}
|
||||
|
||||
12
play-life-web/tailwind.config.js
Normal file
12
play-life-web/tailwind.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
51
play-life-web/vite.config.js
Normal file
51
play-life-web/vite.config.js
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user