diff --git a/.dockerignore b/.dockerignore index dc8c194..b6317a9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,7 +2,7 @@ .venv data web-react -web +stats-react __pycache__ *.py[cod] .pytest_cache diff --git a/Dockerfile.stats b/Dockerfile.stats deleted file mode 100644 index 878c6d7..0000000 --- a/Dockerfile.stats +++ /dev/null @@ -1,22 +0,0 @@ -FROM python:3.14-slim - -WORKDIR /app - -# matplotlib нужен fontconfig для кириллицы -RUN apt-get update && apt-get install -y --no-install-recommends \ - fontconfig \ - fonts-dejavu-core \ - && rm -rf /var/lib/apt/lists/* - -RUN pip install --no-cache-dir \ - "flet==0.82.2" \ - "flet-web==0.82.2" \ - "uvicorn[standard]" \ - httpx \ - matplotlib - -COPY stats/ . - -EXPOSE 8081 - -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8081"] diff --git a/README.md b/README.md index 1575431..14f89bc 100644 --- a/README.md +++ b/README.md @@ -131,8 +131,7 @@ api-copp/ ├── .dockerignore # что не попадает в build context образов ├── .env.example # шаблон переменных окружения без секретов ├── Dockerfile.api # образ API -├── Dockerfile.stats # образ дашборда статистики -├── docker-compose.yml # api + web-react + stats (+ postgres/redis за профилем local-db) +├── docker-compose.yml # api + web-react + stats-react (+ postgres/redis за профилем local-db) │ ├── bd/ │ ├── __init__.py # Settings (pydantic), make_engine() @@ -175,7 +174,10 @@ api-copp/ │ ├── src/pages/ # страницы (Login, AnonymousEntry, Poll, MyTests, ...) │ └── nginx.conf # раздаёт статику SPA, проксирует /api → api:8000 │ -├── stats/ # отдельный дашборд статистики (Flet, порт 8081) +├── stats-react/ # дашборд статистики, React SPA (порт 8081, свой Dockerfile+nginx) +│ ├── src/api/client.ts # HTTP-клиент к /stats/*, /polls/, /organizations/, /groups/ +│ ├── src/components/charts/ # BarChart, LineChart, PartToWholeBar — свои SVG/HTML, без сторонних libs +│ └── src/App.tsx # фильтры + два таба (прохождения / результаты тестов) │ └── data/ # том Docker (./data:/app/data) ├── postgres/ # данные PostgreSQL @@ -620,3 +622,11 @@ ALTER TABLE radar_results ADD COLUMN IF NOT EXISTS image_path VARCHAR(512); **Было:** `docker-compose.yml` (api + web-react + stats) и `docker-compose_db.yml` (postgres + redis) — два отдельных файла и две команды на разворачивание. **Решение:** `postgres`/`redis` перенесены в тот же `docker-compose.yml`, за профилем `local-db` — не поднимаются командой `docker compose up -d` по умолчанию (приложение по умолчанию ходит на внешние `DB_HOST`/`REDIS_HOST` из `.env`), но доступны через `docker compose --profile local-db up -d`, когда нужна локальная БД. `docker-compose_db.yml` удалён. + +--- + +### 14. Дашборд статистики был на Flet — переписан на React ✅ + +**Проблема:** `stats/` (Flet, `Dockerfile.stats`) генерировал графики через matplotlib в PNG на сервере и отдавал их клиенту через Flutter web c долгоживущим WebSocket-соединением — та же архитектура, что подвела основной фронтенд (см. записи №1–4 выше). Плавающая версия `flet>=0.82.2` в какой-то момент подтянула мажорный `0.86.0` с несовместимым протоколом фреймирования, и дашборд перестал открываться в браузере. + +**Решение:** `stats/` и `Dockerfile.stats` удалены, дашборд переписан на React SPA (`stats-react/`, тот же паттерн, что и `web-react/`) — same-origin прокси `/api/*` вместо WebSocket, графики — свой SVG/HTML без сторонних библиотек (bar/line/part-to-whole с hover-тултипами), функциональность 1:1 с оригиналом (2 таба, 4+4 графика, топ-10 таблица, все фильтры). Категориальная палитра графиков провалидирована на CVD-безопасность (`node scripts/validate_palette.js`) — у исходной 8-цветной палитры из `designer.py` эта проверка проваливалась по нескольким пунктам. diff --git a/docker-compose.yml b/docker-compose.yml index 7aefe69..f7331bf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,14 +37,12 @@ services: depends_on: - api restart: unless-stopped - stats: + stats-react: build: - context: . - dockerfile: Dockerfile.stats + context: ./stats-react + dockerfile: Dockerfile ports: - - "8081:8081" - environment: - - API_URL=http://api:8000 + - "8081:80" depends_on: - api restart: unless-stopped diff --git a/stats-react/.dockerignore b/stats-react/.dockerignore new file mode 100644 index 0000000..fe8741c --- /dev/null +++ b/stats-react/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.vite +*.local +Dockerfile +.dockerignore diff --git a/stats-react/.gitignore b/stats-react/.gitignore new file mode 100644 index 0000000..ec61b4b --- /dev/null +++ b/stats-react/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +*.local +.vite +*.tsbuildinfo diff --git a/stats-react/Dockerfile b/stats-react/Dockerfile new file mode 100644 index 0000000..ef580e5 --- /dev/null +++ b/stats-react/Dockerfile @@ -0,0 +1,14 @@ +# --- Стадия 1: сборка React-приложения --- +FROM node:20-alpine AS build +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm install +COPY . . +RUN npm run build + +# --- Стадия 2: раздача статики через nginx + прокси /api --- +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/stats-react/index.html b/stats-react/index.html new file mode 100644 index 0000000..d117c13 --- /dev/null +++ b/stats-react/index.html @@ -0,0 +1,13 @@ + + + + + + + ЦОПП — Статистика + + +
+ + + diff --git a/stats-react/nginx.conf b/stats-react/nginx.conf new file mode 100644 index 0000000..54e9f4a --- /dev/null +++ b/stats-react/nginx.conf @@ -0,0 +1,36 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Статика SPA: всё неизвестное отдаём index.html (клиентский роутинг) + location / { + try_files $uri $uri/ /index.html; + } + + # Прокси на внутренний API-контейнер (браузер к api:8000 напрямую не ходит). + # Префикс /api срезается: /api/stats/summary -> http://api:8000/stats/summary + # + # Используем встроенный DNS Docker (127.0.0.11) и переменную в proxy_pass, + # чтобы nginx переразрешал имя `api` на каждый запрос. Иначе nginx кэширует + # IP при старте и ломается (502), если контейнер api перезапустился и сменил IP. + location /api/ { + resolver 127.0.0.11 valid=10s ipv6=off; + set $api_backend api; + rewrite ^/api/(.*)$ /$1 break; + proxy_pass http://$api_backend:8000; + proxy_http_version 1.1; + 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_read_timeout 60s; + } + + # Кэш статических ассетов с хэшем в имени + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/stats-react/package-lock.json b/stats-react/package-lock.json new file mode 100644 index 0000000..d38a09f --- /dev/null +++ b/stats-react/package-lock.json @@ -0,0 +1,1732 @@ +{ + "name": "copp-stats-react", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "copp-stats-react", + "version": "1.0.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.5.4", + "vite": "^5.4.6" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.43", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.6", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.392", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.392.tgz", + "integrity": "sha512-1yQq3VQCZRwsnYc67Oc+1fge6Lwtn0hzi6zmEVkB61Zx21kTbwJAW4dFLadl5Rc1tKhG/kSpYXnfiAhu0f0a1g==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/stats-react/package.json b/stats-react/package.json new file mode 100644 index 0000000..2de875a --- /dev/null +++ b/stats-react/package.json @@ -0,0 +1,22 @@ +{ + "name": "copp-stats-react", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.1", + "typescript": "^5.5.4", + "vite": "^5.4.6" + } +} diff --git a/stats/assets/favicon.png b/stats-react/public/favicon.png similarity index 100% rename from stats/assets/favicon.png rename to stats-react/public/favicon.png diff --git a/stats-react/src/App.tsx b/stats-react/src/App.tsx new file mode 100644 index 0000000..cfd247a --- /dev/null +++ b/stats-react/src/App.tsx @@ -0,0 +1,342 @@ +import { useEffect, useState } from "react"; +import * as api from "./api/client"; +import type { AvgDatum, ChartDatum, Filters, TopUserRow } from "./api/client"; +import { SearchableDropdown, type Option } from "./components/SearchableDropdown"; +import { ChartCard } from "./components/ChartCard"; +import { SummaryTile } from "./components/SummaryTile"; +import { TopUsersTable } from "./components/TopUsersTable"; +import { BarChart } from "./components/charts/BarChart"; +import { LineChart } from "./components/charts/LineChart"; +import { PartToWholeBar } from "./components/charts/PartToWholeBar"; +import { useIsDark } from "./theme/useIsDark"; +import { categoricalColor } from "./theme/palette"; +import { + IconAssignment, + IconBarChart, + IconBusiness, + IconFilterOff, + IconInsights, + IconPlayCircle, + IconQuiz, + IconRefresh, +} from "./components/icons"; + +function toValueData(data: ChartDatum[]): { label: string; value: number }[] { + return data.map((d) => ({ label: d.label, value: d.count })); +} + +const COMPARE_OPTIONS: Option[] = [ + { key: "organization", text: "Организациям" }, + { key: "group", text: "Группам" }, +]; + +export default function App() { + const dark = useIsDark(); + + // ── справочники для фильтров ────────────────────────────────────────── + const [pollOptions, setPollOptions] = useState([]); + const [orgOptions, setOrgOptions] = useState([]); + const [groupOptions, setGroupOptions] = useState([]); + const [yearOptions, setYearOptions] = useState([]); + const [dimensionOptions, setDimensionOptions] = useState([]); + + // ── общие фильтры ──────────────────────────────────────────────────── + const [pollId, setPollId] = useState(null); + const [orgId, setOrgId] = useState(null); + const [groupId, setGroupId] = useState(null); + const [year, setYear] = useState(null); + + // ── фильтры таба 2 ─────────────────────────────────────────────────── + const [dimension, setDimension] = useState(null); + const [compareBy, setCompareBy] = useState("organization"); + + const [tab, setTab] = useState<0 | 1>(0); + + // ── summary ────────────────────────────────────────────────────────── + const [summary, setSummary] = useState({ + total_responses: 0, + total_polls: 0, + active_polls: 0, + orgs_active: 0, + }); + + // ── tab1 данные ────────────────────────────────────────────────────── + const [tab1Loading, setTab1Loading] = useState(true); + const [pollData, setPollData] = useState([]); + const [orgData, setOrgData] = useState([]); + const [groupData, setGroupData] = useState([]); + const [monthData, setMonthData] = useState([]); + + // ── tab2 данные ────────────────────────────────────────────────────── + const [tab2Loading, setTab2Loading] = useState(true); + const [avgDimData, setAvgDimData] = useState([]); + const [leadingData, setLeadingData] = useState([]); + const [compareData, setCompareData] = useState([]); + const [trendData, setTrendData] = useState([]); + const [topUsers, setTopUsers] = useState([]); + + const filters = (): Filters => ({ + poll_id: pollId, + org_id: orgId, + group_id: groupId, + year: year ? parseInt(year, 10) : null, + }); + + async function refreshTab1() { + setTab1Loading(true); + const f = filters(); + const [p, o, g, m] = await Promise.all([ + api.getResponsesGrouped("poll", f), + api.getResponsesGrouped("organization", f), + api.getResponsesGrouped("group", f), + api.getResponsesGrouped("month", f), + ]); + setPollData(p); + setOrgData(o); + setGroupData(g); + setMonthData(m); + setTab1Loading(false); + } + + async function refreshTab2() { + setTab2Loading(true); + const f = filters(); + const dim = dimension; + const [avg, lead] = await Promise.all([api.getAvgByDimension(f), api.getLeadingType(f)]); + let top: TopUserRow[] = []; + let compare: AvgDatum[] = []; + let trend: AvgDatum[] = []; + if (dim) { + [top, compare, trend] = await Promise.all([ + api.getTopUsers(dim, f), + api.getCompareGroups(dim, compareBy as api.CompareBy, f.poll_id, f.year), + api.getDimensionTrend(dim, f), + ]); + } + setAvgDimData(avg); + setLeadingData(lead); + setTopUsers(top); + setCompareData(compare); + setTrendData(trend); + setTab2Loading(false); + } + + function refreshCurrentTab() { + if (tab === 0) void refreshTab1(); + else void refreshTab2(); + } + + // ── первичная загрузка ─────────────────────────────────────────────── + useEffect(() => { + (async () => { + const [polls, orgs, groups, years, summaryData, dims] = await Promise.all([ + api.getPolls(), + api.getOrganizations(), + api.getGroups(), + api.getYears(), + api.getSummary(), + api.getDimensions(), + ]); + setPollOptions(polls.map((p) => ({ key: p.id, text: p.title }))); + setOrgOptions(orgs.map((o) => ({ key: o.id, text: o.name_organization }))); + setGroupOptions(groups.map((g) => ({ key: g.id, text: g.name_group }))); + setYearOptions(years.map((y) => ({ key: String(y), text: String(y) }))); + setDimensionOptions(dims.map((d) => ({ key: d, text: d }))); + setSummary(summaryData); + if (dims.length > 0) setDimension(dims[0]); + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Первый прогон обоих табов, как только известна выбранная ось (dimension). + const [initialized, setInitialized] = useState(false); + useEffect(() => { + if (initialized || dimensionOptions.length === 0) return; + setInitialized(true); + void refreshTab1(); + void refreshTab2(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dimensionOptions]); + + const onGeneralFilterChange = () => { + if (!initialized) return; + refreshCurrentTab(); + }; + + const onResultsFilterChange = () => { + if (!initialized) return; + void refreshTab2(); + }; + + const resetFilters = () => { + setPollId(null); + setOrgId(null); + setGroupId(null); + setYear(null); + setTimeout(refreshCurrentTab, 0); + }; + + const dimColor = (i: number) => categoricalColor(i, dark); + + return ( +
+
+ ЦОПП + + Статистика прохождений +
+ +
+
+ } color="var(--primary)" /> + } color="var(--info)" /> + } color="var(--success)" /> + } color="var(--accent)" /> +
+ +
+ Фильтры +
+ { + setPollId(v); + setTimeout(onGeneralFilterChange, 0); + }} + /> + { + setOrgId(v); + setTimeout(onGeneralFilterChange, 0); + }} + /> + { + setGroupId(v); + setTimeout(onGeneralFilterChange, 0); + }} + /> + { + setYear(v); + setTimeout(onGeneralFilterChange, 0); + }} + /> +
+
+ + +
+
+ +
+ + +
+ + {tab === 0 ? ( +
+ + "var(--primary)"} /> + + + "var(--info)"} /> + + + "var(--success)"} /> + + + String(v)} /> + +
+ ) : ( +
+
+ Фильтр по оси/типу +
+ { + setDimension(v); + setTimeout(onResultsFilterChange, 0); + }} + /> + { + setCompareBy(v ?? "organization"); + setTimeout(onResultsFilterChange, 0); + }} + /> +
+
+ + + ({ label: d.label, value: d.avg }))} + colorFor={dimColor} + formatValue={(v) => v.toFixed(1)} + /> + + + + ({ label: d.label, count: d.count }))} dark={dark} /> + + + + ({ label: d.label, value: d.avg }))} + colorFor={dimColor} + formatValue={(v) => v.toFixed(1)} + /> + + + + ({ label: d.label, value: d.avg }))} formatValue={(v) => v.toFixed(1)} /> + + +
+
Топ-10 участников по выбранной оси
+ {tab2Loading ? ( +
+
+
+ ) : ( + + )} +
+
+ )} +
+
+ ); +} diff --git a/stats-react/src/api/client.ts b/stats-react/src/api/client.ts new file mode 100644 index 0000000..bff8534 --- /dev/null +++ b/stats-react/src/api/client.ts @@ -0,0 +1,156 @@ +// Единое место для всех HTTP-запросов к бэкенду. +// Порт stats/api_client.py. Базовый путь /api проксируется nginx (прод) или Vite (dev) на api:8000. +// Все эндпоинты публичные — дашборд не требует авторизации (как и раньше). + +const BASE = "/api"; + +async function getJson(path: string): Promise { + try { + const resp = await fetch(BASE + path); + if (resp.status !== 200) return null; + return (await resp.json()) as T; + } catch { + return null; + } +} + +function qs(params: Record): string { + const usp = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) { + if (v !== null && v !== undefined && v !== "") usp.set(k, String(v)); + } + const s = usp.toString(); + return s ? `?${s}` : ""; +} + +export interface Summary { + total_responses: number; + total_polls: number; + active_polls: number; + orgs_active: number; +} + +export interface Poll { + id: string; + title: string; +} + +export interface Organization { + id: string; + name_organization: string; +} + +export interface Group { + id: string; + name_group: string; +} + +export interface ChartDatum { + label: string; + count: number; +} + +export interface AvgDatum { + label: string; + avg: number; + count?: number; +} + +export interface TopUserRow { + name: string; + poll: string; + value: number; +} + +export type GroupBy = "poll" | "organization" | "group" | "month"; +export type CompareBy = "organization" | "group"; + +export interface Filters { + poll_id?: string | null; + org_id?: string | null; + group_id?: string | null; + year?: number | null; +} + +export async function getSummary(): Promise { + return ( + (await getJson("/stats/summary")) ?? { + total_responses: 0, + total_polls: 0, + active_polls: 0, + orgs_active: 0, + } + ); +} + +export async function getYears(): Promise { + return (await getJson("/stats/years")) ?? []; +} + +export async function getPolls(): Promise { + return (await getJson("/polls/")) ?? []; +} + +export async function getOrganizations(): Promise { + return (await getJson("/organizations/")) ?? []; +} + +export async function getGroups(): Promise { + return (await getJson("/groups/")) ?? []; +} + +export async function getResponsesGrouped(groupBy: GroupBy, f: Filters): Promise { + const query = qs({ group_by: groupBy, poll_id: f.poll_id, org_id: f.org_id, group_id: f.group_id, year: f.year }); + return (await getJson(`/stats/responses${query}`)) ?? []; +} + +export async function getDimensions(pollId?: string | null): Promise { + const query = qs({ poll_id: pollId }); + return (await getJson(`/stats/results/dimensions${query}`)) ?? []; +} + +export async function getAvgByDimension(f: Filters): Promise { + const query = qs({ poll_id: f.poll_id, org_id: f.org_id, group_id: f.group_id, year: f.year }); + return (await getJson(`/stats/results/avg-by-dimension${query}`)) ?? []; +} + +export async function getLeadingType(f: Filters): Promise { + const query = qs({ poll_id: f.poll_id, org_id: f.org_id, group_id: f.group_id, year: f.year }); + return (await getJson(`/stats/results/leading-type${query}`)) ?? []; +} + +export async function getCompareGroups( + dimensionName: string, + by: CompareBy, + pollId?: string | null, + year?: number | null, +): Promise { + const query = qs({ dimension_name: dimensionName, by, poll_id: pollId, year }); + return (await getJson(`/stats/results/compare-groups${query}`)) ?? []; +} + +export async function getTopUsers( + dimensionName: string, + f: Filters, + limit = 10, +): Promise { + const query = qs({ + dimension_name: dimensionName, + poll_id: f.poll_id, + org_id: f.org_id, + group_id: f.group_id, + year: f.year, + limit, + }); + return (await getJson(`/stats/results/top-users${query}`)) ?? []; +} + +export async function getDimensionTrend(dimensionName: string, f: Filters): Promise { + const query = qs({ + dimension_name: dimensionName, + poll_id: f.poll_id, + org_id: f.org_id, + group_id: f.group_id, + }); + return (await getJson(`/stats/results/dimension-trend${query}`)) ?? []; +} diff --git a/stats-react/src/components/ChartCard.tsx b/stats-react/src/components/ChartCard.tsx new file mode 100644 index 0000000..408074a --- /dev/null +++ b/stats-react/src/components/ChartCard.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from "react"; + +export function ChartCard({ + title, + loading, + children, +}: { + title: string; + loading?: boolean; + children: ReactNode; +}) { + return ( +
+
{title}
+ {loading ? ( +
+
+
+ ) : ( + children + )} +
+ ); +} diff --git a/stats-react/src/components/SearchableDropdown.tsx b/stats-react/src/components/SearchableDropdown.tsx new file mode 100644 index 0000000..8a01705 --- /dev/null +++ b/stats-react/src/components/SearchableDropdown.tsx @@ -0,0 +1,83 @@ +import { useMemo, useState } from "react"; +import { IconCaretDown } from "./icons"; + +export interface Option { + key: string; + text: string; +} + +// Выпадающий список с поиском — порт web-react/src/components/SearchableDropdown.tsx +// (изначально web/designer.py SearchableDropdown из Flet-версии). +export function SearchableDropdown({ + label, + hint, + options, + value, + onChange, +}: { + label?: string; + hint?: string; + options: Option[]; + value: string | null; + onChange: (key: string | null) => void; +}) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + + const selected = useMemo(() => options.find((o) => o.key === value) ?? null, [options, value]); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return options; + return options.filter((o) => o.text.toLowerCase().includes(q)); + }, [options, query]); + + return ( +
+ {label && {label}} +
{ + setQuery(""); + setOpen(true); + }} + > + + {selected ? selected.text : hint ?? "Выберите..."} + + +
+ + {open && ( +
setOpen(false)}> +
e.stopPropagation()}> +
{label}
+ setQuery(e.target.value)} + /> +
+
+ {filtered.length === 0 &&
Ничего не найдено
} + {filtered.map((o) => ( +
{ + onChange(o.key); + setOpen(false); + }} + > + {o.text} +
+ ))} +
+
+
+ )} +
+ ); +} diff --git a/stats-react/src/components/SummaryTile.tsx b/stats-react/src/components/SummaryTile.tsx new file mode 100644 index 0000000..175215d --- /dev/null +++ b/stats-react/src/components/SummaryTile.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from "react"; + +export function SummaryTile({ + title, + value, + icon, + color, +}: { + title: string; + value: number | string; + icon: ReactNode; + color: string; +}) { + return ( +
+
+ {icon} + {title} +
+
{value}
+
+ ); +} diff --git a/stats-react/src/components/TopUsersTable.tsx b/stats-react/src/components/TopUsersTable.tsx new file mode 100644 index 0000000..5c4da28 --- /dev/null +++ b/stats-react/src/components/TopUsersTable.tsx @@ -0,0 +1,33 @@ +import type { TopUserRow } from "../api/client"; + +export function TopUsersTable({ rows }: { rows: TopUserRow[] }) { + if (rows.length === 0) { + return
Нет данных
; + } + return ( +
+ + + + + + + + + + {rows.map((r, i) => ( + + + + + + ))} + +
УчастникТестБалл
{r.name} + {r.poll} + + {r.value} +
+
+ ); +} diff --git a/stats-react/src/components/charts/BarChart.tsx b/stats-react/src/components/charts/BarChart.tsx new file mode 100644 index 0000000..c864401 --- /dev/null +++ b/stats-react/src/components/charts/BarChart.tsx @@ -0,0 +1,115 @@ +import { useState } from "react"; +import { ChartTooltip, type TooltipState } from "./ChartTooltip"; + +export interface BarDatum { + label: string; + value: number; +} + +// Горизонтальный bar chart: ≤24px толщина бара, 4px скруглённый конец, +// подпись значения у края, hover-тултип на каждом баре (сам бар — hit target). +export function BarChart({ + data, + colorFor, + formatValue = (v) => String(v), + xLabel, +}: { + data: BarDatum[]; + colorFor: (index: number) => string; + formatValue?: (v: number) => string; + xLabel?: string; +}) { + const [tooltip, setTooltip] = useState(null); + + if (data.length === 0) { + return
Нет данных
; + } + + const max = Math.max(...data.map((d) => d.value), 1); + + return ( +
+
+ {data.map((d, i) => { + const pct = Math.max((d.value / max) * 100, 2); + const color = colorFor(i); + return ( +
{ + const rect = e.currentTarget.getBoundingClientRect(); + const parent = e.currentTarget.parentElement!.parentElement!.getBoundingClientRect(); + setTooltip({ + x: rect.left - parent.left + rect.width * 0.3, + y: rect.top - parent.top, + label: d.label, + value: formatValue(d.value), + swatch: color, + }); + }} + onMouseLeave={() => setTooltip(null)} + onFocus={() => + setTooltip({ x: 0, y: 0, label: d.label, value: formatValue(d.value), swatch: color }) + } + onBlur={() => setTooltip(null)} + tabIndex={0} + > + + {d.label} + +
+
+
+ + {formatValue(d.value)} + +
+ ); + })} +
+ {xLabel && ( +
+ {xLabel} +
+ )} + +
+ ); +} diff --git a/stats-react/src/components/charts/ChartTooltip.tsx b/stats-react/src/components/charts/ChartTooltip.tsx new file mode 100644 index 0000000..09b84ec --- /dev/null +++ b/stats-react/src/components/charts/ChartTooltip.tsx @@ -0,0 +1,50 @@ +// Плавающий тултип, привязанный к позиции курсора внутри контейнера графика. +// Общий для всех типов графиков — см. dataviz: "значение — сильный элемент, подпись — вторичная". +export interface TooltipState { + x: number; + y: number; + label: string; + value: string; + swatch?: string; +} + +export function ChartTooltip({ tooltip }: { tooltip: TooltipState | null }) { + if (!tooltip) return null; + return ( +
+ {tooltip.swatch && ( + + )} + {tooltip.label} + {tooltip.value} +
+ ); +} diff --git a/stats-react/src/components/charts/LineChart.tsx b/stats-react/src/components/charts/LineChart.tsx new file mode 100644 index 0000000..25b4f4d --- /dev/null +++ b/stats-react/src/components/charts/LineChart.tsx @@ -0,0 +1,152 @@ +import { useRef, useState } from "react"; +import { ChartTooltip, type TooltipState } from "./ChartTooltip"; + +export interface LineDatum { + label: string; + value: number; +} + +const W = 1000; +const H = 300; +const PAD_L = 44; +const PAD_R = 16; +const PAD_T = 16; +const PAD_B = 48; + +// Линейный график для одного ряда: 2px линия, area fill ~10%, маркеры ≥8px +// с 2px кольцом в цвет поверхности, вертикальный crosshair с тултипом, +// прямая подпись значения на последней точке. +export function LineChart({ + data, + color = "var(--primary)", + formatValue = (v) => String(v), +}: { + data: LineDatum[]; + color?: string; + formatValue?: (v: number) => string; +}) { + const [hoverIdx, setHoverIdx] = useState(null); + const [tooltip, setTooltip] = useState(null); + const svgRef = useRef(null); + + if (data.length === 0) { + return
Нет данных
; + } + + const max = Math.max(...data.map((d) => d.value), 1); + const plotW = W - PAD_L - PAD_R; + const plotH = H - PAD_T - PAD_B; + const n = data.length; + + const xAt = (i: number) => PAD_L + (n === 1 ? plotW / 2 : (i / (n - 1)) * plotW); + const yAt = (v: number) => PAD_T + plotH - (v / max) * plotH; + + const points = data.map((d, i) => ({ x: xAt(i), y: yAt(d.value), ...d })); + const linePath = points.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" "); + const areaPath = `${linePath} L ${points[points.length - 1].x} ${PAD_T + plotH} L ${points[0].x} ${PAD_T + plotH} Z`; + + const gridLines = [0, 0.25, 0.5, 0.75, 1]; + + const handleMove = (e: React.PointerEvent) => { + const svg = svgRef.current; + if (!svg) return; + const rect = svg.getBoundingClientRect(); + const relX = ((e.clientX - rect.left) / rect.width) * W; + let nearest = 0; + let best = Infinity; + points.forEach((p, i) => { + const d = Math.abs(p.x - relX); + if (d < best) { + best = d; + nearest = i; + } + }); + setHoverIdx(nearest); + const p = points[nearest]; + setTooltip({ + x: (p.x / W) * rect.width, + y: (p.y / H) * rect.height, + label: p.label, + value: formatValue(p.value), + swatch: color, + }); + }; + + const last = points[points.length - 1]; + + return ( +
+ { + setHoverIdx(null); + setTooltip(null); + }} + > + {/* gridlines */} + {gridLines.map((g) => { + const y = PAD_T + plotH * (1 - g); + return ( + + + + {Math.round(max * g)} + + + ); + })} + + {/* area fill */} + + + {/* line */} + + + {/* crosshair */} + {hoverIdx !== null && ( + + )} + + {/* markers */} + {points.map((p, i) => ( + + ))} + + {/* end label */} + + {formatValue(last.value)} + + + {/* x labels */} + {points.map((p, i) => { + const anchor = i === 0 ? "start" : i === points.length - 1 ? "end" : "middle"; + return ( + + {p.label} + + ); + })} + + +
+ ); +} diff --git a/stats-react/src/components/charts/PartToWholeBar.tsx b/stats-react/src/components/charts/PartToWholeBar.tsx new file mode 100644 index 0000000..a271d93 --- /dev/null +++ b/stats-react/src/components/charts/PartToWholeBar.tsx @@ -0,0 +1,104 @@ +import { useState } from "react"; +import { ChartTooltip, type TooltipState } from "./ChartTooltip"; +import { categoricalColor } from "../../theme/palette"; + +export interface ShareDatum { + label: string; + count: number; +} + +// Part-to-whole как один горизонтальный 100%-stacked bar вместо pie chart — +// dataviz: "part-to-whole rides on the stacked bar chart; donut stays deprioritized". +// Легенда обязательна (>=2 сегментов), т.к. на самих сегментах подписи не всегда влезают. +// Токен-потолок категориальной палитры — 8 слотов (см. dataviz choosing-a-form: +// "series 7-8: token ceiling; past it, fold the tail into Other"). Данные уже +// отсортированы бэкендом по убыванию — берём топ-7 и сворачиваем остаток. +const MAX_SLOTS = 7; +const OTHER_COLOR_LIGHT = "#898781"; +const OTHER_COLOR_DARK = "#a7b2c0"; + +function foldTail(data: ShareDatum[]): ShareDatum[] { + if (data.length <= MAX_SLOTS + 1) return data; + const head = data.slice(0, MAX_SLOTS); + const tail = data.slice(MAX_SLOTS); + const otherCount = tail.reduce((s, d) => s + d.count, 0); + return [...head, { label: `Другое (${tail.length})`, count: otherCount }]; +} + +export function PartToWholeBar({ data, dark }: { data: ShareDatum[]; dark: boolean }) { + const [tooltip, setTooltip] = useState(null); + + if (data.length === 0) { + return
Нет данных
; + } + + const folded = foldTail(data); + const wasFolded = folded.length < data.length; + const isOther = (i: number) => wasFolded && i === folded.length - 1; + const total = folded.reduce((s, d) => s + d.count, 0) || 1; + + return ( +
+
+ {folded.map((d, i) => { + const pct = (d.count / total) * 100; + const color = isOther(i) ? (dark ? OTHER_COLOR_DARK : OTHER_COLOR_LIGHT) : categoricalColor(i, dark); + return ( +
{ + const rect = e.currentTarget.getBoundingClientRect(); + const parent = e.currentTarget.parentElement!.parentElement!.getBoundingClientRect(); + setTooltip({ + x: rect.left - parent.left + rect.width / 2, + y: rect.top - parent.top, + label: d.label, + value: `${d.count} (${pct.toFixed(1)}%)`, + swatch: color, + }); + }} + onMouseLeave={() => setTooltip(null)} + onFocus={() => + setTooltip({ x: 0, y: 0, label: d.label, value: `${d.count} (${pct.toFixed(1)}%)`, swatch: color }) + } + onBlur={() => setTooltip(null)} + /> + ); + })} +
+ +
+ {folded.map((d, i) => { + const pct = (d.count / total) * 100; + const color = isOther(i) ? (dark ? OTHER_COLOR_DARK : OTHER_COLOR_LIGHT) : categoricalColor(i, dark); + return ( + + + {d.label} — {d.count} ({pct.toFixed(1)}%) + + ); + })} +
+ + +
+ ); +} diff --git a/stats-react/src/components/icons.tsx b/stats-react/src/components/icons.tsx new file mode 100644 index 0000000..8bd3813 --- /dev/null +++ b/stats-react/src/components/icons.tsx @@ -0,0 +1,67 @@ +// Небольшой набор inline-SVG иконок (замена Material Icons из Flet-версии). +import type { SVGProps } from "react"; + +type P = SVGProps; +const base = (p: P) => ({ + width: "1em", + height: "1em", + viewBox: "0 0 24 24", + fill: "currentColor", + ...p, +}); + +export const IconAssignment = (p: P) => ( + + + +); + +export const IconQuiz = (p: P) => ( + + + +); + +export const IconPlayCircle = (p: P) => ( + + + +); + +export const IconBusiness = (p: P) => ( + + + +); + +export const IconFilterOff = (p: P) => ( + + + +); + +export const IconRefresh = (p: P) => ( + + + + +); + +export const IconBarChart = (p: P) => ( + + + +); + +export const IconInsights = (p: P) => ( + + + + +); + +export const IconCaretDown = (p: P) => ( + + + +); diff --git a/stats-react/src/main.tsx b/stats-react/src/main.tsx new file mode 100644 index 0000000..1155add --- /dev/null +++ b/stats-react/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "./styles/global.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/stats-react/src/styles/global.css b/stats-react/src/styles/global.css new file mode 100644 index 0000000..8438122 --- /dev/null +++ b/stats-react/src/styles/global.css @@ -0,0 +1,478 @@ +/* Сброс + переменные. Цвета — web-react/src/theme/themes.ts (designer.py), + категориальная палитра графиков — theme/palette.ts (провалидирована dataviz-скиллом). */ + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; +} + +:root { + color-scheme: light; + --primary: #f85a40; + --primary-dark: #d94530; + --primary-light: #ffeab7; + --accent: #ffc845; + --info: #007fbd; + --success: #85c446; + + --background: #f4f6f8; + --surface: #ffffff; + --surface-alt: #f0f2f5; + --text-primary: #1e2a3a; + --text-secondary: #5a6779; + --text-on-primary: #ffffff; + --border: #dde3ea; + + --chart-grid: #dde3ea; + --chart-tooltip-bg: #1e2a3a; + --chart-tooltip-text: #ffffff; +} + +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --primary: #f85a40; + --primary-dark: #d94530; + --primary-light: #ffeab7; + --accent: #ffc845; + --info: #4db3e8; + --success: #a2d97a; + + --background: #141a22; + --surface: #1e2733; + --surface-alt: #263140; + --text-primary: #f2f4f7; + --text-secondary: #a7b2c0; + --text-on-primary: #ffffff; + --border: #33404f; + + --chart-grid: #33404f; + --chart-tooltip-bg: #f2f4f7; + --chart-tooltip-text: #1e2a3a; + } +} + +body { + background: var(--background); + color: var(--text-primary); +} + +.muted { + color: var(--text-secondary); +} + +/* ── Layout ──────────────────────────────────────────────────────────── */ + +.page { + min-height: 100vh; + padding-bottom: 2rem; +} + +.app-header { + display: flex; + align-items: center; + gap: 0.75rem; + background: var(--surface); + padding: 0.9rem 1.25rem; + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 20; +} + +.app-header .brand { + color: var(--primary); + font-size: 1.35rem; + font-weight: 700; +} + +.app-header .divider { + width: 1px; + height: 1.4rem; + background: var(--border); +} + +.app-header .subtitle { + color: var(--text-primary); + font-size: 1rem; +} + +.container { + max-width: 1300px; + margin: 0 auto; + padding: 0 1rem; +} + +/* ── Summary tiles ───────────────────────────────────────────────────── */ + +.summary-row { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + padding: 1rem 0; +} + +.summary-tile { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.9rem 1rem; + min-width: 190px; + flex: 1 1 190px; +} + +.summary-tile .tile-head { + display: flex; + align-items: center; + gap: 0.4rem; + color: var(--text-secondary); + font-size: 0.72rem; + margin-bottom: 0.35rem; +} + +.summary-tile .tile-value { + font-size: 1.7rem; + font-weight: 700; + color: var(--text-primary); +} + +/* ── Filters ─────────────────────────────────────────────────────────── */ + +.filters-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 1rem; + margin-bottom: 1rem; +} + +.filters-label { + color: var(--text-secondary); + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; + margin-bottom: 0.5rem; + display: block; +} + +.filters-row { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + align-items: flex-end; +} + +.filters-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.7rem; +} + +/* ── Buttons ─────────────────────────────────────────────────────────── */ + +.btn { + border: none; + border-radius: 8px; + padding: 0.5rem 0.9rem; + font-size: 0.85rem; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +.btn-primary { + background: var(--primary); + color: var(--text-on-primary); +} + +.btn-primary:hover { + background: var(--primary-dark); +} + +.btn-ghost { + background: transparent; + color: var(--primary); +} + +.btn-ghost:hover { + background: var(--primary-light); +} + +/* ── Dropdown ────────────────────────────────────────────────────────── */ + +.field { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 190px; +} + +.field-label { + color: var(--text-secondary); + font-size: 0.72rem; + font-weight: 500; +} + +.dropdown-control { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.55rem 0.75rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + cursor: pointer; + min-height: 2.3rem; +} + +.dropdown-value { + font-size: 0.85rem; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dropdown-value.placeholder { + color: var(--text-secondary); + font-style: italic; +} + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + padding: 1rem; +} + +.modal { + background: var(--surface); + border-radius: 12px; + padding: 1rem; + width: 100%; + max-width: 380px; + max-height: 70vh; + display: flex; + flex-direction: column; +} + +.modal-title { + font-size: 0.95rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.6rem; +} + +.dropdown-search { + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.5rem 0.7rem; + font-size: 0.85rem; + background: var(--background); + color: var(--text-primary); +} + +.divider { + border: none; + border-top: 1px solid var(--border); + margin: 0.6rem 0; +} + +.sd-list { + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.dropdown-option { + padding: 0.55rem 0.5rem; + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; + color: var(--text-primary); +} + +.dropdown-option:hover { + background: var(--primary-light); +} + +.dropdown-option.active { + background: var(--primary); + color: var(--text-on-primary); +} + +.dropdown-empty { + color: var(--text-secondary); + font-size: 0.85rem; + padding: 0.5rem; + text-align: center; +} + +/* ── Tabs ────────────────────────────────────────────────────────────── */ + +.tabs-bar { + display: flex; + gap: 0.25rem; + border-bottom: 1px solid var(--border); + margin-bottom: 1rem; +} + +.tab-btn { + background: none; + border: none; + padding: 0.7rem 1rem; + font-size: 0.88rem; + color: var(--text-secondary); + cursor: pointer; + display: flex; + align-items: center; + gap: 0.4rem; + border-bottom: 2px solid transparent; + margin-bottom: -1px; +} + +.tab-btn.active { + color: var(--primary); + border-bottom-color: var(--primary); + font-weight: 600; +} + +/* ── Chart cards ─────────────────────────────────────────────────────── */ + +.chart-grid { + display: flex; + flex-direction: column; + gap: 1rem; + padding-bottom: 1.5rem; +} + +.chart-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 1rem; +} + +.chart-card-title { + font-size: 0.92rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.7rem; +} + +.chart-empty { + color: var(--text-secondary); + font-style: italic; + font-size: 0.85rem; + padding: 2rem 0; + text-align: center; +} + +.chart-loading { + padding: 2rem 0; + text-align: center; + color: var(--text-secondary); + font-size: 0.85rem; +} + +/* ── Legend ──────────────────────────────────────────────────────────── */ + +.chart-legend { + display: flex; + flex-wrap: wrap; + gap: 0.6rem 1rem; + margin-top: 0.6rem; + font-size: 0.8rem; + color: var(--text-secondary); +} + +.legend-item { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.legend-swatch { + width: 10px; + height: 10px; + border-radius: 3px; + flex-shrink: 0; +} + +/* ── Table ───────────────────────────────────────────────────────────── */ + +.stats-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} + +.stats-table th { + text-align: left; + color: var(--text-secondary); + font-weight: 600; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.02em; + padding: 0.5rem 0.7rem; + border-bottom: 1px solid var(--border); +} + +.stats-table td { + padding: 0.55rem 0.7rem; + border-bottom: 1px solid var(--border); +} + +.stats-table tr:last-child td { + border-bottom: none; +} + +.stats-table .value-cell { + font-weight: 700; + color: var(--primary); + font-variant-numeric: tabular-nums; +} + +.stats-table .poll-cell { + color: var(--text-secondary); + max-width: 260px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ── Spinner ─────────────────────────────────────────────────────────── */ + +.spinner { + width: 22px; + height: 22px; + border: 3px solid var(--border); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.7s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.spinner-center { + display: flex; + justify-content: center; + padding: 2rem; +} diff --git a/stats-react/src/theme/palette.ts b/stats-react/src/theme/palette.ts new file mode 100644 index 0000000..024a72c --- /dev/null +++ b/stats-react/src/theme/palette.ts @@ -0,0 +1,43 @@ +// Палитра дашборда. Бренд-цвета — из web-react/src/theme/themes.ts (designer.py). +// Категориальная 8-цветная палитра для графиков (pie/multi-bar) провалидирована +// dataviz-скиллом (scripts/validate_palette.js) на CVD-безопасность в light и +// dark режимах — порядок слотов и hex не менять без повторной валидации. +// Единственная замена относительно референсного набора: слот "orange" — бренд-оранжевый. + +export const BRAND = { + primary: "#F85A40", + primaryDark: "#D94530", + primaryLight: "#FFEAB7", + accent: "#FFC845", + info: "#007FBD", + success: "#85C446", +}; + +// [PASS] все проверки, light: surface #FFFFFF (WARN contrast на magenta/yellow/aqua — компенсируется прямыми подписями значений) +export const CATEGORICAL_LIGHT = [ + "#2a78d6", // blue + "#008300", // green + "#e87ba4", // magenta + "#eda100", // yellow + "#1baf7a", // aqua + "#F85A40", // orange (бренд) + "#4a3aa7", // violet + "#e34948", // red +]; + +// [PASS] все проверки, dark: surface #1a1a19 +export const CATEGORICAL_DARK = [ + "#3987e5", + "#008300", + "#d55181", + "#c98500", + "#199e70", + "#D94530", // orange (бренд, тёмный шаг) + "#9085e9", + "#e66767", +]; + +export function categoricalColor(index: number, dark: boolean): string { + const arr = dark ? CATEGORICAL_DARK : CATEGORICAL_LIGHT; + return arr[index % arr.length]; +} diff --git a/stats-react/src/theme/useIsDark.ts b/stats-react/src/theme/useIsDark.ts new file mode 100644 index 0000000..afc4698 --- /dev/null +++ b/stats-react/src/theme/useIsDark.ts @@ -0,0 +1,18 @@ +import { useEffect, useState } from "react"; + +// Категориальная палитра графиков зависит от режима (см. palette.ts) — +// нужно знать активный prefers-color-scheme, чтобы выбрать нужный набор. +export function useIsDark(): boolean { + const [dark, setDark] = useState( + () => typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches, + ); + + useEffect(() => { + const mq = window.matchMedia("(prefers-color-scheme: dark)"); + const onChange = () => setDark(mq.matches); + mq.addEventListener("change", onChange); + return () => mq.removeEventListener("change", onChange); + }, []); + + return dark; +} diff --git a/stats-react/tsconfig.json b/stats-react/tsconfig.json new file mode 100644 index 0000000..d109555 --- /dev/null +++ b/stats-react/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/stats-react/vite.config.ts b/stats-react/vite.config.ts new file mode 100644 index 0000000..1f4edc4 --- /dev/null +++ b/stats-react/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// В dev-режиме проксируем /api на запущенный локально API (docker compose up -d api). +// В проде эту роль выполняет nginx (stats-react/nginx.conf). +export default defineConfig({ + plugins: [react()], + server: { + port: 5174, + proxy: { + "/api": { + target: process.env.API_URL || "http://localhost:8000", + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, ""), + }, + }, + }, +}); diff --git a/stats/api_client.py b/stats/api_client.py deleted file mode 100644 index 06bd07c..0000000 --- a/stats/api_client.py +++ /dev/null @@ -1,209 +0,0 @@ -""" -api_client.py — HTTP-клиент для stats-приложения. -Запросы идут к основному API без авторизации. -""" -import os -from typing import Optional -import httpx - -API_URL = os.getenv("API_URL", "http://api:8000") -_TIMEOUT = 15 - - -async def get_summary() -> dict: - try: - async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client: - resp = await client.get("/stats/summary") - if resp.status_code == 200: - return resp.json() - except Exception: - pass - return {"total_responses": 0, "total_polls": 0, "active_polls": 0, "orgs_active": 0} - - -async def get_years() -> list[int]: - try: - async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client: - resp = await client.get("/stats/years") - if resp.status_code == 200: - return resp.json() - except Exception: - pass - return [] - - -async def get_chart_data( - group_by: str, - poll_id: Optional[str] = None, - org_id: Optional[str] = None, - group_id: Optional[str] = None, - year: Optional[int] = None, -) -> list[dict]: - params: dict = {"group_by": group_by} - if poll_id: - params["poll_id"] = poll_id - if org_id: - params["org_id"] = org_id - if group_id: - params["group_id"] = group_id - if year: - params["year"] = year - try: - async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client: - resp = await client.get("/stats/responses", params=params) - if resp.status_code == 200: - return resp.json() - except Exception: - pass - return [] - - -async def get_polls() -> list[dict]: - try: - async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client: - resp = await client.get("/polls/") - if resp.status_code == 200: - return resp.json() - except Exception: - pass - return [] - - -async def get_organizations() -> list[dict]: - try: - async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client: - resp = await client.get("/organizations/") - if resp.status_code == 200: - return resp.json() - except Exception: - pass - return [] - - -async def get_groups() -> list[dict]: - try: - async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client: - resp = await client.get("/groups/") - if resp.status_code == 200: - return resp.json() - except Exception: - pass - return [] - - -async def get_dimensions(poll_id: Optional[str] = None) -> list[str]: - params = {} - if poll_id: - params["poll_id"] = poll_id - try: - async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client: - resp = await client.get("/stats/results/dimensions", params=params) - if resp.status_code == 200: - return resp.json() - except Exception: - pass - return [] - - -async def get_avg_by_dimension( - poll_id: Optional[str] = None, - org_id: Optional[str] = None, - group_id: Optional[str] = None, - year: Optional[int] = None, -) -> list[dict]: - params = {} - if poll_id: params["poll_id"] = poll_id - if org_id: params["org_id"] = org_id - if group_id: params["group_id"] = group_id - if year: params["year"] = year - try: - async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client: - resp = await client.get("/stats/results/avg-by-dimension", params=params) - if resp.status_code == 200: - return resp.json() - except Exception: - pass - return [] - - -async def get_leading_type( - poll_id: Optional[str] = None, - org_id: Optional[str] = None, - group_id: Optional[str] = None, - year: Optional[int] = None, -) -> list[dict]: - params = {} - if poll_id: params["poll_id"] = poll_id - if org_id: params["org_id"] = org_id - if group_id: params["group_id"] = group_id - if year: params["year"] = year - try: - async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client: - resp = await client.get("/stats/results/leading-type", params=params) - if resp.status_code == 200: - return resp.json() - except Exception: - pass - return [] - - -async def get_compare_groups( - dimension_name: str, - by: str = "organization", - poll_id: Optional[str] = None, - year: Optional[int] = None, -) -> list[dict]: - params = {"dimension_name": dimension_name, "by": by} - if poll_id: params["poll_id"] = poll_id - if year: params["year"] = year - try: - async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client: - resp = await client.get("/stats/results/compare-groups", params=params) - if resp.status_code == 200: - return resp.json() - except Exception: - pass - return [] - - -async def get_top_users( - dimension_name: str, - poll_id: Optional[str] = None, - org_id: Optional[str] = None, - group_id: Optional[str] = None, - year: Optional[int] = None, - limit: int = 10, -) -> list[dict]: - params = {"dimension_name": dimension_name, "limit": limit} - if poll_id: params["poll_id"] = poll_id - if org_id: params["org_id"] = org_id - if group_id: params["group_id"] = group_id - if year: params["year"] = year - try: - async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client: - resp = await client.get("/stats/results/top-users", params=params) - if resp.status_code == 200: - return resp.json() - except Exception: - pass - return [] - - -async def get_dimension_trend( - dimension_name: str, - poll_id: Optional[str] = None, - org_id: Optional[str] = None, - group_id: Optional[str] = None, -) -> list[dict]: - params = {"dimension_name": dimension_name} - if poll_id: params["poll_id"] = poll_id - if org_id: params["org_id"] = org_id - if group_id: params["group_id"] = group_id - try: - async with httpx.AsyncClient(base_url=API_URL, timeout=_TIMEOUT) as client: - resp = await client.get("/stats/results/dimension-trend", params=params) - if resp.status_code == 200: - return resp.json() - except Exception: - pass - return [] diff --git a/stats/assets/icons/IMG_4533.png b/stats/assets/icons/IMG_4533.png deleted file mode 100644 index 7c9703b..0000000 Binary files a/stats/assets/icons/IMG_4533.png and /dev/null differ diff --git a/stats/assets/icons/README b/stats/assets/icons/README deleted file mode 100644 index 709aa22..0000000 --- a/stats/assets/icons/README +++ /dev/null @@ -1,3 +0,0 @@ -# Положи сюда своё изображение с именем loading-animation.png -# Это заменит мигающий логотип Flet при загрузке страницы. -# Рекомендуемый размер: PNG, минимум 200x200 px. diff --git a/stats/assets/icons/clipboard-question_16542596.svg b/stats/assets/icons/clipboard-question_16542596.svg deleted file mode 100644 index 803761e..0000000 --- a/stats/assets/icons/clipboard-question_16542596.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/stats/assets/icons/leaderboard-trophy_14227576.svg b/stats/assets/icons/leaderboard-trophy_14227576.svg deleted file mode 100644 index 011f4e3..0000000 --- a/stats/assets/icons/leaderboard-trophy_14227576.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/stats/assets/icons/loading-animation.png b/stats/assets/icons/loading-animation.png deleted file mode 100644 index 91c0a0c..0000000 Binary files a/stats/assets/icons/loading-animation.png and /dev/null differ diff --git a/stats/dashboard.py b/stats/dashboard.py deleted file mode 100644 index ed6fd84..0000000 --- a/stats/dashboard.py +++ /dev/null @@ -1,745 +0,0 @@ -""" -dashboard.py — главный вид статистического дашборда. -Графики строятся через matplotlib и отображаются как изображения (ft.Image). -""" -import asyncio -import base64 -import io -import math - -import flet as ft -import matplotlib -import matplotlib.colors -import matplotlib.pyplot as plt -import matplotlib.font_manager as fm - -import designer as ds -import api_client as ac - -matplotlib.use("Agg") # без GUI, только в память - -# Цвета графиков -_PALETTE = [ - "#F85A40", "#007FBD", "#85C446", "#FFC845", - "#D94530", "#5A6779", "#006AA3", "#4A9E21", -] - - -def _wrap_label(text: str, max_len: int = 18) -> str: - """Перенос длинного текста через \n для подписей осей.""" - if len(text) <= max_len: - return text - words = text.split() - lines, line = [], "" - for w in words: - if len(line) + len(w) + 1 <= max_len: - line = f"{line} {w}".strip() - else: - if line: - lines.append(line) - line = w - if line: - lines.append(line) - return "\n".join(lines) - - -def _build_chart_image(data: list[dict], title: str, color: str) -> bytes | None: - """Строит горизонтальную bar chart через matplotlib, возвращает PNG bytes.""" - if not data: - return None - - labels = [_wrap_label(d["label"]) for d in data] - counts = [d["count"] for d in data] - n = len(labels) - - fig_h = max(4.5, n * 0.55 + 1.2) - fig, ax = plt.subplots(figsize=(10, fig_h)) - fig.patch.set_facecolor("#FFFFFF") - ax.set_facecolor("#F4F6F8") - - bars = ax.barh( - range(n), counts, - color=color, - height=0.6, - edgecolor="white", - linewidth=0.5, - ) - - # Подписи значений - for bar, v in zip(bars, counts): - ax.text( - v + max(counts) * 0.01, - bar.get_y() + bar.get_height() / 2, - str(v), - va="center", - ha="left", - fontsize=11, - color="#1E2A3A", - ) - - ax.set_yticks(range(n)) - ax.set_yticklabels(labels, fontsize=11, color="#5A6779") - ax.invert_yaxis() - ax.set_xlabel("Количество ответов", fontsize=11, color="#5A6779") - ax.xaxis.set_tick_params(labelsize=11, colors="#5A6779") - ax.spines["top"].set_visible(False) - ax.spines["right"].set_visible(False) - ax.spines["left"].set_color("#DDE3EA") - ax.spines["bottom"].set_color("#DDE3EA") - ax.set_xlim(0, max(counts) * 1.18 if max(counts) > 0 else 1) - ax.grid(axis="x", color="#DDE3EA", linewidth=0.5, linestyle="--") - - plt.tight_layout(pad=1.2) - - buf = io.BytesIO() - fig.savefig(buf, format="png", dpi=130, bbox_inches="tight") - plt.close(fig) - buf.seek(0) - return buf.read() - - -def _build_line_chart_image(data: list[dict]) -> bytes | None: - """Строит линейный график для данных по месяцам.""" - if not data: - return None - - labels = [d["label"] for d in data] - counts = [d["count"] for d in data] - n = len(labels) - - fig, ax = plt.subplots(figsize=(max(10, n * 1.2), 5.5)) - fig.patch.set_facecolor("#FFFFFF") - ax.set_facecolor("#F4F6F8") - - ax.plot(range(n), counts, color="#F85A40", linewidth=2.5, marker="o", - markersize=7, markerfacecolor="#F85A40", markeredgecolor="white", - markeredgewidth=1.5) - ax.fill_between(range(n), counts, alpha=0.12, color="#F85A40") - - for i, v in enumerate(counts): - ax.text(i, v + max(counts) * 0.03, str(v), ha="center", - fontsize=11, color="#1E2A3A") - - ax.set_xticks(range(n)) - ax.set_xticklabels(labels, rotation=35, ha="right", fontsize=11, color="#5A6779") - ax.yaxis.set_tick_params(labelsize=11, colors="#5A6779") - ax.set_ylabel("Ответов", fontsize=11, color="#5A6779") - ax.spines["top"].set_visible(False) - ax.spines["right"].set_visible(False) - ax.spines["left"].set_color("#DDE3EA") - ax.spines["bottom"].set_color("#DDE3EA") - ax.set_ylim(0, max(counts) * 1.25) - ax.grid(axis="y", color="#DDE3EA", linewidth=0.5, linestyle="--") - - plt.tight_layout(pad=1.2) - buf = io.BytesIO() - fig.savefig(buf, format="png", dpi=130, bbox_inches="tight") - plt.close(fig) - buf.seek(0) - return buf.read() - - -def _build_avg_bar(data: list[dict], xlabel: str = "Средний балл") -> bytes | None: - """Горизонтальный bar chart со средними баллами (поле 'avg').""" - if not data: - return None - labels = [_wrap_label(d["label"]) for d in data] - values = [d["avg"] for d in data] - n = len(labels) - colors = [_PALETTE[i % len(_PALETTE)] for i in range(n)] - - fig_h = max(5.5, n * 0.65 + 1.5) - fig, ax = plt.subplots(figsize=(14, fig_h)) - fig.patch.set_facecolor("#FFFFFF") - ax.set_facecolor("#F4F6F8") - - bars = ax.barh(range(n), values, color=colors, height=0.6, edgecolor="white", linewidth=0.5) - for bar, v in zip(bars, values): - ax.text(v + max(values) * 0.01, bar.get_y() + bar.get_height() / 2, - f"{v:.1f}", va="center", ha="left", fontsize=11, color="#1E2A3A") - - ax.set_yticks(range(n)) - ax.set_yticklabels(labels, fontsize=11, color="#5A6779") - ax.invert_yaxis() - ax.set_xlabel(xlabel, fontsize=11, color="#5A6779") - ax.xaxis.set_tick_params(labelsize=11, colors="#5A6779") - ax.spines["top"].set_visible(False) - ax.spines["right"].set_visible(False) - ax.spines["left"].set_color("#DDE3EA") - ax.spines["bottom"].set_color("#DDE3EA") - ax.set_xlim(0, max(values) * 1.18 if max(values) > 0 else 1) - ax.grid(axis="x", color="#DDE3EA", linewidth=0.5, linestyle="--") - plt.tight_layout(pad=1.2) - buf = io.BytesIO() - fig.savefig(buf, format="png", dpi=130, bbox_inches="tight") - plt.close(fig) - buf.seek(0) - return buf.read() - - -def _build_pie_chart(data: list[dict]) -> bytes | None: - """Круговая диаграмма распределения ведущего типа.""" - if not data: - return None - labels = [d["label"] for d in data] - values = [d["count"] for d in data] - n_colors = len(labels) - if n_colors <= len(_PALETTE): - colors = _PALETTE[:n_colors] - else: - # Генерируем уникальные цвета через hsv colormap - cmap = matplotlib.colormaps["hsv"] - colors = [matplotlib.colors.to_hex(cmap(i / n_colors)) for i in range(n_colors)] - - # Размер фигуры растёт с числом элементов для читаемой легенды - legend_rows = math.ceil(n_colors / 2) - fig_h = max(7, legend_rows * 0.35 + 5) - fig, ax = plt.subplots(figsize=(11, fig_h)) - fig.patch.set_facecolor("#FFFFFF") - wedges, texts, autotexts = ax.pie( - values, - labels=None, - autopct="%1.1f%%", - colors=colors, - startangle=140, - wedgeprops=dict(edgecolor="white", linewidth=1.5), - pctdistance=0.78, - ) - for t in autotexts: - t.set_fontsize(9 if n_colors > 15 else 10) - t.set_color("#1E2A3A") - ncol = 3 if n_colors > 20 else 2 - ax.legend( - wedges, - [f"{l} ({v})" for l, v in zip(labels, values)], - loc="lower center", - bbox_to_anchor=(0.5, -0.05 - legend_rows * 0.04), - ncol=ncol, - fontsize=9, - frameon=False, - ) - plt.tight_layout(pad=1.5) - buf = io.BytesIO() - fig.savefig(buf, format="png", dpi=130, bbox_inches="tight") - plt.close(fig) - buf.seek(0) - return buf.read() - - -def _build_avg_line(data: list[dict]) -> bytes | None: - """Линейный тренд средних баллов по месяцам (поле 'avg').""" - if not data: - return None - labels = [d["label"] for d in data] - values = [d["avg"] for d in data] - n = len(labels) - - fig, ax = plt.subplots(figsize=(max(10, n * 1.2), 5.5)) - fig.patch.set_facecolor("#FFFFFF") - ax.set_facecolor("#F4F6F8") - ax.plot(range(n), values, color="#F85A40", linewidth=2.5, marker="o", - markersize=7, markerfacecolor="#F85A40", markeredgecolor="white", - markeredgewidth=1.5) - ax.fill_between(range(n), values, alpha=0.12, color="#F85A40") - for i, v in enumerate(values): - ax.text(i, v + max(values) * 0.03, f"{v:.1f}", ha="center", fontsize=11, color="#1E2A3A") - ax.set_xticks(range(n)) - ax.set_xticklabels(labels, rotation=35, ha="right", fontsize=11, color="#5A6779") - ax.yaxis.set_tick_params(labelsize=11, colors="#5A6779") - ax.set_ylabel("Средний балл", fontsize=11, color="#5A6779") - ax.spines["top"].set_visible(False) - ax.spines["right"].set_visible(False) - ax.spines["left"].set_color("#DDE3EA") - ax.spines["bottom"].set_color("#DDE3EA") - ax.set_ylim(0, max(values) * 1.25 if max(values) > 0 else 1) - ax.grid(axis="y", color="#DDE3EA", linewidth=0.5, linestyle="--") - plt.tight_layout(pad=1.2) - buf = io.BytesIO() - fig.savefig(buf, format="png", dpi=130, bbox_inches="tight") - plt.close(fig) - buf.seek(0) - return buf.read() - - -def _image_or_placeholder(img_bytes: bytes | None, width: int = 1100) -> ft.Control: - if img_bytes: - b64 = base64.b64encode(img_bytes).decode() - return ft.Image( - src=f"data:image/png;base64,{b64}", - fit=ft.BoxFit.FIT_WIDTH, - width=width, - ) - return ft.Container( - content=ft.Text("Нет данных", color=ds.colors.text_secondary, size=ds.s(13), italic=True), - alignment=ft.Alignment(0, 0), - height=ds.s(120), - width=width, - ) - - -class DashboardView(ft.Column): - def __init__(self, page: ft.Page): - self._page = page - - # ── Общие фильтры ──────────────────────────────────────────────────── - self._poll_dd = ds.SearchableDropdown(label="Тест", hint_text="Все тесты") - self._poll_dd.on_select = self._on_filter_change - - self._org_dd = ds.SearchableDropdown(label="Организация", hint_text="Все организации") - self._org_dd.on_select = self._on_filter_change - - self._group_dd = ds.SearchableDropdown(label="Группа", hint_text="Все группы") - self._group_dd.on_select = self._on_filter_change - - self._year_dd = ds.SearchableDropdown(label="Год", hint_text="Все годы") - self._year_dd.on_select = self._on_filter_change - - self._reset_btn = ft.TextButton( - "Сбросить фильтры", - icon=ft.Icons.FILTER_ALT_OFF, - style=ft.ButtonStyle(color=ds.colors.primary), - on_click=self._reset_filters, - ) - - self._refresh_btn = ft.FilledButton( - "Обновить", - icon=ft.Icons.REFRESH, - style=ft.ButtonStyle( - bgcolor={ft.ControlState.DEFAULT: ds.colors.primary, - ft.ControlState.HOVERED: ds.colors.primary_dark}, - color=ds.colors.text_on_primary, - shape=ft.RoundedRectangleBorder(radius=ds.s(8)), - ), - on_click=lambda e: page.run_task(self._refresh_current_tab), - ) - - # ── Summary карточки ───────────────────────────────────────────────── - self._card_total = ft.Text("—", color=ds.colors.text_primary, size=ds.s(26), weight=ft.FontWeight.BOLD) - self._card_polls = ft.Text("—", color=ds.colors.text_primary, size=ds.s(26), weight=ft.FontWeight.BOLD) - self._card_active = ft.Text("—", color=ds.colors.text_primary, size=ds.s(26), weight=ft.FontWeight.BOLD) - self._card_orgs = ft.Text("—", color=ds.colors.text_primary, size=ds.s(26), weight=ft.FontWeight.BOLD) - - # ── Таб 1: графики по прохождениям ─────────────────────────────────── - self._chart_poll = self._make_chart_box() - self._chart_org = self._make_chart_box() - self._chart_group = self._make_chart_box() - self._chart_month = self._make_chart_box() - - # ── Таб 2: результаты — фильтр по оси ──────────────────────────────── - self._dim_dd = ds.SearchableDropdown(label="Ось/Тип", hint_text="Выберите ось") - self._dim_dd.on_select = self._on_results_filter_change - - self._compare_by_dd = ds.SearchableDropdown(label="Сравнение по", hint_text="Организациям") - self._compare_by_dd.options = [ - ft.DropdownOption(key="organization", text="Организациям"), - ft.DropdownOption(key="group", text="Группам"), - ] - self._compare_by_dd.value = "organization" - self._compare_by_dd.on_select = self._on_results_filter_change - - self._chart_avg_dim = self._make_chart_box() - self._chart_leading = self._make_chart_box() - self._chart_compare = self._make_chart_box() - self._chart_trend = self._make_chart_box() - - # Таблица топ-пользователей - self._top_table = ft.DataTable( - columns=[ - ft.DataColumn(ft.Text("Участник", size=ds.s(12), weight=ft.FontWeight.W_600)), - ft.DataColumn(ft.Text("Тест", size=ds.s(12), weight=ft.FontWeight.W_600)), - ft.DataColumn(ft.Text("Балл", size=ds.s(12), weight=ft.FontWeight.W_600), numeric=True), - ], - rows=[], - border=ft.border.all(1, ds.colors.border), - border_radius=ds.s(8), - horizontal_lines=ft.BorderSide(1, ds.colors.border), - column_spacing=ds.s(20), - ) - - # ── Навигация по табам ──────────────────────────────────────────────── - self._current_tab = 0 - - # ── Содержимое табов ────────────────────────────────────────────────── - self._tab1_content = ft.Column( - spacing=ds.s(16), - controls=[ - ft.Container( - content=self._chart_card_wrap("По тестам", self._chart_poll), - padding=ft.padding.symmetric(horizontal=ds.s(16)), - ), - ft.Container( - content=self._chart_card_wrap("По организациям", self._chart_org), - padding=ft.padding.symmetric(horizontal=ds.s(16)), - ), - ft.Container( - content=self._chart_card_wrap("По группам", self._chart_group), - padding=ft.padding.symmetric(horizontal=ds.s(16)), - ), - ft.Container( - content=self._chart_card_wrap("Динамика по месяцам", self._chart_month), - padding=ft.padding.symmetric(horizontal=ds.s(16)), - margin=ft.margin.only(bottom=ds.s(24)), - ), - ], - ) - - self._tab2_content = ft.Column( - spacing=ds.s(16), - controls=[ - # Дополнительный фильтр по оси - ft.Container( - content=ft.Column( - spacing=ds.s(8), - controls=[ - ft.Text("Фильтр по оси/типу", color=ds.colors.text_secondary, - size=ds.s(12), weight=ft.FontWeight.W_600), - ft.Row( - wrap=True, spacing=ds.s(10), run_spacing=ds.s(10), - controls=[self._dim_dd, self._compare_by_dd], - ), - ], - ), - bgcolor=ds.colors.surface, - border_radius=ds.s(12), - padding=ft.padding.all(ds.s(16)), - margin=ft.margin.symmetric(horizontal=ds.s(16)), - border=ft.border.all(1, ds.colors.border), - ), - ft.Container( - content=self._chart_card_wrap("Средний балл по осям", self._chart_avg_dim), - padding=ft.padding.symmetric(horizontal=ds.s(16)), - ), - ft.Container( - content=self._chart_card_wrap("Распределение ведущего типа", self._chart_leading), - padding=ft.padding.symmetric(horizontal=ds.s(16)), - ), - ft.Container( - content=self._chart_card_wrap("Сравнение по группам/организациям", self._chart_compare), - padding=ft.padding.symmetric(horizontal=ds.s(16)), - ), - ft.Container( - content=self._chart_card_wrap("Динамика среднего балла", self._chart_trend), - padding=ft.padding.symmetric(horizontal=ds.s(16)), - ), - # Топ участников - ft.Container( - content=ft.Column( - spacing=ds.s(10), - controls=[ - ft.Text("Топ-10 участников по выбранной оси", - color=ds.colors.text_primary, size=ds.s(14), - weight=ft.FontWeight.W_600), - ft.Container(content=self._top_table, expand=True), - ], - ), - bgcolor=ds.colors.surface, - border_radius=ds.s(12), - padding=ft.padding.all(ds.s(16)), - margin=ft.margin.only(left=ds.s(16), right=ds.s(16), bottom=ds.s(24)), - border=ft.border.all(1, ds.colors.border), - ), - ], - ) - - # ── Tabs (TabBar + ручное переключение) ────────────────────────────── - self._tab_body = ft.Container(content=self._tab1_content, expand=True) - self._tabs_ctrl = ft.Tabs( - content=ft.Column( - expand=True, - spacing=0, - controls=[ - ft.TabBar( - tabs=[ - ft.Tab(label="Прохождения", icon=ft.Icons.BAR_CHART), - ft.Tab(label="Результаты тестов", icon=ft.Icons.INSIGHTS), - ], - scrollable=False, - ), - self._tab_body, - ], - ), - length=2, - selected_index=0, - on_change=self._on_tab_change, - expand=True, - ) - - super().__init__( - spacing=ds.s(16), - scroll=ft.ScrollMode.AUTO, - expand=True, - controls=[ - # Заголовок - ft.Container( - content=ft.Row( - controls=[ - ft.Text("ЦОПП", color=ds.colors.primary, size=ds.s(22), - weight=ft.FontWeight.BOLD), - ft.VerticalDivider(width=ds.s(16), color=ds.colors.border), - ft.Text("Статистика прохождений", color=ds.colors.text_primary, - size=ds.s(16)), - ], - vertical_alignment=ft.CrossAxisAlignment.CENTER, - ), - bgcolor=ds.colors.surface, - padding=ft.padding.symmetric(horizontal=ds.s(20), vertical=ds.s(14)), - border=ft.border.only(bottom=ft.BorderSide(1, ds.colors.border)), - ), - # Summary карточки - ft.Container( - content=ft.Row( - wrap=True, spacing=ds.s(12), run_spacing=ds.s(12), - controls=[ - self._summary_tile("Всего ответов", self._card_total, - ft.Icons.ASSIGNMENT_TURNED_IN, ds.colors.primary), - self._summary_tile("Тестов всего", self._card_polls, - ft.Icons.QUIZ, ds.colors.info), - self._summary_tile("Активных тестов", self._card_active, - ft.Icons.PLAY_CIRCLE, ds.colors.success), - self._summary_tile("Организаций с ответами", self._card_orgs, - ft.Icons.BUSINESS, ds.colors.accent), - ], - ), - padding=ft.padding.symmetric(horizontal=ds.s(16)), - ), - # Общие фильтры - ft.Container( - content=ft.Column( - spacing=ds.s(10), - controls=[ - ft.Text("Фильтры", color=ds.colors.text_secondary, - size=ds.s(12), weight=ft.FontWeight.W_600), - ft.Row( - wrap=True, spacing=ds.s(10), run_spacing=ds.s(10), - controls=[self._poll_dd, self._org_dd, - self._group_dd, self._year_dd], - ), - ft.Row(spacing=ds.s(8), - controls=[self._reset_btn, self._refresh_btn]), - ], - ), - bgcolor=ds.colors.surface, - border_radius=ds.s(12), - padding=ft.padding.all(ds.s(16)), - margin=ft.margin.symmetric(horizontal=ds.s(16)), - border=ft.border.all(1, ds.colors.border), - ), - # Вкладки - self._tabs_ctrl, - ], - ) - - # ── helpers ───────────────────────────────────────────────────────────── - - def _make_chart_box(self) -> ft.Container: - return ft.Container( - content=ft.ProgressRing(width=ds.s(24), height=ds.s(24)), - alignment=ft.Alignment(0, 0), - expand=True, - ) - - def _summary_tile(self, title: str, value_text: ft.Text, icon: str, color: str) -> ft.Container: - return ft.Container( - content=ft.Column( - spacing=ds.s(4), tight=True, - controls=[ - ft.Row(spacing=ds.s(6), controls=[ - ft.Icon(icon, color=color, size=ds.s(18)), - ft.Text(title, color=ds.colors.text_secondary, size=ds.s(11)), - ]), - value_text, - ], - ), - bgcolor=ds.colors.surface, - border_radius=ds.s(10), - padding=ft.padding.all(ds.s(14)), - border=ft.border.all(1, ds.colors.border), - width=ds.s(200), - ) - - def _chart_card_wrap(self, title: str, body: ft.Container) -> ft.Container: - return ft.Container( - content=ft.Column( - spacing=ds.s(10), tight=True, - controls=[ - ft.Text(title, color=ds.colors.text_primary, size=ds.s(14), - weight=ft.FontWeight.W_600), - body, - ], - ), - bgcolor=ds.colors.surface, - border_radius=ds.s(12), - padding=ft.padding.all(ds.s(16)), - border=ft.border.all(1, ds.colors.border), - expand=True, - ) - - # ── mounting ───────────────────────────────────────────────────────────── - - def _chart_width(self) -> int: - """Ширина графика = ширина страницы минус отступы.""" - try: - w = self.page.width or 1200 - except Exception: - w = 1200 - return max(600, int(w) - 80) - - def did_mount(self): - self.page.run_task(self._initial_load) - - async def _initial_load(self): - polls, orgs, groups, years, summary, dimensions = await asyncio.gather( - ac.get_polls(), - ac.get_organizations(), - ac.get_groups(), - ac.get_years(), - ac.get_summary(), - ac.get_dimensions(), - ) - - _dd_opt = lambda key, text: ft.DropdownOption(key=key, text=text) - - self._poll_dd.options = [_dd_opt(p["id"], p["title"]) for p in (polls or [])] - self._org_dd.options = [_dd_opt(o["id"], o["name_organization"]) for o in (orgs or [])] - self._group_dd.options = [_dd_opt(g["id"], g["name_group"]) for g in (groups or [])] - self._year_dd.options = [_dd_opt(str(y), str(y)) for y in (years or [])] - self._dim_dd.options = [_dd_opt(d, d) for d in (dimensions or [])] - if dimensions: - self._dim_dd.value = dimensions[0] - - self._card_total.value = str(summary.get("total_responses", "—")) - self._card_polls.value = str(summary.get("total_polls", "—")) - self._card_active.value = str(summary.get("active_polls", "—")) - self._card_orgs.value = str(summary.get("orgs_active", "—")) - - self.update() - await self._refresh_tab1() - await self._refresh_tab2() - - # ── tab switching ───────────────────────────────────────────────────────── - - def _on_tab_change(self, e): - self._current_tab = int(e.data) - self._tab_body.content = self._tab1_content if self._current_tab == 0 else self._tab2_content - self._tab_body.update() - - async def _refresh_current_tab(self): - if self._current_tab == 0: - await self._refresh_tab1() - else: - await self._refresh_tab2() - - # ── filters ─────────────────────────────────────────────────────────────── - - def _on_filter_change(self, e): - self.page.run_task(self._refresh_current_tab) - - def _on_results_filter_change(self, e): - self.page.run_task(self._refresh_tab2) - - def _reset_filters(self, e): - self._poll_dd.value = None - self._org_dd.value = None - self._group_dd.value = None - self._year_dd.value = None - self.update() - self.page.run_task(self._refresh_current_tab) - - def _get_filters(self) -> dict: - year_str = self._year_dd.value - return { - "poll_id": self._poll_dd.value, - "org_id": self._org_dd.value, - "group_id": self._group_dd.value, - "year": int(year_str) if year_str else None, - } - - # ── TAB 1: прохождения ─────────────────────────────────────────────────── - - async def _refresh_tab1(self): - f = self._get_filters() - spinner = lambda: ft.ProgressRing(width=ds.s(24), height=ds.s(24)) - for c in [self._chart_poll, self._chart_org, self._chart_group, self._chart_month]: - c.content = spinner() - self.update() - - d_poll, d_org, d_group, d_month = await asyncio.gather( - ac.get_chart_data("poll", **f), - ac.get_chart_data("organization", **f), - ac.get_chart_data("group", **f), - ac.get_chart_data("month", **f), - ) - loop = asyncio.get_event_loop() - img_poll, img_org, img_group, img_month = await asyncio.gather( - loop.run_in_executor(None, _build_chart_image, d_poll, "По тестам", "#F85A40"), - loop.run_in_executor(None, _build_chart_image, d_org, "По организациям", "#007FBD"), - loop.run_in_executor(None, _build_chart_image, d_group, "По группам", "#85C446"), - loop.run_in_executor(None, _build_line_chart_image, d_month), - ) - w = self._chart_width() - self._chart_poll.content = _image_or_placeholder(img_poll, w) - self._chart_org.content = _image_or_placeholder(img_org, w) - self._chart_group.content = _image_or_placeholder(img_group, w) - self._chart_month.content = _image_or_placeholder(img_month, w) - self.update() - - # ── TAB 2: результаты ──────────────────────────────────────────────────── - - async def _refresh_tab2(self): - f = self._get_filters() - dim = self._dim_dd.value - by = self._compare_by_dd.value or "organization" - - spinner = lambda: ft.ProgressRing(width=ds.s(24), height=ds.s(24)) - for c in [self._chart_avg_dim, self._chart_leading, - self._chart_compare, self._chart_trend]: - c.content = spinner() - self._top_table.rows = [] - self.update() - - # Параллельная загрузка - d_avg, d_lead, d_top = await asyncio.gather( - ac.get_avg_by_dimension(**f), - ac.get_leading_type(**f), - ac.get_top_users(dim, **f) if dim else asyncio.sleep(0), - ) - if not dim: - d_top = [] - d_compare = await ac.get_compare_groups(dim or "", by=by, - poll_id=f["poll_id"], - year=f["year"]) if dim else [] - d_trend = await ac.get_dimension_trend(dim or "", - poll_id=f["poll_id"], - org_id=f["org_id"], - group_id=f["group_id"]) if dim else [] - - loop = asyncio.get_event_loop() - img_avg, img_lead, img_compare, img_trend = await asyncio.gather( - loop.run_in_executor(None, _build_avg_bar, d_avg, "Средний балл"), - loop.run_in_executor(None, _build_pie_chart, d_lead), - loop.run_in_executor(None, _build_avg_bar, d_compare, - f"Средний балл по {'организациям' if by == 'organization' else 'группам'}"), - loop.run_in_executor(None, _build_avg_line, d_trend), - ) - - w = self._chart_width() - self._chart_avg_dim.content = _image_or_placeholder(img_avg, w) - self._chart_leading.content = _image_or_placeholder(img_lead, w) - self._chart_compare.content = _image_or_placeholder(img_compare, w) - self._chart_trend.content = _image_or_placeholder(img_trend, w) - - # Таблица топ - self._top_table.rows = [ - ft.DataRow(cells=[ - ft.DataCell(ft.Text(row["name"], size=ds.s(12))), - ft.DataCell(ft.Text(row["poll"], size=ds.s(11), - color=ds.colors.text_secondary, - overflow=ft.TextOverflow.ELLIPSIS, - max_lines=1)), - ft.DataCell(ft.Text(str(row["value"]), size=ds.s(12), - weight=ft.FontWeight.BOLD, - color=ds.colors.primary)), - ]) - for row in (d_top or []) - ] - self.update() diff --git a/stats/designer.py b/stats/designer.py deleted file mode 100644 index e749205..0000000 --- a/stats/designer.py +++ /dev/null @@ -1,223 +0,0 @@ -""" -designer.py для stats-приложения. -Та же палитра и компоненты что в web/designer.py, адаптированные для дашборда. -""" -import flet as ft - -SCALE: float = 1.0 - - -def s(v: int | float) -> int: - return round(v * SCALE) - - -class colors: - primary = "#F85A40" - primary_dark = "#D94530" - primary_light = "#FFEAB7" - background = "#F4F6F8" - surface = "#FFFFFF" - surface_alt = "#F0F2F5" - accent = "#FFC845" - info = "#007FBD" - success = "#85C446" - text_primary = "#1E2A3A" - text_secondary = "#5A6779" - text_on_primary = "#FFFFFF" - border = "#DDE3EA" - - -class SearchableDropdown(ft.Container): - """Выпадающий список с поисковой строкой.""" - - def __init__(self, label: str = "", hint_text: str = "", options=None, on_select=None, width: int | None = None): - self._label_str = label - self._hint_str = hint_text - self._options: list = options or [] - self.on_select = on_select - self._value = None - - self._value_text = ft.Text( - hint_text, - color=colors.border, - size=s(14), - italic=True, - expand=True, - no_wrap=True, - overflow=ft.TextOverflow.ELLIPSIS, - ) - - super().__init__( - content=ft.Column( - spacing=s(2), - tight=True, - controls=[ - ft.Text(label, color=colors.text_secondary, size=s(12), weight=ft.FontWeight.W_500), - ft.Row( - controls=[ - self._value_text, - ft.Icon(ft.Icons.ARROW_DROP_DOWN, color=colors.text_secondary, size=s(20)), - ], - alignment=ft.MainAxisAlignment.SPACE_BETWEEN, - vertical_alignment=ft.CrossAxisAlignment.CENTER, - ), - ], - ), - bgcolor=colors.surface, - border=ft.border.all(1, colors.border), - border_radius=s(10), - padding=ft.padding.symmetric(horizontal=s(12), vertical=s(10)), - on_click=self._open_dialog, - ink=True, - width=width or s(220), - ) - - @property - def value(self): - return self._value - - @value.setter - def value(self, val): - self._value = val - if val is None: - self._value_text.value = self._hint_str - self._value_text.color = colors.border - self._value_text.italic = True - else: - for opt in self._options: - if opt.key == val: - self._value_text.value = opt.text - self._value_text.color = colors.text_primary - self._value_text.italic = False - break - try: - if self.page: - self.update() - except RuntimeError: - pass - - @property - def options(self): - return self._options - - @options.setter - def options(self, opts): - self._options = opts or [] - - @property - def hint_text(self): - return self._hint_str - - @hint_text.setter - def hint_text(self, val): - self._hint_str = val - if self._value is None: - self._value_text.value = val - if self.page: - self.update() - - def _open_dialog(self, e): - search_field = ft.TextField( - label="Поиск", - hint_text="Введите для поиска...", - prefix_icon=ft.Icons.SEARCH, - autofocus=True, - filled=True, - bgcolor=colors.surface, - border_color=colors.border, - focused_border_color=colors.primary, - border_radius=s(8), - text_style=ft.TextStyle(color=colors.text_primary, size=s(14)), - ) - items_list = ft.ListView(spacing=0, expand=True) - - def _build_items(query: str = ""): - q = query.lower() - items_list.controls = [ - ft.ListTile( - title=ft.Text(opt.text, color=colors.text_primary, size=s(13)), - hover_color=colors.primary_light, - on_click=lambda ev, o=opt: _select(o), - ) - for opt in self._options - if not q or q in opt.text.lower() - ] - - def _on_search(ev): - _build_items(search_field.value or "") - items_list.update() - - def _select(option): - self._value = option.key - self._value_text.value = option.text - self._value_text.color = colors.text_primary - self._value_text.italic = False - dlg.open = False - self.page.update() - self.update() - if self.on_select: - self.on_select(type("_E", (), {"control": self})()) - - search_field.on_change = _on_search - _build_items() - - dlg = ft.AlertDialog( - title=ft.Text(self._label_str, color=colors.text_primary, size=s(15), weight=ft.FontWeight.W_600), - content=ft.Container( - content=ft.Column( - controls=[search_field, ft.Divider(color=colors.border), items_list], - spacing=s(8), - tight=True, - ), - height=s(320), - width=s(380), - ), - bgcolor=colors.surface, - shape=ft.RoundedRectangleBorder(radius=s(12)), - ) - self.page.overlay.append(dlg) - dlg.open = True - self.page.update() - - -def stat_card(title: str, value: str, icon: str, color: str) -> ft.Container: - """Карточка с одной цифрой для summary-панели.""" - return ft.Container( - content=ft.Column( - spacing=s(6), - tight=True, - controls=[ - ft.Row( - spacing=s(8), - controls=[ - ft.Icon(icon, color=color, size=s(20)), - ft.Text(title, color=colors.text_secondary, size=s(12)), - ], - ), - ft.Text(value, color=colors.text_primary, size=s(28), weight=ft.FontWeight.BOLD), - ], - ), - bgcolor=colors.surface, - border_radius=s(12), - padding=ft.padding.all(s(16)), - border=ft.border.all(1, colors.border), - expand=True, - ) - - -def chart_card(title: str, body: ft.Control) -> ft.Container: - """Обёртка-карточка для графика.""" - return ft.Container( - content=ft.Column( - spacing=s(12), - controls=[ - ft.Text(title, color=colors.text_primary, size=s(15), weight=ft.FontWeight.W_600), - body, - ], - ), - bgcolor=colors.surface, - border_radius=s(12), - padding=ft.padding.all(s(16)), - border=ft.border.all(1, colors.border), - expand=True, - ) diff --git a/stats/main.py b/stats/main.py deleted file mode 100644 index 33b3a11..0000000 --- a/stats/main.py +++ /dev/null @@ -1,19 +0,0 @@ -import flet as ft -import designer as ds -from dashboard import DashboardView -from starlette.applications import Starlette -from starlette.routing import Mount - - -async def main(page: ft.Page): - page.title = "ЦОПП — Статистика" - page.bgcolor = ds.colors.background - page.padding = ft.padding.all(0) - page.scroll = ft.ScrollMode.HIDDEN - - view = DashboardView(page) - page.add(view) - - -_flet_app = ft.run(main, export_asgi_app=True, assets_dir="assets") -app = Starlette(routes=[Mount("/", _flet_app)])