commit 016910ffb7db905040292a7fadb633d8ab078f92 Author: влад Date: Thu Jul 16 10:11:54 2026 +0300 Initial commit: site monorepo with API, web, and infra. Co-authored-by: Cursor diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f5f1bbc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,27 @@ +# Git / editor noise +.git +.gitignore +.cursor +.vscode +*.md + +# Dependencies & build artifacts (installed inside the image) +**/node_modules +**/dist +**/coverage +**/.turbo + +# Python API — not needed for the web image +apps/api + +# Infra / CI helpers +infra + +# Env & secrets (injected via compose or mounted at runtime) +**/.env +**/.env.* +!**/.env.example + +# Test / e2e artifacts +**/playwright-report +**/test-results diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8a7563f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: pnpm/action-setup@v4 + with: + version: 9 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install frontend deps + run: pnpm install --frozen-lockfile=false + - name: Install backend deps + run: pip install -r apps/api/requirements-dev.txt + - name: Sync WESP UI parity + run: bash scripts/sync-wesp-ui.sh + - name: Lint + run: pnpm lint + - name: Types + run: pnpm typecheck + - name: Mypy + working-directory: apps/api + run: python -m mypy app + - name: Frontend tests with coverage + run: pnpm --filter web test:ci + - name: Backend tests with coverage + working-directory: apps/api + run: python -m pytest --cov=app --cov-fail-under=90 + - name: Security scan (python deps) + run: pip-audit + - name: Bandit scan + run: bandit -q -r apps/api/app + - name: Security scan (node deps) + run: pnpm audit --audit-level high + - name: Gitleaks scan + run: docker run --rm -v "$PWD:/repo" zricethezav/gitleaks:latest detect --source=/repo --no-git -v + - name: Install Playwright browsers + run: pnpm --filter web exec playwright install chromium + - name: E2E smoke + run: pnpm --filter web e2e diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2f9bf73 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +node_modules/ +.venv/ +__pycache__/ +.pytest_cache/ +.mypy_cache/ +coverage/ +.coverage +dist/ +playwright-report/ +test-results/ +.DS_Store +.env +*.pyc +apps/web/.env +apps/api/.env +apps/api/.e2e.sqlite +apps/api/.e2e-test.sqlite +apps/api/data/secrets/install.env +apps/api/data/secrets/install.meta.json +data/secrets/ +apps/api/data/logs/ +apps/api/data/compton_settings.json +apps/web/public/static/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..ca82079 --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ +# Compton Platform + +Если вы открыли этот файл — поздравляем: перед вами monorepo с React, FastAPI и амбициями выйти в production. Амбиции живут в [docs/release.md](docs/release.md), код — в `apps/`, секреты — **не** в git. + +Платформа Compton Organic Tech: лендинг, auth, профиль, CMS, админка в стиле WESP/zootech. ТЗ: [docs/TZ.md](docs/TZ.md). + +## Быстрый старт + +```bash +python apps/api/scripts/bootstrap_install.py # один раз, до первого up +docker compose --profile docker-web up -d --build +curl http://localhost:8000/api/v1/health # Windows: curl.exe +``` + +| URL | Что там | +|-----|---------| +| http://localhost:5173 | Лендинг `/` + SPA (`/login`, `/admin`, …) | +| http://localhost:8000/api/v1/health | API жив? | +| http://localhost:8000/api/v1/docs | OpenAPI (только dev) | + +**Логин для пробы:** `admin@compton.example` / `Admin1234` → `/admin`. + +> Bootstrap **до** первого `docker compose up`. Иначе Postgres скажет «password authentication failed» — см. [docs/deploy.md § восстановление](docs/deploy.md#восстановление-секретов). + +**Не делайте:** Docker `web` и `pnpm --filter web dev` одновременно — оба хотят порт **5173**. + +## Демо-аккаунты (dev) + +Подробнее: [docs/deploy.md § стандартные логины](docs/deploy.md#стандартные-логины-dev). + +| Email | Пароль | Env | Роль | Superuser | +|-------|--------|-----|------|-----------| +| admin@compton.example | Admin1234 | `ADMIN_INITIAL_PASSWORD` | admin | да — Security, Diagnostics, secrets | +| ops@compton.example | OpsAdmin1234 | `DEMO_OPS_PASSWORD` | admin | нет — Users, Content, Activity | +| user@compton.example | User1234 | `DEMO_USER_PASSWORD` | user | — | + +CMS после seed: `/pages/about`, `/pages/privacy`, `/pages/terms`. + +В production/staging demo-users **выключены** (`SEED_DEMO_USERS=false`). + +## Тесты + +```bash +pnpm --filter web test:ci # frontend, cov ≥ 85% +cd apps/api && python -m pytest --cov=app --cov-fail-under=90 +pnpm --filter web e2e # Playwright, API :8001 +``` + +## Документация + +Вся нормальная дока — в **`docs/`**, на русском, без водянистого README на 500 строк: + +| Файл | О чём | +|------|-------| +| [docs/README.md](docs/README.md) | Оглавление | +| [docs/project.md](docs/project.md) | Структура, архитектура, API, маршруты | +| [docs/security.md](docs/security.md) | Auth, JWT revoke, секреты, чеклист prod | +| [docs/deploy.md](docs/deploy.md) | Dev/staging/prod, бэкапы, troubleshooting | +| [docs/release.md](docs/release.md) | QA gates, k6, ZAP, go-live | +| [docs/TZ.md](docs/TZ.md) | Техническое задание | + +## Работа с Git + +**Репозиторий:** https://git.groupkomton.ru/Matvey/site.git +**Ветка:** `main` (одна ветка, без сюрпризов) + +```bash +git pull origin main # перед работой — всегда +git status && git diff # перед коммитом — тоже всегда +git add . +git commit -m "Сделал то-то на русском" +git push origin main +``` + +**Коммиты — только на русском.** Плохо: `fix`, `WIP`, `asdf`. + +**Не коммитить:** `.env`, `install.env`, `node_modules/`, логи, кэши. + +**Не делать** `git push --force` на `main` без согласования. + +| Команда | Зачем | +|---------|-------| +| `git log --oneline -10` | что было | +| `git checkout -- файл` | откатить файл | +| `git stash` / `git stash pop` | спрятать / вернуть правки | + +## План по ТЗ (§16) + +| Фаза | Задачи | Статус | +|------|--------|--------| +| **0 — Подготовка** | Monorepo, Docker Compose, UI kit, CI/coverage gates, Playwright smoke | Выполнено | +| **1 — MVP** | Landing, Auth+SMTP, Profile, Content, Admin, staging + полный регресс | В процессе | +| **2 — v1.0** | Celery/notifications, Catalog+Orders, Analytics, k6 + monitoring | Когда начнём? | +| **3 — Scale** | Read replica PG, Celery workers, CDN, horizontal API | Когда начнём? | + +### Фаза 1 — детализация + +| # | Задача | Модули | Статус | +|---|--------|--------|--------| +| 1.1 | Landing (перенос заглушки) | landing | Выполнено | +| 1.2 | Auth + SMTP | auth | Выполнено | +| 1.2b | Security baseline | core, auth | В процессе | +| 1.3 | Profile CRUD + avatar | profile | Выполнено | +| 1.4 | Content pages | content | Выполнено | +| 1.5 | Admin panel | admin | Выполнено | +| 1.6 | Staging deploy + полный регресс (§15.7) | — | В процессе | + +Критерии приёмки MVP — §17.1 ТЗ (k6 50 CCU, Lighthouse ≥85, E2E 10/10, coverage gates). + +--- + +*Дальше — [docs/](docs/README.md). Там схемы, таблицы и вещи, которые не помещаются в README без страданий.* diff --git a/TZ (1).md b/TZ (1).md new file mode 100644 index 0000000..5260ce1 --- /dev/null +++ b/TZ (1).md @@ -0,0 +1,1510 @@ +# Техническое задание +## Платформа «Комптон» — React-приложение с модульной архитектурой +### Испольнителю следовать плану реализации с особым пристрастрием + +**Версия документа:** 1.0 +**Дата:** 09.07.2026 +**Разработчик:** Huli + +--- + +## Содержание + +1. [Общие сведения](#1-общие-сведения) +2. [Цели и границы проекта](#2-цели-и-границы-проекта) +3. [Профиль нагрузки и масштабирование](#3-профиль-нагрузки-и-масштабирование) +4. [Технологический стек](#4-технологический-стек) +5. [Архитектура системы](#5-архитектура-системы) +6. [Модульная структура Frontend (React)](#6-модульная-структура-frontend-react) +7. [Модульная структура Backend (API)](#7-модульная-структура-backend-api) +8. [Модель данных](#8-модель-данных) +9. [API-контракты](#9-api-контракты) +10. [Аутентификация и авторизация](#10-аутентификация-и-авторизация) +11. [UI/UX и дизайн-система](#11-uiux-и-дизайн-система) +12. [Нефункциональные требования](#12-нефункциональные-требования) +13. [Инфраструктура и DevOps](#13-инфраструктура-и-devops) +14. [Безопасность](#14-безопасность) +15. [Тестирование и качество](#15-тестирование-и-качество) +16. [Этапы разработки](#16-этапы-разработки) +17. [Критерии приёмки](#17-критерии-приёмки) +18. [Риски и допущения](#18-риски-и-допущения) + +--- + +## 1. Общие сведения + +### 1.1. Наименование проекта + +**Комптон®** — веб-платформа бренда «Organic Tech» с публичной витриной, личным кабинетом пользователя и административной панелью. + +### 1.2. Текущее состояние + +- Чисто поржать на ошибках и нейронке +- Заглушка на Flask (`main.py`) с одностраничным лендингом. +- Визуальная идентичность задана не полностью: цвета, шрифты (Inter, JetBrains Mono), hero-блок, бегущая строка. +- Рефакторинг текщего состояния не возможен, задача переписать проект. +- Функциональность отсутствует: нет регистрации, контента, API, БД. + + +### 1.3. Заказчик / продукт + +Еблан который зовет себя Huli. Документ описывает целевую архитектуру для замены заглушки полноценным продуктом. + +### 1.4. Термины + +| Термин | Определение | +|--------|-------------| +| **Модуль (feature)** | Изолированный функциональный блок с собственными компонентами, API-слоем, типами и тестами | +| **Shared** | Переиспользуемый код без бизнес-логики конкретного модуля | +| **Core** | Ядро приложения: роутинг, провайдеры, конфигурация, HTTP-клиент | +| **MAU** | Monthly Active Users — уникальные пользователи за месяц | +| **CCU** | Concurrent Users — одновременно онлайн | + +--- + +## 2. Цели и границы проекта + +### 2.1. Бизнес-цели + +1. Заменить заглушку работающим сайтом с сохранением бренда. +2. Обеспечить регистрацию и работу **до 1 000 зарегистрированных пользователей** (~100–150 DAU) без деградации UX. +3. Заложить архитектуру, позволяющую масштабироваться до **10 000+ пользователей** без переписывания модулей. +4. Обеспечить независимую разработку модулей разными разработчиками. +5. **Каждый шаг реализации** (задача / PR / модуль) поставляется **только с полным набором тестов**: unit/integration логики + E2E пользовательских сценариев (§15). + +### 2.2. Функциональный scope (MVP → v1.0) + +#### MVP (фаза 1) + +| Модуль | Функции | +|--------|---------| +| **Landing** | Hero, бегущая строка, «О бренде», контакты, SEO-мета | +| **Auth** | Регистрация, вход, выход, восстановление пароля, подтверждение email *(требует SMTP в MVP — см. §16)* | +| **Profile** | Просмотр/редактирование профиля, аватар, смена пароля | +| **Content** | Статические страницы (О нас, Политика, Условия) из CMS или markdown | +| **Admin** | Управление пользователями, контентом, просмотр метрик | + +#### v1.0 (фаза 2, опционально) + +| Модуль | Функции | +|--------|---------| +| **Catalog** | Каталог продуктов/услуг, карточки, фильтры | +| **Orders** | Корзина, оформление заявки (без оплаты — см. §2.3), история | +| **Notifications** | In-app уведомления; email-рассылки через очередь (Celery) | +| **Analytics** | Дашборд событий, экспорт | + +### 2.3. Out of scope (не входит в v1.0) + +- Мобильное нативное приложение (только responsive web). +- Мультиязычность (заложить i18n-структуру, реализовать позже). +- Платёжные шлюзы (интеграция — отдельный этап). Модуль Orders в v1.0 работает как **заявка/бронирование без оплаты**. +- Real-time чат / WebRTC. + +--- + +## 3. Профиль нагрузки и масштабирование + +### 3.1. Целевые метрики (1000 пользователей) + +| Метрика | Значение | Комментарий | +|---------|----------|-------------| +| Зарегистрированных пользователей | 1 000 | Целевой объём на старте | +| DAU | 100–150 (10–15%) | Типичный коэффициент для B2C | +| CCU (пик) | 30–50 | Одновременные сессии | +| RPS API (пик) | 20–50 req/s | С запасом ×3 | +| Размер БД | < 5 GB | Профили, контент, логи | +| Медиа | < 50 GB | CDN для статики | + +### 3.2. Путь масштабирования + +```mermaid +flowchart LR + subgraph phase1 [Фаза 1: до 1K] + A1[Monolith API] + A2[PostgreSQL] + A3[Redis cache] + A4[CDN static] + end + + subgraph phase2 [Фаза 2: 1K–10K] + B1[2× API instances] + B2[Read replica PG] + B3[Redis cluster] + B4[Object storage S3] + end + + subgraph phase3 [Фаза 3: 10K+] + C1[Horizontal API scale] + C2[Extract heavy modules] + C3[Message queue] + C4[Separate admin] + end + + phase1 --> phase2 --> phase3 +``` + +**Принцип:** на 1000 пользователей достаточно **модульного монолита** (один backend-процесс, чёткие границы модулей). Микросервисы не нужны до 10K+ и только для узких «тяжёлых» модулей (уведомления, аналитика). + +### 3.3. SLA (целевые) + +| Параметр | MVP | v1.0 | +|----------|-----|------| +| Uptime | 99.5% | 99.9% | +| TTFB публичных страниц | < 500 ms | < 300 ms | +| API p95 latency | < 300 ms | < 200 ms | +| LCP (Lighthouse mobile) | < 2.5 s | < 2.0 s | + +--- + +## 4. Технологический стек + +### 4.1. Frontend + +| Слой | Технология | Обоснование | +|------|------------|-------------| +| Framework | **React 19** + **TypeScript 5** | Экосистема, типизация | +| Bundler | **Vite 6** | Быстрая сборка, HMR | +| Routing | **React Router 7** | Стандарт de facto | +| Server state | **TanStack Query 5** | Кеш, retry, invalidation | +| Client state | **Zustand** | Лёгкий, без boilerplate | +| Forms | **React Hook Form** + **Zod** | Валидация, DX | +| UI | **Tailwind CSS 4** + headless (Radix UI) | Соответствие дизайн-системе | +| HTTP | **Axios** или fetch-обёртка | Interceptors, типизация | +| i18n (заготовка) | **react-i18next** | Структура без реализации | +| Tests | **Vitest** + **Testing Library** + **Playwright** + **pytest** | Unit + integration + E2E на каждом PR (§15) | + +### 4.2. Backend + +| Слой | Технология | Обоснование | +|------|------------|-------------| +| Runtime | **Python 3.12** | Преемственность Flask-проекта | +| Framework | **FastAPI** | Async, OpenAPI, производительность | +| ORM | **SQLAlchemy 2** + **Alembic** | Миграции, типизация | +| Validation | **Pydantic v2** | Согласованность с OpenAPI | +| Auth | **JWT** (access + refresh rotation) + **passlib/bcrypt** | Stateless access, revocable refresh | +| Task queue (v1.0) | **Celery** + Redis | Email, фоновые задачи | +| Email | **SMTP** / SendGrid / Resend | MVP: синхронная отправка verify/reset; v1.0: Celery queue | + +### 4.3. Data & Infra + +| Компонент | Технология | +|-----------|------------| +| Primary DB | **PostgreSQL 16** | +| Cache / sessions | **Redis 7** | +| File storage | **S3-compatible** (MinIO dev / Yandex S3 / AWS prod) | +| Reverse proxy | **Nginx** | +| Containerization | **Docker** + **Docker Compose** (dev/staging) | +| CI/CD | **GitHub Actions** | +| Monitoring | **Prometheus** + **Grafana** (или managed: Datadog) | +| Logging | Structured JSON → **Loki** или cloud logs | +| Error tracking | **Sentry** | + +### 4.4. Архитектурный стиль Frontend + +**Feature-Sliced Design (FSD)** — адаптированная версия с явными модулями: + +``` +app → pages → modules → shared +``` + +- **app** — инициализация, провайдеры, глобальный роутер. +- **pages** — композиция модулей в маршруты (тонкий слой). +- **modules** — бизнес-фичи (auth, profile, catalog…). +- **shared** — UI-kit, utils, API client, types без доменной логики. + +--- + +## 5. Архитектура системы + +### 5.1. Общая схема + +```mermaid +flowchart TB + subgraph client [Клиент] + Browser[Browser / Mobile Web] + SPA[React SPA] + end + + subgraph edge [Edge] + CDN[CDN — static assets] + Nginx[Nginx — TLS, gzip, rate limit] + end + + subgraph backend [Backend] + API[FastAPI Monolith] + subgraph modules_api [Modules] + M1[auth] + M2[users] + M3[content] + M4[admin] + end + end + + subgraph data [Data Layer] + PG[(PostgreSQL)] + Redis[(Redis)] + S3[(Object Storage)] + end + + Browser --> CDN + Browser --> Nginx + Nginx -->|static| SPA + SPA -->|REST JSON /api| Nginx + Nginx --> API + API --> modules_api + modules_api --> PG + modules_api --> Redis + modules_api --> S3 +``` + +### 5.2. Принципы модульности + +1. **Вертикальные срезы** — каждый модуль владеет UI + API + схемой БД (таблицы через общий ORM, но namespace по модулю). +2. **Запрет cross-import между modules** — общение только через: + - публичный API модуля (`modules/auth/api/index.ts`); + - shared-слой; + - backend REST/events. +3. **Публичный контракт модуля** — `index.ts` экспортирует только то, что нужно снаружи. +4. **Приватная реализация** — `internal/` не импортируется из других модулей. + +### 5.3. Репозиторий (monorepo-lite) + +``` +compton/ +├── apps/ +│ ├── web/ # React SPA +│ └── api/ # FastAPI +├── packages/ +│ ├── shared-types/ # OpenAPI-generated TS types +│ └── eslint-config/ # Общие lint rules +├── infra/ +│ ├── docker/ +│ ├── nginx/ +│ └── terraform/ # опционально +├── docs/ +│ └── TZ.md +└── docker-compose.yml +``` + +На старте допустим **single repo** без Turborepo; `packages/shared-types` генерируется из OpenAPI при CI. + +--- + +## 6. Модульная структура Frontend (React) + +### 6.1. Дерево каталогов `apps/web/src` + +``` +src/ +├── app/ +│ ├── App.tsx +│ ├── providers/ +│ │ ├── QueryProvider.tsx +│ │ ├── AuthProvider.tsx +│ │ └── ThemeProvider.tsx +│ ├── router/ +│ │ ├── routes.tsx +│ │ └── guards/ +│ │ ├── AuthGuard.tsx +│ │ └── AdminGuard.tsx +│ └── styles/ +│ └── globals.css +│ +├── pages/ +│ ├── HomePage/ +│ ├── LoginPage/ +│ ├── RegisterPage/ +│ ├── ProfilePage/ +│ ├── ContentPage/ +│ └── AdminPage/ +│ +├── modules/ +│ ├── landing/ +│ │ ├── index.ts # public API +│ │ ├── components/ +│ │ │ ├── HeroSection.tsx +│ │ │ └── MarqueeSection.tsx +│ │ ├── hooks/ +│ │ └── types/ +│ │ +│ ├── auth/ +│ │ ├── index.ts +│ │ ├── api/ +│ │ │ └── authApi.ts +│ │ ├── components/ +│ │ │ ├── LoginForm.tsx +│ │ │ └── RegisterForm.tsx +│ │ ├── hooks/ +│ │ │ ├── useLogin.ts +│ │ │ └── useAuth.ts +│ │ ├── store/ +│ │ │ └── authStore.ts +│ │ └── types/ +│ │ +│ ├── profile/ +│ │ ├── index.ts +│ │ ├── api/ +│ │ ├── components/ +│ │ ├── hooks/ +│ │ └── types/ +│ │ +│ ├── content/ +│ │ ├── index.ts +│ │ ├── api/ +│ │ ├── components/ +│ │ └── hooks/ +│ │ +│ ├── admin/ +│ │ ├── index.ts +│ │ ├── api/ +│ │ ├── components/ +│ │ └── hooks/ +│ │ +│ └── catalog/ # v1.0 +│ └── ... +│ +├── __tests__/ # cross-module integration (frontend) +│ └── setup.ts +│ +└── shared/ + ├── api/ + │ ├── client.ts + │ ├── client.test.ts + │ ├── errors.ts + │ └── types.ts + ├── ui/ + │ ├── Button/ + │ │ ├── Button.tsx + │ │ └── Button.test.tsx + │ └── ... + └── ... + +# Каждый module обязан содержать: +modules// +├── __tests__/ # unit: hooks, utils, store +├── components/ +│ └── *.test.tsx # component tests (Testing Library) +└── api/ + └── *.test.ts # API client + mock handlers +``` + +### 6.2. Правила зависимостей (import rules) + +```mermaid +flowchart TD + app --> pages + pages --> modules + modules --> shared + modules -.->|FORBIDDEN| modules + shared -.->|FORBIDDEN| modules + shared -.->|FORBIDDEN| pages +``` + +Enforcement через **ESLint** (`eslint-plugin-boundaries` или custom rules): + +| From → To | app | pages | modules | shared | +|-----------|-----|-------|---------|--------| +| app | ✓ | ✓ | ✓ | ✓ | +| pages | — | ✓ | ✓ | ✓ | +| modules | — | — | ✓ (own) | ✓ | +| shared | — | — | ✗ | ✓ | + +### 6.3. Описание модулей Frontend + +#### 6.3.1. `landing` + +**Ответственность:** + +- Буду ебать за каждый нейрослоп + +**Зависимости:** только `shared/ui`, `shared/hooks`. + +#### 6.3.2. `auth` + +**Ответственность:** аутентификация, сессия, guards. + +| Export (public API) | Описание | +|---------------------|----------| +| `LoginForm`, `RegisterForm` | Формы | +| `useAuth()` | `{ user, isAuthenticated, login, logout, refreshSession }` | + +**Guards** (`AuthGuard`, `GuestGuard`, `AdminGuard`) живут в `app/router/guards/` — они **импортируют** `useAuth` из модуля `auth`, но не экспортируются из него (слой `app` композирует модули). + +**Store:** `accessToken` только in-memory (Zustand). `refreshToken` **только** httpOnly Secure cookie (`SameSite=Lax`). Хранение refresh в `localStorage` / `sessionStorage` **запрещено**. + +#### 6.3.3. `profile` + +**Ответственность:** CRUD профиля пользователя. + +| Функции | API endpoints | +|---------|---------------| +| Просмотр профиля | `GET /api/v1/users/me` | +| Редактирование | `PATCH /api/v1/users/me` | +| Смена пароля (авторизован) | `POST /api/v1/users/me/password` | +| Загрузка аватара | `POST /api/v1/users/me/avatar` | + +#### 6.3.4. `content` + +**Ответственность:** рендер CMS-страниц по slug. Запись — только для `admin` (через те же роуты с RBAC-проверкой; отдельный `admin`-модуль не дублирует CRUD контента). + +| Функции | Описание | +|---------|----------| +| `ContentPage` | `/about`, `/privacy`, `/terms` | +| SEO | meta title, description, og:image | + +#### 6.3.5. `admin` + +**Ответственность:** панель администратора (role: `admin`). + +| Раздел | Функции | +|--------|---------| +| Users | список, блокировка, смена роли *(с ограничениями — §14.10)* | +| Content | — *(CRUD контента — модуль `content`, §9.4)* | +| Dashboard | базовые метрики (users count, registrations/day) | + +### 6.4. Роутинг + +| Маршрут | Page | Guard | Модуль(и) | +|---------|------|-------|-----------| +| `/` | HomePage | — | landing | +| `/login` | LoginPage | guest only | auth | +| `/register` | RegisterPage | guest only | auth | +| `/profile` | ProfilePage | AuthGuard | profile | +| `/pages/:slug` | ContentPage | — | content | +| `/admin/*` | AdminPage | AdminGuard | admin | + +### 6.5. Code splitting + +- Lazy load: `admin`, `profile`, `catalog`. +- Prefetch on hover для `/login`, `/register`. +- Landing — в initial bundle (LCP-critical). + +--- + +## 7. Модульная структура Backend (API) + +### 7.1. Дерево каталогов `apps/api` + +``` +app/ +├── main.py # FastAPI app factory +├── core/ +│ ├── config.py +│ ├── database.py +│ ├── redis.py +│ ├── security.py # JWT, password hashing +│ ├── dependencies.py # get_db, get_current_user +│ └── exceptions.py +│ +├── modules/ +│ ├── auth/ +│ │ ├── router.py +│ │ ├── service.py +│ │ ├── schemas.py +│ │ └── repository.py +│ │ +│ ├── users/ +│ │ ├── router.py +│ │ ├── service.py +│ │ ├── models.py +│ │ ├── schemas.py +│ │ └── repository.py +│ │ +│ ├── content/ +│ │ ├── router.py +│ │ ├── service.py +│ │ ├── models.py +│ │ └── schemas.py +│ │ +│ ├── media/ +│ │ ├── router.py +│ │ ├── service.py # S3 upload +│ │ └── schemas.py +│ │ +│ └── admin/ +│ ├── router.py +│ ├── service.py +│ └── schemas.py +│ +├── migrations/ # Alembic +└── tests/ + ├── conftest.py # fixtures: db, client, test users + ├── factories.py # user/content factories + ├── modules/ + │ ├── auth/ + │ │ ├── test_service.py # unit: бизнес-логика + │ │ ├── test_router.py # integration: HTTP + DB + │ │ └── test_security.py # rotation, lockout, enumeration + │ ├── users/ + │ ├── content/ + │ └── admin/ + └── e2e/ # pytest API smoke (optional layer) + └── test_health.py +``` + +### 7.2. Слои внутри модуля (Clean Architecture lite) + +``` +Router → Service → Repository → Model + ↓ + Schemas (Pydantic) +``` + +| Слой | Ответственность | +|------|-----------------| +| **Router** | HTTP, status codes, dependency injection | +| **Service** | Бизнес-логика, orchestration | +| **Repository** | SQL-запросы, абстракция БД | +| **Schemas** | Request/Response DTO | +| **Models** | SQLAlchemy ORM | + +**Правило:** модуль не импортирует `repository` другого модуля. Межмодульные вызовы — только через **публичный `service`-фасад** (например, `users.public.get_by_id()`) или domain events. + +### 7.3. Версионирование API + +- Префикс: `/api/v1/` +- Breaking changes → `/api/v2/` +- OpenAPI: `/api/v1/openapi.json` +- Swagger UI: `/api/v1/docs` (только dev/staging) + +--- + +## 8. Модель данных + +### 8.1. ER-диаграмма (MVP) + +```mermaid +erDiagram + users ||--o| user_profiles : has + users ||--o{ refresh_tokens : has + users ||--o{ password_reset_tokens : has + users ||--o{ content_pages : creates + + users { + uuid id PK + string email UK + string password_hash + enum role "user|admin" + enum status "active|blocked|pending" + timestamp email_verified_at + int failed_login_attempts + timestamp locked_until + timestamp created_at + timestamp updated_at + } + + user_profiles { + uuid user_id PK,FK + string display_name + string avatar_url + json metadata + } + + refresh_tokens { + uuid id PK + uuid user_id FK + string token_hash UK + uuid family_id + timestamp expires_at + timestamp revoked_at + timestamp created_at + } + + password_reset_tokens { + uuid id PK + uuid user_id FK + string token_hash UK + timestamp expires_at + timestamp used_at + } + + content_pages { + uuid id PK + string slug UK + string title + text body + enum status "draft|published" + uuid author_id FK + timestamp published_at + timestamp updated_at + } +``` + +### 8.2. Индексы + +```sql +CREATE UNIQUE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_status ON users(status); +CREATE INDEX idx_content_slug ON content_pages(slug); +CREATE INDEX idx_content_status ON content_pages(status); +CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id); +CREATE INDEX idx_refresh_tokens_family ON refresh_tokens(family_id); +CREATE INDEX idx_password_reset_user ON password_reset_tokens(user_id); +``` + +### 8.3. Миграции + +- Alembic, одна миграция = одна логическая задача. +- Rollback обязателен для каждой миграции. +- Seed: admin user, demo content pages. + +--- + +## 9. API-контракты + +### 9.1. Общие соглашения + +| Аспект | Стандарт | +|--------|----------| +| Format | JSON, UTF-8 | +| Dates | ISO 8601 UTC (`2026-07-09T12:00:00Z`) | +| IDs | UUID v4 | +| Pagination | `?page=1&limit=20`, response: `{ data, meta: { total, page, limit } }` | +| Errors | `{ "error": { "code": "VALIDATION_ERROR", "message": "...", "details": [] } }` | + +### 9.2. Auth endpoints + +| Method | Path | Auth | Описание | +|--------|------|------|----------| +| POST | `/api/v1/auth/register` | — | Регистрация → `status: pending`, письмо с подтверждением | +| POST | `/api/v1/auth/login` | — | Вход → access в body, refresh в `Set-Cookie` | +| POST | `/api/v1/auth/refresh` | refresh cookie | Новая пара access + refresh (rotation) | +| POST | `/api/v1/auth/logout` | refresh cookie | Revoke token family, очистка cookie | +| POST | `/api/v1/auth/verify-email` | — | Body: `{ "token": "..." }` → `status: active` | +| POST | `/api/v1/auth/resend-verification` | — | Повторная отправка (rate limited) | +| POST | `/api/v1/auth/forgot-password` | — | Отправка reset-link (без раскрытия наличия email) | +| POST | `/api/v1/auth/reset-password` | — | Смена пароля по одноразовому token | + +**Правила ответов auth (anti-enumeration):** +- `register` с существующим email → **200** с нейтральным телом *или* **409** без указания причины в prod (на выбор; зафиксировать в реализации). +- `forgot-password` → всегда **200** «Если email зарегистрирован, письмо отправлено». +- `login` при неверных данных → **401** с общим сообщением «Неверный email или пароль». +- `login` при `status: pending` → **403** `EMAIL_NOT_VERIFIED`. +- `login` при `status: blocked` → **403** `ACCOUNT_BLOCKED`. +- `login` при блокировке brute-force → **429** `ACCOUNT_TEMPORARILY_LOCKED`. + +**Response login (200):** +```json +{ + "access_token": "eyJ...", + "token_type": "bearer", + "expires_in": 900, + "user": { + "id": "uuid", + "email": "user@example.com", + "role": "user", + "status": "active" + } +} +``` + +**Set-Cookie (login / refresh — обязательно):** +``` +Set-Cookie: refresh_token=; HttpOnly; Secure; SameSite=Lax; Path=/api/v1/auth; Max-Age=2592000 +``` + +> Refresh token **никогда** не возвращается в JSON-body. + +### 9.3. Users endpoints + +| Method | Path | Auth | Описание | +|--------|------|------|----------| +| GET | `/api/v1/users/me` | user | Текущий пользователь + profile | +| PATCH | `/api/v1/users/me` | user | Обновление профиля (whitelist полей) | +| POST | `/api/v1/users/me/password` | user | `{ current_password, new_password }` | +| POST | `/api/v1/users/me/avatar` | user | multipart upload | +| DELETE | `/api/v1/users/me` | user | Удаление аккаунта (v1.0, soft-delete + anonymize) | + +### 9.4. Content endpoints + +| Method | Path | Auth | Описание | +|--------|------|------|----------| +| GET | `/api/v1/content/pages` | — | Список published pages | +| GET | `/api/v1/content/pages/{slug}` | — | Страница по slug | +| POST | `/api/v1/content/pages` | admin | Создание | +| PATCH | `/api/v1/content/pages/{id}` | admin | Обновление | +| DELETE | `/api/v1/content/pages/{id}` | admin | Удаление | + +### 9.5. Admin endpoints + +| Method | Path | Auth | Описание | +|--------|------|------|----------| +| GET | `/api/v1/admin/users` | admin | Список пользователей (pagination, без password_hash) | +| PATCH | `/api/v1/admin/users/{id}` | admin | Блокировка, смена роли *(§14.10)* | +| GET | `/api/v1/admin/stats` | admin | Базовые метрики | +| GET | `/api/v1/admin/audit-log` | admin | Журнал действий админов (v1.0) | + +### 9.6. Rate limiting + +| Endpoint group | Limit | Key | +|----------------|-------|-----| +| Auth: login | 5 req/min | IP + email (composite) | +| Auth: register | 3 req/hour | IP | +| Auth: forgot-password, resend-verification | 3 req/hour | IP + email | +| Auth: refresh | 30 req/min | user_id (из token) | +| Public API | 100 req/min | IP | +| Authenticated API | 300 req/min | user_id (fallback: IP) | +| Upload (avatar) | 10 req/hour | user_id | + +При превышении → **429** + заголовок `Retry-After`. + +**Brute-force (login):** после 5 неудачных попыток за 15 мин — `locked_until = now + 15 min` (поле в `users`). + +--- + +## 10. Аутентификация и авторизация + +### 10.1. JWT Strategy + +| Token | Формат | TTL | Storage | Передача | +|-------|--------|-----|---------|----------| +| Access | JWT (HS256 или RS256) | 15 min | In-memory (JS) | `Authorization: Bearer` | +| Refresh | Opaque random (256 bit) | 30 days | httpOnly Secure cookie | Cookie `refresh_token` | + +**Claims access JWT:** `sub` (user_id), `role`, `iat`, `exp`, `jti` (уникальный ID для optional denylist). + +**Refresh token:** +- В БД хранится **только SHA-256 hash**, не plaintext. +- **Rotation:** каждый `/auth/refresh` выдаёт новый refresh, старый revoke. +- **Reuse detection:** повторное использование revoked token → revoke всей `family_id`, принудительный logout на всех устройствах. +- Cookie `Path=/api/v1/auth` — не отправляется на остальные API-роуты (минимизация поверхности CSRF). + +### 10.2. RBAC + +| Role | Permissions | +|------|-------------| +| `guest` | Чтение опубликованного контента | +| `user` (`status: active`) | Управление своим профилем | +| `admin` | Управление пользователями, контентом, stats; **не может** изменить собственную роль (§14.10) | + +**Проверка на каждом protected endpoint:** +1. Валидный access JWT (подпись, exp, jti). +2. Пользователь существует и `status != blocked`. +3. Для admin-роутов — `role == admin`. +4. Для user-роутов — `sub == resource owner` (защита от IDOR). + +`pending`-пользователь может вызвать только: verify-email, resend-verification, logout. + +### 10.3. Password policy + +- Минимум 8 символов, 1 uppercase, 1 lowercase, 1 digit. +- Denylist топ-10k паролей (Have I Been Pwned или локальный список). +- bcrypt, cost factor 12. +- Reset/verify token: 256 bit random, **hash в БД**, TTL 1 hour, одноразовый. +- При смене/сбросе пароля — revoke все `refresh_tokens` пользователя. + +### 10.4. CORS и credentials + +```python +# FastAPI CORSMiddleware +allow_origins = CORS_ORIGINS # whitelist, без wildcard в prod +allow_credentials = True # обязательно для refresh cookie +allow_methods = ["GET", "POST", "PATCH", "DELETE", "OPTIONS"] +allow_headers = ["Authorization", "Content-Type", "X-Request-ID"] +``` + +Frontend HTTP-клиент: `withCredentials: true` только для auth-запросов (login, refresh, logout). + +### 10.5. CSRF (cookie-based refresh) + +| Механизм | Применение | +|----------|------------| +| `SameSite=Lax` на refresh cookie | Блокирует cross-site POST в большинстве браузеров | +| `Path=/api/v1/auth` | Cookie не уходит на PATCH/POST других ресурсов | +| Проверка `Origin` / `Referer` на auth-роутах | Backend отклоняет запросы с чужого origin | +| Access JWT в header (не cookie) | State-changing API через Bearer не уязвим к CSRF | + +Дополнительный CSRF-token **не требуется** при соблюдении схемы выше. Если refresh cookie когда-либо расширят на весь `/api` — добавить double-submit CSRF token. + +--- + +## 11. UI/UX и дизайн-система + +### 11.1. Design tokens (из текущей заглушки) + +```css +:root { + --bg: #F6F6F4; + --foreground: #1A1E1C; + --primary: #48816D; + --muted: #4A5A52; + --marquee-bg: #EBEBE5; +} +``` + +### 11.2. Типографика + +| Назначение | Шрифт | +|------------|-------| +| UI / body | Inter (400, 500, 600) | +| Mono / акценты | JetBrains Mono (400, 500) | +| Декоративный (опционально) | Oktyabrina Script | + +### 11.3. Breakpoints + +| Token | Width | +|-------|-------| +| `sm` | 380px | +| `md` | 480px | +| `lg` | 768px | +| `xl` | 1024px | +| `2xl` | 1280px | + +### 11.4. Компоненты shared/ui (MVP) + +- Button (primary, secondary, ghost) +- Input, Textarea +- Label, ErrorMessage +- Card, Modal, Spinner +- Header, Footer, Container +- Avatar, Badge + +### 11.5. Accessibility + +- WCAG 2.1 AA для MVP. +- Keyboard navigation, focus visible. +- `alt` для всех изображений. +- `prefers-reduced-motion` — отключение marquee-анимации. + +--- + +## 12. Нефункциональные требования + +### 12.1. Performance + +| Метрика | Target | +|---------|--------| +| Initial JS bundle (landing) | < 150 KB gzip | +| Total JS (app loaded) | < 350 KB gzip | +| API cache (public content) | Redis TTL 5 min | +| Static assets | CDN, cache-control 1 year (hash in filename) | + +### 12.2. SEO + +- SSR/SSG **не обязателен** для MVP (SPA + prerender landing через Vite SSG plugin). +- Meta tags, sitemap.xml, robots.txt. +- Semantic HTML (`header`, `main`, `section`). + +### 12.3. Observability + +| Signal | Tool | +|--------|------| +| Logs | JSON structured, correlation-id | +| Metrics | request duration, error rate, DB pool | +| Traces | OpenTelemetry (optional v1.0) | +| Frontend errors | Sentry browser SDK | + +### 12.4. Backup & Recovery + +- PostgreSQL: daily backup, retention 30 days. +- RPO: 24 hours, RTO: 4 hours (на 1000 users достаточно). + +--- + +## 13. Инфраструктура и DevOps + +### 13.1. Окружения + +| Env | Назначение | URL | +|-----|------------|-----| +| local | разработка | localhost:5173 (web), :8000 (api) | +| staging | QA, demo | staging.compton.example | +| production | prod | compton.example | + +### 13.2. Docker Compose (local) + +```yaml +services: + web: + build: ./apps/web + ports: ["5173:5173"] + api: + build: ./apps/api + ports: ["8000:8000"] + depends_on: [postgres, redis] + postgres: + image: postgres:16 + redis: + image: redis:7-alpine + minio: + image: minio/minio # S3-compatible dev + +# apps/web/e2e/ — Playwright specs (§15.3) +# docker-compose.test.yml — postgres + redis + api + web для CI/E2E +``` + +### 13.3. CI/CD Pipeline + +```mermaid +flowchart LR + Push[Git Push / PR] --> Lint[Lint + Typecheck] + Lint --> Unit[Unit + Integration] + Unit --> Cov{Coverage ≥ threshold?} + Cov -->|No| Block1[❌ Block merge] + Cov -->|Yes| Build[Build Docker] + Build --> DeployStaging[Deploy Staging] + DeployStaging --> E2E[Playwright E2E] + E2E --> E2EPass{All E2E pass?} + E2EPass -->|No| Block2[❌ Block merge] + E2EPass -->|Yes| Review[Code Review] + Review --> ManualApprove[Manual Approve] + ManualApprove --> DeployProd[Deploy Production] +``` + +**Checks на каждый PR (обязательны, merge заблокирован при падении):** + +| Gate | Команда | Условие pass | +|------|---------|--------------| +| Lint | `eslint`, `ruff`, `prettier --check` | 0 errors | +| Types | `tsc --noEmit`, `mypy` | 0 errors | +| Unit (FE) | `vitest run --coverage` | ≥ порогов §15.2 | +| Unit + Integration (BE) | `pytest --cov=app --cov-fail-under=...` | ≥ порогов §15.2 | +| OpenAPI | schema diff | no breaking без bump version | +| E2E | `playwright test` | 100% pass, 0 flaky retries exhausted | +| Security | `pip-audit`, `npm audit`, gitleaks | no unwaived critical | + +> **Правило:** PR без тестов на изменённую логику **не ревьюится** и **не мержится**. + +### 13.4. Production topology (1000 users) + +``` +1× VPS (4 vCPU, 8 GB RAM) или managed PaaS +├── Nginx +├── API container (uvicorn, 2 workers) +├── Web static (built SPA) +├── PostgreSQL (managed или co-located) +└── Redis (managed или co-located) +``` + +**Запас на рост:** второй API instance за load balancer при CPU > 70% sustained. + +--- + +## 14. Безопасность + +### 14.1. Threat model (STRIDE, MVP) + +| Угроза | Вектор | Митигация | +|--------|--------|-----------| +| **Spoofing** | Подделка JWT / session | Подпись JWT, короткий TTL, rotation refresh | +| **Tampering** | Изменение чужих данных | RBAC + owner check (`sub == user_id`) | +| **Repudiation** | Отрицание admin-действий | Audit log (v1.0), correlation-id в логах | +| **Information disclosure** | Enumeration email, утечка PII | Anti-enumeration (§9.2), маскировка логов | +| **Denial of service** | Flood login/register | Rate limit + account lockout (§9.6) | +| **Elevation of privilege** | user → admin | RBAC middleware, admin self-edit запрещён | + +**Trust boundaries:** +1. Browser (недоверенный) ↔ Nginx (TLS termination) +2. Nginx ↔ API (internal network / docker network) +3. API ↔ PostgreSQL / Redis / S3 (credentials via env) + +### 14.2. Transport и security headers + +**Обязательно в production (Nginx / middleware):** + +| Header | Значение | +|--------|----------| +| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` | +| `X-Content-Type-Options` | `nosniff` | +| `X-Frame-Options` | `DENY` | +| `Referrer-Policy` | `strict-origin-when-cross-origin` | +| `Permissions-Policy` | `camera=(), microphone=(), geolocation=()` | +| `Content-Security-Policy` | см. §14.3 | + +- TLS 1.2+ only, сильные cipher suites. +- Swagger UI (`/docs`) и OpenAPI JSON — **отключены в production** (env flag `ENABLE_DOCS=false`). + +### 14.3. Content Security Policy + +``` +default-src 'self'; +script-src 'self'; +style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; +font-src 'self' https://fonts.gstatic.com; +img-src 'self' data: https://; +connect-src 'self' https://; +frame-ancestors 'none'; +base-uri 'self'; +form-action 'self'; +``` + +- Inline scripts в SPA — через nonce или только bundled (Vite). +- Google Fonts — допустимое исключение; при ужесточении CSP — self-host шрифтов. + +### 14.4. XSS и инъекции + +| Поверхность | Защита | +|-------------|--------| +| React UI | Auto-escaping JSX | +| CMS `content_pages.body` (HTML) | Server-side sanitize (**bleach** / **nh3**) + client DOMPurify allowlist перед `dangerouslySetInnerHTML` | +| User input (profile) | Pydantic validation, max length, strip control chars | +| SQL | SQLAlchemy ORM, parameterized queries; raw SQL запрещён без review | +| Log injection | Structured JSON logging, sanitize `\n\r` в user input | + +**Allowlist HTML-тегов CMS:** `p, h1-h4, ul, ol, li, a, strong, em, br, img` — без `script`, `iframe`, `on*` attributes. + +### 14.5. IDOR и авторизация + +| Endpoint | Правило | +|----------|---------| +| `GET/PATCH /users/me` | `current_user.id` из JWT, не из body | +| `POST /users/me/avatar` | только свой user_id | +| `PATCH /admin/users/{id}` | admin + business rules (§14.10) | +| `GET /content/pages/{slug}` | только `status=published` для non-admin | +| `PATCH /content/pages/{id}` | admin only; проверка существования id | + +**Запрещено:** принимать `user_id` / `role` из request body для повышения привилегий. + +### 14.6. Загрузка файлов (avatar) + +| Проверка | Значение | +|----------|----------| +| Max size | 2 MB | +| MIME (whitelist) | `image/jpeg`, `image/png`, `image/webp` | +| Magic bytes | python-magic / file signature check | +| Filename | UUID + ext, без user-supplied path | +| Storage | S3 private bucket; URL — signed / через CDN proxy | +| Processing | Re-encode (Pillow) для удаления EXIF и embedded payload | + +**Запрещено:** SVG upload (XSS vector), исполнение файлов на сервере. + +### 14.7. Secrets и криптография + +| Secret | Хранение | Rotation | +|--------|----------|----------| +| `JWT_ACCESS_SECRET` | env / vault | при компрометации, quarterly | +| `JWT_REFRESH_PEPPER` | env / vault | отдельный от access secret | +| DB credentials | env / managed DB | managed rotation | +| S3 keys | IAM role (preferred) или env | quarterly | + +- `.env` в `.gitignore`; `.env.example` без реальных значений. +- Pre-commit hook / CI: **gitleaks** или **trufflehog**. +- JWT secret ≥ 256 bit entropy. + +### 14.8. Сессии и logout + +- **Logout:** revoke refresh token family + clear cookie (`Max-Age=0`). +- **Password change / reset:** revoke **все** refresh tokens пользователя. +- **Block user (admin):** revoke tokens немедленно. +- Optional: Redis denylist для access JWT by `jti` до exp (для instant revoke admin-сессий). + +### 14.9. Логирование и PII + +**Логировать:** +- `request_id`, method, path, status, duration, user_id (if auth), IP (hashed в prod опционально). + +**Не логировать:** +- Passwords, tokens, reset links, full email в debug (mask: `u***@example.com`). + +**Retention:** application logs 30 days; audit log admin-действий 1 year. + +### 14.10. Admin security rules + +| Правило | Описание | +|---------|----------| +| Last admin | Нельзя удалить/понизить последнего admin | +| Self-demotion | Admin не может снять с себя роль admin | +| Self-block | Admin не может заблокировать себя | +| Role escalation | Только admin может назначать admin; 2FA для admin — v1.0 (recommended) | +| Seed admin | Создаётся миграцией; пароль из env при первом deploy, затем смена | + +### 14.11. Supply chain + +- Dependabot / Renovate — auto PR на CVE. +- CI: `pip-audit` (backend), `npm audit` (frontend), **Trivy** scan Docker images. +- Pin dependencies (`poetry.lock`, `package-lock.json`). +- Block merge при critical/high без explicit waiver. + +### 14.12. GDPR / 152-ФЗ + +| Требование | Реализация | +|------------|------------| +| Согласие | Checkbox при регистрации + ссылка на политику | +| Политика конфиденциальности | CMS-страница `/privacy` | +| Право на удаление | `DELETE /users/me` (v1.0): soft-delete, anonymize PII, retain audit/legal minimum | +| Data minimization | `metadata` json — только необходимые поля | +| Хранение | RU/EU region hosting (допущение §18.1) | +| Breach notification | Runbook в docs (v1.0) | + +### 14.13. Security checklist (pre-production) + +- [ ] HTTPS + HSTS +- [ ] CSP, security headers (§14.2) +- [ ] CORS whitelist, `allow_credentials` только для trusted origins +- [ ] Refresh: httpOnly, Secure, SameSite, Path, rotation + reuse detection +- [ ] Access JWT in memory only +- [ ] Anti-enumeration на auth endpoints +- [ ] Rate limiting + brute-force lockout +- [ ] IDOR tests на все `/me` и admin routes +- [ ] File upload validation + private S3 +- [ ] CMS HTML sanitization +- [ ] Secrets not in git; gitleaks in CI +- [ ] Swagger disabled in prod +- [ ] Dependency scan clean (no unwaived critical) +- [ ] OWASP ASVS Level 1 review +- [ ] Sentry: scrub PII from breadcrumbs + +### 14.14. Security testing (дополнение к §15) + +| Тест | Tool / метод | +|------|--------------| +| SAST | Bandit (Python), ESLint security plugins | +| DAST | OWASP ZAP baseline scan на staging | +| Auth flows | pytest: token rotation, reuse detection, lockout | +| IDOR | pytest: user A cannot access user B | +| Fuzzing upload | Invalid MIME, oversized, polyglot files | + +--- + +## 15. Тестирование и качество + +> **Базовое правило проекта:** функция считается реализованной только когда поставлены **тесты логики** (unit + integration) **и E2E-сценарии** для затронутых пользовательских потоков. Исключений нет. + +### 15.1. Пирамида и обязательные уровни + +| Уровень | Что покрывает | Tools | Обязательность | +|---------|---------------|-------|----------------| +| **Unit** | service, repository, hooks, store, utils, pure components | Vitest, pytest | **Обязательно** на каждый PR с логикой | +| **Integration** | API routers + DB + Redis; form → API client | pytest + TestClient, MSW | **Обязательно** для backend и API-слоя frontend | +| **Component** | UI: render, a11y, user events | Testing Library | **Обязательно** для новых/изменённых компонентов | +| **E2E** | Сквозные user journeys в браузере | Playwright | **Обязательно** на каждый модуль / фазу (§15.5) | +| **Visual** | Landing regression | Percy/Chromatic | Опционально | +| **Load** | API под CCU | k6 | Перед релизом MVP/v1.0 | +| **Security** | auth, IDOR, upload | pytest + ZAP | Перед релизом (§14.14) | + +### 15.2. Пороги покрытия (coverage gates) + +Измерение: **line coverage** (Vitest v8 / pytest-cov). CI падает при падении ниже порога. + +| Область | Минимум | Примечание | +|---------|---------|------------| +| `apps/api/app/modules/*/service.py` | **≥ 95%** | Бизнес-логика backend | +| `apps/api/app/modules/*/repository.py` | **≥ 90%** | SQL, edge cases | +| `apps/api/app/core/security.py` | **100%** | JWT, hash, lockout | +| `apps/web/src/modules/*/hooks`, `store`, `api` | **≥ 90%** | Логика frontend | +| `apps/web/src/shared/lib`, `shared/api` | **≥ 90%** | Общие утилиты | +| `apps/web/src/modules/*/components` | **≥ 80%** | UI; допускается исключать pure layout | +| **Overall backend** `app/` | **≥ 90%** | `--cov-fail-under=90` | +| **Overall frontend** `src/` | **≥ 85%** | `--coverage.thresholds.lines=85` | + +**Diff coverage (рекомендуется):** новые/changed строки в PR — **≥ 95%** (Codecov / diff-cover). + +**Критические модули** (`auth`, `admin`, `core/security`) — **100% branch coverage** для функций авторизации и token rotation. + +### 15.3. Структура E2E (Playwright) + +``` +apps/web/e2e/ +├── fixtures/ +│ ├── auth.fixture.ts # login helpers, test users +│ └── api.fixture.ts # seed via API +├── landing/ +│ └── landing.spec.ts +├── auth/ +│ ├── register.spec.ts +│ ├── login.spec.ts +│ └── password-reset.spec.ts +├── profile/ +│ └── profile.spec.ts +├── content/ +│ └── content-pages.spec.ts +├── admin/ +│ └── admin-users.spec.ts +└── playwright.config.ts +``` + +**Конфигурация:** +- Бrowsers: Chromium (CI), + Firefox/WebKit локально. +- Retry: 1 в CI только для infra flakes; повторный flake → bug. +- Trace/video: on-first-retry. +- Параллельность: по файлам, isolated test users. +- E2E против **staging** в CI post-deploy; PR — против docker-compose stack. + +### 15.4. Матрица тестов по модулям (MVP) + +Каждая строка — **минимальный обязательный набор** перед merge задачи. + +| Модуль | Unit / Integration (логика) | E2E (Playwright) | +|--------|----------------------------|------------------| +| **landing** | Hero/Marquee render, breakpoints, reduced-motion | `/` загрузка, marquee visible, a11y snapshot | +| **auth** | register, login, refresh rotation, reuse detection, lockout, anti-enumeration, verify-email | register → verify → login; wrong password; pending/blocked redirect | +| **profile** | PATCH whitelist, avatar validation (MIME, size), IDOR | edit name, upload avatar, change password | +| **content** | slug unique, draft vs published, HTML sanitize | public page by slug; admin publish → visible | +| **admin** | last-admin rule, self-block forbidden, RBAC | admin list users, block user, blocked cannot login | +| **shared/ui** | Button, Input, Form validation states | используются в module E2E | + +### 15.5. Тесты на каждом шаге реализации (workflow) + +```mermaid +flowchart TD + Task[Задача из §16] --> Impl[Реализация] + Impl --> Unit[Unit tests] + Unit --> Int[Integration tests] + Int --> Comp[Component tests если UI] + Comp --> E2E[E2E spec для flow] + E2E --> CI[CI green] + CI --> DoD[Definition of Done §15.6] + DoD --> Merge[Merge allowed] +``` + +**Порядок TDD (рекомендуется, не optional для auth/security):** +1. Написать failing test (unit или e2e). +2. Реализовать минимальный код. +3. Довести coverage до порога. +4. Открыть PR — CI должен быть green. + +**Запрещено:** +- Откладывать тесты «на потом» / отдельным PR. +- Merge с `@pytest.mark.skip` / `test.todo` / `it.skip` без linked issue и срока. +- Mock всего подряд в integration — DB/Redis должны быть real (testcontainers или docker-compose). + +### 15.6. Definition of Done (модуль / задача) + +- [ ] TypeScript strict / mypy — 0 errors +- [ ] **Unit tests** для всей новой/изменённой бизнес-логики +- [ ] **Integration tests** для новых/изменённых API endpoints +- [ ] **Component tests** для новых/изменённых React-компонентов +- [ ] **E2E spec** для затронутых user flows (§15.4) +- [ ] Coverage ≥ порогов §15.2 (CI enforced) +- [ ] API documented in OpenAPI +- [ ] ESLint boundaries pass +- [ ] Security tests пройдены (если модуль auth/admin/media) +- [ ] Code review approved +- [ ] 0 flaky E2E за 3 прогона CI + +### 15.7. Critical E2E scenarios (регрессия релиза) + +Полный прогон перед каждым релизом MVP/v1.0: + +1. Landing: LCP < 2.5s, hero + marquee, `prefers-reduced-motion`. +2. Register → verify email → login → profile edit → change password → logout. +3. Forgot password → reset → login with new password. +4. Admin: create content → publish → visible on `/pages/:slug`. +5. Admin: block user → blocked user gets 403 on login. +6. Pending user cannot access `/profile`. +7. Refresh rotation: old refresh token rejected; reuse revokes family. +8. IDOR: user A cannot GET/PATCH user B profile. +9. Admin cannot demote/block self; last admin protected. +10. Avatar: reject .svg, oversize, wrong MIME. + +### 15.8. Test data и изоляция + +| Аспект | Правило | +|--------|---------| +| Test DB | Отдельная `compton_test`; транзакционный rollback per test | +| Seed users | factories.py: `user`, `admin`, `pending_user`, `blocked_user` | +| E2E users | Создаются через API fixture перед spec, cleanup after | +| Secrets in tests | Только `test-*` keys из `.env.test` | +| Parallel | Изolated data via UUID suffix emails | + +### 15.9. Команды (локально) + +```bash +# Frontend +pnpm --filter web test # vitest watch +pnpm --filter web test:ci # vitest run --coverage +pnpm --filter web e2e # playwright test +pnpm --filter web e2e:ui # playwright --ui + +# Backend +cd apps/api && pytest # all +pytest tests/modules/auth -v # module +pytest --cov=app --cov-report=term-missing --cov-fail-under=90 + +# Full stack (pre-push) +docker compose -f docker-compose.test.yml up -d +pnpm test:ci && pytest && playwright test +``` + +--- + +## 16. Этапы разработки + +### Фаза 0 — Подготовка (1–2 недели) + +| # | Задача | Результат | Тесты (обязательно) | +|---|--------|-----------|---------------------| +| 0.1 | Monorepo: Vite, FastAPI skeleton | `apps/web`, `apps/api` | smoke: `vitest` 1 test, `pytest` health 1 test | +| 0.2 | Docker Compose + `docker-compose.test.yml` | dev + test stack | CI job: lint + smoke green | +| 0.3 | Shared UI kit + design tokens | Button, Input, Layout | component tests ≥80%; Storybook optional | +| 0.4 | ESLint boundaries, OpenAPI codegen, coverage gates | Quality gates в CI | CI блокирует merge без coverage config | +| 0.5 | Playwright + pytest fixtures | `e2e/`, `conftest.py`, factories | E2E smoke: landing page loads | + +### Фаза 1 — MVP (4–6 недель) + +> Каждая задача завершается **своим** набором unit + integration + E2E. Задача 1.6 — финальный регресс, не «первые E2E». + +| # | Задача | Модули | Тесты (обязательно в том же PR) | +|---|--------|--------|----------------------------------| +| 1.1 | Landing (перенос заглушки) | landing | unit: breakpoints; E2E: `landing.spec.ts` | +| 1.2 | Auth + SMTP | auth | unit: service/security; integration: all auth routes; E2E: register/login/verify/reset | +| 1.2b | Security baseline | core, auth | 100% coverage `security.py`; tests: lockout, rotation, enumeration | +| 1.3 | Profile CRUD | profile | unit: validation; integration: `/users/me`; E2E: edit + avatar + password | +| 1.4 | Content pages | content | unit: sanitize; integration: CRUD; E2E: publish → public view | +| 1.5 | Admin panel | admin | unit: business rules; integration: admin routes; E2E: block user flow | +| 1.6 | Staging deploy + **полный регресс** | — | Playwright §15.7 (all 10 scenarios); coverage ≥ §15.2 | + +### Фаза 2 — v1.0 (4–6 недель) + +| # | Задача | Модули | Тесты (обязательно в том же PR) | +|---|--------|--------|----------------------------------| +| 2.1 | Celery + email queue | notifications | unit: task handlers; integration: queue + mock SMTP; E2E: notification received | +| 2.2 | Catalog + Orders | catalog, orders | unit + integration per module; E2E: browse → cart → submit order | +| 2.3 | Analytics dashboard | admin | integration: stats API; E2E: dashboard renders metrics | +| 2.4 | Performance + monitoring | infra | k6 load test §17.2; no coverage regression | + +### Фаза 3 — Scale prep (по необходимости) + +- Read replica PostgreSQL +- Celery workers +- CDN + object storage migration +- Horizontal API scaling + +--- + +## 17. Критерии приёмки + +### 17.1. MVP + +**Функциональность:** +- [ ] Landing визуально соответствует текущей заглушке (hero, marquee, цвета). +- [ ] Регистрация и вход работают; load test **50 CCU** на API. +- [ ] Профиль редактируется, аватар загружается. +- [ ] Admin создаёт/редактирует контент-страницы. + +**Производительность и безопасность:** +- [ ] API p95 < 300 ms при 50 CCU (k6). +- [ ] Lighthouse Performance ≥ 85 на mobile. +- [ ] Security checklist §14.13 — все пункты закрыты. +- [ ] OWASP ZAP baseline — 0 high/critical на staging. + +**Тестирование (обязательно):** +- [ ] Backend coverage ≥ **90%** (`app/`), auth/security ≥ **95–100%** (§15.2). +- [ ] Frontend coverage ≥ **85%** (`src/`), hooks/api ≥ **90%**. +- [ ] Все E2E сценарии §15.7 — **10/10 pass** на staging. +- [ ] 0 skipped tests без waiver. +- [ ] CI pipeline §13.3 — все gates green на `main`. +- [ ] Каждый модуль MVP имеет строки в матрице §15.4 (закрыты). + +**Документация:** +- [ ] README: как запускать `test`, `test:ci`, `e2e`. +- [ ] OpenAPI, `.env.example`. + +### 17.2. Load test сценарий (k6, API-only) + +```javascript +// 50 VU, ramp 5 min, target: api:8000 через Nginx +// 35% GET /api/v1/content/pages +// 25% POST /api/v1/auth/login (test credentials pool) +// 20% GET /api/v1/users/me (authenticated) +// 10% POST /api/v1/auth/refresh (cookie) +// 10% GET /api/v1/content/pages/{slug} +``` + +**Pass:** error rate < 1%, p95 < 300ms, 0 auth bypass. + +> Frontend (`GET /`) — отдельный Lighthouse-тест, не смешивать с API load test. + +--- + +## 18. Риски и допущения + +### 18.1. Допущения + +1. 1000 пользователей — registered, не 1000 RPS. +2. Контент преимущественно текст + изображения, без video streaming. +3. Один регион хостинга (RU/EU), latency < 100ms для целевой аудитории. +4. Команда: 1–3 fullstack-разработчика. + +### 18.2. Риски + +| Риск | Вероятность | Митигация | +|------|-------------|-----------| +| Scope creep (catalog/orders раньше MVP) | Высокая | Жёсткое следование фазам | +| SPA SEO проблемы | Средняя | Prerender landing, meta tags | +| Cross-module imports ломают архитектуру | Средняя | ESLint boundaries в CI | +| Auth flow complexity (rotation, verify) | Средняя | TDD + integration tests §15.4–15.7 | +| Flaky E2E блокируют CI | Средняя | fixtures, isolated data §15.8, trace-on-retry | +| Перегрузка monolith при росте | Низкая (до 10K) | Redis cache, horizontal scale | + +--- + +## Приложение A — Карта модулей (summary) + +```mindmap + root((Комpton)) + Frontend + app + pages + modules + landing + auth + profile + content + admin + catalog + shared + Backend + core + modules + auth + users + content + media + admin + Infra + PostgreSQL + Redis + S3 + Nginx + "CI/CD" +``` + +--- + +## Приложение B — Переменные окружения + +### Frontend (`apps/web/.env`) + +```env +VITE_API_URL=http://localhost:8000 +VITE_APP_NAME=Комpton +VITE_SENTRY_DSN= +``` + +### Backend (`apps/api/.env`) + +```env +DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/compton +REDIS_URL=redis://localhost:6379/0 +JWT_ACCESS_SECRET= # ≥ 32 bytes random, NOT commit +JWT_REFRESH_PEPPER= # отдельный секрет для hash refresh tokens +JWT_ACCESS_TTL_MIN=15 +JWT_REFRESH_TTL_DAYS=30 +ENABLE_DOCS=true # false в production +CORS_ORIGINS=http://localhost:5173 +S3_ENDPOINT=http://localhost:9000 +S3_BUCKET=compton-media +S3_ACCESS_KEY= +S3_SECRET_KEY= +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM=noreply@compton.example +ADMIN_INITIAL_PASSWORD= # только first deploy, затем сменить +``` + +--- + +## Приложение C — Согласование + +| Роль | ФИО | Подпись | Дата | +|------|-----|---------|------| +| Product Owner | | | | +| Tech Lead | | | | +| Developer | | | | + +--- + +*Документ является живым артеfactом. Изменения фиксируются через PR в `docs/TZ.md` с bump версии.* diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 0000000..b00a7b2 --- /dev/null +++ b/apps/api/.env.example @@ -0,0 +1,43 @@ +DATABASE_URL=postgresql+psycopg://user:pass@localhost:5432/compton +REDIS_URL=redis://localhost:6379/0 +JWT_ACCESS_SECRET=replace-with-32-byte-secret +JWT_REFRESH_PEPPER=replace-with-32-byte-pepper +JWT_ACCESS_TTL_MIN=15 +JWT_REFRESH_TTL_DAYS=30 +ENABLE_DOCS=true +COOKIE_SECURE=false +ENABLE_RATE_LIMIT=true +APP_ENV=development +AUTH_LOCKOUT_ATTEMPTS=5 +AUTH_LOCKOUT_MINUTES=15 +CORS_ORIGINS=["http://localhost:5173"] +TRUSTED_PROXY_IPS= +SMTP_HOST=localhost +SMTP_PORT=1025 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM=noreply@compton.example +FRONTEND_URL=http://localhost:5173 +PUBLIC_BASE_URL=http://localhost:5173 +AUTH_TOKEN_TTL_HOURS=1 +EMAIL_DELIVERY_MODE=memory +S3_ENDPOINT=http://localhost:9000 +S3_ACCESS_KEY=minio +S3_SECRET_KEY=minio123 +S3_BUCKET=compton +S3_REGION=us-east-1 +STORAGE_MODE=s3 +AVATAR_MAX_BYTES=2097152 +MEDIA_URL_TTL_SECONDS=600 +LOG_LEVEL=INFO +AUDIT_RETENTION_DAYS=90 +COMPTON_SETTINGS_PATH=data/compton_settings.json +ADMIN_AUDIT_LOG_PATH=data/logs/admin-audit.jsonl +SERVER_LOG_PATH=data/logs/server.log +PASSWORD_DENYLIST_PATH=data/security/password-denylist.txt +ADMIN_INITIAL_PASSWORD=Admin1234 +DEMO_USER_PASSWORD=User1234 +DEMO_OPS_PASSWORD=OpsAdmin1234 +# E2E only, never enable in production. +ENABLE_TEST_ROUTES=false +SEED_DEMO_USERS=true diff --git a/apps/api/.env.production.example b/apps/api/.env.production.example new file mode 100644 index 0000000..dadff1a --- /dev/null +++ b/apps/api/.env.production.example @@ -0,0 +1,22 @@ +# Production API environment (non-secret keys). Secrets live in data/secrets/install.env +APP_ENV=production +ENABLE_DOCS=false +ENABLE_TEST_ROUTES=false +COOKIE_SECURE=true +ENABLE_RATE_LIMIT=true +EMAIL_DELIVERY_MODE=smtp +SEED_DEMO_USERS=false +JWT_ACCESS_TTL_MIN=15 +JWT_REFRESH_TTL_DAYS=30 +STORAGE_MODE=s3 +S3_BUCKET=compton +S3_REGION=us-east-1 +AVATAR_MAX_BYTES=2097152 +MEDIA_URL_TTL_SECONDS=600 +LOG_LEVEL=INFO +AUDIT_RETENTION_DAYS=90 +COMPTON_SETTINGS_PATH=data/compton_settings.json +ADMIN_AUDIT_LOG_PATH=data/logs/admin-audit.jsonl +SERVER_LOG_PATH=data/logs/server.log +PASSWORD_DENYLIST_PATH=data/security/password-denylist.txt +# Set on server: FRONTEND_URL, PUBLIC_BASE_URL, CORS_ORIGINS, SMTP_*, DATABASE_URL (via install.env) diff --git a/apps/api/.env.test b/apps/api/.env.test new file mode 100644 index 0000000..04a1725 --- /dev/null +++ b/apps/api/.env.test @@ -0,0 +1,11 @@ +DATABASE_URL=postgresql+psycopg://test:test@localhost:5433/compton_test +REDIS_URL=redis://localhost:6380/0 +JWT_ACCESS_SECRET=test-access-secret-32-bytes-minimum +JWT_REFRESH_PEPPER=test-refresh-pepper-32-bytes-min +JWT_ACCESS_TTL_MIN=15 +JWT_REFRESH_TTL_DAYS=30 +ENABLE_DOCS=true +CORS_ORIGINS=["http://localhost:5175"] +EMAIL_DELIVERY_MODE=memory +STORAGE_MODE=memory +ENABLE_TEST_ROUTES=true diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000..96db24e --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.12-slim +WORKDIR /app +ENV PYTHONPATH=/app +COPY requirements-dev.txt . +RUN pip install --no-cache-dir -r requirements-dev.txt +COPY . . +EXPOSE 8000 +CMD ["python", "scripts/docker_entrypoint.py"] diff --git a/apps/api/alembic.ini b/apps/api/alembic.ini new file mode 100644 index 0000000..8a6ad0a --- /dev/null +++ b/apps/api/alembic.ini @@ -0,0 +1,42 @@ +[alembic] +script_location = migrations +prepend_sys_path = . +version_path_separator = os + +sqlalchemy.url = driver://user:pass@localhost/dbname + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/apps/api/app/__init__.py b/apps/api/app/__init__.py new file mode 100644 index 0000000..69d681a --- /dev/null +++ b/apps/api/app/__init__.py @@ -0,0 +1 @@ +"""Compton API application package.""" diff --git a/apps/api/app/celery_app.py b/apps/api/app/celery_app.py new file mode 100644 index 0000000..2c75c49 --- /dev/null +++ b/apps/api/app/celery_app.py @@ -0,0 +1,21 @@ +"""Optional Celery worker for sync reconcile + report refresh.""" + +from __future__ import annotations + +import os + +from celery import Celery + +broker = os.environ.get("CELERY_BROKER_URL", "redis://localhost:6379/0") + +celery_app = Celery("wesp_orchestrator", broker=broker) +celery_app.conf.task_serializer = "json" +celery_app.conf.result_serializer = "json" +celery_app.conf.accept_content = ["json"] + + +@celery_app.task(name="sync.reconcile") +def reconcile_task() -> int: + from app.worker import run_sync_reconcile + + return run_sync_reconcile() diff --git a/apps/api/app/core/app_settings.py b/apps/api/app/core/app_settings.py new file mode 100644 index 0000000..98a146f --- /dev/null +++ b/apps/api/app/core/app_settings.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +from app.core.config import settings + +SETTINGS_ENV_KEYS: dict[str, str] = { + "enable_rate_limit": "ENABLE_RATE_LIMIT", + "enable_docs": "ENABLE_DOCS", + "cookie_secure": "COOKIE_SECURE", + "jwt_access_ttl_min": "JWT_ACCESS_TTL_MIN", + "auth_lockout_attempts": "AUTH_LOCKOUT_ATTEMPTS", + "auth_lockout_minutes": "AUTH_LOCKOUT_MINUTES", + "cors_origins": "CORS_ORIGINS", + "frontend_url": "FRONTEND_URL", + "public_base_url": "PUBLIC_BASE_URL", + "smtp_host": "SMTP_HOST", + "smtp_port": "SMTP_PORT", + "smtp_from": "SMTP_FROM", + "avatar_max_bytes": "AVATAR_MAX_BYTES", + "media_url_ttl_seconds": "MEDIA_URL_TTL_SECONDS", + "log_level": "LOG_LEVEL", + "audit_retention_days": "AUDIT_RETENTION_DAYS", + "jwt_refresh_ttl_days": "JWT_REFRESH_TTL_DAYS", +} + +MANAGED_KEYS = tuple(SETTINGS_ENV_KEYS.keys()) + + +def _settings_file() -> Path: + return Path(settings.compton_settings_path) + + +def get_settings_values() -> dict[str, Any]: + return {key: getattr(settings, key) for key in MANAGED_KEYS} + + +def _coerce_value(key: str, value: Any) -> Any: + current = getattr(settings, key) + if isinstance(current, bool): + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.lower() in {"1", "true", "yes", "on"} + return bool(value) + if isinstance(current, int): + return int(value) + if isinstance(current, list): + if isinstance(value, list): + return [str(item) for item in value] + if isinstance(value, str): + return [item.strip() for item in value.split(",") if item.strip()] + raise ValueError(f"INVALID_LIST_{key}") + return value + + +def env_locks() -> dict[str, bool]: + return {key: os.getenv(env_key) is not None for key, env_key in SETTINGS_ENV_KEYS.items()} + + +def apply_settings_to_app(values: dict[str, Any]) -> None: + for key, value in values.items(): + if key not in MANAGED_KEYS: + continue + setattr(settings, key, _coerce_value(key, value)) + + +def read_settings() -> dict[str, Any]: + path = _settings_file() + if not path.exists(): + return {} + with path.open("r", encoding="utf-8") as file: + payload = json.load(file) + if not isinstance(payload, dict): + return {} + return {key: payload[key] for key in MANAGED_KEYS if key in payload} + + +def write_settings(partial: dict[str, Any]) -> dict[str, Any]: + locks = env_locks() + current = get_settings_values() + for key, value in partial.items(): + if key not in MANAGED_KEYS: + continue + if locks[key]: + continue + current[key] = _coerce_value(key, value) + + path = _settings_file() + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as file: + json.dump(current, file, ensure_ascii=False, indent=2) + return current + + +def bootstrap_settings() -> None: + apply_settings_to_app(read_settings()) + + +def get_settings_payload() -> dict[str, Any]: + locks = env_locks() + values = get_settings_values() + secrets = { + "jwt_access_secret_configured": bool(settings.jwt_access_secret), + "jwt_refresh_pepper_configured": bool(settings.jwt_refresh_pepper), + "smtp_password_configured": bool(settings.smtp_password), + "s3_secret_key_configured": bool(settings.s3_secret_key), + } + return { + "values": values, + "locks": locks, + "settings_path": str(_settings_file()), + "secrets": secrets, + } diff --git a/apps/api/app/core/audit_log.py b/apps/api/app/core/audit_log.py new file mode 100644 index 0000000..ebe9955 --- /dev/null +++ b/apps/api/app/core/audit_log.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from app.core.config import settings + + +def _audit_path() -> Path: + return Path(settings.admin_audit_log_path) + + +def write_audit_event( + action: str, + actor_user_id: str, + actor_email: str, + details: dict[str, Any] | None = None, +) -> None: + payload = { + "timestamp": datetime.now(UTC).isoformat(), + "action": action, + "actor_user_id": actor_user_id, + "actor_email": actor_email, + "details": details or {}, + } + path = _audit_path() + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as file: + file.write(json.dumps(payload, ensure_ascii=False)) + file.write("\n") + + +def read_audit_events(limit: int = 200) -> list[dict[str, Any]]: + path = _audit_path() + if not path.exists(): + return [] + lines = path.read_text(encoding="utf-8").splitlines() + tail = lines[-limit:] + events: list[dict[str, Any]] = [] + for line in tail: + if not line.strip(): + continue + try: + events.append(json.loads(line)) + except json.JSONDecodeError: + continue + return list(reversed(events)) diff --git a/apps/api/app/core/config.py b/apps/api/app/core/config.py new file mode 100644 index 0000000..c99cf35 --- /dev/null +++ b/apps/api/app/core/config.py @@ -0,0 +1,55 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + +from app.core.install_secrets import load_install_secrets_to_env + +load_install_secrets_to_env() + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + database_url: str = "postgresql+psycopg://user:pass@localhost:5432/compton" + redis_url: str = "redis://localhost:6379/0" + jwt_access_secret: str = "change-me-access-secret-with-at-least-32-bytes" + jwt_refresh_pepper: str = "change-me-refresh-pepper-with-at-least-32-bytes" + jwt_access_ttl_min: int = 15 + jwt_refresh_ttl_days: int = 30 + enable_docs: bool = True + cookie_secure: bool = False + cors_origins: list[str] = ["http://localhost:5173"] + enable_rate_limit: bool = True + auth_lockout_attempts: int = 5 + auth_lockout_minutes: int = 15 + admin_initial_password: str = "Admin1234" + demo_user_password: str = "User1234" + demo_ops_password: str = "OpsAdmin1234" + seed_demo_users: bool = True + smtp_host: str = "localhost" + smtp_port: int = 1025 + smtp_user: str = "" + smtp_password: str = "" + smtp_from: str = "noreply@compton.example" + frontend_url: str = "http://localhost:5173" + public_base_url: str = "http://localhost:5173" + auth_token_ttl_hours: int = 1 + email_delivery_mode: str = "smtp" + s3_endpoint: str = "http://localhost:9000" + s3_access_key: str = "minio" + s3_secret_key: str = "minio123" + s3_bucket: str = "compton" + s3_region: str = "us-east-1" + storage_mode: str = "s3" + avatar_max_bytes: int = 2 * 1024 * 1024 + media_url_ttl_seconds: int = 600 + log_level: str = "INFO" + audit_retention_days: int = 90 + password_denylist_path: str = "data/security/password-denylist.txt" + compton_settings_path: str = "data/compton_settings.json" + admin_audit_log_path: str = "data/logs/admin-audit.jsonl" + server_log_path: str = "data/logs/server.log" + enable_test_routes: bool = False + app_env: str = "development" + trusted_proxy_ips: str = "" + + +settings = Settings() diff --git a/apps/api/app/core/crypto.py b/apps/api/app/core/crypto.py new file mode 100644 index 0000000..6fb5367 --- /dev/null +++ b/apps/api/app/core/crypto.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import hashlib +import hmac +import secrets +import time +from datetime import UTC, datetime, timedelta +from urllib.parse import urlencode + +import bcrypt +from jose import jwt + +def generate_secret_token_urlsafe(length: int = 32) -> str: + return secrets.token_urlsafe(length) + + +def generate_secret_token_hex(length: int = 32) -> str: + return secrets.token_hex(length) + + +def generate_install_bundle() -> dict[str, str]: + postgres_user = "compton_app" + postgres_password = generate_secret_token_urlsafe(32) + postgres_db = "compton" + minio_root_user = "minio" + minio_root_password = generate_secret_token_urlsafe(32) + return { + "POSTGRES_USER": postgres_user, + "POSTGRES_PASSWORD": postgres_password, + "POSTGRES_DB": postgres_db, + "DATABASE_URL": f"postgresql+psycopg://{postgres_user}:{postgres_password}@postgres:5432/{postgres_db}", + "JWT_ACCESS_SECRET": generate_secret_token_hex(32), + "JWT_REFRESH_PEPPER": generate_secret_token_hex(32), + "S3_ACCESS_KEY": minio_root_user, + "S3_SECRET_KEY": minio_root_password, + "MINIO_ROOT_USER": minio_root_user, + "MINIO_ROOT_PASSWORD": minio_root_password, + } + + +def hash_password(raw_password: str) -> str: + return bcrypt.hashpw(raw_password.encode("utf-8"), bcrypt.gensalt(rounds=12)).decode("utf-8") + + +def verify_password(raw_password: str, password_hash: str) -> bool: + return bcrypt.checkpw(raw_password.encode("utf-8"), password_hash.encode("utf-8")) + + +def create_access_token( + user_id: str, + role: str, + is_superuser: bool = False, + *, + enterprise_id: str | None = None, + enterprise_role: str | None = None, + farm_ids: list[str] | None = None, +) -> str: + from app.core.config import settings + from app.core.jwt_denylist import get_auth_epoch + + now = datetime.now(UTC) + payload = { + "sub": user_id, + "role": role, + "is_superuser": is_superuser, + "auth_epoch": get_auth_epoch(user_id), + "iat": int(now.timestamp()), + "exp": int((now + timedelta(minutes=settings.jwt_access_ttl_min)).timestamp()), + "jti": generate_secret_token_hex(16), + } + if enterprise_id: + payload["enterprise_id"] = enterprise_id + if enterprise_role: + payload["enterprise_role"] = enterprise_role + if farm_ids is not None: + payload["farm_ids"] = farm_ids + return jwt.encode(payload, settings.jwt_access_secret, algorithm="HS256") + + +def decode_access_token(token: str) -> dict: + from app.core.config import settings + + return jwt.decode(token, settings.jwt_access_secret, algorithms=["HS256"]) + + +def generate_refresh_token() -> str: + return generate_secret_token_urlsafe(48) + + +def generate_opaque_token() -> str: + return generate_secret_token_urlsafe(32) + + +def hash_opaque_token(token: str) -> str: + return hash_refresh_token(token) + + +def hash_refresh_token(token: str) -> str: + from app.core.config import settings + + return hashlib.sha256(f"{token}:{settings.jwt_refresh_pepper}".encode("utf-8")).hexdigest() + + +def build_signed_media_url(stored_url: str | None) -> str | None: + from app.core.config import settings + + if not stored_url: + return None + if not stored_url.startswith("/api/v1/media/files/"): + return stored_url + path = stored_url.removeprefix("/api/v1/media/files/") + expires = int(time.time()) + settings.media_url_ttl_seconds + signature = _sign_media_path(path, expires) + query = urlencode({"expires": expires, "sig": signature}) + return f"/api/v1/media/files/{path}?{query}" + + +def verify_signed_media(path: str, expires: int, signature: str) -> bool: + if expires < int(time.time()): + return False + expected = _sign_media_path(path, expires) + return hmac.compare_digest(expected, signature) + + +def _sign_media_path(path: str, expires: int) -> str: + from app.core.config import settings + + payload = f"{path}:{expires}" + return hmac.new( + settings.jwt_access_secret.encode("utf-8"), + payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() diff --git a/apps/api/app/core/database.py b/apps/api/app/core/database.py new file mode 100644 index 0000000..46dd17f --- /dev/null +++ b/apps/api/app/core/database.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from collections.abc import Generator +from contextlib import contextmanager + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.core.config import settings + +_connect_args: dict[str, object] = {} +_engine_kwargs: dict[str, object] = { + "pool_pre_ping": True, + "future": True, + "pool_size": 5, + "max_overflow": 10, + "pool_recycle": 1800, +} + +if settings.database_url.startswith("sqlite"): + _connect_args["check_same_thread"] = False + _engine_kwargs["poolclass"] = StaticPool + _engine_kwargs.pop("pool_size", None) + _engine_kwargs.pop("max_overflow", None) + _engine_kwargs.pop("pool_recycle", None) + +engine = create_engine(settings.database_url, connect_args=_connect_args, **_engine_kwargs) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + + +@contextmanager +def session_scope() -> Generator[Session, None, None]: + session = SessionLocal() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() + + +def get_db() -> Generator[Session, None, None]: + session = SessionLocal() + try: + yield session + finally: + session.close() diff --git a/apps/api/app/core/datetime_utils.py b/apps/api/app/core/datetime_utils.py new file mode 100644 index 0000000..edae980 --- /dev/null +++ b/apps/api/app/core/datetime_utils.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from datetime import UTC, datetime + + +def ensure_utc(value: datetime | None) -> datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC) + + +def utc_now() -> datetime: + return datetime.now(UTC) diff --git a/apps/api/app/core/dependencies.py b/apps/api/app/core/dependencies.py new file mode 100644 index 0000000..85286af --- /dev/null +++ b/apps/api/app/core/dependencies.py @@ -0,0 +1,45 @@ +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from app.core.jwt_denylist import validate_access_claims +from app.core.security import decode_access_token +from app.modules.users.repository import get_user_by_id + +bearer = HTTPBearer(auto_error=False) + + +def get_current_user(credentials: HTTPAuthorizationCredentials | None = Depends(bearer)): + if credentials is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="UNAUTHORIZED") + try: + payload = decode_access_token(credentials.credentials) + validate_access_claims(payload) + except ValueError as exc: + detail = str(exc) + if detail == "TOKEN_REVOKED": + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="TOKEN_REVOKED") + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_TOKEN") from exc + except Exception as exc: # pragma: no cover - defensive + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_TOKEN") from exc + user = get_user_by_id(payload["sub"]) + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="UNAUTHORIZED") + if user.status == "pending": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="EMAIL_NOT_VERIFIED") + if user.status == "blocked": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ACCOUNT_BLOCKED") + return user + + +def require_admin(user=Depends(get_current_user)): + if user.role != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ADMIN_ONLY") + return user + + +def require_superuser(user=Depends(get_current_user)): + if user.role != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ADMIN_ONLY") + if not user.is_superuser: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="SUPERUSER_ONLY") + return user diff --git a/apps/api/app/core/email.py b/apps/api/app/core/email.py new file mode 100644 index 0000000..cb9a136 --- /dev/null +++ b/apps/api/app/core/email.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import smtplib +from dataclasses import dataclass +from email.message import EmailMessage + +from app.core.config import settings + + +@dataclass +class SentEmail: + to: str + subject: str + body: str + template: str + + +class MemoryMailer: + def __init__(self) -> None: + self.sent: list[SentEmail] = [] + + def send(self, to: str, subject: str, body: str, template: str) -> None: + self.sent.append(SentEmail(to=to, subject=subject, body=body, template=template)) + + def clear(self) -> None: + self.sent.clear() + + def latest_token(self, recipient: str, template: str) -> str | None: + for message in reversed(self.sent): + if message.to == recipient and message.template == template: + for line in message.body.splitlines(): + if line.startswith("TOKEN:"): + return line.split(":", 1)[1].strip() + return None + + +class SmtpMailer: + def send(self, to: str, subject: str, body: str, template: str) -> None: + _ = template + message = EmailMessage() + message["From"] = settings.smtp_from + message["To"] = to + message["Subject"] = subject + message.set_content(body) + with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as smtp: + if settings.smtp_user: + smtp.login(settings.smtp_user, settings.smtp_password) + smtp.send_message(message) + + +memory_mailer = MemoryMailer() + + +def get_mailer(): + if settings.email_delivery_mode == "memory": + return memory_mailer + return SmtpMailer() + + +def send_template_email(to: str, template: str, subject: str, body: str) -> None: + get_mailer().send(to=to, subject=subject, body=body, template=template) diff --git a/apps/api/app/core/exceptions.py b/apps/api/app/core/exceptions.py new file mode 100644 index 0000000..0f15a8c --- /dev/null +++ b/apps/api/app/core/exceptions.py @@ -0,0 +1,2 @@ +class DomainError(Exception): + """Base domain error.""" diff --git a/apps/api/app/core/install_secrets.py b/apps/api/app/core/install_secrets.py new file mode 100644 index 0000000..c880215 --- /dev/null +++ b/apps/api/app/core/install_secrets.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from urllib.parse import ParseResult, urlparse, urlunparse +from uuid import uuid4 + +from app.core.crypto import generate_install_bundle + +_API_ROOT = Path(__file__).resolve().parents[2] +_INSTALL_SECRETS_ROOT = Path(os.getenv("COMPTON_INSTALL_SECRETS_DIR", str(_API_ROOT / "data" / "secrets"))) +INSTALL_SECRETS_DIR = _INSTALL_SECRETS_ROOT +INSTALL_SECRETS_FILE = INSTALL_SECRETS_DIR / "install.env" +INSTALL_SECRETS_META_FILE = INSTALL_SECRETS_DIR / "install.meta.json" +REQUIRED_KEYS = ( + "POSTGRES_USER", + "POSTGRES_PASSWORD", + "POSTGRES_DB", + "DATABASE_URL", + "JWT_ACCESS_SECRET", + "JWT_REFRESH_PEPPER", + "S3_ACCESS_KEY", + "S3_SECRET_KEY", + "MINIO_ROOT_USER", + "MINIO_ROOT_PASSWORD", +) + + +@dataclass +class InstallSecretsStatus: + initialized: bool + locked: bool + path: str + created: bool + + +def _parse_env_text(raw: str) -> dict[str, str]: + values: dict[str, str] = {} + for line in raw.splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + values[key.strip()] = value.strip() + return values + + +def _render_env(values: dict[str, str]) -> str: + ordered = [f"{key}={values[key]}" for key in sorted(values.keys())] + return "\n".join(ordered) + "\n" + + +def read_install_secrets() -> dict[str, str]: + if not INSTALL_SECRETS_FILE.exists(): + return {} + return _parse_env_text(INSTALL_SECRETS_FILE.read_text(encoding="utf-8")) + + +def _write_install_secrets(values: dict[str, str]) -> None: + INSTALL_SECRETS_DIR.mkdir(parents=True, exist_ok=True) + INSTALL_SECRETS_FILE.write_text(_render_env(values), encoding="utf-8") + + +def _write_meta() -> None: + payload = ( + "{\n" + f' "install_id": "{uuid4()}",\n' + f' "locked_at": "{datetime.now(UTC).isoformat()}"\n' + "}\n" + ) + INSTALL_SECRETS_META_FILE.write_text(payload, encoding="utf-8") + + +def _is_postgres_database_url(database_url: str) -> bool: + scheme = urlparse(database_url).scheme.lower() + return scheme.startswith("postgresql") or scheme.startswith("postgres+") + + +def _adopt_from_environment() -> dict[str, str]: + env_values = {key: os.getenv(key, "") for key in REQUIRED_KEYS if key != "DATABASE_URL"} + db_url = os.getenv("DATABASE_URL", "") + if db_url and _is_postgres_database_url(db_url): + parsed = urlparse(db_url) + if parsed.username: + env_values["POSTGRES_USER"] = parsed.username + if parsed.password: + env_values["POSTGRES_PASSWORD"] = parsed.password + if parsed.path and parsed.path != "/": + env_values["POSTGRES_DB"] = parsed.path.lstrip("/") + env_values["DATABASE_URL"] = db_url + return {key: value for key, value in env_values.items() if value} + + +def _sync_minio_s3_secrets(values: dict[str, str]) -> dict[str, str]: + """MinIO root credentials are the S3 access key pair — keep them aligned.""" + if ( + values.get("S3_ACCESS_KEY") == values.get("MINIO_ROOT_USER") + and values.get("MINIO_ROOT_PASSWORD") + and values.get("S3_SECRET_KEY") != values["MINIO_ROOT_PASSWORD"] + ): + values = dict(values) + values["S3_SECRET_KEY"] = values["MINIO_ROOT_PASSWORD"] + return values + + +def load_install_secrets_to_env() -> None: + values = _sync_minio_s3_secrets(read_install_secrets()) + for key, value in values.items(): + os.environ[key] = value + + +def ensure_install_secrets() -> InstallSecretsStatus: + existing = read_install_secrets() + if existing: + db_url = existing.get("DATABASE_URL", "") + if db_url and not _is_postgres_database_url(db_url): + raise RuntimeError( + "install.env DATABASE_URL must be PostgreSQL (got SQLite). " + "Remove apps/api/data/secrets/install.env and run: python apps/api/scripts/bootstrap_install.py" + ) + synced = _sync_minio_s3_secrets(existing) + if synced != existing: + synced["SECRETS_LOCKED"] = existing.get("SECRETS_LOCKED", "true") + _write_install_secrets(synced) + existing = synced + load_install_secrets_to_env() + return InstallSecretsStatus(True, existing.get("SECRETS_LOCKED", "false") == "true", str(INSTALL_SECRETS_FILE), False) + + adopted = _adopt_from_environment() + generated = generate_install_bundle() + values = generated | adopted + values["SECRETS_LOCKED"] = "true" + _write_install_secrets(values) + _write_meta() + load_install_secrets_to_env() + return InstallSecretsStatus(True, True, str(INSTALL_SECRETS_FILE), True) + + +def masked_database_url(database_url: str) -> str: + parsed = urlparse(database_url) + if not parsed.username: + return database_url + password = "***" if parsed.password else "" + credentials = f"{parsed.username}:{password}" if password else parsed.username + host = parsed.hostname or "" + if parsed.port: + host = f"{host}:{parsed.port}" + netloc = f"{credentials}@{host}" + sanitized = ParseResult( + scheme=parsed.scheme, + netloc=netloc, + path=parsed.path, + params=parsed.params, + query=parsed.query, + fragment=parsed.fragment, + ) + return urlunparse(sanitized) + + +def install_secrets_payload() -> dict: + values = read_install_secrets() + db = urlparse(values.get("DATABASE_URL", "")) + return { + "initialized": bool(values), + "locked": values.get("SECRETS_LOCKED") == "true", + "secrets_path": str(INSTALL_SECRETS_FILE), + "database": { + "host": db.hostname, + "port": db.port, + "database": db.path.lstrip("/") if db.path else "", + "user": db.username, + "password_configured": bool(values.get("POSTGRES_PASSWORD")), + }, + "connection_string_masked": masked_database_url(values.get("DATABASE_URL", "")), + "secrets_status": { + "jwt_access_secret": "configured" if bool(values.get("JWT_ACCESS_SECRET")) else "missing", + "jwt_refresh_pepper": "configured" if bool(values.get("JWT_REFRESH_PEPPER")) else "missing", + "postgres_password": "configured" if bool(values.get("POSTGRES_PASSWORD")) else "missing", + "s3_secret_key": "configured" if bool(values.get("S3_SECRET_KEY")) else "missing", + "password_bcrypt_salt": "per_user_in_db", + }, + } + + +def reveal_install_secret(key: str) -> str: + mapping = { + "database_password": "POSTGRES_PASSWORD", + "jwt_access_secret": "JWT_ACCESS_SECRET", + "jwt_refresh_pepper": "JWT_REFRESH_PEPPER", + "s3_secret_key": "S3_SECRET_KEY", + } + env_key = mapping.get(key) + if not env_key: + raise ValueError("UNSUPPORTED_SECRET_KEY") + values = read_install_secrets() + if env_key in values: + return values[env_key] + if env_key == "POSTGRES_PASSWORD": + database_url = values.get("DATABASE_URL") or os.getenv("DATABASE_URL", "") + parsed = urlparse(database_url) + return parsed.password or "" + return os.getenv(env_key, "") + diff --git a/apps/api/app/core/jwt_denylist.py b/apps/api/app/core/jwt_denylist.py new file mode 100644 index 0000000..3d606b0 --- /dev/null +++ b/apps/api/app/core/jwt_denylist.py @@ -0,0 +1,108 @@ +"""Redis-backed JWT jti denylist and per-user auth_epoch for instant access revocation.""" + +from __future__ import annotations + +import time + +from app.core.config import settings +from app.core.redis import get_redis_client + +AUTH_EPOCH_PREFIX = "auth:epoch:" +JWT_DENY_PREFIX = "jwt:deny:" + +_memory_epochs: dict[str, int] = {} +_memory_denied_jti: dict[str, float] = {} + + +def _purge_expired_memory_jtis() -> None: + now = time.time() + expired = [jti for jti, exp in _memory_denied_jti.items() if exp <= now] + for jti in expired: + _memory_denied_jti.pop(jti, None) + + +def ensure_jwt_revocation_backend() -> None: + if settings.app_env.lower() != "production": + return + if get_redis_client() is None: + raise RuntimeError("Redis is required for JWT revocation in production") + + +def get_auth_epoch(user_id: str) -> int: + client = get_redis_client() + if client is not None: + try: + value = client.get(f"{AUTH_EPOCH_PREFIX}{user_id}") + return int(value) if value is not None else 0 + except Exception: + if settings.app_env.lower() == "production": + raise + return _memory_epochs.get(user_id, 0) + + +def bump_auth_epoch(user_id: str) -> int: + client = get_redis_client() + if client is not None: + try: + return int(client.incr(f"{AUTH_EPOCH_PREFIX}{user_id}")) + except Exception: + if settings.app_env.lower() == "production": + raise + next_epoch = _memory_epochs.get(user_id, 0) + 1 + _memory_epochs[user_id] = next_epoch + return next_epoch + + +def deny_jti(jti: str, exp: int) -> None: + if not jti: + return + ttl = max(int(exp - time.time()), 1) + client = get_redis_client() + if client is not None: + try: + client.setex(f"{JWT_DENY_PREFIX}{jti}", ttl, "1") + return + except Exception: + if settings.app_env.lower() == "production": + raise + _purge_expired_memory_jtis() + _memory_denied_jti[jti] = time.time() + ttl + + +def is_jti_denied(jti: str) -> bool: + if not jti: + return False + client = get_redis_client() + if client is not None: + try: + return bool(client.exists(f"{JWT_DENY_PREFIX}{jti}")) + except Exception: + if settings.app_env.lower() == "production": + return True + _purge_expired_memory_jtis() + return jti in _memory_denied_jti + + +def revoke_access_token(token: str) -> None: + from app.core.security import decode_access_token + + try: + payload = decode_access_token(token) + except Exception: + return + jti = payload.get("jti") + exp = payload.get("exp") + if jti and exp: + deny_jti(str(jti), int(exp)) + + +def validate_access_claims(payload: dict) -> None: + user_id = payload.get("sub") + if not user_id: + raise ValueError("INVALID_TOKEN") + jti = payload.get("jti") + if jti and is_jti_denied(str(jti)): + raise ValueError("TOKEN_REVOKED") + token_epoch = int(payload.get("auth_epoch", 0)) + if token_epoch != get_auth_epoch(str(user_id)): + raise ValueError("TOKEN_REVOKED") diff --git a/apps/api/app/core/media_signing.py b/apps/api/app/core/media_signing.py new file mode 100644 index 0000000..abdadf7 --- /dev/null +++ b/apps/api/app/core/media_signing.py @@ -0,0 +1,3 @@ +from app.core.crypto import build_signed_media_url, verify_signed_media + +__all__ = ["build_signed_media_url", "verify_signed_media"] diff --git a/apps/api/app/core/password_denylist.py b/apps/api/app/core/password_denylist.py new file mode 100644 index 0000000..aeb0995 --- /dev/null +++ b/apps/api/app/core/password_denylist.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from pathlib import Path + +from app.core.config import settings + +COMMON_PASSWORDS = frozenset( + { + "password", + "password1", + "password123", + "12345678", + "123456789", + "qwerty123", + "admin123", + "admin1234", + "letmein1", + "welcome1", + "iloveyou1", + "sunshine1", + "football1", + "baseball1", + "monkey123", + "dragon123", + "master123", + "trustno1", + "passw0rd", + "passw0rd1", + } +) + + +def load_denylist() -> set[str]: + denylist = set(COMMON_PASSWORDS) + path = Path(settings.password_denylist_path) + if not path.exists(): + return denylist + for line in path.read_text(encoding="utf-8").splitlines(): + candidate = line.strip().lower() + if candidate and not candidate.startswith("#"): + denylist.add(candidate) + return denylist + + +def is_denied_password(password: str) -> bool: + return password.lower() in load_denylist() diff --git a/apps/api/app/core/password_policy.py b/apps/api/app/core/password_policy.py new file mode 100644 index 0000000..1b0f56e --- /dev/null +++ b/apps/api/app/core/password_policy.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import re + + +from app.core.password_denylist import is_denied_password + + +def validate_password_strength(password: str) -> str: + if len(password) < 8: + raise ValueError("Password must be at least 8 characters long") + if is_denied_password(password): + raise ValueError("Password is too common") + if not re.search(r"[A-Z]", password): + raise ValueError("Password must include at least one uppercase letter") + if not re.search(r"[a-z]", password): + raise ValueError("Password must include at least one lowercase letter") + if not re.search(r"\d", password): + raise ValueError("Password must include at least one digit") + return password diff --git a/apps/api/app/core/redis.py b/apps/api/app/core/redis.py new file mode 100644 index 0000000..647fd80 --- /dev/null +++ b/apps/api/app/core/redis.py @@ -0,0 +1,74 @@ +"""Redis-first rate limiter with in-memory fallback.""" + +from __future__ import annotations + +from collections import defaultdict +from datetime import UTC, datetime, timedelta + +from fastapi import HTTPException, Request, status +from redis import Redis +from redis.exceptions import RedisError + +from app.core.config import settings + +_buckets: dict[str, list[datetime]] = defaultdict(list) +_redis_client: Redis | None = None + + +def get_redis_client() -> Redis | None: + global _redis_client + if _redis_client is not None: + return _redis_client + try: + _redis_client = Redis.from_url(settings.redis_url, decode_responses=True) + _redis_client.ping() + return _redis_client + except RedisError: + _redis_client = None + return None + + +def check_rate_limit(key: str, limit: int, window_seconds: int) -> None: + if not settings.enable_rate_limit: + return + redis_client = get_redis_client() + if redis_client is not None: + redis_key = f"rl:{key}" + try: + current = redis_client.incr(redis_key) + if current == 1: + redis_client.expire(redis_key, window_seconds) + if current > limit: + ttl = redis_client.ttl(redis_key) + retry_after = ttl if ttl and ttl > 0 else window_seconds + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="RATE_LIMIT_EXCEEDED", + headers={"Retry-After": str(retry_after)}, + ) + return + except RedisError: + pass + + now = datetime.now(UTC) + cutoff = now - timedelta(seconds=window_seconds) + timestamps = [moment for moment in _buckets[key] if moment > cutoff] + if len(timestamps) >= limit: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="RATE_LIMIT_EXCEEDED", + headers={"Retry-After": str(window_seconds)}, + ) + timestamps.append(now) + _buckets[key] = timestamps + + +def client_ip(request: Request) -> str: + trusted_proxy_ips = {item.strip() for item in settings.trusted_proxy_ips.split(",") if item.strip()} + forwarded = request.headers.get("X-Forwarded-For") + request_ip = request.client.host if request.client else "" + if forwarded and request_ip in trusted_proxy_ips: + return forwarded.split(",")[0].strip() + if request.client: + return request_ip + return "unknown" diff --git a/apps/api/app/core/security.py b/apps/api/app/core/security.py new file mode 100644 index 0000000..72d8460 --- /dev/null +++ b/apps/api/app/core/security.py @@ -0,0 +1,21 @@ +from app.core.crypto import ( + create_access_token, + decode_access_token, + generate_opaque_token, + generate_refresh_token, + hash_opaque_token, + hash_password, + hash_refresh_token, + verify_password, +) + +__all__ = [ + "create_access_token", + "decode_access_token", + "generate_opaque_token", + "generate_refresh_token", + "hash_opaque_token", + "hash_password", + "hash_refresh_token", + "verify_password", +] diff --git a/apps/api/app/core/server_logging.py b/apps/api/app/core/server_logging.py new file mode 100644 index 0000000..0bda524 --- /dev/null +++ b/apps/api/app/core/server_logging.py @@ -0,0 +1,63 @@ +"""File logging for WESP admin server-log viewer (SERVER_LOG_PATH).""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from app.core.config import settings + +_LOG_FORMAT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s" + + +def server_log_path() -> Path: + return Path(settings.server_log_path) + + +def ensure_server_log_file() -> Path: + path = server_log_path() + path.parent.mkdir(parents=True, exist_ok=True) + if not path.is_file(): + started = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + path.write_text(f"{started} [INFO] compton.api: server log initialized\n", encoding="utf-8") + return path.resolve() + + +def build_uvicorn_log_config() -> dict[str, Any]: + log_path = str(ensure_server_log_file()) + return { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "default": { + "format": _LOG_FORMAT, + }, + }, + "handlers": { + "file": { + "class": "logging.FileHandler", + "filename": log_path, + "encoding": "utf-8", + "formatter": "default", + }, + "console": { + "class": "logging.StreamHandler", + "formatter": "default", + }, + }, + "loggers": { + "uvicorn": {"handlers": ["file", "console"], "level": settings.log_level, "propagate": False}, + "uvicorn.error": {"handlers": ["file", "console"], "level": settings.log_level, "propagate": False}, + "uvicorn.access": {"handlers": ["file", "console"], "level": "INFO", "propagate": False}, + }, + "root": {"handlers": ["file", "console"], "level": settings.log_level}, + } + + +def append_server_log_line(message: str, level: str = "INFO") -> None: + path = ensure_server_log_file() + ts = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + with path.open("a", encoding="utf-8") as fh: + fh.write(f"{ts} [{level}] compton.api: {message}\n") diff --git a/apps/api/app/core/storage.py b/apps/api/app/core/storage.py new file mode 100644 index 0000000..488f045 --- /dev/null +++ b/apps/api/app/core/storage.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from app.core.config import settings + +_s3_client = None + + +def get_s3_client(): + global _s3_client + if _s3_client is None: + import boto3 + + _s3_client = boto3.client( + "s3", + endpoint_url=settings.s3_endpoint, + aws_access_key_id=settings.s3_access_key, + aws_secret_access_key=settings.s3_secret_key, + region_name=settings.s3_region, + ) + return _s3_client + + +def ensure_bucket() -> None: + if settings.storage_mode != "s3": + return + client = get_s3_client() + bucket = settings.s3_bucket + try: + client.head_bucket(Bucket=bucket) + except Exception: + client.create_bucket(Bucket=bucket) + + +def upload_object(key: str, body: bytes, content_type: str) -> None: + if settings.storage_mode == "memory": + memory_store[key] = (body, content_type) + return + client = get_s3_client() + ensure_bucket() + client.put_object( + Bucket=settings.s3_bucket, + Key=key, + Body=body, + ContentType=content_type, + ) + + +def download_object(key: str) -> tuple[bytes, str] | None: + if settings.storage_mode == "memory": + return memory_store.get(key) + client = get_s3_client() + try: + response = client.get_object(Bucket=settings.s3_bucket, Key=key) + body = response["Body"].read() + content_type = response.get("ContentType", "application/octet-stream") + return body, content_type + except Exception: + return None + + +memory_store: dict[str, tuple[bytes, str]] = {} diff --git a/apps/api/app/db/__init__.py b/apps/api/app/db/__init__.py new file mode 100644 index 0000000..5367486 --- /dev/null +++ b/apps/api/app/db/__init__.py @@ -0,0 +1,3 @@ +from app.db.base import Base + +__all__ = ["Base"] diff --git a/apps/api/app/db/base.py b/apps/api/app/db/base.py new file mode 100644 index 0000000..fa2b68a --- /dev/null +++ b/apps/api/app/db/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/apps/api/app/db/models.py b/apps/api/app/db/models.py new file mode 100644 index 0000000..6b1575b --- /dev/null +++ b/apps/api/app/db/models.py @@ -0,0 +1,80 @@ +from app.modules.auth.models import EmailVerificationToken, PasswordResetToken, RefreshToken +from app.modules.content.models import ContentPage +from app.modules.sync.models import ( + Enterprise, + EnterpriseMember, + FarmHub, + HubCredential, + HubPairingSession, + ReportSyncState, + SyncAppliedEvent, + SyncConflict, + SyncCursor, + SyncEventLog, + SyncOutbox, + SyncReconcileRun, + SyncRecordState, + UserFarmAccess, +) +from app.modules.users.models import User, UserProfile +from app.modules.zootech.settings_models import ZootechFeedQualitySettings, ZootechOrgSettings +from app.modules.zootech.catalog_models import ( + ZootechDailyComponentNormAdjustment, + ZootechDailyIngredientReplacement, + ZootechDailyIngredientSkip, + ZootechDailyTripSkip, + ZootechDailyUnloadingGroupSkip, + ZootechFeedDispenser, + ZootechFeedingLocation, + ZootechFeedingPeriod, + ZootechFeedingPoint, + ZootechFeedMixer, + ZootechPeriodRecipe, + ZootechTrip, + ZootechUnloadingGroup, +) +from app.modules.zootech.report_models import ZootechFeedAlert, ZootechLoadingReport, ZootechUnloadingReport + +__all__ = [ + "User", + "UserProfile", + "RefreshToken", + "PasswordResetToken", + "EmailVerificationToken", + "ContentPage", + "Enterprise", + "EnterpriseMember", + "FarmHub", + "UserFarmAccess", + "HubCredential", + "HubPairingSession", + "SyncOutbox", + "SyncEventLog", + "SyncAppliedEvent", + "SyncCursor", + "SyncRecordState", + "SyncConflict", + "SyncReconcileRun", + "ReportSyncState", + "ZootechComponent", + "ZootechRecipe", + "ZootechIngredient", + "ZootechUnloadingGroup", + "ZootechFeedMixer", + "ZootechFeedDispenser", + "ZootechFeedingLocation", + "ZootechFeedingPeriod", + "ZootechFeedingPoint", + "ZootechPeriodRecipe", + "ZootechTrip", + "ZootechDailyTripSkip", + "ZootechDailyIngredientSkip", + "ZootechDailyUnloadingGroupSkip", + "ZootechDailyIngredientReplacement", + "ZootechDailyComponentNormAdjustment", + "ZootechLoadingReport", + "ZootechUnloadingReport", + "ZootechFeedAlert", + "ZootechOrgSettings", + "ZootechFeedQualitySettings", +] diff --git a/apps/api/app/db/seed.py b/apps/api/app/db/seed.py new file mode 100644 index 0000000..f851cf6 --- /dev/null +++ b/apps/api/app/db/seed.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from app.core.config import settings +from app.core.security import hash_password +from app.modules.content.repository import create_page, get_page_by_slug +from app.modules.users.repository import create_user, get_user_by_email + + +def _ensure_user(email: str, password: str, role: str, is_superuser: bool, status: str): + from app.modules.users import repository + + user = get_user_by_email(email) + if user: + changed = False + if user.role != role: + user.role = role + changed = True + if user.is_superuser != is_superuser: + user.is_superuser = is_superuser + changed = True + if user.status != status: + user.status = status + changed = True + if user.email_verified_at is None and status == "active": + user.email_verified_at = datetime.now(UTC) + changed = True + if changed: + repository.update_user(user) + return user + + user = create_user( + email=email, + password_hash=hash_password(password), + role=role, + is_superuser=is_superuser, + status=status, + ) + user.email_verified_at = datetime.now(UTC) + repository.update_user(user) + return user + + +def run_seed(include_demo_pages: bool = True) -> None: + admin = _ensure_user( + email="admin@compton.example", + password=settings.admin_initial_password, + role="admin", + is_superuser=True, + status="active", + ) + if settings.seed_demo_users and settings.app_env.lower() != "production": + _ensure_user( + email="user@compton.example", + password=settings.demo_user_password, + role="user", + is_superuser=False, + status="active", + ) + _ensure_user( + email="ops@compton.example", + password=settings.demo_ops_password, + role="admin", + is_superuser=False, + status="active", + ) + + if not include_demo_pages: + return + + demo_pages = [ + { + "slug": "about", + "title": "О бренде", + "body": "

Compton — платформа Organic Tech.

", + }, + { + "slug": "privacy", + "title": "Политика конфиденциальности", + "body": "

Мы обрабатываем персональные данные согласно политике.

", + }, + { + "slug": "terms", + "title": "Условия использования", + "body": "

Используя сервис, вы принимаете условия.

", + }, + ] + + for page in demo_pages: + if get_page_by_slug(page["slug"], include_draft=True): + continue + create_page( + slug=page["slug"], + title=page["title"], + body=page["body"], + status="published", + author_id=admin.id, + ) diff --git a/apps/api/app/db/token_cleanup.py b/apps/api/app/db/token_cleanup.py new file mode 100644 index 0000000..b1ddc60 --- /dev/null +++ b/apps/api/app/db/token_cleanup.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from sqlalchemy import delete, or_ + +from app.core.database import session_scope +from app.modules.auth.models import EmailVerificationToken, PasswordResetToken, RefreshToken + + +def cleanup_expired_tokens(retention_days: int = 30) -> dict[str, int]: + now = datetime.now(UTC) + revoked_cutoff = now - timedelta(days=retention_days) + with session_scope() as db: + refresh_deleted = db.execute( + delete(RefreshToken).where( + or_( + RefreshToken.expires_at < now, + RefreshToken.revoked_at.is_not(None) & (RefreshToken.revoked_at < revoked_cutoff), + ) + ) + ).rowcount or 0 + reset_deleted = db.execute( + delete(PasswordResetToken).where( + or_( + PasswordResetToken.expires_at < now, + PasswordResetToken.used_at.is_not(None) & (PasswordResetToken.used_at < now), + ) + ) + ).rowcount or 0 + verify_deleted = db.execute( + delete(EmailVerificationToken).where( + or_( + EmailVerificationToken.expires_at < now, + EmailVerificationToken.used_at.is_not(None) & (EmailVerificationToken.used_at < now), + ) + ) + ).rowcount or 0 + return { + "refresh_tokens_deleted": int(refresh_deleted), + "password_reset_tokens_deleted": int(reset_deleted), + "email_verification_tokens_deleted": int(verify_deleted), + } diff --git a/apps/api/app/main.py b/apps/api/app/main.py new file mode 100644 index 0000000..c4dec6f --- /dev/null +++ b/apps/api/app/main.py @@ -0,0 +1,121 @@ +from contextlib import asynccontextmanager +from urllib.parse import urlparse, parse_qs + +from fastapi import FastAPI, HTTPException, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from fastapi.exception_handlers import http_exception_handler + +from app.core.install_secrets import ensure_install_secrets +from app.core.config import settings +from app.core.jwt_denylist import ensure_jwt_revocation_backend +from app.core.app_settings import bootstrap_settings +from app.core.storage import ensure_bucket +from app.core import database as db_module +from app.db.token_cleanup import cleanup_expired_tokens +from app.db.base import Base +from app.db import models as _models # noqa: F401 +from app.db.seed import run_seed +from app.modules.auth.router import router as auth_router +from app.modules.users.router import router as users_router +from app.modules.content.router import router as content_router +from app.modules.admin.router import router as admin_router +from app.modules.media.router import router as media_router +from app.modules.test.router import router as test_router +from app.modules.sync.router import router as sync_router, enterprise_router as sync_enterprise_router +from app.modules.zootech.router import router as zootech_router +from app.modules.zootech.wesp_compat import router as wesp_compat_router +from app.modules.zootech.wesp_compat_auth import router as wesp_compat_auth_router +from app.modules.zootech.wesp_compat_sync import router as wesp_compat_sync_router +from app.modules.zootech.wesp_compat_admin import router as wesp_compat_admin_router +from app.modules.zootech.wesp_compat_misc import router as wesp_compat_misc_router +from app.modules.zootech.wesp_compat_reports import router as wesp_compat_reports_router +from app.modules.zootech.wesp_compat_analytics import router as wesp_compat_analytics_router +from app.modules.enterprise.router import router as enterprise_router + + +def _assert_production_guards() -> None: + if settings.app_env.lower() != "production": + return + if settings.enable_test_routes: + raise RuntimeError("ENABLE_TEST_ROUTES must be false in production") + if settings.enable_docs: + raise RuntimeError("ENABLE_DOCS must be false in production") + if not settings.enable_rate_limit: + raise RuntimeError("ENABLE_RATE_LIMIT must be true in production") + if not settings.cookie_secure: + raise RuntimeError("COOKIE_SECURE must be true in production") + if settings.jwt_access_secret.startswith("change-me-"): + raise RuntimeError("JWT_ACCESS_SECRET placeholder is not allowed in production") + if settings.jwt_refresh_pepper.startswith("change-me-"): + raise RuntimeError("JWT_REFRESH_PEPPER placeholder is not allowed in production") + parsed = urlparse(settings.database_url) + if parsed.username == "user" and parsed.password == "pass": + raise RuntimeError("Default database credentials are not allowed in production") + if parsed.scheme.startswith("postgresql"): + sslmode = parse_qs(parsed.query).get("sslmode", [""])[0] + if sslmode != "require": + raise RuntimeError("DATABASE_URL must contain sslmode=require in production") + + +def create_app() -> FastAPI: + @asynccontextmanager + async def lifespan(_: FastAPI): + ensure_install_secrets() + bootstrap_settings() + ensure_jwt_revocation_backend() + _assert_production_guards() + if settings.enable_test_routes: + Base.metadata.create_all(db_module.engine) + run_seed() + cleanup_expired_tokens() + ensure_bucket() + yield + + app = FastAPI( + title="Compton API", + version="1.0.0", + docs_url="/api/v1/docs" if settings.enable_docs else None, + openapi_url="/api/v1/openapi.json" if settings.enable_docs else None, + lifespan=lifespan, + ) + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"], + allow_headers=["Authorization", "Content-Type", "X-Request-ID"], + ) + + @app.exception_handler(HTTPException) + async def wesp_compat_http_exception_handler(request: Request, exc: HTTPException): + if isinstance(exc.detail, dict) and exc.detail.get("status") == "error": + return JSONResponse(status_code=exc.status_code, content=exc.detail) + return await http_exception_handler(request, exc) + + @app.get("/api/v1/health") + async def health() -> dict[str, str]: + return {"status": "ok"} + + app.include_router(auth_router, prefix="/api/v1/auth", tags=["auth"]) + app.include_router(users_router, prefix="/api/v1/users", tags=["users"]) + app.include_router(content_router, prefix="/api/v1/content", tags=["content"]) + app.include_router(admin_router, prefix="/api/v1/admin", tags=["admin"]) + app.include_router(media_router, prefix="/api/v1/media", tags=["media"]) + app.include_router(sync_router, prefix="/api/v1/sync", tags=["sync"]) + app.include_router(sync_enterprise_router, prefix="/api/v1/enterprise", tags=["enterprise"]) + app.include_router(enterprise_router, prefix="/api/v1/enterprise", tags=["enterprise"]) + app.include_router(zootech_router, prefix="/api/v1/zootech", tags=["zootech"]) + app.include_router(wesp_compat_router, prefix="/api", tags=["zootech-wesp-compat"]) + app.include_router(wesp_compat_auth_router, prefix="/api", tags=["zootech-wesp-compat"]) + app.include_router(wesp_compat_sync_router, prefix="/api", tags=["zootech-wesp-compat"]) + app.include_router(wesp_compat_admin_router, prefix="/api", tags=["zootech-wesp-compat"]) + app.include_router(wesp_compat_misc_router, prefix="/api", tags=["zootech-wesp-compat"]) + app.include_router(wesp_compat_reports_router, prefix="/api", tags=["zootech-wesp-compat"]) + app.include_router(wesp_compat_analytics_router, prefix="/api", tags=["zootech-wesp-compat"]) + if settings.enable_test_routes: + app.include_router(test_router, prefix="/api/v1/test", tags=["test"]) + return app + + +app = create_app() diff --git a/apps/api/app/modules/__init__.py b/apps/api/app/modules/__init__.py new file mode 100644 index 0000000..51f2905 --- /dev/null +++ b/apps/api/app/modules/__init__.py @@ -0,0 +1 @@ +"""Application modules.""" diff --git a/apps/api/app/modules/admin/__init__.py b/apps/api/app/modules/admin/__init__.py new file mode 100644 index 0000000..ace7d15 --- /dev/null +++ b/apps/api/app/modules/admin/__init__.py @@ -0,0 +1 @@ +"""Admin module.""" diff --git a/apps/api/app/modules/admin/router.py b/apps/api/app/modules/admin/router.py new file mode 100644 index 0000000..0411db8 --- /dev/null +++ b/apps/api/app/modules/admin/router.py @@ -0,0 +1,171 @@ +from fastapi import APIRouter, Depends, HTTPException, Query + +from app.core.dependencies import require_admin, require_superuser +from app.modules.admin.schemas import ( + AdminSettingsPatchIn, + AdminRevealSecretIn, + AdminUiActivityIn, + AdminUserCreateIn, + AdminUserPasswordPatchIn, + AdminUserPatchIn, +) +from app.modules.admin.service import ( + create_admin_user, + delete_admin_user, + get_admin_settings, + get_admin_summary, + get_install_secrets, + get_diagnostics_report, + get_server_log_tail, + list_activity_feed, + list_admin_users, + patch_admin_settings, + patch_user, + record_ui_activity, + reveal_secret, + reset_user_password, +) +from app.modules.analytics.service import get_admin_dashboard_metrics + +router = APIRouter() + + +@router.get("/users") +async def list_users_route( + page: int = Query(default=1, ge=1), + limit: int = Query(default=20, ge=1, le=100), + _admin=Depends(require_admin), +): + return list_admin_users(page, limit) + + +@router.patch("/users/{user_id}") +async def patch_user_route( + user_id: str, + payload: AdminUserPatchIn, + admin=Depends(require_admin), +): + if payload.is_superuser is not None and not admin.is_superuser: + raise HTTPException(status_code=403, detail="SUPERUSER_ONLY") + try: + return patch_user(admin, user_id, payload.role, payload.status, payload.is_superuser) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + +@router.post("/users") +async def create_user_route(payload: AdminUserCreateIn, admin=Depends(require_superuser)): + try: + return create_admin_user( + admin, + email=payload.email, + password=payload.password, + role=payload.role, + is_superuser=payload.is_superuser, + status=payload.status, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + +@router.patch("/users/{user_id}/password") +async def reset_user_password_route( + user_id: str, payload: AdminUserPasswordPatchIn, admin=Depends(require_superuser) +): + try: + return reset_user_password(admin, user_id, payload.password) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + +@router.delete("/users/{user_id}") +async def delete_user_route(user_id: str, admin=Depends(require_superuser)): + try: + return delete_admin_user(admin, user_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + +@router.get("/summary") +async def summary_route(_admin=Depends(require_admin)): + return get_admin_summary() + + +@router.get("/stats") +async def stats_route(_admin=Depends(require_admin)): + return get_admin_dashboard_metrics() + + +@router.get("/settings") +async def admin_settings_route(_admin=Depends(require_superuser)): + return get_admin_settings() + + +@router.patch("/settings") +async def admin_settings_patch_route(payload: AdminSettingsPatchIn, admin=Depends(require_superuser)): + return patch_admin_settings(admin, payload.values) + + +@router.get("/diagnostics/report") +async def diagnostics_route(_admin=Depends(require_superuser)): + return get_diagnostics_report() + + +@router.get("/activity-feed") +async def activity_feed_route(limit: int = Query(default=200, ge=1, le=1000), _admin=Depends(require_admin)): + return list_activity_feed(limit=limit) + + +@router.post("/ui-activity") +async def ui_activity_route(payload: AdminUiActivityIn, admin=Depends(require_admin)): + return record_ui_activity(admin, payload.event, payload.meta) + + +@router.get("/server-log") +async def server_log_route( + lines: int = Query(default=200, ge=1, le=1000), + _admin=Depends(require_superuser), +): + return get_server_log_tail(lines=lines) + + +@router.get("/secrets") +async def install_secrets_route(_admin=Depends(require_superuser)): + return get_install_secrets() + + +@router.post("/secrets/reveal") +async def reveal_secret_route(payload: AdminRevealSecretIn, admin=Depends(require_superuser)): + try: + return reveal_secret(admin, payload.key) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + +@router.get("/enterprises") +async def admin_enterprises(_admin=Depends(require_superuser)): + from sqlalchemy import select + + from app.core.database import session_scope + from app.modules.sync.models import Enterprise + + with session_scope() as db: + rows = list(db.scalars(select(Enterprise).order_by(Enterprise.name))) + return [{"id": r.id, "name": r.name, "slug": r.slug, "status": r.status} for r in rows] + + +@router.get("/sync-metrics") +async def admin_sync_metrics(_admin=Depends(require_superuser)): + from sqlalchemy import select + + from app.core.database import session_scope + from app.modules.sync.models import Enterprise + from app.modules.sync.service import get_sync_metrics + + with session_scope() as db: + enterprises = list(db.scalars(select(Enterprise).order_by(Enterprise.name))) + rows = [(e.id, e.name, e.slug) for e in enterprises] + return [ + {"enterprise_id": enterprise_id, "name": name, "slug": slug, **get_sync_metrics(enterprise_id)} + for enterprise_id, name, slug in rows + ] diff --git a/apps/api/app/modules/admin/schemas.py b/apps/api/app/modules/admin/schemas.py new file mode 100644 index 0000000..8620a8a --- /dev/null +++ b/apps/api/app/modules/admin/schemas.py @@ -0,0 +1,46 @@ +from typing import Literal + +from pydantic import BaseModel, EmailStr, Field, field_validator + +from app.core.password_policy import validate_password_strength + + +class AdminUserPatchIn(BaseModel): + role: Literal["user", "admin"] | None = None + status: Literal["pending", "active", "blocked"] | None = None + is_superuser: bool | None = None + + +class AdminUserCreateIn(BaseModel): + email: EmailStr + password: str = Field(min_length=8) + role: Literal["user", "admin"] = "user" + is_superuser: bool = False + status: Literal["pending", "active", "blocked"] = "active" + + @field_validator("password") + @classmethod + def password_policy(cls, value: str) -> str: + return validate_password_strength(value) + + +class AdminUserPasswordPatchIn(BaseModel): + password: str = Field(min_length=8) + + @field_validator("password") + @classmethod + def password_policy(cls, value: str) -> str: + return validate_password_strength(value) + + +class AdminSettingsPatchIn(BaseModel): + values: dict + + +class AdminUiActivityIn(BaseModel): + event: str + meta: dict | None = None + + +class AdminRevealSecretIn(BaseModel): + key: Literal["database_password", "jwt_access_secret", "jwt_refresh_pepper", "s3_secret_key"] diff --git a/apps/api/app/modules/admin/security_diagnostics.py b/apps/api/app/modules/admin/security_diagnostics.py new file mode 100644 index 0000000..8243a3f --- /dev/null +++ b/apps/api/app/modules/admin/security_diagnostics.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from urllib.parse import parse_qs, urlparse + +from app.core.config import settings +from app.core.install_secrets import read_install_secrets +from app.core.password_denylist import load_denylist +from app.core.redis import get_redis_client +from app.core.database import session_scope +from app.modules.auth.models import RefreshToken +from app.modules.users import repository +from sqlalchemy import func, select + + +def build_security_diagnostics_report() -> dict: + redis_client = get_redis_client() + db = urlparse(settings.database_url) + sslmode = parse_qs(db.query).get("sslmode", [""])[0] + install_secrets = read_install_secrets() + with session_scope() as session: + refresh_count = session.scalar(select(func.count()).select_from(RefreshToken)) or 0 + checks = [ + { + "id": "jwt_access_secret", + "status": "ok" if len(settings.jwt_access_secret) >= 32 else "fail", + "message": "JWT access secret configured", + }, + { + "id": "jwt_refresh_pepper", + "status": "ok" if len(settings.jwt_refresh_pepper) >= 32 else "fail", + "message": "JWT refresh pepper configured", + }, + { + "id": "cookie_secure", + "status": "ok" if settings.cookie_secure else "warn", + "message": "Refresh cookie Secure flag is enabled", + }, + { + "id": "rate_limit_backend", + "status": "ok" if redis_client is not None else "warn", + "message": "Rate limiter uses Redis backend", + }, + { + "id": "password_denylist", + "status": "ok" if len(load_denylist()) >= 1000 else "warn", + "message": "Password denylist has strong coverage", + }, + { + "id": "cors_wildcard", + "status": "ok" if "*" not in settings.cors_origins else "fail", + "message": "CORS does not include wildcard origin", + }, + { + "id": "superuser_exists", + "status": "ok" if repository.count_superusers() > 0 else "fail", + "message": "At least one superuser exists", + }, + { + "id": "docs_production", + "status": "warn" if settings.enable_docs else "ok", + "message": "API docs are disabled in production", + }, + { + "id": "db_default_credentials", + "status": "fail" if db.username == "user" and db.password == "pass" else "ok", + "message": "Database does not use default credentials", + }, + { + "id": "db_localhost_exposed", + "status": "fail" if settings.app_env.lower() == "production" and db.hostname in {"localhost", "127.0.0.1"} else "ok", + "message": "Production database host is not localhost", + }, + { + "id": "db_ssl_mode", + "status": "ok" if settings.app_env.lower() != "production" or sslmode == "require" else "warn", + "message": "Production database URL uses sslmode=require", + }, + { + "id": "db_refresh_token_table_size", + "status": "warn" if refresh_count > 10000 else "ok", + "message": "Refresh token table size is under threshold", + }, + { + "id": "install_secrets_initialized", + "status": "ok" if bool(install_secrets) else "fail", + "message": "Install secrets file is initialized", + }, + { + "id": "install_secrets_locked", + "status": "ok" if install_secrets.get("SECRETS_LOCKED") == "true" else "warn", + "message": "Install secrets are locked after bootstrap", + }, + ] + return {"checks": checks} diff --git a/apps/api/app/modules/admin/service.py b/apps/api/app/modules/admin/service.py new file mode 100644 index 0000000..ce2fe03 --- /dev/null +++ b/apps/api/app/modules/admin/service.py @@ -0,0 +1,209 @@ +from pathlib import Path + +from app.core.app_settings import apply_settings_to_app, get_settings_payload, write_settings +from app.core.audit_log import read_audit_events, write_audit_event +from app.core.config import settings +from app.core.install_secrets import install_secrets_payload, reveal_install_secret +from app.core.jwt_denylist import bump_auth_epoch +from app.core.security import hash_password +from app.modules.admin.security_diagnostics import build_security_diagnostics_report +from app.modules.auth.service import revoke_user_refresh_family +from app.modules.users import repository + + +def list_admin_users(page: int, limit: int) -> dict: + users, total = repository.list_users(page, limit) + return { + "data": [ + { + "id": user.id, + "email": user.email, + "role": user.role, + "is_superuser": user.is_superuser, + "status": user.status, + } + for user in users + ], + "meta": {"total": total, "page": page, "limit": limit}, + } + + +def _ensure_last_superuser_protection(target, role: str | None, is_superuser: bool | None) -> None: + super_count = repository.count_superusers() + role_becomes_admin = target.role if role is None else role + super_becomes_true = target.is_superuser if is_superuser is None else is_superuser + is_losing_super = target.role == "admin" and target.is_superuser and ( + role_becomes_admin != "admin" or not super_becomes_true + ) + if is_losing_super and super_count <= 1: + raise ValueError("LAST_SUPERUSER_PROTECTED") + + +def patch_user( + admin_user, target_user_id: str, role: str | None, status: str | None, is_superuser: bool | None = None +) -> dict: + target = repository.get_user_by_id(target_user_id) + if not target: + raise ValueError("USER_NOT_FOUND") + if admin_user.id == target_user_id and role and role != "admin": + raise ValueError("SELF_DEMOTION_FORBIDDEN") + if admin_user.id == target_user_id and is_superuser is False: + raise ValueError("SELF_DEMOTION_FORBIDDEN") + if admin_user.id == target_user_id and status == "blocked": + raise ValueError("SELF_BLOCK_FORBIDDEN") + + admin_count = repository.count_admins() + if target.role == "admin" and role and role != "admin" and admin_count <= 1: + raise ValueError("LAST_ADMIN_PROTECTED") + _ensure_last_superuser_protection(target, role, is_superuser) + + if role: + target.role = role + if is_superuser is not None: + target.is_superuser = bool(is_superuser) + if status: + target.status = status + if status == "blocked": + bump_auth_epoch(target.id) + revoke_user_refresh_family(target.id) + repository.update_user(target) + write_audit_event( + action="admin.user.patch", + actor_user_id=admin_user.id, + actor_email=admin_user.email, + details={"target_user_id": target.id}, + ) + return { + "id": target.id, + "email": target.email, + "role": target.role, + "is_superuser": target.is_superuser, + "status": target.status, + } + + +def create_admin_user(admin_user, email: str, password: str, role: str, is_superuser: bool, status: str) -> dict: + if repository.get_user_by_email(email): + raise ValueError("USER_EXISTS") + user = repository.create_user( + email=email, + password_hash=hash_password(password), + role=role, + is_superuser=is_superuser, + status=status, + ) + write_audit_event( + action="admin.user.create", + actor_user_id=admin_user.id, + actor_email=admin_user.email, + details={"target_user_id": user.id}, + ) + return { + "id": user.id, + "email": user.email, + "role": user.role, + "is_superuser": user.is_superuser, + "status": user.status, + } + + +def reset_user_password(admin_user, target_user_id: str, password: str) -> dict: + target = repository.get_user_by_id(target_user_id) + if not target: + raise ValueError("USER_NOT_FOUND") + target.password_hash = hash_password(password) + repository.update_user(target) + bump_auth_epoch(target.id) + revoke_user_refresh_family(target.id) + write_audit_event( + action="admin.user.reset_password", + actor_user_id=admin_user.id, + actor_email=admin_user.email, + details={"target_user_id": target.id}, + ) + return {"status": "ok"} + + +def delete_admin_user(admin_user, target_user_id: str) -> dict: + target = repository.get_user_by_id(target_user_id) + if not target: + raise ValueError("USER_NOT_FOUND") + if admin_user.id == target.id: + raise ValueError("SELF_DELETE_FORBIDDEN") + _ensure_last_superuser_protection(target, "user", False) + if target.role == "admin" and repository.count_admins() <= 1: + raise ValueError("LAST_ADMIN_PROTECTED") + repository.delete_user(target.id) + revoke_user_refresh_family(target.id) + write_audit_event( + action="admin.user.delete", + actor_user_id=admin_user.id, + actor_email=admin_user.email, + details={"target_user_id": target.id}, + ) + return {"status": "deleted"} + + +def get_admin_summary() -> dict: + return { + "users_count": repository.count_users(), + "registrations_day": repository.count_users_registered_today(), + "admins_count": repository.count_admins(), + "superusers_count": repository.count_superusers(), + } + + +def get_admin_settings() -> dict: + return get_settings_payload() + + +def patch_admin_settings(admin_user, values: dict) -> dict: + merged = write_settings(values) + apply_settings_to_app(merged) + write_audit_event( + action="admin.settings.patch", + actor_user_id=admin_user.id, + actor_email=admin_user.email, + details={"updated_keys": sorted(values.keys())}, + ) + return get_settings_payload() + + +def get_diagnostics_report() -> dict: + return build_security_diagnostics_report() + + +def list_activity_feed(limit: int = 200) -> dict: + return {"events": read_audit_events(limit=limit)} + + +def record_ui_activity(admin_user, event: str, meta: dict | None) -> dict: + write_audit_event( + action=f"ui.{event}", + actor_user_id=admin_user.id, + actor_email=admin_user.email, + details=meta or {}, + ) + return {"status": "ok"} + + +def get_server_log_tail(lines: int = 200) -> dict: + path = Path(settings.server_log_path) + if not path.exists(): + return {"lines": []} + return {"lines": path.read_text(encoding="utf-8", errors="ignore").splitlines()[-lines:]} + + +def get_install_secrets() -> dict: + return install_secrets_payload() + + +def reveal_secret(admin_user, key: str) -> dict: + value = reveal_install_secret(key) + write_audit_event( + action="admin.secrets.reveal", + actor_user_id=admin_user.id, + actor_email=admin_user.email, + details={"key": key}, + ) + return {"key": key, "value": value} diff --git a/apps/api/app/modules/analytics/service.py b/apps/api/app/modules/analytics/service.py new file mode 100644 index 0000000..34ab0fb --- /dev/null +++ b/apps/api/app/modules/analytics/service.py @@ -0,0 +1,8 @@ +from app.modules.users import repository + + +def get_admin_dashboard_metrics() -> dict: + return { + "users_count": repository.count_users(), + "registrations_day": repository.count_users_registered_today(), + } diff --git a/apps/api/app/modules/auth/__init__.py b/apps/api/app/modules/auth/__init__.py new file mode 100644 index 0000000..257f2c7 --- /dev/null +++ b/apps/api/app/modules/auth/__init__.py @@ -0,0 +1 @@ +"""Auth module.""" diff --git a/apps/api/app/modules/auth/models.py b/apps/api/app/modules/auth/models.py new file mode 100644 index 0000000..cf27a2d --- /dev/null +++ b/apps/api/app/modules/auth/models.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import uuid4 + +from sqlalchemy import DateTime, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class RefreshToken(Base): + __tablename__ = "refresh_tokens" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True) + token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + family_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC) + ) + + +class PasswordResetToken(Base): + __tablename__ = "password_reset_tokens" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True) + token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +class EmailVerificationToken(Base): + __tablename__ = "email_verification_tokens" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True) + token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/apps/api/app/modules/auth/repository.py b/apps/api/app/modules/auth/repository.py new file mode 100644 index 0000000..8c71990 --- /dev/null +++ b/apps/api/app/modules/auth/repository.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import delete, select + +from app.core.database import session_scope +from app.modules.auth.models import EmailVerificationToken, PasswordResetToken, RefreshToken + + +def _detach(db, instance): + db.refresh(instance) + db.expunge(instance) + return instance + + +def create_refresh_token( + user_id: str, + token_hash: str, + family_id: str, + expires_at: datetime, +) -> RefreshToken: + with session_scope() as db: + token = RefreshToken( + user_id=user_id, + token_hash=token_hash, + family_id=family_id, + expires_at=expires_at, + ) + db.add(token) + db.flush() + return _detach(db, token) + + +def get_refresh_token(token_hash: str) -> RefreshToken | None: + with session_scope() as db: + token = db.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash)) + if not token: + return None + return _detach(db, token) + + +def revoke_refresh_token(token_hash: str) -> RefreshToken | None: + with session_scope() as db: + token = db.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash)) + if not token: + return None + token.revoked_at = datetime.now(UTC) + db.flush() + return _detach(db, token) + + +def revoke_user_families(user_id: str) -> None: + with session_scope() as db: + tokens = db.scalars( + select(RefreshToken).where( + RefreshToken.user_id == user_id, + RefreshToken.revoked_at.is_(None), + ) + ).all() + now = datetime.now(UTC) + for token in tokens: + token.revoked_at = now + + +def revoke_family_tokens(family_id: str) -> None: + with session_scope() as db: + tokens = db.scalars( + select(RefreshToken).where( + RefreshToken.family_id == family_id, + RefreshToken.revoked_at.is_(None), + ) + ).all() + now = datetime.now(UTC) + for token in tokens: + token.revoked_at = now + + +def create_email_verification_token(user_id: str, token_hash: str, expires_at: datetime) -> None: + with session_scope() as db: + db.execute( + delete(EmailVerificationToken).where( + EmailVerificationToken.user_id == user_id, + EmailVerificationToken.used_at.is_(None), + ) + ) + db.add(EmailVerificationToken(user_id=user_id, token_hash=token_hash, expires_at=expires_at)) + + +def get_email_verification_token(token_hash: str) -> EmailVerificationToken | None: + with session_scope() as db: + token = db.scalar( + select(EmailVerificationToken).where(EmailVerificationToken.token_hash == token_hash) + ) + if not token: + return None + return _detach(db, token) + + +def mark_email_verification_token_used(token_hash: str) -> None: + with session_scope() as db: + token = db.scalar( + select(EmailVerificationToken).where(EmailVerificationToken.token_hash == token_hash) + ) + if token: + token.used_at = datetime.now(UTC) + + +def create_password_reset_token(user_id: str, token_hash: str, expires_at: datetime) -> None: + with session_scope() as db: + db.execute( + delete(PasswordResetToken).where( + PasswordResetToken.user_id == user_id, + PasswordResetToken.used_at.is_(None), + ) + ) + db.add(PasswordResetToken(user_id=user_id, token_hash=token_hash, expires_at=expires_at)) + + +def get_password_reset_token(token_hash: str) -> PasswordResetToken | None: + with session_scope() as db: + token = db.scalar(select(PasswordResetToken).where(PasswordResetToken.token_hash == token_hash)) + if not token: + return None + return _detach(db, token) + + +def mark_password_reset_token_used(token_hash: str) -> None: + with session_scope() as db: + token = db.scalar(select(PasswordResetToken).where(PasswordResetToken.token_hash == token_hash)) + if token: + token.used_at = datetime.now(UTC) diff --git a/apps/api/app/modules/auth/router.py b/apps/api/app/modules/auth/router.py new file mode 100644 index 0000000..0c0cf36 --- /dev/null +++ b/apps/api/app/modules/auth/router.py @@ -0,0 +1,217 @@ +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from app.core.config import settings +from app.core.datetime_utils import ensure_utc, utc_now +from app.core.redis import check_rate_limit, client_ip +from app.modules.auth.schemas import ( + ForgotPasswordIn, + LoginIn, + LoginOut, + RegisterIn, + ResendVerificationIn, + ResetPasswordIn, + VerifyEmailIn, +) +from app.modules.auth.service import ( + forgot_password, + login, + logout, + refresh, + register, + resend_verification, + reset_password, + verify_email_token, +) +from app.modules.users import repository + +router = APIRouter() +optional_bearer = HTTPBearer(auto_error=False) + + +def _allowed_origins() -> set[str]: + origins: set[str] = set() + for raw in (settings.frontend_url, settings.public_base_url, *settings.cors_origins): + if not raw: + continue + normalized = raw.rstrip("/") + origins.add(normalized) + if "://localhost" in normalized: + origins.add(normalized.replace("://localhost", "://127.0.0.1")) + if "://127.0.0.1" in normalized: + origins.add(normalized.replace("://127.0.0.1", "://localhost")) + return origins + + +def _enforce_origin(request: Request, *, require_header: bool = False) -> None: + allowed = _allowed_origins() + origin = (request.headers.get("Origin") or "").rstrip("/") + referer = request.headers.get("Referer") or "" + if require_header and not origin and not referer: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="INVALID_ORIGIN") + if origin and origin not in allowed: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="INVALID_ORIGIN") + if not origin and referer: + if not any(referer.startswith(base) for base in allowed): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="INVALID_ORIGIN") + + +def _set_refresh_cookie(response: Response, refresh_token: str) -> None: + response.set_cookie( + key="refresh_token", + value=refresh_token, + httponly=True, + secure=settings.cookie_secure, + samesite="lax", + path="/api/v1/auth", + max_age=settings.jwt_refresh_ttl_days * 24 * 60 * 60, + ) + + +@router.post("/register") +async def register_route(payload: RegisterIn, request: Request): + _enforce_origin(request) + check_rate_limit(f"register:{client_ip(request)}", limit=3, window_seconds=3600) + user = register(payload.email, payload.password) + _ = user + return {"message": "If email is valid, verification has been sent."} + + +@router.post("/verify-email") +async def verify_email_route(payload: VerifyEmailIn): + try: + user = verify_email_token(payload.token) + except ValueError as exc: + detail = str(exc) + if detail == "TOKEN_EXPIRED": + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOKEN_EXPIRED") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="INVALID_TOKEN") + _ = user + return {"status": "active"} + + +@router.post("/login", response_model=LoginOut) +async def login_route(payload: LoginIn, request: Request, response: Response): + _enforce_origin(request) + user = repository.get_user_by_email(payload.email) + if user: + locked_until = ensure_utc(user.locked_until) + if locked_until and locked_until > utc_now(): + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="ACCOUNT_TEMPORARILY_LOCKED", + ) + check_rate_limit( + f"login:{client_ip(request)}:{payload.email.lower()}", + limit=5, + window_seconds=60, + ) + try: + access_token, refresh_token, user = login(payload.email, payload.password) + except ValueError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_CREDENTIALS") + except PermissionError as exc: + detail = str(exc) + if detail == "EMAIL_NOT_VERIFIED": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="EMAIL_NOT_VERIFIED") + if detail == "ACCOUNT_TEMPORARILY_LOCKED": + raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="ACCOUNT_TEMPORARILY_LOCKED") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ACCOUNT_BLOCKED") + + _set_refresh_cookie(response, refresh_token) + return { + "access_token": access_token, + "expires_in": settings.jwt_access_ttl_min * 60, + "user": { + "id": user.id, + "email": user.email, + "role": user.role, + "is_superuser": user.is_superuser, + "status": user.status, + }, + } + + +@router.post("/refresh", response_model=LoginOut) +async def refresh_route(request: Request, response: Response): + refresh_token = request.cookies.get("refresh_token") + if not refresh_token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="REFRESH_MISSING") + _enforce_origin(request, require_header=True) + check_rate_limit(f"refresh:{client_ip(request)}", limit=30, window_seconds=60) + try: + access_token, new_refresh, user = refresh(refresh_token) + except PermissionError as exc: + detail = str(exc) + if detail == "EMAIL_NOT_VERIFIED": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="EMAIL_NOT_VERIFIED") + if detail == "ACCOUNT_BLOCKED": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ACCOUNT_BLOCKED") + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="INVALID_REFRESH") + _set_refresh_cookie(response, new_refresh) + return { + "access_token": access_token, + "expires_in": settings.jwt_access_ttl_min * 60, + "user": { + "id": user.id, + "email": user.email, + "role": user.role, + "is_superuser": user.is_superuser, + "status": user.status, + }, + } + + +@router.post("/logout") +async def logout_route( + request: Request, + response: Response, + credentials: HTTPAuthorizationCredentials | None = Depends(optional_bearer), +): + _enforce_origin(request, require_header=True) + refresh_token = request.cookies.get("refresh_token") + access_token = credentials.credentials if credentials else None + logout(refresh_token, access_token) + response.delete_cookie( + "refresh_token", + path="/api/v1/auth", + secure=settings.cookie_secure, + samesite="lax", + ) + return {"message": "logged_out"} + + +@router.post("/forgot-password") +async def forgot_password_route(payload: ForgotPasswordIn, request: Request): + _enforce_origin(request) + check_rate_limit( + f"forgot:{client_ip(request)}:{payload.email.lower()}", + limit=3, + window_seconds=3600, + ) + forgot_password(payload.email) + return {"message": "If email is registered, reset instructions have been sent."} + + +@router.post("/reset-password") +async def reset_password_route(payload: ResetPasswordIn): + try: + reset_password(payload.token, payload.new_password) + except ValueError as exc: + detail = str(exc) + if detail == "TOKEN_EXPIRED": + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="TOKEN_EXPIRED") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="INVALID_TOKEN") + return {"message": "password_updated"} + + +@router.post("/resend-verification") +async def resend_verification_route(payload: ResendVerificationIn, request: Request): + _enforce_origin(request) + check_rate_limit( + f"resend:{client_ip(request)}:{payload.email.lower()}", + limit=3, + window_seconds=3600, + ) + resend_verification(payload.email) + return {"message": "If email is registered, verification has been sent."} diff --git a/apps/api/app/modules/auth/schemas.py b/apps/api/app/modules/auth/schemas.py new file mode 100644 index 0000000..f2058f3 --- /dev/null +++ b/apps/api/app/modules/auth/schemas.py @@ -0,0 +1,69 @@ +from pydantic import BaseModel, EmailStr, Field, field_validator + +from app.core.password_policy import validate_password_strength + + +class RegisterIn(BaseModel): + email: EmailStr + password: str = Field(min_length=8) + + @field_validator("email", "password", mode="before") + @classmethod + def strip_whitespace(cls, value: str) -> str: + if isinstance(value, str): + return value.strip() + return value + + @field_validator("password") + @classmethod + def password_policy(cls, value: str) -> str: + return validate_password_strength(value) + + +class LoginIn(BaseModel): + email: EmailStr + password: str = Field(min_length=8) + + @field_validator("email", "password", mode="before") + @classmethod + def strip_whitespace(cls, value: str) -> str: + if isinstance(value, str): + return value.strip() + return value + + +class VerifyEmailIn(BaseModel): + token: str + + +class ForgotPasswordIn(BaseModel): + email: EmailStr + + +class ResendVerificationIn(BaseModel): + email: EmailStr + + +class ResetPasswordIn(BaseModel): + token: str + new_password: str = Field(min_length=8) + + @field_validator("new_password") + @classmethod + def password_policy(cls, value: str) -> str: + return validate_password_strength(value) + + +class AuthUserOut(BaseModel): + id: str + email: EmailStr + role: str + is_superuser: bool + status: str + + +class LoginOut(BaseModel): + access_token: str + token_type: str = "bearer" + expires_in: int = 900 + user: AuthUserOut diff --git a/apps/api/app/modules/auth/service.py b/apps/api/app/modules/auth/service.py new file mode 100644 index 0000000..eb3692f --- /dev/null +++ b/apps/api/app/modules/auth/service.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +from datetime import timedelta +from uuid import uuid4 + +from app.core.config import settings +from app.core.datetime_utils import ensure_utc, utc_now +from app.core.email import send_template_email +from app.core.jwt_denylist import bump_auth_epoch, revoke_access_token +from app.core.security import ( + create_access_token, + generate_opaque_token, + generate_refresh_token, + hash_opaque_token, + hash_password, + hash_refresh_token, + verify_password, +) +from app.modules.auth import repository as auth_repository +from app.modules.users import repository +from app.modules.users.models import User + +def _token_expires_at(): + return utc_now() + timedelta(hours=settings.auth_token_ttl_hours) + + +def _send_verification_email(user: User, token: str) -> None: + verify_url = f"{settings.frontend_url}/verify?token={token}" + show_token = settings.email_delivery_mode == "memory" or settings.enable_test_routes + token_line = f"TOKEN:{token}\n" if show_token else "" + body = "Confirm your Compton account.\n\n" + f"Open: {verify_url}\n" + token_line + send_template_email( + to=user.email, + template="verify_email", + subject="Confirm your Compton account", + body=body, + ) + + +def _send_password_reset_email(user: User, token: str) -> None: + reset_url = f"{settings.frontend_url}/reset-password?token={token}" + show_token = settings.email_delivery_mode == "memory" or settings.enable_test_routes + token_line = f"TOKEN:{token}\n" if show_token else "" + body = "Reset your Compton password.\n\n" + f"Open: {reset_url}\n" + token_line + send_template_email( + to=user.email, + template="reset_password", + subject="Reset your Compton password", + body=body, + ) + + +def _issue_verification_token(user_id: str) -> str: + token = generate_opaque_token() + auth_repository.create_email_verification_token( + user_id=user_id, + token_hash=hash_opaque_token(token), + expires_at=_token_expires_at(), + ) + return token + + +def _issue_password_reset_token(user_id: str) -> str: + token = generate_opaque_token() + auth_repository.create_password_reset_token( + user_id=user_id, + token_hash=hash_opaque_token(token), + expires_at=_token_expires_at(), + ) + return token + + +def register(email: str, password: str) -> User: + existing = repository.get_user_by_email(email) + if existing: + if existing.status == "pending": + token = _issue_verification_token(existing.id) + _send_verification_email(existing, token) + return existing + user = repository.create_user(email=email, password_hash=hash_password(password), status="pending") + token = _issue_verification_token(user.id) + _send_verification_email(user, token) + return user + + +def verify_email_token(token: str) -> User: + token_hash = hash_opaque_token(token) + token_row = auth_repository.get_email_verification_token(token_hash) + if not token_row or token_row.used_at is not None: + raise ValueError("INVALID_TOKEN") + if ensure_utc(token_row.expires_at) < utc_now(): + raise ValueError("TOKEN_EXPIRED") + + user = repository.get_user_by_id(token_row.user_id) + if not user: + raise ValueError("INVALID_TOKEN") + + user.status = "active" + user.email_verified_at = utc_now() + repository.update_user(user) + auth_repository.mark_email_verification_token_used(token_hash) + return user + + +def resend_verification(email: str) -> None: + user = repository.get_user_by_email(email) + if not user or user.status != "pending": + return + token = _issue_verification_token(user.id) + _send_verification_email(user, token) + + +def forgot_password(email: str) -> None: + user = repository.get_user_by_email(email) + if not user or user.status == "blocked": + return + token = _issue_password_reset_token(user.id) + _send_password_reset_email(user, token) + + +def reset_password(token: str, new_password: str) -> None: + token_hash = hash_opaque_token(token) + token_row = auth_repository.get_password_reset_token(token_hash) + if not token_row or token_row.used_at is not None: + raise ValueError("INVALID_TOKEN") + if ensure_utc(token_row.expires_at) < utc_now(): + raise ValueError("TOKEN_EXPIRED") + + user = repository.get_user_by_id(token_row.user_id) + if not user: + raise ValueError("INVALID_TOKEN") + + user.password_hash = hash_password(new_password) + repository.update_user(user) + auth_repository.mark_password_reset_token_used(token_hash) + bump_auth_epoch(user.id) + revoke_user_refresh_family(user.id) + + +def _is_locked(user: User) -> bool: + locked_until = ensure_utc(user.locked_until) + if locked_until and locked_until > utc_now(): + return True + if locked_until and locked_until <= utc_now(): + user.failed_login_attempts = 0 + user.locked_until = None + repository.update_user(user) + return False + + +def _record_failed_login(user: User) -> None: + locked_until = ensure_utc(user.locked_until) + if locked_until and locked_until <= utc_now(): + user.failed_login_attempts = 0 + user.locked_until = None + user.failed_login_attempts += 1 + if user.failed_login_attempts >= settings.auth_lockout_attempts: + user.locked_until = utc_now() + timedelta(minutes=settings.auth_lockout_minutes) + repository.update_user(user) + + +def _reset_login_attempts(user: User) -> None: + user.failed_login_attempts = 0 + user.locked_until = None + repository.update_user(user) + + +def login(email: str, password: str) -> tuple[str, str, User]: + user = repository.get_user_by_email(email) + if user and _is_locked(user): + raise PermissionError("ACCOUNT_TEMPORARILY_LOCKED") + if not user or not verify_password(password, user.password_hash): + if user: + _record_failed_login(user) + raise ValueError("INVALID_CREDENTIALS") + if user.status == "pending": + raise PermissionError("EMAIL_NOT_VERIFIED") + if user.status == "blocked": + raise PermissionError("ACCOUNT_BLOCKED") + _reset_login_attempts(user) + access_token = create_access_token(user.id, user.role, user.is_superuser) + refresh_token = issue_refresh_token(user.id) + return access_token, refresh_token, user + + +def issue_refresh_token(user_id: str, family_id: str | None = None) -> str: + token = generate_refresh_token() + token_hash = hash_refresh_token(token) + family = family_id or str(uuid4()) + auth_repository.create_refresh_token( + user_id=user_id, + token_hash=token_hash, + family_id=family, + expires_at=utc_now() + timedelta(days=settings.jwt_refresh_ttl_days), + ) + return token + + +def refresh(refresh_token: str) -> tuple[str, str, User]: + token_hash = hash_refresh_token(refresh_token) + token_row = auth_repository.get_refresh_token(token_hash) + if not token_row: + raise PermissionError("INVALID_REFRESH") + + user = repository.get_user_by_id(token_row.user_id) + if not user: + raise PermissionError("INVALID_REFRESH") + if user.status == "pending": + raise PermissionError("EMAIL_NOT_VERIFIED") + if user.status == "blocked": + raise PermissionError("ACCOUNT_BLOCKED") + + if token_row.revoked_at is not None: + auth_repository.revoke_family_tokens(token_row.family_id) + raise PermissionError("INVALID_REFRESH") + if ensure_utc(token_row.expires_at) < utc_now(): + raise PermissionError("EXPIRED_REFRESH") + + auth_repository.revoke_refresh_token(token_hash) + new_refresh = issue_refresh_token(user.id, family_id=token_row.family_id) + access = create_access_token(user.id, user.role, user.is_superuser) + return access, new_refresh, user + + +def revoke_refresh_token(refresh_token: str) -> None: + token_hash = hash_refresh_token(refresh_token) + token_row = auth_repository.revoke_refresh_token(token_hash) + if token_row: + auth_repository.revoke_family_tokens(token_row.family_id) + + +def logout(refresh_token: str | None, access_token: str | None) -> None: + if access_token: + revoke_access_token(access_token) + if refresh_token: + revoke_refresh_token(refresh_token) + + +def revoke_user_refresh_family(user_id: str) -> None: + auth_repository.revoke_user_families(user_id) diff --git a/apps/api/app/modules/content/__init__.py b/apps/api/app/modules/content/__init__.py new file mode 100644 index 0000000..23a1791 --- /dev/null +++ b/apps/api/app/modules/content/__init__.py @@ -0,0 +1 @@ +"""Content module.""" diff --git a/apps/api/app/modules/content/models.py b/apps/api/app/modules/content/models.py new file mode 100644 index 0000000..93c98f0 --- /dev/null +++ b/apps/api/app/modules/content/models.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import uuid4 + +from sqlalchemy import DateTime, ForeignKey, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class ContentPage(Base): + __tablename__ = "content_pages" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + slug: Mapped[str] = mapped_column(String(120), unique=True, nullable=False, index=True) + title: Mapped[str] = mapped_column(String(255), nullable=False) + body: Mapped[str] = mapped_column(Text, nullable=False) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="draft", index=True) + author_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("users.id"), nullable=True) + published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) diff --git a/apps/api/app/modules/content/repository.py b/apps/api/app/modules/content/repository.py new file mode 100644 index 0000000..796892e --- /dev/null +++ b/apps/api/app/modules/content/repository.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import uuid4 + +from sqlalchemy import select + +from app.core.database import session_scope +from app.modules.content.models import ContentPage + +ALLOWED_PAGE_STATUSES = {"draft", "published"} + + +def _detach(db, instance): + db.refresh(instance) + db.expunge(instance) + return instance + + +def list_published_pages() -> list[ContentPage]: + with session_scope() as db: + pages = list( + db.scalars(select(ContentPage).where(ContentPage.status == "published").order_by(ContentPage.slug)) + ) + return [_detach(db, page) for page in pages] + + +def list_all_pages() -> list[ContentPage]: + with session_scope() as db: + pages = list(db.scalars(select(ContentPage).order_by(ContentPage.slug))) + return [_detach(db, page) for page in pages] + + +def get_page_by_slug(slug: str, include_draft: bool = False) -> ContentPage | None: + with session_scope() as db: + page = db.scalar(select(ContentPage).where(ContentPage.slug == slug)) + if not page: + return None + if include_draft or page.status == "published": + return _detach(db, page) + return None + + +def get_page_by_id(page_id: str) -> ContentPage | None: + with session_scope() as db: + page = db.get(ContentPage, page_id) + if not page: + return None + return _detach(db, page) + + +def create_page( + slug: str, + title: str, + body: str, + status: str, + author_id: str, +) -> ContentPage: + if status not in ALLOWED_PAGE_STATUSES: + raise ValueError("INVALID_STATUS") + with session_scope() as db: + page = ContentPage( + id=str(uuid4()), + slug=slug, + title=title, + body=body, + status=status, + author_id=author_id, + published_at=datetime.now(UTC) if status == "published" else None, + ) + db.add(page) + db.flush() + return _detach(db, page) + + +def update_page( + page_id: str, + title: str | None, + body: str | None, + status: str | None, +) -> ContentPage: + with session_scope() as db: + page = db.get(ContentPage, page_id) + if not page: + raise KeyError(page_id) + if title: + page.title = title + if body: + page.body = body + if status: + if status not in ALLOWED_PAGE_STATUSES: + raise ValueError("INVALID_STATUS") + page.status = status + if status == "published" and page.published_at is None: + page.published_at = datetime.now(UTC) + page.updated_at = datetime.now(UTC) + db.flush() + return _detach(db, page) + + +def delete_page(page_id: str) -> None: + with session_scope() as db: + page = db.get(ContentPage, page_id) + if page: + db.delete(page) diff --git a/apps/api/app/modules/content/router.py b/apps/api/app/modules/content/router.py new file mode 100644 index 0000000..407d48e --- /dev/null +++ b/apps/api/app/modules/content/router.py @@ -0,0 +1,56 @@ +from fastapi import APIRouter, Depends, HTTPException + +from app.core.dependencies import require_admin +from app.modules.content.schemas import ContentPageIn, ContentPagePatchIn +from app.modules.content.service import ( + create_page, + delete_page, + get_page_by_slug, + list_all_pages, + list_published_pages, + page_exists, + page_to_dict, + update_page, +) + +router = APIRouter() + + +@router.get("/pages") +async def list_pages_route(): + pages = list_published_pages() + return {"data": [page_to_dict(page) for page in pages]} + + +@router.get("/pages/manage/all") +async def list_all_pages_route(_admin=Depends(require_admin)): + pages = list_all_pages() + return {"data": [page_to_dict(page) for page in pages]} + + +@router.get("/pages/{slug}") +async def get_page_route(slug: str): + page = get_page_by_slug(slug) + if not page: + raise HTTPException(status_code=404, detail="PAGE_NOT_FOUND") + return page_to_dict(page) + + +@router.post("/pages") +async def create_page_route(payload: ContentPageIn, admin=Depends(require_admin)): + page = create_page(payload.slug, payload.title, payload.body, payload.status, admin.id) + return page_to_dict(page) + + +@router.patch("/pages/{page_id}") +async def update_page_route(page_id: str, payload: ContentPagePatchIn, _admin=Depends(require_admin)): + if not page_exists(page_id): + raise HTTPException(status_code=404, detail="PAGE_NOT_FOUND") + page = update_page(page_id, payload.title, payload.body, payload.status) + return page_to_dict(page) + + +@router.delete("/pages/{page_id}") +async def delete_page_route(page_id: str, _admin=Depends(require_admin)): + delete_page(page_id) + return {"message": "deleted"} diff --git a/apps/api/app/modules/content/schemas.py b/apps/api/app/modules/content/schemas.py new file mode 100644 index 0000000..61afe9c --- /dev/null +++ b/apps/api/app/modules/content/schemas.py @@ -0,0 +1,24 @@ +from typing import Literal + +from pydantic import BaseModel, Field + + +class ContentPageIn(BaseModel): + slug: str = Field(min_length=2, max_length=120) + title: str = Field(min_length=2, max_length=200) + body: str + status: Literal["draft", "published"] = "draft" + + +class ContentPagePatchIn(BaseModel): + title: str | None = Field(default=None, min_length=2, max_length=200) + body: str | None = None + status: Literal["draft", "published"] | None = None + + +class ContentPageOut(BaseModel): + id: str + slug: str + title: str + body: str + status: str diff --git a/apps/api/app/modules/content/service.py b/apps/api/app/modules/content/service.py new file mode 100644 index 0000000..2302ebb --- /dev/null +++ b/apps/api/app/modules/content/service.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import bleach + +from app.modules.content import repository +from app.modules.content.models import ContentPage + +ALLOWED_TAGS = ["p", "h1", "h2", "h3", "h4", "ul", "ol", "li", "a", "strong", "em", "br", "img"] + + +def sanitize_html(raw_html: str) -> str: + return bleach.clean( + raw_html, + tags=ALLOWED_TAGS, + attributes={"a": ["href"], "img": ["src", "alt"]}, + protocols=["http", "https", "mailto"], + strip=True, + ) + + +def page_to_dict(page: ContentPage) -> dict: + return { + "id": page.id, + "slug": page.slug, + "title": page.title, + "body": page.body, + "status": page.status, + "author_id": page.author_id, + "published_at": page.published_at.isoformat() if page.published_at else None, + "updated_at": page.updated_at.isoformat() if page.updated_at else None, + } + + +def list_published_pages() -> list[ContentPage]: + return repository.list_published_pages() + + +def list_all_pages() -> list[ContentPage]: + return repository.list_all_pages() + + +def get_page_by_slug(slug: str, include_draft: bool = False) -> ContentPage | None: + return repository.get_page_by_slug(slug, include_draft=include_draft) + + +def create_page(slug: str, title: str, body: str, status: str, author_id: str) -> ContentPage: + return repository.create_page( + slug=slug, + title=title, + body=sanitize_html(body), + status=status, + author_id=author_id, + ) + + +def update_page(page_id: str, title: str | None, body: str | None, status: str | None) -> ContentPage: + return repository.update_page( + page_id, + title, + sanitize_html(body) if body else None, + status, + ) + + +def delete_page(page_id: str) -> None: + repository.delete_page(page_id) + + +def page_exists(page_id: str) -> bool: + return repository.get_page_by_id(page_id) is not None diff --git a/apps/api/app/modules/enterprise/__init__.py b/apps/api/app/modules/enterprise/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/app/modules/enterprise/router.py b/apps/api/app/modules/enterprise/router.py new file mode 100644 index 0000000..8e60078 --- /dev/null +++ b/apps/api/app/modules/enterprise/router.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query, status + +from app.core.crypto import create_access_token +from app.core.dependencies import get_current_user, require_superuser +from app.modules.sync import repository as sync_repo +from app.modules.sync.schemas import EnterpriseOut, FarmHubOut +from app.modules.users.models import User + +router = APIRouter() + + +@router.get("/enterprises") +def list_enterprises(user: User = Depends(get_current_user)): + # Superuser sees all; regular users see memberships only (simplified v1) + if user.is_superuser: + from sqlalchemy import select + + from app.core.database import session_scope + from app.modules.sync.models import Enterprise + + with session_scope() as db: + rows = list(db.scalars(select(Enterprise).order_by(Enterprise.name))) + return [ + EnterpriseOut(id=r.id, name=r.name, slug=r.slug, status=r.status).model_dump() + for r in rows + ] + from sqlalchemy import select + + from app.core.database import session_scope + from app.modules.sync.models import Enterprise, EnterpriseMember + + with session_scope() as db: + rows = list( + db.scalars( + select(Enterprise) + .join(EnterpriseMember, EnterpriseMember.enterprise_id == Enterprise.id) + .where(EnterpriseMember.user_id == user.id) + ) + ) + return [EnterpriseOut(id=r.id, name=r.name, slug=r.slug, status=r.status).model_dump() for r in rows] + + +@router.post("/enterprises") +def create_enterprise(body: dict, _: User = Depends(require_superuser)): + ent = sync_repo.create_enterprise(body["name"], body["slug"]) + return EnterpriseOut(id=ent.id, name=ent.name, slug=ent.slug, status=ent.status).model_dump() + + +@router.get("/enterprises/{enterprise_id}/farms") +def list_farms(enterprise_id: str, user: User = Depends(get_current_user)): + member = sync_repo.get_member(user.id, enterprise_id) + if not member and not user.is_superuser: + from fastapi import HTTPException, status + + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN") + hubs = sync_repo.list_farm_hubs(enterprise_id) + return [ + FarmHubOut( + id=h.id, + enterprise_id=h.enterprise_id, + name=h.name, + hub_site_id=h.hub_site_id, + url=h.url, + status=h.status, + last_seen=h.last_seen, + wesp_version=h.wesp_version, + ).model_dump() + for h in hubs + ] + + +@router.post("/enterprises/{enterprise_id}/session") +def enterprise_session(enterprise_id: str, user: User = Depends(get_current_user)): + member = sync_repo.get_member(user.id, enterprise_id) + if not member and not user.is_superuser: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN") + role = member.role if member else "admin" + farm_ids = None if role == "admin" else sync_repo.list_farm_access(user.id, enterprise_id) + token = create_access_token( + user.id, + user.role, + user.is_superuser, + enterprise_id=enterprise_id, + enterprise_role=role, + farm_ids=farm_ids, + ) + return { + "access_token": token, + "enterprise_id": enterprise_id, + "enterprise_role": role, + "farm_ids": farm_ids, + } + + +@router.get("/enterprises/{enterprise_id}/sync-status") +def enterprise_sync_status(enterprise_id: str, user: User = Depends(get_current_user)): + member = sync_repo.get_member(user.id, enterprise_id) + if not member and not user.is_superuser: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN") + from app.modules.sync.service import get_sync_metrics + + return get_sync_metrics(enterprise_id) + + +@router.get("/enterprises/{enterprise_id}/members") +def list_members(enterprise_id: str, user: User = Depends(get_current_user)): + member = sync_repo.get_member(user.id, enterprise_id) + if not member or member.role != "admin": + if not user.is_superuser: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_ADMIN_ONLY") + from app.modules.users.repository import get_user_by_id + + rows = sync_repo.list_members(enterprise_id) + return [ + { + "user_id": m.user_id, + "role": m.role, + "email": (get_user_by_id(m.user_id).email if get_user_by_id(m.user_id) else None), + "farm_ids": sync_repo.list_farm_access(m.user_id, enterprise_id), + } + for m in rows + ] + + +@router.post("/enterprises/{enterprise_id}/members") +def add_enterprise_member(enterprise_id: str, body: dict, user: User = Depends(get_current_user)): + member = sync_repo.get_member(user.id, enterprise_id) + if not member or member.role != "admin": + if not user.is_superuser: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_ADMIN_ONLY") + from app.modules.users.repository import get_user_by_email + + target = get_user_by_email(body["email"].strip().lower()) + if not target: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="USER_NOT_FOUND") + existing = sync_repo.get_member(target.id, enterprise_id) + if existing: + sync_repo.set_member_role(target.id, enterprise_id, body.get("role", existing.role)) + else: + sync_repo.add_member(target.id, enterprise_id, body.get("role", "viewer")) + for farm_id in body.get("farm_ids") or []: + sync_repo.grant_farm_access(target.id, farm_id) + return {"status": "ok", "user_id": target.id} + + +@router.post("/enterprises/{enterprise_id}/farm-access") +def grant_farm_access(enterprise_id: str, body: dict, user: User = Depends(get_current_user)): + member = sync_repo.get_member(user.id, enterprise_id) + if not member or member.role != "admin": + if not user.is_superuser: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_ADMIN_ONLY") + sync_repo.grant_farm_access(body["user_id"], body["farm_hub_id"]) + return {"status": "ok"} diff --git a/apps/api/app/modules/media/router.py b/apps/api/app/modules/media/router.py new file mode 100644 index 0000000..3fccbdf --- /dev/null +++ b/apps/api/app/modules/media/router.py @@ -0,0 +1,24 @@ +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import Response + +from app.core.media_signing import verify_signed_media +from app.core.storage import download_object + +router = APIRouter() + + +@router.get("/files/{file_path:path}") +async def get_media_file( + file_path: str, + expires: int = Query(...), + sig: str = Query(...), +): + if not file_path.startswith("avatars/"): + raise HTTPException(status_code=404, detail="FILE_NOT_FOUND") + if not verify_signed_media(file_path, expires, sig): + raise HTTPException(status_code=403, detail="INVALID_SIGNATURE") + stored = download_object(file_path) + if not stored: + raise HTTPException(status_code=404, detail="FILE_NOT_FOUND") + body, content_type = stored + return Response(content=body, media_type=content_type) diff --git a/apps/api/app/modules/media/schemas.py b/apps/api/app/modules/media/schemas.py new file mode 100644 index 0000000..e6cab33 --- /dev/null +++ b/apps/api/app/modules/media/schemas.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class AvatarUploadOut(BaseModel): + message: str diff --git a/apps/api/app/modules/media/service.py b/apps/api/app/modules/media/service.py new file mode 100644 index 0000000..d6e7960 --- /dev/null +++ b/apps/api/app/modules/media/service.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from io import BytesIO +from uuid import uuid4 + +from PIL import Image + +from app.core.config import settings +from app.core.storage import upload_object + +ALLOWED_MIME = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", +} + +FORMAT_TO_MIME = { + "JPEG": "image/jpeg", + "PNG": "image/png", + "WEBP": "image/webp", +} + + +class AvatarValidationError(ValueError): + pass + + +def validate_and_process_avatar(content: bytes) -> tuple[str, bytes]: + if len(content) > settings.avatar_max_bytes: + raise AvatarValidationError("FILE_TOO_LARGE") + if not content: + raise AvatarValidationError("INVALID_IMAGE") + + try: + with Image.open(BytesIO(content)) as image: + image.verify() + with Image.open(BytesIO(content)) as image: + mime = FORMAT_TO_MIME.get(image.format or "") + if mime not in ALLOWED_MIME: + raise AvatarValidationError("INVALID_MIME") + + buffer = BytesIO() + if mime == "image/jpeg": + rgb = image.convert("RGB") + rgb.save(buffer, format="JPEG", quality=85, optimize=True) + elif mime == "image/png": + image.save(buffer, format="PNG", optimize=True) + else: + image.save(buffer, format="WEBP", quality=85, method=6) + return mime, buffer.getvalue() + except AvatarValidationError: + raise + except Exception as exc: + raise AvatarValidationError("INVALID_IMAGE") from exc + + +def upload_user_avatar(user_id: str, content: bytes) -> str: + mime, processed = validate_and_process_avatar(content) + extension = ALLOWED_MIME[mime] + key = f"avatars/{user_id}/{uuid4()}{extension}" + upload_object(key, processed, mime) + return f"/api/v1/media/files/{key}" diff --git a/apps/api/app/modules/notifications/service.py b/apps/api/app/modules/notifications/service.py new file mode 100644 index 0000000..15558de --- /dev/null +++ b/apps/api/app/modules/notifications/service.py @@ -0,0 +1,8 @@ +"""Celery-ready notifications service placeholder. + +MVP keeps SMTP sync path in auth; v1.0 should move to async queue. +""" + + +def enqueue_email(template: str, recipient: str, context: dict) -> dict: + return {"queued": True, "template": template, "recipient": recipient, "context": context} diff --git a/apps/api/app/modules/orders/service.py b/apps/api/app/modules/orders/service.py new file mode 100644 index 0000000..4e29b8b --- /dev/null +++ b/apps/api/app/modules/orders/service.py @@ -0,0 +1,5 @@ +"""Orders module placeholder (request/booking mode without payment).""" + + +def create_order_request(user_id: str, items: list[dict]) -> dict: + return {"id": "order-placeholder", "user_id": user_id, "items": items, "status": "submitted"} diff --git a/apps/api/app/modules/sync/__init__.py b/apps/api/app/modules/sync/__init__.py new file mode 100644 index 0000000..2660a31 --- /dev/null +++ b/apps/api/app/modules/sync/__init__.py @@ -0,0 +1 @@ +"""WESP sync orchestrator: enterprise tenants, hub pairing, ChangeEvent protocol.""" diff --git a/apps/api/app/modules/sync/engine.py b/apps/api/app/modules/sync/engine.py new file mode 100644 index 0000000..870234d --- /dev/null +++ b/apps/api/app/modules/sync/engine.py @@ -0,0 +1,381 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime +from uuid import uuid4 + +from app.core.database import session_scope +from app.modules.sync import repository as repo +from app.modules.sync.models import SyncConflict, SyncOutbox +from app.modules.sync.schemas import ( + AckChangesRequest, + AckChangesResponse, + ChangeEventIn, + ChangeEventOut, + PullChangesResponse, + PushChangesResponse, +) +from app.modules.zootech.catalog_apply import apply_catalog_change + + +class SyncEngine: + ORCHESTRATOR_SITE_ID = "orchestrator" + + def __init__(self, enterprise_id: str, site_id: str) -> None: + self.enterprise_id = enterprise_id + self.site_id = site_id + + def process_push(self, events: list[ChangeEventIn], origin: str) -> PushChangesResponse: + applied: list[str] = [] + held: list[str] = [] + conflict_ids: list[str] = [] + + for event in events: + if repo.event_exists(event.event_id): + applied.append(event.event_id) + continue + if repo.applied_event_exists(self.site_id, event.event_id): + applied.append(event.event_id) + continue + + seq = event.seq or repo.next_seq(self.enterprise_id) + payload_json = json.dumps(event.payload, ensure_ascii=False) + repo.append_event_log( + enterprise_id=self.enterprise_id, + event_id=event.event_id, + origin=origin, + origin_site_id=event.origin_site_id or self.site_id, + seq=seq, + domain=event.domain, + table_name=event.table, + record_id=event.record_id, + action=event.action, + version=event.version, + content_hash=event.content_hash, + payload_json=payload_json, + ) + + outcome = self._apply_or_hold(event, origin) + if outcome == "applied": + repo.mark_applied(self.enterprise_id, self.ORCHESTRATOR_SITE_ID, event.event_id) + applied.append(event.event_id) + elif outcome == "held": + held.append(event.event_id) + elif isinstance(outcome, str) and outcome.startswith("conflict:"): + conflict_ids.append(outcome.split(":", 1)[1]) + + return PushChangesResponse( + applied_event_ids=applied, + held_event_ids=held, + conflicts=conflict_ids, + ) + + def _apply_or_hold(self, event: ChangeEventIn, origin: str) -> str: + state = repo.get_record_state(self.enterprise_id, event.table, event.record_id) + if state and state.agreed_hash and state.agreed_hash != event.content_hash: + agreed_version = int(state.agreed_version or 0) + if event.version <= agreed_version: + pending = self._find_pending_conflict(event.table, event.record_id) + if pending: + self._append_held_event(pending.id, event.event_id) + return "held" + conflict_id = self._create_conflict(event, origin, state) + self._hold_outbox_for_record(event.table, event.record_id) + return f"conflict:{conflict_id}" + + if event.domain in ("report", "reports"): + from app.modules.zootech.report_apply import apply_report_change + + apply_report_change( + self.enterprise_id, + event.table, + event.record_id, + event.action, + event.payload, + event.version, + event.content_hash, + farm_hub_id=event.origin_site_id if origin == "hub" else None, + ) + else: + apply_catalog_change( + self.enterprise_id, + event.table, + event.record_id, + event.action, + event.payload, + event.version, + event.content_hash, + ) + repo.upsert_record_state( + self.enterprise_id, + event.table, + event.record_id, + event.version, + event.content_hash, + event.event_id, + ) + if origin == "orchestrator" or self.site_id == self.ORCHESTRATOR_SITE_ID: + self._enqueue_fanout(event) + return "applied" + + def _append_held_event(self, conflict_id: str, event_id: str) -> None: + with session_scope() as db: + row = db.get(SyncConflict, conflict_id) + if not row: + return + held = json.loads(row.held_event_ids_json or "[]") + if event_id not in held: + held.append(event_id) + row.held_event_ids_json = json.dumps(held, ensure_ascii=False) + + def _hold_outbox_for_record(self, table_name: str, record_id: str) -> None: + with session_scope() as db: + from sqlalchemy import select + + rows = list( + db.scalars( + select(SyncOutbox).where( + SyncOutbox.enterprise_id == self.enterprise_id, + SyncOutbox.table_name == table_name, + SyncOutbox.record_id == record_id, + SyncOutbox.status.in_(("pending", "sent")), + ) + ) + ) + for row in rows: + row.status = "held" + + def _has_pending_orchestrator_change(self, table_name: str, record_id: str, origin: str) -> bool: + if origin == "hub": + with session_scope() as db: + from sqlalchemy import select + + row = db.scalar( + select(SyncOutbox).where( + SyncOutbox.enterprise_id == self.enterprise_id, + SyncOutbox.table_name == table_name, + SyncOutbox.record_id == record_id, + SyncOutbox.status.in_(("pending", "sent", "held")), + SyncOutbox.origin == "orchestrator", + ) + ) + return row is not None + return origin == "orchestrator" + + def _find_pending_conflict(self, table_name: str, record_id: str) -> SyncConflict | None: + with session_scope() as db: + from sqlalchemy import select + + row = db.scalar( + select(SyncConflict).where( + SyncConflict.enterprise_id == self.enterprise_id, + SyncConflict.table_name == table_name, + SyncConflict.record_id == record_id, + SyncConflict.status == "pending", + ) + ) + if row: + db.refresh(row) + db.expunge(row) + return row + return None + + def _create_conflict(self, event: ChangeEventIn, origin: str, state) -> str: + from app.modules.zootech.catalog_apply import load_catalog_row + + orchestrator_row = load_catalog_row(self.enterprise_id, event.table, event.record_id) or {} + if origin == "hub": + hub_snapshot = event.payload + orch_snapshot = orchestrator_row + else: + hub_snapshot = {} + orch_snapshot = event.payload or orchestrator_row + + conflict_id = str(uuid4()) + with session_scope() as db: + db.add( + SyncConflict( + id=conflict_id, + enterprise_id=self.enterprise_id, + table_name=event.table, + record_id=event.record_id, + farm_hub_id=event.origin_site_id if origin == "hub" else None, + orchestrator_snapshot_json=json.dumps(orch_snapshot, ensure_ascii=False), + hub_snapshot_json=json.dumps(hub_snapshot, ensure_ascii=False), + held_event_ids_json=json.dumps([event.event_id]), + status="pending", + ) + ) + return conflict_id + + def _enqueue_fanout(self, event: ChangeEventIn) -> None: + seq = repo.next_seq(self.enterprise_id) + with session_scope() as db: + existing = db.scalar( + __import__("sqlalchemy").select(SyncOutbox).where( + SyncOutbox.enterprise_id == self.enterprise_id, + SyncOutbox.table_name == event.table, + SyncOutbox.record_id == event.record_id, + SyncOutbox.status.in_(("pending", "sent")), + ) + ) + payload_json = json.dumps(event.payload, ensure_ascii=False) + if existing: + existing.event_id = event.event_id + existing.action = event.action + existing.version = event.version + existing.content_hash = event.content_hash + existing.payload_json = payload_json + existing.seq = seq + existing.status = "pending" + existing.emitted_at = event.emitted_at + else: + db.add( + SyncOutbox( + enterprise_id=self.enterprise_id, + event_id=event.event_id, + origin="orchestrator", + origin_site_id=self.ORCHESTRATOR_SITE_ID, + seq=seq, + domain=event.domain, + table_name=event.table, + record_id=event.record_id, + action=event.action, + version=event.version, + content_hash=event.content_hash, + payload_json=payload_json, + status="pending", + emitted_at=event.emitted_at, + ) + ) + + def process_pull(self, farm_hub_id: str, cursor: int, limit: int) -> PullChangesResponse: + rows = repo.pull_events_since( + self.enterprise_id, + cursor, + limit, + exclude_origin_site_id=self.site_id, + ) + events = [ + ChangeEventOut( + event_id=row.event_id, + seq=row.seq, + domain=row.domain, # type: ignore[arg-type] + table=row.table_name, + record_id=row.record_id, + action=row.action, # type: ignore[arg-type] + version=row.version, + content_hash=row.content_hash, + payload=json.loads(row.payload_json or "{}"), + emitted_at=row.received_at, + origin_site_id=row.origin_site_id, + ) + for row in rows + ] + next_cursor = events[-1].seq if events else cursor + repo.get_or_create_cursor(self.enterprise_id, farm_hub_id, "outbound") + return PullChangesResponse(events=events, next_cursor=next_cursor) + + def process_ack(self, farm_hub_id: str, body: AckChangesRequest) -> AckChangesResponse: + cursor = repo.get_or_create_cursor(self.enterprise_id, farm_hub_id, body.direction) + max_seq = cursor.last_acked_seq + for event_id in body.event_ids: + row = self._event_log_by_id(event_id) + if row: + max_seq = max(max_seq, row.seq) + repo.mark_applied(self.enterprise_id, farm_hub_id, event_id) + repo.update_cursor_ack(farm_hub_id, body.direction, max_seq) + return AckChangesResponse(last_acked_seq=max_seq) + + def _event_log_by_id(self, event_id: str): + with session_scope() as db: + from sqlalchemy import select + + from app.modules.sync.models import SyncEventLog + + row = db.scalar(select(SyncEventLog).where(SyncEventLog.event_id == event_id)) + if row: + db.refresh(row) + db.expunge(row) + return row + + def resolve_conflict(self, conflict_id: str, user_id: str, resolution: str) -> None: + conflict = repo.get_conflict(conflict_id) + if not conflict or conflict.enterprise_id != self.enterprise_id: + raise ValueError("CONFLICT_NOT_FOUND") + snapshot = ( + json.loads(conflict.orchestrator_snapshot_json) + if resolution == "keep_orchestrator" + else json.loads(conflict.hub_snapshot_json) + ) + event = ChangeEventIn( + event_id=str(uuid4()), + seq=repo.next_seq(self.enterprise_id), + domain="global", + table=conflict.table_name, + record_id=conflict.record_id, + action="upsert", + version=int(snapshot.get("version", 1)), + content_hash=str(snapshot.get("content_hash", "")), + payload=snapshot, + emitted_at=datetime.now(UTC), + origin_site_id=self.ORCHESTRATOR_SITE_ID, + ) + apply_catalog_change( + self.enterprise_id, + event.table, + event.record_id, + event.action, + event.payload, + event.version, + event.content_hash, + ) + repo.upsert_record_state( + self.enterprise_id, + event.table, + event.record_id, + event.version, + event.content_hash, + event.event_id, + ) + repo.append_event_log( + enterprise_id=self.enterprise_id, + event_id=event.event_id, + origin="orchestrator", + origin_site_id=self.ORCHESTRATOR_SITE_ID, + seq=event.seq or repo.next_seq(self.enterprise_id), + domain=event.domain, + table_name=event.table, + record_id=event.record_id, + action=event.action, + version=event.version, + content_hash=event.content_hash, + payload_json=json.dumps(event.payload, ensure_ascii=False), + ) + repo.mark_applied(self.enterprise_id, self.ORCHESTRATOR_SITE_ID, event.event_id) + self._enqueue_fanout(event) + self._release_held_outbox(conflict.table_name, conflict.record_id) + with session_scope() as db: + row = db.get(SyncConflict, conflict_id) + if row: + row.status = "resolved" + row.resolution = resolution + row.resolved_by = user_id + row.resolved_at = datetime.now(UTC) + + def _release_held_outbox(self, table_name: str, record_id: str) -> None: + with session_scope() as db: + from sqlalchemy import select + + rows = list( + db.scalars( + select(SyncOutbox).where( + SyncOutbox.enterprise_id == self.enterprise_id, + SyncOutbox.table_name == table_name, + SyncOutbox.record_id == record_id, + SyncOutbox.status == "held", + ) + ) + ) + for row in rows: + row.status = "pending" diff --git a/apps/api/app/modules/sync/models.py b/apps/api/app/modules/sync/models.py new file mode 100644 index 0000000..fa5bafd --- /dev/null +++ b/apps/api/app/modules/sync/models.py @@ -0,0 +1,299 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import uuid4 + +from sqlalchemy import ( + BigInteger, + Boolean, + DateTime, + ForeignKey, + Index, + Integer, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +class Enterprise(Base): + __tablename__ = "enterprises" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + name: Mapped[str] = mapped_column(String(255), nullable=False) + slug: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="active", index=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) + + members: Mapped[list["EnterpriseMember"]] = relationship(back_populates="enterprise", cascade="all, delete-orphan") + farm_hubs: Mapped[list["FarmHub"]] = relationship(back_populates="enterprise", cascade="all, delete-orphan") + + +class EnterpriseMember(Base): + __tablename__ = "enterprise_members" + __table_args__ = (UniqueConstraint("user_id", "enterprise_id", name="uq_enterprise_members_user_enterprise"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + enterprise_id: Mapped[str] = mapped_column( + String(36), ForeignKey("enterprises.id", ondelete="CASCADE"), nullable=False, index=True + ) + role: Mapped[str] = mapped_column(String(32), nullable=False, default="viewer") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC) + ) + + enterprise: Mapped[Enterprise] = relationship(back_populates="members") + + +class FarmHub(Base): + __tablename__ = "farm_hubs" + __table_args__ = (UniqueConstraint("hub_site_id", name="uq_farm_hubs_hub_site_id"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + enterprise_id: Mapped[str] = mapped_column( + String(36), ForeignKey("enterprises.id", ondelete="CASCADE"), nullable=False, index=True + ) + name: Mapped[str] = mapped_column(String(255), nullable=False) + hub_site_id: Mapped[str] = mapped_column(String(36), nullable=False) + url: Mapped[str | None] = mapped_column(String(512), nullable=True) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True) + last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + wesp_version: Mapped[str | None] = mapped_column(String(64), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) + + enterprise: Mapped[Enterprise] = relationship(back_populates="farm_hubs") + credentials: Mapped[list["HubCredential"]] = relationship(back_populates="farm_hub", cascade="all, delete-orphan") + farm_access: Mapped[list["UserFarmAccess"]] = relationship(back_populates="farm_hub", cascade="all, delete-orphan") + + +class UserFarmAccess(Base): + __tablename__ = "user_farm_access" + __table_args__ = (UniqueConstraint("user_id", "farm_hub_id", name="uq_user_farm_access_user_farm"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + farm_hub_id: Mapped[str] = mapped_column( + String(36), ForeignKey("farm_hubs.id", ondelete="CASCADE"), nullable=False, index=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC) + ) + + farm_hub: Mapped[FarmHub] = relationship(back_populates="farm_access") + + +class HubCredential(Base): + __tablename__ = "hub_credentials" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + farm_hub_id: Mapped[str] = mapped_column( + String(36), ForeignKey("farm_hubs.id", ondelete="CASCADE"), nullable=False, index=True + ) + secret_hash: Mapped[str] = mapped_column(String(255), nullable=False) + paired_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC) + ) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + farm_hub: Mapped[FarmHub] = relationship(back_populates="credentials") + + +class HubPairingSession(Base): + __tablename__ = "hub_pairing_sessions" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + enterprise_id: Mapped[str] = mapped_column( + String(36), ForeignKey("enterprises.id", ondelete="CASCADE"), nullable=False, index=True + ) + code: Mapped[str] = mapped_column(String(6), nullable=False, index=True) + code_hash: Mapped[str] = mapped_column(String(64), nullable=False) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + confirmed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + farm_hub_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("farm_hubs.id", ondelete="SET NULL"), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC) + ) + + +class SyncOutbox(Base): + __tablename__ = "sync_outbox" + __table_args__ = ( + Index("idx_sync_outbox_enterprise_status", "enterprise_id", "status"), + Index("idx_sync_outbox_table_record", "table_name", "record_id"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + enterprise_id: Mapped[str] = mapped_column( + String(36), ForeignKey("enterprises.id", ondelete="CASCADE"), nullable=False, index=True + ) + event_id: Mapped[str] = mapped_column(String(36), nullable=False, unique=True) + origin: Mapped[str] = mapped_column(String(32), nullable=False) + origin_site_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + seq: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) + domain: Mapped[str] = mapped_column(String(16), nullable=False, default="global") + table_name: Mapped[str] = mapped_column(String(64), nullable=False) + record_id: Mapped[str] = mapped_column(String(128), nullable=False) + action: Mapped[str] = mapped_column(String(16), nullable=False) + version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + content_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="") + payload_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True) + emitted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC) + ) + + +class SyncEventLog(Base): + __tablename__ = "sync_event_log" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + enterprise_id: Mapped[str] = mapped_column( + String(36), ForeignKey("enterprises.id", ondelete="CASCADE"), nullable=False, index=True + ) + event_id: Mapped[str] = mapped_column(String(36), nullable=False, unique=True) + origin: Mapped[str] = mapped_column(String(32), nullable=False) + origin_site_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + seq: Mapped[int] = mapped_column(BigInteger, nullable=False) + domain: Mapped[str] = mapped_column(String(16), nullable=False, default="global") + table_name: Mapped[str] = mapped_column(String(64), nullable=False) + record_id: Mapped[str] = mapped_column(String(128), nullable=False) + action: Mapped[str] = mapped_column(String(16), nullable=False) + version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + content_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="") + payload_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + received_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC) + ) + + +class SyncAppliedEvent(Base): + __tablename__ = "sync_applied_events" + __table_args__ = (UniqueConstraint("site_id", "event_id", name="uq_sync_applied_events_site_event"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + enterprise_id: Mapped[str] = mapped_column( + String(36), ForeignKey("enterprises.id", ondelete="CASCADE"), nullable=False, index=True + ) + site_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + event_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + applied_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC) + ) + + +class SyncCursor(Base): + __tablename__ = "sync_cursors" + __table_args__ = (UniqueConstraint("farm_hub_id", "direction", name="uq_sync_cursors_hub_direction"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + enterprise_id: Mapped[str] = mapped_column( + String(36), ForeignKey("enterprises.id", ondelete="CASCADE"), nullable=False, index=True + ) + farm_hub_id: Mapped[str] = mapped_column( + String(36), ForeignKey("farm_hubs.id", ondelete="CASCADE"), nullable=False, index=True + ) + direction: Mapped[str] = mapped_column(String(16), nullable=False) + last_acked_seq: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) + last_pulled_seq: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) + + +class SyncRecordState(Base): + __tablename__ = "sync_record_state" + __table_args__ = ( + UniqueConstraint("enterprise_id", "table_name", "record_id", name="uq_sync_record_state_ent_table_record"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + enterprise_id: Mapped[str] = mapped_column( + String(36), ForeignKey("enterprises.id", ondelete="CASCADE"), nullable=False, index=True + ) + table_name: Mapped[str] = mapped_column(String(64), nullable=False) + record_id: Mapped[str] = mapped_column(String(128), nullable=False) + agreed_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + agreed_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="") + last_event_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) + + +class SyncConflict(Base): + __tablename__ = "sync_conflicts" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + enterprise_id: Mapped[str] = mapped_column( + String(36), ForeignKey("enterprises.id", ondelete="CASCADE"), nullable=False, index=True + ) + table_name: Mapped[str] = mapped_column(String(64), nullable=False) + record_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True) + farm_hub_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("farm_hubs.id", ondelete="SET NULL"), nullable=True) + orchestrator_snapshot_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + hub_snapshot_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + held_event_ids_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]") + status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True) + resolution: Mapped[str | None] = mapped_column(String(32), nullable=True) + resolved_by: Mapped[str | None] = mapped_column(String(36), nullable=True) + resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC) + ) + + +class SyncReconcileRun(Base): + __tablename__ = "sync_reconcile_runs" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + enterprise_id: Mapped[str] = mapped_column( + String(36), ForeignKey("enterprises.id", ondelete="CASCADE"), nullable=False, index=True + ) + started_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC) + ) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + mismatches_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + auto_repaired: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="running") + + +class ReportSyncState(Base): + __tablename__ = "report_sync_state" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + farm_hub_id: Mapped[str] = mapped_column( + String(36), ForeignKey("farm_hubs.id", ondelete="CASCADE"), nullable=False, unique=True + ) + enterprise_id: Mapped[str] = mapped_column( + String(36), ForeignKey("enterprises.id", ondelete="CASCADE"), nullable=False, index=True + ) + last_report_cursor: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) + last_pull_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/apps/api/app/modules/sync/reconcile.py b/apps/api/app/modules/sync/reconcile.py new file mode 100644 index 0000000..2444836 --- /dev/null +++ b/apps/api/app/modules/sync/reconcile.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime + +from sqlalchemy import select + +from app.core.database import session_scope +from app.modules.sync.models import FarmHub, SyncRecordState +from app.modules.zootech.catalog_apply import load_catalog_row + + +def run_sync_reconcile() -> int: + """Compare orchestrator catalog content_hash vs sync_record_state agreed_hash.""" + mismatches = 0 + with session_scope() as db: + hubs = list(db.scalars(select(FarmHub).where(FarmHub.status == "active"))) + states = list(db.scalars(select(SyncRecordState))) + for state in states: + row = load_catalog_row(state.enterprise_id, state.table_name, state.record_id) + if not row: + continue + catalog_hash = str(row.get("content_hash") or "") + if state.agreed_hash and catalog_hash and state.agreed_hash != catalog_hash: + mismatches += 1 + state.agreed_hash = catalog_hash + state.agreed_version = int(row.get("version") or state.agreed_version) + state.updated_at = datetime.now(UTC) + for hub in hubs: + from app.modules.sync.models import SyncReconcileRun + + db.add( + SyncReconcileRun( + enterprise_id=hub.enterprise_id, + status="completed", + mismatches_count=mismatches, + auto_repaired=mismatches, + finished_at=datetime.now(UTC), + ) + ) + return mismatches diff --git a/apps/api/app/modules/sync/repository.py b/apps/api/app/modules/sync/repository.py new file mode 100644 index 0000000..3ce5437 --- /dev/null +++ b/apps/api/app/modules/sync/repository.py @@ -0,0 +1,394 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from uuid import uuid4 + +from sqlalchemy import func, select + +from app.core.crypto import hash_opaque_token +from app.core.database import session_scope +from app.modules.sync.models import ( + Enterprise, + EnterpriseMember, + FarmHub, + HubCredential, + HubPairingSession, + SyncAppliedEvent, + SyncConflict, + SyncCursor, + SyncEventLog, + SyncOutbox, + SyncRecordState, + UserFarmAccess, +) + + +def _detach(db, instance): + db.refresh(instance) + db.expunge(instance) + return instance + + +def get_enterprise_by_id(enterprise_id: str) -> Enterprise | None: + with session_scope() as db: + row = db.get(Enterprise, enterprise_id) + return _detach(db, row) if row else None + + +def get_enterprise_by_slug(slug: str) -> Enterprise | None: + with session_scope() as db: + row = db.scalar(select(Enterprise).where(Enterprise.slug == slug)) + return _detach(db, row) if row else None + + +def create_enterprise(name: str, slug: str) -> Enterprise: + with session_scope() as db: + row = Enterprise(name=name, slug=slug, status="active") + db.add(row) + db.flush() + return _detach(db, row) + + +def get_member(user_id: str, enterprise_id: str) -> EnterpriseMember | None: + with session_scope() as db: + row = db.scalar( + select(EnterpriseMember).where( + EnterpriseMember.user_id == user_id, + EnterpriseMember.enterprise_id == enterprise_id, + ) + ) + return _detach(db, row) if row else None + + +def add_member(user_id: str, enterprise_id: str, role: str) -> EnterpriseMember: + with session_scope() as db: + row = EnterpriseMember(user_id=user_id, enterprise_id=enterprise_id, role=role) + db.add(row) + db.flush() + return _detach(db, row) + + +def list_members(enterprise_id: str) -> list[EnterpriseMember]: + with session_scope() as db: + rows = list( + db.scalars( + select(EnterpriseMember).where(EnterpriseMember.enterprise_id == enterprise_id) + ) + ) + return [_detach(db, row) for row in rows] + + +def set_member_role(user_id: str, enterprise_id: str, role: str) -> EnterpriseMember | None: + with session_scope() as db: + row = db.scalar( + select(EnterpriseMember).where( + EnterpriseMember.user_id == user_id, + EnterpriseMember.enterprise_id == enterprise_id, + ) + ) + if not row: + return None + row.role = role + db.flush() + return _detach(db, row) + + +def list_farm_access(user_id: str, enterprise_id: str) -> list[str]: + with session_scope() as db: + rows = db.scalars( + select(UserFarmAccess.farm_hub_id) + .join(FarmHub, FarmHub.id == UserFarmAccess.farm_hub_id) + .where(UserFarmAccess.user_id == user_id, FarmHub.enterprise_id == enterprise_id) + ).all() + return list(rows) + + +def grant_farm_access(user_id: str, farm_hub_id: str) -> UserFarmAccess: + with session_scope() as db: + existing = db.scalar( + select(UserFarmAccess).where( + UserFarmAccess.user_id == user_id, + UserFarmAccess.farm_hub_id == farm_hub_id, + ) + ) + if existing: + return _detach(db, existing) + row = UserFarmAccess(user_id=user_id, farm_hub_id=farm_hub_id) + db.add(row) + db.flush() + return _detach(db, row) + + +def get_farm_hub_by_site_id(hub_site_id: str) -> FarmHub | None: + with session_scope() as db: + row = db.scalar(select(FarmHub).where(FarmHub.hub_site_id == hub_site_id)) + return _detach(db, row) if row else None + + +def get_farm_hub_by_id(farm_hub_id: str) -> FarmHub | None: + with session_scope() as db: + row = db.get(FarmHub, farm_hub_id) + return _detach(db, row) if row else None + + +def list_farm_hubs(enterprise_id: str) -> list[FarmHub]: + with session_scope() as db: + rows = list(db.scalars(select(FarmHub).where(FarmHub.enterprise_id == enterprise_id).order_by(FarmHub.name))) + return [_detach(db, row) for row in rows] + + +def create_pairing_session(enterprise_id: str, code: str, code_hash: str, ttl_minutes: int = 15) -> HubPairingSession: + with session_scope() as db: + row = HubPairingSession( + enterprise_id=enterprise_id, + code=code, + code_hash=code_hash, + expires_at=datetime.now(UTC) + timedelta(minutes=ttl_minutes), + ) + db.add(row) + db.flush() + return _detach(db, row) + + +def get_pairing_session_by_code_hash(code_hash: str) -> HubPairingSession | None: + with session_scope() as db: + row = db.scalar( + select(HubPairingSession).where( + HubPairingSession.code_hash == code_hash, + HubPairingSession.confirmed_at.is_(None), + ) + ) + return _detach(db, row) if row else None + + +def confirm_pairing_session(session_id: str, farm_hub_id: str) -> None: + with session_scope() as db: + row = db.get(HubPairingSession, session_id) + if row: + row.confirmed_at = datetime.now(UTC) + row.farm_hub_id = farm_hub_id + + +def create_farm_hub( + enterprise_id: str, + name: str, + hub_site_id: str, + url: str | None = None, +) -> FarmHub: + with session_scope() as db: + row = FarmHub( + enterprise_id=enterprise_id, + name=name, + hub_site_id=hub_site_id, + url=url, + status="active", + ) + db.add(row) + db.flush() + return _detach(db, row) + + +def create_hub_credential(farm_hub_id: str, api_key: str) -> HubCredential: + from app.core.crypto import hash_opaque_token as _hash + + with session_scope() as db: + row = HubCredential(farm_hub_id=farm_hub_id, secret_hash=_hash(api_key)) + db.add(row) + db.flush() + return _detach(db, row) + + +def verify_hub_credential(hub_site_id: str, api_key: str) -> FarmHub | None: + from app.core.crypto import hash_opaque_token as _hash + + key_hash = _hash(api_key) + with session_scope() as db: + row = db.scalar( + select(FarmHub) + .join(HubCredential, HubCredential.farm_hub_id == FarmHub.id) + .where( + FarmHub.hub_site_id == hub_site_id, + HubCredential.secret_hash == key_hash, + HubCredential.revoked_at.is_(None), + ) + ) + return _detach(db, row) if row else None + + +def update_hub_heartbeat(farm_hub_id: str, wesp_version: str | None) -> None: + with session_scope() as db: + row = db.get(FarmHub, farm_hub_id) + if row: + row.last_seen = datetime.now(UTC) + if wesp_version: + row.wesp_version = wesp_version + + +def next_seq(enterprise_id: str) -> int: + with session_scope() as db: + current = db.scalar( + select(func.max(SyncEventLog.seq)).where(SyncEventLog.enterprise_id == enterprise_id) + ) + return int(current or 0) + 1 + + +def event_exists(event_id: str) -> bool: + with session_scope() as db: + row = db.scalar(select(SyncEventLog.id).where(SyncEventLog.event_id == event_id)) + return row is not None + + +def append_event_log(**kwargs) -> SyncEventLog: + with session_scope() as db: + row = SyncEventLog(**kwargs) + db.add(row) + db.flush() + return _detach(db, row) + + +def applied_event_exists(site_id: str, event_id: str) -> bool: + with session_scope() as db: + row = db.scalar( + select(SyncAppliedEvent.id).where( + SyncAppliedEvent.site_id == site_id, + SyncAppliedEvent.event_id == event_id, + ) + ) + return row is not None + + +def mark_applied(enterprise_id: str, site_id: str, event_id: str) -> None: + with session_scope() as db: + db.add( + SyncAppliedEvent( + enterprise_id=enterprise_id, + site_id=site_id, + event_id=event_id, + ) + ) + + +def get_record_state(enterprise_id: str, table_name: str, record_id: str) -> SyncRecordState | None: + with session_scope() as db: + row = db.scalar( + select(SyncRecordState).where( + SyncRecordState.enterprise_id == enterprise_id, + SyncRecordState.table_name == table_name, + SyncRecordState.record_id == record_id, + ) + ) + return _detach(db, row) if row else None + + +def upsert_record_state( + enterprise_id: str, + table_name: str, + record_id: str, + agreed_version: int, + agreed_hash: str, + last_event_id: str, +) -> None: + with session_scope() as db: + row = db.scalar( + select(SyncRecordState).where( + SyncRecordState.enterprise_id == enterprise_id, + SyncRecordState.table_name == table_name, + SyncRecordState.record_id == record_id, + ) + ) + if row: + row.agreed_version = agreed_version + row.agreed_hash = agreed_hash + row.last_event_id = last_event_id + row.updated_at = datetime.now(UTC) + else: + db.add( + SyncRecordState( + enterprise_id=enterprise_id, + table_name=table_name, + record_id=record_id, + agreed_version=agreed_version, + agreed_hash=agreed_hash, + last_event_id=last_event_id, + ) + ) + + +def list_conflicts(enterprise_id: str, status: str = "pending") -> list[SyncConflict]: + with session_scope() as db: + rows = list( + db.scalars( + select(SyncConflict) + .where(SyncConflict.enterprise_id == enterprise_id, SyncConflict.status == status) + .order_by(SyncConflict.created_at.desc()) + ) + ) + return [_detach(db, row) for row in rows] + + +def get_conflict(conflict_id: str) -> SyncConflict | None: + with session_scope() as db: + row = db.get(SyncConflict, conflict_id) + return _detach(db, row) if row else None + + +def get_or_create_cursor(enterprise_id: str, farm_hub_id: str, direction: str) -> SyncCursor: + with session_scope() as db: + row = db.scalar( + select(SyncCursor).where( + SyncCursor.farm_hub_id == farm_hub_id, + SyncCursor.direction == direction, + ) + ) + if row: + return _detach(db, row) + row = SyncCursor( + enterprise_id=enterprise_id, + farm_hub_id=farm_hub_id, + direction=direction, + last_acked_seq=0, + last_pulled_seq=0, + ) + db.add(row) + db.flush() + return _detach(db, row) + + +def update_cursor_ack(farm_hub_id: str, direction: str, last_acked_seq: int) -> None: + with session_scope() as db: + row = db.scalar( + select(SyncCursor).where( + SyncCursor.farm_hub_id == farm_hub_id, + SyncCursor.direction == direction, + ) + ) + if row: + row.last_acked_seq = max(row.last_acked_seq, last_acked_seq) + row.updated_at = datetime.now(UTC) + + +def pull_events_since( + enterprise_id: str, + cursor: int, + limit: int, + *, + exclude_origin_site_id: str | None = None, +) -> list[SyncEventLog]: + with session_scope() as db: + query = select(SyncEventLog).where( + SyncEventLog.enterprise_id == enterprise_id, + SyncEventLog.seq > cursor, + ) + if exclude_origin_site_id: + query = query.where( + (SyncEventLog.origin_site_id.is_(None)) + | (SyncEventLog.origin_site_id != exclude_origin_site_id) + ) + rows = list(db.scalars(query.order_by(SyncEventLog.seq.asc()).limit(limit))) + return [_detach(db, row) for row in rows] + + +def get_farm_hub_site_id(farm_hub_id: str) -> str | None: + with session_scope() as db: + row = db.get(FarmHub, farm_hub_id) + return row.hub_site_id if row else None diff --git a/apps/api/app/modules/sync/router.py b/apps/api/app/modules/sync/router.py new file mode 100644 index 0000000..6c0e22a --- /dev/null +++ b/apps/api/app/modules/sync/router.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query, status + +from app.core.dependencies import require_superuser +from app.modules.sync import service +from app.modules.sync.schemas import ( + AckChangesRequest, + AckChangesResponse, + HeartbeatRequest, + HeartbeatResponse, + PairConfirmRequest, + PairConfirmResponse, + PairStartRequest, + PullChangesRequest, + PullChangesResponse, + PushChangesRequest, + PushChangesResponse, + ResolveConflictRequest, + SyncCapabilitiesResponse, +) +from app.modules.sync.service import SyncServiceError +from app.modules.sync.tenant import ( + HubPrincipal, + TenantContext, + get_hub_auth, + get_tenant_context, + require_enterprise_zootech, +) +from app.modules.users.models import User +from app.core.dependencies import get_current_user + +router = APIRouter() + + +def _map_error(exc: SyncServiceError) -> HTTPException: + code = str(exc) + status_code = status.HTTP_400_BAD_REQUEST + if code in {"ENTERPRISE_ADMIN_ONLY", "ENTERPRISE_FORBIDDEN", "ENTERPRISE_ZOOTECH_ONLY"}: + status_code = status.HTTP_403_FORBIDDEN + if code in {"INVALID_PAIRING_CODE", "PAIRING_CODE_EXPIRED"}: + status_code = status.HTTP_400_BAD_REQUEST + if code == "CONFLICT_NOT_FOUND": + status_code = status.HTTP_404_NOT_FOUND + return HTTPException(status_code=status_code, detail=code) + + +@router.post("/reports/push") +def reports_push(body: PushChangesRequest, hub: HubPrincipal = Depends(get_hub_auth)) -> PushChangesResponse: + """Report domain ingest — same durability path as global push.""" + return service.push_changes(hub, body) + + +@router.post("/reports/refresh") +def reports_refresh( + farm_hub_id: str = Query(...), + enterprise_id: str = Query(...), + tenant: TenantContext = Depends(require_enterprise_zootech), +): + if tenant.farm_hub_ids is not None and farm_hub_id not in tenant.farm_hub_ids: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="FARM_ACCESS_FORBIDDEN") + return {"status": "queued", "farm_hub_id": farm_hub_id, "enterprise_id": tenant.enterprise_id} + + +@router.get("/metrics") +def sync_metrics( + enterprise_id: str = Query(...), + tenant: TenantContext = Depends(require_enterprise_zootech), +): + if tenant.enterprise_id != enterprise_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN") + return service.get_sync_metrics(enterprise_id) + + +@router.get("/capabilities", response_model=SyncCapabilitiesResponse) +def capabilities() -> SyncCapabilitiesResponse: + return service.get_capabilities() + + +@router.post("/changes/push", response_model=PushChangesResponse) +def push_changes(body: PushChangesRequest, hub: HubPrincipal = Depends(get_hub_auth)) -> PushChangesResponse: + return service.push_changes(hub, body) + + +@router.post("/changes/pull", response_model=PullChangesResponse) +def pull_changes(body: PullChangesRequest, hub: HubPrincipal = Depends(get_hub_auth)) -> PullChangesResponse: + return service.pull_changes(hub, body) + + +@router.post("/changes/ack", response_model=AckChangesResponse) +def ack_changes(body: AckChangesRequest, hub: HubPrincipal = Depends(get_hub_auth)) -> AckChangesResponse: + return service.ack_changes(hub, body) + + +@router.post("/hubs/heartbeat", response_model=HeartbeatResponse) +def heartbeat(body: HeartbeatRequest, hub: HubPrincipal = Depends(get_hub_auth)) -> HeartbeatResponse: + return service.hub_heartbeat(hub, body) + + +@router.get("/hub/conflicts") +def list_hub_conflicts(hub: HubPrincipal = Depends(get_hub_auth)): + return service.list_conflicts_for_enterprise(hub.enterprise_id) + + +@router.get("/hub/conflicts/{conflict_id}") +def get_hub_conflict(conflict_id: str, hub: HubPrincipal = Depends(get_hub_auth)): + try: + return service.get_conflict_detail(conflict_id, hub.enterprise_id) + except SyncServiceError as exc: + raise _map_error(exc) from exc + + +@router.post("/hub/conflicts/{conflict_id}/resolve") +def resolve_hub_conflict( + conflict_id: str, + body: ResolveConflictRequest, + hub: HubPrincipal = Depends(get_hub_auth), +): + try: + service.resolve_conflict(conflict_id, hub.enterprise_id, f"hub:{hub.hub_site_id}", body) + return {"status": "ok"} + except SyncServiceError as exc: + raise _map_error(exc) from exc + + +@router.get("/conflicts") +def list_conflicts( + enterprise_id: str = Query(...), + tenant: TenantContext = Depends(require_enterprise_zootech), +): + if tenant.enterprise_id != enterprise_id and tenant.enterprise_role != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN") + return service.list_conflicts_for_enterprise(enterprise_id) + + +@router.get("/conflicts/{conflict_id}") +def get_conflict( + conflict_id: str, + enterprise_id: str = Query(...), + tenant: TenantContext = Depends(require_enterprise_zootech), +): + try: + return service.get_conflict_detail(conflict_id, enterprise_id) + except SyncServiceError as exc: + raise _map_error(exc) from exc + + +@router.post("/conflicts/{conflict_id}/resolve") +def resolve_conflict( + conflict_id: str, + body: ResolveConflictRequest, + enterprise_id: str = Query(...), + tenant: TenantContext = Depends(require_enterprise_zootech), +): + try: + service.resolve_conflict(conflict_id, enterprise_id, tenant.user_id or "", body) + return {"status": "ok"} + except SyncServiceError as exc: + raise _map_error(exc) from exc + + +enterprise_router = APIRouter() + + +@enterprise_router.post("/pair/start") +def pair_start(body: PairStartRequest, user: User = Depends(get_current_user)): + try: + return service.start_pairing(user, body) + except SyncServiceError as exc: + raise _map_error(exc) from exc + + +@enterprise_router.post("/pair/confirm", response_model=PairConfirmResponse) +def pair_confirm(body: PairConfirmRequest) -> PairConfirmResponse: + try: + return service.confirm_pairing(body) + except SyncServiceError as exc: + raise _map_error(exc) from exc diff --git a/apps/api/app/modules/sync/schemas.py b/apps/api/app/modules/sync/schemas.py new file mode 100644 index 0000000..01f345b --- /dev/null +++ b/apps/api/app/modules/sync/schemas.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Literal + +from pydantic import BaseModel, Field + + +ChangeAction = Literal["upsert", "delete"] +ChangeDomain = Literal["global", "report"] +EnterpriseRole = Literal["admin", "zootech", "viewer"] +ConflictResolution = Literal["keep_orchestrator", "keep_hub"] + + +class ChangeEventIn(BaseModel): + event_id: str + seq: int + domain: ChangeDomain = "global" + table: str = Field(alias="table") + record_id: str + action: ChangeAction + version: int = 1 + content_hash: str = "" + payload: dict[str, Any] = Field(default_factory=dict) + emitted_at: datetime + origin_site_id: str | None = None + + model_config = {"populate_by_name": True} + + +class ChangeEventOut(BaseModel): + event_id: str + seq: int + domain: ChangeDomain + table: str + record_id: str + action: ChangeAction + version: int + content_hash: str + payload: dict[str, Any] + emitted_at: datetime + origin_site_id: str | None = None + + +class PushChangesRequest(BaseModel): + events: list[ChangeEventIn] + + +class PushChangesResponse(BaseModel): + applied_event_ids: list[str] + held_event_ids: list[str] + conflicts: list[str] + + +class PullChangesRequest(BaseModel): + cursor: int = 0 + limit: int = 100 + + +class PullChangesResponse(BaseModel): + events: list[ChangeEventOut] + next_cursor: int + + +class AckChangesRequest(BaseModel): + event_ids: list[str] + direction: Literal["inbound", "outbound"] = "inbound" + + +class AckChangesResponse(BaseModel): + last_acked_seq: int + + +class SyncCapabilitiesResponse(BaseModel): + protocol_version: str = "1.0" + domains: list[str] = Field(default_factory=lambda: ["global", "report"]) + + +class HeartbeatRequest(BaseModel): + wesp_version: str | None = None + lag_seconds: int | None = None + + +class HeartbeatResponse(BaseModel): + status: str = "ok" + server_time: datetime + + +class PairStartRequest(BaseModel): + enterprise_id: str + farm_name: str + + +class PairStartResponse(BaseModel): + session_id: str + code: str + expires_at: datetime + + +class PairConfirmRequest(BaseModel): + code: str + hub_site_id: str + hub_name: str | None = None + hub_url: str | None = None + + +class PairConfirmResponse(BaseModel): + farm_hub_id: str + hub_site_id: str + api_key: str + enterprise_id: str + + +class ConflictSummary(BaseModel): + id: str + table_name: str + record_id: str + farm_hub_id: str | None + status: str + created_at: datetime + + +class ConflictDetail(ConflictSummary): + orchestrator_snapshot: dict[str, Any] + hub_snapshot: dict[str, Any] + held_event_ids: list[str] + + +class ResolveConflictRequest(BaseModel): + resolution: ConflictResolution + + +class EnterpriseOut(BaseModel): + id: str + name: str + slug: str + status: str + + +class FarmHubOut(BaseModel): + id: str + enterprise_id: str + name: str + hub_site_id: str + url: str | None + status: str + last_seen: datetime | None + wesp_version: str | None + + +@dataclass +class HubAuthContext: + farm_hub_id: str + hub_site_id: str + enterprise_id: str + farm_hub: Any + + +@dataclass +class TenantContext: + enterprise_id: str + enterprise_role: EnterpriseRole | None + user_id: str | None + farm_hub_ids: list[str] | None diff --git a/apps/api/app/modules/sync/service.py b/apps/api/app/modules/sync/service.py new file mode 100644 index 0000000..68760e3 --- /dev/null +++ b/apps/api/app/modules/sync/service.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +import json +import secrets +from datetime import UTC, datetime +from uuid import uuid4 + +from app.core.crypto import generate_secret_token_urlsafe, hash_opaque_token +from app.core.exceptions import DomainError +from app.modules.sync import repository as repo +from app.modules.sync.schemas import ( + AckChangesRequest, + AckChangesResponse, + ChangeEventIn, + ChangeEventOut, + ConflictDetail, + ConflictSummary, + HeartbeatRequest, + HeartbeatResponse, + PairConfirmRequest, + PairConfirmResponse, + PairStartRequest, + PairStartResponse, + PullChangesRequest, + PullChangesResponse, + PushChangesRequest, + PushChangesResponse, + ResolveConflictRequest, + SyncCapabilitiesResponse, +) +from app.modules.sync.engine import SyncEngine +from app.modules.users.models import User + + +class SyncServiceError(DomainError): + pass + + +def get_capabilities() -> SyncCapabilitiesResponse: + return SyncCapabilitiesResponse() + + +def start_pairing(user: User, body: PairStartRequest) -> PairStartResponse: + member = repo.get_member(user.id, body.enterprise_id) + if not member or member.role != "admin": + if not user.is_superuser: + raise SyncServiceError("ENTERPRISE_ADMIN_ONLY") + enterprise = repo.get_enterprise_by_id(body.enterprise_id) + if not enterprise: + raise SyncServiceError("ENTERPRISE_NOT_FOUND") + code = f"{secrets.randbelow(900000) + 100000:06d}" + session = repo.create_pairing_session(body.enterprise_id, code, hash_opaque_token(code)) + return PairStartResponse(session_id=session.id, code=code, expires_at=session.expires_at) + + +def confirm_pairing(body: PairConfirmRequest) -> PairConfirmResponse: + code_hash = hash_opaque_token(body.code.strip()) + session = repo.get_pairing_session_by_code_hash(code_hash) + if not session: + raise SyncServiceError("INVALID_PAIRING_CODE") + if session.expires_at.replace(tzinfo=UTC) < datetime.now(UTC): + raise SyncServiceError("PAIRING_CODE_EXPIRED") + hub = repo.create_farm_hub( + session.enterprise_id, + body.hub_name or f"Hub {body.hub_site_id[:8]}", + body.hub_site_id, + body.hub_url, + ) + api_key = generate_secret_token_urlsafe(48) + repo.create_hub_credential(hub.id, api_key) + repo.confirm_pairing_session(session.id, hub.id) + return PairConfirmResponse( + farm_hub_id=hub.id, + hub_site_id=hub.hub_site_id, + api_key=api_key, + enterprise_id=session.enterprise_id, + ) + + +def hub_heartbeat(hub, body: HeartbeatRequest) -> HeartbeatResponse: + repo.update_hub_heartbeat(hub.farm_hub_id, body.wesp_version) + return HeartbeatResponse(server_time=datetime.now(UTC)) + + +def push_changes(hub, body: PushChangesRequest) -> PushChangesResponse: + return SyncEngine(hub.enterprise_id, hub.hub_site_id).process_push(body.events, origin="hub") + + +def pull_changes(hub, body: PullChangesRequest) -> PullChangesResponse: + return SyncEngine(hub.enterprise_id, hub.hub_site_id).process_pull(hub.farm_hub_id, body.cursor, body.limit) + + +def ack_changes(hub, body: AckChangesRequest) -> AckChangesResponse: + return SyncEngine(hub.enterprise_id, hub.hub_site_id).process_ack(hub.farm_hub_id, body) + + +def list_conflicts_for_enterprise(enterprise_id: str) -> list[ConflictSummary]: + rows = repo.list_conflicts(enterprise_id) + return [ + ConflictSummary( + id=row.id, + table_name=row.table_name, + record_id=row.record_id, + farm_hub_id=row.farm_hub_id, + status=row.status, + created_at=row.created_at, + ) + for row in rows + ] + + +def get_conflict_detail(conflict_id: str, enterprise_id: str) -> ConflictDetail: + row = repo.get_conflict(conflict_id) + if not row or row.enterprise_id != enterprise_id: + raise SyncServiceError("CONFLICT_NOT_FOUND") + return ConflictDetail( + id=row.id, + table_name=row.table_name, + record_id=row.record_id, + farm_hub_id=row.farm_hub_id, + status=row.status, + created_at=row.created_at, + orchestrator_snapshot=json.loads(row.orchestrator_snapshot_json or "{}"), + hub_snapshot=json.loads(row.hub_snapshot_json or "{}"), + held_event_ids=json.loads(row.held_event_ids_json or "[]"), + ) + + +def resolve_conflict(conflict_id: str, enterprise_id: str, user_id: str, body: ResolveConflictRequest) -> None: + SyncEngine(enterprise_id, "orchestrator").resolve_conflict(conflict_id, user_id, body.resolution) + + +def get_sync_metrics(enterprise_id: str) -> dict: + from sqlalchemy import func, select + + from app.core.database import session_scope + from app.modules.sync.models import FarmHub, SyncConflict, SyncCursor, SyncEventLog, SyncOutbox + + with session_scope() as db: + hubs = list(db.scalars(select(FarmHub).where(FarmHub.enterprise_id == enterprise_id))) + outbox_pending = db.scalar( + select(func.count()) + .select_from(SyncOutbox) + .where(SyncOutbox.enterprise_id == enterprise_id, SyncOutbox.status == "pending") + ) + conflicts_pending = db.scalar( + select(func.count()) + .select_from(SyncConflict) + .where(SyncConflict.enterprise_id == enterprise_id, SyncConflict.status == "pending") + ) + max_seq = db.scalar( + select(func.max(SyncEventLog.seq)).where(SyncEventLog.enterprise_id == enterprise_id) + ) or 0 + hub_metrics = [] + for h in hubs: + cursor = db.scalar( + select(SyncCursor).where( + SyncCursor.farm_hub_id == h.id, + SyncCursor.direction == "inbound", + ) + ) + lag = max(0, int(max_seq) - int(cursor.last_acked_seq if cursor else 0)) + hub_metrics.append( + { + "farm_hub_id": h.id, + "name": h.name, + "hub_site_id": h.hub_site_id, + "status": h.status, + "last_seen": h.last_seen.isoformat() if h.last_seen else None, + "sync_lag": lag, + } + ) + return { + "hubs": hub_metrics, + "outbox_pending": int(outbox_pending or 0), + "conflicts_pending": int(conflicts_pending or 0), + "max_event_seq": int(max_seq), + } diff --git a/apps/api/app/modules/sync/tenant.py b/apps/api/app/modules/sync/tenant.py new file mode 100644 index 0000000..11cedbf --- /dev/null +++ b/apps/api/app/modules/sync/tenant.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from fastapi import Depends, Header, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials + +from app.core.dependencies import bearer, get_current_user +from app.core.security import decode_access_token +from app.modules.sync import repository as sync_repo +from app.modules.sync.schemas import HubAuthContext, TenantContext +from app.modules.users.models import User + + +@dataclass +class HubPrincipal: + farm_hub_id: str + hub_site_id: str + enterprise_id: str + + +def get_hub_auth(authorization: str | None = Header(default=None)) -> HubPrincipal: + if not authorization or not authorization.startswith("Hub "): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="HUB_UNAUTHORIZED") + token = authorization[4:].strip() + if ":" not in token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="HUB_UNAUTHORIZED") + hub_site_id, api_key = token.split(":", 1) + hub = sync_repo.verify_hub_credential(hub_site_id.strip(), api_key.strip()) + if not hub: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="HUB_UNAUTHORIZED") + return HubPrincipal(farm_hub_id=hub.id, hub_site_id=hub.hub_site_id, enterprise_id=hub.enterprise_id) + + +def get_tenant_context( + enterprise_id: str, + user: User = Depends(get_current_user), + credentials: HTTPAuthorizationCredentials | None = Depends(bearer), +) -> TenantContext: + if user.is_superuser: + return TenantContext( + enterprise_id=enterprise_id, + enterprise_role="admin", + user_id=user.id, + farm_hub_ids=None, + ) + if credentials: + try: + payload = decode_access_token(credentials.credentials) + if payload.get("enterprise_id") == enterprise_id: + farm_ids = payload.get("farm_ids") + return TenantContext( + enterprise_id=enterprise_id, + enterprise_role=payload.get("enterprise_role", "viewer"), # type: ignore[arg-type] + user_id=user.id, + farm_hub_ids=farm_ids, + ) + except Exception: + pass + member = sync_repo.get_member(user.id, enterprise_id) + if not member: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN") + farm_ids = None if member.role == "admin" else sync_repo.list_farm_access(user.id, enterprise_id) + return TenantContext( + enterprise_id=enterprise_id, + enterprise_role=member.role, # type: ignore[arg-type] + user_id=user.id, + farm_hub_ids=farm_ids, + ) + + +def require_farm_access( + farm_hub_id: str, + enterprise_id: str, + tenant: TenantContext, +) -> None: + if tenant.enterprise_role == "admin" or tenant.farm_hub_ids is None: + return + if farm_hub_id not in (tenant.farm_hub_ids or []): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="FARM_ACCESS_FORBIDDEN") + + +def require_enterprise_admin(tenant: TenantContext = Depends(get_tenant_context)) -> TenantContext: + if tenant.enterprise_role != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_ADMIN_ONLY") + return tenant + + +def require_enterprise_zootech(tenant: TenantContext = Depends(get_tenant_context)) -> TenantContext: + if tenant.enterprise_role not in ("admin", "zootech"): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_ZOOTECH_ONLY") + return tenant diff --git a/apps/api/app/modules/test/router.py b/apps/api/app/modules/test/router.py new file mode 100644 index 0000000..15448f8 --- /dev/null +++ b/apps/api/app/modules/test/router.py @@ -0,0 +1,16 @@ +from fastapi import APIRouter, HTTPException, Query + +from app.core.config import settings +from app.core.email import memory_mailer + +router = APIRouter() + + +@router.get("/emails/latest-token") +async def latest_email_token(to: str = Query(...), template: str = Query(...)): + if not settings.enable_test_routes or settings.email_delivery_mode != "memory": + raise HTTPException(status_code=404, detail="NOT_FOUND") + token = memory_mailer.latest_token(to, template) + if not token: + raise HTTPException(status_code=404, detail="TOKEN_NOT_FOUND") + return {"token": token} diff --git a/apps/api/app/modules/users/__init__.py b/apps/api/app/modules/users/__init__.py new file mode 100644 index 0000000..dbc6482 --- /dev/null +++ b/apps/api/app/modules/users/__init__.py @@ -0,0 +1 @@ +"""Users module.""" diff --git a/apps/api/app/modules/users/models.py b/apps/api/app/modules/users/models.py new file mode 100644 index 0000000..8e5d418 --- /dev/null +++ b/apps/api/app/modules/users/models.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import uuid4 + +from sqlalchemy import DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + role: Mapped[str] = mapped_column(String(16), nullable=False, default="user") + is_superuser: Mapped[bool] = mapped_column(nullable=False, default=False) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True) + failed_login_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) + + profile: Mapped["UserProfile"] = relationship( + back_populates="user", + uselist=False, + cascade="all, delete-orphan", + passive_deletes=True, + ) + + +class UserProfile(Base): + __tablename__ = "user_profiles" + + user_id: Mapped[str] = mapped_column( + String(36), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True + ) + display_name: Mapped[str] = mapped_column(String(120), nullable=False) + avatar_url: Mapped[str | None] = mapped_column(String(512), nullable=True) + metadata_json: Mapped[str | None] = mapped_column(Text, nullable=True) + + user: Mapped[User] = relationship(back_populates="profile") diff --git a/apps/api/app/modules/users/repository.py b/apps/api/app/modules/users/repository.py new file mode 100644 index 0000000..247a444 --- /dev/null +++ b/apps/api/app/modules/users/repository.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import uuid4 + +from sqlalchemy import func, select + +from app.core.database import session_scope +from app.modules.users.models import User, UserProfile + +ALLOWED_ROLES = {"user", "admin"} +ALLOWED_STATUSES = {"pending", "active", "blocked"} + + +def _detach(db, instance): + db.refresh(instance) + db.expunge(instance) + return instance + + +def create_user( + email: str, + password_hash: str, + role: str = "user", + is_superuser: bool = False, + status: str = "pending", +) -> User: + if role not in ALLOWED_ROLES: + raise ValueError("INVALID_ROLE") + if status not in ALLOWED_STATUSES: + raise ValueError("INVALID_STATUS") + if is_superuser and role != "admin": + raise ValueError("SUPERUSER_REQUIRES_ADMIN") + with session_scope() as db: + user = User( + id=str(uuid4()), + email=email.lower(), + password_hash=password_hash, + role=role, + is_superuser=is_superuser, + status=status, + ) + db.add(user) + db.flush() + profile = UserProfile(user_id=user.id, display_name=email.split("@")[0]) + db.add(profile) + db.flush() + return _detach(db, user) + + +def get_user_by_email(email: str) -> User | None: + with session_scope() as db: + user = db.scalar(select(User).where(User.email == email.lower())) + if not user: + return None + return _detach(db, user) + + +def get_user_by_id(user_id: str) -> User | None: + with session_scope() as db: + user = db.get(User, user_id) + if not user: + return None + return _detach(db, user) + + +def update_user(user: User) -> None: + if user.role not in ALLOWED_ROLES: + raise ValueError("INVALID_ROLE") + if user.status not in ALLOWED_STATUSES: + raise ValueError("INVALID_STATUS") + if user.is_superuser and user.role != "admin": + raise ValueError("SUPERUSER_REQUIRES_ADMIN") + with session_scope() as db: + db_user = db.get(User, user.id) + if not db_user: + return + db_user.email = user.email + db_user.password_hash = user.password_hash + db_user.role = user.role + db_user.is_superuser = user.is_superuser + db_user.status = user.status + db_user.failed_login_attempts = user.failed_login_attempts + db_user.locked_until = user.locked_until + db_user.email_verified_at = user.email_verified_at + db_user.updated_at = datetime.now(UTC) + + +def list_users(page: int, limit: int) -> tuple[list[User], int]: + with session_scope() as db: + total = db.scalar(select(func.count()).select_from(User)) or 0 + users = db.scalars( + select(User).order_by(User.created_at.desc()).offset((page - 1) * limit).limit(limit) + ).all() + return [_detach(db, user) for user in users], total + + +def count_users() -> int: + with session_scope() as db: + return db.scalar(select(func.count()).select_from(User)) or 0 + + +def count_users_registered_today() -> int: + today = datetime.now(UTC).date() + with session_scope() as db: + return ( + db.scalar( + select(func.count()) + .select_from(User) + .where(func.date(User.created_at) == today) + ) + or 0 + ) + + +def count_admins() -> int: + with session_scope() as db: + return db.scalar(select(func.count()).select_from(User).where(User.role == "admin")) or 0 + + +def count_superusers() -> int: + with session_scope() as db: + return ( + db.scalar( + select(func.count()) + .select_from(User) + .where(User.role == "admin", User.is_superuser.is_(True)) + ) + or 0 + ) + + +def delete_user(user_id: str) -> bool: + with session_scope() as db: + user = db.get(User, user_id) + if not user: + return False + db.delete(user) + return True + + +def get_profile(user_id: str) -> UserProfile: + with session_scope() as db: + profile = db.get(UserProfile, user_id) + if not profile: + raise KeyError(user_id) + return _detach(db, profile) + + +def update_profile( + user_id: str, + display_name: str | None = None, + avatar_url: str | None = None, +) -> UserProfile: + with session_scope() as db: + profile = db.get(UserProfile, user_id) + if not profile: + raise KeyError(user_id) + if display_name is not None: + profile.display_name = display_name + if avatar_url is not None: + profile.avatar_url = avatar_url + db.flush() + return _detach(db, profile) diff --git a/apps/api/app/modules/users/router.py b/apps/api/app/modules/users/router.py new file mode 100644 index 0000000..868260b --- /dev/null +++ b/apps/api/app/modules/users/router.py @@ -0,0 +1,59 @@ +from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile + +from app.core.dependencies import get_current_user +from app.core.media_signing import build_signed_media_url +from app.core.redis import check_rate_limit +from app.modules.media.service import AvatarValidationError +from app.modules.users.schemas import PasswordChangeIn, UserPatchIn +from app.modules.users.service import change_password, get_me, update_me, upload_avatar + +router = APIRouter() + + +def _profile_response(data: dict) -> dict: + return { + "user": { + "id": data["user"].id, + "email": data["user"].email, + "role": data["user"].role, + "status": data["user"].status, + }, + "profile": { + "display_name": data["profile"].display_name, + "avatar_url": build_signed_media_url(data["profile"].avatar_url), + }, + } + + +@router.get("/me") +async def me_route(current_user=Depends(get_current_user)): + return _profile_response(get_me(current_user)) + + +@router.patch("/me") +async def patch_me_route(payload: UserPatchIn, current_user=Depends(get_current_user)): + return _profile_response(update_me(current_user, payload.display_name)) + + +@router.post("/me/password") +async def change_password_route(payload: PasswordChangeIn, current_user=Depends(get_current_user)): + try: + change_password(current_user, payload.current_password, payload.new_password) + except ValueError: + raise HTTPException(status_code=400, detail="INVALID_CURRENT_PASSWORD") + return {"message": "password_changed"} + + +@router.post("/me/avatar") +async def upload_avatar_route( + request: Request, + file: UploadFile = File(...), + current_user=Depends(get_current_user), +): + check_rate_limit(f"avatar:{current_user.id}", limit=10, window_seconds=3600) + content = await file.read() + try: + data = upload_avatar(current_user, content) + except AvatarValidationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return _profile_response(data) diff --git a/apps/api/app/modules/users/schemas.py b/apps/api/app/modules/users/schemas.py new file mode 100644 index 0000000..a6f7138 --- /dev/null +++ b/apps/api/app/modules/users/schemas.py @@ -0,0 +1,34 @@ +from pydantic import BaseModel, EmailStr, Field, field_validator + +from app.core.password_policy import validate_password_strength + + +class UserOut(BaseModel): + id: str + email: EmailStr + role: str + status: str + + +class UserProfileOut(BaseModel): + display_name: str + avatar_url: str | None = None + + +class UserMeOut(BaseModel): + user: UserOut + profile: UserProfileOut + + +class UserPatchIn(BaseModel): + display_name: str | None = Field(default=None, min_length=1, max_length=120) + + +class PasswordChangeIn(BaseModel): + current_password: str + new_password: str = Field(min_length=8) + + @field_validator("new_password") + @classmethod + def password_policy(cls, value: str) -> str: + return validate_password_strength(value) diff --git a/apps/api/app/modules/users/service.py b/apps/api/app/modules/users/service.py new file mode 100644 index 0000000..1fc55e6 --- /dev/null +++ b/apps/api/app/modules/users/service.py @@ -0,0 +1,31 @@ +from app.core.jwt_denylist import bump_auth_epoch +from app.core.security import hash_password, verify_password +from app.modules.auth.service import revoke_user_refresh_family +from app.modules.media.service import upload_user_avatar +from app.modules.users import repository +from app.modules.users.models import User + + +def get_me(user: User) -> dict: + profile = repository.get_profile(user.id) + return {"user": user, "profile": profile} + + +def update_me(user: User, display_name: str | None) -> dict: + profile = repository.update_profile(user.id, display_name=display_name) + return {"user": user, "profile": profile} + + +def upload_avatar(user: User, content: bytes) -> dict: + avatar_url = upload_user_avatar(user.id, content) + profile = repository.update_profile(user.id, avatar_url=avatar_url) + return {"user": user, "profile": profile} + + +def change_password(user: User, current_password: str, new_password: str) -> None: + if not verify_password(current_password, user.password_hash): + raise ValueError("INVALID_CURRENT_PASSWORD") + user.password_hash = hash_password(new_password) + repository.update_user(user) + bump_auth_epoch(user.id) + revoke_user_refresh_family(user.id) diff --git a/apps/api/app/modules/zootech/__init__.py b/apps/api/app/modules/zootech/__init__.py new file mode 100644 index 0000000..98411d6 --- /dev/null +++ b/apps/api/app/modules/zootech/__init__.py @@ -0,0 +1 @@ +"""Zootech catalog API — WESP-compatible shape for copied static UI.""" diff --git a/apps/api/app/modules/zootech/catalog_apply.py b/apps/api/app/modules/zootech/catalog_apply.py new file mode 100644 index 0000000..e154abb --- /dev/null +++ b/apps/api/app/modules/zootech/catalog_apply.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import DateTime, select + +from app.core.database import session_scope +from app.modules.zootech.catalog_models import ( + ZootechDailyComponentNormAdjustment, + ZootechDailyIngredientReplacement, + ZootechDailyIngredientSkip, + ZootechDailyTripSkip, + ZootechDailyUnloadingGroupSkip, + ZootechFeedDispenser, + ZootechFeedingLocation, + ZootechFeedingPeriod, + ZootechFeedingPoint, + ZootechFeedMixer, + ZootechPeriodRecipe, + ZootechTrip, + ZootechUnloadingGroup, +) +from app.modules.zootech.models import ZootechComponent, ZootechIngredient, ZootechRecipe + +TABLE_MODEL_MAP = { + "component": ZootechComponent, + "recipe": ZootechRecipe, + "ingredient": ZootechIngredient, + "unloading_group": ZootechUnloadingGroup, + "feed_mixer": ZootechFeedMixer, + "feed_dispenser": ZootechFeedDispenser, + "feeding_location": ZootechFeedingLocation, + "feeding_period": ZootechFeedingPeriod, + "feeding_point": ZootechFeedingPoint, + "period_recipes": ZootechPeriodRecipe, + "daily_trip_skip": ZootechDailyTripSkip, + "daily_ingredient_skip": ZootechDailyIngredientSkip, + "daily_unloading_group_skip": ZootechDailyUnloadingGroupSkip, + "daily_ingredient_replacement": ZootechDailyIngredientReplacement, + "daily_component_norm_adjustment": ZootechDailyComponentNormAdjustment, + "trip": ZootechTrip, +} + + +def _coerce_for_model(model, key: str, value: Any) -> Any: + if value is None: + return value + column = model.__table__.columns.get(key) + if column is None: + return value + if isinstance(column.type, DateTime) and isinstance(value, str): + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return datetime.now(UTC) + return value + + +def _parse_period_recipe_id(record_id: str) -> tuple[str, str]: + if ":" in record_id: + period_id, recipe_id = record_id.split(":", 1) + return period_id, recipe_id + return record_id, record_id + + +def load_catalog_row(enterprise_id: str, table_name: str, record_id: str) -> dict[str, Any] | None: + model = TABLE_MODEL_MAP.get(table_name) + if not model: + return None + with session_scope() as db: + if model is ZootechPeriodRecipe: + period_id, recipe_id = _parse_period_recipe_id(record_id) + row = db.scalar( + select(model).where( + model.enterprise_id == enterprise_id, + model.period_id == period_id, + model.recipe_id == recipe_id, + ) + ) + else: + row = db.scalar( + select(model).where(model.enterprise_id == enterprise_id, model.id == record_id) + ) + if not row: + return None + return _row_to_dict(row, table_name) + + +def apply_catalog_change( + enterprise_id: str, + table_name: str, + record_id: str, + action: str, + payload: dict[str, Any], + version: int, + content_hash: str, +) -> None: + model = TABLE_MODEL_MAP.get(table_name) + if not model: + return + with session_scope() as db: + if model is ZootechPeriodRecipe: + period_id, recipe_id = _parse_period_recipe_id(record_id) + row = db.scalar( + select(model).where( + model.enterprise_id == enterprise_id, + model.period_id == period_id, + model.recipe_id == recipe_id, + ) + ) + else: + row = db.scalar( + select(model).where(model.enterprise_id == enterprise_id, model.id == record_id) + ) + if action == "delete": + if row: + row.is_deleted = True + row.version = version + row.content_hash = content_hash + row.updated_at = datetime.now(UTC) + return + data = dict(payload) + data["enterprise_id"] = enterprise_id + data["version"] = version + data["content_hash"] = content_hash + data["is_deleted"] = False + if model is ZootechPeriodRecipe: + data["period_id"] = data.get("period_id") or _parse_period_recipe_id(record_id)[0] + data["recipe_id"] = data.get("recipe_id") or _parse_period_recipe_id(record_id)[1] + else: + data["id"] = record_id + if row: + _apply_fields(row, data, model) + row.updated_at = datetime.now(UTC) + else: + allowed = {c.key for c in model.__table__.columns} + filtered = { + k: _coerce_for_model(model, k, v) for k, v in data.items() if k in allowed + } + extra = {k: v for k, v in data.items() if k not in allowed} + if extra and "payload_json" in allowed: + filtered["payload_json"] = json.dumps(extra, ensure_ascii=False) + db.add(model(**filtered)) + + +def _apply_fields(row, data: dict[str, Any], model) -> None: + allowed = {c.key for c in model.__table__.columns} + extra: dict[str, Any] = {} + for key, value in data.items(): + if key in allowed and key not in ("enterprise_id", "created_at"): + setattr(row, key, _coerce_for_model(model, key, value)) + elif key not in ("enterprise_id", "created_at", "id"): + extra[key] = value + if model is ZootechRecipe and "ingredients" in data: + row.payload_json = json.dumps(data.get("ingredients"), ensure_ascii=False) + elif extra and "payload_json" in allowed: + row.payload_json = json.dumps(extra, ensure_ascii=False) + + +def _row_to_dict(row, table_name: str) -> dict[str, Any]: + result = {} + for col in row.__table__.columns: + val = getattr(row, col.key) + if isinstance(val, datetime): + val = val.isoformat() + result[col.key] = val + if isinstance(row, ZootechRecipe) and row.payload_json: + try: + parsed = json.loads(row.payload_json) + if isinstance(parsed, list): + result["ingredients"] = parsed + except json.JSONDecodeError: + pass + if table_name == "period_recipes": + result["id"] = f"{row.period_id}:{row.recipe_id}" + elif hasattr(row, "payload_json") and row.payload_json and table_name != "recipe": + try: + result.update(json.loads(row.payload_json)) + except json.JSONDecodeError: + pass + return result diff --git a/apps/api/app/modules/zootech/catalog_models.py b/apps/api/app/modules/zootech/catalog_models.py new file mode 100644 index 0000000..fa08791 --- /dev/null +++ b/apps/api/app/modules/zootech/catalog_models.py @@ -0,0 +1,161 @@ +"""Additional zootech catalog tables (SERVER_MASTER_TABLES parity).""" + +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import uuid4 + +from sqlalchemy import Boolean, DateTime, Float, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class _CatalogMixin: + enterprise_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + content_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="") + is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + payload_json: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) + + +class ZootechUnloadingGroup(_CatalogMixin, Base): + __tablename__ = "zootech_unloading_group" + __table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_unloading_group_ent_id"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + name: Mapped[str] = mapped_column(String(100), nullable=False, default="") + recipe_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + + +class ZootechFeedMixer(_CatalogMixin, Base): + __tablename__ = "zootech_feed_mixer" + __table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_feed_mixer_ent_id"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + name: Mapped[str] = mapped_column(String(100), nullable=False, default="") + + +class ZootechFeedDispenser(_CatalogMixin, Base): + __tablename__ = "zootech_feed_dispenser" + __table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_feed_dispenser_ent_id"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + name: Mapped[str] = mapped_column(String(100), nullable=False, default="") + + +class ZootechFeedingLocation(_CatalogMixin, Base): + __tablename__ = "zootech_feeding_location" + __table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_feeding_location_ent_id"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + name: Mapped[str] = mapped_column(String(100), nullable=False, default="") + + +class ZootechFeedingPeriod(_CatalogMixin, Base): + __tablename__ = "zootech_feeding_period" + __table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_feeding_period_ent_id"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + name: Mapped[str] = mapped_column(String(100), nullable=False, default="") + dispenser_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + + +class ZootechFeedingPoint(_CatalogMixin, Base): + __tablename__ = "zootech_feeding_point" + __table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_feeding_point_ent_id"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + name: Mapped[str] = mapped_column(String(100), nullable=False, default="") + period_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + + +class ZootechPeriodRecipe(_CatalogMixin, Base): + __tablename__ = "zootech_period_recipes" + __table_args__ = ( + UniqueConstraint("enterprise_id", "period_id", "recipe_id", name="uq_zootech_period_recipes_ent"), + ) + + period_id: Mapped[str] = mapped_column(String(36), primary_key=True) + recipe_id: Mapped[str] = mapped_column(String(36), primary_key=True) + order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + +class ZootechTrip(_CatalogMixin, Base): + __tablename__ = "zootech_trip" + __table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_trip_ent_id"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + mixer_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + recipe_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending") + + +class ZootechDailyTripSkip(_CatalogMixin, Base): + __tablename__ = "zootech_daily_trip_skip" + __table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_daily_trip_skip_ent_id"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + + +class ZootechDailyIngredientSkip(_CatalogMixin, Base): + __tablename__ = "zootech_daily_ingredient_skip" + __table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_daily_ingredient_skip_ent_id"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + + +class ZootechDailyUnloadingGroupSkip(_CatalogMixin, Base): + __tablename__ = "zootech_daily_unloading_group_skip" + __table_args__ = ( + UniqueConstraint("enterprise_id", "id", name="uq_zootech_daily_unloading_group_skip_ent_id"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + + +class ZootechDailyIngredientReplacement(_CatalogMixin, Base): + __tablename__ = "zootech_daily_ingredient_replacement" + __table_args__ = ( + UniqueConstraint("enterprise_id", "id", name="uq_zootech_daily_ingredient_replacement_ent_id"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + + +class ZootechDailyComponentNormAdjustment(_CatalogMixin, Base): + __tablename__ = "zootech_daily_component_norm_adjustment" + __table_args__ = ( + UniqueConstraint("enterprise_id", "id", name="uq_zootech_daily_component_norm_adjustment_ent_id"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + + +CATALOG_TABLE_ORDER = [ + "component", + "recipe", + "ingredient", + "unloading_group", + "feed_mixer", + "feed_dispenser", + "feeding_location", + "feeding_period", + "feeding_point", + "period_recipes", + "daily_trip_skip", + "daily_ingredient_skip", + "daily_unloading_group_skip", + "daily_ingredient_replacement", + "daily_component_norm_adjustment", + "trip", +] diff --git a/apps/api/app/modules/zootech/models.py b/apps/api/app/modules/zootech/models.py new file mode 100644 index 0000000..eb5ef5e --- /dev/null +++ b/apps/api/app/modules/zootech/models.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import uuid4 + +from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class ZootechComponent(Base): + __tablename__ = "zootech_component" + __table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_component_ent_id"),) + + enterprise_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + name: Mapped[str] = mapped_column(String(100), nullable=False) + type: Mapped[str] = mapped_column(String(100), nullable=False, default="") + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + dry_matter: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) + protein: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) + energy: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) + price: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) + external_no: Mapped[int | None] = mapped_column(Integer, nullable=True) + version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + content_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="") + is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) + + +class ZootechRecipe(Base): + __tablename__ = "zootech_recipe" + __table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_recipe_ent_id"),) + + enterprise_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + name: Mapped[str] = mapped_column(String(100), nullable=False) + heads_per_trip: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + mixing_time: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + trip_percent: Mapped[float] = mapped_column(Float, nullable=False, default=100.0) + dry_matter_locked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + unloading_link_broken: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + target_component_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + ration_type: Mapped[str | None] = mapped_column(String(10), nullable=True) + version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + content_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="") + is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + payload_json: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) + + +class ZootechIngredient(Base): + __tablename__ = "zootech_ingredient" + __table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_ingredient_ent_id"),) + + enterprise_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + recipe_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + component_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + name: Mapped[str] = mapped_column(String(100), nullable=False) + amount: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) + weight_per_head: Mapped[float | None] = mapped_column(Float, nullable=True) + dry_matter: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) + dry_matter_per_head: Mapped[float | None] = mapped_column(Float, nullable=True) + order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + content_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="") + is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) diff --git a/apps/api/app/modules/zootech/orchestrator_system_metrics.py b/apps/api/app/modules/zootech/orchestrator_system_metrics.py new file mode 100644 index 0000000..0890033 --- /dev/null +++ b/apps/api/app/modules/zootech/orchestrator_system_metrics.py @@ -0,0 +1,135 @@ +"""Orchestrator system metrics in WESP admin-panel shape.""" + +from __future__ import annotations + +import os +import time +from typing import Any + +_NET_STATE: dict[str, float | int] | None = None + + +def _human_uptime(seconds: int | None) -> str | None: + if seconds is None: + return None + sec = max(0, int(seconds)) + days, rem = divmod(sec, 86400) + hours, rem = divmod(rem, 3600) + minutes, _ = divmod(rem, 60) + parts: list[str] = [] + if days: + parts.append(f"{days}d") + if hours or days: + parts.append(f"{hours}h") + parts.append(f"{minutes}m") + return " ".join(parts) + + +def collect_orchestrator_system_metrics() -> dict[str, Any]: + try: + import psutil # type: ignore[import-untyped] + except ImportError: + return { + "available": False, + "message": "Установите пакет psutil (pip install psutil) для метрик CPU/RAM/диска.", + } + + global _NET_STATE + now = time.time() + boot_t = float(psutil.boot_time()) + host_uptime_sec = int(max(0.0, now - boot_t)) + + proc_uptime_sec = None + try: + proc = psutil.Process() + proc_uptime_sec = int(max(0.0, now - float(proc.create_time()))) + except Exception: + pass + + vm = psutil.virtual_memory() + sw = psutil.swap_memory() + + disk_block: dict[str, Any] | None + try: + du = psutil.disk_usage("/") + disk_block = { + "path": "/", + "used": int(du.used), + "total": int(du.total), + "percent": round(du.percent, 2), + } + except OSError: + disk_block = None + + load_avg = None + try: + load_avg = [round(x, 2) for x in os.getloadavg()] + except (OSError, AttributeError): + pass + + net = psutil.net_io_counters() + upload_bps = 0.0 + download_bps = 0.0 + if _NET_STATE is not None and now > float(_NET_STATE["t"]): + dt = now - float(_NET_STATE["t"]) + if dt > 0: + upload_bps = max(0.0, (int(net.bytes_sent) - int(_NET_STATE["sent"])) / dt) + download_bps = max(0.0, (int(net.bytes_recv) - int(_NET_STATE["recv"])) / dt) + _NET_STATE = {"t": now, "sent": int(net.bytes_sent), "recv": int(net.bytes_recv)} + + conn_counts: dict[str, Any] = {"tcp": "—", "udp": "—"} + try: + conn_counts = { + "tcp": len(psutil.net_connections(kind="tcp")), + "udp": len(psutil.net_connections(kind="udp")), + } + except Exception: + pass + + addresses: list[dict[str, Any]] = [] + try: + for name, addrs in psutil.net_if_addrs().items(): + for addr in addrs: + if getattr(addr, "family", None) and str(getattr(addr, "address", "")).strip(): + addresses.append({"iface": name, "address": addr.address}) + except Exception: + pass + + return { + "available": True, + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(int(now))), + "host": { + "boot_time_unix": int(boot_t), + "uptime_seconds": host_uptime_sec, + "uptime_human": _human_uptime(host_uptime_sec), + }, + "process": { + "pid": os.getpid(), + "uptime_seconds": proc_uptime_sec, + "uptime_human": _human_uptime(proc_uptime_sec) if proc_uptime_sec is not None else None, + }, + "cpu": { + "percent": round(float(psutil.cpu_percent(interval=0.1)), 2), + "cores": int(psutil.cpu_count(logical=True) or 1), + }, + "memory": { + "used": int(vm.used), + "total": int(vm.total), + "percent": round(vm.percent, 2), + }, + "swap": { + "used": int(sw.used), + "total": int(sw.total), + "percent": round(sw.percent, 2) if sw.total else 0.0, + }, + "disk": disk_block, + "network": { + "upload_bps": round(upload_bps, 2), + "download_bps": round(download_bps, 2), + "bytes_sent_total": int(net.bytes_sent), + "bytes_recv_total": int(net.bytes_recv), + }, + "load_avg": load_avg, + "connections": conn_counts, + "addresses": addresses, + } diff --git a/apps/api/app/modules/zootech/recipe_calculator.py b/apps/api/app/modules/zootech/recipe_calculator.py new file mode 100644 index 0000000..fb01727 --- /dev/null +++ b/apps/api/app/modules/zootech/recipe_calculator.py @@ -0,0 +1,372 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Модуль для расчетов рецептов кормления. + +Перенесен из корневого recipe_calculator.py в пакет app.services +без изменения алгоритмов. +""" + +from typing import List, Dict, Any, Optional, Tuple +import os +import json +import logging + + +logger = logging.getLogger(__name__) + + +def _calc_debug_enabled() -> bool: + """ + Включает подробный трейс расчетов только по флагу окружения. + Удобно для диагностики: set CALC_DEBUG=1 + """ + return str(os.getenv("CALC_DEBUG", "")).strip().lower() in { + "1", + "true", + "yes", + "on", + } + + +def _calc_log(msg: str, **data) -> None: + if not _calc_debug_enabled(): + return + try: + if data: + logger.info( + "[CALC] %s | %s", + msg, + json.dumps(data, ensure_ascii=False, default=str), + ) + else: + logger.info("[CALC] %s", msg) + except Exception: + # никогда не ломаем расчет из-за логов + pass + + +def round_to_step5(value: float) -> float: + """ + Округляет значение до кратного 5 кг + """ + return round(value / 5) * 5 + + +def calculate_ingredients( + ingredients: List[Dict[str, Any]], + heads_count: int, + trip_percent: float, + component_dry_matter_map: Optional[Dict[str, float]] = None, +) -> List[Dict[str, float]]: + """ + Рассчитывает веса и сухое вещество для ингредиентов + """ + calculated_ingredients = [] + + _calc_log( + "calculate_ingredients:start", + heads_count=heads_count, + trip_percent=trip_percent, + ingredient_count=len(ingredients), + ) + + for idx, ing in enumerate(ingredients, 1): + weight_per_head_raw = ing.get("weightPerHead", 0) + weight_per_head = float(weight_per_head_raw) + dry_matter_percent = float(ing.get("dryMatter", 0)) + component_id = ing.get("component_id") + _calc_log( + "calculate_ingredients:weightPerHead:input", + idx=idx, + component_id=component_id, + weightPerHead_raw=weight_per_head_raw, + weightPerHead_raw_type=type(weight_per_head_raw).__name__, + weightPerHead_float=weight_per_head, + ) + + # Получаем dry_matter из компонента, если не указан и есть component_id + dm_source = "payload" + if dry_matter_percent == 0 and component_id and component_dry_matter_map: + if component_id in component_dry_matter_map: + dry_matter_percent = component_dry_matter_map[component_id] + dm_source = "component_map" + + # Расчеты + weight = weight_per_head * heads_count + trip_weight = weight * (trip_percent / 100) + dry_matter_per_head = weight_per_head * (dry_matter_percent / 100) + + if 0 < weight_per_head < 0.01: + rounded_wph = round(weight_per_head, 3) + else: + rounded_wph = round(weight_per_head, 2) + + if 0 < dry_matter_per_head < 0.01: + rounded_dm_per_head = round(dry_matter_per_head, 4) + else: + rounded_dm_per_head = round(dry_matter_per_head, 4) + + _calc_log( + "ingredient:calc", + idx=idx, + component_id=component_id, + dm_source=dm_source, + input={ + "weightPerHead": weight_per_head, + "dryMatterPercent": dry_matter_percent, + "headsCount": heads_count, + "tripPercent": trip_percent, + }, + formula={ + "totalWeight": "weightPerHead * headsCount", + "tripWeight": "totalWeight * (tripPercent/100)", + "dryMatterPerHead": "weightPerHead * (dryMatterPercent/100)", + }, + result={ + "totalWeight": round(weight, 2), + "tripWeight": round(trip_weight, 2), + "dryMatterPerHead": rounded_dm_per_head, + "weightPerHead": rounded_wph, + }, + ) + _calc_log( + "calculate_ingredients:weightPerHead:output", + idx=idx, + component_id=component_id, + weightPerHead_before_round=weight_per_head, + rounded_wph=rounded_wph, + rounded_wph_type=type(rounded_wph).__name__, + ) + + calculated_ingredients.append( + { + "totalWeight": round(weight, 2), + "tripWeight": round(trip_weight, 2), + "dryMatterPerHead": rounded_dm_per_head, + "weightPerHead": rounded_wph, + } + ) + + _calc_log("calculate_ingredients:end", ingredient_count=len(calculated_ingredients)) + return calculated_ingredients + + +def calculate_totals( + calculated_ingredients: List[Dict[str, float]], + ingredients: List[Dict[str, Any]], +) -> Dict[str, float]: + """ + Рассчитывает общие итоги по ингредиентам + """ + total_weight = 0.0 + total_trip_weight = 0.0 + total_dry_matter_per_head = 0.0 + total_weight_per_head = 0.0 + + _calc_log( + "calculate_totals:start", ingredient_count=len(calculated_ingredients) + ) + + for i, calc in enumerate(calculated_ingredients): + total_weight += calc["totalWeight"] + total_trip_weight += calc["tripWeight"] + total_dry_matter_per_head += calc["dryMatterPerHead"] + + if "weightPerHead" in calc: + total_weight_per_head += calc["weightPerHead"] + elif i < len(ingredients): + total_weight_per_head += float(ingredients[i].get("weightPerHead", 0)) + + totals = { + "totalWeight": round(total_weight, 2), + "totalTripWeight": round(total_trip_weight, 2), + "totalDryMatterPerHead": round(total_dry_matter_per_head, 2), + "totalWeightPerHead": round(total_weight_per_head, 2), + } + + _calc_log("calculate_totals:end", totals=totals) + return totals + + +def calculate_unloading_groups( + unloading_groups: List[Dict[str, Any]], + total_trip_weight: float, + heads_count: int, +) -> Tuple[List[Dict[str, float]], Dict[str, Any]]: + """ + Рассчитывает веса для групп выгрузки. + """ + calculated_groups: List[Dict[str, float]] = [] + total_percent = 0.0 + total_heads = 0.0 + total_weight_kg = 0.0 + + for group in unloading_groups: + group_type = group.get("distributionType", "percent") + value = float(group.get("value", 0)) + + calculated_weight = 0.0 + if group_type == "percent" and value > 0: + calculated_weight = (total_trip_weight * value) / 100.0 + elif group_type == "heads" and value > 0 and heads_count > 0: + calculated_weight = (total_trip_weight / heads_count) * value + + calculated_weight_rounded = round_to_step5(calculated_weight) + + calculated_groups.append({"calculatedWeight": calculated_weight_rounded}) + + if group_type == "percent": + total_percent += value + else: + total_heads += value + + total_weight_kg += calculated_weight + + unloading_totals = { + "totalPercent": round(total_percent, 1), + "totalHeads": int(total_heads), + "totalWeightKg": round_to_step5(total_weight_kg), + } + + return calculated_groups, unloading_totals + + +def calculate_recipe( + ingredients: List[Dict[str, Any]], + heads_count: int, + trip_percent: float, + unloading_groups: Optional[List[Dict[str, Any]]] = None, + component_dry_matter_map: Optional[Dict[str, float]] = None, + calculate_from_dry_matter: bool = False, +) -> Dict[str, Any]: + """ + Главная функция для расчета всего рецепта. + """ + _calc_log( + "calculate_recipe:start", + heads_count=heads_count, + trip_percent=trip_percent, + calculate_from_dry_matter=calculate_from_dry_matter, + ingredient_count=len(ingredients or []), + unloading_group_count=len(unloading_groups or []), + ) + + if unloading_groups is None: + unloading_groups = [] + + if calculate_from_dry_matter: + calculated_ingredients = calculate_ingredients_from_dry_matter( + ingredients, heads_count, trip_percent, component_dry_matter_map + ) + else: + calculated_ingredients = calculate_ingredients( + ingredients, heads_count, trip_percent, component_dry_matter_map + ) + + totals = calculate_totals(calculated_ingredients, ingredients) + + calculated_groups, unloading_totals = calculate_unloading_groups( + unloading_groups, totals["totalTripWeight"], heads_count + ) + + result: Dict[str, Any] = { + "ingredients": calculated_ingredients, + "totals": totals, + "unloadingGroups": calculated_groups, + "unloadingTotals": unloading_totals, + } + + _calc_log("calculate_recipe:end", totals=totals, unloadingTotals=unloading_totals) + return result + + +def calculate_ingredients_from_dry_matter( + ingredients: List[Dict[str, Any]], + heads_count: int, + trip_percent: float, + component_dry_matter_map: Optional[Dict[str, float]] = None, +) -> List[Dict[str, float]]: + """ + Рассчитывает веса от сухого вещества (обратный расчет). + """ + calculated_ingredients: List[Dict[str, float]] = [] + + _calc_log( + "calculate_ingredients_from_dry_matter:start", + heads_count=heads_count, + trip_percent=trip_percent, + ingredient_count=len(ingredients), + ) + + for idx, ing in enumerate(ingredients, 1): + dry_matter_per_head_raw = ing.get("dryMatterPerHead", 0) + dry_matter_per_head = float(dry_matter_per_head_raw) + dry_matter_percent = float(ing.get("dryMatter", 0)) + component_id = ing.get("component_id") + _calc_log( + "calculate_ingredients_from_dry_matter:input", + idx=idx, + component_id=component_id, + dryMatterPerHead_raw=dry_matter_per_head_raw, + dryMatterPercent=dry_matter_percent, + ) + + dm_source = "payload" + if dry_matter_percent == 0 and component_id and component_dry_matter_map: + if component_id in component_dry_matter_map: + dry_matter_percent = component_dry_matter_map[component_id] + dm_source = "component_map" + + EPSILON = 1e-6 + + if dry_matter_percent > EPSILON: + weight_per_head = (dry_matter_per_head * 100.0) / dry_matter_percent + + if weight_per_head < 0.01 and dry_matter_per_head >= 0.001: + weight_per_head = 0.01 + else: + if dry_matter_per_head > EPSILON: + _calc_log( + "ingredient:warning_zero_dm_percent", + idx=idx, + component_id=component_id, + dry_matter_per_head=dry_matter_per_head, + message=( + "dry_matter_percent равен 0, но dry_matter_per_head > 0 - " + "веса обнулены" + ), + ) + weight_per_head = 0.0 + + weight = weight_per_head * heads_count + trip_weight = weight * (trip_percent / 100.0) + + _calc_log( + "ingredient:inverse_calc", + idx=idx, + component_id=component_id, + dm_source=dm_source, + input={ + "dryMatterPerHead": dry_matter_per_head, + "dryMatterPercent": dry_matter_percent, + "headsCount": heads_count, + "tripPercent": trip_percent, + }, + ) + + calculated_ingredients.append( + { + "totalWeight": round(weight, 2), + "tripWeight": round(trip_weight, 2), + "weightPerHead": round(weight_per_head, 2), + "dryMatterPerHead": round(dry_matter_per_head, 4), + } + ) + + _calc_log( + "calculate_ingredients_from_dry_matter:end", + ingredient_count=len(calculated_ingredients), + ) + return calculated_ingredients + diff --git a/apps/api/app/modules/zootech/report_apply.py b/apps/api/app/modules/zootech/report_apply.py new file mode 100644 index 0000000..0f536b9 --- /dev/null +++ b/apps/api/app/modules/zootech/report_apply.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import select + +from app.core.database import session_scope +from app.modules.zootech.report_models import ZootechFeedAlert, ZootechLoadingReport, ZootechUnloadingReport + +REPORT_TABLE_MAP = { + "loading_report": ZootechLoadingReport, + "unloading_report": ZootechUnloadingReport, + "feed_alert": ZootechFeedAlert, +} + + +def _write_payload_json(row: Any, extra: dict[str, Any]) -> None: + if not extra: + return + row.payload_json = json.dumps(extra, ensure_ascii=False) + + +def repair_report_payloads_from_event_log(enterprise_id: str) -> int: + """Re-apply latest sync event payload per report row (one-time repair helper).""" + from app.modules.sync.models import SyncEventLog + + repaired = 0 + events: list[tuple[str, str, str, int, str, str | None]] = [] + with session_scope() as db: + rows = list( + db.scalars( + select(SyncEventLog) + .where( + SyncEventLog.enterprise_id == enterprise_id, + SyncEventLog.table_name.in_(("loading_report", "unloading_report", "feed_alert")), + ) + .order_by(SyncEventLog.record_id.asc(), SyncEventLog.received_at.desc()) + ) + ) + latest_by_record: dict[tuple[str, str], SyncEventLog] = {} + for row in rows: + key = (row.table_name, row.record_id) + if key not in latest_by_record: + latest_by_record[key] = row + for (table_name, record_id), event in latest_by_record.items(): + events.append( + ( + table_name, + record_id, + event.payload_json or "{}", + int(event.version or 1), + str(event.content_hash or ""), + event.origin_site_id, + ) + ) + + for table_name, record_id, payload_json, version, content_hash, origin_site_id in events: + try: + payload = json.loads(payload_json) + except json.JSONDecodeError: + continue + if not isinstance(payload, dict): + continue + apply_report_change( + enterprise_id, + table_name, + record_id, + "upsert", + payload, + version, + content_hash, + farm_hub_id=origin_site_id, + ) + repaired += 1 + return repaired + + +def apply_report_change( + enterprise_id: str, + table_name: str, + record_id: str, + action: str, + payload: dict[str, Any], + version: int, + content_hash: str, + farm_hub_id: str | None = None, +) -> None: + model = REPORT_TABLE_MAP.get(table_name) + if not model: + return + with session_scope() as db: + row = db.scalar( + select(model).where(model.enterprise_id == enterprise_id, model.id == record_id) + ) + if action == "delete": + if row: + row.is_deleted = True + row.version = version + row.content_hash = content_hash + row.updated_at = datetime.now(UTC) + return + data = dict(payload) + data["id"] = record_id + data["enterprise_id"] = enterprise_id + data["farm_hub_id"] = farm_hub_id or data.get("farm_hub_id") + data["version"] = version + data["content_hash"] = content_hash + data["is_deleted"] = False + if table_name == "feed_alert": + if data.get("event_type") and not data.get("alert_type"): + data["alert_type"] = str(data["event_type"]) + if data.get("detail") and not data.get("message"): + data["message"] = str(data["detail"]) + if row: + allowed = {c.key for c in model.__table__.columns} + extra = {k: v for k, v in data.items() if k not in allowed} + for key, value in data.items(): + if key in allowed and key not in ("enterprise_id", "created_at"): + setattr(row, key, value) + if extra and "payload_json" in allowed: + _write_payload_json(row, extra) + # #region agent log + try: + import pathlib + _log_path = pathlib.Path("/Users/vlad/Documents/wesp new (1)/.cursor/debug-785e22.log") + _log_path.parent.mkdir(parents=True, exist_ok=True) + with _log_path.open("a", encoding="utf-8") as _lf: + _lf.write(json.dumps({"sessionId":"785e22","hypothesisId":"D","location":"report_apply.py:update","message":"report payload update","data":{"table":table_name,"record_id":record_id,"extra_keys":sorted(extra.keys()),"components_len":len(extra.get("components") or []) if isinstance(extra.get("components"), list) else None,"groups_len":len(extra.get("unloading_groups") or []) if isinstance(extra.get("unloading_groups"), list) else None},"timestamp":int(datetime.now(UTC).timestamp()*1000)}, ensure_ascii=False) + "\n") + except Exception: + pass + # #endregion + row.updated_at = datetime.now(UTC) + else: + allowed = {c.key for c in model.__table__.columns} + filtered = {k: v for k, v in data.items() if k in allowed} + extra = {k: v for k, v in data.items() if k not in allowed} + if extra and "payload_json" in allowed: + filtered["payload_json"] = json.dumps(extra, ensure_ascii=False) + db.add(model(**filtered)) + # #region agent log + try: + import pathlib + _log_path = pathlib.Path("/Users/vlad/Documents/wesp new (1)/.cursor/debug-785e22.log") + _log_path.parent.mkdir(parents=True, exist_ok=True) + with _log_path.open("a", encoding="utf-8") as _lf: + _lf.write(json.dumps({"sessionId":"785e22","hypothesisId":"D","location":"report_apply.py:insert","message":"report payload insert","data":{"table":table_name,"record_id":record_id,"components_len":len(extra.get("components") or []) if isinstance(extra.get("components"), list) else None},"timestamp":int(datetime.now(UTC).timestamp()*1000)}, ensure_ascii=False) + "\n") + except Exception: + pass + # #endregion diff --git a/apps/api/app/modules/zootech/report_models.py b/apps/api/app/modules/zootech/report_models.py new file mode 100644 index 0000000..317e1ef --- /dev/null +++ b/apps/api/app/modules/zootech/report_models.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import uuid4 + +from sqlalchemy import Boolean, DateTime, Float, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class ZootechLoadingReport(Base): + __tablename__ = "zootech_loading_report" + __table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_loading_report_ent_id"),) + + enterprise_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + farm_hub_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + trip_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending") + version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + content_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="") + payload_json: Mapped[str | None] = mapped_column(Text, nullable=True) + is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) + + +class ZootechUnloadingReport(Base): + __tablename__ = "zootech_unloading_report" + __table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_unloading_report_ent_id"),) + + enterprise_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + farm_hub_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + trip_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending") + version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + content_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="") + payload_json: Mapped[str | None] = mapped_column(Text, nullable=True) + is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) + + +class ZootechFeedAlert(Base): + __tablename__ = "zootech_feed_alert" + __table_args__ = (UniqueConstraint("enterprise_id", "id", name="uq_zootech_feed_alert_ent_id"),) + + enterprise_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + farm_hub_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + alert_type: Mapped[str] = mapped_column(String(64), nullable=False, default="") + severity: Mapped[str] = mapped_column(String(16), nullable=False, default="info") + message: Mapped[str] = mapped_column(Text, nullable=False, default="") + version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + content_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="") + payload_json: Mapped[str | None] = mapped_column(Text, nullable=True) + is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) diff --git a/apps/api/app/modules/zootech/router.py b/apps/api/app/modules/zootech/router.py new file mode 100644 index 0000000..1e5523f --- /dev/null +++ b/apps/api/app/modules/zootech/router.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import select + +from app.core.database import session_scope +from app.modules.sync.tenant import TenantContext, get_tenant_context, require_enterprise_zootech +from app.modules.zootech.catalog_apply import load_catalog_row +from app.modules.zootech import service as zootech_service +from app.modules.zootech.service import ZootechServiceError +from app.modules.zootech.models import ZootechComponent, ZootechRecipe + +router = APIRouter() + + +def _list_components(enterprise_id: str) -> list[dict]: + with session_scope() as db: + rows = list( + db.scalars( + select(ZootechComponent).where( + ZootechComponent.enterprise_id == enterprise_id, + ZootechComponent.is_deleted.is_(False), + ) + ) + ) + return [ + { + "id": row.id, + "name": row.name, + "type": row.type, + "is_active": row.is_active, + "dry_matter": row.dry_matter, + "protein": row.protein, + "energy": row.energy, + "price": row.price, + "external_no": row.external_no, + "version": row.version, + "content_hash": row.content_hash, + } + for row in rows + ] + + +def _list_recipes(enterprise_id: str) -> list[dict]: + with session_scope() as db: + rows = list( + db.scalars( + select(ZootechRecipe).where( + ZootechRecipe.enterprise_id == enterprise_id, + ZootechRecipe.is_deleted.is_(False), + ) + ) + ) + return [ + { + "id": row.id, + "name": row.name, + "heads_per_trip": row.heads_per_trip, + "mixing_time": row.mixing_time, + "trip_percent": row.trip_percent, + "dry_matter_locked": row.dry_matter_locked, + "version": row.version, + "content_hash": row.content_hash, + } + for row in rows + ] + + +@router.get("/components") +def list_components( + enterprise_id: str = Query(...), + tenant: TenantContext = Depends(require_enterprise_zootech), +): + if tenant.enterprise_id != enterprise_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN") + return {"components": _list_components(enterprise_id)} + + +@router.get("/components/{component_id}") +def get_component( + component_id: str, + enterprise_id: str = Query(...), + tenant: TenantContext = Depends(require_enterprise_zootech), +): + if tenant.enterprise_id != enterprise_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN") + row = load_catalog_row(enterprise_id, "component", component_id) + if not row: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND") + return row + + +@router.post("/components") +def create_component( + body: dict, + enterprise_id: str = Query(...), + tenant: TenantContext = Depends(require_enterprise_zootech), +): + if tenant.enterprise_id != enterprise_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN") + return zootech_service.upsert_component(enterprise_id, None, body) + + +@router.patch("/components/{component_id}") +def update_component( + component_id: str, + body: dict, + enterprise_id: str = Query(...), + tenant: TenantContext = Depends(require_enterprise_zootech), +): + if tenant.enterprise_id != enterprise_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN") + try: + return zootech_service.patch_component(enterprise_id, component_id, body) + except ZootechServiceError: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND") from None + + +@router.post("/recipes") +def create_recipe( + body: dict, + enterprise_id: str = Query(...), + tenant: TenantContext = Depends(require_enterprise_zootech), +): + if tenant.enterprise_id != enterprise_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN") + return zootech_service.upsert_recipe(enterprise_id, None, body) + + +@router.get("/recipes") +def list_recipes( + enterprise_id: str = Query(...), + tenant: TenantContext = Depends(require_enterprise_zootech), +): + if tenant.enterprise_id != enterprise_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN") + return {"recipes": _list_recipes(enterprise_id)} + + +@router.get("/recipes/{recipe_id}") +def get_recipe( + recipe_id: str, + enterprise_id: str = Query(...), + tenant: TenantContext = Depends(require_enterprise_zootech), +): + if tenant.enterprise_id != enterprise_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN") + row = load_catalog_row(enterprise_id, "recipe", recipe_id) + if not row: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND") + return row diff --git a/apps/api/app/modules/zootech/service.py b/apps/api/app/modules/zootech/service.py new file mode 100644 index 0000000..0a1a595 --- /dev/null +++ b/apps/api/app/modules/zootech/service.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import uuid4 + +from app.core.exceptions import DomainError +from app.modules.sync.engine import SyncEngine +from app.modules.sync.schemas import ChangeEventIn +from app.modules.zootech.catalog_apply import load_catalog_row +from app.modules.zootech.sync_content_hash import compute_content_hash + + +class ZootechServiceError(DomainError): + pass + + +def _emit_change(enterprise_id: str, table: str, record_id: str, payload: dict, version: int, content_hash: str) -> None: + event = ChangeEventIn( + event_id=str(uuid4()), + seq=None, + domain="global", + table=table, + record_id=record_id, + action="upsert", + version=version, + content_hash=content_hash, + payload=payload, + emitted_at=datetime.now(UTC), + origin_site_id=SyncEngine.ORCHESTRATOR_SITE_ID, + ) + SyncEngine(enterprise_id, SyncEngine.ORCHESTRATOR_SITE_ID).process_push([event], origin="orchestrator") + + +def upsert_component(enterprise_id: str, record_id: str | None, body: dict) -> dict: + rid = record_id or str(uuid4()) + existing = load_catalog_row(enterprise_id, "component", rid) + version = int(body.get("version") or (existing or {}).get("version") or 0) + 1 if existing else 1 + payload = { + "id": rid, + "name": body.get("name", (existing or {}).get("name", "")), + "type": body.get("type", (existing or {}).get("type", "")), + "is_active": body.get("is_active", (existing or {}).get("is_active", True)), + "dry_matter": float(body.get("dry_matter", (existing or {}).get("dry_matter", 0.0))), + "protein": float(body.get("protein", (existing or {}).get("protein", 0.0))), + "energy": float(body.get("energy", (existing or {}).get("energy", 0.0))), + "price": float(body.get("price", (existing or {}).get("price", 0.0))), + "external_no": body.get("external_no", (existing or {}).get("external_no")), + "version": version, + } + content_hash = compute_content_hash(payload) + payload["content_hash"] = content_hash + _emit_change(enterprise_id, "component", rid, payload, version, content_hash) + return load_catalog_row(enterprise_id, "component", rid) or payload + + +def patch_component(enterprise_id: str, record_id: str, body: dict) -> dict: + existing = load_catalog_row(enterprise_id, "component", record_id) + if not existing: + raise ZootechServiceError("NOT_FOUND") + merged = {**existing, **body} + return upsert_component(enterprise_id, record_id, merged) + + +def upsert_recipe(enterprise_id: str, record_id: str | None, body: dict) -> dict: + rid = record_id or str(uuid4()) + existing = load_catalog_row(enterprise_id, "recipe", rid) + version = int(body.get("version") or (existing or {}).get("version") or 0) + 1 if existing else 1 + payload = { + "id": rid, + "name": body.get("name", (existing or {}).get("name", "")), + "heads_per_trip": int(body.get("heads_per_trip", (existing or {}).get("heads_per_trip", 1))), + "mixing_time": int(body.get("mixing_time", (existing or {}).get("mixing_time", 0))), + "trip_percent": float(body.get("trip_percent", (existing or {}).get("trip_percent", 100.0))), + "dry_matter_locked": body.get("dry_matter_locked", (existing or {}).get("dry_matter_locked", False)), + "version": version, + } + if "ingredients" in body: + payload["ingredients"] = body["ingredients"] + elif existing and "ingredients" in existing: + payload["ingredients"] = existing["ingredients"] + content_hash = compute_content_hash(payload) + payload["content_hash"] = content_hash + _emit_change(enterprise_id, "recipe", rid, payload, version, content_hash) + return load_catalog_row(enterprise_id, "recipe", rid) or payload diff --git a/apps/api/app/modules/zootech/settings_models.py b/apps/api/app/modules/zootech/settings_models.py new file mode 100644 index 0000000..6333451 --- /dev/null +++ b/apps/api/app/modules/zootech/settings_models.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import uuid4 + +from sqlalchemy import DateTime, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class ZootechOrgSettings(Base): + __tablename__ = "zootech_org_settings" + __table_args__ = (UniqueConstraint("enterprise_id", name="uq_zootech_org_settings_enterprise"),) + + enterprise_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + payload_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + content_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="") + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC) + ) + + +class ZootechFeedQualitySettings(Base): + __tablename__ = "zootech_feed_quality_settings" + __table_args__ = (UniqueConstraint("enterprise_id", name="uq_zootech_feed_quality_settings_enterprise"),) + + enterprise_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4())) + payload_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + content_hash: Mapped[str] = mapped_column(String(64), nullable=False, default="") + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC) + ) diff --git a/apps/api/app/modules/zootech/sync_content_hash.py b/apps/api/app/modules/zootech/sync_content_hash.py new file mode 100644 index 0000000..e2e114f --- /dev/null +++ b/apps/api/app/modules/zootech/sync_content_hash.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import hashlib +import json +from typing import Any + +_EXCLUDED_FROM_HASH = frozenset( + { + "version", + "content_hash", + "sync_timestamp", + "sync_status", + "created_at", + "updated_at", + "created_by", + "updated_by", + "client_id", + "server_synced", + "is_deleted", + "deleted_at", + "deleted_by", + "enterprise_id", + } +) + + +def stable_payload_for_hash(data: dict[str, Any]) -> dict[str, Any]: + return {k: v for k, v in data.items() if k not in _EXCLUDED_FROM_HASH} + + +def compute_content_hash_hex(payload: dict[str, Any]) -> str: + raw = json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str).encode("utf-8") + return hashlib.sha256(raw).hexdigest() + + +def compute_content_hash(data: dict[str, Any]) -> str: + return compute_content_hash_hex(stable_payload_for_hash(data)) diff --git a/apps/api/app/modules/zootech/wesp_compat.py b/apps/api/app/modules/zootech/wesp_compat.py new file mode 100644 index 0000000..919ca8a --- /dev/null +++ b/apps/api/app/modules/zootech/wesp_compat.py @@ -0,0 +1,435 @@ +"""WESP-shaped HTTP API for copied static UI (orchestrator zootech catalog).""" + +from __future__ import annotations + +import json +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import select + +from app.core.database import session_scope +from app.modules.sync.tenant import TenantContext, require_enterprise_zootech +from app.modules.zootech.catalog_apply import load_catalog_row +from app.modules.zootech.catalog_models import ( + ZootechFeedDispenser, + ZootechFeedingPeriod, + ZootechPeriodRecipe, + ZootechUnloadingGroup, +) +from app.modules.zootech.models import ZootechComponent, ZootechIngredient, ZootechRecipe + +router = APIRouter() + + +def _require_ent(enterprise_id: str, tenant: TenantContext) -> None: + if tenant.enterprise_id != enterprise_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN") + + +def _component_wesp(row: ZootechComponent) -> dict[str, Any]: + return { + "id": row.id, + "name": row.name, + "type": row.type, + "is_active": row.is_active, + "dryMatter": row.dry_matter, + "protein": row.protein, + "energy": row.energy, + "price": row.price, + "externalNo": row.external_no, + "version": row.version, + "content_hash": row.content_hash, + } + + +def _recipe_short(row: ZootechRecipe) -> dict[str, Any]: + return { + "id": row.id, + "name": row.name, + "heads_count": row.heads_per_trip, + "mixing_time": row.mixing_time, + "trip_percent": row.trip_percent, + } + + +def _ingredient_wesp(row: ZootechIngredient, comp_name: str | None = None) -> dict[str, Any]: + wph = float(row.weight_per_head or 0) + dm_pct = float(row.dry_matter or 0) + dm_ph = float(row.dry_matter_per_head or 0) + if not dm_ph and wph > 0 and dm_pct > 0: + dm_ph = wph * (dm_pct / 100.0) + name = (row.name or "").strip() or (comp_name or "") or "—" + return { + "id": row.id, + "name": name, + "weightPerHead": wph, + "weight_per_head": wph, + "amount": float(row.amount or 0), + "dry_matter": dm_pct, + "dry_matter_per_head": dm_ph, + "order": int(row.order or 0), + "component_id": row.component_id, + "version": row.version, + } + + +def _unloading_group_wesp(row: ZootechUnloadingGroup) -> dict[str, Any]: + extra: dict[str, Any] = {} + if row.payload_json: + try: + extra = json.loads(row.payload_json) + except json.JSONDecodeError: + pass + distribution_type = str(extra.get("distribution_type") or extra.get("distributionType") or "percent") + value = float(extra.get("value") or 0) + weight = float(extra.get("weight") or 0) + order = int(extra.get("order") or 0) + base = { + "id": row.id, + "name": row.name, + "distributionType": distribution_type, + "distribution_type": distribution_type, + "value": value, + "weight": weight, + "order": order, + "version": row.version, + } + for key in ("created_at", "updated_at", "created_by", "updated_by"): + if key in extra: + base[key] = extra[key] + return base + + +def _serialize_recipe_wesp(db, enterprise_id: str, recipe: ZootechRecipe) -> dict[str, Any]: + ingredients = list( + db.scalars( + select(ZootechIngredient) + .where( + ZootechIngredient.enterprise_id == enterprise_id, + ZootechIngredient.recipe_id == recipe.id, + ZootechIngredient.is_deleted.is_(False), + ) + .order_by(ZootechIngredient.order.asc()) + ) + ) + comp_ids = [i.component_id for i in ingredients if i.component_id] + comp_names: dict[str, str] = {} + if comp_ids: + for comp in db.scalars( + select(ZootechComponent).where( + ZootechComponent.enterprise_id == enterprise_id, + ZootechComponent.id.in_(comp_ids), + ) + ): + comp_names[comp.id] = comp.name + + groups = list( + db.scalars( + select(ZootechUnloadingGroup) + .where( + ZootechUnloadingGroup.enterprise_id == enterprise_id, + ZootechUnloadingGroup.recipe_id == recipe.id, + ZootechUnloadingGroup.is_deleted.is_(False), + ) + ) + ) + groups.sort(key=lambda g: int((_unloading_group_wesp(g).get("order") or 0))) + + unloading_groups = [_unloading_group_wesp(g) for g in groups] + return { + "id": recipe.id, + "name": recipe.name, + "headsPerTrip": recipe.heads_per_trip, + "mixingTime": recipe.mixing_time, + "tripPercent": recipe.trip_percent, + "heads_count": recipe.heads_per_trip, + "mixing_time": recipe.mixing_time, + "trip_percent": recipe.trip_percent, + "dryMatterLocked": recipe.dry_matter_locked, + "dry_matter_locked": recipe.dry_matter_locked, + "unloading_link_broken": recipe.unloading_link_broken, + "unloadingLinkBroken": recipe.unloading_link_broken, + "target_component_id": recipe.target_component_id, + "version": recipe.version, + "ingredients": [_ingredient_wesp(i, comp_names.get(i.component_id or "")) for i in ingredients], + "unloadingGroups": unloading_groups, + "unloading_groups": unloading_groups, + } + + +def _dispenser_wesp(row: ZootechFeedDispenser, periods: list[dict] | None = None) -> dict[str, Any]: + extra: dict[str, Any] = {} + if row.payload_json: + try: + extra = json.loads(row.payload_json) + except json.JSONDecodeError: + pass + return { + "id": row.id, + "name": row.name, + "version": row.version, + "content_hash": row.content_hash, + "periods": periods or [], + "hasSkipToday": False, + **{k: v for k, v in extra.items() if k not in ("id", "name")}, + } + + +@router.get("/components/ping") +def components_ping(): + return {"status": "ok"} + + +@router.get("/components") +def wesp_list_components( + enterprise_id: str = Query(...), + tenant: TenantContext = Depends(require_enterprise_zootech), + limit: int = Query(1000), + offset: int = Query(0), +): + _require_ent(enterprise_id, tenant) + with session_scope() as db: + rows = list( + db.scalars( + select(ZootechComponent) + .where( + ZootechComponent.enterprise_id == enterprise_id, + ZootechComponent.is_deleted.is_(False), + ZootechComponent.is_active.is_(True), + ) + .offset(offset) + .limit(limit) + ) + ) + return [_component_wesp(r) for r in rows] + + +@router.get("/components/{component_id}") +def wesp_get_component( + component_id: str, + enterprise_id: str = Query(...), + tenant: TenantContext = Depends(require_enterprise_zootech), +): + _require_ent(enterprise_id, tenant) + row = load_catalog_row(enterprise_id, "component", component_id) + if not row: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND") + return { + "id": row["id"], + "name": row["name"], + "type": row.get("type", ""), + "is_active": row.get("is_active", True), + "dryMatter": row.get("dry_matter", 0), + "protein": row.get("protein", 0), + "energy": row.get("energy", 0), + "price": row.get("price", 0), + "externalNo": row.get("external_no"), + "version": row.get("version", 1), + "content_hash": row.get("content_hash", ""), + } + + +@router.get("/feed_dispensers/ping") +def feed_dispensers_ping(): + return {"status": "ok"} + + +@router.get("/feed_dispensers/names") +def feed_dispenser_names( + enterprise_id: str = Query(...), + tenant: TenantContext = Depends(require_enterprise_zootech), +): + _require_ent(enterprise_id, tenant) + with session_scope() as db: + rows = list( + db.scalars( + select(ZootechFeedDispenser) + .where( + ZootechFeedDispenser.enterprise_id == enterprise_id, + ZootechFeedDispenser.is_deleted.is_(False), + ) + .order_by(ZootechFeedDispenser.name.asc()) + ) + ) + seen: set[str] = set() + names: list[str] = [] + for r in rows: + if r.name and r.name not in seen: + seen.add(r.name) + names.append(r.name) + return names + + +@router.get("/feed_dispensers") +def list_feed_dispensers( + enterprise_id: str = Query(...), + tenant: TenantContext = Depends(require_enterprise_zootech), + limit: int = Query(100), + offset: int = Query(0), +): + _require_ent(enterprise_id, tenant) + with session_scope() as db: + dispensers = list( + db.scalars( + select(ZootechFeedDispenser) + .where( + ZootechFeedDispenser.enterprise_id == enterprise_id, + ZootechFeedDispenser.is_deleted.is_(False), + ) + .order_by(ZootechFeedDispenser.created_at.desc()) + .offset(offset) + .limit(limit) + ) + ) + result = [] + for d in dispensers: + periods = list( + db.scalars( + select(ZootechFeedingPeriod).where( + ZootechFeedingPeriod.enterprise_id == enterprise_id, + ZootechFeedingPeriod.dispenser_id == d.id, + ZootechFeedingPeriod.is_deleted.is_(False), + ) + ) + ) + period_payload = [{"id": p.id, "name": p.name} for p in periods] + result.append(_dispenser_wesp(d, period_payload)) + return result + + +@router.get("/feed_dispensers/{dispenser_id}/periods") +def dispenser_periods( + dispenser_id: str, + enterprise_id: str = Query(...), + tenant: TenantContext = Depends(require_enterprise_zootech), +): + _require_ent(enterprise_id, tenant) + with session_scope() as db: + periods = list( + db.scalars( + select(ZootechFeedingPeriod).where( + ZootechFeedingPeriod.enterprise_id == enterprise_id, + ZootechFeedingPeriod.dispenser_id == dispenser_id, + ZootechFeedingPeriod.is_deleted.is_(False), + ) + ) + ) + return [{"id": p.id, "name": p.name, "dispenser_id": p.dispenser_id} for p in periods] + + +def _recipes_for_period(db, enterprise_id: str, period_id: str) -> list[dict]: + links = list( + db.scalars( + select(ZootechPeriodRecipe) + .where( + ZootechPeriodRecipe.enterprise_id == enterprise_id, + ZootechPeriodRecipe.period_id == period_id, + ZootechPeriodRecipe.is_deleted.is_(False), + ) + .order_by(ZootechPeriodRecipe.order.asc()) + ) + ) + out: list[dict] = [] + for link in links: + recipe = db.scalar( + select(ZootechRecipe).where( + ZootechRecipe.enterprise_id == enterprise_id, + ZootechRecipe.id == link.recipe_id, + ZootechRecipe.is_deleted.is_(False), + ) + ) + if recipe: + out.append(_recipe_short(recipe)) + return out + + +@router.get("/feed_dispensers/{dispenser_id}/recipes") +def dispenser_recipes( + dispenser_id: str, + enterprise_id: str = Query(...), + tenant: TenantContext = Depends(require_enterprise_zootech), +): + _require_ent(enterprise_id, tenant) + with session_scope() as db: + periods = list( + db.scalars( + select(ZootechFeedingPeriod).where( + ZootechFeedingPeriod.enterprise_id == enterprise_id, + ZootechFeedingPeriod.dispenser_id == dispenser_id, + ZootechFeedingPeriod.is_deleted.is_(False), + ) + ) + ) + merged: dict[str, dict] = {} + for p in periods: + for r in _recipes_for_period(db, enterprise_id, p.id): + merged[r["id"]] = r + return list(merged.values()) + + +@router.get("/periods/{period_id}/recipes") +def period_recipes( + period_id: str, + enterprise_id: str = Query(...), + tenant: TenantContext = Depends(require_enterprise_zootech), +): + _require_ent(enterprise_id, tenant) + with session_scope() as db: + period = db.scalar( + select(ZootechFeedingPeriod).where( + ZootechFeedingPeriod.enterprise_id == enterprise_id, + ZootechFeedingPeriod.id == period_id, + ZootechFeedingPeriod.is_deleted.is_(False), + ) + ) + if not period: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND") + return _recipes_for_period(db, enterprise_id, period_id) + + +@router.get("/recipes/{recipe_id}") +def wesp_get_recipe( + recipe_id: str, + enterprise_id: str = Query(...), + tenant: TenantContext = Depends(require_enterprise_zootech), + date: str | None = Query(None), +): + _require_ent(enterprise_id, tenant) + with session_scope() as db: + recipe = db.scalar( + select(ZootechRecipe).where( + ZootechRecipe.enterprise_id == enterprise_id, + ZootechRecipe.id == recipe_id, + ZootechRecipe.is_deleted.is_(False), + ) + ) + if not recipe: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="NOT_FOUND") + return _serialize_recipe_wesp(db, enterprise_id, recipe) + + +@router.post("/recipes/calculate") +def wesp_calculate_recipe(body: dict): + from app.modules.zootech.wesp_recipe_write import RecipeWriteError, calculate_recipe_wesp, recipe_write_http_error + + try: + return calculate_recipe_wesp(body) + except RecipeWriteError as exc: + raise recipe_write_http_error(exc) from exc + + +@router.put("/recipes/{recipe_id}") +def wesp_update_recipe( + recipe_id: str, + body: dict, + enterprise_id: str = Query(...), + tenant: TenantContext = Depends(require_enterprise_zootech), +): + from app.modules.zootech.wesp_recipe_write import RecipeWriteError, recipe_write_http_error, update_recipe_wesp + + _require_ent(enterprise_id, tenant) + try: + return update_recipe_wesp(enterprise_id, recipe_id, body) + except RecipeWriteError as exc: + raise recipe_write_http_error(exc) from exc diff --git a/apps/api/app/modules/zootech/wesp_compat_admin.py b/apps/api/app/modules/zootech/wesp_compat_admin.py new file mode 100644 index 0000000..d8598a0 --- /dev/null +++ b/apps/api/app/modules/zootech/wesp_compat_admin.py @@ -0,0 +1,729 @@ +"""WESP-shaped admin endpoints for copied static UI (/api/admin/*).""" + +from __future__ import annotations + +import json +import time +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi.responses import FileResponse +from pydantic import BaseModel, Field +from sqlalchemy import func, select + +from app.core.config import settings +from app.core.server_logging import ensure_server_log_file +from app.core.database import session_scope +from app.core.dependencies import require_superuser +from app.core.audit_log import read_audit_events, write_audit_event +from app.modules.admin.service import ( + create_admin_user, + delete_admin_user, + get_diagnostics_report, + get_server_log_tail, + patch_user, + reset_user_password, +) +from app.modules.zootech.orchestrator_system_metrics import collect_orchestrator_system_metrics +from app.modules.sync.models import Enterprise, FarmHub, SyncConflict, SyncOutbox +from app.modules.sync.service import get_sync_metrics +from app.modules.users import repository as users_repository +from app.modules.users.models import User +from app.modules.zootech.catalog_models import ZootechFeedDispenser, ZootechFeedingPeriod +from app.modules.zootech.models import ZootechComponent, ZootechIngredient, ZootechRecipe + +router = APIRouter(prefix="/admin", tags=["zootech-wesp-admin"]) + + +def _wesp_error(message: str, code: int = 400) -> None: + raise HTTPException(status_code=code, detail={"status": "error", "message": message}) + + +def _count_zootech(model) -> int: + with session_scope() as db: + return ( + db.scalar(select(func.count()).select_from(model).where(model.is_deleted.is_(False))) or 0 + ) + + +def _sync_queue_stats() -> dict[str, int]: + with session_scope() as db: + total = db.scalar(select(func.count()).select_from(SyncOutbox)) or 0 + pending = ( + db.scalar( + select(func.count()).select_from(SyncOutbox).where(SyncOutbox.status == "pending") + ) + or 0 + ) + processing = ( + db.scalar( + select(func.count()) + .select_from(SyncOutbox) + .where(SyncOutbox.status == "processing") + ) + or 0 + ) + failed = ( + db.scalar( + select(func.count()).select_from(SyncOutbox).where(SyncOutbox.status == "failed") + ) + or 0 + ) + return { + "total": int(total), + "pending": int(pending), + "processing": int(processing), + "failed": int(failed), + } + + +def _user_to_wesp(user: User) -> dict[str, Any]: + return { + "id": user.id, + "login": user.email, + "is_superuser": bool(user.is_superuser), + "lab_access": False, + "created_at": user.created_at.isoformat() if user.created_at else None, + "updated_at": user.updated_at.isoformat() if user.updated_at else None, + } + + +class WespUiActivityIn(BaseModel): + text: str = Field(min_length=1) + level: str = "ok" + + +class WespUserCreateIn(BaseModel): + login: str = Field(min_length=1) + password: str = Field(min_length=8) + is_superuser: bool = False + + +class WespUserPatchIn(BaseModel): + password: str | None = None + is_superuser: bool | None = None + lab_access: bool | None = None + + +class WespNetworkPatchIn(BaseModel): + local_hostname: str | None = None + public_base_url: str | None = None + mdns_enabled: bool | None = None + + +class WespOrchestratorSyncPatchIn(BaseModel): + upstream_url: str | None = None + hub_site_id: str | None = None + api_key: str | None = None + + +@router.get("/users") +def wesp_list_users(_admin: User = Depends(require_superuser)): + users, _total = users_repository.list_users(page=1, limit=500) + payload = {"status": "success", "users": [_user_to_wesp(u) for u in users]} + return payload + + +@router.post("/users", status_code=201) +def wesp_create_user(payload: WespUserCreateIn, admin: User = Depends(require_superuser)): + email = payload.login.strip() + if "@" not in email: + _wesp_error("Используйте email в поле логина (admin@compton.example)") + try: + create_admin_user( + admin, + email=email, + password=payload.password, + role="admin" if payload.is_superuser else "user", + is_superuser=payload.is_superuser, + status="active", + ) + except ValueError as exc: + if str(exc) == "USER_EXISTS": + _wesp_error("Пользователь с таким логином уже существует", 409) + _wesp_error(str(exc)) + return {"status": "success"} + + +@router.patch("/users/{user_id}") +def wesp_patch_user(user_id: str, payload: WespUserPatchIn, admin: User = Depends(require_superuser)): + if payload.password: + try: + reset_user_password(admin, user_id, payload.password) + except ValueError as exc: + _wesp_error(str(exc), 404 if str(exc) == "USER_NOT_FOUND" else 400) + return {"status": "success"} + if payload.is_superuser is not None: + try: + patch_user(admin, user_id, None, None, payload.is_superuser) + except ValueError as exc: + _wesp_error(str(exc), 404 if str(exc) == "USER_NOT_FOUND" else 400) + return {"status": "success"} + if payload.lab_access is not None: + return {"status": "success"} + _wesp_error("Нет полей для обновления") + + +@router.delete("/users/{user_id}") +def wesp_delete_user(user_id: str, admin: User = Depends(require_superuser)): + try: + delete_admin_user(admin, user_id) + except ValueError as exc: + _wesp_error(str(exc), 404 if str(exc) == "USER_NOT_FOUND" else 400) + return {"status": "success"} + + +@router.get("/summary") +def wesp_admin_summary(_admin: User = Depends(require_superuser)): + queue = _sync_queue_stats() + with session_scope() as db: + enterprises = list(db.scalars(select(Enterprise).order_by(Enterprise.name))) + hub_count = db.scalar(select(func.count()).select_from(FarmHub)) or 0 + enterprise_ids = [e.id for e in enterprises] + + conflicts_pending = 0 + for enterprise_id in enterprise_ids: + conflicts_pending += int(get_sync_metrics(enterprise_id).get("conflicts_pending") or 0) + + payload = { + "status": "success", + "generated_at": datetime.now(UTC).isoformat(), + "app": { + "config_mode": settings.app_env, + "debug": settings.app_env != "production", + "version": "1.0.0-orchestrator", + "role": "server", + "first_launch_at": None, + "warranty_until": None, + "warranty_days_remaining": None, + }, + "databases": { + "recipes": {"engine": "postgresql", "path": "orchestrator"}, + "reports": {"engine": "postgresql", "path": "orchestrator"}, + }, + "machine": {"platform": "orchestrator"}, + "counts": { + "users": users_repository.count_users(), + "recipes": _count_zootech(ZootechRecipe), + "ingredients": _count_zootech(ZootechIngredient), + "components": _count_zootech(ZootechComponent), + "feed_dispensers": _count_zootech(ZootechFeedDispenser), + "feeding_periods": _count_zootech(ZootechFeedingPeriod), + }, + "sync": { + "client_role": "server", + "connection": { + "role": "server", + "server_url": settings.public_base_url.rstrip("/"), + "configured": True, + }, + "max_concurrent": 4, + "queue": queue, + "client_poll_interval_sec": 14, + "first_sync_banner": False, + "initial_sync_progress": {"active": False}, + "guarantees": { + "master_data": "Orchestrator — источник правды для предприятий и хабов.", + "reports": "Отчёты принимаются через sync API.", + }, + "state": {"hub_count": int(hub_count), "enterprise_count": len(enterprise_ids)}, + "queue_errors": [], + }, + "security": { + "mac_lock_enabled": False, + "calibration_public": False, + "device_token_configured": False, + "kiosk_enforce_paired_only": False, + }, + "auto_update": {"enabled": False, "note": "Обновления управляются деплоем orchestrator."}, + "hardware": {"simulation_mode": True, "note": "Железо недоступно на orchestrator."}, + } + return payload + + +@router.get("/system-metrics") +def wesp_system_metrics(_admin: User = Depends(require_superuser)): + metrics = collect_orchestrator_system_metrics() + return {"status": "success", "metrics": metrics} + + +@router.get("/factory-reset/preview") +def wesp_factory_reset_preview(_admin: User = Depends(require_superuser)): + return { + "status": "success", + "confirm_phrase": "СБРОС ДАННЫХ", + "preserved_note": "Сброс data/ недоступен на orchestrator — данные в PostgreSQL и S3.", + "files": [], + "directories": [], + } + + +@router.get("/network-settings") +def wesp_network_settings_get(_admin: User = Depends(require_superuser)): + public_url = settings.public_base_url.rstrip("/") + return { + "status": "success", + "network": { + "local_hostname": "orchestrator.local", + "default_local_hostname": "orchestrator.local", + "public_base_url": public_url, + "mdns_enabled": False, + "local_hostname_locked_by_env": True, + "public_base_url_locked_by_env": False, + "mdns_enabled_locked_by_env": True, + "detected": { + "listen_port": 5173, + "listen_host": "0.0.0.0", + "os_hostname": "orchestrator", + "mdns_available": False, + "mdns_active": False, + }, + "suggested_public_url": public_url, + "sync_url_by_ip": public_url, + }, + } + + +@router.patch("/network-settings") +def wesp_network_settings_patch(_payload: WespNetworkPatchIn, _admin: User = Depends(require_superuser)): + return {"status": "success", "message": "Сетевые настройки orchestrator задаются через PUBLIC_BASE_URL / nginx."} + + +@router.get("/peripheral-events") +def wesp_peripheral_events( + limit: int = Query(default=50, ge=1, le=200), + component: str | None = None, + exclude_component: str | None = None, + _admin: User = Depends(require_superuser), +): + _ = (limit, component, exclude_component) + return {"status": "success", "events": []} + + +@router.get("/sync-diagnostics") +def wesp_sync_diagnostics(_admin: User = Depends(require_superuser)): + queue = _sync_queue_stats() + with session_scope() as db: + enterprises = list(db.scalars(select(Enterprise).order_by(Enterprise.name))) + hubs = list(db.scalars(select(FarmHub).order_by(FarmHub.created_at.desc()))) + conflicts_total = db.scalar(select(func.count()).select_from(SyncConflict)) or 0 + conflicts_pending = ( + db.scalar( + select(func.count()) + .select_from(SyncConflict) + .where(SyncConflict.status == "pending") + ) + or 0 + ) + hub_rows = [ + { + "farm_hub_id": h.id, + "name": h.name, + "hub_site_id": h.hub_site_id, + "status": h.status, + "last_seen": h.last_seen.isoformat() if h.last_seen else None, + "enterprise_id": h.enterprise_id, + } + for h in hubs + ] + enterprise_rows = [{"id": e.id, "name": e.name, "slug": e.slug} for e in enterprises] + + for row in hub_rows: + row.update(get_sync_metrics(row.pop("enterprise_id"))) + + payload = { + "status": "success", + "role": "server", + "queues": { + "summary": queue, + "by_table": {}, + "stuck_processing": [], + "processing_missing_processed_at": [], + "failed_recent": [], + "high_retry_pending": [], + }, + "clients": {"recent_deliveries": []}, + "conflicts": { + "summary": {"total": int(conflicts_total), "pending": int(conflicts_pending)}, + "recent": [], + }, + "report_push": { + "scope": "orchestrator", + "note": "Push отчётов обрабатывается через sync engine.", + "total": 0, + }, + "engine_state": { + "enterprises": enterprise_rows, + "hubs": hub_rows, + }, + "initial_sync_progress": {"active": False}, + "actions_capabilities": { + "requeue_stuck": False, + "restart_local_sync": False, + "refresh_diagnostics": True, + "retry_report_push": False, + }, + } + return payload + + +@router.get("/orchestrator-sync") +def wesp_orchestrator_sync_get(_admin: User = Depends(require_superuser)): + with session_scope() as db: + hub = db.scalar(select(FarmHub).order_by(FarmHub.created_at.desc())) + hub_site_id = hub.hub_site_id if hub else "" + return { + "status": "success", + "orchestrator_sync": { + "upstream_url": settings.public_base_url.rstrip("/"), + "hub_site_id": hub_site_id, + }, + } + + +@router.patch("/orchestrator-sync") +def wesp_orchestrator_sync_patch(_payload: WespOrchestratorSyncPatchIn, _admin: User = Depends(require_superuser)): + return { + "status": "success", + "message": "Параметры hub↔orchestrator настраиваются через pairing, не из этой формы.", + } + + +@router.get("/activity-feed") +def wesp_activity_feed(limit: int = Query(default=200, ge=1, le=1000), _admin: User = Depends(require_superuser)): + entries: list[dict[str, Any]] = [] + for event in read_audit_events(limit=limit): + ts_raw = event.get("timestamp") + ts_ms = 0 + if ts_raw: + try: + ts_ms = int(datetime.fromisoformat(str(ts_raw).replace("Z", "+00:00")).timestamp() * 1000) + except ValueError: + ts_ms = int(time.time() * 1000) + details = event.get("details") or {} + text = str(details.get("text") or event.get("action") or "") + level = str(details.get("level") or "ok") + if level not in {"ok", "err", "warn"}: + level = "ok" + entries.append({"ts": ts_ms, "level": level, "text": text}) + return {"status": "success", "entries": entries, "meta": "orchestrator-audit"} + + +@router.post("/ui-activity") +def wesp_ui_activity(payload: WespUiActivityIn, admin: User = Depends(require_superuser)): + level = payload.level if payload.level in {"ok", "err", "warn"} else "ok" + write_audit_event( + action="ui.activity", + actor_user_id=admin.id, + actor_email=admin.email, + details={"text": payload.text, "level": level}, + ) + return {"status": "success"} + + +@router.get("/diagnostics/report") +def wesp_diagnostics_report(_admin: User = Depends(require_superuser)): + report = get_diagnostics_report() + return {"status": "success", **report} + + +def _security_settings_payload() -> dict[str, Any]: + return { + "mac_lock_enabled": False, + "mac_lock_enabled_locked_by_env": True, + "allowed_mac_addresses": "", + "allowed_mac_addresses_locked_by_env": True, + "kiosk_enforce_paired_only": False, + "kiosk_enforce_paired_only_locked_by_env": True, + "calibration_public": False, + "calibration_public_locked_by_env": True, + "device_token_configured": False, + "device_token_locked_by_env": True, + "device_token_header": "X-Device-Token", + "device_token_header_locked_by_env": True, + "kiosk_pair_token_ttl_seconds": 300, + "kiosk_pair_token_ttl_seconds_locked_by_env": True, + "kiosk_auth_cookie_days": 18250, + "kiosk_auth_cookie_days_locked_by_env": True, + "session_remember_days": 7, + "session_remember_days_locked_by_env": True, + "llm_autostart": False, + "llm_autostart_locked_by_env": True, + "admin_llm_enabled": False, + "admin_llm_enabled_locked_by_env": True, + "llm_chat_db_context": False, + "llm_chat_db_context_locked_by_env": True, + "llm_tools_enabled": False, + "llm_tools_enabled_locked_by_env": True, + } + + +def _auto_update_payload() -> dict[str, Any]: + return { + "enabled": False, + "auto_install": False, + "gitea_url": "", + "gitea_owner": "", + "gitea_repo": "", + "repository_url": "", + "check_interval_sec": 3600, + "note": "Автообновление недоступно на orchestrator.", + } + + +def _hub_only_stub(message: str, **extra: Any) -> dict[str, Any]: + return {"status": "success", "available": False, "orchestrator": True, "message": message, **extra} + + +def _patch_ok(message: str, **extra: Any) -> dict[str, Any]: + return {"status": "success", "message": message, **extra} + + +@router.get("/hardware-status") +def wesp_hardware_status(_admin: User = Depends(require_superuser)): + return { + "status": "success", + "config": {"simulation_mode": True, "read_interval": 0.05, "samples_per_read": 3}, + "peripherals": {}, + "scales": {"available": False, "error": "Железо недоступно на orchestrator (Docker)."}, + } + + +@router.get("/hardware-metrics-history") +def wesp_hardware_metrics_history(_admin: User = Depends(require_superuser)): + return {"status": "success", "points": [], "path": None, "retention_days": 0} + + +@router.patch("/hardware/simulation") +def wesp_hardware_simulation_patch(_admin: User = Depends(require_superuser)): + return _patch_ok("Симуляция железа недоступна на orchestrator.") + + +@router.put("/hardware/simulation/weight") +@router.patch("/hardware/simulation/weight") +def wesp_hardware_simulation_weight(_admin: User = Depends(require_superuser)): + return _patch_ok("Симуляция весов недоступна на orchestrator.") + + +@router.get("/llm/status") +def wesp_llm_status(_admin: User = Depends(require_superuser)): + return { + "status": "success", + "enabled": False, + "llm_base_url": "", + "model": "", + "assistant_dir": "", + "llm_autostart": False, + "llm_autostart_locked_by_env": True, + "admin_llm_enabled_locked_by_env": True, + "llm_chat_db_context": False, + "llm_chat_db_context_locked_by_env": True, + "llm_tools_enabled": False, + "llm_tools_enabled_locked_by_env": True, + "llm_reachable": False, + "model_present": False, + "llm_error": "LLM недоступен на orchestrator.", + } + + +@router.get("/llm/activity") +def wesp_llm_activity(_admin: User = Depends(require_superuser)): + return {"status": "success", "entries": []} + + +@router.post("/llm/chat") +@router.post("/llm/ping") +@router.post("/llm/diagnostics") +@router.post("/llm/summary") +def wesp_llm_actions(_admin: User = Depends(require_superuser)): + return {"status": "error", "message": "LLM недоступен на orchestrator."} + + +@router.patch("/security-settings") +def wesp_security_settings_patch(_admin: User = Depends(require_superuser)): + return _patch_ok( + "Настройки безопасности hub недоступны на orchestrator.", + security=_security_settings_payload(), + ) + + +@router.patch("/auto-update-settings") +def wesp_auto_update_settings_patch(_admin: User = Depends(require_superuser)): + return _patch_ok( + "Автообновление недоступно на orchestrator.", + auto_update=_auto_update_payload(), + ) + + +@router.patch("/gitea-secrets") +def wesp_gitea_secrets_patch(_admin: User = Depends(require_superuser)): + return _patch_ok("Gitea secrets недоступны на orchestrator.", auto_update=_auto_update_payload()) + + +@router.get("/sync-settings") +def wesp_sync_settings_get(_admin: User = Depends(require_superuser)): + return { + "status": "success", + "connection": {"role": "server", "server_url": settings.public_base_url.rstrip("/"), "configured": True}, + "state": {}, + } + + +@router.patch("/sync-settings") +def wesp_sync_settings_patch(_admin: User = Depends(require_superuser)): + return _patch_ok("Sync settings hub-client недоступны на orchestrator (роль server).") + + +@router.post("/sync-actions/requeue-stuck") +@router.post("/sync-actions/restart-local-sync") +@router.post("/sync-actions/refresh-runtime") +@router.post("/sync-actions/retry-report-push") +def wesp_sync_actions(_admin: User = Depends(require_superuser)): + return _patch_ok("Действие sync hub-client недоступно на orchestrator.") + + +@router.get("/kiosk-full-setup/status") +def wesp_kiosk_full_setup_status(_admin: User = Depends(require_superuser)): + return _hub_only_stub( + "Kiosk setup только на Raspberry Pi hub.", + running_as_root=False, + full_setup_ready=False, + platform_ready=False, + kiosk_enabled=False, + chromium_found=False, + ) + + +@router.post("/kiosk-full-setup") +def wesp_kiosk_full_setup_post(_admin: User = Depends(require_superuser)): + _wesp_error("Kiosk setup только на Raspberry Pi hub.", 400) + + +@router.get("/pi-platform/status") +def wesp_pi_platform_status(_admin: User = Depends(require_superuser)): + return _hub_only_stub("Pi platform setup только на hub.", running_as_root=False, platform_ready=False) + + +@router.post("/pi-platform/apply") +def wesp_pi_platform_apply(_admin: User = Depends(require_superuser)): + _wesp_error("Pi platform setup только на hub.", 400) + + +@router.get("/pi-boot/rainbow-splash/status") +def wesp_pi_rainbow_status(_admin: User = Depends(require_superuser)): + return _hub_only_stub("Pi boot config только на hub.", config_found=False, disable_splash=True) + + +@router.post("/pi-boot/rainbow-splash") +def wesp_pi_rainbow_post(_admin: User = Depends(require_superuser)): + _wesp_error("Pi boot config только на hub.", 400) + + +@router.get("/plymouth/status") +def wesp_plymouth_status(_admin: User = Depends(require_superuser)): + return _hub_only_stub("Plymouth только на hub.", installed=False, running=False) + + +@router.post("/plymouth/install") +def wesp_plymouth_install(_admin: User = Depends(require_superuser)): + _wesp_error("Plymouth только на hub.", 400) + + +@router.get("/kiosk-boot/status") +def wesp_kiosk_boot_status(_admin: User = Depends(require_superuser)): + return _hub_only_stub("Kiosk boot только на hub.", configured=False) + + +@router.post("/kiosk-boot") +def wesp_kiosk_boot_post(_admin: User = Depends(require_superuser)): + _wesp_error("Kiosk boot только на hub.", 400) + + +@router.post("/factory-reset") +def wesp_factory_reset_post(_admin: User = Depends(require_superuser)): + _wesp_error("Factory reset data/ недоступен на orchestrator.", 400) + + +@router.post("/service-control") +def wesp_service_control(_admin: User = Depends(require_superuser)): + _wesp_error("Управление systemd недоступно на orchestrator.", 400) + + +@router.get("/backup.sqlite") +@router.post("/restore.sqlite") +def wesp_sqlite_backup(_admin: User = Depends(require_superuser)): + _wesp_error("SQLite backup/restore только на hub.", 400) + + +@router.get("/client-uploaded-logs") +def wesp_client_uploaded_logs(_admin: User = Depends(require_superuser)): + return {"status": "success", "root": "", "clients": []} + + +@router.get("/client-uploaded-logs/tail") +def wesp_client_uploaded_logs_tail( + client_id: str | None = None, + file: str | None = None, + lines: int = Query(default=300, ge=1, le=2000), + _admin: User = Depends(require_superuser), +): + _ = (client_id, file, lines) + return { + "status": "error", + "message": "Логи клиентов (POST /api/sync/client-log) на orchestrator пока не настроены.", + "lines": [], + } + + +@router.get("/client-uploaded-logs/download") +def wesp_client_uploaded_logs_download( + client_id: str | None = None, + file: str | None = None, + _admin: User = Depends(require_superuser), +): + _ = (client_id, file) + _wesp_error("Логи клиентов на orchestrator пока не настроены.", 404) + + +@router.get("/server-log") +def wesp_server_log(lines: int = Query(default=200, ge=1, le=1000), _admin: User = Depends(require_superuser)): + log_path = ensure_server_log_file() + tail = get_server_log_tail(lines=lines) + payload = {"status": "success", "path": str(log_path), "lines": tail.get("lines") or []} + return payload + + +@router.get("/server-log/download") +def wesp_server_log_download(_admin: User = Depends(require_superuser)): + log_path = ensure_server_log_file() + return FileResponse( + log_path, + media_type="text/plain; charset=utf-8", + filename=log_path.name, + ) + + +@router.get("/diagnostics/settings") +def wesp_diagnostics_settings_get(_admin: User = Depends(require_superuser)): + return {"status": "success", "targets": [], "extra_targets": []} + + +@router.patch("/diagnostics/settings") +def wesp_diagnostics_settings_patch(_admin: User = Depends(require_superuser)): + return _patch_ok("Diagnostics settings сохранены локально недоступны на orchestrator.") + + +@router.post("/diagnostics/network-run") +@router.post("/diagnostics/hx711-sample") +@router.post("/diagnostics/gpio-blink") +@router.post("/diagnostics/traceroute-by-hosts") +def wesp_diagnostics_actions(_admin: User = Depends(require_superuser)): + return _patch_ok("Диагностика железа/сети hub недоступна на orchestrator.") + + +@router.patch("/sync-clients/{node_id}/ip") +def wesp_sync_client_ip(_node_id: str, _admin: User = Depends(require_superuser)): + return _patch_ok("Sync client IP управляется через hub sync API.") + diff --git a/apps/api/app/modules/zootech/wesp_compat_analytics.py b/apps/api/app/modules/zootech/wesp_compat_analytics.py new file mode 100644 index 0000000..1f084e0 --- /dev/null +++ b/apps/api/app/modules/zootech/wesp_compat_analytics.py @@ -0,0 +1,324 @@ +"""WESP-shaped analytics and feed-quality endpoints for /reports tabs.""" + +from __future__ import annotations + +import json +from collections import defaultdict +from datetime import UTC, datetime, timedelta +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import select + +from app.core.database import session_scope +from app.modules.sync.tenant import TenantContext, require_enterprise_zootech +from app.modules.zootech.models import ZootechComponent +from app.modules.zootech.report_models import ZootechFeedAlert, ZootechLoadingReport +from app.modules.zootech.wesp_compat_reports import _parse_payload, _parse_report_time + +router = APIRouter() + + +def _require_ent(enterprise_id: str, tenant: TenantContext) -> None: + if tenant.enterprise_id != enterprise_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN") + + +def _parse_date_window(date_from: str | None, date_to: str | None) -> tuple[datetime, datetime] | None: + if not date_from or not date_to: + return None + try: + start = datetime.strptime(date_from.strip(), "%Y-%m-%d").replace(tzinfo=UTC) + end = datetime.strptime(date_to.strip(), "%Y-%m-%d").replace(tzinfo=UTC) + timedelta(days=1) + return start, end + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"error": True, "message": "Некорректный формат date_from/date_to (YYYY-MM-DD)"}, + ) from exc + + +def _loading_reports_in_range( + enterprise_id: str, window: tuple[datetime, datetime] | None +) -> list[tuple[str, dict[str, Any]]]: + with session_scope() as db: + rows = list( + db.scalars( + select(ZootechLoadingReport).where( + ZootechLoadingReport.enterprise_id == enterprise_id, + ZootechLoadingReport.is_deleted.is_(False), + ) + ) + ) + out: list[tuple[str, dict[str, Any]]] = [] + for row in rows: + payload = _parse_payload(row) + start_time = _parse_report_time(payload.get("start_time")) + if window and start_time and not (window[0] <= start_time < window[1]): + continue + if window and start_time is None: + continue + out.append((row.id, payload)) + return out + + +def _component_prices(enterprise_id: str) -> dict[str, dict[str, float]]: + with session_scope() as db: + rows = list( + db.scalars( + select(ZootechComponent).where( + ZootechComponent.enterprise_id == enterprise_id, + ZootechComponent.is_deleted.is_(False), + ) + ) + ) + by_id = {row.id: float(row.price or 0) for row in rows} + by_name = {(row.name or "").strip().lower(): float(row.price or 0) for row in rows if row.name} + return {"id": by_id, "name": by_name} + + +def _price_for(prices: dict[str, dict[str, float]], component_id: str | None, name: str) -> float: + if component_id and component_id in prices["id"]: + return prices["id"][component_id] + key = (name or "").strip().lower() + if key and key in prices["name"]: + return prices["name"][key] + return 0.0 + + +@router.get("/analytics/finance") +def analytics_finance_wesp( + enterprise_id: str = Query(...), + date_from: str | None = Query(None), + date_to: str | None = Query(None), + tenant: TenantContext = Depends(require_enterprise_zootech), +): + _require_ent(enterprise_id, tenant) + window = _parse_date_window(date_from, date_to) + reports = _loading_reports_in_range(enterprise_id, window) + if not reports: + return { + "overloadRub": 0.0, + "underloadRub": 0.0, + "netRub": 0.0, + "dominantIssue": "balanced", + "topComponents": [], + "reportCount": 0, + } + + prices = _component_prices(enterprise_id) + by_component: dict[str, dict[str, Any]] = defaultdict( + lambda: {"name": "", "overloadRub": 0.0, "underloadRub": 0.0, "netRub": 0.0} + ) + overload_total = 0.0 + underload_total = 0.0 + + for _report_id, payload in reports: + components = payload.get("components") + if not isinstance(components, list): + continue + for comp in components: + if not isinstance(comp, dict): + continue + target = float(comp.get("target_weight") or 0) + actual = float(comp.get("actual_weight") or 0) + if target <= 0 and actual <= 0: + continue + name = str(comp.get("component_name") or "—") + price = _price_for(prices, comp.get("component_id"), name) + dev_kg = actual - target + dev_rub = dev_kg * price + key = str(comp.get("component_id") or name) + row = by_component[key] + row["name"] = name + row["componentId"] = comp.get("component_id") + if dev_rub > 0: + row["overloadRub"] += dev_rub + overload_total += dev_rub + elif dev_rub < 0: + row["underloadRub"] += abs(dev_rub) + underload_total += abs(dev_rub) + row["netRub"] += dev_rub + + top = sorted( + by_component.values(), + key=lambda item: max(item["overloadRub"], item["underloadRub"]), + reverse=True, + )[:3] + top_out = [ + { + "name": item["name"], + "componentId": item.get("componentId"), + "overloadRub": round(item["overloadRub"], 2), + "underloadRub": round(item["underloadRub"], 2), + "netRub": round(item["netRub"], 2), + } + for item in top + if max(item["overloadRub"], item["underloadRub"]) > 0 + ] + net = overload_total - underload_total + if overload_total > underload_total: + dominant = "overload" + elif underload_total > overload_total: + dominant = "underload" + else: + dominant = "balanced" + result = { + "overloadRub": round(overload_total, 2), + "underloadRub": round(underload_total, 2), + "netRub": round(net, 2), + "dominantIssue": dominant, + "topComponents": top_out, + "reportCount": len(reports), + } + # #region agent log + try: + import pathlib + _log_path = pathlib.Path("/Users/vlad/Documents/wesp new (1)/.cursor/debug-785e22.log") + _log_path.parent.mkdir(parents=True, exist_ok=True) + with _log_path.open("a", encoding="utf-8") as _lf: + _lf.write(json.dumps({"sessionId":"785e22","hypothesisId":"E","location":"wesp_compat_analytics.py:finance","message":"finance summary","data":{"date_from":date_from,"date_to":date_to,"report_count":len(reports),"underloadRub":result["underloadRub"],"top_count":len(top_out)},"timestamp":int(datetime.now(UTC).timestamp()*1000)}, ensure_ascii=False) + "\n") + except Exception: + pass + # #endregion + return result + + +@router.get("/analytics/plan-fact") +def analytics_plan_fact_wesp( + enterprise_id: str = Query(...), + date_from: str | None = Query(None), + date_to: str | None = Query(None), + tenant: TenantContext = Depends(require_enterprise_zootech), +): + _require_ent(enterprise_id, tenant) + return {"items": []} + + +def _parse_alert_payload_json(raw: str | None) -> dict[str, Any]: + if not raw: + return {} + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + return {} + return parsed if isinstance(parsed, dict) else {} + + +def _alert_payload(row: ZootechFeedAlert) -> dict[str, Any]: + return _parse_alert_payload_json(row.payload_json) + + +def _serialize_feed_alert( + alert_id: str, + alert_type: str, + severity: str, + message: str, + created_at: datetime | None, + payload: dict[str, Any], +) -> dict[str, Any]: + event_type = payload.get("event_type") or alert_type or "" + created = payload.get("created_at") or (created_at.isoformat() if created_at else None) + return { + "id": alert_id, + "eventType": event_type, + "severity": payload.get("severity") or severity or "warning", + "loadingReportId": payload.get("loading_report_id") or "", + "unloadingReportId": payload.get("unloading_report_id"), + "recipeId": payload.get("recipe_id") or "", + "recipeName": payload.get("recipe_name") or "", + "componentName": payload.get("component_name"), + "groupName": payload.get("group_name"), + "detail": payload.get("detail") or message or "", + "deviationKg": payload.get("deviation_kg"), + "deviationPct": payload.get("deviation_pct"), + "costDeviationRub": payload.get("cost_deviation_rub"), + "clientId": payload.get("client_id"), + "createdAt": created, + "linkKind": "report_loading", + "linkId": payload.get("loading_report_id") or "", + "targetKg": None, + "actualKg": None, + "durationSec": None, + } + + +def _feed_alerts_for_range( + enterprise_id: str, + window: tuple[datetime, datetime] | None, + *, + severity: str | None = None, + limit: int = 200, +) -> list[dict[str, Any]]: + loading_by_id = { + report_id: payload for report_id, payload in _loading_reports_in_range(enterprise_id, window) + } + alert_rows: list[tuple[str, str, str, str, datetime | None, dict[str, Any]]] = [] + with session_scope() as db: + rows = list( + db.scalars( + select(ZootechFeedAlert).where( + ZootechFeedAlert.enterprise_id == enterprise_id, + ZootechFeedAlert.is_deleted.is_(False), + ) + ) + ) + for row in rows: + payload = _parse_alert_payload_json(row.payload_json) + alert_rows.append( + (row.id, row.alert_type, row.severity, row.message, row.created_at, payload) + ) + items: list[dict[str, Any]] = [] + for alert_id, alert_type, sev, message, created_at, payload in alert_rows: + loading_id = str(payload.get("loading_report_id") or "").strip() + if window and loading_id and loading_id not in loading_by_id: + continue + item = _serialize_feed_alert(alert_id, alert_type, sev, message, created_at, payload) + if severity and (item.get("severity") or "").lower() != severity.strip().lower(): + continue + items.append(item) + items.sort(key=lambda item: str(item.get("createdAt") or ""), reverse=True) + return items[: max(1, min(limit, 500))] + + +@router.get("/feed-quality/alerts/summary") +def feed_quality_alerts_summary_wesp( + enterprise_id: str = Query(...), + date_from: str | None = Query(None), + date_to: str | None = Query(None), + tenant: TenantContext = Depends(require_enterprise_zootech), +): + _require_ent(enterprise_id, tenant) + window = _parse_date_window(date_from, date_to) + items = _feed_alerts_for_range(enterprise_id, window, limit=500) + by_severity = {"warning": 0, "error": 0, "info": 0} + for item in items: + sev = (item.get("severity") or "warning").lower() + if sev in by_severity: + by_severity[sev] += 1 + return { + "total": len(items), + "warning": by_severity["warning"], + "error": by_severity["error"], + "info": by_severity["info"], + } + + +@router.get("/feed-quality/alerts") +def feed_quality_alerts_wesp( + enterprise_id: str = Query(...), + date_from: str | None = Query(None), + date_to: str | None = Query(None), + severity: str | None = Query(None), + limit: int = Query(200, ge=1, le=500), + tenant: TenantContext = Depends(require_enterprise_zootech), +): + _require_ent(enterprise_id, tenant) + window = _parse_date_window(date_from, date_to) + items = _feed_alerts_for_range(enterprise_id, window, severity=severity, limit=limit) + by_severity = {"warning": 0, "error": 0, "info": 0} + for item in items: + sev = (item.get("severity") or "warning").lower() + if sev in by_severity: + by_severity[sev] += 1 + return {"items": items, "total": len(items), "bySeverity": by_severity} diff --git a/apps/api/app/modules/zootech/wesp_compat_auth.py b/apps/api/app/modules/zootech/wesp_compat_auth.py new file mode 100644 index 0000000..d33d951 --- /dev/null +++ b/apps/api/app/modules/zootech/wesp_compat_auth.py @@ -0,0 +1,89 @@ +"""WESP-shaped auth endpoints for copied static UI (/api/auth/*).""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from pydantic import BaseModel, Field + +from app.core.dependencies import get_current_user +from app.modules.auth.router import _set_refresh_cookie +from app.modules.auth.service import login as auth_login +from app.modules.users.models import User + +router = APIRouter(prefix="/auth", tags=["zootech-wesp-auth"]) + +_USER_RULES = { + "password_min_len": 8, + "login_min_len": 3, +} + + +class WespLoginIn(BaseModel): + login: str = Field(min_length=1) + password: str = Field(min_length=1) + remember: bool = False + + +@router.post("/login") +def wesp_auth_login(payload: WespLoginIn, response: Response): + email = payload.login.strip() + if "@" not in email: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail={"status": "error", "message": "Используйте email (admin@compton.example)"}, + ) + try: + access_token, refresh_token, user = auth_login(email, payload.password) + except ValueError: + return {"status": "error", "message": "Неверный логин или пароль"} + except PermissionError as exc: + detail = str(exc) + if detail == "EMAIL_NOT_VERIFIED": + return {"status": "error", "message": "Email не подтверждён"} + if detail == "ACCOUNT_BLOCKED": + return {"status": "error", "message": "Учётная запись заблокирована"} + return {"status": "error", "message": "Вход временно недоступен"} + + _set_refresh_cookie(response, refresh_token) + return { + "status": "success", + "message": "Авторизация успешна", + "authenticated": True, + "user_login": user.email, + "access_token": access_token, + "user": { + "id": user.id, + "email": user.email, + "role": user.role, + "is_superuser": user.is_superuser, + "status": user.status, + }, + } + + +@router.get("/check") +def wesp_auth_check(user: User = Depends(get_current_user)): + return { + "status": "success", + "authenticated": True, + "user_login": user.email, + "remember_login": False, + "is_superuser": bool(user.is_superuser), + "can_lab": bool(user.is_superuser), + "user_rules": _USER_RULES, + } + + +@router.get("/get_current_credentials") +def wesp_get_current_credentials(user: User = Depends(get_current_user)): + return { + "status": "success", + "login": user.email, + "password": "", + "user_rules": _USER_RULES, + } + + +@router.post("/logout") +def wesp_logout(): + return {"status": "success", "message": "Выход выполнен успешно"} diff --git a/apps/api/app/modules/zootech/wesp_compat_misc.py b/apps/api/app/modules/zootech/wesp_compat_misc.py new file mode 100644 index 0000000..19f884c --- /dev/null +++ b/apps/api/app/modules/zootech/wesp_compat_misc.py @@ -0,0 +1,101 @@ +"""WESP-shaped misc endpoints: notifications, updates (/api/notifications, /api/updates/*).""" + +from __future__ import annotations + +from datetime import date + +from fastapi import APIRouter, Depends +from pydantic import BaseModel + +from app.core.dependencies import get_current_user +from app.modules.users.models import User + +router = APIRouter(tags=["zootech-wesp-misc"]) + + +def _empty_notifications(*, summary: bool = False) -> dict: + today = date.today().isoformat() + if summary: + return {"items": [], "unreadCount": 0} + return { + "items": [], + "unreadCount": 0, + "date": today, + "prevDate": None, + "hasMore": False, + } + + +@router.get("/notifications") +def wesp_notifications_list( + summary: str | None = None, + _user: User = Depends(get_current_user), +): + if summary in ("1", "true", "yes"): + return _empty_notifications(summary=True) + return _empty_notifications() + + +@router.post("/notifications") +def wesp_notifications_create(_user: User = Depends(get_current_user)): + return { + "id": "orchestrator-stub", + "title": "", + "detail": "", + "kind": "info", + "category": "general", + "read": False, + } + + +@router.patch("/notifications/read-all") +def wesp_notifications_read_all(_user: User = Depends(get_current_user)): + return {"success": True, "marked": 0} + + +@router.patch("/notifications/{notification_id}/read") +def wesp_notifications_mark_read(notification_id: str, _user: User = Depends(get_current_user)): + return {"id": notification_id, "read": True} + + +@router.get("/updates/status") +def wesp_updates_status(_user: User = Depends(get_current_user)): + return { + "initialized": False, + "check_enabled": False, + "current_version": "1.0.0-orchestrator", + "update_available": False, + "pending_version": None, + "pending_name": None, + "pending_body": None, + "published_at": None, + "last_check_at": None, + "is_running": False, + "is_updating": False, + "update_progress": None, + "last_update_state": {"status": "idle", "note": "orchestrator deploy"}, + "gitea_configured": False, + "restart_configured": False, + "gitea_url": None, + "gitea_repo": None, + } + + +class UpdatesInstallIn(BaseModel): + version: str | None = None + + +@router.post("/updates/install") +def wesp_updates_install(_payload: UpdatesInstallIn, _user: User = Depends(get_current_user)): + return { + "status": "error", + "message": "Автообновление недоступно на orchestrator — используйте деплой Docker.", + } + + +@router.get("/updates/check") +def wesp_updates_check(_user: User = Depends(get_current_user)): + return { + "update_available": False, + "message": "Проверка обновлений недоступна на orchestrator.", + } diff --git a/apps/api/app/modules/zootech/wesp_compat_reports.py b/apps/api/app/modules/zootech/wesp_compat_reports.py new file mode 100644 index 0000000..e60f92f --- /dev/null +++ b/apps/api/app/modules/zootech/wesp_compat_reports.py @@ -0,0 +1,183 @@ +"""WESP-shaped reports API for copied static UI (/api/reports).""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime, timedelta +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query, status + +from app.core.database import session_scope +from app.modules.sync.tenant import TenantContext, require_enterprise_zootech +from app.modules.zootech.report_models import ZootechLoadingReport, ZootechUnloadingReport +from sqlalchemy import select + +router = APIRouter() + + +def _require_ent(enterprise_id: str, tenant: TenantContext) -> None: + if tenant.enterprise_id != enterprise_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN") + + +def _parse_payload(row: ZootechLoadingReport | ZootechUnloadingReport) -> dict[str, Any]: + if not row.payload_json: + return {} + try: + data = json.loads(row.payload_json) + except json.JSONDecodeError: + return {} + return data if isinstance(data, dict) else {} + + +def _parse_date_range( + date_from: str | None, date_to: str | None +) -> tuple[datetime, datetime] | None: + if not date_from or not date_to: + end = datetime.now(UTC) + return end - timedelta(hours=24), end + timedelta(seconds=1) + try: + start = datetime.strptime(date_from.strip(), "%Y-%m-%d").replace(tzinfo=UTC) + end = datetime.strptime(date_to.strip(), "%Y-%m-%d").replace(tzinfo=UTC) + timedelta(days=1) + return start, end + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"error": True, "message": "Некорректный формат date_from/date_to (YYYY-MM-DD)"}, + ) from exc + + +def _parse_report_time(value: Any) -> datetime | None: + if value is None: + return None + if isinstance(value, datetime): + return value if value.tzinfo else value.replace(tzinfo=UTC) + text = str(value).strip() + if not text: + return None + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError: + return None + return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC) + + +def _in_range(start_time: datetime | None, window: tuple[datetime, datetime]) -> bool: + if start_time is None: + return False + start, end = window + return start <= start_time < end + + +def _serialize_unloading_id(unloading_id: str, payload: dict[str, Any]) -> dict[str, Any]: + groups = payload.get("unloading_groups") + if not isinstance(groups, list): + groups = payload.get("groups") if isinstance(payload.get("groups"), list) else [] + return { + "id": unloading_id, + "start_time": payload.get("start_time"), + "end_time": payload.get("end_time"), + "total_weight": payload.get("total_weight"), + "total_unloaded_weight": payload.get("total_unloaded_weight"), + "remaining_weight": payload.get("remaining_weight"), + "unloading_groups": groups, + } + + +def _serialize_unloading(row: ZootechUnloadingReport, payload: dict[str, Any]) -> dict[str, Any]: + return _serialize_unloading_id(row.id, payload) + + +@router.get("/reports") +def list_reports_wesp( + enterprise_id: str = Query(...), + date_from: str | None = Query(None), + date_to: str | None = Query(None), + limit: int = Query(500, ge=1, le=2000), + offset: int = Query(0, ge=0), + tenant: TenantContext = Depends(require_enterprise_zootech), +): + """Legacy-compatible aggregated loading reports for reports.html.""" + _require_ent(enterprise_id, tenant) + window = _parse_date_range(date_from, date_to) + + with session_scope() as db: + loading_rows = list( + db.scalars( + select(ZootechLoadingReport).where( + ZootechLoadingReport.enterprise_id == enterprise_id, + ZootechLoadingReport.is_deleted.is_(False), + ) + ) + ) + unloading_rows = list( + db.scalars( + select(ZootechUnloadingReport).where( + ZootechUnloadingReport.enterprise_id == enterprise_id, + ZootechUnloadingReport.is_deleted.is_(False), + ) + ) + ) + loading_data = [(row.id, _parse_payload(row)) for row in loading_rows] + unloading_data: list[tuple[str, str, dict[str, Any]]] = [] + for row in unloading_rows: + payload = _parse_payload(row) + loading_id = str(payload.get("loading_report_id") or "").strip() + if loading_id: + unloading_data.append((loading_id, row.id, payload)) + + unloading_by_loading: dict[str, tuple[str, dict[str, Any]]] = { + loading_id: (unloading_id, payload) for loading_id, unloading_id, payload in unloading_data + } + + payload: list[dict[str, Any]] = [] + for row_id, data in loading_data: + start_time = _parse_report_time(data.get("start_time")) + if window and not _in_range(start_time, window): + continue + unloading = unloading_by_loading.get(row_id) + unloading_payload = ( + _serialize_unloading_id(unloading[0], unloading[1]) if unloading else None + ) + payload.append( + { + "id": row_id, + "recipe_id": data.get("recipe_id"), + "recipe_name": data.get("recipe_name"), + "start_time": data.get("start_time"), + "end_time": data.get("end_time"), + "target_mixing_time": data.get("target_mixing_time"), + "actual_mixing_time": data.get("actual_mixing_time"), + "total_weight": data.get("total_weight"), + "dispenser_type": data.get("dispenser_type") or "dispenser", + "components": data.get("components") if isinstance(data.get("components"), list) else [], + "component_loading_times": ( + data.get("component_loading_times") + if isinstance(data.get("component_loading_times"), list) + else [] + ), + "unloading_data": unloading_payload, + } + ) + + payload.sort(key=lambda item: str(item.get("start_time") or ""), reverse=True) + result = payload[offset : offset + limit] + + # #region agent log + try: + import pathlib + _log_path = pathlib.Path("/Users/vlad/Documents/wesp new (1)/.cursor/debug-785e22.log") + _log_path.parent.mkdir(parents=True, exist_ok=True) + with _log_path.open("a", encoding="utf-8") as _lf: + _lf.write(json.dumps({"sessionId":"785e22","hypothesisId":"A,B","location":"wesp_compat_reports.py:list","message":"reports api response","data":{"date_from":date_from,"date_to":date_to,"total_before_filter":len(payload),"returned":len(result),"component_counts":[len(r.get("components") or []) for r in result[:5]]},"timestamp":int(datetime.now(UTC).timestamp()*1000)}, ensure_ascii=False) + "\n") + except Exception: + pass + # #endregion + + return result + + +@router.get("/reports/ping") +def reports_ping_wesp(): + return {"status": "ok"} diff --git a/apps/api/app/modules/zootech/wesp_compat_sync.py b/apps/api/app/modules/zootech/wesp_compat_sync.py new file mode 100644 index 0000000..71bd8be --- /dev/null +++ b/apps/api/app/modules/zootech/wesp_compat_sync.py @@ -0,0 +1,30 @@ +"""WESP /api/sync/clients compat — maps to orchestrator farm hubs.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query, status + +from app.modules.sync import repository as sync_repo +from app.modules.sync.tenant import TenantContext, require_enterprise_zootech + +router = APIRouter() + + +@router.get("/sync/clients") +def list_sync_clients( + enterprise_id: str = Query(...), + tenant: TenantContext = Depends(require_enterprise_zootech), +): + if tenant.enterprise_id != enterprise_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN") + hubs = sync_repo.list_farm_hubs(enterprise_id) + return [ + { + "id": h.id, + "node_id": h.hub_site_id, + "client_name": h.name, + "status": h.status, + "last_seen": h.last_seen.isoformat() if h.last_seen else None, + } + for h in hubs + ] diff --git a/apps/api/app/modules/zootech/wesp_recipe_write.py b/apps/api/app/modules/zootech/wesp_recipe_write.py new file mode 100644 index 0000000..749197a --- /dev/null +++ b/apps/api/app/modules/zootech/wesp_recipe_write.py @@ -0,0 +1,352 @@ +"""WESP-shaped recipe write/calculate for orchestrator static UI.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any +from uuid import uuid4 + +from fastapi import HTTPException, status +from sqlalchemy import select + +from app.core.database import session_scope +from app.modules.sync.engine import SyncEngine +from app.modules.sync.schemas import ChangeEventIn +from app.modules.zootech.catalog_apply import load_catalog_row +from app.modules.zootech.catalog_models import ZootechUnloadingGroup +from app.modules.zootech.models import ZootechComponent, ZootechIngredient, ZootechRecipe +from app.modules.zootech.recipe_calculator import calculate_recipe +from app.modules.zootech.sync_content_hash import compute_content_hash + + +class RecipeWriteError(Exception): + def __init__(self, message: str, status_code: int = 400): + self.message = message + self.status_code = status_code + super().__init__(message) + + +def _normalize_groups(raw: list[Any]) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for group in raw: + if not isinstance(group, dict): + continue + g = dict(group) + if "distributionType" not in g and "distribution_type" in g: + g["distributionType"] = g.get("distribution_type") + out.append(g) + return out + + +def calculate_recipe_wesp(body: dict[str, Any]) -> dict[str, Any]: + if not body: + raise RecipeWriteError("Данные не предоставлены", 400) + try: + heads_count = int(body.get("headsCount") or body.get("heads_count") or body.get("headsPerTrip") or 0) + trip_percent = float(body.get("tripPercent") or body.get("trip_percent") or 100) + except (TypeError, ValueError) as exc: + raise RecipeWriteError("Некорректные числовые параметры", 400) from exc + + ingredients = body.get("ingredients") or [] + unloading_groups = _normalize_groups(body.get("unloadingGroups") or body.get("unloading_groups") or []) + calculate_from_dry_matter = bool( + body.get("calculateFromDryMatter") + if body.get("calculateFromDryMatter") is not None + else body.get("calculate_from_dry_matter", False) + ) + + component_ids = [i.get("component_id") for i in ingredients if isinstance(i, dict) and i.get("component_id")] + component_dry_matter_map: dict[str, float] = {} + if component_ids: + with session_scope() as db: + for comp in db.scalars(select(ZootechComponent).where(ZootechComponent.id.in_(component_ids))): + component_dry_matter_map[comp.id] = float(comp.dry_matter or 0) + + result = calculate_recipe( + ingredients=[i for i in ingredients if isinstance(i, dict)], + heads_count=heads_count, + trip_percent=trip_percent, + unloading_groups=unloading_groups, + component_dry_matter_map=component_dry_matter_map or None, + calculate_from_dry_matter=calculate_from_dry_matter, + ) + + def _to_float2(x: Any) -> float: + try: + return round(float(x), 2) + except (TypeError, ValueError): + return 0.0 + + def _truncate2(x: Any) -> float: + try: + v = float(x) + return float(int(v * 100)) / 100.0 + except (TypeError, ValueError): + return 0.0 + + for ing in result.get("ingredients") or []: + for key in ("weightPerHead", "tripWeight", "totalWeight", "dryMatterPerHead"): + if key in ing and ing[key] is not None: + raw_val = ing[key] + v = _truncate2(raw_val) if key == "weightPerHead" else _to_float2(raw_val) + ing[key] = f"{v:.2f}" if key == "weightPerHead" else v + + totals = result.get("totals") or {} + for key in ("totalWeight", "totalTripWeight", "totalDryMatterPerHead", "totalWeightPerHead"): + if key in totals and totals[key] is not None: + totals[key] = _to_float2(totals[key]) + result["totals"] = totals + return result + + +def _push_change( + enterprise_id: str, + table: str, + record_id: str, + payload: dict[str, Any], + *, + action: str = "upsert", +) -> None: + version = int(payload.get("version") or 1) + content_hash = compute_content_hash(payload) + payload = {**payload, "version": version, "content_hash": content_hash} + event = ChangeEventIn( + event_id=str(uuid4()), + seq=0, + domain="global", + table=table, + record_id=record_id, + action=action, + version=version, + content_hash=content_hash, + payload=payload, + emitted_at=datetime.now(UTC), + origin_site_id=SyncEngine.ORCHESTRATOR_SITE_ID, + ) + SyncEngine(enterprise_id, SyncEngine.ORCHESTRATOR_SITE_ID).process_push([event], origin="orchestrator") + + +def _resolve_component(enterprise_id: str, ing: dict[str, Any]) -> ZootechComponent | None: + comp_id = ing.get("component_id") + with session_scope() as db: + if comp_id: + row = db.scalar( + select(ZootechComponent).where( + ZootechComponent.enterprise_id == enterprise_id, + ZootechComponent.id == str(comp_id), + ZootechComponent.is_deleted.is_(False), + ) + ) + if row: + return row + name = (ing.get("name") or "").strip() + if name: + return db.scalar( + select(ZootechComponent).where( + ZootechComponent.enterprise_id == enterprise_id, + ZootechComponent.name == name, + ZootechComponent.is_deleted.is_(False), + ) + ) + return None + + +def update_recipe_wesp(enterprise_id: str, recipe_id: str, data: dict[str, Any]) -> dict[str, Any]: + if not data: + raise RecipeWriteError("Данные не предоставлены", 400) + for key in ("name", "heads_count", "mixing_time"): + if key not in data: + raise RecipeWriteError(f'Отсутствует обязательное поле "{key}"', 400) + + with session_scope() as db: + recipe = db.scalar( + select(ZootechRecipe).where( + ZootechRecipe.enterprise_id == enterprise_id, + ZootechRecipe.id == recipe_id, + ZootechRecipe.is_deleted.is_(False), + ) + ) + if not recipe: + raise RecipeWriteError("Рецепт не найден", 404) + + existing_ings = list( + db.scalars( + select(ZootechIngredient).where( + ZootechIngredient.enterprise_id == enterprise_id, + ZootechIngredient.recipe_id == recipe_id, + ZootechIngredient.is_deleted.is_(False), + ) + ) + ) + existing_groups = list( + db.scalars( + select(ZootechUnloadingGroup).where( + ZootechUnloadingGroup.enterprise_id == enterprise_id, + ZootechUnloadingGroup.recipe_id == recipe_id, + ZootechUnloadingGroup.is_deleted.is_(False), + ) + ) + ) + recipe_trip_percent = float(recipe.trip_percent or 100) + recipe_dry_matter_locked = bool(recipe.dry_matter_locked) + recipe_unloading_link_broken = bool(recipe.unloading_link_broken) + recipe_target_component_id = recipe.target_component_id + recipe_version = int(recipe.version or 1) + 1 + ing_by_id = { + str(i.id): {"id": str(i.id), "name": i.name, "version": int(i.version or 1)} + for i in existing_ings + } + grp_by_id = { + str(g.id): {"id": str(g.id), "name": g.name, "version": int(g.version or 1)} + for g in existing_groups + } + + recipe_payload = { + "id": recipe_id, + "name": str(data["name"]), + "heads_per_trip": int(data["heads_count"]), + "mixing_time": int(data["mixing_time"]), + "trip_percent": float(data.get("trip_percent") or recipe_trip_percent), + "dry_matter_locked": bool(data.get("dry_matter_locked", recipe_dry_matter_locked)), + "unloading_link_broken": bool(data.get("unloading_link_broken", recipe_unloading_link_broken)), + "target_component_id": data.get("target_component_id", recipe_target_component_id), + "version": recipe_version, + } + _push_change(enterprise_id, "recipe", recipe_id, recipe_payload) + + seen_ing_ids: set[str] = set() + for idx, ing in enumerate([x for x in (data.get("ingredients") or []) if isinstance(x, dict)], start=1): + component = _resolve_component(enterprise_id, ing) + if not component: + raise RecipeWriteError( + f'Компонент не найден (id="{ing.get("component_id", "")}", name="{ing.get("name", "")}")', + 400, + ) + ing_id = str(ing.get("id") or "").strip() or str(uuid4()) + existing = ing_by_id.get(ing_id) + order_value = int(ing.get("order") or idx) + wph = float(ing.get("weight_per_head") or ing.get("weightPerHead") or 0) + amount = float(ing.get("amount") or 0) + dm = float(ing.get("dry_matter") or component.dry_matter or 0) + dm_ph = ing.get("dry_matter_per_head") + if dm_ph is None: + dm_ph = ing.get("dryMatterPerHead") + dm_ph_f = float(dm_ph) if dm_ph not in (None, "") else (wph * (dm / 100.0) if wph and dm else 0.0) + version = int(existing.get("version") or 1) + 1 if existing else 1 + payload = { + "id": ing_id, + "recipe_id": recipe_id, + "component_id": component.id, + "name": component.name, + "amount": amount, + "weight_per_head": wph, + "dry_matter": dm, + "dry_matter_per_head": dm_ph_f, + "order": order_value, + "version": version, + } + _push_change(enterprise_id, "ingredient", ing_id, payload) + seen_ing_ids.add(ing_id) + + for raw_id in data.get("deleted_ingredient_ids") or []: + ing_id = str(raw_id or "").strip() + if not ing_id or ing_id not in ing_by_id: + continue + existing = ing_by_id[ing_id] + version = int(existing["version"] or 1) + 1 + payload = { + "id": ing_id, + "recipe_id": recipe_id, + "name": existing["name"], + "version": version, + } + _push_change(enterprise_id, "ingredient", ing_id, payload, action="delete") + + for ing_id, existing in ing_by_id.items(): + if ing_id not in seen_ing_ids and ing_id not in {str(x) for x in (data.get("deleted_ingredient_ids") or [])}: + version = int(existing["version"] or 1) + 1 + _push_change( + enterprise_id, + "ingredient", + ing_id, + {"id": ing_id, "recipe_id": recipe_id, "name": existing["name"], "version": version}, + action="delete", + ) + + seen_grp_ids: set[str] = set() + groups_in = [x for x in (data.get("unloading_groups") or data.get("unloadingGroups") or []) if isinstance(x, dict)] + for idx, group in enumerate(groups_in, start=1): + try: + gname = str(group["name"]) + gdist = str(group.get("distribution_type") or group.get("distributionType") or "percent") + gval = float(group.get("value") or 0) + except (KeyError, TypeError, ValueError) as exc: + raise RecipeWriteError(f"Некорректная группа выгрузки (order={idx}): {exc}", 400) from exc + grp_id = str(group.get("id") or "").strip() or str(uuid4()) + existing = grp_by_id.get(grp_id) + order_value = int(group.get("order") or idx) + weight_raw = group.get("weight") + weight = float(weight_raw) if weight_raw not in (None, "") else None + version = int(existing.get("version") or 1) + 1 if existing else 1 + payload = { + "id": grp_id, + "recipe_id": recipe_id, + "name": gname, + "distribution_type": gdist, + "value": gval, + "weight": weight, + "order": order_value, + "version": version, + } + _push_change(enterprise_id, "unloading_group", grp_id, payload) + seen_grp_ids.add(grp_id) + + for raw_id in data.get("deleted_unloading_group_ids") or []: + grp_id = str(raw_id or "").strip() + if not grp_id or grp_id not in grp_by_id: + continue + existing = grp_by_id[grp_id] + version = int(existing["version"] or 1) + 1 + _push_change( + enterprise_id, + "unloading_group", + grp_id, + {"id": grp_id, "recipe_id": recipe_id, "name": existing["name"], "version": version}, + action="delete", + ) + + for grp_id, existing in grp_by_id.items(): + if grp_id not in seen_grp_ids and grp_id not in {str(x) for x in (data.get("deleted_unloading_group_ids") or [])}: + version = int(existing["version"] or 1) + 1 + _push_change( + enterprise_id, + "unloading_group", + grp_id, + {"id": grp_id, "recipe_id": recipe_id, "name": existing["name"], "version": version}, + action="delete", + ) + + with session_scope() as db: + saved = db.scalar( + select(ZootechRecipe).where( + ZootechRecipe.enterprise_id == enterprise_id, + ZootechRecipe.id == recipe_id, + ZootechRecipe.is_deleted.is_(False), + ) + ) + if not saved: + raise RecipeWriteError("Рецепт не найден после сохранения", 404) + + return { + "success": True, + "message": "Рецепт обновлен", + "id": recipe_id, + "stats": { + "ingredients": len(seen_ing_ids), + "unloading_groups": len(seen_grp_ids), + }, + } + + +def recipe_write_http_error(exc: RecipeWriteError) -> HTTPException: + return HTTPException(status_code=exc.status_code, detail={"message": exc.message, "error": True}) diff --git a/apps/api/app/worker.py b/apps/api/app/worker.py new file mode 100644 index 0000000..2c25c50 --- /dev/null +++ b/apps/api/app/worker.py @@ -0,0 +1,29 @@ +"""Background worker: sync reconcile + report jobs.""" + +from __future__ import annotations + +import logging +import time + +logger = logging.getLogger(__name__) + + +def run_sync_reconcile() -> int: + from app.modules.sync.reconcile import run_sync_reconcile as _reconcile + + return _reconcile() + + +def main() -> None: + logging.basicConfig(level=logging.INFO) + logger.info("worker started") + while True: + try: + run_sync_reconcile() + except Exception: + logger.exception("reconcile failed") + time.sleep(300) + + +if __name__ == "__main__": + main() diff --git a/apps/api/data/secrets/.gitkeep b/apps/api/data/secrets/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/api/data/secrets/.gitkeep @@ -0,0 +1 @@ + diff --git a/apps/api/migrations/env.py b/apps/api/migrations/env.py new file mode 100644 index 0000000..234f01b --- /dev/null +++ b/apps/api/migrations/env.py @@ -0,0 +1,69 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.core.config import settings +from app.db.base import Base + +# Import models so Alembic can discover metadata. +from app.modules.auth.models import EmailVerificationToken, PasswordResetToken, RefreshToken # noqa: F401 +from app.modules.content.models import ContentPage # noqa: F401 +from app.modules.sync.models import ( # noqa: F401 + Enterprise, + EnterpriseMember, + FarmHub, + HubCredential, + HubPairingSession, + ReportSyncState, + SyncAppliedEvent, + SyncConflict, + SyncCursor, + SyncEventLog, + SyncOutbox, + SyncReconcileRun, + SyncRecordState, + UserFarmAccess, +) +from app.modules.users.models import User, UserProfile # noqa: F401 +from app.modules.zootech.models import ZootechComponent, ZootechIngredient, ZootechRecipe # noqa: F401 + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +config.set_main_option("sqlalchemy.url", settings.database_url) +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + transaction_per_migration=True, + ) + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/apps/api/migrations/script.py.mako b/apps/api/migrations/script.py.mako new file mode 100644 index 0000000..17dcba0 --- /dev/null +++ b/apps/api/migrations/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/apps/api/migrations/versions/20260711_0001_initial_schema.py b/apps/api/migrations/versions/20260711_0001_initial_schema.py new file mode 100644 index 0000000..fecce06 --- /dev/null +++ b/apps/api/migrations/versions/20260711_0001_initial_schema.py @@ -0,0 +1,120 @@ +"""initial schema + +Revision ID: 20260711_0001 +Revises: +Create Date: 2026-07-11 14:00:00 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260711_0001" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "users", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("email", sa.String(length=255), nullable=False), + sa.Column("password_hash", sa.String(length=255), nullable=False), + sa.Column("role", sa.String(length=16), nullable=False), + sa.Column("status", sa.String(length=16), nullable=False), + sa.Column("failed_login_attempts", sa.Integer(), nullable=False), + sa.Column("locked_until", sa.DateTime(timezone=True), nullable=True), + sa.Column("email_verified_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("email"), + ) + op.create_index("idx_users_email", "users", ["email"], unique=True) + op.create_index("idx_users_status", "users", ["status"], unique=False) + + op.create_table( + "user_profiles", + sa.Column("user_id", sa.String(length=36), nullable=False), + sa.Column("display_name", sa.String(length=120), nullable=False), + sa.Column("avatar_url", sa.String(length=512), nullable=True), + sa.Column("metadata_json", sa.Text(), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("user_id"), + ) + + op.create_table( + "refresh_tokens", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("user_id", sa.String(length=36), nullable=False), + sa.Column("token_hash", sa.String(length=64), nullable=False), + sa.Column("family_id", sa.String(length=36), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("token_hash"), + ) + op.create_index("idx_refresh_tokens_user", "refresh_tokens", ["user_id"], unique=False) + op.create_index("idx_refresh_tokens_family", "refresh_tokens", ["family_id"], unique=False) + + op.create_table( + "password_reset_tokens", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("user_id", sa.String(length=36), nullable=False), + sa.Column("token_hash", sa.String(length=64), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("used_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("token_hash"), + ) + op.create_index("idx_password_reset_user", "password_reset_tokens", ["user_id"], unique=False) + + op.create_table( + "email_verification_tokens", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("user_id", sa.String(length=36), nullable=False), + sa.Column("token_hash", sa.String(length=64), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("used_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("token_hash"), + ) + + op.create_table( + "content_pages", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("slug", sa.String(length=120), nullable=False), + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("body", sa.Text(), nullable=False), + sa.Column("status", sa.String(length=16), nullable=False), + sa.Column("author_id", sa.String(length=36), nullable=True), + sa.Column("published_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["author_id"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("slug"), + ) + op.create_index("idx_content_slug", "content_pages", ["slug"], unique=True) + op.create_index("idx_content_status", "content_pages", ["status"], unique=False) + + +def downgrade() -> None: + op.drop_index("idx_content_status", table_name="content_pages") + op.drop_index("idx_content_slug", table_name="content_pages") + op.drop_table("content_pages") + op.drop_table("email_verification_tokens") + op.drop_index("idx_password_reset_user", table_name="password_reset_tokens") + op.drop_table("password_reset_tokens") + op.drop_index("idx_refresh_tokens_family", table_name="refresh_tokens") + op.drop_index("idx_refresh_tokens_user", table_name="refresh_tokens") + op.drop_table("refresh_tokens") + op.drop_table("user_profiles") + op.drop_index("idx_users_status", table_name="users") + op.drop_index("idx_users_email", table_name="users") + op.drop_table("users") diff --git a/apps/api/migrations/versions/20260711_0002_seed_data.py b/apps/api/migrations/versions/20260711_0002_seed_data.py new file mode 100644 index 0000000..a633ea0 --- /dev/null +++ b/apps/api/migrations/versions/20260711_0002_seed_data.py @@ -0,0 +1,33 @@ +"""seed admin and demo content + +Revision ID: 20260711_0002 +Revises: 20260711_0001 +Create Date: 2026-07-11 14:05:00 +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "20260711_0002" +down_revision: Union[str, None] = "20260711_0001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Demo users and CMS pages are seeded on API startup (app.main lifespan → run_seed) + # after all schema migrations, including is_superuser (20260714_0003). + pass + + +def downgrade() -> None: + from sqlalchemy import delete + + from app.core.database import session_scope + from app.modules.content.models import ContentPage + from app.modules.users.models import User + + with session_scope() as db: + db.execute(delete(ContentPage).where(ContentPage.slug.in_(["about", "privacy", "terms"]))) + db.execute(delete(User).where(User.email == "admin@compton.example")) diff --git a/apps/api/migrations/versions/20260714_0003_add_is_superuser.py b/apps/api/migrations/versions/20260714_0003_add_is_superuser.py new file mode 100644 index 0000000..770ff8f --- /dev/null +++ b/apps/api/migrations/versions/20260714_0003_add_is_superuser.py @@ -0,0 +1,36 @@ +"""add is_superuser to users + +Revision ID: 20260714_0003 +Revises: 20260711_0002 +Create Date: 2026-07-14 12:45:00 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260714_0003" +down_revision: Union[str, None] = "20260711_0002" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "users", + sa.Column("is_superuser", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + bind = op.get_bind() + if bind.dialect.name != "sqlite": + op.alter_column("users", "is_superuser", server_default=None) + op.execute( + sa.text( + "UPDATE users SET is_superuser = true " + "WHERE email = 'admin@compton.example' AND role = 'admin'" + ) + ) + + +def downgrade() -> None: + op.drop_column("users", "is_superuser") diff --git a/apps/api/migrations/versions/20260714_0004_db_security_constraints.py b/apps/api/migrations/versions/20260714_0004_db_security_constraints.py new file mode 100644 index 0000000..f952031 --- /dev/null +++ b/apps/api/migrations/versions/20260714_0004_db_security_constraints.py @@ -0,0 +1,55 @@ +"""db security constraints and token indexes + +Revision ID: 20260714_0004 +Revises: 20260714_0003 +Create Date: 2026-07-14 13:30:00 +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "20260714_0004" +down_revision: Union[str, None] = "20260714_0003" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + if bind.dialect.name != "sqlite": + op.create_check_constraint( + "ck_users_role_allowed", + "users", + "role IN ('user', 'admin')", + ) + op.create_check_constraint( + "ck_users_status_allowed", + "users", + "status IN ('pending', 'active', 'blocked')", + ) + op.create_check_constraint( + "ck_users_superuser_requires_admin", + "users", + "(is_superuser = false) OR (role = 'admin')", + ) + op.create_check_constraint( + "ck_content_pages_status_allowed", + "content_pages", + "status IN ('draft', 'published')", + ) + op.create_index("idx_refresh_tokens_expires_at", "refresh_tokens", ["expires_at"], unique=False) + op.create_index("idx_password_reset_expires_at", "password_reset_tokens", ["expires_at"], unique=False) + op.create_index("idx_email_verify_expires_at", "email_verification_tokens", ["expires_at"], unique=False) + + +def downgrade() -> None: + op.drop_index("idx_email_verify_expires_at", table_name="email_verification_tokens") + op.drop_index("idx_password_reset_expires_at", table_name="password_reset_tokens") + op.drop_index("idx_refresh_tokens_expires_at", table_name="refresh_tokens") + bind = op.get_bind() + if bind.dialect.name != "sqlite": + op.drop_constraint("ck_content_pages_status_allowed", "content_pages", type_="check") + op.drop_constraint("ck_users_superuser_requires_admin", "users", type_="check") + op.drop_constraint("ck_users_status_allowed", "users", type_="check") + op.drop_constraint("ck_users_role_allowed", "users", type_="check") diff --git a/apps/api/migrations/versions/20260714_0005_backfill_superuser_admin.py b/apps/api/migrations/versions/20260714_0005_backfill_superuser_admin.py new file mode 100644 index 0000000..9c77707 --- /dev/null +++ b/apps/api/migrations/versions/20260714_0005_backfill_superuser_admin.py @@ -0,0 +1,35 @@ +"""backfill superuser flag for seeded admin + +Revision ID: 20260714_0005 +Revises: 20260714_0004 +Create Date: 2026-07-14 14:00:00 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260714_0005" +down_revision: Union[str, None] = "20260714_0004" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + sa.text( + "UPDATE users SET is_superuser = true " + "WHERE email = 'admin@compton.example' AND role = 'admin'" + ) + ) + op.execute( + sa.text( + "UPDATE users SET is_superuser = false " + "WHERE email IN ('ops@compton.example', 'user@compton.example')" + ) + ) + + +def downgrade() -> None: + pass diff --git a/apps/api/migrations/versions/20260715_0006_enterprise_tenant_schema.py b/apps/api/migrations/versions/20260715_0006_enterprise_tenant_schema.py new file mode 100644 index 0000000..16fd2bb --- /dev/null +++ b/apps/api/migrations/versions/20260715_0006_enterprise_tenant_schema.py @@ -0,0 +1,367 @@ +"""enterprise tenant and sync durability schema + +Revision ID: 20260715_0006 +Revises: 20260714_0005 +Create Date: 2026-07-15 12:00:00 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260715_0006" +down_revision: Union[str, None] = "20260714_0005" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "enterprises", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("slug", sa.String(length=64), nullable=False), + sa.Column("status", sa.String(length=16), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("slug"), + ) + op.create_index("idx_enterprises_slug", "enterprises", ["slug"], unique=True) + op.create_index("idx_enterprises_status", "enterprises", ["status"], unique=False) + + op.create_table( + "enterprise_members", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("user_id", sa.String(length=36), nullable=False), + sa.Column("enterprise_id", sa.String(length=36), nullable=False), + sa.Column("role", sa.String(length=32), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["enterprise_id"], ["enterprises.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", "enterprise_id", name="uq_enterprise_members_user_enterprise"), + ) + op.create_index("idx_enterprise_members_user", "enterprise_members", ["user_id"], unique=False) + op.create_index("idx_enterprise_members_enterprise", "enterprise_members", ["enterprise_id"], unique=False) + + op.create_table( + "farm_hubs", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("enterprise_id", sa.String(length=36), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("hub_site_id", sa.String(length=36), nullable=False), + sa.Column("url", sa.String(length=512), nullable=True), + sa.Column("status", sa.String(length=16), nullable=False), + sa.Column("last_seen", sa.DateTime(timezone=True), nullable=True), + sa.Column("wesp_version", sa.String(length=64), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["enterprise_id"], ["enterprises.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("hub_site_id"), + ) + op.create_index("idx_farm_hubs_enterprise", "farm_hubs", ["enterprise_id"], unique=False) + op.create_index("idx_farm_hubs_status", "farm_hubs", ["status"], unique=False) + + op.create_table( + "user_farm_access", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("user_id", sa.String(length=36), nullable=False), + sa.Column("farm_hub_id", sa.String(length=36), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["farm_hub_id"], ["farm_hubs.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", "farm_hub_id", name="uq_user_farm_access_user_farm"), + ) + op.create_index("idx_user_farm_access_user", "user_farm_access", ["user_id"], unique=False) + op.create_index("idx_user_farm_access_farm", "user_farm_access", ["farm_hub_id"], unique=False) + + op.create_table( + "hub_credentials", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("farm_hub_id", sa.String(length=36), nullable=False), + sa.Column("secret_hash", sa.String(length=255), nullable=False), + sa.Column("paired_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["farm_hub_id"], ["farm_hubs.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("idx_hub_credentials_farm", "hub_credentials", ["farm_hub_id"], unique=False) + + op.create_table( + "hub_pairing_sessions", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("enterprise_id", sa.String(length=36), nullable=False), + sa.Column("code", sa.String(length=6), nullable=False), + sa.Column("code_hash", sa.String(length=64), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("confirmed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("farm_hub_id", sa.String(length=36), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["enterprise_id"], ["enterprises.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["farm_hub_id"], ["farm_hubs.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("idx_hub_pairing_code", "hub_pairing_sessions", ["code"], unique=False) + op.create_index("idx_hub_pairing_enterprise", "hub_pairing_sessions", ["enterprise_id"], unique=False) + + _create_sync_tables() + _create_zootech_tables() + + bind = op.get_bind() + if bind.dialect.name == "postgresql": + _create_rls_policies() + + +def _create_sync_tables() -> None: + op.create_table( + "sync_outbox", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("enterprise_id", sa.String(length=36), nullable=False), + sa.Column("event_id", sa.String(length=36), nullable=False), + sa.Column("origin", sa.String(length=32), nullable=False), + sa.Column("origin_site_id", sa.String(length=36), nullable=True), + sa.Column("seq", sa.BigInteger(), nullable=False), + sa.Column("domain", sa.String(length=16), nullable=False), + sa.Column("table_name", sa.String(length=64), nullable=False), + sa.Column("record_id", sa.String(length=64), nullable=False), + sa.Column("action", sa.String(length=16), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("content_hash", sa.String(length=64), nullable=False), + sa.Column("payload_json", sa.Text(), nullable=False), + sa.Column("status", sa.String(length=16), nullable=False), + sa.Column("emitted_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["enterprise_id"], ["enterprises.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("event_id"), + ) + op.create_index("idx_sync_outbox_enterprise_status", "sync_outbox", ["enterprise_id", "status"], unique=False) + op.create_index("idx_sync_outbox_table_record", "sync_outbox", ["table_name", "record_id"], unique=False) + + op.create_table( + "sync_event_log", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("enterprise_id", sa.String(length=36), nullable=False), + sa.Column("event_id", sa.String(length=36), nullable=False), + sa.Column("origin", sa.String(length=32), nullable=False), + sa.Column("origin_site_id", sa.String(length=36), nullable=True), + sa.Column("seq", sa.BigInteger(), nullable=False), + sa.Column("domain", sa.String(length=16), nullable=False), + sa.Column("table_name", sa.String(length=64), nullable=False), + sa.Column("record_id", sa.String(length=64), nullable=False), + sa.Column("action", sa.String(length=16), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("content_hash", sa.String(length=64), nullable=False), + sa.Column("payload_json", sa.Text(), nullable=False), + sa.Column("received_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["enterprise_id"], ["enterprises.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("event_id"), + ) + op.create_index("idx_sync_event_log_enterprise", "sync_event_log", ["enterprise_id"], unique=False) + op.create_index("idx_sync_event_log_seq", "sync_event_log", ["seq"], unique=False) + + op.create_table( + "sync_applied_events", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("enterprise_id", sa.String(length=36), nullable=False), + sa.Column("site_id", sa.String(length=36), nullable=False), + sa.Column("event_id", sa.String(length=36), nullable=False), + sa.Column("applied_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["enterprise_id"], ["enterprises.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("site_id", "event_id", name="uq_sync_applied_events_site_event"), + ) + op.create_index("idx_sync_applied_enterprise", "sync_applied_events", ["enterprise_id"], unique=False) + + op.create_table( + "sync_cursors", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("enterprise_id", sa.String(length=36), nullable=False), + sa.Column("farm_hub_id", sa.String(length=36), nullable=False), + sa.Column("direction", sa.String(length=16), nullable=False), + sa.Column("last_acked_seq", sa.BigInteger(), nullable=False), + sa.Column("last_pulled_seq", sa.BigInteger(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["enterprise_id"], ["enterprises.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["farm_hub_id"], ["farm_hubs.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("farm_hub_id", "direction", name="uq_sync_cursors_hub_direction"), + ) + + op.create_table( + "sync_record_state", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("enterprise_id", sa.String(length=36), nullable=False), + sa.Column("table_name", sa.String(length=64), nullable=False), + sa.Column("record_id", sa.String(length=64), nullable=False), + sa.Column("agreed_version", sa.Integer(), nullable=False), + sa.Column("agreed_hash", sa.String(length=64), nullable=False), + sa.Column("last_event_id", sa.String(length=36), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["enterprise_id"], ["enterprises.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("enterprise_id", "table_name", "record_id", name="uq_sync_record_state_ent_table_record"), + ) + + op.create_table( + "sync_conflicts", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("enterprise_id", sa.String(length=36), nullable=False), + sa.Column("table_name", sa.String(length=64), nullable=False), + sa.Column("record_id", sa.String(length=64), nullable=False), + sa.Column("farm_hub_id", sa.String(length=36), nullable=True), + sa.Column("orchestrator_snapshot_json", sa.Text(), nullable=False), + sa.Column("hub_snapshot_json", sa.Text(), nullable=False), + sa.Column("held_event_ids_json", sa.Text(), nullable=False), + sa.Column("status", sa.String(length=16), nullable=False), + sa.Column("resolution", sa.String(length=32), nullable=True), + sa.Column("resolved_by", sa.String(length=36), nullable=True), + sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["enterprise_id"], ["enterprises.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["farm_hub_id"], ["farm_hubs.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("idx_sync_conflicts_status", "sync_conflicts", ["status"], unique=False) + op.create_index("idx_sync_conflicts_record", "sync_conflicts", ["record_id"], unique=False) + + op.create_table( + "sync_reconcile_runs", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("enterprise_id", sa.String(length=36), nullable=False), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("mismatches_count", sa.Integer(), nullable=False), + sa.Column("auto_repaired", sa.Integer(), nullable=False), + sa.Column("status", sa.String(length=16), nullable=False), + sa.ForeignKeyConstraint(["enterprise_id"], ["enterprises.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + + op.create_table( + "report_sync_state", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("farm_hub_id", sa.String(length=36), nullable=False), + sa.Column("enterprise_id", sa.String(length=36), nullable=False), + sa.Column("last_report_cursor", sa.BigInteger(), nullable=False), + sa.Column("last_pull_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["enterprise_id"], ["enterprises.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["farm_hub_id"], ["farm_hubs.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("farm_hub_id"), + ) + + +def _create_zootech_tables() -> None: + op.create_table( + "zootech_component", + sa.Column("enterprise_id", sa.String(length=36), nullable=False), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("type", sa.String(length=100), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("dry_matter", sa.Float(), nullable=False), + sa.Column("protein", sa.Float(), nullable=False), + sa.Column("energy", sa.Float(), nullable=False), + sa.Column("price", sa.Float(), nullable=False), + sa.Column("external_no", sa.Integer(), nullable=True), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("content_hash", sa.String(length=64), nullable=False), + sa.Column("is_deleted", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("enterprise_id", "id", name="uq_zootech_component_ent_id"), + ) + op.create_index("idx_zootech_component_enterprise", "zootech_component", ["enterprise_id"], unique=False) + + op.create_table( + "zootech_recipe", + sa.Column("enterprise_id", sa.String(length=36), nullable=False), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("heads_per_trip", sa.Integer(), nullable=False), + sa.Column("mixing_time", sa.Integer(), nullable=False), + sa.Column("trip_percent", sa.Float(), nullable=False), + sa.Column("dry_matter_locked", sa.Boolean(), nullable=False), + sa.Column("unloading_link_broken", sa.Boolean(), nullable=False), + sa.Column("target_component_id", sa.String(length=36), nullable=True), + sa.Column("ration_type", sa.String(length=10), nullable=True), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("content_hash", sa.String(length=64), nullable=False), + sa.Column("is_deleted", sa.Boolean(), nullable=False), + sa.Column("payload_json", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("enterprise_id", "id", name="uq_zootech_recipe_ent_id"), + ) + op.create_index("idx_zootech_recipe_enterprise", "zootech_recipe", ["enterprise_id"], unique=False) + + op.create_table( + "zootech_ingredient", + sa.Column("enterprise_id", sa.String(length=36), nullable=False), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("recipe_id", sa.String(length=36), nullable=False), + sa.Column("component_id", sa.String(length=36), nullable=True), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("amount", sa.Float(), nullable=False), + sa.Column("weight_per_head", sa.Float(), nullable=True), + sa.Column("dry_matter", sa.Float(), nullable=False), + sa.Column("dry_matter_per_head", sa.Float(), nullable=True), + sa.Column("order", sa.Integer(), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("content_hash", sa.String(length=64), nullable=False), + sa.Column("is_deleted", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("enterprise_id", "id", name="uq_zootech_ingredient_ent_id"), + ) + op.create_index("idx_zootech_ingredient_enterprise", "zootech_ingredient", ["enterprise_id"], unique=False) + op.create_index("idx_zootech_ingredient_recipe", "zootech_ingredient", ["recipe_id"], unique=False) + + +def _create_rls_policies() -> None: + for table in ("zootech_component", "zootech_recipe", "zootech_ingredient"): + op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY") + op.execute( + f""" + CREATE POLICY tenant_isolation_{table} ON {table} + USING (enterprise_id = current_setting('app.enterprise_id', true)) + WITH CHECK (enterprise_id = current_setting('app.enterprise_id', true)) + """ + ) + + +def downgrade() -> None: + bind = op.get_bind() + if bind.dialect.name == "postgresql": + for table in ("zootech_component", "zootech_recipe", "zootech_ingredient"): + op.execute(f"DROP POLICY IF EXISTS tenant_isolation_{table} ON {table}") + + for table in ( + "zootech_ingredient", + "zootech_recipe", + "zootech_component", + "report_sync_state", + "sync_reconcile_runs", + "sync_conflicts", + "sync_record_state", + "sync_cursors", + "sync_applied_events", + "sync_event_log", + "sync_outbox", + "hub_pairing_sessions", + "hub_credentials", + "user_farm_access", + "farm_hubs", + "enterprise_members", + "enterprises", + ): + op.drop_table(table) diff --git a/apps/api/migrations/versions/20260716_0007_zootech_full_catalog.py b/apps/api/migrations/versions/20260716_0007_zootech_full_catalog.py new file mode 100644 index 0000000..f2150f1 --- /dev/null +++ b/apps/api/migrations/versions/20260716_0007_zootech_full_catalog.py @@ -0,0 +1,180 @@ +"""zootech full catalog tables + +Revision ID: 20260716_0007 +Revises: 20260715_0006 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260716_0007" +down_revision: Union[str, None] = "20260715_0006" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _simple_catalog(name: str, uq: str) -> None: + op.create_table( + name, + sa.Column("enterprise_id", sa.String(length=36), nullable=False), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("content_hash", sa.String(length=64), nullable=False), + sa.Column("is_deleted", sa.Boolean(), nullable=False), + sa.Column("payload_json", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("enterprise_id", "id", name=uq), + ) + op.create_index(f"idx_{name}_enterprise", name, ["enterprise_id"]) + + +def upgrade() -> None: + op.create_table( + "zootech_unloading_group", + sa.Column("enterprise_id", sa.String(length=36), nullable=False), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("recipe_id", sa.String(length=36), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("content_hash", sa.String(length=64), nullable=False), + sa.Column("is_deleted", sa.Boolean(), nullable=False), + sa.Column("payload_json", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("enterprise_id", "id", name="uq_zootech_unloading_group_ent_id"), + ) + op.create_table( + "zootech_feed_mixer", + sa.Column("enterprise_id", sa.String(length=36), nullable=False), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("content_hash", sa.String(length=64), nullable=False), + sa.Column("is_deleted", sa.Boolean(), nullable=False), + sa.Column("payload_json", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("enterprise_id", "id", name="uq_zootech_feed_mixer_ent_id"), + ) + op.create_table( + "zootech_feed_dispenser", + sa.Column("enterprise_id", sa.String(length=36), nullable=False), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("content_hash", sa.String(length=64), nullable=False), + sa.Column("is_deleted", sa.Boolean(), nullable=False), + sa.Column("payload_json", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("enterprise_id", "id", name="uq_zootech_feed_dispenser_ent_id"), + ) + op.create_table( + "zootech_feeding_location", + sa.Column("enterprise_id", sa.String(length=36), nullable=False), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("content_hash", sa.String(length=64), nullable=False), + sa.Column("is_deleted", sa.Boolean(), nullable=False), + sa.Column("payload_json", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("enterprise_id", "id", name="uq_zootech_feeding_location_ent_id"), + ) + op.create_table( + "zootech_feeding_period", + sa.Column("enterprise_id", sa.String(length=36), nullable=False), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("dispenser_id", sa.String(length=36), nullable=True), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("content_hash", sa.String(length=64), nullable=False), + sa.Column("is_deleted", sa.Boolean(), nullable=False), + sa.Column("payload_json", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("enterprise_id", "id", name="uq_zootech_feeding_period_ent_id"), + ) + op.create_table( + "zootech_feeding_point", + sa.Column("enterprise_id", sa.String(length=36), nullable=False), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("period_id", sa.String(length=36), nullable=True), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("content_hash", sa.String(length=64), nullable=False), + sa.Column("is_deleted", sa.Boolean(), nullable=False), + sa.Column("payload_json", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("enterprise_id", "id", name="uq_zootech_feeding_point_ent_id"), + ) + op.create_table( + "zootech_period_recipes", + sa.Column("enterprise_id", sa.String(length=36), nullable=False), + sa.Column("period_id", sa.String(length=36), nullable=False), + sa.Column("recipe_id", sa.String(length=36), nullable=False), + sa.Column("order", sa.Integer(), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("content_hash", sa.String(length=64), nullable=False), + sa.Column("is_deleted", sa.Boolean(), nullable=False), + sa.Column("payload_json", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("period_id", "recipe_id"), + sa.UniqueConstraint("enterprise_id", "period_id", "recipe_id", name="uq_zootech_period_recipes_ent"), + ) + op.create_table( + "zootech_trip", + sa.Column("enterprise_id", sa.String(length=36), nullable=False), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("mixer_id", sa.String(length=36), nullable=True), + sa.Column("recipe_id", sa.String(length=36), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("content_hash", sa.String(length=64), nullable=False), + sa.Column("is_deleted", sa.Boolean(), nullable=False), + sa.Column("payload_json", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("enterprise_id", "id", name="uq_zootech_trip_ent_id"), + ) + for tbl, uq in ( + ("zootech_daily_trip_skip", "uq_zootech_daily_trip_skip_ent_id"), + ("zootech_daily_ingredient_skip", "uq_zootech_daily_ingredient_skip_ent_id"), + ("zootech_daily_unloading_group_skip", "uq_zootech_daily_unloading_group_skip_ent_id"), + ("zootech_daily_ingredient_replacement", "uq_zootech_daily_ingredient_replacement_ent_id"), + ("zootech_daily_component_norm_adjustment", "uq_zootech_daily_component_norm_adjustment_ent_id"), + ): + _simple_catalog(tbl, uq) + + +def downgrade() -> None: + for tbl in ( + "zootech_daily_component_norm_adjustment", + "zootech_daily_ingredient_replacement", + "zootech_daily_unloading_group_skip", + "zootech_daily_ingredient_skip", + "zootech_daily_trip_skip", + "zootech_trip", + "zootech_period_recipes", + "zootech_feeding_point", + "zootech_feeding_period", + "zootech_feeding_location", + "zootech_feed_dispenser", + "zootech_feed_mixer", + "zootech_unloading_group", + ): + op.drop_table(tbl) diff --git a/apps/api/migrations/versions/20260716_0008_reports_abac.py b/apps/api/migrations/versions/20260716_0008_reports_abac.py new file mode 100644 index 0000000..edb9d2b --- /dev/null +++ b/apps/api/migrations/versions/20260716_0008_reports_abac.py @@ -0,0 +1,81 @@ +"""Report tables + report outbox + +Revision ID: 20260716_0008 +Revises: 20260716_0007 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260716_0008" +down_revision: Union[str, None] = "20260716_0007" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _report_table(name: str) -> None: + op.create_table( + name, + sa.Column("enterprise_id", sa.String(length=36), nullable=False), + sa.Column("farm_hub_id", sa.String(length=36), nullable=True), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("trip_id", sa.String(length=36), nullable=True), + sa.Column("status", sa.String(length=32), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("content_hash", sa.String(length=64), nullable=False), + sa.Column("payload_json", sa.Text(), nullable=True), + sa.Column("is_deleted", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("enterprise_id", "id", name=f"uq_{name}_ent_id"), + ) + op.create_index(f"idx_{name}_enterprise", name, ["enterprise_id"]) + op.create_index(f"idx_{name}_farm_hub", name, ["farm_hub_id"]) + + +def upgrade() -> None: + _report_table("zootech_loading_report") + _report_table("zootech_unloading_report") + op.create_table( + "zootech_feed_alert", + sa.Column("enterprise_id", sa.String(length=36), nullable=False), + sa.Column("farm_hub_id", sa.String(length=36), nullable=True), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("alert_type", sa.String(length=64), nullable=False), + sa.Column("severity", sa.String(length=16), nullable=False), + sa.Column("message", sa.Text(), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("content_hash", sa.String(length=64), nullable=False), + sa.Column("is_deleted", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("enterprise_id", "id", name="uq_zootech_feed_alert_ent_id"), + ) + op.create_table( + "report_outbox", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("enterprise_id", sa.String(length=36), nullable=False), + sa.Column("farm_hub_id", sa.String(length=36), nullable=False), + sa.Column("event_id", sa.String(length=36), nullable=False), + sa.Column("table_name", sa.String(length=64), nullable=False), + sa.Column("record_id", sa.String(length=64), nullable=False), + sa.Column("action", sa.String(length=16), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("content_hash", sa.String(length=64), nullable=False), + sa.Column("payload_json", sa.Text(), nullable=False), + sa.Column("status", sa.String(length=16), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("event_id"), + ) + + +def downgrade() -> None: + op.drop_table("report_outbox") + op.drop_table("zootech_feed_alert") + op.drop_table("zootech_unloading_report") + op.drop_table("zootech_loading_report") diff --git a/apps/api/migrations/versions/20260716_0009_org_settings.py b/apps/api/migrations/versions/20260716_0009_org_settings.py new file mode 100644 index 0000000..20b5fa8 --- /dev/null +++ b/apps/api/migrations/versions/20260716_0009_org_settings.py @@ -0,0 +1,45 @@ +"""org settings + feed quality (enterprise scoped) + +Revision ID: 20260716_0009 +Revises: 20260716_0008 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260716_0009" +down_revision: Union[str, None] = "20260716_0008" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "zootech_org_settings", + sa.Column("enterprise_id", sa.String(length=36), nullable=False), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("payload_json", sa.Text(), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("content_hash", sa.String(length=64), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("enterprise_id", name="uq_zootech_org_settings_enterprise"), + ) + op.create_table( + "zootech_feed_quality_settings", + sa.Column("enterprise_id", sa.String(length=36), nullable=False), + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("payload_json", sa.Text(), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column("content_hash", sa.String(length=64), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("enterprise_id", name="uq_zootech_feed_quality_settings_enterprise"), + ) + + +def downgrade() -> None: + op.drop_table("zootech_feed_quality_settings") + op.drop_table("zootech_org_settings") diff --git a/apps/api/migrations/versions/20260716_0010_sync_record_id_length.py b/apps/api/migrations/versions/20260716_0010_sync_record_id_length.py new file mode 100644 index 0000000..4a40048 --- /dev/null +++ b/apps/api/migrations/versions/20260716_0010_sync_record_id_length.py @@ -0,0 +1,35 @@ +"""Widen sync record_id for composite keys (period_recipes uuid:uuid).""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20260716_0010" +down_revision = "20260716_0009" +branch_labels = None +depends_on = None + +_TABLES = ("sync_outbox", "sync_event_log", "sync_record_state", "sync_conflicts") + + +def upgrade() -> None: + for table in _TABLES: + op.alter_column( + table, + "record_id", + existing_type=sa.String(length=64), + type_=sa.String(length=128), + existing_nullable=False, + ) + + +def downgrade() -> None: + for table in reversed(_TABLES): + op.alter_column( + table, + "record_id", + existing_type=sa.String(length=128), + type_=sa.String(length=64), + existing_nullable=False, + ) diff --git a/apps/api/migrations/versions/20260716_0011_feed_alert_payload_json.py b/apps/api/migrations/versions/20260716_0011_feed_alert_payload_json.py new file mode 100644 index 0000000..8efb7ab --- /dev/null +++ b/apps/api/migrations/versions/20260716_0011_feed_alert_payload_json.py @@ -0,0 +1,23 @@ +"""Add payload_json to zootech_feed_alert + +Revision ID: 20260716_0011 +Revises: 20260716_0010 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "20260716_0011" +down_revision: Union[str, None] = "20260716_0010" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("zootech_feed_alert", sa.Column("payload_json", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("zootech_feed_alert", "payload_json") diff --git a/apps/api/pyproject.toml b/apps/api/pyproject.toml new file mode 100644 index 0000000..469e4d0 --- /dev/null +++ b/apps/api/pyproject.toml @@ -0,0 +1,13 @@ +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] + +[tool.mypy] +python_version = "3.12" +warn_unused_configs = true +check_untyped_defs = true +ignore_missing_imports = true diff --git a/apps/api/requirements-dev.txt b/apps/api/requirements-dev.txt new file mode 100644 index 0000000..e6a40e1 --- /dev/null +++ b/apps/api/requirements-dev.txt @@ -0,0 +1,26 @@ +fastapi>=0.115.0 +uvicorn>=0.32.0 +sqlalchemy>=2.0.36 +alembic>=1.13.3 +psycopg[binary]>=3.2.0 +asyncpg>=0.30.0 +redis>=5.1.1 +python-jose>=3.3.0 +bcrypt>=4.0.0,<5.0.0 +pydantic>=2.9.2 +email-validator>=2.2.0 +pydantic-settings>=2.6.0 +httpx>=0.27.2 +pytest>=8.3.3 +pytest-asyncio>=0.24.0 +pytest-cov>=5.0.0 +mypy>=1.11.2 +ruff>=0.7.1 +pip-audit>=2.7.3 +bandit>=1.7.9 +bleach>=6.1.0 +python-multipart>=0.0.12 +boto3>=1.35.0 +Pillow>=10.4.0 +celery>=5.4.0 +psutil>=6.0.0 diff --git a/apps/api/scripts/bootstrap_install.py b/apps/api/scripts/bootstrap_install.py new file mode 100644 index 0000000..e64fad6 --- /dev/null +++ b/apps/api/scripts/bootstrap_install.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from app.core.install_secrets import ensure_install_secrets + + +def main() -> None: + status = ensure_install_secrets() + if status.created: + print(f"Created install secrets at {status.path}") + print( + "\nIf docker compose was already started without install.env, reset the Postgres volume " + "before the next start (local dev only — deletes DB data):\n" + " docker compose --profile docker-web down -v\n" + " docker compose --profile docker-web up -d --build" + ) + else: + print(f"Install secrets already exist at {status.path}") + + +if __name__ == "__main__": + main() diff --git a/apps/api/scripts/docker_entrypoint.py b/apps/api/scripts/docker_entrypoint.py new file mode 100644 index 0000000..59423cb --- /dev/null +++ b/apps/api/scripts/docker_entrypoint.py @@ -0,0 +1,135 @@ +"""Docker entrypoint: wait for DB, reconcile Alembic state, migrate, start API.""" + +from __future__ import annotations + +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from alembic import command +from alembic.config import Config +from sqlalchemy import create_engine, inspect, text + +from app.core.install_secrets import ensure_install_secrets +from app.core.config import settings +from app.core.server_logging import append_server_log_line, build_uvicorn_log_config, ensure_server_log_file + +INITIAL_REVISION = "20260711_0001" +HEAD_REVISION = "20260716_0009" +SCHEMA_TABLES = ( + "content_pages", + "email_verification_tokens", + "password_reset_tokens", + "refresh_tokens", + "user_profiles", + "users", + "enterprises", + "enterprise_members", + "farm_hubs", + "user_farm_access", + "hub_credentials", + "hub_pairing_sessions", + "sync_outbox", + "sync_event_log", + "sync_applied_events", + "sync_cursors", + "sync_record_state", + "sync_conflicts", + "sync_reconcile_runs", + "report_sync_state", + "zootech_component", + "zootech_recipe", + "zootech_ingredient", +) + + +def wait_for_database(max_attempts: int = 30, delay_seconds: float = 1.0): + if settings.database_url.startswith("sqlite"): + raise RuntimeError( + "DATABASE_URL points to SQLite — Docker API requires PostgreSQL.\n" + "Your apps/api/data/secrets/install.env was likely overwritten by a local test.\n" + "Fix:\n" + " rm apps/api/data/secrets/install.env\n" + " python apps/api/scripts/bootstrap_install.py\n" + " docker compose --profile docker-web down -v\n" + " docker compose --profile docker-web up -d --build" + ) + engine = create_engine(settings.database_url) + last_error: Exception | None = None + for _ in range(max_attempts): + try: + with engine.connect() as connection: + connection.execute(text("SELECT 1")) + return engine + except Exception as exc: + last_error = exc + time.sleep(delay_seconds) + detail = str(last_error or "unknown error") + hint = "" + if "password authentication failed" in detail or "does not exist" in detail: + hint = ( + "\n\nPostgres credentials in install.env do not match the existing database volume " + "(common if docker compose ran before bootstrap_install.py).\n" + "Local dev fix — deletes Docker DB data:\n" + " docker compose --profile docker-web down -v\n" + " docker compose --profile docker-web up -d --build\n" + "See docs/deploy.md#восстановление-секретов" + ) + raise RuntimeError(f"Database is unavailable: {detail}{hint}") from last_error + + +def current_revision(engine) -> str | None: + inspector = inspect(engine) + if "alembic_version" not in inspector.get_table_names(): + return None + with engine.connect() as connection: + return connection.execute(text("SELECT version_num FROM alembic_version")).scalar() + + +def _reset_schema(engine) -> None: + cascade = " CASCADE" if engine.dialect.name == "postgresql" else "" + with engine.begin() as connection: + for table in SCHEMA_TABLES: + connection.execute(text(f'DROP TABLE IF EXISTS "{table}"{cascade}')) + connection.execute(text(f'DROP TABLE IF EXISTS "alembic_version"{cascade}')) + + +def run_migrations(engine) -> None: + config = Config("alembic.ini") + tables = set(inspect(engine).get_table_names()) + revision = current_revision(engine) + schema_tables = set(SCHEMA_TABLES) + existing_schema = tables & schema_tables + + if existing_schema: + if schema_tables.issubset(tables): + if revision is None: + command.stamp(config, INITIAL_REVISION) + else: + _reset_schema(engine) + + command.upgrade(config, "head") + + +def main() -> None: + ensure_install_secrets() + engine = wait_for_database() + run_migrations(engine) + ensure_server_log_file() + append_server_log_line("API starting (docker entrypoint)") + import uvicorn + + uvicorn.run( + "app.main:app", + host="0.0.0.0", + port=8000, + log_config=build_uvicorn_log_config(), + ) + + +if __name__ == "__main__": + main() diff --git a/apps/api/scripts/export_openapi.py b/apps/api/scripts/export_openapi.py new file mode 100644 index 0000000..71664e1 --- /dev/null +++ b/apps/api/scripts/export_openapi.py @@ -0,0 +1,14 @@ +from pathlib import Path +import json + +from app.main import app + + +def main() -> None: + schema = app.openapi() + output = Path(__file__).resolve().parent.parent / "openapi.json" + output.write_text(json.dumps(schema, indent=2), encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/apps/api/scripts/migrate.py b/apps/api/scripts/migrate.py new file mode 100644 index 0000000..6e41242 --- /dev/null +++ b/apps/api/scripts/migrate.py @@ -0,0 +1,13 @@ +"""Apply Alembic migrations.""" + +from alembic import command +from alembic.config import Config + + +def main() -> None: + config = Config("alembic.ini") + command.upgrade(config, "head") + + +if __name__ == "__main__": + main() diff --git a/apps/api/scripts/start_e2e_api.py b/apps/api/scripts/start_e2e_api.py new file mode 100644 index 0000000..e8fe123 --- /dev/null +++ b/apps/api/scripts/start_e2e_api.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +API_DIR = Path(__file__).resolve().parents[1] +os.chdir(API_DIR) +sys.path.insert(0, str(API_DIR)) + +db_file = API_DIR / ".e2e.sqlite" +if db_file.exists(): + db_file.unlink() + +os.environ["DATABASE_URL"] = f"sqlite+pysqlite:///{db_file.as_posix()}" +os.environ["EMAIL_DELIVERY_MODE"] = "memory" +os.environ["STORAGE_MODE"] = "memory" +os.environ["ENABLE_RATE_LIMIT"] = "false" +os.environ["ENABLE_TEST_ROUTES"] = "true" +web_port = os.environ.get("E2E_WEB_PORT", "5175") +os.environ["CORS_ORIGINS"] = f'["http://127.0.0.1:{web_port}","http://localhost:{web_port}"]' + +port = os.environ.get("E2E_API_PORT", "8001") + +subprocess.run( + [sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", port], + cwd=API_DIR, + check=True, +) diff --git a/apps/api/tests/conftest.py b/apps/api/tests/conftest.py new file mode 100644 index 0000000..b5ea772 --- /dev/null +++ b/apps/api/tests/conftest.py @@ -0,0 +1,55 @@ +import os +import tempfile +from pathlib import Path + +# Pytest must not pick up Docker install.env (postgresql@postgres) from the repo. +_test_secrets = Path(tempfile.mkdtemp(prefix="compton-pytest-secrets-")) +os.environ["COMPTON_INSTALL_SECRETS_DIR"] = str(_test_secrets) +os.environ["DATABASE_URL"] = "sqlite+pysqlite:///:memory:" +os.environ.setdefault("EMAIL_DELIVERY_MODE", "memory") + +from fastapi.testclient import TestClient +import pytest + +from app.core import database as db_module +from app.core.email import memory_mailer +from app.core.storage import memory_store +from app.db.base import Base +from app.db.seed import run_seed +from app.main import app + + +@pytest.fixture(scope="session", autouse=True) +def setup_database(): + Base.metadata.create_all(db_module.engine) + run_seed(include_demo_pages=False) + yield + Base.metadata.drop_all(db_module.engine) + + +@pytest.fixture(autouse=True) +def disable_rate_limits(monkeypatch): + from app.core.config import settings + + monkeypatch.setattr(settings, "enable_rate_limit", False) + monkeypatch.setattr(settings, "email_delivery_mode", "memory") + monkeypatch.setattr(settings, "storage_mode", "memory") + + +@pytest.fixture(autouse=True) +def clear_email_outbox(): + memory_mailer.clear() + yield + memory_mailer.clear() + + +@pytest.fixture(autouse=True) +def clear_storage(): + memory_store.clear() + yield + memory_store.clear() + + +@pytest.fixture() +def client() -> TestClient: + return TestClient(app) diff --git a/apps/api/tests/core/test_app_settings.py b/apps/api/tests/core/test_app_settings.py new file mode 100644 index 0000000..c52e006 --- /dev/null +++ b/apps/api/tests/core/test_app_settings.py @@ -0,0 +1,63 @@ +import json + +import pytest + +from app.core.app_settings import ( + _coerce_value, + apply_settings_to_app, + bootstrap_settings, + env_locks, + get_settings_payload, + write_settings, +) + + +def test_write_and_bootstrap_settings(tmp_path, monkeypatch): + settings_file = tmp_path / "compton_settings.json" + monkeypatch.setattr("app.core.app_settings.settings.compton_settings_path", str(settings_file)) + monkeypatch.delenv("ENABLE_DOCS", raising=False) + + merged = write_settings({"enable_docs": False, "log_level": "INFO"}) + assert merged["enable_docs"] is False + assert settings_file.exists() + + bootstrap_settings() + from app.core.config import settings + + assert settings.enable_docs is False + + +def test_env_locks_skip_locked_keys(tmp_path, monkeypatch): + settings_file = tmp_path / "compton_settings.json" + monkeypatch.setattr("app.core.app_settings.settings.compton_settings_path", str(settings_file)) + monkeypatch.setenv("ENABLE_DOCS", "true") + monkeypatch.setattr("app.core.app_settings.settings.enable_docs", True) + + write_settings({"enable_docs": False}) + payload = get_settings_payload() + assert payload["locks"]["enable_docs"] is True + assert payload["values"]["enable_docs"] is True + + +def test_apply_settings_coerces_list_and_bool(monkeypatch): + apply_settings_to_app({"enable_docs": "false", "cors_origins": "http://a.test,http://b.test"}) + from app.core.config import settings + + assert settings.enable_docs is False + assert settings.cors_origins == ["http://a.test", "http://b.test"] + assert isinstance(env_locks(), dict) + + +def test_read_settings_ignores_invalid_payload(tmp_path, monkeypatch): + settings_file = tmp_path / "compton_settings.json" + settings_file.write_text("[]", encoding="utf-8") + monkeypatch.setattr("app.core.app_settings.settings.compton_settings_path", str(settings_file)) + from app.core.app_settings import read_settings + + assert read_settings() == {} + + +def test_coerce_value_rejects_invalid_list(): + with pytest.raises(ValueError, match="INVALID_LIST_cors_origins"): + _coerce_value("cors_origins", 123) + diff --git a/apps/api/tests/core/test_audit_log.py b/apps/api/tests/core/test_audit_log.py new file mode 100644 index 0000000..8da07f4 --- /dev/null +++ b/apps/api/tests/core/test_audit_log.py @@ -0,0 +1,19 @@ +from app.core.audit_log import read_audit_events, write_audit_event + + +def test_audit_log_roundtrip(tmp_path, monkeypatch): + audit_file = tmp_path / "admin-audit.jsonl" + monkeypatch.setattr("app.core.audit_log.settings.admin_audit_log_path", str(audit_file)) + write_audit_event("admin.user.patch", "u1", "admin@example.com", {"target": "u2"}) + events = read_audit_events() + assert len(events) == 1 + assert events[0]["action"] == "admin.user.patch" + assert events[0]["details"]["target"] == "u2" + + +def test_read_audit_events_ignores_invalid_json(tmp_path, monkeypatch): + audit_file = tmp_path / "admin-audit.jsonl" + audit_file.write_text('{"action":"ok"}\nnot-json\n', encoding="utf-8") + monkeypatch.setattr("app.core.audit_log.settings.admin_audit_log_path", str(audit_file)) + events = read_audit_events() + assert len(events) == 1 diff --git a/apps/api/tests/core/test_dependencies.py b/apps/api/tests/core/test_dependencies.py new file mode 100644 index 0000000..cab7c9b --- /dev/null +++ b/apps/api/tests/core/test_dependencies.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import pytest +from fastapi import HTTPException +from fastapi.security import HTTPAuthorizationCredentials + +from app.core.dependencies import get_current_user, require_admin, require_superuser +from app.core.security import create_access_token, hash_password +from app.modules.users.repository import create_user, update_user + + +def test_get_current_user_rejects_missing_credentials(): + with pytest.raises(HTTPException) as exc: + get_current_user(None) + assert exc.value.status_code == 401 + + +def test_get_current_user_rejects_pending_user(): + user = create_user("pending-dep@example.com", hash_password("Valid123"), status="pending") + token = create_access_token(user.id, user.role, user.is_superuser) + credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token) + with pytest.raises(HTTPException) as exc: + get_current_user(credentials) + assert exc.value.status_code == 403 + assert exc.value.detail == "EMAIL_NOT_VERIFIED" + + +def test_get_current_user_rejects_blocked_user(): + user = create_user("blocked-dep@example.com", hash_password("Valid123"), status="active") + user.status = "blocked" + update_user(user) + token = create_access_token(user.id, user.role, user.is_superuser) + credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token) + with pytest.raises(HTTPException) as exc: + get_current_user(credentials) + assert exc.value.status_code == 403 + assert exc.value.detail == "ACCOUNT_BLOCKED" + + +def test_get_current_user_rejects_invalid_token(): + credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials="not-a-jwt") + with pytest.raises(HTTPException) as exc: + get_current_user(credentials) + assert exc.value.status_code == 401 + assert exc.value.detail == "INVALID_TOKEN" + + +def test_get_current_user_rejects_unknown_user(): + token = create_access_token("missing-user-id", "user", False) + credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=token) + with pytest.raises(HTTPException) as exc: + get_current_user(credentials) + assert exc.value.status_code == 401 + + +def test_require_admin_rejects_regular_user(): + user = create_user("regular-dep@example.com", hash_password("Valid123"), role="user", status="active") + with pytest.raises(HTTPException) as exc: + require_admin(user) + assert exc.value.status_code == 403 + + +def test_require_superuser_rejects_non_super_admin(): + user = create_user("ops-dep@example.com", hash_password("Valid123"), role="admin", status="active") + with pytest.raises(HTTPException) as exc: + require_superuser(user) + assert exc.value.status_code == 403 + assert exc.value.detail == "SUPERUSER_ONLY" diff --git a/apps/api/tests/core/test_email.py b/apps/api/tests/core/test_email.py new file mode 100644 index 0000000..482773c --- /dev/null +++ b/apps/api/tests/core/test_email.py @@ -0,0 +1,29 @@ +from unittest.mock import MagicMock, patch + +from app.core.email import SmtpMailer, get_mailer, memory_mailer, send_template_email + + +def test_memory_mailer_latest_token(): + memory_mailer.clear() + send_template_email( + to="user@example.com", + template="verify_email", + subject="Verify", + body="Open link\nTOKEN:abc123\n", + ) + assert memory_mailer.latest_token("user@example.com", "verify_email") == "abc123" + + +def test_smtp_mailer_sends_message(monkeypatch): + monkeypatch.setattr("app.core.email.settings.email_delivery_mode", "smtp") + monkeypatch.setattr("app.core.email.settings.smtp_from", "noreply@example.com") + monkeypatch.setattr("app.core.email.settings.smtp_host", "localhost") + monkeypatch.setattr("app.core.email.settings.smtp_port", 1025) + monkeypatch.setattr("app.core.email.settings.smtp_user", "") + monkeypatch.setattr("app.core.email.settings.smtp_password", "") + + smtp_instance = MagicMock() + with patch("app.core.email.smtplib.SMTP") as smtp_cls: + smtp_cls.return_value.__enter__.return_value = smtp_instance + get_mailer().send("user@example.com", "Subject", "Body", "verify_email") + smtp_instance.send_message.assert_called_once() diff --git a/apps/api/tests/core/test_install_secrets.py b/apps/api/tests/core/test_install_secrets.py new file mode 100644 index 0000000..cc9dbe1 --- /dev/null +++ b/apps/api/tests/core/test_install_secrets.py @@ -0,0 +1,47 @@ +"""Install secrets must never be poisoned by sqlite test DATABASE_URL.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from app.core import install_secrets as mod + + +def test_adopt_from_environment_ignores_sqlite(monkeypatch): + monkeypatch.setenv("DATABASE_URL", "sqlite+pysqlite:///:memory:") + adopted = mod._adopt_from_environment() + assert "DATABASE_URL" not in adopted + + +def test_ensure_install_secrets_rejects_sqlite_install_env(tmp_path, monkeypatch): + secrets_dir = tmp_path / "secrets" + secrets_dir.mkdir() + install_file = secrets_dir / "install.env" + install_file.write_text( + "DATABASE_URL=sqlite+pysqlite:////tmp/bad.db\n" + "JWT_ACCESS_SECRET=x\n" + "JWT_REFRESH_PEPPER=y\n" + "POSTGRES_PASSWORD=z\n" + "SECRETS_LOCKED=true\n", + encoding="utf-8", + ) + monkeypatch.setenv("COMPTON_INSTALL_SECRETS_DIR", str(secrets_dir)) + monkeypatch.setattr(mod, "INSTALL_SECRETS_DIR", secrets_dir) + monkeypatch.setattr(mod, "INSTALL_SECRETS_FILE", install_file) + with pytest.raises(RuntimeError, match="PostgreSQL"): + mod.ensure_install_secrets() + + +def test_docker_entrypoint_rejects_sqlite(monkeypatch): + from scripts import docker_entrypoint + + monkeypatch.setattr( + docker_entrypoint.settings, + "database_url", + "sqlite+pysqlite:////tmp/bad.db", + ) + with pytest.raises(RuntimeError, match="SQLite"): + docker_entrypoint.wait_for_database(max_attempts=1, delay_seconds=0) diff --git a/apps/api/tests/core/test_jwt_denylist.py b/apps/api/tests/core/test_jwt_denylist.py new file mode 100644 index 0000000..806a8f4 --- /dev/null +++ b/apps/api/tests/core/test_jwt_denylist.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from app.core import jwt_denylist as denylist_module +from app.core.jwt_denylist import ( + bump_auth_epoch, + deny_jti, + ensure_jwt_revocation_backend, + get_auth_epoch, + is_jti_denied, + revoke_access_token, + validate_access_claims, +) +from app.core.security import create_access_token, hash_password +from app.modules.users.repository import create_user + + +def test_auth_epoch_bump_invalidates_token(): + user = create_user("epoch-user@example.com", hash_password("Valid123"), status="active") + token = create_access_token(user.id, user.role, user.is_superuser) + from app.core.security import decode_access_token + + payload = decode_access_token(token) + validate_access_claims(payload) + + bump_auth_epoch(user.id) + with pytest.raises(ValueError, match="TOKEN_REVOKED"): + validate_access_claims(payload) + + +def test_deny_jti_blocks_specific_token(): + user = create_user("jti-user@example.com", hash_password("Valid123"), status="active") + token = create_access_token(user.id, user.role, user.is_superuser) + from app.core.security import decode_access_token + + payload = decode_access_token(token) + deny_jti(payload["jti"], payload["exp"]) + assert is_jti_denied(payload["jti"]) + with pytest.raises(ValueError, match="TOKEN_REVOKED"): + validate_access_claims(payload) + + +def test_revoke_access_token_helper(): + user = create_user("revoke-user@example.com", hash_password("Valid123"), status="active") + token = create_access_token(user.id, user.role, user.is_superuser) + revoke_access_token(token) + from app.core.security import decode_access_token + + with pytest.raises(ValueError, match="TOKEN_REVOKED"): + validate_access_claims(decode_access_token(token)) + + +def test_get_auth_epoch_defaults_to_zero(): + assert get_auth_epoch("missing-user-id") == 0 + + +def test_validate_access_claims_rejects_missing_sub(): + with pytest.raises(ValueError, match="INVALID_TOKEN"): + validate_access_claims({}) + + +def test_production_is_jti_denied_fail_closed(monkeypatch): + monkeypatch.setattr(denylist_module.settings, "app_env", "production") + mock_client = MagicMock() + mock_client.exists.side_effect = RuntimeError("redis down") + with patch("app.core.jwt_denylist.get_redis_client", return_value=mock_client): + assert is_jti_denied("any-jti") is True + + +def test_revoke_access_token_ignores_invalid_token(): + revoke_access_token("not-a-jwt") + + +def test_bump_auth_epoch_production_raises_when_redis_fails(monkeypatch): + monkeypatch.setattr(denylist_module.settings, "app_env", "production") + mock_client = MagicMock() + mock_client.incr.side_effect = RuntimeError("redis down") + with patch("app.core.jwt_denylist.get_redis_client", return_value=mock_client): + with pytest.raises(RuntimeError): + bump_auth_epoch("user-x") + + +def test_empty_jti_is_not_denied(): + assert is_jti_denied("") is False + deny_jti("", 9999999999) + + +def test_ensure_jwt_revocation_backend_requires_redis_in_production(monkeypatch): + monkeypatch.setattr(denylist_module.settings, "app_env", "production") + with patch("app.core.jwt_denylist.get_redis_client", return_value=None): + with pytest.raises(RuntimeError, match="Redis is required"): + ensure_jwt_revocation_backend() + + +def test_get_auth_epoch_production_raises_when_redis_fails(monkeypatch): + monkeypatch.setattr(denylist_module.settings, "app_env", "production") + mock_client = MagicMock() + mock_client.get.side_effect = RuntimeError("redis down") + with patch("app.core.jwt_denylist.get_redis_client", return_value=mock_client): + with pytest.raises(RuntimeError): + get_auth_epoch("user-y") + + +def test_deny_jti_production_raises_when_redis_fails(monkeypatch): + monkeypatch.setattr(denylist_module.settings, "app_env", "production") + mock_client = MagicMock() + mock_client.setex.side_effect = RuntimeError("redis down") + with patch("app.core.jwt_denylist.get_redis_client", return_value=mock_client): + with pytest.raises(RuntimeError): + deny_jti("jti-123", int(__import__("time").time()) + 3600) + + +def test_memory_denylist_purges_expired_jti(monkeypatch): + monkeypatch.setattr(denylist_module, "_memory_denied_jti", {"expired-jti": 1.0}) + assert is_jti_denied("expired-jti") is False + diff --git a/apps/api/tests/core/test_main.py b/apps/api/tests/core/test_main.py new file mode 100644 index 0000000..5297a1a --- /dev/null +++ b/apps/api/tests/core/test_main.py @@ -0,0 +1,9 @@ +from app.main import create_app +from app.core.config import settings + + +def test_create_app_includes_test_routes_when_enabled(monkeypatch): + monkeypatch.setattr(settings, "enable_test_routes", True) + app = create_app() + paths = set(app.openapi()["paths"]) + assert "/api/v1/test/emails/latest-token" in paths diff --git a/apps/api/tests/core/test_media_signing.py b/apps/api/tests/core/test_media_signing.py new file mode 100644 index 0000000..a1bf860 --- /dev/null +++ b/apps/api/tests/core/test_media_signing.py @@ -0,0 +1,38 @@ +from app.core.media_signing import build_signed_media_url, verify_signed_media +from app.core.config import settings +import time + + +def test_build_and_verify_signed_media_url(): + signed = build_signed_media_url("/api/v1/media/files/avatars/user/file.png") + assert signed is not None + assert "expires=" in signed + assert "sig=" in signed + + path = "avatars/user/file.png" + query = signed.split("?", 1)[1] + params = dict(part.split("=") for part in query.split("&")) + assert int(params["expires"]) - int(time.time()) <= settings.media_url_ttl_seconds + assert verify_signed_media(path, int(params["expires"]), params["sig"]) + + +def test_build_signed_media_url_none(): + assert build_signed_media_url(None) is None + + +def test_verify_signed_media_rejects_expired_signature(): + signed = build_signed_media_url("/api/v1/media/files/avatars/user/file.png") + assert signed is not None + path = "avatars/user/file.png" + query = signed.split("?", 1)[1] + params = dict(part.split("=") for part in query.split("&")) + assert not verify_signed_media(path, int(params["expires"]) - 10_000, params["sig"]) + + +def test_verify_signed_media_rejects_tampered_signature(): + signed = build_signed_media_url("/api/v1/media/files/avatars/user/file.png") + assert signed is not None + path = "avatars/user/file.png" + query = signed.split("?", 1)[1] + params = dict(part.split("=") for part in query.split("&")) + assert not verify_signed_media(path, int(params["expires"]), "invalid") diff --git a/apps/api/tests/core/test_media_signing_passthrough.py b/apps/api/tests/core/test_media_signing_passthrough.py new file mode 100644 index 0000000..7211764 --- /dev/null +++ b/apps/api/tests/core/test_media_signing_passthrough.py @@ -0,0 +1,6 @@ +from app.core.media_signing import build_signed_media_url + + +def test_build_signed_media_url_passthrough(): + external = "https://cdn.example.com/avatar.png" + assert build_signed_media_url(external) == external diff --git a/apps/api/tests/core/test_password_denylist.py b/apps/api/tests/core/test_password_denylist.py new file mode 100644 index 0000000..2fa550c --- /dev/null +++ b/apps/api/tests/core/test_password_denylist.py @@ -0,0 +1,15 @@ +from app.core.password_denylist import is_denied_password, load_denylist + + +def test_password_denylist_blocks_common_password(): + assert is_denied_password("password123") + + +def test_password_denylist_loads_custom_entries(tmp_path, monkeypatch): + denylist_file = tmp_path / "denylist.txt" + denylist_file.write_text("# comment\nCustomBad1\n", encoding="utf-8") + monkeypatch.setattr("app.core.password_denylist.settings.password_denylist_path", str(denylist_file)) + denylist = load_denylist() + assert "custombad1" in denylist + assert is_denied_password("CustomBad1") + diff --git a/apps/api/tests/core/test_production_guards.py b/apps/api/tests/core/test_production_guards.py new file mode 100644 index 0000000..e5a88cc --- /dev/null +++ b/apps/api/tests/core/test_production_guards.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import pytest + +from app.main import _assert_production_guards +from app.core.config import settings + + +@pytest.fixture(autouse=True) +def reset_app_env(monkeypatch): + monkeypatch.setattr(settings, "app_env", "development") + monkeypatch.setattr(settings, "enable_test_routes", True) + monkeypatch.setattr(settings, "enable_docs", True) + monkeypatch.setattr(settings, "enable_rate_limit", False) + monkeypatch.setattr(settings, "cookie_secure", False) + monkeypatch.setattr(settings, "jwt_access_secret", "dev-access-secret-32bytes-minimum!!") + monkeypatch.setattr(settings, "jwt_refresh_pepper", "dev-refresh-pepper-32bytes-minimum!!") + monkeypatch.setattr( + settings, + "database_url", + "postgresql+psycopg://compton_app:secret@postgres:5432/compton?sslmode=require", + ) + + +def test_production_guards_skip_in_development(): + _assert_production_guards() + + +def test_production_guards_reject_test_routes(monkeypatch): + monkeypatch.setattr(settings, "app_env", "production") + with pytest.raises(RuntimeError, match="ENABLE_TEST_ROUTES"): + _assert_production_guards() + + +def test_production_guards_reject_insecure_cookie(monkeypatch): + monkeypatch.setattr(settings, "app_env", "production") + monkeypatch.setattr(settings, "enable_test_routes", False) + monkeypatch.setattr(settings, "enable_docs", False) + monkeypatch.setattr(settings, "enable_rate_limit", True) + with pytest.raises(RuntimeError, match="COOKIE_SECURE"): + _assert_production_guards() + + +def test_production_guards_reject_default_db_credentials(monkeypatch): + monkeypatch.setattr(settings, "app_env", "production") + monkeypatch.setattr(settings, "enable_test_routes", False) + monkeypatch.setattr(settings, "enable_docs", False) + monkeypatch.setattr(settings, "enable_rate_limit", True) + monkeypatch.setattr(settings, "cookie_secure", True) + monkeypatch.setattr(settings, "database_url", "postgresql://user:pass@db/app?sslmode=require") + with pytest.raises(RuntimeError, match="Default database credentials"): + _assert_production_guards() + + +def test_production_guards_reject_placeholder_jwt(monkeypatch): + monkeypatch.setattr(settings, "app_env", "production") + monkeypatch.setattr(settings, "enable_test_routes", False) + monkeypatch.setattr(settings, "enable_docs", False) + monkeypatch.setattr(settings, "enable_rate_limit", True) + monkeypatch.setattr(settings, "cookie_secure", True) + monkeypatch.setattr(settings, "jwt_access_secret", "change-me-access") + with pytest.raises(RuntimeError, match="JWT_ACCESS_SECRET"): + _assert_production_guards() + + +def test_production_guards_reject_placeholder_refresh_pepper(monkeypatch): + monkeypatch.setattr(settings, "app_env", "production") + monkeypatch.setattr(settings, "enable_test_routes", False) + monkeypatch.setattr(settings, "enable_docs", False) + monkeypatch.setattr(settings, "enable_rate_limit", True) + monkeypatch.setattr(settings, "cookie_secure", True) + monkeypatch.setattr(settings, "jwt_access_secret", "prod-access-secret-32bytes-minimum!!") + monkeypatch.setattr(settings, "jwt_refresh_pepper", "change-me-pepper") + with pytest.raises(RuntimeError, match="JWT_REFRESH_PEPPER"): + _assert_production_guards() + + +def test_production_guards_reject_missing_sslmode(monkeypatch): + monkeypatch.setattr(settings, "app_env", "production") + monkeypatch.setattr(settings, "enable_test_routes", False) + monkeypatch.setattr(settings, "enable_docs", False) + monkeypatch.setattr(settings, "enable_rate_limit", True) + monkeypatch.setattr(settings, "cookie_secure", True) + monkeypatch.setattr(settings, "database_url", "postgresql://app:secret@db/app") + with pytest.raises(RuntimeError, match="sslmode=require"): + _assert_production_guards() diff --git a/apps/api/tests/core/test_redis.py b/apps/api/tests/core/test_redis.py new file mode 100644 index 0000000..0d1b6e9 --- /dev/null +++ b/apps/api/tests/core/test_redis.py @@ -0,0 +1,32 @@ +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import HTTPException, Request + +from app.core.config import settings +from app.core.redis import check_rate_limit, client_ip, _buckets + + +def test_check_rate_limit_uses_memory_fallback(monkeypatch): + monkeypatch.setattr(settings, "enable_rate_limit", True) + _buckets.clear() + with patch("app.core.redis.get_redis_client", return_value=None): + check_rate_limit("memory-key", limit=1, window_seconds=60) + with pytest.raises(HTTPException) as exc: + check_rate_limit("memory-key", limit=1, window_seconds=60) + assert exc.value.status_code == 429 + + +def test_client_ip_honors_trusted_proxy(monkeypatch): + monkeypatch.setattr(settings, "trusted_proxy_ips", "127.0.0.1") + request = MagicMock(spec=Request) + request.headers = {"X-Forwarded-For": "203.0.113.10, 127.0.0.1"} + request.client.host = "127.0.0.1" + assert client_ip(request) == "203.0.113.10" + + +def test_client_ip_unknown_without_client(): + request = MagicMock(spec=Request) + request.headers = {} + request.client = None + assert client_ip(request) == "unknown" diff --git a/apps/api/tests/core/test_server_logging.py b/apps/api/tests/core/test_server_logging.py new file mode 100644 index 0000000..e55a354 --- /dev/null +++ b/apps/api/tests/core/test_server_logging.py @@ -0,0 +1,18 @@ +from pathlib import Path + +from app.core.server_logging import build_uvicorn_log_config, ensure_server_log_file + + +def test_ensure_server_log_file(tmp_path, monkeypatch): + log_file = tmp_path / "logs" / "server.log" + monkeypatch.setattr("app.core.server_logging.settings.server_log_path", str(log_file)) + path = ensure_server_log_file() + assert path.is_file() + assert "server log initialized" in path.read_text(encoding="utf-8") + + +def test_build_uvicorn_log_config(tmp_path, monkeypatch): + log_file = tmp_path / "logs" / "server.log" + monkeypatch.setattr("app.core.server_logging.settings.server_log_path", str(log_file)) + cfg = build_uvicorn_log_config() + assert cfg["handlers"]["file"]["filename"] == str(log_file.resolve()) diff --git a/apps/api/tests/core/test_storage.py b/apps/api/tests/core/test_storage.py new file mode 100644 index 0000000..a0e33b0 --- /dev/null +++ b/apps/api/tests/core/test_storage.py @@ -0,0 +1,14 @@ +from app.core.storage import download_object, ensure_bucket, memory_store, upload_object + + +def test_ensure_bucket_noop_in_memory(): + ensure_bucket() + + +def test_memory_storage_roundtrip(): + memory_store.clear() + upload_object("avatars/test.png", b"abc", "image/png") + payload = download_object("avatars/test.png") + assert payload == (b"abc", "image/png") + assert download_object("missing") is None + diff --git a/apps/api/tests/db/test_seed.py b/apps/api/tests/db/test_seed.py new file mode 100644 index 0000000..9b78148 --- /dev/null +++ b/apps/api/tests/db/test_seed.py @@ -0,0 +1,32 @@ +from app.db.seed import run_seed +from app.modules.users.repository import get_user_by_email + + +def test_seed_ensures_admin_is_superuser(): + admin = get_user_by_email("admin@compton.example") + assert admin is not None + admin.is_superuser = False + from app.modules.users import repository + + repository.update_user(admin) + + run_seed(include_demo_pages=False) + + refreshed = get_user_by_email("admin@compton.example") + assert refreshed is not None + assert refreshed.is_superuser is True + + +def test_seed_ensures_ops_is_not_superuser(): + ops = get_user_by_email("ops@compton.example") + assert ops is not None + ops.is_superuser = True + from app.modules.users import repository + + repository.update_user(ops) + + run_seed(include_demo_pages=False) + + refreshed = get_user_by_email("ops@compton.example") + assert refreshed is not None + assert refreshed.is_superuser is False diff --git a/apps/api/tests/db/test_token_cleanup.py b/apps/api/tests/db/test_token_cleanup.py new file mode 100644 index 0000000..e4ba24c --- /dev/null +++ b/apps/api/tests/db/test_token_cleanup.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from app.core.database import session_scope +from app.db.token_cleanup import cleanup_expired_tokens +from app.modules.auth.models import EmailVerificationToken, PasswordResetToken, RefreshToken +from app.modules.users.repository import get_user_by_email + + +def test_cleanup_expired_tokens_removes_stale_rows(): + user = get_user_by_email("admin@compton.example") + now = datetime.now(UTC) + with session_scope() as db: + db.add( + RefreshToken( + user_id=user.id, + token_hash="f" * 64, + family_id="fam-cleanup-1", + expires_at=now - timedelta(days=2), + revoked_at=now - timedelta(days=2), + ) + ) + db.add( + PasswordResetToken( + user_id=user.id, + token_hash="e" * 64, + expires_at=now - timedelta(days=1), + used_at=None, + ) + ) + db.add( + EmailVerificationToken( + user_id=user.id, + token_hash="d" * 64, + expires_at=now - timedelta(days=1), + used_at=None, + ) + ) + + result = cleanup_expired_tokens(retention_days=1) + assert result["refresh_tokens_deleted"] >= 1 + assert result["password_reset_tokens_deleted"] >= 1 + assert result["email_verification_tokens_deleted"] >= 1 diff --git a/apps/api/tests/e2e/test_health.py b/apps/api/tests/e2e/test_health.py new file mode 100644 index 0000000..4c886af --- /dev/null +++ b/apps/api/tests/e2e/test_health.py @@ -0,0 +1,3 @@ +def test_health_smoke(client): + response = client.get("/api/v1/health") + assert response.status_code == 200 diff --git a/apps/api/tests/fixtures.py b/apps/api/tests/fixtures.py new file mode 100644 index 0000000..f61046b --- /dev/null +++ b/apps/api/tests/fixtures.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from io import BytesIO + +from PIL import Image + + +def make_test_png() -> bytes: + image = Image.new("RGB", (8, 8), color=(70, 129, 109)) + buffer = BytesIO() + image.save(buffer, format="PNG") + return buffer.getvalue() diff --git a/apps/api/tests/helpers.py b/apps/api/tests/helpers.py new file mode 100644 index 0000000..c08cff1 --- /dev/null +++ b/apps/api/tests/helpers.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from fastapi.testclient import TestClient + +from app.core.email import memory_mailer + + +def clear_sent_emails() -> None: + memory_mailer.clear() + + +def latest_token(recipient: str, template: str) -> str: + token = memory_mailer.latest_token(recipient, template) + if not token: + raise AssertionError(f"No {template} email sent to {recipient}") + return token + + +def register_and_verify(client: TestClient, email: str, password: str = "Valid123") -> None: + client.post("/api/v1/auth/register", json={"email": email, "password": password}) + token = latest_token(email, "verify_email") + response = client.post("/api/v1/auth/verify-email", json={"token": token}) + assert response.status_code == 200 + + +def register_verify_login(client: TestClient, email: str, password: str = "Valid123") -> dict[str, str]: + register_and_verify(client, email, password) + login = client.post("/api/v1/auth/login", json={"email": email, "password": password}) + assert login.status_code == 200 + return {"Authorization": f"Bearer {login.json()['access_token']}"} diff --git a/apps/api/tests/modules/admin/test_admin_password_policy.py b/apps/api/tests/modules/admin/test_admin_password_policy.py new file mode 100644 index 0000000..bc5a6ce --- /dev/null +++ b/apps/api/tests/modules/admin/test_admin_password_policy.py @@ -0,0 +1,26 @@ +from __future__ import annotations + + +def _super_headers(client) -> dict[str, str]: + login = client.post( + "/api/v1/auth/login", + json={"email": "admin@compton.example", "password": "Admin1234"}, + ) + assert login.status_code == 200 + return {"Authorization": f"Bearer {login.json()['access_token']}"} + + +def test_admin_create_user_rejects_weak_password(client): + headers = _super_headers(client) + response = client.post( + "/api/v1/admin/users", + headers=headers, + json={ + "email": "weak-pass@example.com", + "password": "password", + "role": "user", + "is_superuser": False, + "status": "active", + }, + ) + assert response.status_code == 422 diff --git a/apps/api/tests/modules/admin/test_admin_router.py b/apps/api/tests/modules/admin/test_admin_router.py new file mode 100644 index 0000000..aff48f5 --- /dev/null +++ b/apps/api/tests/modules/admin/test_admin_router.py @@ -0,0 +1,125 @@ +from app.core.security import hash_password +from app.modules.users import repository + + +def _admin_headers(client): + login = client.post( + "/api/v1/auth/login", + json={"email": "admin@compton.example", "password": "Admin1234"}, + ) + token = login.json()["access_token"] + return {"Authorization": f"Bearer {token}"} + + +def _plain_admin_headers(client): + email = "ops-admin@compton.example" + if not repository.get_user_by_email(email): + repository.create_user( + email=email, + password_hash=hash_password("Admin1234"), + role="admin", + is_superuser=False, + status="active", + ) + login = client.post("/api/v1/auth/login", json={"email": email, "password": "Admin1234"}) + token = login.json()["access_token"] + return {"Authorization": f"Bearer {token}"} + + +def test_admin_users_list(client): + response = client.get("/api/v1/admin/users", headers=_admin_headers(client)) + assert response.status_code == 200 + assert "data" in response.json() + + +def test_non_superuser_cannot_patch_settings(client): + response = client.patch( + "/api/v1/admin/settings", + json={"values": {"enable_docs": False}}, + headers=_plain_admin_headers(client), + ) + assert response.status_code == 403 + assert response.json()["detail"] == "SUPERUSER_ONLY" + + +def test_superuser_can_patch_settings(client): + response = client.patch( + "/api/v1/admin/settings", + json={"values": {"enable_docs": False}}, + headers=_admin_headers(client), + ) + assert response.status_code == 200 + payload = response.json() + assert "values" in payload + + +def test_superuser_can_create_and_delete_user(client): + created = client.post( + "/api/v1/admin/users", + json={ + "email": "created-by-admin@compton.example", + "password": "StrongPass123A", + "role": "user", + "is_superuser": False, + "status": "active", + }, + headers=_admin_headers(client), + ) + assert created.status_code == 200 + user_id = created.json()["id"] + + deleted = client.delete(f"/api/v1/admin/users/{user_id}", headers=_admin_headers(client)) + assert deleted.status_code == 200 + assert deleted.json()["status"] == "deleted" + + +def test_admin_summary_and_stats(client): + headers = _admin_headers(client) + summary = client.get("/api/v1/admin/summary", headers=headers) + assert summary.status_code == 200 + assert "users_count" in summary.json() + + stats = client.get("/api/v1/admin/stats", headers=headers) + assert stats.status_code == 200 + + +def test_superuser_diagnostics_and_server_log(client): + headers = _admin_headers(client) + diagnostics = client.get("/api/v1/admin/diagnostics/report", headers=headers) + assert diagnostics.status_code == 200 + assert "checks" in diagnostics.json() + + activity = client.get("/api/v1/admin/activity-feed", headers=headers) + assert activity.status_code == 200 + assert "events" in activity.json() + + server_log = client.get("/api/v1/admin/server-log", headers=headers) + assert server_log.status_code == 200 + assert "lines" in server_log.json() + + +def test_admin_ui_activity(client): + response = client.post( + "/api/v1/admin/ui-activity", + json={"event": "tab_open", "meta": {"tab": "users"}}, + headers=_admin_headers(client), + ) + assert response.status_code == 200 + assert response.json()["status"] == "ok" + + +def test_admin_sync_metrics_with_enterprise(client): + from app.modules.sync import repository as sync_repo + + ent = sync_repo.create_enterprise("Metrics Test Farm", "metrics-test-farm") + headers = _admin_headers(client) + response = client.get("/api/v1/admin/sync-metrics", headers=headers) + assert response.status_code == 200 + payload = response.json() + assert isinstance(payload, list) + match = next((row for row in payload if row["enterprise_id"] == ent.id), None) + assert match is not None + assert match["name"] == "Metrics Test Farm" + assert "hubs" in match + assert "outbox_pending" in match + diff --git a/apps/api/tests/modules/admin/test_secrets_router.py b/apps/api/tests/modules/admin/test_secrets_router.py new file mode 100644 index 0000000..dd181a0 --- /dev/null +++ b/apps/api/tests/modules/admin/test_secrets_router.py @@ -0,0 +1,35 @@ +from __future__ import annotations + + +def _login(client, email: str, password: str) -> dict[str, str]: + response = client.post("/api/v1/auth/login", json={"email": email, "password": password}) + assert response.status_code == 200 + return {"Authorization": f"Bearer {response.json()['access_token']}"} + + +def test_superuser_can_read_install_secrets(client): + headers = _login(client, "admin@compton.example", "Admin1234") + response = client.get("/api/v1/admin/secrets", headers=headers) + assert response.status_code == 200 + payload = response.json() + assert "secrets_status" in payload + assert payload["secrets_status"]["postgres_password"] in {"configured", "missing"} + assert "POSTGRES_PASSWORD" not in str(payload) + + +def test_non_superuser_cannot_read_install_secrets(client): + headers = _login(client, "ops@compton.example", "OpsAdmin1234") + response = client.get("/api/v1/admin/secrets", headers=headers) + assert response.status_code == 403 + + +def test_superuser_can_reveal_db_password(client): + headers = _login(client, "admin@compton.example", "Admin1234") + response = client.post( + "/api/v1/admin/secrets/reveal", + headers=headers, + json={"key": "database_password"}, + ) + assert response.status_code == 200 + assert response.json()["key"] == "database_password" + assert "value" in response.json() diff --git a/apps/api/tests/modules/admin/test_security_diagnostics.py b/apps/api/tests/modules/admin/test_security_diagnostics.py new file mode 100644 index 0000000..4ac95d8 --- /dev/null +++ b/apps/api/tests/modules/admin/test_security_diagnostics.py @@ -0,0 +1,10 @@ +from app.modules.admin.security_diagnostics import build_security_diagnostics_report + + +def test_build_security_diagnostics_report_returns_checks(): + report = build_security_diagnostics_report() + assert "checks" in report + assert len(report["checks"]) >= 10 + ids = {check["id"] for check in report["checks"]} + assert "jwt_access_secret" in ids + assert "install_secrets_locked" in ids diff --git a/apps/api/tests/modules/admin/test_service.py b/apps/api/tests/modules/admin/test_service.py new file mode 100644 index 0000000..80553c0 --- /dev/null +++ b/apps/api/tests/modules/admin/test_service.py @@ -0,0 +1,88 @@ +from unittest.mock import patch + +from app.modules.admin.service import ( + create_admin_user, + delete_admin_user, + get_server_log_tail, + patch_user, + reset_user_password, +) +from app.modules.users import repository +from app.modules.users.repository import create_user, get_user_by_email +from app.core.security import hash_password + + +def test_admin_cannot_self_demote(): + admin = get_user_by_email("admin@compton.example") + try: + patch_user(admin, admin.id, "user", None) + assert False, "Expected self-demotion error" + except ValueError as exc: + assert str(exc) == "SELF_DEMOTION_FORBIDDEN" + + +def test_admin_can_promote_user(): + admin = get_user_by_email("admin@compton.example") + regular = create_user("sample@example.com", hash_password("Valid123"), role="user", status="active") + result = patch_user(admin, regular.id, "admin", None) + assert result["role"] == "admin" + + +def test_last_admin_protected(): + admin = get_user_by_email("admin@compton.example") + target = create_user("target@example.com", hash_password("Valid123"), role="admin", status="active") + with patch.object(repository, "count_admins", return_value=1): + try: + patch_user(admin, target.id, "user", None) + assert False, "Expected last-admin protection" + except ValueError as exc: + assert str(exc) == "LAST_ADMIN_PROTECTED" + + +def test_admin_cannot_self_block(): + admin = get_user_by_email("admin@compton.example") + try: + patch_user(admin, admin.id, None, "blocked") + assert False, "Expected self-block error" + except ValueError as exc: + assert str(exc) == "SELF_BLOCK_FORBIDDEN" + + +def test_reset_user_password_revokes_sessions(): + admin = get_user_by_email("admin@compton.example") + target = create_user("reset-pw@example.com", hash_password("Valid123"), status="active") + result = reset_user_password(admin, target.id, "NewValid123") + assert result["status"] == "ok" + + +def test_delete_admin_user_forbidden_for_self(): + admin = get_user_by_email("admin@compton.example") + try: + delete_admin_user(admin, admin.id) + assert False, "Expected self-delete error" + except ValueError as exc: + assert str(exc) == "SELF_DELETE_FORBIDDEN" + + +def test_create_admin_user_rejects_duplicate_email(): + admin = get_user_by_email("admin@compton.example") + try: + create_admin_user( + admin, + email="user@compton.example", + password="Valid123", + role="user", + is_superuser=False, + status="active", + ) + assert False, "Expected duplicate user error" + except ValueError as exc: + assert str(exc) == "USER_EXISTS" + + +def test_get_server_log_tail_empty_when_missing(): + with patch("app.modules.admin.service.settings") as mock_settings: + mock_settings.server_log_path = "/tmp/compton-missing-log.txt" + payload = get_server_log_tail() + assert payload["lines"] == [] + diff --git a/apps/api/tests/modules/auth/test_auth_router.py b/apps/api/tests/modules/auth/test_auth_router.py new file mode 100644 index 0000000..59077e2 --- /dev/null +++ b/apps/api/tests/modules/auth/test_auth_router.py @@ -0,0 +1,42 @@ +from tests.helpers import latest_token, register_and_verify + + +def test_health(client): + response = client.get("/api/v1/health") + assert response.status_code == 200 + assert response.json()["status"] == "ok" + + +def test_register_and_verify_and_login(client): + register_response = client.post( + "/api/v1/auth/register", + json={"email": "user@example.com", "password": "Valid123"}, + ) + assert register_response.status_code == 200 + assert "user_id" not in register_response.json() + + duplicate = client.post( + "/api/v1/auth/register", + json={"email": "user@example.com", "password": "Valid123"}, + ) + assert duplicate.status_code == 200 + assert "user_id" not in duplicate.json() + + verify_response = client.post( + "/api/v1/auth/verify-email", + json={"token": latest_token("user@example.com", "verify_email")}, + ) + assert verify_response.status_code == 200 + + login_response = client.post( + "/api/v1/auth/login", + json={"email": "user@example.com", "password": "Valid123"}, + ) + assert login_response.status_code == 200 + assert "access_token" in login_response.json() + + +def test_forgot_password_anti_enumeration(client): + response = client.post("/api/v1/auth/forgot-password", json={"email": "missing@example.com"}) + assert response.status_code == 200 + assert "If email is registered" in response.json()["message"] diff --git a/apps/api/tests/modules/auth/test_email_tokens.py b/apps/api/tests/modules/auth/test_email_tokens.py new file mode 100644 index 0000000..182542a --- /dev/null +++ b/apps/api/tests/modules/auth/test_email_tokens.py @@ -0,0 +1,20 @@ +from tests.helpers import latest_token + + +def test_verify_email_rejects_invalid_token(client): + client.post("/api/v1/auth/register", json={"email": "bad@example.com", "password": "Valid123"}) + response = client.post("/api/v1/auth/verify-email", json={"token": "invalid-token"}) + assert response.status_code == 400 + assert response.json()["detail"] == "INVALID_TOKEN" + + +def test_resend_verification_sends_new_token(client): + client.post("/api/v1/auth/register", json={"email": "resend@example.com", "password": "Valid123"}) + first_token = latest_token("resend@example.com", "verify_email") + + client.post("/api/v1/auth/resend-verification", json={"email": "resend@example.com"}) + second_token = latest_token("resend@example.com", "verify_email") + assert first_token != second_token + + verify = client.post("/api/v1/auth/verify-email", json={"token": second_token}) + assert verify.status_code == 200 diff --git a/apps/api/tests/modules/auth/test_jwt_revocation_integration.py b/apps/api/tests/modules/auth/test_jwt_revocation_integration.py new file mode 100644 index 0000000..ba0bec2 --- /dev/null +++ b/apps/api/tests/modules/auth/test_jwt_revocation_integration.py @@ -0,0 +1,35 @@ +from app.core.security import create_access_token, hash_password +from tests.helpers import register_and_verify + + +def _admin_headers(client) -> dict[str, str]: + from app.modules.users.repository import get_user_by_email + + admin = get_user_by_email("admin@compton.example") + token = create_access_token(admin.id, admin.role, admin.is_superuser) + return {"Authorization": f"Bearer {token}"} + + +def test_blocked_user_access_token_revoked_after_admin_block(client): + register_and_verify(client, "blocked-jwt@example.com") + login = client.post( + "/api/v1/auth/login", + json={"email": "blocked-jwt@example.com", "password": "Valid123"}, + ) + assert login.status_code == 200 + access_token = login.json()["access_token"] + + me = client.get("/api/v1/users/me", headers={"Authorization": f"Bearer {access_token}"}) + assert me.status_code == 200 + + user_id = me.json()["user"]["id"] + blocked = client.patch( + f"/api/v1/admin/users/{user_id}", + headers=_admin_headers(client), + json={"status": "blocked"}, + ) + assert blocked.status_code == 200 + + revoked = client.get("/api/v1/users/me", headers={"Authorization": f"Bearer {access_token}"}) + assert revoked.status_code == 401 + assert revoked.json()["detail"] == "TOKEN_REVOKED" diff --git a/apps/api/tests/modules/auth/test_lockout.py b/apps/api/tests/modules/auth/test_lockout.py new file mode 100644 index 0000000..24fec7b --- /dev/null +++ b/apps/api/tests/modules/auth/test_lockout.py @@ -0,0 +1,19 @@ +from tests.helpers import latest_token, register_and_verify + + +def test_auth_brute_force_lockout(client): + register_and_verify(client, "locked@example.com") + + for _ in range(5): + response = client.post( + "/api/v1/auth/login", + json={"email": "locked@example.com", "password": "WrongPass1"}, + ) + assert response.status_code == 401 + + locked = client.post( + "/api/v1/auth/login", + json={"email": "locked@example.com", "password": "Valid123"}, + ) + assert locked.status_code == 429 + assert locked.json()["detail"] == "ACCOUNT_TEMPORARILY_LOCKED" diff --git a/apps/api/tests/modules/auth/test_logout_and_forgot.py b/apps/api/tests/modules/auth/test_logout_and_forgot.py new file mode 100644 index 0000000..ea1ea11 --- /dev/null +++ b/apps/api/tests/modules/auth/test_logout_and_forgot.py @@ -0,0 +1,77 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from app.core import jwt_denylist as denylist_module +from app.core.jwt_denylist import ( + bump_auth_epoch, + deny_jti, + ensure_jwt_revocation_backend, + get_auth_epoch, + is_jti_denied, +) + + +def test_ensure_jwt_revocation_backend_requires_redis_in_production(monkeypatch): + monkeypatch.setattr(denylist_module.settings, "app_env", "production") + with patch("app.core.jwt_denylist.get_redis_client", return_value=None): + with pytest.raises(RuntimeError, match="Redis is required"): + ensure_jwt_revocation_backend() + + +def test_redis_auth_epoch_roundtrip(): + mock_client = MagicMock() + mock_client.get.return_value = "3" + mock_client.incr.return_value = 4 + with patch("app.core.jwt_denylist.get_redis_client", return_value=mock_client): + assert get_auth_epoch("user-1") == 3 + assert bump_auth_epoch("user-1") == 4 + mock_client.incr.assert_called_once() + + +def test_redis_deny_jti_and_check(): + mock_client = MagicMock() + with patch("app.core.jwt_denylist.get_redis_client", return_value=mock_client): + with patch("app.core.jwt_denylist.time.time", return_value=1000): + deny_jti("abc-jti", 1060) + mock_client.setex.assert_called_once_with("jwt:deny:abc-jti", 60, "1") + mock_client.exists.return_value = 1 + assert is_jti_denied("abc-jti") is True + + +def test_forgot_password_skips_blocked_user(client): + from app.modules.users.repository import create_user, get_user_by_email, update_user + from app.core.security import hash_password + + create_user("blocked-forgot@example.com", hash_password("Valid123"), status="active") + user = get_user_by_email("blocked-forgot@example.com") + user.status = "blocked" + update_user(user) + + response = client.post( + "/api/v1/auth/forgot-password", + json={"email": "blocked-forgot@example.com"}, + ) + assert response.status_code == 200 + from app.core.email import memory_mailer + + assert not any(msg.to == "blocked-forgot@example.com" for msg in memory_mailer.sent) + + +def test_logout_revokes_access_token(client): + from tests.helpers import register_and_verify + + register_and_verify(client, "logout-jti@example.com") + login = client.post( + "/api/v1/auth/login", + json={"email": "logout-jti@example.com", "password": "Valid123"}, + ) + token = login.json()["access_token"] + logout = client.post( + "/api/v1/auth/logout", + headers={"Authorization": f"Bearer {token}", "Origin": "http://localhost:5173"}, + ) + assert logout.status_code == 200 + me = client.get("/api/v1/users/me", headers={"Authorization": f"Bearer {token}"}) + assert me.status_code == 401 + assert me.json()["detail"] == "TOKEN_REVOKED" diff --git a/apps/api/tests/modules/auth/test_password_denylist_policy.py b/apps/api/tests/modules/auth/test_password_denylist_policy.py new file mode 100644 index 0000000..7dca225 --- /dev/null +++ b/apps/api/tests/modules/auth/test_password_denylist_policy.py @@ -0,0 +1,7 @@ +from app.core.password_policy import validate_password_strength +import pytest + + +def test_password_policy_rejects_denied_password(): + with pytest.raises(ValueError, match="too common"): + validate_password_strength("Password123") diff --git a/apps/api/tests/modules/auth/test_password_policy.py b/apps/api/tests/modules/auth/test_password_policy.py new file mode 100644 index 0000000..9756e96 --- /dev/null +++ b/apps/api/tests/modules/auth/test_password_policy.py @@ -0,0 +1,12 @@ +import pytest + +from app.core.password_policy import validate_password_strength + + +def test_password_policy_accepts_valid_password(): + assert validate_password_strength("Valid123") == "Valid123" + + +def test_password_policy_rejects_weak_password(): + with pytest.raises(ValueError, match="uppercase letter"): + validate_password_strength("valid123") diff --git a/apps/api/tests/modules/auth/test_rate_limit.py b/apps/api/tests/modules/auth/test_rate_limit.py new file mode 100644 index 0000000..906324c --- /dev/null +++ b/apps/api/tests/modules/auth/test_rate_limit.py @@ -0,0 +1,22 @@ +from uuid import uuid4 + + +def test_login_rate_limit(client, monkeypatch): + from app.core.config import settings + + monkeypatch.setattr(settings, "enable_rate_limit", True) + + email = f"missing-{uuid4().hex}@example.com" + for _ in range(5): + response = client.post( + "/api/v1/auth/login", + json={"email": email, "password": "Valid123"}, + ) + assert response.status_code == 401 + + blocked = client.post( + "/api/v1/auth/login", + json={"email": email, "password": "Valid123"}, + ) + assert blocked.status_code == 429 + assert blocked.json()["detail"] == "RATE_LIMIT_EXCEEDED" diff --git a/apps/api/tests/modules/auth/test_refresh_security.py b/apps/api/tests/modules/auth/test_refresh_security.py new file mode 100644 index 0000000..5003e2b --- /dev/null +++ b/apps/api/tests/modules/auth/test_refresh_security.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from tests.helpers import register_and_verify + + +def _admin_headers(client) -> dict[str, str]: + from app.core.security import create_access_token + from app.modules.users.repository import get_user_by_email + + admin = get_user_by_email("admin@compton.example") + token = create_access_token(admin.id, admin.role, admin.is_superuser) + return {"Authorization": f"Bearer {token}"} + + +def test_refresh_fails_for_pending_user(client): + client.post( + "/api/v1/auth/register", + json={"email": "pending-refresh@example.com", "password": "Valid123"}, + ) + login = client.post( + "/api/v1/auth/login", + json={"email": "pending-refresh@example.com", "password": "Valid123"}, + ) + assert login.status_code == 403 + assert login.json()["detail"] == "EMAIL_NOT_VERIFIED" + + # Simulate stale refresh cookie from an earlier active session edge case via direct token issue. + from app.modules.auth.service import issue_refresh_token + from app.modules.users.repository import get_user_by_email + + user = get_user_by_email("pending-refresh@example.com") + refresh_token = issue_refresh_token(user.id) + client.cookies.set("refresh_token", refresh_token, path="/api/v1/auth") + + refresh = client.post("/api/v1/auth/refresh", headers={"Origin": "http://localhost:5173"}) + assert refresh.status_code == 403 + assert refresh.json()["detail"] == "EMAIL_NOT_VERIFIED" + + +def test_refresh_fails_for_blocked_user(client): + register_and_verify(client, "blocked-refresh@example.com") + login = client.post( + "/api/v1/auth/login", + json={"email": "blocked-refresh@example.com", "password": "Valid123"}, + ) + assert login.status_code == 200 + admin_headers = _admin_headers(client) + me = client.get( + "/api/v1/users/me", + headers={"Authorization": f"Bearer {login.json()['access_token']}"}, + ) + user_id = me.json()["user"]["id"] + blocked = client.patch( + f"/api/v1/admin/users/{user_id}", + headers=admin_headers, + json={"status": "blocked"}, + ) + assert blocked.status_code == 200 + refresh = client.post("/api/v1/auth/refresh", headers={"Origin": "http://localhost:5173"}) + assert refresh.status_code == 403 + assert refresh.json()["detail"] == "ACCOUNT_BLOCKED" + + +def test_refresh_requires_origin_header_when_cookie_present(client): + register_and_verify(client, "origin-required@example.com") + login = client.post( + "/api/v1/auth/login", + json={"email": "origin-required@example.com", "password": "Valid123"}, + ) + assert login.status_code == 200 + response = client.post("/api/v1/auth/refresh") + assert response.status_code == 403 + assert response.json()["detail"] == "INVALID_ORIGIN" diff --git a/apps/api/tests/modules/auth/test_security.py b/apps/api/tests/modules/auth/test_security.py new file mode 100644 index 0000000..befb590 --- /dev/null +++ b/apps/api/tests/modules/auth/test_security.py @@ -0,0 +1,26 @@ +from app.core.security import ( + create_access_token, + decode_access_token, + generate_refresh_token, + hash_password, + hash_refresh_token, + verify_password, +) + + +def test_password_hashing_roundtrip(): + hashed = hash_password("Strong123") + assert verify_password("Strong123", hashed) is True + + +def test_access_token_encode_decode(): + token = create_access_token("u1", "user", False) + payload = decode_access_token(token) + assert payload["sub"] == "u1" + assert payload["role"] == "user" + assert payload["is_superuser"] is False + + +def test_refresh_token_hashing(): + token = generate_refresh_token() + assert hash_refresh_token(token) == hash_refresh_token(token) diff --git a/apps/api/tests/modules/auth/test_service_refresh.py b/apps/api/tests/modules/auth/test_service_refresh.py new file mode 100644 index 0000000..e56b108 --- /dev/null +++ b/apps/api/tests/modules/auth/test_service_refresh.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from app.core.security import hash_password +from app.modules.auth import repository as auth_repository +from app.modules.auth.service import issue_refresh_token, refresh +from app.modules.users.repository import create_user, get_user_by_id, update_user + + +def test_refresh_returns_account_blocked_for_blocked_user(): + user = create_user("blocked-svc@example.com", hash_password("Valid123"), status="active") + token = issue_refresh_token(user.id) + user.status = "blocked" + update_user(user) + auth_repository.revoke_user_families(user.id) + + with pytest.raises(PermissionError, match="ACCOUNT_BLOCKED"): + refresh(token) + + +def test_refresh_returns_email_not_verified_for_pending_user(): + user = create_user("pending-svc@example.com", hash_password("Valid123"), status="pending") + token = issue_refresh_token(user.id) + + with pytest.raises(PermissionError, match="EMAIL_NOT_VERIFIED"): + refresh(token) + + +def test_refresh_rejects_revoked_token_for_active_user(): + user = create_user("active-svc@example.com", hash_password("Valid123"), status="active") + token = issue_refresh_token(user.id) + auth_repository.revoke_user_families(user.id) + + with patch.object(auth_repository, "revoke_family_tokens") as revoke_family: + with pytest.raises(PermissionError, match="INVALID_REFRESH"): + refresh(token) + revoke_family.assert_called_once() diff --git a/apps/api/tests/modules/content/test_content_router.py b/apps/api/tests/modules/content/test_content_router.py new file mode 100644 index 0000000..b220e87 --- /dev/null +++ b/apps/api/tests/modules/content/test_content_router.py @@ -0,0 +1,45 @@ +def _admin_headers(client): + login = client.post( + "/api/v1/auth/login", + json={"email": "admin@compton.example", "password": "Admin1234"}, + ) + token = login.json()["access_token"] + return {"Authorization": f"Bearer {token}"} + + +def test_create_and_read_published_page(client): + headers = _admin_headers(client) + created = client.post( + "/api/v1/content/pages", + headers=headers, + json={"slug": "about", "title": "About", "body": "

Hello

", "status": "published"}, + ) + assert created.status_code == 200 + + fetched = client.get("/api/v1/content/pages/about") + assert fetched.status_code == 200 + assert fetched.json()["slug"] == "about" + + +def test_list_all_pages_requires_admin(client): + response = client.get("/api/v1/content/pages/manage/all") + assert response.status_code == 401 + + +def test_list_all_pages_includes_drafts(client): + headers = _admin_headers(client) + created = client.post( + "/api/v1/content/pages", + headers=headers, + json={"slug": "draft-page", "title": "Draft", "body": "

Draft

", "status": "draft"}, + ) + assert created.status_code == 200 + + listed = client.get("/api/v1/content/pages/manage/all", headers=headers) + assert listed.status_code == 200 + slugs = [page["slug"] for page in listed.json()["data"]] + assert "draft-page" in slugs + + public = client.get("/api/v1/content/pages") + public_slugs = [page["slug"] for page in public.json()["data"]] + assert "draft-page" not in public_slugs diff --git a/apps/api/tests/modules/content/test_content_sanitize_protocols.py b/apps/api/tests/modules/content/test_content_sanitize_protocols.py new file mode 100644 index 0000000..cf89d37 --- /dev/null +++ b/apps/api/tests/modules/content/test_content_sanitize_protocols.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from app.modules.content.service import sanitize_html + + +def test_sanitize_html_strips_javascript_protocol(): + raw = 'xx' + clean = sanitize_html(raw) + assert "javascript:" not in clean diff --git a/apps/api/tests/modules/media/test_media_router.py b/apps/api/tests/modules/media/test_media_router.py new file mode 100644 index 0000000..ade0ea5 --- /dev/null +++ b/apps/api/tests/modules/media/test_media_router.py @@ -0,0 +1,21 @@ +def test_media_requires_signature(client): + response = client.get("/api/v1/media/files/avatars/missing.png") + assert response.status_code == 422 + + +def test_media_file_not_found(client): + from app.core.media_signing import build_signed_media_url + + signed_url = build_signed_media_url("/api/v1/media/files/avatars/missing.png") + response = client.get(signed_url) + assert response.status_code == 404 + assert response.json()["detail"] == "FILE_NOT_FOUND" + + +def test_media_rejects_non_avatar_path(client): + from app.core.media_signing import build_signed_media_url + + signed_url = build_signed_media_url("/api/v1/media/files/other/file.png") + response = client.get(signed_url) + assert response.status_code == 404 + assert response.json()["detail"] == "FILE_NOT_FOUND" diff --git a/apps/api/tests/modules/media/test_media_service.py b/apps/api/tests/modules/media/test_media_service.py new file mode 100644 index 0000000..c23fe7f --- /dev/null +++ b/apps/api/tests/modules/media/test_media_service.py @@ -0,0 +1,14 @@ +import pytest + +from app.modules.media.service import AvatarValidationError, validate_and_process_avatar +from tests.fixtures import make_test_png + + +def test_validate_rejects_empty(): + with pytest.raises(AvatarValidationError, match="INVALID_IMAGE"): + validate_and_process_avatar(b"") + + +def test_validate_rejects_corrupt_bytes(): + with pytest.raises(AvatarValidationError, match="INVALID_IMAGE"): + validate_and_process_avatar(b"not-an-image") diff --git a/apps/api/tests/modules/notifications/test_notifications_service.py b/apps/api/tests/modules/notifications/test_notifications_service.py new file mode 100644 index 0000000..c3e8a9d --- /dev/null +++ b/apps/api/tests/modules/notifications/test_notifications_service.py @@ -0,0 +1,7 @@ +from app.modules.notifications.service import enqueue_email + + +def test_enqueue_email_returns_payload(): + data = enqueue_email("verify", "u@example.com", {"token": "123"}) + assert data["queued"] is True + assert data["template"] == "verify" diff --git a/apps/api/tests/modules/sync/__init__.py b/apps/api/tests/modules/sync/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/tests/modules/sync/test_multiserver_api.py b/apps/api/tests/modules/sync/test_multiserver_api.py new file mode 100644 index 0000000..c267536 --- /dev/null +++ b/apps/api/tests/modules/sync/test_multiserver_api.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient + +from app.modules.sync import repository as repo +from tests.orchestrator_dual_harness import OrchestratorDualHubHarness, make_enterprise + + +def test_hub_conflicts_via_hub_auth(client: TestClient): + enterprise_id = make_enterprise(client) + harness = OrchestratorDualHubHarness(client, enterprise_id) + hub_a, hub_b = harness.pair_hubs() + record_id = "hub-auth-conflict-1" + harness.push_from( + hub_a, + [ + { + "event_id": str(uuid4()), + "seq": 1, + "domain": "global", + "table": "component", + "record_id": record_id, + "action": "upsert", + "version": 1, + "content_hash": "hub-h1", + "payload": {"name": "A", "type": "g", "dry_matter": 1, "id": record_id, "version": 1, "content_hash": "hub-h1"}, + "emitted_at": "2026-07-15T12:00:00Z", + "origin_site_id": hub_a.hub_site_id, + } + ], + ) + harness.push_from( + hub_b, + [ + { + "event_id": str(uuid4()), + "seq": 1, + "domain": "global", + "table": "component", + "record_id": record_id, + "action": "upsert", + "version": 1, + "content_hash": "hub-h2", + "payload": {"name": "B", "type": "g", "dry_matter": 2, "id": record_id, "version": 1, "content_hash": "hub-h2"}, + "emitted_at": "2026-07-15T12:01:00Z", + "origin_site_id": hub_b.hub_site_id, + } + ], + ) + listed = client.get("/api/v1/sync/hub/conflicts", headers=hub_a.auth_header()) + assert listed.status_code == 200 + conflicts = listed.json() + assert len(conflicts) >= 1 + + +def test_multiserver_list_and_resolve(client: TestClient): + enterprise_id = make_enterprise(client) + harness = OrchestratorDualHubHarness(client, enterprise_id) + hub_a, hub_b = harness.pair_hubs() + record_id = "ms-conflict-1" + harness.push_from( + hub_a, + [ + { + "event_id": str(uuid4()), + "seq": 1, + "domain": "global", + "table": "component", + "record_id": record_id, + "action": "upsert", + "version": 1, + "content_hash": "ms-h1", + "payload": {"name": "A", "type": "g", "dry_matter": 1, "id": record_id, "version": 1, "content_hash": "ms-h1"}, + "emitted_at": "2026-07-15T12:00:00Z", + "origin_site_id": hub_a.hub_site_id, + } + ], + ) + harness.push_from( + hub_b, + [ + { + "event_id": str(uuid4()), + "seq": 1, + "domain": "global", + "table": "component", + "record_id": record_id, + "action": "upsert", + "version": 1, + "content_hash": "ms-h2", + "payload": {"name": "B", "type": "g", "dry_matter": 2, "id": record_id, "version": 1, "content_hash": "ms-h2"}, + "emitted_at": "2026-07-15T12:01:00Z", + "origin_site_id": hub_b.hub_site_id, + } + ], + ) + login = client.post("/api/v1/auth/login", json={"email": "admin@compton.example", "password": "Admin1234"}) + token = login.json()["access_token"] + listed = client.get( + f"/api/v1/sync/conflicts?enterprise_id={enterprise_id}", + headers={"Authorization": f"Bearer {token}"}, + ) + assert listed.status_code == 200 + conflicts = listed.json() + assert len(conflicts) >= 1 + cid = conflicts[0]["id"] + detail = client.get( + f"/api/v1/sync/conflicts/{cid}?enterprise_id={enterprise_id}", + headers={"Authorization": f"Bearer {token}"}, + ) + assert detail.status_code == 200 + resolved = client.post( + f"/api/v1/sync/conflicts/{cid}/resolve?enterprise_id={enterprise_id}", + headers={"Authorization": f"Bearer {token}"}, + json={"resolution": "keep_orchestrator"}, + ) + assert resolved.status_code == 200 diff --git a/apps/api/tests/modules/sync/test_orchestrator_chaos.py b/apps/api/tests/modules/sync/test_orchestrator_chaos.py new file mode 100644 index 0000000..b823c89 --- /dev/null +++ b/apps/api/tests/modules/sync/test_orchestrator_chaos.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient + +from app.modules.sync import repository as repo +from app.modules.sync.engine import SyncEngine +from tests.orchestrator_dual_harness import OrchestratorDualHubHarness, make_enterprise + + +@pytest.fixture() +def chaos_harness(client: TestClient): + enterprise_id = make_enterprise(client) + harness = OrchestratorDualHubHarness(client, enterprise_id) + hub_a, hub_b = harness.pair_hubs() + return harness, hub_a, hub_b + + +def test_duplicate_ack_safe(client: TestClient, chaos_harness): + harness, hub_a, _hub_b = chaos_harness + record_id = "chaos-ack-1" + event = harness.make_component_event( + hub_a, record_id, name="DupAck", version=1, content_hash="da1", event_id="evt-dup-ack" + ) + harness.push_from(hub_a, [event]) + events = harness.pull_for(hub_a) # hub should not see own events + assert events == [] + resp = client.post( + "/api/v1/sync/changes/ack", + headers=hub_a.auth_header(), + json={"event_ids": ["evt-dup-ack"], "direction": "inbound"}, + ) + assert resp.status_code == 200 + + +def test_out_of_order_seq_replay_idempotent(client: TestClient, chaos_harness): + harness, hub_a, hub_b = chaos_harness + record_id = "chaos-seq-1" + e2 = harness.make_component_event( + hub_a, record_id, name="Second", version=2, content_hash="s2", event_id="evt-seq-2" + ) + e1 = harness.make_component_event( + hub_a, record_id, name="First", version=1, content_hash="s1", event_id="evt-seq-1" + ) + harness.push_from(hub_a, [e2]) + harness.push_from(hub_a, [e1]) + harness.drain() + row = harness.get_catalog(hub_b, "component", record_id) + assert row is not None + assert row["content_hash"] in {"s1", "s2"} + + +def test_resolve_keep_orchestrator_fanout(client: TestClient, chaos_harness): + harness, hub_a, hub_b = chaos_harness + record_id = "chaos-resolve-1" + harness.push_from( + hub_a, + [ + { + "event_id": str(uuid4()), + "seq": 1, + "domain": "global", + "table": "component", + "record_id": record_id, + "action": "upsert", + "version": 1, + "content_hash": "orch-hash", + "payload": { + "name": "Orch", + "type": "grain", + "dry_matter": 50.0, + "id": record_id, + "version": 1, + "content_hash": "orch-hash", + }, + "emitted_at": datetime.now(UTC).isoformat(), + "origin_site_id": hub_a.hub_site_id, + } + ], + ) + harness.push_from( + hub_b, + [ + { + "event_id": str(uuid4()), + "seq": 1, + "domain": "global", + "table": "component", + "record_id": record_id, + "action": "upsert", + "version": 1, + "content_hash": "hub-hash", + "payload": { + "name": "HubB", + "type": "grain", + "dry_matter": 60.0, + "id": record_id, + "version": 1, + "content_hash": "hub-hash", + }, + "emitted_at": datetime.now(UTC).isoformat(), + "origin_site_id": hub_b.hub_site_id, + } + ], + ) + conflicts = repo.list_conflicts(harness.enterprise_id, "pending") + assert len(conflicts) == 1 + engine = SyncEngine(harness.enterprise_id, SyncEngine.ORCHESTRATOR_SITE_ID) + engine.resolve_conflict(conflicts[0].id, "admin", "keep_orchestrator") + harness.drain() + row = harness.get_orchestrator_catalog("component", record_id) + assert row is not None + assert row["content_hash"] == "orch-hash" + + +def test_resolve_keep_hub_fanout(client: TestClient, chaos_harness): + harness, hub_a, hub_b = chaos_harness + record_id = "chaos-resolve-hub-1" + harness.push_from( + hub_a, + [ + { + "event_id": str(uuid4()), + "seq": 1, + "domain": "global", + "table": "component", + "record_id": record_id, + "action": "upsert", + "version": 1, + "content_hash": "orch-hash2", + "payload": { + "name": "Orch", + "type": "grain", + "dry_matter": 50.0, + "id": record_id, + "version": 1, + "content_hash": "orch-hash2", + }, + "emitted_at": "2026-07-15T10:00:00Z", + "origin_site_id": hub_a.hub_site_id, + } + ], + ) + harness.push_from( + hub_b, + [ + { + "event_id": str(uuid4()), + "seq": 1, + "domain": "global", + "table": "component", + "record_id": record_id, + "action": "upsert", + "version": 1, + "content_hash": "hub-b-hash2", + "payload": { + "name": "Hub B wins", + "type": "grain", + "dry_matter": 77.0, + "id": record_id, + "version": 1, + "content_hash": "hub-b-hash2", + }, + "emitted_at": "2026-07-15T10:01:00Z", + "origin_site_id": hub_b.hub_site_id, + } + ], + ) + conflicts = repo.list_conflicts(harness.enterprise_id, "pending") + assert len(conflicts) == 1 + engine = SyncEngine(harness.enterprise_id, SyncEngine.ORCHESTRATOR_SITE_ID) + engine.resolve_conflict(conflicts[0].id, "admin", "keep_hub") + harness.drain() + row = harness.get_orchestrator_catalog("component", record_id) + assert row is not None + assert row["content_hash"] == "hub-b-hash2" + assert row["name"] == "Hub B wins" diff --git a/apps/api/tests/modules/sync/test_orchestrator_dual_hub_e2e.py b/apps/api/tests/modules/sync/test_orchestrator_dual_hub_e2e.py new file mode 100644 index 0000000..bd2a5c7 --- /dev/null +++ b/apps/api/tests/modules/sync/test_orchestrator_dual_hub_e2e.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient + +from tests.orchestrator_dual_harness import OrchestratorDualHubHarness, make_enterprise + + +@pytest.fixture() +def dual_harness(client: TestClient): + enterprise_id = make_enterprise(client) + harness = OrchestratorDualHubHarness(client, enterprise_id) + hub_a, hub_b = harness.pair_hubs() + return harness, hub_a, hub_b + + +def test_hub_a_push_reaches_hub_b(client: TestClient, dual_harness): + harness, hub_a, hub_b = dual_harness + record_id = "comp-dual-001" + event = harness.make_component_event( + hub_a, record_id, name="Corn A", version=1, content_hash="hash-a1" + ) + harness.push_from(hub_a, [event]) + harness.drain() + row = harness.get_catalog(hub_b, "component", record_id) + assert row is not None + assert row["name"] == "Corn A" + assert row["content_hash"] == "hash-a1" + + +def test_hub_b_push_back_to_hub_a(client: TestClient, dual_harness): + harness, hub_a, hub_b = dual_harness + record_id = "comp-dual-002" + harness.push_from( + hub_a, + [harness.make_component_event(hub_a, record_id, name="V1", version=1, content_hash="h1")], + ) + harness.drain() + harness.push_from( + hub_b, + [harness.make_component_event(hub_b, record_id, name="V2 from B", version=2, content_hash="h2")], + ) + harness.drain() + row_a = harness.get_catalog(hub_a, "component", record_id) + assert row_a is not None + assert row_a["content_hash"] == "h2" + assert row_a["name"] == "V2 from B" + + +def test_idempotent_replay(client: TestClient, dual_harness): + harness, hub_a, hub_b = dual_harness + record_id = "comp-dual-003" + event = harness.make_component_event( + hub_a, record_id, name="Once", version=1, content_hash="once1", event_id="evt-fixed-003" + ) + harness.push_from(hub_a, [event]) + harness.push_from(hub_a, [event]) + harness.drain() + orch = harness.get_orchestrator_catalog("component", record_id) + assert orch is not None + assert orch["name"] == "Once" + row_b = harness.get_catalog(hub_b, "component", record_id) + assert row_b is not None + assert row_b["content_hash"] == "once1" + + +def test_ack_cursor_no_double_apply(client: TestClient, dual_harness): + harness, hub_a, hub_b = dual_harness + record_id = "comp-dual-004" + event = harness.make_component_event( + hub_a, record_id, name="Ack safe", version=1, content_hash="ack1", event_id="evt-ack-004" + ) + harness.push_from(hub_a, [event]) + events1 = harness.pull_for(hub_b) + assert len(events1) == 1 + events2 = harness.pull_for(hub_b) + assert len(events2) == 0 + row = harness.get_catalog(hub_b, "component", record_id) + assert row is not None + assert row["name"] == "Ack safe" + + +def test_no_conflict_single_editor(client: TestClient, dual_harness): + harness, hub_a, hub_b = dual_harness + record_id = "comp-dual-005" + harness.push_from( + hub_a, + [harness.make_component_event(hub_a, record_id, name="Solo", version=1, content_hash="solo1")], + ) + harness.drain() + from app.modules.sync import repository as repo + + conflicts = repo.list_conflicts(harness.enterprise_id, "pending") + assert conflicts == [] + row = harness.get_orchestrator_catalog("component", record_id) + assert row is not None + assert row["name"] == "Solo" + + +def test_conflict_dual_edit(client: TestClient, dual_harness): + harness, hub_a, hub_b = dual_harness + record_id = "comp-dual-006" + base = {"name": "Base", "type": "grain", "dry_matter": 50.0} + harness.push_from( + hub_a, + [ + { + "event_id": str(uuid4()), + "seq": 1, + "domain": "global", + "table": "component", + "record_id": record_id, + "action": "upsert", + "version": 1, + "content_hash": "base-hash", + "payload": {**base, "id": record_id, "version": 1, "content_hash": "base-hash"}, + "emitted_at": "2026-07-15T10:00:00Z", + "origin_site_id": hub_a.hub_site_id, + } + ], + ) + harness.drain() + harness.push_from( + hub_b, + [ + { + "event_id": str(uuid4()), + "seq": 1, + "domain": "global", + "table": "component", + "record_id": record_id, + "action": "upsert", + "version": 1, + "content_hash": "b-conflict-hash", + "payload": { + "name": "B edit", + "type": "grain", + "dry_matter": 60.0, + "id": record_id, + "version": 1, + "content_hash": "b-conflict-hash", + }, + "emitted_at": "2026-07-15T10:01:00Z", + "origin_site_id": hub_b.hub_site_id, + } + ], + ) + from app.modules.sync import repository as repo + + conflicts = repo.list_conflicts(harness.enterprise_id, "pending") + assert len(conflicts) == 1 + orch = harness.get_orchestrator_catalog("component", record_id) + assert orch is not None + assert orch["content_hash"] == "base-hash" + + +def test_period_recipes_composite_record_id(client: TestClient, dual_harness): + harness, hub_a, hub_b = dual_harness + period_id = "737a78a5-492a-4a84-94f3-18b4d7f21666" + recipe_id = "07fd17cf-22c3-44fe-92db-46363b24cee0" + record_id = f"{period_id}:{recipe_id}" + event = { + "event_id": str(uuid4()), + "seq": 1, + "domain": "global", + "table": "period_recipes", + "record_id": record_id, + "action": "upsert", + "version": 1, + "content_hash": "pr1", + "payload": { + "period_id": period_id, + "recipe_id": recipe_id, + "order": 1, + "version": 1, + "content_hash": "pr1", + }, + "emitted_at": "2026-07-15T12:00:00Z", + "origin_site_id": hub_a.hub_site_id, + } + harness.push_from(hub_a, [event]) + harness.drain() + row = harness.get_catalog(hub_b, "period_recipes", record_id) + assert row is not None + assert row.get("order") == 1 diff --git a/apps/api/tests/modules/sync/test_orchestrator_recipe_tree.py b/apps/api/tests/modules/sync/test_orchestrator_recipe_tree.py new file mode 100644 index 0000000..19df285 --- /dev/null +++ b/apps/api/tests/modules/sync/test_orchestrator_recipe_tree.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from pathlib import Path +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient + +from tests.orchestrator_dual_harness import OrchestratorDualHubHarness, make_enterprise + + +def test_recipe_ingredient_tree_roundtrip(client: TestClient): + enterprise_id = make_enterprise(client) + harness = OrchestratorDualHubHarness(client, enterprise_id) + hub_a, hub_b = harness.pair_hubs() + recipe_id = "recipe-tree-1" + ing_id = "ing-tree-1" + comp_id = "comp-tree-1" + harness.push_from( + hub_a, + [ + harness.make_component_event(hub_a, comp_id, name="Barley", version=1, content_hash="c1"), + { + "event_id": str(uuid4()), + "seq": 2, + "domain": "global", + "table": "recipe", + "record_id": recipe_id, + "action": "upsert", + "version": 1, + "content_hash": "r1", + "payload": { + "id": recipe_id, + "name": "Mix A", + "heads_per_trip": 100, + "ingredients": [{"id": ing_id, "component_id": comp_id, "name": "Barley", "amount": 10}], + "version": 1, + "content_hash": "r1", + }, + "emitted_at": "2026-07-15T12:00:00Z", + "origin_site_id": hub_a.hub_site_id, + }, + { + "event_id": str(uuid4()), + "seq": 3, + "domain": "global", + "table": "ingredient", + "record_id": ing_id, + "action": "upsert", + "version": 1, + "content_hash": "i1", + "payload": { + "id": ing_id, + "recipe_id": recipe_id, + "component_id": comp_id, + "name": "Barley", + "amount": 10.0, + "version": 1, + "content_hash": "i1", + }, + "emitted_at": "2026-07-15T12:00:01Z", + "origin_site_id": hub_a.hub_site_id, + }, + ], + ) + harness.drain([hub_b]) + recipe = harness.get_catalog(hub_b, "recipe", recipe_id) + assert recipe is not None + assert recipe.get("name") == "Mix A" + ing = harness.get_catalog(hub_b, "ingredient", ing_id) + assert ing is not None + assert ing.get("component_id") == comp_id diff --git a/apps/api/tests/modules/sync/test_rls_abac.py b/apps/api/tests/modules/sync/test_rls_abac.py new file mode 100644 index 0000000..44e3da3 --- /dev/null +++ b/apps/api/tests/modules/sync/test_rls_abac.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import os + +import pytest + +from app.modules.sync import repository as repo + + +@pytest.mark.skipif( + "postgresql" not in os.environ.get("DATABASE_URL", ""), + reason="RLS tests require PostgreSQL", +) +def test_rls_cross_enterprise(): + ent_a = repo.create_enterprise("Farm A", f"farm-a-{os.getpid()}") + ent_b = repo.create_enterprise("Farm B", f"farm-b-{os.getpid()}") + assert ent_a.id != ent_b.id + hub_a = repo.create_farm_hub(ent_a.id, "Hub A", f"hub-a-{os.getpid()}", None) + hubs_b = repo.list_farm_hubs(ent_b.id) + assert all(h.id != hub_a.id for h in hubs_b) + + +def test_viewer_farm_scope(client): + from uuid import uuid4 + + from app.modules.users.repository import get_user_by_email + + slug = f"abac-{uuid4().hex[:8]}" + ent = repo.create_enterprise("ABAC Farm", slug) + admin = get_user_by_email("admin@compton.example") + assert admin + repo.add_member(admin.id, ent.id, "admin") + hub1 = repo.create_farm_hub(ent.id, "H1", f"h1-{uuid4().hex[:8]}", None) + hub2 = repo.create_farm_hub(ent.id, "H2", f"h2-{uuid4().hex[:8]}", None) + viewer_id = admin.id + repo.grant_farm_access(viewer_id, hub1.id) + allowed = repo.list_farm_access(viewer_id, ent.id) + assert hub1.id in allowed + assert hub2.id not in allowed diff --git a/apps/api/tests/modules/sync/test_sync_protocol.py b/apps/api/tests/modules/sync/test_sync_protocol.py new file mode 100644 index 0000000..1ab528b --- /dev/null +++ b/apps/api/tests/modules/sync/test_sync_protocol.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from app.core.crypto import hash_opaque_token +from app.modules.sync import repository as repo + + +from uuid import uuid4 + + +@pytest.fixture() +def enterprise(client: TestClient): + slug = f"test-farm-{uuid4().hex[:8]}" + ent = repo.create_enterprise("Test Farm Co", slug) + admin = repo.get_member.__module__ # noqa: ensure import path + from app.modules.users.repository import get_user_by_email + + user = get_user_by_email("admin@compton.example") + assert user + repo.add_member(user.id, ent.id, "admin") + return ent + + +def test_sync_capabilities(client: TestClient): + resp = client.get("/api/v1/sync/capabilities") + assert resp.status_code == 200 + data = resp.json() + assert data["protocol_version"] == "1.0" + assert "global" in data["domains"] + + +def test_pairing_flow(client: TestClient, enterprise): + login = client.post("/api/v1/auth/login", json={"email": "admin@compton.example", "password": "Admin1234"}) + token = login.json()["access_token"] + start = client.post( + "/api/v1/enterprise/pair/start", + headers={"Authorization": f"Bearer {token}"}, + json={"enterprise_id": enterprise.id, "farm_name": "Farm A"}, + ) + assert start.status_code == 200 + code = start.json()["code"] + confirm = client.post( + "/api/v1/enterprise/pair/confirm", + json={"code": code, "hub_site_id": "hub-site-001", "hub_name": "Farm A Hub"}, + ) + assert confirm.status_code == 200 + body = confirm.json() + assert body["hub_site_id"] == "hub-site-001" + assert body["api_key"] + + +def test_hub_push_idempotent(client: TestClient, enterprise): + login = client.post("/api/v1/auth/login", json={"email": "admin@compton.example", "password": "Admin1234"}) + token = login.json()["access_token"] + start = client.post( + "/api/v1/enterprise/pair/start", + headers={"Authorization": f"Bearer {token}"}, + json={"enterprise_id": enterprise.id, "farm_name": "Farm B"}, + ) + code = start.json()["code"] + confirm = client.post( + "/api/v1/enterprise/pair/confirm", + json={"code": code, "hub_site_id": "hub-site-002", "hub_name": "Farm B Hub"}, + ) + api_key = confirm.json()["api_key"] + hub_auth = f"Hub hub-site-002:{api_key}" + from datetime import UTC, datetime + + event = { + "event_id": "evt-001", + "seq": 1, + "domain": "global", + "table": "component", + "record_id": "comp-001", + "action": "upsert", + "version": 1, + "content_hash": "hash1", + "payload": {"name": "Corn", "type": "grain", "dry_matter": 88.0}, + "emitted_at": datetime.now(UTC).isoformat(), + "origin_site_id": "hub-site-002", + } + push1 = client.post( + "/api/v1/sync/changes/push", + headers={"Authorization": hub_auth}, + json={"events": [event]}, + ) + assert push1.status_code == 200 + assert "evt-001" in push1.json()["applied_event_ids"] + push2 = client.post( + "/api/v1/sync/changes/push", + headers={"Authorization": hub_auth}, + json={"events": [event]}, + ) + assert push2.status_code == 200 + assert "evt-001" in push2.json()["applied_event_ids"] diff --git a/apps/api/tests/modules/test/test_router.py b/apps/api/tests/modules/test/test_router.py new file mode 100644 index 0000000..7fcf1d9 --- /dev/null +++ b/apps/api/tests/modules/test/test_router.py @@ -0,0 +1,38 @@ +from fastapi.testclient import TestClient + +from app.core.config import settings +from app.core.email import memory_mailer +from app.main import create_app + + +def _test_client(monkeypatch) -> TestClient: + monkeypatch.setattr(settings, "enable_test_routes", True) + monkeypatch.setattr(settings, "email_delivery_mode", "memory") + return TestClient(create_app()) + + +def test_latest_email_token_route(monkeypatch): + client = _test_client(monkeypatch) + memory_mailer.clear() + memory_mailer.send( + to="token-route@example.com", + subject="Verify", + body="TOKEN:route-token\n", + template="verify_email", + ) + response = client.get( + "/api/v1/test/emails/latest-token", + params={"to": "token-route@example.com", "template": "verify_email"}, + ) + assert response.status_code == 200 + assert response.json()["token"] == "route-token" + + +def test_latest_email_token_route_missing_token(monkeypatch): + client = _test_client(monkeypatch) + response = client.get( + "/api/v1/test/emails/latest-token", + params={"to": "missing@example.com", "template": "verify_email"}, + ) + assert response.status_code == 404 + assert response.json()["detail"] == "TOKEN_NOT_FOUND" diff --git a/apps/api/tests/modules/test_api_integration.py b/apps/api/tests/modules/test_api_integration.py new file mode 100644 index 0000000..1a35791 --- /dev/null +++ b/apps/api/tests/modules/test_api_integration.py @@ -0,0 +1,170 @@ +from tests.fixtures import make_test_png +from tests.helpers import latest_token, register_and_verify, register_verify_login + + +def _admin_headers(client) -> dict[str, str]: + login = client.post( + "/api/v1/auth/login", + json={"email": "admin@compton.example", "password": "Admin1234"}, + ) + token = login.json()["access_token"] + return {"Authorization": f"Bearer {token}"} + + +def test_auth_refresh_and_logout(client): + register_and_verify(client, "refresh@example.com") + login = client.post( + "/api/v1/auth/login", + json={"email": "refresh@example.com", "password": "Valid123"}, + ) + assert login.status_code == 200 + assert "refresh_token" in login.cookies + + refresh = client.post("/api/v1/auth/refresh", headers={"Origin": "http://localhost:5173"}) + assert refresh.status_code == 200 + assert "access_token" in refresh.json() + + logout = client.post("/api/v1/auth/logout", headers={"Origin": "http://localhost:5173"}) + assert logout.status_code == 200 + + +def test_auth_invalid_login(client): + response = client.post( + "/api/v1/auth/login", + json={"email": "missing@example.com", "password": "Valid123"}, + ) + assert response.status_code == 401 + + +def test_users_patch_and_password_and_avatar(client): + headers = register_verify_login(client, "patch@example.com") + patch = client.patch("/api/v1/users/me", headers=headers, json={"display_name": "Patched"}) + assert patch.status_code == 200 + assert patch.json()["profile"]["display_name"] == "Patched" + + bad_password = client.post( + "/api/v1/users/me/password", + headers=headers, + json={"current_password": "wrong", "new_password": "NewValid1"}, + ) + assert bad_password.status_code == 400 + + avatar = client.post( + "/api/v1/users/me/avatar", + headers=headers, + files={"file": ("avatar.png", make_test_png(), "image/png")}, + ) + assert avatar.status_code == 200 + assert avatar.json()["profile"]["avatar_url"] + + +def test_users_unauthorized(client): + response = client.get("/api/v1/users/me") + assert response.status_code == 401 + + +def test_content_crud_flow(client): + headers = _admin_headers(client) + created = client.post( + "/api/v1/content/pages", + headers=headers, + json={"slug": "terms", "title": "Terms", "body": "

Terms

", "status": "draft"}, + ) + assert created.status_code == 200 + page_id = created.json()["id"] + + listed = client.get("/api/v1/content/pages") + assert listed.status_code == 200 + + updated = client.patch( + f"/api/v1/content/pages/{page_id}", + headers=headers, + json={"status": "published"}, + ) + assert updated.status_code == 200 + + fetched = client.get("/api/v1/content/pages/terms") + assert fetched.status_code == 200 + + deleted = client.delete(f"/api/v1/content/pages/{page_id}", headers=headers) + assert deleted.status_code == 200 + + +def test_admin_stats_and_patch_user(client): + headers = _admin_headers(client) + stats = client.get("/api/v1/admin/stats", headers=headers) + assert stats.status_code == 200 + + user_headers = register_verify_login(client, "blockme@example.com") + me = client.get("/api/v1/users/me", headers=user_headers) + user_id = me.json()["user"]["id"] + + blocked = client.patch( + f"/api/v1/admin/users/{user_id}", + headers=headers, + json={"status": "blocked"}, + ) + assert blocked.status_code == 200 + assert blocked.json()["status"] == "blocked" + + +def test_auth_pending_user_cannot_login(client): + client.post("/api/v1/auth/register", json={"email": "pending@example.com", "password": "Valid123"}) + response = client.post( + "/api/v1/auth/login", + json={"email": "pending@example.com", "password": "Valid123"}, + ) + assert response.status_code == 403 + assert response.json()["detail"] == "EMAIL_NOT_VERIFIED" + + +def test_pending_user_cannot_access_profile(client): + from app.core.security import create_access_token + from app.modules.users.repository import get_user_by_email + + client.post("/api/v1/auth/register", json={"email": "pendingme@example.com", "password": "Valid123"}) + user = get_user_by_email("pendingme@example.com") + token = create_access_token(user.id, user.role) + response = client.get("/api/v1/users/me", headers={"Authorization": f"Bearer {token}"}) + assert response.status_code == 403 + assert response.json()["detail"] == "EMAIL_NOT_VERIFIED" + + +def test_auth_invalid_refresh_token(client): + response = client.post("/api/v1/auth/refresh") + assert response.status_code == 401 + + client.cookies.set("refresh_token", "invalid-token", path="/api/v1/auth") + invalid = client.post("/api/v1/auth/refresh", headers={"Origin": "http://localhost:5173"}) + assert invalid.status_code == 401 + + +def test_auth_resend_and_reset_password(client): + client.post("/api/v1/auth/register", json={"email": "resetme@example.com", "password": "Valid123"}) + resend = client.post("/api/v1/auth/resend-verification", json={"email": "resetme@example.com"}) + assert resend.status_code == 200 + verify_token = latest_token("resetme@example.com", "verify_email") + verify = client.post("/api/v1/auth/verify-email", json={"token": verify_token}) + assert verify.status_code == 200 + + forgot = client.post("/api/v1/auth/forgot-password", json={"email": "resetme@example.com"}) + assert forgot.status_code == 200 + reset_token = latest_token("resetme@example.com", "reset_password") + + reset = client.post( + "/api/v1/auth/reset-password", + json={"token": reset_token, "new_password": "NewValid1"}, + ) + assert reset.status_code == 200 + + login = client.post( + "/api/v1/auth/login", + json={"email": "resetme@example.com", "password": "NewValid1"}, + ) + assert login.status_code == 200 + + invalid = client.post( + "/api/v1/auth/reset-password", + json={"token": "invalid-token", "new_password": "NewValid1"}, + ) + assert invalid.status_code == 400 diff --git a/apps/api/tests/modules/users/test_avatar.py b/apps/api/tests/modules/users/test_avatar.py new file mode 100644 index 0000000..02db59c --- /dev/null +++ b/apps/api/tests/modules/users/test_avatar.py @@ -0,0 +1,42 @@ +from tests.fixtures import make_test_png +from tests.helpers import register_verify_login + + +def test_upload_avatar_success(client): + headers = register_verify_login(client, "avatar@example.com") + response = client.post( + "/api/v1/users/me/avatar", + headers=headers, + files={"file": ("avatar.png", make_test_png(), "image/png")}, + ) + assert response.status_code == 200 + avatar_url = response.json()["profile"]["avatar_url"] + assert avatar_url.startswith("/api/v1/media/files/avatars/") + + media = client.get(avatar_url) + assert media.status_code == 200 + assert media.headers["content-type"].startswith("image/") + + +def test_upload_avatar_rejects_svg(client): + headers = register_verify_login(client, "svg@example.com") + svg = b"" + response = client.post( + "/api/v1/users/me/avatar", + headers=headers, + files={"file": ("avatar.svg", svg, "image/svg+xml")}, + ) + assert response.status_code == 400 + assert response.json()["detail"] in {"INVALID_MIME", "INVALID_IMAGE"} + + +def test_upload_avatar_rejects_oversize(client): + headers = register_verify_login(client, "big@example.com") + oversized = make_test_png() + b"0" * (2 * 1024 * 1024) + response = client.post( + "/api/v1/users/me/avatar", + headers=headers, + files={"file": ("big.png", oversized, "image/png")}, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "FILE_TOO_LARGE" diff --git a/apps/api/tests/modules/users/test_users_router.py b/apps/api/tests/modules/users/test_users_router.py new file mode 100644 index 0000000..4ae6164 --- /dev/null +++ b/apps/api/tests/modules/users/test_users_router.py @@ -0,0 +1,30 @@ +from tests.helpers import register_verify_login + + +def test_me_endpoint(client): + headers = register_verify_login(client, "me@example.com", "Valid123") + response = client.get("/api/v1/users/me", headers=headers) + assert response.status_code == 200 + assert response.json()["user"]["email"] == "me@example.com" + + +def test_patch_me_updates_display_name(client): + headers = register_verify_login(client, "display@example.com", "Valid123") + response = client.patch( + "/api/v1/users/me", + headers=headers, + json={"display_name": "Updated Name"}, + ) + assert response.status_code == 200 + assert response.json()["profile"]["display_name"] == "Updated Name" + + +def test_change_password_rejects_invalid_current(client): + headers = register_verify_login(client, "pwd@example.com", "Valid123") + response = client.post( + "/api/v1/users/me/password", + headers=headers, + json={"current_password": "Wrong123", "new_password": "NewValid1"}, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "INVALID_CURRENT_PASSWORD" diff --git a/apps/api/tests/modules/zootech/test_orchestrator_system_metrics.py b/apps/api/tests/modules/zootech/test_orchestrator_system_metrics.py new file mode 100644 index 0000000..530a08c --- /dev/null +++ b/apps/api/tests/modules/zootech/test_orchestrator_system_metrics.py @@ -0,0 +1,11 @@ +from app.modules.zootech.orchestrator_system_metrics import collect_orchestrator_system_metrics + + +def test_collect_orchestrator_system_metrics_shape(): + metrics = collect_orchestrator_system_metrics() + if metrics.get("available"): + assert "memory" in metrics + assert "cpu" in metrics + assert "swap" in metrics + else: + assert "message" in metrics diff --git a/apps/api/tests/modules/zootech/test_wesp_compat.py b/apps/api/tests/modules/zootech/test_wesp_compat.py new file mode 100644 index 0000000..15f8449 --- /dev/null +++ b/apps/api/tests/modules/zootech/test_wesp_compat.py @@ -0,0 +1,356 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient + +from app.modules.zootech.catalog_apply import apply_catalog_change +from tests.orchestrator_dual_harness import make_enterprise + + +def test_wesp_compat_feed_dispensers(client: TestClient): + enterprise_id = make_enterprise(client) + login = client.post("/api/v1/auth/login", json={"email": "admin@compton.example", "password": "Admin1234"}) + token = login.json()["access_token"] + session = client.post( + f"/api/v1/enterprise/enterprises/{enterprise_id}/session", + headers={"Authorization": f"Bearer {token}"}, + ) + token = session.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + resp = client.get(f"/api/feed_dispensers?enterprise_id={enterprise_id}", headers=headers) + assert resp.status_code == 200 + assert isinstance(resp.json(), list) + + +def test_wesp_compat_components(client: TestClient): + enterprise_id = make_enterprise(client) + login = client.post("/api/v1/auth/login", json={"email": "admin@compton.example", "password": "Admin1234"}) + token = login.json()["access_token"] + session = client.post( + f"/api/v1/enterprise/enterprises/{enterprise_id}/session", + headers={"Authorization": f"Bearer {token}"}, + ) + token = session.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + resp = client.get(f"/api/components?enterprise_id={enterprise_id}", headers=headers) + assert resp.status_code == 200 + assert isinstance(resp.json(), list) + + +def test_wesp_compat_recipe_includes_ingredients_and_unloading_groups(client: TestClient): + enterprise_id = make_enterprise(client) + login = client.post("/api/v1/auth/login", json={"email": "admin@compton.example", "password": "Admin1234"}) + token = login.json()["access_token"] + session = client.post( + f"/api/v1/enterprise/enterprises/{enterprise_id}/session", + headers={"Authorization": f"Bearer {token}"}, + ) + token = session.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + recipe_id = "recipe-compat-1" + ing_id = "ing-compat-1" + grp_id = "grp-compat-1" + apply_catalog_change( + enterprise_id, + "recipe", + recipe_id, + "upsert", + { + "id": recipe_id, + "name": "Compat Mix", + "heads_per_trip": 100, + "mixing_time": 10, + "trip_percent": 100.0, + }, + 1, + "r1", + ) + apply_catalog_change( + enterprise_id, + "ingredient", + ing_id, + "upsert", + { + "id": ing_id, + "recipe_id": recipe_id, + "name": "Corn", + "amount": 12.5, + "order": 1, + "dry_matter": 88.0, + }, + 1, + "i1", + ) + apply_catalog_change( + enterprise_id, + "unloading_group", + grp_id, + "upsert", + { + "id": grp_id, + "recipe_id": recipe_id, + "name": "Barn A", + "distribution_type": "percent", + "value": 50.0, + "weight": 500.0, + "order": 1, + }, + 1, + "g1", + ) + + resp = client.get(f"/api/recipes/{recipe_id}?enterprise_id={enterprise_id}", headers=headers) + assert resp.status_code == 200 + body = resp.json() + assert body["name"] == "Compat Mix" + assert len(body.get("ingredients") or []) == 1 + assert body["ingredients"][0]["name"] == "Corn" + assert len(body.get("unloading_groups") or body.get("unloadingGroups") or []) == 1 + assert body["unloading_groups"][0]["name"] == "Barn A" + + +def test_wesp_compat_recipe_calculate(client: TestClient): + resp = client.post( + "/api/recipes/calculate", + json={ + "ingredients": [{"weightPerHead": 10, "dryMatter": 88, "component_id": "c1"}], + "headsCount": 100, + "tripPercent": 100, + "unloadingGroups": [{"distributionType": "percent", "value": 50}], + }, + ) + assert resp.status_code == 200 + body = resp.json() + assert len(body.get("ingredients") or []) == 1 + assert len(body.get("unloadingGroups") or []) == 1 + + +def test_wesp_compat_recipe_put_persists_unloading_group(client: TestClient): + enterprise_id = make_enterprise(client) + login = client.post("/api/v1/auth/login", json={"email": "admin@compton.example", "password": "Admin1234"}) + token = login.json()["access_token"] + session = client.post( + f"/api/v1/enterprise/enterprises/{enterprise_id}/session", + headers={"Authorization": f"Bearer {token}"}, + ) + token = session.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + recipe_id = "recipe-put-1" + apply_catalog_change( + enterprise_id, + "recipe", + recipe_id, + "upsert", + {"id": recipe_id, "name": "Before", "heads_per_trip": 100, "mixing_time": 10, "trip_percent": 100.0}, + 1, + "r1", + ) + + put = client.put( + f"/api/recipes/{recipe_id}?enterprise_id={enterprise_id}", + headers=headers, + json={ + "name": "After", + "heads_count": 100, + "mixing_time": 10, + "trip_percent": 100, + "dry_matter_locked": False, + "unloading_link_broken": False, + "ingredients": [], + "unloading_groups": [ + { + "name": "Group A", + "distribution_type": "percent", + "value": 100, + "weight": 500, + "order": 1, + } + ], + }, + ) + assert put.status_code == 200 + + got = client.get(f"/api/recipes/{recipe_id}?enterprise_id={enterprise_id}", headers=headers) + assert got.status_code == 200 + groups = got.json().get("unloading_groups") or [] + assert len(groups) == 1 + assert groups[0]["name"] == "Group A" + + +def test_wesp_compat_analytics_endpoints(client: TestClient): + enterprise_id = make_enterprise(client) + login = client.post("/api/v1/auth/login", json={"email": "admin@compton.example", "password": "Admin1234"}) + token = login.json()["access_token"] + session = client.post( + f"/api/v1/enterprise/enterprises/{enterprise_id}/session", + headers={"Authorization": f"Bearer {token}"}, + ) + token = session.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + params = f"enterprise_id={enterprise_id}&date_from=2026-07-01&date_to=2026-07-15" + + finance = client.get(f"/api/analytics/finance?{params}", headers=headers) + assert finance.status_code == 200 + assert "overloadRub" in finance.json() + + plan_fact = client.get(f"/api/analytics/plan-fact?{params}", headers=headers) + assert plan_fact.status_code == 200 + assert plan_fact.json()["items"] == [] + + summary = client.get(f"/api/feed-quality/alerts/summary?{params}", headers=headers) + assert summary.status_code == 200 + assert "total" in summary.json() + + alerts = client.get(f"/api/feed-quality/alerts?{params}", headers=headers) + assert alerts.status_code == 200 + assert "items" in alerts.json() + + +def test_wesp_compat_reports_list(client: TestClient): + from app.modules.zootech.report_apply import apply_report_change + + enterprise_id = make_enterprise(client) + login = client.post("/api/v1/auth/login", json={"email": "admin@compton.example", "password": "Admin1234"}) + token = login.json()["access_token"] + session = client.post( + f"/api/v1/enterprise/enterprises/{enterprise_id}/session", + headers={"Authorization": f"Bearer {token}"}, + ) + token = session.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + loading_id = "lr-test-1" + apply_report_change( + enterprise_id, + "loading_report", + loading_id, + "upsert", + { + "recipe_id": "recipe-1", + "recipe_name": "Test Mix", + "start_time": datetime.now(UTC).isoformat(), + "end_time": datetime.now(UTC).isoformat(), + "total_weight": 1200.0, + "dispenser_type": "dispenser", + "components": [{"component_name": "Corn", "actual_weight": 100.0}], + }, + 1, + "h1", + ) + apply_report_change( + enterprise_id, + "unloading_report", + "ur-test-1", + "upsert", + { + "loading_report_id": loading_id, + "start_time": datetime.now(UTC).isoformat(), + "total_weight": 1200.0, + "total_unloaded_weight": 1100.0, + "remaining_weight": 100.0, + }, + 1, + "u1", + ) + + resp = client.get(f"/api/reports?enterprise_id={enterprise_id}", headers=headers) + assert resp.status_code == 200 + body = resp.json() + assert isinstance(body, list) + assert len(body) == 1 + assert body[0]["recipe_id"] == "recipe-1" + assert body[0]["unloading_data"]["total_unloaded_weight"] == 1100.0 + + +def test_apply_report_change_update_adds_components(client: TestClient): + import json + from app.core.database import session_scope + from app.modules.zootech.report_apply import apply_report_change + from app.modules.zootech.report_models import ZootechLoadingReport + from sqlalchemy import select + + enterprise_id = make_enterprise(client) + loading_id = "lr-test-components" + apply_report_change( + enterprise_id, + "loading_report", + loading_id, + "upsert", + { + "recipe_id": "recipe-1", + "recipe_name": "Test Mix", + "start_time": datetime.now(UTC).isoformat(), + "total_weight": 1200.0, + }, + 1, + "h1", + ) + apply_report_change( + enterprise_id, + "loading_report", + loading_id, + "upsert", + { + "recipe_id": "recipe-1", + "recipe_name": "Test Mix", + "start_time": datetime.now(UTC).isoformat(), + "total_weight": 1200.0, + "components": [{"component_name": "Corn", "actual_weight": 100.0, "overload": 5.0}], + }, + 2, + "h2", + ) + with session_scope() as db: + row = db.scalar( + select(ZootechLoadingReport).where( + ZootechLoadingReport.enterprise_id == enterprise_id, + ZootechLoadingReport.id == loading_id, + ) + ) + assert row is not None + payload = json.loads(row.payload_json or "{}") + assert len(payload.get("components") or []) == 1 + assert payload["components"][0]["component_name"] == "Corn" + + +def test_apply_report_change_feed_alert(client: TestClient): + from app.core.database import session_scope + from app.modules.zootech.report_apply import apply_report_change + from app.modules.zootech.report_models import ZootechFeedAlert + from sqlalchemy import select + + enterprise_id = make_enterprise(client) + alert_id = "fa-test-1" + apply_report_change( + enterprise_id, + "feed_alert", + alert_id, + "upsert", + { + "event_type": "UNDERLOAD", + "severity": "error", + "loading_report_id": "lr-1", + "recipe_id": "recipe-1", + "recipe_name": "Mix", + "detail": "Underload detected", + "deviation_kg": -5.0, + }, + 1, + "h1", + ) + with session_scope() as db: + row = db.scalar( + select(ZootechFeedAlert).where( + ZootechFeedAlert.enterprise_id == enterprise_id, + ZootechFeedAlert.id == alert_id, + ) + ) + assert row is not None + assert row.alert_type == "UNDERLOAD" + assert row.message == "Underload detected" + assert row.payload_json diff --git a/apps/api/tests/modules/zootech/test_wesp_compat_admin.py b/apps/api/tests/modules/zootech/test_wesp_compat_admin.py new file mode 100644 index 0000000..c1f0188 --- /dev/null +++ b/apps/api/tests/modules/zootech/test_wesp_compat_admin.py @@ -0,0 +1,97 @@ +def test_wesp_admin_users_shape(client): + login = client.post( + "/api/v1/auth/login", + json={"email": "admin@compton.example", "password": "Admin1234"}, + ) + token = login.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + response = client.get("/api/admin/users", headers=headers) + assert response.status_code == 200 + body = response.json() + assert body["status"] == "success" + assert isinstance(body["users"], list) + assert body["users"] + assert "login" in body["users"][0] + + +def test_wesp_admin_summary_and_stubs(client): + login = client.post( + "/api/v1/auth/login", + json={"email": "admin@compton.example", "password": "Admin1234"}, + ) + token = login.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + summary = client.get("/api/admin/summary", headers=headers) + assert summary.status_code == 200 + data = summary.json() + assert data["status"] == "success" + assert "counts" in data + assert "sync" in data + + metrics = client.get("/api/admin/system-metrics", headers=headers) + assert metrics.status_code == 200 + assert metrics.json()["status"] == "success" + assert "metrics" in metrics.json() + + network = client.get("/api/admin/network-settings", headers=headers) + assert network.status_code == 200 + assert network.json()["network"]["public_base_url"] + + sync_diag = client.get("/api/admin/sync-diagnostics", headers=headers) + assert sync_diag.status_code == 200 + assert sync_diag.json()["status"] == "success" + + +def test_wesp_admin_server_log(client): + login = client.post( + "/api/v1/auth/login", + json={"email": "admin@compton.example", "password": "Admin1234"}, + ) + headers = {"Authorization": f"Bearer {login.json()['access_token']}"} + response = client.get("/api/admin/server-log?lines=10", headers=headers) + assert response.status_code == 200 + body = response.json() + assert body["status"] in {"success", "error"} + assert "lines" in body + + download = client.get("/api/admin/server-log/download", headers=headers) + assert download.status_code == 200 + assert "text/plain" in (download.headers.get("content-type") or "") + + +def test_wesp_admin_client_logs_index(client): + login = client.post( + "/api/v1/auth/login", + json={"email": "admin@compton.example", "password": "Admin1234"}, + ) + headers = {"Authorization": f"Bearer {login.json()['access_token']}"} + response = client.get("/api/admin/client-uploaded-logs", headers=headers) + assert response.status_code == 200 + body = response.json() + assert body["status"] == "success" + assert isinstance(body["clients"], list) + + +def test_wesp_admin_ui_activity(client): + login = client.post( + "/api/v1/auth/login", + json={"email": "admin@compton.example", "password": "Admin1234"}, + ) + token = login.json()["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + response = client.post( + "/api/admin/ui-activity", + headers=headers, + json={"text": "test event", "level": "ok"}, + ) + assert response.status_code == 200 + assert response.json()["status"] == "success" + + feed = client.get("/api/admin/activity-feed", headers=headers) + assert feed.status_code == 200 + body = feed.json() + assert body["status"] == "success" + assert isinstance(body["entries"], list) diff --git a/apps/api/tests/modules/zootech/test_wesp_compat_auth.py b/apps/api/tests/modules/zootech/test_wesp_compat_auth.py new file mode 100644 index 0000000..d525291 --- /dev/null +++ b/apps/api/tests/modules/zootech/test_wesp_compat_auth.py @@ -0,0 +1,24 @@ +def test_wesp_auth_login(client): + response = client.post( + "/api/auth/login", + json={"login": "admin@compton.example", "password": "Admin1234"}, + ) + assert response.status_code == 200 + body = response.json() + assert body["status"] == "success" + assert body.get("access_token") + + +def test_wesp_auth_check(client): + login = client.post( + "/api/v1/auth/login", + json={"email": "admin@compton.example", "password": "Admin1234"}, + ) + token = login.json()["access_token"] + response = client.get("/api/auth/check", headers={"Authorization": f"Bearer {token}"}) + assert response.status_code == 200 + body = response.json() + assert body["status"] == "success" + assert body["authenticated"] is True + assert body["user_login"] == "admin@compton.example" + assert body["is_superuser"] is True diff --git a/apps/api/tests/modules/zootech/test_wesp_compat_misc.py b/apps/api/tests/modules/zootech/test_wesp_compat_misc.py new file mode 100644 index 0000000..097021a --- /dev/null +++ b/apps/api/tests/modules/zootech/test_wesp_compat_misc.py @@ -0,0 +1,32 @@ +def test_wesp_updates_status(client): + login = client.post( + "/api/v1/auth/login", + json={"email": "admin@compton.example", "password": "Admin1234"}, + ) + headers = {"Authorization": f"Bearer {login.json()['access_token']}"} + response = client.get("/api/updates/status", headers=headers) + assert response.status_code == 200 + body = response.json() + assert body["update_available"] is False + + +def test_wesp_notifications_summary(client): + login = client.post( + "/api/v1/auth/login", + json={"email": "admin@compton.example", "password": "Admin1234"}, + ) + headers = {"Authorization": f"Bearer {login.json()['access_token']}"} + response = client.get("/api/notifications?summary=1", headers=headers) + assert response.status_code == 200 + assert response.json()["unreadCount"] == 0 + + +def test_wesp_admin_hardware_stub(client): + login = client.post( + "/api/v1/auth/login", + json={"email": "admin@compton.example", "password": "Admin1234"}, + ) + headers = {"Authorization": f"Bearer {login.json()['access_token']}"} + response = client.get("/api/admin/hardware-status", headers=headers) + assert response.status_code == 200 + assert response.json()["status"] == "success" diff --git a/apps/api/tests/orchestrator_dual_harness.py b/apps/api/tests/orchestrator_dual_harness.py new file mode 100644 index 0000000..dddf1ff --- /dev/null +++ b/apps/api/tests/orchestrator_dual_harness.py @@ -0,0 +1,178 @@ +"""Orchestrator + two virtual hub clients for dual-hub sync E2E tests.""" + +from __future__ import annotations + +import copy +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Any +from uuid import uuid4 + +from fastapi.testclient import TestClient + +from app.modules.sync import repository as repo + + +@dataclass +class HubClient: + hub_site_id: str + farm_hub_id: str + api_key: str + pull_cursor: int = 0 + local_catalog: dict[str, dict[str, dict[str, Any]]] = field(default_factory=dict) + + def auth_header(self) -> dict[str, str]: + return {"Authorization": f"Hub {self.hub_site_id}:{self.api_key}"} + + +class OrchestratorDualHubHarness: + def __init__(self, client: TestClient, enterprise_id: str) -> None: + self.client = client + self.enterprise_id = enterprise_id + self.hub_a: HubClient | None = None + self.hub_b: HubClient | None = None + + def pair_hubs(self) -> tuple[HubClient, HubClient]: + suffix = uuid4().hex[:8] + code_a = self._start_pairing() + hub_a = self._confirm_hub(code_a, f"hub-site-a-{suffix}", "Hub A") + code_b = self._start_pairing() + hub_b = self._confirm_hub(code_b, f"hub-site-b-{suffix}", "Hub B") + self.hub_a = hub_a + self.hub_b = hub_b + return hub_a, hub_b + + def _start_pairing(self) -> str: + login = self.client.post( + "/api/v1/auth/login", + json={"email": "admin@compton.example", "password": "Admin1234"}, + ) + token = login.json()["access_token"] + start = self.client.post( + "/api/v1/enterprise/pair/start", + headers={"Authorization": f"Bearer {token}"}, + json={"enterprise_id": self.enterprise_id, "farm_name": "Farm"}, + ) + return start.json()["code"] + + def _confirm_hub(self, code: str, hub_site_id: str, hub_name: str) -> HubClient: + confirm = self.client.post( + "/api/v1/enterprise/pair/confirm", + json={"code": code, "hub_site_id": hub_site_id, "hub_name": hub_name}, + ) + body = confirm.json() + return HubClient( + hub_site_id=body["hub_site_id"], + farm_hub_id=body["farm_hub_id"], + api_key=body["api_key"], + ) + + def edit_local(self, hub: HubClient, table: str, record_id: str, payload: dict, *, version: int, content_hash: str) -> dict: + row = copy.deepcopy(payload) + row["id"] = record_id + row["version"] = version + row["content_hash"] = content_hash + hub.local_catalog.setdefault(table, {})[record_id] = row + return row + + def push_from(self, hub: HubClient, events: list[dict]) -> dict: + resp = self.client.post( + "/api/v1/sync/changes/push", + headers=hub.auth_header(), + json={"events": events}, + ) + resp.raise_for_status() + return resp.json() + + def pull_for(self, hub: HubClient) -> list[dict]: + resp = self.client.post( + "/api/v1/sync/changes/pull", + headers=hub.auth_header(), + json={"cursor": hub.pull_cursor, "limit": 100}, + ) + resp.raise_for_status() + body = resp.json() + events = body.get("events") or [] + ack_ids: list[str] = [] + for ev in events: + self._apply_local(hub, ev) + ack_ids.append(ev["event_id"]) + if ack_ids: + self.client.post( + "/api/v1/sync/changes/ack", + headers=hub.auth_header(), + json={"event_ids": ack_ids, "direction": "inbound"}, + ) + if body.get("next_cursor") is not None: + hub.pull_cursor = int(body["next_cursor"]) + return events + + def _apply_local(self, hub: HubClient, event: dict) -> None: + table = event["table"] + record_id = event["record_id"] + hub.local_catalog.setdefault(table, {})[record_id] = { + **(event.get("payload") or {}), + "id": record_id, + "version": event.get("version"), + "content_hash": event.get("content_hash"), + } + + def drain(self, hubs: list[HubClient] | None = None, rounds: int = 10) -> None: + targets = hubs or [h for h in (self.hub_a, self.hub_b) if h] + for _ in range(rounds): + for hub in targets: + self.pull_for(hub) + + def get_catalog(self, hub: HubClient, table: str, record_id: str) -> dict | None: + return hub.local_catalog.get(table, {}).get(record_id) + + def get_orchestrator_catalog(self, table: str, record_id: str) -> dict | None: + from app.modules.zootech.catalog_apply import load_catalog_row + + return load_catalog_row(self.enterprise_id, table, record_id) + + def make_component_event( + self, + hub: HubClient, + record_id: str, + *, + name: str, + version: int, + content_hash: str, + event_id: str | None = None, + dry_matter: float = 88.0, + ) -> dict: + payload = { + "name": name, + "type": "grain", + "dry_matter": dry_matter, + "protein": 8.0, + "energy": 1.2, + "price": 0.0, + "is_active": True, + } + self.edit_local(hub, "component", record_id, payload, version=version, content_hash=content_hash) + return { + "event_id": event_id or str(uuid4()), + "seq": version, + "domain": "global", + "table": "component", + "record_id": record_id, + "action": "upsert", + "version": version, + "content_hash": content_hash, + "payload": {**payload, "id": record_id, "version": version, "content_hash": content_hash}, + "emitted_at": datetime.now(UTC).isoformat(), + "origin_site_id": hub.hub_site_id, + } + + +def make_enterprise(client: TestClient) -> str: + slug = f"test-ent-{uuid4().hex[:8]}" + ent = repo.create_enterprise("Test Enterprise", slug) + from app.modules.users.repository import get_user_by_email + + user = get_user_by_email("admin@compton.example") + assert user + repo.add_member(user.id, ent.id, "admin") + return ent.id diff --git a/apps/api/tests/scripts/test_docker_entrypoint.py b/apps/api/tests/scripts/test_docker_entrypoint.py new file mode 100644 index 0000000..9b6041c --- /dev/null +++ b/apps/api/tests/scripts/test_docker_entrypoint.py @@ -0,0 +1,101 @@ +from sqlalchemy import create_engine, inspect + +from scripts.docker_entrypoint import HEAD_REVISION, INITIAL_REVISION, current_revision, run_migrations + + +def test_run_migrations_on_empty_sqlite(tmp_path, monkeypatch): + db_path = tmp_path / "migrate.sqlite" + database_url = f"sqlite+pysqlite:///{db_path.as_posix()}" + monkeypatch.setenv("DATABASE_URL", database_url) + + from app.core.config import settings + + monkeypatch.setattr(settings, "database_url", database_url) + + engine = create_engine(database_url) + run_migrations(engine) + + tables = set(inspect(engine).get_table_names()) + assert "users" in tables + assert current_revision(engine) == HEAD_REVISION + + +def test_run_migrations_stamps_existing_schema_without_alembic(tmp_path, monkeypatch): + db_path = tmp_path / "existing.sqlite" + database_url = f"sqlite+pysqlite:///{db_path.as_posix()}" + monkeypatch.setenv("DATABASE_URL", database_url) + + from app.core.config import settings + + monkeypatch.setattr(settings, "database_url", database_url) + + engine = create_engine(database_url) + with engine.begin() as connection: + connection.exec_driver_sql( + """ + CREATE TABLE users ( + id VARCHAR(36) PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(16) NOT NULL, + status VARCHAR(16) NOT NULL, + failed_login_attempts INTEGER NOT NULL, + locked_until TIMESTAMP, + email_verified_at TIMESTAMP, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ) + """ + ) + + run_migrations(engine) + + tables = set(inspect(engine).get_table_names()) + assert "refresh_tokens" in tables + assert current_revision(engine) == HEAD_REVISION + assert INITIAL_REVISION == "20260711_0001" + + +def test_run_migrations_repairs_partial_schema_with_stale_alembic(tmp_path, monkeypatch): + db_path = tmp_path / "stale.sqlite" + database_url = f"sqlite+pysqlite:///{db_path.as_posix()}" + monkeypatch.setenv("DATABASE_URL", database_url) + + from app.core.config import settings + + monkeypatch.setattr(settings, "database_url", database_url) + + engine = create_engine(database_url) + with engine.begin() as connection: + connection.exec_driver_sql( + """ + CREATE TABLE users ( + id VARCHAR(36) PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(16) NOT NULL, + status VARCHAR(16) NOT NULL, + failed_login_attempts INTEGER NOT NULL, + locked_until TIMESTAMP, + email_verified_at TIMESTAMP, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ) + """ + ) + connection.exec_driver_sql( + """ + CREATE TABLE alembic_version ( + version_num VARCHAR(32) NOT NULL PRIMARY KEY + ) + """ + ) + connection.exec_driver_sql( + f"INSERT INTO alembic_version (version_num) VALUES ('{INITIAL_REVISION}')" + ) + + run_migrations(engine) + + tables = set(inspect(engine).get_table_names()) + assert "refresh_tokens" in tables + assert current_revision(engine) == HEAD_REVISION diff --git a/apps/api/tests/test_wesp_ui_parity.py b/apps/api/tests/test_wesp_ui_parity.py new file mode 100644 index 0000000..6e4af34 --- /dev/null +++ b/apps/api/tests/test_wesp_ui_parity.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from pathlib import Path + + +def test_wesp_ui_parity_files_exist(): + root = Path(__file__).resolve().parents[2] / "web" / "public" / "static" + required = [ + "recipes.html", + "components.html", + "feed_dispensers.html", + "consumption.html", + "reports.html", + "admin.html", + "js/wesp-orchestrator-boot.js", + "js/wesp-orchestrator-auth-bridge.js", + "js/pages/recipes-data-controller.js", + "js/pages/multiserver-panel.js", + "css/wesp-zootech-shell.css", + ] + for rel in required: + assert (root / rel).is_file(), f"missing {rel}" diff --git a/apps/api/tests/test_zootech_k_hub_ui_contract.py b/apps/api/tests/test_zootech_k_hub_ui_contract.py new file mode 100644 index 0000000..fcf2f8d --- /dev/null +++ b/apps/api/tests/test_zootech_k_hub_ui_contract.py @@ -0,0 +1,35 @@ +"""Port of WESP test_zootech_k_hub_ui_contract for orchestrator public/wesp copy.""" + +from __future__ import annotations + +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] / "web" / "public" / "static" + +ZOOTECH_PAGES = ( + "recipes.html", + "reports.html", + "components.html", + "feed_dispensers.html", + "consumption.html", +) + + +def test_zootech_pages_include_k_hub_assets(): + for page in ZOOTECH_PAGES: + html = (PROJECT_ROOT / page).read_text(encoding="utf-8") + assert "wesp-zootech-k-hub.css" in html, page + assert "daily-plan-panel.js" in html, page + assert "wesp-zootech-notification-center.js" in html, page + + +def test_orchestrator_boot_present(): + boot = (PROJECT_ROOT / "js" / "wesp-orchestrator-boot.js").read_text(encoding="utf-8") + assert "WespOrchestratorBoot" in boot + assert "enterprise_id" in boot + + +def test_multiserver_panel_uses_sync_api(): + js = (PROJECT_ROOT / "js" / "pages" / "multiserver-panel.js").read_text(encoding="utf-8") + assert "/api/v1/sync/conflicts" in js + assert "resolve" in js diff --git a/apps/web/.dockerignore b/apps/web/.dockerignore new file mode 100644 index 0000000..a6022bf --- /dev/null +++ b/apps/web/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +coverage +.git +*.log +.env +.env.* diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..bb9ac1c --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,6 @@ +# Copy to .env for local dev: cp .env.example .env +# In dev, API proxy is enabled by default (requests go through Vite, not cross-origin). +VITE_USE_API_PROXY=true +VITE_API_URL=http://localhost:8000 +VITE_APP_NAME=Compton +VITE_SENTRY_DSN= diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000..9fe76d8 --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,31 @@ +# Monorepo dev image: build context must be the repository root (see docker-compose.yml). +FROM node:22-alpine + +# pnpm 9+ via Corepack (bundled with Node 22) +RUN corepack enable && corepack prepare pnpm@9.15.9 --activate + +WORKDIR /app + +# --- dependency layer: copy only manifests so `pnpm install` can be cached --- +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY apps/web/package.json ./apps/web/ +COPY packages/shared-types/package.json ./packages/shared-types/ +COPY packages/eslint-config/package.json ./packages/eslint-config/ + +# Workspace packages referenced by the lockfile (minimal source for install/link) +COPY packages/shared-types ./packages/shared-types/ +COPY packages/eslint-config ./packages/eslint-config/ + +RUN pnpm install --frozen-lockfile + +# App source baked into the image; at runtime bind-mount overrides for live-reload +COPY apps/web ./apps/web/ + +EXPOSE 5173 + +# Chokidar polling helps file watching on Docker Desktop (Windows/macOS bind mounts) +ENV CHOKIDAR_USEPOLLING=true +ENV WATCHPACK_POLLING=true + +# `pnpm exec` runs vite directly in the web workspace (avoids `--` arg forwarding issues) +CMD ["pnpm", "--filter", "web", "exec", "vite", "--host", "0.0.0.0"] diff --git a/apps/web/app.html b/apps/web/app.html new file mode 100644 index 0000000..1411558 --- /dev/null +++ b/apps/web/app.html @@ -0,0 +1,39 @@ + + + + + + + + + + + + + Compton + + +
+ + + diff --git a/apps/web/e2e/admin/admin-users.spec.ts b/apps/web/e2e/admin/admin-users.spec.ts new file mode 100644 index 0000000..fe93dd7 --- /dev/null +++ b/apps/web/e2e/admin/admin-users.spec.ts @@ -0,0 +1,85 @@ +import { expect, test } from "@playwright/test"; +import { API_URL, adminLogin, loginViaUi, registerVerifyLogin, uniqueEmail } from "../helpers/api"; + +test.describe("§15.7 scenarios 5 & 9: Admin users", () => { + test("blocked user access token rejected immediately after block", async ({ request }) => { + const email = uniqueEmail("e2e-block-jwt"); + const session = await registerVerifyLogin(request, email); + const admin = await adminLogin(request); + const adminToken = (await admin.json()).access_token; + + const patch = await request.patch(`${API_URL}/api/v1/admin/users/${session.user.id}`, { + headers: { Authorization: `Bearer ${adminToken}` }, + data: { status: "blocked" } + }); + expect(patch.ok()).toBeTruthy(); + + const me = await request.get(`${API_URL}/api/v1/users/me`, { + headers: { Authorization: `Bearer ${session.accessToken}` } + }); + expect(me.status()).toBe(401); + expect((await me.json()).detail).toBe("TOKEN_REVOKED"); + }); + + test("admin blocks user → blocked user cannot login", async ({ page, request }) => { + const email = uniqueEmail("e2e-block"); + const session = await registerVerifyLogin(request, email); + const admin = await adminLogin(request); + const adminToken = (await admin.json()).access_token; + + const patch = await request.patch(`${API_URL}/api/v1/admin/users/${session.user.id}`, { + headers: { Authorization: `Bearer ${adminToken}` }, + data: { status: "blocked" } + }); + expect(patch.ok()).toBeTruthy(); + + await loginViaUi(page, email, "Valid1234"); + await expect(page).toHaveURL(/\/login$/); + }); + + test("admin can open platform users page", async ({ page }) => { + await loginViaUi(page, "admin@compton.example", "Admin1234"); + await expect(page).toHaveURL(/\/recipes$/); + await page.goto("/platform"); + await expect(page.getByRole("heading", { name: "Users" })).toBeVisible(); + }); + + test("admin session survives reload on /platform", async ({ page }) => { + await loginViaUi(page, "admin@compton.example", "Admin1234"); + await page.goto("/platform"); + await expect(page.getByRole("heading", { name: "Users" })).toBeVisible(); + await page.goto("/platform"); + await expect(page.getByRole("heading", { name: "Users" })).toBeVisible(); + }); + + test("non-admin cannot open platform page", async ({ page, request }) => { + const email = uniqueEmail("e2e-nonadmin"); + await registerVerifyLogin(request, email); + await loginViaUi(page, email, "Valid1234"); + await expect(page).toHaveURL(/\/profile$/); + await page.goto("/platform"); + await expect(page).toHaveURL(/\/$/); + }); + + test("admin cannot block self", async ({ request }) => { + const admin = await adminLogin(request); + const body = await admin.json(); + const response = await request.patch(`${API_URL}/api/v1/admin/users/${body.user.id}`, { + headers: { Authorization: `Bearer ${body.access_token}` }, + data: { status: "blocked" } + }); + expect(response.status()).toBe(400); + expect((await response.json()).detail).toBe("SELF_BLOCK_FORBIDDEN"); + }); + + test("admin cannot demote self", async ({ request }) => { + const admin = await adminLogin(request); + const body = await admin.json(); + const response = await request.patch(`${API_URL}/api/v1/admin/users/${body.user.id}`, { + headers: { Authorization: `Bearer ${body.access_token}` }, + data: { role: "user" } + }); + expect(response.status()).toBe(400); + expect((await response.json()).detail).toBe("SELF_DEMOTION_FORBIDDEN"); + }); +}); diff --git a/apps/web/e2e/auth/login.spec.ts b/apps/web/e2e/auth/login.spec.ts new file mode 100644 index 0000000..80b5cfe --- /dev/null +++ b/apps/web/e2e/auth/login.spec.ts @@ -0,0 +1,6 @@ +import { expect, test } from "@playwright/test"; + +test("login page renders", async ({ page }) => { + await page.goto("/login"); + await expect(page.getByRole("heading", { name: "Вход" })).toBeVisible(); +}); diff --git a/apps/web/e2e/auth/password-reset.spec.ts b/apps/web/e2e/auth/password-reset.spec.ts new file mode 100644 index 0000000..8096d75 --- /dev/null +++ b/apps/web/e2e/auth/password-reset.spec.ts @@ -0,0 +1,33 @@ +import { expect, test } from "@playwright/test"; +import { API_URL, fetchLatestToken, registerVerifyLogin, uniqueEmail } from "../helpers/api"; + +test.describe("§15.7 scenario 3: Password reset", () => { + test("forgot password → reset → login with new password", async ({ request }) => { + const email = uniqueEmail("e2e-reset"); + const oldPassword = "Valid1234"; + const newPassword = "ResetValid1"; + + await registerVerifyLogin(request, email, oldPassword); + + const forgot = await request.post(`${API_URL}/api/v1/auth/forgot-password`, { + data: { email } + }); + expect(forgot.ok()).toBeTruthy(); + + const token = await fetchLatestToken(request, email, "reset_password"); + const reset = await request.post(`${API_URL}/api/v1/auth/reset-password`, { + data: { token, new_password: newPassword } + }); + expect(reset.ok()).toBeTruthy(); + + const oldLogin = await request.post(`${API_URL}/api/v1/auth/login`, { + data: { email, password: oldPassword } + }); + expect(oldLogin.status()).toBe(401); + + const newLogin = await request.post(`${API_URL}/api/v1/auth/login`, { + data: { email, password: newPassword } + }); + expect(newLogin.ok()).toBeTruthy(); + }); +}); diff --git a/apps/web/e2e/auth/refresh-rotation.spec.ts b/apps/web/e2e/auth/refresh-rotation.spec.ts new file mode 100644 index 0000000..01fc78f --- /dev/null +++ b/apps/web/e2e/auth/refresh-rotation.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from "@playwright/test"; +import { API_URL, registerVerifyLogin, uniqueEmail } from "../helpers/api"; + +function extractRefreshCookie(headers: Record): string { + const setCookie = headers["set-cookie"] ?? headers["Set-Cookie"] ?? ""; + const match = setCookie.match(/refresh_token=([^;]+)/); + if (!match) { + throw new Error("refresh_token cookie missing"); + } + return match[1]; +} + +test.describe("§15.7 scenario 7: Refresh rotation", () => { + test("old refresh token rejected after rotation; reuse revokes family", async ({ request }) => { + const email = uniqueEmail("e2e-refresh"); + await registerVerifyLogin(request, email); + + const login = await request.post(`${API_URL}/api/v1/auth/login`, { data: { email, password: "Valid1234" } }); + const oldRefresh = extractRefreshCookie(login.headers()); + + const rotated = await request.post(`${API_URL}/api/v1/auth/refresh`, { + headers: { Cookie: `refresh_token=${oldRefresh}` } + }); + expect(rotated.ok()).toBeTruthy(); + const newRefresh = extractRefreshCookie(rotated.headers()); + + const oldReuse = await request.post(`${API_URL}/api/v1/auth/refresh`, { + headers: { Cookie: `refresh_token=${oldRefresh}` } + }); + expect(oldReuse.status()).toBe(401); + + const familyReuse = await request.post(`${API_URL}/api/v1/auth/refresh`, { + headers: { Cookie: `refresh_token=${newRefresh}` } + }); + expect(familyReuse.status()).toBe(401); + }); +}); diff --git a/apps/web/e2e/auth/register-login-flow.spec.ts b/apps/web/e2e/auth/register-login-flow.spec.ts new file mode 100644 index 0000000..f1c7077 --- /dev/null +++ b/apps/web/e2e/auth/register-login-flow.spec.ts @@ -0,0 +1,40 @@ +import { expect, test } from "@playwright/test"; +import { + loginViaUi, + registerUser, + uniqueEmail, + verifyEmail +} from "../helpers/api"; + +test.describe("§15.7 scenario 2: Auth full journey", () => { + test("register → verify → login → profile edit → change password → logout", async ({ + page, + request + }) => { + const email = uniqueEmail("e2e-flow"); + const password = "Valid1234"; + const newPassword = "NewValid1"; + + await registerUser(request, email, password); + await verifyEmail(request, email); + + await loginViaUi(page, email, password); + await expect(page).toHaveURL(/\/profile$/); + await expect(page.getByText(email)).toBeVisible(); + + await page.getByLabel("Display name").fill("E2E User"); + await page.getByRole("button", { name: "Save name" }).click(); + await expect(page.getByText("Profile updated")).toBeVisible(); + + await page.getByPlaceholder("Current password").fill(password); + await page.getByPlaceholder("New password").fill(newPassword); + await page.getByRole("button", { name: "Change password" }).click(); + await expect(page.getByText("Password changed")).toBeVisible(); + + await page.getByRole("button", { name: "Logout" }).click(); + await expect(page).toHaveURL(/\/login$/); + + await loginViaUi(page, email, newPassword); + await expect(page).toHaveURL(/\/profile$/); + }); +}); diff --git a/apps/web/e2e/content/content-pages.spec.ts b/apps/web/e2e/content/content-pages.spec.ts new file mode 100644 index 0000000..e67eef2 --- /dev/null +++ b/apps/web/e2e/content/content-pages.spec.ts @@ -0,0 +1,25 @@ +import { expect, test } from "@playwright/test"; +import { API_URL, adminLogin } from "../helpers/api"; + +test.describe("§15.7 scenario 4: Admin content publish", () => { + test("admin creates content → publish → visible on /pages/:slug", async ({ page, request }) => { + const admin = await adminLogin(request); + const headers = { Authorization: `Bearer ${(await admin.json()).access_token}` }; + const slug = `e2e-page-${Date.now()}`; + + const created = await request.post(`${API_URL}/api/v1/content/pages`, { + headers, + data: { + slug, + title: "E2E Published Page", + body: "

Published by admin

", + status: "published" + } + }); + expect(created.ok()).toBeTruthy(); + + await page.goto(`/pages/${slug}`); + await expect(page.getByRole("heading", { name: "E2E Published Page" })).toBeVisible(); + await expect(page.getByText("Published by admin")).toBeVisible(); + }); +}); diff --git a/apps/web/e2e/helpers/api.ts b/apps/web/e2e/helpers/api.ts new file mode 100644 index 0000000..7aff881 --- /dev/null +++ b/apps/web/e2e/helpers/api.ts @@ -0,0 +1,64 @@ +import { APIRequestContext } from "@playwright/test"; + +export const API_URL = process.env.E2E_API_URL ?? "http://127.0.0.1:8001"; + +export function uniqueEmail(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`; +} + +export async function registerUser(request: APIRequestContext, email: string, password = "Valid1234") { + const response = await request.post(`${API_URL}/api/v1/auth/register`, { + data: { email, password } + }); + return response; +} + +export async function fetchLatestToken( + request: APIRequestContext, + email: string, + template: "verify_email" | "reset_password" +): Promise { + const response = await request.get(`${API_URL}/api/v1/test/emails/latest-token`, { + params: { to: email, template } + }); + if (!response.ok()) { + throw new Error(`Token not found for ${email} (${template})`); + } + const body = await response.json(); + return body.token as string; +} + +export async function verifyEmail(request: APIRequestContext, email: string) { + const token = await fetchLatestToken(request, email, "verify_email"); + return request.post(`${API_URL}/api/v1/auth/verify-email`, { data: { token } }); +} + +export async function loginApi(request: APIRequestContext, email: string, password = "Valid1234") { + return request.post(`${API_URL}/api/v1/auth/login`, { data: { email, password } }); +} + +export async function adminLogin(request: APIRequestContext) { + return loginApi(request, "admin@compton.example", "Admin1234"); +} + +export async function registerVerifyLogin( + request: APIRequestContext, + email: string, + password = "Valid1234" +) { + await registerUser(request, email, password); + await verifyEmail(request, email); + const login = await loginApi(request, email, password); + const body = await login.json(); + return { + accessToken: body.access_token as string, + user: body.user as { id: string; email: string; role: string; status: string } + }; +} + +export async function loginViaUi(page: import("@playwright/test").Page, email: string, password: string) { + await page.goto("/login"); + await page.getByPlaceholder("Email").fill(email); + await page.getByPlaceholder("Password").fill(password); + await page.getByRole("button", { name: "Login" }).click(); +} diff --git a/apps/web/e2e/landing/landing.spec.ts b/apps/web/e2e/landing/landing.spec.ts new file mode 100644 index 0000000..19ad1c8 --- /dev/null +++ b/apps/web/e2e/landing/landing.spec.ts @@ -0,0 +1,15 @@ +import { expect, test } from "@playwright/test"; + +test.describe("§15.7 scenario 1: Landing", () => { + test("hero and marquee are visible", async ({ page }) => { + await page.goto("/"); + await expect(page.getByRole("heading", { name: /Технологии будущего на вашей ферме/i })).toBeVisible(); + await expect(page.locator("#integrations .marquee-track")).toBeVisible(); + }); + + test("respects prefers-reduced-motion", async ({ page }) => { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto("/"); + await expect(page.getByRole("heading", { name: /Технологии будущего на вашей ферме/i })).toBeVisible(); + }); +}); diff --git a/apps/web/e2e/profile/avatar-validation.spec.ts b/apps/web/e2e/profile/avatar-validation.spec.ts new file mode 100644 index 0000000..30a9d76 --- /dev/null +++ b/apps/web/e2e/profile/avatar-validation.spec.ts @@ -0,0 +1,52 @@ +import { expect, test } from "@playwright/test"; +import { API_URL, registerVerifyLogin, uniqueEmail } from "../helpers/api"; + +const tinyPng = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64" +); + +test.describe("§15.7 scenario 10: Avatar validation", () => { + test("rejects svg, oversize and accepts valid png", async ({ request }) => { + const session = await registerVerifyLogin(request, uniqueEmail("e2e-avatar")); + const headers = { Authorization: `Bearer ${session.accessToken}` }; + + const svg = await request.post(`${API_URL}/api/v1/users/me/avatar`, { + headers, + multipart: { + file: { + name: "avatar.svg", + mimeType: "image/svg+xml", + buffer: Buffer.from("") + } + } + }); + expect(svg.status()).toBe(400); + + const oversize = await request.post(`${API_URL}/api/v1/users/me/avatar`, { + headers, + multipart: { + file: { + name: "big.png", + mimeType: "image/png", + buffer: Buffer.concat([tinyPng, Buffer.alloc(2 * 1024 * 1024 + 1)]) + } + } + }); + expect(oversize.status()).toBe(400); + expect((await oversize.json()).detail).toBe("FILE_TOO_LARGE"); + + const valid = await request.post(`${API_URL}/api/v1/users/me/avatar`, { + headers, + multipart: { + file: { + name: "avatar.png", + mimeType: "image/png", + buffer: tinyPng + } + } + }); + expect(valid.ok()).toBeTruthy(); + expect((await valid.json()).profile.avatar_url).toContain("/api/v1/media/files/avatars/"); + }); +}); diff --git a/apps/web/e2e/profile/profile.spec.ts b/apps/web/e2e/profile/profile.spec.ts new file mode 100644 index 0000000..602f88b --- /dev/null +++ b/apps/web/e2e/profile/profile.spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from "@playwright/test"; +import { registerUser, uniqueEmail } from "../helpers/api"; + +test.describe("§15.7 scenario 6: Pending user", () => { + test("pending user cannot access /profile", async ({ page, request }) => { + const email = uniqueEmail("e2e-pending"); + await registerUser(request, email, "Valid1234"); + + await page.goto("/profile"); + await expect(page).toHaveURL(/\/login$/); + + await page.goto("/login"); + await page.getByPlaceholder("Email").fill(email); + await page.getByPlaceholder("Password").fill("Valid1234"); + await page.getByRole("button", { name: "Login" }).click(); + await expect(page).toHaveURL(/\/login$/); + }); +}); diff --git a/apps/web/e2e/security/idor.spec.ts b/apps/web/e2e/security/idor.spec.ts new file mode 100644 index 0000000..7890ed5 --- /dev/null +++ b/apps/web/e2e/security/idor.spec.ts @@ -0,0 +1,15 @@ +import { expect, test } from "@playwright/test"; +import { API_URL, registerVerifyLogin, uniqueEmail } from "../helpers/api"; + +test.describe("§15.7 scenario 8: IDOR", () => { + test("user A cannot patch user B via admin route", async ({ request }) => { + const userA = await registerVerifyLogin(request, uniqueEmail("e2e-a")); + const userB = await registerVerifyLogin(request, uniqueEmail("e2e-b")); + + const forbidden = await request.patch(`${API_URL}/api/v1/admin/users/${userB.user.id}`, { + headers: { Authorization: `Bearer ${userA.accessToken}` }, + data: { status: "blocked" } + }); + expect(forbidden.status()).toBe(403); + }); +}); diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js new file mode 100644 index 0000000..756c425 --- /dev/null +++ b/apps/web/eslint.config.js @@ -0,0 +1,39 @@ +import tsParser from "@typescript-eslint/parser"; +import tsPlugin from "@typescript-eslint/eslint-plugin"; +import boundaries from "eslint-plugin-boundaries"; + +export default [ + { + files: ["src/**/*.{ts,tsx}"], + languageOptions: { + parser: tsParser, + parserOptions: { project: "./tsconfig.json" } + }, + plugins: { + "@typescript-eslint": tsPlugin, + boundaries + }, + rules: { + "boundaries/element-types": [ + "error", + { + default: "disallow", + rules: [ + { from: "app", allow: ["app", "pages", "modules", "shared"] }, + { from: "pages", allow: ["pages", "modules", "shared"] }, + { from: "modules", allow: ["modules", "shared"] }, + { from: "shared", allow: ["shared"] } + ] + } + ] + }, + settings: { + "boundaries/elements": [ + { type: "app", pattern: "src/app/*" }, + { type: "pages", pattern: "src/pages/*" }, + { type: "modules", pattern: "src/modules/*" }, + { type: "shared", pattern: "src/shared/*" } + ] + } + } +]; diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..405bb4c --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,283 @@ + + + + + + Комптон + + + + + + + + + + + + + +
+ +
+

Технологии будущего на вашей ферме

+

+ Интеллектуальная система контроля кормления КРС на основе искусственного интеллекта. + Снижаем затраты на корма, повышаем продуктивность стада и даём полный контроль над каждым этапом. +

+ +
+
+ + +
+
+
+ +

Как устроена наша система

+
+
+ +
+
+ План рационов +
+

План рационов

+

Зоотехник составляет и корректирует рацион для каждой группы КРС в удобной программе на офисном ПК, ноутбуке или смартфоне.

+
+ +
+
+ Синхронизация данных +
+

Синхронизация данных

+

Созданное задание мгновенно и без проводов передается на весовой терминал кормосмесителя.

+
+ +
+
+ Точная загрузка +
+

Точная загрузка

+

Тракторист на дисплее видит подсказки и точный вес каждого компонента на лету, что исключает ошибки перегруза.

+
+ +
+
+ Контроль руководителя +
+

Контроль руководителя

+

Собственник или управляющий получает автоматический отчет о всех отклонениях и реальном расходе прямо на смартфон.

+
+
+
+
+ + + + +
+
+ +
+

От формулы до кормушки

+
+ + +
+

«Каждый килограмм корма — под контролем.
Каждая копейка — на счету. Каждая минута — сэкономлена»

+
+
+
+ + +
+
+

Работа на любом устройстве

+

+ Управляйте системой кормления с телефона, планшета или компьютера — интерфейс адаптируется под любой экран. +

+ +
+
+

Всегда на связи с фермой Абсолютный контроль рационов и остатков в любом месте, в любое время и с любого гаджета.

+
+ +
+ Комптон всегда под рукой +
+ +
+

Прозрачность и контроль Вы всегда видите, сколько корма съедено, как меняется продуктивность и где можно сэкономить без потери качества.

+
+
+
+
+ + +
+
+

Начни кормить по новому!

+

+ Всё, что нужно для точного, экономного и эффективного кормления КРС. +

+ +
+
+
+ Индивидуальный рацион +
+
+
+ +
+

Автоматический расчет рационов

+

Программа сама подбирает оптимальный состав корма для каждой группы животных с учётом возраста, веса и продуктивности.

+
+
+ +
+
+ Удобное редактирование рационов +
+
+
+ +
+

Удобное редактирование рационов

+

Редактируйте рацион в один клик: меняйте рецепты местами, добавляйте новые компоненты и мгновенно корректируйте сухое вещество.

+
+
+ +
+
+ Прогноз продуктивности +
+
+
+ +
+

Учет кормов

+

Автоматический учет остатков: программа точно рассчитывает остатки кормов на складе и прогнозирует дату следующей закупки на основе текущего расхода, защищая от внезапного дефицита.

+
+
+ +
+
+ Визуализация экономии кормов +
+
+
+ +
+

Гибкая система уведомлений

+

Умная система уведомлений — ваш новый помощник, который следит за процессом кормления КРС и мгновенно предупреждает команду фермы о любых сбоях. Она заменяет ручной контроль автоматическим мониторингом.

+
+
+ +
+
+ Анализ микроклимата +
+
+
+ +
+

Анализ микроклимата

+

Интеграция с системами климат-контроля: учитываем температуру и влажность для коррекции потребности в питании.

+
+
+ +
+
+ Единая платформа +
+
+
+ +
+

Единая платформа

+

Все данные о кормлении, здоровье и продуктивности в одном окне – для быстрого принятия решений.

+
+
+
+
+
+ + +
+
+ ✦ АГРО-ХОЛДИНГ + ✦ МОЛОЧНЫЙ КОМБИНАТ + ✦ ФЕРМА №1 + ✦ ЗЕРНО-ТРЕЙД + ✦ ВЕТЕРИНАРНАЯ СЛУЖБА + ✦ КОРМОВОЙ ЦЕНТР + ✦ ПЛЕМЗАВОД + ✦ АГРО-ИНТЕЛЛЕКТ + ✦ АГРО-ХОЛДИНГ + ✦ МОЛОЧНЫЙ КОМБИНАТ + ✦ ФЕРМА №1 + ✦ ЗЕРНО-ТРЕЙД + ✦ ВЕТЕРИНАРНАЯ СЛУЖБА + ✦ КОРМОВОЙ ЦЕНТР + ✦ ПЛЕМЗАВОД + ✦ АГРО-ИНТЕЛЛЕКТ +
+
+ + +
+
+

Внедряйте технологии будущего

+

+ Получите консультацию по настройке системы для вашего хозяйства. + Первые 2 месяца – полная поддержка и мониторинг. +

+ Оставить заявку +
+
+ + + + + + + \ No newline at end of file diff --git a/apps/web/main/css/style.css b/apps/web/main/css/style.css new file mode 100644 index 0000000..6d78a81 --- /dev/null +++ b/apps/web/main/css/style.css @@ -0,0 +1,1094 @@ +/* ========== ГЛОБАЛЬНЫЕ ПЕРЕМЕННЫЕ ========== */ + @import "./tokens.css"; + + /* ========== БАЗА ========== */ + *, + *::before, + *::after { + margin: 0; + padding: 0; + box-sizing: border-box; + } + html { + scroll-behavior: smooth; + -webkit-font-smoothing: antialiased; + } + body { + + font-family: var(--font-sans); + background-color: var(--bg); + color: var(--foreground); + line-height: 1.6; + padding-top: 80px; + } + .container { + max-width: 1280px; + margin: 0 auto; + padding-left: 2rem; + padding-right: 2rem; + } + .section-padding { + padding: clamp(3rem, 10vh, 6rem) 0; + } + + /* ========== ТИПОГРАФИКА ========== */ + h1, + h2, + h3 { + font-weight: 500; + letter-spacing: -0.02em; + line-height: 1.1; + } + h1 { + font-size: clamp(2.5rem, 8vw, 5rem); + } + h2 { + font-size: clamp(2rem, 5vw, 3.5rem); + } + h3 { + font-size: 1.5rem; + } + .text-muted { + color: var(--muted); + } + .text-mono { + font-family: var(--font-mono); + font-weight: 400; + } + + /* ========== КНОПКИ ========== */ + .btn { + display: inline-block; + font-family: var(--font-sans); + font-weight: 500; + font-size: 1rem; + padding: 0.7rem 1.8rem; + border-radius: var(--radius-btn); + border: 1px solid transparent; + cursor: pointer; + transition: all 0.2s ease; + text-decoration: none; + text-align: center; + } + .btn-primary { + background-color: var(--primary); + color: #fff; + border-color: var(--primary); + } + .btn-primary:hover { + background-color: var(--primary-dark); + border-color: var(--primary-dark); + transform: translateY(-2px); + } + .btn-outline { + background-color: #fff; + color: var(--foreground); + border-color: #fff; + } + .btn-outline:hover { + background-color: rgba(255, 255, 255, 0.85); + border-color: rgba(255, 255, 255, 0.85); + transform: translateY(-2px); + } + + /* ========== НАВБАР ========== */ + .navbar { + position: fixed; + top: 0; + left: 0; + width: 100%; + z-index: 100; + background-color: rgba(246, 246, 244, 0.85); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid rgba(0, 0, 0, 0.05); + } + .navbar .container { + display: flex; + align-items: center; + justify-content: space-between; + height: 80px; + padding-top: 0; + padding-bottom: 0; + } + .navbar-logo { + display: flex; + align-items: center; + text-decoration: none; + height: 100%; + } + .navbar-logo img { + height: 100px; + width: auto; + display: block; + transition: opacity 0.2s ease; + } + .navbar-logo:hover img { + opacity: 0.85; + } + .navbar-menu { + display: flex; + align-items: center; + gap: 2.5rem; + list-style: none; + } + .navbar-menu a { + font-family: var(--font-mono); + font-size: 0.85rem; + color: var(--muted); + text-decoration: none; + transition: color 0.2s ease; + } + .navbar-menu a:hover { + color: var(--foreground); + } + .burger-checkbox { + display: none; + } + .burger-icon { + display: none; + flex-direction: column; + gap: 5px; + cursor: pointer; + padding: 4px; + } + .burger-icon span { + display: block; + width: 26px; + height: 2px; + background-color: var(--foreground); + border-radius: 4px; + transition: all 0.3s ease; + } + + /* ========== HERO С ВИДЕО ========== */ + .hero { + min-height: 90vh; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + position: relative; + overflow: hidden; + } + .hero-video { + position: absolute; + inset: 0; + z-index: 0; + width: 100%; + height: 100%; + object-fit: cover; + filter: blur(2px) brightness(0.7) saturate(0.9); + transform: scale(1.05); + transition: filter 0.5s ease; + } + .hero::after { + content: ''; + position: absolute; + inset: 0; + z-index: 1; + background: + radial-gradient(ellipse at 30% 50%, rgba(72, 129, 109, 0.25) 0%, transparent 60%), + radial-gradient(ellipse at 70% 80%, rgba(26, 30, 28, 0.30) 0%, transparent 50%), + linear-gradient(180deg, rgba(246, 246, 244, 0.15) 0%, rgba(246, 246, 244, 0.05) 40%, rgba(26, 30, 28, 0.20) 100%); + pointer-events: none; + } + .hero .container { + position: relative; + z-index: 2; + } + .hero h1 { + max-width: 900px; + margin: 0 auto 1.5rem; + color: #fff; + text-shadow: 0 4px 30px rgba(0, 0, 0, 0.3); + } + .hero .subtitle { + font-size: clamp(1rem, 2vw, 1.4rem); + color: rgba(255, 255, 255, 0.85); + max-width: 640px; + margin: 0 auto 2.5rem; + line-height: 1.6; + text-shadow: 0 2px 20px rgba(0, 0, 0, 0.2); + } + .hero .btn-group { + display: flex; + gap: 1rem; + justify-content: center; + flex-wrap: wrap; + } + @media (max-width: 768px) { + .hero-video { + filter: blur(4px) brightness(0.6) saturate(0.8); + transform: scale(1.1); + } + .hero h1 { + text-shadow: 0 4px 40px rgba(0, 0, 0, 0.5); + } + .hero .subtitle { + text-shadow: 0 2px 30px rgba(0, 0, 0, 0.4); + } + } + + /* ========== БЛОК С ЗАГОЛОВКОМ, ИЗОБРАЖЕНИЕМ И ВЫЕЗЖАЮЩИМ ТЕКСТОМ ========== */ + .expand-block { + background-color: var(--bg); + border-bottom: 1px solid var(--card-border); + overflow: hidden; + } + .expand-block .container { + padding-top: 1.5rem; + padding-bottom: 1.5rem; + } + .expand-block .section-title { + text-align: center; + margin-bottom: 0.5rem; + } + .expand-block .section-subtitle { + text-align: center; + color: var(--muted); + max-width: 600px; + margin: 0 auto 2.5rem; + font-size: 1.1rem; + line-height: 1.6; + } + .expand-content { + position: relative; + display: flex; + align-items: center; + justify-content: center; + min-height: 320px; + overflow: visible; + } + .image-wrapper { + flex-shrink: 0; + width: 55%; + max-width: 650px; + border-radius: var(--radius-card); + overflow: hidden; + box-shadow: 0 5px 20px rgba(0, 0, 0, 0.05); + background: #fff; + z-index: 2; + transition: transform 0.8s cubic-bezier(0.25, 0.8, 0.25, 1), + width 0.8s cubic-bezier(0.25, 0.8, 0.25, 1); + transform: scale(1); + } + .image-wrapper img { + width: 100%; + height: auto; + display: block; + } + /* Боковые панели — ширина 22%, сдвинуты наружу */ + .side-panel { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 22%; + max-width: 280px; + padding: 1.5rem; + z-index: 1; + opacity: 0; + transition: opacity 0.6s ease 0.2s, + transform 0.8s cubic-bezier(0.25, 0.8, 0.25, 1); + background: transparent; + pointer-events: none; + } + .side-panel.left { + left: -2rem; + transform: translateY(-50%) translateX(-120%); + text-align: right; + } + .side-panel.right { + right: -2rem; + transform: translateY(-50%) translateX(120%); + text-align: left; + } + .side-panel p { + margin: 0; + font-size: 1rem; + line-height: 1.6; + color: var(--muted); + } + .side-panel p strong { + display: block; + color: var(--primary); + margin-bottom: 0.3rem; + } + + /* Состояние expanded */ + .expand-content.expanded .image-wrapper { + transform: scale(1.08); + width: 60%; + } + .expand-content.expanded .side-panel { + opacity: 1; + transform: translateY(-50%) translateX(0); + pointer-events: auto; + } + + /* ========== АДАПТИВ ДЛЯ ПЛАНШЕТОВ ========== */ + @media (max-width: 1024px) and (min-width: 769px) { + .image-wrapper { + width: 60%; + max-width: 500px; + } + .side-panel { + width: 22%; + max-width: 220px; + left: -1rem; + right: -1rem; + } + } + + /* ========== АДАПТИВ ДЛЯ МОБИЛЬНЫХ ========== */ + @media (max-width: 768px) { + .expand-block .section-subtitle { + margin-bottom: 1.5rem; + font-size: 1rem; + } + .expand-content { + flex-wrap: wrap; + min-height: auto; + padding: 0 0.5rem; + } + .image-wrapper { + width: 100% !important; + max-width: 100%; + transform: none !important; + margin: 1rem 0; + order: 1; + } + .side-panel { + position: static; + transform: none !important; + width: 100% !important; + max-width: 100%; + opacity: 1 !important; + padding: 0.5rem 1rem; + text-align: center !important; + order: 2; + pointer-events: auto; + left: auto !important; + right: auto !important; + } + .side-panel.left { + order: 0; + } + .side-panel.right { + order: 3; + } + .side-panel p { + font-size: 0.95rem; + } + } + + /* ========== КАРТОЧКИ ========== */ + .cards-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 2rem; + } + .card { + background-color: var(--card); + border: 1px solid var(--card-border); + border-radius: var(--radius-card); + padding: 2rem; + transition: all 0.3s ease; + box-shadow: 0 5px 20px rgba(0, 0, 0, 0.02); + will-change: transform; + display: flex; + flex-direction: column; + } + .card:hover { + transform: translateY(-5px); + box-shadow: 0 12px 30px rgba(72, 129, 109, 0.08); + } + .card-icon { + width: 48px; + height: 48px; + border-radius: var(--radius-icon); + background-color: var(--primary-tint); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 1.2rem; + color: var(--primary); + flex-shrink: 0; + } + .card-icon svg { + width: 28px; + height: 28px; + stroke: currentColor; + stroke-width: 1.5; + fill: none; + } + .card h3 { + margin-bottom: 0.6rem; + } + .card p { + color: var(--muted); + line-height: 1.5; + font-size: 0.95rem; + flex: 1; + } + + /* ===== СПЕЦИАЛЬНЫЕ ПРАВИЛА ДЛЯ КАРТОЧЕК С GIF ===== */ + .card-with-gif { + padding: 0; + overflow: hidden; + display: flex; + flex-direction: column; + } + .card-with-gif .card-gif-wrapper { + margin: 0; + border-radius: 0; + background: none; + box-shadow: none; + flex-shrink: 0; + overflow: hidden; + } + .card-with-gif .card-gif-wrapper img { + display: block; + width: 100%; + height: auto; + } + .card-with-gif .card-body { + padding: 1.5rem; + flex: 1; + } + .card-with-gif .card-icon { + margin-bottom: 1rem; + } + + /* ========== БЕГУЩАЯ СТРОКА ========== */ + .marquee-section { + background-color: #EBEBE5; + padding: 2rem 0; + overflow: hidden; + position: relative; + } + .marquee-track { + display: flex; + width: max-content; + animation: marqueeScroll 30s linear infinite; + will-change: transform; + } + .marquee-track .logo-item { + flex-shrink: 0; + padding: 0 3rem; + font-family: var(--font-mono); + font-weight: 500; + font-size: 1.3rem; + letter-spacing: 0.06em; + color: var(--muted); + white-space: nowrap; + opacity: 0.6; + transition: opacity 0.3s ease; + } + .marquee-track .logo-item:hover { + opacity: 1; + } + @keyframes marqueeScroll { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(-50%); + } + } + + /* ========== CTA ========== */ + .cta-section { + text-align: center; + } + .cta-section h2 { + max-width: 700px; + margin: 0 auto 1.5rem; + } + .cta-section p { + max-width: 560px; + margin: 0 auto 2rem; + color: var(--muted); + font-size: 1.1rem; + } + + /* ========== ФУТЕР ========== */ + .footer { + background-color: var(--footer-bg); + color: rgba(255, 255, 255, 0.6); + padding: 3rem 0; + margin-top: 2rem; + } + .footer .container { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 1.5rem; + } + .footer a { + color: rgba(255, 255, 255, 0.5); + text-decoration: none; + font-family: var(--font-mono); + font-size: 0.85rem; + transition: color 0.2s ease; + } + .footer a:hover { + color: #fff; + } + .footer .footer-logo { + display: flex; + align-items: center; + text-decoration: none; + } + .footer .footer-logo img { + height: 32px; + width: auto; + display: block; + filter: brightness(0) invert(1); + transition: opacity 0.2s ease; + } + .footer .footer-logo:hover img { + opacity: 0.8; + } + + /* ========== FADE-UP ========== */ + .fade-up { + opacity: 0; + transform: translateY(30px); + transition: opacity 0.7s ease, transform 0.7s ease; + will-change: opacity, transform; + } + .fade-up.visible { + opacity: 1; + transform: translateY(0); + } + .card:nth-child(1) { + transition-delay: 0.05s; + } + .card:nth-child(2) { + transition-delay: 0.12s; + } + .card:nth-child(3) { + transition-delay: 0.19s; + } + .card:nth-child(4) { + transition-delay: 0.26s; + } + .card:nth-child(5) { + transition-delay: 0.33s; + } + .card:nth-child(6) { + transition-delay: 0.40s; + } + + .scramble-target { + font-family: var(--font-mono); + font-weight: 500; + } + + /* ========== ОБЩАЯ АДАПТИВНОСТЬ ========== */ + @media (max-width: 1024px) and (min-width: 769px) { + .cards-grid { + grid-template-columns: repeat(2, 1fr); + } + } + @media (max-width: 768px) { + body { + padding-top: 68px; + } + .navbar .container { + height: 68px; + } + .navbar-logo img { + height: 32px; + } + .burger-icon { + display: flex; + } + .navbar-menu { + position: absolute; + top: 68px; + left: 0; + width: 100%; + background: rgba(246, 246, 244, 0.96); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + flex-direction: column; + padding: 2rem 1.5rem; + gap: 1.5rem; + transform: scaleY(0); + transform-origin: top center; + opacity: 0; + transition: transform 0.35s ease, opacity 0.35s ease; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); + pointer-events: none; + } + .burger-checkbox:checked~.navbar-menu { + transform: scaleY(1); + opacity: 1; + pointer-events: auto; + } + .burger-checkbox:checked~.burger-icon span:nth-child(1) { + transform: rotate(45deg) translate(5px, 5px); + } + .burger-checkbox:checked~.burger-icon span:nth-child(2) { + opacity: 0; + } + .burger-checkbox:checked~.burger-icon span:nth-child(3) { + transform: rotate(-45deg) translate(5px, -5px); + } + .navbar-auth { + flex-direction: column; + width: 100%; + } + .navbar-auth .btn { + width: 100%; + text-align: center; + } + .cards-grid { + grid-template-columns: 1fr; + gap: 1.5rem; + } + .container { + padding-left: 1.5rem; + padding-right: 1.5rem; + } + .hero .btn-group { + flex-direction: column; + align-items: center; + } + .hero .btn-group .btn { + width: 100%; + max-width: 280px; + } + .marquee-track .logo-item { + padding: 0 1.5rem; + font-size: 1rem; + } + .footer .container { + flex-direction: column; + text-align: center; + } + .footer .footer-logo img { + height: 28px; + } + .navbar { + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + } + .card-gif-wrapper { + margin-top: 0; + border-radius: 0; + } + .card-with-gif .card-body { + padding: 1.25rem; + } + } + @media (prefers-reduced-motion: reduce) { + + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + } + + /* ========== БЛОК: СХЕМА РАБОТЫ ========== */ + .steps-section { + background-color: var(--bg); + border-bottom: 1px solid var(--card-border); + } + + .steps-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 2rem; + margin-top: 4rem; + counter-reset: step-counter; + position: relative; + } + + .step-card { + background-color: var(--card); + border: 1px solid var(--card-border); + border-radius: var(--radius-card); + padding: 2.5rem 2rem; + transition: transform 0.8s cubic-bezier(0.34, 1.56, 0.64, 1), + box-shadow 0.3s ease; + position: relative; + counter-increment: step-counter; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + opacity: 0; + transform: translateX(40px) scale(0.95); + animation: stepAppear 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; + } + + /* ===== КРУГЛАЯ ОБЕРТКА ДЛЯ ИЗОБРАЖЕНИЯ ===== */ + .step-image-wrapper { + width: 110px; + height: 110px; + border-radius: 50%; + overflow: hidden; + flex-shrink: 0; + margin-bottom: 1.25rem; + /* Убрали border и box-shadow с синим цветом */ + border: none; + box-shadow: none; + transition: transform 0.3s ease; + background: #fff; + display: flex; + align-items: center; + justify-content: center; + padding: 10px; + } + + .step-image-wrapper img { + width: 100%; + height: 100%; + object-fit: contain; + object-position: center; + display: block; + } + + .step-card:hover .step-image-wrapper { + transform: scale(1.05); + } + + /* Убираем ::before для карточек с изображением */ + .step-card:has(.step-image-wrapper)::before { + display: none; + } + + /* Для остальных карточек ::before остаётся (теперь их нет, все 4 шага с изображениями) */ + .step-card:not(:has(.step-image-wrapper))::before { + content: "0" counter(step-counter); + font-family: var(--font-mono); + font-weight: 500; + font-size: 1.25rem; + color: var(--primary); + background: rgba(72, 129, 109, 0.1); + display: inline-block; + padding: 0.3rem 0.9rem; + border-radius: 40px; + margin-bottom: 1.25rem; + align-self: center; + letter-spacing: 0.02em; + } + + /* Фаза 1: появление */ + .step-card:nth-child(1) { + animation-delay: 0.1s; + transform: translateX(0) scale(1); + opacity: 1; + } + .step-card:nth-child(2) { + animation-delay: 0.4s; + } + .step-card:nth-child(3) { + animation-delay: 0.7s; + } + .step-card:nth-child(4) { + animation-delay: 1.0s; + } + + @keyframes stepAppear { + 0% { + opacity: 0; + transform: translateX(40px) scale(0.95); + } + 100% { + opacity: 1; + transform: translateX(0) scale(1); + } + } + + /* Фаза 2: шахматный порядок */ + .step-card:nth-child(1) { + animation: stepAppear 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) forwards, + stepChessUp 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) 1.7s forwards; + } + .step-card:nth-child(2) { + animation: stepAppear 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) 0.4s forwards, + stepChessDown 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) 1.7s forwards; + } + .step-card:nth-child(3) { + animation: stepAppear 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) 0.7s forwards, + stepChessUp 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) 1.9s forwards; + } + .step-card:nth-child(4) { + animation: stepAppear 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) 1.0s forwards, + stepChessDown 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) 2.1s forwards; + } + + @keyframes stepChessUp { + 0% { transform: translateY(0); } + 100% { transform: translateY(-20px); } + } + + @keyframes stepChessDown { + 0% { transform: translateY(0); } + 100% { transform: translateY(20px); } + } + + .step-card:hover { + box-shadow: 0 12px 30px rgba(72, 129, 109, 0.06); + } + + .step-card h3 { + margin-bottom: 0.6rem; + font-size: 1.3rem; + } + + .step-card p { + color: var(--muted); + line-height: 1.5; + font-size: 0.95rem; + flex: 1; + } + + /* Стрелочки между карточками (десктоп) */ + @media (min-width: 992px) { + .step-card:not(:last-child)::after { + content: "→"; + position: absolute; + right: -1.5rem; + top: 50%; + transform: translateY(-50%); + font-size: 1.6rem; + color: var(--primary); + opacity: 0.5; + font-weight: 300; + pointer-events: none; + z-index: 5; + } + } + + /* Мобильная адаптация */ + @media (max-width: 768px) { + .steps-grid { + gap: 1.5rem; + margin-top: 2.5rem; + } + .step-card { + padding: 1.75rem 1.25rem; + transform: translateX(0) scale(1); + animation: stepAppearMobile 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; + } + .step-card:nth-child(1) { + animation-delay: 0.1s; + opacity: 1; + transform: translateY(0) scale(1); + } + .step-card:nth-child(2) { + animation-delay: 0.3s; + } + .step-card:nth-child(3) { + animation-delay: 0.5s; + } + .step-card:nth-child(4) { + animation-delay: 0.7s; + } + + .step-card:nth-child(1), + .step-card:nth-child(2), + .step-card:nth-child(3), + .step-card:nth-child(4) { + animation: stepAppearMobile 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; + } + .step-card:nth-child(1) { animation-delay: 0.1s; } + .step-card:nth-child(2) { animation-delay: 0.3s; } + .step-card:nth-child(3) { animation-delay: 0.5s; } + .step-card:nth-child(4) { animation-delay: 0.7s; } + + @keyframes stepAppearMobile { + 0% { + opacity: 0; + transform: translateY(30px) scale(0.95); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } + } + + .step-image-wrapper { + width: 85px; + height: 85px; + margin-bottom: 1rem; + padding: 8px; + } + + .step-card:not(:has(.step-image-wrapper))::before { + font-size: 1rem; + padding: 0.2rem 0.8rem; + } + .step-card h3 { + font-size: 1.15rem; + } + .step-card:not(:last-child)::after { + content: "↓"; + position: static; + transform: none; + text-align: center; + display: block; + margin: 0.5rem 0 -0.25rem; + font-size: 1.4rem; + color: var(--primary); + opacity: 0.4; + } + .step-card:has(.step-image-wrapper)::after { + display: none !important; + } + } + + /* ================================================================ */ + /* ====== НОВЫЙ БЛОК: ВОРОНКА ПРОЦЕССА (УДАЛЁН, ОСТАВЛЕН ТОЛЬКО СЛОГАН) ====== */ + /* ================================================================ */ + .funnel-section { + background: linear-gradient(180deg, var(--bg) 0%, #FFFFFF 100%); + border-top: 1px solid var(--card-border); + border-bottom: 1px solid var(--card-border); + padding: clamp(3rem, 10vh, 6rem) 0; + position: relative; + overflow: hidden; + } + + .funnel-section::before { + content: ''; + position: absolute; + top: -50%; + right: -20%; + width: 600px; + height: 600px; + background: radial-gradient(circle, rgba(72, 129, 109, 0.05) 0%, transparent 70%); + border-radius: 50%; + pointer-events: none; + } + + .funnel-header { + text-align: center; + margin-bottom: 3rem; + position: relative; + z-index: 2; + } + + .funnel-header h2 { + margin-bottom: 0.75rem; + } + + .funnel-header .subtitle { + color: var(--muted); + font-size: 1.15rem; + max-width: 640px; + margin: 0 auto; + line-height: 1.7; + } + + /* ====== ФИНАЛЬНЫЙ СЛОГАН ====== */ + .funnel-final-slogan { + text-align: center; + padding: 2.5rem 2rem; + background: var(--foreground); + border-radius: var(--radius-card); + position: relative; + z-index: 2; + overflow: hidden; + max-width: 900px; + margin: 0 auto; + } + + .funnel-final-slogan::before { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(ellipse at 30% 50%, rgba(72, 129, 109, 0.15) 0%, transparent 70%); + pointer-events: none; + } + + .funnel-final-slogan p { + font-size: clamp(1.2rem, 2.5vw, 1.8rem); + font-weight: 500; + color: #FFFFFF; + line-height: 1.5; + position: relative; + z-index: 1; + letter-spacing: -0.02em; + } + + .funnel-final-slogan p span { + color: var(--primary-accent); + } + + /* ====== АДАПТИВ ВОРОНКИ ====== */ + @media (max-width: 768px) { + .funnel-header { + margin-bottom: 2rem; + } + .funnel-header .subtitle { + font-size: 1rem; + } + .funnel-final-slogan { + padding: 1.75rem 1.25rem; + } + .funnel-final-slogan p { + font-size: 1.1rem; + } + } + + @media (max-width: 480px) { + .funnel-final-slogan { + padding: 1.25rem 1rem; + } + .funnel-final-slogan p { + font-size: 1rem; + } + } +/* ========== ИЗВЛЕЧЁННЫЕ INLINE-СТИЛИ ========== */ +.btn-sm { + padding: 0.5rem 1.4rem; + font-size: 0.85rem; +} +.btn-lg { + font-size: 1.1rem; + padding: 0.9rem 2.8rem; +} +.section-intro { + text-align: center; + max-width: 700px; + margin: 0 auto; +} +.section-intro--mb { + margin-bottom: 0.5rem; +} +.section-label { + color: var(--primary); + font-size: 0.9rem; + letter-spacing: 0.06em; + display: block; + margin-bottom: 0.5rem; +} +.section-subtitle-centered { + text-align: center; + color: var(--muted); + max-width: 600px; + margin: 0 auto 3.5rem; + font-size: 1.1rem; +} +.footer-copy { + font-size: 0.85rem; +} +.footer-links { + display: flex; + gap: 1.5rem; +} +.navbar-auth { + display: flex; + gap: 0.75rem; + align-items: center; +} diff --git a/apps/web/main/css/tokens.css b/apps/web/main/css/tokens.css new file mode 100644 index 0000000..5bec1cf --- /dev/null +++ b/apps/web/main/css/tokens.css @@ -0,0 +1,112 @@ +/* Compton design tokens — single source of truth */ +:root { + /* Brand */ + --primary: #48816d; + --primary-dark: #3a6b58; + --primary-light: #5a9a82; + --primary-accent: #5a9a82; + --primary-tint: #e8f2ef; + --primary-color: var(--primary); + + /* Light surfaces */ + --bg: #f6f6f4; + --foreground: #1a1e1c; + --muted: #4a5a52; + --marquee-bg: #ebebe5; + --surface: #ffffff; + --border-light: #e8e8e2; + --card: #ffffff; + --card-border: #e8e8e2; + --footer-bg: #1a1e1c; + --bg-page: var(--bg); + --text: var(--foreground); + + /* Typography */ + --font-sans: "Inter", system-ui, -apple-system, sans-serif; + --font-mono: "JetBrains Mono", "SF Mono", monospace; + --zootech-font: var(--font-sans); + + /* Radius */ + --radius-card: 18px; + --radius-btn: 40px; + --radius-icon: 16px; + --zt-radius-lg: 16px; + + /* Effects */ + --shadow-soft: 0 16px 48px rgba(26, 30, 28, 0.08), 0 0 0 1px rgba(26, 30, 28, 0.04); + + /* Semantic */ + --color-error: #b42318; + --color-success: #0f766e; + --color-danger: #cd3838; + --color-danger-hover: #e94b4b; + --color-warning: #d4a72c; + + /* Admin (light — matches site) */ + --admin-bg: var(--bg); + --admin-surface: var(--surface); + --admin-surface-2: var(--marquee-bg); + --admin-stroke: var(--border-light); + --admin-soft: var(--muted); + --admin-text: var(--foreground); + --admin-primary: var(--primary); + --admin-primary-hover: var(--primary-tint); + --admin-primary-selected: rgba(72, 129, 109, 0.16); + --admin-primary-option: rgba(72, 129, 109, 0.1); + --admin-btn-bg: var(--surface); + --admin-btn-border: var(--border-light); + --admin-btn-text: var(--foreground); + --admin-success: var(--color-success); + --admin-tag-ok-border: rgba(72, 129, 109, 0.35); + --admin-sider-bg: var(--surface); + --admin-table-header: var(--marquee-bg); + --admin-card-radius: 20px; + + /* WESP admin aliases */ + --wesp-admin-bg: var(--admin-bg); + --wesp-admin-surface: var(--admin-surface); + --wesp-admin-surface-2: var(--admin-surface-2); + --wesp-admin-stroke: var(--admin-stroke); + --wesp-admin-soft: var(--admin-soft); + --wesp-admin-text: var(--admin-text); + --wesp-admin-primary: var(--admin-primary); +} + +html[data-theme="organic"] { + color-scheme: light; +} + +html.admin-route, +html.admin-route body { + background: var(--admin-bg); +} + +/* Admin dark theme — isolated from public site */ +html.admin-route[data-admin-theme="dark"] { + color-scheme: dark; + --admin-deep: #0b0a10; + --admin-bg: #1f2229; + --admin-surface: #0b0a10; + --admin-surface-2: #15141c; + --admin-stroke: rgba(186, 184, 208, 0.14); + --admin-soft: #a8adbb; + --admin-text: #ececf1; + --admin-primary: #5a9a82; + --admin-primary-hover: rgba(90, 154, 130, 0.2); + --admin-primary-selected: rgba(90, 154, 130, 0.32); + --admin-primary-option: rgba(90, 154, 130, 0.16); + --admin-btn-bg: #15141c; + --admin-btn-border: rgba(186, 184, 208, 0.16); + --admin-btn-text: #ececf1; + --admin-success: #6bc4a8; + --admin-tag-ok-border: rgba(90, 154, 130, 0.5); + --admin-sider-bg: #0b0a10; + --admin-table-header: #1a1924; + --admin-card-radius: 20px; + --surface: #0b0a10; + --foreground: #ececf1; + --muted: #a8adbb; + --border-light: rgba(186, 184, 208, 0.14); + --marquee-bg: #1a1924; + --primary-tint: rgba(90, 154, 130, 0.16); +} diff --git a/apps/web/main/fonts/JetBrainsMono.ttf b/apps/web/main/fonts/JetBrainsMono.ttf new file mode 100644 index 0000000..aa310be Binary files /dev/null and b/apps/web/main/fonts/JetBrainsMono.ttf differ diff --git a/apps/web/main/fonts/Pacifico-Regular.ttf b/apps/web/main/fonts/Pacifico-Regular.ttf new file mode 100644 index 0000000..27765d0 Binary files /dev/null and b/apps/web/main/fonts/Pacifico-Regular.ttf differ diff --git a/apps/web/main/fonts/oktyabrina-script.ttf b/apps/web/main/fonts/oktyabrina-script.ttf new file mode 100644 index 0000000..04b9c1c Binary files /dev/null and b/apps/web/main/fonts/oktyabrina-script.ttf differ diff --git a/apps/web/main/gif/16-24-58.gif b/apps/web/main/gif/16-24-58.gif new file mode 100644 index 0000000..a3e71aa Binary files /dev/null and b/apps/web/main/gif/16-24-58.gif differ diff --git a/apps/web/main/gif/рецепты.gif b/apps/web/main/gif/рецепты.gif new file mode 100644 index 0000000..0aeaa79 Binary files /dev/null and b/apps/web/main/gif/рецепты.gif differ diff --git a/apps/web/main/gif/учет потребления.gif b/apps/web/main/gif/учет потребления.gif new file mode 100644 index 0000000..fb2a668 Binary files /dev/null and b/apps/web/main/gif/учет потребления.gif differ diff --git a/apps/web/main/icons/.gitkeep b/apps/web/main/icons/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/web/main/img/control.png b/apps/web/main/img/control.png new file mode 100644 index 0000000..24016d6 Binary files /dev/null and b/apps/web/main/img/control.png differ diff --git a/apps/web/main/img/hero section.jpg b/apps/web/main/img/hero section.jpg new file mode 100644 index 0000000..10e2cc8 Binary files /dev/null and b/apps/web/main/img/hero section.jpg differ diff --git a/apps/web/main/img/letter K.png b/apps/web/main/img/letter K.png new file mode 100644 index 0000000..d17b529 Binary files /dev/null and b/apps/web/main/img/letter K.png differ diff --git a/apps/web/main/img/load.png b/apps/web/main/img/load.png new file mode 100644 index 0000000..c9c09ea Binary files /dev/null and b/apps/web/main/img/load.png differ diff --git a/apps/web/main/img/logo3.png b/apps/web/main/img/logo3.png new file mode 100644 index 0000000..893aacc Binary files /dev/null and b/apps/web/main/img/logo3.png differ diff --git a/apps/web/main/img/wifi.png b/apps/web/main/img/wifi.png new file mode 100644 index 0000000..49c1cc2 Binary files /dev/null and b/apps/web/main/img/wifi.png differ diff --git a/apps/web/main/img/zoo.png b/apps/web/main/img/zoo.png new file mode 100644 index 0000000..8cab387 Binary files /dev/null and b/apps/web/main/img/zoo.png differ diff --git a/apps/web/main/img/Комптон всегда под рукой на белом фоне.jpg b/apps/web/main/img/Комптон всегда под рукой на белом фоне.jpg new file mode 100644 index 0000000..7e5f3d1 Binary files /dev/null and b/apps/web/main/img/Комптон всегда под рукой на белом фоне.jpg differ diff --git a/apps/web/main/js/main.js b/apps/web/main/js/main.js new file mode 100644 index 0000000..a4496be --- /dev/null +++ b/apps/web/main/js/main.js @@ -0,0 +1,111 @@ +(function() { + 'use strict'; + + // ===== SCRAMBLE HERO ===== + const heroTitleEl = document.getElementById('heroTitle'); + if (heroTitleEl) { + const originalText = heroTitleEl.textContent.trim() || 'Технологии будущего на вашей ферме'; + const chars = '!@#$%^&*()_+{}[]|;:,.<>?'; + let iteration = 0; + let isScrambling = true; + let frameId = null; + const maxIterations = 30; + + function scramble() { + if (!isScrambling) return; + const result = originalText + .split('') + .map((char, index) => { + if (index < iteration) return originalText[index]; + if (char === ' ') return ' '; + return chars[Math.floor(Math.random() * chars.length)]; + }) + .join(''); + heroTitleEl.textContent = result; + iteration += 1; + if (iteration <= maxIterations) { + frameId = requestAnimationFrame(scramble); + } else { + heroTitleEl.textContent = originalText; + isScrambling = false; + if (frameId) { + cancelAnimationFrame(frameId); + frameId = null; + } + } + } + setTimeout(() => { + scramble(); + }, 300); + } + + // ===== FADE-UP ===== + const fadeElements = document.querySelectorAll('.fade-up'); + if (fadeElements.length > 0 && 'IntersectionObserver' in window) { + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + entry.target.classList.add('visible'); + observer.unobserve(entry.target); + } + }); + }, { threshold: 0.15, rootMargin: '0px 0px -30px 0px' }); + fadeElements.forEach(el => observer.observe(el)); + } else { + fadeElements.forEach(el => el.classList.add('visible')); + } + + // ===== EXPAND BLOCK ===== + const expandContent = document.getElementById('expandContent'); + if (expandContent && 'IntersectionObserver' in window) { + let isExpanded = false; + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + if (!isExpanded) { + expandContent.classList.add('expanded'); + isExpanded = true; + } + } else { + if (isExpanded) { + expandContent.classList.remove('expanded'); + isExpanded = false; + } + } + }); + }, { threshold: 0.25, rootMargin: '0px 0px -30px 0px' }); + observer.observe(expandContent); + } else if (expandContent) { + expandContent.classList.add('expanded'); + } + + // ===== MARQUEE ===== + const marqueeTrack = document.getElementById('marqueeTrack'); + const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); + if (prefersReducedMotion.matches && marqueeTrack) { + marqueeTrack.style.animation = 'none'; + marqueeTrack.style.transform = 'translateX(0)'; + } + prefersReducedMotion.addEventListener('change', (e) => { + if (marqueeTrack) { + if (e.matches) { + marqueeTrack.style.animation = 'none'; + marqueeTrack.style.transform = 'translateX(0)'; + } else { + marqueeTrack.style.animation = 'marqueeScroll 30s linear infinite'; + marqueeTrack.style.transform = ''; + } + } + }); + + // ===== HERO VIDEO ===== + const heroVideo = document.querySelector('.hero-video'); + if (heroVideo) { + heroVideo.play().catch(() => { + document.addEventListener('click', () => { + heroVideo.play(); + }, { once: true }); + }); + } + + })(); \ No newline at end of file diff --git a/apps/web/main/main.html b/apps/web/main/main.html new file mode 100644 index 0000000..e69de29 diff --git a/apps/web/main/templates/index.html b/apps/web/main/templates/index.html new file mode 100644 index 0000000..405bb4c --- /dev/null +++ b/apps/web/main/templates/index.html @@ -0,0 +1,283 @@ + + + + + + Комптон + + + + + + + + + + + + + +
+ +
+

Технологии будущего на вашей ферме

+

+ Интеллектуальная система контроля кормления КРС на основе искусственного интеллекта. + Снижаем затраты на корма, повышаем продуктивность стада и даём полный контроль над каждым этапом. +

+ +
+
+ + +
+
+
+ +

Как устроена наша система

+
+
+ +
+
+ План рационов +
+

План рационов

+

Зоотехник составляет и корректирует рацион для каждой группы КРС в удобной программе на офисном ПК, ноутбуке или смартфоне.

+
+ +
+
+ Синхронизация данных +
+

Синхронизация данных

+

Созданное задание мгновенно и без проводов передается на весовой терминал кормосмесителя.

+
+ +
+
+ Точная загрузка +
+

Точная загрузка

+

Тракторист на дисплее видит подсказки и точный вес каждого компонента на лету, что исключает ошибки перегруза.

+
+ +
+
+ Контроль руководителя +
+

Контроль руководителя

+

Собственник или управляющий получает автоматический отчет о всех отклонениях и реальном расходе прямо на смартфон.

+
+
+
+
+ + + + +
+
+ +
+

От формулы до кормушки

+
+ + +
+

«Каждый килограмм корма — под контролем.
Каждая копейка — на счету. Каждая минута — сэкономлена»

+
+
+
+ + +
+
+

Работа на любом устройстве

+

+ Управляйте системой кормления с телефона, планшета или компьютера — интерфейс адаптируется под любой экран. +

+ +
+
+

Всегда на связи с фермой Абсолютный контроль рационов и остатков в любом месте, в любое время и с любого гаджета.

+
+ +
+ Комптон всегда под рукой +
+ +
+

Прозрачность и контроль Вы всегда видите, сколько корма съедено, как меняется продуктивность и где можно сэкономить без потери качества.

+
+
+
+
+ + +
+
+

Начни кормить по новому!

+

+ Всё, что нужно для точного, экономного и эффективного кормления КРС. +

+ +
+
+
+ Индивидуальный рацион +
+
+
+ +
+

Автоматический расчет рационов

+

Программа сама подбирает оптимальный состав корма для каждой группы животных с учётом возраста, веса и продуктивности.

+
+
+ +
+
+ Удобное редактирование рационов +
+
+
+ +
+

Удобное редактирование рационов

+

Редактируйте рацион в один клик: меняйте рецепты местами, добавляйте новые компоненты и мгновенно корректируйте сухое вещество.

+
+
+ +
+
+ Прогноз продуктивности +
+
+
+ +
+

Учет кормов

+

Автоматический учет остатков: программа точно рассчитывает остатки кормов на складе и прогнозирует дату следующей закупки на основе текущего расхода, защищая от внезапного дефицита.

+
+
+ +
+
+ Визуализация экономии кормов +
+
+
+ +
+

Гибкая система уведомлений

+

Умная система уведомлений — ваш новый помощник, который следит за процессом кормления КРС и мгновенно предупреждает команду фермы о любых сбоях. Она заменяет ручной контроль автоматическим мониторингом.

+
+
+ +
+
+ Анализ микроклимата +
+
+
+ +
+

Анализ микроклимата

+

Интеграция с системами климат-контроля: учитываем температуру и влажность для коррекции потребности в питании.

+
+
+ +
+
+ Единая платформа +
+
+
+ +
+

Единая платформа

+

Все данные о кормлении, здоровье и продуктивности в одном окне – для быстрого принятия решений.

+
+
+
+
+
+ + +
+
+ ✦ АГРО-ХОЛДИНГ + ✦ МОЛОЧНЫЙ КОМБИНАТ + ✦ ФЕРМА №1 + ✦ ЗЕРНО-ТРЕЙД + ✦ ВЕТЕРИНАРНАЯ СЛУЖБА + ✦ КОРМОВОЙ ЦЕНТР + ✦ ПЛЕМЗАВОД + ✦ АГРО-ИНТЕЛЛЕКТ + ✦ АГРО-ХОЛДИНГ + ✦ МОЛОЧНЫЙ КОМБИНАТ + ✦ ФЕРМА №1 + ✦ ЗЕРНО-ТРЕЙД + ✦ ВЕТЕРИНАРНАЯ СЛУЖБА + ✦ КОРМОВОЙ ЦЕНТР + ✦ ПЛЕМЗАВОД + ✦ АГРО-ИНТЕЛЛЕКТ +
+
+ + +
+
+

Внедряйте технологии будущего

+

+ Получите консультацию по настройке системы для вашего хозяйства. + Первые 2 месяца – полная поддержка и мониторинг. +

+ Оставить заявку +
+
+ + + + + + + \ No newline at end of file diff --git a/apps/web/main/video/Тестовое видео.mp4 b/apps/web/main/video/Тестовое видео.mp4 new file mode 100644 index 0000000..a9f2c7a Binary files /dev/null and b/apps/web/main/video/Тестовое видео.mp4 differ diff --git a/apps/web/main/webfonts/.gitkeep b/apps/web/main/webfonts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..94bfdb8 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,50 @@ +{ + "name": "web", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "lint": "eslint src --max-warnings=0", + "typecheck": "tsc --noEmit", + "test": "vitest", + "test:ci": "vitest run --coverage", + "e2e": "playwright test" + }, + "dependencies": { + "@ant-design/icons": "^6.3.2", + "@hookform/resolvers": "^3.9.1", + "@tanstack/react-query": "^5.59.0", + "antd": "^5.29.3", + "axios": "^1.7.7", + "dompurify": "^3.2.2", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-hook-form": "^7.53.0", + "react-router-dom": "^7.0.0", + "zod": "^3.23.8", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@playwright/test": "^1.48.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.0.1", + "@types/dompurify": "^3.2.0", + "@types/node": "^22.8.6", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@typescript-eslint/eslint-plugin": "^8.10.0", + "@typescript-eslint/parser": "^8.10.0", + "@vitejs/plugin-react": "^4.3.2", + "@vitest/coverage-v8": "^2.1.9", + "eslint": "^9.12.0", + "eslint-plugin-boundaries": "^4.2.0", + "eslint-plugin-react-hooks": "^5.1.0", + "jsdom": "^25.0.1", + "typescript": "^5.6.3", + "vite": "^5.4.21", + "vitest": "^2.1.9" + } +} diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts new file mode 100644 index 0000000..6655e57 --- /dev/null +++ b/apps/web/playwright.config.ts @@ -0,0 +1,48 @@ +import { defineConfig } from "@playwright/test"; + +const webPort = process.env.E2E_WEB_PORT ?? "5175"; +const webUrl = process.env.E2E_BASE_URL ?? `http://127.0.0.1:${webPort}`; + +const apiPort = process.env.E2E_API_PORT ?? "8001"; +const apiUrl = process.env.E2E_API_URL ?? `http://127.0.0.1:${apiPort}`; +const startApi = process.env.E2E_START_API !== "false"; + +export default defineConfig({ + testDir: "./e2e", + retries: process.env.CI ? 1 : 0, + workers: 1, + webServer: startApi + ? [ + { + command: "python scripts/start_e2e_api.py", + cwd: "../api", + url: `${apiUrl}/api/v1/health`, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + env: { + E2E_API_PORT: apiPort, + E2E_WEB_PORT: webPort + } + }, + { + command: `npx vite --host 127.0.0.1 --port ${webPort}`, + url: webUrl, + reuseExistingServer: !process.env.CI, + env: { + VITE_USE_API_PROXY: "true", + VITE_API_URL: apiUrl + } + } + ] + : { + command: `npx vite --host 127.0.0.1 --port ${webPort}`, + url: webUrl, + reuseExistingServer: !process.env.CI + }, + use: { + baseURL: webUrl, + trace: "on-first-retry", + video: "on-first-retry" + }, + projects: [{ name: "chromium", use: { browserName: "chromium" } }] +}); diff --git a/apps/web/public/robots.txt b/apps/web/public/robots.txt new file mode 100644 index 0000000..b08aee5 --- /dev/null +++ b/apps/web/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: /sitemap.xml diff --git a/apps/web/public/sitemap.xml b/apps/web/public/sitemap.xml new file mode 100644 index 0000000..4b13e6d --- /dev/null +++ b/apps/web/public/sitemap.xml @@ -0,0 +1,7 @@ + + + http://localhost:5173/ + http://localhost:5173/pages/about + http://localhost:5173/pages/privacy + http://localhost:5173/pages/terms + diff --git a/apps/web/public/wesp/components.html b/apps/web/public/wesp/components.html new file mode 100644 index 0000000..dd01e30 --- /dev/null +++ b/apps/web/public/wesp/components.html @@ -0,0 +1,1291 @@ + + + + + + Компоненты + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ +
+
+

+ + Добавить новый компонент +

+ +
+ +
+
+
+ + +
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+
+ +
+
+ +
+

Поставка из lab

+

AgroStar · xml, pdf, xlsx — кинь файл, привяжем к реагентам

+
+
+
+
+ + +

Кинь файл сюда

+

Приготовим науку, понюхаем, привяжем к реагентам, приготовим партию... и посмотрим, кто из нас химик.

+ +
+
+ + + +
+ + + +
+
+
+
+ + +
+
+
+
+
+
+ + +
+

+ + Список компонентов +

+
+
+ +

Загрузка компонентов…

+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Комптон® является зарегистрированным товарным знаком +
+ + + + + \ No newline at end of file diff --git a/apps/web/public/wesp/consumption.html b/apps/web/public/wesp/consumption.html new file mode 100644 index 0000000..e896861 --- /dev/null +++ b/apps/web/public/wesp/consumption.html @@ -0,0 +1,1861 @@ + + + + + + + + + + + Учёт кормов + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+
+

+ + Склад и учёт кормов +

+
+ + + + + + +
+ +
+ +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + +
+ + +
+ Комптон® является зарегистрированным товарным знаком +
+ + + + + + diff --git a/apps/web/public/wesp/css/bootstrap.min.css b/apps/web/public/wesp/css/bootstrap.min.css new file mode 100644 index 0000000..a8d744e --- /dev/null +++ b/apps/web/public/wesp/css/bootstrap.min.css @@ -0,0 +1,5 @@ +@charset "UTF-8";/*! + * Bootstrap v5.3.6 (https://getbootstrap.com/) + * Copyright 2011-2025 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:#052c65;--bs-secondary-text-emphasis:#2b2f32;--bs-success-text-emphasis:#0a3622;--bs-info-text-emphasis:#055160;--bs-warning-text-emphasis:#664d03;--bs-danger-text-emphasis:#58151c;--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:#cfe2ff;--bs-secondary-bg-subtle:#e2e3e5;--bs-success-bg-subtle:#d1e7dd;--bs-info-bg-subtle:#cff4fc;--bs-warning-bg-subtle:#fff3cd;--bs-danger-bg-subtle:#f8d7da;--bs-light-bg-subtle:#fcfcfd;--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:#9ec5fe;--bs-secondary-border-subtle:#c4c8cb;--bs-success-border-subtle:#a3cfbb;--bs-info-border-subtle:#9eeaf9;--bs-warning-border-subtle:#ffe69c;--bs-danger-border-subtle:#f1aeb5;--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-color:#212529;--bs-highlight-bg:#fff3cd;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb:222,226,230;--bs-tertiary-bg:#2b3035;--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:#6ea8fe;--bs-secondary-text-emphasis:#a7acb1;--bs-success-text-emphasis:#75b798;--bs-info-text-emphasis:#6edff6;--bs-warning-text-emphasis:#ffda6a;--bs-danger-text-emphasis:#ea868f;--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:#031633;--bs-secondary-bg-subtle:#161719;--bs-success-bg-subtle:#051b11;--bs-info-bg-subtle:#032830;--bs-warning-bg-subtle:#332701;--bs-danger-bg-subtle:#2c0b0e;--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:#084298;--bs-secondary-border-subtle:#41464b;--bs-success-border-subtle:#0f5132;--bs-info-border-subtle:#087990;--bs-warning-border-subtle:#997404;--bs-danger-border-subtle:#842029;--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherit;--bs-link-color:#6ea8fe;--bs-link-hover-color:#8bb9fe;--bs-link-color-rgb:110,168,254;--bs-link-hover-color-rgb:139,185,254;--bs-code-color:#e685b5;--bs-highlight-color:#dee2e6;--bs-highlight-bg:#664d03;--bs-border-color:#495057;--bs-border-color-translucent:rgba(255, 255, 255, 0.15);--bs-form-valid-color:#75b798;--bs-form-valid-border-color:#75b798;--bs-form-invalid-color:#ea868f;--bs-form-invalid-border-color:#ea868f}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;border:0;border-top:var(--bs-border-width) solid;opacity:.25}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2;color:var(--bs-heading-color)}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{padding:.1875em;color:var(--bs-highlight-color);background-color:var(--bs-highlight-bg)}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,1));text-decoration:underline}a:hover{--bs-link-color-rgb:var(--bs-link-hover-color-rgb)}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:var(--bs-code-color);word-wrap:break-word}a>code{color:inherit}kbd{padding:.1875rem .375rem;font-size:.875em;color:var(--bs-body-bg);background-color:var(--bs-body-color);border-radius:.25rem}kbd kbd{padding:0;font-size:1em}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-secondary-color);text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator{display:none!important}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;line-height:inherit;font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}::file-selector-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}.lead{font-size:1.25rem;font-weight:300}.display-1{font-weight:300;line-height:1.2;font-size:calc(1.625rem + 4.5vw)}@media (min-width:1200px){.display-1{font-size:5rem}}.display-2{font-weight:300;line-height:1.2;font-size:calc(1.575rem + 3.9vw)}@media (min-width:1200px){.display-2{font-size:4.5rem}}.display-3{font-weight:300;line-height:1.2;font-size:calc(1.525rem + 3.3vw)}@media (min-width:1200px){.display-3{font-size:4rem}}.display-4{font-weight:300;line-height:1.2;font-size:calc(1.475rem + 2.7vw)}@media (min-width:1200px){.display-4{font-size:3.5rem}}.display-5{font-weight:300;line-height:1.2;font-size:calc(1.425rem + 2.1vw)}@media (min-width:1200px){.display-5{font-size:3rem}}.display-6{font-weight:300;line-height:1.2;font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.display-6{font-size:2.5rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#6c757d}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:var(--bs-body-bg);border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:.875em;color:var(--bs-secondary-color)}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{--bs-gutter-x:1.5rem;--bs-gutter-y:0;width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}:root{--bs-breakpoint-xs:0;--bs-breakpoint-sm:576px;--bs-breakpoint-md:768px;--bs-breakpoint-lg:992px;--bs-breakpoint-xl:1200px;--bs-breakpoint-xxl:1400px}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-right:calc(-.5 * var(--bs-gutter-x));margin-left:calc(-.5 * var(--bs-gutter-x))}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.66666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.66666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.66666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.66666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.66666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.66666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.table{--bs-table-color-type:initial;--bs-table-bg-type:initial;--bs-table-color-state:initial;--bs-table-bg-state:initial;--bs-table-color:var(--bs-emphasis-color);--bs-table-bg:var(--bs-body-bg);--bs-table-border-color:var(--bs-border-color);--bs-table-accent-bg:transparent;--bs-table-striped-color:var(--bs-emphasis-color);--bs-table-striped-bg:rgba(var(--bs-emphasis-color-rgb), 0.05);--bs-table-active-color:var(--bs-emphasis-color);--bs-table-active-bg:rgba(var(--bs-emphasis-color-rgb), 0.1);--bs-table-hover-color:var(--bs-emphasis-color);--bs-table-hover-bg:rgba(var(--bs-emphasis-color-rgb), 0.075);width:100%;margin-bottom:1rem;vertical-align:top;border-color:var(--bs-table-border-color)}.table>:not(caption)>*>*{padding:.5rem .5rem;color:var(--bs-table-color-state,var(--bs-table-color-type,var(--bs-table-color)));background-color:var(--bs-table-bg);border-bottom-width:var(--bs-border-width);box-shadow:inset 0 0 0 9999px var(--bs-table-bg-state,var(--bs-table-bg-type,var(--bs-table-accent-bg)))}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table-group-divider{border-top:calc(var(--bs-border-width) * 2) solid currentcolor}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.table-bordered>:not(caption)>*{border-width:var(--bs-border-width) 0}.table-bordered>:not(caption)>*>*{border-width:0 var(--bs-border-width)}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-borderless>:not(:first-child){border-top-width:0}.table-striped>tbody>tr:nth-of-type(odd)>*{--bs-table-color-type:var(--bs-table-striped-color);--bs-table-bg-type:var(--bs-table-striped-bg)}.table-striped-columns>:not(caption)>tr>:nth-child(2n){--bs-table-color-type:var(--bs-table-striped-color);--bs-table-bg-type:var(--bs-table-striped-bg)}.table-active{--bs-table-color-state:var(--bs-table-active-color);--bs-table-bg-state:var(--bs-table-active-bg)}.table-hover>tbody>tr:hover>*{--bs-table-color-state:var(--bs-table-hover-color);--bs-table-bg-state:var(--bs-table-hover-bg)}.table-primary{--bs-table-color:#000;--bs-table-bg:#cfe2ff;--bs-table-border-color:#a6b5cc;--bs-table-striped-bg:#c5d7f2;--bs-table-striped-color:#000;--bs-table-active-bg:#bacbe6;--bs-table-active-color:#000;--bs-table-hover-bg:#bfd1ec;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-secondary{--bs-table-color:#000;--bs-table-bg:#e2e3e5;--bs-table-border-color:#b5b6b7;--bs-table-striped-bg:#d7d8da;--bs-table-striped-color:#000;--bs-table-active-bg:#cbccce;--bs-table-active-color:#000;--bs-table-hover-bg:#d1d2d4;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-success{--bs-table-color:#000;--bs-table-bg:#d1e7dd;--bs-table-border-color:#a7b9b1;--bs-table-striped-bg:#c7dbd2;--bs-table-striped-color:#000;--bs-table-active-bg:#bcd0c7;--bs-table-active-color:#000;--bs-table-hover-bg:#c1d6cc;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-info{--bs-table-color:#000;--bs-table-bg:#cff4fc;--bs-table-border-color:#a6c3ca;--bs-table-striped-bg:#c5e8ef;--bs-table-striped-color:#000;--bs-table-active-bg:#badce3;--bs-table-active-color:#000;--bs-table-hover-bg:#bfe2e9;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-warning{--bs-table-color:#000;--bs-table-bg:#fff3cd;--bs-table-border-color:#ccc2a4;--bs-table-striped-bg:#f2e7c3;--bs-table-striped-color:#000;--bs-table-active-bg:#e6dbb9;--bs-table-active-color:#000;--bs-table-hover-bg:#ece1be;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-danger{--bs-table-color:#000;--bs-table-bg:#f8d7da;--bs-table-border-color:#c6acae;--bs-table-striped-bg:#eccccf;--bs-table-striped-color:#000;--bs-table-active-bg:#dfc2c4;--bs-table-active-color:#000;--bs-table-hover-bg:#e5c7ca;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-light{--bs-table-color:#000;--bs-table-bg:#f8f9fa;--bs-table-border-color:#c6c7c8;--bs-table-striped-bg:#ecedee;--bs-table-striped-color:#000;--bs-table-active-bg:#dfe0e1;--bs-table-active-color:#000;--bs-table-hover-bg:#e5e6e7;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-dark{--bs-table-color:#fff;--bs-table-bg:#212529;--bs-table-border-color:#4d5154;--bs-table-striped-bg:#2c3034;--bs-table-striped-color:#fff;--bs-table-active-bg:#373b3e;--bs-table-active-color:#fff;--bs-table-hover-bg:#323539;--bs-table-hover-color:#fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media (max-width:575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label{margin-bottom:.5rem}.col-form-label{padding-top:calc(.375rem + var(--bs-border-width));padding-bottom:calc(.375rem + var(--bs-border-width));margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + var(--bs-border-width));padding-bottom:calc(.5rem + var(--bs-border-width));font-size:1.25rem}.col-form-label-sm{padding-top:calc(.25rem + var(--bs-border-width));padding-bottom:calc(.25rem + var(--bs-border-width));font-size:.875rem}.form-text{margin-top:.25rem;font-size:.875em;color:var(--bs-secondary-color)}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-body-bg);background-clip:padding-box;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:var(--bs-body-color);background-color:var(--bs-body-bg);border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-control::-webkit-date-and-time-value{min-width:85px;height:1.5em;margin:0}.form-control::-webkit-datetime-edit{display:block;padding:0}.form-control::placeholder{color:var(--bs-secondary-color);opacity:1}.form-control:disabled{background-color:var(--bs-secondary-bg);opacity:1}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:var(--bs-secondary-bg)}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:var(--bs-secondary-bg)}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:var(--bs-body-color);background-color:transparent;border:solid transparent;border-width:var(--bs-border-width) 0}.form-control-plaintext:focus{outline:0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2));padding:.25rem .5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2));padding:.5rem 1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + .75rem + calc(var(--bs-border-width) * 2))}textarea.form-control-sm{min-height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2))}textarea.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2))}.form-control-color{width:3rem;height:calc(1.5em + .75rem + calc(var(--bs-border-width) * 2));padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{border:0!important;border-radius:var(--bs-border-radius)}.form-control-color::-webkit-color-swatch{border:0!important;border-radius:var(--bs-border-radius)}.form-control-color.form-control-sm{height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2))}.form-control-color.form-control-lg{height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2))}.form-select{--bs-form-select-bg-img:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-body-bg);background-image:var(--bs-form-select-bg-img),var(--bs-form-select-bg-icon,none);background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-select{transition:none}}.form-select:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{background-color:var(--bs-secondary-bg)}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 var(--bs-body-color)}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}[data-bs-theme=dark] .form-select{--bs-form-select-bg-img:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23dee2e6' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e")}.form-check{display:block;min-height:1.5rem;padding-left:1.5em;margin-bottom:.125rem}.form-check .form-check-input{float:left;margin-left:-1.5em}.form-check-reverse{padding-right:1.5em;padding-left:0;text-align:right}.form-check-reverse .form-check-input{float:right;margin-right:-1.5em;margin-left:0}.form-check-input{--bs-form-check-bg:var(--bs-body-bg);flex-shrink:0;width:1em;height:1em;margin-top:.25em;vertical-align:top;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-form-check-bg);background-image:var(--bs-form-check-bg-image);background-repeat:no-repeat;background-position:center;background-size:contain;border:var(--bs-border-width) solid var(--bs-border-color);-webkit-print-color-adjust:exact;color-adjust:exact;print-color-adjust:exact}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:active{filter:brightness(90%)}.form-check-input:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-check-input:checked{background-color:#0d6efd;border-color:#0d6efd}.form-check-input:checked[type=checkbox]{--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio]{--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate{background-color:#0d6efd;border-color:#0d6efd;--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{cursor:default;opacity:.5}.form-switch{padding-left:2.5em}.form-switch .form-check-input{--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");width:2em;margin-left:-2.5em;background-image:var(--bs-form-switch-bg);background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2386b7fe'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-switch.form-check-reverse{padding-right:2.5em;padding-left:0}.form-switch.form-check-reverse .form-check-input{margin-right:-2.5em;margin-left:0}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn-check:disabled+.btn,.btn-check[disabled]+.btn{pointer-events:none;filter:none;opacity:.65}[data-bs-theme=dark] .form-switch .form-check-input:not(:checked):not(:focus){--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%28255, 255, 255, 0.25%29'/%3e%3c/svg%3e")}.form-range{width:100%;height:1.5rem;padding:0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;-webkit-appearance:none;appearance:none;background-color:#0d6efd;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.form-range::-webkit-slider-thumb:active{background-color:#b6d4fe}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:var(--bs-secondary-bg);border-color:transparent;border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;-moz-appearance:none;appearance:none;background-color:#0d6efd;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-range::-moz-range-thumb{-moz-transition:none;transition:none}}.form-range::-moz-range-thumb:active{background-color:#b6d4fe}.form-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:var(--bs-secondary-bg);border-color:transparent;border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:var(--bs-secondary-color)}.form-range:disabled::-moz-range-thumb{background-color:var(--bs-secondary-color)}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-control-plaintext,.form-floating>.form-select{height:calc(3.5rem + calc(var(--bs-border-width) * 2));min-height:calc(3.5rem + calc(var(--bs-border-width) * 2));line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;z-index:2;max-width:100%;height:100%;padding:1rem .75rem;overflow:hidden;color:rgba(var(--bs-body-color-rgb),.65);text-align:start;text-overflow:ellipsis;white-space:nowrap;pointer-events:none;border:var(--bs-border-width) solid transparent;transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media (prefers-reduced-motion:reduce){.form-floating>label{transition:none}}.form-floating>.form-control,.form-floating>.form-control-plaintext{padding:1rem .75rem}.form-floating>.form-control-plaintext::placeholder,.form-floating>.form-control::placeholder{color:transparent}.form-floating>.form-control-plaintext:focus,.form-floating>.form-control-plaintext:not(:placeholder-shown),.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:-webkit-autofill,.form-floating>.form-control:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem;padding-left:.75rem}.form-floating>.form-control-plaintext~label,.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-select~label{transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:-webkit-autofill~label{transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>textarea:focus~label::after,.form-floating>textarea:not(:placeholder-shown)~label::after{position:absolute;inset:1rem 0.375rem;z-index:-1;height:1.5em;content:"";background-color:var(--bs-body-bg);border-radius:var(--bs-border-radius)}.form-floating>textarea:disabled~label::after{background-color:var(--bs-secondary-bg)}.form-floating>.form-control-plaintext~label{border-width:var(--bs-border-width) 0}.form-floating>.form-control:disabled~label,.form-floating>:disabled~label{color:#6c757d}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-floating,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-floating:focus-within,.input-group>.form-select:focus{z-index:5}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:5}.input-group-text{display:flex;align-items:center;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);text-align:center;white-space:nowrap;background-color:var(--bs-tertiary-bg);border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius)}.input-group-lg>.btn,.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}.input-group-sm>.btn,.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text{padding:.25rem .5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:3rem}.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-control,.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-select,.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-control,.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-select,.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:calc(-1 * var(--bs-border-width));border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.form-floating:not(:first-child)>.form-control,.input-group>.form-floating:not(:first-child)>.form-select{border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--bs-form-valid-color)}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:var(--bs-success);border-radius:var(--bs-border-radius)}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:var(--bs-form-valid-border-color);padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:var(--bs-form-valid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-valid,.was-validated .form-select:valid{border-color:var(--bs-form-valid-border-color)}.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"],.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"]{--bs-form-select-bg-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-valid:focus,.was-validated .form-select:valid:focus{border-color:var(--bs-form-valid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.form-control-color.is-valid,.was-validated .form-control-color:valid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-valid,.was-validated .form-check-input:valid{border-color:var(--bs-form-valid-border-color)}.form-check-input.is-valid:checked,.was-validated .form-check-input:valid:checked{background-color:var(--bs-form-valid-color)}.form-check-input.is-valid:focus,.was-validated .form-check-input:valid:focus{box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:var(--bs-form-valid-color)}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-valid,.input-group>.form-floating:not(:focus-within).is-valid,.input-group>.form-select:not(:focus).is-valid,.was-validated .input-group>.form-control:not(:focus):valid,.was-validated .input-group>.form-floating:not(:focus-within):valid,.was-validated .input-group>.form-select:not(:focus):valid{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--bs-form-invalid-color)}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:var(--bs-danger);border-radius:var(--bs-border-radius)}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:var(--bs-form-invalid-border-color);padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:var(--bs-form-invalid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-invalid,.was-validated .form-select:invalid{border-color:var(--bs-form-invalid-border-color)}.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"],.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"]{--bs-form-select-bg-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-invalid:focus,.was-validated .form-select:invalid:focus{border-color:var(--bs-form-invalid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.form-control-color.is-invalid,.was-validated .form-control-color:invalid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-invalid,.was-validated .form-check-input:invalid{border-color:var(--bs-form-invalid-border-color)}.form-check-input.is-invalid:checked,.was-validated .form-check-input:invalid:checked{background-color:var(--bs-form-invalid-color)}.form-check-input.is-invalid:focus,.was-validated .form-check-input:invalid:focus{box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:var(--bs-form-invalid-color)}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-invalid,.input-group>.form-floating:not(:focus-within).is-invalid,.input-group>.form-select:not(:focus).is-invalid,.was-validated .input-group>.form-control:not(:focus):invalid,.was-validated .input-group>.form-floating:not(:focus-within):invalid,.was-validated .input-group>.form-select:not(:focus):invalid{z-index:4}.btn{--bs-btn-padding-x:0.75rem;--bs-btn-padding-y:0.375rem;--bs-btn-font-family: ;--bs-btn-font-size:1rem;--bs-btn-font-weight:400;--bs-btn-line-height:1.5;--bs-btn-color:var(--bs-body-color);--bs-btn-bg:transparent;--bs-btn-border-width:var(--bs-border-width);--bs-btn-border-color:transparent;--bs-btn-border-radius:var(--bs-border-radius);--bs-btn-hover-border-color:transparent;--bs-btn-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.15),0 1px 1px rgba(0, 0, 0, 0.075);--bs-btn-disabled-opacity:0.65;--bs-btn-focus-box-shadow:0 0 0 0.25rem rgba(var(--bs-btn-focus-shadow-rgb), .5);display:inline-block;padding:var(--bs-btn-padding-y) var(--bs-btn-padding-x);font-family:var(--bs-btn-font-family);font-size:var(--bs-btn-font-size);font-weight:var(--bs-btn-font-weight);line-height:var(--bs-btn-line-height);color:var(--bs-btn-color);text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;border:var(--bs-btn-border-width) solid var(--bs-btn-border-color);border-radius:var(--bs-btn-border-radius);background-color:var(--bs-btn-bg);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color)}.btn-check+.btn:hover{color:var(--bs-btn-color);background-color:var(--bs-btn-bg);border-color:var(--bs-btn-border-color)}.btn:focus-visible{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:focus-visible+.btn{border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked+.btn,.btn.active,.btn.show,.btn:first-child:active,:not(.btn-check)+.btn:active{color:var(--bs-btn-active-color);background-color:var(--bs-btn-active-bg);border-color:var(--bs-btn-active-border-color)}.btn-check:checked+.btn:focus-visible,.btn.active:focus-visible,.btn.show:focus-visible,.btn:first-child:active:focus-visible,:not(.btn-check)+.btn:active:focus-visible{box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked:focus-visible+.btn{box-shadow:var(--bs-btn-focus-box-shadow)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{color:var(--bs-btn-disabled-color);pointer-events:none;background-color:var(--bs-btn-disabled-bg);border-color:var(--bs-btn-disabled-border-color);opacity:var(--bs-btn-disabled-opacity)}.btn-primary{--bs-btn-color:#fff;--bs-btn-bg:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0b5ed7;--bs-btn-hover-border-color:#0a58ca;--bs-btn-focus-shadow-rgb:49,132,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0a58ca;--bs-btn-active-border-color:#0a53be;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#0d6efd;--bs-btn-disabled-border-color:#0d6efd}.btn-secondary{--bs-btn-color:#fff;--bs-btn-bg:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#5c636a;--bs-btn-hover-border-color:#565e64;--bs-btn-focus-shadow-rgb:130,138,145;--bs-btn-active-color:#fff;--bs-btn-active-bg:#565e64;--bs-btn-active-border-color:#51585e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#6c757d;--bs-btn-disabled-border-color:#6c757d}.btn-success{--bs-btn-color:#fff;--bs-btn-bg:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#157347;--bs-btn-hover-border-color:#146c43;--bs-btn-focus-shadow-rgb:60,153,110;--bs-btn-active-color:#fff;--bs-btn-active-bg:#146c43;--bs-btn-active-border-color:#13653f;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#198754;--bs-btn-disabled-border-color:#198754}.btn-info{--bs-btn-color:#000;--bs-btn-bg:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#31d2f2;--bs-btn-hover-border-color:#25cff2;--bs-btn-focus-shadow-rgb:11,172,204;--bs-btn-active-color:#000;--bs-btn-active-bg:#3dd5f3;--bs-btn-active-border-color:#25cff2;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#0dcaf0;--bs-btn-disabled-border-color:#0dcaf0}.btn-warning{--bs-btn-color:#000;--bs-btn-bg:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffca2c;--bs-btn-hover-border-color:#ffc720;--bs-btn-focus-shadow-rgb:217,164,6;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffcd39;--bs-btn-active-border-color:#ffc720;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#ffc107;--bs-btn-disabled-border-color:#ffc107}.btn-danger{--bs-btn-color:#fff;--bs-btn-bg:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#bb2d3b;--bs-btn-hover-border-color:#b02a37;--bs-btn-focus-shadow-rgb:225,83,97;--bs-btn-active-color:#fff;--bs-btn-active-bg:#b02a37;--bs-btn-active-border-color:#a52834;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#dc3545;--bs-btn-disabled-border-color:#dc3545}.btn-light{--bs-btn-color:#000;--bs-btn-bg:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#d3d4d5;--bs-btn-hover-border-color:#c6c7c8;--bs-btn-focus-shadow-rgb:211,212,213;--bs-btn-active-color:#000;--bs-btn-active-bg:#c6c7c8;--bs-btn-active-border-color:#babbbc;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#f8f9fa;--bs-btn-disabled-border-color:#f8f9fa}.btn-dark{--bs-btn-color:#fff;--bs-btn-bg:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#424649;--bs-btn-hover-border-color:#373b3e;--bs-btn-focus-shadow-rgb:66,70,73;--bs-btn-active-color:#fff;--bs-btn-active-bg:#4d5154;--bs-btn-active-border-color:#373b3e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#212529;--bs-btn-disabled-border-color:#212529}.btn-outline-primary{--bs-btn-color:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0d6efd;--bs-btn-hover-border-color:#0d6efd;--bs-btn-focus-shadow-rgb:13,110,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0d6efd;--bs-btn-active-border-color:#0d6efd;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0d6efd;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0d6efd;--bs-gradient:none}.btn-outline-secondary{--bs-btn-color:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#6c757d;--bs-btn-hover-border-color:#6c757d;--bs-btn-focus-shadow-rgb:108,117,125;--bs-btn-active-color:#fff;--bs-btn-active-bg:#6c757d;--bs-btn-active-border-color:#6c757d;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#6c757d;--bs-gradient:none}.btn-outline-success{--bs-btn-color:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#198754;--bs-btn-hover-border-color:#198754;--bs-btn-focus-shadow-rgb:25,135,84;--bs-btn-active-color:#fff;--bs-btn-active-bg:#198754;--bs-btn-active-border-color:#198754;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#198754;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#198754;--bs-gradient:none}.btn-outline-info{--bs-btn-color:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#0dcaf0;--bs-btn-hover-border-color:#0dcaf0;--bs-btn-focus-shadow-rgb:13,202,240;--bs-btn-active-color:#000;--bs-btn-active-bg:#0dcaf0;--bs-btn-active-border-color:#0dcaf0;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0dcaf0;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0dcaf0;--bs-gradient:none}.btn-outline-warning{--bs-btn-color:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffc107;--bs-btn-hover-border-color:#ffc107;--bs-btn-focus-shadow-rgb:255,193,7;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffc107;--bs-btn-active-border-color:#ffc107;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#ffc107;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#ffc107;--bs-gradient:none}.btn-outline-danger{--bs-btn-color:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#dc3545;--bs-btn-hover-border-color:#dc3545;--bs-btn-focus-shadow-rgb:220,53,69;--bs-btn-active-color:#fff;--bs-btn-active-bg:#dc3545;--bs-btn-active-border-color:#dc3545;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#dc3545;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#dc3545;--bs-gradient:none}.btn-outline-light{--bs-btn-color:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#f8f9fa;--bs-btn-hover-border-color:#f8f9fa;--bs-btn-focus-shadow-rgb:248,249,250;--bs-btn-active-color:#000;--bs-btn-active-bg:#f8f9fa;--bs-btn-active-border-color:#f8f9fa;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#f8f9fa;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#f8f9fa;--bs-gradient:none}.btn-outline-dark{--bs-btn-color:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#212529;--bs-btn-hover-border-color:#212529;--bs-btn-focus-shadow-rgb:33,37,41;--bs-btn-active-color:#fff;--bs-btn-active-bg:#212529;--bs-btn-active-border-color:#212529;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#212529;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#212529;--bs-gradient:none}.btn-link{--bs-btn-font-weight:400;--bs-btn-color:var(--bs-link-color);--bs-btn-bg:transparent;--bs-btn-border-color:transparent;--bs-btn-hover-color:var(--bs-link-hover-color);--bs-btn-hover-border-color:transparent;--bs-btn-active-color:var(--bs-link-hover-color);--bs-btn-active-border-color:transparent;--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-border-color:transparent;--bs-btn-box-shadow:0 0 0 #000;--bs-btn-focus-shadow-rgb:49,132,253;text-decoration:underline}.btn-link:focus-visible{color:var(--bs-btn-color)}.btn-link:hover{color:var(--bs-btn-hover-color)}.btn-group-lg>.btn,.btn-lg{--bs-btn-padding-y:0.5rem;--bs-btn-padding-x:1rem;--bs-btn-font-size:1.25rem;--bs-btn-border-radius:var(--bs-border-radius-lg)}.btn-group-sm>.btn,.btn-sm{--bs-btn-padding-y:0.25rem;--bs-btn-padding-x:0.5rem;--bs-btn-font-size:0.875rem;--bs-btn-border-radius:var(--bs-border-radius-sm)}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.collapse-horizontal{transition:none}}.dropdown,.dropdown-center,.dropend,.dropstart,.dropup,.dropup-center{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{--bs-dropdown-zindex:1000;--bs-dropdown-min-width:10rem;--bs-dropdown-padding-x:0;--bs-dropdown-padding-y:0.5rem;--bs-dropdown-spacer:0.125rem;--bs-dropdown-font-size:1rem;--bs-dropdown-color:var(--bs-body-color);--bs-dropdown-bg:var(--bs-body-bg);--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-border-radius:var(--bs-border-radius);--bs-dropdown-border-width:var(--bs-border-width);--bs-dropdown-inner-border-radius:calc(var(--bs-border-radius) - var(--bs-border-width));--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-divider-margin-y:0.5rem;--bs-dropdown-box-shadow:var(--bs-box-shadow);--bs-dropdown-link-color:var(--bs-body-color);--bs-dropdown-link-hover-color:var(--bs-body-color);--bs-dropdown-link-hover-bg:var(--bs-tertiary-bg);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:var(--bs-tertiary-color);--bs-dropdown-item-padding-x:1rem;--bs-dropdown-item-padding-y:0.25rem;--bs-dropdown-header-color:#6c757d;--bs-dropdown-header-padding-x:1rem;--bs-dropdown-header-padding-y:0.5rem;position:absolute;z-index:var(--bs-dropdown-zindex);display:none;min-width:var(--bs-dropdown-min-width);padding:var(--bs-dropdown-padding-y) var(--bs-dropdown-padding-x);margin:0;font-size:var(--bs-dropdown-font-size);color:var(--bs-dropdown-color);text-align:left;list-style:none;background-color:var(--bs-dropdown-bg);background-clip:padding-box;border:var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color);border-radius:var(--bs-dropdown-border-radius)}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:var(--bs-dropdown-spacer)}.dropdown-menu-start{--bs-position:start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position:end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-start{--bs-position:start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position:end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-start{--bs-position:start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position:end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-start{--bs-position:start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position:end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-start{--bs-position:start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position:end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1400px){.dropdown-menu-xxl-start{--bs-position:start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position:end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:var(--bs-dropdown-spacer)}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:var(--bs-dropdown-spacer)}.dropend .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropend .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:var(--bs-dropdown-spacer)}.dropstart .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle::after{display:none}.dropstart .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropstart .dropdown-toggle:empty::after{margin-left:0}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:var(--bs-dropdown-divider-margin-y) 0;overflow:hidden;border-top:1px solid var(--bs-dropdown-divider-bg);opacity:1}.dropdown-item{display:block;width:100%;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);clear:both;font-weight:400;color:var(--bs-dropdown-link-color);text-align:inherit;text-decoration:none;white-space:nowrap;background-color:transparent;border:0;border-radius:var(--bs-dropdown-item-border-radius,0)}.dropdown-item:focus,.dropdown-item:hover{color:var(--bs-dropdown-link-hover-color);background-color:var(--bs-dropdown-link-hover-bg)}.dropdown-item.active,.dropdown-item:active{color:var(--bs-dropdown-link-active-color);text-decoration:none;background-color:var(--bs-dropdown-link-active-bg)}.dropdown-item.disabled,.dropdown-item:disabled{color:var(--bs-dropdown-link-disabled-color);pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:var(--bs-dropdown-header-padding-y) var(--bs-dropdown-header-padding-x);margin-bottom:0;font-size:.875rem;color:var(--bs-dropdown-header-color);white-space:nowrap}.dropdown-item-text{display:block;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);color:var(--bs-dropdown-link-color)}.dropdown-menu-dark{--bs-dropdown-color:#dee2e6;--bs-dropdown-bg:#343a40;--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-box-shadow: ;--bs-dropdown-link-color:#dee2e6;--bs-dropdown-link-hover-color:#fff;--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-link-hover-bg:rgba(255, 255, 255, 0.15);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:#adb5bd;--bs-dropdown-header-color:#adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;flex:1 1 auto}.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group{border-radius:var(--bs-border-radius)}.btn-group>.btn-group:not(:first-child),.btn-group>:not(.btn-check:first-child)+.btn{margin-left:calc(-1 * var(--bs-border-width))}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn.dropdown-toggle-split:first-child,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{flex-direction:column;align-items:flex-start;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:calc(-1 * var(--bs-border-width))}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn:nth-child(n+3),.btn-group-vertical>:not(.btn-check)+.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{--bs-nav-link-padding-x:1rem;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-link-color);--bs-nav-link-hover-color:var(--bs-link-hover-color);--bs-nav-link-disabled-color:var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);font-size:var(--bs-nav-link-font-size);font-weight:var(--bs-nav-link-font-weight);color:var(--bs-nav-link-color);text-decoration:none;background:0 0;border:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:var(--bs-nav-link-hover-color)}.nav-link:focus-visible{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.nav-link.disabled,.nav-link:disabled{color:var(--bs-nav-link-disabled-color);pointer-events:none;cursor:default}.nav-tabs{--bs-nav-tabs-border-width:var(--bs-border-width);--bs-nav-tabs-border-color:var(--bs-border-color);--bs-nav-tabs-border-radius:var(--bs-border-radius);--bs-nav-tabs-link-hover-border-color:var(--bs-secondary-bg) var(--bs-secondary-bg) var(--bs-border-color);--bs-nav-tabs-link-active-color:var(--bs-emphasis-color);--bs-nav-tabs-link-active-bg:var(--bs-body-bg);--bs-nav-tabs-link-active-border-color:var(--bs-border-color) var(--bs-border-color) var(--bs-body-bg);border-bottom:var(--bs-nav-tabs-border-width) solid var(--bs-nav-tabs-border-color)}.nav-tabs .nav-link{margin-bottom:calc(-1 * var(--bs-nav-tabs-border-width));border:var(--bs-nav-tabs-border-width) solid transparent;border-top-left-radius:var(--bs-nav-tabs-border-radius);border-top-right-radius:var(--bs-nav-tabs-border-radius)}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{isolation:isolate;border-color:var(--bs-nav-tabs-link-hover-border-color)}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:var(--bs-nav-tabs-link-active-color);background-color:var(--bs-nav-tabs-link-active-bg);border-color:var(--bs-nav-tabs-link-active-border-color)}.nav-tabs .dropdown-menu{margin-top:calc(-1 * var(--bs-nav-tabs-border-width));border-top-left-radius:0;border-top-right-radius:0}.nav-pills{--bs-nav-pills-border-radius:var(--bs-border-radius);--bs-nav-pills-link-active-color:#fff;--bs-nav-pills-link-active-bg:#0d6efd}.nav-pills .nav-link{border-radius:var(--bs-nav-pills-border-radius)}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:var(--bs-nav-pills-link-active-color);background-color:var(--bs-nav-pills-link-active-bg)}.nav-underline{--bs-nav-underline-gap:1rem;--bs-nav-underline-border-width:0.125rem;--bs-nav-underline-link-active-color:var(--bs-emphasis-color);gap:var(--bs-nav-underline-gap)}.nav-underline .nav-link{padding-right:0;padding-left:0;border-bottom:var(--bs-nav-underline-border-width) solid transparent}.nav-underline .nav-link:focus,.nav-underline .nav-link:hover{border-bottom-color:currentcolor}.nav-underline .nav-link.active,.nav-underline .show>.nav-link{font-weight:700;color:var(--bs-nav-underline-link-active-color);border-bottom-color:currentcolor}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-grow:1;flex-basis:0;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{--bs-navbar-padding-x:0;--bs-navbar-padding-y:0.5rem;--bs-navbar-color:rgba(var(--bs-emphasis-color-rgb), 0.65);--bs-navbar-hover-color:rgba(var(--bs-emphasis-color-rgb), 0.8);--bs-navbar-disabled-color:rgba(var(--bs-emphasis-color-rgb), 0.3);--bs-navbar-active-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-padding-y:0.3125rem;--bs-navbar-brand-margin-end:1rem;--bs-navbar-brand-font-size:1.25rem;--bs-navbar-brand-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-hover-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-nav-link-padding-x:0.5rem;--bs-navbar-toggler-padding-y:0.25rem;--bs-navbar-toggler-padding-x:0.75rem;--bs-navbar-toggler-font-size:1.25rem;--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%2833, 37, 41, 0.75%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");--bs-navbar-toggler-border-color:rgba(var(--bs-emphasis-color-rgb), 0.15);--bs-navbar-toggler-border-radius:var(--bs-border-radius);--bs-navbar-toggler-focus-width:0.25rem;--bs-navbar-toggler-transition:box-shadow 0.15s ease-in-out;position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding:var(--bs-navbar-padding-y) var(--bs-navbar-padding-x)}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg,.navbar>.container-md,.navbar>.container-sm,.navbar>.container-xl,.navbar>.container-xxl{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:var(--bs-navbar-brand-padding-y);padding-bottom:var(--bs-navbar-brand-padding-y);margin-right:var(--bs-navbar-brand-margin-end);font-size:var(--bs-navbar-brand-font-size);color:var(--bs-navbar-brand-color);text-decoration:none;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{color:var(--bs-navbar-brand-hover-color)}.navbar-nav{--bs-nav-link-padding-x:0;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-navbar-color);--bs-nav-link-hover-color:var(--bs-navbar-hover-color);--bs-nav-link-disabled-color:var(--bs-navbar-disabled-color);display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link.active,.navbar-nav .nav-link.show{color:var(--bs-navbar-active-color)}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-navbar-color)}.navbar-text a,.navbar-text a:focus,.navbar-text a:hover{color:var(--bs-navbar-active-color)}.navbar-collapse{flex-grow:1;flex-basis:100%;align-items:center}.navbar-toggler{padding:var(--bs-navbar-toggler-padding-y) var(--bs-navbar-toggler-padding-x);font-size:var(--bs-navbar-toggler-font-size);line-height:1;color:var(--bs-navbar-color);background-color:transparent;border:var(--bs-border-width) solid var(--bs-navbar-toggler-border-color);border-radius:var(--bs-navbar-toggler-border-radius);transition:var(--bs-navbar-toggler-transition)}@media (prefers-reduced-motion:reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 var(--bs-navbar-toggler-focus-width)}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-image:var(--bs-navbar-toggler-icon-bg);background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height,75vh);overflow-y:auto}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-sm .offcanvas .offcanvas-header{display:none}.navbar-expand-sm .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:768px){.navbar-expand-md{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-md .offcanvas .offcanvas-header{display:none}.navbar-expand-md .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-lg .offcanvas .offcanvas-header{display:none}.navbar-expand-lg .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1200px){.navbar-expand-xl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xl .offcanvas .offcanvas-header{display:none}.navbar-expand-xl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1400px){.navbar-expand-xxl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xxl .offcanvas .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand .offcanvas .offcanvas-header{display:none}.navbar-expand .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}.navbar-dark,.navbar[data-bs-theme=dark]{--bs-navbar-color:rgba(255, 255, 255, 0.55);--bs-navbar-hover-color:rgba(255, 255, 255, 0.75);--bs-navbar-disabled-color:rgba(255, 255, 255, 0.25);--bs-navbar-active-color:#fff;--bs-navbar-brand-color:#fff;--bs-navbar-brand-hover-color:#fff;--bs-navbar-toggler-border-color:rgba(255, 255, 255, 0.1);--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}[data-bs-theme=dark] .navbar-toggler-icon{--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.card{--bs-card-spacer-y:1rem;--bs-card-spacer-x:1rem;--bs-card-title-spacer-y:0.5rem;--bs-card-title-color: ;--bs-card-subtitle-color: ;--bs-card-border-width:var(--bs-border-width);--bs-card-border-color:var(--bs-border-color-translucent);--bs-card-border-radius:var(--bs-border-radius);--bs-card-box-shadow: ;--bs-card-inner-border-radius:calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-card-cap-padding-y:0.5rem;--bs-card-cap-padding-x:1rem;--bs-card-cap-bg:rgba(var(--bs-body-color-rgb), 0.03);--bs-card-cap-color: ;--bs-card-height: ;--bs-card-color: ;--bs-card-bg:var(--bs-body-bg);--bs-card-img-overlay-padding:1rem;--bs-card-group-margin:0.75rem;position:relative;display:flex;flex-direction:column;min-width:0;height:var(--bs-card-height);color:var(--bs-body-color);word-wrap:break-word;background-color:var(--bs-card-bg);background-clip:border-box;border:var(--bs-card-border-width) solid var(--bs-card-border-color);border-radius:var(--bs-card-border-radius)}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;padding:var(--bs-card-spacer-y) var(--bs-card-spacer-x);color:var(--bs-card-color)}.card-title{margin-bottom:var(--bs-card-title-spacer-y);color:var(--bs-card-title-color)}.card-subtitle{margin-top:calc(-.5 * var(--bs-card-title-spacer-y));margin-bottom:0;color:var(--bs-card-subtitle-color)}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:var(--bs-card-spacer-x)}.card-header{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);margin-bottom:0;color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-bottom:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-header:first-child{border-radius:var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius) 0 0}.card-footer{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-top:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-footer:last-child{border-radius:0 0 var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius)}.card-header-tabs{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-bottom:calc(-1 * var(--bs-card-cap-padding-y));margin-left:calc(-.5 * var(--bs-card-cap-padding-x));border-bottom:0}.card-header-tabs .nav-link.active{background-color:var(--bs-card-bg);border-bottom-color:var(--bs-card-bg)}.card-header-pills{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-left:calc(-.5 * var(--bs-card-cap-padding-x))}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:var(--bs-card-img-overlay-padding);border-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom{border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card-group>.card{margin-bottom:var(--bs-card-group-margin)}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child)>.card-header,.card-group>.card:not(:last-child)>.card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child)>.card-footer,.card-group>.card:not(:last-child)>.card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child)>.card-header,.card-group>.card:not(:first-child)>.card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child)>.card-footer,.card-group>.card:not(:first-child)>.card-img-bottom{border-bottom-left-radius:0}}.accordion{--bs-accordion-color:var(--bs-body-color);--bs-accordion-bg:var(--bs-body-bg);--bs-accordion-transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out,border-radius 0.15s ease;--bs-accordion-border-color:var(--bs-border-color);--bs-accordion-border-width:var(--bs-border-width);--bs-accordion-border-radius:var(--bs-border-radius);--bs-accordion-inner-border-radius:calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-accordion-btn-padding-x:1.25rem;--bs-accordion-btn-padding-y:1rem;--bs-accordion-btn-color:var(--bs-body-color);--bs-accordion-btn-bg:var(--bs-accordion-bg);--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23212529' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m2 5 6 6 6-6'/%3e%3c/svg%3e");--bs-accordion-btn-icon-width:1.25rem;--bs-accordion-btn-icon-transform:rotate(-180deg);--bs-accordion-btn-icon-transition:transform 0.2s ease-in-out;--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23052c65' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m2 5 6 6 6-6'/%3e%3c/svg%3e");--bs-accordion-btn-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-accordion-body-padding-x:1.25rem;--bs-accordion-body-padding-y:1rem;--bs-accordion-active-color:var(--bs-primary-text-emphasis);--bs-accordion-active-bg:var(--bs-primary-bg-subtle)}.accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:var(--bs-accordion-btn-padding-y) var(--bs-accordion-btn-padding-x);font-size:1rem;color:var(--bs-accordion-btn-color);text-align:left;background-color:var(--bs-accordion-btn-bg);border:0;border-radius:0;overflow-anchor:none;transition:var(--bs-accordion-transition)}@media (prefers-reduced-motion:reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:var(--bs-accordion-active-color);background-color:var(--bs-accordion-active-bg);box-shadow:inset 0 calc(-1 * var(--bs-accordion-border-width)) 0 var(--bs-accordion-border-color)}.accordion-button:not(.collapsed)::after{background-image:var(--bs-accordion-btn-active-icon);transform:var(--bs-accordion-btn-icon-transform)}.accordion-button::after{flex-shrink:0;width:var(--bs-accordion-btn-icon-width);height:var(--bs-accordion-btn-icon-width);margin-left:auto;content:"";background-image:var(--bs-accordion-btn-icon);background-repeat:no-repeat;background-size:var(--bs-accordion-btn-icon-width);transition:var(--bs-accordion-btn-icon-transition)}@media (prefers-reduced-motion:reduce){.accordion-button::after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;outline:0;box-shadow:var(--bs-accordion-btn-focus-box-shadow)}.accordion-header{margin-bottom:0}.accordion-item{color:var(--bs-accordion-color);background-color:var(--bs-accordion-bg);border:var(--bs-accordion-border-width) solid var(--bs-accordion-border-color)}.accordion-item:first-of-type{border-top-left-radius:var(--bs-accordion-border-radius);border-top-right-radius:var(--bs-accordion-border-radius)}.accordion-item:first-of-type>.accordion-header .accordion-button{border-top-left-radius:var(--bs-accordion-inner-border-radius);border-top-right-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-item:last-of-type>.accordion-header .accordion-button.collapsed{border-bottom-right-radius:var(--bs-accordion-inner-border-radius);border-bottom-left-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:last-of-type>.accordion-collapse{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-body{padding:var(--bs-accordion-body-padding-y) var(--bs-accordion-body-padding-x)}.accordion-flush>.accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush>.accordion-item:first-child{border-top:0}.accordion-flush>.accordion-item:last-child{border-bottom:0}.accordion-flush>.accordion-item>.accordion-collapse,.accordion-flush>.accordion-item>.accordion-header .accordion-button,.accordion-flush>.accordion-item>.accordion-header .accordion-button.collapsed{border-radius:0}[data-bs-theme=dark] .accordion-button::after{--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708'/%3e%3c/svg%3e");--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708'/%3e%3c/svg%3e")}.breadcrumb{--bs-breadcrumb-padding-x:0;--bs-breadcrumb-padding-y:0;--bs-breadcrumb-margin-bottom:1rem;--bs-breadcrumb-bg: ;--bs-breadcrumb-border-radius: ;--bs-breadcrumb-divider-color:var(--bs-secondary-color);--bs-breadcrumb-item-padding-x:0.5rem;--bs-breadcrumb-item-active-color:var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding:var(--bs-breadcrumb-padding-y) var(--bs-breadcrumb-padding-x);margin-bottom:var(--bs-breadcrumb-margin-bottom);font-size:var(--bs-breadcrumb-font-size);list-style:none;background-color:var(--bs-breadcrumb-bg);border-radius:var(--bs-breadcrumb-border-radius)}.breadcrumb-item+.breadcrumb-item{padding-left:var(--bs-breadcrumb-item-padding-x)}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:var(--bs-breadcrumb-item-padding-x);color:var(--bs-breadcrumb-divider-color);content:var(--bs-breadcrumb-divider, "/")}.breadcrumb-item.active{color:var(--bs-breadcrumb-item-active-color)}.pagination{--bs-pagination-padding-x:0.75rem;--bs-pagination-padding-y:0.375rem;--bs-pagination-font-size:1rem;--bs-pagination-color:var(--bs-link-color);--bs-pagination-bg:var(--bs-body-bg);--bs-pagination-border-width:var(--bs-border-width);--bs-pagination-border-color:var(--bs-border-color);--bs-pagination-border-radius:var(--bs-border-radius);--bs-pagination-hover-color:var(--bs-link-hover-color);--bs-pagination-hover-bg:var(--bs-tertiary-bg);--bs-pagination-hover-border-color:var(--bs-border-color);--bs-pagination-focus-color:var(--bs-link-hover-color);--bs-pagination-focus-bg:var(--bs-secondary-bg);--bs-pagination-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-pagination-active-color:#fff;--bs-pagination-active-bg:#0d6efd;--bs-pagination-active-border-color:#0d6efd;--bs-pagination-disabled-color:var(--bs-secondary-color);--bs-pagination-disabled-bg:var(--bs-secondary-bg);--bs-pagination-disabled-border-color:var(--bs-border-color);display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;padding:var(--bs-pagination-padding-y) var(--bs-pagination-padding-x);font-size:var(--bs-pagination-font-size);color:var(--bs-pagination-color);text-decoration:none;background-color:var(--bs-pagination-bg);border:var(--bs-pagination-border-width) solid var(--bs-pagination-border-color);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:var(--bs-pagination-hover-color);background-color:var(--bs-pagination-hover-bg);border-color:var(--bs-pagination-hover-border-color)}.page-link:focus{z-index:3;color:var(--bs-pagination-focus-color);background-color:var(--bs-pagination-focus-bg);outline:0;box-shadow:var(--bs-pagination-focus-box-shadow)}.active>.page-link,.page-link.active{z-index:3;color:var(--bs-pagination-active-color);background-color:var(--bs-pagination-active-bg);border-color:var(--bs-pagination-active-border-color)}.disabled>.page-link,.page-link.disabled{color:var(--bs-pagination-disabled-color);pointer-events:none;background-color:var(--bs-pagination-disabled-bg);border-color:var(--bs-pagination-disabled-border-color)}.page-item:not(:first-child) .page-link{margin-left:calc(-1 * var(--bs-border-width))}.page-item:first-child .page-link{border-top-left-radius:var(--bs-pagination-border-radius);border-bottom-left-radius:var(--bs-pagination-border-radius)}.page-item:last-child .page-link{border-top-right-radius:var(--bs-pagination-border-radius);border-bottom-right-radius:var(--bs-pagination-border-radius)}.pagination-lg{--bs-pagination-padding-x:1.5rem;--bs-pagination-padding-y:0.75rem;--bs-pagination-font-size:1.25rem;--bs-pagination-border-radius:var(--bs-border-radius-lg)}.pagination-sm{--bs-pagination-padding-x:0.5rem;--bs-pagination-padding-y:0.25rem;--bs-pagination-font-size:0.875rem;--bs-pagination-border-radius:var(--bs-border-radius-sm)}.badge{--bs-badge-padding-x:0.65em;--bs-badge-padding-y:0.35em;--bs-badge-font-size:0.75em;--bs-badge-font-weight:700;--bs-badge-color:#fff;--bs-badge-border-radius:var(--bs-border-radius);display:inline-block;padding:var(--bs-badge-padding-y) var(--bs-badge-padding-x);font-size:var(--bs-badge-font-size);font-weight:var(--bs-badge-font-weight);line-height:1;color:var(--bs-badge-color);text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:var(--bs-badge-border-radius)}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{--bs-alert-bg:transparent;--bs-alert-padding-x:1rem;--bs-alert-padding-y:1rem;--bs-alert-margin-bottom:1rem;--bs-alert-color:inherit;--bs-alert-border-color:transparent;--bs-alert-border:var(--bs-border-width) solid var(--bs-alert-border-color);--bs-alert-border-radius:var(--bs-border-radius);--bs-alert-link-color:inherit;position:relative;padding:var(--bs-alert-padding-y) var(--bs-alert-padding-x);margin-bottom:var(--bs-alert-margin-bottom);color:var(--bs-alert-color);background-color:var(--bs-alert-bg);border:var(--bs-alert-border);border-radius:var(--bs-alert-border-radius)}.alert-heading{color:inherit}.alert-link{font-weight:700;color:var(--bs-alert-link-color)}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-primary{--bs-alert-color:var(--bs-primary-text-emphasis);--bs-alert-bg:var(--bs-primary-bg-subtle);--bs-alert-border-color:var(--bs-primary-border-subtle);--bs-alert-link-color:var(--bs-primary-text-emphasis)}.alert-secondary{--bs-alert-color:var(--bs-secondary-text-emphasis);--bs-alert-bg:var(--bs-secondary-bg-subtle);--bs-alert-border-color:var(--bs-secondary-border-subtle);--bs-alert-link-color:var(--bs-secondary-text-emphasis)}.alert-success{--bs-alert-color:var(--bs-success-text-emphasis);--bs-alert-bg:var(--bs-success-bg-subtle);--bs-alert-border-color:var(--bs-success-border-subtle);--bs-alert-link-color:var(--bs-success-text-emphasis)}.alert-info{--bs-alert-color:var(--bs-info-text-emphasis);--bs-alert-bg:var(--bs-info-bg-subtle);--bs-alert-border-color:var(--bs-info-border-subtle);--bs-alert-link-color:var(--bs-info-text-emphasis)}.alert-warning{--bs-alert-color:var(--bs-warning-text-emphasis);--bs-alert-bg:var(--bs-warning-bg-subtle);--bs-alert-border-color:var(--bs-warning-border-subtle);--bs-alert-link-color:var(--bs-warning-text-emphasis)}.alert-danger{--bs-alert-color:var(--bs-danger-text-emphasis);--bs-alert-bg:var(--bs-danger-bg-subtle);--bs-alert-border-color:var(--bs-danger-border-subtle);--bs-alert-link-color:var(--bs-danger-text-emphasis)}.alert-light{--bs-alert-color:var(--bs-light-text-emphasis);--bs-alert-bg:var(--bs-light-bg-subtle);--bs-alert-border-color:var(--bs-light-border-subtle);--bs-alert-link-color:var(--bs-light-text-emphasis)}.alert-dark{--bs-alert-color:var(--bs-dark-text-emphasis);--bs-alert-bg:var(--bs-dark-bg-subtle);--bs-alert-border-color:var(--bs-dark-border-subtle);--bs-alert-link-color:var(--bs-dark-text-emphasis)}@keyframes progress-bar-stripes{0%{background-position-x:var(--bs-progress-height)}}.progress,.progress-stacked{--bs-progress-height:1rem;--bs-progress-font-size:0.75rem;--bs-progress-bg:var(--bs-secondary-bg);--bs-progress-border-radius:var(--bs-border-radius);--bs-progress-box-shadow:var(--bs-box-shadow-inset);--bs-progress-bar-color:#fff;--bs-progress-bar-bg:#0d6efd;--bs-progress-bar-transition:width 0.6s ease;display:flex;height:var(--bs-progress-height);overflow:hidden;font-size:var(--bs-progress-font-size);background-color:var(--bs-progress-bg);border-radius:var(--bs-progress-border-radius)}.progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:var(--bs-progress-bar-color);text-align:center;white-space:nowrap;background-color:var(--bs-progress-bar-bg);transition:var(--bs-progress-bar-transition)}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:var(--bs-progress-height) var(--bs-progress-height)}.progress-stacked>.progress{overflow:visible}.progress-stacked>.progress>.progress-bar{width:100%}.progress-bar-animated{animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion:reduce){.progress-bar-animated{animation:none}}.list-group{--bs-list-group-color:var(--bs-body-color);--bs-list-group-bg:var(--bs-body-bg);--bs-list-group-border-color:var(--bs-border-color);--bs-list-group-border-width:var(--bs-border-width);--bs-list-group-border-radius:var(--bs-border-radius);--bs-list-group-item-padding-x:1rem;--bs-list-group-item-padding-y:0.5rem;--bs-list-group-action-color:var(--bs-secondary-color);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-tertiary-bg);--bs-list-group-action-active-color:var(--bs-body-color);--bs-list-group-action-active-bg:var(--bs-secondary-bg);--bs-list-group-disabled-color:var(--bs-secondary-color);--bs-list-group-disabled-bg:var(--bs-body-bg);--bs-list-group-active-color:#fff;--bs-list-group-active-bg:#0d6efd;--bs-list-group-active-border-color:#0d6efd;display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:var(--bs-list-group-border-radius)}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>.list-group-item::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item{position:relative;display:block;padding:var(--bs-list-group-item-padding-y) var(--bs-list-group-item-padding-x);color:var(--bs-list-group-color);text-decoration:none;background-color:var(--bs-list-group-bg);border:var(--bs-list-group-border-width) solid var(--bs-list-group-border-color)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:var(--bs-list-group-disabled-color);pointer-events:none;background-color:var(--bs-list-group-disabled-bg)}.list-group-item.active{z-index:2;color:var(--bs-list-group-active-color);background-color:var(--bs-list-group-active-bg);border-color:var(--bs-list-group-active-border-color)}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:calc(-1 * var(--bs-list-group-border-width));border-top-width:var(--bs-list-group-border-width)}.list-group-item-action{width:100%;color:var(--bs-list-group-action-color);text-align:inherit}.list-group-item-action:not(.active):focus,.list-group-item-action:not(.active):hover{z-index:1;color:var(--bs-list-group-action-hover-color);text-decoration:none;background-color:var(--bs-list-group-action-hover-bg)}.list-group-item-action:not(.active):active{color:var(--bs-list-group-action-active-color);background-color:var(--bs-list-group-action-active-bg)}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 var(--bs-list-group-border-width)}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{--bs-list-group-color:var(--bs-primary-text-emphasis);--bs-list-group-bg:var(--bs-primary-bg-subtle);--bs-list-group-border-color:var(--bs-primary-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-primary-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-primary-border-subtle);--bs-list-group-active-color:var(--bs-primary-bg-subtle);--bs-list-group-active-bg:var(--bs-primary-text-emphasis);--bs-list-group-active-border-color:var(--bs-primary-text-emphasis)}.list-group-item-secondary{--bs-list-group-color:var(--bs-secondary-text-emphasis);--bs-list-group-bg:var(--bs-secondary-bg-subtle);--bs-list-group-border-color:var(--bs-secondary-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-secondary-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-secondary-border-subtle);--bs-list-group-active-color:var(--bs-secondary-bg-subtle);--bs-list-group-active-bg:var(--bs-secondary-text-emphasis);--bs-list-group-active-border-color:var(--bs-secondary-text-emphasis)}.list-group-item-success{--bs-list-group-color:var(--bs-success-text-emphasis);--bs-list-group-bg:var(--bs-success-bg-subtle);--bs-list-group-border-color:var(--bs-success-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-success-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-success-border-subtle);--bs-list-group-active-color:var(--bs-success-bg-subtle);--bs-list-group-active-bg:var(--bs-success-text-emphasis);--bs-list-group-active-border-color:var(--bs-success-text-emphasis)}.list-group-item-info{--bs-list-group-color:var(--bs-info-text-emphasis);--bs-list-group-bg:var(--bs-info-bg-subtle);--bs-list-group-border-color:var(--bs-info-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-info-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-info-border-subtle);--bs-list-group-active-color:var(--bs-info-bg-subtle);--bs-list-group-active-bg:var(--bs-info-text-emphasis);--bs-list-group-active-border-color:var(--bs-info-text-emphasis)}.list-group-item-warning{--bs-list-group-color:var(--bs-warning-text-emphasis);--bs-list-group-bg:var(--bs-warning-bg-subtle);--bs-list-group-border-color:var(--bs-warning-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-warning-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-warning-border-subtle);--bs-list-group-active-color:var(--bs-warning-bg-subtle);--bs-list-group-active-bg:var(--bs-warning-text-emphasis);--bs-list-group-active-border-color:var(--bs-warning-text-emphasis)}.list-group-item-danger{--bs-list-group-color:var(--bs-danger-text-emphasis);--bs-list-group-bg:var(--bs-danger-bg-subtle);--bs-list-group-border-color:var(--bs-danger-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-danger-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-danger-border-subtle);--bs-list-group-active-color:var(--bs-danger-bg-subtle);--bs-list-group-active-bg:var(--bs-danger-text-emphasis);--bs-list-group-active-border-color:var(--bs-danger-text-emphasis)}.list-group-item-light{--bs-list-group-color:var(--bs-light-text-emphasis);--bs-list-group-bg:var(--bs-light-bg-subtle);--bs-list-group-border-color:var(--bs-light-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-light-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-light-border-subtle);--bs-list-group-active-color:var(--bs-light-bg-subtle);--bs-list-group-active-bg:var(--bs-light-text-emphasis);--bs-list-group-active-border-color:var(--bs-light-text-emphasis)}.list-group-item-dark{--bs-list-group-color:var(--bs-dark-text-emphasis);--bs-list-group-bg:var(--bs-dark-bg-subtle);--bs-list-group-border-color:var(--bs-dark-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-dark-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-dark-border-subtle);--bs-list-group-active-color:var(--bs-dark-bg-subtle);--bs-list-group-active-bg:var(--bs-dark-text-emphasis);--bs-list-group-active-border-color:var(--bs-dark-text-emphasis)}.btn-close{--bs-btn-close-color:#000;--bs-btn-close-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414'/%3e%3c/svg%3e");--bs-btn-close-opacity:0.5;--bs-btn-close-hover-opacity:0.75;--bs-btn-close-focus-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-btn-close-focus-opacity:1;--bs-btn-close-disabled-opacity:0.25;box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:var(--bs-btn-close-color);background:transparent var(--bs-btn-close-bg) center/1em auto no-repeat;filter:var(--bs-btn-close-filter);border:0;border-radius:.375rem;opacity:var(--bs-btn-close-opacity)}.btn-close:hover{color:var(--bs-btn-close-color);text-decoration:none;opacity:var(--bs-btn-close-hover-opacity)}.btn-close:focus{outline:0;box-shadow:var(--bs-btn-close-focus-shadow);opacity:var(--bs-btn-close-focus-opacity)}.btn-close.disabled,.btn-close:disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:var(--bs-btn-close-disabled-opacity)}.btn-close-white{--bs-btn-close-filter:invert(1) grayscale(100%) brightness(200%)}:root,[data-bs-theme=light]{--bs-btn-close-filter: }[data-bs-theme=dark]{--bs-btn-close-filter:invert(1) grayscale(100%) brightness(200%)}.toast{--bs-toast-zindex:1090;--bs-toast-padding-x:0.75rem;--bs-toast-padding-y:0.5rem;--bs-toast-spacing:1.5rem;--bs-toast-max-width:350px;--bs-toast-font-size:0.875rem;--bs-toast-color: ;--bs-toast-bg:rgba(var(--bs-body-bg-rgb), 0.85);--bs-toast-border-width:var(--bs-border-width);--bs-toast-border-color:var(--bs-border-color-translucent);--bs-toast-border-radius:var(--bs-border-radius);--bs-toast-box-shadow:var(--bs-box-shadow);--bs-toast-header-color:var(--bs-secondary-color);--bs-toast-header-bg:rgba(var(--bs-body-bg-rgb), 0.85);--bs-toast-header-border-color:var(--bs-border-color-translucent);width:var(--bs-toast-max-width);max-width:100%;font-size:var(--bs-toast-font-size);color:var(--bs-toast-color);pointer-events:auto;background-color:var(--bs-toast-bg);background-clip:padding-box;border:var(--bs-toast-border-width) solid var(--bs-toast-border-color);box-shadow:var(--bs-toast-box-shadow);border-radius:var(--bs-toast-border-radius)}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{--bs-toast-zindex:1090;position:absolute;z-index:var(--bs-toast-zindex);width:-webkit-max-content;width:-moz-max-content;width:max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:var(--bs-toast-spacing)}.toast-header{display:flex;align-items:center;padding:var(--bs-toast-padding-y) var(--bs-toast-padding-x);color:var(--bs-toast-header-color);background-color:var(--bs-toast-header-bg);background-clip:padding-box;border-bottom:var(--bs-toast-border-width) solid var(--bs-toast-header-border-color);border-top-left-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width));border-top-right-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width))}.toast-header .btn-close{margin-right:calc(-.5 * var(--bs-toast-padding-x));margin-left:var(--bs-toast-padding-x)}.toast-body{padding:var(--bs-toast-padding-x);word-wrap:break-word}.modal{--bs-modal-zindex:1055;--bs-modal-width:500px;--bs-modal-padding:1rem;--bs-modal-margin:0.5rem;--bs-modal-color:var(--bs-body-color);--bs-modal-bg:var(--bs-body-bg);--bs-modal-border-color:var(--bs-border-color-translucent);--bs-modal-border-width:var(--bs-border-width);--bs-modal-border-radius:var(--bs-border-radius-lg);--bs-modal-box-shadow:var(--bs-box-shadow-sm);--bs-modal-inner-border-radius:calc(var(--bs-border-radius-lg) - (var(--bs-border-width)));--bs-modal-header-padding-x:1rem;--bs-modal-header-padding-y:1rem;--bs-modal-header-padding:1rem 1rem;--bs-modal-header-border-color:var(--bs-border-color);--bs-modal-header-border-width:var(--bs-border-width);--bs-modal-title-line-height:1.5;--bs-modal-footer-gap:0.5rem;--bs-modal-footer-bg: ;--bs-modal-footer-border-color:var(--bs-border-color);--bs-modal-footer-border-width:var(--bs-border-width);position:fixed;top:0;left:0;z-index:var(--bs-modal-zindex);display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:var(--bs-modal-margin);pointer-events:none}.modal.fade .modal-dialog{transform:translate(0,-50px);transition:transform .3s ease-out}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - var(--bs-modal-margin) * 2)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - var(--bs-modal-margin) * 2)}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;color:var(--bs-modal-color);pointer-events:auto;background-color:var(--bs-modal-bg);background-clip:padding-box;border:var(--bs-modal-border-width) solid var(--bs-modal-border-color);border-radius:var(--bs-modal-border-radius);outline:0}.modal-backdrop{--bs-backdrop-zindex:1050;--bs-backdrop-bg:#000;--bs-backdrop-opacity:0.5;position:fixed;top:0;left:0;z-index:var(--bs-backdrop-zindex);width:100vw;height:100vh;background-color:var(--bs-backdrop-bg)}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:var(--bs-backdrop-opacity)}.modal-header{display:flex;flex-shrink:0;align-items:center;padding:var(--bs-modal-header-padding);border-bottom:var(--bs-modal-header-border-width) solid var(--bs-modal-header-border-color);border-top-left-radius:var(--bs-modal-inner-border-radius);border-top-right-radius:var(--bs-modal-inner-border-radius)}.modal-header .btn-close{padding:calc(var(--bs-modal-header-padding-y) * .5) calc(var(--bs-modal-header-padding-x) * .5);margin-top:calc(-.5 * var(--bs-modal-header-padding-y));margin-right:calc(-.5 * var(--bs-modal-header-padding-x));margin-bottom:calc(-.5 * var(--bs-modal-header-padding-y));margin-left:auto}.modal-title{margin-bottom:0;line-height:var(--bs-modal-title-line-height)}.modal-body{position:relative;flex:1 1 auto;padding:var(--bs-modal-padding)}.modal-footer{display:flex;flex-shrink:0;flex-wrap:wrap;align-items:center;justify-content:flex-end;padding:calc(var(--bs-modal-padding) - var(--bs-modal-footer-gap) * .5);background-color:var(--bs-modal-footer-bg);border-top:var(--bs-modal-footer-border-width) solid var(--bs-modal-footer-border-color);border-bottom-right-radius:var(--bs-modal-inner-border-radius);border-bottom-left-radius:var(--bs-modal-inner-border-radius)}.modal-footer>*{margin:calc(var(--bs-modal-footer-gap) * .5)}@media (min-width:576px){.modal{--bs-modal-margin:1.75rem;--bs-modal-box-shadow:var(--bs-box-shadow)}.modal-dialog{max-width:var(--bs-modal-width);margin-right:auto;margin-left:auto}.modal-sm{--bs-modal-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{--bs-modal-width:800px}}@media (min-width:1200px){.modal-xl{--bs-modal-width:1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-footer,.modal-fullscreen .modal-header{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}@media (max-width:575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-footer,.modal-fullscreen-sm-down .modal-header{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}}@media (max-width:767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-footer,.modal-fullscreen-md-down .modal-header{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}}@media (max-width:991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-footer,.modal-fullscreen-lg-down .modal-header{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}}@media (max-width:1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-footer,.modal-fullscreen-xl-down .modal-header{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}}@media (max-width:1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-footer,.modal-fullscreen-xxl-down .modal-header{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}}.tooltip{--bs-tooltip-zindex:1080;--bs-tooltip-max-width:200px;--bs-tooltip-padding-x:0.5rem;--bs-tooltip-padding-y:0.25rem;--bs-tooltip-margin: ;--bs-tooltip-font-size:0.875rem;--bs-tooltip-color:var(--bs-body-bg);--bs-tooltip-bg:var(--bs-emphasis-color);--bs-tooltip-border-radius:var(--bs-border-radius);--bs-tooltip-opacity:0.9;--bs-tooltip-arrow-width:0.8rem;--bs-tooltip-arrow-height:0.4rem;z-index:var(--bs-tooltip-zindex);display:block;margin:var(--bs-tooltip-margin);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-tooltip-font-size);word-wrap:break-word;opacity:0}.tooltip.show{opacity:var(--bs-tooltip-opacity)}.tooltip .tooltip-arrow{display:block;width:var(--bs-tooltip-arrow-width);height:var(--bs-tooltip-arrow-height)}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow,.bs-tooltip-top .tooltip-arrow{bottom:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,.bs-tooltip-top .tooltip-arrow::before{top:-1px;border-width:var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-top-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow,.bs-tooltip-end .tooltip-arrow{left:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before,.bs-tooltip-end .tooltip-arrow::before{right:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-right-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow,.bs-tooltip-bottom .tooltip-arrow{top:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before,.bs-tooltip-bottom .tooltip-arrow::before{bottom:-1px;border-width:0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-bottom-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow,.bs-tooltip-start .tooltip-arrow{right:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before,.bs-tooltip-start .tooltip-arrow::before{left:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) 0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-left-color:var(--bs-tooltip-bg)}.tooltip-inner{max-width:var(--bs-tooltip-max-width);padding:var(--bs-tooltip-padding-y) var(--bs-tooltip-padding-x);color:var(--bs-tooltip-color);text-align:center;background-color:var(--bs-tooltip-bg);border-radius:var(--bs-tooltip-border-radius)}.popover{--bs-popover-zindex:1070;--bs-popover-max-width:276px;--bs-popover-font-size:0.875rem;--bs-popover-bg:var(--bs-body-bg);--bs-popover-border-width:var(--bs-border-width);--bs-popover-border-color:var(--bs-border-color-translucent);--bs-popover-border-radius:var(--bs-border-radius-lg);--bs-popover-inner-border-radius:calc(var(--bs-border-radius-lg) - var(--bs-border-width));--bs-popover-box-shadow:var(--bs-box-shadow);--bs-popover-header-padding-x:1rem;--bs-popover-header-padding-y:0.5rem;--bs-popover-header-font-size:1rem;--bs-popover-header-color:inherit;--bs-popover-header-bg:var(--bs-secondary-bg);--bs-popover-body-padding-x:1rem;--bs-popover-body-padding-y:1rem;--bs-popover-body-color:var(--bs-body-color);--bs-popover-arrow-width:1rem;--bs-popover-arrow-height:0.5rem;--bs-popover-arrow-border:var(--bs-popover-border-color);z-index:var(--bs-popover-zindex);display:block;max-width:var(--bs-popover-max-width);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-popover-font-size);word-wrap:break-word;background-color:var(--bs-popover-bg);background-clip:padding-box;border:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-radius:var(--bs-popover-border-radius)}.popover .popover-arrow{display:block;width:var(--bs-popover-arrow-width);height:var(--bs-popover-arrow-height)}.popover .popover-arrow::after,.popover .popover-arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid;border-width:0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow,.bs-popover-top>.popover-arrow{bottom:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::after,.bs-popover-top>.popover-arrow::before{border-width:var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::before{bottom:0;border-top-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-top>.popover-arrow::after{bottom:var(--bs-popover-border-width);border-top-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow,.bs-popover-end>.popover-arrow{left:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::after,.bs-popover-end>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::before{left:0;border-right-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-end>.popover-arrow::after{left:var(--bs-popover-border-width);border-right-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow,.bs-popover-bottom>.popover-arrow{top:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::before{border-width:0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::before{top:0;border-bottom-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::after{top:var(--bs-popover-border-width);border-bottom-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:var(--bs-popover-arrow-width);margin-left:calc(-.5 * var(--bs-popover-arrow-width));content:"";border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-header-bg)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow,.bs-popover-start>.popover-arrow{right:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::after,.bs-popover-start>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) 0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::before{right:0;border-left-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-start>.popover-arrow::after{right:var(--bs-popover-border-width);border-left-color:var(--bs-popover-bg)}.popover-header{padding:var(--bs-popover-header-padding-y) var(--bs-popover-header-padding-x);margin-bottom:0;font-size:var(--bs-popover-header-font-size);color:var(--bs-popover-header-color);background-color:var(--bs-popover-header-bg);border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-top-left-radius:var(--bs-popover-inner-border-radius);border-top-right-radius:var(--bs-popover-inner-border-radius)}.popover-header:empty{display:none}.popover-body{padding:var(--bs-popover-body-padding-y) var(--bs-popover-body-padding-x);color:var(--bs-popover-body-color)}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-end,.carousel-item-next:not(.carousel-item-start){transform:translateX(100%)}.active.carousel-item-start,.carousel-item-prev:not(.carousel-item-end){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:flex;align-items:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:0 0;filter:var(--bs-carousel-control-icon-filter);border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:var(--bs-carousel-indicator-active-bg);background-clip:padding-box;border:0;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:var(--bs-carousel-caption-color);text-align:center}.carousel-dark{--bs-carousel-indicator-active-bg:#000;--bs-carousel-caption-color:#000;--bs-carousel-control-icon-filter:invert(1) grayscale(100)}:root,[data-bs-theme=light]{--bs-carousel-indicator-active-bg:#fff;--bs-carousel-caption-color:#fff;--bs-carousel-control-icon-filter: }[data-bs-theme=dark]{--bs-carousel-indicator-active-bg:#000;--bs-carousel-caption-color:#000;--bs-carousel-control-icon-filter:invert(1) grayscale(100)}.spinner-border,.spinner-grow{display:inline-block;width:var(--bs-spinner-width);height:var(--bs-spinner-height);vertical-align:var(--bs-spinner-vertical-align);border-radius:50%;animation:var(--bs-spinner-animation-speed) linear infinite var(--bs-spinner-animation-name)}@keyframes spinner-border{to{transform:rotate(360deg)}}.spinner-border{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-border-width:0.25em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-border;border:var(--bs-spinner-border-width) solid currentcolor;border-right-color:transparent}.spinner-border-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem;--bs-spinner-border-width:0.2em}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-grow;background-color:currentcolor;opacity:0}.spinner-grow-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{--bs-spinner-animation-speed:1.5s}}.offcanvas,.offcanvas-lg,.offcanvas-md,.offcanvas-sm,.offcanvas-xl,.offcanvas-xxl{--bs-offcanvas-zindex:1045;--bs-offcanvas-width:400px;--bs-offcanvas-height:30vh;--bs-offcanvas-padding-x:1rem;--bs-offcanvas-padding-y:1rem;--bs-offcanvas-color:var(--bs-body-color);--bs-offcanvas-bg:var(--bs-body-bg);--bs-offcanvas-border-width:var(--bs-border-width);--bs-offcanvas-border-color:var(--bs-border-color-translucent);--bs-offcanvas-box-shadow:var(--bs-box-shadow-sm);--bs-offcanvas-transition:transform 0.3s ease-in-out;--bs-offcanvas-title-line-height:1.5}@media (max-width:575.98px){.offcanvas-sm{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:575.98px) and (prefers-reduced-motion:reduce){.offcanvas-sm{transition:none}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-sm.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-sm.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-sm.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-sm.show:not(.hiding),.offcanvas-sm.showing{transform:none}.offcanvas-sm.hiding,.offcanvas-sm.show,.offcanvas-sm.showing{visibility:visible}}@media (min-width:576px){.offcanvas-sm{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-sm .offcanvas-header{display:none}.offcanvas-sm .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:767.98px){.offcanvas-md{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:767.98px) and (prefers-reduced-motion:reduce){.offcanvas-md{transition:none}}@media (max-width:767.98px){.offcanvas-md.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-md.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-md.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-md.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-md.show:not(.hiding),.offcanvas-md.showing{transform:none}.offcanvas-md.hiding,.offcanvas-md.show,.offcanvas-md.showing{visibility:visible}}@media (min-width:768px){.offcanvas-md{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-md .offcanvas-header{display:none}.offcanvas-md .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:991.98px){.offcanvas-lg{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:991.98px) and (prefers-reduced-motion:reduce){.offcanvas-lg{transition:none}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-lg.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-lg.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-lg.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-lg.show:not(.hiding),.offcanvas-lg.showing{transform:none}.offcanvas-lg.hiding,.offcanvas-lg.show,.offcanvas-lg.showing{visibility:visible}}@media (min-width:992px){.offcanvas-lg{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-lg .offcanvas-header{display:none}.offcanvas-lg .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1199.98px){.offcanvas-xl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:1199.98px) and (prefers-reduced-motion:reduce){.offcanvas-xl{transition:none}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xl.show:not(.hiding),.offcanvas-xl.showing{transform:none}.offcanvas-xl.hiding,.offcanvas-xl.show,.offcanvas-xl.showing{visibility:visible}}@media (min-width:1200px){.offcanvas-xl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xl .offcanvas-header{display:none}.offcanvas-xl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1399.98px){.offcanvas-xxl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:1399.98px) and (prefers-reduced-motion:reduce){.offcanvas-xxl{transition:none}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xxl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xxl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xxl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xxl.show:not(.hiding),.offcanvas-xxl.showing{transform:none}.offcanvas-xxl.hiding,.offcanvas-xxl.show,.offcanvas-xxl.showing{visibility:visible}}@media (min-width:1400px){.offcanvas-xxl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xxl .offcanvas-header{display:none}.offcanvas-xxl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}.offcanvas{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}@media (prefers-reduced-motion:reduce){.offcanvas{transition:none}}.offcanvas.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas.show:not(.hiding),.offcanvas.showing{transform:none}.offcanvas.hiding,.offcanvas.show,.offcanvas.showing{visibility:visible}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x)}.offcanvas-header .btn-close{padding:calc(var(--bs-offcanvas-padding-y) * .5) calc(var(--bs-offcanvas-padding-x) * .5);margin-top:calc(-.5 * var(--bs-offcanvas-padding-y));margin-right:calc(-.5 * var(--bs-offcanvas-padding-x));margin-bottom:calc(-.5 * var(--bs-offcanvas-padding-y));margin-left:auto}.offcanvas-title{margin-bottom:0;line-height:var(--bs-offcanvas-title-line-height)}.offcanvas-body{flex-grow:1;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x);overflow-y:auto}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentcolor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{animation:placeholder-glow 2s ease-in-out infinite}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{-webkit-mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);-webkit-mask-size:200% 100%;mask-size:200% 100%;animation:placeholder-wave 2s linear infinite}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.clearfix::after{display:block;clear:both;content:""}.text-bg-primary{color:#fff!important;background-color:RGBA(var(--bs-primary-rgb),var(--bs-bg-opacity,1))!important}.text-bg-secondary{color:#fff!important;background-color:RGBA(var(--bs-secondary-rgb),var(--bs-bg-opacity,1))!important}.text-bg-success{color:#fff!important;background-color:RGBA(var(--bs-success-rgb),var(--bs-bg-opacity,1))!important}.text-bg-info{color:#000!important;background-color:RGBA(var(--bs-info-rgb),var(--bs-bg-opacity,1))!important}.text-bg-warning{color:#000!important;background-color:RGBA(var(--bs-warning-rgb),var(--bs-bg-opacity,1))!important}.text-bg-danger{color:#fff!important;background-color:RGBA(var(--bs-danger-rgb),var(--bs-bg-opacity,1))!important}.text-bg-light{color:#000!important;background-color:RGBA(var(--bs-light-rgb),var(--bs-bg-opacity,1))!important}.text-bg-dark{color:#fff!important;background-color:RGBA(var(--bs-dark-rgb),var(--bs-bg-opacity,1))!important}.link-primary{color:RGBA(var(--bs-primary-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity,1))!important}.link-primary:focus,.link-primary:hover{color:RGBA(10,88,202,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(10,88,202,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(10,88,202,var(--bs-link-underline-opacity,1))!important}.link-secondary{color:RGBA(var(--bs-secondary-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity,1))!important}.link-secondary:focus,.link-secondary:hover{color:RGBA(86,94,100,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(86,94,100,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(86,94,100,var(--bs-link-underline-opacity,1))!important}.link-success{color:RGBA(var(--bs-success-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity,1))!important}.link-success:focus,.link-success:hover{color:RGBA(20,108,67,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity,1))!important}.link-info{color:RGBA(var(--bs-info-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity,1))!important}.link-info:focus,.link-info:hover{color:RGBA(61,213,243,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(61,213,243,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(61,213,243,var(--bs-link-underline-opacity,1))!important}.link-warning{color:RGBA(var(--bs-warning-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity,1))!important}.link-warning:focus,.link-warning:hover{color:RGBA(255,205,57,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity,1))!important}.link-danger{color:RGBA(var(--bs-danger-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity,1))!important}.link-danger:focus,.link-danger:hover{color:RGBA(176,42,55,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity,1))!important}.link-light{color:RGBA(var(--bs-light-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity,1))!important}.link-light:focus,.link-light:hover{color:RGBA(249,250,251,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity,1))!important}.link-dark{color:RGBA(var(--bs-dark-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity,1))!important}.link-dark:focus,.link-dark:hover{color:RGBA(26,30,33,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity,1))!important}.link-body-emphasis{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-body-emphasis:focus,.link-body-emphasis:hover{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,.75))!important;-webkit-text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,0.75))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,0.75))!important}.focus-ring:focus{outline:0;box-shadow:var(--bs-focus-ring-x,0) var(--bs-focus-ring-y,0) var(--bs-focus-ring-blur,0) var(--bs-focus-ring-width) var(--bs-focus-ring-color)}.icon-link{display:inline-flex;gap:.375rem;align-items:center;-webkit-text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,0.5));text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,0.5));text-underline-offset:0.25em;-webkit-backface-visibility:hidden;backface-visibility:hidden}.icon-link>.bi{flex-shrink:0;width:1em;height:1em;fill:currentcolor;transition:.2s ease-in-out transform}@media (prefers-reduced-motion:reduce){.icon-link>.bi{transition:none}}.icon-link-hover:focus-visible>.bi,.icon-link-hover:hover>.bi{transform:var(--bs-icon-link-transform,translate3d(.25em,0,0))}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio:100%}.ratio-4x3{--bs-aspect-ratio:75%}.ratio-16x9{--bs-aspect-ratio:56.25%}.ratio-21x9{--bs-aspect-ratio:42.8571428571%}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-sm-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-md-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-lg-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xxl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.visually-hidden-focusable:not(:focus):not(:focus-within):not(caption),.visually-hidden:not(caption){position:absolute!important}.visually-hidden *,.visually-hidden-focusable:not(:focus):not(:focus-within) *{overflow:hidden!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:var(--bs-border-width);min-height:1em;background-color:currentcolor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.object-fit-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-none{-o-object-fit:none!important;object-fit:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.overflow-x-auto{overflow-x:auto!important}.overflow-x-hidden{overflow-x:hidden!important}.overflow-x-visible{overflow-x:visible!important}.overflow-x-scroll{overflow-x:scroll!important}.overflow-y-auto{overflow-y:auto!important}.overflow-y-hidden{overflow-y:hidden!important}.overflow-y-visible{overflow-y:visible!important}.overflow-y-scroll{overflow-y:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-inline-grid{display:inline-grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.shadow{box-shadow:var(--bs-box-shadow)!important}.shadow-sm{box-shadow:var(--bs-box-shadow-sm)!important}.shadow-lg{box-shadow:var(--bs-box-shadow-lg)!important}.shadow-none{box-shadow:none!important}.focus-ring-primary{--bs-focus-ring-color:rgba(var(--bs-primary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-secondary{--bs-focus-ring-color:rgba(var(--bs-secondary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-success{--bs-focus-ring-color:rgba(var(--bs-success-rgb), var(--bs-focus-ring-opacity))}.focus-ring-info{--bs-focus-ring-color:rgba(var(--bs-info-rgb), var(--bs-focus-ring-opacity))}.focus-ring-warning{--bs-focus-ring-color:rgba(var(--bs-warning-rgb), var(--bs-focus-ring-opacity))}.focus-ring-danger{--bs-focus-ring-color:rgba(var(--bs-danger-rgb), var(--bs-focus-ring-opacity))}.focus-ring-light{--bs-focus-ring-color:rgba(var(--bs-light-rgb), var(--bs-focus-ring-opacity))}.focus-ring-dark{--bs-focus-ring-color:rgba(var(--bs-dark-rgb), var(--bs-focus-ring-opacity))}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translateX(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-0{border:0!important}.border-top{border-top:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-top-0{border-top:0!important}.border-end{border-right:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-start-0{border-left:0!important}.border-primary{--bs-border-opacity:1;border-color:rgba(var(--bs-primary-rgb),var(--bs-border-opacity))!important}.border-secondary{--bs-border-opacity:1;border-color:rgba(var(--bs-secondary-rgb),var(--bs-border-opacity))!important}.border-success{--bs-border-opacity:1;border-color:rgba(var(--bs-success-rgb),var(--bs-border-opacity))!important}.border-info{--bs-border-opacity:1;border-color:rgba(var(--bs-info-rgb),var(--bs-border-opacity))!important}.border-warning{--bs-border-opacity:1;border-color:rgba(var(--bs-warning-rgb),var(--bs-border-opacity))!important}.border-danger{--bs-border-opacity:1;border-color:rgba(var(--bs-danger-rgb),var(--bs-border-opacity))!important}.border-light{--bs-border-opacity:1;border-color:rgba(var(--bs-light-rgb),var(--bs-border-opacity))!important}.border-dark{--bs-border-opacity:1;border-color:rgba(var(--bs-dark-rgb),var(--bs-border-opacity))!important}.border-black{--bs-border-opacity:1;border-color:rgba(var(--bs-black-rgb),var(--bs-border-opacity))!important}.border-white{--bs-border-opacity:1;border-color:rgba(var(--bs-white-rgb),var(--bs-border-opacity))!important}.border-primary-subtle{border-color:var(--bs-primary-border-subtle)!important}.border-secondary-subtle{border-color:var(--bs-secondary-border-subtle)!important}.border-success-subtle{border-color:var(--bs-success-border-subtle)!important}.border-info-subtle{border-color:var(--bs-info-border-subtle)!important}.border-warning-subtle{border-color:var(--bs-warning-border-subtle)!important}.border-danger-subtle{border-color:var(--bs-danger-border-subtle)!important}.border-light-subtle{border-color:var(--bs-light-border-subtle)!important}.border-dark-subtle{border-color:var(--bs-dark-border-subtle)!important}.border-1{border-width:1px!important}.border-2{border-width:2px!important}.border-3{border-width:3px!important}.border-4{border-width:4px!important}.border-5{border-width:5px!important}.border-opacity-10{--bs-border-opacity:0.1}.border-opacity-25{--bs-border-opacity:0.25}.border-opacity-50{--bs-border-opacity:0.5}.border-opacity-75{--bs-border-opacity:0.75}.border-opacity-100{--bs-border-opacity:1}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}.row-gap-0{row-gap:0!important}.row-gap-1{row-gap:.25rem!important}.row-gap-2{row-gap:.5rem!important}.row-gap-3{row-gap:1rem!important}.row-gap-4{row-gap:1.5rem!important}.row-gap-5{row-gap:3rem!important}.column-gap-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.375rem + 1.5vw)!important}.fs-2{font-size:calc(1.325rem + .9vw)!important}.fs-3{font-size:calc(1.3rem + .6vw)!important}.fs-4{font-size:calc(1.275rem + .3vw)!important}.fs-5{font-size:1.25rem!important}.fs-6{font-size:1rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-lighter{font-weight:lighter!important}.fw-light{font-weight:300!important}.fw-normal{font-weight:400!important}.fw-medium{font-weight:500!important}.fw-semibold{font-weight:600!important}.fw-bold{font-weight:700!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--bs-text-opacity:1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity:1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity:1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity:1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-color-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-black-50{--bs-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--bs-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-body-secondary{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-body-tertiary{--bs-text-opacity:1;color:var(--bs-tertiary-color)!important}.text-body-emphasis{--bs-text-opacity:1;color:var(--bs-emphasis-color)!important}.text-reset{--bs-text-opacity:1;color:inherit!important}.text-opacity-25{--bs-text-opacity:0.25}.text-opacity-50{--bs-text-opacity:0.5}.text-opacity-75{--bs-text-opacity:0.75}.text-opacity-100{--bs-text-opacity:1}.text-primary-emphasis{color:var(--bs-primary-text-emphasis)!important}.text-secondary-emphasis{color:var(--bs-secondary-text-emphasis)!important}.text-success-emphasis{color:var(--bs-success-text-emphasis)!important}.text-info-emphasis{color:var(--bs-info-text-emphasis)!important}.text-warning-emphasis{color:var(--bs-warning-text-emphasis)!important}.text-danger-emphasis{color:var(--bs-danger-text-emphasis)!important}.text-light-emphasis{color:var(--bs-light-text-emphasis)!important}.text-dark-emphasis{color:var(--bs-dark-text-emphasis)!important}.link-opacity-10{--bs-link-opacity:0.1}.link-opacity-10-hover:hover{--bs-link-opacity:0.1}.link-opacity-25{--bs-link-opacity:0.25}.link-opacity-25-hover:hover{--bs-link-opacity:0.25}.link-opacity-50{--bs-link-opacity:0.5}.link-opacity-50-hover:hover{--bs-link-opacity:0.5}.link-opacity-75{--bs-link-opacity:0.75}.link-opacity-75-hover:hover{--bs-link-opacity:0.75}.link-opacity-100{--bs-link-opacity:1}.link-opacity-100-hover:hover{--bs-link-opacity:1}.link-offset-1{text-underline-offset:0.125em!important}.link-offset-1-hover:hover{text-underline-offset:0.125em!important}.link-offset-2{text-underline-offset:0.25em!important}.link-offset-2-hover:hover{text-underline-offset:0.25em!important}.link-offset-3{text-underline-offset:0.375em!important}.link-offset-3-hover:hover{text-underline-offset:0.375em!important}.link-underline-primary{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-secondary{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-success{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important}.link-underline-info{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important}.link-underline-warning{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important}.link-underline-danger{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important}.link-underline-light{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important}.link-underline-dark{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important}.link-underline{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-underline-opacity-0{--bs-link-underline-opacity:0}.link-underline-opacity-0-hover:hover{--bs-link-underline-opacity:0}.link-underline-opacity-10{--bs-link-underline-opacity:0.1}.link-underline-opacity-10-hover:hover{--bs-link-underline-opacity:0.1}.link-underline-opacity-25{--bs-link-underline-opacity:0.25}.link-underline-opacity-25-hover:hover{--bs-link-underline-opacity:0.25}.link-underline-opacity-50{--bs-link-underline-opacity:0.5}.link-underline-opacity-50-hover:hover{--bs-link-underline-opacity:0.5}.link-underline-opacity-75{--bs-link-underline-opacity:0.75}.link-underline-opacity-75-hover:hover{--bs-link-underline-opacity:0.75}.link-underline-opacity-100{--bs-link-underline-opacity:1}.link-underline-opacity-100-hover:hover{--bs-link-underline-opacity:1}.bg-primary{--bs-bg-opacity:1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity:1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity:1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-bg-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important}.bg-body-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-bg-rgb),var(--bs-bg-opacity))!important}.bg-body-tertiary{--bs-bg-opacity:1;background-color:rgba(var(--bs-tertiary-bg-rgb),var(--bs-bg-opacity))!important}.bg-opacity-10{--bs-bg-opacity:0.1}.bg-opacity-25{--bs-bg-opacity:0.25}.bg-opacity-50{--bs-bg-opacity:0.5}.bg-opacity-75{--bs-bg-opacity:0.75}.bg-opacity-100{--bs-bg-opacity:1}.bg-primary-subtle{background-color:var(--bs-primary-bg-subtle)!important}.bg-secondary-subtle{background-color:var(--bs-secondary-bg-subtle)!important}.bg-success-subtle{background-color:var(--bs-success-bg-subtle)!important}.bg-info-subtle{background-color:var(--bs-info-bg-subtle)!important}.bg-warning-subtle{background-color:var(--bs-warning-bg-subtle)!important}.bg-danger-subtle{background-color:var(--bs-danger-bg-subtle)!important}.bg-light-subtle{background-color:var(--bs-light-bg-subtle)!important}.bg-dark-subtle{background-color:var(--bs-dark-bg-subtle)!important}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:var(--bs-border-radius)!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:var(--bs-border-radius-sm)!important}.rounded-2{border-radius:var(--bs-border-radius)!important}.rounded-3{border-radius:var(--bs-border-radius-lg)!important}.rounded-4{border-radius:var(--bs-border-radius-xl)!important}.rounded-5{border-radius:var(--bs-border-radius-xxl)!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:var(--bs-border-radius-pill)!important}.rounded-top{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-0{border-top-left-radius:0!important;border-top-right-radius:0!important}.rounded-top-1{border-top-left-radius:var(--bs-border-radius-sm)!important;border-top-right-radius:var(--bs-border-radius-sm)!important}.rounded-top-2{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-3{border-top-left-radius:var(--bs-border-radius-lg)!important;border-top-right-radius:var(--bs-border-radius-lg)!important}.rounded-top-4{border-top-left-radius:var(--bs-border-radius-xl)!important;border-top-right-radius:var(--bs-border-radius-xl)!important}.rounded-top-5{border-top-left-radius:var(--bs-border-radius-xxl)!important;border-top-right-radius:var(--bs-border-radius-xxl)!important}.rounded-top-circle{border-top-left-radius:50%!important;border-top-right-radius:50%!important}.rounded-top-pill{border-top-left-radius:var(--bs-border-radius-pill)!important;border-top-right-radius:var(--bs-border-radius-pill)!important}.rounded-end{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-0{border-top-right-radius:0!important;border-bottom-right-radius:0!important}.rounded-end-1{border-top-right-radius:var(--bs-border-radius-sm)!important;border-bottom-right-radius:var(--bs-border-radius-sm)!important}.rounded-end-2{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-3{border-top-right-radius:var(--bs-border-radius-lg)!important;border-bottom-right-radius:var(--bs-border-radius-lg)!important}.rounded-end-4{border-top-right-radius:var(--bs-border-radius-xl)!important;border-bottom-right-radius:var(--bs-border-radius-xl)!important}.rounded-end-5{border-top-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-right-radius:var(--bs-border-radius-xxl)!important}.rounded-end-circle{border-top-right-radius:50%!important;border-bottom-right-radius:50%!important}.rounded-end-pill{border-top-right-radius:var(--bs-border-radius-pill)!important;border-bottom-right-radius:var(--bs-border-radius-pill)!important}.rounded-bottom{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-0{border-bottom-right-radius:0!important;border-bottom-left-radius:0!important}.rounded-bottom-1{border-bottom-right-radius:var(--bs-border-radius-sm)!important;border-bottom-left-radius:var(--bs-border-radius-sm)!important}.rounded-bottom-2{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-3{border-bottom-right-radius:var(--bs-border-radius-lg)!important;border-bottom-left-radius:var(--bs-border-radius-lg)!important}.rounded-bottom-4{border-bottom-right-radius:var(--bs-border-radius-xl)!important;border-bottom-left-radius:var(--bs-border-radius-xl)!important}.rounded-bottom-5{border-bottom-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-left-radius:var(--bs-border-radius-xxl)!important}.rounded-bottom-circle{border-bottom-right-radius:50%!important;border-bottom-left-radius:50%!important}.rounded-bottom-pill{border-bottom-right-radius:var(--bs-border-radius-pill)!important;border-bottom-left-radius:var(--bs-border-radius-pill)!important}.rounded-start{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-0{border-bottom-left-radius:0!important;border-top-left-radius:0!important}.rounded-start-1{border-bottom-left-radius:var(--bs-border-radius-sm)!important;border-top-left-radius:var(--bs-border-radius-sm)!important}.rounded-start-2{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-3{border-bottom-left-radius:var(--bs-border-radius-lg)!important;border-top-left-radius:var(--bs-border-radius-lg)!important}.rounded-start-4{border-bottom-left-radius:var(--bs-border-radius-xl)!important;border-top-left-radius:var(--bs-border-radius-xl)!important}.rounded-start-5{border-bottom-left-radius:var(--bs-border-radius-xxl)!important;border-top-left-radius:var(--bs-border-radius-xxl)!important}.rounded-start-circle{border-bottom-left-radius:50%!important;border-top-left-radius:50%!important}.rounded-start-pill{border-bottom-left-radius:var(--bs-border-radius-pill)!important;border-top-left-radius:var(--bs-border-radius-pill)!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}.z-n1{z-index:-1!important}.z-0{z-index:0!important}.z-1{z-index:1!important}.z-2{z-index:2!important}.z-3{z-index:3!important}@media (min-width:576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.object-fit-sm-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-sm-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-sm-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-sm-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-sm-none{-o-object-fit:none!important;object-fit:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-inline-grid{display:inline-grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!important}.row-gap-sm-0{row-gap:0!important}.row-gap-sm-1{row-gap:.25rem!important}.row-gap-sm-2{row-gap:.5rem!important}.row-gap-sm-3{row-gap:1rem!important}.row-gap-sm-4{row-gap:1.5rem!important}.row-gap-sm-5{row-gap:3rem!important}.column-gap-sm-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-sm-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-sm-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-sm-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-sm-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-sm-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.object-fit-md-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-md-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-md-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-md-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-md-none{-o-object-fit:none!important;object-fit:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-inline-grid{display:inline-grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!important}.row-gap-md-0{row-gap:0!important}.row-gap-md-1{row-gap:.25rem!important}.row-gap-md-2{row-gap:.5rem!important}.row-gap-md-3{row-gap:1rem!important}.row-gap-md-4{row-gap:1.5rem!important}.row-gap-md-5{row-gap:3rem!important}.column-gap-md-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-md-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-md-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-md-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-md-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-md-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.object-fit-lg-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-lg-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-lg-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-lg-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-lg-none{-o-object-fit:none!important;object-fit:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-inline-grid{display:inline-grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!important}.row-gap-lg-0{row-gap:0!important}.row-gap-lg-1{row-gap:.25rem!important}.row-gap-lg-2{row-gap:.5rem!important}.row-gap-lg-3{row-gap:1rem!important}.row-gap-lg-4{row-gap:1.5rem!important}.row-gap-lg-5{row-gap:3rem!important}.column-gap-lg-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-lg-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-lg-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-lg-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-lg-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-lg-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.object-fit-xl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xl-none{-o-object-fit:none!important;object-fit:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-inline-grid{display:inline-grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!important}.row-gap-xl-0{row-gap:0!important}.row-gap-xl-1{row-gap:.25rem!important}.row-gap-xl-2{row-gap:.5rem!important}.row-gap-xl-3{row-gap:1rem!important}.row-gap-xl-4{row-gap:1.5rem!important}.row-gap-xl-5{row-gap:3rem!important}.column-gap-xl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}}@media (min-width:1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.object-fit-xxl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xxl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xxl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xxl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xxl-none{-o-object-fit:none!important;object-fit:none!important}.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-inline-grid{display:inline-grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!important}.row-gap-xxl-0{row-gap:0!important}.row-gap-xxl-1{row-gap:.25rem!important}.row-gap-xxl-2{row-gap:.5rem!important}.row-gap-xxl-3{row-gap:1rem!important}.row-gap-xxl-4{row-gap:1.5rem!important}.row-gap-xxl-5{row-gap:3rem!important}.column-gap-xxl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xxl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xxl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xxl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xxl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xxl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}}@media (min-width:1200px){.fs-1{font-size:2.5rem!important}.fs-2{font-size:2rem!important}.fs-3{font-size:1.75rem!important}.fs-4{font-size:1.5rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-inline-grid{display:inline-grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}} \ No newline at end of file diff --git a/apps/web/public/wesp/css/font-awesome.min.css b/apps/web/public/wesp/css/font-awesome.min.css new file mode 100644 index 0000000..0a2cf52 --- /dev/null +++ b/apps/web/public/wesp/css/font-awesome.min.css @@ -0,0 +1,6 @@ +/*! + * Font Awesome Free 6.0.0 by @fontawesome - https://fontawesome.com + * License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) + * Copyright 2022 Fonticons, Inc. + */ +.fa{font-family:var(--fa-style-family,"Font Awesome 6 Free");font-weight:var(--fa-style,900)}.fa,.fa-brands,.fa-duotone,.fa-light,.fa-regular,.fa-solid,.fa-thin,.fab,.fad,.fal,.far,.fas,.fat{-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased;display:var(--fa-display,inline-block);font-style:normal;font-variant:normal;line-height:1;text-rendering:auto}.fa-1x{font-size:1em}.fa-2x{font-size:2em}.fa-3x{font-size:3em}.fa-4x{font-size:4em}.fa-5x{font-size:5em}.fa-6x{font-size:6em}.fa-7x{font-size:7em}.fa-8x{font-size:8em}.fa-9x{font-size:9em}.fa-10x{font-size:10em}.fa-2xs{font-size:.625em;line-height:.1em;vertical-align:.225em}.fa-xs{font-size:.75em;line-height:.08333em;vertical-align:.125em}.fa-sm{font-size:.875em;line-height:.07143em;vertical-align:.05357em}.fa-lg{font-size:1.25em;line-height:.05em;vertical-align:-.075em}.fa-xl{font-size:1.5em;line-height:.04167em;vertical-align:-.125em}.fa-2xl{font-size:2em;line-height:.03125em;vertical-align:-.1875em}.fa-fw{text-align:center;width:1.25em}.fa-ul{list-style-type:none;margin-left:var(--fa-li-margin,2.5em);padding-left:0}.fa-ul>li{position:relative}.fa-li{left:calc(var(--fa-li-width, 2em)*-1);position:absolute;text-align:center;width:var(--fa-li-width,2em);line-height:inherit}.fa-border{border-radius:var(--fa-border-radius,.1em);border:var(--fa-border-width,.08em) var(--fa-border-style,solid) var(--fa-border-color,#eee);padding:var(--fa-border-padding,.2em .25em .15em)}.fa-pull-left{float:left;margin-right:var(--fa-pull-margin,.3em)}.fa-pull-right{float:right;margin-left:var(--fa-pull-margin,.3em)}.fa-beat{-webkit-animation-name:fa-beat;animation-name:fa-beat;-webkit-animation-delay:var(--fa-animation-delay,0);animation-delay:var(--fa-animation-delay,0);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,ease-in-out);animation-timing-function:var(--fa-animation-timing,ease-in-out)}.fa-bounce{-webkit-animation-name:fa-bounce;animation-name:fa-bounce;-webkit-animation-delay:var(--fa-animation-delay,0);animation-delay:var(--fa-animation-delay,0);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,cubic-bezier(.28,.84,.42,1));animation-timing-function:var(--fa-animation-timing,cubic-bezier(.28,.84,.42,1))}.fa-fade{-webkit-animation-name:fa-fade;animation-name:fa-fade;-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1));animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1))}.fa-beat-fade,.fa-fade{-webkit-animation-delay:var(--fa-animation-delay,0);animation-delay:var(--fa-animation-delay,0);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s)}.fa-beat-fade{-webkit-animation-name:fa-beat-fade;animation-name:fa-beat-fade;-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1));animation-timing-function:var(--fa-animation-timing,cubic-bezier(.4,0,.6,1))}.fa-flip{-webkit-animation-name:fa-flip;animation-name:fa-flip;-webkit-animation-delay:var(--fa-animation-delay,0);animation-delay:var(--fa-animation-delay,0);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,ease-in-out);animation-timing-function:var(--fa-animation-timing,ease-in-out)}.fa-shake{-webkit-animation-name:fa-shake;animation-name:fa-shake;-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,linear);animation-timing-function:var(--fa-animation-timing,linear)}.fa-shake,.fa-spin{-webkit-animation-delay:var(--fa-animation-delay,0);animation-delay:var(--fa-animation-delay,0);-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal)}.fa-spin{-webkit-animation-name:fa-spin;animation-name:fa-spin;-webkit-animation-duration:var(--fa-animation-duration,2s);animation-duration:var(--fa-animation-duration,2s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,linear);animation-timing-function:var(--fa-animation-timing,linear)}.fa-spin-reverse{--fa-animation-direction:reverse}.fa-pulse,.fa-spin-pulse{-webkit-animation-name:fa-spin;animation-name:fa-spin;-webkit-animation-direction:var(--fa-animation-direction,normal);animation-direction:var(--fa-animation-direction,normal);-webkit-animation-duration:var(--fa-animation-duration,1s);animation-duration:var(--fa-animation-duration,1s);-webkit-animation-iteration-count:var(--fa-animation-iteration-count,infinite);animation-iteration-count:var(--fa-animation-iteration-count,infinite);-webkit-animation-timing-function:var(--fa-animation-timing,steps(8));animation-timing-function:var(--fa-animation-timing,steps(8))}@media (prefers-reduced-motion:reduce){.fa-beat,.fa-beat-fade,.fa-bounce,.fa-fade,.fa-flip,.fa-pulse,.fa-shake,.fa-spin,.fa-spin-pulse{-webkit-animation-delay:-1ms;animation-delay:-1ms;-webkit-animation-duration:1ms;animation-duration:1ms;-webkit-animation-iteration-count:1;animation-iteration-count:1;transition-delay:0s;transition-duration:0s}}@-webkit-keyframes fa-beat{0%,90%{-webkit-transform:scale(1);transform:scale(1)}45%{-webkit-transform:scale(var(--fa-beat-scale,1.25));transform:scale(var(--fa-beat-scale,1.25))}}@keyframes fa-beat{0%,90%{-webkit-transform:scale(1);transform:scale(1)}45%{-webkit-transform:scale(var(--fa-beat-scale,1.25));transform:scale(var(--fa-beat-scale,1.25))}}@-webkit-keyframes fa-bounce{0%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}10%{-webkit-transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0);transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0)}30%{-webkit-transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em));transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em))}50%{-webkit-transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0);transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0)}57%{-webkit-transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em));transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em))}64%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}to{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}}@keyframes fa-bounce{0%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}10%{-webkit-transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0);transform:scale(var(--fa-bounce-start-scale-x,1.1),var(--fa-bounce-start-scale-y,.9)) translateY(0)}30%{-webkit-transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em));transform:scale(var(--fa-bounce-jump-scale-x,.9),var(--fa-bounce-jump-scale-y,1.1)) translateY(var(--fa-bounce-height,-.5em))}50%{-webkit-transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0);transform:scale(var(--fa-bounce-land-scale-x,1.05),var(--fa-bounce-land-scale-y,.95)) translateY(0)}57%{-webkit-transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em));transform:scale(1) translateY(var(--fa-bounce-rebound,-.125em))}64%{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}to{-webkit-transform:scale(1) translateY(0);transform:scale(1) translateY(0)}}@-webkit-keyframes fa-fade{50%{opacity:var(--fa-fade-opacity,.4)}}@keyframes fa-fade{50%{opacity:var(--fa-fade-opacity,.4)}}@-webkit-keyframes fa-beat-fade{0%,to{opacity:var(--fa-beat-fade-opacity,.4);-webkit-transform:scale(1);transform:scale(1)}50%{opacity:1;-webkit-transform:scale(var(--fa-beat-fade-scale,1.125));transform:scale(var(--fa-beat-fade-scale,1.125))}}@keyframes fa-beat-fade{0%,to{opacity:var(--fa-beat-fade-opacity,.4);-webkit-transform:scale(1);transform:scale(1)}50%{opacity:1;-webkit-transform:scale(var(--fa-beat-fade-scale,1.125));transform:scale(var(--fa-beat-fade-scale,1.125))}}@-webkit-keyframes fa-flip{50%{-webkit-transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg));transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg))}}@keyframes fa-flip{50%{-webkit-transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg));transform:rotate3d(var(--fa-flip-x,0),var(--fa-flip-y,1),var(--fa-flip-z,0),var(--fa-flip-angle,-180deg))}}@-webkit-keyframes fa-shake{0%{-webkit-transform:rotate(-15deg);transform:rotate(-15deg)}4%{-webkit-transform:rotate(15deg);transform:rotate(15deg)}8%,24%{-webkit-transform:rotate(-18deg);transform:rotate(-18deg)}12%,28%{-webkit-transform:rotate(18deg);transform:rotate(18deg)}16%{-webkit-transform:rotate(-22deg);transform:rotate(-22deg)}20%{-webkit-transform:rotate(22deg);transform:rotate(22deg)}32%{-webkit-transform:rotate(-12deg);transform:rotate(-12deg)}36%{-webkit-transform:rotate(12deg);transform:rotate(12deg)}40%,to{-webkit-transform:rotate(0deg);transform:rotate(0deg)}}@keyframes fa-shake{0%{-webkit-transform:rotate(-15deg);transform:rotate(-15deg)}4%{-webkit-transform:rotate(15deg);transform:rotate(15deg)}8%,24%{-webkit-transform:rotate(-18deg);transform:rotate(-18deg)}12%,28%{-webkit-transform:rotate(18deg);transform:rotate(18deg)}16%{-webkit-transform:rotate(-22deg);transform:rotate(-22deg)}20%{-webkit-transform:rotate(22deg);transform:rotate(22deg)}32%{-webkit-transform:rotate(-12deg);transform:rotate(-12deg)}36%{-webkit-transform:rotate(12deg);transform:rotate(12deg)}40%,to{-webkit-transform:rotate(0deg);transform:rotate(0deg)}}@-webkit-keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}@keyframes fa-spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}.fa-rotate-90{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.fa-rotate-180{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.fa-rotate-270{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.fa-flip-horizontal{-webkit-transform:scaleX(-1);transform:scaleX(-1)}.fa-flip-vertical{-webkit-transform:scaleY(-1);transform:scaleY(-1)}.fa-flip-both,.fa-flip-horizontal.fa-flip-vertical{-webkit-transform:scale(-1);transform:scale(-1)}.fa-rotate-by{-webkit-transform:rotate(var(--fa-rotate-angle,none));transform:rotate(var(--fa-rotate-angle,none))}.fa-stack{display:inline-block;height:2em;line-height:2em;position:relative;vertical-align:middle;width:2.5em}.fa-stack-1x,.fa-stack-2x{left:0;position:absolute;text-align:center;width:100%;z-index:var(--fa-stack-z-index,auto)}.fa-stack-1x{line-height:inherit}.fa-stack-2x{font-size:2em}.fa-inverse{color:var(--fa-inverse,#fff)}.fa-0:before{content:"\30"}.fa-1:before{content:"\31"}.fa-2:before{content:"\32"}.fa-3:before{content:"\33"}.fa-4:before{content:"\34"}.fa-5:before{content:"\35"}.fa-6:before{content:"\36"}.fa-7:before{content:"\37"}.fa-8:before{content:"\38"}.fa-9:before{content:"\39"}.fa-a:before{content:"\41"}.fa-address-book:before,.fa-contact-book:before{content:"\f2b9"}.fa-address-card:before,.fa-contact-card:before,.fa-vcard:before{content:"\f2bb"}.fa-align-center:before{content:"\f037"}.fa-align-justify:before{content:"\f039"}.fa-align-left:before{content:"\f036"}.fa-align-right:before{content:"\f038"}.fa-anchor:before{content:"\f13d"}.fa-angle-down:before{content:"\f107"}.fa-angle-left:before{content:"\f104"}.fa-angle-right:before{content:"\f105"}.fa-angle-up:before{content:"\f106"}.fa-angle-double-down:before,.fa-angles-down:before{content:"\f103"}.fa-angle-double-left:before,.fa-angles-left:before{content:"\f100"}.fa-angle-double-right:before,.fa-angles-right:before{content:"\f101"}.fa-angle-double-up:before,.fa-angles-up:before{content:"\f102"}.fa-ankh:before{content:"\f644"}.fa-apple-alt:before,.fa-apple-whole:before{content:"\f5d1"}.fa-archway:before{content:"\f557"}.fa-arrow-down:before{content:"\f063"}.fa-arrow-down-1-9:before,.fa-sort-numeric-asc:before,.fa-sort-numeric-down:before{content:"\f162"}.fa-arrow-down-9-1:before,.fa-sort-numeric-desc:before,.fa-sort-numeric-down-alt:before{content:"\f886"}.fa-arrow-down-a-z:before,.fa-sort-alpha-asc:before,.fa-sort-alpha-down:before{content:"\f15d"}.fa-arrow-down-long:before,.fa-long-arrow-down:before{content:"\f175"}.fa-arrow-down-short-wide:before,.fa-sort-amount-desc:before,.fa-sort-amount-down-alt:before{content:"\f884"}.fa-arrow-down-wide-short:before,.fa-sort-amount-asc:before,.fa-sort-amount-down:before{content:"\f160"}.fa-arrow-down-z-a:before,.fa-sort-alpha-desc:before,.fa-sort-alpha-down-alt:before{content:"\f881"}.fa-arrow-left:before{content:"\f060"}.fa-arrow-left-long:before,.fa-long-arrow-left:before{content:"\f177"}.fa-arrow-pointer:before,.fa-mouse-pointer:before{content:"\f245"}.fa-arrow-right:before{content:"\f061"}.fa-arrow-right-arrow-left:before,.fa-exchange:before{content:"\f0ec"}.fa-arrow-right-from-bracket:before,.fa-sign-out:before{content:"\f08b"}.fa-arrow-right-long:before,.fa-long-arrow-right:before{content:"\f178"}.fa-arrow-right-to-bracket:before,.fa-sign-in:before{content:"\f090"}.fa-arrow-left-rotate:before,.fa-arrow-rotate-back:before,.fa-arrow-rotate-backward:before,.fa-arrow-rotate-left:before,.fa-undo:before{content:"\f0e2"}.fa-arrow-right-rotate:before,.fa-arrow-rotate-forward:before,.fa-arrow-rotate-right:before,.fa-redo:before{content:"\f01e"}.fa-arrow-trend-down:before{content:"\e097"}.fa-arrow-trend-up:before{content:"\e098"}.fa-arrow-turn-down:before,.fa-level-down:before{content:"\f149"}.fa-arrow-turn-up:before,.fa-level-up:before{content:"\f148"}.fa-arrow-up:before{content:"\f062"}.fa-arrow-up-1-9:before,.fa-sort-numeric-up:before{content:"\f163"}.fa-arrow-up-9-1:before,.fa-sort-numeric-up-alt:before{content:"\f887"}.fa-arrow-up-a-z:before,.fa-sort-alpha-up:before{content:"\f15e"}.fa-arrow-up-from-bracket:before{content:"\e09a"}.fa-arrow-up-long:before,.fa-long-arrow-up:before{content:"\f176"}.fa-arrow-up-right-from-square:before,.fa-external-link:before{content:"\f08e"}.fa-arrow-up-short-wide:before,.fa-sort-amount-up-alt:before{content:"\f885"}.fa-arrow-up-wide-short:before,.fa-sort-amount-up:before{content:"\f161"}.fa-arrow-up-z-a:before,.fa-sort-alpha-up-alt:before{content:"\f882"}.fa-arrows-h:before,.fa-arrows-left-right:before{content:"\f07e"}.fa-arrows-rotate:before,.fa-refresh:before,.fa-sync:before{content:"\f021"}.fa-arrows-up-down:before,.fa-arrows-v:before{content:"\f07d"}.fa-arrows-up-down-left-right:before,.fa-arrows:before{content:"\f047"}.fa-asterisk:before{content:"\2a"}.fa-at:before{content:"\40"}.fa-atom:before{content:"\f5d2"}.fa-audio-description:before{content:"\f29e"}.fa-austral-sign:before{content:"\e0a9"}.fa-award:before{content:"\f559"}.fa-b:before{content:"\42"}.fa-baby:before{content:"\f77c"}.fa-baby-carriage:before,.fa-carriage-baby:before{content:"\f77d"}.fa-backward:before{content:"\f04a"}.fa-backward-fast:before,.fa-fast-backward:before{content:"\f049"}.fa-backward-step:before,.fa-step-backward:before{content:"\f048"}.fa-bacon:before{content:"\f7e5"}.fa-bacteria:before{content:"\e059"}.fa-bacterium:before{content:"\e05a"}.fa-bag-shopping:before,.fa-shopping-bag:before{content:"\f290"}.fa-bahai:before{content:"\f666"}.fa-baht-sign:before{content:"\e0ac"}.fa-ban:before,.fa-cancel:before{content:"\f05e"}.fa-ban-smoking:before,.fa-smoking-ban:before{content:"\f54d"}.fa-band-aid:before,.fa-bandage:before{content:"\f462"}.fa-barcode:before{content:"\f02a"}.fa-bars:before,.fa-navicon:before{content:"\f0c9"}.fa-bars-progress:before,.fa-tasks-alt:before{content:"\f828"}.fa-bars-staggered:before,.fa-reorder:before,.fa-stream:before{content:"\f550"}.fa-baseball-ball:before,.fa-baseball:before{content:"\f433"}.fa-baseball-bat-ball:before{content:"\f432"}.fa-basket-shopping:before,.fa-shopping-basket:before{content:"\f291"}.fa-basketball-ball:before,.fa-basketball:before{content:"\f434"}.fa-bath:before,.fa-bathtub:before{content:"\f2cd"}.fa-battery-0:before,.fa-battery-empty:before{content:"\f244"}.fa-battery-5:before,.fa-battery-full:before,.fa-battery:before{content:"\f240"}.fa-battery-3:before,.fa-battery-half:before{content:"\f242"}.fa-battery-2:before,.fa-battery-quarter:before{content:"\f243"}.fa-battery-4:before,.fa-battery-three-quarters:before{content:"\f241"}.fa-bed:before{content:"\f236"}.fa-bed-pulse:before,.fa-procedures:before{content:"\f487"}.fa-beer-mug-empty:before,.fa-beer:before{content:"\f0fc"}.fa-bell:before{content:"\f0f3"}.fa-bell-concierge:before,.fa-concierge-bell:before{content:"\f562"}.fa-bell-slash:before{content:"\f1f6"}.fa-bezier-curve:before{content:"\f55b"}.fa-bicycle:before{content:"\f206"}.fa-binoculars:before{content:"\f1e5"}.fa-biohazard:before{content:"\f780"}.fa-bitcoin-sign:before{content:"\e0b4"}.fa-blender:before{content:"\f517"}.fa-blender-phone:before{content:"\f6b6"}.fa-blog:before{content:"\f781"}.fa-bold:before{content:"\f032"}.fa-bolt:before,.fa-zap:before{content:"\f0e7"}.fa-bolt-lightning:before{content:"\e0b7"}.fa-bomb:before{content:"\f1e2"}.fa-bone:before{content:"\f5d7"}.fa-bong:before{content:"\f55c"}.fa-book:before{content:"\f02d"}.fa-atlas:before,.fa-book-atlas:before{content:"\f558"}.fa-bible:before,.fa-book-bible:before{content:"\f647"}.fa-book-journal-whills:before,.fa-journal-whills:before{content:"\f66a"}.fa-book-medical:before{content:"\f7e6"}.fa-book-open:before{content:"\f518"}.fa-book-open-reader:before,.fa-book-reader:before{content:"\f5da"}.fa-book-quran:before,.fa-quran:before{content:"\f687"}.fa-book-dead:before,.fa-book-skull:before{content:"\f6b7"}.fa-bookmark:before{content:"\f02e"}.fa-border-all:before{content:"\f84c"}.fa-border-none:before{content:"\f850"}.fa-border-style:before,.fa-border-top-left:before{content:"\f853"}.fa-bowling-ball:before{content:"\f436"}.fa-box:before{content:"\f466"}.fa-archive:before,.fa-box-archive:before{content:"\f187"}.fa-box-open:before{content:"\f49e"}.fa-box-tissue:before{content:"\e05b"}.fa-boxes-alt:before,.fa-boxes-stacked:before,.fa-boxes:before{content:"\f468"}.fa-braille:before{content:"\f2a1"}.fa-brain:before{content:"\f5dc"}.fa-brazilian-real-sign:before{content:"\e46c"}.fa-bread-slice:before{content:"\f7ec"}.fa-briefcase:before{content:"\f0b1"}.fa-briefcase-medical:before{content:"\f469"}.fa-broom:before{content:"\f51a"}.fa-broom-ball:before,.fa-quidditch-broom-ball:before,.fa-quidditch:before{content:"\f458"}.fa-brush:before{content:"\f55d"}.fa-bug:before{content:"\f188"}.fa-bug-slash:before{content:"\e490"}.fa-building:before{content:"\f1ad"}.fa-bank:before,.fa-building-columns:before,.fa-institution:before,.fa-museum:before,.fa-university:before{content:"\f19c"}.fa-bullhorn:before{content:"\f0a1"}.fa-bullseye:before{content:"\f140"}.fa-burger:before,.fa-hamburger:before{content:"\f805"}.fa-bus:before{content:"\f207"}.fa-bus-alt:before,.fa-bus-simple:before{content:"\f55e"}.fa-briefcase-clock:before,.fa-business-time:before{content:"\f64a"}.fa-c:before{content:"\43"}.fa-birthday-cake:before,.fa-cake-candles:before,.fa-cake:before{content:"\f1fd"}.fa-calculator:before{content:"\f1ec"}.fa-calendar:before{content:"\f133"}.fa-calendar-check:before{content:"\f274"}.fa-calendar-day:before{content:"\f783"}.fa-calendar-alt:before,.fa-calendar-days:before{content:"\f073"}.fa-calendar-minus:before{content:"\f272"}.fa-calendar-plus:before{content:"\f271"}.fa-calendar-week:before{content:"\f784"}.fa-calendar-times:before,.fa-calendar-xmark:before{content:"\f273"}.fa-camera-alt:before,.fa-camera:before{content:"\f030"}.fa-camera-retro:before{content:"\f083"}.fa-camera-rotate:before{content:"\e0d8"}.fa-campground:before{content:"\f6bb"}.fa-candy-cane:before{content:"\f786"}.fa-cannabis:before{content:"\f55f"}.fa-capsules:before{content:"\f46b"}.fa-automobile:before,.fa-car:before{content:"\f1b9"}.fa-battery-car:before,.fa-car-battery:before{content:"\f5df"}.fa-car-crash:before{content:"\f5e1"}.fa-car-alt:before,.fa-car-rear:before{content:"\f5de"}.fa-car-side:before{content:"\f5e4"}.fa-caravan:before{content:"\f8ff"}.fa-caret-down:before{content:"\f0d7"}.fa-caret-left:before{content:"\f0d9"}.fa-caret-right:before{content:"\f0da"}.fa-caret-up:before{content:"\f0d8"}.fa-carrot:before{content:"\f787"}.fa-cart-arrow-down:before{content:"\f218"}.fa-cart-flatbed:before,.fa-dolly-flatbed:before{content:"\f474"}.fa-cart-flatbed-suitcase:before,.fa-luggage-cart:before{content:"\f59d"}.fa-cart-plus:before{content:"\f217"}.fa-cart-shopping:before,.fa-shopping-cart:before{content:"\f07a"}.fa-cash-register:before{content:"\f788"}.fa-cat:before{content:"\f6be"}.fa-cedi-sign:before{content:"\e0df"}.fa-cent-sign:before{content:"\e3f5"}.fa-certificate:before{content:"\f0a3"}.fa-chair:before{content:"\f6c0"}.fa-blackboard:before,.fa-chalkboard:before{content:"\f51b"}.fa-chalkboard-teacher:before,.fa-chalkboard-user:before{content:"\f51c"}.fa-champagne-glasses:before,.fa-glass-cheers:before{content:"\f79f"}.fa-charging-station:before{content:"\f5e7"}.fa-area-chart:before,.fa-chart-area:before{content:"\f1fe"}.fa-bar-chart:before,.fa-chart-bar:before{content:"\f080"}.fa-chart-column:before{content:"\e0e3"}.fa-chart-gantt:before{content:"\e0e4"}.fa-chart-line:before,.fa-line-chart:before{content:"\f201"}.fa-chart-pie:before,.fa-pie-chart:before{content:"\f200"}.fa-check:before{content:"\f00c"}.fa-check-double:before{content:"\f560"}.fa-check-to-slot:before,.fa-vote-yea:before{content:"\f772"}.fa-cheese:before{content:"\f7ef"}.fa-chess:before{content:"\f439"}.fa-chess-bishop:before{content:"\f43a"}.fa-chess-board:before{content:"\f43c"}.fa-chess-king:before{content:"\f43f"}.fa-chess-knight:before{content:"\f441"}.fa-chess-pawn:before{content:"\f443"}.fa-chess-queen:before{content:"\f445"}.fa-chess-rook:before{content:"\f447"}.fa-chevron-down:before{content:"\f078"}.fa-chevron-left:before{content:"\f053"}.fa-chevron-right:before{content:"\f054"}.fa-chevron-up:before{content:"\f077"}.fa-child:before{content:"\f1ae"}.fa-church:before{content:"\f51d"}.fa-circle:before{content:"\f111"}.fa-arrow-circle-down:before,.fa-circle-arrow-down:before{content:"\f0ab"}.fa-arrow-circle-left:before,.fa-circle-arrow-left:before{content:"\f0a8"}.fa-arrow-circle-right:before,.fa-circle-arrow-right:before{content:"\f0a9"}.fa-arrow-circle-up:before,.fa-circle-arrow-up:before{content:"\f0aa"}.fa-check-circle:before,.fa-circle-check:before{content:"\f058"}.fa-chevron-circle-down:before,.fa-circle-chevron-down:before{content:"\f13a"}.fa-chevron-circle-left:before,.fa-circle-chevron-left:before{content:"\f137"}.fa-chevron-circle-right:before,.fa-circle-chevron-right:before{content:"\f138"}.fa-chevron-circle-up:before,.fa-circle-chevron-up:before{content:"\f139"}.fa-circle-dollar-to-slot:before,.fa-donate:before{content:"\f4b9"}.fa-circle-dot:before,.fa-dot-circle:before{content:"\f192"}.fa-arrow-alt-circle-down:before,.fa-circle-down:before{content:"\f358"}.fa-circle-exclamation:before,.fa-exclamation-circle:before{content:"\f06a"}.fa-circle-h:before,.fa-hospital-symbol:before{content:"\f47e"}.fa-adjust:before,.fa-circle-half-stroke:before{content:"\f042"}.fa-circle-info:before,.fa-info-circle:before{content:"\f05a"}.fa-arrow-alt-circle-left:before,.fa-circle-left:before{content:"\f359"}.fa-circle-minus:before,.fa-minus-circle:before{content:"\f056"}.fa-circle-notch:before{content:"\f1ce"}.fa-circle-pause:before,.fa-pause-circle:before{content:"\f28b"}.fa-circle-play:before,.fa-play-circle:before{content:"\f144"}.fa-circle-plus:before,.fa-plus-circle:before{content:"\f055"}.fa-circle-question:before,.fa-question-circle:before{content:"\f059"}.fa-circle-radiation:before,.fa-radiation-alt:before{content:"\f7ba"}.fa-arrow-alt-circle-right:before,.fa-circle-right:before{content:"\f35a"}.fa-circle-stop:before,.fa-stop-circle:before{content:"\f28d"}.fa-arrow-alt-circle-up:before,.fa-circle-up:before{content:"\f35b"}.fa-circle-user:before,.fa-user-circle:before{content:"\f2bd"}.fa-circle-xmark:before,.fa-times-circle:before,.fa-xmark-circle:before{content:"\f057"}.fa-city:before{content:"\f64f"}.fa-clapperboard:before{content:"\e131"}.fa-clipboard:before{content:"\f328"}.fa-clipboard-check:before{content:"\f46c"}.fa-clipboard-list:before{content:"\f46d"}.fa-clock-four:before,.fa-clock:before{content:"\f017"}.fa-clock-rotate-left:before,.fa-history:before{content:"\f1da"}.fa-clone:before{content:"\f24d"}.fa-closed-captioning:before{content:"\f20a"}.fa-cloud:before{content:"\f0c2"}.fa-cloud-arrow-down:before,.fa-cloud-download-alt:before,.fa-cloud-download:before{content:"\f0ed"}.fa-cloud-arrow-up:before,.fa-cloud-upload-alt:before,.fa-cloud-upload:before{content:"\f0ee"}.fa-cloud-meatball:before{content:"\f73b"}.fa-cloud-moon:before{content:"\f6c3"}.fa-cloud-moon-rain:before{content:"\f73c"}.fa-cloud-rain:before{content:"\f73d"}.fa-cloud-showers-heavy:before{content:"\f740"}.fa-cloud-sun:before{content:"\f6c4"}.fa-cloud-sun-rain:before{content:"\f743"}.fa-clover:before{content:"\e139"}.fa-code:before{content:"\f121"}.fa-code-branch:before{content:"\f126"}.fa-code-commit:before{content:"\f386"}.fa-code-compare:before{content:"\e13a"}.fa-code-fork:before{content:"\e13b"}.fa-code-merge:before{content:"\f387"}.fa-code-pull-request:before{content:"\e13c"}.fa-coins:before{content:"\f51e"}.fa-colon-sign:before{content:"\e140"}.fa-comment:before{content:"\f075"}.fa-comment-dollar:before{content:"\f651"}.fa-comment-dots:before,.fa-commenting:before{content:"\f4ad"}.fa-comment-medical:before{content:"\f7f5"}.fa-comment-slash:before{content:"\f4b3"}.fa-comment-sms:before,.fa-sms:before{content:"\f7cd"}.fa-comments:before{content:"\f086"}.fa-comments-dollar:before{content:"\f653"}.fa-compact-disc:before{content:"\f51f"}.fa-compass:before{content:"\f14e"}.fa-compass-drafting:before,.fa-drafting-compass:before{content:"\f568"}.fa-compress:before{content:"\f066"}.fa-computer-mouse:before,.fa-mouse:before{content:"\f8cc"}.fa-cookie:before{content:"\f563"}.fa-cookie-bite:before{content:"\f564"}.fa-copy:before{content:"\f0c5"}.fa-copyright:before{content:"\f1f9"}.fa-couch:before{content:"\f4b8"}.fa-credit-card-alt:before,.fa-credit-card:before{content:"\f09d"}.fa-crop:before{content:"\f125"}.fa-crop-alt:before,.fa-crop-simple:before{content:"\f565"}.fa-cross:before{content:"\f654"}.fa-crosshairs:before{content:"\f05b"}.fa-crow:before{content:"\f520"}.fa-crown:before{content:"\f521"}.fa-crutch:before{content:"\f7f7"}.fa-cruzeiro-sign:before{content:"\e152"}.fa-cube:before{content:"\f1b2"}.fa-cubes:before{content:"\f1b3"}.fa-d:before{content:"\44"}.fa-database:before{content:"\f1c0"}.fa-backspace:before,.fa-delete-left:before{content:"\f55a"}.fa-democrat:before{content:"\f747"}.fa-desktop-alt:before,.fa-desktop:before{content:"\f390"}.fa-dharmachakra:before{content:"\f655"}.fa-diagram-next:before{content:"\e476"}.fa-diagram-predecessor:before{content:"\e477"}.fa-diagram-project:before,.fa-project-diagram:before{content:"\f542"}.fa-diagram-successor:before{content:"\e47a"}.fa-diamond:before{content:"\f219"}.fa-diamond-turn-right:before,.fa-directions:before{content:"\f5eb"}.fa-dice:before{content:"\f522"}.fa-dice-d20:before{content:"\f6cf"}.fa-dice-d6:before{content:"\f6d1"}.fa-dice-five:before{content:"\f523"}.fa-dice-four:before{content:"\f524"}.fa-dice-one:before{content:"\f525"}.fa-dice-six:before{content:"\f526"}.fa-dice-three:before{content:"\f527"}.fa-dice-two:before{content:"\f528"}.fa-disease:before{content:"\f7fa"}.fa-divide:before{content:"\f529"}.fa-dna:before{content:"\f471"}.fa-dog:before{content:"\f6d3"}.fa-dollar-sign:before,.fa-dollar:before,.fa-usd:before{content:"\24"}.fa-dolly-box:before,.fa-dolly:before{content:"\f472"}.fa-dong-sign:before{content:"\e169"}.fa-door-closed:before{content:"\f52a"}.fa-door-open:before{content:"\f52b"}.fa-dove:before{content:"\f4ba"}.fa-compress-alt:before,.fa-down-left-and-up-right-to-center:before{content:"\f422"}.fa-down-long:before,.fa-long-arrow-alt-down:before{content:"\f309"}.fa-download:before{content:"\f019"}.fa-dragon:before{content:"\f6d5"}.fa-draw-polygon:before{content:"\f5ee"}.fa-droplet:before,.fa-tint:before{content:"\f043"}.fa-droplet-slash:before,.fa-tint-slash:before{content:"\f5c7"}.fa-drum:before{content:"\f569"}.fa-drum-steelpan:before{content:"\f56a"}.fa-drumstick-bite:before{content:"\f6d7"}.fa-dumbbell:before{content:"\f44b"}.fa-dumpster:before{content:"\f793"}.fa-dumpster-fire:before{content:"\f794"}.fa-dungeon:before{content:"\f6d9"}.fa-e:before{content:"\45"}.fa-deaf:before,.fa-deafness:before,.fa-ear-deaf:before,.fa-hard-of-hearing:before{content:"\f2a4"}.fa-assistive-listening-systems:before,.fa-ear-listen:before{content:"\f2a2"}.fa-earth-africa:before,.fa-globe-africa:before{content:"\f57c"}.fa-earth-america:before,.fa-earth-americas:before,.fa-earth:before,.fa-globe-americas:before{content:"\f57d"}.fa-earth-asia:before,.fa-globe-asia:before{content:"\f57e"}.fa-earth-europe:before,.fa-globe-europe:before{content:"\f7a2"}.fa-earth-oceania:before,.fa-globe-oceania:before{content:"\e47b"}.fa-egg:before{content:"\f7fb"}.fa-eject:before{content:"\f052"}.fa-elevator:before{content:"\e16d"}.fa-ellipsis-h:before,.fa-ellipsis:before{content:"\f141"}.fa-ellipsis-v:before,.fa-ellipsis-vertical:before{content:"\f142"}.fa-envelope:before{content:"\f0e0"}.fa-envelope-open:before{content:"\f2b6"}.fa-envelope-open-text:before{content:"\f658"}.fa-envelopes-bulk:before,.fa-mail-bulk:before{content:"\f674"}.fa-equals:before{content:"\3d"}.fa-eraser:before{content:"\f12d"}.fa-ethernet:before{content:"\f796"}.fa-eur:before,.fa-euro-sign:before,.fa-euro:before{content:"\f153"}.fa-exclamation:before{content:"\21"}.fa-expand:before{content:"\f065"}.fa-eye:before{content:"\f06e"}.fa-eye-dropper-empty:before,.fa-eye-dropper:before,.fa-eyedropper:before{content:"\f1fb"}.fa-eye-low-vision:before,.fa-low-vision:before{content:"\f2a8"}.fa-eye-slash:before{content:"\f070"}.fa-f:before{content:"\46"}.fa-angry:before,.fa-face-angry:before{content:"\f556"}.fa-dizzy:before,.fa-face-dizzy:before{content:"\f567"}.fa-face-flushed:before,.fa-flushed:before{content:"\f579"}.fa-face-frown:before,.fa-frown:before{content:"\f119"}.fa-face-frown-open:before,.fa-frown-open:before{content:"\f57a"}.fa-face-grimace:before,.fa-grimace:before{content:"\f57f"}.fa-face-grin:before,.fa-grin:before{content:"\f580"}.fa-face-grin-beam:before,.fa-grin-beam:before{content:"\f582"}.fa-face-grin-beam-sweat:before,.fa-grin-beam-sweat:before{content:"\f583"}.fa-face-grin-hearts:before,.fa-grin-hearts:before{content:"\f584"}.fa-face-grin-squint:before,.fa-grin-squint:before{content:"\f585"}.fa-face-grin-squint-tears:before,.fa-grin-squint-tears:before{content:"\f586"}.fa-face-grin-stars:before,.fa-grin-stars:before{content:"\f587"}.fa-face-grin-tears:before,.fa-grin-tears:before{content:"\f588"}.fa-face-grin-tongue:before,.fa-grin-tongue:before{content:"\f589"}.fa-face-grin-tongue-squint:before,.fa-grin-tongue-squint:before{content:"\f58a"}.fa-face-grin-tongue-wink:before,.fa-grin-tongue-wink:before{content:"\f58b"}.fa-face-grin-wide:before,.fa-grin-alt:before{content:"\f581"}.fa-face-grin-wink:before,.fa-grin-wink:before{content:"\f58c"}.fa-face-kiss:before,.fa-kiss:before{content:"\f596"}.fa-face-kiss-beam:before,.fa-kiss-beam:before{content:"\f597"}.fa-face-kiss-wink-heart:before,.fa-kiss-wink-heart:before{content:"\f598"}.fa-face-laugh:before,.fa-laugh:before{content:"\f599"}.fa-face-laugh-beam:before,.fa-laugh-beam:before{content:"\f59a"}.fa-face-laugh-squint:before,.fa-laugh-squint:before{content:"\f59b"}.fa-face-laugh-wink:before,.fa-laugh-wink:before{content:"\f59c"}.fa-face-meh:before,.fa-meh:before{content:"\f11a"}.fa-face-meh-blank:before,.fa-meh-blank:before{content:"\f5a4"}.fa-face-rolling-eyes:before,.fa-meh-rolling-eyes:before{content:"\f5a5"}.fa-face-sad-cry:before,.fa-sad-cry:before{content:"\f5b3"}.fa-face-sad-tear:before,.fa-sad-tear:before{content:"\f5b4"}.fa-face-smile:before,.fa-smile:before{content:"\f118"}.fa-face-smile-beam:before,.fa-smile-beam:before{content:"\f5b8"}.fa-face-smile-wink:before,.fa-smile-wink:before{content:"\f4da"}.fa-face-surprise:before,.fa-surprise:before{content:"\f5c2"}.fa-face-tired:before,.fa-tired:before{content:"\f5c8"}.fa-fan:before{content:"\f863"}.fa-faucet:before{content:"\e005"}.fa-fax:before{content:"\f1ac"}.fa-feather:before{content:"\f52d"}.fa-feather-alt:before,.fa-feather-pointed:before{content:"\f56b"}.fa-file:before{content:"\f15b"}.fa-file-arrow-down:before,.fa-file-download:before{content:"\f56d"}.fa-file-arrow-up:before,.fa-file-upload:before{content:"\f574"}.fa-file-audio:before{content:"\f1c7"}.fa-file-code:before{content:"\f1c9"}.fa-file-contract:before{content:"\f56c"}.fa-file-csv:before{content:"\f6dd"}.fa-file-excel:before{content:"\f1c3"}.fa-arrow-right-from-file:before,.fa-file-export:before{content:"\f56e"}.fa-file-image:before{content:"\f1c5"}.fa-arrow-right-to-file:before,.fa-file-import:before{content:"\f56f"}.fa-file-invoice:before{content:"\f570"}.fa-file-invoice-dollar:before{content:"\f571"}.fa-file-alt:before,.fa-file-lines:before,.fa-file-text:before{content:"\f15c"}.fa-file-medical:before{content:"\f477"}.fa-file-pdf:before{content:"\f1c1"}.fa-file-powerpoint:before{content:"\f1c4"}.fa-file-prescription:before{content:"\f572"}.fa-file-signature:before{content:"\f573"}.fa-file-video:before{content:"\f1c8"}.fa-file-medical-alt:before,.fa-file-waveform:before{content:"\f478"}.fa-file-word:before{content:"\f1c2"}.fa-file-archive:before,.fa-file-zipper:before{content:"\f1c6"}.fa-fill:before{content:"\f575"}.fa-fill-drip:before{content:"\f576"}.fa-film:before{content:"\f008"}.fa-filter:before{content:"\f0b0"}.fa-filter-circle-dollar:before,.fa-funnel-dollar:before{content:"\f662"}.fa-filter-circle-xmark:before{content:"\e17b"}.fa-fingerprint:before{content:"\f577"}.fa-fire:before{content:"\f06d"}.fa-fire-extinguisher:before{content:"\f134"}.fa-fire-alt:before,.fa-fire-flame-curved:before{content:"\f7e4"}.fa-burn:before,.fa-fire-flame-simple:before{content:"\f46a"}.fa-fish:before{content:"\f578"}.fa-flag:before{content:"\f024"}.fa-flag-checkered:before{content:"\f11e"}.fa-flag-usa:before{content:"\f74d"}.fa-flask:before{content:"\f0c3"}.fa-floppy-disk:before,.fa-save:before{content:"\f0c7"}.fa-florin-sign:before{content:"\e184"}.fa-folder:before{content:"\f07b"}.fa-folder-minus:before{content:"\f65d"}.fa-folder-open:before{content:"\f07c"}.fa-folder-plus:before{content:"\f65e"}.fa-folder-tree:before{content:"\f802"}.fa-font:before{content:"\f031"}.fa-football-ball:before,.fa-football:before{content:"\f44e"}.fa-forward:before{content:"\f04e"}.fa-fast-forward:before,.fa-forward-fast:before{content:"\f050"}.fa-forward-step:before,.fa-step-forward:before{content:"\f051"}.fa-franc-sign:before{content:"\e18f"}.fa-frog:before{content:"\f52e"}.fa-futbol-ball:before,.fa-futbol:before,.fa-soccer-ball:before{content:"\f1e3"}.fa-g:before{content:"\47"}.fa-gamepad:before{content:"\f11b"}.fa-gas-pump:before{content:"\f52f"}.fa-dashboard:before,.fa-gauge-med:before,.fa-gauge:before,.fa-tachometer-alt-average:before{content:"\f624"}.fa-gauge-high:before,.fa-tachometer-alt-fast:before,.fa-tachometer-alt:before{content:"\f625"}.fa-gauge-simple-med:before,.fa-gauge-simple:before,.fa-tachometer-average:before{content:"\f629"}.fa-gauge-simple-high:before,.fa-tachometer-fast:before,.fa-tachometer:before{content:"\f62a"}.fa-gavel:before,.fa-legal:before{content:"\f0e3"}.fa-cog:before,.fa-gear:before{content:"\f013"}.fa-cogs:before,.fa-gears:before{content:"\f085"}.fa-gem:before{content:"\f3a5"}.fa-genderless:before{content:"\f22d"}.fa-ghost:before{content:"\f6e2"}.fa-gift:before{content:"\f06b"}.fa-gifts:before{content:"\f79c"}.fa-glasses:before{content:"\f530"}.fa-globe:before{content:"\f0ac"}.fa-golf-ball-tee:before,.fa-golf-ball:before{content:"\f450"}.fa-gopuram:before{content:"\f664"}.fa-graduation-cap:before,.fa-mortar-board:before{content:"\f19d"}.fa-greater-than:before{content:"\3e"}.fa-greater-than-equal:before{content:"\f532"}.fa-grip-horizontal:before,.fa-grip:before{content:"\f58d"}.fa-grip-lines:before{content:"\f7a4"}.fa-grip-lines-vertical:before{content:"\f7a5"}.fa-grip-vertical:before{content:"\f58e"}.fa-guarani-sign:before{content:"\e19a"}.fa-guitar:before{content:"\f7a6"}.fa-gun:before{content:"\e19b"}.fa-h:before{content:"\48"}.fa-hammer:before{content:"\f6e3"}.fa-hamsa:before{content:"\f665"}.fa-hand-paper:before,.fa-hand:before{content:"\f256"}.fa-hand-back-fist:before,.fa-hand-rock:before{content:"\f255"}.fa-allergies:before,.fa-hand-dots:before{content:"\f461"}.fa-fist-raised:before,.fa-hand-fist:before{content:"\f6de"}.fa-hand-holding:before{content:"\f4bd"}.fa-hand-holding-dollar:before,.fa-hand-holding-usd:before{content:"\f4c0"}.fa-hand-holding-droplet:before,.fa-hand-holding-water:before{content:"\f4c1"}.fa-hand-holding-heart:before{content:"\f4be"}.fa-hand-holding-medical:before{content:"\e05c"}.fa-hand-lizard:before{content:"\f258"}.fa-hand-middle-finger:before{content:"\f806"}.fa-hand-peace:before{content:"\f25b"}.fa-hand-point-down:before{content:"\f0a7"}.fa-hand-point-left:before{content:"\f0a5"}.fa-hand-point-right:before{content:"\f0a4"}.fa-hand-point-up:before{content:"\f0a6"}.fa-hand-pointer:before{content:"\f25a"}.fa-hand-scissors:before{content:"\f257"}.fa-hand-sparkles:before{content:"\e05d"}.fa-hand-spock:before{content:"\f259"}.fa-hands:before,.fa-sign-language:before,.fa-signing:before{content:"\f2a7"}.fa-american-sign-language-interpreting:before,.fa-asl-interpreting:before,.fa-hands-american-sign-language-interpreting:before,.fa-hands-asl-interpreting:before{content:"\f2a3"}.fa-hands-bubbles:before,.fa-hands-wash:before{content:"\e05e"}.fa-hands-clapping:before{content:"\e1a8"}.fa-hands-holding:before{content:"\f4c2"}.fa-hands-praying:before,.fa-praying-hands:before{content:"\f684"}.fa-handshake:before{content:"\f2b5"}.fa-hands-helping:before,.fa-handshake-angle:before{content:"\f4c4"}.fa-handshake-alt-slash:before,.fa-handshake-simple-slash:before{content:"\e05f"}.fa-handshake-slash:before{content:"\e060"}.fa-hanukiah:before{content:"\f6e6"}.fa-hard-drive:before,.fa-hdd:before{content:"\f0a0"}.fa-hashtag:before{content:"\23"}.fa-hat-cowboy:before{content:"\f8c0"}.fa-hat-cowboy-side:before{content:"\f8c1"}.fa-hat-wizard:before{content:"\f6e8"}.fa-head-side-cough:before{content:"\e061"}.fa-head-side-cough-slash:before{content:"\e062"}.fa-head-side-mask:before{content:"\e063"}.fa-head-side-virus:before{content:"\e064"}.fa-header:before,.fa-heading:before{content:"\f1dc"}.fa-headphones:before{content:"\f025"}.fa-headphones-alt:before,.fa-headphones-simple:before{content:"\f58f"}.fa-headset:before{content:"\f590"}.fa-heart:before{content:"\f004"}.fa-heart-broken:before,.fa-heart-crack:before{content:"\f7a9"}.fa-heart-pulse:before,.fa-heartbeat:before{content:"\f21e"}.fa-helicopter:before{content:"\f533"}.fa-hard-hat:before,.fa-hat-hard:before,.fa-helmet-safety:before{content:"\f807"}.fa-highlighter:before{content:"\f591"}.fa-hippo:before{content:"\f6ed"}.fa-hockey-puck:before{content:"\f453"}.fa-holly-berry:before{content:"\f7aa"}.fa-horse:before{content:"\f6f0"}.fa-horse-head:before{content:"\f7ab"}.fa-hospital-alt:before,.fa-hospital-wide:before,.fa-hospital:before{content:"\f0f8"}.fa-hospital-user:before{content:"\f80d"}.fa-hot-tub-person:before,.fa-hot-tub:before{content:"\f593"}.fa-hotdog:before{content:"\f80f"}.fa-hotel:before{content:"\f594"}.fa-hourglass-2:before,.fa-hourglass-half:before,.fa-hourglass:before{content:"\f254"}.fa-hourglass-empty:before{content:"\f252"}.fa-hourglass-3:before,.fa-hourglass-end:before{content:"\f253"}.fa-hourglass-1:before,.fa-hourglass-start:before{content:"\f251"}.fa-home-alt:before,.fa-home-lg-alt:before,.fa-home:before,.fa-house:before{content:"\f015"}.fa-home-lg:before,.fa-house-chimney:before{content:"\e3af"}.fa-house-chimney-crack:before,.fa-house-damage:before{content:"\f6f1"}.fa-clinic-medical:before,.fa-house-chimney-medical:before{content:"\f7f2"}.fa-house-chimney-user:before{content:"\e065"}.fa-house-chimney-window:before{content:"\e00d"}.fa-house-crack:before{content:"\e3b1"}.fa-house-laptop:before,.fa-laptop-house:before{content:"\e066"}.fa-house-medical:before{content:"\e3b2"}.fa-home-user:before,.fa-house-user:before{content:"\e1b0"}.fa-hryvnia-sign:before,.fa-hryvnia:before{content:"\f6f2"}.fa-i:before{content:"\49"}.fa-i-cursor:before{content:"\f246"}.fa-ice-cream:before{content:"\f810"}.fa-icicles:before{content:"\f7ad"}.fa-heart-music-camera-bolt:before,.fa-icons:before{content:"\f86d"}.fa-id-badge:before{content:"\f2c1"}.fa-drivers-license:before,.fa-id-card:before{content:"\f2c2"}.fa-id-card-alt:before,.fa-id-card-clip:before{content:"\f47f"}.fa-igloo:before{content:"\f7ae"}.fa-image:before{content:"\f03e"}.fa-image-portrait:before,.fa-portrait:before{content:"\f3e0"}.fa-images:before{content:"\f302"}.fa-inbox:before{content:"\f01c"}.fa-indent:before{content:"\f03c"}.fa-indian-rupee-sign:before,.fa-indian-rupee:before,.fa-inr:before{content:"\e1bc"}.fa-industry:before{content:"\f275"}.fa-infinity:before{content:"\f534"}.fa-info:before{content:"\f129"}.fa-italic:before{content:"\f033"}.fa-j:before{content:"\4a"}.fa-jedi:before{content:"\f669"}.fa-fighter-jet:before,.fa-jet-fighter:before{content:"\f0fb"}.fa-joint:before{content:"\f595"}.fa-k:before{content:"\4b"}.fa-kaaba:before{content:"\f66b"}.fa-key:before{content:"\f084"}.fa-keyboard:before{content:"\f11c"}.fa-khanda:before{content:"\f66d"}.fa-kip-sign:before{content:"\e1c4"}.fa-first-aid:before,.fa-kit-medical:before{content:"\f479"}.fa-kiwi-bird:before{content:"\f535"}.fa-l:before{content:"\4c"}.fa-landmark:before{content:"\f66f"}.fa-language:before{content:"\f1ab"}.fa-laptop:before{content:"\f109"}.fa-laptop-code:before{content:"\f5fc"}.fa-laptop-medical:before{content:"\f812"}.fa-lari-sign:before{content:"\e1c8"}.fa-layer-group:before{content:"\f5fd"}.fa-leaf:before{content:"\f06c"}.fa-left-long:before,.fa-long-arrow-alt-left:before{content:"\f30a"}.fa-arrows-alt-h:before,.fa-left-right:before{content:"\f337"}.fa-lemon:before{content:"\f094"}.fa-less-than:before{content:"\3c"}.fa-less-than-equal:before{content:"\f537"}.fa-life-ring:before{content:"\f1cd"}.fa-lightbulb:before{content:"\f0eb"}.fa-chain:before,.fa-link:before{content:"\f0c1"}.fa-chain-broken:before,.fa-chain-slash:before,.fa-link-slash:before,.fa-unlink:before{content:"\f127"}.fa-lira-sign:before{content:"\f195"}.fa-list-squares:before,.fa-list:before{content:"\f03a"}.fa-list-check:before,.fa-tasks:before{content:"\f0ae"}.fa-list-1-2:before,.fa-list-numeric:before,.fa-list-ol:before{content:"\f0cb"}.fa-list-dots:before,.fa-list-ul:before{content:"\f0ca"}.fa-litecoin-sign:before{content:"\e1d3"}.fa-location-arrow:before{content:"\f124"}.fa-location-crosshairs:before,.fa-location:before{content:"\f601"}.fa-location-dot:before,.fa-map-marker-alt:before{content:"\f3c5"}.fa-location-pin:before,.fa-map-marker:before{content:"\f041"}.fa-lock:before{content:"\f023"}.fa-lock-open:before{content:"\f3c1"}.fa-lungs:before{content:"\f604"}.fa-lungs-virus:before{content:"\e067"}.fa-m:before{content:"\4d"}.fa-magnet:before{content:"\f076"}.fa-magnifying-glass:before,.fa-search:before{content:"\f002"}.fa-magnifying-glass-dollar:before,.fa-search-dollar:before{content:"\f688"}.fa-magnifying-glass-location:before,.fa-search-location:before{content:"\f689"}.fa-magnifying-glass-minus:before,.fa-search-minus:before{content:"\f010"}.fa-magnifying-glass-plus:before,.fa-search-plus:before{content:"\f00e"}.fa-manat-sign:before{content:"\e1d5"}.fa-map:before{content:"\f279"}.fa-map-location:before,.fa-map-marked:before{content:"\f59f"}.fa-map-location-dot:before,.fa-map-marked-alt:before{content:"\f5a0"}.fa-map-pin:before{content:"\f276"}.fa-marker:before{content:"\f5a1"}.fa-mars:before{content:"\f222"}.fa-mars-and-venus:before{content:"\f224"}.fa-mars-double:before{content:"\f227"}.fa-mars-stroke:before{content:"\f229"}.fa-mars-stroke-h:before,.fa-mars-stroke-right:before{content:"\f22b"}.fa-mars-stroke-up:before,.fa-mars-stroke-v:before{content:"\f22a"}.fa-glass-martini-alt:before,.fa-martini-glass:before{content:"\f57b"}.fa-cocktail:before,.fa-martini-glass-citrus:before{content:"\f561"}.fa-glass-martini:before,.fa-martini-glass-empty:before{content:"\f000"}.fa-mask:before{content:"\f6fa"}.fa-mask-face:before{content:"\e1d7"}.fa-masks-theater:before,.fa-theater-masks:before{content:"\f630"}.fa-expand-arrows-alt:before,.fa-maximize:before{content:"\f31e"}.fa-medal:before{content:"\f5a2"}.fa-memory:before{content:"\f538"}.fa-menorah:before{content:"\f676"}.fa-mercury:before{content:"\f223"}.fa-comment-alt:before,.fa-message:before{content:"\f27a"}.fa-meteor:before{content:"\f753"}.fa-microchip:before{content:"\f2db"}.fa-microphone:before{content:"\f130"}.fa-microphone-alt:before,.fa-microphone-lines:before{content:"\f3c9"}.fa-microphone-alt-slash:before,.fa-microphone-lines-slash:before{content:"\f539"}.fa-microphone-slash:before{content:"\f131"}.fa-microscope:before{content:"\f610"}.fa-mill-sign:before{content:"\e1ed"}.fa-compress-arrows-alt:before,.fa-minimize:before{content:"\f78c"}.fa-minus:before,.fa-subtract:before{content:"\f068"}.fa-mitten:before{content:"\f7b5"}.fa-mobile-android:before,.fa-mobile-phone:before,.fa-mobile:before{content:"\f3ce"}.fa-mobile-button:before{content:"\f10b"}.fa-mobile-alt:before,.fa-mobile-screen-button:before{content:"\f3cd"}.fa-money-bill:before{content:"\f0d6"}.fa-money-bill-1:before,.fa-money-bill-alt:before{content:"\f3d1"}.fa-money-bill-1-wave:before,.fa-money-bill-wave-alt:before{content:"\f53b"}.fa-money-bill-wave:before{content:"\f53a"}.fa-money-check:before{content:"\f53c"}.fa-money-check-alt:before,.fa-money-check-dollar:before{content:"\f53d"}.fa-monument:before{content:"\f5a6"}.fa-moon:before{content:"\f186"}.fa-mortar-pestle:before{content:"\f5a7"}.fa-mosque:before{content:"\f678"}.fa-motorcycle:before{content:"\f21c"}.fa-mountain:before{content:"\f6fc"}.fa-mug-hot:before{content:"\f7b6"}.fa-coffee:before,.fa-mug-saucer:before{content:"\f0f4"}.fa-music:before{content:"\f001"}.fa-n:before{content:"\4e"}.fa-naira-sign:before{content:"\e1f6"}.fa-network-wired:before{content:"\f6ff"}.fa-neuter:before{content:"\f22c"}.fa-newspaper:before{content:"\f1ea"}.fa-not-equal:before{content:"\f53e"}.fa-note-sticky:before,.fa-sticky-note:before{content:"\f249"}.fa-notes-medical:before{content:"\f481"}.fa-o:before{content:"\4f"}.fa-object-group:before{content:"\f247"}.fa-object-ungroup:before{content:"\f248"}.fa-oil-can:before{content:"\f613"}.fa-om:before{content:"\f679"}.fa-otter:before{content:"\f700"}.fa-dedent:before,.fa-outdent:before{content:"\f03b"}.fa-p:before{content:"\50"}.fa-pager:before{content:"\f815"}.fa-paint-roller:before{content:"\f5aa"}.fa-paint-brush:before,.fa-paintbrush:before{content:"\f1fc"}.fa-palette:before{content:"\f53f"}.fa-pallet:before{content:"\f482"}.fa-panorama:before{content:"\e209"}.fa-paper-plane:before{content:"\f1d8"}.fa-paperclip:before{content:"\f0c6"}.fa-parachute-box:before{content:"\f4cd"}.fa-paragraph:before{content:"\f1dd"}.fa-passport:before{content:"\f5ab"}.fa-file-clipboard:before,.fa-paste:before{content:"\f0ea"}.fa-pause:before{content:"\f04c"}.fa-paw:before{content:"\f1b0"}.fa-peace:before{content:"\f67c"}.fa-pen:before{content:"\f304"}.fa-pen-alt:before,.fa-pen-clip:before{content:"\f305"}.fa-pen-fancy:before{content:"\f5ac"}.fa-pen-nib:before{content:"\f5ad"}.fa-pen-ruler:before,.fa-pencil-ruler:before{content:"\f5ae"}.fa-edit:before,.fa-pen-to-square:before{content:"\f044"}.fa-pencil-alt:before,.fa-pencil:before{content:"\f303"}.fa-people-arrows-left-right:before,.fa-people-arrows:before{content:"\e068"}.fa-people-carry-box:before,.fa-people-carry:before{content:"\f4ce"}.fa-pepper-hot:before{content:"\f816"}.fa-percent:before,.fa-percentage:before{content:"\25"}.fa-male:before,.fa-person:before{content:"\f183"}.fa-biking:before,.fa-person-biking:before{content:"\f84a"}.fa-person-booth:before{content:"\f756"}.fa-diagnoses:before,.fa-person-dots-from-line:before{content:"\f470"}.fa-female:before,.fa-person-dress:before{content:"\f182"}.fa-hiking:before,.fa-person-hiking:before{content:"\f6ec"}.fa-person-praying:before,.fa-pray:before{content:"\f683"}.fa-person-running:before,.fa-running:before{content:"\f70c"}.fa-person-skating:before,.fa-skating:before{content:"\f7c5"}.fa-person-skiing:before,.fa-skiing:before{content:"\f7c9"}.fa-person-skiing-nordic:before,.fa-skiing-nordic:before{content:"\f7ca"}.fa-person-snowboarding:before,.fa-snowboarding:before{content:"\f7ce"}.fa-person-swimming:before,.fa-swimmer:before{content:"\f5c4"}.fa-person-walking:before,.fa-walking:before{content:"\f554"}.fa-blind:before,.fa-person-walking-with-cane:before{content:"\f29d"}.fa-peseta-sign:before{content:"\e221"}.fa-peso-sign:before{content:"\e222"}.fa-phone:before{content:"\f095"}.fa-phone-alt:before,.fa-phone-flip:before{content:"\f879"}.fa-phone-slash:before{content:"\f3dd"}.fa-phone-volume:before,.fa-volume-control-phone:before{content:"\f2a0"}.fa-photo-film:before,.fa-photo-video:before{content:"\f87c"}.fa-piggy-bank:before{content:"\f4d3"}.fa-pills:before{content:"\f484"}.fa-pizza-slice:before{content:"\f818"}.fa-place-of-worship:before{content:"\f67f"}.fa-plane:before{content:"\f072"}.fa-plane-arrival:before{content:"\f5af"}.fa-plane-departure:before{content:"\f5b0"}.fa-plane-slash:before{content:"\e069"}.fa-play:before{content:"\f04b"}.fa-plug:before{content:"\f1e6"}.fa-add:before,.fa-plus:before{content:"\2b"}.fa-plus-minus:before{content:"\e43c"}.fa-podcast:before{content:"\f2ce"}.fa-poo:before{content:"\f2fe"}.fa-poo-bolt:before,.fa-poo-storm:before{content:"\f75a"}.fa-poop:before{content:"\f619"}.fa-power-off:before{content:"\f011"}.fa-prescription:before{content:"\f5b1"}.fa-prescription-bottle:before{content:"\f485"}.fa-prescription-bottle-alt:before,.fa-prescription-bottle-medical:before{content:"\f486"}.fa-print:before{content:"\f02f"}.fa-pump-medical:before{content:"\e06a"}.fa-pump-soap:before{content:"\e06b"}.fa-puzzle-piece:before{content:"\f12e"}.fa-q:before{content:"\51"}.fa-qrcode:before{content:"\f029"}.fa-question:before{content:"\3f"}.fa-quote-left-alt:before,.fa-quote-left:before{content:"\f10d"}.fa-quote-right-alt:before,.fa-quote-right:before{content:"\f10e"}.fa-r:before{content:"\52"}.fa-radiation:before{content:"\f7b9"}.fa-rainbow:before{content:"\f75b"}.fa-receipt:before{content:"\f543"}.fa-record-vinyl:before{content:"\f8d9"}.fa-ad:before,.fa-rectangle-ad:before{content:"\f641"}.fa-list-alt:before,.fa-rectangle-list:before{content:"\f022"}.fa-rectangle-times:before,.fa-rectangle-xmark:before,.fa-times-rectangle:before,.fa-window-close:before{content:"\f410"}.fa-recycle:before{content:"\f1b8"}.fa-registered:before{content:"\f25d"}.fa-repeat:before{content:"\f363"}.fa-mail-reply:before,.fa-reply:before{content:"\f3e5"}.fa-mail-reply-all:before,.fa-reply-all:before{content:"\f122"}.fa-republican:before{content:"\f75e"}.fa-restroom:before{content:"\f7bd"}.fa-retweet:before{content:"\f079"}.fa-ribbon:before{content:"\f4d6"}.fa-right-from-bracket:before,.fa-sign-out-alt:before{content:"\f2f5"}.fa-exchange-alt:before,.fa-right-left:before{content:"\f362"}.fa-long-arrow-alt-right:before,.fa-right-long:before{content:"\f30b"}.fa-right-to-bracket:before,.fa-sign-in-alt:before{content:"\f2f6"}.fa-ring:before{content:"\f70b"}.fa-road:before{content:"\f018"}.fa-robot:before{content:"\f544"}.fa-rocket:before{content:"\f135"}.fa-rotate:before,.fa-sync-alt:before{content:"\f2f1"}.fa-rotate-back:before,.fa-rotate-backward:before,.fa-rotate-left:before,.fa-undo-alt:before{content:"\f2ea"}.fa-redo-alt:before,.fa-rotate-forward:before,.fa-rotate-right:before{content:"\f2f9"}.fa-route:before{content:"\f4d7"}.fa-feed:before,.fa-rss:before{content:"\f09e"}.fa-rouble:before,.fa-rub:before,.fa-ruble-sign:before,.fa-ruble:before{content:"\f158"}.fa-ruler:before{content:"\f545"}.fa-ruler-combined:before{content:"\f546"}.fa-ruler-horizontal:before{content:"\f547"}.fa-ruler-vertical:before{content:"\f548"}.fa-rupee-sign:before,.fa-rupee:before{content:"\f156"}.fa-rupiah-sign:before{content:"\e23d"}.fa-s:before{content:"\53"}.fa-sailboat:before{content:"\e445"}.fa-satellite:before{content:"\f7bf"}.fa-satellite-dish:before{content:"\f7c0"}.fa-balance-scale:before,.fa-scale-balanced:before{content:"\f24e"}.fa-balance-scale-left:before,.fa-scale-unbalanced:before{content:"\f515"}.fa-balance-scale-right:before,.fa-scale-unbalanced-flip:before{content:"\f516"}.fa-school:before{content:"\f549"}.fa-cut:before,.fa-scissors:before{content:"\f0c4"}.fa-screwdriver:before{content:"\f54a"}.fa-screwdriver-wrench:before,.fa-tools:before{content:"\f7d9"}.fa-scroll:before{content:"\f70e"}.fa-scroll-torah:before,.fa-torah:before{content:"\f6a0"}.fa-sd-card:before{content:"\f7c2"}.fa-section:before{content:"\e447"}.fa-seedling:before,.fa-sprout:before{content:"\f4d8"}.fa-server:before{content:"\f233"}.fa-shapes:before,.fa-triangle-circle-square:before{content:"\f61f"}.fa-arrow-turn-right:before,.fa-mail-forward:before,.fa-share:before{content:"\f064"}.fa-share-from-square:before,.fa-share-square:before{content:"\f14d"}.fa-share-alt:before,.fa-share-nodes:before{content:"\f1e0"}.fa-ils:before,.fa-shekel-sign:before,.fa-shekel:before,.fa-sheqel-sign:before,.fa-sheqel:before{content:"\f20b"}.fa-shield:before{content:"\f132"}.fa-shield-alt:before,.fa-shield-blank:before{content:"\f3ed"}.fa-shield-virus:before{content:"\e06c"}.fa-ship:before{content:"\f21a"}.fa-shirt:before,.fa-t-shirt:before,.fa-tshirt:before{content:"\f553"}.fa-shoe-prints:before{content:"\f54b"}.fa-shop:before,.fa-store-alt:before{content:"\f54f"}.fa-shop-slash:before,.fa-store-alt-slash:before{content:"\e070"}.fa-shower:before{content:"\f2cc"}.fa-shrimp:before{content:"\e448"}.fa-random:before,.fa-shuffle:before{content:"\f074"}.fa-shuttle-space:before,.fa-space-shuttle:before{content:"\f197"}.fa-sign-hanging:before,.fa-sign:before{content:"\f4d9"}.fa-signal-5:before,.fa-signal-perfect:before,.fa-signal:before{content:"\f012"}.fa-signature:before{content:"\f5b7"}.fa-map-signs:before,.fa-signs-post:before{content:"\f277"}.fa-sim-card:before{content:"\f7c4"}.fa-sink:before{content:"\e06d"}.fa-sitemap:before{content:"\f0e8"}.fa-skull:before{content:"\f54c"}.fa-skull-crossbones:before{content:"\f714"}.fa-slash:before{content:"\f715"}.fa-sleigh:before{content:"\f7cc"}.fa-sliders-h:before,.fa-sliders:before{content:"\f1de"}.fa-smog:before{content:"\f75f"}.fa-smoking:before{content:"\f48d"}.fa-snowflake:before{content:"\f2dc"}.fa-snowman:before{content:"\f7d0"}.fa-snowplow:before{content:"\f7d2"}.fa-soap:before{content:"\e06e"}.fa-socks:before{content:"\f696"}.fa-solar-panel:before{content:"\f5ba"}.fa-sort:before,.fa-unsorted:before{content:"\f0dc"}.fa-sort-desc:before,.fa-sort-down:before{content:"\f0dd"}.fa-sort-asc:before,.fa-sort-up:before{content:"\f0de"}.fa-spa:before{content:"\f5bb"}.fa-pastafarianism:before,.fa-spaghetti-monster-flying:before{content:"\f67b"}.fa-spell-check:before{content:"\f891"}.fa-spider:before{content:"\f717"}.fa-spinner:before{content:"\f110"}.fa-splotch:before{content:"\f5bc"}.fa-spoon:before,.fa-utensil-spoon:before{content:"\f2e5"}.fa-spray-can:before{content:"\f5bd"}.fa-air-freshener:before,.fa-spray-can-sparkles:before{content:"\f5d0"}.fa-square:before{content:"\f0c8"}.fa-external-link-square:before,.fa-square-arrow-up-right:before{content:"\f14c"}.fa-caret-square-down:before,.fa-square-caret-down:before{content:"\f150"}.fa-caret-square-left:before,.fa-square-caret-left:before{content:"\f191"}.fa-caret-square-right:before,.fa-square-caret-right:before{content:"\f152"}.fa-caret-square-up:before,.fa-square-caret-up:before{content:"\f151"}.fa-check-square:before,.fa-square-check:before{content:"\f14a"}.fa-envelope-square:before,.fa-square-envelope:before{content:"\f199"}.fa-square-full:before{content:"\f45c"}.fa-h-square:before,.fa-square-h:before{content:"\f0fd"}.fa-minus-square:before,.fa-square-minus:before{content:"\f146"}.fa-parking:before,.fa-square-parking:before{content:"\f540"}.fa-pen-square:before,.fa-pencil-square:before,.fa-square-pen:before{content:"\f14b"}.fa-phone-square:before,.fa-square-phone:before{content:"\f098"}.fa-phone-square-alt:before,.fa-square-phone-flip:before{content:"\f87b"}.fa-plus-square:before,.fa-square-plus:before{content:"\f0fe"}.fa-poll-h:before,.fa-square-poll-horizontal:before{content:"\f682"}.fa-poll:before,.fa-square-poll-vertical:before{content:"\f681"}.fa-square-root-alt:before,.fa-square-root-variable:before{content:"\f698"}.fa-rss-square:before,.fa-square-rss:before{content:"\f143"}.fa-share-alt-square:before,.fa-square-share-nodes:before{content:"\f1e1"}.fa-external-link-square-alt:before,.fa-square-up-right:before{content:"\f360"}.fa-square-xmark:before,.fa-times-square:before,.fa-xmark-square:before{content:"\f2d3"}.fa-stairs:before{content:"\e289"}.fa-stamp:before{content:"\f5bf"}.fa-star:before{content:"\f005"}.fa-star-and-crescent:before{content:"\f699"}.fa-star-half:before{content:"\f089"}.fa-star-half-alt:before,.fa-star-half-stroke:before{content:"\f5c0"}.fa-star-of-david:before{content:"\f69a"}.fa-star-of-life:before{content:"\f621"}.fa-gbp:before,.fa-pound-sign:before,.fa-sterling-sign:before{content:"\f154"}.fa-stethoscope:before{content:"\f0f1"}.fa-stop:before{content:"\f04d"}.fa-stopwatch:before{content:"\f2f2"}.fa-stopwatch-20:before{content:"\e06f"}.fa-store:before{content:"\f54e"}.fa-store-slash:before{content:"\e071"}.fa-street-view:before{content:"\f21d"}.fa-strikethrough:before{content:"\f0cc"}.fa-stroopwafel:before{content:"\f551"}.fa-subscript:before{content:"\f12c"}.fa-suitcase:before{content:"\f0f2"}.fa-medkit:before,.fa-suitcase-medical:before{content:"\f0fa"}.fa-suitcase-rolling:before{content:"\f5c1"}.fa-sun:before{content:"\f185"}.fa-superscript:before{content:"\f12b"}.fa-swatchbook:before{content:"\f5c3"}.fa-synagogue:before{content:"\f69b"}.fa-syringe:before{content:"\f48e"}.fa-t:before{content:"\54"}.fa-table:before{content:"\f0ce"}.fa-table-cells:before,.fa-th:before{content:"\f00a"}.fa-table-cells-large:before,.fa-th-large:before{content:"\f009"}.fa-columns:before,.fa-table-columns:before{content:"\f0db"}.fa-table-list:before,.fa-th-list:before{content:"\f00b"}.fa-ping-pong-paddle-ball:before,.fa-table-tennis-paddle-ball:before,.fa-table-tennis:before{content:"\f45d"}.fa-tablet-android:before,.fa-tablet:before{content:"\f3fb"}.fa-tablet-button:before{content:"\f10a"}.fa-tablet-alt:before,.fa-tablet-screen-button:before{content:"\f3fa"}.fa-tablets:before{content:"\f490"}.fa-digital-tachograph:before,.fa-tachograph-digital:before{content:"\f566"}.fa-tag:before{content:"\f02b"}.fa-tags:before{content:"\f02c"}.fa-tape:before{content:"\f4db"}.fa-cab:before,.fa-taxi:before{content:"\f1ba"}.fa-teeth:before{content:"\f62e"}.fa-teeth-open:before{content:"\f62f"}.fa-temperature-0:before,.fa-temperature-empty:before,.fa-thermometer-0:before,.fa-thermometer-empty:before{content:"\f2cb"}.fa-temperature-4:before,.fa-temperature-full:before,.fa-thermometer-4:before,.fa-thermometer-full:before{content:"\f2c7"}.fa-temperature-2:before,.fa-temperature-half:before,.fa-thermometer-2:before,.fa-thermometer-half:before{content:"\f2c9"}.fa-temperature-high:before{content:"\f769"}.fa-temperature-low:before{content:"\f76b"}.fa-temperature-1:before,.fa-temperature-quarter:before,.fa-thermometer-1:before,.fa-thermometer-quarter:before{content:"\f2ca"}.fa-temperature-3:before,.fa-temperature-three-quarters:before,.fa-thermometer-3:before,.fa-thermometer-three-quarters:before{content:"\f2c8"}.fa-tenge-sign:before,.fa-tenge:before{content:"\f7d7"}.fa-terminal:before{content:"\f120"}.fa-text-height:before{content:"\f034"}.fa-remove-format:before,.fa-text-slash:before{content:"\f87d"}.fa-text-width:before{content:"\f035"}.fa-thermometer:before{content:"\f491"}.fa-thumbs-down:before{content:"\f165"}.fa-thumbs-up:before{content:"\f164"}.fa-thumb-tack:before,.fa-thumbtack:before{content:"\f08d"}.fa-ticket:before{content:"\f145"}.fa-ticket-alt:before,.fa-ticket-simple:before{content:"\f3ff"}.fa-timeline:before{content:"\e29c"}.fa-toggle-off:before{content:"\f204"}.fa-toggle-on:before{content:"\f205"}.fa-toilet:before{content:"\f7d8"}.fa-toilet-paper:before{content:"\f71e"}.fa-toilet-paper-slash:before{content:"\e072"}.fa-toolbox:before{content:"\f552"}.fa-tooth:before{content:"\f5c9"}.fa-torii-gate:before{content:"\f6a1"}.fa-broadcast-tower:before,.fa-tower-broadcast:before{content:"\f519"}.fa-tractor:before{content:"\f722"}.fa-trademark:before{content:"\f25c"}.fa-traffic-light:before{content:"\f637"}.fa-trailer:before{content:"\e041"}.fa-train:before{content:"\f238"}.fa-subway:before,.fa-train-subway:before{content:"\f239"}.fa-train-tram:before,.fa-tram:before{content:"\f7da"}.fa-transgender-alt:before,.fa-transgender:before{content:"\f225"}.fa-trash:before{content:"\f1f8"}.fa-trash-arrow-up:before,.fa-trash-restore:before{content:"\f829"}.fa-trash-alt:before,.fa-trash-can:before{content:"\f2ed"}.fa-trash-can-arrow-up:before,.fa-trash-restore-alt:before{content:"\f82a"}.fa-tree:before{content:"\f1bb"}.fa-exclamation-triangle:before,.fa-triangle-exclamation:before,.fa-warning:before{content:"\f071"}.fa-trophy:before{content:"\f091"}.fa-truck:before{content:"\f0d1"}.fa-shipping-fast:before,.fa-truck-fast:before{content:"\f48b"}.fa-ambulance:before,.fa-truck-medical:before{content:"\f0f9"}.fa-truck-monster:before{content:"\f63b"}.fa-truck-moving:before{content:"\f4df"}.fa-truck-pickup:before{content:"\f63c"}.fa-truck-loading:before,.fa-truck-ramp-box:before{content:"\f4de"}.fa-teletype:before,.fa-tty:before{content:"\f1e4"}.fa-try:before,.fa-turkish-lira-sign:before,.fa-turkish-lira:before{content:"\e2bb"}.fa-level-down-alt:before,.fa-turn-down:before{content:"\f3be"}.fa-level-up-alt:before,.fa-turn-up:before{content:"\f3bf"}.fa-television:before,.fa-tv-alt:before,.fa-tv:before{content:"\f26c"}.fa-u:before{content:"\55"}.fa-umbrella:before{content:"\f0e9"}.fa-umbrella-beach:before{content:"\f5ca"}.fa-underline:before{content:"\f0cd"}.fa-universal-access:before{content:"\f29a"}.fa-unlock:before{content:"\f09c"}.fa-unlock-alt:before,.fa-unlock-keyhole:before{content:"\f13e"}.fa-arrows-alt-v:before,.fa-up-down:before{content:"\f338"}.fa-arrows-alt:before,.fa-up-down-left-right:before{content:"\f0b2"}.fa-long-arrow-alt-up:before,.fa-up-long:before{content:"\f30c"}.fa-expand-alt:before,.fa-up-right-and-down-left-from-center:before{content:"\f424"}.fa-external-link-alt:before,.fa-up-right-from-square:before{content:"\f35d"}.fa-upload:before{content:"\f093"}.fa-user:before{content:"\f007"}.fa-user-astronaut:before{content:"\f4fb"}.fa-user-check:before{content:"\f4fc"}.fa-user-clock:before{content:"\f4fd"}.fa-user-doctor:before,.fa-user-md:before{content:"\f0f0"}.fa-user-cog:before,.fa-user-gear:before{content:"\f4fe"}.fa-user-graduate:before{content:"\f501"}.fa-user-friends:before,.fa-user-group:before{content:"\f500"}.fa-user-injured:before{content:"\f728"}.fa-user-alt:before,.fa-user-large:before{content:"\f406"}.fa-user-alt-slash:before,.fa-user-large-slash:before{content:"\f4fa"}.fa-user-lock:before{content:"\f502"}.fa-user-minus:before{content:"\f503"}.fa-user-ninja:before{content:"\f504"}.fa-user-nurse:before{content:"\f82f"}.fa-user-edit:before,.fa-user-pen:before{content:"\f4ff"}.fa-user-plus:before{content:"\f234"}.fa-user-secret:before{content:"\f21b"}.fa-user-shield:before{content:"\f505"}.fa-user-slash:before{content:"\f506"}.fa-user-tag:before{content:"\f507"}.fa-user-tie:before{content:"\f508"}.fa-user-times:before,.fa-user-xmark:before{content:"\f235"}.fa-users:before{content:"\f0c0"}.fa-users-cog:before,.fa-users-gear:before{content:"\f509"}.fa-users-slash:before{content:"\e073"}.fa-cutlery:before,.fa-utensils:before{content:"\f2e7"}.fa-v:before{content:"\56"}.fa-shuttle-van:before,.fa-van-shuttle:before{content:"\f5b6"}.fa-vault:before{content:"\e2c5"}.fa-vector-square:before{content:"\f5cb"}.fa-venus:before{content:"\f221"}.fa-venus-double:before{content:"\f226"}.fa-venus-mars:before{content:"\f228"}.fa-vest:before{content:"\e085"}.fa-vest-patches:before{content:"\e086"}.fa-vial:before{content:"\f492"}.fa-vials:before{content:"\f493"}.fa-video-camera:before,.fa-video:before{content:"\f03d"}.fa-video-slash:before{content:"\f4e2"}.fa-vihara:before{content:"\f6a7"}.fa-virus:before{content:"\e074"}.fa-virus-covid:before{content:"\e4a8"}.fa-virus-covid-slash:before{content:"\e4a9"}.fa-virus-slash:before{content:"\e075"}.fa-viruses:before{content:"\e076"}.fa-voicemail:before{content:"\f897"}.fa-volleyball-ball:before,.fa-volleyball:before{content:"\f45f"}.fa-volume-high:before,.fa-volume-up:before{content:"\f028"}.fa-volume-down:before,.fa-volume-low:before{content:"\f027"}.fa-volume-off:before{content:"\f026"}.fa-volume-mute:before,.fa-volume-times:before,.fa-volume-xmark:before{content:"\f6a9"}.fa-vr-cardboard:before{content:"\f729"}.fa-w:before{content:"\57"}.fa-wallet:before{content:"\f555"}.fa-magic:before,.fa-wand-magic:before{content:"\f0d0"}.fa-magic-wand-sparkles:before,.fa-wand-magic-sparkles:before{content:"\e2ca"}.fa-wand-sparkles:before{content:"\f72b"}.fa-warehouse:before{content:"\f494"}.fa-water:before{content:"\f773"}.fa-ladder-water:before,.fa-swimming-pool:before,.fa-water-ladder:before{content:"\f5c5"}.fa-wave-square:before{content:"\f83e"}.fa-weight-hanging:before{content:"\f5cd"}.fa-weight-scale:before,.fa-weight:before{content:"\f496"}.fa-wheelchair:before{content:"\f193"}.fa-glass-whiskey:before,.fa-whiskey-glass:before{content:"\f7a0"}.fa-wifi-3:before,.fa-wifi-strong:before,.fa-wifi:before{content:"\f1eb"}.fa-wind:before{content:"\f72e"}.fa-window-maximize:before{content:"\f2d0"}.fa-window-minimize:before{content:"\f2d1"}.fa-window-restore:before{content:"\f2d2"}.fa-wine-bottle:before{content:"\f72f"}.fa-wine-glass:before{content:"\f4e3"}.fa-wine-glass-alt:before,.fa-wine-glass-empty:before{content:"\f5ce"}.fa-krw:before,.fa-won-sign:before,.fa-won:before{content:"\f159"}.fa-wrench:before{content:"\f0ad"}.fa-x:before{content:"\58"}.fa-x-ray:before{content:"\f497"}.fa-close:before,.fa-multiply:before,.fa-remove:before,.fa-times:before,.fa-xmark:before{content:"\f00d"}.fa-y:before{content:"\59"}.fa-cny:before,.fa-jpy:before,.fa-rmb:before,.fa-yen-sign:before,.fa-yen:before{content:"\f157"}.fa-yin-yang:before{content:"\f6ad"}.fa-z:before{content:"\5a"}.fa-sr-only,.fa-sr-only-focusable:not(:focus),.sr-only,.sr-only-focusable:not(:focus){position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}:host,:root{--fa-font-brands:normal 400 1em/1 "Font Awesome 6 Brands"}@font-face{font-family:"Font Awesome 6 Brands";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.ttf) format("truetype")}.fa-brands,.fab{font-family:"Font Awesome 6 Brands";font-weight:400}.fa-42-group:before,.fa-innosoft:before{content:"\e080"}.fa-500px:before{content:"\f26e"}.fa-accessible-icon:before{content:"\f368"}.fa-accusoft:before{content:"\f369"}.fa-adn:before{content:"\f170"}.fa-adversal:before{content:"\f36a"}.fa-affiliatetheme:before{content:"\f36b"}.fa-airbnb:before{content:"\f834"}.fa-algolia:before{content:"\f36c"}.fa-alipay:before{content:"\f642"}.fa-amazon:before{content:"\f270"}.fa-amazon-pay:before{content:"\f42c"}.fa-amilia:before{content:"\f36d"}.fa-android:before{content:"\f17b"}.fa-angellist:before{content:"\f209"}.fa-angrycreative:before{content:"\f36e"}.fa-angular:before{content:"\f420"}.fa-app-store:before{content:"\f36f"}.fa-app-store-ios:before{content:"\f370"}.fa-apper:before{content:"\f371"}.fa-apple:before{content:"\f179"}.fa-apple-pay:before{content:"\f415"}.fa-artstation:before{content:"\f77a"}.fa-asymmetrik:before{content:"\f372"}.fa-atlassian:before{content:"\f77b"}.fa-audible:before{content:"\f373"}.fa-autoprefixer:before{content:"\f41c"}.fa-avianex:before{content:"\f374"}.fa-aviato:before{content:"\f421"}.fa-aws:before{content:"\f375"}.fa-bandcamp:before{content:"\f2d5"}.fa-battle-net:before{content:"\f835"}.fa-behance:before{content:"\f1b4"}.fa-behance-square:before{content:"\f1b5"}.fa-bilibili:before{content:"\e3d9"}.fa-bimobject:before{content:"\f378"}.fa-bitbucket:before{content:"\f171"}.fa-bitcoin:before{content:"\f379"}.fa-bity:before{content:"\f37a"}.fa-black-tie:before{content:"\f27e"}.fa-blackberry:before{content:"\f37b"}.fa-blogger:before{content:"\f37c"}.fa-blogger-b:before{content:"\f37d"}.fa-bluetooth:before{content:"\f293"}.fa-bluetooth-b:before{content:"\f294"}.fa-bootstrap:before{content:"\f836"}.fa-bots:before{content:"\e340"}.fa-btc:before{content:"\f15a"}.fa-buffer:before{content:"\f837"}.fa-buromobelexperte:before{content:"\f37f"}.fa-buy-n-large:before{content:"\f8a6"}.fa-buysellads:before{content:"\f20d"}.fa-canadian-maple-leaf:before{content:"\f785"}.fa-cc-amazon-pay:before{content:"\f42d"}.fa-cc-amex:before{content:"\f1f3"}.fa-cc-apple-pay:before{content:"\f416"}.fa-cc-diners-club:before{content:"\f24c"}.fa-cc-discover:before{content:"\f1f2"}.fa-cc-jcb:before{content:"\f24b"}.fa-cc-mastercard:before{content:"\f1f1"}.fa-cc-paypal:before{content:"\f1f4"}.fa-cc-stripe:before{content:"\f1f5"}.fa-cc-visa:before{content:"\f1f0"}.fa-centercode:before{content:"\f380"}.fa-centos:before{content:"\f789"}.fa-chrome:before{content:"\f268"}.fa-chromecast:before{content:"\f838"}.fa-cloudflare:before{content:"\e07d"}.fa-cloudscale:before{content:"\f383"}.fa-cloudsmith:before{content:"\f384"}.fa-cloudversify:before{content:"\f385"}.fa-cmplid:before{content:"\e360"}.fa-codepen:before{content:"\f1cb"}.fa-codiepie:before{content:"\f284"}.fa-confluence:before{content:"\f78d"}.fa-connectdevelop:before{content:"\f20e"}.fa-contao:before{content:"\f26d"}.fa-cotton-bureau:before{content:"\f89e"}.fa-cpanel:before{content:"\f388"}.fa-creative-commons:before{content:"\f25e"}.fa-creative-commons-by:before{content:"\f4e7"}.fa-creative-commons-nc:before{content:"\f4e8"}.fa-creative-commons-nc-eu:before{content:"\f4e9"}.fa-creative-commons-nc-jp:before{content:"\f4ea"}.fa-creative-commons-nd:before{content:"\f4eb"}.fa-creative-commons-pd:before{content:"\f4ec"}.fa-creative-commons-pd-alt:before{content:"\f4ed"}.fa-creative-commons-remix:before{content:"\f4ee"}.fa-creative-commons-sa:before{content:"\f4ef"}.fa-creative-commons-sampling:before{content:"\f4f0"}.fa-creative-commons-sampling-plus:before{content:"\f4f1"}.fa-creative-commons-share:before{content:"\f4f2"}.fa-creative-commons-zero:before{content:"\f4f3"}.fa-critical-role:before{content:"\f6c9"}.fa-css3:before{content:"\f13c"}.fa-css3-alt:before{content:"\f38b"}.fa-cuttlefish:before{content:"\f38c"}.fa-d-and-d:before{content:"\f38d"}.fa-d-and-d-beyond:before{content:"\f6ca"}.fa-dailymotion:before{content:"\e052"}.fa-dashcube:before{content:"\f210"}.fa-deezer:before{content:"\e077"}.fa-delicious:before{content:"\f1a5"}.fa-deploydog:before{content:"\f38e"}.fa-deskpro:before{content:"\f38f"}.fa-dev:before{content:"\f6cc"}.fa-deviantart:before{content:"\f1bd"}.fa-dhl:before{content:"\f790"}.fa-diaspora:before{content:"\f791"}.fa-digg:before{content:"\f1a6"}.fa-digital-ocean:before{content:"\f391"}.fa-discord:before{content:"\f392"}.fa-discourse:before{content:"\f393"}.fa-dochub:before{content:"\f394"}.fa-docker:before{content:"\f395"}.fa-draft2digital:before{content:"\f396"}.fa-dribbble:before{content:"\f17d"}.fa-dribbble-square:before{content:"\f397"}.fa-dropbox:before{content:"\f16b"}.fa-drupal:before{content:"\f1a9"}.fa-dyalog:before{content:"\f399"}.fa-earlybirds:before{content:"\f39a"}.fa-ebay:before{content:"\f4f4"}.fa-edge:before{content:"\f282"}.fa-edge-legacy:before{content:"\e078"}.fa-elementor:before{content:"\f430"}.fa-ello:before{content:"\f5f1"}.fa-ember:before{content:"\f423"}.fa-empire:before{content:"\f1d1"}.fa-envira:before{content:"\f299"}.fa-erlang:before{content:"\f39d"}.fa-ethereum:before{content:"\f42e"}.fa-etsy:before{content:"\f2d7"}.fa-evernote:before{content:"\f839"}.fa-expeditedssl:before{content:"\f23e"}.fa-facebook:before{content:"\f09a"}.fa-facebook-f:before{content:"\f39e"}.fa-facebook-messenger:before{content:"\f39f"}.fa-facebook-square:before{content:"\f082"}.fa-fantasy-flight-games:before{content:"\f6dc"}.fa-fedex:before{content:"\f797"}.fa-fedora:before{content:"\f798"}.fa-figma:before{content:"\f799"}.fa-firefox:before{content:"\f269"}.fa-firefox-browser:before{content:"\e007"}.fa-first-order:before{content:"\f2b0"}.fa-first-order-alt:before{content:"\f50a"}.fa-firstdraft:before{content:"\f3a1"}.fa-flickr:before{content:"\f16e"}.fa-flipboard:before{content:"\f44d"}.fa-fly:before{content:"\f417"}.fa-font-awesome-flag:before,.fa-font-awesome-logo-full:before,.fa-font-awesome:before{content:"\f2b4"}.fa-fonticons:before{content:"\f280"}.fa-fonticons-fi:before{content:"\f3a2"}.fa-fort-awesome:before{content:"\f286"}.fa-fort-awesome-alt:before{content:"\f3a3"}.fa-forumbee:before{content:"\f211"}.fa-foursquare:before{content:"\f180"}.fa-free-code-camp:before{content:"\f2c5"}.fa-freebsd:before{content:"\f3a4"}.fa-fulcrum:before{content:"\f50b"}.fa-galactic-republic:before{content:"\f50c"}.fa-galactic-senate:before{content:"\f50d"}.fa-get-pocket:before{content:"\f265"}.fa-gg:before{content:"\f260"}.fa-gg-circle:before{content:"\f261"}.fa-git:before{content:"\f1d3"}.fa-git-alt:before{content:"\f841"}.fa-git-square:before{content:"\f1d2"}.fa-github:before{content:"\f09b"}.fa-github-alt:before{content:"\f113"}.fa-github-square:before{content:"\f092"}.fa-gitkraken:before{content:"\f3a6"}.fa-gitlab:before{content:"\f296"}.fa-gitter:before{content:"\f426"}.fa-glide:before{content:"\f2a5"}.fa-glide-g:before{content:"\f2a6"}.fa-gofore:before{content:"\f3a7"}.fa-golang:before{content:"\e40f"}.fa-goodreads:before{content:"\f3a8"}.fa-goodreads-g:before{content:"\f3a9"}.fa-google:before{content:"\f1a0"}.fa-google-drive:before{content:"\f3aa"}.fa-google-pay:before{content:"\e079"}.fa-google-play:before{content:"\f3ab"}.fa-google-plus:before{content:"\f2b3"}.fa-google-plus-g:before{content:"\f0d5"}.fa-google-plus-square:before{content:"\f0d4"}.fa-google-wallet:before{content:"\f1ee"}.fa-gratipay:before{content:"\f184"}.fa-grav:before{content:"\f2d6"}.fa-gripfire:before{content:"\f3ac"}.fa-grunt:before{content:"\f3ad"}.fa-guilded:before{content:"\e07e"}.fa-gulp:before{content:"\f3ae"}.fa-hacker-news:before{content:"\f1d4"}.fa-hacker-news-square:before{content:"\f3af"}.fa-hackerrank:before{content:"\f5f7"}.fa-hashnode:before{content:"\e499"}.fa-hips:before{content:"\f452"}.fa-hire-a-helper:before{content:"\f3b0"}.fa-hive:before{content:"\e07f"}.fa-hooli:before{content:"\f427"}.fa-hornbill:before{content:"\f592"}.fa-hotjar:before{content:"\f3b1"}.fa-houzz:before{content:"\f27c"}.fa-html5:before{content:"\f13b"}.fa-hubspot:before{content:"\f3b2"}.fa-ideal:before{content:"\e013"}.fa-imdb:before{content:"\f2d8"}.fa-instagram:before{content:"\f16d"}.fa-instagram-square:before{content:"\e055"}.fa-instalod:before{content:"\e081"}.fa-intercom:before{content:"\f7af"}.fa-internet-explorer:before{content:"\f26b"}.fa-invision:before{content:"\f7b0"}.fa-ioxhost:before{content:"\f208"}.fa-itch-io:before{content:"\f83a"}.fa-itunes:before{content:"\f3b4"}.fa-itunes-note:before{content:"\f3b5"}.fa-java:before{content:"\f4e4"}.fa-jedi-order:before{content:"\f50e"}.fa-jenkins:before{content:"\f3b6"}.fa-jira:before{content:"\f7b1"}.fa-joget:before{content:"\f3b7"}.fa-joomla:before{content:"\f1aa"}.fa-js:before{content:"\f3b8"}.fa-js-square:before{content:"\f3b9"}.fa-jsfiddle:before{content:"\f1cc"}.fa-kaggle:before{content:"\f5fa"}.fa-keybase:before{content:"\f4f5"}.fa-keycdn:before{content:"\f3ba"}.fa-kickstarter:before{content:"\f3bb"}.fa-kickstarter-k:before{content:"\f3bc"}.fa-korvue:before{content:"\f42f"}.fa-laravel:before{content:"\f3bd"}.fa-lastfm:before{content:"\f202"}.fa-lastfm-square:before{content:"\f203"}.fa-leanpub:before{content:"\f212"}.fa-less:before{content:"\f41d"}.fa-line:before{content:"\f3c0"}.fa-linkedin:before{content:"\f08c"}.fa-linkedin-in:before{content:"\f0e1"}.fa-linode:before{content:"\f2b8"}.fa-linux:before{content:"\f17c"}.fa-lyft:before{content:"\f3c3"}.fa-magento:before{content:"\f3c4"}.fa-mailchimp:before{content:"\f59e"}.fa-mandalorian:before{content:"\f50f"}.fa-markdown:before{content:"\f60f"}.fa-mastodon:before{content:"\f4f6"}.fa-maxcdn:before{content:"\f136"}.fa-mdb:before{content:"\f8ca"}.fa-medapps:before{content:"\f3c6"}.fa-medium-m:before,.fa-medium:before{content:"\f23a"}.fa-medrt:before{content:"\f3c8"}.fa-meetup:before{content:"\f2e0"}.fa-megaport:before{content:"\f5a3"}.fa-mendeley:before{content:"\f7b3"}.fa-microblog:before{content:"\e01a"}.fa-microsoft:before{content:"\f3ca"}.fa-mix:before{content:"\f3cb"}.fa-mixcloud:before{content:"\f289"}.fa-mixer:before{content:"\e056"}.fa-mizuni:before{content:"\f3cc"}.fa-modx:before{content:"\f285"}.fa-monero:before{content:"\f3d0"}.fa-napster:before{content:"\f3d2"}.fa-neos:before{content:"\f612"}.fa-nimblr:before{content:"\f5a8"}.fa-node:before{content:"\f419"}.fa-node-js:before{content:"\f3d3"}.fa-npm:before{content:"\f3d4"}.fa-ns8:before{content:"\f3d5"}.fa-nutritionix:before{content:"\f3d6"}.fa-octopus-deploy:before{content:"\e082"}.fa-odnoklassniki:before{content:"\f263"}.fa-odnoklassniki-square:before{content:"\f264"}.fa-old-republic:before{content:"\f510"}.fa-opencart:before{content:"\f23d"}.fa-openid:before{content:"\f19b"}.fa-opera:before{content:"\f26a"}.fa-optin-monster:before{content:"\f23c"}.fa-orcid:before{content:"\f8d2"}.fa-osi:before{content:"\f41a"}.fa-padlet:before{content:"\e4a0"}.fa-page4:before{content:"\f3d7"}.fa-pagelines:before{content:"\f18c"}.fa-palfed:before{content:"\f3d8"}.fa-patreon:before{content:"\f3d9"}.fa-paypal:before{content:"\f1ed"}.fa-perbyte:before{content:"\e083"}.fa-periscope:before{content:"\f3da"}.fa-phabricator:before{content:"\f3db"}.fa-phoenix-framework:before{content:"\f3dc"}.fa-phoenix-squadron:before{content:"\f511"}.fa-php:before{content:"\f457"}.fa-pied-piper:before{content:"\f2ae"}.fa-pied-piper-alt:before{content:"\f1a8"}.fa-pied-piper-hat:before{content:"\f4e5"}.fa-pied-piper-pp:before{content:"\f1a7"}.fa-pied-piper-square:before{content:"\e01e"}.fa-pinterest:before{content:"\f0d2"}.fa-pinterest-p:before{content:"\f231"}.fa-pinterest-square:before{content:"\f0d3"}.fa-pix:before{content:"\e43a"}.fa-playstation:before{content:"\f3df"}.fa-product-hunt:before{content:"\f288"}.fa-pushed:before{content:"\f3e1"}.fa-python:before{content:"\f3e2"}.fa-qq:before{content:"\f1d6"}.fa-quinscape:before{content:"\f459"}.fa-quora:before{content:"\f2c4"}.fa-r-project:before{content:"\f4f7"}.fa-raspberry-pi:before{content:"\f7bb"}.fa-ravelry:before{content:"\f2d9"}.fa-react:before{content:"\f41b"}.fa-reacteurope:before{content:"\f75d"}.fa-readme:before{content:"\f4d5"}.fa-rebel:before{content:"\f1d0"}.fa-red-river:before{content:"\f3e3"}.fa-reddit:before{content:"\f1a1"}.fa-reddit-alien:before{content:"\f281"}.fa-reddit-square:before{content:"\f1a2"}.fa-redhat:before{content:"\f7bc"}.fa-renren:before{content:"\f18b"}.fa-replyd:before{content:"\f3e6"}.fa-researchgate:before{content:"\f4f8"}.fa-resolving:before{content:"\f3e7"}.fa-rev:before{content:"\f5b2"}.fa-rocketchat:before{content:"\f3e8"}.fa-rockrms:before{content:"\f3e9"}.fa-rust:before{content:"\e07a"}.fa-safari:before{content:"\f267"}.fa-salesforce:before{content:"\f83b"}.fa-sass:before{content:"\f41e"}.fa-schlix:before{content:"\f3ea"}.fa-scribd:before{content:"\f28a"}.fa-searchengin:before{content:"\f3eb"}.fa-sellcast:before{content:"\f2da"}.fa-sellsy:before{content:"\f213"}.fa-servicestack:before{content:"\f3ec"}.fa-shirtsinbulk:before{content:"\f214"}.fa-shopify:before{content:"\e057"}.fa-shopware:before{content:"\f5b5"}.fa-simplybuilt:before{content:"\f215"}.fa-sistrix:before{content:"\f3ee"}.fa-sith:before{content:"\f512"}.fa-sitrox:before{content:"\e44a"}.fa-sketch:before{content:"\f7c6"}.fa-skyatlas:before{content:"\f216"}.fa-skype:before{content:"\f17e"}.fa-slack-hash:before,.fa-slack:before{content:"\f198"}.fa-slideshare:before{content:"\f1e7"}.fa-snapchat-ghost:before,.fa-snapchat:before{content:"\f2ab"}.fa-snapchat-square:before{content:"\f2ad"}.fa-soundcloud:before{content:"\f1be"}.fa-sourcetree:before{content:"\f7d3"}.fa-speakap:before{content:"\f3f3"}.fa-speaker-deck:before{content:"\f83c"}.fa-spotify:before{content:"\f1bc"}.fa-square-font-awesome:before{content:"\f425"}.fa-font-awesome-alt:before,.fa-square-font-awesome-stroke:before{content:"\f35c"}.fa-squarespace:before{content:"\f5be"}.fa-stack-exchange:before{content:"\f18d"}.fa-stack-overflow:before{content:"\f16c"}.fa-stackpath:before{content:"\f842"}.fa-staylinked:before{content:"\f3f5"}.fa-steam:before{content:"\f1b6"}.fa-steam-square:before{content:"\f1b7"}.fa-steam-symbol:before{content:"\f3f6"}.fa-sticker-mule:before{content:"\f3f7"}.fa-strava:before{content:"\f428"}.fa-stripe:before{content:"\f429"}.fa-stripe-s:before{content:"\f42a"}.fa-studiovinari:before{content:"\f3f8"}.fa-stumbleupon:before{content:"\f1a4"}.fa-stumbleupon-circle:before{content:"\f1a3"}.fa-superpowers:before{content:"\f2dd"}.fa-supple:before{content:"\f3f9"}.fa-suse:before{content:"\f7d6"}.fa-swift:before{content:"\f8e1"}.fa-symfony:before{content:"\f83d"}.fa-teamspeak:before{content:"\f4f9"}.fa-telegram-plane:before,.fa-telegram:before{content:"\f2c6"}.fa-tencent-weibo:before{content:"\f1d5"}.fa-the-red-yeti:before{content:"\f69d"}.fa-themeco:before{content:"\f5c6"}.fa-themeisle:before{content:"\f2b2"}.fa-think-peaks:before{content:"\f731"}.fa-tiktok:before{content:"\e07b"}.fa-trade-federation:before{content:"\f513"}.fa-trello:before{content:"\f181"}.fa-tumblr:before{content:"\f173"}.fa-tumblr-square:before{content:"\f174"}.fa-twitch:before{content:"\f1e8"}.fa-twitter:before{content:"\f099"}.fa-twitter-square:before{content:"\f081"}.fa-typo3:before{content:"\f42b"}.fa-uber:before{content:"\f402"}.fa-ubuntu:before{content:"\f7df"}.fa-uikit:before{content:"\f403"}.fa-umbraco:before{content:"\f8e8"}.fa-uncharted:before{content:"\e084"}.fa-uniregistry:before{content:"\f404"}.fa-unity:before{content:"\e049"}.fa-unsplash:before{content:"\e07c"}.fa-untappd:before{content:"\f405"}.fa-ups:before{content:"\f7e0"}.fa-usb:before{content:"\f287"}.fa-usps:before{content:"\f7e1"}.fa-ussunnah:before{content:"\f407"}.fa-vaadin:before{content:"\f408"}.fa-viacoin:before{content:"\f237"}.fa-viadeo:before{content:"\f2a9"}.fa-viadeo-square:before{content:"\f2aa"}.fa-viber:before{content:"\f409"}.fa-vimeo:before{content:"\f40a"}.fa-vimeo-square:before{content:"\f194"}.fa-vimeo-v:before{content:"\f27d"}.fa-vine:before{content:"\f1ca"}.fa-vk:before{content:"\f189"}.fa-vnv:before{content:"\f40b"}.fa-vuejs:before{content:"\f41f"}.fa-watchman-monitoring:before{content:"\e087"}.fa-waze:before{content:"\f83f"}.fa-weebly:before{content:"\f5cc"}.fa-weibo:before{content:"\f18a"}.fa-weixin:before{content:"\f1d7"}.fa-whatsapp:before{content:"\f232"}.fa-whatsapp-square:before{content:"\f40c"}.fa-whmcs:before{content:"\f40d"}.fa-wikipedia-w:before{content:"\f266"}.fa-windows:before{content:"\f17a"}.fa-wirsindhandwerk:before,.fa-wsh:before{content:"\e2d0"}.fa-wix:before{content:"\f5cf"}.fa-wizards-of-the-coast:before{content:"\f730"}.fa-wodu:before{content:"\e088"}.fa-wolf-pack-battalion:before{content:"\f514"}.fa-wordpress:before{content:"\f19a"}.fa-wordpress-simple:before{content:"\f411"}.fa-wpbeginner:before{content:"\f297"}.fa-wpexplorer:before{content:"\f2de"}.fa-wpforms:before{content:"\f298"}.fa-wpressr:before{content:"\f3e4"}.fa-xbox:before{content:"\f412"}.fa-xing:before{content:"\f168"}.fa-xing-square:before{content:"\f169"}.fa-y-combinator:before{content:"\f23b"}.fa-yahoo:before{content:"\f19e"}.fa-yammer:before{content:"\f840"}.fa-yandex:before{content:"\f413"}.fa-yandex-international:before{content:"\f414"}.fa-yarn:before{content:"\f7e3"}.fa-yelp:before{content:"\f1e9"}.fa-yoast:before{content:"\f2b1"}.fa-youtube:before{content:"\f167"}.fa-youtube-square:before{content:"\f431"}.fa-zhihu:before{content:"\f63f"}:host,:root{--fa-font-regular:normal 400 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype")}.fa-regular,.far{font-family:"Font Awesome 6 Free";font-weight:400}:host,:root{--fa-font-solid:normal 900 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}.fa-solid,.fas{font-family:"Font Awesome 6 Free";font-weight:900}@font-face{font-family:"Font Awesome 5 Brands";font-display:block;font-weight:400;src:url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.ttf) format("truetype")}@font-face{font-family:"Font Awesome 5 Free";font-display:block;font-weight:900;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}@font-face{font-family:"Font Awesome 5 Free";font-display:block;font-weight:400;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype");unicode-range:u+f003,u+f006,u+f014,u+f016-f017,u+f01a-f01b,u+f01d,u+f022,u+f03e,u+f044,u+f046,u+f05c-f05d,u+f06e,u+f070,u+f087-f088,u+f08a,u+f094,u+f096-f097,u+f09d,u+f0a0,u+f0a2,u+f0a4-f0a7,u+f0c5,u+f0c7,u+f0e5-f0e6,u+f0eb,u+f0f6-f0f8,u+f10c,u+f114-f115,u+f118-f11a,u+f11c-f11d,u+f133,u+f147,u+f14e,u+f150-f152,u+f185-f186,u+f18e,u+f190-f192,u+f196,u+f1c1-f1c9,u+f1d9,u+f1db,u+f1e3,u+f1ea,u+f1f7,u+f1f9,u+f20a,u+f247-f248,u+f24a,u+f24d,u+f255-f25b,u+f25d,u+f271-f274,u+f278,u+f27b,u+f28c,u+f28e,u+f29c,u+f2b5,u+f2b7,u+f2ba,u+f2bc,u+f2be,u+f2c0-f2c1,u+f2c3,u+f2d0,u+f2d2,u+f2d4,u+f2dc}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-v4compatibility.woff2) format("woff2"),url(../webfonts/fa-v4compatibility.ttf) format("truetype");unicode-range:u+f041,u+f047,u+f065-f066,u+f07d-f07e,u+f080,u+f08b,u+f08e,u+f090,u+f09a,u+f0ac,u+f0ae,u+f0b2,u+f0d0,u+f0d6,u+f0e4,u+f0ec,u+f10a-f10b,u+f123,u+f13e,u+f148-f149,u+f14c,u+f156,u+f15e,u+f160-f161,u+f163,u+f175-f178,u+f195,u+f1f8,u+f219,u+f250,u+f252,u+f27a} \ No newline at end of file diff --git a/apps/web/public/wesp/css/notyf.min.css b/apps/web/public/wesp/css/notyf.min.css new file mode 100644 index 0000000..dbb5a16 --- /dev/null +++ b/apps/web/public/wesp/css/notyf.min.css @@ -0,0 +1 @@ +@-webkit-keyframes notyf-fadeinup{0%{opacity:0;transform:translateY(25%)}to{opacity:1;transform:translateY(0)}}@keyframes notyf-fadeinup{0%{opacity:0;transform:translateY(25%)}to{opacity:1;transform:translateY(0)}}@-webkit-keyframes notyf-fadeinleft{0%{opacity:0;transform:translateX(25%)}to{opacity:1;transform:translateX(0)}}@keyframes notyf-fadeinleft{0%{opacity:0;transform:translateX(25%)}to{opacity:1;transform:translateX(0)}}@-webkit-keyframes notyf-fadeoutright{0%{opacity:1;transform:translateX(0)}to{opacity:0;transform:translateX(25%)}}@keyframes notyf-fadeoutright{0%{opacity:1;transform:translateX(0)}to{opacity:0;transform:translateX(25%)}}@-webkit-keyframes notyf-fadeoutdown{0%{opacity:1;transform:translateY(0)}to{opacity:0;transform:translateY(25%)}}@keyframes notyf-fadeoutdown{0%{opacity:1;transform:translateY(0)}to{opacity:0;transform:translateY(25%)}}@-webkit-keyframes ripple{0%{transform:scale(0) translateY(-45%) translateX(13%)}to{transform:scale(1) translateY(-45%) translateX(13%)}}@keyframes ripple{0%{transform:scale(0) translateY(-45%) translateX(13%)}to{transform:scale(1) translateY(-45%) translateX(13%)}}.notyf{position:fixed;top:0;left:0;height:100%;width:100%;color:#fff;z-index:9999;display:flex;flex-direction:column;align-items:flex-end;justify-content:flex-end;pointer-events:none;box-sizing:border-box;padding:20px}.notyf__icon--error,.notyf__icon--success{height:21px;width:21px;background:#fff;border-radius:50%;display:block;margin:0 auto;position:relative}.notyf__icon--error:after,.notyf__icon--error:before{content:"";background:currentColor;display:block;position:absolute;width:3px;border-radius:3px;left:9px;height:12px;top:5px}.notyf__icon--error:after{transform:rotate(-45deg)}.notyf__icon--error:before{transform:rotate(45deg)}.notyf__icon--success:after,.notyf__icon--success:before{content:"";background:currentColor;display:block;position:absolute;width:3px;border-radius:3px}.notyf__icon--success:after{height:6px;transform:rotate(-45deg);top:9px;left:6px}.notyf__icon--success:before{height:11px;transform:rotate(45deg);top:5px;left:10px}.notyf__toast{display:block;overflow:hidden;pointer-events:auto;-webkit-animation:notyf-fadeinup .3s ease-in forwards;animation:notyf-fadeinup .3s ease-in forwards;box-shadow:0 3px 7px 0 rgba(0,0,0,.25);position:relative;padding:0 15px;border-radius:2px;max-width:300px;transform:translateY(25%);box-sizing:border-box;flex-shrink:0}.notyf__toast--disappear{transform:translateY(0);-webkit-animation:notyf-fadeoutdown .3s forwards;animation:notyf-fadeoutdown .3s forwards;-webkit-animation-delay:.25s;animation-delay:.25s}.notyf__toast--disappear .notyf__icon,.notyf__toast--disappear .notyf__message{-webkit-animation:notyf-fadeoutdown .3s forwards;animation:notyf-fadeoutdown .3s forwards;opacity:1;transform:translateY(0)}.notyf__toast--disappear .notyf__dismiss{-webkit-animation:notyf-fadeoutright .3s forwards;animation:notyf-fadeoutright .3s forwards;opacity:1;transform:translateX(0)}.notyf__toast--disappear .notyf__message{-webkit-animation-delay:.05s;animation-delay:.05s}.notyf__toast--upper{margin-bottom:20px}.notyf__toast--lower{margin-top:20px}.notyf__toast--dismissible .notyf__wrapper{padding-right:30px}.notyf__ripple{height:400px;width:400px;position:absolute;transform-origin:bottom right;right:0;top:0;border-radius:50%;transform:scale(0) translateY(-51%) translateX(13%);z-index:5;-webkit-animation:ripple .4s ease-out forwards;animation:ripple .4s ease-out forwards}.notyf__wrapper{display:flex;align-items:center;padding-top:17px;padding-bottom:17px;padding-right:15px;border-radius:3px;position:relative;z-index:10}.notyf__icon{width:22px;text-align:center;font-size:1.3em;opacity:0;-webkit-animation:notyf-fadeinup .3s forwards;animation:notyf-fadeinup .3s forwards;-webkit-animation-delay:.3s;animation-delay:.3s;margin-right:13px}.notyf__dismiss{position:absolute;top:0;right:0;height:100%;width:26px;margin-right:-15px;-webkit-animation:notyf-fadeinleft .3s forwards;animation:notyf-fadeinleft .3s forwards;-webkit-animation-delay:.35s;animation-delay:.35s;opacity:0}.notyf__dismiss-btn{background-color:rgba(0,0,0,.25);border:none;cursor:pointer;transition:opacity .2s ease,background-color .2s ease;outline:none;opacity:.35;height:100%;width:100%}.notyf__dismiss-btn:after,.notyf__dismiss-btn:before{content:"";background:#fff;height:12px;width:2px;border-radius:3px;position:absolute;left:calc(50% - 1px);top:calc(50% - 5px)}.notyf__dismiss-btn:after{transform:rotate(-45deg)}.notyf__dismiss-btn:before{transform:rotate(45deg)}.notyf__dismiss-btn:hover{opacity:.7;background-color:rgba(0,0,0,.15)}.notyf__dismiss-btn:active{opacity:.8}.notyf__message{vertical-align:middle;position:relative;opacity:0;-webkit-animation:notyf-fadeinup .3s forwards;animation:notyf-fadeinup .3s forwards;-webkit-animation-delay:.25s;animation-delay:.25s;line-height:1.5em}@media only screen and (max-width:480px){.notyf{padding:0}.notyf__ripple{height:600px;width:600px;-webkit-animation-duration:.5s;animation-duration:.5s}.notyf__toast{max-width:none;border-radius:0;box-shadow:0 -2px 7px 0 rgba(0,0,0,.13);width:100%}.notyf__dismiss{width:56px}} \ No newline at end of file diff --git a/apps/web/public/wesp/css/wesp-lab-profiles.css b/apps/web/public/wesp/css/wesp-lab-profiles.css new file mode 100644 index 0000000..2d9a5f3 --- /dev/null +++ b/apps/web/public/wesp/css/wesp-lab-profiles.css @@ -0,0 +1,239 @@ +.lab-profiles-page .lab-profiles-header { + margin-bottom: 1rem; +} + +.lab-profiles-page .lab-profiles-header h1 { + font-size: 1.35rem; + font-weight: 700; + margin: 0 0 0.35rem; +} + +.lab-profiles-page.lab-sandbox-page .lab-profiles-header p { + color: #f8fafc; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85); +} + +.lab-profiles-page.lab-sandbox-page .lab-profiles-header a { + color: #f8fafc; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85); +} + +.lab-profiles-page.lab-sandbox-page .lab-profiles-header a:hover { + color: #fde047; +} + +.lab-profiles-layout { + display: grid; + grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); + gap: 1rem; + align-items: start; +} + +@media (max-width: 992px) { + .lab-profiles-layout { + grid-template-columns: 1fr; + } +} + +.lab-profiles-list { + max-height: calc(100vh - 12rem); + overflow: auto; +} + +.lab-profiles-list button { + display: block; + width: 100%; + text-align: left; + border: none; + background: transparent; + padding: 0.45rem 0.5rem; + border-radius: 8px; + font-size: 0.8125rem; + cursor: pointer; +} + +.lab-profiles-list button:hover { + background: rgba(3, 105, 161, 0.08); +} + +.lab-profiles-list button.is-active { + background: #e0f2fe; + font-weight: 600; +} + +html[data-theme="dark"] .lab-profiles-list button.is-active { + background: rgba(3, 105, 161, 0.25); +} + +.lab-profiles-list .profile-key { + display: block; + font-size: 0.7rem; + color: var(--text-muted, #64748b); +} + +.lab-profiles-editor table { + width: 100%; + font-size: 0.8125rem; +} + +.lab-profiles-editor th { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--text-muted, #64748b); + white-space: nowrap; +} + +.lab-profiles-editor td { + vertical-align: middle; +} + +.lab-profiles-editor input[type="number"] { + min-width: 5.5rem; + max-width: 7rem; +} + +.lab-profiles-editor .norm-calc-badge { + font-size: 0.65rem; + padding: 0.1rem 0.35rem; + border-radius: 4px; + background: #dbeafe; + color: #1d4ed8; +} + +.lab-profiles-editor .norm-source-badge { + display: inline-block; + font-size: 0.62rem; + padding: 0.08rem 0.32rem; + border-radius: 4px; + margin-right: 0.2rem; + white-space: nowrap; + font-weight: 600; + letter-spacing: 0.02em; +} + +.lab-profiles-editor .norm-source-badge--db { + background: #f1f5f9; + color: #475569; +} + +.lab-profiles-editor .norm-source-badge--racion { + background: #fef3c7; + color: #b45309; +} + +.lab-profiles-editor .norm-source-badge--derived { + background: #ede9fe; + color: #6d28d9; +} + +.lab-profiles-editor .norm-source-badge--fallback, +.lab-profiles-editor .norm-source-badge--stored { + background: #dcfce7; + color: #15803d; +} + +.lab-profiles-editor .norm-source-badge--gfe { + background: #e0f2fe; + color: #0369a1; +} + +.lab-profiles-editor .norm-source-badge--empty { + background: transparent; + color: var(--text-muted, #94a3b8); + font-weight: 400; +} + +.lab-profiles-source-cell { + white-space: nowrap; +} + +.lab-profiles-norms-summary strong { + font-weight: 600; +} + +html[data-theme="dark"] .lab-profiles-editor .norm-calc-badge { + background: rgba(29, 78, 216, 0.25); + color: #93c5fd; +} + +.lab-profiles-toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + margin-bottom: 0.75rem; +} + +.lab-profiles-meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.lab-profiles-table-wrap { + max-height: calc(100vh - 18rem); + overflow: auto; +} + +.lab-profiles-gfe { + font-size: 0.8125rem; +} + +.lab-profiles-gfe strong { + font-weight: 600; +} + +.lab-profiles-seed { + font-size: 0.8125rem; +} + +.lab-profiles-seed select { + max-width: 100%; +} + +.lab-heisenberg-prime .lab-profiles-list button:hover { + background: rgba(0, 200, 255, 0.1); + color: #f8fafc; +} + +.lab-heisenberg-prime .lab-profiles-list button.is-active { + background: rgba(253, 224, 71, 0.15); + color: var(--lab-prime-yellow, #fde047); + box-shadow: inset 0 0 0 1px rgba(253, 224, 71, 0.3); +} + +.lab-heisenberg-prime .lab-profiles-list .profile-key { + color: #94a3b8; +} + +.lab-heisenberg-prime .lab-profiles-editor th { + color: var(--lab-prime-crystal, #00c8ff); +} + +.lab-heisenberg-prime .lab-profiles-editor .norm-calc-badge { + background: rgba(0, 200, 255, 0.15); + color: var(--lab-prime-crystal, #00c8ff); +} + +.lab-heisenberg-prime .lab-profiles-editor .norm-source-badge--racion { + background: rgba(253, 224, 71, 0.2); + color: var(--lab-prime-yellow, #fde047); +} + +.lab-heisenberg-prime .lab-profiles-editor .norm-source-badge--derived { + background: rgba(167, 139, 250, 0.2); + color: #c4b5fd; +} + +.lab-heisenberg-prime .lab-profiles-editor .norm-source-badge--fallback, +.lab-heisenberg-prime .lab-profiles-editor .norm-source-badge--stored { + background: rgba(34, 197, 94, 0.15); + color: #86efac; +} + +.lab-heisenberg-prime .lab-profiles-editor .norm-source-badge--db { + background: rgba(148, 163, 184, 0.2); + color: #cbd5e1; +} diff --git a/apps/web/public/wesp/css/wesp-lab-sandbox.css b/apps/web/public/wesp/css/wesp-lab-sandbox.css new file mode 100644 index 0000000..435209d --- /dev/null +++ b/apps/web/public/wesp/css/wesp-lab-sandbox.css @@ -0,0 +1,667 @@ +.lab-sandbox-page { + position: relative; + min-height: 100vh; + background-color: transparent; +} + +/* Прозрачный body — иначе --bg-page перекрывает фон Heisenberg (::before) */ +body.zootech-app-body.lab-sandbox-page { + background-color: transparent !important; +} + +html:has(body.lab-sandbox-page) { + background-color: transparent; +} + +.lab-sandbox-page::before { + content: ""; + position: fixed; + inset: 0; + z-index: -2; + background: url("/static/img/lab-heisenberg-bg.jpg") center center / cover no-repeat; + background-attachment: fixed; +} + +.lab-sandbox-page::after { + content: ""; + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; + background: linear-gradient( + 180deg, + rgba(0, 0, 0, 0.35) 0%, + rgba(0, 0, 0, 0.08) 40%, + rgba(0, 0, 0, 0.2) 100% + ); +} + +.lab-sandbox-page .wesp-page-main { + background: transparent; +} + +.lab-sandbox-page .lab-sandbox-header { + margin-bottom: 1.25rem; +} + +.lab-sandbox-page .lab-sandbox-header h1, +.lab-sandbox-page .lab-sandbox-header h2, +.lab-sandbox-page .lab-sandbox-header p, +.lab-sandbox-page .lab-sandbox-header a { + color: #f8fafc; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85); +} + +.lab-sandbox-page .lab-sandbox-header a:hover { + color: #fde047; +} + +.lab-sandbox-page .lab-sandbox-header h1 { + font-size: 1.35rem; + font-weight: 700; + margin: 0 0 0.35rem; +} + +.lab-sandbox-page .lab-sandbox-header p { + margin: 0; + color: var(--text-muted, #64748b); + font-size: 0.9rem; +} + +.lab-sandbox-flow { + display: grid; + grid-template-columns: minmax(260px, 320px) 1fr minmax(280px, 360px); + gap: 1rem; + align-items: start; +} + +@media (max-width: 1200px) { + .lab-sandbox-flow { + grid-template-columns: 1fr; + } +} + +.lab-sandbox-panel { + background: var(--bg-white, #fff); + border: 1px solid var(--border-light, #e2e8f0); + border-radius: 12px; + padding: 1rem; +} + +html[data-theme="dark"] .lab-sandbox-panel { + background: var(--bg-white, #1e2428); + border-color: rgba(255, 255, 255, 0.12); +} + +.lab-sandbox-panel__title { + font-size: 0.8rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted, #64748b); + margin-bottom: 0.75rem; +} + +.lab-sandbox-field { + margin-bottom: 0.65rem; +} + +.lab-sandbox-field label { + font-size: 0.8125rem; + font-weight: 500; + margin-bottom: 0.2rem; +} + +.lab-sandbox-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.lab-sandbox-table { + width: 100%; + font-size: 0.875rem; +} + +.lab-sandbox-table th { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--text-muted, #64748b); + white-space: nowrap; +} + +.lab-sandbox-table td { + vertical-align: middle; +} + +.lab-sandbox-table .form-control, +.lab-sandbox-table .form-select { + min-width: 0; +} + +.lab-sandbox-results table { + width: 100%; + font-size: 0.8125rem; +} + +.lab-sandbox-results th, +.lab-sandbox-results td { + padding: 0.35rem 0.4rem; + border-bottom: 1px solid var(--border-light, #e2e8f0); +} + +.lab-sandbox-diff--ok { + color: #15803d; + font-weight: 600; +} + +.lab-sandbox-diff--bad { + color: #b91c1c; + font-weight: 600; +} + +.lab-sandbox-diff--na { + color: var(--text-muted, #94a3b8); +} + +.lab-sandbox-norm--empty { + color: var(--text-muted, #94a3b8); + font-style: italic; + font-size: 0.78rem; +} + +.lab-sandbox-norms { + font-size: 0.78rem; + max-height: 220px; + overflow: auto; +} + +.lab-sandbox-norms dt { + font-weight: 600; + margin-top: 0.35rem; +} + +.lab-sandbox-norms dd { + margin: 0 0 0.25rem; + color: var(--text-muted, #64748b); +} + +.lab-sandbox-empty { + color: var(--text-muted, #94a3b8); + font-size: 0.875rem; + text-align: center; + padding: 1.5rem 0.5rem; +} + +.lab-sandbox-step { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.75rem; + font-weight: 600; + padding: 0.2rem 0.5rem; + border-radius: 999px; + background: #e0f2fe; + color: #0369a1; + margin-bottom: 0.75rem; +} + +html[data-theme="dark"] .lab-sandbox-step { + background: rgba(3, 105, 161, 0.25); + color: #7dd3fc; +} + +.lab-formulate-section { + border-top: 1px solid var(--border-light, #e2e8f0); + padding-top: 1.5rem; +} + +.lab-agrostar-section { + border-top: 1px solid var(--border-light, #e2e8f0); + padding-top: 1.5rem; +} + +.lab-agrostar-drop { + border: 2px dashed var(--border-light, #cbd5e1); + border-radius: 0.75rem; + padding: 2rem 1rem; + text-align: center; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + background: rgba(248, 250, 252, 0.6); +} + +html[data-theme="dark"] .lab-agrostar-drop { + background: rgba(15, 23, 42, 0.35); +} + +.lab-agrostar-drop:hover, +.lab-agrostar-drop:focus-visible { + border-color: #38bdf8; + outline: none; +} + +.lab-agrostar-drop--active { + border-color: #0ea5e9; + background: rgba(224, 242, 254, 0.45); +} + +html[data-theme="dark"] .lab-agrostar-drop--active { + background: rgba(3, 105, 161, 0.2); +} + +.lab-agrostar-drop__icon { + font-size: 2rem; + color: #0ea5e9; + margin-bottom: 0.5rem; +} + +.lab-agrostar-drop__title { + font-weight: 600; + margin: 0; +} + +.lab-agrostar-drop__file { + color: #0369a1; + font-weight: 500; +} + +.lab-agrostar-warn { + display: inline-block; + font-size: 0.7rem; + padding: 0.1rem 0.35rem; + border-radius: 0.25rem; + background: #fef3c7; + color: #92400e; + margin: 0.1rem 0.15rem 0.1rem 0; +} + +html[data-theme="dark"] .lab-agrostar-warn { + background: rgba(146, 64, 14, 0.25); + color: #fcd34d; +} + +.lab-agrostar-result--ok { + color: #15803d; +} + +.lab-agrostar-result--err { + color: #b91c1c; +} + +.lab-agrostar-sniff { + margin-top: 1rem; + padding-top: 0.75rem; + border-top: 1px dashed rgba(148, 163, 184, 0.45); +} + +.lab-agrostar-sniff-card { + background: rgba(248, 250, 252, 0.85); + border: 1px solid rgba(148, 163, 184, 0.35); + border-radius: 0.5rem; + padding: 1rem 1.1rem; + margin-bottom: 0.75rem; +} + +html[data-theme="dark"] .lab-agrostar-sniff-card { + background: rgba(15, 23, 42, 0.55); + border-color: rgba(71, 85, 105, 0.6); +} + +.lab-agrostar-sniff-card__head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.75rem; +} + +.lab-agrostar-sniff__target--warn { + color: #92400e; + font-size: 0.875rem; +} + +html[data-theme="dark"] .lab-agrostar-sniff__target--warn { + color: #fcd34d; +} + +.lab-agrostar-sniff-meta th { + width: 38%; + font-weight: 500; + color: #64748b; + vertical-align: top; +} + +.lab-agrostar-sniff-nutrients td:first-child { + max-width: 14rem; +} + +.lab-agrostar-sniff__skip { + color: var(--bs-secondary-color); + line-height: 1.45; +} + +.lab-agrostar-sniff__notes ul { + padding-left: 1.1rem; + color: #92400e; +} + +html[data-theme="dark"] .lab-agrostar-sniff__notes ul { + color: #fcd34d; +} + +/* —— Heisenberg prime (ошибка на /lab) —— */ +.lab-heisenberg-prime-overlay { + position: fixed; + inset: 0; + z-index: 6500; + pointer-events: none; + opacity: 0; + transition: opacity 0.35s ease; + --lab-prime-eye-y: 26.5vh; + --lab-prime-eye-gap: 78px; +} + +.lab-heisenberg-prime-active .lab-heisenberg-prime-overlay { + opacity: 1; +} + +.lab-heisenberg-prime-active.lab-sandbox-page::before { + filter: saturate(0.45) contrast(1.2) brightness(0.62) sepia(0.15); + transform: scale(1.06); + background-position: center 28%; + transition: filter 0.4s ease, transform 0.6s ease, background-position 0.5s ease; +} + +.lab-heisenberg-prime-active.lab-sandbox-page::after { + background: linear-gradient( + 180deg, + rgba(40, 0, 0, 0.35) 0%, + rgba(10, 0, 0, 0.08) 38%, + rgba(50, 0, 0, 0.55) 100% + ); +} + +/* UI убираем — остаётся Walter и баннер prime */ +.lab-heisenberg-prime-active #wespZootechNavMount, +.lab-heisenberg-prime-active .lab-sandbox-header, +.lab-heisenberg-prime-active .lab-sandbox-flow, +.lab-heisenberg-prime-active .lab-agrostar-section, +.lab-heisenberg-prime-active .lab-formulate-section { + opacity: 0 !important; + visibility: hidden !important; + pointer-events: none !important; + transform: translateY(16px) scale(0.97); + transition: opacity 0.35s ease, visibility 0.35s ease, transform 0.35s ease; +} + +.lab-heisenberg-prime-active .wesp-page-main { + filter: none; +} + +.lab-heisenberg-prime__vignette { + position: absolute; + inset: 0; + background: radial-gradient( + ellipse at 50% var(--lab-prime-eye-y), + transparent 0%, + rgba(0, 0, 0, 0.12) 38%, + rgba(80, 0, 0, 0.78) 100% + ); + animation: labPrimeVignette 0.9s ease-in-out infinite alternate; +} + +.lab-heisenberg-prime__glow { + position: absolute; + left: 50%; + top: var(--lab-prime-eye-y); + width: min(36vw, 280px); + height: min(28vw, 200px); + transform: translate(-50%, -50%); + border-radius: 50%; + background: radial-gradient(circle, rgba(220, 38, 38, 0.42) 0%, transparent 68%); + animation: labPrimeGlow 1.1s ease-in-out infinite alternate; +} + +.lab-heisenberg-prime__eyes { + position: absolute; + inset: 0; + pointer-events: none; +} + +.lab-heisenberg-prime__eye { + position: absolute; + top: var(--lab-prime-eye-y); + width: 102px; + height: 58px; + margin: 0; + border-radius: 50%; + background: #ef4444; + box-shadow: + 0 0 10px 3px rgba(239, 98, 98, 0.95), + 0 0 22px 8px rgba(185, 28, 28, 0.6), + 0 0 40px 16px rgba(127, 29, 29, 0.35); + animation: labPrimeEyePulse 0.55s ease-in-out infinite alternate; +} + +.lab-heisenberg-prime__eye--left { + left: calc(50% - var(--lab-prime-eye-gap)); + transform: translate(-50%, -50%); +} + +.lab-heisenberg-prime__eye--right { + left: calc(50% + var(--lab-prime-eye-gap)); + transform: translate(-50%, -50%); +} + +.lab-heisenberg-prime__scanlines { + position: absolute; + inset: 0; + opacity: 0.12; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(255, 255, 255, 0.04) 2px, + rgba(255, 255, 255, 0.04) 4px + ); +} + +.lab-heisenberg-prime__banner { + position: absolute; + left: 50%; + bottom: clamp(1.5rem, 8vh, 4rem); + transform: translateX(-50%); + width: min(92vw, 520px); + padding: 0.85rem 1.1rem; + border-radius: 0.75rem; + text-align: center; + background: rgba(15, 0, 0, 0.82); + border: 1px solid rgba(239, 68, 68, 0.45); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45); + color: #fecaca; + backdrop-filter: blur(6px); +} + +.lab-heisenberg-prime__title { + font-size: 1.15rem; + font-weight: 700; + letter-spacing: 0.04em; + color: #fff; + text-shadow: 0 0 18px rgba(239, 68, 68, 0.8); +} + +.lab-heisenberg-prime__subtitle { + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: #f87171; + margin-top: 0.15rem; +} + +.lab-heisenberg-prime__reason { + margin-top: 0.55rem; + font-size: 0.92rem; + line-height: 1.35; + color: #fee2e2; +} + +.lab-heisenberg-prime__source { + margin-top: 0.35rem; + font-size: 0.72rem; + color: #fca5a5; + opacity: 0.9; +} + +@keyframes labPrimeEyePulse { + from { + opacity: 0.75; + filter: brightness(0.92); + } + to { + opacity: 1; + filter: brightness(1.15); + } +} + +@keyframes labPrimeVignette { + from { + opacity: 0.85; + } + to { + opacity: 1; + } +} + +@keyframes labPrimeGlow { + from { + opacity: 0.45; + transform: translate(-50%, -50%) scale(0.96); + } + to { + opacity: 0.85; + transform: translate(-50%, -50%) scale(1.04); + } +} + +@media (max-width: 768px) { + .lab-heisenberg-prime-overlay { + --lab-prime-eye-y: 24.5vh; + --lab-prime-eye-gap: 1198px; + } +} + +@media (min-width: 1400px) { + .lab-heisenberg-prime-overlay { + --lab-prime-eye-y: 27vh; + --lab-prime-eye-gap: 74px; + } +} + +@media (prefers-reduced-motion: reduce) { + .lab-heisenberg-prime__eye, + .lab-heisenberg-prime__vignette, + .lab-heisenberg-prime__glow { + animation: none; + } + + .lab-heisenberg-prime-overlay { + transition: none; + } +} + +.lab-formulate-steps { + display: flex; + gap: 0.35rem; + flex-wrap: wrap; +} + +.lab-formulate-step { + border: 1px solid var(--border-light, #e2e8f0); + background: transparent; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + padding: 0.25rem 0.65rem; + cursor: pointer; +} + +.lab-formulate-step--active { + background: #e0f2fe; + border-color: #7dd3fc; + color: #0369a1; +} + +.lab-formulate-step-panel--calc.d-none { + display: none !important; +} + +.lab-formulate-pool-wrap { + max-height: 320px; + overflow: auto; +} + +.lab-formulate-badge { + display: inline-block; + font-size: 0.7rem; + font-weight: 600; + padding: 0.1rem 0.45rem; + border-radius: 999px; +} + +.lab-formulate-badge--ok { + background: #dcfce7; + color: #166534; +} + +.lab-formulate-badge--bad { + background: #fee2e2; + color: #991b1b; +} + +.lab-formulate-row--ineligible { + opacity: 0.55; +} + +.lab-formulate-opt-keys { + display: flex; + flex-direction: column; + gap: 0.35rem; + max-height: 220px; + overflow: auto; + font-size: 0.8rem; +} + +.lab-formulate-opt-key { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.25rem 0.5rem; + align-items: start; + cursor: pointer; +} + +.lab-formulate-opt-key small { + grid-column: 2; +} + +.lab-formulate-result-meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1rem; + font-size: 0.75rem; + color: var(--text-muted, #64748b); + margin-bottom: 0.5rem; +} + +.lab-formulate-row--target td { + background: rgba(14, 165, 233, 0.08); + font-weight: 600; +} + +.lab-formulate-row--target td.lab-sandbox-diff--bad, +.lab-formulate-row--target td.lab-sandbox-diff--ok { + font-size: 0.95rem; +} diff --git a/apps/web/public/wesp/css/wesp-recipes-editor.css b/apps/web/public/wesp/css/wesp-recipes-editor.css new file mode 100644 index 0000000..27955fd --- /dev/null +++ b/apps/web/public/wesp/css/wesp-recipes-editor.css @@ -0,0 +1,521 @@ +/** + * Recipe editor — form grid, section cards, CSS Grid data tables (#recipeEdit). + * Loaded after zootech shell/components; replaces Bootstrap .table in editor sections. + */ + +/* —— Form 2×2 grid —— */ +#recipeEdit .recipe-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem 1.25rem; + margin-bottom: 0; + align-items: start; +} + +#recipeEdit .recipe-form-grid .form-group { + margin-bottom: 0; + min-width: 0; +} + +#recipeEdit .recipe-form-grid .form-group label, +#recipeEdit .recipe-form-grid .zt-form-label-with-help { + display: block; + margin-bottom: 0.5rem; + font-weight: 600; + font-size: 0.875rem; + color: var(--text-primary); + min-height: 1.375rem; + line-height: 1.375rem; +} + +#recipeEdit .zt-form-label-with-help { + position: relative; + padding-right: 2rem; +} + +#recipeEdit .zt-form-label-with-help .recipe-help-icon-btn { + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + margin: 0; + width: 1.5rem; + height: 1.5rem; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} + +#recipeEdit .recipe-form-grid .form-control { + width: 100%; + max-width: 100%; + box-sizing: border-box; +} + +#recipeEdit .recipe-form-grid + .zt-notice { + margin-top: 0.5rem; +} + +#recipeEdit .recipe-section-card__help.zt-notice { + margin: 0.75rem 1.5rem 0; +} + +#recipeEdit .recipe-section-card__help.zt-notice.is-visible { + margin-bottom: 0.5rem; +} + +#recipeEdit .card-body > .zt-notice.is-visible { + margin-bottom: 0.75rem; +} + +@media (max-width: 768px) { + #recipeEdit .recipe-plain-help.zt-notice { + display: none !important; + } +} + +@media (max-width: 767.98px) { + #recipeEdit .recipe-form-grid { + grid-template-columns: 1fr; + } +} + +/* —— Section cards (single border, no nested shell) —— */ +#recipeEdit .recipe-section-card { + margin-bottom: 0 !important; + flex-shrink: 0; + border-radius: 12px; + border: 1px solid var(--border-subtle); + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); + overflow: hidden; +} + +#recipeEdit .recipe-section-card > .card-header { + background: var(--surface-muted) !important; + font-weight: 600; + padding: 0.75rem 1.5rem; + border-bottom: 1px solid var(--border-subtle); + /* Глобальный .card .card-header = 14px; карточка секции = 12px — без сброса виден «ступень» в углу */ + border-radius: 0; +} + +#recipeEdit .recipe-section-card > .card-body { + padding: 1rem 1.5rem 1.25rem; +} + +#recipeEdit .recipe-section-card > .card-body.recipe-section-card__body--grid { + padding: 0; +} + +#recipeEdit #recipeForm > .card-body > .recipe-section-card + .recipe-section-card { + margin-top: 0.25rem; +} + +#recipeEdit .recipe-section-card__help { + padding: 0.75rem 1.5rem 0; + margin: 0; +} + +#recipeEdit .recipe-section-card__body--grid .recipe-section-card__help:last-child { + padding-bottom: 0.75rem; +} + +html[data-theme="dark"] #recipeEdit .recipe-section-card > .card-header { + background: var(--dm-field) !important; + border-bottom-color: var(--dm-border); +} + +html[data-theme="dark"] #recipeEdit .recipe-section-card { + border-color: var(--dm-border); + box-shadow: none; +} + +/* —— Data grid base —— */ +#recipeEdit .zt-data-grid__scroll { + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; +} + +#recipeEdit .zt-data-grid { + display: flex; + flex-direction: column; + gap: 0.625rem; + min-width: 100%; + width: 100%; +} + +#recipeEdit .zt-data-grid__body { + display: flex; + flex-direction: column; +} + +#recipeEdit .zt-data-grid__head, +#recipeEdit .zt-data-grid__row { + display: grid; + grid-template-columns: var(--zt-data-grid-cols); + min-width: min(100%, 52rem); +} + +#recipeEdit .zt-data-grid--ingredients { + --zt-data-grid-actions-col: 10.75rem; + --zt-data-grid-cols: minmax(0, 1.5fr) repeat(5, minmax(0, 0.72fr)) var(--zt-data-grid-actions-col); +} + +#recipeEdit .zt-data-grid--unloading-groups { + --zt-data-grid-actions-col: 10.75rem; + --zt-data-grid-cols: minmax(0, 1.35fr) minmax(0, 1.15fr) minmax(0, 0.78fr) minmax(0, 0.78fr) var(--zt-data-grid-actions-col); +} + +#recipeEdit .zt-data-grid__cell { + min-width: 0; + padding: 0.5rem 0.75rem; + vertical-align: middle; + border-bottom: 1px solid var(--border-subtle); + font-variant-numeric: tabular-nums; + font-family: var(--font-numeric, inherit); + background: var(--surface, #fff); +} + +#recipeEdit .zt-data-grid__cell--head { + display: flex; + align-items: center; + justify-content: center; + text-align: center; + background: var(--surface-muted); + color: var(--text-secondary); + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.625rem 0.75rem; + border-bottom: 1px solid var(--border-subtle); + font-family: inherit; + font-variant-numeric: normal; +} + +#recipeEdit .zt-data-grid__cell--num { + text-align: center; +} + +#recipeEdit .zt-data-grid__cell--actions { + display: flex; + align-items: center; + justify-content: flex-end; + padding: 0.5rem 0.75rem; + text-align: right; +} + +#recipeEdit .zt-data-grid__cell--head.zt-data-grid__cell--actions { + justify-content: center; + text-align: center; +} + +#recipeEdit .zt-data-grid__body .zt-data-grid__row:last-child .zt-data-grid__cell { + border-bottom: none; +} + +#recipeEdit .zt-data-grid__body .zt-data-grid__row:hover .zt-data-grid__cell { + background: rgba(var(--primary-rgb), 0.05); +} + +/* Итого: gap у .zt-data-grid отделяет body от foot; внутри foot — только боковые отступы */ +#recipeEdit .zt-data-grid__foot { + padding: 0 0.75rem 0.75rem; + margin-top: 0; +} + +#recipeEdit .zt-data-grid__foot .zt-data-grid__row--total { + background: var(--surface-muted); + border-radius: 10px; + overflow: hidden; +} + +#recipeEdit .zt-data-grid__foot .zt-data-grid__cell { + padding: 0.625rem 0.75rem; + font-weight: 600; + font-size: 0.875rem; + background: transparent; + border: none; +} + +#recipeEdit .zt-data-grid__foot .zt-data-grid__cell--foot-label, +#recipeEdit .zt-data-grid__foot .zt-data-grid__cell--span-2 { + display: flex; + align-items: center; + text-align: left; + justify-content: flex-start; +} + +#recipeEdit .zt-data-grid__foot .zt-data-grid__cell--num { + display: flex; + align-items: center; + justify-content: center; +} + +#recipeEdit .zt-total-line { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.2rem; +} + +#recipeEdit .zt-total-label { + font-size: 0.625rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-secondary); + line-height: 1.2; +} + +html[data-theme="dark"] #recipeEdit .zt-total-label { + color: var(--dm-muted); +} + +#recipeEdit .zt-total-value { + font-variant-numeric: tabular-nums; + font-weight: 600; + font-size: 0.875rem; +} + +#recipeEdit .zt-data-grid__foot .zt-data-grid__cell--actions { + padding: 0.625rem 0.75rem; +} + +#recipeEdit .zt-data-grid__cell--span-2 { + grid-column: span 2; +} + +#recipeEdit .zt-data-grid .form-control, +#recipeEdit .zt-data-grid .form-select { + width: 100%; + max-width: 100%; + min-height: 2.25rem; + padding: 0.375rem 0.5rem; + font-size: 0.875rem; + box-sizing: border-box; +} + +#recipeEdit .zt-data-grid .text-cell { + display: inline-block; + min-height: 2.25rem; + line-height: 2.25rem; + font-size: 0.875rem; +} + +#recipeEdit .zt-data-grid input[type="number"] { + -moz-appearance: textfield; +} + +#recipeEdit .zt-data-grid input[type="number"]::-webkit-outer-spin-button, +#recipeEdit .zt-data-grid input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +/* Row actions — кнопки по высоте полей строки, колонка без лишнего воздуха */ +#recipeEdit .recipe-table-row-actions { + gap: 0.375rem; + justify-content: flex-end; + width: 100%; + max-width: 100%; +} + +/* Список рейсов: не width:100% — иначе прозрачный слой перекрывает .recipe-drag-handle слева */ +#recipesList .recipe-table-row-actions { + gap: 0.375rem; + justify-content: flex-end; + flex: 1 1 0; + min-width: 0; + width: auto; + max-width: 100%; + pointer-events: none; +} + +#recipesList .recipe-table-row-actions .btn, +#recipesList .recipe-table-row-actions .btn-group { + pointer-events: auto; +} + +#recipesList .list-item-leading { + position: relative; + z-index: 2; + flex-shrink: 0; + pointer-events: auto; +} + +#recipeEdit .recipe-table-move-group, +#recipesList .recipe-table-move-group, +.feed-dispensers-page .dispenser-card .recipe-table-move-group, +.components-page .component-card .recipe-table-move-group { + display: inline-flex; + flex-shrink: 0; + gap: 0.25rem; +} + +#recipeEdit .recipe-table-move-group > .btn + .btn, +#recipesList .recipe-table-move-group > .btn + .btn, +.feed-dispensers-page .dispenser-card .recipe-table-move-group > .btn + .btn, +.components-page .component-card .recipe-table-move-group > .btn + .btn { + margin-left: 0 !important; +} + +#recipeEdit .recipe-table-move-group .btn, +#recipeEdit .recipe-table-delete-btn, +#recipesList .recipe-table-move-group .btn, +#recipesList .recipe-table-delete-btn, +.feed-dispensers-page .dispenser-card .recipe-table-move-group .btn, +.feed-dispensers-page .dispenser-card .recipe-table-delete-btn, +.components-page .component-card .recipe-table-move-group .btn, +.components-page .component-card .recipe-table-delete-btn, +#recipesList .recipe-drag-handle { + width: 2.5rem; + height: 2.5rem; + min-width: 2.5rem; + min-height: 2.5rem; + padding: 0 !important; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; + border-radius: var(--zt-radius-sm, 8px) !important; + flex-shrink: 0; +} + +#recipeEdit .recipe-table-move-group .btn i, +#recipeEdit .recipe-table-delete-btn i, +#recipesList .recipe-table-move-group .btn i, +#recipesList .recipe-table-delete-btn i, +.feed-dispensers-page .dispenser-card .recipe-table-move-group .btn i, +.feed-dispensers-page .dispenser-card .recipe-table-delete-btn i, +.components-page .component-card .recipe-table-move-group .btn i, +.components-page .component-card .recipe-table-delete-btn i, +#recipesList .recipe-drag-handle i { + font-size: 0.875rem; +} + +#recipesList .recipe-drag-handle { + cursor: grab; + user-select: none; + -webkit-user-select: none; +} + +#recipesList .recipe-drag-handle:active { + cursor: grabbing; +} + +#recipesList .recipe-drag-handle:focus-visible { + outline: 2px solid var(--primary-color, #0d6efd); + outline-offset: 2px; + z-index: 2; +} + +#recipesList .recipe-drag-handle i { + pointer-events: none; +} + +#recipeEdit .recipe-table-move-group .btn:focus, +#recipesList .recipe-table-move-group .btn:focus { + z-index: 2; +} + +#recipeEdit .recipe-table-delete-btn, +#recipesList .recipe-table-delete-btn, +.feed-dispensers-page .dispenser-card .recipe-table-delete-btn, +.components-page .component-card .recipe-table-delete-btn { + background: transparent !important; + border: 1px solid var(--border-light, rgba(148, 163, 184, 0.35)) !important; + color: var(--text-muted, #94a3b8) !important; + transition: var(--transition); +} + +#recipeEdit .recipe-table-delete-btn:hover, +#recipeEdit .recipe-table-delete-btn:focus, +#recipesList .recipe-table-delete-btn:hover, +#recipesList .recipe-table-delete-btn:focus, +.feed-dispensers-page .dispenser-card .recipe-table-delete-btn:hover, +.feed-dispensers-page .dispenser-card .recipe-table-delete-btn:focus, +.components-page .component-card .recipe-table-delete-btn:hover, +.components-page .component-card .recipe-table-delete-btn:focus { + color: var(--danger-color, #ef4444) !important; + background: rgba(239, 68, 68, 0.08) !important; + border-color: rgba(239, 68, 68, 0.35) !important; +} + +/* Dark theme */ +html[data-theme="dark"] #recipeEdit .zt-data-grid__cell { + background: var(--dm-surface); + border-bottom-color: var(--dm-border); + color: var(--dm-text); +} + +html[data-theme="dark"] #recipeEdit .zt-data-grid__cell--head { + background: var(--dm-field); + color: var(--dm-muted); + border-bottom-color: var(--dm-border); +} + +html[data-theme="dark"] #recipeEdit .zt-data-grid__body .zt-data-grid__row:hover .zt-data-grid__cell { + background: rgba(var(--primary-rgb), 0.15); +} + +html[data-theme="dark"] #recipeEdit .zt-data-grid__foot .zt-data-grid__row--total { + background: var(--dm-field); +} + +html[data-theme="dark"] #recipeEdit .zt-data-grid__foot .zt-data-grid__cell { + background: transparent; + color: var(--dm-text); +} + +html[data-theme="dark"] #recipeEdit .recipe-table-delete-btn, +html[data-theme="dark"] #recipesList .recipe-table-delete-btn, +html[data-theme="dark"] .feed-dispensers-page .dispenser-card .recipe-table-delete-btn, +html[data-theme="dark"] .components-page .component-card .recipe-table-delete-btn { + color: var(--dm-muted) !important; +} + +html[data-theme="dark"] #recipeEdit .recipe-table-delete-btn:hover, +html[data-theme="dark"] #recipeEdit .recipe-table-delete-btn:focus, +html[data-theme="dark"] #recipesList .recipe-table-delete-btn:hover, +html[data-theme="dark"] #recipesList .recipe-table-delete-btn:focus, +html[data-theme="dark"] .feed-dispensers-page .dispenser-card .recipe-table-delete-btn:hover, +html[data-theme="dark"] .feed-dispensers-page .dispenser-card .recipe-table-delete-btn:focus, +html[data-theme="dark"] .components-page .component-card .recipe-table-delete-btn:hover, +html[data-theme="dark"] .components-page .component-card .recipe-table-delete-btn:focus { + color: #f87171 !important; + background: rgba(239, 68, 68, 0.12) !important; + border-color: rgba(239, 68, 68, 0.35) !important; +} + +html[data-theme="dark"] #recipeEdit .zt-data-grid .form-control { + min-height: 2.5rem; + line-height: 1.5; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + box-shadow: none !important; + opacity: 1 !important; + border-width: 1px; + border-style: solid; + background-color: var(--dm-field) !important; + color: var(--dm-text) !important; + border-color: var(--dm-border) !important; +} + +html[data-theme="dark"] #recipeEdit .zt-data-grid .form-control:focus:not(:disabled):not(.mode-locked-field) { + box-shadow: 0 0 0 0.2rem rgba(var(--primary-rgb), 0.25) !important; +} + +html[data-theme="dark"] #recipeEdit .zt-data-grid .form-control.mode-locked-field, +html[data-theme="dark"] #recipeEdit .zt-data-grid .form-control:disabled, +html[data-theme="dark"] #recipeEdit .zt-data-grid .form-control[readonly] { + background-color: #23292f !important; + background-image: none !important; + border: 1px solid rgba(255, 255, 255, 0.38) !important; + color: var(--dm-text) !important; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35) !important; + min-height: 2.5rem; + -webkit-text-fill-color: var(--dm-text) !important; +} diff --git a/apps/web/public/wesp/css/wesp-recipes-mobile.css b/apps/web/public/wesp/css/wesp-recipes-mobile.css new file mode 100644 index 0000000..c0c4b8c --- /dev/null +++ b/apps/web/public/wesp/css/wesp-recipes-mobile.css @@ -0,0 +1,888 @@ +/** + * Mobile layout for /recipes — dashboard steps, card tables, fullscreen editor. + * Breakpoint matches recipes.html mobile-nav (@768px). + */ + +.ingredient-mobile-summary, +.ingredient-mobile-sheet, +.unloading-group-mobile-summary, +.unloading-group-mobile-sheet { + display: none; +} + +@media (max-width: 768px) { + /* --- Dashboard step navigation --- */ + .recipes-mobile-nav { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; + padding: 0.65rem 0.85rem; + background: var(--surface-card, #fff); + border: 1px solid var(--border-subtle, #e2e8f0); + border-radius: 12px; + box-shadow: var(--box-shadow, 0 1px 3px rgba(15, 23, 42, 0.06)); + } + + .recipes-mobile-nav[hidden] { + display: none !important; + } + + .recipes-mobile-nav-back { + flex-shrink: 0; + min-height: 44px; + padding: 0.4rem 0.75rem; + border-radius: 10px; + touch-action: manipulation; + } + + .recipes-mobile-nav-title { + flex: 1 1 auto; + min-width: 0; + font-size: 0.9rem; + font-weight: 600; + color: var(--text-primary, #0f172a); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + body.dashboard-mobile-step-dispensers #periodsCol, + body.dashboard-mobile-step-dispensers #recipesCol { + display: none !important; + } + + body.dashboard-mobile-step-periods #dispensersCol, + body.dashboard-mobile-step-periods #recipesCol { + display: none !important; + } + + body.dashboard-mobile-step-recipes #dispensersCol, + body.dashboard-mobile-step-recipes #periodsCol { + display: none !important; + } + + body.dashboard-mobile-step-periods #periodsCol, + body.dashboard-mobile-step-recipes #recipesCol { + display: block !important; + } + + body.dashboard-mobile-step-dispensers #dispensersCol { + display: block !important; + } + + /* --- Recipe list cards --- */ + #recipesList .recipe-actions .btn { + min-width: 44px; + min-height: 44px; + padding: 0.45rem 0.65rem; + } + + #recipesList .recipe-actions-row { + flex-wrap: wrap; + } + + #recipesList .recipe-drag-handle { + touch-action: none; + -webkit-user-drag: element; + } + + #recipesList .list-item.recipe-card-dragging { + opacity: 0.72; + } + + #recipesList.recipe-list-reorder-active { + touch-action: none; + } + + body.recipe-list-reorder-tracking { + touch-action: none; + } + + .recipe-reorder-edge-hints { + position: fixed; + inset: 0; + z-index: 1295; + pointer-events: none; + } + + .recipe-reorder-edge-hint { + position: absolute; + left: 0; + right: 0; + height: 3.25rem; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.18s ease; + } + + .recipe-reorder-edge-hint--top { + top: 0; + background: linear-gradient(to bottom, rgba(var(--primary-rgb), 0.28), rgba(var(--primary-rgb), 0)); + } + + .recipe-reorder-edge-hint--bottom { + bottom: 0; + background: linear-gradient(to top, rgba(var(--primary-rgb), 0.28), rgba(var(--primary-rgb), 0)); + } + + .recipe-reorder-edge-hint.is-active { + opacity: 1; + } + + .recipe-reorder-hint--floating { + position: fixed; + left: 0.75rem; + right: 0.75rem; + bottom: calc(0.85rem + env(safe-area-inset-bottom, 0px)); + z-index: 1296; + margin: 0; + } + + .recipe-list-scroll-zone-label { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.28rem 0.65rem; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 600; + color: var(--primary-color, #2196f3); + background: rgba(255, 255, 255, 0.92); + box-shadow: 0 2px 10px rgba(15, 23, 42, 0.12); + } + + .recipe-reorder-hint, + .recipe-reorder-hint--floating { + padding: 0.55rem 0.75rem; + border-radius: 10px; + font-size: 0.78rem; + line-height: 1.4; + font-weight: 500; + color: var(--primary-color, #2196f3); + background: rgba(var(--primary-rgb), 0.1); + border: 1px solid rgba(var(--primary-rgb), 0.22); + opacity: 0; + transform: translateY(4px); + transition: opacity 0.22s ease, transform 0.22s ease; + } + + .recipe-reorder-hint.is-visible, + .recipe-reorder-hint--floating.is-visible { + opacity: 1; + transform: translateY(0); + } + + #recipesList { + position: relative; + } + + #recipesList .recipe-card-insert-indicator { + position: absolute; + left: 0.5rem; + right: 0.5rem; + height: 3px; + border-radius: 999px; + background: var(--primary-color, #2196f3); + box-shadow: 0 0 0 3px rgba(var(--primary-rgb), 0.18); + pointer-events: none; + z-index: 8; + } + + #recipesList .list-item.recipe-card-dragging { + opacity: 0.35; + border-style: dashed; + } + + .recipe-card-drag-ghost--card-clone { + position: fixed; + z-index: 1300; + pointer-events: none; + opacity: 0.96; + box-shadow: 0 14px 36px rgba(15, 23, 42, 0.22); + margin: 0 !important; + transition: none !important; + transform: none; + will-change: left, top; + } + + body.recipe-mobile-transfer-armed #recipesList .recipe-drag-handle { + color: var(--primary-color, #2196f3); + border-color: rgba(var(--primary-rgb), 0.45); + background: rgba(var(--primary-rgb), 0.12); + } + + body.recipe-mobile-transfer-armed #recipesCol .card-header::after { + content: "Режим переноса: выберите период или оборудование"; + display: block; + width: 100%; + margin-top: 0.35rem; + font-size: 0.78rem; + font-weight: 500; + color: var(--primary-color, #2196f3); + line-height: 1.35; + } + + /* --- Transfer drag: show hidden dashboard columns as overlay --- */ + body.recipe-transfer-session #recipesMobileNav { + display: none !important; + } + + body.recipe-transfer-session #dispensersCol, + body.recipe-transfer-session #periodsCol { + display: block !important; + position: fixed; + left: 0; + right: 0; + z-index: 1250; + margin: 0 !important; + padding: 0 0.5rem; + max-height: 42vh; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + background: var(--surface-page, #f5f7fa); + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.18); + } + + body.recipe-transfer-session #dispensersCol { + top: 0; + } + + body.recipe-transfer-session #periodsCol { + top: 42vh; + max-height: 38vh; + } + + body.recipe-transfer-session #recipesCol { + display: block !important; + margin-top: 80vh; + } + + body.recipe-transfer-session #recipesCol .card-header { + position: sticky; + top: 0; + z-index: 2; + } + + #recipesList .recipe-card-meta { + flex-wrap: wrap; + gap: 0.35rem 0.75rem; + justify-content: flex-start !important; + } + + #recipesList .recipe-card-meta > * { + flex: 0 1 auto; + } + + /* --- Fullscreen recipe editor --- */ + body.recipe-edit-mobile-open { + overflow: hidden; + touch-action: pan-x pan-y; + } + + body.recipe-edit-mobile-open .mobile-navbar, + body.recipe-edit-mobile-open .app-footer, + body.recipe-edit-mobile-open .page-content { + display: none !important; + } + + body.recipe-edit-mobile-open #recipesDashboard, + body.recipe-edit-mobile-open #recipesMobileNav { + display: none !important; + } + + body.recipe-edit-mobile-open #recipeEdit, + #recipeEdit.recipe-edit--mobile-active { + display: flex !important; + flex-direction: column; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + width: 100%; + max-width: none; + height: 100%; + height: 100dvh; + z-index: 1200; + margin: 0 !important; + overflow: hidden !important; + background: var(--surface-page, #f5f7fa); + padding-bottom: 0; + touch-action: pan-x pan-y; + } + + body.recipe-edit-mobile-open #recipeEdit .recipe-edit-panel, + #recipeEdit.recipe-edit--mobile-active .recipe-edit-panel { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + border-radius: 0; + box-shadow: none; + border: none; + overflow: hidden !important; + touch-action: pan-x pan-y; + padding-bottom: 0; + } + + body.recipe-edit-mobile-open #recipeEdit #recipeForm, + #recipeEdit.recipe-edit--mobile-active #recipeForm { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + } + + body.recipe-edit-mobile-open #recipeEdit #recipeForm > .card-body, + #recipeEdit.recipe-edit--mobile-active #recipeForm > .card-body { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior-y: contain; + touch-action: pan-y; + padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px)); + } + + body.recipe-edit-mobile-open #recipeEdit #recipeForm > .card-header, + #recipeEdit.recipe-edit--mobile-active #recipeForm > .card-header { + flex-shrink: 0; + } + + body.recipe-edit-mobile-open #recipeEdit .card-body, + #recipeEdit.recipe-edit--mobile-active .card-body { + padding-left: 0.85rem; + padding-right: 0.85rem; + } + + /* --- Compact recipe edit header --- */ + body.recipe-edit-mobile-open #recipeEdit .recipe-edit-header, + #recipeEdit.recipe-edit--mobile-active .recipe-edit-header { + padding: 0.4rem 0.85rem; + padding-top: calc(0.4rem + env(safe-area-inset-top, 0px)); + background: var(--surface-card, #fff); + border-bottom: 1px solid var(--border-subtle, #e2e8f0); + } + + body.recipe-edit-mobile-open #recipeEdit .recipe-edit-header-text, + #recipeEdit.recipe-edit--mobile-active .recipe-edit-header-text { + display: none !important; + } + + body.recipe-edit-mobile-open #recipeEdit .recipe-edit-header-inner, + #recipeEdit.recipe-edit--mobile-active .recipe-edit-header-inner { + justify-content: flex-end; + gap: 0.35rem; + } + + body.recipe-edit-mobile-open #recipeEdit .recipe-edit-header-actions, + #recipeEdit.recipe-edit--mobile-active .recipe-edit-header-actions { + flex: 1 1 auto; + width: 100%; + } + + body.recipe-edit-mobile-open #recipeEdit .recipe-edit-header-actions .d-flex, + #recipeEdit.recipe-edit--mobile-active .recipe-edit-header-actions .d-flex { + justify-content: flex-end; + gap: 0.35rem; + flex-wrap: nowrap; + } + + body.recipe-edit-mobile-open #recipeEdit .recipe-edit-header-icon-btn, + #recipeEdit.recipe-edit--mobile-active .recipe-edit-header-icon-btn { + min-width: 36px; + min-height: 36px; + padding: 0.28rem 0.38rem; + } + + #recipeEdit .recipe-section-toolbar > .btn-primary, + #recipeEdit .recipe-section-toolbar .recipe-section-toggle-btn { + min-height: 36px; + padding: 0.32rem 0.7rem; + font-size: 0.875rem; + touch-action: manipulation; + } + + #recipeEdit .recipe-section-card > .card-header { + padding: 0.65rem 0.85rem; + } + + #recipeEdit .recipe-section-card > .card-body { + padding: 0.75rem 1rem 1rem; + } + + #recipeEdit .recipe-section-toolbar { + margin-bottom: 0.75rem; + gap: 0.5rem; + } + + #recipeEdit .recipe-section-toolbar-title { + font-size: 0.95rem; + } + + body.recipe-edit-mobile-open #recipeEdit #recipeForm > .card-body > .row, + #recipeEdit.recipe-edit--mobile-active #recipeForm > .card-body > .row { + margin-bottom: 0.65rem !important; + } + + body.recipe-edit-mobile-open #recipeEdit #recipeForm > .card-body > .row .form-group, + #recipeEdit.recipe-edit--mobile-active #recipeForm > .card-body > .row .form-group { + margin-bottom: 0.55rem; + } + + /* --- Card layout for edit data grids --- */ + #recipeEdit .zt-data-grid__scroll { + overflow: visible; + } + + #recipeEdit .zt-data-grid__head { + display: none; + } + + #recipeEdit #ingredientsTable.ingredients-table--dm-unlocked .ingredient-detail-cell.dry-matter-per-head { + display: none; + } + + /* --- Compact ingredient list (mobile) --- */ + #recipeEdit #ingredientsTableBody .ingredient-row { + display: block; + margin: 0; + padding: 0; + border: none; + border-radius: 0; + background: transparent; + box-shadow: none; + border-bottom: 1px solid var(--border-subtle, #e2e8f0); + } + + #recipeEdit #ingredientsTableBody .ingredient-row:last-child { + border-bottom: none; + } + + #recipeEdit #ingredientsTableBody .ingredient-row .zt-data-grid__cell { + border-bottom: none; + padding: 0; + background: transparent; + } + + #recipeEdit #ingredientsTableBody .ingredient-row .ingredient-mobile-summary { + display: block; + padding: 0; + } + + #recipeEdit #ingredientsTableBody .ingredient-row .ingredient-detail-cell { + display: none !important; + } + + /* --- Compact unloading groups list (mobile) --- */ + #recipeEdit #unloadingGroupsTableBody .unloading-group-row { + display: block; + margin: 0; + padding: 0; + border: none; + border-radius: 0; + background: transparent; + box-shadow: none; + border-bottom: 1px solid var(--border-subtle, #e2e8f0); + } + + #recipeEdit #unloadingGroupsTableBody .unloading-group-row:last-child { + border-bottom: none; + } + + #recipeEdit #unloadingGroupsTableBody .unloading-group-row .zt-data-grid__cell { + border-bottom: none; + padding: 0; + background: transparent; + } + + #recipeEdit #unloadingGroupsTableBody .unloading-group-row .unloading-group-mobile-summary { + display: block; + padding: 0; + } + + #recipeEdit #unloadingGroupsTableBody .unloading-group-row .unloading-group-detail-cell { + display: none !important; + } + + #recipeEdit #unloadingGroupsTable .zt-data-grid__foot, + #recipeEdit #ingredientsTable .zt-data-grid__foot { + display: none !important; + } + + #recipeEdit .recipe-table-row-actions { + justify-content: flex-start !important; + flex-wrap: wrap; + gap: 0.5rem !important; + } + + #recipeEdit .recipe-table-move-group .btn, + #recipeEdit .recipe-table-delete-btn { + min-height: 44px; + min-width: 44px; + touch-action: manipulation; + } + + .recipe-mobile-row-summary-btn, + .ingredient-mobile-summary-btn, + .unloading-group-mobile-summary-btn { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + width: 100%; + padding: 0.75rem 0.85rem; + border: none; + background: transparent; + text-align: left; + gap: 0.55rem; + touch-action: manipulation; + cursor: pointer; + color: inherit; + } + + .ingredient-mobile-name, + .unloading-group-mobile-name { + font-weight: 600; + font-size: 0.95rem; + color: var(--text-primary, #0f172a); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .ingredient-mobile-trip, + .unloading-group-mobile-meta { + font-size: 0.875rem; + font-weight: 600; + color: var(--text-secondary, #64748b); + white-space: nowrap; + } + + .recipe-mobile-row-summary-chevron, + .ingredient-mobile-summary-chevron { + flex-shrink: 0; + color: var(--text-secondary, #64748b); + font-size: 0.85rem; + } + + /* Hide desktop totals block — values are in edit sheet or recalculated */ + + /* --- Mobile edit bottom sheets --- */ + .recipe-mobile-edit-sheet[hidden], + .ingredient-mobile-sheet[hidden], + .unloading-group-mobile-sheet[hidden] { + display: none !important; + } + + .recipe-mobile-edit-sheet:not([hidden]), + .ingredient-mobile-sheet:not([hidden]), + .unloading-group-mobile-sheet:not([hidden]) { + position: fixed; + inset: 0; + z-index: 1400; + display: flex; + flex-direction: column; + justify-content: flex-end; + } + + body.ingredient-mobile-sheet-open, + body.unloading-group-mobile-sheet-open, + body.recipe-help-modal-open { + overflow: hidden; + } + + .recipe-mobile-edit-sheet-backdrop, + .ingredient-mobile-sheet-backdrop { + position: absolute; + inset: 0; + border: none; + padding: 0; + background: rgba(15, 23, 42, 0.45); + cursor: pointer; + } + + .recipe-mobile-edit-sheet-panel, + .ingredient-mobile-sheet-panel { + position: relative; + display: flex; + flex-direction: column; + max-height: min(82dvh, 620px); + min-height: 0; + background: var(--surface-card, #fff); + border-radius: 16px 16px 0 0; + overflow: hidden; + box-shadow: 0 -8px 32px rgba(15, 23, 42, 0.15); + touch-action: pan-y; + will-change: transform; + } + + .recipe-mobile-edit-sheet-grab { + flex-shrink: 0; + width: 2.75rem; + height: 0.28rem; + margin: 0.55rem auto 0.15rem; + border-radius: 999px; + background: var(--border-subtle, rgba(148, 163, 184, 0.55)); + touch-action: none; + cursor: grab; + } + + .recipe-mobile-edit-sheet--dragging .recipe-mobile-edit-sheet-grab { + cursor: grabbing; + } + + .recipe-mobile-edit-sheet--dragging .recipe-mobile-edit-sheet-body, + .recipe-mobile-edit-sheet--dragging .ingredient-mobile-sheet-body { + overflow: hidden; + } + + .recipe-mobile-edit-sheet-header, + .ingredient-mobile-sheet-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.65rem; + padding: 0.85rem 1.1rem 0.75rem; + border-bottom: 1px solid var(--border-subtle, #e2e8f0); + flex-shrink: 0; + touch-action: none; + cursor: grab; + } + + .recipe-mobile-edit-sheet-done, + .ingredient-mobile-sheet-done { + min-height: 36px; + min-width: 5.5rem; + padding-left: 0.85rem; + padding-right: 0.85rem; + font-size: 0.875rem; + flex-shrink: 0; + touch-action: manipulation; + cursor: pointer; + } + + .recipe-mobile-edit-sheet-title, + .ingredient-mobile-sheet-title { + margin: 0; + font-size: 1rem; + font-weight: 600; + line-height: 1.25; + padding-right: 0.35rem; + } + + .recipe-mobile-edit-sheet-body, + .ingredient-mobile-sheet-body { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 1rem 1.1rem calc(1.1rem + env(safe-area-inset-bottom, 0px)); + -webkit-overflow-scrolling: touch; + overscroll-behavior-y: contain; + } + + .ingredient-mobile-sheet-field, + .unloading-group-mobile-sheet-field { + margin-bottom: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border-subtle, #e2e8f0); + } + + .ingredient-mobile-sheet-field:last-child, + .unloading-group-mobile-sheet-field:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; + } + + .ingredient-mobile-sheet-label, + .unloading-group-mobile-sheet-label { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--text-secondary, #64748b); + margin-bottom: 0.5rem; + } + + .ingredient-mobile-sheet-field .form-control, + .ingredient-mobile-sheet-field .form-select, + .unloading-group-mobile-sheet-field .form-control, + .unloading-group-mobile-sheet-field .form-select { + width: 100%; + font-size: 16px; + min-height: 44px; + } + + .ingredient-mobile-sheet-field.ingredient-mobile-sheet-actions, + .unloading-group-mobile-sheet-field.unloading-group-mobile-sheet-actions { + padding-top: 0.75rem; + margin-top: 0.25rem; + margin-bottom: 0; + border-top: 1px solid var(--border-color, rgba(148, 163, 184, 0.2)); + border-bottom: none; + padding-bottom: 0; + } + + .ingredient-mobile-sheet-actions .recipe-table-row-actions, + .unloading-group-mobile-sheet-actions .recipe-table-row-actions { + display: grid !important; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; + width: 100%; + justify-content: stretch !important; + flex-wrap: wrap; + } + + .ingredient-mobile-sheet-actions .recipe-table-move-group, + .unloading-group-mobile-sheet-actions .recipe-table-move-group { + display: contents; + } + + .ingredient-mobile-sheet-actions .recipe-table-move-group .btn, + .unloading-group-mobile-sheet-actions .recipe-table-move-group .btn, + .ingredient-mobile-sheet-actions .recipe-table-delete-btn, + .unloading-group-mobile-sheet-actions .recipe-table-delete-btn { + width: 100%; + min-height: 48px; + min-width: 0; + padding: 0.55rem 0.75rem; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 1.05rem; + border-radius: 0.5rem !important; + touch-action: manipulation; + } + + .ingredient-mobile-sheet-actions .recipe-table-delete-btn, + .unloading-group-mobile-sheet-actions .recipe-table-delete-btn { + grid-column: 1 / -1; + } + + .ingredient-mobile-sheet.ingredients-table--dm-unlocked .ingredient-mobile-sheet-field.dry-matter-per-head { + display: none; + } + + #recipeEdit .recipe-help-icon-btn { + width: 2rem; + height: 2rem; + touch-action: manipulation; + } + + .recipe-help-modal-body { + padding: 0 1rem 1.25rem; + } + + .recipe-help-modal-body .zt-notice--static { + margin: 0; + box-shadow: none; + } + + /* Footer actions: hidden on mobile — use header icons (save / print / close) */ + body.recipe-edit-mobile-open #recipeEdit .recipe-form-footer-actions, + #recipeEdit.recipe-edit--mobile-active .recipe-form-footer-actions { + display: none !important; + } + + /* iOS zoom prevention for top-level form fields */ + #recipeEdit .form-control, + #recipeEdit .form-select { + font-size: 16px; + } +} + +@media (max-width: 400px) { + #recipesList .recipe-card-meta { + flex-direction: column; + align-items: flex-start !important; + } +} + +@media (max-width: 768px) { + html[data-theme="dark"] .recipes-mobile-nav { + background: var(--dm-surface, #1a2228); + border-color: var(--dm-border, rgba(255, 255, 255, 0.12)); + } + + html[data-theme="dark"] .recipes-mobile-nav-title { + color: var(--dm-text, #e9ecef); + } + + html[data-theme="dark"] #recipeEdit #ingredientsTableBody .ingredient-row, + html[data-theme="dark"] #recipeEdit #unloadingGroupsTableBody .unloading-group-row { + border-color: var(--dm-border, rgba(255, 255, 255, 0.12)); + } + + html[data-theme="dark"] #recipeEdit .zt-data-grid__foot .zt-data-grid__row { + border-color: var(--dm-border, rgba(255, 255, 255, 0.12)); + } + + html[data-theme="dark"] body.recipe-edit-mobile-open #recipeEdit, + html[data-theme="dark"] #recipeEdit.recipe-edit--mobile-active { + background: var(--dm-bg, #12181b); + } + + html[data-theme="dark"] body.recipe-transfer-session #dispensersCol, + html[data-theme="dark"] body.recipe-transfer-session #periodsCol { + background: var(--dm-bg, #12181b); + } + + html[data-theme="dark"] body.recipe-edit-mobile-open #recipeEdit .recipe-edit-header, + html[data-theme="dark"] #recipeEdit.recipe-edit--mobile-active .recipe-edit-header { + background: var(--dm-surface, #1a2228); + border-color: var(--dm-border, rgba(255, 255, 255, 0.12)); + } + + html[data-theme="dark"] .recipe-help-modal-panel, + html[data-theme="dark"] .recipe-mobile-edit-sheet-panel, + html[data-theme="dark"] .ingredient-mobile-sheet-panel { + background: var(--dm-surface, #1a2228); + } + + html[data-theme="dark"] .recipe-mobile-edit-sheet-header, + html[data-theme="dark"] .ingredient-mobile-sheet-header { + border-color: var(--dm-border, rgba(255, 255, 255, 0.12)); + } + + html[data-theme="dark"] .recipe-mobile-edit-sheet-grab { + background: rgba(255, 255, 255, 0.28); + } + + html[data-theme="dark"] .ingredient-mobile-sheet-field, + html[data-theme="dark"] .unloading-group-mobile-sheet-field { + border-color: var(--dm-border, rgba(255, 255, 255, 0.12)); + } + + html[data-theme="dark"] .ingredient-mobile-name, + html[data-theme="dark"] .unloading-group-mobile-name { + color: var(--dm-text, #e9ecef); + } + + html[data-theme="dark"] #recipeEdit #ingredientsTableBody .ingredient-row, + html[data-theme="dark"] #recipeEdit #unloadingGroupsTableBody .unloading-group-row { + border-color: var(--dm-border, rgba(255, 255, 255, 0.12)); + } + + html[data-theme="dark"] #recipesList .recipe-card-insert-indicator { + box-shadow: 0 0 0 3px rgba(100, 181, 246, 0.22); + } + + html[data-theme="dark"] .recipe-card-drag-ghost--card-clone { + box-shadow: 0 14px 36px rgba(0, 0, 0, 0.45); + } + + html[data-theme="dark"] .recipe-list-scroll-zone-label { + background: rgba(26, 34, 40, 0.94); + color: #64b5f6; + } + + html[data-theme="dark"] .recipe-reorder-hint { + background: rgba(100, 181, 246, 0.1); + border-color: rgba(100, 181, 246, 0.24); + color: #64b5f6; + } +} diff --git a/apps/web/public/wesp/css/wesp-recipes-skeleton.css b/apps/web/public/wesp/css/wesp-recipes-skeleton.css new file mode 100644 index 0000000..118537b --- /dev/null +++ b/apps/web/public/wesp/css/wesp-recipes-skeleton.css @@ -0,0 +1,370 @@ +/** + * Skeleton loaders for /recipes — list cards, edit panel, drag ghost. + * Uses page tokens from recipes.html :root and dark --dm-* from wesp-zootech-shell.css. + */ + +@keyframes dashboard-skeleton-shimmer { + 0% { + background-position: 240% 0; + } + 100% { + background-position: -240% 0; + } +} + +@keyframes dashboard-stat-card-unfold { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +/* --- List skeleton container --- */ + +.dashboard-list-skeleton { + display: flex; + flex-direction: column; + gap: 0; + padding: 2px 0; + min-height: 11.5rem; + transition: opacity 150ms ease; +} + +.dashboard-list-skeleton--exit { + opacity: 0; + pointer-events: none; +} + +/* Card row — matches .list-item */ +.dashboard-skeleton-row { + border-radius: 8px; + padding: 1.25rem; + margin-bottom: 1.25rem; + background: var(--surface-card, #fff); + border: 1px solid var(--border-strong, #cbd5e1); + pointer-events: none; +} + +.dashboard-skeleton-row:last-child { + margin-bottom: 0; +} + +.dashboard-skeleton-row:nth-child(1) { --skel-i: 0; } +.dashboard-skeleton-row:nth-child(2) { --skel-i: 1; } +.dashboard-skeleton-row:nth-child(3) { --skel-i: 2; } +.dashboard-skeleton-row:nth-child(4) { --skel-i: 3; } + +.dashboard-skeleton-row--recipe { + padding: 1.25rem; +} + +.dashboard-skeleton-row--field { + background: transparent; + border: none; + padding: 0; + margin-bottom: 0; +} + +/* --- Bars & pills --- */ + +.dashboard-skeleton-bar, +.dashboard-skeleton-pill { + border-radius: 5px; + background: linear-gradient( + 105deg, + var(--border-subtle, #e2e8f0) 0%, + var(--border-subtle, #e2e8f0) 34%, + rgba(255, 255, 255, 0.92) 50%, + var(--border-subtle, #e2e8f0) 66%, + var(--border-subtle, #e2e8f0) 100% + ); + background-size: 240% 100%; + animation: dashboard-skeleton-shimmer 1.2s ease-in-out infinite; + animation-delay: calc(var(--skel-i, 0) * 0.08s); +} + +.dashboard-skeleton-bar { + height: 0.72rem; + margin-bottom: 0.65rem; + width: 100%; +} + +.dashboard-skeleton-bar:last-child { + margin-bottom: 0; +} + +.dashboard-skeleton-bar--narrow { + width: 58%; + height: 0.62rem; +} + +.dashboard-skeleton-bar--head-title { + flex: 1 1 auto; + min-width: 0; + max-width: 72%; + margin-bottom: 0; +} + +.dashboard-skeleton-bar--meta-half { + flex: 1 1 0; + min-width: 0; + margin-bottom: 0; +} + +.dashboard-skeleton-bar--actions-left { + width: 32%; + height: 0.62rem; + margin-bottom: 0; +} + +.dashboard-skeleton-bar--actions-right { + width: 38%; + margin-bottom: 0; + margin-left: auto; + max-width: 40%; +} + +.dashboard-skeleton-bar--title { + width: 92%; + margin-bottom: 0; +} + +.dashboard-skeleton-bar--subtitle { + width: 70%; + margin-top: 0.65rem; +} + +.dashboard-skeleton-bar--meta { + height: 0.58rem; + flex: 1 1 0; + min-width: 0; + margin-bottom: 0; +} + +.dashboard-skeleton-bar--meta.dashboard-skeleton-bar--narrow { + flex: 0 0 30%; + width: auto; +} + +.dashboard-skeleton-bar--label { + width: 40%; + height: 0.62rem; + margin-bottom: 0.5rem; +} + +.dashboard-skeleton-bar--input { + height: 2.5rem; + margin-bottom: 0; + border-radius: 6px; +} + +.dashboard-skeleton-bar--hero-title { + width: min(420px, 72%); + height: 1rem; +} + +.dashboard-skeleton-bar--hero-sub { + width: 36%; + margin-top: 0.65rem; +} + +.dashboard-skeleton-pill { + flex: 0 0 auto; + width: 4.75rem; + height: 1.25rem; + border-radius: 999px; + margin-bottom: 0; +} + +.dashboard-skeleton-pill--wide { + width: 5.5rem; +} + +/* --- Layout rows inside skeleton cards --- */ + +.dashboard-skeleton-row-head, +.dashboard-skeleton-row-foot { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + justify-content: space-between; +} + +.dashboard-skeleton-row-head { + margin-bottom: 0.75rem; +} + +.dashboard-skeleton-row-foot--end { + justify-content: flex-end; +} + +.dashboard-skeleton-flex-row { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; +} + +.dashboard-skeleton-flex-row--actions { + margin-bottom: 0.65rem; +} + +.dashboard-skeleton-flex-row--actions-end { + justify-content: flex-end; + margin-bottom: 0.65rem; +} + +.dashboard-skeleton-flex-row--actions-end .dashboard-skeleton-pill { + width: 2.25rem; + height: 2.25rem; +} + +.dashboard-skeleton-card-inner--mill > .dashboard-skeleton-bar--subtitle { + margin-bottom: 0.65rem; +} + +.dashboard-skeleton-flex-row--meta .dashboard-skeleton-bar { + flex: 1 1 0; + min-width: 0; +} + +.dashboard-skeleton-card-inner { + display: flex; + flex-direction: column; + gap: 0; +} + +.dashboard-skeleton-card-inner > .dashboard-skeleton-bar--subtitle { + margin-bottom: 0.65rem; +} + +/* --- Edit panel skeleton --- */ + +.recipe-edit-skeleton-panel { + display: none; +} + +.recipe-edit-panel--loading .recipe-edit-skeleton-panel { + display: flex; + align-items: center; + justify-content: center; +} + +.recipe-edit-panel--loading #recipeForm { + display: none !important; +} + +.recipe-edit-skeleton-hero { + margin-bottom: 1.35rem; + padding-bottom: 1.25rem; + border-bottom: 1px solid var(--border-subtle, #e2e8f0); +} + +.recipe-edit-skeleton-field { + margin-bottom: 0; +} + +.recipe-edit-skeleton-table { + display: flex; + flex-direction: column; + gap: 0.65rem; + min-height: 10rem; + padding: 0.25rem 0; +} + +.recipe-edit-panel:not(.recipe-edit-panel--loading) #recipeForm.recipe-form--enter { + animation: dashboard-stat-card-unfold 240ms cubic-bezier(0.22, 1, 0.36, 1) both; +} + +/* --- Drag ghost --- */ + +.recipe-card-drag-ghost { + box-sizing: border-box; + padding: 1.25rem; + margin: 0; + border-radius: 8px; + border: 1px solid var(--border-strong, #cbd5e1); + background: var(--surface-card, #fff); + box-shadow: var(--box-shadow-hover, 0 10px 28px rgba(15, 23, 42, 0.14)); +} + +.recipe-card-drag-ghost--follow { + position: fixed; + z-index: 1300; + pointer-events: none; + transform: translate(-50%, -50%); + opacity: 0.96; + will-change: transform, left, top; +} + +.recipe-card-drag-ghost .dashboard-skeleton-bar { + margin-bottom: 0; +} + +.recipe-card-drag-ghost .dashboard-skeleton-flex-row--actions { + margin-bottom: 0.65rem; +} + +.recipe-card-drag-ghost .dashboard-skeleton-card-inner > .dashboard-skeleton-bar--subtitle { + margin-bottom: 0.65rem; +} + +/* --- Dark theme --- */ + +html[data-theme="dark"] .dashboard-skeleton-row { + background: var(--dm-surface, #2c3338); + border-color: var(--dm-border, rgba(255, 255, 255, 0.12)); +} + +html[data-theme="dark"] .dashboard-skeleton-bar, +html[data-theme="dark"] .dashboard-skeleton-pill { + background: linear-gradient( + 105deg, + rgba(255, 255, 255, 0.06) 0%, + rgba(255, 255, 255, 0.06) 34%, + rgba(255, 255, 255, 0.2) 50%, + rgba(255, 255, 255, 0.06) 66%, + rgba(255, 255, 255, 0.06) 100% + ); + background-size: 240% 100%; +} + +html[data-theme="dark"] .recipe-card-drag-ghost { + background: var(--dm-surface) !important; + border-color: var(--dm-border) !important; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45); +} + +html[data-theme="dark"] #recipeEdit .recipe-edit-skeleton-panel { + background: var(--dm-surface) !important; +} + +html[data-theme="dark"] #recipeEdit .recipe-edit-skeleton-hero { + border-bottom-color: var(--dm-border) !important; +} + +/* --- Reduced motion --- */ + +@media (prefers-reduced-motion: reduce) { + .dashboard-skeleton-bar, + .dashboard-skeleton-pill { + animation: none !important; + background: var(--border-subtle, #e2e8f0); + } + + html[data-theme="dark"] .dashboard-skeleton-bar, + html[data-theme="dark"] .dashboard-skeleton-pill { + background: rgba(255, 255, 255, 0.12); + } + + .dashboard-list-skeleton--exit { + transition: none; + } + + .recipe-edit-panel:not(.recipe-edit-panel--loading) #recipeForm.recipe-form--enter { + animation: none !important; + } +} diff --git a/apps/web/public/wesp/css/wesp-reports-page.css b/apps/web/public/wesp/css/wesp-reports-page.css new file mode 100644 index 0000000..c813c84 --- /dev/null +++ b/apps/web/public/wesp/css/wesp-reports-page.css @@ -0,0 +1,986 @@ +/** + * /reports — таблицы, фильтры, отступы (карточки — inline в reports.html как на /components). + */ + +/* Карточки отчётов не интерактивные — без hover-подсветки (кроме контроля отклонений) */ +.zootech-app-body.reports-page .report-card.list-item:not(.deviation-report-card):hover { + background: var(--bg-white, #fff) !important; + border-color: var(--border-light) !important; + box-shadow: none !important; + transform: none !important; +} + +html[data-theme="dark"] .zootech-app-body.reports-page .report-card.list-item:not(.deviation-report-card):hover { + background: var(--bg-white, #2c3338) !important; + border-color: rgba(255, 255, 255, 0.12) !important; +} + +.reports-page .reports-container { + max-width: none; + margin: 0; + padding: 0; +} + +.reports-page .period-selectors { + margin-bottom: 0.75rem; +} + +/* Шапка и вкладки */ +.reports-page .reports-controls__head { + margin-bottom: var(--zt-space-3, 1rem); +} + +.reports-page .reports-controls__head .form-title { + margin-bottom: 0; +} + +.reports-page .reports-controls__block + .reports-controls__block { + margin-top: var(--zt-space-3, 1rem); + padding-top: var(--zt-space-3, 1rem); + border-top: 1px solid var(--border-light); +} + +.reports-page .reports-controls__block--tabs { + padding-top: 0; + border-top: none; + margin-top: 0; +} + +.reports-page .reports-controls__block--filters { + margin-bottom: 0; + overflow: visible; +} + +.reports-page .reports-controls .zt-tabs { + margin-bottom: 0; +} + +.reports-page .reports-view-tabs-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--zt-space-2, 0.5rem) var(--zt-space-3, 0.75rem); +} + +.reports-page .reports-view-tabs-row__sep { + width: 1px; + height: 1.25rem; + background: var(--border-light); + flex-shrink: 0; +} + +.reports-page .reports-journal-tabs-wrap { + display: flex; + align-items: center; + gap: var(--zt-space-2, 0.5rem); +} + +/* Компактная строка фильтров — единая сетка */ +.reports-page .reports-filter-bar { + display: grid; + grid-template-columns: minmax(11rem, 1.15fr) minmax(8.5rem, 0.95fr) minmax(7.5rem, 0.85fr) auto; + gap: var(--zt-space-2, 0.5rem); + align-items: center; +} + +.reports-page .reports-filter-bar--with-farm { + grid-template-columns: + minmax(10rem, 1fr) + minmax(7.5rem, 0.85fr) + minmax(7rem, 0.8fr) + minmax(6.5rem, 0.75fr) + auto; +} + +.reports-page .reports-filter-bar__control, +.reports-page .reports-filter-bar__select, +.reports-page .reports-drp__trigger, +.reports-page .reports-filter-bar__apply { + width: 100%; + min-height: var(--zt-btn-height, 40px); + height: var(--zt-btn-height, 40px); + font-size: var(--zt-font-body, 0.875rem); +} + +.reports-page .reports-filter-bar__apply { + width: auto; + min-width: 7.5rem; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + white-space: nowrap; + padding-inline: 1rem; + font-weight: 600; +} + +.reports-page .reports-filter-select { + position: relative; + min-width: 0; +} + +.reports-page .reports-filter-select__native { + position: absolute !important; + width: 1px !important; + height: 1px !important; + opacity: 0 !important; + pointer-events: none !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + padding: 0; + margin: -1px; +} + +.reports-page .reports-filter-dropdown__trigger { + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 0.45rem; + width: 100%; + padding: 0 0.75rem; + border: 1px solid var(--border-light); + border-radius: var(--zt-radius-md, 10px); + background: var(--surface, var(--bg-white, #fff)); + color: var(--text, var(--text-dark)); + cursor: pointer; + text-align: left; + box-shadow: none; + appearance: none; + font-size: var(--zt-font-body, 0.875rem); +} + +.reports-page .reports-filter-dropdown__label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.reports-page .reports-filter-dropdown__chev { + flex-shrink: 0; + font-size: 0.65rem; + color: var(--muted, var(--text-muted)); +} + +.reports-page .reports-filter-dropdown__trigger:hover { + border-color: rgba(var(--primary-rgb, 13, 110, 253), 0.35); +} + +.reports-page .reports-filter-dropdown__trigger:focus, +.reports-page .reports-filter-dropdown__trigger[aria-expanded="true"] { + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(var(--primary-rgb, 13, 110, 253), 0.15); + outline: none; +} + +.reports-page .reports-filter-dropdown__menu { + position: absolute; + left: 0; + right: 0; + top: calc(100% + 0.35rem); + z-index: 1200; + max-height: 16rem; + overflow-y: auto; + padding: 0.35rem; + border: 1px solid var(--border-light); + border-radius: var(--zt-radius-md, 10px); + background: var(--surface, var(--bg-white, #fff)); + box-shadow: var(--shadow-soft, 0 8px 24px rgba(15, 23, 42, 0.12)); +} + +.reports-page .reports-filter-dropdown__item { + display: block; + width: 100%; + border: none; + background: transparent; + text-align: left; + padding: 0.45rem 0.65rem; + border-radius: var(--zt-radius-sm, 8px); + cursor: pointer; + color: var(--text, var(--text-dark)); + font-size: var(--zt-font-body, 0.875rem); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.reports-page .reports-filter-dropdown__item:hover, +.reports-page .reports-filter-dropdown__item--active { + background: rgba(var(--primary-rgb, 13, 110, 253), 0.08); + color: var(--primary-color); +} + +.reports-page .reports-filter-dropdown__check { + display: flex; + align-items: center; + gap: 0.55rem; + width: 100%; + padding: 0.45rem 0.65rem; + border-radius: var(--zt-radius-sm, 8px); + cursor: pointer; + color: var(--text, var(--text-dark)); + font-size: var(--zt-font-body, 0.875rem); + user-select: none; +} + +.reports-page .reports-filter-dropdown__check:hover { + background: rgba(var(--primary-rgb, 13, 110, 253), 0.06); +} + +.reports-page .reports-filter-dropdown__check-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +html[data-theme="dark"] .reports-page .reports-filter-dropdown__trigger, +html[data-theme="dark"] .reports-page .reports-filter-dropdown__menu { + background: var(--dm-field, var(--bg-white, #2c3338)); + border-color: rgba(255, 255, 255, 0.12); + color: #f5f5f5; +} + +html[data-theme="dark"] .reports-page .reports-filter-dropdown__item { + color: #f5f5f5; +} + +html[data-theme="dark"] .reports-page .reports-filter-dropdown__check { + color: #f5f5f5; +} + +.reports-page .reports-filter-select .form-select { + appearance: none; + -webkit-appearance: none; + padding-right: 2rem; + background-color: var(--surface, var(--bg-white, #fff)); + color: var(--text, var(--text-dark)); + border: 1px solid var(--border-light); + border-radius: var(--zt-radius-md, 10px); + box-shadow: none; +} + +.reports-page .reports-filter-select .form-select:focus { + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(var(--primary-rgb, 13, 110, 253), 0.15); +} + +.reports-page .reports-filter-select__chev { + position: absolute; + right: 0.75rem; + top: 50%; + transform: translateY(-50%); + pointer-events: none; + font-size: 0.65rem; + color: var(--muted, var(--text-muted)); +} + +html[data-theme="dark"] .reports-page .reports-filter-select .form-select { + background-color: var(--dm-field, var(--bg-white, #2c3338)); + border-color: rgba(255, 255, 255, 0.12); + color: #f5f5f5; +} + +.reports-page .reports-export-dropdown { + position: relative; +} + +.reports-page .reports-export-dropdown__btn { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.reports-page .reports-export-dropdown__chev { + font-size: 0.6rem; + opacity: 0.7; +} + +.reports-page .reports-export-dropdown__menu { + position: absolute; + right: 0; + top: calc(100% + 0.35rem); + z-index: 1200; + min-width: 16.5rem; + max-width: 19rem; + padding: 0.35rem; + border: 1px solid var(--border-light); + border-radius: var(--zt-radius-md, 10px); + background: var(--surface, var(--bg-white, #fff)); + box-shadow: var(--shadow-soft, 0 8px 24px rgba(15, 23, 42, 0.12)); +} + +.reports-page .reports-export-dropdown__group + .reports-export-dropdown__group { + margin-top: 0.35rem; + padding-top: 0.35rem; + border-top: 1px solid var(--border-light); +} + +.reports-page .reports-export-dropdown__group-label { + padding: 0.2rem 0.65rem 0.35rem; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted, var(--text-muted)); +} + +.reports-page .reports-export-dropdown__item { + display: flex; + align-items: flex-start; + gap: 0.55rem; + width: 100%; + border: none; + background: transparent; + text-align: left; + padding: 0.55rem 0.65rem; + border-radius: var(--zt-radius-sm, 8px); + cursor: pointer; + color: var(--text, var(--text-dark)); +} + +.reports-page .reports-export-dropdown__icon { + flex-shrink: 0; + width: 1.125rem; + display: flex; + align-items: center; + justify-content: center; + margin-top: 0.05rem; + font-size: 1rem; + line-height: 1; +} + +.reports-page .reports-export-dropdown__text { + flex: 1; + min-width: 0; +} + +.reports-page .reports-export-dropdown__item:hover { + background: rgba(var(--primary-rgb, 13, 110, 253), 0.08); +} + +.reports-page .reports-export-dropdown__item strong { + display: block; + font-size: 0.8125rem; +} + +.reports-page .reports-export-dropdown__item small { + display: block; + color: var(--muted, var(--text-muted)); + font-size: 0.6875rem; + margin-top: 0.1rem; +} + +html[data-theme="dark"] .reports-page .reports-export-dropdown__menu { + background: var(--dm-field, #2c3338); + border-color: rgba(255, 255, 255, 0.12); +} + +.reports-page .reports-drp { + position: relative; + min-width: 0; +} + +.reports-page .reports-drp__trigger { + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0 0.75rem; + border: 1px solid var(--border-light); + border-radius: var(--zt-radius-md, 10px); + background: var(--surface, var(--bg-white, #fff)); + color: var(--text, var(--text-dark)); + cursor: pointer; + text-align: left; + box-shadow: none; + appearance: none; +} + +.reports-page .reports-drp__trigger:hover { + border-color: rgba(var(--primary-rgb, 13, 110, 253), 0.35); +} + +.reports-page .reports-drp__trigger:focus, +.reports-page .reports-drp__trigger[aria-expanded="true"] { + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(var(--primary-rgb, 13, 110, 253), 0.15); + outline: none; +} + +.reports-page .reports-drp__trigger .fa-calendar-alt { + color: var(--primary-color); + flex-shrink: 0; + font-size: 0.875rem; +} + +.reports-page .reports-drp__trigger #reportsDateRangeLabel { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.reports-page .reports-drp__chev { + margin-left: auto; + font-size: 0.65rem; + color: var(--muted, var(--text-muted)); + flex-shrink: 0; +} + +.reports-page .reports-drp__panel { + position: fixed; + z-index: 2500; + width: min(19rem, calc(100vw - 1.5rem)); + padding: 0.75rem; + border: 1px solid var(--border-light); + border-radius: var(--zt-radius-lg, 12px); + background: var(--surface, var(--bg-white, #fff)); + box-shadow: var(--shadow-soft, 0 8px 24px rgba(15, 23, 42, 0.12)); +} + +.reports-page .reports-drp__presets { + display: flex; + gap: 0.35rem; + margin-bottom: 0.65rem; +} + +.reports-page .reports-drp__preset { + flex: 1; + border: 1px solid var(--border-light); + border-radius: var(--zt-radius-sm, 8px); + background: transparent; + color: var(--text, var(--text-dark)); + font-size: 0.75rem; + font-weight: 600; + padding: 0.35rem 0.35rem; + min-height: 2rem; + cursor: pointer; + transition: background var(--transition, 0.15s), border-color var(--transition, 0.15s); +} + +.reports-page .reports-drp__preset.active, +.reports-page .reports-drp__preset:hover { + border-color: var(--primary-color); + color: var(--primary-color); + background: rgba(var(--primary-rgb, 13, 110, 253), 0.08); +} + +.reports-page .reports-drp__cal-head { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.35rem; + font-size: 0.8125rem; + font-weight: 600; + color: var(--text, var(--text-dark)); +} + +.reports-page .reports-drp__nav { + border: none; + background: transparent; + color: var(--muted, var(--text-muted)); + width: 1.75rem; + height: 1.75rem; + border-radius: var(--zt-radius-sm, 8px); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.reports-page .reports-drp__nav:hover { + background: rgba(var(--primary-rgb, 13, 110, 253), 0.08); + color: var(--primary-color); +} + +.reports-page .reports-drp__weekdays, +.reports-page .reports-drp__grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 0.15rem; +} + +.reports-page .reports-drp__weekdays { + margin-bottom: 0.25rem; + font-size: 0.625rem; + font-weight: 600; + color: var(--muted, var(--text-muted)); + text-align: center; +} + +.reports-page .reports-drp__day { + border: none; + background: transparent; + border-radius: var(--zt-radius-sm, 8px); + font-size: 0.75rem; + line-height: 1.75rem; + height: 1.75rem; + padding: 0; + cursor: pointer; + color: var(--text, var(--text-dark)); +} + +.reports-page .reports-drp__day--empty { + pointer-events: none; +} + +.reports-page .reports-drp__day--today { + font-weight: 700; + color: var(--primary-color); +} + +.reports-page .reports-drp__day--in-range { + background: rgba(var(--primary-rgb, 13, 110, 253), 0.12); +} + +.reports-page .reports-drp__day--edge { + background: var(--primary-color); + color: #fff; +} + +.reports-page .reports-drp__day--edge.reports-drp__day--today { + color: #fff; +} + + +html[data-theme="dark"] .reports-page .reports-drp__trigger, +html[data-theme="dark"] .reports-page .reports-drp__panel { + background: var(--dm-field, var(--bg-white, #2c3338)); + border-color: rgba(255, 255, 255, 0.12); + color: #f5f5f5; +} + +html[data-theme="dark"] .reports-page .reports-drp__preset { + color: #f5f5f5; + border-color: rgba(255, 255, 255, 0.12); +} + +html[data-theme="dark"] .reports-page .reports-drp__day { + color: #f5f5f5; +} + +html[data-theme="dark"] .reports-page .reports-drp__cal-head { + color: #f5f5f5; +} + +html[data-theme="dark"] .reports-page .reports-drp__weekdays { + color: #a8b0b8; +} + +@media (max-width: 768px) { + .reports-page .reports-filter-bar, + .reports-page .reports-filter-bar--with-farm { + grid-template-columns: 1fr 1fr; + } + + .reports-page .reports-drp { + grid-column: 1 / -1; + } + + .reports-page .reports-filter-bar__apply { + grid-column: 1 / -1; + width: 100%; + } +} + +@media (max-width: 640px) { + .reports-page .reports-filter-bar__apply-text { + display: none; + } +} + +.reports-page .calendar-inputs { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: flex-end; +} + +.reports-page .calendar-group { + flex: 1; + min-width: min(100%, 200px); +} + +.reports-page .filters-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + align-items: end; +} + +.reports-page .filter-buttons { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.reports-page .report-section { + margin-top: 0.75rem; +} + +.reports-page .report-section__title { + margin: 0 0 0.5rem; + font-size: 0.875rem; + font-weight: 600; + color: var(--text-dark); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.reports-page .report-section__title i { + color: var(--primary-color); +} + +.reports-page .report-table-wrap { + overflow-x: auto; + border: 1px solid var(--border-light); + border-radius: var(--zt-radius-sm, 8px); +} + +.reports-page .components-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8125rem; + background: transparent; + margin: 0; +} + +.reports-page .components-table th, +.reports-page .components-table td { + padding: 0.5rem 0.65rem; + text-align: right; + border-bottom: 1px solid var(--border-light); + vertical-align: middle; +} + +.reports-page .components-table th:first-child, +.reports-page .components-table td:first-child { + text-align: left; +} + +.reports-page .components-table th { + background: rgba(var(--primary-rgb, 33, 150, 243), 0.06); + font-weight: 600; + font-size: 0.75rem; + color: var(--text-muted); + cursor: pointer; + user-select: none; + white-space: nowrap; +} + +.reports-page .components-table th:hover { + background: rgba(var(--primary-rgb, 33, 150, 243), 0.1); +} + +.reports-page .components-table tbody tr:last-child td { + border-bottom: none; +} + +.reports-page .components-table tbody tr:hover { + background: rgba(var(--primary-rgb, 33, 150, 243), 0.04); +} + +.reports-page .deviation-negative { + color: #ef4444; + font-weight: 500; +} + +.reports-page .deviation-positive { + color: #10b981; + font-weight: 500; +} + +.reports-page .deviation-significant { + color: #ef4444; + font-weight: 600; +} + +.reports-page .loading-duration { + font-variant-numeric: tabular-nums; + color: var(--primary-color); +} + +.reports-page .empty-state { + text-align: center; + padding: 2.5rem 1rem; + color: var(--text-muted); +} + +.reports-page .empty-state .empty-icon { + font-size: 3rem; + color: var(--text-muted); + margin-bottom: 0.75rem; +} + +.reports-page .reports-controls .btn-outline-success { + --bs-btn-color: #059669; + --bs-btn-border-color: rgba(5, 150, 105, 0.45); + --bs-btn-hover-bg: rgba(5, 150, 105, 0.08); + --bs-btn-hover-border-color: #059669; +} + +html[data-theme="dark"] .reports-page .report-section__title { + color: #f5f5f5; +} + +html[data-theme="dark"] .reports-page .components-table th { + background: rgba(255, 255, 255, 0.06); +} + +html[data-theme="dark"] .reports-page .components-table th, +html[data-theme="dark"] .reports-page .components-table td { + border-bottom-color: rgba(255, 255, 255, 0.08); +} + +html[data-theme="dark"] .reports-page .report-table-wrap { + border-color: rgba(255, 255, 255, 0.12); +} + +html[data-theme="dark"] .reports-page .reports-controls .btn-outline-success { + --bs-btn-color: #6ee7b7; + --bs-btn-border-color: rgba(110, 231, 183, 0.35); +} + +@media (max-width: 768px) { + .reports-page .report-card__top { + flex-direction: column; + align-items: flex-start; + } + + .reports-page .component-info { + grid-template-columns: 1fr; + } + + .reports-page .calendar-inputs { + flex-direction: column; + align-items: stretch; + } + + .reports-page .filter-buttons { + width: 100%; + } + + .reports-page .filter-buttons .btn { + flex: 1; + } +} + +.reports-page .reports-controls__head-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.reports-page .feed-alerts-summary-chip { + font-size: 0.8125rem; + font-weight: 600; + color: var(--warning-color, #d97706); + background: rgba(217, 119, 6, 0.12); + border-radius: 999px; + padding: 0.25rem 0.65rem; +} + +.reports-page .reports-view-tabs, +.reports-page .alerts-severity-tabs { + margin-bottom: 0; +} + +/* Лента отклонений — карточки как на /components */ +.reports-page .deviation-feed { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.reports-page .deviation-alert-card.list-item { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0.5rem; + width: 100%; + text-align: left; + appearance: none; + -webkit-appearance: none; + font: inherit; + color: inherit; + margin: 0; + background: var(--bg-white, #fff); + border: 1px solid var(--border-light); + border-radius: 1rem; + padding: 1rem 1.25rem; + cursor: pointer; + box-shadow: var(--shadow-soft); + transition: background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; +} + +.reports-page .deviation-alert-card.list-item:hover { + border-color: var(--primary-color); + box-shadow: var(--shadow-soft), 0 0 0 1px rgba(var(--primary-rgb, 33, 150, 243), 0.2); + background-color: rgba(var(--primary-rgb), 0.04); +} + +.reports-page .deviation-alert-card__top { + display: flex; + align-items: flex-start; + gap: 0.75rem; +} + +.reports-page .deviation-alert-card__head { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem 0.75rem; +} + +.reports-page .deviation-alert-card__title { + font-size: 1.0625rem; + font-weight: 600; + color: var(--text-dark); + margin: 0; + display: inline-flex; + align-items: center; + gap: 0.4rem; + min-width: 0; +} + +.reports-page .deviation-alert-card__title i { + color: var(--primary-color); + font-size: 0.8rem; + flex-shrink: 0; +} + +.reports-page .deviation-type-badge { + font-size: 0.75rem; + font-weight: 600; + padding: 0.2rem 0.55rem; + border-radius: 999px; + white-space: nowrap; +} + +.reports-page .deviation-type-badge--warning { + background: rgba(245, 158, 11, 0.14); + color: #b45309; +} + +.reports-page .deviation-type-badge--error { + background: rgba(239, 68, 68, 0.12); + color: #b91c1c; +} + +.reports-page .deviation-alert-card__info { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 0.35rem 1rem; +} + +.reports-page .deviation-alert-card__context { + font-size: 0.875rem; + color: var(--text-dark); + font-weight: 600; + line-height: 1.35; +} + +.reports-page .deviation-alert-card__meta { + font-size: 0.8125rem; + color: var(--text-muted); +} + +.reports-page .deviation-feed-item__dot { + flex-shrink: 0; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + margin-top: 0.45rem; +} + +.reports-page .deviation-feed-item__dot--warning { + background: var(--warning-color, #f59e0b); +} + +.reports-page .deviation-feed-item__dot--error { + background: var(--danger-color, #ef4444); +} + +.reports-page .deviation-feed-item__dot--info { + background: var(--info-color, #0ea5e9); +} + +.reports-page .deviation-feed-item__delta { + flex-shrink: 0; + font-size: 0.9375rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + margin-top: 0.15rem; +} + +.reports-page .deviation-feed-item__chevron { + align-self: flex-end; + color: var(--text-muted); + opacity: 0.45; + font-size: 0.7rem; + transition: opacity 0.15s ease, transform 0.15s ease; +} + +.reports-page .deviation-alert-card:hover .deviation-feed-item__chevron { + opacity: 0.85; + transform: translateX(2px); +} + +html[data-theme="dark"] .reports-page .deviation-alert-card.list-item { + background: var(--bg-white) !important; + border-color: rgba(255, 255, 255, 0.12) !important; + box-shadow: none !important; + color: #f5f5f5; +} + +html[data-theme="dark"] .reports-page .deviation-alert-card.list-item:hover { + background-color: rgba(var(--primary-rgb), 0.1) !important; + border-color: var(--primary-color) !important; +} + +html[data-theme="dark"] .reports-page .deviation-alert-card__title, +html[data-theme="dark"] .reports-page .deviation-alert-card__context { + color: #f5f5f5 !important; +} + +html[data-theme="dark"] .reports-page .deviation-alert-card__meta { + color: #94a3b8; +} + +html[data-theme="dark"] .reports-page .deviation-type-badge--warning { + background: rgba(251, 191, 36, 0.22); + color: #fcd34d; +} + +html[data-theme="dark"] .reports-page .deviation-type-badge--error { + background: rgba(248, 113, 113, 0.22); + color: #fca5a5; +} + +html[data-theme="dark"] .reports-page .deviation-feed-item__delta { + color: #f1f5f9; +} + +html[data-theme="dark"] .reports-page .deviation-negative, +html[data-theme="dark"] .reports-page .deviation-significant { + color: #f87171; +} + +html[data-theme="dark"] .reports-page .deviation-positive { + color: #34d399; +} + +html[data-theme="dark"] .reports-page .feed-alerts-summary-chip { + color: #fbbf24; + background: rgba(251, 191, 36, 0.16); +} diff --git a/apps/web/public/wesp/css/wesp-zootech-components.css b/apps/web/public/wesp/css/wesp-zootech-components.css new file mode 100644 index 0000000..4df55e2 --- /dev/null +++ b/apps/web/public/wesp/css/wesp-zootech-components.css @@ -0,0 +1,807 @@ +/** + * Компоненты операторского UI — карточки, кнопки, формы, табы, loading, empty. + * После wesp-zootech-layout.css + */ + +/* —— Section —— */ +.zt-section { + background: var(--surface); + border: 1px solid var(--border-light); + border-radius: var(--zt-radius-lg); + box-shadow: var(--shadow-soft); + padding: var(--zt-space-4); + margin-bottom: var(--zt-space-4); +} + +.zt-section__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--zt-space-3); + margin-bottom: var(--zt-space-3); + flex-wrap: wrap; +} + +.zt-section__title { + display: flex; + align-items: center; + gap: var(--zt-space-2); + font-size: var(--zt-font-section); + font-weight: 600; + color: var(--text); + margin: 0; +} + +.zt-section__title i { + color: var(--primary-color); + font-size: 1.1rem; +} + +.zt-section__subtitle { + font-size: var(--zt-font-body); + color: var(--muted); + margin: 0.25rem 0 0; +} + +.zt-section__actions { + display: flex; + align-items: center; + gap: var(--zt-space-2); + flex-shrink: 0; +} + +/* —— Card —— */ +.zt-card { + background: var(--surface); + border: 1px solid var(--border-light); + border-radius: var(--zt-radius-lg); + padding: var(--zt-space-4); + box-shadow: var(--shadow-soft); + transition: border-color var(--transition), box-shadow var(--transition); +} + +.zt-card--interactive { + cursor: pointer; +} + +.zt-card--interactive:hover { + border-color: var(--primary-color); + box-shadow: var(--shadow-soft), 0 0 0 2px rgba(var(--primary-rgb), 0.18); +} + +.zt-card--dashed { + border-style: dashed; + border-color: var(--border-light); + min-height: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + gap: var(--zt-space-3); +} + +.zt-card--stat { + border-left: 3px solid var(--primary-color); + padding: var(--zt-space-3) var(--zt-space-4); +} + +.zt-card-grid { + display: grid; + gap: var(--zt-space-3); + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); +} + +/* Legacy aliases */ +.component-card, +.dispenser-card, +.consumption-block .zt-card { + /* migrated pages use zt-card; keep hooks for gradual migration */ +} + +/* —— Loading placeholder —— */ +.zt-loading-list { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--zt-space-2); + min-height: 8rem; + padding: var(--zt-space-3) 0; +} + +.zt-loading-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--zt-space-2); +} + +.zt-loading-list.dashboard-list-skeleton--exit, +.dashboard-list-skeleton--exit { + opacity: 0; + pointer-events: none; + transition: opacity 0.16s ease; +} + +.zt-card--loading { + pointer-events: none; + min-height: 5.5rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--zt-space-2); + animation: zt-card-pulse 1.6s ease-in-out infinite; +} + +@keyframes zt-card-pulse { + 0%, + 100% { + border-color: var(--border-light); + opacity: 1; + } + 50% { + border-color: rgba(var(--primary-rgb), 0.35); + opacity: 0.92; + } +} + +.zt-spinner { + width: 22px; + height: 22px; + border: 2px solid var(--border-light); + border-top-color: var(--primary-color); + border-radius: 50%; + animation: zt-spin 0.7s linear infinite; +} + +.zt-spinner--lg { + width: 32px; + height: 32px; + border-width: 3px; +} + +@keyframes zt-spin { + to { + transform: rotate(360deg); + } +} + +.zt-loading-caption { + font-size: var(--zt-font-caption); + color: var(--muted); + margin: 0; +} + +.zt-loading-panel { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--zt-space-3); + padding: var(--zt-space-5); + min-height: 12rem; +} + +/* —— Tabs —— */ +.zt-tabs { + display: flex; + flex-wrap: wrap; + gap: var(--zt-space-1); + margin-bottom: var(--zt-space-3); +} + +.zt-tab, +.fa-tab-btn.period-btn { + border: 1px solid var(--border-light); + background: transparent; + border-radius: var(--zt-radius-sm); + padding: 0.45rem 0.85rem; + font-size: var(--zt-font-body); + font-weight: 500; + color: var(--text); + cursor: pointer; + transition: background var(--transition), border-color var(--transition), + color var(--transition); + min-height: var(--zt-btn-height); + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; +} + +.zt-tab:hover, +.fa-tab-btn:not(.active):hover { + background: rgba(var(--primary-rgb), 0.06); + border-color: rgba(var(--primary-rgb), 0.25); +} + +.zt-tab.active, +.zt-tab--active, +.fa-tab-btn.active, +.period-btn.active { + background: var(--gradient-blue) !important; + border-color: transparent !important; + color: #fff !important; +} + +/* —— Empty state —— */ +.zt-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: var(--zt-space-5) var(--zt-space-4); + color: var(--muted); +} + +.zt-empty__icon { + font-size: 2.5rem; + margin-bottom: var(--zt-space-3); + opacity: 0.45; +} + +.zt-empty__title { + font-size: var(--zt-font-section); + font-weight: 600; + color: var(--text); + margin: 0 0 var(--zt-space-1); +} + +.zt-empty__hint { + font-size: var(--zt-font-body); + margin: 0; + max-width: 28rem; +} + +/* —— Inline notices & toasts (единый вид подсказок и уведомлений) —— */ +.zt-notice { + display: none; + margin: 0; + padding: 0.75rem 1rem; + font-size: var(--zt-font-body); + line-height: 1.55; + color: var(--text); + background: var(--bg-light); + border: 1px solid var(--border-light); + border-radius: var(--zt-radius-md); + border-left-width: 3px; + border-left-style: solid; + box-shadow: var(--box-shadow); +} + +.zt-notice.is-visible, +.zt-notice--static { + display: block; +} + +.zt-notice--info { + border-left-color: var(--primary-color); +} + +.zt-notice--success { + border-left-color: var(--success-color); + background: rgba(16, 185, 129, 0.08); +} + +.zt-notice--warning { + border-left-color: var(--warning-color); + background: rgba(245, 158, 11, 0.1); +} + +.zt-notice--error { + border-left-color: var(--danger-color); + background: rgba(239, 68, 68, 0.08); +} + +.zt-notice__p { + margin: 0 0 0.65rem; +} + +.zt-notice__p:last-child { + margin-bottom: 0; +} + +.zt-notice__p strong { + font-weight: 600; + color: var(--text); +} + +html[data-theme="dark"] .zt-notice { + background: rgba(255, 255, 255, 0.06); + border-color: rgba(255, 255, 255, 0.12); + color: var(--text); + box-shadow: none; +} + +html[data-theme="dark"] .zt-notice--success { + background: rgba(16, 185, 129, 0.12); +} + +html[data-theme="dark"] .zt-notice--warning { + background: rgba(245, 158, 11, 0.12); +} + +html[data-theme="dark"] .zt-notice--error { + background: rgba(239, 68, 68, 0.12); +} + +/* —— Bottom-center toast (Yandex Music style) —— */ +@keyframes wesp-zt-toast-enter { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes wesp-zt-toast-exit { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(12px); + } +} + +@keyframes wesp-zt-toast-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes wesp-zt-toast-fade-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +.wesp-zt-toast-host { + position: fixed; + left: 50%; + bottom: 24px; + transform: translateX(-50%); + z-index: 6000; + display: flex; + flex-direction: column; + align-items: center; + pointer-events: none; + width: max-content; + max-width: min(28rem, calc(100vw - 2rem)); +} + +.wesp-zt-toast { + pointer-events: auto; + display: inline-flex; + align-items: center; + gap: 0.6rem; + width: max-content; + max-width: min(28rem, calc(100vw - 2rem)); + min-height: 40px; + padding: 0.45rem 0.55rem 0.45rem 0.65rem; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(12, 12, 14, 0.88); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.28); + animation: wesp-zt-toast-enter 280ms ease-out both; +} + +.wesp-zt-toast.is-exiting { + animation: wesp-zt-toast-exit 220ms ease-in both; +} + +@media (prefers-reduced-motion: reduce) { + .wesp-zt-toast { + animation: wesp-zt-toast-fade-in 200ms ease-out both; + } + + .wesp-zt-toast.is-exiting { + animation: wesp-zt-toast-fade-out 180ms ease-in both; + } +} + +.wesp-zt-toast__accent { + flex-shrink: 0; + width: 0.5rem; + height: 0.5rem; + align-self: center; + border-radius: 50%; + background: var(--success-color); + box-shadow: 0 0 6px rgba(16, 185, 129, 0.45); +} + +.wesp-zt-toast--error .wesp-zt-toast__accent { + background: var(--danger-color); + box-shadow: 0 0 6px rgba(239, 68, 68, 0.45); +} + +.wesp-zt-toast--warning .wesp-zt-toast__accent { + background: var(--warning-color); + box-shadow: 0 0 6px rgba(245, 158, 11, 0.45); +} + +.wesp-zt-toast__message { + flex: 0 1 auto; + min-width: 0; + margin: 0; + padding-right: 0.15rem; + font-size: 0.9375rem; + line-height: 1.35; + font-weight: 400; + color: rgba(255, 255, 255, 0.92); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.wesp-zt-toast--multiline .wesp-zt-toast__message { + white-space: normal; +} + +.wesp-zt-toast__message strong { + font-weight: 600; + color: #fff; +} + +.wesp-zt-toast__close { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + margin: 0; + padding: 0; + border: none; + border-radius: 999px; + background: transparent; + color: rgba(255, 255, 255, 0.55); + font-size: 1.1rem; + line-height: 1; + cursor: pointer; + transition: color 0.15s ease, background 0.15s ease; +} + +.wesp-zt-toast__close:hover { + color: #fff; + background: rgba(255, 255, 255, 0.08); +} + +html[data-theme="light"] .wesp-zt-toast { + background: rgba(30, 30, 34, 0.9); + border-color: rgba(255, 255, 255, 0.12); +} + +html[data-theme="light"] .wesp-zt-toast__message { + color: rgba(255, 255, 255, 0.92); +} + +/* —— Notification center FAB (bottom-left) —— */ +.wesp-zt-notify-fab { + position: fixed; + left: max(20px, calc(env(safe-area-inset-left, 0px) + 16px)); + bottom: max(24px, calc(env(safe-area-inset-bottom, 0px) + 20px)); + z-index: 5500; + width: 44px; + height: 44px; + padding: 0; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 50%; + background: rgba(12, 12, 14, 0.88); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.28); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: transform 0.18s ease, box-shadow 0.18s ease; +} + +.wesp-zt-notify-fab:hover { + transform: scale(1.04); +} + +.wesp-zt-notify-fab:focus-visible { + outline: 2px solid rgba(var(--primary-rgb), 0.85); + outline-offset: 2px; +} + +.wesp-zt-notify-fab__glyph { + font-family: Inter, Arial, Helvetica, "DejaVu Sans", sans-serif; + font-size: 1.25rem; + font-weight: 700; + line-height: 1; + color: #fff; + transform: translateY(0.5px); +} + +.wesp-zt-notify-fab__badge { + position: absolute; + top: -2px; + right: -2px; + min-width: 18px; + height: 18px; + padding: 0 4px; + border-radius: 999px; + background: #ef4444; + color: #fff; + font-size: 0.65rem; + font-weight: 700; + line-height: 18px; + text-align: center; + box-shadow: 0 0 0 2px rgba(12, 12, 14, 0.95); +} + +.wesp-zt-notify-fab__badge[hidden] { + display: none !important; +} + +/* —— Button grid (modals) —— */ +.zt-btn-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--zt-space-2); +} + +@media (max-width: 480px) { + .zt-btn-grid { + grid-template-columns: 1fr; + } +} + +/* —— Form grid —— */ +.zt-form-grid { + display: grid; + gap: var(--zt-space-3); + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); +} + +.zootech-app-body .form-label, +.zt-section .form-label { + font-size: var(--zt-font-caption); + font-weight: 500; + color: var(--muted); + margin-bottom: 0.35rem; +} + +/* —— Bootstrap overrides (operator pages) —— */ +.zootech-app-body .btn { + min-height: var(--zt-btn-height); + padding: 0.45rem 1rem; + border-radius: var(--zt-radius-md); + font-weight: 500; + font-size: var(--zt-font-body); + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--zt-space-1); + transition: transform var(--transition), filter var(--transition), + background var(--transition), border-color var(--transition); +} + +.zootech-app-body .btn:active:not(:disabled) { + transform: scale(0.98); +} + +.zootech-app-body .btn:focus-visible { + outline: none; + box-shadow: var(--zt-focus-ring); +} + +.zootech-app-body .btn-primary { + background: var(--gradient-blue) !important; + border: none !important; + color: #fff !important; +} + +.zootech-app-body .btn-primary:hover:not(:disabled) { + filter: brightness(1.06); +} + +.zootech-app-body .btn-success { + background: var(--success-color) !important; + border-color: var(--success-color) !important; + color: #fff !important; +} + +.zootech-app-body .btn-success:hover:not(:disabled) { + filter: brightness(1.05); +} + +.zootech-app-body .btn-secondary { + background: var(--field) !important; + border-color: var(--border-light) !important; + color: var(--text) !important; +} + +.zootech-app-body .btn-outline-primary { + color: var(--primary-color) !important; + border-color: var(--primary-color) !important; +} + +.zootech-app-body .btn-outline-primary:hover:not(:disabled) { + background: rgba(var(--primary-rgb), 0.08) !important; +} + +.zootech-app-body .btn-outline-secondary { + color: var(--text) !important; + border-color: var(--border-light) !important; +} + +.zootech-app-body .btn-outline-danger { + color: var(--danger-color) !important; + border-color: rgba(239, 68, 68, 0.5) !important; +} + +.zootech-app-body .btn-outline-success { + color: var(--success-color) !important; + border-color: rgba(16, 185, 129, 0.5) !important; +} + +.zootech-app-body .btn-outline-success:hover:not(:disabled) { + background: rgba(16, 185, 129, 0.1) !important; +} + +.zootech-app-body .form-control, +.zootech-app-body .form-select { + min-height: var(--zt-btn-height); + border-radius: var(--zt-radius-md); + border-color: var(--border-light); + font-size: var(--zt-font-body); +} + +.zootech-app-body .form-control:focus, +.zootech-app-body .form-select:focus { + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(var(--primary-rgb), 0.15); +} + +/* Magic / modern btn aliases */ +.zootech-app-body .magic-button, +.zootech-app-body .modern-btn.btn-primary-modern, +.zootech-app-body .btn-primary-modern { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--zt-space-1); + padding: 0.55rem 1.25rem; + font-weight: 600; + color: #fff; + background: var(--gradient-blue); + border: none; + border-radius: var(--zt-radius-md); + cursor: pointer; + min-height: var(--zt-btn-height); + box-shadow: var(--shadow-soft); +} + +.zootech-app-body .magic-button:hover { + filter: brightness(1.06); +} + +/* Recipe editor — квадратные icon-кнопки в шапке */ +#recipeEdit .recipe-edit-header-icon-btn { + width: 2.5rem; + height: 2.5rem; + min-width: 2.5rem; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--zt-radius-md); +} + +#recipeEdit .recipe-section-toolbar > .btn-primary, +#recipeEdit .recipe-form-footer-actions .btn-primary { + min-height: var(--zt-btn-height); + padding: 0.45rem 1rem; + font-weight: 600; +} + +#recipeEdit .recipe-save-exit-btn { + min-width: 11rem; +} + +@keyframes zt-reveal { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Recipes boot overlay — только зона контента, navbar остаётся виден */ +#wesp-recipes-boot { + position: fixed; + left: 0; + right: 0; + bottom: 0; + top: 0; + z-index: 900; + background: var(--bg-page); + display: flex; + align-items: center; + justify-content: center; + opacity: 1; + transition: opacity 0.35s ease; + pointer-events: none; +} + +#wesp-recipes-boot.wesp-recipes-boot--hide { + opacity: 0; +} + +html[data-theme="dark"] #wesp-recipes-boot { + background: var(--bg-page); +} + +#wesp-recipes-boot .wesp-logo-loader--embedded { + width: min(480px, 90vw); + height: min(222px, 36vh); +} + +/* Gray scale legacy (reports) */ +:root { + --gray-50: var(--bg-light); + --gray-100: #f3f4f6; + --gray-200: var(--border-light); + --gray-300: #d1d5db; + --gray-400: #9ca3af; + --gray-500: var(--text-muted); + --gray-600: #4b5563; + --gray-700: #374151; + --gray-800: #1f2937; + --gray-900: var(--text-dark); + --white: var(--surface); +} + +html[data-theme="dark"] { + --gray-50: var(--bg-page); + --gray-100: var(--surface); + --gray-200: #2a3036; + --gray-300: var(--field); + --gray-400: var(--muted); + --gray-500: #b8c0c8; + --gray-600: #d0d4d8; + --gray-700: #e8eaec; + --gray-800: #f0f4f8; + --gray-900: var(--text); + --white: var(--surface); +} + +@media (max-width: 768px) { + .zootech-app-body .btn { + min-height: var(--zt-btn-height-touch); + } +} + +@media (prefers-reduced-motion: reduce) { + .zt-card--loading { + animation: none; + } + .zt-spinner { + animation: none; + border-top-color: var(--primary-color); + } +} diff --git a/apps/web/public/wesp/css/wesp-zootech-k-hub.css b/apps/web/public/wesp/css/wesp-zootech-k-hub.css new file mode 100644 index 0000000..68c56dc --- /dev/null +++ b/apps/web/public/wesp/css/wesp-zootech-k-hub.css @@ -0,0 +1,1389 @@ +/* Hub «К»: уведомления + план на день — в стиле shell-модалок zootech */ + +/* —— Размер модалки «План на день» —— */ +.modal.wesp-shell-modal .modal-dialog.modal-dialog--shell-plan { + max-width: min(1240px, calc(100vw - 2rem)); +} + +/* —— Размер модалки «Уведомления» —— */ +.modal.wesp-shell-modal .modal-dialog.modal-dialog--shell-notify { + max-width: min(680px, calc(100vw - 1.5rem)); +} + +.modal.wesp-shell-modal.zt-k-hub--notify .modal-content.wesp-shell-bsmodal { + max-height: min(92vh, 820px); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.modal.wesp-shell-modal.zt-k-hub--notify .zt-k-hub-modal__header { + flex-shrink: 0; +} + +.modal.wesp-shell-modal.zt-k-hub--notify .zt-k-hub-modal__body { + flex: 1; + min-height: 0; + max-height: none; + min-height: 280px; +} + +.modal.wesp-shell-modal.zt-k-hub--plan .modal-content.wesp-shell-bsmodal { + max-height: min(94vh, 900px); + padding: 1.15rem 1.35rem; + display: flex; + flex-direction: column; + overflow: hidden; + position: relative; +} + +.modal.wesp-shell-modal.zt-k-hub--plan .zt-k-hub-modal__header { + flex-shrink: 0; +} + +.zt-k-hub-modal__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.zt-k-hub-modal__header-left { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; +} + +.zt-k-hub-modal__header-right { + display: flex; + align-items: center; + gap: 0.35rem; + flex-shrink: 0; +} + +.zt-k-hub-modal__title { + margin: 0; +} + +.zt-k-hub-modal__back { + border: none; + background: transparent; + color: var(--primary-color, #2196f3); + font-size: 0.8125rem; + font-weight: 600; + padding: 0.25rem 0.35rem; + cursor: pointer; +} + +.zt-k-hub-modal__body { + min-height: 160px; + max-height: min(52vh, 380px); + overflow: auto; +} + +.zt-k-hub-modal__body--plan { + flex: 1; + min-height: 0; + max-height: none; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.zt-k-hub-plan-mount { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +/* —— Hub-карточки (как zt-notify-item / zt-card) —— */ +.zt-k-hub__grid { + display: grid; + grid-template-columns: 1fr; + gap: 0.5rem; +} + +@media (min-width: 420px) { + .zt-k-hub__grid { + grid-template-columns: 1fr 1fr; + } +} + +.zt-k-hub-card { + display: flex; + align-items: flex-start; + gap: 0.75rem; + width: 100%; + text-align: left; + padding: 0.75rem 0.85rem; + box-shadow: none; +} + +.zt-k-hub-card.zt-card { + background: var(--surface-muted, #f2f5f9); + border-color: var(--border-light, #e2e8f0); +} + +.zt-k-hub-card.zt-card--interactive:hover { + background: var(--surface, #fff); + box-shadow: none; + border-color: rgba(var(--primary-rgb, 33, 150, 243), 0.35); +} + +.zt-k-hub-card__icon { + flex-shrink: 0; + width: 1.75rem; + margin-top: 0.1rem; + color: var(--muted, #64748b); + font-size: 0.95rem; + line-height: 1; +} + +.zt-k-hub-card__text { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; +} + +.zt-k-hub-card__title { + font-size: 0.9rem; + font-weight: 600; + color: var(--text, #1e293b); + margin: 0; +} + +.zt-k-hub-card__desc { + font-size: 0.75rem; + color: var(--muted, #64748b); + line-height: 1.35; +} + +/* —— План на день —— */ +.zt-k-hub-plan { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + position: relative; +} + +.zt-k-hub-plan__toolbar { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0.65rem; + margin-bottom: 0.75rem; + flex-shrink: 0; +} + +.zt-k-hub-plan__field { + flex: 1 1 160px; + min-width: 0; + max-width: 220px; +} + +.zt-k-hub-plan__field .form-label { + font-size: 0.75rem; + margin-bottom: 0.2rem; +} + +.zt-k-hub-plan__pdf { + flex-shrink: 0; +} + +.zt-k-hub-plan__content { + flex: 1; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.zt-k-hub-plan__split { + display: grid; + grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); + gap: 0.85rem; + flex: 1; + min-height: 0; + align-items: stretch; +} + +@media (max-width: 860px) { + .zt-k-hub-plan__split { + grid-template-columns: 1fr; + grid-template-rows: auto 1fr; + min-height: 0; + } +} + +.zt-k-hub-plan__totals-col, +.zt-k-hub-plan__trips-col { + min-height: 0; + max-height: 100%; + display: flex; + flex-direction: column; + border: 1px solid var(--border-light, #e2e8f0); + border-radius: var(--zt-radius-lg, 8px); + background: var(--surface-muted, #f2f5f9); + overflow: hidden; +} + +.zt-k-hub-plan__trips-col { + min-width: 0; +} + +.zt-k-hub-plan__col-title { + font-size: 0.8125rem; + font-weight: 600; + margin: 0; + padding: 0.6rem 0.75rem; + border-bottom: 1px solid var(--border-light, #e2e8f0); + color: var(--text, #1e293b); + flex-shrink: 0; +} + +.zt-k-hub-plan__col-scroll { + flex: 1 1 auto; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + padding: 0.5rem 0.65rem 0.65rem; + overscroll-behavior: contain; +} + +.zt-k-hub-plan__col-empty { + padding: 0.65rem 0.75rem; + margin: 0; + font-size: 0.8125rem; +} + +.zt-k-hub-plan__period { + margin-bottom: 0.85rem; +} + +.zt-k-hub-plan__period:last-child { + margin-bottom: 0; +} + +.zt-k-hub-plan__period-title { + font-size: 0.8125rem; + font-weight: 600; + margin: 0 0 0.45rem; + color: var(--text, #1e293b); +} + +.zt-k-hub-plan__trips { + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.zt-k-hub-plan-trip { + padding: 0.55rem 0.65rem; + box-shadow: none; + background: var(--surface, #fff); +} + +.zt-k-hub-plan-trip__title { + font-size: 0.8125rem; + font-weight: 600; + margin: 0 0 0.4rem; +} + +.zt-k-hub-plan-trip__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.4rem; +} + +.zt-k-hub-plan-trip__head .zt-k-hub-plan-trip__title { + margin: 0; + flex: 1; + min-width: 0; +} + +.zt-k-hub-plan-trip__skip { + flex-shrink: 0; +} + +.zt-k-hub-plan-table__actions { + width: 2rem; + text-align: right; + vertical-align: middle; + padding-right: 0 !important; +} + +.zt-k-hub-plan-row__skip-dot { + display: inline-block; + margin-right: 0.35rem; + vertical-align: middle; +} + +.zt-k-hub-plan-row--skipped td { + color: var(--text-muted, #64748b); +} + +.zt-k-hub-plan-cell--hint { + cursor: help; + position: relative; + overflow: visible; +} + +.zt-k-hub-plan-cell--hint:hover { + z-index: 30; +} + +.zt-k-hub-plan-cell--hint:hover::after { + content: attr(data-hint); + position: absolute; + left: 50%; + top: calc(100% + 0.4rem); + bottom: auto; + transform: translateX(-50%); + z-index: 1000; + max-width: none; + padding: 0.45rem 0.7rem; + border-radius: 0.4rem; + font-size: 0.8125rem; + font-weight: 600; + line-height: 1.35; + font-style: normal; + white-space: nowrap; + color: #1e293b; + background: #fffbeb; + border: 1px solid #d97706; + box-shadow: + 0 8px 24px rgba(15, 23, 42, 0.18), + 0 0 0 1px rgba(217, 119, 6, 0.12); + pointer-events: none; + opacity: 1; +} + +html[data-theme="dark"] .zt-k-hub-plan-cell--hint:hover::after { + color: #fef3c7; + background: #422006; + border-color: #f59e0b; + box-shadow: + 0 8px 24px rgba(0, 0, 0, 0.45), + 0 0 0 1px rgba(245, 158, 11, 0.25); +} + +.zt-k-hub-plan-skipped__item .recipe-skip-badge__dot { + margin-right: 0.35rem; +} + +.zt-k-hub-plan-skipped { + margin-top: 0.75rem; + padding-top: 0.65rem; + border-top: 1px solid var(--border-light, #e2e8f0); +} + +.zt-k-hub-plan-skipped__title { + font-size: 0.8125rem; + font-weight: 600; + margin: 0 0 0.45rem; + color: var(--danger, #dc3545); +} + +.zt-k-hub-plan-skipped__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.zt-k-hub-plan-skipped__item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + font-size: 0.8125rem; + padding: 0.35rem 0.5rem; + background: rgba(220, 53, 69, 0.06); + border-radius: 0.35rem; +} + +.zt-k-hub-plan__undo-toast { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-top: 0.5rem; + padding: 0.45rem 0.65rem; + font-size: 0.8125rem; + background: var(--surface, #fff); + border: 1px solid var(--border-light, #e2e8f0); + border-radius: 0.35rem; + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08); +} + +.zt-k-hub-plan__undo-btn { + font-size: 0.8125rem; + padding: 0; + text-decoration: none; + white-space: nowrap; +} + +.zt-k-hub-plan-trip__sub { + font-size: 0.75rem; + font-weight: 600; + margin: 0.45rem 0 0.2rem; + color: var(--muted, #64748b); +} + +.zt-k-hub-plan-table { + width: 100%; + border-collapse: collapse; + font-size: 0.75rem; +} + +.zt-k-hub-plan-table th, +.zt-k-hub-plan-table td { + border: 1px solid var(--border-light, #e2e8f0); + padding: 0.28rem 0.4rem; + text-align: left; +} + +.zt-k-hub-plan-table th { + background: var(--surface, #fff); + font-weight: 600; +} + +.zt-k-hub-plan-table--totals td:last-child { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.zt-k-hub-plan__empty { + text-align: center; + color: var(--muted, #64748b); + padding: 2rem 1rem; +} + +.zt-k-hub-plan__empty i { + font-size: 2rem; + margin-bottom: 0.65rem; + opacity: 0.45; +} + +.zt-k-hub-plan__loading { + padding: 1.5rem 0; +} + +/* Тёмная тема — как shell-модалки и zt-notify-item */ +html[data-theme="dark"] .zt-k-hub-card.zt-card { + background: rgba(255, 255, 255, 0.06); + border-color: var(--dm-border, rgba(255, 255, 255, 0.12)); +} + +html[data-theme="dark"] .zt-k-hub-card.zt-card--interactive:hover { + background: rgba(255, 255, 255, 0.09); + border-color: rgba(255, 255, 255, 0.2); + box-shadow: none; +} + +html[data-theme="dark"] .zt-k-hub-card__icon { + color: var(--dm-muted, #a8b0b8); +} + +html[data-theme="dark"] .zt-k-hub-card__title, +html[data-theme="dark"] .zt-k-hub-plan__period-title, +html[data-theme="dark"] .zt-k-hub-plan__col-title { + color: var(--dm-text, #f5f5f5); +} + +html[data-theme="dark"] .zt-k-hub-plan__totals-col, +html[data-theme="dark"] .zt-k-hub-plan__trips-col { + background: rgba(255, 255, 255, 0.04); + border-color: var(--dm-border, rgba(255, 255, 255, 0.12)); +} + +html[data-theme="dark"] .zt-k-hub-plan__col-title { + border-bottom-color: var(--dm-border, rgba(255, 255, 255, 0.12)); +} + +html[data-theme="dark"] .zt-k-hub-plan-trip { + background: rgba(255, 255, 255, 0.06); + border-color: var(--dm-border, rgba(255, 255, 255, 0.12)); +} + +html[data-theme="dark"] .zt-k-hub-plan-table th, +html[data-theme="dark"] .zt-k-hub-plan-table td { + border-color: var(--dm-border, rgba(255, 255, 255, 0.12)); +} + +html[data-theme="dark"] .zt-k-hub-plan-table th { + background: rgba(255, 255, 255, 0.06); +} + +.zt-k-hub-plan-skip-duration, +.zt-k-hub-plan-replace, +.zt-k-hub-plan-norms { + /* legacy hooks — sizing lives on __dialog */ +} + +/* Затемнение поверх hub-модалки (монтируется на .modal.wesp-shell-modal) */ +.modal.wesp-shell-modal > .zt-k-hub-plan-overlay { + position: fixed; + inset: 0; + z-index: 1060; + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + background: rgba(15, 23, 42, 0.62); +} + +.zt-k-hub-plan-overlay { + position: fixed; + inset: 0; + z-index: 1060; + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + background: rgba(15, 23, 42, 0.62); +} + +.zt-k-hub-plan-subdialog { + width: min(100%, 28rem); + background: #fff; + border: 1px solid var(--border-light, #e2e8f0); + border-radius: 0.75rem; + padding: 1.25rem 1.35rem 1.15rem; + box-shadow: + 0 24px 48px rgba(15, 23, 42, 0.22), + 0 0 0 1px rgba(255, 255, 255, 0.06) inset; +} + +.zt-k-hub-plan-subdialog__title { + font-size: 1.125rem; + font-weight: 700; + line-height: 1.35; + margin: 0 0 1rem; + color: var(--text-primary, #0f172a); +} + +.zt-k-hub-plan-subdialog__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + flex-shrink: 0; + margin-bottom: 0.65rem; +} + +.zt-k-hub-plan-subdialog__header .zt-k-hub-plan-subdialog__title { + margin: 0; + flex: 1; + min-width: 0; +} + +.zt-k-hub-plan-subdialog__actions { + display: flex; + justify-content: flex-end; + gap: 0.65rem; + margin-top: 1rem; +} + +.zt-k-hub-plan-skip-duration__options { + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.zt-k-hub-plan-skip-duration__option { + display: flex; + align-items: center; + gap: 0.55rem; + font-size: 0.9375rem; + line-height: 1.4; + margin: 0; + cursor: pointer; +} + +.zt-k-hub-plan-skip-duration__option input[type="radio"] { + width: 1.05rem; + height: 1.05rem; + flex-shrink: 0; +} + +.zt-k-hub-plan-skip-duration__option--date { + flex-wrap: wrap; +} + +.zt-k-hub-plan-skip-duration__option--date input[type="date"] { + flex: 1 1 100%; + margin-left: 1.6rem; + margin-top: 0.35rem; + font-size: 0.9375rem; +} + +.zt-k-hub-plan-replace__dialog { + width: min(100%, 34rem); + max-height: min(88vh, 36rem); + overflow: auto; + display: flex; + flex-direction: column; +} + +.zt-k-hub-plan-replace__lead { + margin: 0 0 0.85rem; + font-size: 0.875rem; + line-height: 1.45; + color: var(--text-secondary, #64748b); +} + +.zt-k-hub-plan-replace__search-label { + display: block; + font-size: 0.875rem; + font-weight: 600; + margin: 0 0 0.45rem; + color: var(--text-secondary, #334155); +} + +.zt-k-hub-plan-replace__search { + font-size: 1rem; + padding: 0.55rem 0.75rem; + margin-bottom: 1rem; +} + +.zt-k-hub-plan-replace__section + .zt-k-hub-plan-replace__section { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border-light, #e2e8f0); +} + +.zt-k-hub-plan-replace__section-title { + font-size: 0.8125rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-muted, #64748b); + margin: 0 0 0.55rem; +} + +.zt-k-hub-plan-replace__list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.zt-k-hub-plan-replace__pick { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.2rem; + width: 100%; + text-align: left; + border: 1px solid var(--border-light, #e2e8f0); + border-radius: 0.5rem; + background: #f8fafc; + padding: 0.7rem 0.85rem; + font-size: 1rem; + line-height: 1.35; + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease; +} + +.zt-k-hub-plan-replace__pick:hover, +.zt-k-hub-plan-replace__pick:focus-visible { + border-color: var(--bs-primary, #0d6efd); + background: #eff6ff; + box-shadow: 0 0 0 3px rgba(13, 110, 253, 0.12); + outline: none; +} + +.zt-k-hub-plan-replace__pick-name { + font-weight: 700; + font-size: 1rem; + color: var(--text-primary, #0f172a); +} + +.zt-k-hub-plan-replace__pick-meta { + font-size: 0.875rem; + color: var(--text-secondary, #475569); +} + +.zt-k-hub-plan-replace__empty { + font-size: 0.9375rem; + color: var(--text-muted, #64748b); + margin: 0; + padding: 0.35rem 0; +} + +html[data-theme="dark"] .zt-k-hub-plan-overlay { + background: rgba(0, 0, 0, 0.72); +} + +html[data-theme="dark"] .zt-k-hub-plan-subdialog { + background: var(--dm-surface, #1e293b); + border-color: var(--dm-border, rgba(255, 255, 255, 0.14)); + box-shadow: 0 24px 48px rgba(0, 0, 0, 0.45); +} + +html[data-theme="dark"] .zt-k-hub-plan-subdialog__title, +html[data-theme="dark"] .zt-k-hub-plan-replace__pick-name { + color: var(--dm-text, #f1f5f9); +} + +html[data-theme="dark"] .zt-k-hub-plan-replace__pick { + background: rgba(255, 255, 255, 0.05); + border-color: var(--dm-border, rgba(255, 255, 255, 0.14)); +} + +html[data-theme="dark"] .zt-k-hub-plan-replace__pick:hover, +html[data-theme="dark"] .zt-k-hub-plan-replace__pick:focus-visible { + background: rgba(13, 110, 253, 0.18); +} + +html[data-theme="dark"] .zt-k-hub-plan-replace__section + .zt-k-hub-plan-replace__section { + border-top-color: var(--dm-border, rgba(255, 255, 255, 0.12)); +} +.zt-k-hub-plan-table__total-row th, +.zt-k-hub-plan-table__total-row td { + font-weight: 700; + border-top: 2px solid var(--border-light, #e2e8f0); + background: rgba(15, 23, 42, 0.03); +} + +.zt-k-hub-plan-table__actions--multi { + white-space: nowrap; +} + +.zt-k-hub-plan-table__actions--multi .btn + .btn { + margin-left: 0.15rem; +} + +.zt-k-hub-plan-row--replaced td:first-child { + color: inherit; +} + +.zt-k-hub-plan-trip__title .recipe-skip-badge { + margin-left: 0.35rem; + vertical-align: middle; + font-size: 0.6875rem; +} + +.zt-k-hub-plan-row--adjusted td:first-child { + color: var(--bs-warning, #ffc107); +} + +.zt-k-hub-plan-row__adjust-dot { + display: inline-block; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: #d97706; + margin-right: 0.35rem; + vertical-align: middle; + flex-shrink: 0; +} + +/* —— Коррекция норм (тот же overlay/subdialog, что замена и skip) —— */ +.zt-k-hub-plan-norms-btn { + margin-right: 0.5rem; + white-space: nowrap; +} + +.recipe-adjust-badge { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.6875rem; + font-weight: 600; + color: #d97706; + white-space: nowrap; +} + +.zt-k-hub-plan-trip__title .recipe-adjust-badge { + margin-left: 0.35rem; + vertical-align: middle; +} + +.zt-k-hub-plan-trip__title .recipe-adjust-badge .zt-k-hub-plan-row__adjust-dot { + margin-right: 0; +} + +.zt-k-hub-plan-norms.zt-k-hub-plan-overlay { + padding: 0.65rem; + align-items: stretch; + justify-content: stretch; +} + +.modal.wesp-shell-modal > .zt-k-hub-plan-norms.zt-k-hub-plan-overlay { + z-index: 1075; +} + +.modal.wesp-shell-modal > .zt-k-hub-plan-replace.zt-k-hub-plan-overlay { + z-index: 1085; +} + +.modal.wesp-shell-modal > .zt-k-hub-plan-skip-duration.zt-k-hub-plan-overlay { + z-index: 1095; +} + +body > .zt-k-hub-plan-norms.zt-k-hub-plan-overlay { + z-index: 3200; +} + +body > .zt-k-hub-plan-replace.zt-k-hub-plan-overlay { + z-index: 3210; +} + +body > .zt-k-hub-plan-skip-duration.zt-k-hub-plan-overlay { + z-index: 3220; +} + +.zt-k-hub-plan-norms__dialog { + width: 100%; + max-width: min(98rem, calc(100vw - 1.25rem)); + height: calc(100vh - 1.25rem); + max-height: calc(100vh - 1.25rem); + overflow: hidden; + display: flex; + flex-direction: column; + padding: 1.25rem 1.5rem 1rem; +} + +.zt-k-hub-plan-norms__dialog .zt-k-hub-plan-subdialog__header .zt-k-hub-plan-subdialog__title { + font-size: 1.25rem; +} + +.zt-k-hub-plan-norms__dialog .zt-k-hub-plan-replace__section-title { + margin-bottom: 0.35rem; +} + +.zt-k-hub-plan-norms__dialog .zt-k-hub-plan-replace__search-label { + margin-bottom: 0.35rem; +} + +.zt-k-hub-plan-norms__dialog .zt-k-hub-plan-replace__search { + margin-bottom: 0.5rem; + font-size: 1rem; +} + +.zt-k-hub-plan-norms__filter { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0 0 0.75rem; + font-size: 0.9375rem; + color: var(--text-secondary, #475569); + cursor: pointer; + user-select: none; +} + +.zt-k-hub-plan-norms__filter-text { + line-height: 1.35; +} + +.zt-k-hub-plan-norms__filter .zt-checkbox { + position: relative; + display: inline-flex; + flex-shrink: 0; + width: 1rem; + height: 1rem; +} + +.zt-k-hub-plan-norms__filter .zt-checkbox__input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + opacity: 0; + cursor: pointer; + z-index: 1; +} + +.zt-k-hub-plan-norms__filter .zt-checkbox__box { + display: flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + border: 1.5px solid var(--border-light, #cbd5e1); + border-radius: 4px; + background: var(--surface, #fff); + color: #fff; + transition: + background-color 0.15s ease, + border-color 0.15s ease, + box-shadow 0.15s ease; + pointer-events: none; +} + +.zt-k-hub-plan-norms__filter .zt-checkbox__box::after { + content: ""; + display: block; + width: 0.28rem; + height: 0.5rem; + border: solid currentColor; + border-width: 0 2px 2px 0; + transform: rotate(45deg) scale(0); + margin-top: -0.12rem; + transition: transform 0.12s ease; +} + +.zt-k-hub-plan-norms__filter .zt-checkbox__input:checked + .zt-checkbox__box { + background: var(--primary-color, #2196f3); + border-color: var(--primary-color, #2196f3); +} + +.zt-k-hub-plan-norms__filter .zt-checkbox__input:checked + .zt-checkbox__box::after { + transform: rotate(45deg) scale(1); +} + +.zt-k-hub-plan-norms__filter .zt-checkbox__input:focus-visible + .zt-checkbox__box { + box-shadow: 0 0 0 3px rgba(var(--primary-rgb, 33, 150, 243), 0.22); +} + +html[data-theme="dark"] .zt-k-hub-plan-norms__filter { + color: var(--dm-muted, #a8b0b8); +} + +html[data-theme="dark"] .zt-k-hub-plan-norms__filter .zt-checkbox__box { + background: var(--dm-field, #2c3338); + border-color: rgba(255, 255, 255, 0.22); +} + +html[data-theme="dark"] .zt-k-hub-plan-norms__filter .zt-checkbox__input:checked + .zt-checkbox__box { + background: var(--primary-color); + border-color: var(--primary-color); +} + +.zt-k-hub-plan-norms__lead { + font-size: 0.9375rem; + line-height: 1.45; + color: var(--text-muted, #64748b); + margin: -0.35rem 0 0.65rem; +} + +.zt-k-hub-plan-norms__hint { + font-size: 0.9375rem; + line-height: 1.45; + color: var(--text-secondary, #475569); + margin: 0 0 0.65rem; + padding: 0.5rem 0.75rem; + border-radius: 0.5rem; + background: #f8fafc; + border: 1px solid var(--border-light, #e2e8f0); +} + +.zt-k-hub-plan-norms__table-wrap { + overflow: auto; + flex: 1 1 auto; + min-height: 0; + margin-top: 0; + -webkit-overflow-scrolling: touch; +} + +.zt-k-hub-plan-norms__empty { + padding: 1.25rem 0.75rem; +} + +/* Data grid — как в редакторе рецептов */ +.zt-k-hub-plan-norms__dialog .zt-data-grid { + display: flex; + flex-direction: column; + gap: 0; + min-width: min(100%, 52rem); + width: 100%; +} + +.zt-k-hub-plan-norms__dialog .zt-data-grid__body { + display: flex; + flex-direction: column; +} + +.zt-k-hub-plan-norms__dialog .zt-data-grid__head, +.zt-k-hub-plan-norms__dialog .zt-data-grid__row { + display: grid; + grid-template-columns: var(--zt-data-grid-cols); + min-width: min(100%, 52rem); +} + +.zt-k-hub-plan-norms__dialog .zt-data-grid--norms { + --zt-data-grid-actions-col: 8.75rem; + --zt-data-grid-cols: minmax(0, 1.25fr) minmax(5.5rem, 0.85fr) minmax(6.25rem, 1fr) minmax(6.25rem, 1fr) var(--zt-data-grid-actions-col); +} + +.zt-k-hub-plan-norms__dialog .zt-data-grid__cell { + min-width: 0; + padding: 0.5rem 0.75rem; + vertical-align: middle; + border-bottom: 1px solid var(--border-subtle); + font-variant-numeric: tabular-nums; + font-family: var(--font-numeric, inherit); + background: var(--surface, #fff); +} + +.zt-k-hub-plan-norms__dialog .zt-data-grid__cell--head { + display: flex; + align-items: center; + justify-content: center; + text-align: center; + background: var(--surface-muted); + color: var(--text-secondary); + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.625rem 0.75rem; + border-bottom: 1px solid var(--border-subtle); + font-family: inherit; + font-variant-numeric: normal; + position: sticky; + top: 0; + z-index: 2; +} + +.zt-k-hub-plan-norms__dialog .zt-data-grid__cell--num { + text-align: center; + overflow: hidden; +} + +.zt-k-hub-plan-norms__dialog .zt-data-grid__cell--actions { + display: flex; + align-items: center; + justify-content: flex-end; + padding: 0.5rem 0.625rem; + text-align: right; + overflow: hidden; + flex-shrink: 0; +} + +.zt-k-hub-plan-norms__dialog .zt-data-grid__cell--head.zt-data-grid__cell--actions { + justify-content: center; + text-align: center; +} + +.zt-k-hub-plan-norms__dialog .zt-data-grid__body .zt-data-grid__row:last-child .zt-data-grid__cell { + border-bottom: none; +} + +.zt-k-hub-plan-norms__dialog .zt-data-grid__body .zt-data-grid__row:hover .zt-data-grid__cell { + background: rgba(var(--primary-rgb), 0.05); +} + +.zt-k-hub-plan-norms__dialog .zt-data-grid .form-control { + width: 100%; + max-width: 100%; + min-height: 2.25rem; + padding: 0.375rem 0.5rem; + font-size: 0.875rem; + box-sizing: border-box; +} + +.zt-k-hub-plan-norms-input { + text-align: center; +} + +.zt-k-hub-plan-norms-input--locked { + color: var(--text-muted, #94a3b8); + background: rgba(220, 53, 69, 0.05); + border-color: rgba(220, 53, 69, 0.22); + cursor: not-allowed; + opacity: 0.92; + pointer-events: auto; +} + +.zt-k-hub-plan-norms-input--locked:focus { + outline: none; + box-shadow: none; +} + +.zt-k-hub-plan-norms__name-row { + display: flex; + align-items: flex-start; + gap: 0.35rem; + min-width: 0; +} + +.zt-k-hub-plan-norms__weight-lock { + flex-shrink: 0; + margin-top: 0.15rem; + font-size: 0.6875rem; + color: var(--danger, #dc3545); + opacity: 0.75; +} + +.zt-k-hub-plan-norms__meta--weight { + font-size: 0.6875rem; + line-height: 1.25; + color: rgba(220, 53, 69, 0.75); +} + +.zt-k-hub-plan-norms__weight-hint { + display: block; + font-size: 0.625rem; + line-height: 1.25; + color: rgba(220, 53, 69, 0.72); + text-align: center; + max-height: 0; + opacity: 0; + overflow: hidden; + transition: max-height 0.2s ease, opacity 0.2s ease, margin 0.2s ease; + margin-top: 0; +} + +.zt-k-hub-plan-norms__weight-hint.is-visible { + max-height: 2.5rem; + opacity: 1; + margin-top: 0.15rem; +} + +.zt-k-hub-plan-norms__dialog .ingredient-row--weight-locked .zt-data-grid__cell { + background: rgba(220, 53, 69, 0.06); +} + +.zt-k-hub-plan-norms__dialog .ingredient-row--weight-locked:hover .zt-data-grid__cell { + background: rgba(220, 53, 69, 0.09); +} + +.zt-k-hub-plan-norms__dm-cell { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0.2rem; + min-width: 0; +} + +.zt-k-hub-plan-norms__base { + font-size: 0.6875rem; + line-height: 1.25; + color: var(--text-secondary, #64748b); + text-align: center; + white-space: nowrap; +} + +.zt-k-hub-plan-norms-preview { + color: var(--text-secondary, #475569); +} + +.zt-k-hub-plan-norms-preview-cell { + display: flex; + align-items: center; + justify-content: center; + min-height: 2.25rem; + line-height: 1.3; + font-size: 0.875rem; + width: 100%; + max-width: 100%; + min-width: 0; + box-sizing: border-box; +} + +.zt-k-hub-plan-norms-preview-cell--compare { + align-items: center; + flex-direction: column; + justify-content: center; + gap: 0.1rem; + min-height: 2.75rem; + padding: 0.15rem 0; +} + +.zt-k-hub-plan-norms-preview--compare { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.1rem; + width: 100%; + max-width: 100%; + min-width: 0; + text-align: center; +} + +.zt-k-hub-plan-norms-preview__was { + font-size: 0.6875rem; + color: var(--text-muted, #94a3b8); + text-decoration: line-through; + line-height: 1.2; + max-width: 100%; +} + +.zt-k-hub-plan-norms-preview__now { + font-size: 0.8125rem; + font-weight: 600; + color: var(--text-primary, #0f172a); + line-height: 1.25; + max-width: 100%; +} + +.zt-k-hub-plan-norms__dialog .zt-data-grid .text-cell.zt-k-hub-plan-norms-preview-cell { + display: flex; + line-height: 1.3; + min-height: 2.25rem; + font-size: inherit; +} + +.zt-k-hub-plan-norms__dialog .zt-data-grid input[type="number"] { + -moz-appearance: textfield; +} + +.zt-k-hub-plan-norms__dialog .zt-data-grid input[type="number"]::-webkit-outer-spin-button, +.zt-k-hub-plan-norms__dialog .zt-data-grid input[type="number"]::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.zt-k-hub-plan-norms__dialog .recipe-table-row-actions { + gap: 0.375rem; + justify-content: flex-end; + width: 100%; + max-width: 100%; +} + +.zt-k-hub-plan-norms__dialog .recipe-table-delete-btn { + width: 2.5rem; + min-width: 2.5rem; + height: 2.25rem; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.zt-k-hub-plan-norms__name { + font-size: 0.875rem; + font-weight: 600; + line-height: 1.35; + word-break: break-word; + flex: 1; + min-width: 0; +} + +.zt-k-hub-plan-norms__meta { + font-size: 0.75rem; + color: var(--text-muted, #64748b); + line-height: 1.3; +} + +html[data-theme="dark"] .zt-k-hub-plan-norms__meta { + color: var(--dm-muted, #a8b0b8); +} + +.zt-k-hub-plan-norms__component-cell { + align-items: flex-start !important; +} + +.zt-k-hub-plan-norms__component { + display: flex; + flex-direction: column; + gap: 0.2rem; + min-width: 0; + width: 100%; +} + +.zt-k-hub-plan-norms__dialog .ingredient-row--adjusted .zt-k-hub-plan-norms-input { + border-color: #d97706; + background: #fffbeb; +} + +html[data-theme="dark"] .zt-k-hub-plan-norms-preview { + color: var(--dm-muted, #a8b0b8); +} + +html[data-theme="dark"] .zt-k-hub-plan-norms-preview__was { + color: var(--dm-muted, #a8b0b8); +} + +html[data-theme="dark"] .zt-k-hub-plan-norms-preview__now { + color: var(--dm-text, #f1f5f9); +} + +html[data-theme="dark"] .zt-k-hub-plan-norms__base { + color: var(--dm-muted, #a8b0b8); +} + +html[data-theme="dark"] .zt-k-hub-plan-norms-input--locked { + color: var(--dm-muted, #a8b0b8); + background: rgba(220, 53, 69, 0.1); + border-color: rgba(220, 53, 69, 0.28); +} + +html[data-theme="dark"] .zt-k-hub-plan-norms__weight-lock { + color: #f87171; + opacity: 0.85; +} + +html[data-theme="dark"] .zt-k-hub-plan-norms__meta--weight, +html[data-theme="dark"] .zt-k-hub-plan-norms__weight-hint { + color: rgba(248, 113, 113, 0.78); +} + +html[data-theme="dark"] .zt-k-hub-plan-norms__dialog .ingredient-row--weight-locked .zt-data-grid__cell { + background: rgba(220, 53, 69, 0.1); +} + +html[data-theme="dark"] .zt-k-hub-plan-norms__dialog .ingredient-row--weight-locked:hover .zt-data-grid__cell { + background: rgba(220, 53, 69, 0.14); +} + +html[data-theme="dark"] .zt-k-hub-plan-norms__dialog .ingredient-row--adjusted .zt-k-hub-plan-norms-input { + border-color: #d97706; + background: rgba(217, 119, 6, 0.12); +} + +html[data-theme="dark"] .zt-k-hub-plan-norms__hint { + background: rgba(255, 255, 255, 0.05); + border-color: var(--dm-border, rgba(255, 255, 255, 0.14)); + color: var(--dm-muted, #a8b0b8); +} + +html[data-theme="dark"] .zt-k-hub-plan-norms__dialog .zt-data-grid__cell--head { + background: var(--dm-field, #334155); +} + +/* Зоотех-мастер (lab) в K-hub */ +.zt-k-hub-lab__toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: flex-end; + margin-bottom: 10px; +} + +.zt-k-hub-lab__field { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 0.78rem; + margin: 0; +} + +.zt-k-hub-lab__field .form-select { + min-width: 120px; + max-width: 200px; +} + +.zt-k-hub-lab__subtitle { + font-size: 0.85rem; + color: var(--bs-secondary-color, #6c757d); + margin-bottom: 8px; +} + +.zt-k-hub-lab__table { + margin-bottom: 10px; +} + +.zt-k-hub-lab__table .form-select { + min-width: 140px; +} + +.zt-k-hub-lab__indicators { + display: flex; + flex-wrap: wrap; + gap: 4px; +} diff --git a/apps/web/public/wesp/css/wesp-zootech-layout.css b/apps/web/public/wesp/css/wesp-zootech-layout.css new file mode 100644 index 0000000..674d579 --- /dev/null +++ b/apps/web/public/wesp/css/wesp-zootech-layout.css @@ -0,0 +1,946 @@ +/** + * Общая вёрстка операторских (зоотех) страниц: токены, шрифт, навбар, футер. + * Подключать после wesp-zootech-shell.css и bootstrap.min.css. + */ + +:root { + --primary-color: #2196f3; + --primary-rgb: 33, 150, 243; + --primary: var(--primary-color); + --gradient-border: linear-gradient( + 135deg, + var(--primary-color), + var(--primary-light), + var(--primary-dark) + ); + --primary-light: #42a5f5; + --primary-dark: #1976d2; + --primary-darker: #1565c0; + --primary-blue: var(--primary-color); + --light-blue: #42a5f5; + --accent-blue: #1565c0; + --secondary-color: #6c757d; + --success-color: #10b981; + --danger-color: #ef4444; + --warning-color: #f59e0b; + --info-color: #0ea5e9; + --bg-page: #f5f7fa; + --bg-light: #f8fafc; + --bg-white: #ffffff; + --surface-muted: #f2f5f9; + /* Слот логотипа: logo2.png 2222×1024; contain + left (обе темы — один файл) */ + --nav-logo-slot-h: 38px; + --nav-logo-slot-w: 115px; + --mobile-menu-logo-slot-h: 32px; + --mobile-menu-logo-slot-w: 96px; + --text-dark: #1e293b; + --text-muted: #64748b; + --border-light: #e2e8f0; + --border-radius: 10px; + --box-shadow: 0 2px 4px rgba(15, 23, 42, 0.06); + --box-shadow-hover: 0 8px 22px rgba(15, 23, 42, 0.1), + 0 0 0 1px rgba(15, 23, 42, 0.04); + --shadow-soft: 0 4px 6px -1px rgb(15 23 42 / 0.08), + 0 2px 4px -2px rgb(15 23 42 / 0.06); + --shadow-medium: 0 10px 20px -4px rgb(15 23 42 / 0.12); + --gradient-blue: linear-gradient( + 135deg, + var(--primary-color) 0%, + var(--primary-light) 100% + ); + --transition: 0.22s ease; + --zootech-font: "Inter", system-ui, -apple-system, BlinkMacSystemFont, + "Segoe UI", Roboto, sans-serif; + --zootech-font-mono: "JetBrains Mono", "SF Mono", ui-monospace, monospace; + --zootech-content-max: 1400px; + --zootech-content-pad-x: clamp(0.75rem, 2.5vw, 1.25rem); + --zootech-nav-pad-y: 0.875rem; + /* Semantic aliases */ + --surface: var(--bg-white); + --field: #ffffff; + --text: var(--text-dark); + --muted: var(--text-muted); + /* Spacing (8px grid) */ + --zt-space-1: 8px; + --zt-space-2: 12px; + --zt-space-3: 16px; + --zt-space-4: 24px; + --zt-space-5: 32px; + --zt-radius-sm: 8px; + --zt-radius-md: 10px; + --zt-radius-lg: 12px; + --zt-font-caption: 0.8125rem; + --zt-font-body: 0.9375rem; + --zt-font-section: 1.125rem; + --zt-font-page: 1.5rem; + --zt-btn-height: 40px; + --zt-btn-height-touch: 44px; + --zt-focus-ring: 0 0 0 2px var(--bg-page), 0 0 0 4px rgba(var(--primary-rgb), 0.45); +} + +html[data-theme="organic"] { + --primary-color: #48816d; + --primary-rgb: 72, 129, 109; + --primary-light: #5a9a82; + --primary-dark: #3a6b58; + --primary-darker: #2f5748; + --bg-page: #f6f6f4; + --bg-light: #f6f6f4; + --bg-white: #ffffff; + --surface-muted: #eff4f2; + --text-dark: #1a1e1c; + --text-muted: #4a5a52; + --border-light: #e8e8e2; + --box-shadow: 0 2px 4px rgba(26, 30, 28, 0.04); + --box-shadow-hover: 0 8px 22px rgba(26, 30, 28, 0.08), + 0 0 0 1px rgba(26, 30, 28, 0.03); + --shadow-soft: 0 5px 20px rgba(0, 0, 0, 0.02); + --shadow-medium: 0 10px 20px -4px rgba(26, 30, 28, 0.1); + --surface: var(--bg-white); + --field: #ffffff; + --text: var(--text-dark); + --muted: var(--text-muted); + --zt-radius-lg: 24px; + --zt-radius-md: 16px; + --organic-logo-filter: brightness(0) saturate(100%) invert(43%) sepia(19%) saturate(747%) + hue-rotate(118deg) brightness(92%) contrast(87%); + --bs-primary: #48816d; + --bs-primary-rgb: 72, 129, 109; + --bs-link-color: #48816d; + --bs-link-hover-color: #3a6b58; +} + +html[data-theme="organic"] .nav-brand .logo.logo--light-theme, +html[data-theme="organic"] .mobile-brand .logo.logo--light-theme { + filter: var(--organic-logo-filter) !important; +} + +/* Усиленный контраст — все темы (ручной data-high-contrast или системный prefers-contrast) */ +html[data-high-contrast="true"][data-theme="light"], +html[data-high-contrast="true"]:not([data-theme]) { + --text-dark: #0f172a; + --text-muted: #334155; + --border-light: #94a3b8; + --primary-color: #1565c0; + --primary-rgb: 21, 101, 192; + --primary-dark: #0d47a1; + --primary-darker: #0a3d91; + --box-shadow: none; + --box-shadow-hover: 0 0 0 1px var(--border-light), 0 4px 12px rgba(15, 23, 42, 0.1); + --zt-focus-ring: 0 0 0 2px var(--bg-white), 0 0 0 4px var(--text-dark); +} + +html[data-high-contrast="true"][data-theme="organic"] { + --primary-color: #2f6b57; + --primary-rgb: 47, 107, 87; + --primary-light: #3d8570; + --primary-dark: #234a3d; + --primary-darker: #1a3830; + --text-dark: #0f1211; + --text-muted: #2e3834; + --border-light: #b0b8b2; + --box-shadow: none; + --box-shadow-hover: 0 0 0 1px var(--border-light), 0 4px 12px rgba(15, 18, 17, 0.08); + --zt-focus-ring: 0 0 0 2px var(--bg-white), 0 0 0 4px var(--text-dark); + --organic-logo-filter: brightness(0) saturate(100%) invert(28%) sepia(22%) saturate(900%) + hue-rotate(118deg) brightness(88%) contrast(96%); + --bs-primary: #2f6b57; + --bs-primary-rgb: 47, 107, 87; + --bs-link-color: #2f6b57; + --bs-link-hover-color: #234a3d; + --bs-success: #2f6b57; + --bs-success-rgb: 47, 107, 87; +} + +html[data-high-contrast="true"][data-theme="dark"] { + --text-dark: #ffffff; + --text-muted: #d1d9e0; + --border-light: rgba(255, 255, 255, 0.28); + --bg-white: #353d44; + --field: #4a5259; + --box-shadow: none; + --box-shadow-hover: 0 0 0 1px var(--border-light); + --zt-focus-ring: 0 0 0 2px var(--bg-page), 0 0 0 4px #ffffff; +} + +@media (prefers-contrast: more) { + html:not([data-high-contrast="false"])[data-theme="light"], + html:not([data-high-contrast="false"]):not([data-theme]) { + --text-dark: #0f172a; + --text-muted: #334155; + --border-light: #94a3b8; + --primary-color: #1565c0; + --primary-rgb: 21, 101, 192; + --primary-dark: #0d47a1; + --primary-darker: #0a3d91; + --box-shadow: none; + --box-shadow-hover: 0 0 0 1px var(--border-light), 0 4px 12px rgba(15, 23, 42, 0.1); + --zt-focus-ring: 0 0 0 2px var(--bg-white), 0 0 0 4px var(--text-dark); + } + + html:not([data-high-contrast="false"])[data-theme="organic"] { + --primary-color: #2f6b57; + --primary-rgb: 47, 107, 87; + --primary-light: #3d8570; + --primary-dark: #234a3d; + --primary-darker: #1a3830; + --text-dark: #0f1211; + --text-muted: #2e3834; + --border-light: #b0b8b2; + --box-shadow: none; + --box-shadow-hover: 0 0 0 1px var(--border-light), 0 4px 12px rgba(15, 18, 17, 0.08); + --zt-focus-ring: 0 0 0 2px var(--bg-white), 0 0 0 4px var(--text-dark); + --organic-logo-filter: brightness(0) saturate(100%) invert(28%) sepia(22%) saturate(900%) + hue-rotate(118deg) brightness(88%) contrast(96%); + --bs-primary: #2f6b57; + --bs-primary-rgb: 47, 107, 87; + --bs-link-color: #2f6b57; + --bs-link-hover-color: #234a3d; + --bs-success: #2f6b57; + --bs-success-rgb: 47, 107, 87; + } + + html:not([data-high-contrast="false"])[data-theme="dark"] { + --text-dark: #ffffff; + --text-muted: #d1d9e0; + --border-light: rgba(255, 255, 255, 0.28); + --bg-white: #353d44; + --field: #4a525a; + --box-shadow: none; + --box-shadow-hover: 0 0 0 1px var(--border-light); + --zt-focus-ring: 0 0 0 2px var(--bg-page), 0 0 0 4px #ffffff; + } +} + +html[data-theme="dark"] { + --bg-page: #12181b; + --bg-light: #12181b; + --bg-white: #2c3338; + --text-dark: #f5f5f5; + --text-muted: #a8b0b8; + --border-light: rgba(255, 255, 255, 0.12); + --shadow-soft: 0 4px 12px rgb(0 0 0 / 0.35); + --shadow-medium: 0 12px 24px rgb(0 0 0 / 0.45); + --box-shadow-hover: 0 8px 22px rgb(0 0 0 / 0.35); + --surface: var(--bg-white); + --field: #3e444a; + --text: var(--text-dark); + --muted: var(--text-muted); +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body.zootech-app-body, +body:has(.mobile-navbar) { + margin: 0; + font-family: var(--zootech-font); + font-size: 0.9375rem; + color: var(--text-dark); + line-height: 1.55; + min-height: 100vh; + display: flex; + flex-direction: column; + background-color: var(--bg-page); + -webkit-font-smoothing: antialiased; +} + +.zootech-app-body .container, +body:has(.mobile-navbar) .container { + max-width: var(--zootech-content-max); + margin-left: auto; + margin-right: auto; + padding-left: var(--zootech-content-pad-x); + padding-right: var(--zootech-content-pad-x); +} + +.app-footer { + margin-top: auto; + padding: 14px var(--zootech-content-pad-x) 20px; + text-align: center; + font-size: 0.8125rem; + color: var(--text-muted); +} + +html[data-theme="dark"] .app-footer, +html[data-theme="dark"] .app-footer .text-muted { + color: var(--text-muted) !important; +} + +.page-content { + flex: 1 0 auto; + width: 100%; +} + +.main-content { + flex: 1 1 auto; + padding-bottom: 1.5rem; +} + +/* —— Верхняя панель —— */ +.mobile-navbar { + background: var(--gradient-blue); + padding: var(--zootech-nav-pad-y) 0; + margin-bottom: 1.75rem; + box-shadow: var(--box-shadow-hover); + position: sticky; + top: 0; + z-index: 1000; +} + +.nav-content { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.75rem; +} + +.nav-brand { + display: flex; + align-items: center; + gap: 0.5rem; + color: #fff; + font-weight: 600; + font-size: 1.05rem; + letter-spacing: 0.01em; + flex-shrink: 0; +} + +.nav-brand i { + font-size: 1.35rem; + color: #ffd54f; +} + +.nav-brand .nav-logo-link { + position: relative; + width: var(--nav-logo-slot-w); + height: var(--nav-logo-slot-h); + flex-shrink: 0; + isolation: isolate; +} + +/* Блик только по контуру букв (mask = тот же PNG, что и логотип) */ +.nav-logo-link::after { + content: ""; + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; + opacity: 0; + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 0) 30%, + rgba(255, 255, 255, 0.72) 50%, + rgba(255, 255, 255, 0) 70%, + rgba(255, 255, 255, 0) 100% + ); + background-size: 280% 100%; + background-position: 100% 0; + background-repeat: no-repeat; + -webkit-mask-image: url("/static/logo2.png"); + mask-image: url("/static/logo2.png"); + -webkit-mask-size: contain; + mask-size: contain; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: left center; + mask-position: left center; + mix-blend-mode: soft-light; + will-change: background-position, opacity; +} + +/* Блик после появления navbar (ready → done), иначе анимация обрывается */ +html.wesp-page-enter-ready .nav-logo-link::after, +html.wesp-page-enter-done .nav-logo-link::after { + animation: wesp-nav-logo-shimmer 3s cubic-bezier(0.42, 0, 0.18, 1) infinite; + animation-delay: 0.35s; +} + +@keyframes wesp-nav-logo-shimmer { + 0%, + 100% { + background-position: 100% 0; + opacity: 0; + } + 6% { + opacity: 1; + } + 24% { + background-position: 0% 0; + opacity: 1; + } + 32%, + 100% { + background-position: 0% 0; + opacity: 0; + } +} + +html[data-theme="dark"] .nav-logo-link::after { + display: none; +} + +html[data-theme="dark"] .nav-logo-link--canvas-shimmer .nav-logo__shimmer-canvas { + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; +} + +.mobile-brand .nav-logo-link { + position: relative; + width: var(--mobile-menu-logo-slot-w); + height: var(--mobile-menu-logo-slot-h); + flex-shrink: 0; + isolation: isolate; +} + +.nav-brand .logo, +.mobile-brand .logo { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; + object-position: left center; + filter: brightness(0) invert(1); +} + +.nav-brand .logo--dark-theme, +.mobile-brand .logo--dark-theme { + display: none; +} + +html[data-theme="dark"] .nav-brand .logo--light-theme, +html[data-theme="dark"] .mobile-brand .logo--light-theme { + display: none; +} + +html[data-theme="dark"] .nav-brand .logo--dark-theme, +html[data-theme="dark"] .mobile-brand .logo--dark-theme { + display: block; +} + +.hamburger-menu { + display: none; + flex-direction: column; + gap: 5px; + cursor: pointer; + padding: 8px; + border-radius: 8px; + transition: background var(--transition), transform var(--transition); +} + +.hamburger-menu:hover { + background: rgba(255, 255, 255, 0.12); +} + +.hamburger-line { + width: 24px; + height: 2.5px; + background: #fff; + border-radius: 2px; + transition: transform var(--transition), opacity var(--transition); +} + +.hamburger-menu.active .hamburger-line:nth-child(1) { + transform: rotate(45deg) translate(5px, 5px); +} + +.hamburger-menu.active .hamburger-line:nth-child(2) { + opacity: 0; +} + +.hamburger-menu.active .hamburger-line:nth-child(3) { + transform: rotate(-45deg) translate(6px, -6px); +} + +.desktop-menu { + display: flex; + gap: 0.45rem; + align-items: center; + flex-wrap: nowrap; +} + +.menu-btn { + position: relative; + display: flex; + align-items: center; + gap: 0.45rem; + padding: 0.55rem 0.9rem; + border-radius: 10px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.14); + transition: transform var(--transition), background var(--transition), + box-shadow var(--transition); + cursor: pointer; + text-decoration: none; + color: #fff; + font-weight: 500; + font-size: 0.875rem; + overflow: hidden; +} + +.menu-btn:hover { + transform: translateY(-1px); + background: rgba(255, 255, 255, 0.18); + border-color: rgba(255, 255, 255, 0.28); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12); + color: #fff; +} + +.menu-btn.active { + background: rgba(255, 255, 255, 0.22); + border-color: rgba(255, 255, 255, 0.35); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.15); +} + +.menu-btn i { + font-size: 0.95rem; + color: #fff; +} + +.user-section { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.user-info { + color: #fff; + font-weight: 500; + font-size: 0.875rem; + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.logout-btn { + display: flex; + align-items: center; + gap: 0.4rem; + color: #ffc9c9; + text-decoration: none; + padding: 0.45rem 0.85rem; + border-radius: 8px; + transition: background var(--transition), color var(--transition); + font-weight: 500; + font-size: 0.875rem; +} + +.logout-btn:hover { + background: rgba(255, 107, 107, 0.2); + color: #fff; +} + +/* Мобильное меню */ +.mobile-menu-overlay { + position: fixed; + inset: 0; + background: rgba(15, 23, 42, 0.55); + backdrop-filter: blur(6px); + z-index: 2000; + opacity: 0; + visibility: hidden; + transition: opacity var(--transition), visibility var(--transition); +} + +.mobile-menu-overlay.active { + opacity: 1; + visibility: visible; +} + +.mobile-menu { + position: fixed; + top: 0; + right: -100%; + width: min(88vw, 340px); + height: 100%; + background: var(--gradient-blue); + box-shadow: -8px 0 32px rgba(0, 0, 0, 0.25); + transition: right 0.28s cubic-bezier(0.22, 1, 0.36, 1); + display: flex; + flex-direction: column; +} + +.mobile-menu-overlay.active .mobile-menu { + right: 0; +} + +.mobile-menu-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.25rem 1.25rem 1rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.12); +} + +.mobile-brand { + display: flex; + align-items: center; + gap: 0.5rem; + color: #fff; + font-weight: 600; + font-size: 1.05rem; +} + +.mobile-brand i { + font-size: 1.35rem; + color: #ffd54f; +} + +.close-menu { + color: #fff; + font-size: 1.35rem; + cursor: pointer; + padding: 8px; + border-radius: 50%; + line-height: 1; + transition: background var(--transition); +} + +.close-menu:hover { + background: rgba(255, 255, 255, 0.12); +} + +.mobile-menu-items { + flex: 1; + padding: 0.5rem 0; + overflow-y: auto; +} + +.mobile-menu-item { + display: flex; + align-items: center; + gap: 0.85rem; + color: #fff; + text-decoration: none; + padding: 0.9rem 1.25rem; + font-weight: 500; + font-size: 0.9375rem; + border-left: 3px solid transparent; + transition: background var(--transition), border-color var(--transition); +} + +.mobile-menu-item:hover, +.mobile-menu-item.active { + background: rgba(255, 255, 255, 0.1); + border-left-color: #ffd54f; + color: #fff; +} + +.mobile-menu-item i { + font-size: 1.1rem; + width: 22px; + text-align: center; +} + +.mobile-menu-footer { + padding: 1.25rem; + border-top: 1px solid rgba(255, 255, 255, 0.12); +} + +.mobile-user-info { + color: #fff; + font-weight: 500; + font-size: 0.875rem; + margin-bottom: 0.85rem; + padding: 0.6rem 0.75rem; + background: rgba(255, 255, 255, 0.1); + border-radius: 8px; +} + +.mobile-logout-btn { + display: flex; + align-items: center; + gap: 0.5rem; + color: #ffc9c9; + text-decoration: none; + padding: 0.7rem 0.85rem; + border-radius: 8px; + font-weight: 500; + background: rgba(255, 107, 107, 0.12); + transition: background var(--transition); +} + +.mobile-logout-btn:hover { + background: rgba(255, 107, 107, 0.22); + color: #fff; +} + +/* Светлая тема: навбар как заголовки таблиц (surface-muted), без синего градиента */ +html:not([data-theme="dark"]) .mobile-navbar { + background: var(--surface-muted); + box-shadow: var(--box-shadow); + border-bottom: 1px solid var(--border-light); +} + +html:not([data-theme="dark"]) .nav-brand { + color: var(--text-dark); +} + +html:not([data-theme="dark"]) .nav-brand i { + color: var(--primary-color); +} + +html:not([data-theme="dark"]):not([data-theme="organic"]) .nav-brand .logo, +html:not([data-theme="dark"]):not([data-theme="organic"]) .mobile-brand .logo { + filter: none; +} + +html:not([data-theme="dark"]):not([data-theme="organic"]) .nav-brand .logo--dark-theme, +html:not([data-theme="dark"]):not([data-theme="organic"]) .mobile-brand .logo--dark-theme { + display: none; +} + +html:not([data-theme="dark"]) .hamburger-line { + background: var(--text-dark); +} + +html:not([data-theme="dark"]) .hamburger-menu:hover { + background: rgba(15, 23, 42, 0.06); +} + +html:not([data-theme="dark"]) .menu-btn { + background: var(--bg-white); + border: 1px solid var(--border-light); + color: var(--text-dark); + box-shadow: none; +} + +html:not([data-theme="dark"]) .menu-btn:hover { + background: var(--bg-white); + border-color: var(--primary-color); + color: var(--primary-darker); + box-shadow: var(--box-shadow); +} + +html:not([data-theme="dark"]) .menu-btn.active { + background: rgba(var(--primary-rgb), 0.1); + border-color: var(--primary-color); + color: var(--primary-darker); + box-shadow: inset 0 0 0 1px rgba(var(--primary-rgb), 0.12); +} + +html:not([data-theme="dark"]) .menu-btn i { + color: var(--primary-color); +} + +html:not([data-theme="dark"]) .user-info { + color: var(--text-dark); +} + +html:not([data-theme="dark"]) .logout-btn { + color: var(--danger-color); +} + +html:not([data-theme="dark"]) .logout-btn:hover { + background: rgba(239, 68, 68, 0.08); + color: var(--danger-color); +} + +html:not([data-theme="dark"]) .mobile-menu { + background: var(--bg-white); + box-shadow: -4px 0 24px rgba(15, 23, 42, 0.12); +} + +html:not([data-theme="dark"]) .mobile-menu-header { + background: var(--surface-muted); + border-bottom-color: var(--border-light); +} + +html:not([data-theme="dark"]) .mobile-brand { + color: var(--text-dark); +} + +html:not([data-theme="dark"]) .mobile-brand i { + color: var(--primary-color); +} + +html:not([data-theme="dark"]) .close-menu { + color: var(--text-muted); +} + +html:not([data-theme="dark"]) .close-menu:hover { + background: rgba(15, 23, 42, 0.06); +} + +html:not([data-theme="dark"]) .mobile-menu-item { + color: var(--text-dark); +} + +html:not([data-theme="dark"]) .mobile-menu-item:hover, +html:not([data-theme="dark"]) .mobile-menu-item.active { + background: rgba(var(--primary-rgb), 0.08); + border-left-color: var(--primary-color); + color: var(--primary-darker); +} + +html:not([data-theme="dark"]) .mobile-menu-item i { + color: var(--primary-color); +} + +html:not([data-theme="dark"]) .mobile-menu-footer { + border-top-color: var(--border-light); +} + +html:not([data-theme="dark"]) .mobile-user-info { + color: var(--text-dark); + background: var(--surface-muted); +} + +html:not([data-theme="dark"]) .mobile-logout-btn { + color: var(--danger-color); + background: rgba(239, 68, 68, 0.08); +} + +html:not([data-theme="dark"]) .mobile-logout-btn:hover { + background: rgba(239, 68, 68, 0.12); + color: var(--danger-color); +} + +/* Заголовок раздела (общий паттерн) */ +.page-header { + background: var(--bg-white); + border-radius: var(--border-radius); + padding: 1.5rem 1.75rem; + margin-bottom: 1.5rem; + box-shadow: var(--shadow-soft); + border: 1px solid var(--border-light); +} + +.page-title { + font-size: 1.5rem; + font-weight: 700; + color: var(--text-dark); + margin: 0 0 0.35rem; + letter-spacing: -0.02em; +} + +.page-subtitle { + font-size: 0.9375rem; + color: var(--text-muted); + margin: 0; +} + +@media (max-width: 1200px) { + .desktop-menu { + gap: 0.35rem; + } + .menu-btn { + padding: 0.5rem 0.7rem; + font-size: 0.8125rem; + } +} + +@media (max-width: 992px) { + .desktop-menu { + display: none; + } +} + +@media (max-width: 768px) { + .hamburger-menu { + display: flex; + } + .user-section { + display: none; + } + :root { + --nav-logo-slot-h: 32px; + --nav-logo-slot-w: 96px; + --mobile-menu-logo-slot-h: 28px; + --mobile-menu-logo-slot-w: 84px; + } + .page-header { + padding: 1.15rem 1.25rem; + } + .page-title { + font-size: 1.3rem; + } +} + +/* —— Page enter (navbar → main → blocks), только opacity — без translate (иначе «дёргается») —— */ +:root { + --wesp-enter-duration: 350ms; + --wesp-enter-duration-fast: 350ms; + --wesp-enter-ease: cubic-bezier(0.22, 1, 0.36, 1); + --wesp-enter-stagger: 55ms; + --wesp-enter-nav-delay: 0ms; + --wesp-enter-items-base-delay: 80ms; +} + +@keyframes wesp-enter-fade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +html.wesp-page-enter-pending .mobile-navbar { + opacity: 0; +} + +html.wesp-page-enter-ready .mobile-navbar { + animation: wesp-enter-fade var(--wesp-enter-duration-fast) var(--wesp-enter-ease) both; + animation-delay: var(--wesp-enter-nav-delay); +} + +html.wesp-page-enter-pending .wesp-page-main { + opacity: 0; +} + +html.wesp-page-enter-grid-page.wesp-page-enter-pending .wesp-page-main, +html.wesp-page-enter-grid-page.wesp-page-enter-ready .wesp-page-main { + opacity: 1; +} + +html.wesp-page-enter-ready .wesp-page-main { + opacity: 1; +} + +html.wesp-page-enter-pending .wesp-page-enter-item { + opacity: 0; +} + +html.wesp-page-enter-ready .wesp-page-enter-item { + animation: wesp-enter-fade var(--wesp-enter-duration) var(--wesp-enter-ease) both; + animation-delay: calc( + var(--wesp-enter-items-base-delay) + var(--wesp-enter-i, 0) * var(--wesp-enter-stagger) + ); +} + +html.wesp-page-enter-done .mobile-navbar, +html.wesp-page-enter-done .wesp-page-main, +html.wesp-page-enter-done .wesp-page-enter-item { + animation: none !important; + opacity: 1; + transform: none; + will-change: auto; +} + +@media (prefers-reduced-motion: reduce) { + html.wesp-page-enter-pending .mobile-navbar, + html.wesp-page-enter-pending .wesp-page-main, + html.wesp-page-enter-pending .wesp-page-enter-item { + opacity: 1 !important; + } + + html.wesp-page-enter-ready .mobile-navbar, + html.wesp-page-enter-ready .wesp-page-main, + html.wesp-page-enter-ready .wesp-page-enter-item { + animation: none !important; + } + + .nav-logo-link::after { + animation: none !important; + display: none; + } +} diff --git a/apps/web/public/wesp/css/wesp-zootech-pages-polish.css b/apps/web/public/wesp/css/wesp-zootech-pages-polish.css new file mode 100644 index 0000000..84a0887 --- /dev/null +++ b/apps/web/public/wesp/css/wesp-zootech-pages-polish.css @@ -0,0 +1,629 @@ +/** + * Общая доводка UI зоотех-страниц (consumption, components, feed_dispensers, reports). + * Подключать после page-inline + + + + + + + + + + + + + + +
+ +
+
+ +
+ +
+
+ +
+

Кормораздатчик

+

Добавить кормораздатчик в систему

+ +
+ + +
+
+ +
+

Кормоцех

+

Добавить кормоцех в систему

+ +
+ + +
+
+ +
+

Учет молока

+

Добавить молочный танк в систему

+ +
+
+ + +
+
+ +

Загрузка кормораздатчиков…

+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Комптон® является зарегистрированным товарным знаком +
+ + + + + \ No newline at end of file diff --git a/apps/web/public/wesp/js/bootstrap.bundle.min.js b/apps/web/public/wesp/js/bootstrap.bundle.min.js new file mode 100644 index 0000000..1b2083d --- /dev/null +++ b/apps/web/public/wesp/js/bootstrap.bundle.min.js @@ -0,0 +1,6 @@ +/*! + * Bootstrap v5.3.6 (https://getbootstrap.com/) + * Copyright 2011-2025 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e()}(this,(function(){"use strict";const t=new Map,e={set(e,i,n){t.has(e)||t.set(e,new Map);const s=t.get(e);s.has(i)||0===s.size?s.set(i,n):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(s.keys())[0]}.`)},get:(e,i)=>t.has(e)&&t.get(e).get(i)||null,remove(e,i){if(!t.has(e))return;const n=t.get(e);n.delete(i),0===n.size&&t.delete(e)}},i="transitionend",n=t=>(t&&window.CSS&&window.CSS.escape&&(t=t.replace(/#([^\s"#']+)/g,((t,e)=>`#${CSS.escape(e)}`))),t),s=t=>{t.dispatchEvent(new Event(i))},o=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),r=t=>o(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(n(t)):null,a=t=>{if(!o(t)||0===t.getClientRects().length)return!1;const e="visible"===getComputedStyle(t).getPropertyValue("visibility"),i=t.closest("details:not([open])");if(!i)return e;if(i!==t){const e=t.closest("summary");if(e&&e.parentNode!==i)return!1;if(null===e)return!1}return e},l=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),c=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?c(t.parentNode):null},h=()=>{},d=t=>{t.offsetHeight},u=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,f=[],p=()=>"rtl"===document.documentElement.dir,m=t=>{var e;e=()=>{const e=u();if(e){const i=t.NAME,n=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=n,t.jQueryInterface)}},"loading"===document.readyState?(f.length||document.addEventListener("DOMContentLoaded",(()=>{for(const t of f)t()})),f.push(e)):e()},g=(t,e=[],i=t)=>"function"==typeof t?t.call(...e):i,_=(t,e,n=!0)=>{if(!n)return void g(t);const o=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const n=Number.parseFloat(e),s=Number.parseFloat(i);return n||s?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(e)+5;let r=!1;const a=({target:n})=>{n===e&&(r=!0,e.removeEventListener(i,a),g(t))};e.addEventListener(i,a),setTimeout((()=>{r||s(e)}),o)},b=(t,e,i,n)=>{const s=t.length;let o=t.indexOf(e);return-1===o?!i&&n?t[s-1]:t[0]:(o+=i?1:-1,n&&(o=(o+s)%s),t[Math.max(0,Math.min(o,s-1))])},v=/[^.]*(?=\..*)\.|.*/,y=/\..*/,w=/::\d+$/,A={};let E=1;const T={mouseenter:"mouseover",mouseleave:"mouseout"},C=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function O(t,e){return e&&`${e}::${E++}`||t.uidEvent||E++}function x(t){const e=O(t);return t.uidEvent=e,A[e]=A[e]||{},A[e]}function k(t,e,i=null){return Object.values(t).find((t=>t.callable===e&&t.delegationSelector===i))}function L(t,e,i){const n="string"==typeof e,s=n?i:e||i;let o=I(t);return C.has(o)||(o=t),[n,s,o]}function S(t,e,i,n,s){if("string"!=typeof e||!t)return;let[o,r,a]=L(e,i,n);if(e in T){const t=t=>function(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};r=t(r)}const l=x(t),c=l[a]||(l[a]={}),h=k(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&s);const d=O(r,e.replace(v,"")),u=o?function(t,e,i){return function n(s){const o=t.querySelectorAll(e);for(let{target:r}=s;r&&r!==this;r=r.parentNode)for(const a of o)if(a===r)return P(s,{delegateTarget:r}),n.oneOff&&N.off(t,s.type,e,i),i.apply(r,[s])}}(t,i,r):function(t,e){return function i(n){return P(n,{delegateTarget:t}),i.oneOff&&N.off(t,n.type,e),e.apply(t,[n])}}(t,r);u.delegationSelector=o?i:null,u.callable=r,u.oneOff=s,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function D(t,e,i,n,s){const o=k(e[i],n,s);o&&(t.removeEventListener(i,o,Boolean(s)),delete e[i][o.uidEvent])}function $(t,e,i,n){const s=e[i]||{};for(const[o,r]of Object.entries(s))o.includes(n)&&D(t,e,i,r.callable,r.delegationSelector)}function I(t){return t=t.replace(y,""),T[t]||t}const N={on(t,e,i,n){S(t,e,i,n,!1)},one(t,e,i,n){S(t,e,i,n,!0)},off(t,e,i,n){if("string"!=typeof e||!t)return;const[s,o,r]=L(e,i,n),a=r!==e,l=x(t),c=l[r]||{},h=e.startsWith(".");if(void 0===o){if(h)for(const i of Object.keys(l))$(t,l,i,e.slice(1));for(const[i,n]of Object.entries(c)){const s=i.replace(w,"");a&&!e.includes(s)||D(t,l,r,n.callable,n.delegationSelector)}}else{if(!Object.keys(c).length)return;D(t,l,r,o,s?i:null)}},trigger(t,e,i){if("string"!=typeof e||!t)return null;const n=u();let s=null,o=!0,r=!0,a=!1;e!==I(e)&&n&&(s=n.Event(e,i),n(t).trigger(s),o=!s.isPropagationStopped(),r=!s.isImmediatePropagationStopped(),a=s.isDefaultPrevented());const l=P(new Event(e,{bubbles:o,cancelable:!0}),i);return a&&l.preventDefault(),r&&t.dispatchEvent(l),l.defaultPrevented&&s&&s.preventDefault(),l}};function P(t,e={}){for(const[i,n]of Object.entries(e))try{t[i]=n}catch(e){Object.defineProperty(t,i,{configurable:!0,get:()=>n})}return t}function j(t){if("true"===t)return!0;if("false"===t)return!1;if(t===Number(t).toString())return Number(t);if(""===t||"null"===t)return null;if("string"!=typeof t)return t;try{return JSON.parse(decodeURIComponent(t))}catch(e){return t}}function M(t){return t.replace(/[A-Z]/g,(t=>`-${t.toLowerCase()}`))}const F={setDataAttribute(t,e,i){t.setAttribute(`data-bs-${M(e)}`,i)},removeDataAttribute(t,e){t.removeAttribute(`data-bs-${M(e)}`)},getDataAttributes(t){if(!t)return{};const e={},i=Object.keys(t.dataset).filter((t=>t.startsWith("bs")&&!t.startsWith("bsConfig")));for(const n of i){let i=n.replace(/^bs/,"");i=i.charAt(0).toLowerCase()+i.slice(1),e[i]=j(t.dataset[n])}return e},getDataAttribute:(t,e)=>j(t.getAttribute(`data-bs-${M(e)}`))};class H{static get Default(){return{}}static get DefaultType(){return{}}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}_getConfig(t){return t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t}_mergeConfigObj(t,e){const i=o(e)?F.getDataAttribute(e,"config"):{};return{...this.constructor.Default,..."object"==typeof i?i:{},...o(e)?F.getDataAttributes(e):{},..."object"==typeof t?t:{}}}_typeCheckConfig(t,e=this.constructor.DefaultType){for(const[n,s]of Object.entries(e)){const e=t[n],r=o(e)?"element":null==(i=e)?`${i}`:Object.prototype.toString.call(i).match(/\s([a-z]+)/i)[1].toLowerCase();if(!new RegExp(s).test(r))throw new TypeError(`${this.constructor.NAME.toUpperCase()}: Option "${n}" provided type "${r}" but expected type "${s}".`)}var i}}class W extends H{constructor(t,i){super(),(t=r(t))&&(this._element=t,this._config=this._getConfig(i),e.set(this._element,this.constructor.DATA_KEY,this))}dispose(){e.remove(this._element,this.constructor.DATA_KEY),N.off(this._element,this.constructor.EVENT_KEY);for(const t of Object.getOwnPropertyNames(this))this[t]=null}_queueCallback(t,e,i=!0){_(t,e,i)}_getConfig(t){return t=this._mergeConfigObj(t,this._element),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}static getInstance(t){return e.get(r(t),this.DATA_KEY)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,"object"==typeof e?e:null)}static get VERSION(){return"5.3.6"}static get DATA_KEY(){return`bs.${this.NAME}`}static get EVENT_KEY(){return`.${this.DATA_KEY}`}static eventName(t){return`${t}${this.EVENT_KEY}`}}const B=t=>{let e=t.getAttribute("data-bs-target");if(!e||"#"===e){let i=t.getAttribute("href");if(!i||!i.includes("#")&&!i.startsWith("."))return null;i.includes("#")&&!i.startsWith("#")&&(i=`#${i.split("#")[1]}`),e=i&&"#"!==i?i.trim():null}return e?e.split(",").map((t=>n(t))).join(","):null},z={find:(t,e=document.documentElement)=>[].concat(...Element.prototype.querySelectorAll.call(e,t)),findOne:(t,e=document.documentElement)=>Element.prototype.querySelector.call(e,t),children:(t,e)=>[].concat(...t.children).filter((t=>t.matches(e))),parents(t,e){const i=[];let n=t.parentNode.closest(e);for(;n;)i.push(n),n=n.parentNode.closest(e);return i},prev(t,e){let i=t.previousElementSibling;for(;i;){if(i.matches(e))return[i];i=i.previousElementSibling}return[]},next(t,e){let i=t.nextElementSibling;for(;i;){if(i.matches(e))return[i];i=i.nextElementSibling}return[]},focusableChildren(t){const e=["a","button","input","textarea","select","details","[tabindex]",'[contenteditable="true"]'].map((t=>`${t}:not([tabindex^="-"])`)).join(",");return this.find(e,t).filter((t=>!l(t)&&a(t)))},getSelectorFromElement(t){const e=B(t);return e&&z.findOne(e)?e:null},getElementFromSelector(t){const e=B(t);return e?z.findOne(e):null},getMultipleElementsFromSelector(t){const e=B(t);return e?z.find(e):[]}},R=(t,e="hide")=>{const i=`click.dismiss${t.EVENT_KEY}`,n=t.NAME;N.on(document,i,`[data-bs-dismiss="${n}"]`,(function(i){if(["A","AREA"].includes(this.tagName)&&i.preventDefault(),l(this))return;const s=z.getElementFromSelector(this)||this.closest(`.${n}`);t.getOrCreateInstance(s)[e]()}))},q=".bs.alert",V=`close${q}`,K=`closed${q}`;class Q extends W{static get NAME(){return"alert"}close(){if(N.trigger(this._element,V).defaultPrevented)return;this._element.classList.remove("show");const t=this._element.classList.contains("fade");this._queueCallback((()=>this._destroyElement()),this._element,t)}_destroyElement(){this._element.remove(),N.trigger(this._element,K),this.dispose()}static jQueryInterface(t){return this.each((function(){const e=Q.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}R(Q,"close"),m(Q);const X='[data-bs-toggle="button"]';class Y extends W{static get NAME(){return"button"}toggle(){this._element.setAttribute("aria-pressed",this._element.classList.toggle("active"))}static jQueryInterface(t){return this.each((function(){const e=Y.getOrCreateInstance(this);"toggle"===t&&e[t]()}))}}N.on(document,"click.bs.button.data-api",X,(t=>{t.preventDefault();const e=t.target.closest(X);Y.getOrCreateInstance(e).toggle()})),m(Y);const U=".bs.swipe",G=`touchstart${U}`,J=`touchmove${U}`,Z=`touchend${U}`,tt=`pointerdown${U}`,et=`pointerup${U}`,it={endCallback:null,leftCallback:null,rightCallback:null},nt={endCallback:"(function|null)",leftCallback:"(function|null)",rightCallback:"(function|null)"};class st extends H{constructor(t,e){super(),this._element=t,t&&st.isSupported()&&(this._config=this._getConfig(e),this._deltaX=0,this._supportPointerEvents=Boolean(window.PointerEvent),this._initEvents())}static get Default(){return it}static get DefaultType(){return nt}static get NAME(){return"swipe"}dispose(){N.off(this._element,U)}_start(t){this._supportPointerEvents?this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX):this._deltaX=t.touches[0].clientX}_end(t){this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX-this._deltaX),this._handleSwipe(),g(this._config.endCallback)}_move(t){this._deltaX=t.touches&&t.touches.length>1?0:t.touches[0].clientX-this._deltaX}_handleSwipe(){const t=Math.abs(this._deltaX);if(t<=40)return;const e=t/this._deltaX;this._deltaX=0,e&&g(e>0?this._config.rightCallback:this._config.leftCallback)}_initEvents(){this._supportPointerEvents?(N.on(this._element,tt,(t=>this._start(t))),N.on(this._element,et,(t=>this._end(t))),this._element.classList.add("pointer-event")):(N.on(this._element,G,(t=>this._start(t))),N.on(this._element,J,(t=>this._move(t))),N.on(this._element,Z,(t=>this._end(t))))}_eventIsPointerPenTouch(t){return this._supportPointerEvents&&("pen"===t.pointerType||"touch"===t.pointerType)}static isSupported(){return"ontouchstart"in document.documentElement||navigator.maxTouchPoints>0}}const ot=".bs.carousel",rt=".data-api",at="ArrowLeft",lt="ArrowRight",ct="next",ht="prev",dt="left",ut="right",ft=`slide${ot}`,pt=`slid${ot}`,mt=`keydown${ot}`,gt=`mouseenter${ot}`,_t=`mouseleave${ot}`,bt=`dragstart${ot}`,vt=`load${ot}${rt}`,yt=`click${ot}${rt}`,wt="carousel",At="active",Et=".active",Tt=".carousel-item",Ct=Et+Tt,Ot={[at]:ut,[lt]:dt},xt={interval:5e3,keyboard:!0,pause:"hover",ride:!1,touch:!0,wrap:!0},kt={interval:"(number|boolean)",keyboard:"boolean",pause:"(string|boolean)",ride:"(boolean|string)",touch:"boolean",wrap:"boolean"};class Lt extends W{constructor(t,e){super(t,e),this._interval=null,this._activeElement=null,this._isSliding=!1,this.touchTimeout=null,this._swipeHelper=null,this._indicatorsElement=z.findOne(".carousel-indicators",this._element),this._addEventListeners(),this._config.ride===wt&&this.cycle()}static get Default(){return xt}static get DefaultType(){return kt}static get NAME(){return"carousel"}next(){this._slide(ct)}nextWhenVisible(){!document.hidden&&a(this._element)&&this.next()}prev(){this._slide(ht)}pause(){this._isSliding&&s(this._element),this._clearInterval()}cycle(){this._clearInterval(),this._updateInterval(),this._interval=setInterval((()=>this.nextWhenVisible()),this._config.interval)}_maybeEnableCycle(){this._config.ride&&(this._isSliding?N.one(this._element,pt,(()=>this.cycle())):this.cycle())}to(t){const e=this._getItems();if(t>e.length-1||t<0)return;if(this._isSliding)return void N.one(this._element,pt,(()=>this.to(t)));const i=this._getItemIndex(this._getActive());if(i===t)return;const n=t>i?ct:ht;this._slide(n,e[t])}dispose(){this._swipeHelper&&this._swipeHelper.dispose(),super.dispose()}_configAfterMerge(t){return t.defaultInterval=t.interval,t}_addEventListeners(){this._config.keyboard&&N.on(this._element,mt,(t=>this._keydown(t))),"hover"===this._config.pause&&(N.on(this._element,gt,(()=>this.pause())),N.on(this._element,_t,(()=>this._maybeEnableCycle()))),this._config.touch&&st.isSupported()&&this._addTouchEventListeners()}_addTouchEventListeners(){for(const t of z.find(".carousel-item img",this._element))N.on(t,bt,(t=>t.preventDefault()));const t={leftCallback:()=>this._slide(this._directionToOrder(dt)),rightCallback:()=>this._slide(this._directionToOrder(ut)),endCallback:()=>{"hover"===this._config.pause&&(this.pause(),this.touchTimeout&&clearTimeout(this.touchTimeout),this.touchTimeout=setTimeout((()=>this._maybeEnableCycle()),500+this._config.interval))}};this._swipeHelper=new st(this._element,t)}_keydown(t){if(/input|textarea/i.test(t.target.tagName))return;const e=Ot[t.key];e&&(t.preventDefault(),this._slide(this._directionToOrder(e)))}_getItemIndex(t){return this._getItems().indexOf(t)}_setActiveIndicatorElement(t){if(!this._indicatorsElement)return;const e=z.findOne(Et,this._indicatorsElement);e.classList.remove(At),e.removeAttribute("aria-current");const i=z.findOne(`[data-bs-slide-to="${t}"]`,this._indicatorsElement);i&&(i.classList.add(At),i.setAttribute("aria-current","true"))}_updateInterval(){const t=this._activeElement||this._getActive();if(!t)return;const e=Number.parseInt(t.getAttribute("data-bs-interval"),10);this._config.interval=e||this._config.defaultInterval}_slide(t,e=null){if(this._isSliding)return;const i=this._getActive(),n=t===ct,s=e||b(this._getItems(),i,n,this._config.wrap);if(s===i)return;const o=this._getItemIndex(s),r=e=>N.trigger(this._element,e,{relatedTarget:s,direction:this._orderToDirection(t),from:this._getItemIndex(i),to:o});if(r(ft).defaultPrevented)return;if(!i||!s)return;const a=Boolean(this._interval);this.pause(),this._isSliding=!0,this._setActiveIndicatorElement(o),this._activeElement=s;const l=n?"carousel-item-start":"carousel-item-end",c=n?"carousel-item-next":"carousel-item-prev";s.classList.add(c),d(s),i.classList.add(l),s.classList.add(l),this._queueCallback((()=>{s.classList.remove(l,c),s.classList.add(At),i.classList.remove(At,c,l),this._isSliding=!1,r(pt)}),i,this._isAnimated()),a&&this.cycle()}_isAnimated(){return this._element.classList.contains("slide")}_getActive(){return z.findOne(Ct,this._element)}_getItems(){return z.find(Tt,this._element)}_clearInterval(){this._interval&&(clearInterval(this._interval),this._interval=null)}_directionToOrder(t){return p()?t===dt?ht:ct:t===dt?ct:ht}_orderToDirection(t){return p()?t===ht?dt:ut:t===ht?ut:dt}static jQueryInterface(t){return this.each((function(){const e=Lt.getOrCreateInstance(this,t);if("number"!=typeof t){if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}else e.to(t)}))}}N.on(document,yt,"[data-bs-slide], [data-bs-slide-to]",(function(t){const e=z.getElementFromSelector(this);if(!e||!e.classList.contains(wt))return;t.preventDefault();const i=Lt.getOrCreateInstance(e),n=this.getAttribute("data-bs-slide-to");return n?(i.to(n),void i._maybeEnableCycle()):"next"===F.getDataAttribute(this,"slide")?(i.next(),void i._maybeEnableCycle()):(i.prev(),void i._maybeEnableCycle())})),N.on(window,vt,(()=>{const t=z.find('[data-bs-ride="carousel"]');for(const e of t)Lt.getOrCreateInstance(e)})),m(Lt);const St=".bs.collapse",Dt=`show${St}`,$t=`shown${St}`,It=`hide${St}`,Nt=`hidden${St}`,Pt=`click${St}.data-api`,jt="show",Mt="collapse",Ft="collapsing",Ht=`:scope .${Mt} .${Mt}`,Wt='[data-bs-toggle="collapse"]',Bt={parent:null,toggle:!0},zt={parent:"(null|element)",toggle:"boolean"};class Rt extends W{constructor(t,e){super(t,e),this._isTransitioning=!1,this._triggerArray=[];const i=z.find(Wt);for(const t of i){const e=z.getSelectorFromElement(t),i=z.find(e).filter((t=>t===this._element));null!==e&&i.length&&this._triggerArray.push(t)}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return Bt}static get DefaultType(){return zt}static get NAME(){return"collapse"}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let t=[];if(this._config.parent&&(t=this._getFirstLevelChildren(".collapse.show, .collapse.collapsing").filter((t=>t!==this._element)).map((t=>Rt.getOrCreateInstance(t,{toggle:!1})))),t.length&&t[0]._isTransitioning)return;if(N.trigger(this._element,Dt).defaultPrevented)return;for(const e of t)e.hide();const e=this._getDimension();this._element.classList.remove(Mt),this._element.classList.add(Ft),this._element.style[e]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;const i=`scroll${e[0].toUpperCase()+e.slice(1)}`;this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(Ft),this._element.classList.add(Mt,jt),this._element.style[e]="",N.trigger(this._element,$t)}),this._element,!0),this._element.style[e]=`${this._element[i]}px`}hide(){if(this._isTransitioning||!this._isShown())return;if(N.trigger(this._element,It).defaultPrevented)return;const t=this._getDimension();this._element.style[t]=`${this._element.getBoundingClientRect()[t]}px`,d(this._element),this._element.classList.add(Ft),this._element.classList.remove(Mt,jt);for(const t of this._triggerArray){const e=z.getElementFromSelector(t);e&&!this._isShown(e)&&this._addAriaAndCollapsedClass([t],!1)}this._isTransitioning=!0,this._element.style[t]="",this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(Ft),this._element.classList.add(Mt),N.trigger(this._element,Nt)}),this._element,!0)}_isShown(t=this._element){return t.classList.contains(jt)}_configAfterMerge(t){return t.toggle=Boolean(t.toggle),t.parent=r(t.parent),t}_getDimension(){return this._element.classList.contains("collapse-horizontal")?"width":"height"}_initializeChildren(){if(!this._config.parent)return;const t=this._getFirstLevelChildren(Wt);for(const e of t){const t=z.getElementFromSelector(e);t&&this._addAriaAndCollapsedClass([e],this._isShown(t))}}_getFirstLevelChildren(t){const e=z.find(Ht,this._config.parent);return z.find(t,this._config.parent).filter((t=>!e.includes(t)))}_addAriaAndCollapsedClass(t,e){if(t.length)for(const i of t)i.classList.toggle("collapsed",!e),i.setAttribute("aria-expanded",e)}static jQueryInterface(t){const e={};return"string"==typeof t&&/show|hide/.test(t)&&(e.toggle=!1),this.each((function(){const i=Rt.getOrCreateInstance(this,e);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t]()}}))}}N.on(document,Pt,Wt,(function(t){("A"===t.target.tagName||t.delegateTarget&&"A"===t.delegateTarget.tagName)&&t.preventDefault();for(const t of z.getMultipleElementsFromSelector(this))Rt.getOrCreateInstance(t,{toggle:!1}).toggle()})),m(Rt);var qt="top",Vt="bottom",Kt="right",Qt="left",Xt="auto",Yt=[qt,Vt,Kt,Qt],Ut="start",Gt="end",Jt="clippingParents",Zt="viewport",te="popper",ee="reference",ie=Yt.reduce((function(t,e){return t.concat([e+"-"+Ut,e+"-"+Gt])}),[]),ne=[].concat(Yt,[Xt]).reduce((function(t,e){return t.concat([e,e+"-"+Ut,e+"-"+Gt])}),[]),se="beforeRead",oe="read",re="afterRead",ae="beforeMain",le="main",ce="afterMain",he="beforeWrite",de="write",ue="afterWrite",fe=[se,oe,re,ae,le,ce,he,de,ue];function pe(t){return t?(t.nodeName||"").toLowerCase():null}function me(t){if(null==t)return window;if("[object Window]"!==t.toString()){var e=t.ownerDocument;return e&&e.defaultView||window}return t}function ge(t){return t instanceof me(t).Element||t instanceof Element}function _e(t){return t instanceof me(t).HTMLElement||t instanceof HTMLElement}function be(t){return"undefined"!=typeof ShadowRoot&&(t instanceof me(t).ShadowRoot||t instanceof ShadowRoot)}const ve={name:"applyStyles",enabled:!0,phase:"write",fn:function(t){var e=t.state;Object.keys(e.elements).forEach((function(t){var i=e.styles[t]||{},n=e.attributes[t]||{},s=e.elements[t];_e(s)&&pe(s)&&(Object.assign(s.style,i),Object.keys(n).forEach((function(t){var e=n[t];!1===e?s.removeAttribute(t):s.setAttribute(t,!0===e?"":e)})))}))},effect:function(t){var e=t.state,i={popper:{position:e.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(e.elements.popper.style,i.popper),e.styles=i,e.elements.arrow&&Object.assign(e.elements.arrow.style,i.arrow),function(){Object.keys(e.elements).forEach((function(t){var n=e.elements[t],s=e.attributes[t]||{},o=Object.keys(e.styles.hasOwnProperty(t)?e.styles[t]:i[t]).reduce((function(t,e){return t[e]="",t}),{});_e(n)&&pe(n)&&(Object.assign(n.style,o),Object.keys(s).forEach((function(t){n.removeAttribute(t)})))}))}},requires:["computeStyles"]};function ye(t){return t.split("-")[0]}var we=Math.max,Ae=Math.min,Ee=Math.round;function Te(){var t=navigator.userAgentData;return null!=t&&t.brands&&Array.isArray(t.brands)?t.brands.map((function(t){return t.brand+"/"+t.version})).join(" "):navigator.userAgent}function Ce(){return!/^((?!chrome|android).)*safari/i.test(Te())}function Oe(t,e,i){void 0===e&&(e=!1),void 0===i&&(i=!1);var n=t.getBoundingClientRect(),s=1,o=1;e&&_e(t)&&(s=t.offsetWidth>0&&Ee(n.width)/t.offsetWidth||1,o=t.offsetHeight>0&&Ee(n.height)/t.offsetHeight||1);var r=(ge(t)?me(t):window).visualViewport,a=!Ce()&&i,l=(n.left+(a&&r?r.offsetLeft:0))/s,c=(n.top+(a&&r?r.offsetTop:0))/o,h=n.width/s,d=n.height/o;return{width:h,height:d,top:c,right:l+h,bottom:c+d,left:l,x:l,y:c}}function xe(t){var e=Oe(t),i=t.offsetWidth,n=t.offsetHeight;return Math.abs(e.width-i)<=1&&(i=e.width),Math.abs(e.height-n)<=1&&(n=e.height),{x:t.offsetLeft,y:t.offsetTop,width:i,height:n}}function ke(t,e){var i=e.getRootNode&&e.getRootNode();if(t.contains(e))return!0;if(i&&be(i)){var n=e;do{if(n&&t.isSameNode(n))return!0;n=n.parentNode||n.host}while(n)}return!1}function Le(t){return me(t).getComputedStyle(t)}function Se(t){return["table","td","th"].indexOf(pe(t))>=0}function De(t){return((ge(t)?t.ownerDocument:t.document)||window.document).documentElement}function $e(t){return"html"===pe(t)?t:t.assignedSlot||t.parentNode||(be(t)?t.host:null)||De(t)}function Ie(t){return _e(t)&&"fixed"!==Le(t).position?t.offsetParent:null}function Ne(t){for(var e=me(t),i=Ie(t);i&&Se(i)&&"static"===Le(i).position;)i=Ie(i);return i&&("html"===pe(i)||"body"===pe(i)&&"static"===Le(i).position)?e:i||function(t){var e=/firefox/i.test(Te());if(/Trident/i.test(Te())&&_e(t)&&"fixed"===Le(t).position)return null;var i=$e(t);for(be(i)&&(i=i.host);_e(i)&&["html","body"].indexOf(pe(i))<0;){var n=Le(i);if("none"!==n.transform||"none"!==n.perspective||"paint"===n.contain||-1!==["transform","perspective"].indexOf(n.willChange)||e&&"filter"===n.willChange||e&&n.filter&&"none"!==n.filter)return i;i=i.parentNode}return null}(t)||e}function Pe(t){return["top","bottom"].indexOf(t)>=0?"x":"y"}function je(t,e,i){return we(t,Ae(e,i))}function Me(t){return Object.assign({},{top:0,right:0,bottom:0,left:0},t)}function Fe(t,e){return e.reduce((function(e,i){return e[i]=t,e}),{})}const He={name:"arrow",enabled:!0,phase:"main",fn:function(t){var e,i=t.state,n=t.name,s=t.options,o=i.elements.arrow,r=i.modifiersData.popperOffsets,a=ye(i.placement),l=Pe(a),c=[Qt,Kt].indexOf(a)>=0?"height":"width";if(o&&r){var h=function(t,e){return Me("number"!=typeof(t="function"==typeof t?t(Object.assign({},e.rects,{placement:e.placement})):t)?t:Fe(t,Yt))}(s.padding,i),d=xe(o),u="y"===l?qt:Qt,f="y"===l?Vt:Kt,p=i.rects.reference[c]+i.rects.reference[l]-r[l]-i.rects.popper[c],m=r[l]-i.rects.reference[l],g=Ne(o),_=g?"y"===l?g.clientHeight||0:g.clientWidth||0:0,b=p/2-m/2,v=h[u],y=_-d[c]-h[f],w=_/2-d[c]/2+b,A=je(v,w,y),E=l;i.modifiersData[n]=((e={})[E]=A,e.centerOffset=A-w,e)}},effect:function(t){var e=t.state,i=t.options.element,n=void 0===i?"[data-popper-arrow]":i;null!=n&&("string"!=typeof n||(n=e.elements.popper.querySelector(n)))&&ke(e.elements.popper,n)&&(e.elements.arrow=n)},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]};function We(t){return t.split("-")[1]}var Be={top:"auto",right:"auto",bottom:"auto",left:"auto"};function ze(t){var e,i=t.popper,n=t.popperRect,s=t.placement,o=t.variation,r=t.offsets,a=t.position,l=t.gpuAcceleration,c=t.adaptive,h=t.roundOffsets,d=t.isFixed,u=r.x,f=void 0===u?0:u,p=r.y,m=void 0===p?0:p,g="function"==typeof h?h({x:f,y:m}):{x:f,y:m};f=g.x,m=g.y;var _=r.hasOwnProperty("x"),b=r.hasOwnProperty("y"),v=Qt,y=qt,w=window;if(c){var A=Ne(i),E="clientHeight",T="clientWidth";A===me(i)&&"static"!==Le(A=De(i)).position&&"absolute"===a&&(E="scrollHeight",T="scrollWidth"),(s===qt||(s===Qt||s===Kt)&&o===Gt)&&(y=Vt,m-=(d&&A===w&&w.visualViewport?w.visualViewport.height:A[E])-n.height,m*=l?1:-1),s!==Qt&&(s!==qt&&s!==Vt||o!==Gt)||(v=Kt,f-=(d&&A===w&&w.visualViewport?w.visualViewport.width:A[T])-n.width,f*=l?1:-1)}var C,O=Object.assign({position:a},c&&Be),x=!0===h?function(t,e){var i=t.x,n=t.y,s=e.devicePixelRatio||1;return{x:Ee(i*s)/s||0,y:Ee(n*s)/s||0}}({x:f,y:m},me(i)):{x:f,y:m};return f=x.x,m=x.y,l?Object.assign({},O,((C={})[y]=b?"0":"",C[v]=_?"0":"",C.transform=(w.devicePixelRatio||1)<=1?"translate("+f+"px, "+m+"px)":"translate3d("+f+"px, "+m+"px, 0)",C)):Object.assign({},O,((e={})[y]=b?m+"px":"",e[v]=_?f+"px":"",e.transform="",e))}const Re={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:function(t){var e=t.state,i=t.options,n=i.gpuAcceleration,s=void 0===n||n,o=i.adaptive,r=void 0===o||o,a=i.roundOffsets,l=void 0===a||a,c={placement:ye(e.placement),variation:We(e.placement),popper:e.elements.popper,popperRect:e.rects.popper,gpuAcceleration:s,isFixed:"fixed"===e.options.strategy};null!=e.modifiersData.popperOffsets&&(e.styles.popper=Object.assign({},e.styles.popper,ze(Object.assign({},c,{offsets:e.modifiersData.popperOffsets,position:e.options.strategy,adaptive:r,roundOffsets:l})))),null!=e.modifiersData.arrow&&(e.styles.arrow=Object.assign({},e.styles.arrow,ze(Object.assign({},c,{offsets:e.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:l})))),e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-placement":e.placement})},data:{}};var qe={passive:!0};const Ve={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:function(t){var e=t.state,i=t.instance,n=t.options,s=n.scroll,o=void 0===s||s,r=n.resize,a=void 0===r||r,l=me(e.elements.popper),c=[].concat(e.scrollParents.reference,e.scrollParents.popper);return o&&c.forEach((function(t){t.addEventListener("scroll",i.update,qe)})),a&&l.addEventListener("resize",i.update,qe),function(){o&&c.forEach((function(t){t.removeEventListener("scroll",i.update,qe)})),a&&l.removeEventListener("resize",i.update,qe)}},data:{}};var Ke={left:"right",right:"left",bottom:"top",top:"bottom"};function Qe(t){return t.replace(/left|right|bottom|top/g,(function(t){return Ke[t]}))}var Xe={start:"end",end:"start"};function Ye(t){return t.replace(/start|end/g,(function(t){return Xe[t]}))}function Ue(t){var e=me(t);return{scrollLeft:e.pageXOffset,scrollTop:e.pageYOffset}}function Ge(t){return Oe(De(t)).left+Ue(t).scrollLeft}function Je(t){var e=Le(t),i=e.overflow,n=e.overflowX,s=e.overflowY;return/auto|scroll|overlay|hidden/.test(i+s+n)}function Ze(t){return["html","body","#document"].indexOf(pe(t))>=0?t.ownerDocument.body:_e(t)&&Je(t)?t:Ze($e(t))}function ti(t,e){var i;void 0===e&&(e=[]);var n=Ze(t),s=n===(null==(i=t.ownerDocument)?void 0:i.body),o=me(n),r=s?[o].concat(o.visualViewport||[],Je(n)?n:[]):n,a=e.concat(r);return s?a:a.concat(ti($e(r)))}function ei(t){return Object.assign({},t,{left:t.x,top:t.y,right:t.x+t.width,bottom:t.y+t.height})}function ii(t,e,i){return e===Zt?ei(function(t,e){var i=me(t),n=De(t),s=i.visualViewport,o=n.clientWidth,r=n.clientHeight,a=0,l=0;if(s){o=s.width,r=s.height;var c=Ce();(c||!c&&"fixed"===e)&&(a=s.offsetLeft,l=s.offsetTop)}return{width:o,height:r,x:a+Ge(t),y:l}}(t,i)):ge(e)?function(t,e){var i=Oe(t,!1,"fixed"===e);return i.top=i.top+t.clientTop,i.left=i.left+t.clientLeft,i.bottom=i.top+t.clientHeight,i.right=i.left+t.clientWidth,i.width=t.clientWidth,i.height=t.clientHeight,i.x=i.left,i.y=i.top,i}(e,i):ei(function(t){var e,i=De(t),n=Ue(t),s=null==(e=t.ownerDocument)?void 0:e.body,o=we(i.scrollWidth,i.clientWidth,s?s.scrollWidth:0,s?s.clientWidth:0),r=we(i.scrollHeight,i.clientHeight,s?s.scrollHeight:0,s?s.clientHeight:0),a=-n.scrollLeft+Ge(t),l=-n.scrollTop;return"rtl"===Le(s||i).direction&&(a+=we(i.clientWidth,s?s.clientWidth:0)-o),{width:o,height:r,x:a,y:l}}(De(t)))}function ni(t){var e,i=t.reference,n=t.element,s=t.placement,o=s?ye(s):null,r=s?We(s):null,a=i.x+i.width/2-n.width/2,l=i.y+i.height/2-n.height/2;switch(o){case qt:e={x:a,y:i.y-n.height};break;case Vt:e={x:a,y:i.y+i.height};break;case Kt:e={x:i.x+i.width,y:l};break;case Qt:e={x:i.x-n.width,y:l};break;default:e={x:i.x,y:i.y}}var c=o?Pe(o):null;if(null!=c){var h="y"===c?"height":"width";switch(r){case Ut:e[c]=e[c]-(i[h]/2-n[h]/2);break;case Gt:e[c]=e[c]+(i[h]/2-n[h]/2)}}return e}function si(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=void 0===n?t.placement:n,o=i.strategy,r=void 0===o?t.strategy:o,a=i.boundary,l=void 0===a?Jt:a,c=i.rootBoundary,h=void 0===c?Zt:c,d=i.elementContext,u=void 0===d?te:d,f=i.altBoundary,p=void 0!==f&&f,m=i.padding,g=void 0===m?0:m,_=Me("number"!=typeof g?g:Fe(g,Yt)),b=u===te?ee:te,v=t.rects.popper,y=t.elements[p?b:u],w=function(t,e,i,n){var s="clippingParents"===e?function(t){var e=ti($e(t)),i=["absolute","fixed"].indexOf(Le(t).position)>=0&&_e(t)?Ne(t):t;return ge(i)?e.filter((function(t){return ge(t)&&ke(t,i)&&"body"!==pe(t)})):[]}(t):[].concat(e),o=[].concat(s,[i]),r=o[0],a=o.reduce((function(e,i){var s=ii(t,i,n);return e.top=we(s.top,e.top),e.right=Ae(s.right,e.right),e.bottom=Ae(s.bottom,e.bottom),e.left=we(s.left,e.left),e}),ii(t,r,n));return a.width=a.right-a.left,a.height=a.bottom-a.top,a.x=a.left,a.y=a.top,a}(ge(y)?y:y.contextElement||De(t.elements.popper),l,h,r),A=Oe(t.elements.reference),E=ni({reference:A,element:v,placement:s}),T=ei(Object.assign({},v,E)),C=u===te?T:A,O={top:w.top-C.top+_.top,bottom:C.bottom-w.bottom+_.bottom,left:w.left-C.left+_.left,right:C.right-w.right+_.right},x=t.modifiersData.offset;if(u===te&&x){var k=x[s];Object.keys(O).forEach((function(t){var e=[Kt,Vt].indexOf(t)>=0?1:-1,i=[qt,Vt].indexOf(t)>=0?"y":"x";O[t]+=k[i]*e}))}return O}function oi(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=i.boundary,o=i.rootBoundary,r=i.padding,a=i.flipVariations,l=i.allowedAutoPlacements,c=void 0===l?ne:l,h=We(n),d=h?a?ie:ie.filter((function(t){return We(t)===h})):Yt,u=d.filter((function(t){return c.indexOf(t)>=0}));0===u.length&&(u=d);var f=u.reduce((function(e,i){return e[i]=si(t,{placement:i,boundary:s,rootBoundary:o,padding:r})[ye(i)],e}),{});return Object.keys(f).sort((function(t,e){return f[t]-f[e]}))}const ri={name:"flip",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name;if(!e.modifiersData[n]._skip){for(var s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0===r||r,l=i.fallbackPlacements,c=i.padding,h=i.boundary,d=i.rootBoundary,u=i.altBoundary,f=i.flipVariations,p=void 0===f||f,m=i.allowedAutoPlacements,g=e.options.placement,_=ye(g),b=l||(_!==g&&p?function(t){if(ye(t)===Xt)return[];var e=Qe(t);return[Ye(t),e,Ye(e)]}(g):[Qe(g)]),v=[g].concat(b).reduce((function(t,i){return t.concat(ye(i)===Xt?oi(e,{placement:i,boundary:h,rootBoundary:d,padding:c,flipVariations:p,allowedAutoPlacements:m}):i)}),[]),y=e.rects.reference,w=e.rects.popper,A=new Map,E=!0,T=v[0],C=0;C=0,S=L?"width":"height",D=si(e,{placement:O,boundary:h,rootBoundary:d,altBoundary:u,padding:c}),$=L?k?Kt:Qt:k?Vt:qt;y[S]>w[S]&&($=Qe($));var I=Qe($),N=[];if(o&&N.push(D[x]<=0),a&&N.push(D[$]<=0,D[I]<=0),N.every((function(t){return t}))){T=O,E=!1;break}A.set(O,N)}if(E)for(var P=function(t){var e=v.find((function(e){var i=A.get(e);if(i)return i.slice(0,t).every((function(t){return t}))}));if(e)return T=e,"break"},j=p?3:1;j>0&&"break"!==P(j);j--);e.placement!==T&&(e.modifiersData[n]._skip=!0,e.placement=T,e.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}};function ai(t,e,i){return void 0===i&&(i={x:0,y:0}),{top:t.top-e.height-i.y,right:t.right-e.width+i.x,bottom:t.bottom-e.height+i.y,left:t.left-e.width-i.x}}function li(t){return[qt,Kt,Vt,Qt].some((function(e){return t[e]>=0}))}const ci={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(t){var e=t.state,i=t.name,n=e.rects.reference,s=e.rects.popper,o=e.modifiersData.preventOverflow,r=si(e,{elementContext:"reference"}),a=si(e,{altBoundary:!0}),l=ai(r,n),c=ai(a,s,o),h=li(l),d=li(c);e.modifiersData[i]={referenceClippingOffsets:l,popperEscapeOffsets:c,isReferenceHidden:h,hasPopperEscaped:d},e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-reference-hidden":h,"data-popper-escaped":d})}},hi={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.offset,o=void 0===s?[0,0]:s,r=ne.reduce((function(t,i){return t[i]=function(t,e,i){var n=ye(t),s=[Qt,qt].indexOf(n)>=0?-1:1,o="function"==typeof i?i(Object.assign({},e,{placement:t})):i,r=o[0],a=o[1];return r=r||0,a=(a||0)*s,[Qt,Kt].indexOf(n)>=0?{x:a,y:r}:{x:r,y:a}}(i,e.rects,o),t}),{}),a=r[e.placement],l=a.x,c=a.y;null!=e.modifiersData.popperOffsets&&(e.modifiersData.popperOffsets.x+=l,e.modifiersData.popperOffsets.y+=c),e.modifiersData[n]=r}},di={name:"popperOffsets",enabled:!0,phase:"read",fn:function(t){var e=t.state,i=t.name;e.modifiersData[i]=ni({reference:e.rects.reference,element:e.rects.popper,placement:e.placement})},data:{}},ui={name:"preventOverflow",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0!==r&&r,l=i.boundary,c=i.rootBoundary,h=i.altBoundary,d=i.padding,u=i.tether,f=void 0===u||u,p=i.tetherOffset,m=void 0===p?0:p,g=si(e,{boundary:l,rootBoundary:c,padding:d,altBoundary:h}),_=ye(e.placement),b=We(e.placement),v=!b,y=Pe(_),w="x"===y?"y":"x",A=e.modifiersData.popperOffsets,E=e.rects.reference,T=e.rects.popper,C="function"==typeof m?m(Object.assign({},e.rects,{placement:e.placement})):m,O="number"==typeof C?{mainAxis:C,altAxis:C}:Object.assign({mainAxis:0,altAxis:0},C),x=e.modifiersData.offset?e.modifiersData.offset[e.placement]:null,k={x:0,y:0};if(A){if(o){var L,S="y"===y?qt:Qt,D="y"===y?Vt:Kt,$="y"===y?"height":"width",I=A[y],N=I+g[S],P=I-g[D],j=f?-T[$]/2:0,M=b===Ut?E[$]:T[$],F=b===Ut?-T[$]:-E[$],H=e.elements.arrow,W=f&&H?xe(H):{width:0,height:0},B=e.modifiersData["arrow#persistent"]?e.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},z=B[S],R=B[D],q=je(0,E[$],W[$]),V=v?E[$]/2-j-q-z-O.mainAxis:M-q-z-O.mainAxis,K=v?-E[$]/2+j+q+R+O.mainAxis:F+q+R+O.mainAxis,Q=e.elements.arrow&&Ne(e.elements.arrow),X=Q?"y"===y?Q.clientTop||0:Q.clientLeft||0:0,Y=null!=(L=null==x?void 0:x[y])?L:0,U=I+K-Y,G=je(f?Ae(N,I+V-Y-X):N,I,f?we(P,U):P);A[y]=G,k[y]=G-I}if(a){var J,Z="x"===y?qt:Qt,tt="x"===y?Vt:Kt,et=A[w],it="y"===w?"height":"width",nt=et+g[Z],st=et-g[tt],ot=-1!==[qt,Qt].indexOf(_),rt=null!=(J=null==x?void 0:x[w])?J:0,at=ot?nt:et-E[it]-T[it]-rt+O.altAxis,lt=ot?et+E[it]+T[it]-rt-O.altAxis:st,ct=f&&ot?function(t,e,i){var n=je(t,e,i);return n>i?i:n}(at,et,lt):je(f?at:nt,et,f?lt:st);A[w]=ct,k[w]=ct-et}e.modifiersData[n]=k}},requiresIfExists:["offset"]};function fi(t,e,i){void 0===i&&(i=!1);var n,s,o=_e(e),r=_e(e)&&function(t){var e=t.getBoundingClientRect(),i=Ee(e.width)/t.offsetWidth||1,n=Ee(e.height)/t.offsetHeight||1;return 1!==i||1!==n}(e),a=De(e),l=Oe(t,r,i),c={scrollLeft:0,scrollTop:0},h={x:0,y:0};return(o||!o&&!i)&&(("body"!==pe(e)||Je(a))&&(c=(n=e)!==me(n)&&_e(n)?{scrollLeft:(s=n).scrollLeft,scrollTop:s.scrollTop}:Ue(n)),_e(e)?((h=Oe(e,!0)).x+=e.clientLeft,h.y+=e.clientTop):a&&(h.x=Ge(a))),{x:l.left+c.scrollLeft-h.x,y:l.top+c.scrollTop-h.y,width:l.width,height:l.height}}function pi(t){var e=new Map,i=new Set,n=[];function s(t){i.add(t.name),[].concat(t.requires||[],t.requiresIfExists||[]).forEach((function(t){if(!i.has(t)){var n=e.get(t);n&&s(n)}})),n.push(t)}return t.forEach((function(t){e.set(t.name,t)})),t.forEach((function(t){i.has(t.name)||s(t)})),n}var mi={placement:"bottom",modifiers:[],strategy:"absolute"};function gi(){for(var t=arguments.length,e=new Array(t),i=0;iNumber.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_getPopperConfig(){const t={placement:this._getPlacement(),modifiers:[{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"offset",options:{offset:this._getOffset()}}]};return(this._inNavbar||"static"===this._config.display)&&(F.setDataAttribute(this._menu,"popper","static"),t.modifiers=[{name:"applyStyles",enabled:!1}]),{...t,...g(this._config.popperConfig,[void 0,t])}}_selectMenuItem({key:t,target:e}){const i=z.find(".dropdown-menu .dropdown-item:not(.disabled):not(:disabled)",this._menu).filter((t=>a(t)));i.length&&b(i,e,t===Oi,!i.includes(e)).focus()}static jQueryInterface(t){return this.each((function(){const e=Ki.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}static clearMenus(t){if(2===t.button||"keyup"===t.type&&"Tab"!==t.key)return;const e=z.find(ji);for(const i of e){const e=Ki.getInstance(i);if(!e||!1===e._config.autoClose)continue;const n=t.composedPath(),s=n.includes(e._menu);if(n.includes(e._element)||"inside"===e._config.autoClose&&!s||"outside"===e._config.autoClose&&s)continue;if(e._menu.contains(t.target)&&("keyup"===t.type&&"Tab"===t.key||/input|select|option|textarea|form/i.test(t.target.tagName)))continue;const o={relatedTarget:e._element};"click"===t.type&&(o.clickEvent=t),e._completeHide(o)}}static dataApiKeydownHandler(t){const e=/input|textarea/i.test(t.target.tagName),i="Escape"===t.key,n=[Ci,Oi].includes(t.key);if(!n&&!i)return;if(e&&!i)return;t.preventDefault();const s=this.matches(Pi)?this:z.prev(this,Pi)[0]||z.next(this,Pi)[0]||z.findOne(Pi,t.delegateTarget.parentNode),o=Ki.getOrCreateInstance(s);if(n)return t.stopPropagation(),o.show(),void o._selectMenuItem(t);o._isShown()&&(t.stopPropagation(),o.hide(),s.focus())}}N.on(document,$i,Pi,Ki.dataApiKeydownHandler),N.on(document,$i,Mi,Ki.dataApiKeydownHandler),N.on(document,Di,Ki.clearMenus),N.on(document,Ii,Ki.clearMenus),N.on(document,Di,Pi,(function(t){t.preventDefault(),Ki.getOrCreateInstance(this).toggle()})),m(Ki);const Qi="backdrop",Xi="show",Yi=`mousedown.bs.${Qi}`,Ui={className:"modal-backdrop",clickCallback:null,isAnimated:!1,isVisible:!0,rootElement:"body"},Gi={className:"string",clickCallback:"(function|null)",isAnimated:"boolean",isVisible:"boolean",rootElement:"(element|string)"};class Ji extends H{constructor(t){super(),this._config=this._getConfig(t),this._isAppended=!1,this._element=null}static get Default(){return Ui}static get DefaultType(){return Gi}static get NAME(){return Qi}show(t){if(!this._config.isVisible)return void g(t);this._append();const e=this._getElement();this._config.isAnimated&&d(e),e.classList.add(Xi),this._emulateAnimation((()=>{g(t)}))}hide(t){this._config.isVisible?(this._getElement().classList.remove(Xi),this._emulateAnimation((()=>{this.dispose(),g(t)}))):g(t)}dispose(){this._isAppended&&(N.off(this._element,Yi),this._element.remove(),this._isAppended=!1)}_getElement(){if(!this._element){const t=document.createElement("div");t.className=this._config.className,this._config.isAnimated&&t.classList.add("fade"),this._element=t}return this._element}_configAfterMerge(t){return t.rootElement=r(t.rootElement),t}_append(){if(this._isAppended)return;const t=this._getElement();this._config.rootElement.append(t),N.on(t,Yi,(()=>{g(this._config.clickCallback)})),this._isAppended=!0}_emulateAnimation(t){_(t,this._getElement(),this._config.isAnimated)}}const Zi=".bs.focustrap",tn=`focusin${Zi}`,en=`keydown.tab${Zi}`,nn="backward",sn={autofocus:!0,trapElement:null},on={autofocus:"boolean",trapElement:"element"};class rn extends H{constructor(t){super(),this._config=this._getConfig(t),this._isActive=!1,this._lastTabNavDirection=null}static get Default(){return sn}static get DefaultType(){return on}static get NAME(){return"focustrap"}activate(){this._isActive||(this._config.autofocus&&this._config.trapElement.focus(),N.off(document,Zi),N.on(document,tn,(t=>this._handleFocusin(t))),N.on(document,en,(t=>this._handleKeydown(t))),this._isActive=!0)}deactivate(){this._isActive&&(this._isActive=!1,N.off(document,Zi))}_handleFocusin(t){const{trapElement:e}=this._config;if(t.target===document||t.target===e||e.contains(t.target))return;const i=z.focusableChildren(e);0===i.length?e.focus():this._lastTabNavDirection===nn?i[i.length-1].focus():i[0].focus()}_handleKeydown(t){"Tab"===t.key&&(this._lastTabNavDirection=t.shiftKey?nn:"forward")}}const an=".fixed-top, .fixed-bottom, .is-fixed, .sticky-top",ln=".sticky-top",cn="padding-right",hn="margin-right";class dn{constructor(){this._element=document.body}getWidth(){const t=document.documentElement.clientWidth;return Math.abs(window.innerWidth-t)}hide(){const t=this.getWidth();this._disableOverFlow(),this._setElementAttributes(this._element,cn,(e=>e+t)),this._setElementAttributes(an,cn,(e=>e+t)),this._setElementAttributes(ln,hn,(e=>e-t))}reset(){this._resetElementAttributes(this._element,"overflow"),this._resetElementAttributes(this._element,cn),this._resetElementAttributes(an,cn),this._resetElementAttributes(ln,hn)}isOverflowing(){return this.getWidth()>0}_disableOverFlow(){this._saveInitialAttribute(this._element,"overflow"),this._element.style.overflow="hidden"}_setElementAttributes(t,e,i){const n=this.getWidth();this._applyManipulationCallback(t,(t=>{if(t!==this._element&&window.innerWidth>t.clientWidth+n)return;this._saveInitialAttribute(t,e);const s=window.getComputedStyle(t).getPropertyValue(e);t.style.setProperty(e,`${i(Number.parseFloat(s))}px`)}))}_saveInitialAttribute(t,e){const i=t.style.getPropertyValue(e);i&&F.setDataAttribute(t,e,i)}_resetElementAttributes(t,e){this._applyManipulationCallback(t,(t=>{const i=F.getDataAttribute(t,e);null!==i?(F.removeDataAttribute(t,e),t.style.setProperty(e,i)):t.style.removeProperty(e)}))}_applyManipulationCallback(t,e){if(o(t))e(t);else for(const i of z.find(t,this._element))e(i)}}const un=".bs.modal",fn=`hide${un}`,pn=`hidePrevented${un}`,mn=`hidden${un}`,gn=`show${un}`,_n=`shown${un}`,bn=`resize${un}`,vn=`click.dismiss${un}`,yn=`mousedown.dismiss${un}`,wn=`keydown.dismiss${un}`,An=`click${un}.data-api`,En="modal-open",Tn="show",Cn="modal-static",On={backdrop:!0,focus:!0,keyboard:!0},xn={backdrop:"(boolean|string)",focus:"boolean",keyboard:"boolean"};class kn extends W{constructor(t,e){super(t,e),this._dialog=z.findOne(".modal-dialog",this._element),this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._isShown=!1,this._isTransitioning=!1,this._scrollBar=new dn,this._addEventListeners()}static get Default(){return On}static get DefaultType(){return xn}static get NAME(){return"modal"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||this._isTransitioning||N.trigger(this._element,gn,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._isTransitioning=!0,this._scrollBar.hide(),document.body.classList.add(En),this._adjustDialog(),this._backdrop.show((()=>this._showElement(t))))}hide(){this._isShown&&!this._isTransitioning&&(N.trigger(this._element,fn).defaultPrevented||(this._isShown=!1,this._isTransitioning=!0,this._focustrap.deactivate(),this._element.classList.remove(Tn),this._queueCallback((()=>this._hideModal()),this._element,this._isAnimated())))}dispose(){N.off(window,un),N.off(this._dialog,un),this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}handleUpdate(){this._adjustDialog()}_initializeBackDrop(){return new Ji({isVisible:Boolean(this._config.backdrop),isAnimated:this._isAnimated()})}_initializeFocusTrap(){return new rn({trapElement:this._element})}_showElement(t){document.body.contains(this._element)||document.body.append(this._element),this._element.style.display="block",this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.scrollTop=0;const e=z.findOne(".modal-body",this._dialog);e&&(e.scrollTop=0),d(this._element),this._element.classList.add(Tn),this._queueCallback((()=>{this._config.focus&&this._focustrap.activate(),this._isTransitioning=!1,N.trigger(this._element,_n,{relatedTarget:t})}),this._dialog,this._isAnimated())}_addEventListeners(){N.on(this._element,wn,(t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():this._triggerBackdropTransition())})),N.on(window,bn,(()=>{this._isShown&&!this._isTransitioning&&this._adjustDialog()})),N.on(this._element,yn,(t=>{N.one(this._element,vn,(e=>{this._element===t.target&&this._element===e.target&&("static"!==this._config.backdrop?this._config.backdrop&&this.hide():this._triggerBackdropTransition())}))}))}_hideModal(){this._element.style.display="none",this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._isTransitioning=!1,this._backdrop.hide((()=>{document.body.classList.remove(En),this._resetAdjustments(),this._scrollBar.reset(),N.trigger(this._element,mn)}))}_isAnimated(){return this._element.classList.contains("fade")}_triggerBackdropTransition(){if(N.trigger(this._element,pn).defaultPrevented)return;const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._element.style.overflowY;"hidden"===e||this._element.classList.contains(Cn)||(t||(this._element.style.overflowY="hidden"),this._element.classList.add(Cn),this._queueCallback((()=>{this._element.classList.remove(Cn),this._queueCallback((()=>{this._element.style.overflowY=e}),this._dialog)}),this._dialog),this._element.focus())}_adjustDialog(){const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._scrollBar.getWidth(),i=e>0;if(i&&!t){const t=p()?"paddingLeft":"paddingRight";this._element.style[t]=`${e}px`}if(!i&&t){const t=p()?"paddingRight":"paddingLeft";this._element.style[t]=`${e}px`}}_resetAdjustments(){this._element.style.paddingLeft="",this._element.style.paddingRight=""}static jQueryInterface(t,e){return this.each((function(){const i=kn.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t](e)}}))}}N.on(document,An,'[data-bs-toggle="modal"]',(function(t){const e=z.getElementFromSelector(this);["A","AREA"].includes(this.tagName)&&t.preventDefault(),N.one(e,gn,(t=>{t.defaultPrevented||N.one(e,mn,(()=>{a(this)&&this.focus()}))}));const i=z.findOne(".modal.show");i&&kn.getInstance(i).hide(),kn.getOrCreateInstance(e).toggle(this)})),R(kn),m(kn);const Ln=".bs.offcanvas",Sn=".data-api",Dn=`load${Ln}${Sn}`,$n="show",In="showing",Nn="hiding",Pn=".offcanvas.show",jn=`show${Ln}`,Mn=`shown${Ln}`,Fn=`hide${Ln}`,Hn=`hidePrevented${Ln}`,Wn=`hidden${Ln}`,Bn=`resize${Ln}`,zn=`click${Ln}${Sn}`,Rn=`keydown.dismiss${Ln}`,qn={backdrop:!0,keyboard:!0,scroll:!1},Vn={backdrop:"(boolean|string)",keyboard:"boolean",scroll:"boolean"};class Kn extends W{constructor(t,e){super(t,e),this._isShown=!1,this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._addEventListeners()}static get Default(){return qn}static get DefaultType(){return Vn}static get NAME(){return"offcanvas"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||N.trigger(this._element,jn,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._backdrop.show(),this._config.scroll||(new dn).hide(),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.classList.add(In),this._queueCallback((()=>{this._config.scroll&&!this._config.backdrop||this._focustrap.activate(),this._element.classList.add($n),this._element.classList.remove(In),N.trigger(this._element,Mn,{relatedTarget:t})}),this._element,!0))}hide(){this._isShown&&(N.trigger(this._element,Fn).defaultPrevented||(this._focustrap.deactivate(),this._element.blur(),this._isShown=!1,this._element.classList.add(Nn),this._backdrop.hide(),this._queueCallback((()=>{this._element.classList.remove($n,Nn),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._config.scroll||(new dn).reset(),N.trigger(this._element,Wn)}),this._element,!0)))}dispose(){this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}_initializeBackDrop(){const t=Boolean(this._config.backdrop);return new Ji({className:"offcanvas-backdrop",isVisible:t,isAnimated:!0,rootElement:this._element.parentNode,clickCallback:t?()=>{"static"!==this._config.backdrop?this.hide():N.trigger(this._element,Hn)}:null})}_initializeFocusTrap(){return new rn({trapElement:this._element})}_addEventListeners(){N.on(this._element,Rn,(t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():N.trigger(this._element,Hn))}))}static jQueryInterface(t){return this.each((function(){const e=Kn.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}N.on(document,zn,'[data-bs-toggle="offcanvas"]',(function(t){const e=z.getElementFromSelector(this);if(["A","AREA"].includes(this.tagName)&&t.preventDefault(),l(this))return;N.one(e,Wn,(()=>{a(this)&&this.focus()}));const i=z.findOne(Pn);i&&i!==e&&Kn.getInstance(i).hide(),Kn.getOrCreateInstance(e).toggle(this)})),N.on(window,Dn,(()=>{for(const t of z.find(Pn))Kn.getOrCreateInstance(t).show()})),N.on(window,Bn,(()=>{for(const t of z.find("[aria-modal][class*=show][class*=offcanvas-]"))"fixed"!==getComputedStyle(t).position&&Kn.getOrCreateInstance(t).hide()})),R(Kn),m(Kn);const Qn={"*":["class","dir","id","lang","role",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],dd:[],div:[],dl:[],dt:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},Xn=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),Yn=/^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i,Un=(t,e)=>{const i=t.nodeName.toLowerCase();return e.includes(i)?!Xn.has(i)||Boolean(Yn.test(t.nodeValue)):e.filter((t=>t instanceof RegExp)).some((t=>t.test(i)))},Gn={allowList:Qn,content:{},extraClass:"",html:!1,sanitize:!0,sanitizeFn:null,template:"
"},Jn={allowList:"object",content:"object",extraClass:"(string|function)",html:"boolean",sanitize:"boolean",sanitizeFn:"(null|function)",template:"string"},Zn={entry:"(string|element|function|null)",selector:"(string|element)"};class ts extends H{constructor(t){super(),this._config=this._getConfig(t)}static get Default(){return Gn}static get DefaultType(){return Jn}static get NAME(){return"TemplateFactory"}getContent(){return Object.values(this._config.content).map((t=>this._resolvePossibleFunction(t))).filter(Boolean)}hasContent(){return this.getContent().length>0}changeContent(t){return this._checkContent(t),this._config.content={...this._config.content,...t},this}toHtml(){const t=document.createElement("div");t.innerHTML=this._maybeSanitize(this._config.template);for(const[e,i]of Object.entries(this._config.content))this._setContent(t,i,e);const e=t.children[0],i=this._resolvePossibleFunction(this._config.extraClass);return i&&e.classList.add(...i.split(" ")),e}_typeCheckConfig(t){super._typeCheckConfig(t),this._checkContent(t.content)}_checkContent(t){for(const[e,i]of Object.entries(t))super._typeCheckConfig({selector:e,entry:i},Zn)}_setContent(t,e,i){const n=z.findOne(i,t);n&&((e=this._resolvePossibleFunction(e))?o(e)?this._putElementInTemplate(r(e),n):this._config.html?n.innerHTML=this._maybeSanitize(e):n.textContent=e:n.remove())}_maybeSanitize(t){return this._config.sanitize?function(t,e,i){if(!t.length)return t;if(i&&"function"==typeof i)return i(t);const n=(new window.DOMParser).parseFromString(t,"text/html"),s=[].concat(...n.body.querySelectorAll("*"));for(const t of s){const i=t.nodeName.toLowerCase();if(!Object.keys(e).includes(i)){t.remove();continue}const n=[].concat(...t.attributes),s=[].concat(e["*"]||[],e[i]||[]);for(const e of n)Un(e,s)||t.removeAttribute(e.nodeName)}return n.body.innerHTML}(t,this._config.allowList,this._config.sanitizeFn):t}_resolvePossibleFunction(t){return g(t,[void 0,this])}_putElementInTemplate(t,e){if(this._config.html)return e.innerHTML="",void e.append(t);e.textContent=t.textContent}}const es=new Set(["sanitize","allowList","sanitizeFn"]),is="fade",ns="show",ss=".tooltip-inner",os=".modal",rs="hide.bs.modal",as="hover",ls="focus",cs={AUTO:"auto",TOP:"top",RIGHT:p()?"left":"right",BOTTOM:"bottom",LEFT:p()?"right":"left"},hs={allowList:Qn,animation:!0,boundary:"clippingParents",container:!1,customClass:"",delay:0,fallbackPlacements:["top","right","bottom","left"],html:!1,offset:[0,6],placement:"top",popperConfig:null,sanitize:!0,sanitizeFn:null,selector:!1,template:'',title:"",trigger:"hover focus"},ds={allowList:"object",animation:"boolean",boundary:"(string|element)",container:"(string|element|boolean)",customClass:"(string|function)",delay:"(number|object)",fallbackPlacements:"array",html:"boolean",offset:"(array|string|function)",placement:"(string|function)",popperConfig:"(null|object|function)",sanitize:"boolean",sanitizeFn:"(null|function)",selector:"(string|boolean)",template:"string",title:"(string|element|function)",trigger:"string"};class us extends W{constructor(t,e){if(void 0===wi)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org/docs/v2/)");super(t,e),this._isEnabled=!0,this._timeout=0,this._isHovered=null,this._activeTrigger={},this._popper=null,this._templateFactory=null,this._newContent=null,this.tip=null,this._setListeners(),this._config.selector||this._fixTitle()}static get Default(){return hs}static get DefaultType(){return ds}static get NAME(){return"tooltip"}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(){this._isEnabled&&(this._isShown()?this._leave():this._enter())}dispose(){clearTimeout(this._timeout),N.off(this._element.closest(os),rs,this._hideModalHandler),this._element.getAttribute("data-bs-original-title")&&this._element.setAttribute("title",this._element.getAttribute("data-bs-original-title")),this._disposePopper(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this._isWithContent()||!this._isEnabled)return;const t=N.trigger(this._element,this.constructor.eventName("show")),e=(c(this._element)||this._element.ownerDocument.documentElement).contains(this._element);if(t.defaultPrevented||!e)return;this._disposePopper();const i=this._getTipElement();this._element.setAttribute("aria-describedby",i.getAttribute("id"));const{container:n}=this._config;if(this._element.ownerDocument.documentElement.contains(this.tip)||(n.append(i),N.trigger(this._element,this.constructor.eventName("inserted"))),this._popper=this._createPopper(i),i.classList.add(ns),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))N.on(t,"mouseover",h);this._queueCallback((()=>{N.trigger(this._element,this.constructor.eventName("shown")),!1===this._isHovered&&this._leave(),this._isHovered=!1}),this.tip,this._isAnimated())}hide(){if(this._isShown()&&!N.trigger(this._element,this.constructor.eventName("hide")).defaultPrevented){if(this._getTipElement().classList.remove(ns),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))N.off(t,"mouseover",h);this._activeTrigger.click=!1,this._activeTrigger[ls]=!1,this._activeTrigger[as]=!1,this._isHovered=null,this._queueCallback((()=>{this._isWithActiveTrigger()||(this._isHovered||this._disposePopper(),this._element.removeAttribute("aria-describedby"),N.trigger(this._element,this.constructor.eventName("hidden")))}),this.tip,this._isAnimated())}}update(){this._popper&&this._popper.update()}_isWithContent(){return Boolean(this._getTitle())}_getTipElement(){return this.tip||(this.tip=this._createTipElement(this._newContent||this._getContentForTemplate())),this.tip}_createTipElement(t){const e=this._getTemplateFactory(t).toHtml();if(!e)return null;e.classList.remove(is,ns),e.classList.add(`bs-${this.constructor.NAME}-auto`);const i=(t=>{do{t+=Math.floor(1e6*Math.random())}while(document.getElementById(t));return t})(this.constructor.NAME).toString();return e.setAttribute("id",i),this._isAnimated()&&e.classList.add(is),e}setContent(t){this._newContent=t,this._isShown()&&(this._disposePopper(),this.show())}_getTemplateFactory(t){return this._templateFactory?this._templateFactory.changeContent(t):this._templateFactory=new ts({...this._config,content:t,extraClass:this._resolvePossibleFunction(this._config.customClass)}),this._templateFactory}_getContentForTemplate(){return{[ss]:this._getTitle()}}_getTitle(){return this._resolvePossibleFunction(this._config.title)||this._element.getAttribute("data-bs-original-title")}_initializeOnDelegatedTarget(t){return this.constructor.getOrCreateInstance(t.delegateTarget,this._getDelegateConfig())}_isAnimated(){return this._config.animation||this.tip&&this.tip.classList.contains(is)}_isShown(){return this.tip&&this.tip.classList.contains(ns)}_createPopper(t){const e=g(this._config.placement,[this,t,this._element]),i=cs[e.toUpperCase()];return yi(this._element,t,this._getPopperConfig(i))}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map((t=>Number.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_resolvePossibleFunction(t){return g(t,[this._element,this._element])}_getPopperConfig(t){const e={placement:t,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:`.${this.constructor.NAME}-arrow`}},{name:"preSetPlacement",enabled:!0,phase:"beforeMain",fn:t=>{this._getTipElement().setAttribute("data-popper-placement",t.state.placement)}}]};return{...e,...g(this._config.popperConfig,[void 0,e])}}_setListeners(){const t=this._config.trigger.split(" ");for(const e of t)if("click"===e)N.on(this._element,this.constructor.eventName("click"),this._config.selector,(t=>{this._initializeOnDelegatedTarget(t).toggle()}));else if("manual"!==e){const t=e===as?this.constructor.eventName("mouseenter"):this.constructor.eventName("focusin"),i=e===as?this.constructor.eventName("mouseleave"):this.constructor.eventName("focusout");N.on(this._element,t,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusin"===t.type?ls:as]=!0,e._enter()})),N.on(this._element,i,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusout"===t.type?ls:as]=e._element.contains(t.relatedTarget),e._leave()}))}this._hideModalHandler=()=>{this._element&&this.hide()},N.on(this._element.closest(os),rs,this._hideModalHandler)}_fixTitle(){const t=this._element.getAttribute("title");t&&(this._element.getAttribute("aria-label")||this._element.textContent.trim()||this._element.setAttribute("aria-label",t),this._element.setAttribute("data-bs-original-title",t),this._element.removeAttribute("title"))}_enter(){this._isShown()||this._isHovered?this._isHovered=!0:(this._isHovered=!0,this._setTimeout((()=>{this._isHovered&&this.show()}),this._config.delay.show))}_leave(){this._isWithActiveTrigger()||(this._isHovered=!1,this._setTimeout((()=>{this._isHovered||this.hide()}),this._config.delay.hide))}_setTimeout(t,e){clearTimeout(this._timeout),this._timeout=setTimeout(t,e)}_isWithActiveTrigger(){return Object.values(this._activeTrigger).includes(!0)}_getConfig(t){const e=F.getDataAttributes(this._element);for(const t of Object.keys(e))es.has(t)&&delete e[t];return t={...e,..."object"==typeof t&&t?t:{}},t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t.container=!1===t.container?document.body:r(t.container),"number"==typeof t.delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),t}_getDelegateConfig(){const t={};for(const[e,i]of Object.entries(this._config))this.constructor.Default[e]!==i&&(t[e]=i);return t.selector=!1,t.trigger="manual",t}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null),this.tip&&(this.tip.remove(),this.tip=null)}static jQueryInterface(t){return this.each((function(){const e=us.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}m(us);const fs=".popover-header",ps=".popover-body",ms={...us.Default,content:"",offset:[0,8],placement:"right",template:'',trigger:"click"},gs={...us.DefaultType,content:"(null|string|element|function)"};class _s extends us{static get Default(){return ms}static get DefaultType(){return gs}static get NAME(){return"popover"}_isWithContent(){return this._getTitle()||this._getContent()}_getContentForTemplate(){return{[fs]:this._getTitle(),[ps]:this._getContent()}}_getContent(){return this._resolvePossibleFunction(this._config.content)}static jQueryInterface(t){return this.each((function(){const e=_s.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}m(_s);const bs=".bs.scrollspy",vs=`activate${bs}`,ys=`click${bs}`,ws=`load${bs}.data-api`,As="active",Es="[href]",Ts=".nav-link",Cs=`${Ts}, .nav-item > ${Ts}, .list-group-item`,Os={offset:null,rootMargin:"0px 0px -25%",smoothScroll:!1,target:null,threshold:[.1,.5,1]},xs={offset:"(number|null)",rootMargin:"string",smoothScroll:"boolean",target:"element",threshold:"array"};class ks extends W{constructor(t,e){super(t,e),this._targetLinks=new Map,this._observableSections=new Map,this._rootElement="visible"===getComputedStyle(this._element).overflowY?null:this._element,this._activeTarget=null,this._observer=null,this._previousScrollData={visibleEntryTop:0,parentScrollTop:0},this.refresh()}static get Default(){return Os}static get DefaultType(){return xs}static get NAME(){return"scrollspy"}refresh(){this._initializeTargetsAndObservables(),this._maybeEnableSmoothScroll(),this._observer?this._observer.disconnect():this._observer=this._getNewObserver();for(const t of this._observableSections.values())this._observer.observe(t)}dispose(){this._observer.disconnect(),super.dispose()}_configAfterMerge(t){return t.target=r(t.target)||document.body,t.rootMargin=t.offset?`${t.offset}px 0px -30%`:t.rootMargin,"string"==typeof t.threshold&&(t.threshold=t.threshold.split(",").map((t=>Number.parseFloat(t)))),t}_maybeEnableSmoothScroll(){this._config.smoothScroll&&(N.off(this._config.target,ys),N.on(this._config.target,ys,Es,(t=>{const e=this._observableSections.get(t.target.hash);if(e){t.preventDefault();const i=this._rootElement||window,n=e.offsetTop-this._element.offsetTop;if(i.scrollTo)return void i.scrollTo({top:n,behavior:"smooth"});i.scrollTop=n}})))}_getNewObserver(){const t={root:this._rootElement,threshold:this._config.threshold,rootMargin:this._config.rootMargin};return new IntersectionObserver((t=>this._observerCallback(t)),t)}_observerCallback(t){const e=t=>this._targetLinks.get(`#${t.target.id}`),i=t=>{this._previousScrollData.visibleEntryTop=t.target.offsetTop,this._process(e(t))},n=(this._rootElement||document.documentElement).scrollTop,s=n>=this._previousScrollData.parentScrollTop;this._previousScrollData.parentScrollTop=n;for(const o of t){if(!o.isIntersecting){this._activeTarget=null,this._clearActiveClass(e(o));continue}const t=o.target.offsetTop>=this._previousScrollData.visibleEntryTop;if(s&&t){if(i(o),!n)return}else s||t||i(o)}}_initializeTargetsAndObservables(){this._targetLinks=new Map,this._observableSections=new Map;const t=z.find(Es,this._config.target);for(const e of t){if(!e.hash||l(e))continue;const t=z.findOne(decodeURI(e.hash),this._element);a(t)&&(this._targetLinks.set(decodeURI(e.hash),e),this._observableSections.set(e.hash,t))}}_process(t){this._activeTarget!==t&&(this._clearActiveClass(this._config.target),this._activeTarget=t,t.classList.add(As),this._activateParents(t),N.trigger(this._element,vs,{relatedTarget:t}))}_activateParents(t){if(t.classList.contains("dropdown-item"))z.findOne(".dropdown-toggle",t.closest(".dropdown")).classList.add(As);else for(const e of z.parents(t,".nav, .list-group"))for(const t of z.prev(e,Cs))t.classList.add(As)}_clearActiveClass(t){t.classList.remove(As);const e=z.find(`${Es}.${As}`,t);for(const t of e)t.classList.remove(As)}static jQueryInterface(t){return this.each((function(){const e=ks.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}N.on(window,ws,(()=>{for(const t of z.find('[data-bs-spy="scroll"]'))ks.getOrCreateInstance(t)})),m(ks);const Ls=".bs.tab",Ss=`hide${Ls}`,Ds=`hidden${Ls}`,$s=`show${Ls}`,Is=`shown${Ls}`,Ns=`click${Ls}`,Ps=`keydown${Ls}`,js=`load${Ls}`,Ms="ArrowLeft",Fs="ArrowRight",Hs="ArrowUp",Ws="ArrowDown",Bs="Home",zs="End",Rs="active",qs="fade",Vs="show",Ks=".dropdown-toggle",Qs=`:not(${Ks})`,Xs='[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]',Ys=`.nav-link${Qs}, .list-group-item${Qs}, [role="tab"]${Qs}, ${Xs}`,Us=`.${Rs}[data-bs-toggle="tab"], .${Rs}[data-bs-toggle="pill"], .${Rs}[data-bs-toggle="list"]`;class Gs extends W{constructor(t){super(t),this._parent=this._element.closest('.list-group, .nav, [role="tablist"]'),this._parent&&(this._setInitialAttributes(this._parent,this._getChildren()),N.on(this._element,Ps,(t=>this._keydown(t))))}static get NAME(){return"tab"}show(){const t=this._element;if(this._elemIsActive(t))return;const e=this._getActiveElem(),i=e?N.trigger(e,Ss,{relatedTarget:t}):null;N.trigger(t,$s,{relatedTarget:e}).defaultPrevented||i&&i.defaultPrevented||(this._deactivate(e,t),this._activate(t,e))}_activate(t,e){t&&(t.classList.add(Rs),this._activate(z.getElementFromSelector(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.removeAttribute("tabindex"),t.setAttribute("aria-selected",!0),this._toggleDropDown(t,!0),N.trigger(t,Is,{relatedTarget:e})):t.classList.add(Vs)}),t,t.classList.contains(qs)))}_deactivate(t,e){t&&(t.classList.remove(Rs),t.blur(),this._deactivate(z.getElementFromSelector(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.setAttribute("aria-selected",!1),t.setAttribute("tabindex","-1"),this._toggleDropDown(t,!1),N.trigger(t,Ds,{relatedTarget:e})):t.classList.remove(Vs)}),t,t.classList.contains(qs)))}_keydown(t){if(![Ms,Fs,Hs,Ws,Bs,zs].includes(t.key))return;t.stopPropagation(),t.preventDefault();const e=this._getChildren().filter((t=>!l(t)));let i;if([Bs,zs].includes(t.key))i=e[t.key===Bs?0:e.length-1];else{const n=[Fs,Ws].includes(t.key);i=b(e,t.target,n,!0)}i&&(i.focus({preventScroll:!0}),Gs.getOrCreateInstance(i).show())}_getChildren(){return z.find(Ys,this._parent)}_getActiveElem(){return this._getChildren().find((t=>this._elemIsActive(t)))||null}_setInitialAttributes(t,e){this._setAttributeIfNotExists(t,"role","tablist");for(const t of e)this._setInitialAttributesOnChild(t)}_setInitialAttributesOnChild(t){t=this._getInnerElement(t);const e=this._elemIsActive(t),i=this._getOuterElement(t);t.setAttribute("aria-selected",e),i!==t&&this._setAttributeIfNotExists(i,"role","presentation"),e||t.setAttribute("tabindex","-1"),this._setAttributeIfNotExists(t,"role","tab"),this._setInitialAttributesOnTargetPanel(t)}_setInitialAttributesOnTargetPanel(t){const e=z.getElementFromSelector(t);e&&(this._setAttributeIfNotExists(e,"role","tabpanel"),t.id&&this._setAttributeIfNotExists(e,"aria-labelledby",`${t.id}`))}_toggleDropDown(t,e){const i=this._getOuterElement(t);if(!i.classList.contains("dropdown"))return;const n=(t,n)=>{const s=z.findOne(t,i);s&&s.classList.toggle(n,e)};n(Ks,Rs),n(".dropdown-menu",Vs),i.setAttribute("aria-expanded",e)}_setAttributeIfNotExists(t,e,i){t.hasAttribute(e)||t.setAttribute(e,i)}_elemIsActive(t){return t.classList.contains(Rs)}_getInnerElement(t){return t.matches(Ys)?t:z.findOne(Ys,t)}_getOuterElement(t){return t.closest(".nav-item, .list-group-item")||t}static jQueryInterface(t){return this.each((function(){const e=Gs.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}N.on(document,Ns,Xs,(function(t){["A","AREA"].includes(this.tagName)&&t.preventDefault(),l(this)||Gs.getOrCreateInstance(this).show()})),N.on(window,js,(()=>{for(const t of z.find(Us))Gs.getOrCreateInstance(t)})),m(Gs);const Js=".bs.toast",Zs=`mouseover${Js}`,to=`mouseout${Js}`,eo=`focusin${Js}`,io=`focusout${Js}`,no=`hide${Js}`,so=`hidden${Js}`,oo=`show${Js}`,ro=`shown${Js}`,ao="hide",lo="show",co="showing",ho={animation:"boolean",autohide:"boolean",delay:"number"},uo={animation:!0,autohide:!0,delay:5e3};class fo extends W{constructor(t,e){super(t,e),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners()}static get Default(){return uo}static get DefaultType(){return ho}static get NAME(){return"toast"}show(){N.trigger(this._element,oo).defaultPrevented||(this._clearTimeout(),this._config.animation&&this._element.classList.add("fade"),this._element.classList.remove(ao),d(this._element),this._element.classList.add(lo,co),this._queueCallback((()=>{this._element.classList.remove(co),N.trigger(this._element,ro),this._maybeScheduleHide()}),this._element,this._config.animation))}hide(){this.isShown()&&(N.trigger(this._element,no).defaultPrevented||(this._element.classList.add(co),this._queueCallback((()=>{this._element.classList.add(ao),this._element.classList.remove(co,lo),N.trigger(this._element,so)}),this._element,this._config.animation)))}dispose(){this._clearTimeout(),this.isShown()&&this._element.classList.remove(lo),super.dispose()}isShown(){return this._element.classList.contains(lo)}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout((()=>{this.hide()}),this._config.delay)))}_onInteraction(t,e){switch(t.type){case"mouseover":case"mouseout":this._hasMouseInteraction=e;break;case"focusin":case"focusout":this._hasKeyboardInteraction=e}if(e)return void this._clearTimeout();const i=t.relatedTarget;this._element===i||this._element.contains(i)||this._maybeScheduleHide()}_setListeners(){N.on(this._element,Zs,(t=>this._onInteraction(t,!0))),N.on(this._element,to,(t=>this._onInteraction(t,!1))),N.on(this._element,eo,(t=>this._onInteraction(t,!0))),N.on(this._element,io,(t=>this._onInteraction(t,!1)))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(t){return this.each((function(){const e=fo.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}return R(fo),m(fo),{Alert:Q,Button:Y,Carousel:Lt,Collapse:Rt,Dropdown:Ki,Modal:kn,Offcanvas:Kn,Popover:_s,ScrollSpy:ks,Tab:Gs,Toast:fo,Tooltip:us}})); \ No newline at end of file diff --git a/apps/web/public/wesp/js/chart.umd.min.js b/apps/web/public/wesp/js/chart.umd.min.js new file mode 100644 index 0000000..78c4e5d --- /dev/null +++ b/apps/web/public/wesp/js/chart.umd.min.js @@ -0,0 +1,20 @@ +/** + * Skipped minification because the original files appears to be already minified. + * Original file: /npm/chart.js@4.4.1/dist/chart.umd.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +/*! + * Chart.js v4.4.1 + * https://www.chartjs.org + * (c) 2023 Chart.js Contributors + * Released under the MIT License + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(this,(function(){"use strict";var t=Object.freeze({__proto__:null,get Colors(){return Go},get Decimation(){return Qo},get Filler(){return ma},get Legend(){return ya},get SubTitle(){return ka},get Title(){return Ma},get Tooltip(){return Ba}});function e(){}const i=(()=>{let t=0;return()=>t++})();function s(t){return null==t}function n(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function o(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}function a(t){return("number"==typeof t||t instanceof Number)&&isFinite(+t)}function r(t,e){return a(t)?t:e}function l(t,e){return void 0===t?e:t}const h=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:+t/e,c=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function d(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function u(t,e,i,s){let a,r,l;if(n(t))if(r=t.length,s)for(a=r-1;a>=0;a--)e.call(i,t[a],a);else for(a=0;at,x:t=>t.x,y:t=>t.y};function v(t){const e=t.split("."),i=[];let s="";for(const t of e)s+=t,s.endsWith("\\")?s=s.slice(0,-1)+".":(i.push(s),s="");return i}function M(t,e){const i=y[e]||(y[e]=function(t){const e=v(t);return t=>{for(const i of e){if(""===i)break;t=t&&t[i]}return t}}(e));return i(t)}function w(t){return t.charAt(0).toUpperCase()+t.slice(1)}const k=t=>void 0!==t,S=t=>"function"==typeof t,P=(t,e)=>{if(t.size!==e.size)return!1;for(const i of t)if(!e.has(i))return!1;return!0};function D(t){return"mouseup"===t.type||"click"===t.type||"contextmenu"===t.type}const C=Math.PI,O=2*C,A=O+C,T=Number.POSITIVE_INFINITY,L=C/180,E=C/2,R=C/4,I=2*C/3,z=Math.log10,F=Math.sign;function V(t,e,i){return Math.abs(t-e)t-e)).pop(),e}function N(t){return!isNaN(parseFloat(t))&&isFinite(t)}function H(t,e){const i=Math.round(t);return i-e<=t&&i+e>=t}function j(t,e,i){let s,n,o;for(s=0,n=t.length;sl&&h=Math.min(e,i)-s&&t<=Math.max(e,i)+s}function et(t,e,i){i=i||(i=>t[i]1;)s=o+n>>1,i(s)?o=s:n=s;return{lo:o,hi:n}}const it=(t,e,i,s)=>et(t,i,s?s=>{const n=t[s][e];return nt[s][e]et(t,i,(s=>t[s][e]>=i));function nt(t,e,i){let s=0,n=t.length;for(;ss&&t[n-1]>i;)n--;return s>0||n{const i="_onData"+w(e),s=t[e];Object.defineProperty(t,e,{configurable:!0,enumerable:!1,value(...e){const n=s.apply(this,e);return t._chartjs.listeners.forEach((t=>{"function"==typeof t[i]&&t[i](...e)})),n}})})))}function rt(t,e){const i=t._chartjs;if(!i)return;const s=i.listeners,n=s.indexOf(e);-1!==n&&s.splice(n,1),s.length>0||(ot.forEach((e=>{delete t[e]})),delete t._chartjs)}function lt(t){const e=new Set(t);return e.size===t.length?t:Array.from(e)}const ht="undefined"==typeof window?function(t){return t()}:window.requestAnimationFrame;function ct(t,e){let i=[],s=!1;return function(...n){i=n,s||(s=!0,ht.call(window,(()=>{s=!1,t.apply(e,i)})))}}function dt(t,e){let i;return function(...s){return e?(clearTimeout(i),i=setTimeout(t,e,s)):t.apply(this,s),e}}const ut=t=>"start"===t?"left":"end"===t?"right":"center",ft=(t,e,i)=>"start"===t?e:"end"===t?i:(e+i)/2,gt=(t,e,i,s)=>t===(s?"left":"right")?i:"center"===t?(e+i)/2:e;function pt(t,e,i){const s=e.length;let n=0,o=s;if(t._sorted){const{iScale:a,_parsed:r}=t,l=a.axis,{min:h,max:c,minDefined:d,maxDefined:u}=a.getUserBounds();d&&(n=J(Math.min(it(r,l,h).lo,i?s:it(e,l,a.getPixelForValue(h)).lo),0,s-1)),o=u?J(Math.max(it(r,a.axis,c,!0).hi+1,i?0:it(e,l,a.getPixelForValue(c),!0).hi+1),n,s)-n:s-n}return{start:n,count:o}}function mt(t){const{xScale:e,yScale:i,_scaleRanges:s}=t,n={xmin:e.min,xmax:e.max,ymin:i.min,ymax:i.max};if(!s)return t._scaleRanges=n,!0;const o=s.xmin!==e.min||s.xmax!==e.max||s.ymin!==i.min||s.ymax!==i.max;return Object.assign(s,n),o}class bt{constructor(){this._request=null,this._charts=new Map,this._running=!1,this._lastDate=void 0}_notify(t,e,i,s){const n=e.listeners[s],o=e.duration;n.forEach((s=>s({chart:t,initial:e.initial,numSteps:o,currentStep:Math.min(i-e.start,o)})))}_refresh(){this._request||(this._running=!0,this._request=ht.call(window,(()=>{this._update(),this._request=null,this._running&&this._refresh()})))}_update(t=Date.now()){let e=0;this._charts.forEach(((i,s)=>{if(!i.running||!i.items.length)return;const n=i.items;let o,a=n.length-1,r=!1;for(;a>=0;--a)o=n[a],o._active?(o._total>i.duration&&(i.duration=o._total),o.tick(t),r=!0):(n[a]=n[n.length-1],n.pop());r&&(s.draw(),this._notify(s,i,t,"progress")),n.length||(i.running=!1,this._notify(s,i,t,"complete"),i.initial=!1),e+=n.length})),this._lastDate=t,0===e&&(this._running=!1)}_getAnims(t){const e=this._charts;let i=e.get(t);return i||(i={running:!1,initial:!0,items:[],listeners:{complete:[],progress:[]}},e.set(t,i)),i}listen(t,e,i){this._getAnims(t).listeners[e].push(i)}add(t,e){e&&e.length&&this._getAnims(t).items.push(...e)}has(t){return this._getAnims(t).items.length>0}start(t){const e=this._charts.get(t);e&&(e.running=!0,e.start=Date.now(),e.duration=e.items.reduce(((t,e)=>Math.max(t,e._duration)),0),this._refresh())}running(t){if(!this._running)return!1;const e=this._charts.get(t);return!!(e&&e.running&&e.items.length)}stop(t){const e=this._charts.get(t);if(!e||!e.items.length)return;const i=e.items;let s=i.length-1;for(;s>=0;--s)i[s].cancel();e.items=[],this._notify(t,e,Date.now(),"complete")}remove(t){return this._charts.delete(t)}}var xt=new bt; +/*! + * @kurkle/color v0.3.2 + * https://github.com/kurkle/color#readme + * (c) 2023 Jukka Kurkela + * Released under the MIT License + */function _t(t){return t+.5|0}const yt=(t,e,i)=>Math.max(Math.min(t,i),e);function vt(t){return yt(_t(2.55*t),0,255)}function Mt(t){return yt(_t(255*t),0,255)}function wt(t){return yt(_t(t/2.55)/100,0,1)}function kt(t){return yt(_t(100*t),0,100)}const St={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},Pt=[..."0123456789ABCDEF"],Dt=t=>Pt[15&t],Ct=t=>Pt[(240&t)>>4]+Pt[15&t],Ot=t=>(240&t)>>4==(15&t);function At(t){var e=(t=>Ot(t.r)&&Ot(t.g)&&Ot(t.b)&&Ot(t.a))(t)?Dt:Ct;return t?"#"+e(t.r)+e(t.g)+e(t.b)+((t,e)=>t<255?e(t):"")(t.a,e):void 0}const Tt=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function Lt(t,e,i){const s=e*Math.min(i,1-i),n=(e,n=(e+t/30)%12)=>i-s*Math.max(Math.min(n-3,9-n,1),-1);return[n(0),n(8),n(4)]}function Et(t,e,i){const s=(s,n=(s+t/60)%6)=>i-i*e*Math.max(Math.min(n,4-n,1),0);return[s(5),s(3),s(1)]}function Rt(t,e,i){const s=Lt(t,1,.5);let n;for(e+i>1&&(n=1/(e+i),e*=n,i*=n),n=0;n<3;n++)s[n]*=1-e-i,s[n]+=e;return s}function It(t){const e=t.r/255,i=t.g/255,s=t.b/255,n=Math.max(e,i,s),o=Math.min(e,i,s),a=(n+o)/2;let r,l,h;return n!==o&&(h=n-o,l=a>.5?h/(2-n-o):h/(n+o),r=function(t,e,i,s,n){return t===n?(e-i)/s+(e>16&255,o>>8&255,255&o]}return t}(),Ht.transparent=[0,0,0,0]);const e=Ht[t.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:4===e.length?e[3]:255}}const $t=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;const Yt=t=>t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055,Ut=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function Xt(t,e,i){if(t){let s=It(t);s[e]=Math.max(0,Math.min(s[e]+s[e]*i,0===e?360:1)),s=Ft(s),t.r=s[0],t.g=s[1],t.b=s[2]}}function qt(t,e){return t?Object.assign(e||{},t):t}function Kt(t){var e={r:0,g:0,b:0,a:255};return Array.isArray(t)?t.length>=3&&(e={r:t[0],g:t[1],b:t[2],a:255},t.length>3&&(e.a=Mt(t[3]))):(e=qt(t,{r:0,g:0,b:0,a:1})).a=Mt(e.a),e}function Gt(t){return"r"===t.charAt(0)?function(t){const e=$t.exec(t);let i,s,n,o=255;if(e){if(e[7]!==i){const t=+e[7];o=e[8]?vt(t):yt(255*t,0,255)}return i=+e[1],s=+e[3],n=+e[5],i=255&(e[2]?vt(i):yt(i,0,255)),s=255&(e[4]?vt(s):yt(s,0,255)),n=255&(e[6]?vt(n):yt(n,0,255)),{r:i,g:s,b:n,a:o}}}(t):Bt(t)}class Zt{constructor(t){if(t instanceof Zt)return t;const e=typeof t;let i;var s,n,o;"object"===e?i=Kt(t):"string"===e&&(o=(s=t).length,"#"===s[0]&&(4===o||5===o?n={r:255&17*St[s[1]],g:255&17*St[s[2]],b:255&17*St[s[3]],a:5===o?17*St[s[4]]:255}:7!==o&&9!==o||(n={r:St[s[1]]<<4|St[s[2]],g:St[s[3]]<<4|St[s[4]],b:St[s[5]]<<4|St[s[6]],a:9===o?St[s[7]]<<4|St[s[8]]:255})),i=n||jt(t)||Gt(t)),this._rgb=i,this._valid=!!i}get valid(){return this._valid}get rgb(){var t=qt(this._rgb);return t&&(t.a=wt(t.a)),t}set rgb(t){this._rgb=Kt(t)}rgbString(){return this._valid?(t=this._rgb)&&(t.a<255?`rgba(${t.r}, ${t.g}, ${t.b}, ${wt(t.a)})`:`rgb(${t.r}, ${t.g}, ${t.b})`):void 0;var t}hexString(){return this._valid?At(this._rgb):void 0}hslString(){return this._valid?function(t){if(!t)return;const e=It(t),i=e[0],s=kt(e[1]),n=kt(e[2]);return t.a<255?`hsla(${i}, ${s}%, ${n}%, ${wt(t.a)})`:`hsl(${i}, ${s}%, ${n}%)`}(this._rgb):void 0}mix(t,e){if(t){const i=this.rgb,s=t.rgb;let n;const o=e===n?.5:e,a=2*o-1,r=i.a-s.a,l=((a*r==-1?a:(a+r)/(1+a*r))+1)/2;n=1-l,i.r=255&l*i.r+n*s.r+.5,i.g=255&l*i.g+n*s.g+.5,i.b=255&l*i.b+n*s.b+.5,i.a=o*i.a+(1-o)*s.a,this.rgb=i}return this}interpolate(t,e){return t&&(this._rgb=function(t,e,i){const s=Ut(wt(t.r)),n=Ut(wt(t.g)),o=Ut(wt(t.b));return{r:Mt(Yt(s+i*(Ut(wt(e.r))-s))),g:Mt(Yt(n+i*(Ut(wt(e.g))-n))),b:Mt(Yt(o+i*(Ut(wt(e.b))-o))),a:t.a+i*(e.a-t.a)}}(this._rgb,t._rgb,e)),this}clone(){return new Zt(this.rgb)}alpha(t){return this._rgb.a=Mt(t),this}clearer(t){return this._rgb.a*=1-t,this}greyscale(){const t=this._rgb,e=_t(.3*t.r+.59*t.g+.11*t.b);return t.r=t.g=t.b=e,this}opaquer(t){return this._rgb.a*=1+t,this}negate(){const t=this._rgb;return t.r=255-t.r,t.g=255-t.g,t.b=255-t.b,this}lighten(t){return Xt(this._rgb,2,t),this}darken(t){return Xt(this._rgb,2,-t),this}saturate(t){return Xt(this._rgb,1,t),this}desaturate(t){return Xt(this._rgb,1,-t),this}rotate(t){return function(t,e){var i=It(t);i[0]=Vt(i[0]+e),i=Ft(i),t.r=i[0],t.g=i[1],t.b=i[2]}(this._rgb,t),this}}function Jt(t){if(t&&"object"==typeof t){const e=t.toString();return"[object CanvasPattern]"===e||"[object CanvasGradient]"===e}return!1}function Qt(t){return Jt(t)?t:new Zt(t)}function te(t){return Jt(t)?t:new Zt(t).saturate(.5).darken(.1).hexString()}const ee=["x","y","borderWidth","radius","tension"],ie=["color","borderColor","backgroundColor"];const se=new Map;function ne(t,e,i){return function(t,e){e=e||{};const i=t+JSON.stringify(e);let s=se.get(i);return s||(s=new Intl.NumberFormat(t,e),se.set(i,s)),s}(e,i).format(t)}const oe={values:t=>n(t)?t:""+t,numeric(t,e,i){if(0===t)return"0";const s=this.chart.options.locale;let n,o=t;if(i.length>1){const e=Math.max(Math.abs(i[0].value),Math.abs(i[i.length-1].value));(e<1e-4||e>1e15)&&(n="scientific"),o=function(t,e){let i=e.length>3?e[2].value-e[1].value:e[1].value-e[0].value;Math.abs(i)>=1&&t!==Math.floor(t)&&(i=t-Math.floor(t));return i}(t,i)}const a=z(Math.abs(o)),r=isNaN(a)?1:Math.max(Math.min(-1*Math.floor(a),20),0),l={notation:n,minimumFractionDigits:r,maximumFractionDigits:r};return Object.assign(l,this.options.ticks.format),ne(t,s,l)},logarithmic(t,e,i){if(0===t)return"0";const s=i[e].significand||t/Math.pow(10,Math.floor(z(t)));return[1,2,3,5,10,15].includes(s)||e>.8*i.length?oe.numeric.call(this,t,e,i):""}};var ae={formatters:oe};const re=Object.create(null),le=Object.create(null);function he(t,e){if(!e)return t;const i=e.split(".");for(let e=0,s=i.length;et.chart.platform.getDevicePixelRatio(),this.elements={},this.events=["mousemove","mouseout","click","touchstart","touchmove"],this.font={family:"'Helvetica Neue', 'Helvetica', 'Arial', sans-serif",size:12,style:"normal",lineHeight:1.2,weight:null},this.hover={},this.hoverBackgroundColor=(t,e)=>te(e.backgroundColor),this.hoverBorderColor=(t,e)=>te(e.borderColor),this.hoverColor=(t,e)=>te(e.color),this.indexAxis="x",this.interaction={mode:"nearest",intersect:!0,includeInvisible:!1},this.maintainAspectRatio=!0,this.onHover=null,this.onClick=null,this.parsing=!0,this.plugins={},this.responsive=!0,this.scale=void 0,this.scales={},this.showLine=!0,this.drawActiveElementsOnTop=!0,this.describe(t),this.apply(e)}set(t,e){return ce(this,t,e)}get(t){return he(this,t)}describe(t,e){return ce(le,t,e)}override(t,e){return ce(re,t,e)}route(t,e,i,s){const n=he(this,t),a=he(this,i),r="_"+e;Object.defineProperties(n,{[r]:{value:n[e],writable:!0},[e]:{enumerable:!0,get(){const t=this[r],e=a[s];return o(t)?Object.assign({},e,t):l(t,e)},set(t){this[r]=t}}})}apply(t){t.forEach((t=>t(this)))}}var ue=new de({_scriptable:t=>!t.startsWith("on"),_indexable:t=>"events"!==t,hover:{_fallback:"interaction"},interaction:{_scriptable:!1,_indexable:!1}},[function(t){t.set("animation",{delay:void 0,duration:1e3,easing:"easeOutQuart",fn:void 0,from:void 0,loop:void 0,to:void 0,type:void 0}),t.describe("animation",{_fallback:!1,_indexable:!1,_scriptable:t=>"onProgress"!==t&&"onComplete"!==t&&"fn"!==t}),t.set("animations",{colors:{type:"color",properties:ie},numbers:{type:"number",properties:ee}}),t.describe("animations",{_fallback:"animation"}),t.set("transitions",{active:{animation:{duration:400}},resize:{animation:{duration:0}},show:{animations:{colors:{from:"transparent"},visible:{type:"boolean",duration:0}}},hide:{animations:{colors:{to:"transparent"},visible:{type:"boolean",easing:"linear",fn:t=>0|t}}}})},function(t){t.set("layout",{autoPadding:!0,padding:{top:0,right:0,bottom:0,left:0}})},function(t){t.set("scale",{display:!0,offset:!1,reverse:!1,beginAtZero:!1,bounds:"ticks",clip:!0,grace:0,grid:{display:!0,lineWidth:1,drawOnChartArea:!0,drawTicks:!0,tickLength:8,tickWidth:(t,e)=>e.lineWidth,tickColor:(t,e)=>e.color,offset:!1},border:{display:!0,dash:[],dashOffset:0,width:1},title:{display:!1,text:"",padding:{top:4,bottom:4}},ticks:{minRotation:0,maxRotation:50,mirror:!1,textStrokeWidth:0,textStrokeColor:"",padding:3,display:!0,autoSkip:!0,autoSkipPadding:3,labelOffset:0,callback:ae.formatters.values,minor:{},major:{},align:"center",crossAlign:"near",showLabelBackdrop:!1,backdropColor:"rgba(255, 255, 255, 0.75)",backdropPadding:2}}),t.route("scale.ticks","color","","color"),t.route("scale.grid","color","","borderColor"),t.route("scale.border","color","","borderColor"),t.route("scale.title","color","","color"),t.describe("scale",{_fallback:!1,_scriptable:t=>!t.startsWith("before")&&!t.startsWith("after")&&"callback"!==t&&"parser"!==t,_indexable:t=>"borderDash"!==t&&"tickBorderDash"!==t&&"dash"!==t}),t.describe("scales",{_fallback:"scale"}),t.describe("scale.ticks",{_scriptable:t=>"backdropPadding"!==t&&"callback"!==t,_indexable:t=>"backdropPadding"!==t})}]);function fe(){return"undefined"!=typeof window&&"undefined"!=typeof document}function ge(t){let e=t.parentNode;return e&&"[object ShadowRoot]"===e.toString()&&(e=e.host),e}function pe(t,e,i){let s;return"string"==typeof t?(s=parseInt(t,10),-1!==t.indexOf("%")&&(s=s/100*e.parentNode[i])):s=t,s}const me=t=>t.ownerDocument.defaultView.getComputedStyle(t,null);function be(t,e){return me(t).getPropertyValue(e)}const xe=["top","right","bottom","left"];function _e(t,e,i){const s={};i=i?"-"+i:"";for(let n=0;n<4;n++){const o=xe[n];s[o]=parseFloat(t[e+"-"+o+i])||0}return s.width=s.left+s.right,s.height=s.top+s.bottom,s}const ye=(t,e,i)=>(t>0||e>0)&&(!i||!i.shadowRoot);function ve(t,e){if("native"in t)return t;const{canvas:i,currentDevicePixelRatio:s}=e,n=me(i),o="border-box"===n.boxSizing,a=_e(n,"padding"),r=_e(n,"border","width"),{x:l,y:h,box:c}=function(t,e){const i=t.touches,s=i&&i.length?i[0]:t,{offsetX:n,offsetY:o}=s;let a,r,l=!1;if(ye(n,o,t.target))a=n,r=o;else{const t=e.getBoundingClientRect();a=s.clientX-t.left,r=s.clientY-t.top,l=!0}return{x:a,y:r,box:l}}(t,i),d=a.left+(c&&r.left),u=a.top+(c&&r.top);let{width:f,height:g}=e;return o&&(f-=a.width+r.width,g-=a.height+r.height),{x:Math.round((l-d)/f*i.width/s),y:Math.round((h-u)/g*i.height/s)}}const Me=t=>Math.round(10*t)/10;function we(t,e,i,s){const n=me(t),o=_e(n,"margin"),a=pe(n.maxWidth,t,"clientWidth")||T,r=pe(n.maxHeight,t,"clientHeight")||T,l=function(t,e,i){let s,n;if(void 0===e||void 0===i){const o=ge(t);if(o){const t=o.getBoundingClientRect(),a=me(o),r=_e(a,"border","width"),l=_e(a,"padding");e=t.width-l.width-r.width,i=t.height-l.height-r.height,s=pe(a.maxWidth,o,"clientWidth"),n=pe(a.maxHeight,o,"clientHeight")}else e=t.clientWidth,i=t.clientHeight}return{width:e,height:i,maxWidth:s||T,maxHeight:n||T}}(t,e,i);let{width:h,height:c}=l;if("content-box"===n.boxSizing){const t=_e(n,"border","width"),e=_e(n,"padding");h-=e.width+t.width,c-=e.height+t.height}h=Math.max(0,h-o.width),c=Math.max(0,s?h/s:c-o.height),h=Me(Math.min(h,a,l.maxWidth)),c=Me(Math.min(c,r,l.maxHeight)),h&&!c&&(c=Me(h/2));return(void 0!==e||void 0!==i)&&s&&l.height&&c>l.height&&(c=l.height,h=Me(Math.floor(c*s))),{width:h,height:c}}function ke(t,e,i){const s=e||1,n=Math.floor(t.height*s),o=Math.floor(t.width*s);t.height=Math.floor(t.height),t.width=Math.floor(t.width);const a=t.canvas;return a.style&&(i||!a.style.height&&!a.style.width)&&(a.style.height=`${t.height}px`,a.style.width=`${t.width}px`),(t.currentDevicePixelRatio!==s||a.height!==n||a.width!==o)&&(t.currentDevicePixelRatio=s,a.height=n,a.width=o,t.ctx.setTransform(s,0,0,s,0,0),!0)}const Se=function(){let t=!1;try{const e={get passive(){return t=!0,!1}};fe()&&(window.addEventListener("test",null,e),window.removeEventListener("test",null,e))}catch(t){}return t}();function Pe(t,e){const i=be(t,e),s=i&&i.match(/^(\d+)(\.\d+)?px$/);return s?+s[1]:void 0}function De(t){return!t||s(t.size)||s(t.family)?null:(t.style?t.style+" ":"")+(t.weight?t.weight+" ":"")+t.size+"px "+t.family}function Ce(t,e,i,s,n){let o=e[n];return o||(o=e[n]=t.measureText(n).width,i.push(n)),o>s&&(s=o),s}function Oe(t,e,i,s){let o=(s=s||{}).data=s.data||{},a=s.garbageCollect=s.garbageCollect||[];s.font!==e&&(o=s.data={},a=s.garbageCollect=[],s.font=e),t.save(),t.font=e;let r=0;const l=i.length;let h,c,d,u,f;for(h=0;hi.length){for(h=0;h0&&t.stroke()}}function Re(t,e,i){return i=i||.5,!e||t&&t.x>e.left-i&&t.xe.top-i&&t.y0&&""!==r.strokeColor;let c,d;for(t.save(),t.font=a.string,function(t,e){e.translation&&t.translate(e.translation[0],e.translation[1]),s(e.rotation)||t.rotate(e.rotation),e.color&&(t.fillStyle=e.color),e.textAlign&&(t.textAlign=e.textAlign),e.textBaseline&&(t.textBaseline=e.textBaseline)}(t,r),c=0;ct[0])){const o=i||t;void 0===s&&(s=ti("_fallback",t));const a={[Symbol.toStringTag]:"Object",_cacheable:!0,_scopes:t,_rootScopes:o,_fallback:s,_getTarget:n,override:i=>je([i,...t],e,o,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete e._keys,delete t[0][i],!0),get:(i,s)=>qe(i,s,(()=>function(t,e,i,s){let n;for(const o of e)if(n=ti(Ue(o,t),i),void 0!==n)return Xe(t,n)?Je(i,s,t,n):n}(s,e,t,i))),getOwnPropertyDescriptor:(t,e)=>Reflect.getOwnPropertyDescriptor(t._scopes[0],e),getPrototypeOf:()=>Reflect.getPrototypeOf(t[0]),has:(t,e)=>ei(t).includes(e),ownKeys:t=>ei(t),set(t,e,i){const s=t._storage||(t._storage=n());return t[e]=s[e]=i,delete t._keys,!0}})}function $e(t,e,i,s){const a={_cacheable:!1,_proxy:t,_context:e,_subProxy:i,_stack:new Set,_descriptors:Ye(t,s),setContext:e=>$e(t,e,i,s),override:n=>$e(t.override(n),e,i,s)};return new Proxy(a,{deleteProperty:(e,i)=>(delete e[i],delete t[i],!0),get:(t,e,i)=>qe(t,e,(()=>function(t,e,i){const{_proxy:s,_context:a,_subProxy:r,_descriptors:l}=t;let h=s[e];S(h)&&l.isScriptable(e)&&(h=function(t,e,i,s){const{_proxy:n,_context:o,_subProxy:a,_stack:r}=i;if(r.has(t))throw new Error("Recursion detected: "+Array.from(r).join("->")+"->"+t);r.add(t);let l=e(o,a||s);r.delete(t),Xe(t,l)&&(l=Je(n._scopes,n,t,l));return l}(e,h,t,i));n(h)&&h.length&&(h=function(t,e,i,s){const{_proxy:n,_context:a,_subProxy:r,_descriptors:l}=i;if(void 0!==a.index&&s(t))return e[a.index%e.length];if(o(e[0])){const i=e,s=n._scopes.filter((t=>t!==i));e=[];for(const o of i){const i=Je(s,n,t,o);e.push($e(i,a,r&&r[t],l))}}return e}(e,h,t,l.isIndexable));Xe(e,h)&&(h=$e(h,a,r&&r[e],l));return h}(t,e,i))),getOwnPropertyDescriptor:(e,i)=>e._descriptors.allKeys?Reflect.has(t,i)?{enumerable:!0,configurable:!0}:void 0:Reflect.getOwnPropertyDescriptor(t,i),getPrototypeOf:()=>Reflect.getPrototypeOf(t),has:(e,i)=>Reflect.has(t,i),ownKeys:()=>Reflect.ownKeys(t),set:(e,i,s)=>(t[i]=s,delete e[i],!0)})}function Ye(t,e={scriptable:!0,indexable:!0}){const{_scriptable:i=e.scriptable,_indexable:s=e.indexable,_allKeys:n=e.allKeys}=t;return{allKeys:n,scriptable:i,indexable:s,isScriptable:S(i)?i:()=>i,isIndexable:S(s)?s:()=>s}}const Ue=(t,e)=>t?t+w(e):e,Xe=(t,e)=>o(e)&&"adapters"!==t&&(null===Object.getPrototypeOf(e)||e.constructor===Object);function qe(t,e,i){if(Object.prototype.hasOwnProperty.call(t,e))return t[e];const s=i();return t[e]=s,s}function Ke(t,e,i){return S(t)?t(e,i):t}const Ge=(t,e)=>!0===t?e:"string"==typeof t?M(e,t):void 0;function Ze(t,e,i,s,n){for(const o of e){const e=Ge(i,o);if(e){t.add(e);const o=Ke(e._fallback,i,n);if(void 0!==o&&o!==i&&o!==s)return o}else if(!1===e&&void 0!==s&&i!==s)return null}return!1}function Je(t,e,i,s){const a=e._rootScopes,r=Ke(e._fallback,i,s),l=[...t,...a],h=new Set;h.add(s);let c=Qe(h,l,i,r||i,s);return null!==c&&((void 0===r||r===i||(c=Qe(h,l,r,c,s),null!==c))&&je(Array.from(h),[""],a,r,(()=>function(t,e,i){const s=t._getTarget();e in s||(s[e]={});const a=s[e];if(n(a)&&o(i))return i;return a||{}}(e,i,s))))}function Qe(t,e,i,s,n){for(;i;)i=Ze(t,e,i,s,n);return i}function ti(t,e){for(const i of e){if(!i)continue;const e=i[t];if(void 0!==e)return e}}function ei(t){let e=t._keys;return e||(e=t._keys=function(t){const e=new Set;for(const i of t)for(const t of Object.keys(i).filter((t=>!t.startsWith("_"))))e.add(t);return Array.from(e)}(t._scopes)),e}function ii(t,e,i,s){const{iScale:n}=t,{key:o="r"}=this._parsing,a=new Array(s);let r,l,h,c;for(r=0,l=s;re"x"===t?"y":"x";function ai(t,e,i,s){const n=t.skip?e:t,o=e,a=i.skip?e:i,r=q(o,n),l=q(a,o);let h=r/(r+l),c=l/(r+l);h=isNaN(h)?0:h,c=isNaN(c)?0:c;const d=s*h,u=s*c;return{previous:{x:o.x-d*(a.x-n.x),y:o.y-d*(a.y-n.y)},next:{x:o.x+u*(a.x-n.x),y:o.y+u*(a.y-n.y)}}}function ri(t,e="x"){const i=oi(e),s=t.length,n=Array(s).fill(0),o=Array(s);let a,r,l,h=ni(t,0);for(a=0;a!t.skip))),"monotone"===e.cubicInterpolationMode)ri(t,n);else{let i=s?t[t.length-1]:t[0];for(o=0,a=t.length;o0===t||1===t,di=(t,e,i)=>-Math.pow(2,10*(t-=1))*Math.sin((t-e)*O/i),ui=(t,e,i)=>Math.pow(2,-10*t)*Math.sin((t-e)*O/i)+1,fi={linear:t=>t,easeInQuad:t=>t*t,easeOutQuad:t=>-t*(t-2),easeInOutQuad:t=>(t/=.5)<1?.5*t*t:-.5*(--t*(t-2)-1),easeInCubic:t=>t*t*t,easeOutCubic:t=>(t-=1)*t*t+1,easeInOutCubic:t=>(t/=.5)<1?.5*t*t*t:.5*((t-=2)*t*t+2),easeInQuart:t=>t*t*t*t,easeOutQuart:t=>-((t-=1)*t*t*t-1),easeInOutQuart:t=>(t/=.5)<1?.5*t*t*t*t:-.5*((t-=2)*t*t*t-2),easeInQuint:t=>t*t*t*t*t,easeOutQuint:t=>(t-=1)*t*t*t*t+1,easeInOutQuint:t=>(t/=.5)<1?.5*t*t*t*t*t:.5*((t-=2)*t*t*t*t+2),easeInSine:t=>1-Math.cos(t*E),easeOutSine:t=>Math.sin(t*E),easeInOutSine:t=>-.5*(Math.cos(C*t)-1),easeInExpo:t=>0===t?0:Math.pow(2,10*(t-1)),easeOutExpo:t=>1===t?1:1-Math.pow(2,-10*t),easeInOutExpo:t=>ci(t)?t:t<.5?.5*Math.pow(2,10*(2*t-1)):.5*(2-Math.pow(2,-10*(2*t-1))),easeInCirc:t=>t>=1?t:-(Math.sqrt(1-t*t)-1),easeOutCirc:t=>Math.sqrt(1-(t-=1)*t),easeInOutCirc:t=>(t/=.5)<1?-.5*(Math.sqrt(1-t*t)-1):.5*(Math.sqrt(1-(t-=2)*t)+1),easeInElastic:t=>ci(t)?t:di(t,.075,.3),easeOutElastic:t=>ci(t)?t:ui(t,.075,.3),easeInOutElastic(t){const e=.1125;return ci(t)?t:t<.5?.5*di(2*t,e,.45):.5+.5*ui(2*t-1,e,.45)},easeInBack(t){const e=1.70158;return t*t*((e+1)*t-e)},easeOutBack(t){const e=1.70158;return(t-=1)*t*((e+1)*t+e)+1},easeInOutBack(t){let e=1.70158;return(t/=.5)<1?t*t*((1+(e*=1.525))*t-e)*.5:.5*((t-=2)*t*((1+(e*=1.525))*t+e)+2)},easeInBounce:t=>1-fi.easeOutBounce(1-t),easeOutBounce(t){const e=7.5625,i=2.75;return t<1/i?e*t*t:t<2/i?e*(t-=1.5/i)*t+.75:t<2.5/i?e*(t-=2.25/i)*t+.9375:e*(t-=2.625/i)*t+.984375},easeInOutBounce:t=>t<.5?.5*fi.easeInBounce(2*t):.5*fi.easeOutBounce(2*t-1)+.5};function gi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:t.y+i*(e.y-t.y)}}function pi(t,e,i,s){return{x:t.x+i*(e.x-t.x),y:"middle"===s?i<.5?t.y:e.y:"after"===s?i<1?t.y:e.y:i>0?e.y:t.y}}function mi(t,e,i,s){const n={x:t.cp2x,y:t.cp2y},o={x:e.cp1x,y:e.cp1y},a=gi(t,n,i),r=gi(n,o,i),l=gi(o,e,i),h=gi(a,r,i),c=gi(r,l,i);return gi(h,c,i)}const bi=/^(normal|(\d+(?:\.\d+)?)(px|em|%)?)$/,xi=/^(normal|italic|initial|inherit|unset|(oblique( -?[0-9]?[0-9]deg)?))$/;function _i(t,e){const i=(""+t).match(bi);if(!i||"normal"===i[1])return 1.2*e;switch(t=+i[2],i[3]){case"px":return t;case"%":t/=100}return e*t}const yi=t=>+t||0;function vi(t,e){const i={},s=o(e),n=s?Object.keys(e):e,a=o(t)?s?i=>l(t[i],t[e[i]]):e=>t[e]:()=>t;for(const t of n)i[t]=yi(a(t));return i}function Mi(t){return vi(t,{top:"y",right:"x",bottom:"y",left:"x"})}function wi(t){return vi(t,["topLeft","topRight","bottomLeft","bottomRight"])}function ki(t){const e=Mi(t);return e.width=e.left+e.right,e.height=e.top+e.bottom,e}function Si(t,e){t=t||{},e=e||ue.font;let i=l(t.size,e.size);"string"==typeof i&&(i=parseInt(i,10));let s=l(t.style,e.style);s&&!(""+s).match(xi)&&(console.warn('Invalid font style specified: "'+s+'"'),s=void 0);const n={family:l(t.family,e.family),lineHeight:_i(l(t.lineHeight,e.lineHeight),i),size:i,style:s,weight:l(t.weight,e.weight),string:""};return n.string=De(n),n}function Pi(t,e,i,s){let o,a,r,l=!0;for(o=0,a=t.length;oi&&0===t?0:t+e;return{min:a(s,-Math.abs(o)),max:a(n,o)}}function Ci(t,e){return Object.assign(Object.create(t),e)}function Oi(t,e,i){return t?function(t,e){return{x:i=>t+t+e-i,setWidth(t){e=t},textAlign:t=>"center"===t?t:"right"===t?"left":"right",xPlus:(t,e)=>t-e,leftForLtr:(t,e)=>t-e}}(e,i):{x:t=>t,setWidth(t){},textAlign:t=>t,xPlus:(t,e)=>t+e,leftForLtr:(t,e)=>t}}function Ai(t,e){let i,s;"ltr"!==e&&"rtl"!==e||(i=t.canvas.style,s=[i.getPropertyValue("direction"),i.getPropertyPriority("direction")],i.setProperty("direction",e,"important"),t.prevTextDirection=s)}function Ti(t,e){void 0!==e&&(delete t.prevTextDirection,t.canvas.style.setProperty("direction",e[0],e[1]))}function Li(t){return"angle"===t?{between:Z,compare:K,normalize:G}:{between:tt,compare:(t,e)=>t-e,normalize:t=>t}}function Ei({start:t,end:e,count:i,loop:s,style:n}){return{start:t%i,end:e%i,loop:s&&(e-t+1)%i==0,style:n}}function Ri(t,e,i){if(!i)return[t];const{property:s,start:n,end:o}=i,a=e.length,{compare:r,between:l,normalize:h}=Li(s),{start:c,end:d,loop:u,style:f}=function(t,e,i){const{property:s,start:n,end:o}=i,{between:a,normalize:r}=Li(s),l=e.length;let h,c,{start:d,end:u,loop:f}=t;if(f){for(d+=l,u+=l,h=0,c=l;hx||l(n,b,p)&&0!==r(n,b),v=()=>!x||0===r(o,p)||l(o,b,p);for(let t=c,i=c;t<=d;++t)m=e[t%a],m.skip||(p=h(m[s]),p!==b&&(x=l(p,n,o),null===_&&y()&&(_=0===r(p,n)?t:i),null!==_&&v()&&(g.push(Ei({start:_,end:t,loop:u,count:a,style:f})),_=null),i=t,b=p));return null!==_&&g.push(Ei({start:_,end:d,loop:u,count:a,style:f})),g}function Ii(t,e){const i=[],s=t.segments;for(let n=0;nn&&t[o%e].skip;)o--;return o%=e,{start:n,end:o}}(i,n,o,s);if(!0===s)return Fi(t,[{start:a,end:r,loop:o}],i,e);return Fi(t,function(t,e,i,s){const n=t.length,o=[];let a,r=e,l=t[e];for(a=e+1;a<=i;++a){const i=t[a%n];i.skip||i.stop?l.skip||(s=!1,o.push({start:e%n,end:(a-1)%n,loop:s}),e=r=i.stop?a:null):(r=a,l.skip&&(e=a)),l=i}return null!==r&&o.push({start:e%n,end:r%n,loop:s}),o}(i,a,r{t[a](e[i],n)&&(o.push({element:t,datasetIndex:s,index:l}),r=r||t.inRange(e.x,e.y,n))})),s&&!r?[]:o}var Xi={evaluateInteractionItems:Hi,modes:{index(t,e,i,s){const n=ve(e,t),o=i.axis||"x",a=i.includeInvisible||!1,r=i.intersect?ji(t,n,o,s,a):Yi(t,n,o,!1,s,a),l=[];return r.length?(t.getSortedVisibleDatasetMetas().forEach((t=>{const e=r[0].index,i=t.data[e];i&&!i.skip&&l.push({element:i,datasetIndex:t.index,index:e})})),l):[]},dataset(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;let r=i.intersect?ji(t,n,o,s,a):Yi(t,n,o,!1,s,a);if(r.length>0){const e=r[0].datasetIndex,i=t.getDatasetMeta(e).data;r=[];for(let t=0;tji(t,ve(e,t),i.axis||"xy",s,i.includeInvisible||!1),nearest(t,e,i,s){const n=ve(e,t),o=i.axis||"xy",a=i.includeInvisible||!1;return Yi(t,n,o,i.intersect,s,a)},x:(t,e,i,s)=>Ui(t,ve(e,t),"x",i.intersect,s),y:(t,e,i,s)=>Ui(t,ve(e,t),"y",i.intersect,s)}};const qi=["left","top","right","bottom"];function Ki(t,e){return t.filter((t=>t.pos===e))}function Gi(t,e){return t.filter((t=>-1===qi.indexOf(t.pos)&&t.box.axis===e))}function Zi(t,e){return t.sort(((t,i)=>{const s=e?i:t,n=e?t:i;return s.weight===n.weight?s.index-n.index:s.weight-n.weight}))}function Ji(t,e){const i=function(t){const e={};for(const i of t){const{stack:t,pos:s,stackWeight:n}=i;if(!t||!qi.includes(s))continue;const o=e[t]||(e[t]={count:0,placed:0,weight:0,size:0});o.count++,o.weight+=n}return e}(t),{vBoxMaxWidth:s,hBoxMaxHeight:n}=e;let o,a,r;for(o=0,a=t.length;o{s[t]=Math.max(e[t],i[t])})),s}return s(t?["left","right"]:["top","bottom"])}function ss(t,e,i,s){const n=[];let o,a,r,l,h,c;for(o=0,a=t.length,h=0;ot.box.fullSize)),!0),s=Zi(Ki(e,"left"),!0),n=Zi(Ki(e,"right")),o=Zi(Ki(e,"top"),!0),a=Zi(Ki(e,"bottom")),r=Gi(e,"x"),l=Gi(e,"y");return{fullSize:i,leftAndTop:s.concat(o),rightAndBottom:n.concat(l).concat(a).concat(r),chartArea:Ki(e,"chartArea"),vertical:s.concat(n).concat(l),horizontal:o.concat(a).concat(r)}}(t.boxes),l=r.vertical,h=r.horizontal;u(t.boxes,(t=>{"function"==typeof t.beforeLayout&&t.beforeLayout()}));const c=l.reduce(((t,e)=>e.box.options&&!1===e.box.options.display?t:t+1),0)||1,d=Object.freeze({outerWidth:e,outerHeight:i,padding:n,availableWidth:o,availableHeight:a,vBoxMaxWidth:o/2/c,hBoxMaxHeight:a/2}),f=Object.assign({},n);ts(f,ki(s));const g=Object.assign({maxPadding:f,w:o,h:a,x:n.left,y:n.top},n),p=Ji(l.concat(h),d);ss(r.fullSize,g,d,p),ss(l,g,d,p),ss(h,g,d,p)&&ss(l,g,d,p),function(t){const e=t.maxPadding;function i(i){const s=Math.max(e[i]-t[i],0);return t[i]+=s,s}t.y+=i("top"),t.x+=i("left"),i("right"),i("bottom")}(g),os(r.leftAndTop,g,d,p),g.x+=g.w,g.y+=g.h,os(r.rightAndBottom,g,d,p),t.chartArea={left:g.left,top:g.top,right:g.left+g.w,bottom:g.top+g.h,height:g.h,width:g.w},u(r.chartArea,(e=>{const i=e.box;Object.assign(i,t.chartArea),i.update(g.w,g.h,{left:0,top:0,right:0,bottom:0})}))}};class rs{acquireContext(t,e){}releaseContext(t){return!1}addEventListener(t,e,i){}removeEventListener(t,e,i){}getDevicePixelRatio(){return 1}getMaximumSize(t,e,i,s){return e=Math.max(0,e||t.width),i=i||t.height,{width:e,height:Math.max(0,s?Math.floor(e/s):i)}}isAttached(t){return!0}updateConfig(t){}}class ls extends rs{acquireContext(t){return t&&t.getContext&&t.getContext("2d")||null}updateConfig(t){t.options.animation=!1}}const hs="$chartjs",cs={touchstart:"mousedown",touchmove:"mousemove",touchend:"mouseup",pointerenter:"mouseenter",pointerdown:"mousedown",pointermove:"mousemove",pointerup:"mouseup",pointerleave:"mouseout",pointerout:"mouseout"},ds=t=>null===t||""===t;const us=!!Se&&{passive:!0};function fs(t,e,i){t.canvas.removeEventListener(e,i,us)}function gs(t,e){for(const i of t)if(i===e||i.contains(e))return!0}function ps(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||gs(i.addedNodes,s),e=e&&!gs(i.removedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}function ms(t,e,i){const s=t.canvas,n=new MutationObserver((t=>{let e=!1;for(const i of t)e=e||gs(i.removedNodes,s),e=e&&!gs(i.addedNodes,s);e&&i()}));return n.observe(document,{childList:!0,subtree:!0}),n}const bs=new Map;let xs=0;function _s(){const t=window.devicePixelRatio;t!==xs&&(xs=t,bs.forEach(((e,i)=>{i.currentDevicePixelRatio!==t&&e()})))}function ys(t,e,i){const s=t.canvas,n=s&&ge(s);if(!n)return;const o=ct(((t,e)=>{const s=n.clientWidth;i(t,e),s{const e=t[0],i=e.contentRect.width,s=e.contentRect.height;0===i&&0===s||o(i,s)}));return a.observe(n),function(t,e){bs.size||window.addEventListener("resize",_s),bs.set(t,e)}(t,o),a}function vs(t,e,i){i&&i.disconnect(),"resize"===e&&function(t){bs.delete(t),bs.size||window.removeEventListener("resize",_s)}(t)}function Ms(t,e,i){const s=t.canvas,n=ct((e=>{null!==t.ctx&&i(function(t,e){const i=cs[t.type]||t.type,{x:s,y:n}=ve(t,e);return{type:i,chart:e,native:t,x:void 0!==s?s:null,y:void 0!==n?n:null}}(e,t))}),t);return function(t,e,i){t.addEventListener(e,i,us)}(s,e,n),n}class ws extends rs{acquireContext(t,e){const i=t&&t.getContext&&t.getContext("2d");return i&&i.canvas===t?(function(t,e){const i=t.style,s=t.getAttribute("height"),n=t.getAttribute("width");if(t[hs]={initial:{height:s,width:n,style:{display:i.display,height:i.height,width:i.width}}},i.display=i.display||"block",i.boxSizing=i.boxSizing||"border-box",ds(n)){const e=Pe(t,"width");void 0!==e&&(t.width=e)}if(ds(s))if(""===t.style.height)t.height=t.width/(e||2);else{const e=Pe(t,"height");void 0!==e&&(t.height=e)}}(t,e),i):null}releaseContext(t){const e=t.canvas;if(!e[hs])return!1;const i=e[hs].initial;["height","width"].forEach((t=>{const n=i[t];s(n)?e.removeAttribute(t):e.setAttribute(t,n)}));const n=i.style||{};return Object.keys(n).forEach((t=>{e.style[t]=n[t]})),e.width=e.width,delete e[hs],!0}addEventListener(t,e,i){this.removeEventListener(t,e);const s=t.$proxies||(t.$proxies={}),n={attach:ps,detach:ms,resize:ys}[e]||Ms;s[e]=n(t,e,i)}removeEventListener(t,e){const i=t.$proxies||(t.$proxies={}),s=i[e];if(!s)return;({attach:vs,detach:vs,resize:vs}[e]||fs)(t,e,s),i[e]=void 0}getDevicePixelRatio(){return window.devicePixelRatio}getMaximumSize(t,e,i,s){return we(t,e,i,s)}isAttached(t){const e=ge(t);return!(!e||!e.isConnected)}}function ks(t){return!fe()||"undefined"!=typeof OffscreenCanvas&&t instanceof OffscreenCanvas?ls:ws}var Ss=Object.freeze({__proto__:null,BasePlatform:rs,BasicPlatform:ls,DomPlatform:ws,_detectPlatform:ks});const Ps="transparent",Ds={boolean:(t,e,i)=>i>.5?e:t,color(t,e,i){const s=Qt(t||Ps),n=s.valid&&Qt(e||Ps);return n&&n.valid?n.mix(s,i).hexString():e},number:(t,e,i)=>t+(e-t)*i};class Cs{constructor(t,e,i,s){const n=e[i];s=Pi([t.to,s,n,t.from]);const o=Pi([t.from,n,s]);this._active=!0,this._fn=t.fn||Ds[t.type||typeof o],this._easing=fi[t.easing]||fi.linear,this._start=Math.floor(Date.now()+(t.delay||0)),this._duration=this._total=Math.floor(t.duration),this._loop=!!t.loop,this._target=e,this._prop=i,this._from=o,this._to=s,this._promises=void 0}active(){return this._active}update(t,e,i){if(this._active){this._notify(!1);const s=this._target[this._prop],n=i-this._start,o=this._duration-n;this._start=i,this._duration=Math.floor(Math.max(o,t.duration)),this._total+=n,this._loop=!!t.loop,this._to=Pi([t.to,e,s,t.from]),this._from=Pi([t.from,s,e])}}cancel(){this._active&&(this.tick(Date.now()),this._active=!1,this._notify(!1))}tick(t){const e=t-this._start,i=this._duration,s=this._prop,n=this._from,o=this._loop,a=this._to;let r;if(this._active=n!==a&&(o||e1?2-r:r,r=this._easing(Math.min(1,Math.max(0,r))),this._target[s]=this._fn(n,a,r))}wait(){const t=this._promises||(this._promises=[]);return new Promise(((e,i)=>{t.push({res:e,rej:i})}))}_notify(t){const e=t?"res":"rej",i=this._promises||[];for(let t=0;t{const a=t[s];if(!o(a))return;const r={};for(const t of e)r[t]=a[t];(n(a.properties)&&a.properties||[s]).forEach((t=>{t!==s&&i.has(t)||i.set(t,r)}))}))}_animateOptions(t,e){const i=e.options,s=function(t,e){if(!e)return;let i=t.options;if(!i)return void(t.options=e);i.$shared&&(t.options=i=Object.assign({},i,{$shared:!1,$animations:{}}));return i}(t,i);if(!s)return[];const n=this._createAnimations(s,i);return i.$shared&&function(t,e){const i=[],s=Object.keys(e);for(let e=0;e{t.options=i}),(()=>{})),n}_createAnimations(t,e){const i=this._properties,s=[],n=t.$animations||(t.$animations={}),o=Object.keys(e),a=Date.now();let r;for(r=o.length-1;r>=0;--r){const l=o[r];if("$"===l.charAt(0))continue;if("options"===l){s.push(...this._animateOptions(t,e));continue}const h=e[l];let c=n[l];const d=i.get(l);if(c){if(d&&c.active()){c.update(d,h,a);continue}c.cancel()}d&&d.duration?(n[l]=c=new Cs(d,t,l,h),s.push(c)):t[l]=h}return s}update(t,e){if(0===this._properties.size)return void Object.assign(t,e);const i=this._createAnimations(t,e);return i.length?(xt.add(this._chart,i),!0):void 0}}function As(t,e){const i=t&&t.options||{},s=i.reverse,n=void 0===i.min?e:0,o=void 0===i.max?e:0;return{start:s?o:n,end:s?n:o}}function Ts(t,e){const i=[],s=t._getSortedDatasetMetas(e);let n,o;for(n=0,o=s.length;n0||!i&&e<0)return n.index}return null}function zs(t,e){const{chart:i,_cachedMeta:s}=t,n=i._stacks||(i._stacks={}),{iScale:o,vScale:a,index:r}=s,l=o.axis,h=a.axis,c=function(t,e,i){return`${t.id}.${e.id}.${i.stack||i.type}`}(o,a,s),d=e.length;let u;for(let t=0;ti[t].axis===e)).shift()}function Vs(t,e){const i=t.controller.index,s=t.vScale&&t.vScale.axis;if(s){e=e||t._parsed;for(const t of e){const e=t._stacks;if(!e||void 0===e[s]||void 0===e[s][i])return;delete e[s][i],void 0!==e[s]._visualValues&&void 0!==e[s]._visualValues[i]&&delete e[s]._visualValues[i]}}}const Bs=t=>"reset"===t||"none"===t,Ws=(t,e)=>e?t:Object.assign({},t);class Ns{static defaults={};static datasetElementType=null;static dataElementType=null;constructor(t,e){this.chart=t,this._ctx=t.ctx,this.index=e,this._cachedDataOpts={},this._cachedMeta=this.getMeta(),this._type=this._cachedMeta.type,this.options=void 0,this._parsing=!1,this._data=void 0,this._objectData=void 0,this._sharedOptions=void 0,this._drawStart=void 0,this._drawCount=void 0,this.enableOptionSharing=!1,this.supportsDecimation=!1,this.$context=void 0,this._syncList=[],this.datasetElementType=new.target.datasetElementType,this.dataElementType=new.target.dataElementType,this.initialize()}initialize(){const t=this._cachedMeta;this.configure(),this.linkScales(),t._stacked=Es(t.vScale,t),this.addElements(),this.options.fill&&!this.chart.isPluginEnabled("filler")&&console.warn("Tried to use the 'fill' option without the 'Filler' plugin enabled. Please import and register the 'Filler' plugin and make sure it is not disabled in the options")}updateIndex(t){this.index!==t&&Vs(this._cachedMeta),this.index=t}linkScales(){const t=this.chart,e=this._cachedMeta,i=this.getDataset(),s=(t,e,i,s)=>"x"===t?e:"r"===t?s:i,n=e.xAxisID=l(i.xAxisID,Fs(t,"x")),o=e.yAxisID=l(i.yAxisID,Fs(t,"y")),a=e.rAxisID=l(i.rAxisID,Fs(t,"r")),r=e.indexAxis,h=e.iAxisID=s(r,n,o,a),c=e.vAxisID=s(r,o,n,a);e.xScale=this.getScaleForId(n),e.yScale=this.getScaleForId(o),e.rScale=this.getScaleForId(a),e.iScale=this.getScaleForId(h),e.vScale=this.getScaleForId(c)}getDataset(){return this.chart.data.datasets[this.index]}getMeta(){return this.chart.getDatasetMeta(this.index)}getScaleForId(t){return this.chart.scales[t]}_getOtherScale(t){const e=this._cachedMeta;return t===e.iScale?e.vScale:e.iScale}reset(){this._update("reset")}_destroy(){const t=this._cachedMeta;this._data&&rt(this._data,this),t._stacked&&Vs(t)}_dataCheck(){const t=this.getDataset(),e=t.data||(t.data=[]),i=this._data;if(o(e))this._data=function(t){const e=Object.keys(t),i=new Array(e.length);let s,n,o;for(s=0,n=e.length;s0&&i._parsed[t-1];if(!1===this._parsing)i._parsed=s,i._sorted=!0,d=s;else{d=n(s[t])?this.parseArrayData(i,s,t,e):o(s[t])?this.parseObjectData(i,s,t,e):this.parsePrimitiveData(i,s,t,e);const a=()=>null===c[l]||f&&c[l]t&&!e.hidden&&e._stacked&&{keys:Ts(i,!0),values:null})(e,i,this.chart),h={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY},{min:c,max:d}=function(t){const{min:e,max:i,minDefined:s,maxDefined:n}=t.getUserBounds();return{min:s?e:Number.NEGATIVE_INFINITY,max:n?i:Number.POSITIVE_INFINITY}}(r);let u,f;function g(){f=s[u];const e=f[r.axis];return!a(f[t.axis])||c>e||d=0;--u)if(!g()){this.updateRangeFromParsed(h,t,f,l);break}return h}getAllParsedValues(t){const e=this._cachedMeta._parsed,i=[];let s,n,o;for(s=0,n=e.length;s=0&&tthis.getContext(i,s,e)),c);return f.$shared&&(f.$shared=r,n[o]=Object.freeze(Ws(f,r))),f}_resolveAnimations(t,e,i){const s=this.chart,n=this._cachedDataOpts,o=`animation-${e}`,a=n[o];if(a)return a;let r;if(!1!==s.options.animation){const s=this.chart.config,n=s.datasetAnimationScopeKeys(this._type,e),o=s.getOptionScopes(this.getDataset(),n);r=s.createResolver(o,this.getContext(t,i,e))}const l=new Os(s,r&&r.animations);return r&&r._cacheable&&(n[o]=Object.freeze(l)),l}getSharedOptions(t){if(t.$shared)return this._sharedOptions||(this._sharedOptions=Object.assign({},t))}includeOptions(t,e){return!e||Bs(t)||this.chart._animationsDisabled}_getSharedOptions(t,e){const i=this.resolveDataElementOptions(t,e),s=this._sharedOptions,n=this.getSharedOptions(i),o=this.includeOptions(e,n)||n!==s;return this.updateSharedOptions(n,e,i),{sharedOptions:n,includeOptions:o}}updateElement(t,e,i,s){Bs(s)?Object.assign(t,i):this._resolveAnimations(e,s).update(t,i)}updateSharedOptions(t,e,i){t&&!Bs(e)&&this._resolveAnimations(void 0,e).update(t,i)}_setStyle(t,e,i,s){t.active=s;const n=this.getStyle(e,s);this._resolveAnimations(e,i,s).update(t,{options:!s&&this.getSharedOptions(n)||n})}removeHoverStyle(t,e,i){this._setStyle(t,i,"active",!1)}setHoverStyle(t,e,i){this._setStyle(t,i,"active",!0)}_removeDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!1)}_setDatasetHoverStyle(){const t=this._cachedMeta.dataset;t&&this._setStyle(t,void 0,"active",!0)}_resyncElements(t){const e=this._data,i=this._cachedMeta.data;for(const[t,e,i]of this._syncList)this[t](e,i);this._syncList=[];const s=i.length,n=e.length,o=Math.min(n,s);o&&this.parse(0,o),n>s?this._insertElements(s,n-s,t):n{for(t.length+=e,a=t.length-1;a>=o;a--)t[a]=t[a-e]};for(r(n),a=t;a{s[t]=i[t]&&i[t].active()?i[t]._to:this[t]})),s}}function js(t,e){const i=t.options.ticks,n=function(t){const e=t.options.offset,i=t._tickSize(),s=t._length/i+(e?0:1),n=t._maxLength/i;return Math.floor(Math.min(s,n))}(t),o=Math.min(i.maxTicksLimit||n,n),a=i.major.enabled?function(t){const e=[];let i,s;for(i=0,s=t.length;io)return function(t,e,i,s){let n,o=0,a=i[0];for(s=Math.ceil(s),n=0;nn)return e}return Math.max(n,1)}(a,e,o);if(r>0){let t,i;const n=r>1?Math.round((h-l)/(r-1)):null;for($s(e,c,d,s(n)?0:l-n,l),t=0,i=r-1;t"top"===e||"left"===e?t[e]+i:t[e]-i,Us=(t,e)=>Math.min(e||t,t);function Xs(t,e){const i=[],s=t.length/e,n=t.length;let o=0;for(;oa+r)))return h}function Ks(t){return t.drawTicks?t.tickLength:0}function Gs(t,e){if(!t.display)return 0;const i=Si(t.font,e),s=ki(t.padding);return(n(t.text)?t.text.length:1)*i.lineHeight+s.height}function Zs(t,e,i){let s=ut(t);return(i&&"right"!==e||!i&&"right"===e)&&(s=(t=>"left"===t?"right":"right"===t?"left":t)(s)),s}class Js extends Hs{constructor(t){super(),this.id=t.id,this.type=t.type,this.options=void 0,this.ctx=t.ctx,this.chart=t.chart,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this._margins={left:0,right:0,top:0,bottom:0},this.maxWidth=void 0,this.maxHeight=void 0,this.paddingTop=void 0,this.paddingBottom=void 0,this.paddingLeft=void 0,this.paddingRight=void 0,this.axis=void 0,this.labelRotation=void 0,this.min=void 0,this.max=void 0,this._range=void 0,this.ticks=[],this._gridLineItems=null,this._labelItems=null,this._labelSizes=null,this._length=0,this._maxLength=0,this._longestTextCache={},this._startPixel=void 0,this._endPixel=void 0,this._reversePixels=!1,this._userMax=void 0,this._userMin=void 0,this._suggestedMax=void 0,this._suggestedMin=void 0,this._ticksLength=0,this._borderValue=0,this._cache={},this._dataLimitsCached=!1,this.$context=void 0}init(t){this.options=t.setContext(this.getContext()),this.axis=t.axis,this._userMin=this.parse(t.min),this._userMax=this.parse(t.max),this._suggestedMin=this.parse(t.suggestedMin),this._suggestedMax=this.parse(t.suggestedMax)}parse(t,e){return t}getUserBounds(){let{_userMin:t,_userMax:e,_suggestedMin:i,_suggestedMax:s}=this;return t=r(t,Number.POSITIVE_INFINITY),e=r(e,Number.NEGATIVE_INFINITY),i=r(i,Number.POSITIVE_INFINITY),s=r(s,Number.NEGATIVE_INFINITY),{min:r(t,i),max:r(e,s),minDefined:a(t),maxDefined:a(e)}}getMinMax(t){let e,{min:i,max:s,minDefined:n,maxDefined:o}=this.getUserBounds();if(n&&o)return{min:i,max:s};const a=this.getMatchingVisibleMetas();for(let r=0,l=a.length;rs?s:i,s=n&&i>s?i:s,{min:r(i,r(s,i)),max:r(s,r(i,s))}}getPadding(){return{left:this.paddingLeft||0,top:this.paddingTop||0,right:this.paddingRight||0,bottom:this.paddingBottom||0}}getTicks(){return this.ticks}getLabels(){const t=this.chart.data;return this.options.labels||(this.isHorizontal()?t.xLabels:t.yLabels)||t.labels||[]}getLabelItems(t=this.chart.chartArea){return this._labelItems||(this._labelItems=this._computeLabelItems(t))}beforeLayout(){this._cache={},this._dataLimitsCached=!1}beforeUpdate(){d(this.options.beforeUpdate,[this])}update(t,e,i){const{beginAtZero:s,grace:n,ticks:o}=this.options,a=o.sampleSize;this.beforeUpdate(),this.maxWidth=t,this.maxHeight=e,this._margins=i=Object.assign({left:0,right:0,top:0,bottom:0},i),this.ticks=null,this._labelSizes=null,this._gridLineItems=null,this._labelItems=null,this.beforeSetDimensions(),this.setDimensions(),this.afterSetDimensions(),this._maxLength=this.isHorizontal()?this.width+i.left+i.right:this.height+i.top+i.bottom,this._dataLimitsCached||(this.beforeDataLimits(),this.determineDataLimits(),this.afterDataLimits(),this._range=Di(this,n,s),this._dataLimitsCached=!0),this.beforeBuildTicks(),this.ticks=this.buildTicks()||[],this.afterBuildTicks();const r=a=n||i<=1||!this.isHorizontal())return void(this.labelRotation=s);const h=this._getLabelSizes(),c=h.widest.width,d=h.highest.height,u=J(this.chart.width-c,0,this.maxWidth);o=t.offset?this.maxWidth/i:u/(i-1),c+6>o&&(o=u/(i-(t.offset?.5:1)),a=this.maxHeight-Ks(t.grid)-e.padding-Gs(t.title,this.chart.options.font),r=Math.sqrt(c*c+d*d),l=Y(Math.min(Math.asin(J((h.highest.height+6)/o,-1,1)),Math.asin(J(a/r,-1,1))-Math.asin(J(d/r,-1,1)))),l=Math.max(s,Math.min(n,l))),this.labelRotation=l}afterCalculateLabelRotation(){d(this.options.afterCalculateLabelRotation,[this])}afterAutoSkip(){}beforeFit(){d(this.options.beforeFit,[this])}fit(){const t={width:0,height:0},{chart:e,options:{ticks:i,title:s,grid:n}}=this,o=this._isVisible(),a=this.isHorizontal();if(o){const o=Gs(s,e.options.font);if(a?(t.width=this.maxWidth,t.height=Ks(n)+o):(t.height=this.maxHeight,t.width=Ks(n)+o),i.display&&this.ticks.length){const{first:e,last:s,widest:n,highest:o}=this._getLabelSizes(),r=2*i.padding,l=$(this.labelRotation),h=Math.cos(l),c=Math.sin(l);if(a){const e=i.mirror?0:c*n.width+h*o.height;t.height=Math.min(this.maxHeight,t.height+e+r)}else{const e=i.mirror?0:h*n.width+c*o.height;t.width=Math.min(this.maxWidth,t.width+e+r)}this._calculatePadding(e,s,c,h)}}this._handleMargins(),a?(this.width=this._length=e.width-this._margins.left-this._margins.right,this.height=t.height):(this.width=t.width,this.height=this._length=e.height-this._margins.top-this._margins.bottom)}_calculatePadding(t,e,i,s){const{ticks:{align:n,padding:o},position:a}=this.options,r=0!==this.labelRotation,l="top"!==a&&"x"===this.axis;if(this.isHorizontal()){const a=this.getPixelForTick(0)-this.left,h=this.right-this.getPixelForTick(this.ticks.length-1);let c=0,d=0;r?l?(c=s*t.width,d=i*e.height):(c=i*t.height,d=s*e.width):"start"===n?d=e.width:"end"===n?c=t.width:"inner"!==n&&(c=t.width/2,d=e.width/2),this.paddingLeft=Math.max((c-a+o)*this.width/(this.width-a),0),this.paddingRight=Math.max((d-h+o)*this.width/(this.width-h),0)}else{let i=e.height/2,s=t.height/2;"start"===n?(i=0,s=t.height):"end"===n&&(i=e.height,s=0),this.paddingTop=i+o,this.paddingBottom=s+o}}_handleMargins(){this._margins&&(this._margins.left=Math.max(this.paddingLeft,this._margins.left),this._margins.top=Math.max(this.paddingTop,this._margins.top),this._margins.right=Math.max(this.paddingRight,this._margins.right),this._margins.bottom=Math.max(this.paddingBottom,this._margins.bottom))}afterFit(){d(this.options.afterFit,[this])}isHorizontal(){const{axis:t,position:e}=this.options;return"top"===e||"bottom"===e||"x"===t}isFullSize(){return this.options.fullSize}_convertTicksToLabels(t){let e,i;for(this.beforeTickToLabelConversion(),this.generateTickLabels(t),e=0,i=t.length;e{const i=t.gc,s=i.length/2;let n;if(s>e){for(n=0;n({width:r[t]||0,height:l[t]||0});return{first:P(0),last:P(e-1),widest:P(k),highest:P(S),widths:r,heights:l}}getLabelForValue(t){return t}getPixelForValue(t,e){return NaN}getValueForPixel(t){}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getPixelForDecimal(t){this._reversePixels&&(t=1-t);const e=this._startPixel+t*this._length;return Q(this._alignToPixels?Ae(this.chart,e,0):e)}getDecimalForPixel(t){const e=(t-this._startPixel)/this._length;return this._reversePixels?1-e:e}getBasePixel(){return this.getPixelForValue(this.getBaseValue())}getBaseValue(){const{min:t,max:e}=this;return t<0&&e<0?e:t>0&&e>0?t:0}getContext(t){const e=this.ticks||[];if(t>=0&&ta*s?a/i:r/s:r*s0}_computeGridLineItems(t){const e=this.axis,i=this.chart,s=this.options,{grid:n,position:a,border:r}=s,h=n.offset,c=this.isHorizontal(),d=this.ticks.length+(h?1:0),u=Ks(n),f=[],g=r.setContext(this.getContext()),p=g.display?g.width:0,m=p/2,b=function(t){return Ae(i,t,p)};let x,_,y,v,M,w,k,S,P,D,C,O;if("top"===a)x=b(this.bottom),w=this.bottom-u,S=x-m,D=b(t.top)+m,O=t.bottom;else if("bottom"===a)x=b(this.top),D=t.top,O=b(t.bottom)-m,w=x+m,S=this.top+u;else if("left"===a)x=b(this.right),M=this.right-u,k=x-m,P=b(t.left)+m,C=t.right;else if("right"===a)x=b(this.left),P=t.left,C=b(t.right)-m,M=x+m,k=this.left+u;else if("x"===e){if("center"===a)x=b((t.top+t.bottom)/2+.5);else if(o(a)){const t=Object.keys(a)[0],e=a[t];x=b(this.chart.scales[t].getPixelForValue(e))}D=t.top,O=t.bottom,w=x+m,S=w+u}else if("y"===e){if("center"===a)x=b((t.left+t.right)/2);else if(o(a)){const t=Object.keys(a)[0],e=a[t];x=b(this.chart.scales[t].getPixelForValue(e))}M=x-m,k=M-u,P=t.left,C=t.right}const A=l(s.ticks.maxTicksLimit,d),T=Math.max(1,Math.ceil(d/A));for(_=0;_0&&(o-=s/2)}d={left:o,top:n,width:s+e.width,height:i+e.height,color:t.backdropColor}}b.push({label:v,font:P,textOffset:O,options:{rotation:m,color:i,strokeColor:o,strokeWidth:h,textAlign:f,textBaseline:A,translation:[M,w],backdrop:d}})}return b}_getXAxisLabelAlignment(){const{position:t,ticks:e}=this.options;if(-$(this.labelRotation))return"top"===t?"left":"right";let i="center";return"start"===e.align?i="left":"end"===e.align?i="right":"inner"===e.align&&(i="inner"),i}_getYAxisLabelAlignment(t){const{position:e,ticks:{crossAlign:i,mirror:s,padding:n}}=this.options,o=t+n,a=this._getLabelSizes().widest.width;let r,l;return"left"===e?s?(l=this.right+n,"near"===i?r="left":"center"===i?(r="center",l+=a/2):(r="right",l+=a)):(l=this.right-o,"near"===i?r="right":"center"===i?(r="center",l-=a/2):(r="left",l=this.left)):"right"===e?s?(l=this.left+n,"near"===i?r="right":"center"===i?(r="center",l-=a/2):(r="left",l-=a)):(l=this.left+o,"near"===i?r="left":"center"===i?(r="center",l+=a/2):(r="right",l=this.right)):r="right",{textAlign:r,x:l}}_computeLabelArea(){if(this.options.ticks.mirror)return;const t=this.chart,e=this.options.position;return"left"===e||"right"===e?{top:0,left:this.left,bottom:t.height,right:this.right}:"top"===e||"bottom"===e?{top:this.top,left:0,bottom:this.bottom,right:t.width}:void 0}drawBackground(){const{ctx:t,options:{backgroundColor:e},left:i,top:s,width:n,height:o}=this;e&&(t.save(),t.fillStyle=e,t.fillRect(i,s,n,o),t.restore())}getLineWidthForValue(t){const e=this.options.grid;if(!this._isVisible()||!e.display)return 0;const i=this.ticks.findIndex((e=>e.value===t));if(i>=0){return e.setContext(this.getContext(i)).lineWidth}return 0}drawGrid(t){const e=this.options.grid,i=this.ctx,s=this._gridLineItems||(this._gridLineItems=this._computeGridLineItems(t));let n,o;const a=(t,e,s)=>{s.width&&s.color&&(i.save(),i.lineWidth=s.width,i.strokeStyle=s.color,i.setLineDash(s.borderDash||[]),i.lineDashOffset=s.borderDashOffset,i.beginPath(),i.moveTo(t.x,t.y),i.lineTo(e.x,e.y),i.stroke(),i.restore())};if(e.display)for(n=0,o=s.length;n{this.drawBackground(),this.drawGrid(t),this.drawTitle()}},{z:s,draw:()=>{this.drawBorder()}},{z:e,draw:t=>{this.drawLabels(t)}}]:[{z:e,draw:t=>{this.draw(t)}}]}getMatchingVisibleMetas(t){const e=this.chart.getSortedVisibleDatasetMetas(),i=this.axis+"AxisID",s=[];let n,o;for(n=0,o=e.length;n{const s=i.split("."),n=s.pop(),o=[t].concat(s).join("."),a=e[i].split("."),r=a.pop(),l=a.join(".");ue.route(o,n,l,r)}))}(e,t.defaultRoutes);t.descriptors&&ue.describe(e,t.descriptors)}(t,o,i),this.override&&ue.override(t.id,t.overrides)),o}get(t){return this.items[t]}unregister(t){const e=this.items,i=t.id,s=this.scope;i in e&&delete e[i],s&&i in ue[s]&&(delete ue[s][i],this.override&&delete re[i])}}class tn{constructor(){this.controllers=new Qs(Ns,"datasets",!0),this.elements=new Qs(Hs,"elements"),this.plugins=new Qs(Object,"plugins"),this.scales=new Qs(Js,"scales"),this._typedRegistries=[this.controllers,this.scales,this.elements]}add(...t){this._each("register",t)}remove(...t){this._each("unregister",t)}addControllers(...t){this._each("register",t,this.controllers)}addElements(...t){this._each("register",t,this.elements)}addPlugins(...t){this._each("register",t,this.plugins)}addScales(...t){this._each("register",t,this.scales)}getController(t){return this._get(t,this.controllers,"controller")}getElement(t){return this._get(t,this.elements,"element")}getPlugin(t){return this._get(t,this.plugins,"plugin")}getScale(t){return this._get(t,this.scales,"scale")}removeControllers(...t){this._each("unregister",t,this.controllers)}removeElements(...t){this._each("unregister",t,this.elements)}removePlugins(...t){this._each("unregister",t,this.plugins)}removeScales(...t){this._each("unregister",t,this.scales)}_each(t,e,i){[...e].forEach((e=>{const s=i||this._getRegistryForType(e);i||s.isForType(e)||s===this.plugins&&e.id?this._exec(t,s,e):u(e,(e=>{const s=i||this._getRegistryForType(e);this._exec(t,s,e)}))}))}_exec(t,e,i){const s=w(t);d(i["before"+s],[],i),e[t](i),d(i["after"+s],[],i)}_getRegistryForType(t){for(let e=0;et.filter((t=>!e.some((e=>t.plugin.id===e.plugin.id))));this._notify(s(e,i),t,"stop"),this._notify(s(i,e),t,"start")}}function nn(t,e){return e||!1!==t?!0===t?{}:t:null}function on(t,{plugin:e,local:i},s,n){const o=t.pluginScopeKeys(e),a=t.getOptionScopes(s,o);return i&&e.defaults&&a.push(e.defaults),t.createResolver(a,n,[""],{scriptable:!1,indexable:!1,allKeys:!0})}function an(t,e){const i=ue.datasets[t]||{};return((e.datasets||{})[t]||{}).indexAxis||e.indexAxis||i.indexAxis||"x"}function rn(t){if("x"===t||"y"===t||"r"===t)return t}function ln(t,...e){if(rn(t))return t;for(const s of e){const e=s.axis||("top"===(i=s.position)||"bottom"===i?"x":"left"===i||"right"===i?"y":void 0)||t.length>1&&rn(t[0].toLowerCase());if(e)return e}var i;throw new Error(`Cannot determine type of '${t}' axis. Please provide 'axis' or 'position' option.`)}function hn(t,e,i){if(i[e+"AxisID"]===t)return{axis:e}}function cn(t,e){const i=re[t.type]||{scales:{}},s=e.scales||{},n=an(t.type,e),a=Object.create(null);return Object.keys(s).forEach((e=>{const r=s[e];if(!o(r))return console.error(`Invalid scale configuration for scale: ${e}`);if(r._proxy)return console.warn(`Ignoring resolver passed as options for scale: ${e}`);const l=ln(e,r,function(t,e){if(e.data&&e.data.datasets){const i=e.data.datasets.filter((e=>e.xAxisID===t||e.yAxisID===t));if(i.length)return hn(t,"x",i[0])||hn(t,"y",i[0])}return{}}(e,t),ue.scales[r.type]),h=function(t,e){return t===e?"_index_":"_value_"}(l,n),c=i.scales||{};a[e]=x(Object.create(null),[{axis:l},r,c[l],c[h]])})),t.data.datasets.forEach((i=>{const n=i.type||t.type,o=i.indexAxis||an(n,e),r=(re[n]||{}).scales||{};Object.keys(r).forEach((t=>{const e=function(t,e){let i=t;return"_index_"===t?i=e:"_value_"===t&&(i="x"===e?"y":"x"),i}(t,o),n=i[e+"AxisID"]||e;a[n]=a[n]||Object.create(null),x(a[n],[{axis:e},s[n],r[t]])}))})),Object.keys(a).forEach((t=>{const e=a[t];x(e,[ue.scales[e.type],ue.scale])})),a}function dn(t){const e=t.options||(t.options={});e.plugins=l(e.plugins,{}),e.scales=cn(t,e)}function un(t){return(t=t||{}).datasets=t.datasets||[],t.labels=t.labels||[],t}const fn=new Map,gn=new Set;function pn(t,e){let i=fn.get(t);return i||(i=e(),fn.set(t,i),gn.add(i)),i}const mn=(t,e,i)=>{const s=M(e,i);void 0!==s&&t.add(s)};class bn{constructor(t){this._config=function(t){return(t=t||{}).data=un(t.data),dn(t),t}(t),this._scopeCache=new Map,this._resolverCache=new Map}get platform(){return this._config.platform}get type(){return this._config.type}set type(t){this._config.type=t}get data(){return this._config.data}set data(t){this._config.data=un(t)}get options(){return this._config.options}set options(t){this._config.options=t}get plugins(){return this._config.plugins}update(){const t=this._config;this.clearCache(),dn(t)}clearCache(){this._scopeCache.clear(),this._resolverCache.clear()}datasetScopeKeys(t){return pn(t,(()=>[[`datasets.${t}`,""]]))}datasetAnimationScopeKeys(t,e){return pn(`${t}.transition.${e}`,(()=>[[`datasets.${t}.transitions.${e}`,`transitions.${e}`],[`datasets.${t}`,""]]))}datasetElementScopeKeys(t,e){return pn(`${t}-${e}`,(()=>[[`datasets.${t}.elements.${e}`,`datasets.${t}`,`elements.${e}`,""]]))}pluginScopeKeys(t){const e=t.id;return pn(`${this.type}-plugin-${e}`,(()=>[[`plugins.${e}`,...t.additionalOptionScopes||[]]]))}_cachedScopes(t,e){const i=this._scopeCache;let s=i.get(t);return s&&!e||(s=new Map,i.set(t,s)),s}getOptionScopes(t,e,i){const{options:s,type:n}=this,o=this._cachedScopes(t,i),a=o.get(e);if(a)return a;const r=new Set;e.forEach((e=>{t&&(r.add(t),e.forEach((e=>mn(r,t,e)))),e.forEach((t=>mn(r,s,t))),e.forEach((t=>mn(r,re[n]||{},t))),e.forEach((t=>mn(r,ue,t))),e.forEach((t=>mn(r,le,t)))}));const l=Array.from(r);return 0===l.length&&l.push(Object.create(null)),gn.has(e)&&o.set(e,l),l}chartOptionScopes(){const{options:t,type:e}=this;return[t,re[e]||{},ue.datasets[e]||{},{type:e},ue,le]}resolveNamedOptions(t,e,i,s=[""]){const o={$shared:!0},{resolver:a,subPrefixes:r}=xn(this._resolverCache,t,s);let l=a;if(function(t,e){const{isScriptable:i,isIndexable:s}=Ye(t);for(const o of e){const e=i(o),a=s(o),r=(a||e)&&t[o];if(e&&(S(r)||_n(r))||a&&n(r))return!0}return!1}(a,e)){o.$shared=!1;l=$e(a,i=S(i)?i():i,this.createResolver(t,i,r))}for(const t of e)o[t]=l[t];return o}createResolver(t,e,i=[""],s){const{resolver:n}=xn(this._resolverCache,t,i);return o(e)?$e(n,e,void 0,s):n}}function xn(t,e,i){let s=t.get(e);s||(s=new Map,t.set(e,s));const n=i.join();let o=s.get(n);if(!o){o={resolver:je(e,i),subPrefixes:i.filter((t=>!t.toLowerCase().includes("hover")))},s.set(n,o)}return o}const _n=t=>o(t)&&Object.getOwnPropertyNames(t).some((e=>S(t[e])));const yn=["top","bottom","left","right","chartArea"];function vn(t,e){return"top"===t||"bottom"===t||-1===yn.indexOf(t)&&"x"===e}function Mn(t,e){return function(i,s){return i[t]===s[t]?i[e]-s[e]:i[t]-s[t]}}function wn(t){const e=t.chart,i=e.options.animation;e.notifyPlugins("afterRender"),d(i&&i.onComplete,[t],e)}function kn(t){const e=t.chart,i=e.options.animation;d(i&&i.onProgress,[t],e)}function Sn(t){return fe()&&"string"==typeof t?t=document.getElementById(t):t&&t.length&&(t=t[0]),t&&t.canvas&&(t=t.canvas),t}const Pn={},Dn=t=>{const e=Sn(t);return Object.values(Pn).filter((t=>t.canvas===e)).pop()};function Cn(t,e,i){const s=Object.keys(t);for(const n of s){const s=+n;if(s>=e){const o=t[n];delete t[n],(i>0||s>e)&&(t[s+i]=o)}}}function On(t,e,i){return t.options.clip?t[i]:e[i]}class An{static defaults=ue;static instances=Pn;static overrides=re;static registry=en;static version="4.4.1";static getChart=Dn;static register(...t){en.add(...t),Tn()}static unregister(...t){en.remove(...t),Tn()}constructor(t,e){const s=this.config=new bn(e),n=Sn(t),o=Dn(n);if(o)throw new Error("Canvas is already in use. Chart with ID '"+o.id+"' must be destroyed before the canvas with ID '"+o.canvas.id+"' can be reused.");const a=s.createResolver(s.chartOptionScopes(),this.getContext());this.platform=new(s.platform||ks(n)),this.platform.updateConfig(s);const r=this.platform.acquireContext(n,a.aspectRatio),l=r&&r.canvas,h=l&&l.height,c=l&&l.width;this.id=i(),this.ctx=r,this.canvas=l,this.width=c,this.height=h,this._options=a,this._aspectRatio=this.aspectRatio,this._layers=[],this._metasets=[],this._stacks=void 0,this.boxes=[],this.currentDevicePixelRatio=void 0,this.chartArea=void 0,this._active=[],this._lastEvent=void 0,this._listeners={},this._responsiveListeners=void 0,this._sortedMetasets=[],this.scales={},this._plugins=new sn,this.$proxies={},this._hiddenIndices={},this.attached=!1,this._animationsDisabled=void 0,this.$context=void 0,this._doResize=dt((t=>this.update(t)),a.resizeDelay||0),this._dataChanges=[],Pn[this.id]=this,r&&l?(xt.listen(this,"complete",wn),xt.listen(this,"progress",kn),this._initialize(),this.attached&&this.update()):console.error("Failed to create chart: can't acquire context from the given item")}get aspectRatio(){const{options:{aspectRatio:t,maintainAspectRatio:e},width:i,height:n,_aspectRatio:o}=this;return s(t)?e&&o?o:n?i/n:null:t}get data(){return this.config.data}set data(t){this.config.data=t}get options(){return this._options}set options(t){this.config.options=t}get registry(){return en}_initialize(){return this.notifyPlugins("beforeInit"),this.options.responsive?this.resize():ke(this,this.options.devicePixelRatio),this.bindEvents(),this.notifyPlugins("afterInit"),this}clear(){return Te(this.canvas,this.ctx),this}stop(){return xt.stop(this),this}resize(t,e){xt.running(this)?this._resizeBeforeDraw={width:t,height:e}:this._resize(t,e)}_resize(t,e){const i=this.options,s=this.canvas,n=i.maintainAspectRatio&&this.aspectRatio,o=this.platform.getMaximumSize(s,t,e,n),a=i.devicePixelRatio||this.platform.getDevicePixelRatio(),r=this.width?"resize":"attach";this.width=o.width,this.height=o.height,this._aspectRatio=this.aspectRatio,ke(this,a,!0)&&(this.notifyPlugins("resize",{size:o}),d(i.onResize,[this,o],this),this.attached&&this._doResize(r)&&this.render())}ensureScalesHaveIDs(){u(this.options.scales||{},((t,e)=>{t.id=e}))}buildOrUpdateScales(){const t=this.options,e=t.scales,i=this.scales,s=Object.keys(i).reduce(((t,e)=>(t[e]=!1,t)),{});let n=[];e&&(n=n.concat(Object.keys(e).map((t=>{const i=e[t],s=ln(t,i),n="r"===s,o="x"===s;return{options:i,dposition:n?"chartArea":o?"bottom":"left",dtype:n?"radialLinear":o?"category":"linear"}})))),u(n,(e=>{const n=e.options,o=n.id,a=ln(o,n),r=l(n.type,e.dtype);void 0!==n.position&&vn(n.position,a)===vn(e.dposition)||(n.position=e.dposition),s[o]=!0;let h=null;if(o in i&&i[o].type===r)h=i[o];else{h=new(en.getScale(r))({id:o,type:r,ctx:this.ctx,chart:this}),i[h.id]=h}h.init(n,t)})),u(s,((t,e)=>{t||delete i[e]})),u(i,(t=>{as.configure(this,t,t.options),as.addBox(this,t)}))}_updateMetasets(){const t=this._metasets,e=this.data.datasets.length,i=t.length;if(t.sort(((t,e)=>t.index-e.index)),i>e){for(let t=e;te.length&&delete this._stacks,t.forEach(((t,i)=>{0===e.filter((e=>e===t._dataset)).length&&this._destroyDatasetMeta(i)}))}buildOrUpdateControllers(){const t=[],e=this.data.datasets;let i,s;for(this._removeUnreferencedMetasets(),i=0,s=e.length;i{this.getDatasetMeta(e).controller.reset()}),this)}reset(){this._resetElements(),this.notifyPlugins("reset")}update(t){const e=this.config;e.update();const i=this._options=e.createResolver(e.chartOptionScopes(),this.getContext()),s=this._animationsDisabled=!i.animation;if(this._updateScales(),this._checkEventBindings(),this._updateHiddenIndices(),this._plugins.invalidate(),!1===this.notifyPlugins("beforeUpdate",{mode:t,cancelable:!0}))return;const n=this.buildOrUpdateControllers();this.notifyPlugins("beforeElementsUpdate");let o=0;for(let t=0,e=this.data.datasets.length;t{t.reset()})),this._updateDatasets(t),this.notifyPlugins("afterUpdate",{mode:t}),this._layers.sort(Mn("z","_idx"));const{_active:a,_lastEvent:r}=this;r?this._eventHandler(r,!0):a.length&&this._updateHoverStyles(a,a,!0),this.render()}_updateScales(){u(this.scales,(t=>{as.removeBox(this,t)})),this.ensureScalesHaveIDs(),this.buildOrUpdateScales()}_checkEventBindings(){const t=this.options,e=new Set(Object.keys(this._listeners)),i=new Set(t.events);P(e,i)&&!!this._responsiveListeners===t.responsive||(this.unbindEvents(),this.bindEvents())}_updateHiddenIndices(){const{_hiddenIndices:t}=this,e=this._getUniformDataChanges()||[];for(const{method:i,start:s,count:n}of e){Cn(t,s,"_removeElements"===i?-n:n)}}_getUniformDataChanges(){const t=this._dataChanges;if(!t||!t.length)return;this._dataChanges=[];const e=this.data.datasets.length,i=e=>new Set(t.filter((t=>t[0]===e)).map(((t,e)=>e+","+t.splice(1).join(",")))),s=i(0);for(let t=1;tt.split(","))).map((t=>({method:t[1],start:+t[2],count:+t[3]})))}_updateLayout(t){if(!1===this.notifyPlugins("beforeLayout",{cancelable:!0}))return;as.update(this,this.width,this.height,t);const e=this.chartArea,i=e.width<=0||e.height<=0;this._layers=[],u(this.boxes,(t=>{i&&"chartArea"===t.position||(t.configure&&t.configure(),this._layers.push(...t._layers()))}),this),this._layers.forEach(((t,e)=>{t._idx=e})),this.notifyPlugins("afterLayout")}_updateDatasets(t){if(!1!==this.notifyPlugins("beforeDatasetsUpdate",{mode:t,cancelable:!0})){for(let t=0,e=this.data.datasets.length;t=0;--e)this._drawDataset(t[e]);this.notifyPlugins("afterDatasetsDraw")}_drawDataset(t){const e=this.ctx,i=t._clip,s=!i.disabled,n=function(t,e){const{xScale:i,yScale:s}=t;return i&&s?{left:On(i,e,"left"),right:On(i,e,"right"),top:On(s,e,"top"),bottom:On(s,e,"bottom")}:e}(t,this.chartArea),o={meta:t,index:t.index,cancelable:!0};!1!==this.notifyPlugins("beforeDatasetDraw",o)&&(s&&Ie(e,{left:!1===i.left?0:n.left-i.left,right:!1===i.right?this.width:n.right+i.right,top:!1===i.top?0:n.top-i.top,bottom:!1===i.bottom?this.height:n.bottom+i.bottom}),t.controller.draw(),s&&ze(e),o.cancelable=!1,this.notifyPlugins("afterDatasetDraw",o))}isPointInArea(t){return Re(t,this.chartArea,this._minPadding)}getElementsAtEventForMode(t,e,i,s){const n=Xi.modes[e];return"function"==typeof n?n(this,t,i,s):[]}getDatasetMeta(t){const e=this.data.datasets[t],i=this._metasets;let s=i.filter((t=>t&&t._dataset===e)).pop();return s||(s={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null,order:e&&e.order||0,index:t,_dataset:e,_parsed:[],_sorted:!1},i.push(s)),s}getContext(){return this.$context||(this.$context=Ci(null,{chart:this,type:"chart"}))}getVisibleDatasetCount(){return this.getSortedVisibleDatasetMetas().length}isDatasetVisible(t){const e=this.data.datasets[t];if(!e)return!1;const i=this.getDatasetMeta(t);return"boolean"==typeof i.hidden?!i.hidden:!e.hidden}setDatasetVisibility(t,e){this.getDatasetMeta(t).hidden=!e}toggleDataVisibility(t){this._hiddenIndices[t]=!this._hiddenIndices[t]}getDataVisibility(t){return!this._hiddenIndices[t]}_updateVisibility(t,e,i){const s=i?"show":"hide",n=this.getDatasetMeta(t),o=n.controller._resolveAnimations(void 0,s);k(e)?(n.data[e].hidden=!i,this.update()):(this.setDatasetVisibility(t,i),o.update(n,{visible:i}),this.update((e=>e.datasetIndex===t?s:void 0)))}hide(t,e){this._updateVisibility(t,e,!1)}show(t,e){this._updateVisibility(t,e,!0)}_destroyDatasetMeta(t){const e=this._metasets[t];e&&e.controller&&e.controller._destroy(),delete this._metasets[t]}_stop(){let t,e;for(this.stop(),xt.remove(this),t=0,e=this.data.datasets.length;t{e.addEventListener(this,i,s),t[i]=s},s=(t,e,i)=>{t.offsetX=e,t.offsetY=i,this._eventHandler(t)};u(this.options.events,(t=>i(t,s)))}bindResponsiveEvents(){this._responsiveListeners||(this._responsiveListeners={});const t=this._responsiveListeners,e=this.platform,i=(i,s)=>{e.addEventListener(this,i,s),t[i]=s},s=(i,s)=>{t[i]&&(e.removeEventListener(this,i,s),delete t[i])},n=(t,e)=>{this.canvas&&this.resize(t,e)};let o;const a=()=>{s("attach",a),this.attached=!0,this.resize(),i("resize",n),i("detach",o)};o=()=>{this.attached=!1,s("resize",n),this._stop(),this._resize(0,0),i("attach",a)},e.isAttached(this.canvas)?a():o()}unbindEvents(){u(this._listeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._listeners={},u(this._responsiveListeners,((t,e)=>{this.platform.removeEventListener(this,e,t)})),this._responsiveListeners=void 0}updateHoverStyle(t,e,i){const s=i?"set":"remove";let n,o,a,r;for("dataset"===e&&(n=this.getDatasetMeta(t[0].datasetIndex),n.controller["_"+s+"DatasetHoverStyle"]()),a=0,r=t.length;a{const i=this.getDatasetMeta(t);if(!i)throw new Error("No dataset found at index "+t);return{datasetIndex:t,element:i.data[e],index:e}}));!f(i,e)&&(this._active=i,this._lastEvent=null,this._updateHoverStyles(i,e))}notifyPlugins(t,e,i){return this._plugins.notify(this,t,e,i)}isPluginEnabled(t){return 1===this._plugins._cache.filter((e=>e.plugin.id===t)).length}_updateHoverStyles(t,e,i){const s=this.options.hover,n=(t,e)=>t.filter((t=>!e.some((e=>t.datasetIndex===e.datasetIndex&&t.index===e.index)))),o=n(e,t),a=i?t:n(t,e);o.length&&this.updateHoverStyle(o,s.mode,!1),a.length&&s.mode&&this.updateHoverStyle(a,s.mode,!0)}_eventHandler(t,e){const i={event:t,replay:e,cancelable:!0,inChartArea:this.isPointInArea(t)},s=e=>(e.options.events||this.options.events).includes(t.native.type);if(!1===this.notifyPlugins("beforeEvent",i,s))return;const n=this._handleEvent(t,e,i.inChartArea);return i.cancelable=!1,this.notifyPlugins("afterEvent",i,s),(n||i.changed)&&this.render(),this}_handleEvent(t,e,i){const{_active:s=[],options:n}=this,o=e,a=this._getActiveElements(t,s,i,o),r=D(t),l=function(t,e,i,s){return i&&"mouseout"!==t.type?s?e:t:null}(t,this._lastEvent,i,r);i&&(this._lastEvent=null,d(n.onHover,[t,a,this],this),r&&d(n.onClick,[t,a,this],this));const h=!f(a,s);return(h||e)&&(this._active=a,this._updateHoverStyles(a,s,e)),this._lastEvent=l,h}_getActiveElements(t,e,i,s){if("mouseout"===t.type)return[];if(!i)return e;const n=this.options.hover;return this.getElementsAtEventForMode(t,n.mode,n,s)}}function Tn(){return u(An.instances,(t=>t._plugins.invalidate()))}function Ln(){throw new Error("This method is not implemented: Check that a complete date adapter is provided.")}class En{static override(t){Object.assign(En.prototype,t)}options;constructor(t){this.options=t||{}}init(){}formats(){return Ln()}parse(){return Ln()}format(){return Ln()}add(){return Ln()}diff(){return Ln()}startOf(){return Ln()}endOf(){return Ln()}}var Rn={_date:En};function In(t){const e=t.iScale,i=function(t,e){if(!t._cache.$bar){const i=t.getMatchingVisibleMetas(e);let s=[];for(let e=0,n=i.length;et-e)))}return t._cache.$bar}(e,t.type);let s,n,o,a,r=e._length;const l=()=>{32767!==o&&-32768!==o&&(k(a)&&(r=Math.min(r,Math.abs(o-a)||r)),a=o)};for(s=0,n=i.length;sMath.abs(r)&&(l=r,h=a),e[i.axis]=h,e._custom={barStart:l,barEnd:h,start:n,end:o,min:a,max:r}}(t,e,i,s):e[i.axis]=i.parse(t,s),e}function Fn(t,e,i,s){const n=t.iScale,o=t.vScale,a=n.getLabels(),r=n===o,l=[];let h,c,d,u;for(h=i,c=i+s;ht.x,i="left",s="right"):(e=t.base"spacing"!==t,_indexable:t=>"spacing"!==t&&!t.startsWith("borderDash")&&!t.startsWith("hoverBorderDash")};static overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(t){const e=t.data;if(e.labels.length&&e.datasets.length){const{labels:{pointStyle:i,color:s}}=t.legend.options;return e.labels.map(((e,n)=>{const o=t.getDatasetMeta(0).controller.getStyle(n);return{text:e,fillStyle:o.backgroundColor,strokeStyle:o.borderColor,fontColor:s,lineWidth:o.borderWidth,pointStyle:i,hidden:!t.getDataVisibility(n),index:n}}))}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}}};constructor(t,e){super(t,e),this.enableOptionSharing=!0,this.innerRadius=void 0,this.outerRadius=void 0,this.offsetX=void 0,this.offsetY=void 0}linkScales(){}parse(t,e){const i=this.getDataset().data,s=this._cachedMeta;if(!1===this._parsing)s._parsed=i;else{let n,a,r=t=>+i[t];if(o(i[t])){const{key:t="value"}=this._parsing;r=e=>+M(i[e],t)}for(n=t,a=t+e;nZ(t,r,l,!0)?1:Math.max(e,e*i,s,s*i),g=(t,e,s)=>Z(t,r,l,!0)?-1:Math.min(e,e*i,s,s*i),p=f(0,h,d),m=f(E,c,u),b=g(C,h,d),x=g(C+E,c,u);s=(p-b)/2,n=(m-x)/2,o=-(p+b)/2,a=-(m+x)/2}return{ratioX:s,ratioY:n,offsetX:o,offsetY:a}}(u,d,r),b=(i.width-o)/f,x=(i.height-o)/g,_=Math.max(Math.min(b,x)/2,0),y=c(this.options.radius,_),v=(y-Math.max(y*r,0))/this._getVisibleDatasetWeightTotal();this.offsetX=p*y,this.offsetY=m*y,s.total=this.calculateTotal(),this.outerRadius=y-v*this._getRingWeightOffset(this.index),this.innerRadius=Math.max(this.outerRadius-v*l,0),this.updateElements(n,0,n.length,t)}_circumference(t,e){const i=this.options,s=this._cachedMeta,n=this._getCircumference();return e&&i.animation.animateRotate||!this.chart.getDataVisibility(t)||null===s._parsed[t]||s.data[t].hidden?0:this.calculateCircumference(s._parsed[t]*n/O)}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.chartArea,r=o.options.animation,l=(a.left+a.right)/2,h=(a.top+a.bottom)/2,c=n&&r.animateScale,d=c?0:this.innerRadius,u=c?0:this.outerRadius,{sharedOptions:f,includeOptions:g}=this._getSharedOptions(e,s);let p,m=this._getRotation();for(p=0;p0&&!isNaN(t)?O*(Math.abs(t)/e):0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t],i.options.locale);return{label:s[t]||"",value:n}}getMaxBorderWidth(t){let e=0;const i=this.chart;let s,n,o,a,r;if(!t)for(s=0,n=i.data.datasets.length;s{const o=t.getDatasetMeta(0).controller.getStyle(n);return{text:e,fillStyle:o.backgroundColor,strokeStyle:o.borderColor,fontColor:s,lineWidth:o.borderWidth,pointStyle:i,hidden:!t.getDataVisibility(n),index:n}}))}return[]}},onClick(t,e,i){i.chart.toggleDataVisibility(e.index),i.chart.update()}}},scales:{r:{type:"radialLinear",angleLines:{display:!1},beginAtZero:!0,grid:{circular:!0},pointLabels:{display:!1},startAngle:0}}};constructor(t,e){super(t,e),this.innerRadius=void 0,this.outerRadius=void 0}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart,s=i.data.labels||[],n=ne(e._parsed[t].r,i.options.locale);return{label:s[t]||"",value:n}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta.data;this._updateRadius(),this.updateElements(e,0,e.length,t)}getMinMax(){const t=this._cachedMeta,e={min:Number.POSITIVE_INFINITY,max:Number.NEGATIVE_INFINITY};return t.data.forEach(((t,i)=>{const s=this.getParsed(i).r;!isNaN(s)&&this.chart.getDataVisibility(i)&&(se.max&&(e.max=s))})),e}_updateRadius(){const t=this.chart,e=t.chartArea,i=t.options,s=Math.min(e.right-e.left,e.bottom-e.top),n=Math.max(s/2,0),o=(n-Math.max(i.cutoutPercentage?n/100*i.cutoutPercentage:1,0))/t.getVisibleDatasetCount();this.outerRadius=n-o*this.index,this.innerRadius=this.outerRadius-o}updateElements(t,e,i,s){const n="reset"===s,o=this.chart,a=o.options.animation,r=this._cachedMeta.rScale,l=r.xCenter,h=r.yCenter,c=r.getIndexAngle(0)-.5*C;let d,u=c;const f=360/this.countVisibleElements();for(d=0;d{!isNaN(this.getParsed(i).r)&&this.chart.getDataVisibility(i)&&e++})),e}_computeAngle(t,e,i){return this.chart.getDataVisibility(t)?$(this.resolveDataElementOptions(t,e).angle||i):0}}var Yn=Object.freeze({__proto__:null,BarController:class extends Ns{static id="bar";static defaults={datasetElementType:!1,dataElementType:"bar",categoryPercentage:.8,barPercentage:.9,grouped:!0,animations:{numbers:{type:"number",properties:["x","y","base","width","height"]}}};static overrides={scales:{_index_:{type:"category",offset:!0,grid:{offset:!0}},_value_:{type:"linear",beginAtZero:!0}}};parsePrimitiveData(t,e,i,s){return Fn(t,e,i,s)}parseArrayData(t,e,i,s){return Fn(t,e,i,s)}parseObjectData(t,e,i,s){const{iScale:n,vScale:o}=t,{xAxisKey:a="x",yAxisKey:r="y"}=this._parsing,l="x"===n.axis?a:r,h="x"===o.axis?a:r,c=[];let d,u,f,g;for(d=i,u=i+s;dt.controller.options.grouped)),o=i.options.stacked,a=[],r=t=>{const i=t.controller.getParsed(e),n=i&&i[t.vScale.axis];if(s(n)||isNaN(n))return!0};for(const i of n)if((void 0===e||!r(i))&&((!1===o||-1===a.indexOf(i.stack)||void 0===o&&void 0===i.stack)&&a.push(i.stack),i.index===t))break;return a.length||a.push(void 0),a}_getStackCount(t){return this._getStacks(void 0,t).length}_getStackIndex(t,e,i){const s=this._getStacks(t,i),n=void 0!==e?s.indexOf(e):-1;return-1===n?s.length-1:n}_getRuler(){const t=this.options,e=this._cachedMeta,i=e.iScale,s=[];let n,o;for(n=0,o=e.data.length;n=i?1:-1)}(u,e,r)*a,f===r&&(b-=u/2);const t=e.getPixelForDecimal(0),s=e.getPixelForDecimal(1),o=Math.min(t,s),h=Math.max(t,s);b=Math.max(Math.min(b,h),o),d=b+u,i&&!c&&(l._stacks[e.axis]._visualValues[n]=e.getValueForPixel(d)-e.getValueForPixel(b))}if(b===e.getPixelForValue(r)){const t=F(u)*e.getLineWidthForValue(r)/2;b+=t,u-=t}return{size:u,base:b,head:d,center:d+u/2}}_calculateBarIndexPixels(t,e){const i=e.scale,n=this.options,o=n.skipNull,a=l(n.maxBarThickness,1/0);let r,h;if(e.grouped){const i=o?this._getStackCount(t):e.stackCount,l="flex"===n.barThickness?function(t,e,i,s){const n=e.pixels,o=n[t];let a=t>0?n[t-1]:null,r=t=0;--i)e=Math.max(e,t[i].size(this.resolveDataElementOptions(i))/2);return e>0&&e}getLabelAndValue(t){const e=this._cachedMeta,i=this.chart.data.labels||[],{xScale:s,yScale:n}=e,o=this.getParsed(t),a=s.getLabelForValue(o.x),r=n.getLabelForValue(o.y),l=o._custom;return{label:i[t]||"",value:"("+a+", "+r+(l?", "+l:"")+")"}}update(t){const e=this._cachedMeta.data;this.updateElements(e,0,e.length,t)}updateElements(t,e,i,s){const n="reset"===s,{iScale:o,vScale:a}=this._cachedMeta,{sharedOptions:r,includeOptions:l}=this._getSharedOptions(e,s),h=o.axis,c=a.axis;for(let d=e;d0&&this.getParsed(e-1);for(let i=0;i<_;++i){const g=t[i],_=b?g:{};if(i=x){_.skip=!0;continue}const v=this.getParsed(i),M=s(v[f]),w=_[u]=a.getPixelForValue(v[u],i),k=_[f]=o||M?r.getBasePixel():r.getPixelForValue(l?this.applyStack(r,v,l):v[f],i);_.skip=isNaN(w)||isNaN(k)||M,_.stop=i>0&&Math.abs(v[u]-y[u])>m,p&&(_.parsed=v,_.raw=h.data[i]),d&&(_.options=c||this.resolveDataElementOptions(i,g.active?"active":n)),b||this.updateElement(g,i,_,n),y=v}}getMaxOverflow(){const t=this._cachedMeta,e=t.dataset,i=e.options&&e.options.borderWidth||0,s=t.data||[];if(!s.length)return i;const n=s[0].size(this.resolveDataElementOptions(0)),o=s[s.length-1].size(this.resolveDataElementOptions(s.length-1));return Math.max(i,n,o)/2}draw(){const t=this._cachedMeta;t.dataset.updateControlPoints(this.chart.chartArea,t.iScale.axis),super.draw()}},PieController:class extends jn{static id="pie";static defaults={cutout:0,rotation:0,circumference:360,radius:"100%"}},PolarAreaController:$n,RadarController:class extends Ns{static id="radar";static defaults={datasetElementType:"line",dataElementType:"point",indexAxis:"r",showLine:!0,elements:{line:{fill:"start"}}};static overrides={aspectRatio:1,scales:{r:{type:"radialLinear"}}};getLabelAndValue(t){const e=this._cachedMeta.vScale,i=this.getParsed(t);return{label:e.getLabels()[t],value:""+e.getLabelForValue(i[e.axis])}}parseObjectData(t,e,i,s){return ii.bind(this)(t,e,i,s)}update(t){const e=this._cachedMeta,i=e.dataset,s=e.data||[],n=e.iScale.getLabels();if(i.points=s,"resize"!==t){const e=this.resolveDatasetElementOptions(t);this.options.showLine||(e.borderWidth=0);const o={_loop:!0,_fullLoop:n.length===s.length,options:e};this.updateElement(i,void 0,o,t)}this.updateElements(s,0,s.length,t)}updateElements(t,e,i,s){const n=this._cachedMeta.rScale,o="reset"===s;for(let a=e;a0&&this.getParsed(e-1);for(let c=e;c0&&Math.abs(i[f]-_[f])>b,m&&(p.parsed=i,p.raw=h.data[c]),u&&(p.options=d||this.resolveDataElementOptions(c,e.active?"active":n)),x||this.updateElement(e,c,p,n),_=i}this.updateSharedOptions(d,n,c)}getMaxOverflow(){const t=this._cachedMeta,e=t.data||[];if(!this.options.showLine){let t=0;for(let i=e.length-1;i>=0;--i)t=Math.max(t,e[i].size(this.resolveDataElementOptions(i))/2);return t>0&&t}const i=t.dataset,s=i.options&&i.options.borderWidth||0;if(!e.length)return s;const n=e[0].size(this.resolveDataElementOptions(0)),o=e[e.length-1].size(this.resolveDataElementOptions(e.length-1));return Math.max(s,n,o)/2}}});function Un(t,e,i,s){const n=vi(t.options.borderRadius,["outerStart","outerEnd","innerStart","innerEnd"]);const o=(i-e)/2,a=Math.min(o,s*e/2),r=t=>{const e=(i-Math.min(o,t))*s/2;return J(t,0,Math.min(o,e))};return{outerStart:r(n.outerStart),outerEnd:r(n.outerEnd),innerStart:J(n.innerStart,0,a),innerEnd:J(n.innerEnd,0,a)}}function Xn(t,e,i,s){return{x:i+t*Math.cos(e),y:s+t*Math.sin(e)}}function qn(t,e,i,s,n,o){const{x:a,y:r,startAngle:l,pixelMargin:h,innerRadius:c}=e,d=Math.max(e.outerRadius+s+i-h,0),u=c>0?c+s+i+h:0;let f=0;const g=n-l;if(s){const t=((c>0?c-s:0)+(d>0?d-s:0))/2;f=(g-(0!==t?g*t/(t+s):g))/2}const p=(g-Math.max(.001,g*d-i/C)/d)/2,m=l+p+f,b=n-p-f,{outerStart:x,outerEnd:_,innerStart:y,innerEnd:v}=Un(e,u,d,b-m),M=d-x,w=d-_,k=m+x/M,S=b-_/w,P=u+y,D=u+v,O=m+y/P,A=b-v/D;if(t.beginPath(),o){const e=(k+S)/2;if(t.arc(a,r,d,k,e),t.arc(a,r,d,e,S),_>0){const e=Xn(w,S,a,r);t.arc(e.x,e.y,_,S,b+E)}const i=Xn(D,b,a,r);if(t.lineTo(i.x,i.y),v>0){const e=Xn(D,A,a,r);t.arc(e.x,e.y,v,b+E,A+Math.PI)}const s=(b-v/u+(m+y/u))/2;if(t.arc(a,r,u,b-v/u,s,!0),t.arc(a,r,u,s,m+y/u,!0),y>0){const e=Xn(P,O,a,r);t.arc(e.x,e.y,y,O+Math.PI,m-E)}const n=Xn(M,m,a,r);if(t.lineTo(n.x,n.y),x>0){const e=Xn(M,k,a,r);t.arc(e.x,e.y,x,m-E,k)}}else{t.moveTo(a,r);const e=Math.cos(k)*d+a,i=Math.sin(k)*d+r;t.lineTo(e,i);const s=Math.cos(S)*d+a,n=Math.sin(S)*d+r;t.lineTo(s,n)}t.closePath()}function Kn(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r,options:l}=e,{borderWidth:h,borderJoinStyle:c,borderDash:d,borderDashOffset:u}=l,f="inner"===l.borderAlign;if(!h)return;t.setLineDash(d||[]),t.lineDashOffset=u,f?(t.lineWidth=2*h,t.lineJoin=c||"round"):(t.lineWidth=h,t.lineJoin=c||"bevel");let g=e.endAngle;if(o){qn(t,e,i,s,g,n);for(let e=0;en?(h=n/l,t.arc(o,a,l,i+h,s-h,!0)):t.arc(o,a,n,i+E,s-E),t.closePath(),t.clip()}(t,e,g),o||(qn(t,e,i,s,g,n),t.stroke())}function Gn(t,e,i=e){t.lineCap=l(i.borderCapStyle,e.borderCapStyle),t.setLineDash(l(i.borderDash,e.borderDash)),t.lineDashOffset=l(i.borderDashOffset,e.borderDashOffset),t.lineJoin=l(i.borderJoinStyle,e.borderJoinStyle),t.lineWidth=l(i.borderWidth,e.borderWidth),t.strokeStyle=l(i.borderColor,e.borderColor)}function Zn(t,e,i){t.lineTo(i.x,i.y)}function Jn(t,e,i={}){const s=t.length,{start:n=0,end:o=s-1}=i,{start:a,end:r}=e,l=Math.max(n,a),h=Math.min(o,r),c=nr&&o>r;return{count:s,start:l,loop:e.loop,ilen:h(a+(h?r-t:t))%o,_=()=>{f!==g&&(t.lineTo(m,g),t.lineTo(m,f),t.lineTo(m,p))};for(l&&(d=n[x(0)],t.moveTo(d.x,d.y)),c=0;c<=r;++c){if(d=n[x(c)],d.skip)continue;const e=d.x,i=d.y,s=0|e;s===u?(ig&&(g=i),m=(b*m+e)/++b):(_(),t.lineTo(e,i),u=s,b=0,f=g=i),p=i}_()}function eo(t){const e=t.options,i=e.borderDash&&e.borderDash.length;return!(t._decimated||t._loop||e.tension||"monotone"===e.cubicInterpolationMode||e.stepped||i)?to:Qn}const io="function"==typeof Path2D;function so(t,e,i,s){io&&!e.options.segment?function(t,e,i,s){let n=e._path;n||(n=e._path=new Path2D,e.path(n,i,s)&&n.closePath()),Gn(t,e.options),t.stroke(n)}(t,e,i,s):function(t,e,i,s){const{segments:n,options:o}=e,a=eo(e);for(const r of n)Gn(t,o,r.style),t.beginPath(),a(t,e,r,{start:i,end:i+s-1})&&t.closePath(),t.stroke()}(t,e,i,s)}class no extends Hs{static id="line";static defaults={borderCapStyle:"butt",borderDash:[],borderDashOffset:0,borderJoinStyle:"miter",borderWidth:3,capBezierPoints:!0,cubicInterpolationMode:"default",fill:!1,spanGaps:!1,stepped:!1,tension:0};static defaultRoutes={backgroundColor:"backgroundColor",borderColor:"borderColor"};static descriptors={_scriptable:!0,_indexable:t=>"borderDash"!==t&&"fill"!==t};constructor(t){super(),this.animated=!0,this.options=void 0,this._chart=void 0,this._loop=void 0,this._fullLoop=void 0,this._path=void 0,this._points=void 0,this._segments=void 0,this._decimated=!1,this._pointsUpdated=!1,this._datasetIndex=void 0,t&&Object.assign(this,t)}updateControlPoints(t,e){const i=this.options;if((i.tension||"monotone"===i.cubicInterpolationMode)&&!i.stepped&&!this._pointsUpdated){const s=i.spanGaps?this._loop:this._fullLoop;hi(this._points,i,t,s,e),this._pointsUpdated=!0}}set points(t){this._points=t,delete this._segments,delete this._path,this._pointsUpdated=!1}get points(){return this._points}get segments(){return this._segments||(this._segments=zi(this,this.options.segment))}first(){const t=this.segments,e=this.points;return t.length&&e[t[0].start]}last(){const t=this.segments,e=this.points,i=t.length;return i&&e[t[i-1].end]}interpolate(t,e){const i=this.options,s=t[e],n=this.points,o=Ii(this,{property:e,start:s,end:s});if(!o.length)return;const a=[],r=function(t){return t.stepped?pi:t.tension||"monotone"===t.cubicInterpolationMode?mi:gi}(i);let l,h;for(l=0,h=o.length;l"borderDash"!==t};circumference;endAngle;fullCircles;innerRadius;outerRadius;pixelMargin;startAngle;constructor(t){super(),this.options=void 0,this.circumference=void 0,this.startAngle=void 0,this.endAngle=void 0,this.innerRadius=void 0,this.outerRadius=void 0,this.pixelMargin=0,this.fullCircles=0,t&&Object.assign(this,t)}inRange(t,e,i){const s=this.getProps(["x","y"],i),{angle:n,distance:o}=X(s,{x:t,y:e}),{startAngle:a,endAngle:r,innerRadius:h,outerRadius:c,circumference:d}=this.getProps(["startAngle","endAngle","innerRadius","outerRadius","circumference"],i),u=(this.options.spacing+this.options.borderWidth)/2,f=l(d,r-a)>=O||Z(n,a,r),g=tt(o,h+u,c+u);return f&&g}getCenterPoint(t){const{x:e,y:i,startAngle:s,endAngle:n,innerRadius:o,outerRadius:a}=this.getProps(["x","y","startAngle","endAngle","innerRadius","outerRadius"],t),{offset:r,spacing:l}=this.options,h=(s+n)/2,c=(o+a+l+r)/2;return{x:e+Math.cos(h)*c,y:i+Math.sin(h)*c}}tooltipPosition(t){return this.getCenterPoint(t)}draw(t){const{options:e,circumference:i}=this,s=(e.offset||0)/4,n=(e.spacing||0)/2,o=e.circular;if(this.pixelMargin="inner"===e.borderAlign?.33:0,this.fullCircles=i>O?Math.floor(i/O):0,0===i||this.innerRadius<0||this.outerRadius<0)return;t.save();const a=(this.startAngle+this.endAngle)/2;t.translate(Math.cos(a)*s,Math.sin(a)*s);const r=s*(1-Math.sin(Math.min(C,i||0)));t.fillStyle=e.backgroundColor,t.strokeStyle=e.borderColor,function(t,e,i,s,n){const{fullCircles:o,startAngle:a,circumference:r}=e;let l=e.endAngle;if(o){qn(t,e,i,s,l,n);for(let e=0;e("string"==typeof e?(i=t.push(e)-1,s.unshift({index:i,label:e})):isNaN(e)&&(i=null),i))(t,e,i,s);return n!==t.lastIndexOf(e)?i:n}function po(t){const e=this.getLabels();return t>=0&&ts=e?s:t,a=t=>n=i?n:t;if(t){const t=F(s),e=F(n);t<0&&e<0?a(0):t>0&&e>0&&o(0)}if(s===n){let e=0===n?1:Math.abs(.05*n);a(n+e),t||o(s-e)}this.min=s,this.max=n}getTickLimit(){const t=this.options.ticks;let e,{maxTicksLimit:i,stepSize:s}=t;return s?(e=Math.ceil(this.max/s)-Math.floor(this.min/s)+1,e>1e3&&(console.warn(`scales.${this.id}.ticks.stepSize: ${s} would result generating up to ${e} ticks. Limiting to 1000.`),e=1e3)):(e=this.computeTickLimit(),i=i||11),i&&(e=Math.min(i,e)),e}computeTickLimit(){return Number.POSITIVE_INFINITY}buildTicks(){const t=this.options,e=t.ticks;let i=this.getTickLimit();i=Math.max(2,i);const n=function(t,e){const i=[],{bounds:n,step:o,min:a,max:r,precision:l,count:h,maxTicks:c,maxDigits:d,includeBounds:u}=t,f=o||1,g=c-1,{min:p,max:m}=e,b=!s(a),x=!s(r),_=!s(h),y=(m-p)/(d+1);let v,M,w,k,S=B((m-p)/g/f)*f;if(S<1e-14&&!b&&!x)return[{value:p},{value:m}];k=Math.ceil(m/S)-Math.floor(p/S),k>g&&(S=B(k*S/g/f)*f),s(l)||(v=Math.pow(10,l),S=Math.ceil(S*v)/v),"ticks"===n?(M=Math.floor(p/S)*S,w=Math.ceil(m/S)*S):(M=p,w=m),b&&x&&o&&H((r-a)/o,S/1e3)?(k=Math.round(Math.min((r-a)/S,c)),S=(r-a)/k,M=a,w=r):_?(M=b?a:M,w=x?r:w,k=h-1,S=(w-M)/k):(k=(w-M)/S,k=V(k,Math.round(k),S/1e3)?Math.round(k):Math.ceil(k));const P=Math.max(U(S),U(M));v=Math.pow(10,s(l)?P:l),M=Math.round(M*v)/v,w=Math.round(w*v)/v;let D=0;for(b&&(u&&M!==a?(i.push({value:a}),Mr)break;i.push({value:t})}return x&&u&&w!==r?i.length&&V(i[i.length-1].value,r,mo(r,y,t))?i[i.length-1].value=r:i.push({value:r}):x&&w!==r||i.push({value:w}),i}({maxTicks:i,bounds:t.bounds,min:t.min,max:t.max,precision:e.precision,step:e.stepSize,count:e.count,maxDigits:this._maxDigits(),horizontal:this.isHorizontal(),minRotation:e.minRotation||0,includeBounds:!1!==e.includeBounds},this._range||this);return"ticks"===t.bounds&&j(n,this,"value"),t.reverse?(n.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),n}configure(){const t=this.ticks;let e=this.min,i=this.max;if(super.configure(),this.options.offset&&t.length){const s=(i-e)/Math.max(t.length-1,1)/2;e-=s,i+=s}this._startValue=e,this._endValue=i,this._valueRange=i-e}getLabelForValue(t){return ne(t,this.chart.options.locale,this.options.ticks.format)}}class xo extends bo{static id="linear";static defaults={ticks:{callback:ae.formatters.numeric}};determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?t:0,this.max=a(e)?e:1,this.handleTickRangeOptions()}computeTickLimit(){const t=this.isHorizontal(),e=t?this.width:this.height,i=$(this.options.ticks.minRotation),s=(t?Math.sin(i):Math.cos(i))||.001,n=this._resolveTickFontOptions(0);return Math.ceil(e/Math.min(40,n.lineHeight/s))}getPixelForValue(t){return null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getValueForPixel(t){return this._startValue+this.getDecimalForPixel(t)*this._valueRange}}const _o=t=>Math.floor(z(t)),yo=(t,e)=>Math.pow(10,_o(t)+e);function vo(t){return 1===t/Math.pow(10,_o(t))}function Mo(t,e,i){const s=Math.pow(10,i),n=Math.floor(t/s);return Math.ceil(e/s)-n}function wo(t,{min:e,max:i}){e=r(t.min,e);const s=[],n=_o(e);let o=function(t,e){let i=_o(e-t);for(;Mo(t,e,i)>10;)i++;for(;Mo(t,e,i)<10;)i--;return Math.min(i,_o(t))}(e,i),a=o<0?Math.pow(10,Math.abs(o)):1;const l=Math.pow(10,o),h=n>o?Math.pow(10,n):0,c=Math.round((e-h)*a)/a,d=Math.floor((e-h)/l/10)*l*10;let u=Math.floor((c-d)/Math.pow(10,o)),f=r(t.min,Math.round((h+d+u*Math.pow(10,o))*a)/a);for(;f=10?u=u<15?15:20:u++,u>=20&&(o++,u=2,a=o>=0?1:a),f=Math.round((h+d+u*Math.pow(10,o))*a)/a;const g=r(t.max,f);return s.push({value:g,major:vo(g),significand:u}),s}class ko extends Js{static id="logarithmic";static defaults={ticks:{callback:ae.formatters.logarithmic,major:{enabled:!0}}};constructor(t){super(t),this.start=void 0,this.end=void 0,this._startValue=void 0,this._valueRange=0}parse(t,e){const i=bo.prototype.parse.apply(this,[t,e]);if(0!==i)return a(i)&&i>0?i:null;this._zero=!0}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!0);this.min=a(t)?Math.max(0,t):null,this.max=a(e)?Math.max(0,e):null,this.options.beginAtZero&&(this._zero=!0),this._zero&&this.min!==this._suggestedMin&&!a(this._userMin)&&(this.min=t===yo(this.min,0)?yo(this.min,-1):yo(this.min,0)),this.handleTickRangeOptions()}handleTickRangeOptions(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let i=this.min,s=this.max;const n=e=>i=t?i:e,o=t=>s=e?s:t;i===s&&(i<=0?(n(1),o(10)):(n(yo(i,-1)),o(yo(s,1)))),i<=0&&n(yo(s,-1)),s<=0&&o(yo(i,1)),this.min=i,this.max=s}buildTicks(){const t=this.options,e=wo({min:this._userMin,max:this._userMax},this);return"ticks"===t.bounds&&j(e,this,"value"),t.reverse?(e.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),e}getLabelForValue(t){return void 0===t?"0":ne(t,this.chart.options.locale,this.options.ticks.format)}configure(){const t=this.min;super.configure(),this._startValue=z(t),this._valueRange=z(this.max)-z(t)}getPixelForValue(t){return void 0!==t&&0!==t||(t=this.min),null===t||isNaN(t)?NaN:this.getPixelForDecimal(t===this.min?0:(z(t)-this._startValue)/this._valueRange)}getValueForPixel(t){const e=this.getDecimalForPixel(t);return Math.pow(10,this._startValue+e*this._valueRange)}}function So(t){const e=t.ticks;if(e.display&&t.display){const t=ki(e.backdropPadding);return l(e.font&&e.font.size,ue.font.size)+t.height}return 0}function Po(t,e,i,s,n){return t===s||t===n?{start:e-i/2,end:e+i/2}:tn?{start:e-i,end:e}:{start:e,end:e+i}}function Do(t){const e={l:t.left+t._padding.left,r:t.right-t._padding.right,t:t.top+t._padding.top,b:t.bottom-t._padding.bottom},i=Object.assign({},e),s=[],o=[],a=t._pointLabels.length,r=t.options.pointLabels,l=r.centerPointLabels?C/a:0;for(let u=0;ue.r&&(r=(s.end-e.r)/o,t.r=Math.max(t.r,e.r+r)),n.starte.b&&(l=(n.end-e.b)/a,t.b=Math.max(t.b,e.b+l))}function Oo(t,e,i){const s=t.drawingArea,{extra:n,additionalAngle:o,padding:a,size:r}=i,l=t.getPointPosition(e,s+n+a,o),h=Math.round(Y(G(l.angle+E))),c=function(t,e,i){90===i||270===i?t-=e/2:(i>270||i<90)&&(t-=e);return t}(l.y,r.h,h),d=function(t){if(0===t||180===t)return"center";if(t<180)return"left";return"right"}(h),u=function(t,e,i){"right"===i?t-=e:"center"===i&&(t-=e/2);return t}(l.x,r.w,d);return{visible:!0,x:l.x,y:c,textAlign:d,left:u,top:c,right:u+r.w,bottom:c+r.h}}function Ao(t,e){if(!e)return!0;const{left:i,top:s,right:n,bottom:o}=t;return!(Re({x:i,y:s},e)||Re({x:i,y:o},e)||Re({x:n,y:s},e)||Re({x:n,y:o},e))}function To(t,e,i){const{left:n,top:o,right:a,bottom:r}=i,{backdropColor:l}=e;if(!s(l)){const i=wi(e.borderRadius),s=ki(e.backdropPadding);t.fillStyle=l;const h=n-s.left,c=o-s.top,d=a-n+s.width,u=r-o+s.height;Object.values(i).some((t=>0!==t))?(t.beginPath(),He(t,{x:h,y:c,w:d,h:u,radius:i}),t.fill()):t.fillRect(h,c,d,u)}}function Lo(t,e,i,s){const{ctx:n}=t;if(i)n.arc(t.xCenter,t.yCenter,e,0,O);else{let i=t.getPointPosition(0,e);n.moveTo(i.x,i.y);for(let o=1;ot,padding:5,centerPointLabels:!1}};static defaultRoutes={"angleLines.color":"borderColor","pointLabels.color":"color","ticks.color":"color"};static descriptors={angleLines:{_fallback:"grid"}};constructor(t){super(t),this.xCenter=void 0,this.yCenter=void 0,this.drawingArea=void 0,this._pointLabels=[],this._pointLabelItems=[]}setDimensions(){const t=this._padding=ki(So(this.options)/2),e=this.width=this.maxWidth-t.width,i=this.height=this.maxHeight-t.height;this.xCenter=Math.floor(this.left+e/2+t.left),this.yCenter=Math.floor(this.top+i/2+t.top),this.drawingArea=Math.floor(Math.min(e,i)/2)}determineDataLimits(){const{min:t,max:e}=this.getMinMax(!1);this.min=a(t)&&!isNaN(t)?t:0,this.max=a(e)&&!isNaN(e)?e:0,this.handleTickRangeOptions()}computeTickLimit(){return Math.ceil(this.drawingArea/So(this.options))}generateTickLabels(t){bo.prototype.generateTickLabels.call(this,t),this._pointLabels=this.getLabels().map(((t,e)=>{const i=d(this.options.pointLabels.callback,[t,e],this);return i||0===i?i:""})).filter(((t,e)=>this.chart.getDataVisibility(e)))}fit(){const t=this.options;t.display&&t.pointLabels.display?Do(this):this.setCenterPoint(0,0,0,0)}setCenterPoint(t,e,i,s){this.xCenter+=Math.floor((t-e)/2),this.yCenter+=Math.floor((i-s)/2),this.drawingArea-=Math.min(this.drawingArea/2,Math.max(t,e,i,s))}getIndexAngle(t){return G(t*(O/(this._pointLabels.length||1))+$(this.options.startAngle||0))}getDistanceFromCenterForValue(t){if(s(t))return NaN;const e=this.drawingArea/(this.max-this.min);return this.options.reverse?(this.max-t)*e:(t-this.min)*e}getValueForDistanceFromCenter(t){if(s(t))return NaN;const e=t/(this.drawingArea/(this.max-this.min));return this.options.reverse?this.max-e:this.min+e}getPointLabelContext(t){const e=this._pointLabels||[];if(t>=0&&t=0;n--){const e=t._pointLabelItems[n];if(!e.visible)continue;const o=s.setContext(t.getPointLabelContext(n));To(i,o,e);const a=Si(o.font),{x:r,y:l,textAlign:h}=e;Ne(i,t._pointLabels[n],r,l+a.lineHeight/2,a,{color:o.color,textAlign:h,textBaseline:"middle"})}}(this,o),s.display&&this.ticks.forEach(((t,e)=>{if(0!==e){r=this.getDistanceFromCenterForValue(t.value);const i=this.getContext(e),a=s.setContext(i),l=n.setContext(i);!function(t,e,i,s,n){const o=t.ctx,a=e.circular,{color:r,lineWidth:l}=e;!a&&!s||!r||!l||i<0||(o.save(),o.strokeStyle=r,o.lineWidth=l,o.setLineDash(n.dash),o.lineDashOffset=n.dashOffset,o.beginPath(),Lo(t,i,a,s),o.closePath(),o.stroke(),o.restore())}(this,a,r,o,l)}})),i.display){for(t.save(),a=o-1;a>=0;a--){const s=i.setContext(this.getPointLabelContext(a)),{color:n,lineWidth:o}=s;o&&n&&(t.lineWidth=o,t.strokeStyle=n,t.setLineDash(s.borderDash),t.lineDashOffset=s.borderDashOffset,r=this.getDistanceFromCenterForValue(e.ticks.reverse?this.min:this.max),l=this.getPointPosition(a,r),t.beginPath(),t.moveTo(this.xCenter,this.yCenter),t.lineTo(l.x,l.y),t.stroke())}t.restore()}}drawBorder(){}drawLabels(){const t=this.ctx,e=this.options,i=e.ticks;if(!i.display)return;const s=this.getIndexAngle(0);let n,o;t.save(),t.translate(this.xCenter,this.yCenter),t.rotate(s),t.textAlign="center",t.textBaseline="middle",this.ticks.forEach(((s,a)=>{if(0===a&&!e.reverse)return;const r=i.setContext(this.getContext(a)),l=Si(r.font);if(n=this.getDistanceFromCenterForValue(this.ticks[a].value),r.showLabelBackdrop){t.font=l.string,o=t.measureText(s.label).width,t.fillStyle=r.backdropColor;const e=ki(r.backdropPadding);t.fillRect(-o/2-e.left,-n-l.size/2-e.top,o+e.width,l.size+e.height)}Ne(t,s.label,0,-n,l,{color:r.color,strokeColor:r.textStrokeColor,strokeWidth:r.textStrokeWidth})})),t.restore()}drawTitle(){}}const Ro={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},Io=Object.keys(Ro);function zo(t,e){return t-e}function Fo(t,e){if(s(e))return null;const i=t._adapter,{parser:n,round:o,isoWeekday:r}=t._parseOpts;let l=e;return"function"==typeof n&&(l=n(l)),a(l)||(l="string"==typeof n?i.parse(l,n):i.parse(l)),null===l?null:(o&&(l="week"!==o||!N(r)&&!0!==r?i.startOf(l,o):i.startOf(l,"isoWeek",r)),+l)}function Vo(t,e,i,s){const n=Io.length;for(let o=Io.indexOf(t);o=e?i[s]:i[n]]=!0}}else t[e]=!0}function Wo(t,e,i){const s=[],n={},o=e.length;let a,r;for(a=0;a=0&&(e[l].major=!0);return e}(t,s,n,i):s}class No extends Js{static id="time";static defaults={bounds:"data",adapters:{},time:{parser:!1,unit:!1,round:!1,isoWeekday:!1,minUnit:"millisecond",displayFormats:{}},ticks:{source:"auto",callback:!1,major:{enabled:!1}}};constructor(t){super(t),this._cache={data:[],labels:[],all:[]},this._unit="day",this._majorUnit=void 0,this._offsets={},this._normalized=!1,this._parseOpts=void 0}init(t,e={}){const i=t.time||(t.time={}),s=this._adapter=new Rn._date(t.adapters.date);s.init(e),x(i.displayFormats,s.formats()),this._parseOpts={parser:i.parser,round:i.round,isoWeekday:i.isoWeekday},super.init(t),this._normalized=e.normalized}parse(t,e){return void 0===t?null:Fo(this,t)}beforeLayout(){super.beforeLayout(),this._cache={data:[],labels:[],all:[]}}determineDataLimits(){const t=this.options,e=this._adapter,i=t.time.unit||"day";let{min:s,max:n,minDefined:o,maxDefined:r}=this.getUserBounds();function l(t){o||isNaN(t.min)||(s=Math.min(s,t.min)),r||isNaN(t.max)||(n=Math.max(n,t.max))}o&&r||(l(this._getLabelBounds()),"ticks"===t.bounds&&"labels"===t.ticks.source||l(this.getMinMax(!1))),s=a(s)&&!isNaN(s)?s:+e.startOf(Date.now(),i),n=a(n)&&!isNaN(n)?n:+e.endOf(Date.now(),i)+1,this.min=Math.min(s,n-1),this.max=Math.max(s+1,n)}_getLabelBounds(){const t=this.getLabelTimestamps();let e=Number.POSITIVE_INFINITY,i=Number.NEGATIVE_INFINITY;return t.length&&(e=t[0],i=t[t.length-1]),{min:e,max:i}}buildTicks(){const t=this.options,e=t.time,i=t.ticks,s="labels"===i.source?this.getLabelTimestamps():this._generate();"ticks"===t.bounds&&s.length&&(this.min=this._userMin||s[0],this.max=this._userMax||s[s.length-1]);const n=this.min,o=nt(s,n,this.max);return this._unit=e.unit||(i.autoSkip?Vo(e.minUnit,this.min,this.max,this._getLabelCapacity(n)):function(t,e,i,s,n){for(let o=Io.length-1;o>=Io.indexOf(i);o--){const i=Io[o];if(Ro[i].common&&t._adapter.diff(n,s,i)>=e-1)return i}return Io[i?Io.indexOf(i):0]}(this,o.length,e.minUnit,this.min,this.max)),this._majorUnit=i.major.enabled&&"year"!==this._unit?function(t){for(let e=Io.indexOf(t)+1,i=Io.length;e+t.value)))}initOffsets(t=[]){let e,i,s=0,n=0;this.options.offset&&t.length&&(e=this.getDecimalForValue(t[0]),s=1===t.length?1-e:(this.getDecimalForValue(t[1])-e)/2,i=this.getDecimalForValue(t[t.length-1]),n=1===t.length?i:(i-this.getDecimalForValue(t[t.length-2]))/2);const o=t.length<3?.5:.25;s=J(s,0,o),n=J(n,0,o),this._offsets={start:s,end:n,factor:1/(s+1+n)}}_generate(){const t=this._adapter,e=this.min,i=this.max,s=this.options,n=s.time,o=n.unit||Vo(n.minUnit,e,i,this._getLabelCapacity(e)),a=l(s.ticks.stepSize,1),r="week"===o&&n.isoWeekday,h=N(r)||!0===r,c={};let d,u,f=e;if(h&&(f=+t.startOf(f,"isoWeek",r)),f=+t.startOf(f,h?"day":o),t.diff(i,e,o)>1e5*a)throw new Error(e+" and "+i+" are too far apart with stepSize of "+a+" "+o);const g="data"===s.ticks.source&&this.getDataTimestamps();for(d=f,u=0;d+t))}getLabelForValue(t){const e=this._adapter,i=this.options.time;return i.tooltipFormat?e.format(t,i.tooltipFormat):e.format(t,i.displayFormats.datetime)}format(t,e){const i=this.options.time.displayFormats,s=this._unit,n=e||i[s];return this._adapter.format(t,n)}_tickFormatFunction(t,e,i,s){const n=this.options,o=n.ticks.callback;if(o)return d(o,[t,e,i],this);const a=n.time.displayFormats,r=this._unit,l=this._majorUnit,h=r&&a[r],c=l&&a[l],u=i[e],f=l&&c&&u&&u.major;return this._adapter.format(t,s||(f?c:h))}generateTickLabels(t){let e,i,s;for(e=0,i=t.length;e0?a:1}getDataTimestamps(){let t,e,i=this._cache.data||[];if(i.length)return i;const s=this.getMatchingVisibleMetas();if(this._normalized&&s.length)return this._cache.data=s[0].controller.getAllParsedValues(this);for(t=0,e=s.length;t=t[r].pos&&e<=t[l].pos&&({lo:r,hi:l}=it(t,"pos",e)),({pos:s,time:o}=t[r]),({pos:n,time:a}=t[l])):(e>=t[r].time&&e<=t[l].time&&({lo:r,hi:l}=it(t,"time",e)),({time:s,pos:o}=t[r]),({time:n,pos:a}=t[l]));const h=n-s;return h?o+(a-o)*(e-s)/h:o}var jo=Object.freeze({__proto__:null,CategoryScale:class extends Js{static id="category";static defaults={ticks:{callback:po}};constructor(t){super(t),this._startValue=void 0,this._valueRange=0,this._addedLabels=[]}init(t){const e=this._addedLabels;if(e.length){const t=this.getLabels();for(const{index:i,label:s}of e)t[i]===s&&t.splice(i,1);this._addedLabels=[]}super.init(t)}parse(t,e){if(s(t))return null;const i=this.getLabels();return((t,e)=>null===t?null:J(Math.round(t),0,e))(e=isFinite(e)&&i[e]===t?e:go(i,t,l(e,t),this._addedLabels),i.length-1)}determineDataLimits(){const{minDefined:t,maxDefined:e}=this.getUserBounds();let{min:i,max:s}=this.getMinMax(!0);"ticks"===this.options.bounds&&(t||(i=0),e||(s=this.getLabels().length-1)),this.min=i,this.max=s}buildTicks(){const t=this.min,e=this.max,i=this.options.offset,s=[];let n=this.getLabels();n=0===t&&e===n.length-1?n:n.slice(t,e+1),this._valueRange=Math.max(n.length-(i?0:1),1),this._startValue=this.min-(i?.5:0);for(let i=t;i<=e;i++)s.push({value:i});return s}getLabelForValue(t){return po.call(this,t)}configure(){super.configure(),this.isHorizontal()||(this._reversePixels=!this._reversePixels)}getPixelForValue(t){return"number"!=typeof t&&(t=this.parse(t)),null===t?NaN:this.getPixelForDecimal((t-this._startValue)/this._valueRange)}getPixelForTick(t){const e=this.ticks;return t<0||t>e.length-1?null:this.getPixelForValue(e[t].value)}getValueForPixel(t){return Math.round(this._startValue+this.getDecimalForPixel(t)*this._valueRange)}getBasePixel(){return this.bottom}},LinearScale:xo,LogarithmicScale:ko,RadialLinearScale:Eo,TimeScale:No,TimeSeriesScale:class extends No{static id="timeseries";static defaults=No.defaults;constructor(t){super(t),this._table=[],this._minPos=void 0,this._tableRange=void 0}initOffsets(){const t=this._getTimestampsForTable(),e=this._table=this.buildLookupTable(t);this._minPos=Ho(e,this.min),this._tableRange=Ho(e,this.max)-this._minPos,super.initOffsets(t)}buildLookupTable(t){const{min:e,max:i}=this,s=[],n=[];let o,a,r,l,h;for(o=0,a=t.length;o=e&&l<=i&&s.push(l);if(s.length<2)return[{time:e,pos:0},{time:i,pos:1}];for(o=0,a=s.length;ot-e))}_getTimestampsForTable(){let t=this._cache.all||[];if(t.length)return t;const e=this.getDataTimestamps(),i=this.getLabelTimestamps();return t=e.length&&i.length?this.normalize(e.concat(i)):e.length?e:i,t=this._cache.all=t,t}getDecimalForValue(t){return(Ho(this._table,t)-this._minPos)/this._tableRange}getValueForPixel(t){const e=this._offsets,i=this.getDecimalForPixel(t)/e.factor-e.end;return Ho(this._table,i*this._tableRange+this._minPos,!0)}}});const $o=["rgb(54, 162, 235)","rgb(255, 99, 132)","rgb(255, 159, 64)","rgb(255, 205, 86)","rgb(75, 192, 192)","rgb(153, 102, 255)","rgb(201, 203, 207)"],Yo=$o.map((t=>t.replace("rgb(","rgba(").replace(")",", 0.5)")));function Uo(t){return $o[t%$o.length]}function Xo(t){return Yo[t%Yo.length]}function qo(t){let e=0;return(i,s)=>{const n=t.getDatasetMeta(s).controller;n instanceof jn?e=function(t,e){return t.backgroundColor=t.data.map((()=>Uo(e++))),e}(i,e):n instanceof $n?e=function(t,e){return t.backgroundColor=t.data.map((()=>Xo(e++))),e}(i,e):n&&(e=function(t,e){return t.borderColor=Uo(e),t.backgroundColor=Xo(e),++e}(i,e))}}function Ko(t){let e;for(e in t)if(t[e].borderColor||t[e].backgroundColor)return!0;return!1}var Go={id:"colors",defaults:{enabled:!0,forceOverride:!1},beforeLayout(t,e,i){if(!i.enabled)return;const{data:{datasets:s},options:n}=t.config,{elements:o}=n;if(!i.forceOverride&&(Ko(s)||(a=n)&&(a.borderColor||a.backgroundColor)||o&&Ko(o)))return;var a;const r=qo(t);s.forEach(r)}};function Zo(t){if(t._decimated){const e=t._data;delete t._decimated,delete t._data,Object.defineProperty(t,"data",{configurable:!0,enumerable:!0,writable:!0,value:e})}}function Jo(t){t.data.datasets.forEach((t=>{Zo(t)}))}var Qo={id:"decimation",defaults:{algorithm:"min-max",enabled:!1},beforeElementsUpdate:(t,e,i)=>{if(!i.enabled)return void Jo(t);const n=t.width;t.data.datasets.forEach(((e,o)=>{const{_data:a,indexAxis:r}=e,l=t.getDatasetMeta(o),h=a||e.data;if("y"===Pi([r,t.options.indexAxis]))return;if(!l.controller.supportsDecimation)return;const c=t.scales[l.xAxisID];if("linear"!==c.type&&"time"!==c.type)return;if(t.options.parsing)return;let{start:d,count:u}=function(t,e){const i=e.length;let s,n=0;const{iScale:o}=t,{min:a,max:r,minDefined:l,maxDefined:h}=o.getUserBounds();return l&&(n=J(it(e,o.axis,a).lo,0,i-1)),s=h?J(it(e,o.axis,r).hi+1,n,i)-n:i-n,{start:n,count:s}}(l,h);if(u<=(i.threshold||4*n))return void Zo(e);let f;switch(s(a)&&(e._data=h,delete e.data,Object.defineProperty(e,"data",{configurable:!0,enumerable:!0,get:function(){return this._decimated},set:function(t){this._data=t}})),i.algorithm){case"lttb":f=function(t,e,i,s,n){const o=n.samples||s;if(o>=i)return t.slice(e,e+i);const a=[],r=(i-2)/(o-2);let l=0;const h=e+i-1;let c,d,u,f,g,p=e;for(a[l++]=t[p],c=0;cu&&(u=f,d=t[s],g=s);a[l++]=d,p=g}return a[l++]=t[h],a}(h,d,u,n,i);break;case"min-max":f=function(t,e,i,n){let o,a,r,l,h,c,d,u,f,g,p=0,m=0;const b=[],x=e+i-1,_=t[e].x,y=t[x].x-_;for(o=e;og&&(g=l,d=o),p=(m*p+a.x)/++m;else{const i=o-1;if(!s(c)&&!s(d)){const e=Math.min(c,d),s=Math.max(c,d);e!==u&&e!==i&&b.push({...t[e],x:p}),s!==u&&s!==i&&b.push({...t[s],x:p})}o>0&&i!==u&&b.push(t[i]),b.push(a),h=e,m=0,f=g=l,c=d=u=o}}return b}(h,d,u,n);break;default:throw new Error(`Unsupported decimation algorithm '${i.algorithm}'`)}e._decimated=f}))},destroy(t){Jo(t)}};function ta(t,e,i,s){if(s)return;let n=e[t],o=i[t];return"angle"===t&&(n=G(n),o=G(o)),{property:t,start:n,end:o}}function ea(t,e,i){for(;e>t;e--){const t=i[e];if(!isNaN(t.x)&&!isNaN(t.y))break}return e}function ia(t,e,i,s){return t&&e?s(t[i],e[i]):t?t[i]:e?e[i]:0}function sa(t,e){let i=[],s=!1;return n(t)?(s=!0,i=t):i=function(t,e){const{x:i=null,y:s=null}=t||{},n=e.points,o=[];return e.segments.forEach((({start:t,end:e})=>{e=ea(t,e,n);const a=n[t],r=n[e];null!==s?(o.push({x:a.x,y:s}),o.push({x:r.x,y:s})):null!==i&&(o.push({x:i,y:a.y}),o.push({x:i,y:r.y}))})),o}(t,e),i.length?new no({points:i,options:{tension:0},_loop:s,_fullLoop:s}):null}function na(t){return t&&!1!==t.fill}function oa(t,e,i){let s=t[e].fill;const n=[e];let o;if(!i)return s;for(;!1!==s&&-1===n.indexOf(s);){if(!a(s))return s;if(o=t[s],!o)return!1;if(o.visible)return s;n.push(s),s=o.fill}return!1}function aa(t,e,i){const s=function(t){const e=t.options,i=e.fill;let s=l(i&&i.target,i);void 0===s&&(s=!!e.backgroundColor);if(!1===s||null===s)return!1;if(!0===s)return"origin";return s}(t);if(o(s))return!isNaN(s.value)&&s;let n=parseFloat(s);return a(n)&&Math.floor(n)===n?function(t,e,i,s){"-"!==t&&"+"!==t||(i=e+i);if(i===e||i<0||i>=s)return!1;return i}(s[0],e,n,i):["origin","start","end","stack","shape"].indexOf(s)>=0&&s}function ra(t,e,i){const s=[];for(let n=0;n=0;--e){const i=n[e].$filler;i&&(i.line.updateControlPoints(o,i.axis),s&&i.fill&&da(t.ctx,i,o))}},beforeDatasetsDraw(t,e,i){if("beforeDatasetsDraw"!==i.drawTime)return;const s=t.getSortedVisibleDatasetMetas();for(let e=s.length-1;e>=0;--e){const i=s[e].$filler;na(i)&&da(t.ctx,i,t.chartArea)}},beforeDatasetDraw(t,e,i){const s=e.meta.$filler;na(s)&&"beforeDatasetDraw"===i.drawTime&&da(t.ctx,s,t.chartArea)},defaults:{propagate:!0,drawTime:"beforeDatasetDraw"}};const ba=(t,e)=>{let{boxHeight:i=e,boxWidth:s=e}=t;return t.usePointStyle&&(i=Math.min(i,e),s=t.pointStyleWidth||Math.min(s,e)),{boxWidth:s,boxHeight:i,itemHeight:Math.max(e,i)}};class xa extends Hs{constructor(t){super(),this._added=!1,this.legendHitBoxes=[],this._hoveredItem=null,this.doughnutMode=!1,this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this.legendItems=void 0,this.columnSizes=void 0,this.lineWidths=void 0,this.maxHeight=void 0,this.maxWidth=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.height=void 0,this.width=void 0,this._margins=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e,i){this.maxWidth=t,this.maxHeight=e,this._margins=i,this.setDimensions(),this.buildLabels(),this.fit()}setDimensions(){this.isHorizontal()?(this.width=this.maxWidth,this.left=this._margins.left,this.right=this.width):(this.height=this.maxHeight,this.top=this._margins.top,this.bottom=this.height)}buildLabels(){const t=this.options.labels||{};let e=d(t.generateLabels,[this.chart],this)||[];t.filter&&(e=e.filter((e=>t.filter(e,this.chart.data)))),t.sort&&(e=e.sort(((e,i)=>t.sort(e,i,this.chart.data)))),this.options.reverse&&e.reverse(),this.legendItems=e}fit(){const{options:t,ctx:e}=this;if(!t.display)return void(this.width=this.height=0);const i=t.labels,s=Si(i.font),n=s.size,o=this._computeTitleHeight(),{boxWidth:a,itemHeight:r}=ba(i,n);let l,h;e.font=s.string,this.isHorizontal()?(l=this.maxWidth,h=this._fitRows(o,n,a,r)+10):(h=this.maxHeight,l=this._fitCols(o,s,a,r)+10),this.width=Math.min(l,t.maxWidth||this.maxWidth),this.height=Math.min(h,t.maxHeight||this.maxHeight)}_fitRows(t,e,i,s){const{ctx:n,maxWidth:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.lineWidths=[0],h=s+a;let c=t;n.textAlign="left",n.textBaseline="middle";let d=-1,u=-h;return this.legendItems.forEach(((t,f)=>{const g=i+e/2+n.measureText(t.text).width;(0===f||l[l.length-1]+g+2*a>o)&&(c+=h,l[l.length-(f>0?0:1)]=0,u+=h,d++),r[f]={left:0,top:u,row:d,width:g,height:s},l[l.length-1]+=g+a})),c}_fitCols(t,e,i,s){const{ctx:n,maxHeight:o,options:{labels:{padding:a}}}=this,r=this.legendHitBoxes=[],l=this.columnSizes=[],h=o-t;let c=a,d=0,u=0,f=0,g=0;return this.legendItems.forEach(((t,o)=>{const{itemWidth:p,itemHeight:m}=function(t,e,i,s,n){const o=function(t,e,i,s){let n=t.text;n&&"string"!=typeof n&&(n=n.reduce(((t,e)=>t.length>e.length?t:e)));return e+i.size/2+s.measureText(n).width}(s,t,e,i),a=function(t,e,i){let s=t;"string"!=typeof e.text&&(s=_a(e,i));return s}(n,s,e.lineHeight);return{itemWidth:o,itemHeight:a}}(i,e,n,t,s);o>0&&u+m+2*a>h&&(c+=d+a,l.push({width:d,height:u}),f+=d+a,g++,d=u=0),r[o]={left:f,top:u,col:g,width:p,height:m},d=Math.max(d,p),u+=m+a})),c+=d,l.push({width:d,height:u}),c}adjustHitBoxes(){if(!this.options.display)return;const t=this._computeTitleHeight(),{legendHitBoxes:e,options:{align:i,labels:{padding:s},rtl:n}}=this,o=Oi(n,this.left,this.width);if(this.isHorizontal()){let n=0,a=ft(i,this.left+s,this.right-this.lineWidths[n]);for(const r of e)n!==r.row&&(n=r.row,a=ft(i,this.left+s,this.right-this.lineWidths[n])),r.top+=this.top+t+s,r.left=o.leftForLtr(o.x(a),r.width),a+=r.width+s}else{let n=0,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height);for(const r of e)r.col!==n&&(n=r.col,a=ft(i,this.top+t+s,this.bottom-this.columnSizes[n].height)),r.top=a,r.left+=this.left+s,r.left=o.leftForLtr(o.x(r.left),r.width),a+=r.height+s}}isHorizontal(){return"top"===this.options.position||"bottom"===this.options.position}draw(){if(this.options.display){const t=this.ctx;Ie(t,this),this._draw(),ze(t)}}_draw(){const{options:t,columnSizes:e,lineWidths:i,ctx:s}=this,{align:n,labels:o}=t,a=ue.color,r=Oi(t.rtl,this.left,this.width),h=Si(o.font),{padding:c}=o,d=h.size,u=d/2;let f;this.drawTitle(),s.textAlign=r.textAlign("left"),s.textBaseline="middle",s.lineWidth=.5,s.font=h.string;const{boxWidth:g,boxHeight:p,itemHeight:m}=ba(o,d),b=this.isHorizontal(),x=this._computeTitleHeight();f=b?{x:ft(n,this.left+c,this.right-i[0]),y:this.top+c+x,line:0}:{x:this.left+c,y:ft(n,this.top+x+c,this.bottom-e[0].height),line:0},Ai(this.ctx,t.textDirection);const _=m+c;this.legendItems.forEach(((y,v)=>{s.strokeStyle=y.fontColor,s.fillStyle=y.fontColor;const M=s.measureText(y.text).width,w=r.textAlign(y.textAlign||(y.textAlign=o.textAlign)),k=g+u+M;let S=f.x,P=f.y;r.setWidth(this.width),b?v>0&&S+k+c>this.right&&(P=f.y+=_,f.line++,S=f.x=ft(n,this.left+c,this.right-i[f.line])):v>0&&P+_>this.bottom&&(S=f.x=S+e[f.line].width+c,f.line++,P=f.y=ft(n,this.top+x+c,this.bottom-e[f.line].height));if(function(t,e,i){if(isNaN(g)||g<=0||isNaN(p)||p<0)return;s.save();const n=l(i.lineWidth,1);if(s.fillStyle=l(i.fillStyle,a),s.lineCap=l(i.lineCap,"butt"),s.lineDashOffset=l(i.lineDashOffset,0),s.lineJoin=l(i.lineJoin,"miter"),s.lineWidth=n,s.strokeStyle=l(i.strokeStyle,a),s.setLineDash(l(i.lineDash,[])),o.usePointStyle){const a={radius:p*Math.SQRT2/2,pointStyle:i.pointStyle,rotation:i.rotation,borderWidth:n},l=r.xPlus(t,g/2);Ee(s,a,l,e+u,o.pointStyleWidth&&g)}else{const o=e+Math.max((d-p)/2,0),a=r.leftForLtr(t,g),l=wi(i.borderRadius);s.beginPath(),Object.values(l).some((t=>0!==t))?He(s,{x:a,y:o,w:g,h:p,radius:l}):s.rect(a,o,g,p),s.fill(),0!==n&&s.stroke()}s.restore()}(r.x(S),P,y),S=gt(w,S+g+u,b?S+k:this.right,t.rtl),function(t,e,i){Ne(s,i.text,t,e+m/2,h,{strikethrough:i.hidden,textAlign:r.textAlign(i.textAlign)})}(r.x(S),P,y),b)f.x+=k+c;else if("string"!=typeof y.text){const t=h.lineHeight;f.y+=_a(y,t)+c}else f.y+=_})),Ti(this.ctx,t.textDirection)}drawTitle(){const t=this.options,e=t.title,i=Si(e.font),s=ki(e.padding);if(!e.display)return;const n=Oi(t.rtl,this.left,this.width),o=this.ctx,a=e.position,r=i.size/2,l=s.top+r;let h,c=this.left,d=this.width;if(this.isHorizontal())d=Math.max(...this.lineWidths),h=this.top+l,c=ft(t.align,c,this.right-d);else{const e=this.columnSizes.reduce(((t,e)=>Math.max(t,e.height)),0);h=l+ft(t.align,this.top,this.bottom-e-t.labels.padding-this._computeTitleHeight())}const u=ft(a,c,c+d);o.textAlign=n.textAlign(ut(a)),o.textBaseline="middle",o.strokeStyle=e.color,o.fillStyle=e.color,o.font=i.string,Ne(o,e.text,u,h,i)}_computeTitleHeight(){const t=this.options.title,e=Si(t.font),i=ki(t.padding);return t.display?e.lineHeight+i.height:0}_getLegendItemAt(t,e){let i,s,n;if(tt(t,this.left,this.right)&&tt(e,this.top,this.bottom))for(n=this.legendHitBoxes,i=0;it.chart.options.color,boxWidth:40,padding:10,generateLabels(t){const e=t.data.datasets,{labels:{usePointStyle:i,pointStyle:s,textAlign:n,color:o,useBorderRadius:a,borderRadius:r}}=t.legend.options;return t._getSortedDatasetMetas().map((t=>{const l=t.controller.getStyle(i?0:void 0),h=ki(l.borderWidth);return{text:e[t.index].label,fillStyle:l.backgroundColor,fontColor:o,hidden:!t.visible,lineCap:l.borderCapStyle,lineDash:l.borderDash,lineDashOffset:l.borderDashOffset,lineJoin:l.borderJoinStyle,lineWidth:(h.width+h.height)/4,strokeStyle:l.borderColor,pointStyle:s||l.pointStyle,rotation:l.rotation,textAlign:n||l.textAlign,borderRadius:a&&(r||l.borderRadius),datasetIndex:t.index}}),this)}},title:{color:t=>t.chart.options.color,display:!1,position:"center",text:""}},descriptors:{_scriptable:t=>!t.startsWith("on"),labels:{_scriptable:t=>!["generateLabels","filter","sort"].includes(t)}}};class va extends Hs{constructor(t){super(),this.chart=t.chart,this.options=t.options,this.ctx=t.ctx,this._padding=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(t,e){const i=this.options;if(this.left=0,this.top=0,!i.display)return void(this.width=this.height=this.right=this.bottom=0);this.width=this.right=t,this.height=this.bottom=e;const s=n(i.text)?i.text.length:1;this._padding=ki(i.padding);const o=s*Si(i.font).lineHeight+this._padding.height;this.isHorizontal()?this.height=o:this.width=o}isHorizontal(){const t=this.options.position;return"top"===t||"bottom"===t}_drawArgs(t){const{top:e,left:i,bottom:s,right:n,options:o}=this,a=o.align;let r,l,h,c=0;return this.isHorizontal()?(l=ft(a,i,n),h=e+t,r=n-i):("left"===o.position?(l=i+t,h=ft(a,s,e),c=-.5*C):(l=n-t,h=ft(a,e,s),c=.5*C),r=s-e),{titleX:l,titleY:h,maxWidth:r,rotation:c}}draw(){const t=this.ctx,e=this.options;if(!e.display)return;const i=Si(e.font),s=i.lineHeight/2+this._padding.top,{titleX:n,titleY:o,maxWidth:a,rotation:r}=this._drawArgs(s);Ne(t,e.text,0,0,i,{color:e.color,maxWidth:a,rotation:r,textAlign:ut(e.align),textBaseline:"middle",translation:[n,o]})}}var Ma={id:"title",_element:va,start(t,e,i){!function(t,e){const i=new va({ctx:t.ctx,options:e,chart:t});as.configure(t,i,e),as.addBox(t,i),t.titleBlock=i}(t,i)},stop(t){const e=t.titleBlock;as.removeBox(t,e),delete t.titleBlock},beforeUpdate(t,e,i){const s=t.titleBlock;as.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"bold"},fullSize:!0,padding:10,position:"top",text:"",weight:2e3},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const wa=new WeakMap;var ka={id:"subtitle",start(t,e,i){const s=new va({ctx:t.ctx,options:i,chart:t});as.configure(t,s,i),as.addBox(t,s),wa.set(t,s)},stop(t){as.removeBox(t,wa.get(t)),wa.delete(t)},beforeUpdate(t,e,i){const s=wa.get(t);as.configure(t,s,i),s.options=i},defaults:{align:"center",display:!1,font:{weight:"normal"},fullSize:!0,padding:0,position:"top",text:"",weight:1500},defaultRoutes:{color:"color"},descriptors:{_scriptable:!0,_indexable:!1}};const Sa={average(t){if(!t.length)return!1;let e,i,s=0,n=0,o=0;for(e=0,i=t.length;e-1?t.split("\n"):t}function Ca(t,e){const{element:i,datasetIndex:s,index:n}=e,o=t.getDatasetMeta(s).controller,{label:a,value:r}=o.getLabelAndValue(n);return{chart:t,label:a,parsed:o.getParsed(n),raw:t.data.datasets[s].data[n],formattedValue:r,dataset:o.getDataset(),dataIndex:n,datasetIndex:s,element:i}}function Oa(t,e){const i=t.chart.ctx,{body:s,footer:n,title:o}=t,{boxWidth:a,boxHeight:r}=e,l=Si(e.bodyFont),h=Si(e.titleFont),c=Si(e.footerFont),d=o.length,f=n.length,g=s.length,p=ki(e.padding);let m=p.height,b=0,x=s.reduce(((t,e)=>t+e.before.length+e.lines.length+e.after.length),0);if(x+=t.beforeBody.length+t.afterBody.length,d&&(m+=d*h.lineHeight+(d-1)*e.titleSpacing+e.titleMarginBottom),x){m+=g*(e.displayColors?Math.max(r,l.lineHeight):l.lineHeight)+(x-g)*l.lineHeight+(x-1)*e.bodySpacing}f&&(m+=e.footerMarginTop+f*c.lineHeight+(f-1)*e.footerSpacing);let _=0;const y=function(t){b=Math.max(b,i.measureText(t).width+_)};return i.save(),i.font=h.string,u(t.title,y),i.font=l.string,u(t.beforeBody.concat(t.afterBody),y),_=e.displayColors?a+2+e.boxPadding:0,u(s,(t=>{u(t.before,y),u(t.lines,y),u(t.after,y)})),_=0,i.font=c.string,u(t.footer,y),i.restore(),b+=p.width,{width:b,height:m}}function Aa(t,e,i,s){const{x:n,width:o}=i,{width:a,chartArea:{left:r,right:l}}=t;let h="center";return"center"===s?h=n<=(r+l)/2?"left":"right":n<=o/2?h="left":n>=a-o/2&&(h="right"),function(t,e,i,s){const{x:n,width:o}=s,a=i.caretSize+i.caretPadding;return"left"===t&&n+o+a>e.width||"right"===t&&n-o-a<0||void 0}(h,t,e,i)&&(h="center"),h}function Ta(t,e,i){const s=i.yAlign||e.yAlign||function(t,e){const{y:i,height:s}=e;return it.height-s/2?"bottom":"center"}(t,i);return{xAlign:i.xAlign||e.xAlign||Aa(t,e,i,s),yAlign:s}}function La(t,e,i,s){const{caretSize:n,caretPadding:o,cornerRadius:a}=t,{xAlign:r,yAlign:l}=i,h=n+o,{topLeft:c,topRight:d,bottomLeft:u,bottomRight:f}=wi(a);let g=function(t,e){let{x:i,width:s}=t;return"right"===e?i-=s:"center"===e&&(i-=s/2),i}(e,r);const p=function(t,e,i){let{y:s,height:n}=t;return"top"===e?s+=i:s-="bottom"===e?n+i:n/2,s}(e,l,h);return"center"===l?"left"===r?g+=h:"right"===r&&(g-=h):"left"===r?g-=Math.max(c,u)+n:"right"===r&&(g+=Math.max(d,f)+n),{x:J(g,0,s.width-e.width),y:J(p,0,s.height-e.height)}}function Ea(t,e,i){const s=ki(i.padding);return"center"===e?t.x+t.width/2:"right"===e?t.x+t.width-s.right:t.x+s.left}function Ra(t){return Pa([],Da(t))}function Ia(t,e){const i=e&&e.dataset&&e.dataset.tooltip&&e.dataset.tooltip.callbacks;return i?t.override(i):t}const za={beforeTitle:e,title(t){if(t.length>0){const e=t[0],i=e.chart.data.labels,s=i?i.length:0;if(this&&this.options&&"dataset"===this.options.mode)return e.dataset.label||"";if(e.label)return e.label;if(s>0&&e.dataIndex{const e={before:[],lines:[],after:[]},n=Ia(i,t);Pa(e.before,Da(Fa(n,"beforeLabel",this,t))),Pa(e.lines,Fa(n,"label",this,t)),Pa(e.after,Da(Fa(n,"afterLabel",this,t))),s.push(e)})),s}getAfterBody(t,e){return Ra(Fa(e.callbacks,"afterBody",this,t))}getFooter(t,e){const{callbacks:i}=e,s=Fa(i,"beforeFooter",this,t),n=Fa(i,"footer",this,t),o=Fa(i,"afterFooter",this,t);let a=[];return a=Pa(a,Da(s)),a=Pa(a,Da(n)),a=Pa(a,Da(o)),a}_createItems(t){const e=this._active,i=this.chart.data,s=[],n=[],o=[];let a,r,l=[];for(a=0,r=e.length;at.filter(e,s,n,i)))),t.itemSort&&(l=l.sort(((e,s)=>t.itemSort(e,s,i)))),u(l,(e=>{const i=Ia(t.callbacks,e);s.push(Fa(i,"labelColor",this,e)),n.push(Fa(i,"labelPointStyle",this,e)),o.push(Fa(i,"labelTextColor",this,e))})),this.labelColors=s,this.labelPointStyles=n,this.labelTextColors=o,this.dataPoints=l,l}update(t,e){const i=this.options.setContext(this.getContext()),s=this._active;let n,o=[];if(s.length){const t=Sa[i.position].call(this,s,this._eventPosition);o=this._createItems(i),this.title=this.getTitle(o,i),this.beforeBody=this.getBeforeBody(o,i),this.body=this.getBody(o,i),this.afterBody=this.getAfterBody(o,i),this.footer=this.getFooter(o,i);const e=this._size=Oa(this,i),a=Object.assign({},t,e),r=Ta(this.chart,i,a),l=La(i,a,r,this.chart);this.xAlign=r.xAlign,this.yAlign=r.yAlign,n={opacity:1,x:l.x,y:l.y,width:e.width,height:e.height,caretX:t.x,caretY:t.y}}else 0!==this.opacity&&(n={opacity:0});this._tooltipItems=o,this.$context=void 0,n&&this._resolveAnimations().update(this,n),t&&i.external&&i.external.call(this,{chart:this.chart,tooltip:this,replay:e})}drawCaret(t,e,i,s){const n=this.getCaretPosition(t,i,s);e.lineTo(n.x1,n.y1),e.lineTo(n.x2,n.y2),e.lineTo(n.x3,n.y3)}getCaretPosition(t,e,i){const{xAlign:s,yAlign:n}=this,{caretSize:o,cornerRadius:a}=i,{topLeft:r,topRight:l,bottomLeft:h,bottomRight:c}=wi(a),{x:d,y:u}=t,{width:f,height:g}=e;let p,m,b,x,_,y;return"center"===n?(_=u+g/2,"left"===s?(p=d,m=p-o,x=_+o,y=_-o):(p=d+f,m=p+o,x=_-o,y=_+o),b=p):(m="left"===s?d+Math.max(r,h)+o:"right"===s?d+f-Math.max(l,c)-o:this.caretX,"top"===n?(x=u,_=x-o,p=m-o,b=m+o):(x=u+g,_=x+o,p=m+o,b=m-o),y=x),{x1:p,x2:m,x3:b,y1:x,y2:_,y3:y}}drawTitle(t,e,i){const s=this.title,n=s.length;let o,a,r;if(n){const l=Oi(i.rtl,this.x,this.width);for(t.x=Ea(this,i.titleAlign,i),e.textAlign=l.textAlign(i.titleAlign),e.textBaseline="middle",o=Si(i.titleFont),a=i.titleSpacing,e.fillStyle=i.titleColor,e.font=o.string,r=0;r0!==t))?(t.beginPath(),t.fillStyle=n.multiKeyBackground,He(t,{x:e,y:g,w:h,h:l,radius:r}),t.fill(),t.stroke(),t.fillStyle=a.backgroundColor,t.beginPath(),He(t,{x:i,y:g+1,w:h-2,h:l-2,radius:r}),t.fill()):(t.fillStyle=n.multiKeyBackground,t.fillRect(e,g,h,l),t.strokeRect(e,g,h,l),t.fillStyle=a.backgroundColor,t.fillRect(i,g+1,h-2,l-2))}t.fillStyle=this.labelTextColors[i]}drawBody(t,e,i){const{body:s}=this,{bodySpacing:n,bodyAlign:o,displayColors:a,boxHeight:r,boxWidth:l,boxPadding:h}=i,c=Si(i.bodyFont);let d=c.lineHeight,f=0;const g=Oi(i.rtl,this.x,this.width),p=function(i){e.fillText(i,g.x(t.x+f),t.y+d/2),t.y+=d+n},m=g.textAlign(o);let b,x,_,y,v,M,w;for(e.textAlign=o,e.textBaseline="middle",e.font=c.string,t.x=Ea(this,m,i),e.fillStyle=i.bodyColor,u(this.beforeBody,p),f=a&&"right"!==m?"center"===o?l/2+h:l+2+h:0,y=0,M=s.length;y0&&e.stroke()}_updateAnimationTarget(t){const e=this.chart,i=this.$animations,s=i&&i.x,n=i&&i.y;if(s||n){const i=Sa[t.position].call(this,this._active,this._eventPosition);if(!i)return;const o=this._size=Oa(this,t),a=Object.assign({},i,this._size),r=Ta(e,t,a),l=La(t,a,r,e);s._to===l.x&&n._to===l.y||(this.xAlign=r.xAlign,this.yAlign=r.yAlign,this.width=o.width,this.height=o.height,this.caretX=i.x,this.caretY=i.y,this._resolveAnimations().update(this,l))}}_willRender(){return!!this.opacity}draw(t){const e=this.options.setContext(this.getContext());let i=this.opacity;if(!i)return;this._updateAnimationTarget(e);const s={width:this.width,height:this.height},n={x:this.x,y:this.y};i=Math.abs(i)<.001?0:i;const o=ki(e.padding),a=this.title.length||this.beforeBody.length||this.body.length||this.afterBody.length||this.footer.length;e.enabled&&a&&(t.save(),t.globalAlpha=i,this.drawBackground(n,t,s,e),Ai(t,e.textDirection),n.y+=o.top,this.drawTitle(n,t,e),this.drawBody(n,t,e),this.drawFooter(n,t,e),Ti(t,e.textDirection),t.restore())}getActiveElements(){return this._active||[]}setActiveElements(t,e){const i=this._active,s=t.map((({datasetIndex:t,index:e})=>{const i=this.chart.getDatasetMeta(t);if(!i)throw new Error("Cannot find a dataset at index "+t);return{datasetIndex:t,element:i.data[e],index:e}})),n=!f(i,s),o=this._positionChanged(s,e);(n||o)&&(this._active=s,this._eventPosition=e,this._ignoreReplayEvents=!0,this.update(!0))}handleEvent(t,e,i=!0){if(e&&this._ignoreReplayEvents)return!1;this._ignoreReplayEvents=!1;const s=this.options,n=this._active||[],o=this._getActiveElements(t,n,e,i),a=this._positionChanged(o,t),r=e||!f(o,n)||a;return r&&(this._active=o,(s.enabled||s.external)&&(this._eventPosition={x:t.x,y:t.y},this.update(!0,e))),r}_getActiveElements(t,e,i,s){const n=this.options;if("mouseout"===t.type)return[];if(!s)return e.filter((t=>this.chart.data.datasets[t.datasetIndex]&&void 0!==this.chart.getDatasetMeta(t.datasetIndex).controller.getParsed(t.index)));const o=this.chart.getElementsAtEventForMode(t,n.mode,n,i);return n.reverse&&o.reverse(),o}_positionChanged(t,e){const{caretX:i,caretY:s,options:n}=this,o=Sa[n.position].call(this,t,e);return!1!==o&&(i!==o.x||s!==o.y)}}var Ba={id:"tooltip",_element:Va,positioners:Sa,afterInit(t,e,i){i&&(t.tooltip=new Va({chart:t,options:i}))},beforeUpdate(t,e,i){t.tooltip&&t.tooltip.initialize(i)},reset(t,e,i){t.tooltip&&t.tooltip.initialize(i)},afterDraw(t){const e=t.tooltip;if(e&&e._willRender()){const i={tooltip:e};if(!1===t.notifyPlugins("beforeTooltipDraw",{...i,cancelable:!0}))return;e.draw(t.ctx),t.notifyPlugins("afterTooltipDraw",i)}},afterEvent(t,e){if(t.tooltip){const i=e.replay;t.tooltip.handleEvent(e.event,i,e.inChartArea)&&(e.changed=!0)}},defaults:{enabled:!0,external:null,position:"average",backgroundColor:"rgba(0,0,0,0.8)",titleColor:"#fff",titleFont:{weight:"bold"},titleSpacing:2,titleMarginBottom:6,titleAlign:"left",bodyColor:"#fff",bodySpacing:2,bodyFont:{},bodyAlign:"left",footerColor:"#fff",footerSpacing:2,footerMarginTop:6,footerFont:{weight:"bold"},footerAlign:"left",padding:6,caretPadding:2,caretSize:5,cornerRadius:6,boxHeight:(t,e)=>e.bodyFont.size,boxWidth:(t,e)=>e.bodyFont.size,multiKeyBackground:"#fff",displayColors:!0,boxPadding:0,borderColor:"rgba(0,0,0,0)",borderWidth:0,animation:{duration:400,easing:"easeOutQuart"},animations:{numbers:{type:"number",properties:["x","y","width","height","caretX","caretY"]},opacity:{easing:"linear",duration:200}},callbacks:za},defaultRoutes:{bodyFont:"font",footerFont:"font",titleFont:"font"},descriptors:{_scriptable:t=>"filter"!==t&&"itemSort"!==t&&"external"!==t,_indexable:!1,callbacks:{_scriptable:!1,_indexable:!1},animation:{_fallback:!1},animations:{_fallback:"animation"}},additionalOptionScopes:["interaction"]};return An.register(Yn,jo,fo,t),An.helpers={...Wi},An._adapters=Rn,An.Animation=Cs,An.Animations=Os,An.animator=xt,An.controllers=en.controllers.items,An.DatasetController=Ns,An.Element=Hs,An.elements=fo,An.Interaction=Xi,An.layouts=as,An.platforms=Ss,An.Scale=Js,An.Ticks=ae,Object.assign(An,Yn,jo,fo,t,Ss),An.Chart=An,"undefined"!=typeof window&&(window.Chart=An),An})); +//# sourceMappingURL=chart.umd.js.map diff --git a/apps/web/public/wesp/js/modules/app-state.js b/apps/web/public/wesp/js/modules/app-state.js new file mode 100644 index 0000000..3e6260b --- /dev/null +++ b/apps/web/public/wesp/js/modules/app-state.js @@ -0,0 +1,26 @@ +export class AppState { + constructor(initialState = {}) { + this._state = { ...initialState }; + this._listeners = new Set(); + } + + get(key) { + return this._state[key]; + } + + getAll() { + return { ...this._state }; + } + + set(patch) { + this._state = { ...this._state, ...patch }; + for (const listener of this._listeners) { + listener(this.getAll()); + } + } + + subscribe(listener) { + this._listeners.add(listener); + return () => this._listeners.delete(listener); + } +} diff --git a/apps/web/public/wesp/js/modules/cross-tab-sync.js b/apps/web/public/wesp/js/modules/cross-tab-sync.js new file mode 100644 index 0000000..c0639bf --- /dev/null +++ b/apps/web/public/wesp/js/modules/cross-tab-sync.js @@ -0,0 +1,27 @@ +export class CrossTabSync { + constructor(channelName) { + this._channel = null; + if (typeof BroadcastChannel !== "undefined") { + this._channel = new BroadcastChannel(channelName); + } + } + + post(type, payload = {}) { + if (!this._channel) return; + this._channel.postMessage({ type, ...payload }); + } + + onMessage(handler) { + if (!this._channel) return () => {}; + const wrapped = (event) => handler(event.data || {}); + this._channel.addEventListener("message", wrapped); + return () => this._channel.removeEventListener("message", wrapped); + } + + close() { + if (this._channel) { + this._channel.close(); + this._channel = null; + } + } +} diff --git a/apps/web/public/wesp/js/modules/daily-plan-replace-component-modal.js b/apps/web/public/wesp/js/modules/daily-plan-replace-component-modal.js new file mode 100644 index 0000000..e564085 --- /dev/null +++ b/apps/web/public/wesp/js/modules/daily-plan-replace-component-modal.js @@ -0,0 +1,185 @@ +/** + * Модалка выбора компонента-замены (похожие + поиск), как в плане на день. + */ +(function (global) { + function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function formatKg(value) { + const n = Number(value); + if (!Number.isFinite(n)) return "—"; + const rounded = Math.round(n * 100) / 100; + return String(rounded).replace(/\.?0+$/, ""); + } + + function renderComponentPickList(items, emptyText) { + if (!items.length) { + return `

${escapeHtml(emptyText)}

`; + } + return items + .map( + (item) => + `` + ) + .join(""); + } + + async function fetchComponentAlternatives(componentId, query) { + const params = new URLSearchParams({ + component_id: componentId, + q: query || "", + limit: "100", + }); + const resp = await fetch(`/api/daily-plan/component-alternatives?${params.toString()}`, { + headers: { Accept: "application/json" }, + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.message || "Не удалось загрузить компоненты"); + } + return resp.json(); + } + + function dismissOverlays(host, keepSelectors) { + host.querySelectorAll(".zt-k-hub-plan-overlay").forEach((el) => { + if (keepSelectors.some((sel) => el.matches(sel))) return; + el.remove(); + }); + } + + /** + * @param {object} options + * @param {string} options.componentId + * @param {string} options.label + * @param {HTMLElement} [options.overlayHost] + * @param {string[]} [options.keepOverlays] — селекторы overlay, которые не закрывать + * @param {string} [options.subtitle] + * @param {(replacementComponentId: string, replacementName: string) => void|Promise} options.onPick + * @param {(message: string) => void} [options.onError] + */ + async function openReplaceComponentModal(options) { + const { + componentId, + label, + overlayHost = document.body, + keepOverlays = [], + subtitle = "", + onPick, + onError = () => {}, + } = options || {}; + if (!componentId || !onPick) return; + + const overlay = document.createElement("div"); + overlay.className = "zt-k-hub-plan-overlay zt-k-hub-plan-replace"; + const subtitleHtml = subtitle + ? `

${escapeHtml(subtitle)}

` + : ""; + overlay.innerHTML = + ''; + + dismissOverlays(overlayHost, keepOverlays); + overlay.setAttribute("data-daily-plan-overlay", "1"); + overlayHost.appendChild(overlay); + + const similarEl = overlay.querySelector("[data-replace-similar]"); + const resultsEl = overlay.querySelector("[data-replace-results]"); + const searchEl = overlay.querySelector("[data-replace-search]"); + let catalog = null; + + const close = () => { + if (overlay.isConnected) overlay.remove(); + }; + + function filterCatalog(query) { + const q = query.trim().toLowerCase(); + if (!q) { + return { + similar: catalog?.similar || [], + items: catalog?.results || [], + q: "", + }; + } + const items = (catalog?.results || []).filter((item) => + String(item.name || "").toLowerCase().includes(q) + ); + const similar = items.filter((item) => item.similar); + return { similar, items, q }; + } + + function paintLists(query = "") { + if (!overlay.isConnected) return; + const { similar, items, q } = filterCatalog(query); + if (similarEl) { + similarEl.innerHTML = renderComponentPickList( + similar, + q ? "Нет похожих по запросу" : "Нет похожих компонентов" + ); + } + if (resultsEl) { + resultsEl.innerHTML = renderComponentPickList( + items, + q ? "Ничего не найдено" : "Нет доступных компонентов" + ); + } + } + + async function loadCatalog() { + try { + catalog = await fetchComponentAlternatives(componentId, ""); + paintLists(searchEl?.value.trim() || ""); + } catch (err) { + if (!overlay.isConnected) return; + onError(err.message || "Не удалось загрузить компоненты"); + } + } + + overlay.querySelector("[data-replace-cancel]")?.addEventListener("click", close); + overlay.addEventListener("click", (event) => { + if (event.target === overlay) close(); + }); + searchEl?.addEventListener("input", () => paintLists(searchEl.value.trim())); + overlay.addEventListener("click", async (event) => { + const pick = event.target.closest("[data-pick-component-id]"); + if (!pick) return; + event.preventDefault(); + const replacementComponentId = pick.dataset.pickComponentId; + const replacementName = pick.dataset.pickComponentName || "Компонент"; + if (!replacementComponentId) return; + close(); + try { + await onPick(replacementComponentId, replacementName); + } catch (err) { + onError(err.message || "Не удалось заменить компонент"); + } + }); + + await loadCatalog(); + searchEl?.focus(); + } + + global.WespDailyPlanReplaceModal = { + open: openReplaceComponentModal, + fetchComponentAlternatives, + }; +})(window); diff --git a/apps/web/public/wesp/js/modules/daily-plan-skip-duration.js b/apps/web/public/wesp/js/modules/daily-plan-skip-duration.js new file mode 100644 index 0000000..5f963e3 --- /dev/null +++ b/apps/web/public/wesp/js/modules/daily-plan-skip-duration.js @@ -0,0 +1,124 @@ +/** + * Диалог «На какой срок…» для skip/replace/adjustment плана на день. + */ +(function (global) { + function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function weekEndIso(planDate) { + const parts = String(planDate || "").split("-"); + const d = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2])); + const jsDay = d.getDay(); + const pyWeekday = jsDay === 0 ? 6 : jsDay - 1; + d.setDate(d.getDate() + (6 - pyWeekday)); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; + } + + function todayIso() { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + } + + function dismissOverlays(host, keepSelectors) { + host.querySelectorAll(".zt-k-hub-plan-overlay").forEach((el) => { + if (keepSelectors.some((sel) => el.matches(sel))) return; + el.remove(); + }); + } + + /** + * @param {object} options + * @param {string} options.planDate YYYY-MM-DD + * @param {string} [options.title] + * @param {HTMLElement} [options.overlayHost] куда монтировать overlay + * @param {string[]} [options.keepOverlays] селекторы overlay, которые не закрывать + * @param {function} [options.onError] + * @param {function} [options.beforeAppend] (overlay) => void — dismiss other overlays + */ + function promptSkipDuration(options) { + const planDate = options.planDate || todayIso(); + const title = options.title || "На какой срок исключить?"; + const onError = options.onError || function () {}; + const host = options.overlayHost || document.body; + const keepOverlays = options.keepOverlays || []; + + return new Promise((resolve) => { + const weekEnd = weekEndIso(planDate); + const overlay = document.createElement("div"); + overlay.className = "zt-k-hub-plan-overlay zt-k-hub-plan-skip-duration"; + overlay.innerHTML = + '"; + if (typeof options.beforeAppend === "function") { + options.beforeAppend(overlay); + } else if (keepOverlays.length) { + dismissOverlays(host, keepOverlays); + } + overlay.setAttribute("data-daily-plan-overlay", "1"); + host.appendChild(overlay); + const dateInput = overlay.querySelector("[data-skip-until-date]"); + const finish = (value) => { + if (overlay.isConnected) overlay.remove(); + resolve(value); + }; + overlay.addEventListener("click", (event) => { + if (event.target === overlay) finish(null); + }); + overlay.querySelector("[data-skip-duration-cancel]")?.addEventListener("click", () => finish(null)); + overlay.querySelector("[data-skip-duration-confirm]")?.addEventListener("click", () => { + const selected = overlay.querySelector('input[name="skip-duration"]:checked'); + const mode = selected?.value || "today"; + if (mode === "week") { + finish({ duration: "week" }); + return; + } + if (mode === "date") { + const untilDate = dateInput?.value || planDate; + if (untilDate < planDate) { + onError("Дата окончания не может быть раньше начала"); + return; + } + finish({ duration: "date", untilDate }); + return; + } + finish({ duration: "today" }); + }); + }); + } + + function skipDurationBody(planDate, extra, title, options) { + return promptSkipDuration({ planDate, title, ...options }).then((choice) => { + if (!choice) return null; + return { date: planDate, ...choice, ...(extra || {}) }; + }); + } + + global.WespDailyPlanSkipDuration = { + weekEndIso, + todayIso, + promptSkipDuration, + skipDurationBody, + escapeHtml, + }; +})(window); diff --git a/apps/web/public/wesp/js/modules/kiosk/kiosk-skeleton.js b/apps/web/public/wesp/js/modules/kiosk/kiosk-skeleton.js new file mode 100644 index 0000000..e39f43d --- /dev/null +++ b/apps/web/public/wesp/js/modules/kiosk/kiosk-skeleton.js @@ -0,0 +1,209 @@ +const KIOSK_SKELETON_MIN_MS = 300; +const KIOSK_SKELETON_EXIT_MS = 160; + +const GRID_CARD_INNER = ` +
+
+
+
+
`; + +const GROUP_ITEM_INNER = ` +
+
`; + +export function buildGridSkeletonCards(rowCount = 4) { + return Array.from({ length: rowCount }, () => + `` + ).join(""); +} + +export function buildDispenserGridSkeletonHtml(rowCount = 4) { + return buildGridSkeletonCards(rowCount); +} + +export function buildPeriodRecipeGridSkeletonHtml(rowCount = 4) { + return buildGridSkeletonCards(rowCount); +} + +export function buildUnloadingGroupsSkeletonHtml(rowCount = 3) { + const items = Array.from({ length: rowCount }, () => + `
${GROUP_ITEM_INNER}
` + ).join(""); + return `
${items}
`; +} + +export function buildDuplicatePanelSkeletonHtml() { + return `
+
+
+
+
+
+
+
`; +} + +export function buildRecipeIngredientsSkeletonHtml(rowCount = 6) { + const rows = Array.from({ length: rowCount }, () => + `
+
+
+
` + ).join(""); + return `
${rows}
`; +} + +function prefersReducedMotion() { + return globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches; +} + +function isGridSkeletonHost(container) { + if (!container) return false; + return ( + container.getAttribute("aria-busy") === "true" && + container.querySelector(":scope > .kiosk-skeleton-card") != null + ); +} + +function findSkeletonRoot(container) { + if (!container) return null; + if (isGridSkeletonHost(container)) return container; + return container.querySelector( + ":scope > .kiosk-skeleton-groups, :scope > .kiosk-skeleton-table, :scope > .kiosk-skeleton-panel" + ); +} + +export function mountKioskSkeleton(container, html, label) { + if (!container) return performance.now(); + container.innerHTML = html; + container.classList.remove("kiosk-skeleton-host--exit"); + if (container.querySelector(":scope > .kiosk-skeleton-card")) { + container.setAttribute("aria-busy", "true"); + if (label) container.setAttribute("aria-label", label); + } + return performance.now(); +} + +export async function awaitKioskSkeletonMin(startedAt, minMs = KIOSK_SKELETON_MIN_MS) { + const elapsed = performance.now() - startedAt; + const rest = minMs - elapsed; + if (rest > 0) { + await new Promise((resolve) => setTimeout(resolve, rest)); + } +} + +async function fadeOutSkeleton(skel) { + if (!skel || prefersReducedMotion()) return; + const exitClass = isGridSkeletonHost(skel) + ? "kiosk-skeleton-host--exit" + : skel.classList.contains("kiosk-skeleton-groups") + ? "kiosk-skeleton-groups--exit" + : skel.classList.contains("kiosk-skeleton-table") + ? "kiosk-skeleton-table--exit" + : "kiosk-skeleton-panel--exit"; + skel.classList.add(exitClass); + await new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + skel.removeEventListener("transitionend", onEnd); + resolve(); + }; + const onEnd = (event) => { + if (event.target === skel && event.propertyName === "opacity") finish(); + }; + skel.addEventListener("transitionend", onEnd); + globalThis.setTimeout(finish, KIOSK_SKELETON_EXIT_MS + 40); + }); +} + +function clearSkeletonHostAttrs(container) { + if (!container) return; + container.classList.remove("kiosk-skeleton-host--exit"); + container.removeAttribute("aria-busy"); + container.removeAttribute("aria-label"); +} + +export async function replaceKioskContent(container, html, options = {}) { + if (!container) return; + const { minMs = KIOSK_SKELETON_MIN_MS, startedAt = performance.now() } = options; + const skel = findSkeletonRoot(container); + if (skel) { + await awaitKioskSkeletonMin(startedAt, minMs); + await fadeOutSkeleton(skel); + } else { + await awaitKioskSkeletonMin(startedAt, minMs); + } + container.innerHTML = html; + clearSkeletonHostAttrs(container); +} + +export function clearStaleKioskSkeleton(container) { + if (!container) return; + if (findSkeletonRoot(container)) { + container.innerHTML = ""; + clearSkeletonHostAttrs(container); + } +} + +export function mountDuplicatePanelSkeleton(recipeInfoEl, weightSectionEl) { + const html = buildDuplicatePanelSkeletonHtml(); + const startedAt = performance.now(); + if (recipeInfoEl) { + recipeInfoEl.style.display = "block"; + recipeInfoEl.innerHTML = html; + } + if (weightSectionEl) { + weightSectionEl.classList.add("kiosk-skeleton-host"); + const host = weightSectionEl.querySelector(".weight-content"); + if (host) { + host.dataset.kioskSkeletonBackup = host.innerHTML; + host.innerHTML = ``; + } + } + return startedAt; +} + +export function clearDuplicatePanelSkeleton(recipeInfoEl, weightSectionEl) { + if (recipeInfoEl) { + const skel = recipeInfoEl.querySelector(".kiosk-skeleton-panel"); + if (skel) { + recipeInfoEl.style.display = "none"; + recipeInfoEl.innerHTML = ` +

Рецепт:

+

Компонент 0 из 0

`; + } + } + if (weightSectionEl) { + weightSectionEl.classList.remove("kiosk-skeleton-host"); + const host = weightSectionEl.querySelector(".weight-content"); + if (host?.dataset.kioskSkeletonBackup) { + host.innerHTML = host.dataset.kioskSkeletonBackup; + delete host.dataset.kioskSkeletonBackup; + } + } +} + +const api = { + buildGridSkeletonCards, + buildDispenserGridSkeletonHtml, + buildPeriodRecipeGridSkeletonHtml, + buildUnloadingGroupsSkeletonHtml, + buildDuplicatePanelSkeletonHtml, + buildRecipeIngredientsSkeletonHtml, + mountKioskSkeleton, + replaceKioskContent, + awaitKioskSkeletonMin, + clearStaleKioskSkeleton, + mountDuplicatePanelSkeleton, + clearDuplicatePanelSkeleton, +}; + +if (typeof globalThis !== "undefined") { + globalThis.WespKioskSkeleton = api; +} diff --git a/apps/web/public/wesp/js/modules/recipes/dispenser-selector.js b/apps/web/public/wesp/js/modules/recipes/dispenser-selector.js new file mode 100644 index 0000000..0c0002f --- /dev/null +++ b/apps/web/public/wesp/js/modules/recipes/dispenser-selector.js @@ -0,0 +1,78 @@ +export class DispenserSelector { + constructor(handlers) { + this.handlers = handlers; + } + + async handleAction(action, event, actionEl) { + const recipeId = actionEl.dataset.recipeId; + const periodId = actionEl.dataset.periodId; + const dispenserId = actionEl.dataset.dispenserId; + const index = Number(actionEl.dataset.index); + + switch (action) { + case "select-dispenser": + if (dispenserId) await this.handlers.selectDispenser(dispenserId); + return true; + case "select-recipe": + if (recipeId) await this.handlers.selectRecipe(recipeId); + return true; + case "select-period": + if (periodId) await this.handlers.selectPeriod(periodId); + return true; + case "copy-recipe": + event.stopPropagation(); + if (recipeId) await this.handlers.copyRecipe(recipeId); + return true; + case "delete-recipe": + event.stopPropagation(); + if (recipeId) await this.handlers.deleteRecipe(recipeId); + return true; + case "paste-recipe": + event.stopPropagation(); + if (periodId) await this.handlers.pasteRecipe(periodId); + return true; + case "create-recipe": + event.stopPropagation(); + if (periodId) await this.handlers.createNewRecipe(periodId); + return true; + case "move-recipe-up": + event.stopPropagation(); + if (recipeId && !Number.isNaN(index)) { + await this.handlers.moveRecipeUp(recipeId, index); + } + return true; + case "move-recipe-down": + event.stopPropagation(); + if (recipeId && !Number.isNaN(index)) { + await this.handlers.moveRecipeDown(recipeId, index); + } + return true; + case "unskip-recipe-today": + event.stopPropagation(); + event.preventDefault(); + if (recipeId && typeof this.handlers.unskipRecipeToday === "function") { + await this.handlers.unskipRecipeToday(recipeId); + } + return true; + case "unskip-ingredient-parts-today": + event.stopPropagation(); + event.preventDefault(); + if (recipeId && typeof this.handlers.unskipIngredientPartsToday === "function") { + await this.handlers.unskipIngredientPartsToday(recipeId); + } + return true; + case "unskip-group-parts-today": + event.stopPropagation(); + event.preventDefault(); + if (recipeId && typeof this.handlers.unskipGroupPartsToday === "function") { + await this.handlers.unskipGroupPartsToday(recipeId); + } + return true; + case "recipe-drag-handle": + event.stopPropagation(); + return true; + default: + return false; + } + } +} diff --git a/apps/web/public/wesp/js/modules/recipes/recipe-editor.js b/apps/web/public/wesp/js/modules/recipes/recipe-editor.js new file mode 100644 index 0000000..325bff3 --- /dev/null +++ b/apps/web/public/wesp/js/modules/recipes/recipe-editor.js @@ -0,0 +1,95 @@ +export class RecipeEditor { + constructor(handlers) { + this.handlers = handlers; + } + + async handleAction(action, event, actionEl) { + switch (action) { + case "add-ingredient": + this.handlers.addIngredient(); + return true; + case "toggle-unloading-link": + this.handlers.toggleUnloadingLink(); + return true; + case "add-unloading-group": + this.handlers.addUnloadingGroup(); + return true; + case "show-recipe-edit-off": + this.handlers.showRecipeEdit(false); + return true; + case "print-all-recipes": + this.handlers.printAllRecipesInPeriod(); + return true; + case "print-recipe": + this.handlers.printRecipe(); + return true; + case "save-and-exit": + await this.handlers.saveRecipe(null, true); + return true; + case "move-group-up": + this.handlers.moveGroupUp(actionEl); + return true; + case "move-group-down": + this.handlers.moveGroupDown(actionEl); + return true; + case "remove-unloading-group": + this.handlers.removeUnloadingGroup(actionEl); + return true; + case "move-ingredient-up": + this.handlers.moveIngredientUp(actionEl); + return true; + case "move-ingredient-down": + this.handlers.moveIngredientDown(actionEl); + return true; + case "remove-ingredient": + this.handlers.removeIngredient(actionEl); + return true; + case "open-ingredient-sheet": { + const row = actionEl.closest(".ingredient-row"); + this.handlers.openIngredientMobileSheet(row); + return true; + } + case "close-ingredient-sheet": + this.handlers.closeIngredientMobileSheet(); + return true; + case "remove-ingredient-sheet": + this.handlers.removeIngredientFromSheet(); + return true; + case "open-unloading-group-sheet": { + const row = actionEl.closest(".unloading-group-row"); + this.handlers.openUnloadingGroupMobileSheet(row); + return true; + } + case "close-unloading-group-sheet": + this.handlers.closeUnloadingGroupMobileSheet(); + return true; + default: + return false; + } + } + + handleChange(action, actionEl) { + switch (action) { + case "group-type-change": + this.handlers.handleGroupTypeChange(actionEl); + return true; + case "group-value-change": + this.handlers.validateGroupValue(actionEl); + return true; + case "ingredient-change": + this.handlers.handleIngredientChange(actionEl); + return true; + case "dry-matter-percent-change": + this.handlers.handleDryMatterPercentChange(actionEl); + return true; + case "recalculate-weights": + this.handlers.recalculateWeights(); + return true; + case "recalculate-total-weight": + this.handlers.recalculateFromTotalWeight(actionEl); + return true; + default: + return false; + } + } +} diff --git a/apps/web/public/wesp/js/modules/recipes/recipe-list-dnd.js b/apps/web/public/wesp/js/modules/recipes/recipe-list-dnd.js new file mode 100644 index 0000000..ca5ae25 --- /dev/null +++ b/apps/web/public/wesp/js/modules/recipes/recipe-list-dnd.js @@ -0,0 +1,875 @@ +/** + * Drag-and-drop reorder for period recipe cards (#recipesList). + * Drag starts from .recipe-drag-handle; uses the same API as arrow buttons. + * Desktop: HTML5 DnD. Mobile: pointer reorder with card clone + insertion line. + */ +import { cloneTemplateInto } from "./recipe-skeleton.js"; + +const MOBILE_REORDER_MQ = "(max-width: 768px)"; +const POINTER_ACTIVATION_PX = { mobile: 10, desktop: 10 }; +const AUTO_SCROLL_EDGE_PX = 72; +const AUTO_SCROLL_MAX_STEP = 28; +const REORDER_TRACKING_BODY_CLASS = "recipe-list-reorder-tracking"; + +function isMobileReorderUi() { + return globalThis.matchMedia?.(MOBILE_REORDER_MQ)?.matches ?? false; +} + +function activationThresholdPx() { + return isMobileReorderUi() ? POINTER_ACTIVATION_PX.mobile : POINTER_ACTIVATION_PX.desktop; +} + +function transferBlocksReorder() { + const listRoot = document.getElementById("recipesList"); + if ( + listRoot?.classList.contains("recipe-list-reorder-active") || + document.body.classList.contains("recipe-list-reorder-active") + ) { + return false; + } + return ( + document.body.classList.contains("recipe-mobile-transfer-armed") || + document.body.classList.contains("recipe-period-transfer-active") + ); +} + +function removeRecipeDragGhost() { + const existing = document.getElementById("recipe-card-drag-ghost-root"); + if (existing?.parentNode) { + existing.parentNode.removeChild(existing); + } +} + +function removeInsertIndicator(listRoot) { + listRoot?.querySelector(":scope > .recipe-card-insert-indicator")?.remove(); +} + +function mountRecipeCardSkeletonInner(container) { + if (!cloneTemplateInto(container, "recipe-card-skeleton-inner")) { + container.innerHTML = + '
'; + } +} + +function getRecipeListItems(listRoot) { + return [...listRoot.querySelectorAll(":scope > .list-item[data-recipe-id]")]; +} + +function resolveRawInsertIndexAtY(listRoot, clientY) { + const items = getRecipeListItems(listRoot); + for (let i = 0; i < items.length; i++) { + const rect = items[i].getBoundingClientRect(); + if (clientY < rect.top + rect.height / 2) { + return i; + } + } + return items.length; +} + +function resolveInsertIndexAtY(listRoot, clientY, draggingItem) { + const items = getRecipeListItems(listRoot); + if (!draggingItem) { + return Math.max(0, Math.min(resolveRawInsertIndexAtY(listRoot, clientY), items.length)); + } + + const fromIndex = items.indexOf(draggingItem); + if (fromIndex < 0) return 0; + + let toIndex = items.length; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item === draggingItem) continue; + const rect = item.getBoundingClientRect(); + const inCard = clientY >= rect.top && clientY <= rect.bottom; + + if (inCard && fromIndex > i) { + toIndex = i; + break; + } + if (inCard && fromIndex < i) { + toIndex = clientY < rect.top + rect.height / 2 ? i : i + 1; + break; + } + if (clientY < rect.top + rect.height / 2) { + toIndex = i; + break; + } + toIndex = i + 1; + } + + if (fromIndex < toIndex) { + toIndex -= 1; + } + return Math.max(0, Math.min(toIndex, Math.max(0, items.length - 1))); +} + +function resolveReorderDropIndex(listRoot, dragEvent, draggingItem) { + const items = getRecipeListItems(listRoot); + const fromIndex = items.indexOf(draggingItem); + if (fromIndex < 0) return null; + + const toIndex = resolveInsertIndexAtY(listRoot, dragEvent.clientY, draggingItem); + if (fromIndex === toIndex) return null; + return { fromIndex, toIndex }; +} + +function resolveTrackedReorderDropIndex(listRoot, draggingItem, dropToIndex) { + const items = getRecipeListItems(listRoot); + const fromIndex = items.indexOf(draggingItem); + if (fromIndex < 0 || dropToIndex == null) return null; + const toIndex = Math.max(0, Math.min(dropToIndex, Math.max(0, items.length - 1))); + if (fromIndex === toIndex) return null; + return { fromIndex, toIndex }; +} + +function mountRecipeCardDragGhost(item, dragEvent) { + removeRecipeDragGhost(); + const ghost = document.createElement("div"); + ghost.id = "recipe-card-drag-ghost-root"; + ghost.className = "recipe-card-drag-ghost"; + ghost.setAttribute("aria-hidden", "true"); + mountRecipeCardSkeletonInner(ghost); + + const w = item.offsetWidth; + ghost.style.cssText = [ + "position:fixed", + "left:-10000px", + "top:0", + "width:" + Math.round(Math.min(Math.max(w, 200), 640)) + "px", + "pointer-events:none", + ].join(";"); + + document.body.appendChild(ghost); + void ghost.offsetWidth; + + const rect = item.getBoundingClientRect(); + const ox = Math.max(0, Math.round(dragEvent.clientX - rect.left)); + const oy = Math.max(0, Math.round(dragEvent.clientY - rect.top)); + + try { + dragEvent.dataTransfer.setDragImage(ghost, ox, oy); + } catch (_) { + /* старые движки без кастомного изображения */ + } +} + +function sanitizeDragGhostClone(node) { + node.querySelectorAll("[data-action]").forEach((el) => el.removeAttribute("data-action")); + node.querySelectorAll("button").forEach((btn) => { + btn.disabled = true; + btn.tabIndex = -1; + }); + node.classList.remove("active", "recipe-card-drop-target"); +} + +function mountPointerFollowGhost(item, clientX, clientY, grabOffsetX, grabOffsetY) { + removeRecipeDragGhost(); + + if (isMobileReorderUi()) { + const ghost = item.cloneNode(true); + ghost.id = "recipe-card-drag-ghost-root"; + ghost.className = "list-item recipe-card-drag-ghost recipe-card-drag-ghost--card-clone"; + ghost.setAttribute("aria-hidden", "true"); + sanitizeDragGhostClone(ghost); + const width = Math.round(Math.min(Math.max(item.offsetWidth, 200), window.innerWidth - 24)); + ghost.style.cssText = [ + "position:fixed", + "left:0", + "top:0", + "width:" + width + "px", + "margin:0", + "transition:none", + "transform:none", + "z-index:1300", + "pointer-events:none", + ].join(";"); + document.body.appendChild(ghost); + positionPointerFollowGhost(ghost, clientX, clientY, grabOffsetX, grabOffsetY); + return ghost; + } + + const ghost = document.createElement("div"); + ghost.id = "recipe-card-drag-ghost-root"; + ghost.className = "recipe-card-drag-ghost recipe-card-drag-ghost--follow"; + ghost.setAttribute("aria-hidden", "true"); + mountRecipeCardSkeletonInner(ghost); + ghost.style.width = `${Math.round(Math.min(Math.max(item.offsetWidth, 200), 640))}px`; + document.body.appendChild(ghost); + positionPointerFollowGhost(ghost, clientX, clientY); + return ghost; +} + +function positionPointerFollowGhost(ghost, clientX, clientY, grabOffsetX = null, grabOffsetY = null) { + if (!ghost) return; + if (grabOffsetX != null && grabOffsetY != null) { + ghost.style.left = `${clientX - grabOffsetX}px`; + ghost.style.top = `${clientY - grabOffsetY}px`; + ghost.style.transform = "none"; + return; + } + ghost.style.left = `${clientX}px`; + ghost.style.top = `${clientY}px`; + ghost.style.transform = "translate(-50%, -50%)"; +} + +function slotPickActive() { + return Boolean(document.querySelector(".recipe-transfer-slot")); +} + +function clearDropTargets(listRoot) { + listRoot.querySelectorAll(".list-item.recipe-card-drop-target").forEach((el) => { + el.classList.remove("recipe-card-drop-target"); + }); + removeInsertIndicator(listRoot); +} + +function updateInsertIndicator(listRoot, draggingItem, clientY) { + if (!isMobileReorderUi()) return; + const items = getRecipeListItems(listRoot); + if (!items.length) return; + + let line = listRoot.querySelector(":scope > .recipe-card-insert-indicator"); + if (!line) { + line = document.createElement("div"); + line.className = "recipe-card-insert-indicator"; + line.setAttribute("aria-hidden", "true"); + listRoot.appendChild(line); + } + + const insertIndex = resolveRawInsertIndexAtY(listRoot, clientY); + let yInList; + + if (insertIndex >= items.length) { + const last = items[items.length - 1]; + yInList = last.offsetTop + last.offsetHeight + 6; + } else { + yInList = items[insertIndex].offsetTop - 4; + } + + line.style.top = `${yInList}px`; + line.hidden = false; +} + +function highlightDropTarget(listRoot, draggingItem, clientY) { + clearDropTargets(listRoot); + if (isMobileReorderUi()) { + updateInsertIndicator(listRoot, draggingItem, clientY); + return; + } + const items = getRecipeListItems(listRoot); + for (const item of items) { + if (item === draggingItem) continue; + const rect = item.getBoundingClientRect(); + if (clientY >= rect.top && clientY <= rect.bottom) { + item.classList.add("recipe-card-drop-target"); + break; + } + } +} + +function configureMobileDragHandles(listRoot) { + if (!isMobileReorderUi()) return; + listRoot.querySelectorAll(".recipe-drag-handle").forEach((handle) => { + handle.setAttribute("draggable", "false"); + }); +} + +function listCanScroll(listRoot) { + return listRoot.scrollHeight > listRoot.clientHeight + 2; +} + +function getPageScrollElement() { + return document.scrollingElement || document.documentElement; +} + +function pageCanScrollUp() { + return getPageScrollElement().scrollTop > 0; +} + +function pageCanScrollDown() { + const scrollEl = getPageScrollElement(); + return scrollEl.scrollTop + window.innerHeight < scrollEl.scrollHeight - 2; +} + +function ensureMobileEdgeScrollHints() { + if (!isMobileReorderUi() || document.getElementById("recipe-reorder-edge-hints")) return; + + const wrap = document.createElement("div"); + wrap.id = "recipe-reorder-edge-hints"; + wrap.className = "recipe-reorder-edge-hints"; + wrap.setAttribute("aria-hidden", "true"); + wrap.innerHTML = + '
' + + 'К краю — вверх' + + "
" + + '
' + + 'К краю — вниз' + + "
"; + document.body.appendChild(wrap); +} + +function updateMobileEdgeScrollHints(clientY) { + if (!isMobileReorderUi()) return; + const wrap = document.getElementById("recipe-reorder-edge-hints"); + if (!wrap) return; + const vh = window.innerHeight; + wrap.querySelector(".recipe-reorder-edge-hint--top")?.classList.toggle( + "is-active", + clientY < AUTO_SCROLL_EDGE_PX && pageCanScrollUp() + ); + wrap.querySelector(".recipe-reorder-edge-hint--bottom")?.classList.toggle( + "is-active", + clientY > vh - AUTO_SCROLL_EDGE_PX && pageCanScrollDown() + ); +} + +function removeReorderUiChrome() { + document.body.classList.remove(REORDER_TRACKING_BODY_CLASS); + document.getElementById("recipe-reorder-edge-hints")?.remove(); + document.getElementById("recipe-reorder-floating-hint")?.remove(); +} + +function showRecipeReorderHint() { + if (!isMobileReorderUi() || document.getElementById("recipe-reorder-floating-hint")) return; + + const hint = document.createElement("div"); + hint.id = "recipe-reorder-floating-hint"; + hint.className = "recipe-reorder-hint recipe-reorder-hint--floating"; + hint.setAttribute("role", "status"); + hint.textContent = "Перетащите вверх или вниз. К краю экрана — прокрутка списка."; + document.body.appendChild(hint); + requestAnimationFrame(() => hint.classList.add("is-visible")); + globalThis.setTimeout(() => { + hint.classList.remove("is-visible"); + globalThis.setTimeout(() => hint.remove(), 320); + }, 3200); +} + +function autoScrollWindowAtEdge(clientY) { + const vh = window.innerHeight; + const scrollEl = getPageScrollElement(); + let delta = 0; + + if (clientY < AUTO_SCROLL_EDGE_PX) { + const speed = Math.min( + AUTO_SCROLL_MAX_STEP, + Math.ceil(((AUTO_SCROLL_EDGE_PX - Math.max(clientY, 0)) / AUTO_SCROLL_EDGE_PX) * AUTO_SCROLL_MAX_STEP) + ); + delta = -Math.max(8, speed); + } else if (clientY > vh - AUTO_SCROLL_EDGE_PX) { + const distBottom = vh - clientY; + const speed = Math.min( + AUTO_SCROLL_MAX_STEP, + Math.ceil(((AUTO_SCROLL_EDGE_PX - Math.max(distBottom, 0)) / AUTO_SCROLL_EDGE_PX) * AUTO_SCROLL_MAX_STEP) + ); + delta = Math.max(8, speed); + } + + if (delta === 0) return false; + const prev = scrollEl.scrollTop; + scrollEl.scrollTop += delta; + return scrollEl.scrollTop !== prev; +} + +function autoScrollRecipeList(listRoot, clientY) { + if (!listCanScroll(listRoot)) return false; + const rect = listRoot.getBoundingClientRect(); + const distTop = clientY - rect.top; + const distBottom = rect.bottom - clientY; + let delta = 0; + + if (distTop < AUTO_SCROLL_EDGE_PX || clientY < rect.top) { + const speed = Math.min( + AUTO_SCROLL_MAX_STEP, + Math.ceil(((AUTO_SCROLL_EDGE_PX - Math.max(distTop, 0)) / AUTO_SCROLL_EDGE_PX) * AUTO_SCROLL_MAX_STEP) + ); + delta = -Math.max(8, speed); + } else if (distBottom < AUTO_SCROLL_EDGE_PX || clientY > rect.bottom) { + const speed = Math.min( + AUTO_SCROLL_MAX_STEP, + Math.ceil(((AUTO_SCROLL_EDGE_PX - Math.max(distBottom, 0)) / AUTO_SCROLL_EDGE_PX) * AUTO_SCROLL_MAX_STEP) + ); + delta = Math.max(8, speed); + } + + if (delta === 0) return false; + listRoot.scrollTop += delta; + return true; +} + +function autoScrollDuringReorder(listRoot, clientY) { + if (isMobileReorderUi()) { + return autoScrollWindowAtEdge(clientY); + } + return autoScrollRecipeList(listRoot, clientY); +} + +function findTouchById(touchList, touchId) { + for (let i = 0; i < touchList.length; i++) { + if (touchList[i].identifier === touchId) { + return touchList[i]; + } + } + return null; +} + +function attachMobileTouchReorder(listRoot, { moveRecipe, notyf }) { + let touchDrag = null; + let dropPending = false; + let autoScrollRaf = null; + let touchMoveListener = null; + let touchEndListener = null; + + const detachTouchListeners = () => { + if (touchMoveListener) { + document.removeEventListener("touchmove", touchMoveListener, true); + touchMoveListener = null; + } + if (touchEndListener) { + document.removeEventListener("touchend", touchEndListener, true); + document.removeEventListener("touchcancel", touchEndListener, true); + touchEndListener = null; + } + }; + + const stopAutoScroll = () => { + if (autoScrollRaf != null) { + cancelAnimationFrame(autoScrollRaf); + autoScrollRaf = null; + } + }; + + const startAutoScroll = () => { + stopAutoScroll(); + const tick = () => { + if (!touchDrag?.active) { + stopAutoScroll(); + return; + } + const y = touchDrag.lastY; + autoScrollDuringReorder(listRoot, y); + updateMobileEdgeScrollHints(y); + highlightDropTarget(listRoot, touchDrag.item, y); + touchDrag.dropToIndex = resolveInsertIndexAtY(listRoot, y, touchDrag.item); + autoScrollRaf = requestAnimationFrame(tick); + }; + autoScrollRaf = requestAnimationFrame(tick); + }; + + const finishTouchDrag = () => { + detachTouchListeners(); + stopAutoScroll(); + if (!touchDrag) return; + touchDrag.item.classList.remove("recipe-card-dragging"); + listRoot.classList.remove("recipe-list-reorder-active"); + removeReorderUiChrome(); + touchDrag = null; + clearDropTargets(listRoot); + removeRecipeDragGhost(); + }; + + const activateTouchDrag = (touch) => { + if (!touchDrag || touchDrag.active) return; + touchDrag.active = true; + touchDrag.item.classList.add("recipe-card-dragging"); + listRoot.classList.add("recipe-list-reorder-active"); + document.body.classList.add(REORDER_TRACKING_BODY_CLASS); + listRoot.dispatchEvent(new CustomEvent("recipe-reorder-active", { bubbles: true })); + ensureMobileEdgeScrollHints(); + showRecipeReorderHint(); + touchDrag.ghost = mountPointerFollowGhost( + touchDrag.item, + touch.clientX, + touch.clientY, + touchDrag.grabOffsetX, + touchDrag.grabOffsetY + ); + startAutoScroll(); + globalThis.navigator?.vibrate?.(8); + }; + + const handleTouchMove = (e) => { + if (!touchDrag) return; + const touch = findTouchById(e.touches, touchDrag.touchId); + if (!touch) return; + + e.preventDefault(); + if (transferBlocksReorder()) { + finishTouchDrag(); + return; + } + + touchDrag.lastY = touch.clientY; + const dx = Math.abs(touch.clientX - touchDrag.startX); + const dy = Math.abs(touch.clientY - touchDrag.startY); + if (!touchDrag.active && dx + dy < activationThresholdPx()) return; + + if (!touchDrag.active) { + activateTouchDrag(touch); + } + + positionPointerFollowGhost( + touchDrag.ghost, + touch.clientX, + touch.clientY, + touchDrag.grabOffsetX, + touchDrag.grabOffsetY + ); + updateMobileEdgeScrollHints(touch.clientY); + highlightDropTarget(listRoot, touchDrag.item, touch.clientY); + touchDrag.dropToIndex = resolveInsertIndexAtY(listRoot, touch.clientY, touchDrag.item); + }; + + const handleTouchEnd = (e) => { + if (!touchDrag) return; + const touch = findTouchById(e.changedTouches, touchDrag.touchId); + if (!touch) return; + + const { item, active, dropToIndex } = touchDrag; + finishTouchDrag(); + + if (!active || dropPending) return; + if (transferBlocksReorder() && slotPickActive()) return; + + const indices = + resolveReorderDropIndex(listRoot, touch, item) || + resolveTrackedReorderDropIndex(listRoot, item, dropToIndex); + if (!indices) return; + + void moveRecipe(item.dataset.recipeId, indices.fromIndex, indices.toIndex); + }; + + listRoot.addEventListener("recipe-transfer-armed", () => { + if (touchDrag?.active) { + finishTouchDrag(); + } else if (touchDrag) { + detachTouchListeners(); + removeReorderUiChrome(); + touchDrag = null; + } + }); + + if (typeof MutationObserver !== "undefined") { + const handleObserver = new MutationObserver(() => configureMobileDragHandles(listRoot)); + handleObserver.observe(listRoot, { childList: true }); + configureMobileDragHandles(listRoot); + } + + listRoot.addEventListener( + "touchstart", + (e) => { + if (e.touches.length !== 1) return; + if (transferBlocksReorder()) return; + const handle = e.target.closest(".recipe-drag-handle"); + if (!handle || !listRoot.contains(handle)) return; + const item = handle.closest(".list-item[data-recipe-id]"); + if (!item || item.dataset.index === undefined) return; + + const touch = e.touches[0]; + const handleRect = handle.getBoundingClientRect(); + touchDrag = { + item, + touchId: touch.identifier, + startX: touch.clientX, + startY: touch.clientY, + lastY: touch.clientY, + grabOffsetX: touch.clientX - handleRect.left, + grabOffsetY: touch.clientY - handleRect.top, + active: false, + ghost: null, + dropToIndex: null, + }; + e.stopPropagation(); + touchMoveListener = handleTouchMove; + touchEndListener = handleTouchEnd; + document.addEventListener("touchmove", touchMoveListener, { capture: true, passive: false }); + document.addEventListener("touchend", touchEndListener, { capture: true }); + document.addEventListener("touchcancel", touchEndListener, { capture: true }); + }, + { capture: true, passive: true } + ); +} + +function attachPointerReorder(listRoot, { moveRecipe, notyf }) { + if (isMobileReorderUi()) { + attachMobileTouchReorder(listRoot, { moveRecipe, notyf }); + return; + } + + let pointerDrag = null; + let dropPending = false; + let autoScrollRaf = null; + let documentDragMove = null; + let documentDragEnd = null; + + const detachDocumentDragListeners = () => { + if (documentDragMove) { + document.removeEventListener("pointermove", documentDragMove, true); + documentDragMove = null; + } + if (documentDragEnd) { + document.removeEventListener("pointerup", documentDragEnd, true); + document.removeEventListener("pointercancel", documentDragEnd, true); + documentDragEnd = null; + } + }; + + const stopAutoScroll = () => { + if (autoScrollRaf != null) { + cancelAnimationFrame(autoScrollRaf); + autoScrollRaf = null; + } + }; + + const startAutoScroll = () => { + stopAutoScroll(); + const tick = () => { + if (!pointerDrag?.active) { + stopAutoScroll(); + return; + } + const y = pointerDrag.lastY; + autoScrollDuringReorder(listRoot, y); + highlightDropTarget(listRoot, pointerDrag.item, y); + pointerDrag.dropToIndex = resolveInsertIndexAtY(listRoot, y, pointerDrag.item); + autoScrollRaf = requestAnimationFrame(tick); + }; + autoScrollRaf = requestAnimationFrame(tick); + }; + + const finishPointerDrag = () => { + detachDocumentDragListeners(); + stopAutoScroll(); + if (!pointerDrag) return; + pointerDrag.item.classList.remove("recipe-card-dragging"); + listRoot.classList.remove("recipe-list-reorder-active"); + removeReorderUiChrome(); + pointerDrag = null; + clearDropTargets(listRoot); + removeRecipeDragGhost(); + }; + + const isOverTransferHoverTarget = (clientX, clientY) => { + const el = document.elementFromPoint(clientX, clientY); + return !!( + el?.closest("#periodsList .list-item[data-period-id]") || + el?.closest("#dispensersList .list-item[data-dispenser-id]") + ); + }; + + const handlePointerMove = (e) => { + if (!pointerDrag || e.pointerId !== pointerDrag.pointerId) return; + if (transferBlocksReorder()) { + finishPointerDrag(); + return; + } + if (!pointerDrag.active && isOverTransferHoverTarget(e.clientX, e.clientY)) { + pointerDrag.lastY = e.clientY; + return; + } + + e.preventDefault(); + pointerDrag.lastY = e.clientY; + const dx = Math.abs(e.clientX - pointerDrag.startX); + const dy = Math.abs(e.clientY - pointerDrag.startY); + if (!pointerDrag.active && dx + dy < activationThresholdPx()) return; + + if (!pointerDrag.active) { + pointerDrag.active = true; + pointerDrag.item.classList.add("recipe-card-dragging"); + listRoot.classList.add("recipe-list-reorder-active"); + listRoot.dispatchEvent(new CustomEvent("recipe-reorder-active", { bubbles: true })); + pointerDrag.ghost = mountPointerFollowGhost( + pointerDrag.item, + e.clientX, + e.clientY, + pointerDrag.grabOffsetX, + pointerDrag.grabOffsetY + ); + startAutoScroll(); + } + + positionPointerFollowGhost( + pointerDrag.ghost, + e.clientX, + e.clientY, + pointerDrag.grabOffsetX, + pointerDrag.grabOffsetY + ); + updateMobileEdgeScrollHints(e.clientY); + highlightDropTarget(listRoot, pointerDrag.item, e.clientY); + pointerDrag.dropToIndex = resolveInsertIndexAtY(listRoot, e.clientY, pointerDrag.item); + }; + + const onPointerEnd = (e) => { + if (!pointerDrag || e.pointerId !== pointerDrag.pointerId) return; + + const { item, active, dropToIndex } = pointerDrag; + finishPointerDrag(); + + if (!active || dropPending) return; + if (transferBlocksReorder() && slotPickActive()) return; + + const indices = + resolveReorderDropIndex(listRoot, e, item) || + resolveTrackedReorderDropIndex(listRoot, item, dropToIndex); + if (!indices) return; + + void moveRecipe(item.dataset.recipeId, indices.fromIndex, indices.toIndex); + }; + + const attachDocumentDragListeners = () => { + if (documentDragMove) return; + documentDragMove = handlePointerMove; + documentDragEnd = onPointerEnd; + document.addEventListener("pointermove", documentDragMove, { capture: true, passive: false }); + document.addEventListener("pointerup", documentDragEnd, { capture: true }); + document.addEventListener("pointercancel", documentDragEnd, { capture: true }); + }; + + const cancelPointerDrag = () => { + if (!pointerDrag) return; + if (pointerDrag.active) return; + finishPointerDrag(); + }; + + listRoot.addEventListener("recipe-list-drag-cancel", cancelPointerDrag); + + listRoot.addEventListener("recipe-transfer-armed", () => { + if (pointerDrag && !pointerDrag.active) { + detachDocumentDragListeners(); + pointerDrag = null; + } else if (pointerDrag?.active) { + finishPointerDrag(); + } + }); + + listRoot.addEventListener( + "pointerdown", + (e) => { + if (e.pointerType === "mouse" && e.button !== 0) return; + if (transferBlocksReorder()) return; + const handle = e.target.closest(".recipe-drag-handle"); + if (!handle || !listRoot.contains(handle)) return; + const item = handle.closest(".list-item[data-recipe-id]"); + if (!item || item.dataset.index === undefined) return; + e.stopPropagation(); + + const handleRect = handle.getBoundingClientRect(); + pointerDrag = { + item, + pointerId: e.pointerId, + startX: e.clientX, + startY: e.clientY, + lastY: e.clientY, + grabOffsetX: e.clientX - handleRect.left, + grabOffsetY: e.clientY - handleRect.top, + active: false, + ghost: null, + dropToIndex: null, + }; + attachDocumentDragListeners(); + }, + { capture: true, passive: false } + ); +} + +export function attachRecipeListDragDrop(listRoot, { moveRecipe, notyf }) { + if (!listRoot || typeof moveRecipe !== "function") return; + + let draggingItem = null; + let dropPending = false; + + listRoot.addEventListener("dragstart", (e) => { + if (isMobileReorderUi()) { + e.preventDefault(); + return; + } + if (transferBlocksReorder()) { + e.preventDefault(); + return; + } + const handle = e.target.closest(".recipe-drag-handle"); + if (!handle || !listRoot.contains(handle)) return; + const item = handle.closest(".list-item[data-recipe-id]"); + if (!item || item.dataset.index === undefined) return; + e.stopPropagation(); + draggingItem = item; + item.classList.add("recipe-card-dragging"); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", item.dataset.recipeId || ""); + mountRecipeCardDragGhost(item, e); + }); + + listRoot.addEventListener("dragend", () => { + removeRecipeDragGhost(); + if (draggingItem) { + draggingItem.classList.remove("recipe-card-dragging"); + } + draggingItem = null; + dropPending = false; + clearDropTargets(listRoot); + }); + + listRoot.addEventListener("dragover", (e) => { + if (!draggingItem) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + + const target = e.target.closest(".list-item[data-recipe-id]"); + clearDropTargets(listRoot); + if (target && target !== draggingItem) { + target.classList.add("recipe-card-drop-target"); + } + }); + + listRoot.addEventListener("dragleave", (e) => { + if (!listRoot.contains(e.relatedTarget)) { + clearDropTargets(listRoot); + } + }); + + listRoot.addEventListener("drop", async (e) => { + if (!draggingItem || dropPending) return; + if (transferBlocksReorder() && slotPickActive()) return; + e.preventDefault(); + e.stopPropagation(); + + clearDropTargets(listRoot); + + const indices = resolveReorderDropIndex(listRoot, e, draggingItem); + if (!indices) return; + + void moveRecipe(draggingItem.dataset.recipeId, indices.fromIndex, indices.toIndex); + }); + + const cancelHtml5Drag = () => { + if (listRoot.classList.contains("recipe-list-reorder-active")) return; + removeRecipeDragGhost(); + if (draggingItem) { + draggingItem.classList.remove("recipe-card-dragging"); + draggingItem = null; + } + dropPending = false; + clearDropTargets(listRoot); + }; + + listRoot.addEventListener("recipe-list-drag-cancel", cancelHtml5Drag); + + if (!listRoot.dataset.recipeDragCancelBound) { + listRoot.dataset.recipeDragCancelBound = "1"; + document.addEventListener( + "keydown", + (e) => { + if (e.key !== "Escape") return; + cancelHtml5Drag(); + listRoot.dispatchEvent( + new CustomEvent("recipe-list-drag-cancel", { bubbles: true }) + ); + }, + true + ); + } + + attachPointerReorder(listRoot, { moveRecipe, notyf }); +} diff --git a/apps/web/public/wesp/js/modules/recipes/recipe-mobile-sheet-gestures.js b/apps/web/public/wesp/js/modules/recipes/recipe-mobile-sheet-gestures.js new file mode 100644 index 0000000..12da8a3 --- /dev/null +++ b/apps/web/public/wesp/js/modules/recipes/recipe-mobile-sheet-gestures.js @@ -0,0 +1,166 @@ +const MOBILE_SHEET_MQ = "(max-width: 768px)"; + +function getSheetParts(root) { + if (!root) return null; + const panel = root.querySelector( + ".recipe-mobile-edit-sheet-panel, .ingredient-mobile-sheet-panel, .recipe-help-modal-panel" + ); + const body = root.querySelector( + ".recipe-mobile-edit-sheet-body, .ingredient-mobile-sheet-body, .recipe-help-modal-body" + ); + const backdrop = root.querySelector(".recipe-mobile-edit-sheet-backdrop"); + return panel ? { root, panel, body, backdrop } : null; +} + +export function resetRecipeMobileSheetPresentation(root) { + const parts = getSheetParts(root); + if (!parts) return; + const { panel, backdrop } = parts; + panel.style.transform = ""; + panel.style.transition = ""; + panel.classList.remove("recipe-mobile-edit-sheet--dragging"); + if (backdrop) { + backdrop.style.opacity = ""; + backdrop.style.transition = ""; + } +} + +function canStartSheetDrag(target, body) { + if (target.closest(".recipe-mobile-edit-sheet-done")) return false; + if (target.closest("button, input, select, textarea, a, label")) { + return !!target.closest(".recipe-mobile-edit-sheet-grab, .recipe-mobile-edit-sheet-title"); + } + if (target.closest(".recipe-mobile-edit-sheet-grab, .recipe-mobile-edit-sheet-header")) { + return true; + } + if (body?.contains(target) && body.scrollTop <= 0) { + return true; + } + return false; +} + +function bindSheetGestures(root, onClose, mobileQuery) { + const parts = getSheetParts(root); + if (!parts || typeof onClose !== "function") return; + + const { panel, body, backdrop } = parts; + let dragState = null; + + function clearDragState() { + dragState = null; + panel.classList.remove("recipe-mobile-edit-sheet--dragging"); + } + + function applyDrag(deltaY) { + const offset = Math.max(0, deltaY); + panel.style.transition = "none"; + panel.style.transform = `translateY(${offset}px)`; + if (backdrop) { + backdrop.style.transition = "none"; + backdrop.style.opacity = String(Math.max(0, 1 - offset / 280)); + } + } + + function snapBack() { + panel.style.transition = "transform 0.24s ease"; + panel.style.transform = ""; + if (backdrop) { + backdrop.style.transition = "opacity 0.24s ease"; + backdrop.style.opacity = ""; + } + window.setTimeout(() => { + panel.style.transition = ""; + if (backdrop) backdrop.style.transition = ""; + clearDragState(); + }, 240); + } + + function dismissWithAnimation() { + panel.style.transition = "transform 0.22s ease"; + panel.style.transform = "translateY(100%)"; + if (backdrop) { + backdrop.style.transition = "opacity 0.22s ease"; + backdrop.style.opacity = "0"; + } + window.setTimeout(() => { + resetRecipeMobileSheetPresentation(root); + onClose(); + }, 220); + } + + function onPointerDown(event) { + if (!mobileQuery.matches || root.hidden) return; + if (event.pointerType === "mouse" && event.button !== 0) return; + if (!canStartSheetDrag(event.target, body)) return; + + dragState = { + pointerId: event.pointerId, + startY: event.clientY, + fromScrollableBody: !!(body && body.contains(event.target) && !event.target.closest(".recipe-mobile-edit-sheet-header, .recipe-mobile-edit-sheet-grab")), + }; + panel.classList.add("recipe-mobile-edit-sheet--dragging"); + panel.setPointerCapture(event.pointerId); + } + + function onPointerMove(event) { + if (!dragState || event.pointerId !== dragState.pointerId) return; + + const deltaY = event.clientY - dragState.startY; + if (dragState.fromScrollableBody) { + if (deltaY <= 0 || (body && body.scrollTop > 0)) { + clearDragState(); + resetRecipeMobileSheetPresentation(root); + return; + } + if (deltaY > 8) { + dragState.fromScrollableBody = false; + } else { + return; + } + } + + if (deltaY > 0) { + event.preventDefault(); + applyDrag(deltaY); + } + } + + function onPointerEnd(event) { + if (!dragState || event.pointerId !== dragState.pointerId) return; + + const deltaY = event.clientY - dragState.startY; + const threshold = Math.min(96, panel.offsetHeight * 0.2); + clearDragState(); + + if (deltaY > threshold) { + dismissWithAnimation(); + return; + } + snapBack(); + } + + panel.addEventListener("pointerdown", onPointerDown); + panel.addEventListener("pointermove", onPointerMove, { passive: false }); + panel.addEventListener("pointerup", onPointerEnd); + panel.addEventListener("pointercancel", onPointerEnd); +} + +export function initRecipeMobileSheetGestures(handlers = {}) { + const mobileQuery = window.matchMedia(MOBILE_SHEET_MQ); + const sheets = [ + { + root: document.getElementById("ingredientMobileSheet"), + onClose: handlers.closeIngredientMobileSheet, + }, + { + root: document.getElementById("unloadingGroupMobileSheet"), + onClose: handlers.closeUnloadingGroupMobileSheet, + }, + { + root: document.getElementById("recipeHelpModal"), + onClose: handlers.closeRecipeHelpModal, + }, + ]; + + sheets.forEach(({ root, onClose }) => bindSheetGestures(root, onClose, mobileQuery)); +} diff --git a/apps/web/public/wesp/js/modules/recipes/recipe-period-transfer.js b/apps/web/public/wesp/js/modules/recipes/recipe-period-transfer.js new file mode 100644 index 0000000..a16a833 --- /dev/null +++ b/apps/web/public/wesp/js/modules/recipes/recipe-period-transfer.js @@ -0,0 +1,655 @@ +/** + * Перенос рейса в другой период/оборудование: drag с ручки, удержание 1–2 с над целью, выбор позиции. + * Отмена: отпустить не над целью / Escape; другой период или оборудование — снова удержать над нужной карточкой или заново перетащить с ручки. + */ +const LONG_HOVER_MS = 1400; +const MOBILE_TRANSFER_MQ = "(max-width: 768px)"; +const MOBILE_TRANSFER_LONG_PRESS_MS = 750; +const MOBILE_TRANSFER_MOVE_CANCEL_PX = 14; +const TRANSFER_MIME = "application/x-wesp-recipe-transfer"; + +export function attachRecipePeriodTransfer({ + getCurrentDispenserType, + getSelectedDispenser, + getSelectedPeriod, + selectDispenser, + selectPeriod, + loadRecipes, + loadPeriods, + notyf, +}) { + const recipesList = document.getElementById("recipesList"); + + let session = null; + let slotPick = false; + let transferInFlight = false; + let longHoverState = { kind: null, id: null, since: 0, fired: false }; + let rafHoverId = null; + let listMutationTimer = null; + + function clearLongHover() { + longHoverState = { kind: null, id: null, since: 0, fired: false }; + if (rafHoverId != null) { + cancelAnimationFrame(rafHoverId); + rafHoverId = null; + } + } + + function isTransferAllowed() { + return ( + typeof getCurrentDispenserType === "function" && + getCurrentDispenserType() === "dispenser" && + getSelectedPeriod() + ); + } + + function clearEligibleHighlights() { + document.querySelectorAll(".recipe-transfer-eligible").forEach((el) => { + el.classList.remove("recipe-transfer-eligible"); + }); + } + + function applyEligibleHighlights() { + clearEligibleHighlights(); + if (!session || slotPick) return; + document.querySelectorAll("#dispensersList .list-item[data-dispenser-id]").forEach((el) => { + if (el.dataset.dispenserType === "mill") return; + el.classList.add("recipe-transfer-eligible"); + }); + document.querySelectorAll("#periodsList .list-item[data-period-id]").forEach((el) => { + const pid = el.dataset.periodId; + if ( + String(pid) === String(session.fromPeriodId) && + String(getSelectedDispenser()) === String(session.fromDispenserId) + ) { + return; + } + el.classList.add("recipe-transfer-eligible"); + }); + } + + function startSession(recipeId, fromDispenserId, fromPeriodId) { + clearSlotPickUi(); + session = { recipeId, fromDispenserId, fromPeriodId }; + slotPick = false; + /* Подсветку целей не включаем здесь — иначе reorder в том же периоде блокируется с первого mousedown. */ + } + + function setMobileTransferOverlay(active) { + if (!globalThis.matchMedia?.("(max-width: 768px)")?.matches) return; + document.body.classList.toggle("recipe-transfer-session", !!active); + } + + let pointerCaptureEl = null; + let pointerCaptureId = null; + let pendingDragItem = null; + + function releaseTransferPointerCapture() { + if (pointerCaptureEl && pointerCaptureId != null) { + try { + pointerCaptureEl.releasePointerCapture(pointerCaptureId); + } catch (_) { + /* ignore */ + } + } + pointerCaptureEl = null; + pointerCaptureId = null; + } + + function clearTransferSessionState({ emitDragCancel = true } = {}) { + if (listMutationTimer != null) { + clearTimeout(listMutationTimer); + listMutationTimer = null; + } + clearMobilePressState(); + releaseTransferPointerCapture(); + pendingDragItem = null; + mouseSessionActive = false; + session = null; + slotPick = false; + pointerSessionId = null; + document.body.classList.remove("recipe-period-transfer-active"); + document.body.classList.remove("recipe-mobile-transfer-armed"); + setMobileTransferOverlay(false); + clearEligibleHighlights(); + clearSlotPickUi(); + clearLongHover(); + if (emitDragCancel) { + recipesList?.dispatchEvent( + new CustomEvent("recipe-list-drag-cancel", { bubbles: true }) + ); + } + } + + function endSession() { + clearTransferSessionState({ emitDragCancel: true }); + } + + function clearSlotPickUi() { + document.querySelectorAll(".recipe-transfer-slot").forEach((n) => n.remove()); + } + + function isValidDropTarget(toDispenser, toPeriod) { + if (!toDispenser || !toPeriod || !session) return false; + return !( + String(toDispenser) === String(session.fromDispenserId) && + String(toPeriod) === String(session.fromPeriodId) + ); + } + + /** Позиция вставки по Y относительно карточек (без учёта кнопок-слотов). */ + function insertIndexFromRecipesListDrop(listRoot, e) { + if (!listRoot) return 0; + const y = e.clientY; + const items = [...listRoot.querySelectorAll(":scope > .list-item[data-recipe-id]")]; + for (let i = 0; i < items.length; i++) { + const r = items[i].getBoundingClientRect(); + if (y < r.top + r.height / 2) return i; + } + return items.length; + } + + async function commitTransfer(toIndex) { + if (!session || transferInFlight) return; + transferInFlight = true; + const sess = session; + const toDispenser = getSelectedDispenser(); + const toPeriod = getSelectedPeriod(); + try { + if (!sess || !toDispenser || !toPeriod || !isValidDropTarget(toDispenser, toPeriod)) { + endSession(); + return; + } + const url = `/api/feed_dispensers/${encodeURIComponent(toDispenser)}/periods/${encodeURIComponent(toPeriod)}/recipes/${encodeURIComponent(sess.recipeId)}/transfer`; + const r = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + from_dispenser_id: sess.fromDispenserId, + from_period_id: sess.fromPeriodId, + to_index: toIndex, + }), + }); + const j = await r.json().catch(() => ({})); + if (!r.ok) { + const msg = j.message || "Ошибка переноса"; + if (notyf) { + if (r.status === 409) { + if (typeof notyf.warning === "function") notyf.warning(msg); + else notyf.error(msg); + } else { + notyf.error(msg); + } + } + clearSlotPickUi(); + slotPick = false; + if (r.status === 409) { + try { + if (typeof loadPeriods === "function") await loadPeriods(toDispenser); + if (typeof loadRecipes === "function") await loadRecipes(toPeriod); + } catch (_) { + /* ignore */ + } + } + endSession(); + return; + } + clearSlotPickUi(); + slotPick = false; + if (notyf) notyf.success(j.message || "Рейс перенесён"); + if (typeof loadPeriods === "function") await loadPeriods(toDispenser); + if (typeof loadRecipes === "function") await loadRecipes(toPeriod); + endSession(); + } catch (err) { + console.error(err); + if (notyf) notyf.error(err.message, { fallback: "Ошибка переноса" }); + endSession(); + } finally { + transferInFlight = false; + } + } + + function scheduleHoverCheck() { + if (rafHoverId != null) return; + const tick = () => { + rafHoverId = null; + if (!session || longHoverState.kind == null) return; + const elapsed = performance.now() - longHoverState.since; + if (!longHoverState.fired && elapsed >= LONG_HOVER_MS) { + longHoverState.fired = true; + void fireLongHover(longHoverState.kind, longHoverState.id); + return; + } + if (!longHoverState.fired && session) { + rafHoverId = requestAnimationFrame(tick); + } + }; + rafHoverId = requestAnimationFrame(tick); + } + + function trackLongHover(kind, id) { + if (!session) return; + if (longHoverState.kind !== kind || longHoverState.id !== id) { + longHoverState = { kind, id, since: performance.now(), fired: false }; + applyEligibleHighlights(); + } + setMobileTransferOverlay(true); + scheduleHoverCheck(); + } + + async function fireLongHover(kind, id) { + if (!session) return; + transferHoverCommitPending = true; + try { + await fireLongHoverImpl(kind, id); + } finally { + transferHoverCommitPending = false; + } + } + + async function fireLongHoverImpl(kind, id) { + if (!session) return; + if (kind === "dispenser") { + if (String(id) === String(getSelectedDispenser())) return; + await selectDispenser(id); + document.body.classList.add("recipe-period-transfer-active"); + applyEligibleHighlights(); + return; + } + if (kind === "period") { + if ( + String(id) === String(session.fromPeriodId) && + String(getSelectedDispenser()) === String(session.fromDispenserId) + ) { + if (notyf) notyf.error("Выберите другой период"); + return; + } + if (slotPick && String(id) === String(getSelectedPeriod())) return; + await selectPeriod(id); + document.body.classList.add("recipe-period-transfer-active"); + enterSlotPickMode(); + } + } + + function enterSlotPickMode() { + slotPick = true; + clearEligibleHighlights(); + clearLongHover(); + setMobileTransferOverlay(true); + requestAnimationFrame(() => injectSlots()); + } + + /** Список рейсов перерисован (loadRecipes, смена периода, перестановка) — убрать «Вставить здесь», если они уже не в DOM. */ + function syncSlotPickAfterListMutation() { + if (!session || !slotPick) return; + const list = document.getElementById("recipesList"); + if (!list || list.querySelector(".recipe-transfer-slot")) return; + endSession(); + } + + function scheduleSyncAfterListMutation() { + clearTimeout(listMutationTimer); + listMutationTimer = setTimeout(() => { + listMutationTimer = null; + syncSlotPickAfterListMutation(); + }, 80); + } + + if (recipesList) { + new MutationObserver(() => scheduleSyncAfterListMutation()).observe(recipesList, { + childList: true, + subtree: false, + }); + } + + function injectSlots() { + clearSlotPickUi(); + const list = document.getElementById("recipesList"); + if (!list || !session) return; + const items = [...list.querySelectorAll(":scope > .list-item[data-recipe-id]")]; + for (let i = 0; i <= items.length; i++) { + const slot = document.createElement("button"); + slot.type = "button"; + slot.className = "recipe-transfer-slot"; + slot.dataset.insertIndex = String(i); + slot.innerHTML = + 'Вставить здесь'; + slot.setAttribute("aria-label", `Вставить рейс на позицию ${i + 1}`); + slot.addEventListener("click", onSlotClick); + if (i < items.length) { + list.insertBefore(slot, items[i]); + } else { + list.appendChild(slot); + } + } + } + + function onSlotClick(e) { + const slot = e.target.closest(".recipe-transfer-slot"); + if (!slot || !session) return; + const toIndex = Number(slot.dataset.insertIndex); + if (Number.isNaN(toIndex)) return; + void commitTransfer(toIndex); + } + + function tryBeginSessionFromPendingDrag(clientX, clientY) { + if (session || !pendingDragItem) return false; + const target = document.elementFromPoint(clientX, clientY); + if (target?.closest("#recipesList .list-item[data-recipe-id]")) return false; + const dItem = target?.closest("#dispensersList .list-item[data-dispenser-id]"); + const pItem = target?.closest("#periodsList .list-item[data-period-id]"); + if (!dItem && !pItem) return false; + if (dItem?.dataset.dispenserType === "mill") return false; + beginHandleSession(pendingDragItem); + return true; + } + + function onDocumentDragOver(e) { + if (!session) { + if (tryBeginSessionFromPendingDrag(e.clientX, e.clientY)) { + handleSessionHoverAtPoint(e.clientX, e.clientY, e); + } + return; + } + handleSessionHoverAtPoint(e.clientX, e.clientY, e); + } + + function handleSessionHoverAtPoint(clientX, clientY, dragEvent) { + if (!session) return; + + if (slotPick && recipesList && recipesList.contains(document.elementFromPoint(clientX, clientY))) { + if (dragEvent?.preventDefault) { + dragEvent.preventDefault(); + if (dragEvent.dataTransfer) dragEvent.dataTransfer.dropEffect = "move"; + } + return; + } + + if (slotPick) return; + + const target = document.elementFromPoint(clientX, clientY); + const dItem = target?.closest("#dispensersList .list-item[data-dispenser-id]"); + const pItem = target?.closest("#periodsList .list-item[data-period-id]"); + if (dItem) { + if (dItem.dataset.dispenserType === "mill") { + clearLongHover(); + return; + } + if (dragEvent?.preventDefault) { + dragEvent.preventDefault(); + if (dragEvent.dataTransfer) dragEvent.dataTransfer.dropEffect = "move"; + } + trackLongHover("dispenser", dItem.dataset.dispenserId); + return; + } + if (pItem) { + if (dragEvent?.preventDefault) { + dragEvent.preventDefault(); + if (dragEvent.dataTransfer) dragEvent.dataTransfer.dropEffect = "move"; + } + trackLongHover("period", pItem.dataset.periodId); + return; + } + clearLongHover(); + } + + function handleSessionDropAtPoint(clientX, clientY, dragEvent) { + if (!session) return false; + + const target = document.elementFromPoint(clientX, clientY); + + if (slotPick && recipesList && target && recipesList.contains(target)) { + const toDispenser = getSelectedDispenser(); + const toPeriod = getSelectedPeriod(); + if (!isValidDropTarget(toDispenser, toPeriod)) { + dragEvent?.preventDefault?.(); + dragEvent?.stopPropagation?.(); + endSession(); + return true; + } + dragEvent?.preventDefault?.(); + dragEvent?.stopPropagation?.(); + const toIndex = insertIndexFromRecipesListDrop(recipesList, { clientY }); + void commitTransfer(toIndex); + return true; + } + + if (slotPick) return false; + + const recipeDrop = target?.closest("#recipesList .list-item[data-recipe-id]"); + if (recipeDrop) return false; + + const dItem = target?.closest("#dispensersList .list-item[data-dispenser-id]"); + const pItem = target?.closest("#periodsList .list-item[data-period-id]"); + if (dItem || pItem) { + dragEvent?.preventDefault?.(); + return true; + } + + dragEvent?.preventDefault?.(); + endSession(); + return true; + } + + function onDocumentDropCapture(e) { + if (!session) return; + handleSessionDropAtPoint(e.clientX, e.clientY, e); + } + + function beginHandleSession(item) { + if (!item || item.dataset.index === undefined) return; + if (!isTransferAllowed()) return; + const recipeId = item.dataset.recipeId; + const fromDispenserId = getSelectedDispenser(); + const fromPeriodId = getSelectedPeriod(); + startSession(recipeId, fromDispenserId, fromPeriodId); + } + + function onDragStartCapture(e) { + const handle = e.target.closest(".recipe-drag-handle"); + if (!handle || !recipesList || !recipesList.contains(handle)) return; + const item = handle.closest(".list-item[data-recipe-id]"); + if (!item) return; + /* Сессию transfer не открываем сразу — иначе ломается повторный reorder в том же списке. */ + pendingDragItem = item; + try { + e.dataTransfer.setData( + TRANSFER_MIME, + JSON.stringify({ + recipeId: item.dataset.recipeId, + fromDispenserId: getSelectedDispenser(), + fromPeriodId: getSelectedPeriod(), + }) + ); + } catch (_) { + /* ignore */ + } + } + + function onDragEndCapture(e) { + const handle = e.target.closest(".recipe-drag-handle"); + if (!handle || !recipesList || !recipesList.contains(handle)) return; + if (slotPick && session) return; + pendingDragItem = null; + releaseTransferPointerCapture(); + pointerSessionId = null; + mouseSessionActive = false; + if (recipesList.classList.contains("recipe-list-reorder-active")) { + if (!slotPick) clearTransferSessionState({ emitDragCancel: false }); + return; + } + if (session) clearTransferSessionState({ emitDragCancel: true }); + } + + let pointerSessionId = null; + let mouseSessionActive = false; + let transferHoverCommitPending = false; + let mobilePressState = null; + + function isMobileTransferUi() { + return globalThis.matchMedia?.(MOBILE_TRANSFER_MQ)?.matches ?? false; + } + + function clearMobilePressState() { + if (mobilePressState?.longPressTimer) { + clearTimeout(mobilePressState.longPressTimer); + } + mobilePressState = null; + } + + function cancelMobilePressForReorder() { + clearMobilePressState(); + } + + function armMobileTransferSession(item) { + if (!item || !isTransferAllowed()) return; + beginHandleSession(item); + document.body.classList.add("recipe-mobile-transfer-armed"); + recipesList?.dispatchEvent(new CustomEvent("recipe-transfer-armed", { bubbles: true })); + globalThis.navigator?.vibrate?.(12); + if (notyf) { + notyf.success("Перетащите рейс на период или оборудование"); + } + } + + function onPointerDownCapture(e) { + if (e.pointerType === "mouse" && e.button !== 0) return; + const handle = e.target.closest(".recipe-drag-handle"); + if (!handle || !recipesList || !recipesList.contains(handle)) return; + const item = handle.closest(".list-item[data-recipe-id]"); + if (!item) return; + if (!isTransferAllowed()) return; + + if (isMobileTransferUi()) { + clearMobilePressState(); + mobilePressState = { + pointerId: e.pointerId, + item, + startX: e.clientX, + startY: e.clientY, + longPressTimer: globalThis.setTimeout(() => { + if (!mobilePressState) return; + if (recipesList?.classList.contains("recipe-list-reorder-active")) { + clearMobilePressState(); + return; + } + mobilePressState.armed = true; + pointerSessionId = mobilePressState.pointerId; + armMobileTransferSession(mobilePressState.item); + }, MOBILE_TRANSFER_LONG_PRESS_MS), + }; + return; + } + + /* Desktop: reorder владеет ручкой через pointer/HTML5; transfer — через dragstart + hover. */ + } + + function onPointerMoveCapture(e) { + if (mobilePressState && e.pointerId === mobilePressState.pointerId && !mobilePressState.armed) { + const dx = Math.abs(e.clientX - mobilePressState.startX); + const dy = Math.abs(e.clientY - mobilePressState.startY); + if (dx + dy >= MOBILE_TRANSFER_MOVE_CANCEL_PX) { + clearMobilePressState(); + } + } + + if (!session || pointerSessionId == null || e.pointerId !== pointerSessionId) return; + if (globalThis.matchMedia?.(MOBILE_TRANSFER_MQ)?.matches && e.clientY < window.innerHeight * 0.38) { + setMobileTransferOverlay(true); + } + handleSessionHoverAtPoint(e.clientX, e.clientY, null); + } + + function onPointerUpCapture(e) { + if (mobilePressState && e.pointerId === mobilePressState.pointerId) { + const wasArmed = mobilePressState.armed; + clearMobilePressState(); + if (!wasArmed) return; + } + + const ownsPointer = pointerSessionId != null && e.pointerId === pointerSessionId; + if (ownsPointer) { + releaseTransferPointerCapture(); + pointerSessionId = null; + mouseSessionActive = false; + } + if (!session) return; + if (!ownsPointer) return; + if (transferHoverCommitPending || (slotPick && session)) return; + + const target = document.elementFromPoint(e.clientX, e.clientY); + const overRecipe = target?.closest("#recipesList .list-item[data-recipe-id]"); + if (overRecipe && !slotPick) { + clearTransferSessionState({ emitDragCancel: true }); + return; + } + + handleSessionDropAtPoint(e.clientX, e.clientY, e); + } + + function onPointerCancelCapture(e) { + if (mobilePressState && e.pointerId === mobilePressState.pointerId) { + clearMobilePressState(); + } + if (pointerSessionId == null || e.pointerId !== pointerSessionId) return; + pointerSessionId = null; + if (slotPick && session) return; + endSession(); + } + + recipesList?.addEventListener("dragstart", onDragStartCapture, true); + recipesList?.addEventListener("dragend", onDragEndCapture, true); + recipesList?.addEventListener("recipe-reorder-active", () => { + cancelMobilePressForReorder(); + pendingDragItem = null; + /* Не шлём recipe-list-drag-cancel — иначе reorder обрывается в pointer-path. */ + if (!slotPick) clearTransferSessionState({ emitDragCancel: false }); + }); + function onMouseDownCapture(e) { + /* Desktop mousedown-сессия ломала повторный reorder; transfer на десктопе — dragstart + dragover. */ + if (!isMobileTransferUi()) return; + if (e.button !== 0 || pointerSessionId != null) return; + const handle = e.target.closest(".recipe-drag-handle"); + if (!handle || !recipesList || !recipesList.contains(handle)) return; + const item = handle.closest(".list-item[data-recipe-id]"); + if (!item || !isTransferAllowed()) return; + beginHandleSession(item); + mouseSessionActive = true; + } + + function onMouseMoveCapture(e) { + if (!session || !mouseSessionActive || slotPick) return; + handleSessionHoverAtPoint(e.clientX, e.clientY, null); + } + + function onMouseUpCapture(e) { + if (!mouseSessionActive) return; + mouseSessionActive = false; + if (!session) return; + if (transferHoverCommitPending || (slotPick && session)) return; + const target = document.elementFromPoint(e.clientX, e.clientY); + const overRecipe = target?.closest("#recipesList .list-item[data-recipe-id]"); + if (overRecipe && !slotPick) { + endSession(); + return; + } + handleSessionDropAtPoint(e.clientX, e.clientY, e); + } + + recipesList?.addEventListener("pointerdown", onPointerDownCapture, true); + recipesList?.addEventListener("mousedown", onMouseDownCapture, true); + document.addEventListener("mousemove", onMouseMoveCapture, true); + document.addEventListener("mouseup", onMouseUpCapture, true); + document.addEventListener("pointermove", onPointerMoveCapture, true); + document.addEventListener("pointerup", onPointerUpCapture, true); + document.addEventListener("pointercancel", onPointerCancelCapture, true); + document.addEventListener("dragover", onDocumentDragOver, true); + document.addEventListener("drop", onDocumentDropCapture, true); + + document.addEventListener("keydown", (e) => { + if (e.key !== "Escape" || !session) return; + endSession(); + }); + + return { endSession }; +} diff --git a/apps/web/public/wesp/js/modules/recipes/recipe-skeleton.js b/apps/web/public/wesp/js/modules/recipes/recipe-skeleton.js new file mode 100644 index 0000000..0706813 --- /dev/null +++ b/apps/web/public/wesp/js/modules/recipes/recipe-skeleton.js @@ -0,0 +1,35 @@ +const LIST_LABELS = { + dispensers: "Загрузка кормораздатчиков", + periods: "Загрузка периодов", + recipes: "Загрузка рейсов", + mill: "Загрузка рецептов", + period: "Загрузка рейсов", +}; + +/** + * Structural loading — один spinner на список (не 4 копии). + */ +export function buildRecipeRowsSkeletonHtml(variant = "period") { + const label = LIST_LABELS[variant] || LIST_LABELS.period; + return `
+
+ +

${label}…

+
+
`; +} + +export function cloneTemplateOuterHtml(templateId) { + const tpl = document.getElementById(templateId); + const el = tpl?.content?.firstElementChild; + if (!el) return ""; + return el.outerHTML; +} + +export function cloneTemplateInto(container, templateId) { + const tpl = document.getElementById(templateId); + const el = tpl?.content?.firstElementChild; + if (!el || !container) return false; + container.appendChild(el.cloneNode(true)); + return true; +} diff --git a/apps/web/public/wesp/js/modules/recipes/sync-panel.js b/apps/web/public/wesp/js/modules/recipes/sync-panel.js new file mode 100644 index 0000000..13d92d7 --- /dev/null +++ b/apps/web/public/wesp/js/modules/recipes/sync-panel.js @@ -0,0 +1,42 @@ +export class SyncPanel { + constructor(handlers) { + this.handlers = handlers; + } + + async handleAction(action, event, actionEl) { + switch (action) { + case "open-settings": + event.preventDefault(); + await this.handlers.openSettings(event); + return true; + case "close-settings": + this.handlers.closeSettings(); + return true; + case "toggle-credentials-form": + this.handlers.toggleCredentialsForm(); + return true; + case "toggle-sync-form": + this.handlers.toggleSyncForm(); + return true; + case "change-credentials": + await this.handlers.changeCredentials(); + return true; + case "sync-edit-client": + event.stopPropagation(); + this.handlers.editSyncClientSettings(actionEl); + return true; + case "sync-delete-client": + event.stopPropagation(); + this.handlers.deleteSyncClientSettings(actionEl); + return true; + case "sync-save-client": + this.handlers.saveSyncClientDisplayName(actionEl.dataset.nodeId || ""); + return true; + case "sync-cancel-edit": + await this.handlers.loadSyncClientsSettings(); + return true; + default: + return false; + } + } +} diff --git a/apps/web/public/wesp/js/modules/reconnecting-event-source.js b/apps/web/public/wesp/js/modules/reconnecting-event-source.js new file mode 100644 index 0000000..db44478 --- /dev/null +++ b/apps/web/public/wesp/js/modules/reconnecting-event-source.js @@ -0,0 +1,40 @@ +export class ReconnectingEventSource { + constructor(url, { retryDelayMs = 2000, maxRetryDelayMs = 15000 } = {}) { + this.url = url; + this.retryDelayMs = retryDelayMs; + this.maxRetryDelayMs = maxRetryDelayMs; + this.onmessage = null; + this.onerror = null; + this._closed = false; + this._es = null; + this._currentDelay = retryDelayMs; + this._connect(); + } + + _connect() { + if (this._closed) return; + this._es = new EventSource(this.url); + this._es.onmessage = (event) => { + this._currentDelay = this.retryDelayMs; + if (this.onmessage) this.onmessage(event); + }; + this._es.onerror = (event) => { + if (this.onerror) this.onerror(event); + this._scheduleReconnect(); + }; + } + + _scheduleReconnect() { + if (this._closed) return; + if (this._es) this._es.close(); + const delay = this._currentDelay; + this._currentDelay = Math.min(this._currentDelay * 2, this.maxRetryDelayMs); + setTimeout(() => this._connect(), delay); + } + + close() { + this._closed = true; + if (this._es) this._es.close(); + this._es = null; + } +} diff --git a/apps/web/public/wesp/js/notyf.min.js b/apps/web/public/wesp/js/notyf.min.js new file mode 100644 index 0000000..f85c84f --- /dev/null +++ b/apps/web/public/wesp/js/notyf.min.js @@ -0,0 +1 @@ +var Notyf=function(){"use strict";var e,o=function(){return(o=Object.assign||function(t){for(var i,e=1,n=arguments.length;e/g, ">") + .replace(/"/g, """); +} + +function parseInputValue(raw, sampleOriginal) { + const t = raw.trim(); + if (t === "" && (sampleOriginal === null || sampleOriginal === undefined)) return null; + if (t === "") return ""; + if (/^-?\d+$/.test(t)) return Number(t); + if (/^-?\d+\.\d+$/.test(t)) return Number(t); + if (t === "true") return 1; + if (t === "false") return 0; + return raw; +} + +function closeModal() { + const m = document.getElementById("adminSqliteModal"); + if (m) m.hidden = true; + document.body.style.overflow = ""; +} + +let browseState = { + bind: "recipes", + table: null, + limit: 50, + offset: 0, + page: null, +}; + +/** Колбэк выбора таблицы (нужен для повторной отрисовки дерева). */ +let dbTreePickCallback = null; + +async function fetchJson(url, options) { + const r = await fetch(url, options); + const data = await r.json().catch(() => ({})); + if (!r.ok) { + throw new Error(data.message || `HTTP ${r.status}`); + } + return data; +} + +function renderTree(binds, preferredBind, onPickTable) { + dbTreePickCallback = onPickTable; + const root = document.getElementById("adminDbTree"); + if (!root) return; + root.innerHTML = binds + .map((b) => { + const open = b.bind === preferredBind ? "is-open" : ""; + const rows = (b.tables || []) + .map( + (t) => + `
  • `, + ) + .join(""); + return `
  • + + ${escHtml(b.bind)} +
      ${rows}
    +
  • `; + }) + .join(""); + + root.querySelectorAll(".wesp-db-tree-caret").forEach((btn) => { + btn.addEventListener("click", () => { + const li = btn.closest(".wesp-db-tree-node"); + if (!li) return; + const open = li.classList.toggle("is-open"); + btn.setAttribute("aria-expanded", open ? "true" : "false"); + }); + }); + + root.querySelectorAll(".wesp-db-tree-table").forEach((btn) => { + btn.addEventListener("click", () => { + const bind = btn.getAttribute("data-bind"); + const table = btn.getAttribute("data-table"); + root.querySelectorAll(".wesp-db-tree-table.is-active").forEach((b) => b.classList.remove("is-active")); + btn.classList.add("is-active"); + onPickTable(bind, table); + }); + }); +} + +function renderGrid(data, setStatus) { + const meta = document.getElementById("adminDbGridMeta"); + const wrap = document.getElementById("adminDbGridWrap"); + const pager = document.getElementById("adminDbPager"); + if (!meta || !wrap || !pager) return; + + browseState.page = data; + const { columns, rows, total, has_rowid: hasRowid } = data; + meta.textContent = `${data.bind} / ${data.table} — строк ${total}, показано ${rows.length} (offset ${browseState.offset})`; + + const colMetaByName = Object.fromEntries((columns || []).map((c) => [c.name, c])); + const displayCols = (data.column_names || []).filter((n) => n !== "_wesp_rowid"); + + let thead = + "" + + (hasRowid ? "rowid" : "") + + displayCols.map((n) => `${escHtml(n)}`).join("") + + "Действия"; + + const body = rows + .map((row) => { + const rowKey = {}; + if (hasRowid && row._wesp_rowid != null) rowKey._wesp_rowid = row._wesp_rowid; + (data.pk_names || []).forEach((pk) => { + if (pk in row) rowKey[pk] = row[pk]; + }); + + const cells = displayCols + .map((name) => { + const cm = colMetaByName[name]; + const val = row[name]; + if (cm && !cm.editable) { + return `(BLOB)`; + } + const v = val == null ? "" : String(val); + return ``; + }) + .join(""); + + const ridCell = hasRowid + ? `${row._wesp_rowid != null ? escHtml(String(row._wesp_rowid)) : "—"}` + : ""; + + const rowKeyEnc = encodeURIComponent(JSON.stringify(rowKey)); + const rowJsonEnc = encodeURIComponent(JSON.stringify(row)); + return ` + ${ridCell}${cells} + + + + + `; + }) + .join(""); + + wrap.innerHTML = `${thead}${body}
    `; + + wrap.querySelectorAll(".wesp-db-row-delete").forEach((btn) => { + btn.addEventListener("click", async () => { + const tr = btn.closest("tr"); + if (!tr) return; + let rowKey; + try { + rowKey = JSON.parse(decodeURIComponent(tr.getAttribute("data-row-key") || "%7B%7D")); + } catch (e) { + setStatus("Ошибка разбора строки", true); + return; + } + btn.disabled = true; + try { + const prev = await fetchJson("/api/admin/db/row/delete-preview", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + bind: browseState.bind, + table: browseState.table, + row_key: rowKey, + }), + }); + let msg = `Будет удалено строк: ${prev.total}\n\n${(prev.lines || []).join("\n")}`; + if (prev.truncated) msg += "\n\n… (в списке не все строки)"; + msg += "\n\nУчитываются только связи, объявленные внешними ключами SQLite.\nПродолжить удаление?"; + if (!window.confirm(msg)) { + setStatus("Удаление отменено"); + return; + } + await fetchJson("/api/admin/db/row/delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + bind: browseState.bind, + table: browseState.table, + row_key: rowKey, + }), + }); + setStatus(`Удалено строк: ${prev.total}`); + await loadTablePage(setStatus); + } catch (e) { + setStatus(e.message, true, "Ошибка удаления"); + } finally { + btn.disabled = false; + } + }); + }); + + wrap.querySelectorAll(".wesp-db-row-save").forEach((btn) => { + btn.addEventListener("click", async () => { + const tr = btn.closest("tr"); + if (!tr) return; + let rowKey; + let original; + try { + rowKey = JSON.parse(decodeURIComponent(tr.getAttribute("data-row-key") || "%7B%7D")); + original = JSON.parse(decodeURIComponent(tr.getAttribute("data-row-json") || "%7B%7D")); + } catch (e) { + setStatus("Ошибка разбора строки", true); + return; + } + const changes = {}; + tr.querySelectorAll(".wesp-db-cell[data-col]").forEach((inp) => { + const col = inp.getAttribute("data-col"); + if (!col || inp.readOnly) return; + const oldVal = Object.prototype.hasOwnProperty.call(original, col) ? original[col] : undefined; + const newVal = parseInputValue(inp.value, oldVal); + const same = + (oldVal == null && (newVal === "" || newVal === null)) || + JSON.stringify(oldVal) === JSON.stringify(newVal === "" && oldVal == null ? null : newVal); + if (!same) { + changes[col] = newVal === "" && oldVal == null ? null : newVal; + } + }); + if (!Object.keys(changes).length) { + setStatus("Нет изменений в строке"); + return; + } + btn.disabled = true; + try { + await fetchJson("/api/admin/db/row", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + bind: browseState.bind, + table: browseState.table, + row_key: rowKey, + changes, + }), + }); + setStatus("Строка сохранена на сервере"); + await loadTablePage(setStatus); + } catch (e) { + setStatus(e.message, true, "Ошибка сохранения"); + } finally { + btn.disabled = false; + } + }); + }); + + const prev = browseState.offset > 0; + const next = browseState.offset + rows.length < total; + pager.innerHTML = ` + + + `; + document.getElementById("adminDbPrev")?.addEventListener("click", async () => { + browseState.offset = Math.max(0, browseState.offset - browseState.limit); + await loadTablePage(setStatus); + }); + document.getElementById("adminDbNext")?.addEventListener("click", async () => { + browseState.offset += browseState.limit; + await loadTablePage(setStatus); + }); +} + +async function loadTablePage(setStatus) { + const errEl = document.getElementById("adminSqliteError"); + if (errEl) errEl.textContent = ""; + if (!browseState.table) return; + const u = `/api/admin/db/table?bind=${encodeURIComponent(browseState.bind)}&table=${encodeURIComponent(browseState.table)}&limit=${browseState.limit}&offset=${browseState.offset}`; + const data = await fetchJson(u); + renderGrid(data, setStatus); +} + +export async function refreshAdminDbTree(setStatus) { + const modal = document.getElementById("adminSqliteModal"); + const errEl = document.getElementById("adminSqliteError"); + if (!modal || modal.hidden) return; + try { + const tree = await fetchJson("/api/admin/db/tree"); + const pref = browseState.bind || "recipes"; + const cb = dbTreePickCallback; + if (typeof cb !== "function") { + if (typeof setStatus === "function") setStatus("Подождите, дерево ещё загружается"); + return; + } + renderTree(tree.binds || [], pref, cb); + document.querySelectorAll(".wesp-db-tree-table").forEach((btn) => { + if ( + btn.getAttribute("data-bind") === browseState.bind && + btn.getAttribute("data-table") === browseState.table + ) { + document.querySelectorAll(".wesp-db-tree-table.is-active").forEach((b) => b.classList.remove("is-active")); + btn.classList.add("is-active"); + } + }); + if (typeof setStatus === "function") setStatus("Каталог таблиц обновлён"); + } catch (e) { + if (errEl) errEl.textContent = globalThis.WespUserMessages?.userFacingMessage?.(e.message, "Не удалось выполнить операцию") || "Не удалось выполнить операцию"; + if (typeof setStatus === "function") setStatus(e.message, true, "Не удалось выполнить операцию"); + } +} + +export async function refreshAdminDbGrid(setStatus) { + const errEl = document.getElementById("adminSqliteError"); + if (!browseState.table) { + if (typeof setStatus === "function") setStatus("Сначала выберите таблицу в дереве"); + return; + } + if (errEl) errEl.textContent = ""; + try { + await loadTablePage(setStatus); + if (typeof setStatus === "function") setStatus("Таблица обновлена с сервера"); + } catch (e) { + if (errEl) errEl.textContent = globalThis.WespUserMessages?.userFacingMessage?.(e.message, "Не удалось выполнить операцию") || "Не удалось выполнить операцию"; + if (typeof setStatus === "function") setStatus(e.message, true, "Не удалось выполнить операцию"); + } +} + +export async function openAdminDbBrowser(options = {}) { + const { preferredBind = "recipes", setStatus } = options; + const modal = document.getElementById("adminSqliteModal"); + const errEl = document.getElementById("adminSqliteError"); + if (!modal || !errEl) { + if (typeof setStatus === "function") setStatus("Нет разметки редактора БД", true); + return; + } + + browseState = { + bind: preferredBind, + table: null, + limit: 50, + offset: 0, + page: null, + }; + + modal.hidden = false; + document.body.style.overflow = "hidden"; + errEl.textContent = ""; + document.getElementById("adminDbGridMeta").textContent = ""; + document.getElementById("adminDbGridWrap").innerHTML = + '

    Выберите таблицу в дереве слева.

    '; + document.getElementById("adminDbPager").innerHTML = ""; + + try { + const tree = await fetchJson("/api/admin/db/tree"); + renderTree(tree.binds || [], preferredBind, async (bind, table) => { + browseState.bind = bind; + browseState.table = table; + browseState.offset = 0; + errEl.textContent = ""; + try { + await loadTablePage(setStatus); + } catch (e) { + errEl.textContent = globalThis.WespUserMessages?.userFacingMessage?.(e.message, "Не удалось выполнить операцию") || "Не удалось выполнить операцию"; + if (typeof setStatus === "function") setStatus(e.message, true, "Не удалось выполнить операцию"); + } + }); + if (typeof setStatus === "function") setStatus("Дерево БД загружено"); + } catch (e) { + errEl.textContent = globalThis.WespUserMessages?.userFacingMessage?.(e.message, "Не удалось выполнить операцию") || "Не удалось выполнить операцию"; + if (typeof setStatus === "function") setStatus(e.message, true, "Не удалось выполнить операцию"); + } +} + +export function wireAdminDbBrowserModal() { + document.getElementById("adminSqliteModalClose")?.addEventListener("click", closeModal); + document.getElementById("adminSqliteModalMask")?.addEventListener("click", closeModal); +} diff --git a/apps/web/public/wesp/js/pages/admin-panel.js b/apps/web/public/wesp/js/pages/admin-panel.js new file mode 100644 index 0000000..8ebcad4 --- /dev/null +++ b/apps/web/public/wesp/js/pages/admin-panel.js @@ -0,0 +1,5447 @@ +import { + openAdminDbBrowser, + refreshAdminDbGrid, + refreshAdminDbTree, + wireAdminDbBrowserModal, +} from "./admin-db-browser.js"; +import { mountWespLogoLoader } from "/static/js/wesp-logo-loader.js?v=admin-kiosk-setup"; + +const GAUGE_RADIUS = 47; +const GAUGE_CIRC = 2 * Math.PI * GAUGE_RADIUS; + +const ADMIN_SIDEBAR_COLLAPSED_KEY = "wespAdminSidebarCollapsed"; +const ADMIN_ACTIVITY_LOG_KEY = "wesp_admin_activity_v1"; +/** История LLM-чата в админке: переживает F5 в той же вкладке. */ +const WESP_ASSISTANT_CHAT_STORAGE_KEY = "wespAdminAssistantChatV1"; +const ADMIN_ACTIVITY_RETENTION_MS = 3 * 24 * 60 * 60 * 1000; +const ADMIN_ACTIVITY_MAX_ENTRIES = 400; +/** Кэш GET /api/admin/activity-feed (JSONL + wesp.log), обновляется при hydrate и «Обновить». */ +let activityServerCache = []; +const ADMIN_SHELL_MIN_MS = 400; +const ADMIN_SKEL_MIN_VISIBLE_MS = 650; +const diagState = { + report: null, + extraTargets: [], +}; + +function _blockHasSkelOverlay(el) { + for (let i = 0; i < el.children.length; i += 1) { + if (el.children[i].classList.contains("wesp-admin-skel-overlay")) return true; + } + return false; +} + +function prepareAdminSkeletonBlocks() { + const app = document.getElementById("app"); + if (!app) return; + const seen = new Set(); + const add = (el) => { + if (!el || seen.has(el)) return; + seen.add(el); + el.classList.add("wesp-admin-block"); + if (_blockHasSkelOverlay(el)) return; + const ov = document.createElement("div"); + ov.className = "wesp-admin-skel-overlay"; + ov.setAttribute("aria-hidden", "true"); + el.appendChild(ov); + }; + add(app.querySelector(".wesp-admin-sider")); + add(app.querySelector("main.ant-layout-content .wesp-admin-content-topbar")); + app.querySelectorAll("main.ant-layout-content .ant-card").forEach(add); +} + +function finishAdminShellReveal(shellStart) { + const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const armedAt = + typeof window.__WESP_ADMIN_SKEL_ARMED_AT__ === "number" + ? window.__WESP_ADMIN_SKEL_ARMED_AT__ + : shellStart; + const elapsed = performance.now() - shellStart; + const sinceArmed = performance.now() - armedAt; + const wait = reduced + ? 0 + : Math.max( + 0, + ADMIN_SHELL_MIN_MS - elapsed, + ADMIN_SKEL_MIN_VISIBLE_MS - sinceArmed, + ); + return new Promise((resolve) => { + window.setTimeout(() => { + document.querySelectorAll(".wesp-admin-block").forEach((el, i) => { + el.style.setProperty("--wesp-reveal-i", String(i)); + }); + document.documentElement.classList.remove("wesp-admin-loading"); + document.documentElement.classList.add("wesp-admin-loaded"); + window.setTimeout(() => { + document.querySelectorAll(".wesp-admin-skel-overlay").forEach((o) => o.remove()); + resolve(); + }, reduced ? 80 : 450); + }, wait); + }); +} + +function stripAdminShellDecorations() { + document.documentElement.classList.remove("wesp-admin-loading"); + document.querySelectorAll(".wesp-admin-skel-overlay").forEach((o) => o.remove()); +} + +function isAdminNarrowViewport() { + return window.matchMedia("(max-width: 768px)").matches; +} + +function applyAdminSidebarCollapsed(collapsed) { + const sider = document.getElementById("wespAdminSider"); + const trigger = document.getElementById("wespAdminSiderTrigger"); + if (!sider || !trigger) return; + if (isAdminNarrowViewport()) collapsed = false; + sider.classList.toggle("ant-layout-sider-collapsed", collapsed); + trigger.setAttribute("aria-expanded", collapsed ? "false" : "true"); + trigger.title = collapsed ? "Развернуть меню" : "Свернуть меню"; + const icon = trigger.querySelector(".wesp-sider-trigger-icon"); + if (icon) icon.textContent = collapsed ? "▶" : "◀"; +} + +function readSidebarCollapsedPreference() { + /* По умолчанию узкий сайдбар; явно «0» в localStorage — развёрнут. */ + return localStorage.getItem(ADMIN_SIDEBAR_COLLAPSED_KEY) !== "0"; +} + +function initAdminSidebar() { + const sider = document.getElementById("wespAdminSider"); + const trigger = document.getElementById("wespAdminSiderTrigger"); + if (!sider || !trigger) return; + applyAdminSidebarCollapsed(readSidebarCollapsedPreference()); + trigger.addEventListener("click", () => { + if (isAdminNarrowViewport()) return; + const next = !sider.classList.contains("ant-layout-sider-collapsed"); + applyAdminSidebarCollapsed(next); + if (next) localStorage.setItem(ADMIN_SIDEBAR_COLLAPSED_KEY, "1"); + else localStorage.setItem(ADMIN_SIDEBAR_COLLAPSED_KEY, "0"); + }); + trigger.addEventListener("keydown", (e) => { + if (e.key !== "Enter" && e.key !== " ") return; + e.preventDefault(); + trigger.click(); + }); + window.addEventListener("resize", () => { + if (isAdminNarrowViewport()) { + applyAdminSidebarCollapsed(false); + } else { + applyAdminSidebarCollapsed(readSidebarCollapsedPreference()); + } + }); +} + +/** Админка всегда в ultra-dark; zootech-тема из localStorage не используется. */ +function applyAdminDocumentTheme() { + const root = document.documentElement; + root.setAttribute("data-theme", "ultra-dark"); + root.classList.add("dark"); + root.classList.remove("light"); +} + +function formatJson(data) { + return JSON.stringify(data, null, 2); +} + +function escHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function defaultAssistantDateRange() { + const to = new Date(); + const from = new Date(to); + from.setDate(from.getDate() - 7); + return { from, to }; +} + +function formatDateInputYmd(d) { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +function _assistantChatEscapeHtml(text) { + const d = document.createElement("div"); + d.textContent = text == null ? "" : String(text); + return d.innerHTML; +} + +/** Сообщения для API: без служебного «ожидания» и без system (его добавляет сервер). */ +function assistantChatMessagesForApi() { + const msgs = window.__wespAssistantChatMessages || []; + return msgs.filter((m) => !m.pending && (m.role === "user" || m.role === "assistant")); +} + +function persistAssistantChatMessages() { + try { + const msgs = window.__wespAssistantChatMessages || []; + const toSave = msgs.filter((m) => !m.pending); + sessionStorage.setItem(WESP_ASSISTANT_CHAT_STORAGE_KEY, JSON.stringify(toSave)); + } catch { + /* quota / private mode */ + } +} + +function hydrateAssistantChatMessagesFromStorage() { + try { + const raw = sessionStorage.getItem(WESP_ASSISTANT_CHAT_STORAGE_KEY); + if (!raw) { + if (!window.__wespAssistantChatMessages) window.__wespAssistantChatMessages = []; + return; + } + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + window.__wespAssistantChatMessages = []; + return; + } + window.__wespAssistantChatMessages = parsed.filter( + (m) => m && (m.role === "user" || m.role === "assistant") && !m.pending, + ); + } catch { + window.__wespAssistantChatMessages = []; + } +} + +function renderAssistantChatLog() { + const log = document.getElementById("wespAssistantChatLog"); + if (!log) return; + const msgs = window.__wespAssistantChatMessages || []; + const parts = []; + for (let i = 0; i < msgs.length; i += 1) { + const m = msgs[i]; + const role = m.role; + if (role !== "user" && role !== "assistant") continue; + const label = role === "user" ? "Вы" : "Модель"; + const cls = role === "user" ? "wesp-assistant-msg-user" : "wesp-assistant-msg-assistant"; + const pendingCls = m.pending ? " wesp-assistant-msg--pending" : ""; + const contentHtml = m.pending + ? `${_assistantChatEscapeHtml(m.content)}` + : _assistantChatEscapeHtml(m.content); + parts.push( + `
    ${label}
    ${contentHtml}
    `, + ); + } + log.innerHTML = parts.join(""); + log.scrollTop = log.scrollHeight; + persistAssistantChatMessages(); +} + +const WESP_LLM_EVENT_LABELS = { + ping: "ping", + summary: "сводка", + chat: "чат", + settings: "настройки", + diagnostics: "диагностика", + status: "статус", +}; + +function setAssistantInteractiveEnabled(enabled) { + const ids = [ + "wespAssistantChatInput", + "wespAssistantChatSendBtn", + "wespAssistantPingBtn", + "wespAssistantSummaryBtn", + "wespAssistantDiagnosticsBtn", + "wespAssistantSqlMode", + "wespAssistantToolTraceCb", + "wespAssistantDateFrom", + "wespAssistantDateTo", + "wespAssistantFocus", + ]; + for (const id of ids) { + const el = document.getElementById(id); + if (!el) continue; + if (el instanceof HTMLInputElement || el instanceof HTMLButtonElement || el instanceof HTMLSelectElement) { + el.disabled = !enabled; + } + } + const section = document.getElementById("wespAssistantChatSectionTitle"); + if (section) { + section.style.opacity = enabled ? "1" : "0.55"; + } +} + +function renderAssistantLlmLog(entries) { + const wrap = document.getElementById("wespAssistantLlmLog"); + if (!wrap) return; + const rows = Array.isArray(entries) ? entries : []; + if (!rows.length) { + wrap.innerHTML = + '
    // журнал пуст (нет вызовов за окно хранения)
    '; + return; + } + const sorted = [...rows].sort((a, b) => Number(a.u || 0) - Number(b.u || 0)); + wrap.innerHTML = sorted + .map((e) => { + let lvl = "OK"; + let cls = "wesp-admin-activity-line--ok"; + const level = String(e.level || "ok"); + if (level === "err") { + lvl = "ERR"; + cls = "wesp-admin-activity-line--err"; + } else if (level === "warn") { + lvl = "WRN"; + cls = "wesp-admin-activity-line--warn"; + } + const ev = WESP_LLM_EVENT_LABELS[String(e.event || "")] || String(e.event || "llm"); + const dur = + typeof e.duration_ms === "number" && e.duration_ms >= 0 ? ` · ${Math.round(e.duration_ms)} ms` : ""; + const user = String(e.user || "").trim(); + const userBit = user ? ` · ${user}` : ""; + const detail = String(e.detail || "").trim(); + const msg = detail ? `${ev}: ${detail}${dur}${userBit}` : `${ev}${dur}${userBit}`; + const tsMs = Number(e.u || 0) * 1000; + return `
    [${escHtml(formatActivityTs(tsMs))}]${lvl}${escHtml(msg)}
    `; + }) + .join(""); + wrap.scrollTop = wrap.scrollHeight; +} + +async function loadAdminAssistantLlmLog() { + const badge = document.getElementById("wespAssistantLlmLogBadge"); + const r = await fetch("/api/admin/llm/activity", { credentials: "same-origin" }); + const data = await r.json().catch(() => ({})); + if (!r.ok || data.status !== "success") { + renderAssistantLlmLog([]); + if (badge) badge.textContent = "ошибка загрузки"; + return; + } + const days = Number(data.retention_days) || 7; + if (badge) badge.textContent = `${days}d · JSONL`; + renderAssistantLlmLog(data.entries || []); +} + +async function loadAdminAssistantStatus() { + const badge = document.getElementById("wespAssistantStatusBadge"); + const hint = document.getElementById("wespAssistantLlmUrlHint"); + const r = await fetch("/api/admin/llm/status", { credentials: "same-origin" }); + const data = await r.json().catch(() => ({})); + if (!r.ok) { + if (badge) { + badge.textContent = "ошибка API"; + badge.className = "ant-tag ant-tag-red"; + } + return; + } + const p = data; + if (hint && p.llm_base_url) { + hint.textContent = String(p.llm_base_url).replace(/^https?:\/\//, ""); + } + const adminLlmCb = document.getElementById("wespAssistantAdminLlmEnabled"); + const adminLlmHint = document.getElementById("wespAssistantAdminLlmEnabledHint"); + if (adminLlmCb) { + adminLlmCb.checked = p.enabled !== false; + adminLlmCb.disabled = !!p.admin_llm_enabled_locked_by_env; + if (adminLlmHint) { + if (p.admin_llm_enabled_locked_by_env) { + adminLlmHint.style.display = "block"; + adminLlmHint.textContent = + "Значение задано в окружении (WESP_ADMIN_LLM_ENABLED). Снимите переменную, чтобы менять галку здесь."; + } else { + adminLlmHint.style.display = "none"; + adminLlmHint.textContent = ""; + } + } + } + const autostartCb = document.getElementById("wespAssistantLlmAutostart"); + const autostartHint = document.getElementById("wespAssistantLlmAutostartHint"); + if (autostartCb) { + autostartCb.checked = p.llm_autostart !== false; + autostartCb.disabled = !!p.llm_autostart_locked_by_env; + if (autostartHint) { + if (p.llm_autostart_locked_by_env) { + autostartHint.style.display = "block"; + autostartHint.textContent = + "Значение задано в окружении (WESP_LLM_AUTOSTART). Снимите переменную, чтобы менять галку здесь."; + } else { + autostartHint.style.display = "none"; + autostartHint.textContent = ""; + } + } + } + const chatDbCb = document.getElementById("wespAssistantChatDbContext"); + const chatDbHint = document.getElementById("wespAssistantChatDbContextHint"); + if (chatDbCb) { + chatDbCb.checked = p.llm_chat_db_context === true; + chatDbCb.disabled = !!p.llm_chat_db_context_locked_by_env; + if (chatDbHint) { + if (p.llm_chat_db_context_locked_by_env) { + chatDbHint.style.display = "block"; + chatDbHint.textContent = + "Значение задано в окружении (WESP_LLM_CHAT_INCLUDE_DB_CONTEXT). Снимите переменную, чтобы менять галку здесь."; + } else { + chatDbHint.style.display = "none"; + chatDbHint.textContent = ""; + } + } + } + const toolsCb = document.getElementById("wespAssistantLlmToolsEnabled"); + const toolsHint = document.getElementById("wespAssistantLlmToolsEnabledHint"); + if (toolsCb) { + toolsCb.checked = p.llm_tools_enabled !== false; + toolsCb.disabled = !!p.llm_tools_enabled_locked_by_env; + if (toolsHint) { + if (p.llm_tools_enabled_locked_by_env) { + toolsHint.style.display = "block"; + toolsHint.textContent = + "Значение задано в окружении (WESP_LLM_TOOLS_ENABLED). Снимите переменную, чтобы менять галку здесь."; + } else { + toolsHint.style.display = "none"; + toolsHint.textContent = ""; + } + } + } + setAssistantInteractiveEnabled(p.enabled !== false); + if (!badge) return; + if (!p.enabled) { + badge.textContent = "ИИ выкл."; + badge.className = "ant-tag"; + return; + } + if (!p.llm_reachable) { + badge.textContent = "LLM недоступен"; + badge.className = "ant-tag ant-tag-red"; + return; + } + if (!p.model_present) { + badge.textContent = "модель не найдена"; + badge.className = "ant-tag"; + return; + } + badge.textContent = "OK"; + badge.className = "ant-tag ant-tag-green"; +} + +async function runAdminAssistantChatSend() { + const input = document.getElementById("wespAssistantChatInput"); + const sendBtn = document.getElementById("wespAssistantChatSendBtn"); + const text = (input?.value || "").trim(); + if (!text) return; + if (!window.__wespAssistantChatMessages) window.__wespAssistantChatMessages = []; + if (sendBtn?.dataset.busy === "1") return; + input.value = ""; + window.__wespAssistantChatMessages.push({ role: "user", content: text }); + window.__wespAssistantChatMessages.push({ + role: "assistant", + content: "Генерируется ответ…", + pending: true, + }); + renderAssistantChatLog(); + if (sendBtn) { + sendBtn.dataset.busy = "1"; + sendBtn.disabled = true; + } + try { + const toolsEl = document.getElementById("wespAssistantLlmToolsEnabled"); + const toolsEnabled = !!(toolsEl && toolsEl instanceof HTMLInputElement && toolsEl.checked); + const sqlModeEl = document.getElementById("wespAssistantSqlMode"); + const sqlMode = + sqlModeEl && sqlModeEl instanceof HTMLSelectElement ? String(sqlModeEl.value || "preview") : "preview"; + const traceCb = document.getElementById("wespAssistantToolTraceCb"); + const toolTrace = !(traceCb && traceCb instanceof HTMLInputElement && !traceCb.checked); + const tracePre = document.getElementById("wespAssistantToolTrace"); + const r = await fetch("/api/admin/llm/chat", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + messages: assistantChatMessagesForApi(), + sql_mode: sqlMode, + tools_enabled: toolsEnabled, + tool_trace: toolTrace, + }), + }); + const data = await r.json().catch(() => ({})); + const last = window.__wespAssistantChatMessages.length - 1; + if (window.__wespAssistantChatMessages[last]?.pending) { + window.__wespAssistantChatMessages.pop(); + } + if (!r.ok) { + const errText = data.message || data.detail || String(r.status); + window.__wespAssistantChatMessages.push({ + role: "assistant", + content: `Ошибка: ${errText}`, + }); + renderAssistantChatLog(); + setStatus(errText, true); + if (tracePre) tracePre.textContent = "—"; + return; + } + window.__wespAssistantChatMessages.push({ + role: "assistant", + content: data.reply || "", + }); + renderAssistantChatLog(); + if (tracePre) { + if (Array.isArray(data.tool_trace) && data.tool_trace.length) { + tracePre.textContent = formatJson(data.tool_trace); + } else { + tracePre.textContent = "—"; + } + } + setStatus("Чат: OK"); + loadAdminAssistantLlmLog().catch(() => {}); + } catch (err) { + const last = window.__wespAssistantChatMessages.length - 1; + if (window.__wespAssistantChatMessages[last]?.pending) { + window.__wespAssistantChatMessages.pop(); + } + const msg = err?.message || "Ошибка сети или сервера"; + window.__wespAssistantChatMessages.push({ + role: "assistant", + content: `Ошибка: ${msg}`, + }); + renderAssistantChatLog(); + const tracePreErr = document.getElementById("wespAssistantToolTrace"); + if (tracePreErr) tracePreErr.textContent = "—"; + setStatus(msg, true); + } finally { + if (sendBtn) { + delete sendBtn.dataset.busy; + const master = document.getElementById("wespAssistantAdminLlmEnabled"); + const llmOn = !(master instanceof HTMLInputElement) || master.checked; + sendBtn.disabled = !llmOn; + } + } +} + +async function runAdminAssistantPing() { + const out = document.getElementById("wespAssistantOutput"); + if (out) out.textContent = "…"; + const r = await fetch("/api/admin/llm/ping", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + const data = await r.json().catch(() => ({})); + if (!r.ok) { + if (out) out.textContent = userHint(data.message || String(r.status), "Не удалось выполнить операцию"); + setStatus(data.message, true, "llm/ping failed"); + return; + } + if (out) out.textContent = data.reply || ""; + setStatus("llm/ping: OK"); + loadAdminAssistantLlmLog().catch(() => {}); +} + +async function runAdminAssistantDiagnostics() { + const out = document.getElementById("wespAssistantOutput"); + if (out) out.textContent = "Загрузка…"; + const r = await fetch("/api/admin/llm/diagnostics", { + credentials: "same-origin", + }); + const data = await r.json().catch(() => ({})); + if (!r.ok) { + if (out) out.textContent = userHint(data.message || String(r.status), "Не удалось выполнить операцию"); + setStatus(data.message, true, "diagnostics failed"); + return; + } + const bundle = { + diagnostics: data.diagnostics, + db_health: data.db_health, + inventory: data.inventory, + text_for_llm: data.text_for_llm, + }; + if (out) out.textContent = formatJson(bundle); + setStatus("Диагностика загружена"); +} + +async function runAdminAssistantSummary() { + const out = document.getElementById("wespAssistantOutput"); + const df = document.getElementById("wespAssistantDateFrom"); + const dt = document.getElementById("wespAssistantDateTo"); + const focusEl = document.getElementById("wespAssistantFocus"); + if (!df?.value || !dt?.value) { + setStatus("Укажите период (даты «с» и «по»)", true); + return; + } + if (out) out.textContent = "…"; + const r = await fetch("/api/admin/llm/summary", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + date_from: df.value, + date_to: dt.value, + focus: focusEl?.value || "both", + }), + }); + const data = await r.json().catch(() => ({})); + if (!r.ok) { + if (out) out.textContent = userHint(data.message || String(r.status), "Не удалось выполнить операцию"); + setStatus(data.message, true, "llm/summary failed"); + return; + } + if (out) out.textContent = data.reply || ""; + setStatus("llm/summary: OK"); + loadAdminAssistantLlmLog().catch(() => {}); +} + +function formatActivityTs(ts) { + const d = new Date(ts); + const pad = (n) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} + +function loadAdminActivityLog() { + try { + const raw = localStorage.getItem(ADMIN_ACTIVITY_LOG_KEY); + if (!raw) return []; + const arr = JSON.parse(raw); + return Array.isArray(arr) ? arr : []; + } catch { + return []; + } +} + +function saveAdminActivityLog(entries) { + try { + localStorage.setItem(ADMIN_ACTIVITY_LOG_KEY, JSON.stringify(entries)); + } catch { + /* quota / private mode */ + } +} + +function pruneAdminActivityLog(entries) { + const cutoff = Date.now() - ADMIN_ACTIVITY_RETENTION_MS; + return entries.filter( + (e) => e && typeof e.ts === "number" && e.ts >= cutoff && typeof e.text === "string" && e.text.trim(), + ); +} + +function renderAdminActivityTerminal(entries) { + const wrap = document.getElementById("adminActivityLog"); + if (!wrap) return; + const sorted = [...entries].sort((a, b) => a.ts - b.ts); + if (!sorted.length) { + wrap.innerHTML = + '
    // no events in retention window
    '; + return; + } + wrap.innerHTML = sorted + .map((e) => { + let lvl = "OK"; + let cls = "wesp-admin-activity-line--ok"; + if (e.level === "err") { + lvl = "ERR"; + cls = "wesp-admin-activity-line--err"; + } else if (e.level === "warn") { + lvl = "WRN"; + cls = "wesp-admin-activity-line--warn"; + } + return `
    [${escHtml(formatActivityTs(e.ts))}]${lvl}${escHtml(e.text)}
    `; + }) + .join(""); + wrap.scrollTop = wrap.scrollHeight; +} + +async function refreshActivityServerCache() { + try { + const r = await fetch("/api/admin/activity-feed", { credentials: "same-origin" }); + const d = await r.json().catch(() => ({})); + if (d.status === "success" && Array.isArray(d.entries)) { + activityServerCache = d.entries.map((e) => ({ + ts: Number(e.ts) || 0, + level: e.level === "err" ? "err" : e.level === "warn" ? "warn" : "ok", + text: String(e.text || ""), + })); + } + } catch { + /* сеть / 401 */ + } +} + +function mergeActivityEntriesForDisplay() { + const local = pruneAdminActivityLog(loadAdminActivityLog()); + const cutoff = Date.now() - ADMIN_ACTIVITY_RETENTION_MS; + const map = new Map(); + const keyOf = (e) => `${e.level}|${String(e.text || "").slice(0, 160)}`; + for (const e of activityServerCache) { + if (!e || !String(e.text || "").trim()) continue; + if (e.ts < cutoff) continue; + map.set(keyOf(e), { ts: e.ts, level: e.level, text: e.text }); + } + for (const e of local) { + if (!e || !String(e.text || "").trim()) continue; + if (e.ts < cutoff) continue; + const k = keyOf(e); + const prev = map.get(k); + if (!prev || prev.ts < e.ts) map.set(k, { ts: e.ts, level: e.level, text: e.text }); + } + return Array.from(map.values()).sort((a, b) => a.ts - b.ts); +} + +function renderAdminActivityTerminalFromMerged() { + renderAdminActivityTerminal(mergeActivityEntriesForDisplay()); +} + +async function hydrateAdminActivityLog() { + let entries = pruneAdminActivityLog(loadAdminActivityLog()); + saveAdminActivityLog(entries); + await refreshActivityServerCache(); + renderAdminActivityTerminalFromMerged(); +} + +function persistAdminUiActivityToServer(text, isError) { + fetch("/api/admin/ui-activity", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text, level: isError ? "err" : "ok" }), + }).catch(() => {}); +} + +function userHint(message, fallback) { + return ( + globalThis.WespUserMessages?.userFacingMessage?.(message, fallback) || + fallback || + "Не удалось выполнить операцию." + ); +} + +function setStatus(message, isError = false, fallback) { + const wrap = document.getElementById("adminActivityLog"); + if (!wrap) return; + let text = message != null ? String(message).trim() : ""; + if (isError) { + text = + globalThis.WespUserMessages?.userFacingMessage?.( + text, + fallback || "Не удалось выполнить операцию." + ) || fallback || "Не удалось выполнить операцию."; + } else if (!text && fallback) { + text = String(fallback).trim(); + } + let entries = pruneAdminActivityLog(loadAdminActivityLog()); + if (text) { + entries.push({ ts: Date.now(), level: isError ? "err" : "ok", text }); + while (entries.length > ADMIN_ACTIVITY_MAX_ENTRIES) entries.shift(); + saveAdminActivityLog(entries); + persistAdminUiActivityToServer(text, Boolean(isError)); + } + renderAdminActivityTerminalFromMerged(); +} + +/** Строка списка настроек (разметка Ant Design): слева заголовок и описание, справа значение или действия. */ +function wespAdmListItem(title, extraHtml, descriptionText = null, opts = {}) { + const itemClass = opts.itemClass || ""; + const dataAttrs = opts.dataAttrs || ""; + const desc = + descriptionText != null && String(descriptionText).trim() + ? `
    ${escHtml(descriptionText)}
    ` + : ""; + return `
  • +
    +
    ${escHtml(title)}
    ${desc} +
    +
    ${extraHtml}
    +
  • `; +} + +function wespAdmListSection(title) { + return ``; +} + +function wespAdmListEmpty(message) { + return `
  • ${escHtml(message)}
  • `; +} + +function wespAdmListLoading(message = "Загрузка…") { + return `
  • ${escHtml(message)}
  • `; +} + +/** Сводка security_checks: лаконично, без разговорных оборотов. */ +function buildDiagSecurityOverviewRows(report) { + const checks = Array.isArray(report.security_checks) ? report.security_checks : []; + if (!checks.length) { + return [ + wespAdmListItem( + "rollup", + diagOkBadge(false), + "security_checks: []. retry GET; app log.", + ), + ]; + } + const failed = checks.filter((c) => c && c.ok === false); + if (!failed.length) { + return [ + wespAdmListItem( + "rollup", + diagOkBadge(true), + `heuristic: n=${checks.length}; fail=0. detail: Security tab.`, + ), + ]; + } + const lines = failed.slice(0, 5).map((c) => { + const t = (c.title || c.id || "check").trim(); + const d = (c.detail || "").trim(); + return d ? `${t}: ${d}` : t; + }); + const tail = failed.length > 5 ? `; +${failed.length - 5} more` : ""; + const body = `fail=${failed.length}/${checks.length}. ${lines.join("; ")}${tail}. registry: Security tab.`; + return [wespAdmListItem("rollup", diagOkBadge(false), body)]; +} + +/** Сводка периферии: классификация состояния, императивы минимальны. */ +function buildDiagHardwareOverviewRows(hwData) { + const sim = Boolean(hwData.simulation_mode); + const gpioOk = Boolean(hwData.rpi_gpio_available); + const hxOk = Boolean(hwData.hx711_driver_ready); + const driversAbsent = Boolean(hwData.drivers_absent); + + if (sim) { + return [ + wespAdmListItem( + "rollup", + diagOkBadge(true), + "SIMULATION_MODE=1: SW model weight+GPIO; ADC/BCM idle. prod BCM: SIMULATION_MODE=0.", + ), + ]; + } + if (driversAbsent) { + return [ + wespAdmListItem( + "rollup", + diagOkBadge(false), + "SIMULATION_MODE=0; RPi.GPIO+HX711 ∅ (typ. x86). mass=0. dev: WESP_SIMULATION_MODE=1. RPi: deps, VDD, BCM cfg.", + ), + ]; + } + if (gpioOk && hxOk) { + return [ + wespAdmListItem( + "rollup", + diagOkBadge(true), + "RPi.GPIO+HX711: init OK. drift: wiring, VDD_ADC, gain; probe: Hardware tab.", + ), + ]; + } + if (gpioOk && !hxOk) { + return [ + wespAdmListItem( + "rollup", + diagOkBadge(false), + "HX711: init fail. check: DOUT/PD_SCK BCM, module VDD, hx711 pkg; WESP_HX711_DOUT_PIN, WESP_HX711_PD_SCK_PIN.", + ), + ]; + } + if (!gpioOk && hxOk) { + return [ + wespAdmListItem( + "rollup", + diagOkBadge(false), + "RPi.GPIO: missing or non-BCM. x86: SIMULATION_MODE=1; RPi: install RPi.GPIO.", + ), + ]; + } + return [ + wespAdmListItem( + "rollup", + diagOkBadge(false), + "peripheral: class unknown. src: Hardware tab, app log.", + ), + ]; +} + +function formatRuDateFromIso(iso) { + if (!iso) return "—"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return String(iso); + return d.toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric" }); +} + +function formatRuDateTimeFromIso(iso) { + if (!iso) return "—"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return String(iso); + return d.toLocaleString("ru-RU", { + day: "numeric", + month: "long", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function ruConfigModeLabel(mode) { + const m = String(mode || "").toLowerCase(); + if (m === "production" || m === "prod") return "production"; + if (m === "testing" || m === "test") return "testing"; + if (m === "development" || m === "dev") return "development"; + return mode ? String(mode) : "—"; +} + +function ruSyncRoleLabel(role) { + const r = String(role || "").toLowerCase(); + if (r === "server") return "Сервер"; + if (r === "client") return "Клиент"; + if (r === "other") return "Другое"; + return role ? String(role) : "—"; +} + +function ruSyncClientStatus(s) { + const x = String(s || "").toLowerCase(); + if (x === "active") return "Активен"; + if (x === "disabled") return "Отключён"; + if (x === "inactive") return "Неактивен"; + return s ? String(s) : "—"; +} + +function formatWarrantyUntilCell(wu) { + if (!wu) return "—"; + const p = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(wu)); + if (p) { + const dt = new Date(Number(p[1]), Number(p[2]) - 1, Number(p[3])); + return dt.toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric" }); + } + return formatRuDateFromIso(wu); +} + +function formatWarrantyDaysCell(dr) { + if (dr == null || Number.isNaN(Number(dr))) return "—"; + if (Number(dr) < 0) return "истекла"; + return String(dr); +} + +function formatBytes(n) { + if (n == null || Number.isNaN(n)) return "—"; + const x = Number(n); + if (x <= 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let v = x; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i += 1; + } + const digits = i === 0 ? 0 : i === 1 ? 1 : 2; + return `${v.toFixed(digits)} ${units[i]}`; +} + +function ruCpuCoresLabel(n) { + const x = Math.trunc(Number(n) || 0); + if (x <= 0) return "—"; + const k100 = x % 100; + const k10 = x % 10; + if (k100 >= 11 && k100 <= 14) return `${x} ядер`; + if (k10 === 1) return `${x} ядро`; + if (k10 >= 2 && k10 <= 4) return `${x} ядра`; + return `${x} ядер`; +} + +function machineOverviewRows(m) { + if (!m || typeof m !== "object") return []; + const cores = m.cpu_logical_cores; + const coresPart = + cores != null && Number(cores) > 0 ? ` · ${ruCpuCoresLabel(Number(cores))}` : ""; + const cpuModel = (m.cpu_model && String(m.cpu_model).trim()) || "—"; + const cpuLine = + cpuModel === "—" && !(cores != null && Number(cores) > 0) ? "—" : `${cpuModel}${coresPart}`; + + let ram = "—"; + if (m.ram_total_bytes != null) { + ram = `${formatBytes(m.ram_total_bytes)} (всего)`; + } + + let disk = (m.root_disk_media && String(m.root_disk_media)) || "—"; + if (m.root_disk_total_bytes != null) { + const u = formatBytes(m.root_disk_used_bytes); + const t = formatBytes(m.root_disk_total_bytes); + const p = m.root_disk_percent != null ? ` · ${m.root_disk_percent}%` : ""; + disk = `${disk} · ${u} / ${t}${p}`; + } + + return [ + ["ОС", m.os || "—"], + ["CPU", cpuLine], + ["GPU", m.gpu || "—"], + ["RAM", ram], + ["Root FS", disk], + ]; +} + +const PERIPHERAL_EVENT_LABELS = { + hx711_read_error: "Ошибка чтения HX711", + hx711_recovered: "HX711 восстановлен", + hx711_not_initialized: "HX711 не инициализирован", + gpio_unavailable: "GPIO недоступен", + host_cpu_high: "Высокая загрузка CPU", + host_ram_high: "Высокая загрузка RAM", + host_disk_high: "Диск почти заполнен", + network_settings_saved: "Настройки сети", + mdns_started: "mDNS запущен", + mdns_stopped: "mDNS остановлен", + mdns_error: "Ошибка mDNS", +}; + +let hardwarePollTimer = null; + +function stopHardwarePolling() { + if (hardwarePollTimer) { + window.clearInterval(hardwarePollTimer); + hardwarePollTimer = null; + } +} + +function startHardwarePolling() { + stopHardwarePolling(); + const tick = () => { + loadHardwareStatus().catch(() => {}); + loadPeripheralEvents().catch(() => {}); + }; + tick(); + hardwarePollTimer = window.setInterval(tick, 3000); +} + +function peripheralEventTitle(code) { + const c = String(code || ""); + return PERIPHERAL_EVENT_LABELS[c] || c || "событие"; +} + +function renderHardwareHostList(machine) { + const el = document.getElementById("hardwareHostList"); + if (!el) return; + const rows = machineOverviewRows(machine); + if (!rows.length) { + el.innerHTML = wespAdmListEmpty("Нет данных о хосте"); + return; + } + el.innerHTML = + wespAdmListSection("Хост") + + rows + .map(([k, v]) => + wespAdmListItem(k, `${escHtml(v)}`), + ) + .join(""); +} + +function renderHardwareHostAlerts(alerts) { + const el = document.getElementById("hardwareHostAlertsList"); + if (!el) return; + const list = Array.isArray(alerts) ? alerts : []; + if (!list.length) { + el.innerHTML = ""; + return; + } + el.innerHTML = + wespAdmListSection("Алерты хоста") + + list + .map((a) => + wespAdmListItem( + a.label || a.code || "—", + diagOkBadge(Boolean(a.ok)), + `${a.percent != null ? `${a.percent}%` : "—"} · порог ${a.threshold != null ? `${a.threshold}%` : "—"}`, + ), + ) + .join(""); +} + +function renderHardwarePeriphList(periph) { + const el = document.getElementById("hardwarePeriphList"); + if (!el) return; + const p = periph && typeof periph === "object" ? periph : {}; + const sim = Boolean(p.simulation_mode); + const scaleHw = Boolean(p.scale_hardware_platform); + const gpioOk = Boolean(p.rpi_gpio_available); + const hxOk = Boolean(p.hx711_driver_ready); + const gpioBadge = sim || !scaleHw ? 'sim' : diagOkBadge(gpioOk); + const hxBadge = sim ? 'sim' : scaleHw ? diagOkBadge(hxOk) : ''; + el.innerHTML = [ + wespAdmListSection("Периферия"), + wespAdmListItem( + "Режим", + sim + ? 'SIM' + : scaleHw + ? diagOkBadge(true) + : 'DEV', + sim + ? "симуляция — HX711 не опрашивается" + : scaleHw + ? "целевое железо (Linux ARM + HX711)" + : "не ARM — HX711 не опрашивается; включите симуляцию", + ), + wespAdmListItem( + "GPIO / LED", + `${gpioBadge}пин ${escHtml(String(p.gpio_led_pin ?? "—"))}`, + gpioOk ? "RPi.GPIO доступен" : scaleHw ? "RPi.GPIO недоступен" : "не используется", + ), + wespAdmListItem( + "HX711", + `${hxBadge}DOUT ${escHtml(String(p.hx711_dout_pin ?? "—"))} · SCK ${escHtml(String(p.hx711_pd_sck_pin ?? "—"))}`, + sim ? "не используется" : scaleHw ? (hxOk ? "драйвер инициализирован" : "драйвер не поднят") : "пропущен (не ARM)", + ), + p.last_hx711_error && !sim + ? wespAdmListItem( + "Последняя ошибка", + '!', + escHtml(String(p.last_hx711_error)), + ) + : "", + ].join(""); +} + +function renderHardwareScalesList(scales) { + const el = document.getElementById("hardwareScalesList"); + if (!el) return; + const s = scales && typeof scales === "object" ? scales : {}; + if (!s.available) { + el.innerHTML = + wespAdmListSection("Весы (live)") + + wespAdmListItem( + "ScalesReader", + diagOkBadge(false), + escHtml(String(s.error || "недоступен")), + ); + return; + } + const sim = Boolean(s.simulation_mode); + const skipped = Boolean(s.hx711_skipped); + const hxOk = Boolean(s.hx711_ok); + el.innerHTML = [ + wespAdmListSection("Весы (live)"), + wespAdmListItem( + "Вес", + `${escHtml(String(s.weight_kg ?? "—"))} кг`, + sim ? "симуляция" : skipped ? "HX711 не опрашивается" : "с датчика", + ), + wespAdmListItem( + "HX711", + sim ? 'sim' : skipped ? '' : diagOkBadge(hxOk), + sim ? "не используется" : skipped ? "включите симуляцию на этой платформе" : escHtml(String(s.hx711_error || "OK")), + ), + wespAdmListItem( + "Калибровка", + `counts/kg ${escHtml(String(s.counts_per_kg ?? "—"))} · tare ${escHtml(String(s.tare_raw ?? "—"))}`, + ), + wespAdmListItem( + "Сырьё", + `n=${escHtml(String(s.raw_history_len ?? 0))}`, + [ + s.last_raw != null ? `последнее ${s.last_raw}` : null, + s.raw_min_recent != null && s.raw_max_recent != null + ? `мин ${s.raw_min_recent} · макс ${s.raw_max_recent}` + : null, + s.error_streak ? `серия ошибок ${s.error_streak}` : null, + ] + .filter(Boolean) + .join(" · ") || "—", + ), + ].join(""); +} + +const HW_SIM_SLIDER_MAX = 500; +let hwSimUiLock = 0; +let hwSimSliderTimer = null; + +function withHwSimUiLock(fn) { + hwSimUiLock += 1; + try { + return fn(); + } finally { + hwSimUiLock = Math.max(0, hwSimUiLock - 1); + } +} + +function syncHardwareSimulationControls(scales, periph) { + if (hwSimUiLock > 0) return; + const s = scales && typeof scales === "object" ? scales : {}; + const p = periph && typeof periph === "object" ? periph : {}; + const sim = Boolean(s.simulation_mode ?? p.simulation_mode); + const weight = Math.max(0, Math.round(Number(s.simulation_weight_kg ?? 0) || 0)); + const liveWeight = Math.max(0, Math.round(Number(s.weight_kg ?? weight) || 0)); + + const toggle = document.getElementById("hwSimulationToggle"); + const panel = document.getElementById("hwSimulationPanel"); + const status = document.getElementById("hwSimulationStatusLabel"); + const input = document.getElementById("hwSimWeightInput"); + const slider = document.getElementById("hwSimWeightSlider"); + const live = document.getElementById("hwSimWeightLive"); + + if (toggle) toggle.checked = sim; + if (panel) panel.hidden = !sim; + if (status) { + status.textContent = sim + ? `Включена · задано ${weight} кг (live ${liveWeight} кг)` + : "Выключена · чтение HX711"; + } + if (input && document.activeElement !== input) input.value = String(weight); + if (slider && document.activeElement !== slider) { + slider.value = String(Math.min(HW_SIM_SLIDER_MAX, weight)); + } + if (live) live.textContent = String(liveWeight); +} + +async function patchHardwareSimulation(enabled) { + const r = await fetch("/api/admin/hardware/simulation", { + method: "PATCH", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: Boolean(enabled) }), + }); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.message || String(r.status)); + const sim = Boolean(d.simulation_mode ?? (d.hardware || {}).simulation_mode); + if (sim !== Boolean(enabled)) { + throw new Error("Сервер не подтвердил переключение симуляции"); + } + return d; +} + +async function setHardwareSimulationWeight(weightKg) { + const w = Math.max(0, Math.min(9999, Math.round(Number(weightKg) || 0))); + const r = await fetch("/api/admin/hardware/simulation/weight", { + method: "PUT", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ weight_kg: w }), + }); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.message || String(r.status)); + return d; +} + +async function adjustHardwareSimulationWeight(deltaKg) { + const r = await fetch("/api/admin/hardware/simulation/weight", { + method: "PUT", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ delta_kg: deltaKg }), + }); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.message || String(r.status)); + return d; +} + +async function applyHardwareSimulationWeightFromUi() { + const input = document.getElementById("hwSimWeightInput"); + const slider = document.getElementById("hwSimWeightSlider"); + const raw = input && input.value !== "" ? input.value : slider ? slider.value : "0"; + await setHardwareSimulationWeight(raw); + await refreshHardwareSection(); +} + +function scheduleHardwareSimulationWeightFromSlider() { + if (hwSimSliderTimer) window.clearTimeout(hwSimSliderTimer); + hwSimSliderTimer = window.setTimeout(async () => { + hwSimSliderTimer = null; + const slider = document.getElementById("hwSimWeightSlider"); + const input = document.getElementById("hwSimWeightInput"); + if (!slider) return; + withHwSimUiLock(() => { + if (input) input.value = slider.value; + }); + try { + await setHardwareSimulationWeight(slider.value); + await loadHardwareStatus().catch(() => {}); + } catch (error) { + setStatus(error.message, true, "Ошибка изменения веса"); + } + }, 180); +} + +function renderHardwareStatus(data) { + const hint = document.getElementById("hardwareStatusHint"); + if (hint) { + hint.textContent = data.generated_at + ? `обновлено ${formatRuDateTimeFromIso(data.generated_at)}` + : "—"; + } + renderHardwareHostList(data.machine); + renderHardwareHostAlerts(data.host_alerts); + renderHardwarePeriphList(data.peripherals); + renderHardwareScalesList(data.scales); + syncHardwareSimulationControls(data.scales, data.peripherals); +} + +async function loadHardwareStatus() { + const r = await fetch("/api/admin/hardware-status", { credentials: "same-origin" }); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`); + renderHardwareStatus(d); +} + +function renderPeripheralEvents(events) { + const el = document.getElementById("hardwareEventsList"); + if (!el) return; + el.innerHTML = renderPeripheralEventsHtml(events); +} + +function renderPeripheralEventsHtml(events) { + const list = Array.isArray(events) ? events : []; + if (!list.length) { + return wespAdmListEmpty("Событий пока нет"); + } + const rows = [...list].reverse().slice(0, 80); + return rows + .map((ev) => { + const ts = ev.u ? formatRuDateTimeFromIso(new Date(Number(ev.u) * 1000).toISOString()) : "—"; + const code = peripheralEventTitle(ev.code); + const lvl = + ev.level === "err" + ? "err" + : ev.level === "ok" + ? "ok" + : ev.level === "info" + ? "info" + : "warn"; + const badge = + lvl === "err" + ? 'ERR' + : lvl === "ok" + ? diagOkBadge(true) + : lvl === "info" + ? 'INFO' + : 'WARN'; + return wespAdmListItem( + `${escHtml(ev.component || "—")} · ${escHtml(code)}`, + badge, + `${ts} — ${escHtml(String(ev.message || ""))}`, + ); + }) + .join(""); +} + +function renderNetworkEvents(events) { + const el = document.getElementById("networkEventsList"); + if (!el) return; + el.innerHTML = renderPeripheralEventsHtml(events); +} + +async function loadNetworkEvents() { + const el = document.getElementById("networkEventsList"); + const showListLoading = el && !document.getElementById("wespSectionNetwork")?.classList.contains("wesp-network-section--loading"); + if (showListLoading && el) el.innerHTML = wespAdmListLoading("Загрузка журнала…"); + beginNetworkSectionLoad(); + try { + const r = await fetch("/api/admin/peripheral-events?limit=50&component=network", { + credentials: "same-origin", + }); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`); + renderNetworkEvents(d.events); + } finally { + endNetworkSectionLoad(); + } +} + +async function loadPeripheralEvents() { + const r = await fetch("/api/admin/peripheral-events?limit=80&exclude_component=network", { + credentials: "same-origin", + }); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`); + renderPeripheralEvents(d.events); +} + +async function clearNetworkEventsLog() { + if (!window.confirm("Очистить журнал сети?")) return; + const r = await fetch("/api/admin/peripheral-events?component=network", { + method: "DELETE", + credentials: "same-origin", + }); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.message || String(r.status)); + renderNetworkEvents([]); + return d; +} + +async function clearPeripheralEventsLog() { + if (!window.confirm("Очистить журнал событий оборудования? (журнал сети не затрагивается)")) return; + const r = await fetch("/api/admin/peripheral-events?exclude_component=network", { + method: "DELETE", + credentials: "same-origin", + }); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.message || String(r.status)); + renderPeripheralEvents([]); + return d; +} + +function renderAutoUpdateFormFromSummary(au) { + const a = au && typeof au === "object" ? au : {}; + const el = (id) => document.getElementById(id); + const en = el("auEnabled"); + if (en) en.checked = Boolean(a.enabled); + const iv = el("auCheckInterval"); + if (iv) { + const n = Number(a.check_interval_sec); + iv.value = Number.isFinite(n) ? String(n) : "3600"; + } + const setVal = (id, v) => { + const x = el(id); + if (x) x.value = v != null ? String(v) : ""; + }; + setVal("auGiteaUrl", a.gitea_url); + setVal("auGiteaOwner", a.gitea_owner); + setVal("auGiteaRepo", a.gitea_repo); + setVal("auRepositoryUrl", a.repository_url); + const tok = el("auGiteaToken"); + if (tok) tok.value = ""; + const gu = el("auGiteaApiUser"); + if (gu) gu.value = ""; + const gp = el("auGiteaApiPass"); + if (gp) gp.value = ""; + const clr = el("auClearSecrets"); + if (clr) clr.checked = false; + const runtimeHint = el("auRuntimeHint"); + if (runtimeHint) { + const rtParts = []; + if (a.runtime_current_version) rtParts.push(`Версия: ${a.runtime_current_version}`); + if (a.runtime_last_check_at) rtParts.push(`Последняя проверка: ${a.runtime_last_check_at}`); + if (a.runtime_update_available && a.runtime_pending_version) { + rtParts.push(`Доступно: ${a.runtime_pending_version}`); + } else if (a.enabled) { + rtParts.push("Новых версий нет"); + } + if (a.restart_configured) rtParts.push("Перезапуск после установки настроен"); + runtimeHint.textContent = rtParts.length ? rtParts.join(" · ") : ""; + } +} + +async function saveAutoUpdateFromForm() { + const el = (id) => document.getElementById(id); + const body = { + enabled: el("auEnabled") ? el("auEnabled").checked : false, + gitea_url: (el("auGiteaUrl") && el("auGiteaUrl").value.trim()) || "", + gitea_owner: (el("auGiteaOwner") && el("auGiteaOwner").value.trim()) || "", + gitea_repo: (el("auGiteaRepo") && el("auGiteaRepo").value.trim()) || "", + repository_url: (el("auRepositoryUrl") && el("auRepositoryUrl").value.trim()) || "", + }; + const iv = el("auCheckInterval"); + if (iv) { + const n = parseInt(iv.value, 10); + if (Number.isFinite(n)) body.check_interval_sec = n; + } + const r = await fetch("/api/admin/auto-update-settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`); + setStatus(d.message || "auto_update: OK"); + if (d.auto_update) renderAutoUpdateFormFromSummary(d.auto_update); + else await updateSummary(); +} + +async function saveGiteaSecretsFromForm() { + const el = (id) => document.getElementById(id); + const clearSecrets = el("auClearSecrets") ? el("auClearSecrets").checked : false; + const body = { + clear_secrets: clearSecrets, + gitea_token: (el("auGiteaToken") && el("auGiteaToken").value.trim()) || "", + gitea_username: (el("auGiteaApiUser") && el("auGiteaApiUser").value.trim()) || "", + gitea_password: (el("auGiteaApiPass") && el("auGiteaApiPass").value.trim()) || "", + }; + const r = await fetch("/api/admin/gitea-secrets", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`); + setStatus(d.message || "gitea_secrets: OK"); + if (d.auto_update) renderAutoUpdateFormFromSummary(d.auto_update); + else await updateSummary(); +} + +let _factoryResetConfirmPhrase = "СБРОС ДАННЫХ"; + +function renderFactoryResetPreview(preview) { + const list = document.getElementById("factoryResetPreviewList"); + const note = document.getElementById("factoryResetPreservedNote"); + if (!list) return; + if (!preview || typeof preview !== "object") { + list.innerHTML = wespAdmListItem("—", `Нет данных`); + return; + } + _factoryResetConfirmPhrase = String(preview.confirm_phrase || "СБРОС ДАННЫХ"); + const phraseInput = document.getElementById("factoryResetConfirmPhrase"); + if (phraseInput && !phraseInput.value) { + phraseInput.placeholder = _factoryResetConfirmPhrase; + } + if (note) { + const preserved = preview.preserved_note; + if (preserved) { + note.hidden = false; + note.textContent = preserved; + } else { + note.hidden = true; + } + } + const rows = []; + for (const f of preview.files || []) { + const sz = + f.size_bytes != null && !Number.isNaN(Number(f.size_bytes)) + ? formatBytes(Number(f.size_bytes)) + : "—"; + rows.push(wespAdmListItem(`файл: ${f.path}`, `${escHtml(sz)}`)); + } + for (const d of preview.directories || []) { + const n = d.files_inside; + const hint = n != null && n >= 0 ? `${n} файл(ов)` : "каталог"; + rows.push( + wespAdmListItem(`каталог: ${d.path}`, `${escHtml(hint)}`), + ); + } + if (!rows.length) { + rows.push( + wespAdmListItem( + "data/", + `пусто или уже сброшено`, + ), + ); + } + list.innerHTML = rows.join(""); +} + +async function loadFactoryResetPreview() { + const list = document.getElementById("factoryResetPreviewList"); + if (list) { + list.innerHTML = wespAdmListItem("—", `Загрузка…`); + } + const r = await fetch("/api/admin/factory-reset/preview"); + const d = await r.json().catch(() => ({})); + if (!r.ok) { + throw new Error(d.message || `Ошибка ${r.status}`); + } + renderFactoryResetPreview(d); + return d; +} + +async function runFactoryReset() { + const phraseEl = document.getElementById("factoryResetConfirmPhrase"); + const phrase = (phraseEl?.value || "").trim(); + if (phrase !== _factoryResetConfirmPhrase) { + throw new Error(`Введите фразу точно: «${_factoryResetConfirmPhrase}»`); + } + const keepLogs = Boolean(document.getElementById("factoryResetKeepLogs")?.checked); + const safetyBackup = document.getElementById("factoryResetSafetyBackup")?.checked !== false; + const ok = window.confirm( + "Будут удалены базы SQLite, JSON настроек и каталоги в data/.\n\n" + + (safetyBackup + ? "Перед удалением будет создан ZIP с копией БД в data/backups/.\n\n" + : "Резервная копия БД перед сбросом отключена.\n\n") + + (keepLogs ? "Каталог data/logs будет сохранён.\n\n" : "Каталог data/logs будет удалён.\n\n") + + "После сброса перезапустите WESP и откройте /setup.\n\n" + + "Продолжить?", + ); + if (!ok) return null; + const r = await fetch("/api/admin/factory-reset", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + confirmation_phrase: phrase, + remove_logs: !keepLogs, + safety_sqlite_backup: safetyBackup, + }), + }); + const d = await r.json().catch(() => ({})); + if (!r.ok) { + throw new Error(d.message || `Ошибка ${r.status}`); + } + if (phraseEl) phraseEl.value = ""; + await loadFactoryResetPreview().catch(() => {}); + return d; +} + +function renderSystemSectionTables(data) { + const app = data.app || {}; + const sysRows = [ + ["CONFIG_MODE", ruConfigModeLabel(app.config_mode)], + ["DEBUG", app.debug ? "true" : "false"], + ["version", app.version ? `v${String(app.version).replace(/^v/i, "")}` : "—"], + ["sync.role", ruSyncRoleLabel(app.role)], + ["first_launch_at", formatRuDateFromIso(app.first_launch_at)], + ["warranty_until", formatWarrantyUntilCell(app.warranty_until)], + ["warranty_days", formatWarrantyDaysCell(app.warranty_days_remaining)], + ]; + const machineRows = machineOverviewRows(data.machine); + const sysList = document.getElementById("systemDataList"); + if (sysList) { + const appHtml = sysRows + .map(([k, v]) => wespAdmListItem(k, `${escHtml(v)}`)) + .join(""); + const sectionHtml = + machineRows.length > 0 + ? `${wespAdmListSection("Host / hardware")}${machineRows + .map(([k, v]) => wespAdmListItem(k, `${escHtml(v)}`)) + .join("")}` + : ""; + sysList.innerHTML = appHtml + sectionHtml; + } + + const dbs = data.databases || {}; + const dbList = document.getElementById("dbDataList"); + if (dbList) { + const bindTitle = (key) => { + if (key === "recipes") return "bind: recipes"; + if (key === "reports") return "bind: reports"; + return key; + }; + const engineRu = (e) => { + if (e == null || e === "") return "—"; + if (String(e).toLowerCase() === "sqlite") return "SQLite"; + return String(e); + }; + dbList.innerHTML = Object.entries(dbs) + .map(([key, desc]) => { + const d = desc && typeof desc === "object" ? desc : {}; + const file = d.database != null && d.database !== "" ? String(d.database) : "—"; + const sz = + d.size_bytes != null && !Number.isNaN(Number(d.size_bytes)) + ? formatBytes(Number(d.size_bytes)) + : "—"; + const eng = engineRu(d.engine); + const descLine = `${eng} · ${file}`; + return wespAdmListItem(bindTitle(key), `${escHtml(sz)}`, descLine); + }) + .join(""); + } +} + +function formatSpeed(bps) { + if (bps == null || Number.isNaN(bps)) return "—"; + const x = Number(bps); + if (x < 1024) return `${x.toFixed(0)} B/s`; + if (x < 1024 * 1024) return `${(x / 1024).toFixed(2)} KB/s`; + return `${(x / (1024 * 1024)).toFixed(2)} MB/s`; +} + +function setRingPercent(gaugeRoot, percent) { + const pctEl = gaugeRoot.querySelector("[data-gauge-pct]"); + if (!pctEl) return; + const p = Math.min(100, Math.max(0, Number(percent) || 0)); + pctEl.textContent = p.toFixed(2); + gaugeRoot.style.setProperty("--gauge-circ", String(GAUGE_CIRC)); + gaugeRoot.style.setProperty("--gauge-pct", String(p)); +} + +function decimateHardwarePoints(points, maxN) { + if (!points.length || points.length <= maxN) return points; + const step = Math.ceil(points.length / maxN); + const out = []; + for (let i = 0; i < points.length; i += step) out.push(points[i]); + if (out[out.length - 1] !== points[points.length - 1]) out.push(points[points.length - 1]); + return out; +} + +function hardwarePathForKey(pts, key, tMin, tMax, padL, padT, plotW, plotH) { + const span = Math.max(1, tMax - tMin); + let d = ""; + let penUp = true; + for (const p of pts) { + let v = p[key]; + if (v == null || Number.isNaN(Number(v))) { + penUp = true; + continue; + } + v = Math.min(100, Math.max(0, Number(v))); + const x = padL + ((p.t - tMin) / span) * plotW; + const y = padT + plotH - (v / 100) * plotH; + d += penUp ? `M${x.toFixed(1)},${y.toFixed(1)}` : `L${x.toFixed(1)},${y.toFixed(1)}`; + penUp = false; + } + return d; +} + +function renderHardwareHistorySvg(svgEl, points) { + const pts = decimateHardwarePoints(points, 900); + const tMin = pts[0].t; + const tMax = pts[pts.length - 1].t; + const W = 640; + const H = 220; + const padL = 42; + const padR = 10; + const padT = 12; + const padB = 26; + const plotW = W - padL - padR; + const plotH = H - padT - padB; + + const fmtTime = (unix) => { + const d = new Date(unix * 1000); + return d.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" }); + }; + + const gridYs = [0, 0.5, 1]; + let grid = ""; + for (const gy of gridYs) { + const y = padT + plotH * (1 - gy); + grid += ``; + } + + const series = [ + { key: "cpu", color: "#008771" }, + { key: "ram", color: "#1890ff" }, + { key: "swap", color: "#faad14" }, + { key: "disk", color: "#722ed1" }, + ]; + + const paths = series + .map((s) => { + const d = hardwarePathForKey(pts, s.key, tMin, tMax, padL, padT, plotW, plotH); + if (!d) return ""; + return ``; + }) + .join(""); + + const yLbl = (v) => + `${v}`; + + svgEl.innerHTML = ` + ${grid} + ${yLbl(0)}${yLbl(50)}${yLbl(100)} + ${fmtTime(tMin)} + ${fmtTime(tMax)} + % + ${paths} + `; +} + +async function loadHardwareHistoryCharts() { + const hint = document.getElementById("wespHardwareHistoryHint"); + const wrap = document.getElementById("wespHardwareHistoryWrap"); + const svg = document.getElementById("wespHardwareHistorySvg"); + const leg = document.getElementById("wespHardwareHistoryLegend"); + if (!hint || !wrap || !svg || !leg) return; + try { + const r = await fetch("/api/admin/hardware-metrics-history"); + const d = await r.json(); + if (!r.ok) throw new Error(d.message || String(r.status)); + const pts = Array.isArray(d.points) ? d.points : []; + const days = d.retention_days ?? 3; + if (pts.length === 0) { + hint.textContent = `timeseries: ∅. Сэмплы при опросе summary; retention ${days} d.`; + wrap.hidden = true; + leg.innerHTML = ""; + return; + } + hint.textContent = `utilization %, окно ${days} d.`; + wrap.hidden = false; + renderHardwareHistorySvg(svg, pts); + leg.innerHTML = [ + { key: "cpu", label: "ЦП", c: "#008771" }, + { key: "ram", label: "ОЗУ", c: "#1890ff" }, + { key: "swap", label: "Подкачка", c: "#faad14" }, + { key: "disk", label: "Диск", c: "#722ed1" }, + ] + .map( + (x) => + `${x.label}`, + ) + .join(""); + } catch (e) { + hint.textContent = userHint(e.message, "Не удалось загрузить метрики"); + wrap.hidden = true; + leg.innerHTML = ""; + } +} + +function scrollToAdminSection(section) { + const el = document.querySelector(`[data-section-content="${section}"]`); + if (!el) return; + window.requestAnimationFrame(() => { + el.scrollIntoView({ behavior: "smooth", block: "start" }); + }); +} + +function goToDashboardStatNav(nav) { + if (nav === "recipes") { + window.location.href = "/recipes"; + return; + } + if (nav === "components") { + window.location.href = "/components"; + return; + } + if (nav === "users" || nav === "sync") { + activateSection(nav); + scrollToAdminSection(nav); + } +} + +/** Какую БД предвыбрать в sql.js (счётчики дашборда относятся к основной). */ +function dashboardStatPreferredBind(_nav) { + return "recipes"; +} + +function initDashboardStatNav() { + document.querySelectorAll("[data-stat-nav]").forEach((el) => { + const nav = el.dataset.statNav; + if (!nav) return; + const onActivate = async (event) => { + if (event && "altKey" in event && event.altKey) { + event.preventDefault(); + try { + await openAdminDbBrowser({ + preferredBind: dashboardStatPreferredBind(nav), + setStatus, + }); + } catch (e) { + setStatus(e.message, true, "Ошибка редактора БД"); + } + return; + } + goToDashboardStatNav(nav); + }; + el.addEventListener("click", onActivate); + el.addEventListener("keydown", (e) => { + if (e.key !== "Enter" && e.key !== " ") return; + e.preventDefault(); + if (e.altKey) { + onActivate(e); + return; + } + goToDashboardStatNav(nav); + }); + }); +} + +function activateSection(section) { + stopHardwarePolling(); + document.querySelectorAll("[data-section]").forEach((item) => { + const on = item.dataset.section === section; + item.classList.toggle("ant-menu-item-selected", on); + }); + document.querySelectorAll("[data-section-content]").forEach((sectionEl) => { + sectionEl.classList.toggle( + "is-active", + sectionEl.dataset.sectionContent === section, + ); + }); + const active = document.querySelector(`[data-section-content="${section}"]`); + if (!active) return; + active.querySelectorAll(".wesp-admin-block").forEach((el, i) => { + el.style.setProperty("--wesp-reveal-i", String(i)); + el.classList.remove("wesp-admin-section-enter"); + void el.offsetWidth; + el.classList.add("wesp-admin-section-enter"); + }); + if (section === "hardware") { + loadHardwareHistoryCharts().catch(() => {}); + startHardwarePolling(); + } + if (section === "sync") { + loadSyncDiagnostics().catch((e) => setStatus(e.message, true, "Ошибка обновления")); + loadOrchestratorSyncSettings().catch(() => {}); + } + if (section === "network") { + Promise.all([ + loadNetworkSettings(), + loadNetworkEvents(), + ]).catch((e) => setStatus(e.message, true, "Ошибка загрузки сети")); + } + if (section === "diagnostics") { + setDiagnosticsSecurityListLoading(); + loadDiagnosticsReport().catch((e) => setStatus(e.message, true, "Ошибка диагностики")); + } + if (section === "system") { + loadFactoryResetPreview().catch((e) => + setStatus(e.message, true, "Ошибка предпросмотра сброса"), + ); + refreshKioskFullSetupHint(); + refreshPiPlatformSetupHint(); + refreshPiRainbowSplashHint(); + refreshPlymouthStatusHint().then((running) => { + if (running) startPlymouthStatusPoll(); + }); + refreshKioskBootStatusHint(); + } + if (section === "assistant") { + loadAdminAssistantStatus().catch(() => {}); + loadAdminAssistantLlmLog().catch(() => {}); + if (!window.__wespAssistantChatMessages) hydrateAssistantChatMessagesFromStorage(); + renderAssistantChatLog(); + } +} + +/** Кнопка «история» у ЦП на дашборде: раздел Железо + график. */ +function openHardwareChartFromDashboard() { + activateSection("hardware"); + window.requestAnimationFrame(() => { + document.getElementById("wespHardwareHistoryCard")?.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + }); +} + +async function fetchAuth() { + const response = await fetch("/api/auth/check"); + const data = await response.json(); + if (!(data.status === "success" && data.authenticated && data.is_superuser)) { + window.location.href = "/recipes"; + return null; + } + return data; +} + +function userRowTemplate(user) { + const role = user.is_superuser ? "Суперпользователь" : "Пользователь"; + const modulesHint = user.is_superuser + ? "Lab: всегда" + : user.lab_access + ? "Lab: вкл." + : "Lab: выкл."; + const extra = ``; + return wespAdmListItem(user.login, extra, `${role} · ${modulesHint}`, { + itemClass: "wesp-adm-list-item--user wesp-adm-list-item--clickable", + dataAttrs: + `data-user-id="${String(user.id).replace(/"/g, """)}" ` + + `data-is-super="${user.is_superuser ? "1" : "0"}" ` + + `data-lab-access="${user.lab_access ? "1" : "0"}" ` + + `data-user-login="${String(user.login).replace(/"/g, """)}"`, + }); +} + +let userModulesModalState = null; + +function closeUserModulesModal() { + const modal = document.getElementById("userModulesModal"); + if (modal) modal.hidden = true; + userModulesModalState = null; +} + +function openUserModulesModal(row) { + userModulesModalState = { + userId: row.dataset.userId, + login: row.dataset.userLogin || "", + isSuper: row.dataset.isSuper === "1", + labAccess: row.dataset.labAccess === "1", + }; + const loginEl = document.getElementById("userModulesModalLogin"); + if (loginEl) loginEl.textContent = userModulesModalState.login || "—"; + const toggle = document.getElementById("userModuleLabAccess"); + if (toggle) { + toggle.checked = userModulesModalState.isSuper || userModulesModalState.labAccess; + toggle.disabled = userModulesModalState.isSuper; + wireAntCheckbox(toggle); + } + const hint = document.getElementById("userModuleLabSuperHint"); + if (hint) hint.hidden = !userModulesModalState.isSuper; + const modal = document.getElementById("userModulesModal"); + if (modal) modal.hidden = false; +} + +async function saveUserModulesModal() { + if (!userModulesModalState) return; + if (userModulesModalState.isSuper) { + closeUserModulesModal(); + return; + } + const toggle = document.getElementById("userModuleLabAccess"); + const labAccess = Boolean(toggle?.checked); + const response = await fetch(`/api/admin/users/${userModulesModalState.userId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ lab_access: labAccess }), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.message || "Не удалось сохранить доступ к модулям"); + setStatus("lab_access: OK"); + closeUserModulesModal(); + await loadUsers(); +} + +async function loadUsers() { + const response = await fetch("/api/admin/users"); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.message || "Ошибка загрузки пользователей"); + } + const list = document.getElementById("usersList"); + if (!list) return; + list.innerHTML = data.users.map(userRowTemplate).join(""); +} + +async function updateSystemMetrics() { + const response = await fetch("/api/admin/system-metrics"); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.message || "Ошибка метрик системы"); + } + const m = data.metrics; + + if (!m || m.available === false) { + const unavailableMsg = + (m && typeof m.message === "string" && m.message.trim()) || + "Метрики CPU/RAM недоступны (нужен psutil на сервере)."; + ["cpu", "ram", "swap", "disk"].forEach((key) => { + const root = document.querySelector(`[data-gauge="${key}"]`); + if (!root) return; + root.style.setProperty("--gauge-pct", "0"); + const d = root.querySelector("[data-gauge-detail]"); + if (d) d.textContent = "—"; + const p = root.querySelector("[data-gauge-pct]"); + if (p) p.textContent = "—"; + }); + const pills = document.getElementById("loadAvgPills"); + if (pills) pills.textContent = "—"; + const nu = document.getElementById("netUpload"); + const nd = document.getElementById("netDownload"); + if (nu) nu.textContent = "—"; + if (nd) nd.textContent = "—"; + const nvs = document.getElementById("netVolumeSent"); + const nvr = document.getElementById("netVolumeRecv"); + if (nvs) nvs.textContent = "—"; + if (nvr) nvr.textContent = "—"; + renderServerIps([]); + const pillH = document.getElementById("pillHostUptime"); + const pillW = document.getElementById("pillWespUptime"); + const pillC = document.getElementById("pillConnections"); + if (pillH) pillH.textContent = "host: —"; + if (pillW) pillW.textContent = "wesp: —"; + if (pillC) pillC.textContent = "TCP — · UDP —"; + const st = document.getElementById("wespRuntimeStatus"); + const dot = document.querySelector(".wesp-dash-status .wesp-status-dot"); + if (st) st.textContent = unavailableMsg; + if (dot) { + dot.classList.remove("wesp-status-dot--ok"); + dot.classList.add("wesp-status-dot--warn"); + } + return; + } + + const cpuRoot = document.querySelector('[data-gauge="cpu"]'); + if (cpuRoot) { + setRingPercent(cpuRoot, m.cpu?.percent ?? 0); + const d = cpuRoot.querySelector("[data-gauge-detail]"); + if (d) d.textContent = ruCpuCoresLabel(m.cpu?.cores); + } + + const ramRoot = document.querySelector('[data-gauge="ram"]'); + if (ramRoot) { + setRingPercent(ramRoot, m.memory?.percent ?? 0); + const d = ramRoot.querySelector("[data-gauge-detail]"); + if (d) { + d.textContent = `${formatBytes(m.memory?.used)} / ${formatBytes(m.memory?.total)}`; + } + } + + const swapRoot = document.querySelector('[data-gauge="swap"]'); + if (swapRoot) { + const sp = m.swap?.total ? m.swap.percent : 0; + setRingPercent(swapRoot, sp); + const d = swapRoot.querySelector("[data-gauge-detail]"); + if (d) { + d.textContent = + m.swap?.total > 0 + ? `${formatBytes(m.swap.used)} / ${formatBytes(m.swap.total)}` + : "Нет swap"; + } + } + + const diskRoot = document.querySelector('[data-gauge="disk"]'); + if (diskRoot) { + if (m.disk) { + setRingPercent(diskRoot, m.disk.percent ?? 0); + const d = diskRoot.querySelector("[data-gauge-detail]"); + if (d) { + d.textContent = `${formatBytes(m.disk.used)} / ${formatBytes(m.disk.total)}`; + } + } else { + setRingPercent(diskRoot, 0); + const d = diskRoot.querySelector("[data-gauge-detail]"); + if (d) d.textContent = "n/a"; + const p = diskRoot.querySelector("[data-gauge-pct]"); + if (p) p.textContent = "—"; + } + } + + const pills = document.getElementById("loadAvgPills"); + if (pills) { + if (Array.isArray(m.load_avg) && m.load_avg.length === 3) { + pills.innerHTML = m.load_avg + .map( + (v, i) => + `${i ? '|' : ""}${v}`, + ) + .join(""); + } else { + pills.textContent = "n/a (OS)"; + } + } + + const nu = document.getElementById("netUpload"); + const nd = document.getElementById("netDownload"); + if (nu) nu.textContent = formatSpeed(m.network?.upload_bps); + if (nd) nd.textContent = formatSpeed(m.network?.download_bps); + + const nvs = document.getElementById("netVolumeSent"); + const nvr = document.getElementById("netVolumeRecv"); + if (nvs) nvs.textContent = formatBytes(m.network?.bytes_sent_total); + if (nvr) nvr.textContent = formatBytes(m.network?.bytes_recv_total); + + renderServerIps(m.addresses); + + const pillH = document.getElementById("pillHostUptime"); + const pillW = document.getElementById("pillWespUptime"); + const pillC = document.getElementById("pillConnections"); + if (pillH && m.host) pillH.textContent = `host: ${m.host.uptime_human || "—"}`; + if (pillW && m.process) { + pillW.textContent = `wesp: ${m.process.uptime_human || "—"}`; + } + if (pillC && m.connections) { + pillC.textContent = `TCP ${m.connections.tcp} · UDP ${m.connections.udp}`; + } else if (pillC) { + pillC.textContent = "TCP/UDP: n/a"; + } + + const st = document.getElementById("wespRuntimeStatus"); + const dot = document.querySelector(".wesp-dash-status .wesp-status-dot"); + if (st) st.textContent = "OK"; + if (dot) { + dot.classList.add("wesp-status-dot--ok"); + dot.classList.remove("wesp-status-dot--warn"); + } +} + +async function updateSummary() { + const response = await fetch("/api/admin/summary"); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.message || "Ошибка загрузки summary"); + } + + document.getElementById("statUsers").textContent = String(data.counts.users); + document.getElementById("statRecipes").textContent = String(data.counts.recipes); + document.getElementById("statComponents").textContent = String(data.counts.components); + document.getElementById("statQueuePending").textContent = String(data.sync.queue.pending); + + const verEl = document.getElementById("wespDashVersion"); + if (verEl) { + const v = data.app?.version || "—"; + verEl.textContent = `v${String(v).replace(/^v/i, "")}`; + } + const firstEl = document.getElementById("wespFirstLaunch"); + if (firstEl) firstEl.textContent = formatRuDateFromIso(data.app?.first_launch_at); + const untilEl = document.getElementById("wespWarrantyUntil"); + if (untilEl) { + const wu = data.app?.warranty_until; + if (!wu) untilEl.textContent = "—"; + else { + const p = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(wu)); + if (p) { + const dt = new Date(Number(p[1]), Number(p[2]) - 1, Number(p[3])); + untilEl.textContent = dt.toLocaleDateString("ru-RU", { + day: "numeric", + month: "long", + year: "numeric", + }); + } else { + untilEl.textContent = formatRuDateFromIso(wu); + } + } + } + const daysEl = document.getElementById("wespWarrantyDays"); + if (daysEl) { + const dr = data.app?.warranty_days_remaining; + if (dr == null || Number.isNaN(Number(dr))) daysEl.textContent = "—"; + else if (Number(dr) < 0) daysEl.textContent = "истекла"; + else daysEl.textContent = String(dr); + } + + const setTxt = (id, text) => { + const el = document.getElementById(id); + if (el) el.textContent = text; + }; + const app = data.app || {}; + const counts = data.counts || {}; + setTxt("dashMetaConfigMode", ruConfigModeLabel(app.config_mode)); + setTxt("dashMetaDebug", app.debug ? "true" : "false"); + setTxt("dashMetaVersion", app.version ? `v${String(app.version).replace(/^v/i, "")}` : "—"); + setTxt("dashMetaSyncRole", ruSyncRoleLabel(app.role)); + setTxt("dashMetaFirstLaunch", formatRuDateFromIso(app.first_launch_at)); + const wu = app.warranty_until; + if (wu) { + const p = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(wu)); + if (p) { + const dt = new Date(Number(p[1]), Number(p[2]) - 1, Number(p[3])); + setTxt( + "dashMetaWarrantyUntil", + dt.toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric" }), + ); + } else { + setTxt("dashMetaWarrantyUntil", formatRuDateFromIso(wu)); + } + } else { + setTxt("dashMetaWarrantyUntil", "—"); + } + const drDash = app.warranty_days_remaining; + if (drDash == null || Number.isNaN(Number(drDash))) setTxt("dashMetaWarrantyDays", "—"); + else if (Number(drDash) < 0) setTxt("dashMetaWarrantyDays", "истекла"); + else setTxt("dashMetaWarrantyDays", String(drDash)); + + setTxt("dashCountUsers", String(counts.users ?? "—")); + setTxt("dashCountRecipes", String(counts.recipes ?? "—")); + setTxt("dashCountIngredients", String(counts.ingredients ?? "—")); + setTxt("dashCountComponents", String(counts.components ?? "—")); + setTxt("dashCountDispensers", String(counts.feed_dispensers ?? "—")); + setTxt("dashCountPeriods", String(counts.feeding_periods ?? "—")); + + setTxt( + "dashMetaGenerated", + data.generated_at + ? `summary @ ${formatRuDateTimeFromIso(data.generated_at)}` + : "—", + ); + renderSystemSectionTables(data); + renderAutoUpdateFormFromSummary(data.auto_update || {}); + renderSyncSectionFromSummary(data.sync || {}); + await loadSyncClientsTable(); + renderSecuritySectionFromSummary(data.security || {}); +} + +async function refreshHardwareSection() { + await Promise.all([loadHardwareStatus(), loadPeripheralEvents()]); + setStatus("Железо: обновлено"); +} + +function setSecurityFieldHint(id, locked, envName) { + const hint = document.getElementById(id); + if (!hint) return; + if (locked) { + hint.hidden = false; + hint.textContent = `Заблокировано переменной окружения (${envName}). Снимите её, чтобы менять из админки.`; + } else { + hint.hidden = true; + hint.textContent = ""; + } +} + +async function patchSecurityBool(field, checked, el) { + if (el?.disabled) return; + const prev = !checked; + try { + const r = await fetch("/api/admin/security-settings", { + method: "PATCH", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ [field]: checked }), + }); + const d = await r.json().catch(() => ({})); + if (!r.ok) { + setStatus(d.message || `Ошибка ${r.status}`, true); + if (el) el.checked = prev; + return; + } + const labels = { + mac_lock_enabled: checked ? "MAC-lock включён" : "MAC-lock выключен", + kiosk_enforce_paired_only: checked + ? "Требование привязки терминала включено" + : "Требование привязки терминала выключено", + calibration_public: checked + ? "Публичная калибровка включена" + : "Публичная калибровка выключена", + }; + setStatus(d.message || labels[field] || "Сохранено"); + if (d.security) renderSecuritySectionFromSummary(d.security); + } catch (err) { + setStatus(err?.message, true, "Ошибка сохранения"); + if (el) el.checked = prev; + } +} + +function wireSecurityToggleAutoSave(id, field) { + document.getElementById(id)?.addEventListener("change", async (e) => { + const el = e.target; + if (!(el instanceof HTMLInputElement)) return; + await patchSecurityBool(field, el.checked, el); + }); +} + +function renderNetworkDetectedList(net) { + const el = document.getElementById("networkDetectedList"); + if (!el) return; + const d = (net && net.detected) || {}; + const mdnsOn = Boolean(d.mdns_active); + const mdnsAvail = Boolean(d.mdns_available); + el.innerHTML = [ + wespAdmListSection("Обнаружено"), + wespAdmListItem( + "LAN IP", + `${escHtml(String(d.lan_ip ?? "—"))}`, + "текущий адрес в сети", + ), + wespAdmListItem( + "Hostname ОС", + escHtml(String(d.os_hostname ?? "—")), + "socket.gethostname()", + ), + wespAdmListItem( + "Listen", + `${escHtml(String(d.listen_host ?? "—"))}:${escHtml(String(d.listen_port ?? "—"))}`, + "WESP_LISTEN / waitress", + ), + wespAdmListItem( + "mDNS", + mdnsOn + ? 'активен' + : mdnsAvail + ? 'выкл.' + : 'zeroconf ∅', + mdnsOn + ? "имя объявляется в LAN" + : d.mdns_error + ? escHtml(String(d.mdns_error)) + : mdnsAvail + ? "включите toggle ниже" + : "pip install zeroconf", + ), + wespAdmListItem( + "Подсказка URL", + `${escHtml(String(net.suggested_public_url ?? "—"))}`, + "из имени + порта", + ), + wespAdmListItem( + "Sync по IP", + `${escHtml(String(net.sync_url_by_ip ?? "—"))}`, + "если .local в сети не резолвится — укажите этот адрес на терминалах", + ), + ].join(""); +} + +function renderNetworkSectionFromSummary(net) { + const n = net && typeof net === "object" ? net : {}; + const hint = document.getElementById("networkSettingsHint"); + const lockParts = []; + if (n.local_hostname_locked_by_env) lockParts.push("local_hostname ← WESP_NETWORK_LOCAL_HOSTNAME"); + if (n.public_base_url_locked_by_env) lockParts.push("public URL ← WESP_KIOSK_PUBLIC_BASE_URL"); + if (n.mdns_enabled_locked_by_env) lockParts.push("mDNS ← WESP_MDNS_ENABLED"); + if (hint) { + const base = + "PATCH → data/wesp_network_settings.json. Адрес sync-клиентов — вкладка «Синхронизация»."; + hint.textContent = lockParts.length ? `${base} env: ${lockParts.join("; ")}.` : base; + } + + const hostInp = document.getElementById("netLocalHostname"); + if (hostInp) { + hostInp.value = n.local_hostname || n.default_local_hostname || "komton_srv_1.local"; + hostInp.disabled = Boolean(n.local_hostname_locked_by_env); + } + const urlInp = document.getElementById("netPublicBaseUrl"); + if (urlInp) { + urlInp.value = n.public_base_url || ""; + urlInp.disabled = Boolean(n.public_base_url_locked_by_env); + } + const mdns = document.getElementById("netMdnsEnabled"); + const mdnsLabel = document.getElementById("netMdnsStatusLabel"); + const mdnsHint = document.getElementById("netMdnsHint"); + const det = n.detected || {}; + if (mdns) { + mdns.checked = Boolean(n.mdns_enabled); + mdns.disabled = Boolean(n.mdns_enabled_locked_by_env) || !det.mdns_available; + } + if (mdnsLabel) { + mdnsLabel.textContent = n.mdns_enabled ? "mDNS: вкл." : "mDNS: выкл."; + } + if (mdnsHint) { + if (!det.mdns_available) { + mdnsHint.textContent = "Пакет zeroconf не установлен — объявление имени в LAN недоступно."; + } else if (det.mdns_active) { + mdnsHint.textContent = "Сервис объявлен в локальной сети."; + } else if (det.mdns_error) { + mdnsHint.textContent = String(det.mdns_error); + } else { + mdnsHint.textContent = + "Включите mDNS, чтобы клиенты в LAN резолвили имя без /etc/hosts и без DNS роутера."; + } + } + const saveBtn = document.getElementById("netSettingsSave"); + if (saveBtn) { + saveBtn.disabled = + Boolean(n.local_hostname_locked_by_env) && + Boolean(n.public_base_url_locked_by_env) && + Boolean(n.mdns_enabled_locked_by_env); + } + renderNetworkDetectedList(n); +} + +let lastNetworkSnapshot = null; +let networkSectionLoadPending = 0; + +function setNetworkSectionLoading(loading) { + const section = document.getElementById("wespSectionNetwork"); + if (section) section.classList.toggle("wesp-network-section--loading", loading); + const banner = document.getElementById("networkFormLoading"); + if (banner) banner.hidden = !loading; +} + +function beginNetworkSectionLoad() { + networkSectionLoadPending += 1; + if (networkSectionLoadPending === 1) { + setNetworkSectionLoading(true); + const det = document.getElementById("networkDetectedList"); + if (det) det.innerHTML = wespAdmListLoading("Определение LAN IP и mDNS…"); + const ev = document.getElementById("networkEventsList"); + if (ev && !ev.querySelector(".wesp-adm-list-loading")) { + ev.innerHTML = wespAdmListLoading("Загрузка журнала…"); + } + } +} + +function endNetworkSectionLoad() { + networkSectionLoadPending = Math.max(0, networkSectionLoadPending - 1); + if (networkSectionLoadPending === 0) { + setNetworkSectionLoading(false); + } +} + +async function loadNetworkSettings() { + beginNetworkSectionLoad(); + try { + const r = await fetch("/api/admin/network-settings", { credentials: "same-origin" }); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.message || String(r.status)); + lastNetworkSnapshot = d.network || d; + renderNetworkSectionFromSummary(lastNetworkSnapshot); + return lastNetworkSnapshot; + } finally { + endNetworkSectionLoad(); + } +} + +function wespListenPortFallback() { + const p = Number(lastNetworkSnapshot?.detected?.listen_port); + return Number.isFinite(p) && p > 0 ? p : 80; +} + +function applyNetworkSuggestedPublicUrl() { + const hostInp = document.getElementById("netLocalHostname"); + const urlInp = document.getElementById("netPublicBaseUrl"); + if (!hostInp || !urlInp || urlInp.disabled) return; + let host = (hostInp.value || "komton_srv_1.local").trim().toLowerCase(); + if (host && !host.endsWith(".local")) host = `${host}.local`; + const port = wespListenPortFallback(); + urlInp.value = port === 80 ? `http://${host}` : `http://${host}:${port}`; +} + +async function saveNetworkSettingsFromForm() { + const hostInp = document.getElementById("netLocalHostname"); + const urlInp = document.getElementById("netPublicBaseUrl"); + const mdns = document.getElementById("netMdnsEnabled"); + const body = {}; + if (hostInp && !hostInp.disabled) body.local_hostname = hostInp.value.trim(); + if (urlInp && !urlInp.disabled) body.public_base_url = urlInp.value.trim(); + if (mdns && !mdns.disabled) body.mdns_enabled = mdns.checked; + if (Object.keys(body).length === 0) { + setStatus("network PATCH: noop (env lock / no delta)", true); + return; + } + beginNetworkSectionLoad(); + try { + const r = await fetch("/api/admin/network-settings", { + method: "PATCH", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`); + setStatus(d.message || "Сеть: сохранено"); + if (d.network) renderNetworkSectionFromSummary(d.network); + await loadNetworkEvents(); + } finally { + endNetworkSectionLoad(); + } +} + +function renderSecuritySectionFromSummary(sec) { + const s = sec && typeof sec === "object" ? sec : {}; + const hint = document.getElementById("securitySettingsHint"); + const lockParts = []; + if (s.mac_lock_enabled_locked_by_env) lockParts.push("MAC: WESP_MAC_LOCK_ENABLED"); + if (s.allowed_mac_addresses_locked_by_env) lockParts.push("MAC list ← WESP_ALLOWED_MAC_ADDRESSES"); + if (s.kiosk_enforce_paired_only_locked_by_env) { + lockParts.push("kiosk_enforce ← WESP_KIOSK_ENFORCE_PAIRED_ONLY"); + } + if (s.calibration_public_locked_by_env) lockParts.push("CALIBRATION_PUBLIC ← env"); + if (s.device_token_locked_by_env) lockParts.push("DEVICE_TOKEN ← env"); + if (s.device_token_header_locked_by_env) lockParts.push("token header ← env"); + if (s.kiosk_pair_token_ttl_seconds_locked_by_env) { + lockParts.push("pair TTL ← WESP_KIOSK_PAIR_TOKEN_TTL_SECONDS"); + } + if (s.kiosk_auth_cookie_days_locked_by_env) lockParts.push("kiosk cookie ← WESP_KIOSK_AUTH_COOKIE_DAYS"); + if (s.session_remember_days_locked_by_env) lockParts.push("remember_days ← WESP_SESSION_REMEMBER_DAYS"); + + if (hint) { + hint.textContent = lockParts.length + ? `env override: ${lockParts.join("; ")}.` + : "PATCH → data/wesp_security_settings.json + in-process config; приоритет у WESP_*."; + } + + const mac = document.getElementById("secMacLock"); + const macs = document.getElementById("secAllowedMacs"); + if (mac) { + mac.checked = Boolean(s.mac_lock_enabled); + mac.disabled = Boolean(s.mac_lock_enabled_locked_by_env); + } + setSecurityFieldHint( + "secMacLockHint", + Boolean(s.mac_lock_enabled_locked_by_env), + "WESP_MAC_LOCK_ENABLED", + ); + if (macs) { + macs.value = s.allowed_mac_addresses || ""; + macs.disabled = Boolean(s.allowed_mac_addresses_locked_by_env); + } + + const kEnf = document.getElementById("secKioskEnforce"); + if (kEnf) { + kEnf.checked = Boolean(s.kiosk_enforce_paired_only); + kEnf.disabled = Boolean(s.kiosk_enforce_paired_only_locked_by_env); + } + setSecurityFieldHint( + "secKioskEnforceHint", + Boolean(s.kiosk_enforce_paired_only_locked_by_env), + "WESP_KIOSK_ENFORCE_PAIRED_ONLY", + ); + + const cal = document.getElementById("secCalibrationPublic"); + if (cal) { + cal.checked = Boolean(s.calibration_public); + cal.disabled = Boolean(s.calibration_public_locked_by_env); + } + setSecurityFieldHint( + "secCalibrationPublicHint", + Boolean(s.calibration_public_locked_by_env), + "WESP_CALIBRATION_PUBLIC", + ); + + const th = document.getElementById("secDeviceTokenHeader"); + if (th) { + th.value = s.device_token_header || "X-Device-Token"; + th.disabled = Boolean(s.device_token_header_locked_by_env); + } + + const token = document.getElementById("secDeviceToken"); + const tclear = document.getElementById("secDeviceTokenClear"); + if (tclear) { + tclear.checked = false; + tclear.disabled = Boolean(s.device_token_locked_by_env); + } + if (token) { + token.value = ""; + const lockedTok = Boolean(s.device_token_locked_by_env); + token.dataset.envLocked = lockedTok ? "1" : ""; + token.disabled = lockedTok || Boolean(tclear && tclear.checked); + } + + const note = document.getElementById("secDeviceTokenNote"); + if (note) { + note.textContent = s.device_token_configured + ? "DEVICE_TOKEN: задан. Смена — поле ниже или clear." + : "DEVICE_TOKEN: ∅. Публичная калибровка OFF."; + } + + const ttl = document.getElementById("secKioskPairTtl"); + if (ttl) { + ttl.value = String(s.kiosk_pair_token_ttl_seconds ?? 300); + ttl.disabled = Boolean(s.kiosk_pair_token_ttl_seconds_locked_by_env); + } + const ckd = document.getElementById("secKioskCookieDays"); + if (ckd) { + ckd.value = String(s.kiosk_auth_cookie_days ?? 18250); + ckd.disabled = Boolean(s.kiosk_auth_cookie_days_locked_by_env); + } + const sess = document.getElementById("secSessionRememberDays"); + if (sess) { + sess.value = String(s.session_remember_days ?? 7); + sess.disabled = Boolean(s.session_remember_days_locked_by_env); + } + + const saveBtn = document.getElementById("securitySettingsSave"); + if (saveBtn) { + const allLocked = + Boolean(s.mac_lock_enabled_locked_by_env) && + Boolean(s.allowed_mac_addresses_locked_by_env) && + Boolean(s.kiosk_enforce_paired_only_locked_by_env) && + Boolean(s.calibration_public_locked_by_env) && + Boolean(s.device_token_locked_by_env) && + Boolean(s.device_token_header_locked_by_env) && + Boolean(s.kiosk_pair_token_ttl_seconds_locked_by_env) && + Boolean(s.kiosk_auth_cookie_days_locked_by_env) && + Boolean(s.session_remember_days_locked_by_env); + saveBtn.disabled = allLocked; + } +} + +async function saveSecuritySettingsFromForm() { + const el = (id) => document.getElementById(id); + const body = {}; + const mac = el("secMacLock"); + if (mac && !mac.disabled) body.mac_lock_enabled = mac.checked; + const macs = el("secAllowedMacs"); + if (macs && !macs.disabled) body.allowed_mac_addresses = macs.value.trim(); + const kEnf = el("secKioskEnforce"); + if (kEnf && !kEnf.disabled) body.kiosk_enforce_paired_only = kEnf.checked; + const cal = el("secCalibrationPublic"); + if (cal && !cal.disabled) body.calibration_public = cal.checked; + const th = el("secDeviceTokenHeader"); + if (th && !th.disabled) body.device_token_header = th.value.trim(); + const token = el("secDeviceToken"); + const tclear = el("secDeviceTokenClear"); + if (token && tclear && !tclear.disabled) { + if (tclear.checked) body.device_token_clear = true; + else if (token.value.trim()) body.device_token = token.value.trim(); + } + const ttl = el("secKioskPairTtl"); + if (ttl && !ttl.disabled) { + const n = parseInt(ttl.value, 10); + if (Number.isFinite(n)) body.kiosk_pair_token_ttl_seconds = n; + } + const ckd = el("secKioskCookieDays"); + if (ckd && !ckd.disabled) { + const n = parseInt(ckd.value, 10); + if (Number.isFinite(n)) body.kiosk_auth_cookie_days = n; + } + const sess = el("secSessionRememberDays"); + if (sess && !sess.disabled) { + const n = parseInt(sess.value, 10); + if (Number.isFinite(n)) body.session_remember_days = n; + } + + if (Object.keys(body).length === 0) { + setStatus("security PATCH: noop (env lock / no delta)", true); + return; + } + + const r = await fetch("/api/admin/security-settings", { + method: "PATCH", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`); + setStatus(d.message || "persist OK"); + if (d.security) renderSecuritySectionFromSummary(d.security); + else await updateSummary(); +} + +function renderSyncQueueFromSummary(sync) { + const list = document.getElementById("syncQueueList"); + if (!list || !sync) return; + const q = sync.queue || {}; + const rows = [ + ["queue.total", String(q.total ?? "—")], + ["queue.pending", String(q.pending ?? "—")], + ["queue.processing", String(q.processing ?? "—")], + ["queue.failed", String(q.failed ?? "—")], + ["max_concurrent", String(sync.max_concurrent ?? "—")], + ["client_poll_interval_s", String(sync.client_poll_interval_sec ?? "—")], + ["client_poll_interval_initial_s", String(sync.client_poll_interval_initial_sec ?? "—")], + ["client_pull_limit_steady", String(sync.client_pull_limit_steady ?? "—")], + ["client_pull_limit_initial", String(sync.client_pull_limit_initial ?? "—")], + ]; + list.innerHTML = rows + .map(([k, v]) => wespAdmListItem(k, `${escHtml(v)}`)) + .join(""); +} + +let lastSyncDiagnostics = null; + +function applySyncSectionFromDiagnostics(diag) { + const o = diag.overview || {}; + const cfg = o.config || {}; + const q = diag.queues || {}; + const stats = q.stats || {}; + const syncLike = { + connection: o.connection, + state: o.client_state, + first_sync_banner: o.first_sync_banner, + initial_sync_progress: (diag.bootstrap || {}).initial_sync_progress || {}, + queue_errors: q.recent_errors || [], + queue: { + total: stats.total, + pending: stats.pending, + processing: stats.processing, + failed: stats.failed, + completed: stats.completed, + }, + max_concurrent: cfg.sync_max_concurrent, + client_poll_interval_sec: cfg.client_poll_interval_sec, + client_poll_interval_initial_sec: cfg.client_poll_interval_initial_sec, + client_pull_limit_steady: cfg.client_pull_limit_steady, + client_pull_limit_initial: cfg.client_pull_limit_initial, + }; + renderSyncRuntimeNotices(o.connection || {}); + applySyncConnectionForm(o.connection || {}); + applySyncStateForm(o.client_state || {}); + renderInitialSyncProgress(syncLike); + renderSyncQueueFromSummary(syncLike); + renderSyncQueueErrorsFromSummary(syncLike); +} + +function renderSyncDiagByTable(byTable) { + const wrap = document.getElementById("syncDiagByTableWrap"); + if (!wrap) return; + const tbl = byTable && typeof byTable === "object" ? byTable : {}; + const tables = Object.keys(tbl).sort(); + if (!tables.length) { + wrap.innerHTML = '

    Нет строк в sync_queue.

    '; + return; + } + const statuses = ["pending", "processing", "completed", "failed"]; + let html = ""; + for (const s of statuses) html += ``; + html += ""; + for (const t of tables) { + const row = tbl[t] || {}; + let sum = 0; + html += ``; + for (const s of statuses) { + const n = Number(row[s] || 0); + sum += n; + html += ``; + } + html += ``; + } + html += "
    table${escHtml(s)}Σ
    ${escHtml(t)}${n || "—"}${sum}
    "; + wrap.innerHTML = html; +} + +function renderSyncDiagProblems(diag) { + const wrap = document.getElementById("syncDiagProblemsWrap"); + if (!wrap) return; + const q = diag.queues || {}; + const chunks = [ + { title: "stuck processing (timeout)", rows: q.stuck_processing || [] }, + { title: "processing без processed_at", rows: q.processing_missing_processed_at || [] }, + { title: "failed (свежие)", rows: q.failed_recent || [] }, + { title: "pending с retry", rows: q.high_retry_pending || [] }, + ]; + const all = []; + for (const ch of chunks) { + for (const r of ch.rows) { + all.push({ diagSection: ch.title, ...r }); + } + } + if (!all.length) { + wrap.innerHTML = + '

    Проблемных задач не найдено (по текущим критериям).

    '; + return; + } + let html = + ''; + const slice = all.slice(0, 120); + for (const r of slice) { + const note = String(r.error_message || r.stuck_reason || "—").slice(0, 320); + html += ``; + } + html += "
    классtableidstatusretryпримечание
    ${escHtml(r.diagSection || "—")}${escHtml( + String(r.table_name || "—"), + )}${escHtml( + String(r.id || "").slice(0, 8), + )}…${escHtml(String(r.status || "—"))}${escHtml( + String(r.retry_count ?? "—"), + )}${escHtml(note)}
    "; + if (all.length > 120) { + html += `

    … и ещё ${all.length - 120}

    `; + } + wrap.innerHTML = html; +} + +function renderSyncDiagnosticsFull(diag) { + lastSyncDiagnostics = diag; + const hint = document.getElementById("syncDiagHint"); + if (hint) { + const at = diag.generated_at ? formatRuDateTimeFromIso(diag.generated_at) : "—"; + hint.innerHTML = `Снимок: ${escHtml(at)}. Метрики runtime — только этот воркер (см. ниже).`; + } + const ov = document.getElementById("syncDiagOverviewList"); + if (ov) { + const o = diag.overview || {}; + const cfg = o.config || {}; + const meta = o.metadata || {}; + const rows = [ + ["effective_role", ruSyncRoleLabel(o.effective_role || "")], + ["server_url", String((o.connection || {}).server_url || "—")], + ["sync_status (metadata)", String(meta.sync_status || "—")], + ["node_id (metadata)", String(meta.node_id || "—")], + ["last_sync (metadata)", meta.last_sync ? formatRuDateTimeFromIso(meta.last_sync) : "—"], + ["requeue_timeout_min", String(cfg.sync_requeue_timeout_minutes ?? "—")], + ["max_concurrent", String(cfg.sync_max_concurrent ?? "—")], + ]; + ov.innerHTML = rows + .map(([k, v]) => + wespAdmListItem(k, `${escHtml(String(v))}`), + ) + .join(""); + } + const rt = document.getElementById("syncDiagRuntimeList"); + if (rt) { + const r = diag.runtime || {}; + const rows = [ + ["scope", escHtml(String(r.scope || "—"))], + [ + "concurrency", + `${r.concurrency_used ?? "—"}/${r.concurrency_max ?? "—"} (avail ${r.concurrency_available ?? "—"})`, + ], + ["pull p95 s", String(r.pull_duration_p95_sec ?? "—")], + ["pull samples", String(r.pull_duration_samples ?? "—")], + ["rate_limit active clients", String(r.rate_limit_windows_with_activity ?? "—")], + ["pull lock ipc", r.pull_lock_ipc_supported ? "yes" : "no"], + [ + "ipc held / thread locks", + `${r.pull_lock_ipc_held_clients ?? "—"} / ${r.pull_lock_thread_clients ?? "—"}`, + ], + ]; + let html = rows + .map(([k, v]) => + wespAdmListItem( + k, + `${escHtml(String(v))}`, + ), + ) + .join(""); + if (r.note) { + html += wespAdmListItem( + "note", + `${escHtml(String(r.note))}`, + ); + } + rt.innerHTML = html; + } + const qs = document.getElementById("syncDiagQueueStatsList"); + if (qs) { + const st = (diag.queues || {}).stats || {}; + const rows = [ + ["total", String(st.total ?? "—")], + ["pending", String(st.pending ?? "—")], + ["processing", String(st.processing ?? "—")], + ["completed", String(st.completed ?? "—")], + ["failed", String(st.failed ?? "—")], + ]; + if (st.by_priority) { + for (let p = 1; p <= 5; p++) { + const key = `priority_${p}`; + if (st.by_priority[key] != null) rows.push([key, String(st.by_priority[key])]); + } + } + qs.innerHTML = rows + .map(([k, v]) => wespAdmListItem(k, `${escHtml(v)}`)) + .join(""); + } + renderSyncDiagByTable((diag.queues || {}).by_table_status); + const boot = document.getElementById("syncDiagBootstrapList"); + if (boot) { + const b = diag.bootstrap || {}; + const es = b.engine_state; + const isp = b.initial_sync_progress || {}; + const parts = []; + if (es) { + parts.push( + wespAdmListItem( + "bootstrap_completed_at", + `${escHtml(String(es.universal_bootstrap_completed_at || "—"))}`, + ), + ); + parts.push( + wespAdmListItem( + "bootstrap_cursor", + `${escHtml(String(es.universal_bootstrap_cursor ?? "—"))}`, + ), + ); + if (es.universal_bootstrap_last_error) { + parts.push( + wespAdmListItem( + "bootstrap_last_error", + `${escHtml(String(es.universal_bootstrap_last_error).slice(0, 500))}`, + ), + ); + } + } else { + parts.push(wespAdmListItem("engine_state", ``)); + } + parts.push( + wespAdmListItem( + "initial_sync.active", + `${escHtml(String(Boolean(isp.active)))}`, + ), + ); + if (isp.percent != null) { + parts.push( + wespAdmListItem( + "initial_sync.percent", + `${escHtml(String(isp.percent))}`, + ), + ); + } + boot.innerHTML = parts.join(""); + } + const rp = document.getElementById("syncDiagReportPushList"); + if (rp) { + const x = diag.report_push || {}; + const parts = []; + parts.push( + wespAdmListItem("scope", `${escHtml(String(x.scope || "—"))}`), + ); + if (x.note) { + parts.push( + wespAdmListItem("note", `${escHtml(String(x.note))}`), + ); + } + if (x.tables && x.tables.length) { + parts.push( + wespAdmListItem( + "tables", + `${escHtml(x.tables.join(", "))}`, + ), + ); + } + if (x.by_status) { + parts.push( + wespAdmListItem( + "by_status", + `${escHtml(JSON.stringify(x.by_status))}`, + ), + ); + } + if (x.total != null) { + parts.push( + wespAdmListItem( + "total report tasks", + `${escHtml(String(x.total))}`, + ), + ); + } + const cr = x.client_runtime || {}; + parts.push( + wespAdmListItem( + "sync_client init", + `${cr.initialized ? "yes" : "no"}`, + ), + ); + parts.push( + wespAdmListItem( + "sync_thread alive", + `${cr.client_thread_alive ? "yes" : "no"}`, + ), + ); + rp.innerHTML = parts.join(""); + } + renderSyncDiagProblems(diag); + const cf = document.getElementById("syncDiagConflictsList"); + if (cf) { + const c = diag.conflicts || {}; + const s = c.summary || {}; + const rec = Array.isArray(c.recent) ? c.recent : []; + const head = [ + wespAdmListItem( + "conflicts.total", + `${escHtml(String(s.total ?? "—"))}`, + ), + wespAdmListItem( + "pending", + `${escHtml(String(s.pending ?? "—"))}`, + ), + ]; + const body = rec.length + ? rec.map((x) => + wespAdmListItem( + `${x.table_name || "—"} · ${String(x.record_id || "").slice(0, 8)}…`, + `${escHtml(String(x.conflict_type || "—"))}`, + `${String(x.resolution || "—")} · ${x.created_at ? formatRuDateTimeFromIso(x.created_at) : "—"}`, + ), + ) + : [wespAdmListEmpty("recent: ∅")]; + cf.innerHTML = head.join("") + body.join(""); + } + const cons = document.getElementById("syncDiagConsistencyList"); + if (cons) { + const c = diag.consistency || {}; + if (c.error) { + cons.innerHTML = wespAdmListItem( + "error", + `${escHtml(c.error)}`, + ); + } else { + const snap = c.snapshot || {}; + const mt = snap.master_tables || {}; + const rows = Object.keys(mt) + .sort() + .map((t) => { + const z = mt[t] || {}; + return wespAdmListItem( + `digest.${t}`, + `${escHtml(String(z.digest || "").slice(0, 16))}…`, + `count=${String(z.count ?? "—")}`, + ); + }); + const ir = snap.recipe_uuid_reference_issues; + if (ir != null) { + rows.push( + wespAdmListItem( + "recipe_uuid_reference_issues", + `${escHtml(String(ir))}`, + ), + ); + } + cons.innerHTML = rows.length ? rows.join("") : wespAdmListEmpty("snapshot: ∅"); + } + } + const cx = document.getElementById("syncDiagClientsExtraList"); + if (cx) { + const cl = diag.clients || {}; + const deliv = cl.recent_deliveries || []; + const lines = [ + wespAdmListSection("recent deliveries"), + ...(deliv.length + ? deliv.slice(0, 12).map((d) => + wespAdmListItem( + `${String(d.task_id || "").slice(0, 8)}…`, + `${escHtml(String(d.client_id || "").slice(0, 8))}…`, + d.delivered_at ? formatRuDateTimeFromIso(d.delivered_at) : "—", + ), + ) + : [wespAdmListEmpty("deliveries: ∅")]), + ]; + cx.innerHTML = lines.join(""); + } + const cap = diag.actions_capabilities || {}; + const bRe = document.getElementById("syncActionRequeueStuck"); + const bRes = document.getElementById("syncActionRestartLocal"); + const bRt = document.getElementById("syncActionRefreshRuntime"); + const bPu = document.getElementById("syncActionRetryPush"); + if (bRe) bRe.disabled = !cap.requeue_stuck; + if (bRes) bRes.disabled = !cap.restart_local_sync; + if (bRt) bRt.disabled = !cap.refresh_diagnostics; + if (bPu) bPu.disabled = !cap.retry_report_push; +} + +async function loadSyncDiagnostics() { + const r = await fetch("/api/admin/sync-diagnostics"); + const data = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(data.message || `sync-diagnostics ${r.status}`); + applySyncSectionFromDiagnostics(data); + renderSyncDiagnosticsFull(data); + await loadSyncClientsTable(); +} + +function splitServerUrlForForm(urlStr) { + const u = String(urlStr || "").trim(); + let scheme = "https"; + let host = ""; + if (!u) return { scheme, host }; + const m = u.match(/^(https?):\/\/(.*)$/i); + if (m) { + scheme = m[1].toLowerCase() === "http" ? "http" : "https"; + host = (m[2] || "").replace(/\/+$/, ""); + } else { + host = u.replace(/^\/+/, "").replace(/\/+$/, ""); + } + return { scheme, host }; +} + +function normalizeSyncHostInput(raw) { + let h = String(raw || "").trim(); + if (!h) return ""; + h = h.replace(/^https?:\/\//i, "").replace(/\/+$/, ""); + return h; +} + +function buildServerUrlFromForm() { + const schemeSel = document.getElementById("syncSettingsServerScheme"); + const hostInp = document.getElementById("syncSettingsServerHost"); + const scheme = schemeSel && schemeSel.value === "http" ? "http" : "https"; + const host = normalizeSyncHostInput(hostInp && hostInp.value); + if (!host) return ""; + return `${scheme}://${host}`; +} + +function renderSyncQueueErrorsFromSummary(sync) { + const list = document.getElementById("syncErrorsList"); + if (!list || !sync) return; + const rows = sync.queue_errors || []; + if (!rows.length) { + list.innerHTML = wespAdmListEmpty("sync errors: ∅"); + return; + } + list.innerHTML = rows + .map((r) => { + const at = r.at ? formatRuDateTimeFromIso(r.at) : "—"; + const errPlain = r.error || ""; + const err = escHtml(errPlain); + const title = [r.table_name || "—", r.record_id || "—"].filter(Boolean).join(" · "); + const desc = `${at} · op=${r.action || "—"}`; + return wespAdmListItem( + title, + `${err}`, + desc, + ); + }) + .join(""); +} + +function renderSyncRuntimeNotices(conn) { + const wrap = document.getElementById("syncRuntimeNotices"); + if (!wrap) return; + const c = conn && typeof conn === "object" ? conn : {}; + const blocks = []; + if (c.role_locked_by_env) { + const v = + c.env_role != null && String(c.env_role).trim() + ? escHtml(String(c.env_role).trim()) + : escHtml(String(c.role || "")); + blocks.push( + ``, + ); + } + if (c.server_url_locked_by_env) { + const v = + c.env_server_url != null && String(c.env_server_url).trim() + ? escHtml(String(c.env_server_url).trim()) + : escHtml(String(c.server_url || "")); + blocks.push( + ``, + ); + } + if (c.sync_client_autostart === false) { + blocks.push( + ``, + ); + } + if (String(c.role || "").toLowerCase() === "client" && c.sync_server_url_explicit === false) { + blocks.push( + ``, + ); + } + wrap.innerHTML = blocks.join(""); + wrap.style.display = blocks.length ? "block" : "none"; +} + +function applySyncConnectionForm(conn) { + const hint = document.getElementById("syncConnectionHint"); + const roleSel = document.getElementById("syncSettingsRole"); + const schemeSel = document.getElementById("syncSettingsServerScheme"); + const hostInp = document.getElementById("syncSettingsServerHost"); + const saveBtn = document.getElementById("syncSettingsSave"); + if (!roleSel || !schemeSel || !hostInp) return; + const c = conn && typeof conn === "object" ? conn : {}; + const r = String(c.role || "server").toLowerCase(); + roleSel.value = ["server", "client", "other"].includes(r) ? r : "server"; + const parts = splitServerUrlForForm(c.server_url || ""); + schemeSel.value = parts.scheme === "http" ? "http" : "https"; + hostInp.value = parts.host; + const lockParts = []; + if (c.role_locked_by_env) lockParts.push("role ← env WESP_SYNC_ROLE"); + if (c.server_url_locked_by_env) lockParts.push("server_url ← env WESP_SYNC_SERVER_URL"); + const autostartOff = c.sync_client_autostart === false; + if (hint) { + if (lockParts.length) { + hint.textContent = `read-only: ${lockParts.join("; ")}. Поля client_id / client_name / first_bootstrap_done можно сохранить в файл состояния. Подробности — в жёлтых блоках выше.`; + } else if (autostartOff) { + hint.textContent = + "Сохранение записывает sync_client_state.json; фоновый опрос сервера отключён — см. предупреждение выше."; + } else { + hint.textContent = + "Настройка синхронизации."; + } + } + roleSel.disabled = Boolean(c.role_locked_by_env); + schemeSel.disabled = Boolean(c.server_url_locked_by_env); + hostInp.disabled = Boolean(c.server_url_locked_by_env); + if (saveBtn) { + saveBtn.disabled = false; + } + renderSyncRuntimeNotices(c); +} + +function applySyncStateForm(state) { + const idInp = document.getElementById("syncSettingsClientId"); + const nameInp = document.getElementById("syncSettingsClientName"); + const bootChk = document.getElementById("syncSettingsFirstBootstrapDone"); + if (!idInp || !nameInp || !bootChk) return; + const s = state && typeof state === "object" ? state : {}; + idInp.value = s.client_id != null ? String(s.client_id) : ""; + nameInp.value = s.client_name != null ? String(s.client_name) : ""; + bootChk.checked = Boolean(s.first_bootstrap_done); +} + +function formatRuEtaSeconds(sec) { + if (sec == null || Number.isNaN(Number(sec))) return "—"; + const s = Math.max(0, Math.ceil(Number(sec))); + if (s < 60) return `${s} с`; + if (s < 3600) return `${Math.floor(s / 60)} мин ${s % 60} с`; + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + return `${h} ч ${m} мин`; +} + +function renderInitialSyncProgress(sync) { + const wrap = document.getElementById("syncInitialSyncProgressWrap"); + const fill = document.getElementById("syncInitialSyncProgressFill"); + const txt = document.getElementById("syncInitialSyncProgressText"); + if (!wrap || !fill || !txt) return; + const p = (sync && sync.initial_sync_progress) || {}; + const show = Boolean(sync && sync.first_sync_banner && p.active); + wrap.style.display = show ? "block" : "none"; + if (!show) return; + const pctRaw = p.percent != null ? Number(p.percent) : null; + const pct = pctRaw != null && !Number.isNaN(pctRaw) ? Math.min(100, Math.max(0, pctRaw)) : null; + fill.style.width = pct != null ? `${pct}%` : "0%"; + const parts = []; + if (pct != null) parts.push(`${pct.toFixed(1)}%`); + if (p.tasks_applied != null && p.tasks_total_estimate != null) { + parts.push(`применено локально: ${p.tasks_applied} / ${p.tasks_total_estimate} (оценка по серверу)`); + } else if (p.tasks_applied != null) { + parts.push(`применено локально: ${p.tasks_applied}`); + } + if (p.tasks_per_sec != null && Number(p.tasks_per_sec) > 0) { + parts.push(`~${Number(p.tasks_per_sec).toFixed(1)} задач/с`); + } + if (p.eta_sec != null && Number(p.eta_sec) >= 0) { + parts.push(`осталось ~${formatRuEtaSeconds(p.eta_sec)}`); + } else { + parts.push("осталось: оценка…"); + } + if (p.elapsed_sec != null) parts.push(`прошло ${formatRuEtaSeconds(p.elapsed_sec)}`); + txt.textContent = parts.join(" · "); +} + +function renderSyncSectionFromSummary(sync) { + const banner = document.getElementById("syncInitialSyncBanner"); + if (banner) { + banner.style.display = sync && sync.first_sync_banner ? "block" : "none"; + } + renderInitialSyncProgress(sync || {}); + renderSyncQueueFromSummary(sync); + renderSyncQueueErrorsFromSummary(sync); + applySyncConnectionForm(sync?.connection || {}); + applySyncStateForm(sync?.state || {}); +} + +function diagOkBadge(ok) { + return ok + ? 'OK' + : 'Ошибка'; +} + +function makeDiagTargetRow(target = {}) { + const role = escHtml(target.role || ""); + const host = escHtml(target.host || ""); + const title = escHtml(target.title || ""); + return `
    + + +
    + + +
    +
    `; +} + +function renderDiagExtraTargetsRows() { + const wrap = document.getElementById("diagExtraTargetsRows"); + if (!wrap) return; + const rows = Array.isArray(diagState.extraTargets) ? diagState.extraTargets : []; + if (!rows.length) { + wrap.innerHTML = makeDiagTargetRow({}); + return; + } + wrap.innerHTML = rows.map((x) => makeDiagTargetRow(x)).join(""); +} + +function collectDiagExtraTargetsFromForm() { + const wrap = document.getElementById("diagExtraTargetsRows"); + if (!wrap) return []; + const out = []; + wrap.querySelectorAll("[data-diag-target-row]").forEach((row) => { + const role = row.querySelector("[data-diag-role]")?.value?.trim() || ""; + const host = row.querySelector("[data-diag-host]")?.value?.trim() || ""; + const title = row.querySelector("[data-diag-title]")?.value?.trim() || ""; + if (!host) return; + out.push({ role, host, title: title || role || host }); + }); + return out; +} + +function setDiagnosticsSecurityListLoading() { + const sec = document.getElementById("diagSecurityList"); + if (!sec) return; + sec.innerHTML = wespAdmListEmpty("загрузка security_checks…"); +} + +function renderDiagnosticsSecurity(report) { + const sec = document.getElementById("diagSecurityList"); + if (!sec) return; + const rpt = report && typeof report === "object" ? report : {}; + const secChecks = Array.isArray(rpt.security_checks) ? rpt.security_checks : []; + if (!secChecks.length) { + const errMsg = rpt.message || rpt.error; + sec.innerHTML = wespAdmListEmpty( + errMsg + ? `security_checks: нет (${String(errMsg).slice(0, 200)})` + : "security_checks: нет — откройте раздел «Диагностика» заново или нажмите «Обновить отчёт»", + ); + return; + } + sec.innerHTML = secChecks + .map((x) => + wespAdmListItem( + x.title || x.id || "check", + diagOkBadge(Boolean(x.ok)), + x.detail || "", + ), + ) + .join(""); +} + +function renderDiagnosticsOverview(report) { + const overview = document.getElementById("diagOverviewList"); + const nodeHint = document.getElementById("diagNodeHint"); + const primary = document.getElementById("diagPrimaryTargetsList"); + const hw = document.getElementById("diagHardwareList"); + const path = document.getElementById("diagSettingsPath"); + if (!overview || !primary || !hw) return; + const rpt = report && typeof report === "object" ? report : {}; + const hwData = rpt.hardware || {}; + if (nodeHint) nodeHint.textContent = rpt.node_hint || ""; + if (path) path.textContent = rpt.paths?.network_settings_path ? `path: ${rpt.paths.network_settings_path}` : ""; + const roleRu = ruSyncRoleLabel(rpt.node_role); + overview.innerHTML = [ + wespAdmListSection("LAN / node"), + wespAdmListItem("node_role", `${escHtml(roleRu)}`), + wespAdmListItem( + "primary_targets.n", + `${escHtml(String((rpt.primary_targets || []).length))}`, + ), + wespAdmListItem( + "extra_targets.n", + `${escHtml(String((rpt.extra_targets || []).length))}`, + ), + wespAdmListSection("Security"), + ...buildDiagSecurityOverviewRows(rpt), + wespAdmListSection("Hardware"), + ...buildDiagHardwareOverviewRows(hwData), + ].join(""); + const warnings = Array.isArray(rpt.warnings) ? rpt.warnings : []; + const targets = Array.isArray(rpt.primary_targets) ? rpt.primary_targets : []; + primary.innerHTML = ""; + if (targets.length === 0) { + primary.innerHTML += wespAdmListEmpty("primary_targets: ∅"); + } else { + primary.innerHTML += targets + .map((t) => + wespAdmListItem( + t.title || t.host || "—", + `${escHtml(t.host || "—")}`, + `kind: ${t.kind || "target"} · node_id: ${t.meta?.node_id || "—"} · last_seen: ${t.meta?.last_seen ? formatRuDateTimeFromIso(t.meta.last_seen) : "—"}`, + ), + ) + .join(""); + } + if (warnings.length) { + primary.innerHTML += warnings + .map((w) => + wespAdmListItem("warning", '!', String(w || "")), + ) + .join(""); + } + const sim = Boolean(hwData.simulation_mode); + const gpioOk = Boolean(hwData.rpi_gpio_available); + const hxOk = Boolean(hwData.hx711_driver_ready); + const driversAbsent = Boolean(hwData.drivers_absent); + const modeLine = sim + ? "SIM=1: SW ADC/GPIO" + : driversAbsent + ? "SIM=0; RPi.GPIO+HX711 ∅ (x86?). mass=0; dev WESP_SIMULATION_MODE=1." + : "SIM=0; target: BCM SoC + peripherals"; + const gpioLine = sim + ? "N/A (sim)" + : gpioOk + ? "RPi.GPIO: LED out, BCM" + : "RPi.GPIO: fail (not Pi / pkg)"; + const hxLine = sim + ? "N/A (sim)" + : hxOk + ? "HX711: init OK" + : "HX711: fail; mass N/A"; + const badgeSim = 'sim'; + const gpioBadge = sim ? badgeSim : diagOkBadge(gpioOk); + const hxBadge = sim ? badgeSim : diagOkBadge(hxOk); + hw.innerHTML = [ + wespAdmListItem("MODE", `${escHtml(modeLine)}`), + wespAdmListItem( + "GPIO LED", + `${gpioBadge}${escHtml(gpioLine)}`, + ), + wespAdmListItem( + "HX711", + `${hxBadge}${escHtml(hxLine)}`, + ), + wespAdmListItem( + "BCM pins", + `LED ${escHtml(String(hwData.gpio_led_pin ?? "—"))} · DOUT ${escHtml(String(hwData.hx711_dout_pin ?? "—"))} / SCK ${escHtml(String(hwData.hx711_pd_sck_pin ?? "—"))}`, + ), + ].join(""); +} + +function renderDiagnosticsNetworkResults(payload) { + const list = document.getElementById("diagNetworkResultsList"); + if (!list) return; + const body = payload && typeof payload === "object" ? payload : {}; + const rows = Array.isArray(body.results) ? body.results : []; + const internet = Array.isArray(body.internet) ? body.internet : []; + const networkRows = rows.map((r) => { + const ping = r.probes?.ping?.ok ? "ping OK" : "ping fail"; + const tcpProbe = r.probes?.tcp ?? r.probes?.tcp80; + const tcpPort = tcpProbe?.port ?? wespListenPortFallback(); + const tcp = tcpProbe?.ok ? `tcp:${tcpPort} OK` : `tcp:${tcpPort} fail`; + const trace = r.probes?.trace + ? r.probes.trace.ok + ? `trace OK (${r.probes.trace.tool || "tool"})` + : `trace fail (${r.probes.trace.tool || "tool"})` + : "trace: off"; + return wespAdmListItem( + r.title || r.host || "—", + diagOkBadge(Boolean(r.ok)), + `${r.host || "—"} · ${ping} · ${tcp} · ${trace}`, + ); + }); + const internetRows = internet.map((it) => + wespAdmListItem( + `Internet: ${it.url || "—"}`, + diagOkBadge(Boolean(it.ok)), + it.status_code != null ? `HTTP ${it.status_code}` : String(it.error || "no response"), + ), + ); + const allRows = []; + if (networkRows.length) { + allRows.push(wespAdmListSection("LAN / node")); + allRows.push(...networkRows); + } + if (internetRows.length) { + allRows.push(wespAdmListSection("WAN / egress")); + allRows.push(...internetRows); + } + list.innerHTML = allRows.length ? allRows.join("") : wespAdmListEmpty("results: ∅"); +} + +async function loadDiagnosticsReport() { + setDiagnosticsSecurityListLoading(); + const r = await fetch("/api/admin/diagnostics/report", { credentials: "same-origin" }); + const d = await r.json().catch(() => ({})); + if (!r.ok) { + renderDiagnosticsSecurity({ message: d.message || `HTTP ${r.status}` }); + throw new Error(d.message || `Ошибка ${r.status}`); + } + diagState.report = d; + diagState.extraTargets = Array.isArray(d.extra_targets) ? d.extra_targets : []; + try { + renderDiagnosticsSecurity(d); + } catch (e) { + renderDiagnosticsSecurity({ message: e?.message || "render security" }); + throw e; + } + try { + renderDiagExtraTargetsRows(); + } catch (e) { + setStatus(e?.message, true, "extra_targets render"); + } + try { + renderDiagnosticsOverview(d); + } catch (e) { + setStatus(e?.message, true, "overview render"); + } + try { + renderDiagnosticsNetworkResults({}); + } catch (e) { + setStatus(e?.message, true, "network results render"); + } +} + +async function saveDiagnosticsSettings() { + const extraTargets = collectDiagExtraTargetsFromForm(); + const r = await fetch("/api/admin/diagnostics/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ extra_targets: extraTargets }), + }); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`); + diagState.extraTargets = Array.isArray(d.extra_targets) ? d.extra_targets : extraTargets; + renderDiagExtraTargetsRows(); + setStatus(d.message || "persist OK"); +} + +async function runDiagnosticsNetwork(scope, runTrace = false, customTargets = null) { + const body = { + scope, + run_trace: runTrace, + timeout_sec: 3, + prefer_mtr: true, + tcp_port: wespListenPortFallback(), + }; + if (scope === "custom" && Array.isArray(customTargets)) { + body.custom_targets = customTargets; + } + const r = await fetch("/api/admin/diagnostics/network-run", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`); + renderDiagnosticsNetworkResults(d); + setStatus("network-run: OK"); +} + +async function runDiagnosticsHxSample(listId = "diagHardwareList") { + const list = document.getElementById(listId); + const r = await fetch("/api/admin/diagnostics/hx711-sample", { method: "POST" }); + const d = await r.json().catch(() => ({})); + if (list) { + const prev = list.innerHTML; + if (!r.ok) { + const row = wespAdmListItem( + "HX711 sample", + diagOkBadge(false), + `${escHtml(d.message || `Ошибка ${r.status}`)} · DOUT ${escHtml(String(d.pins?.dout ?? "—"))} · SCK ${escHtml(String(d.pins?.pd_sck ?? "—"))}`, + ); + list.innerHTML = `${row}${prev}`; + setStatus(d.message || `Ошибка ${r.status}`, true); + return; + } + const row = wespAdmListItem( + "HX711 sample", + diagOkBadge(true), + `sample=${d.sample} · DOUT ${d.pins?.dout ?? "—"} · SCK ${d.pins?.pd_sck ?? "—"} · driver: ${d.simulation_mode ? "sim" : d.driver_ready ? "ready" : "—"}`, + ); + list.innerHTML = `${row}${prev}`; + } + if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`); + setStatus("HX711 sample: OK"); +} + +async function runDiagnosticsGpioBlink(listId = "diagHardwareList") { + const list = document.getElementById(listId); + const r = await fetch("/api/admin/diagnostics/gpio-blink", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ times: 3, delay: 0.2 }), + }); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`); + if (list) { + const prev = list.innerHTML; + const gpioMode = d.simulation_mode + ? "sim" + : d.rpi_gpio_available + ? "RPi.GPIO hw" + : "RPi.GPIO ∅ (soft-only)"; + const row = wespAdmListItem( + "GPIO blink", + diagOkBadge(true), + `${d.message || "started"} · pin ${d.pin ?? "—"} · ${gpioMode}`, + ); + list.innerHTML = `${row}${prev}`; + } + setStatus("GPIO blink: dispatched"); +} + +function openTracerouteModal() { + const m = document.getElementById("tracerouteModal"); + if (!m) return; + m.hidden = false; + document.body.style.overflow = "hidden"; +} + +function closeTracerouteModal() { + const m = document.getElementById("tracerouteModal"); + if (m) m.hidden = true; + document.body.style.overflow = ""; +} + +function fillTracerouteHostsFromPrimary() { + const ta = document.getElementById("tracerouteHostsInput"); + if (!ta) return; + const rpt = diagState.report; + const targets = Array.isArray(rpt?.primary_targets) ? rpt.primary_targets : []; + const hosts = [...new Set(targets.map((t) => (t.host || "").trim()).filter(Boolean))]; + ta.value = hosts.join("\n"); + if (!hosts.length) { + setStatus("primary_targets: ∅; ввод вручную или отчёт диагностики", true); + } +} + +function renderTracerouteResults(data) { + const wrap = document.getElementById("tracerouteModalResults"); + if (!wrap) return; + const results = Array.isArray(data.results) ? data.results : []; + if (!results.length) { + wrap.innerHTML = '

    results: ∅

    '; + return; + } + let html = ""; + for (const row of results) { + const host = escHtml(row.host); + if (row.validate_error) { + html += `
    ${host}${escHtml(row.validate_error)}
    `; + continue; + } + const tr = row.trace || {}; + const okLabel = tr.ok ? "OK" : "fail"; + const tool = escHtml(tr.tool || "—"); + const pingLbl = row.ping ? (row.ping.ok ? "ping OK" : "ping fail") : null; + const tcpP = row.tcp?.port ?? wespListenPortFallback(); + const tcpLbl = row.tcp ? (row.tcp.ok ? `tcp:${tcpP} OK` : `tcp:${tcpP} fail`) : null; + const probeBits = [pingLbl, tcpLbl].filter(Boolean).join(" · "); + let body = `
    ${host}`; + if (probeBits) { + body += `${escHtml(probeBits)}`; + } + body += `${tool}${okLabel}`; + if (tr.exit_code != null && tr.exit_code !== "") { + body += `exit ${escHtml(tr.exit_code)}`; + } + if (tr.error) body += `${escHtml(tr.error)}`; + body += "
    "; + const pr = row.parse; + if (pr && pr.hops && pr.hops.length) { + const st = pr.stats || {}; + body += `

    hops=${escHtml(st.total_hops ?? pr.hops.length)} · private=${escHtml(st.private_count ?? "—")} · max_rtt_ms=${escHtml(st.max_delay_ms ?? "—")}

    `; + body += + ''; + for (const h of pr.hops) { + const nt = + h.network_type === "private" ? "RFC1918" : h.network_type === "public" ? "WAN" : "?"; + const avg = h.avg_ms != null ? escHtml(h.avg_ms) : "—"; + body += ``; + } + body += "
    ttladdrnetavg_ms
    ${escHtml(h.num)}${escHtml(h.ip)}${nt}${avg}
    "; + } else if (tr.lines && tr.lines.length) { + body += `
    ${escHtml(tr.lines.join("\n"))}
    `; + } else { + body += '

    stdout: ∅

    '; + } + body += "
    "; + html += body; + } + wrap.innerHTML = html; +} + +async function runTracerouteByHostsModal() { + const ta = document.getElementById("tracerouteHostsInput"); + const wrap = document.getElementById("tracerouteModalResults"); + if (!ta || !wrap) return; + const text = ta.value.trim(); + if (!text) { + setStatus("hosts: требуется ≥1 строка FQDN/IP", true); + return; + } + wrap.innerHTML = '

    traceroute: running…

    '; + let r; + let d = {}; + try { + r = await fetch("/api/admin/diagnostics/traceroute-by-hosts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ hosts: text, timeout_sec: 12, prefer_mtr: true, tcp_port: wespListenPortFallback() }), + }); + d = await r.json().catch(() => ({})); + } catch (e) { + wrap.innerHTML = `

    ${escHtml(userHint(e.message, "Не удалось загрузить данные сети"))}

    `; + return; + } + if (!r.ok || d.status !== "success") { + wrap.innerHTML = `

    ${escHtml(d.message || `Ошибка ${r.status}`)}

    `; + return; + } + renderTracerouteResults(d); + setStatus("traceroute: OK"); +} + +function openTracerouteModalFromDiagnostics() { + openTracerouteModal(); + const wrap = document.getElementById("tracerouteModalResults"); + if (wrap) wrap.innerHTML = ""; + fillTracerouteHostsFromPrimary(); +} + +async function loadSyncClientsTable() { + const list = document.getElementById("syncClientsList"); + if (!list) return; + let d; + try { + const r = await fetch("/api/sync/clients"); + d = await r.json().catch(() => ({})); + if (!r.ok) { + throw new Error(d.message || `Ошибка ${r.status}`); + } + } catch (e) { + list.innerHTML = wespAdmListEmpty(userHint(e.message, "Не удалось загрузить список")); + return; + } + if (!Array.isArray(d) || d.length === 0) { + list.innerHTML = wespAdmListEmpty("SyncClient[]: ∅"); + return; + } + list.innerHTML = d + .map((c) => { + const title = + (c.display_name && String(c.display_name).trim()) || c.client_name || "—"; + const node = escHtml(c.node_id || "—"); + const ip = escHtml(c.ip_address || "—"); + const seen = c.last_seen ? formatRuDateTimeFromIso(c.last_seen) : "—"; + const st = ruSyncClientStatus(c.status); + const nsync = String(c.total_syncs ?? "0"); + const nid = String(c.node_id || "").replace(/"/g, """); + const errPlain = c.sync_error && String(c.sync_error).trim(); + const descParts = [ + `node_id=${c.node_id || "—"}`, + `ip=${c.ip_address || "—"}`, + `last_seen=${seen}`, + `status=${st}`, + `sync_count=${nsync}`, + ]; + if (errPlain) descParts.push(`err=${errPlain}`); + const desc = descParts.join(" · "); + const extra = `
    + + + +
    `; + return wespAdmListItem(title, extra, desc, { dataAttrs: `data-node-id="${nid}"` }); + }) + .join(""); +} + +async function saveSyncSettingsFromForm() { + const roleSel = document.getElementById("syncSettingsRole"); + const hostInp = document.getElementById("syncSettingsServerHost"); + const cidInp = document.getElementById("syncSettingsClientId"); + const nameInp = document.getElementById("syncSettingsClientName"); + const bootChk = document.getElementById("syncSettingsFirstBootstrapDone"); + if (!roleSel || !hostInp) return; + const body = {}; + if (!roleSel.disabled) body.role = roleSel.value; + if (!hostInp.disabled) body.server_url = buildServerUrlFromForm(); + if (cidInp) body.client_id = cidInp.value.trim(); + if (nameInp) body.client_name = nameInp.value.trim(); + if (bootChk) body.first_bootstrap_done = bootChk.checked; + if (Object.keys(body).length === 0) { + setStatus("sync form: нечего сохранить", true); + return; + } + const r = await fetch("/api/admin/sync-settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const d = await r.json().catch(() => ({})); + if (!r.ok) { + throw new Error(d.message || `Ошибка ${r.status}`); + } + setStatus(d.message || "persist OK"); + if (d.sync_reloaded) { + setStatus(d.message || "Сохранено. Синхронизация перезапущена."); + } + if (d.state) { + applySyncStateForm(d.state); + } + await loadSyncDiagnostics(); +} + +async function loadOrchestratorSyncSettings() { + const r = await fetch("/api/admin/orchestrator-sync"); + const d = await r.json().catch(() => ({})); + if (!r.ok) return; + const s = d.orchestrator_sync || {}; + const urlEl = document.getElementById("orchSyncUpstreamUrl"); + const hubEl = document.getElementById("orchSyncHubSiteId"); + if (urlEl) urlEl.value = s.upstream_url || ""; + if (hubEl) hubEl.value = s.hub_site_id || ""; +} + +async function saveOrchestratorSyncFromForm() { + const body = { + upstream_url: document.getElementById("orchSyncUpstreamUrl")?.value?.trim() || "", + hub_site_id: document.getElementById("orchSyncHubSiteId")?.value?.trim() || "", + }; + const key = document.getElementById("orchSyncApiKey")?.value?.trim(); + if (key) body.api_key = key; + const r = await fetch("/api/admin/orchestrator-sync", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const d = await r.json().catch(() => ({})); + const msg = document.getElementById("orchSyncMessage"); + if (!r.ok) { + if (msg) msg.textContent = d.message || `Ошибка ${r.status}`; + throw new Error(d.message || `Ошибка ${r.status}`); + } + if (msg) msg.textContent = d.message || "Сохранено"; + const keyEl = document.getElementById("orchSyncApiKey"); + if (keyEl) keyEl.value = ""; + await loadOrchestratorSyncSettings(); +} + +async function syncClientsTableAction(event) { + const btn = event.target.closest("[data-sync-client-action]"); + if (!btn) return; + const row = event.target.closest("li[data-node-id]"); + if (!row || !row.dataset.nodeId) return; + const nodeId = row.dataset.nodeId; + const action = btn.dataset.syncClientAction; + if (action === "set-ip") { + const curDesc = row.querySelector(".wesp-adm-list-desc")?.textContent || ""; + const curMatch = /IP:\s*([^·]+)/.exec(curDesc); + const cur = curMatch ? String(curMatch[1]).trim() : ""; + const next = window.prompt( + "IP или хост клиента (для трассировки). Пусто — очистить:", + cur === "—" ? "" : cur, + ); + if (next === null) return; + const r = await fetch(`/api/admin/sync-clients/${encodeURIComponent(nodeId)}/ip`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ip_address: next.trim() }), + }); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`); + setStatus("SyncClient.ip: OK"); + await loadSyncClientsTable(); + return; + } + if (action === "rename") { + const cur = row.querySelector(".wesp-adm-list-title")?.textContent?.trim() || ""; + const name = window.prompt("Отображаемое имя клиента:", cur); + if (name === null) return; + const r = await fetch(`/api/sync/clients/${encodeURIComponent(nodeId)}/display_name`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ display_name: name.trim() }), + }); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`); + setStatus("SyncClient.name: OK"); + await loadSyncClientsTable(); + return; + } + if (action === "delete") { + if (!window.confirm(`Удалить клиента ${nodeId} из реестра?`)) return; + const r = await fetch(`/api/sync/clients/${encodeURIComponent(nodeId)}`, { method: "DELETE" }); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`); + setStatus("SyncClient: deleted"); + await loadSyncClientsTable(); + } +} + +async function createUser(event) { + event.preventDefault(); + const login = document.getElementById("newUserLogin")?.value?.trim(); + const password = document.getElementById("newUserPassword")?.value || ""; + const isSuperuser = Boolean(document.getElementById("newUserIsSuper")?.checked); + + const response = await fetch("/api/admin/users", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ login, password, is_superuser: isSuperuser }), + }); + const data = await response.json(); + if (!response.ok) { + throw new Error(data.message || "Не удалось создать пользователя"); + } + + event.target.reset(); + setStatus("WebUser: created"); + await loadUsers(); + await updateSummary(); +} + +async function usersTableAction(event) { + const actionEl = event.target.closest("[data-action]"); + if (actionEl) { + const row = event.target.closest("li[data-user-id]"); + if (!row) return; + const userId = row.dataset.userId; + const action = actionEl.dataset.action; + + if (action === "set-password") { + const password = window.prompt("Новый пароль (минимум 8 символов, буквы и цифры):", ""); + if (!password) return; + const response = await fetch(`/api/admin/users/${userId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.message || "Не удалось обновить пароль"); + setStatus("password_hash: OK"); + return; + } + + if (action === "toggle-super") { + const targetSuper = row.dataset.isSuper !== "1"; + const response = await fetch(`/api/admin/users/${userId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ is_superuser: targetSuper }), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.message || "Не удалось изменить роль"); + setStatus("is_superuser: OK"); + await loadUsers(); + await updateSummary(); + return; + } + + if (action === "delete-user") { + const ok = window.confirm("Удалить пользователя?"); + if (!ok) return; + const response = await fetch(`/api/admin/users/${userId}`, { method: "DELETE" }); + const data = await response.json(); + if (!response.ok) throw new Error(data.message || "Не удалось удалить пользователя"); + setStatus("WebUser: deleted"); + await loadUsers(); + await updateSummary(); + } + return; + } + + const main = event.target.closest(".wesp-adm-list-main"); + const row = event.target.closest("li[data-user-id]"); + if (!main || !row || event.target.closest(".wesp-adm-list-actions")) return; + openUserModulesModal(row); +} + +function renderServerIps(addresses) { + const el = document.getElementById("serverIpList"); + if (!el) return; + if (!addresses || addresses.length === 0) { + el.textContent = "—"; + return; + } + el.innerHTML = addresses + .map( + (a) => + `
    ${escHtml(a.interface)} ${escHtml(a.family)} ${escHtml(a.address)}
    `, + ) + .join(""); + applyServerIpMask(); +} + +function applyServerIpMask() { + const el = document.getElementById("serverIpList"); + const btn = document.getElementById("btnToggleServerIp"); + if (!el) return; + const hide = localStorage.getItem("wespAdminMaskIp") === "1"; + el.classList.toggle("wesp-ip-masked", hide); + if (btn) { + btn.setAttribute("aria-pressed", hide ? "true" : "false"); + btn.classList.toggle("is-ip-hidden", hide); + btn.title = hide ? "Показать адреса" : "Скрыть адреса"; + } +} + +/** При каждом открытии админки адреса по умолчанию скрыты (до явного «показать» в этой сессии). */ +function resetServerIpMaskOnPageOpen() { + try { + localStorage.setItem("wespAdminMaskIp", "1"); + } catch (e) { + /* ignore */ + } + applyServerIpMask(); +} + +function openAdminModal(title, text) { + const m = document.getElementById("adminModal"); + const t = document.getElementById("adminModalTitle"); + const b = document.getElementById("adminModalBody"); + if (!m || !t || !b) return; + t.textContent = title; + b.textContent = text; + m.hidden = false; + document.body.style.overflow = "hidden"; +} + +function closeAdminModal() { + const m = document.getElementById("adminModal"); + if (m) m.hidden = true; + document.body.style.overflow = ""; +} + +async function fetchAndShowServerLog(modalTitle) { + const r = await fetch("/api/admin/server-log?lines=300"); + const d = await r.json(); + if (d.status !== "success") { + openAdminModal( + modalTitle, + `${d.message || "Не удалось прочитать лог"}\n\n(Переменная WESP_ADMIN_LOG_PATH на сервере.)`, + ); + return; + } + const body = (d.lines && d.lines.length ? d.lines.join("\n") : "(файл пуст)") + `\n\n— ${d.path}`; + openAdminModal(`${modalTitle}: ${d.path}`, body); +} + +async function fetchUploadedClientLogsIndex() { + const r = await fetch("/api/admin/client-uploaded-logs", { credentials: "same-origin" }); + const d = await r.json(); + if (d.status !== "success") { + throw new Error(d.message || `HTTP ${r.status}`); + } + return d; +} + +/** + * Диалог выбора клиента (и при необходимости файла) для логов из POST /api/sync/client-log. + * @returns {Promise<{ apiClientId: string, fileName: string|null }|null>} + */ +async function promptPickUploadedLogClient(options) { + const needExactFile = Boolean(options && options.needExactFile); + const d = await fetchUploadedClientLogsIndex(); + const clients = d.clients || []; + if (!clients.length) { + openAdminModal( + "Логи с клиентов", + "Пока нет выгруженных файлов.\n\nНа узле: sync_client.upload_client_log_file(...) или POST /api/sync/client-log.", + ); + return null; + } + const lines = clients.map((c, i) => { + const label = c.display_name || c.client_name || c.node_id || c.dir_segment; + const n = c.file_count != null ? c.file_count : (c.files || []).length; + return `${i + 1}) ${label} — ${n} файл(ов)`; + }); + const ans = window.prompt(`Выберите клиента (номер от 1 до ${clients.length}):\n\n${lines.join("\n")}`); + if (ans === null) return null; + const num = parseInt(String(ans).trim(), 10); + if (!Number.isFinite(num) || num < 1 || num > clients.length) { + window.alert("Некорректный номер."); + return null; + } + const chosen = clients[num - 1]; + const apiClientId = chosen.api_client_id; + const files = chosen.files || []; + + let fileName = null; + if (needExactFile) { + if (!files.length) { + window.alert("У этого клиента нет файлов логов."); + return null; + } + if (files.length === 1) { + fileName = files[0].name; + } else { + const fl = files.map((f, i) => `${i + 1}) ${f.name} (${f.size} B)`).join("\n"); + const fa = window.prompt(`Файл для скачивания (1–${files.length}):\n\n${fl}`); + if (fa === null) return null; + const fi = parseInt(String(fa).trim(), 10); + if (!Number.isFinite(fi) || fi < 1 || fi > files.length) { + window.alert("Некорректный номер файла."); + return null; + } + fileName = files[fi - 1].name; + } + } else if (files.length > 1) { + const fl = files.map((f, i) => `${i + 1}) ${f.name} (${f.size} B)`).join("\n"); + const fa = window.prompt( + `Какой файл показать (1–${files.length})? Пусто + OK — самый свежий.\n\n${fl}`, + "", + ); + if (fa === null) return null; + const trimmed = String(fa).trim(); + if (trimmed) { + const fi = parseInt(trimmed, 10); + if (!Number.isFinite(fi) || fi < 1 || fi > files.length) { + window.alert("Некорректный номер файла."); + return null; + } + fileName = files[fi - 1].name; + } + } + + return { apiClientId, fileName }; +} + +async function fetchAndShowClientUploadedLog() { + const picked = await promptPickUploadedLogClient({ needExactFile: false }); + if (!picked) return; + const q = new URLSearchParams({ client_id: picked.apiClientId, lines: "300" }); + if (picked.fileName) q.set("file", picked.fileName); + const r = await fetch(`/api/admin/client-uploaded-logs/tail?${q}`, { credentials: "same-origin" }); + const d = await r.json(); + if (d.status !== "success") { + openAdminModal( + "Лог клиента", + `${d.message || "Не удалось прочитать лог"}\n\n(client_id: ${picked.apiClientId})`, + ); + return; + } + const body = + (d.lines && d.lines.length ? d.lines.join("\n") : "(файл пуст)") + `\n\n— ${d.path || ""}`; + openAdminModal(`Лог клиента: ${d.file || ""}`, body); +} + +async function downloadClientUploadedLogFile() { + const picked = await promptPickUploadedLogClient({ needExactFile: true }); + if (!picked || !picked.fileName) return; + const q = new URLSearchParams({ client_id: picked.apiClientId, file: picked.fileName }); + const r = await fetch(`/api/admin/client-uploaded-logs/download?${q}`, { credentials: "same-origin" }); + if (!r.ok) { + let msg = "client log download failed"; + try { + const j = await r.json(); + if (j.message) msg = j.message; + } catch (e) { + /* ignore */ + } + throw new Error(msg); + } + const cd = r.headers.get("Content-Disposition") || ""; + let name = picked.fileName; + const mStar = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(cd); + const mQuot = /filename\s*=\s*"([^"]+)"/i.exec(cd); + const mPlain = /filename\s*=\s*([^;\s]+)/i.exec(cd); + if (mStar) name = decodeURIComponent(mStar[1].trim()); + else if (mQuot) name = mQuot[1].trim(); + else if (mPlain) name = mPlain[1].trim(); + + const blob = await r.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = name; + a.click(); + URL.revokeObjectURL(url); + setStatus("client log: downloaded"); +} + +async function downloadServerLogFile() { + const r = await fetch("/api/admin/server-log/download", { credentials: "same-origin" }); + if (!r.ok) { + let msg = "log download failed"; + try { + const j = await r.json(); + if (j.message) msg = j.message; + } catch (e) { + /* ignore */ + } + throw new Error(msg); + } + const cd = r.headers.get("Content-Disposition") || ""; + let name = "wesp.log"; + const mStar = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(cd); + const mQuot = /filename\s*=\s*"([^"]+)"/i.exec(cd); + const mPlain = /filename\s*=\s*([^;\s]+)/i.exec(cd); + if (mStar) name = decodeURIComponent(mStar[1].trim()); + else if (mQuot) name = mQuot[1].trim(); + else if (mPlain) name = mPlain[1].trim(); + + const blob = await r.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = name; + a.click(); + URL.revokeObjectURL(url); + setStatus("log file: downloaded"); +} + +const KIOSK_FULL_SETUP_QUIPS = [ + "Настраиваем wesp.env и systemd…", + "Патчим labwc — рабочий стол отдыхает…", + "Готовим Chromium к первому взвешиванию…", + "Собираем пиксели логотипа в боевой порядок…", + "Синхронизируем киоск с тёмной темой /starting…", + "Проверяем: server и client — не один Pi", + "Чайник почти вскипел, осталось чуть-чуть", +]; + +let kioskFullSetupLoaderMounted = false; +let kioskFullSetupQuipTimer = null; + +function setKioskFullSetupQuip(text, isError = false) { + const quipEl = document.getElementById("kioskFullSetupQuip"); + if (!quipEl) return; + const next = String(text || "").trim(); + if (!next) return; + quipEl.classList.add("wesp-startup__quip--fade"); + quipEl.classList.toggle("wesp-startup__quip--error", Boolean(isError)); + window.setTimeout(() => { + quipEl.textContent = next; + quipEl.classList.remove("wesp-startup__quip--fade"); + }, 200); +} + +function stopKioskFullSetupQuipRotation() { + if (kioskFullSetupQuipTimer) { + clearInterval(kioskFullSetupQuipTimer); + kioskFullSetupQuipTimer = null; + } +} + +function startKioskFullSetupQuipRotation() { + stopKioskFullSetupQuipRotation(); + let i = 0; + kioskFullSetupQuipTimer = window.setInterval(() => { + setKioskFullSetupQuip(KIOSK_FULL_SETUP_QUIPS[i % KIOSK_FULL_SETUP_QUIPS.length]); + i += 1; + }, 1400); +} + +async function showKioskFullSetupOverlay() { + const overlay = document.getElementById("kioskFullSetupOverlay"); + const introEl = document.getElementById("kioskFullSetupIntro"); + if (!overlay || !introEl) return; + overlay.hidden = false; + overlay.setAttribute("aria-hidden", "false"); + document.body.style.overflow = "hidden"; + if (!kioskFullSetupLoaderMounted) { + introEl.innerHTML = ""; + await mountWespLogoLoader(introEl, { + loop: true, + durationMs: 6000, + embedded: true, + transparentBackground: true, + knockoutBackground: true, + darkTheme: true, + hideChrome: false, + }); + kioskFullSetupLoaderMounted = true; + } + setKioskFullSetupQuip(KIOSK_FULL_SETUP_QUIPS[0]); + startKioskFullSetupQuipRotation(); +} + +function hideKioskFullSetupOverlay() { + const overlay = document.getElementById("kioskFullSetupOverlay"); + if (!overlay) return; + overlay.hidden = true; + overlay.setAttribute("aria-hidden", "true"); + document.body.style.overflow = ""; + stopKioskFullSetupQuipRotation(); +} + +async function refreshKioskFullSetupHint() { + const hint = document.getElementById("kioskFullSetupHint"); + const btn = document.getElementById("btnKioskFullSetup"); + if (!hint) return; + try { + const r = await fetch("/api/admin/kiosk-full-setup/status"); + const d = await r.json().catch(() => ({})); + if (!r.ok) { + hint.textContent = d.message || "Не удалось прочитать статус"; + hint.classList.add("wesp-sync-hint--error"); + return; + } + const parts = []; + if (!d.running_as_root) parts.push("WESP не от root"); + else if (d.full_setup_ready) parts.push("полностью настроен"); + else { + if (!d.platform_ready) parts.push("нужен env/systemd"); + if (!d.kiosk_enabled || d.kiosk_mode !== "system_labwc") parts.push("нужен киоск"); + } + if (!d.chromium_found) parts.push("chromium не найден"); + if (d.gui_user) parts.push(`user: ${d.gui_user}`); + hint.textContent = parts.length ? parts.join(" · ") : (d.message || "—"); + hint.classList.toggle("wesp-sync-hint--ok", Boolean(d.full_setup_ready)); + hint.classList.toggle("wesp-sync-hint--error", !d.running_as_root || !d.chromium_found); + if (btn) btn.disabled = !d.running_as_root || !d.chromium_found; + } catch (error) { + hint.textContent = userHint(error.message, "Ошибка статуса"); + hint.classList.add("wesp-sync-hint--error"); + } +} + +async function applyFullKioskSetup() { + const hideCb = document.getElementById("kioskBootHideDesktop"); + const hideDesktop = hideCb ? Boolean(hideCb.checked) : true; + const ok = window.confirm( + "Настроить киоск Pi целиком?\n\n" + + "• /etc/wesp/wesp.env и myscript.service\n" + + "• киоск Chromium" + + (hideDesktop ? " без рабочего стола" : "") + + "\n• перезагрузка устройства\n\n" + + "Покажется анимация загрузки (как /starting). Нужен root.", + ); + if (!ok) return; + + const btn = document.getElementById("btnKioskFullSetup"); + if (btn) btn.disabled = true; + await showKioskFullSetupOverlay(); + setKioskFullSetupQuip("Записываем настройки…"); + + try { + const r = await fetch("/api/admin/kiosk-full-setup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ hide_desktop: hideDesktop }), + }); + const d = await r.json().catch(() => ({})); + if (!r.ok) { + throw new Error(d.message || `Ошибка ${r.status}`); + } + stopKioskFullSetupQuipRotation(); + setStatus(d.message || "Настройка киоска завершена"); + if (d.reboot_scheduled) { + const sec = Math.max(5, Number(d.reboot_delay_sec) || 10); + let left = sec; + setKioskFullSetupQuip(`Готово — перезагрузка через ${left} с…`); + const countdown = window.setInterval(() => { + left -= 1; + if (left <= 0) { + clearInterval(countdown); + setKioskFullSetupQuip("Перезагружаем устройство…"); + return; + } + setKioskFullSetupQuip(`Готово — перезагрузка через ${left} с…`); + }, 1000); + } else { + setKioskFullSetupQuip("Готово — перезагрузите устройство вручную"); + window.setTimeout(() => hideKioskFullSetupOverlay(), 4000); + } + await refreshKioskFullSetupHint(); + await refreshPiPlatformSetupHint(); + await refreshKioskBootStatusHint(); + } catch (error) { + stopKioskFullSetupQuipRotation(); + setKioskFullSetupQuip(error.message || "Ошибка настройки", true); + setStatus(error.message, true, "Ошибка полной настройки киоска"); + window.setTimeout(() => hideKioskFullSetupOverlay(), 5000); + await refreshKioskFullSetupHint(); + } finally { + if (btn) btn.disabled = false; + } +} + +async function refreshPiPlatformSetupHint() { + const hint = document.getElementById("piPlatformSetupHint"); + const btn = document.getElementById("btnPiPlatformApply"); + if (!hint) return; + try { + const r = await fetch("/api/admin/pi-platform/status"); + const d = await r.json().catch(() => ({})); + if (!r.ok) { + hint.textContent = d.message || "Не удалось прочитать статус"; + hint.classList.add("wesp-sync-hint--error"); + return; + } + const parts = []; + if (!d.running_as_root) parts.push("WESP не от root — применить нельзя"); + if (d.env_path) parts.push(d.env_path); + if (d.systemd_unit) parts.push(`unit: ${d.systemd_unit}`); + if (d.env_keys_missing?.length) { + parts.push(`нет ключей: ${d.env_keys_missing.join(", ")}`); + } + if (d.platform_ready) parts.push("готово"); + else parts.push("нужно применить"); + hint.textContent = parts.join(" · "); + hint.classList.toggle("wesp-sync-hint--ok", Boolean(d.platform_ready)); + hint.classList.toggle("wesp-sync-hint--error", !d.running_as_root); + if (btn) btn.disabled = !d.running_as_root; + } catch (error) { + hint.textContent = userHint(error.message, "Ошибка статуса"); + hint.classList.add("wesp-sync-hint--error"); + } +} + +async function applyPiPlatformSetup() { + const ok = window.confirm( + "Применить настройки Pi?\n\n" + + "• /etc/wesp/wesp.env (systemd, OTA, reboot, киоск)\n" + + "• systemd unit (myscript)\n" + + "• перезапуск службы WESP через ~2 с\n\n" + + "Нужны права root.", + ); + if (!ok) return; + const btn = document.getElementById("btnPiPlatformApply"); + if (btn) btn.disabled = true; + try { + const r = await fetch("/api/admin/pi-platform/apply", { method: "POST" }); + const d = await r.json().catch(() => ({})); + if (!r.ok) { + throw new Error(d.message || `Ошибка ${r.status}`); + } + setStatus(d.message || "Настройка Pi применена"); + window.alert(d.message || "Готово"); + await refreshPiPlatformSetupHint(); + } catch (error) { + setStatus(error.message, true, "Ошибка настройки Pi"); + await refreshPiPlatformSetupHint(); + } finally { + if (btn) btn.disabled = false; + } +} + +async function refreshPiRainbowSplashHint() { + const hint = document.getElementById("piRainbowSplashHint"); + const btnOff = document.getElementById("btnPiRainbowSplashDisable"); + const btnOn = document.getElementById("btnPiRainbowSplashEnable"); + if (!hint) return; + try { + const r = await fetch("/api/admin/pi-boot/rainbow-splash/status"); + const d = await r.json().catch(() => ({})); + if (!r.ok) { + hint.textContent = d.message || "Не удалось прочитать статус"; + hint.classList.add("wesp-sync-hint--error"); + return; + } + const parts = []; + if (!d.config_found) parts.push("config.txt не найден"); + else parts.push(d.config_path || "config.txt"); + parts.push(d.disable_splash ? "disable_splash=1 (выкл.)" : "splash включён"); + if (d.message) parts.push(d.message); + hint.textContent = parts.join(" · "); + hint.classList.toggle("wesp-sync-hint--ok", Boolean(d.disable_splash)); + hint.classList.toggle("wesp-sync-hint--error", !d.config_found); + if (btnOff) btnOff.disabled = Boolean(d.disable_splash) || !d.config_found; + if (btnOn) btnOn.disabled = !d.disable_splash || !d.config_found; + } catch (error) { + hint.textContent = userHint(error.message, "Ошибка статуса"); + hint.classList.add("wesp-sync-hint--error"); + } +} + +async function setPiRainbowSplash(enabled) { + const label = enabled + ? "Отключить радужный splash прошивки (disable_splash=1)?" + : "Вернуть стандартный splash прошивки (убрать disable_splash)?"; + const extra = enabled + ? "\n\nПосле записи устройство перезагрузится автоматически (если включено в wesp.env)." + : "\n\nПосле изменения устройство перезагрузится автоматически (если включено)."; + if (!window.confirm(label + extra)) return; + + const btnOff = document.getElementById("btnPiRainbowSplashDisable"); + const btnOn = document.getElementById("btnPiRainbowSplashEnable"); + if (btnOff) btnOff.disabled = true; + if (btnOn) btnOn.disabled = true; + try { + const r = await fetch("/api/admin/pi-boot/rainbow-splash", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }); + const d = await r.json().catch(() => ({})); + if (!r.ok) { + throw new Error(d.message || `Ошибка ${r.status}`); + } + setStatus(d.message || "Готово"); + if (d.reboot_scheduled) { + window.alert(d.message || "Запланирована перезагрузка устройства."); + } + await refreshPiRainbowSplashHint(); + } catch (error) { + setStatus(error.message, true, "Ошибка config.txt"); + await refreshPiRainbowSplashHint(); + } +} + +let plymouthStatusPoll = null; +let plymouthJobTracked = false; +let plymouthLastNotifiedFinishAt = null; + +function plymouthInstallFailed(job) { + if (job.phase === "error") return true; + const install = job.result?.install; + return Boolean(install && install.ok === false); +} + +function buildPlymouthResultMessage(job, statusPayload) { + if (job.phase === "error") { + return String(job.error || job.message || "Ошибка установки Plymouth").trim(); + } + const install = job.result?.install; + if (install && install.ok === false) { + return String(install.message || "Установка темы Plymouth не удалась").trim(); + } + const render = job.result?.render; + const frames = render?.frames ?? statusPayload?.built_frames ?? statusPayload?.installed_frames; + const base = String(job.message || install?.message || "Тема Plymouth WESP установлена").trim(); + if (frames) return `${base} (${frames} кадров). Перезагрузите устройство.`; + return `${base} Перезагрузите устройство.`; +} + +function notifyPlymouthJobFinished(job, statusPayload) { + const finishedAt = job.finished_at; + if (!plymouthJobTracked || !finishedAt || finishedAt === plymouthLastNotifiedFinishAt) return; + if (job.phase !== "done" && job.phase !== "error") return; + + plymouthLastNotifiedFinishAt = finishedAt; + plymouthJobTracked = false; + + const failed = plymouthInstallFailed(job); + const message = buildPlymouthResultMessage(job, statusPayload); + setStatus(message, failed); + + if (!failed) { + const reboot = job.result?.reboot; + const rebootNote = reboot?.scheduled + ? `\n\n${reboot.message || "Перезагрузка запланирована."}` + : "\n\nПерезагрузите устройство, чтобы увидеть заставку при загрузке."; + window.alert("Тема Plymouth WESP установлена." + rebootNote); + } +} + +async function refreshPlymouthStatusHint() { + const hint = document.getElementById("plymouthStatusHint"); + const btn = document.getElementById("btnPlymouthInstall"); + if (!hint && !btn) return false; + try { + const r = await fetch("/api/admin/plymouth/status"); + const d = await r.json().catch(() => ({})); + if (!r.ok) return false; + const job = d.job || {}; + const parts = []; + if (!d.pillow_available) parts.push("нет Pillow"); + if (!d.plymouth_installed) parts.push("нет plymouth"); + if (d.active_theme) parts.push(`тема: ${d.active_theme}`); + if (d.installed_frames) parts.push(`установлено ${d.installed_frames} кадр.`); + else if (d.built_frames) parts.push(`собрано ${d.built_frames}`); + if (job.running) { + const prog = job.total ? `${job.progress || 0}/${job.total}` : ""; + parts.push([job.message || job.phase, prog].filter(Boolean).join(" ")); + } else if (job.phase === "error") { + parts.push(String(job.error || job.message || "Ошибка").slice(0, 200)); + } else if (job.phase === "done") { + parts.push(String(buildPlymouthResultMessage(job, d)).slice(0, 200)); + } + + const failed = !job.running && plymouthInstallFailed(job); + const succeeded = !job.running && job.phase === "done" && !failed; + hint.textContent = parts.length ? parts.join(" · ") : "Нажмите «Установить» для сборки темы (800×600)."; + hint.classList.toggle("wesp-sync-hint--error", failed); + hint.classList.toggle("wesp-sync-hint--ok", succeeded); + + if (btn) btn.disabled = Boolean(job.running); + + if (job.running) { + plymouthJobTracked = true; + } else { + notifyPlymouthJobFinished(job, d); + } + + return Boolean(job.running); + } catch { + return false; + } +} + +function stopPlymouthStatusPoll() { + if (plymouthStatusPoll) { + clearInterval(plymouthStatusPoll); + plymouthStatusPoll = null; + } +} + +function startPlymouthStatusPoll() { + stopPlymouthStatusPoll(); + plymouthStatusPoll = window.setInterval(async () => { + const running = await refreshPlymouthStatusHint(); + if (!running) stopPlymouthStatusPoll(); + }, 1200); +} + +async function installPlymouthTheme() { + const ok = window.confirm( + "Собрать анимацию WESP (800×600) и установить тему Plymouth?\n\n" + + "Нужны: Pillow (из requirements), plymouth, sudo для install_wesp_theme.sh " + + "(или WESP_PLYMOUTH_INSTALL_CMD).\n\nПосле установки перезагрузите устройство." + ); + if (!ok) return; + const btn = document.getElementById("btnPlymouthInstall"); + if (btn) btn.disabled = true; + try { + const r = await fetch("/api/admin/plymouth/install", { method: "POST" }); + const d = await r.json().catch(() => ({})); + if (!r.ok) { + throw new Error(d.message || `Ошибка ${r.status}`); + } + plymouthJobTracked = true; + plymouthLastNotifiedFinishAt = null; + setStatus(d.message || "Установка Plymouth запущена"); + startPlymouthStatusPoll(); + await refreshPlymouthStatusHint(); + } catch (error) { + setStatus(error.message, true, "Plymouth: ошибка"); + if (btn) btn.disabled = false; + } +} + +async function refreshKioskBootStatusHint() { + const hint = document.getElementById("kioskBootStatusHint"); + const btnOn = document.getElementById("btnKioskBootEnable"); + const btnOff = document.getElementById("btnKioskBootDisable"); + const hideCb = document.getElementById("kioskBootHideDesktop"); + if (!hint) return; + try { + const r = await fetch("/api/admin/kiosk-boot/status"); + const d = await r.json().catch(() => ({})); + if (!r.ok) { + hint.textContent = d.message || "Не удалось прочитать статус киоска"; + hint.classList.add("wesp-sync-hint--error"); + return; + } + const parts = []; + parts.push(d.kiosk_enabled ? "включён" : "выключен"); + if (d.gui_user) parts.push(`user: ${d.gui_user}`); + if (d.active_url) parts.push(d.active_url); + if (!d.chromium_found) parts.push("chromium не найден"); + if (d.kiosk_mode === "system_labwc") parts.push("патч /etc/xdg/labwc/autostart"); + else if (d.kiosk_mode === "user_labwc_legacy") parts.push("устаревший ~/.config/labwc"); + else if (d.kiosk_mode === "xdg") parts.push("режим XDG"); + if (d.system_labwc_patched) parts.push("system labwc OK"); + if (d.home_dir) parts.push(`home: ${d.home_dir}`); + if (d.message) parts.push(d.message); + hint.textContent = parts.join(" · "); + hint.classList.toggle("wesp-sync-hint--ok", Boolean(d.kiosk_enabled)); + hint.classList.toggle("wesp-sync-hint--error", !d.chromium_found || !d.home_exists); + if (hideCb && typeof d.hide_desktop === "boolean") hideCb.checked = d.hide_desktop; + if (btnOn) btnOn.disabled = Boolean(d.kiosk_enabled) || !d.chromium_found; + if (btnOff) btnOff.disabled = !d.kiosk_enabled; + } catch (error) { + hint.textContent = userHint(error.message, "Ошибка статуса киоска"); + hint.classList.add("wesp-sync-hint--error"); + } +} + +async function setKioskBoot(enabled) { + const hideCb = document.getElementById("kioskBootHideDesktop"); + const hideDesktop = hideCb ? Boolean(hideCb.checked) : false; + const label = enabled + ? "Включить автозапуск Chromium в kiosk?" + : "Выключить киоск и удалить autostart?"; + const extra = enabled + ? (hideDesktop + ? "\n\nБудут скрыты панель/рабочий стол (если компоненты есть в системе)." + : "") + "\n\nПосле сохранения — автоперезагрузка Pi (если включена в wesp.env)." + : "\n\nПосле отключения — автоперезагрузка Pi (если включена)."; + if (!window.confirm(label + extra)) return; + + const btnOn = document.getElementById("btnKioskBootEnable"); + const btnOff = document.getElementById("btnKioskBootDisable"); + if (btnOn) btnOn.disabled = true; + if (btnOff) btnOff.disabled = true; + try { + const body = { enabled }; + if (enabled) body.hide_desktop = hideDesktop; + const r = await fetch("/api/admin/kiosk-boot", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const d = await r.json().catch(() => ({})); + if (!r.ok) { + throw new Error(d.message || `Ошибка ${r.status}`); + } + setStatus(d.message || "Готово"); + if (d.reboot_scheduled) { + window.alert(d.message || "Запланирована перезагрузка устройства."); + } else if (enabled && !hideDesktop) { + window.alert( + "Киоск включён (XDG).\n\n" + + "Включите «Без рабочего стола» и снова «Включить киоск», затем reboot.", + ); + } + await refreshKioskBootStatusHint(); + } catch (error) { + setStatus(error.message, true, "Ошибка киоска"); + await refreshKioskBootStatusHint(); + } finally { + if (btnOn) btnOn.disabled = false; + if (btnOff) btnOff.disabled = false; + } +} + +async function postServiceControl(action) { + const label = action === "stop" ? "остановку службы" : "перезапуск службы"; + if (!window.confirm(`Подтвердите ${label}. Сессия может оборваться.`)) return; + const r = await fetch("/api/admin/service-control", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action }), + }); + let d = {}; + try { + d = await r.json(); + } catch (e) { + /* ignore */ + } + if (r.ok && d.ok) { + setStatus(d.message || "Команда запущена"); + } else { + setStatus(d.message, true, "Операция не выполнена"); + } +} + +async function downloadSqliteBackup() { + const r = await fetch("/api/admin/backup.sqlite"); + if (!r.ok) { + let msg = "Ошибка резервной копии"; + try { + const j = await r.json(); + if (j.message) msg = j.message; + } catch (e) { + /* ignore */ + } + throw new Error(msg); + } + const cd = r.headers.get("Content-Disposition") || ""; + let name = "wesp-backup.zip"; + const mStar = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(cd); + const mQuot = /filename\s*=\s*"([^"]+)"/i.exec(cd); + const mPlain = /filename\s*=\s*([^;\s]+)/i.exec(cd); + if (mStar) name = decodeURIComponent(mStar[1].trim()); + else if (mQuot) name = mQuot[1].trim(); + else if (mPlain) name = mPlain[1].trim(); + + const blob = await r.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = name; + a.click(); + URL.revokeObjectURL(url); + setStatus("SQLite ZIP: downloaded"); +} + +function wireAntCheckbox(input) { + if (!input) return; + const wrapper = input.closest(".ant-checkbox-wrapper"); + const box = wrapper?.querySelector(".ant-checkbox"); + if (!box) return; + + const apply = () => { + box.classList.toggle("ant-checkbox-checked", input.checked); + wrapper?.classList.toggle("ant-checkbox-disabled", input.disabled); + }; + + apply(); + if (input.dataset.antCheckboxWired === "1") return; + input.dataset.antCheckboxWired = "1"; + input.addEventListener("change", apply); +} + +function syncAntCheckboxVisual() { + wireAntCheckbox(document.getElementById("newUserIsSuper")); + wireAntCheckbox(document.getElementById("userModuleLabAccess")); +} + +async function init() { + let metricsTimer = null; + const shellStart = performance.now(); + prepareAdminSkeletonBlocks(); + try { + const authData = await fetchAuth(); + if (!authData) { + stripAdminShellDecorations(); + return; + } + + syncAntCheckboxVisual(); + applyAdminDocumentTheme(); + initAdminSidebar(); + hydrateAssistantChatMessagesFromStorage(); + await hydrateAdminActivityLog(); + wireAdminDbBrowserModal(); + document.getElementById("adminDbRefreshTreeBtn")?.addEventListener("click", () => { + refreshAdminDbTree(setStatus); + }); + document.getElementById("adminDbRefreshGridBtn")?.addEventListener("click", () => { + refreshAdminDbGrid(setStatus); + }); + initDashboardStatNav(); + resetServerIpMaskOnPageOpen(); + + document.querySelectorAll("[data-section]").forEach((btn) => { + btn.addEventListener("click", () => activateSection(btn.dataset.section)); + }); + { + const { from, to } = defaultAssistantDateRange(); + const df = document.getElementById("wespAssistantDateFrom"); + const dt = document.getElementById("wespAssistantDateTo"); + if (df && !df.value) df.value = formatDateInputYmd(from); + if (dt && !dt.value) dt.value = formatDateInputYmd(to); + } + document.getElementById("wespAssistantRefreshStatusBtn")?.addEventListener("click", () => { + loadAdminAssistantStatus().catch((e) => setStatus(e.message, true, "status fail")); + }); + document.getElementById("wespAssistantRefreshLogBtn")?.addEventListener("click", () => { + loadAdminAssistantLlmLog().catch((e) => setStatus(e.message, true, "журнал ИИ")); + }); + document.getElementById("wespAssistantAdminLlmEnabled")?.addEventListener("change", async (e) => { + const el = e.target; + if (!(el instanceof HTMLInputElement)) return; + const checked = el.checked; + const prev = !checked; + try { + const r = await fetch("/api/admin/security-settings", { + method: "PATCH", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ admin_llm_enabled: checked }), + }); + const data = await r.json().catch(() => ({})); + if (!r.ok) { + setStatus(data.message || String(r.status), true); + el.checked = prev; + return; + } + setStatus(checked ? "ИИ включён" : "ИИ выключен"); + loadAdminAssistantStatus().catch(() => {}); + loadAdminAssistantLlmLog().catch(() => {}); + } catch (err) { + setStatus(err?.message, true, "save fail"); + el.checked = prev; + } + }); + document.getElementById("wespAssistantLlmAutostart")?.addEventListener("change", async (e) => { + const el = e.target; + if (!(el instanceof HTMLInputElement)) return; + const checked = el.checked; + const prev = !checked; + try { + const r = await fetch("/api/admin/security-settings", { + method: "PATCH", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ llm_autostart: checked }), + }); + const data = await r.json().catch(() => ({})); + if (!r.ok) { + setStatus(data.message || String(r.status), true); + el.checked = prev; + return; + } + setStatus("Автозапуск llama-server сохранён в конфиг."); + loadAdminAssistantStatus().catch(() => {}); + loadAdminAssistantLlmLog().catch(() => {}); + } catch (err) { + setStatus(err?.message, true, "save fail"); + el.checked = prev; + } + }); + document.getElementById("wespAssistantChatDbContext")?.addEventListener("change", async (e) => { + const el = e.target; + if (!(el instanceof HTMLInputElement)) return; + const checked = el.checked; + const prev = !checked; + try { + const r = await fetch("/api/admin/security-settings", { + method: "PATCH", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ llm_chat_db_context: checked }), + }); + const data = await r.json().catch(() => ({})); + if (!r.ok) { + setStatus(data.message || String(r.status), true); + el.checked = prev; + return; + } + setStatus("Режим чата с контекстом БД сохранён."); + loadAdminAssistantStatus().catch(() => {}); + } catch (err) { + setStatus(err?.message, true, "save fail"); + el.checked = prev; + } + }); + document.getElementById("wespAssistantLlmToolsEnabled")?.addEventListener("change", async (e) => { + const el = e.target; + if (!(el instanceof HTMLInputElement)) return; + const checked = el.checked; + const prev = !checked; + try { + const r = await fetch("/api/admin/security-settings", { + method: "PATCH", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ llm_tools_enabled: checked }), + }); + const data = await r.json().catch(() => ({})); + if (!r.ok) { + setStatus(data.message || String(r.status), true); + el.checked = prev; + return; + } + setStatus("Инструменты чата (slim-tools) сохранены в конфиг."); + loadAdminAssistantStatus().catch(() => {}); + } catch (err) { + setStatus(err?.message, true, "save fail"); + el.checked = prev; + } + }); + document.getElementById("wespAssistantChatSendBtn")?.addEventListener("click", () => { + runAdminAssistantChatSend().catch((e) => setStatus(e.message, true, "chat fail")); + }); + document.getElementById("wespAssistantChatClearBtn")?.addEventListener("click", () => { + window.__wespAssistantChatMessages = []; + try { + sessionStorage.removeItem(WESP_ASSISTANT_CHAT_STORAGE_KEY); + } catch { + /* ignore */ + } + renderAssistantChatLog(); + setStatus("Диалог очищен"); + }); + document.getElementById("wespAssistantPingBtn")?.addEventListener("click", () => { + runAdminAssistantPing().catch((e) => setStatus(e.message, true, "ping fail")); + }); + document.getElementById("wespAssistantSummaryBtn")?.addEventListener("click", () => { + runAdminAssistantSummary().catch((e) => setStatus(e.message, true, "summary fail")); + }); + document.getElementById("wespAssistantDiagnosticsBtn")?.addEventListener("click", () => { + runAdminAssistantDiagnostics().catch((e) => setStatus(e.message, true, "diagnostics fail")); + }); + document.getElementById("wespAssistantChatInput")?.addEventListener("keydown", (e) => { + if (e.key !== "Enter" || e.shiftKey) return; + e.preventDefault(); + runAdminAssistantChatSend().catch((err) => setStatus(err.message, true, "chat fail")); + }); + document.getElementById("btnDashHardwareChart")?.addEventListener("click", () => { + openHardwareChartFromDashboard(); + }); + document.getElementById("refreshSummaryBtn")?.addEventListener("click", async () => { + try { + await Promise.all([updateSummary(), updateSystemMetrics(), loadUsers(), refreshActivityServerCache()]); + renderAdminActivityTerminalFromMerged(); + setStatus("state: OK"); + } catch (error) { + setStatus(error.message, true, "Ошибка обновления"); + } + }); + document.getElementById("orchSyncSave")?.addEventListener("click", async () => { + try { + await saveOrchestratorSyncFromForm(); + } catch (error) { + setStatus(error.message, true, "Orchestrator sync save failed"); + } + }); + document.getElementById("syncSettingsSave")?.addEventListener("click", async () => { + const btn = document.getElementById("syncSettingsSave"); + if (btn?.dataset.saving === "1") return; + if (btn) { + btn.dataset.saving = "1"; + btn.disabled = true; + } + try { + await saveSyncSettingsFromForm(); + } catch (error) { + setStatus(error.message, true, "Ошибка сохранения"); + } finally { + if (btn) { + btn.dataset.saving = "0"; + btn.disabled = false; + } + } + }); + document.getElementById("syncDiagRefreshBtn")?.addEventListener("click", async () => { + try { + await loadSyncDiagnostics(); + setStatus("sync diagnostics: OK"); + } catch (e) { + setStatus(e.message, true, "diagnostics fail"); + } + }); + document.getElementById("syncActionRequeueStuck")?.addEventListener("click", async () => { + if ( + !window.confirm( + "Переставить зависшие задачи (processing → pending) по порогу SYNC_REQUEUE_TIMEOUT_MINUTES?", + ) + ) { + return; + } + try { + const r = await fetch("/api/admin/sync-actions/requeue-stuck", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`); + setStatus(d.message || "requeue OK"); + await loadSyncDiagnostics(); + } catch (e) { + setStatus(e.message, true, "requeue fail"); + } + }); + document.getElementById("syncActionRestartLocal")?.addEventListener("click", async () => { + if (!window.confirm("Перезапустить фоновый sync_client в этом процессе?")) return; + try { + const r = await fetch("/api/admin/sync-actions/restart-local-sync", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`); + setStatus(d.message || "restart OK"); + await loadSyncDiagnostics(); + } catch (e) { + setStatus(e.message, true, "restart fail"); + } + }); + document.getElementById("syncActionRefreshRuntime")?.addEventListener("click", async () => { + try { + const r = await fetch("/api/admin/sync-actions/refresh-runtime", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`); + setStatus(d.message || "runtime OK"); + const msg = document.getElementById("syncDiagActionMessage"); + if (msg && d.runtime) { + msg.textContent = `runtime: ${JSON.stringify(d.runtime)}`; + } + } catch (e) { + setStatus(e.message, true, "runtime fail"); + } + }); + document.getElementById("syncActionRetryPush")?.addEventListener("click", async () => { + if (!window.confirm("Запланировать полный sync_cycle (push отчётов + pull) в фоне?")) return; + try { + const r = await fetch("/api/admin/sync-actions/retry-report-push", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.message || `Ошибка ${r.status}`); + setStatus(d.message || "scheduled OK"); + } catch (e) { + setStatus(e.message, true, "push fail"); + } + }); + document.getElementById("securitySettingsSave")?.addEventListener("click", async () => { + try { + await saveSecuritySettingsFromForm(); + } catch (error) { + setStatus(error.message, true, "Ошибка сохранения"); + } + }); + document.getElementById("netSettingsSave")?.addEventListener("click", async () => { + try { + await saveNetworkSettingsFromForm(); + } catch (error) { + setStatus(error.message, true, "Ошибка сохранения сети"); + } + }); + document.getElementById("netSettingsRefreshBtn")?.addEventListener("click", async () => { + try { + await loadNetworkSettings(); + await loadNetworkEvents(); + setStatus("Сеть: обновлено"); + } catch (error) { + setStatus(error.message, true, "Ошибка загрузки сети"); + } + }); + document.getElementById("netEventsRefreshBtn")?.addEventListener("click", async () => { + try { + await loadNetworkEvents(); + setStatus("Журнал сети обновлён"); + } catch (error) { + setStatus(error.message, true, "Ошибка журнала сети"); + } + }); + document.getElementById("netEventsClearBtn")?.addEventListener("click", async () => { + try { + await clearNetworkEventsLog(); + setStatus("Журнал сети очищен"); + } catch (error) { + setStatus(error.message, true, "Ошибка очистки журнала сети"); + } + }); + document.getElementById("netBuildUrlBtn")?.addEventListener("click", () => { + applyNetworkSuggestedPublicUrl(); + }); + document.getElementById("netMdnsEnabled")?.addEventListener("change", (ev) => { + const label = document.getElementById("netMdnsStatusLabel"); + if (label && ev.target instanceof HTMLInputElement) { + label.textContent = ev.target.checked ? "mDNS: вкл." : "mDNS: выкл."; + } + }); + wireSecurityToggleAutoSave("secMacLock", "mac_lock_enabled"); + wireSecurityToggleAutoSave("secKioskEnforce", "kiosk_enforce_paired_only"); + wireSecurityToggleAutoSave("secCalibrationPublic", "calibration_public"); + document.getElementById("auSave")?.addEventListener("click", async () => { + try { + await saveAutoUpdateFromForm(); + } catch (error) { + setStatus(error.message, true, "Ошибка сохранения автообновления"); + } + }); + document.getElementById("auSaveSecrets")?.addEventListener("click", async () => { + try { + await saveGiteaSecretsFromForm(); + } catch (error) { + setStatus(error.message, true, "Ошибка сохранения секретов Gitea"); + } + }); + document.getElementById("secDeviceTokenClear")?.addEventListener("change", (ev) => { + const token = document.getElementById("secDeviceToken"); + const box = ev.target; + if (!token || !box) return; + const locked = token.dataset.envLocked === "1"; + token.disabled = locked || box.checked; + }); + document.getElementById("syncClientsList")?.addEventListener("click", async (event) => { + try { + await syncClientsTableAction(event); + } catch (error) { + setStatus(error.message, true, "Ошибка"); + } + }); + document.getElementById("createUserForm")?.addEventListener("submit", async (event) => { + try { + await createUser(event); + } catch (error) { + setStatus(error.message, true, "Ошибка создания пользователя"); + } + }); + document.getElementById("usersList")?.addEventListener("click", async (event) => { + try { + await usersTableAction(event); + } catch (error) { + setStatus(error.message, true, "Ошибка операции с пользователем"); + } + }); + + document.getElementById("adminModalClose")?.addEventListener("click", closeAdminModal); + document.getElementById("adminModalMask")?.addEventListener("click", closeAdminModal); + + document.getElementById("userModulesModalClose")?.addEventListener("click", closeUserModulesModal); + document.getElementById("userModulesModalMask")?.addEventListener("click", closeUserModulesModal); + document.getElementById("userModulesModalCancel")?.addEventListener("click", closeUserModulesModal); + document.getElementById("userModulesModalSave")?.addEventListener("click", async () => { + try { + await saveUserModulesModal(); + } catch (error) { + setStatus(error.message, true, "Ошибка сохранения модулей"); + } + }); + + document.getElementById("btnManageLog")?.addEventListener("click", () => { + fetchAndShowServerLog("Журнал").catch((e) => setStatus(e.message, true, "Не удалось выполнить операцию.")); + }); + document.getElementById("btnManageLogDownload")?.addEventListener("click", async () => { + try { + await downloadServerLogFile(); + } catch (error) { + setStatus(error.message, true, "log download failed"); + } + }); + document.getElementById("btnManageClientLogs")?.addEventListener("click", () => { + fetchAndShowClientUploadedLog().catch((e) => setStatus(e.message, true, "client log failed")); + }); + document.getElementById("btnManageClientLogsDownload")?.addEventListener("click", async () => { + try { + await downloadClientUploadedLogFile(); + } catch (error) { + setStatus(error.message, true, "client log download failed"); + } + }); + document.getElementById("btnManageConfig")?.addEventListener("click", () => { + activateSection("system"); + document.getElementById("wespSectionSystem")?.scrollIntoView({ behavior: "smooth" }); + }); + document.getElementById("btnFactoryResetPreview")?.addEventListener("click", async () => { + try { + await loadFactoryResetPreview(); + setStatus("Список для сброса обновлён"); + } catch (error) { + setStatus(error.message, true, "Ошибка предпросмотра"); + } + }); + document.getElementById("btnFactoryReset")?.addEventListener("click", async () => { + try { + const d = await runFactoryReset(); + if (!d) return; + const extra = d.safety_backup_path ? ` (${d.safety_backup_path})` : ""; + setStatus((d.message || "Сброс выполнен") + extra + " Перезапустите WESP."); + } catch (error) { + setStatus(error.message, true, "Ошибка сброса"); + } + }); + document.getElementById("btnManageBackup")?.addEventListener("click", async () => { + try { + await downloadSqliteBackup(); + } catch (error) { + setStatus(error.message, true, "Ошибка копии"); + } + }); + document.getElementById("btnManageRestore")?.addEventListener("click", () => { + document.getElementById("restoreSqliteFile")?.click(); + }); + document.getElementById("restoreSqliteFile")?.addEventListener("change", async (event) => { + const input = event.target; + const file = input?.files?.[0]; + if (!file) return; + const name = (file.name || "").toLowerCase(); + if (!name.endsWith(".zip")) { + setStatus("restore: требуется .zip", true); + input.value = ""; + return; + } + const ok = window.confirm( + "Текущие файлы SQLite будут скопированы с суффиксом .restore-bak-* и заменены содержимым архива.\n\n" + + "Используйте только ZIP, скачанный здесь же («Скачать ZIP»).\n\n" + + "После восстановления перезапустите WESP и обновите страницу.\n\n" + + "Продолжить?", + ); + if (!ok) { + input.value = ""; + return; + } + const fd = new FormData(); + fd.append("file", file); + try { + const r = await fetch("/api/admin/restore.sqlite", { method: "POST", body: fd }); + const d = await r.json().catch(() => ({})); + if (!r.ok) { + throw new Error(d.message || `Ошибка ${r.status}`); + } + setStatus(d.message || "restore: OK"); + } catch (error) { + setStatus(error.message, true, "Ошибка восстановления"); + } finally { + input.value = ""; + } + }); + document.getElementById("btnKioskFullSetup")?.addEventListener("click", () => { + applyFullKioskSetup().catch((e) => setStatus(e.message, true, "Kiosk full setup")); + }); + refreshKioskFullSetupHint(); + document.getElementById("btnPiPlatformApply")?.addEventListener("click", () => { + applyPiPlatformSetup().catch((e) => setStatus(e.message, true, "Pi setup")); + }); + refreshPiPlatformSetupHint(); + document.getElementById("btnPiRainbowSplashDisable")?.addEventListener("click", () => { + setPiRainbowSplash(true).catch((e) => setStatus(e.message, true, "Pi boot")); + }); + document.getElementById("btnPiRainbowSplashEnable")?.addEventListener("click", () => { + setPiRainbowSplash(false).catch((e) => setStatus(e.message, true, "Pi boot")); + }); + refreshPiRainbowSplashHint(); + document.getElementById("btnPlymouthInstall")?.addEventListener("click", () => { + installPlymouthTheme().catch((e) => setStatus(e.message, true, "Plymouth")); + }); + refreshPlymouthStatusHint().then((running) => { + if (running) startPlymouthStatusPoll(); + }); + document.getElementById("btnKioskBootEnable")?.addEventListener("click", () => { + setKioskBoot(true).catch((e) => setStatus(e.message, true, "Kiosk boot")); + }); + document.getElementById("btnKioskBootDisable")?.addEventListener("click", () => { + setKioskBoot(false).catch((e) => setStatus(e.message, true, "Kiosk boot")); + }); + refreshKioskBootStatusHint(); + document.getElementById("btnWespStop")?.addEventListener("click", () => { + postServiceControl("stop"); + }); + document.getElementById("btnWespRestart")?.addEventListener("click", () => { + postServiceControl("restart"); + }); + document.getElementById("btnToggleServerIp")?.addEventListener("click", () => { + const next = localStorage.getItem("wespAdminMaskIp") === "1" ? "0" : "1"; + if (next === "1") localStorage.setItem("wespAdminMaskIp", "1"); + else localStorage.removeItem("wespAdminMaskIp"); + applyServerIpMask(); + }); + document.getElementById("diagAddExtraTargetBtn")?.addEventListener("click", () => { + const wrap = document.getElementById("diagExtraTargetsRows"); + if (!wrap) return; + wrap.insertAdjacentHTML("beforeend", makeDiagTargetRow({})); + }); + document.getElementById("diagExtraTargetsRows")?.addEventListener("click", (event) => { + const btn = event.target.closest("[data-diag-remove-target]"); + if (!btn) return; + const row = btn.closest("[data-diag-target-row]"); + if (row) row.remove(); + }); + document.getElementById("diagSaveExtraTargetsBtn")?.addEventListener("click", async () => { + try { + await saveDiagnosticsSettings(); + } catch (error) { + setStatus(error.message, true, "Не удалось сохранить точки"); + } + }); + document.getElementById("diagRefreshReportBtn")?.addEventListener("click", () => { + loadDiagnosticsReport() + .then(() => setStatus("Диагностика: отчёт обновлён")) + .catch((e) => setStatus(e.message, true, "Ошибка диагностики")); + }); + document.getElementById("diagRunPrimaryBtn")?.addEventListener("click", async () => { + try { + await runDiagnosticsNetwork("primary", false); + } catch (error) { + setStatus(error.message, true, "Ошибка проверки"); + } + }); + document.getElementById("diagRunPrimaryTraceBtn")?.addEventListener("click", () => { + openTracerouteModalFromDiagnostics(); + }); + document.getElementById("tracerouteModalClose")?.addEventListener("click", closeTracerouteModal); + document.getElementById("tracerouteModalMask")?.addEventListener("click", closeTracerouteModal); + document.getElementById("tracerouteRunBtn")?.addEventListener("click", async () => { + try { + await runTracerouteByHostsModal(); + } catch (error) { + setStatus(error.message, true, "Ошибка трассировки"); + } + }); + document.getElementById("tracerouteFillPrimaryBtn")?.addEventListener("click", fillTracerouteHostsFromPrimary); + document.getElementById("tracerouteClearHostsBtn")?.addEventListener("click", () => { + const ta = document.getElementById("tracerouteHostsInput"); + const wrap = document.getElementById("tracerouteModalResults"); + if (ta) ta.value = ""; + if (wrap) wrap.innerHTML = ""; + }); + document.getElementById("diagRunInternetBtn")?.addEventListener("click", async () => { + try { + await runDiagnosticsNetwork("custom", false, []); + } catch (error) { + setStatus(error.message, true, "Ошибка проверки интернета"); + } + }); + document.getElementById("diagRunExtraTargetsBtn")?.addEventListener("click", async () => { + try { + await saveDiagnosticsSettings(); + await runDiagnosticsNetwork("extra", true); + } catch (error) { + setStatus(error.message, true, "Ошибка доп. точек"); + } + }); + document.getElementById("diagHxSampleBtn")?.addEventListener("click", async () => { + try { + await runDiagnosticsHxSample("diagHardwareList"); + await loadPeripheralEvents().catch(() => {}); + } catch (error) { + setStatus(error.message, true, "Ошибка HX711"); + } + }); + document.getElementById("diagGpioBlinkBtn")?.addEventListener("click", async () => { + try { + await runDiagnosticsGpioBlink("diagHardwareList"); + } catch (error) { + setStatus(error.message, true, "Ошибка GPIO"); + } + }); + document.getElementById("hardwareRefreshBtn")?.addEventListener("click", async () => { + try { + await refreshHardwareSection(); + } catch (error) { + setStatus(error.message, true, "Ошибка обновления железа"); + } + }); + document.getElementById("hardwareEventsClearBtn")?.addEventListener("click", async () => { + try { + await clearPeripheralEventsLog(); + setStatus("Журнал событий очищен"); + } catch (error) { + setStatus(error.message, true, "Ошибка очистки журнала"); + } + }); + document.getElementById("hwHxSampleBtn")?.addEventListener("click", async () => { + try { + await runDiagnosticsHxSample("hardwarePeriphList"); + await loadHardwareStatus().catch(() => {}); + await loadPeripheralEvents().catch(() => {}); + } catch (error) { + setStatus(error.message, true, "Ошибка HX711"); + } + }); + document.getElementById("hwGpioBlinkBtn")?.addEventListener("click", async () => { + try { + await runDiagnosticsGpioBlink("hardwarePeriphList"); + } catch (error) { + setStatus(error.message, true, "Ошибка GPIO"); + } + }); + document.getElementById("hwSimulationToggle")?.addEventListener("change", async (ev) => { + const enabled = Boolean(ev.target && ev.target.checked); + withHwSimUiLock(() => {}); + try { + await patchHardwareSimulation(enabled); + await refreshHardwareSection(); + setStatus( + enabled + ? "Симуляция включена — HX711 не опрашивается" + : "Симуляция выключена — чтение с HX711", + ); + } catch (error) { + if (ev.target) ev.target.checked = !enabled; + setStatus(error.message, true, "Ошибка симуляции"); + } + }); + document.getElementById("hwSimWeightMinus")?.addEventListener("click", async () => { + try { + await adjustHardwareSimulationWeight(-1); + await refreshHardwareSection(); + } catch (error) { + setStatus(error.message, true, "Ошибка изменения веса"); + } + }); + document.getElementById("hwSimWeightPlus")?.addEventListener("click", async () => { + try { + await adjustHardwareSimulationWeight(1); + await refreshHardwareSection(); + } catch (error) { + setStatus(error.message, true, "Ошибка изменения веса"); + } + }); + document.getElementById("hwSimWeightApply")?.addEventListener("click", async () => { + try { + await applyHardwareSimulationWeightFromUi(); + setStatus("Вес симуляции применён"); + } catch (error) { + setStatus(error.message, true, "Ошибка изменения веса"); + } + }); + document.getElementById("hwSimWeightInput")?.addEventListener("keydown", async (ev) => { + if (ev.key !== "Enter") return; + ev.preventDefault(); + try { + await applyHardwareSimulationWeightFromUi(); + setStatus("Вес симуляции применён"); + } catch (error) { + setStatus(error.message, true, "Ошибка изменения веса"); + } + }); + document.getElementById("hwSimWeightSlider")?.addEventListener("input", (ev) => { + const slider = ev.target; + const input = document.getElementById("hwSimWeightInput"); + if (input && slider) input.value = slider.value; + scheduleHardwareSimulationWeightFromSlider(); + }); + + await Promise.all([updateSummary(), updateSystemMetrics(), loadUsers()]); + await finishAdminShellReveal(shellStart); + setStatus(`session: ${authData.user_login}`); + + metricsTimer = window.setInterval(() => { + updateSystemMetrics().catch((err) => { + const st = document.getElementById("wespRuntimeStatus"); + if (st) st.textContent = userHint(err?.message, "Не удалось загрузить метрики"); + }); + }, 4000); + window.addEventListener("beforeunload", () => { + if (metricsTimer) window.clearInterval(metricsTimer); + stopHardwarePolling(); + }); + } catch (error) { + stripAdminShellDecorations(); + setStatus(error.message, true, "Ошибка загрузки админ-панели"); + } +} + +init(); diff --git a/apps/web/public/wesp/js/pages/analytics-copy.js b/apps/web/public/wesp/js/pages/analytics-copy.js new file mode 100644 index 0000000..58ef007 --- /dev/null +++ b/apps/web/public/wesp/js/pages/analytics-copy.js @@ -0,0 +1,53 @@ +/** + * Единый словарь подписей analytics для зоотехника. + */ +(function (global) { + global.WespAnalyticsCopy = { + tabs: { + summary: "Итоги", + comparison: "Сравнение", + journal: "Подробно", + reports: "Отчёты", + alerts: "Отклонения", + }, + summary: { + overloadTitle: "Перерасход", + overloadHint: "Насыпали больше, чем в плане", + overloadHintZero: "Перерасхода нет — загрузка в пределах плана", + underloadTitle: "Недогруз", + underloadHint: "Риск для надоя: коровы могли недополучить корм", + underloadHintZero: "Недогруза нет — план выполнен", + netTitle: "Итого по деньгам", + netHintOverload: "Преобладает перерасход — прямой убыток бюджета", + netHintUnderload: "Преобладает недогруз — экономия сейчас, риск падения надоев", + netHintBalanced: "Перерасход и недогруз за период уравновешены", + topTitle: "Где больше всего потеряли", + allAlertsLink: "Все отклонения →", + openStockLink: "Открыть склад →", + empty: "За выбранный период отчётов нет", + exportExcel: "Excel", + }, + comparison: { + sectionLoading: "Загрузка", + sectionUnloading: "Выгрузка", + legendBase: "По рецепту", + legendPlan: "На сегодня", + legendActual: "Сделали", + noteExecution: "Ошибка при загрузке", + noteUnloadingExecution: "Ошибка при выгрузке", + mixerRemainderLabel: "Остаток в миксере", + noteExcluded: "Исключено из плана", + noteAdjusted: "План скорректирован", + empty: "За этот период рейсов с отчётами нет", + }, + stock: { + bannerTitle: "Обратите внимание:", + daysRecipe: "Хватит по рецепту", + daysAdjusted: "Хватит (с учётом плана)", + }, + formatRub(value) { + const n = Number(value) || 0; + return `${Math.round(n).toLocaleString("ru-RU")} ₽`; + }, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/apps/web/public/wesp/js/pages/analytics-plan-fact.js b/apps/web/public/wesp/js/pages/analytics-plan-fact.js new file mode 100644 index 0000000..b6fb16a --- /dev/null +++ b/apps/web/public/wesp/js/pages/analytics-plan-fact.js @@ -0,0 +1,196 @@ +/** + * Вкладка «Сравнение» на /reports. + */ +(function (global) { + const COPY = () => global.WespAnalyticsCopy || {}; + + function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">"); + } + + function getDateParams() { + const from = document.getElementById("date-from")?.value || ""; + const to = document.getElementById("date-to")?.value || ""; + return { date_from: from, date_to: to }; + } + + function buildUrl() { + const params = global.WespReportsFilters?.buildAnalyticsParams?.() || new URLSearchParams(getDateParams()); + return `/api/analytics/plan-fact?${params.toString()}`; + } + + function barWidth(value, max) { + if (!max || max <= 0) return 0; + return Math.min(100, (Number(value) / max) * 100); + } + + function faultBadge(fault, executionLabel) { + const c = COPY().comparison || {}; + if (fault === "execution") { + return `${escapeHtml( + executionLabel || c.noteExecution || "" + )}`; + } + if (fault === "excluded" || fault === "zootech_ok") { + return `${escapeHtml(c.noteExcluded || "")}`; + } + if (fault === "adjusted" || fault === "zootech") { + return `${escapeHtml(c.noteAdjusted || "")}`; + } + return ""; + } + + function legendHtml() { + const c = COPY().comparison || {}; + return ( + `
    ` + + `${escapeHtml(c.legendBase)}` + + `${escapeHtml(c.legendPlan)}` + + `${escapeHtml(c.legendActual)}` + + `
    ` + ); + } + + function renderMetricRows(rows, maxKg, options) { + const executionLabel = options?.executionLabel; + return rows + .map((c) => { + const notes = (c.notes || []) + .map((n) => { + const note = typeof n === "string" ? { text: n, kind: "execution" } : n; + const kind = note.kind === "zootech" ? "zootech" : "execution"; + const cls = + kind === "zootech" + ? "analytics-pf-note analytics-pf-note--zootech" + : "analytics-pf-note analytics-pf-note--execution"; + return `
  • ${escapeHtml(note.text)}
  • `; + }) + .join(""); + const faultBadgeHtml = faultBadge(c.fault, executionLabel); + const compCls = + c.skippedToday && (c.fault === "excluded" || c.fault === "zootech_ok") + ? "analytics-pf-comp analytics-pf-comp--excluded" + : options?.rowClass || "analytics-pf-comp"; + return ( + `
    ` + + `
    ` + + `
    ${escapeHtml(c.name)}
    ` + + faultBadgeHtml + + `
    ` + + `
    ` + + `
    ${Number(c.baseKg).toFixed(1)}
    ` + + `
    ${Number(c.planTodayKg).toFixed(1)}
    ` + + `
    ${Number(c.actualKg).toFixed(1)}
    ` + + `
    ` + + (notes ? `
      ${notes}
    ` : "") + + `
    ` + ); + }) + .join(""); + } + + function sectionHtml(title, body) { + if (!body) return ""; + return ( + `
    ` + + `

    ${escapeHtml(title)}

    ` + + body + + `
    ` + ); + } + + function renderItem(item) { + const comps = item.components || []; + const groups = item.unloadingGroups || []; + const mixer = item.mixerRemainder || null; + const compMax = Math.max(...comps.flatMap((c) => [c.baseKg, c.planTodayKg, c.actualKg]), 1); + const unloadMax = Math.max(...groups.flatMap((c) => [c.baseKg, c.planTodayKg, c.actualKg]), 1); + + const compHtml = renderMetricRows(comps, compMax, { + executionLabel: COPY().comparison?.noteExecution, + }); + const unloadHtml = renderMetricRows(groups, unloadMax, { + executionLabel: COPY().comparison?.noteUnloadingExecution, + }); + const mixerHtml = mixer + ? (() => { + const kg = Number(mixer.actualKg); + const sign = kg > 0 ? "+" : ""; + const cls = + kg < 0 + ? "analytics-pf-mixer-line analytics-pf-mixer-line--negative" + : "analytics-pf-mixer-line"; + return ( + `

    ${escapeHtml( + COPY().comparison?.mixerRemainderLabel || "Остаток в миксере" + )}: ${sign}${kg.toFixed(1)} кг

    ` + ); + })() + : ""; + + const when = item.startTime ? escapeHtml(item.startTime.slice(0, 16).replace("T", " ")) : ""; + const unloadingSection = + groups.length || mixer + ? sectionHtml( + COPY().comparison?.sectionUnloading || "Выгрузка", + legendHtml() + unloadHtml + mixerHtml + ) + : ""; + + return ( + `
    ` + + `
    ` + + `${escapeHtml(item.recipeName)}` + + `${when}` + + `
    ` + + sectionHtml( + COPY().comparison?.sectionLoading || "Загрузка", + legendHtml() + compHtml + ) + + unloadingSection + + `
    ` + ); + } + + function render(data) { + const el = document.getElementById("analyticsPlanFactList"); + if (!el) return; + const items = data.items || []; + if (!items.length) { + el.innerHTML = `

    ${escapeHtml(COPY().comparison?.empty || "")}

    `; + return; + } + el.innerHTML = `
    ${items.map(renderItem).join("")}
    `; + el.querySelectorAll("[data-loading-report-id]").forEach((card) => { + card.addEventListener("click", () => { + const id = card.getAttribute("data-loading-report-id"); + if (id) global.WespFeedAlerts?.openReportFromAlert?.(id); + }); + }); + global.WespZootechModals?.revealContent(el.querySelector(".analytics-pf-list")); + } + + async function load() { + const el = document.getElementById("analyticsPlanFactList"); + if (!el) return; + el.innerHTML = + '
    ' + + '

    Загрузка сравнения…

    '; + try { + const resp = await fetch(buildUrl()); + if (!resp.ok) throw new Error("Не удалось загрузить сравнение"); + render(await resp.json()); + } catch (err) { + el.innerHTML = `

    ${escapeHtml(err.message)}

    `; + } + } + + function onFiltersApplied() { + if (global.WespFeedAlerts?.getCurrentView?.() === "comparison") load(); + } + + global.WespAnalyticsPlanFact = { load, onFiltersApplied }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/apps/web/public/wesp/js/pages/analytics-summary.js b/apps/web/public/wesp/js/pages/analytics-summary.js new file mode 100644 index 0000000..5b4172e --- /dev/null +++ b/apps/web/public/wesp/js/pages/analytics-summary.js @@ -0,0 +1,151 @@ +/** + * Вкладка «Итоги» на /reports. + */ +(function (global) { + const COPY = () => global.WespAnalyticsCopy || {}; + + function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">"); + } + + function getDateParams() { + const from = document.getElementById("date-from")?.value || ""; + const to = document.getElementById("date-to")?.value || ""; + return { date_from: from, date_to: to }; + } + + function buildUrl() { + const params = global.WespReportsFilters?.buildAnalyticsParams?.() || new URLSearchParams(getDateParams()); + return `/api/analytics/finance?${params.toString()}`; + } + + function valueToneClass(kind) { + if (kind === "loss") return "analytics-kpi-card__value--loss"; + if (kind === "risk") return "analytics-kpi-card__value--risk"; + return ""; + } + + function netHint(data) { + const c = COPY().summary || {}; + if (data.dominantIssue === "overload") return c.netHintOverload || ""; + if (data.dominantIssue === "underload") return c.netHintUnderload || ""; + return c.netHintBalanced || ""; + } + + function overloadHint(data) { + const c = COPY().summary || {}; + if (Number(data.overloadRub) > 0) return c.overloadHint || ""; + return c.overloadHintZero || c.overloadHint || ""; + } + + function underloadHint(data) { + const c = COPY().summary || {}; + if (Number(data.underloadRub) > 0) return c.underloadHint || ""; + return c.underloadHintZero || c.underloadHint || ""; + } + + function netTone(data) { + if (data.dominantIssue === "overload") return "loss"; + if (data.dominantIssue === "underload") return "risk"; + return ""; + } + + function renderTopBar(data) { + const top = data.topComponents || []; + if (!top.length) { + return `

    ${escapeHtml(COPY().summary?.empty || "")}

    `; + } + const maxVal = Math.max( + ...top.map((row) => Math.max(row.overloadRub, row.underloadRub)), + 1 + ); + const rows = top + .map((row) => { + const isOverload = row.overloadRub > row.underloadRub; + const main = isOverload + ? `${COPY().formatRub(row.overloadRub)} перерасход` + : `${COPY().formatRub(row.underloadRub)} недогруз`; + const tone = isOverload ? "analytics-top-row__value--loss" : "analytics-top-row__value--risk"; + const widthPct = Math.round( + Math.min(100, (Math.max(row.overloadRub, row.underloadRub) / maxVal) * 1000) + ) / 10; + return ( + `
    ` + + `${escapeHtml(row.name)}` + + `` + + `` + + `${escapeHtml(main)}` + + `
    ` + ); + }) + .join(""); + return `
    ${rows}
    `; + } + + function render(data) { + const el = document.getElementById("analyticsSummaryList"); + if (!el) return; + const c = COPY().summary || {}; + const overloadTone = Number(data.overloadRub) > 0 ? "loss" : ""; + const underloadTone = Number(data.underloadRub) > 0 ? "risk" : ""; + const netT = netTone(data); + + el.innerHTML = + `
    ` + + `
    ` + + `
    ${escapeHtml(c.overloadTitle)}
    ` + + `
    ${escapeHtml(COPY().formatRub(data.overloadRub))}
    ` + + `
    ${escapeHtml(overloadHint(data))}
    ` + + `
    ` + + `
    ` + + `
    ${escapeHtml(c.underloadTitle)}
    ` + + `
    ${escapeHtml(COPY().formatRub(data.underloadRub))}
    ` + + `
    ${escapeHtml(underloadHint(data))}
    ` + + `
    ` + + `
    ` + + `
    ${escapeHtml(c.netTitle)}
    ` + + `
    ${escapeHtml(COPY().formatRub(Math.abs(data.netRub)))}
    ` + + `
    ${escapeHtml(netHint(data))}
    ` + + `
    ` + + `
    ` + + `
    ` + + `

    ${escapeHtml(c.topTitle)}

    ` + + renderTopBar(data) + + `
    ` + + ``; + el.querySelector("[data-analytics-goto-alerts]")?.addEventListener("click", () => { + global.WespFeedAlerts?.openJournalAlerts?.(); + }); + global.WespZootechModals?.revealContent(el); + } + + async function load() { + const el = document.getElementById("analyticsSummaryList"); + if (!el) return; + el.innerHTML = + '
    ' + + '

    Загрузка итогов…

    '; + try { + const resp = await fetch(buildUrl()); + if (!resp.ok) throw new Error("Не удалось загрузить итоги"); + render(await resp.json()); + } catch (err) { + el.innerHTML = `

    ${escapeHtml(err.message)}

    `; + } + } + + function onFiltersApplied() { + if (global.WespFeedAlerts?.getCurrentView?.() === "summary") load(); + } + + function init() { + /* экспорт — WespReportsExport */ + } + + global.WespAnalyticsSummary = { init, load, onFiltersApplied }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/apps/web/public/wesp/js/pages/daily-plan-component-norms-modal.js b/apps/web/public/wesp/js/pages/daily-plan-component-norms-modal.js new file mode 100644 index 0000000..f81dc23 --- /dev/null +++ b/apps/web/public/wesp/js/pages/daily-plan-component-norms-modal.js @@ -0,0 +1,665 @@ +/** + * Коррекция СВ% компонентов в плане — overlay в стиле K-hub. + * Редактируется только СВ%; превью через /api/recipes/calculate. + */ +(function (global) { + function todayIso() { + return global.WespDailyPlanSkipDuration?.todayIso?.() || new Date().toISOString().slice(0, 10); + } + + function escapeHtml(value) { + return ( + global.WespDailyPlanSkipDuration?.escapeHtml?.(value) || + String(value ?? "").replace(/&/g, "&").replace(/ 0 && n < 0.01) return 0.01; + return Math.round(n * 100) / 100; + } + + function fmtRange(values, digits) { + const nums = values.filter((n) => Number.isFinite(n)); + if (!nums.length) return "—"; + const min = Math.min(...nums); + const max = Math.max(...nums); + const a = fmtNum(min, digits); + const b = fmtNum(max, digits); + return a === b ? a : `${a}–${b}`; + } + + function usageEntries(item) { + const usages = Array.isArray(item.usages) ? item.usages : []; + if (usages.length) { + return usages.map((u) => ({ + wph: Number(u.masterWeightPerHead), + dmPh: Number(u.masterDryMatterPerHead), + dryMatterLocked: !!u.dryMatterLocked, + })); + } + return [ + { + wph: Number(item.masterWeightPerHead), + dmPh: Number(item.masterDryMatterPerHead), + dryMatterLocked: false, + }, + ]; + } + + const PHYS_WEIGHT_HINT = "Этот компонент выдается строго по физическому весу"; + + function itemIsDmEditable(item) { + return itemHasLockedUsage(item); + } + + function componentCellHtml(item) { + const editable = itemIsDmEditable(item); + const lockedCount = lockedUsageEntries(item).length; + const trips = Number(item.usageCount); + let meta = ""; + if (editable && lockedCount > 0) { + meta = `
    ${lockedCount} рец. с замком СВ
    `; + } else if (!editable) { + meta = `
    ${escapeHtml(PHYS_WEIGHT_HINT)}
    `; + } else if (Number.isFinite(trips) && trips > 1) { + meta = `
    ${trips} рейсов в плане
    `; + } + const lockIcon = editable + ? "" + : ``; + return ( + `
    ` + + `
    ` + + lockIcon + + `
    ${escapeHtml(item.componentName)}
    ` + + `
    ` + + meta + + "
    " + ); + } + + function flashWeightLockedHint(input) { + const cell = input?.closest(".zt-k-hub-plan-norms__dm-cell"); + if (!cell) return; + let hint = cell.querySelector("[data-norms-weight-hint]"); + if (!hint) { + hint = document.createElement("span"); + hint.className = "zt-k-hub-plan-norms__weight-hint"; + hint.dataset.normsWeightHint = "1"; + hint.textContent = PHYS_WEIGHT_HINT; + cell.appendChild(hint); + } + hint.classList.add("is-visible"); + clearTimeout(hint._hideTimer); + hint._hideTimer = setTimeout(() => hint.classList.remove("is-visible"), 2800); + } + + function notify(msg, type) { + const n = global.WespZootechNotify?.createAdapter?.(); + if (!n) return; + if (type === "error") n.error(msg, { skipRecord: true }); + else n.success(msg, { skipRecord: true }); + } + + function overlayHost() { + return document.getElementById("zootechNotificationCenterModal") || document.body; + } + + let overlayEl = null; + let items = []; + const rowPreviewTimers = new Map(); + let recalcSeq = 0; + let lockedOnlyFilter = true; + + function itemHasLockedUsage(item) { + return usageEntries(item).some((entry) => entry.dryMatterLocked); + } + + function lockedUsageEntries(item) { + return usageEntries(item).filter((entry) => entry.dryMatterLocked); + } + + function clearRowPreviewTimers() { + rowPreviewTimers.forEach((timer) => clearTimeout(timer)); + rowPreviewTimers.clear(); + } + + function closeModal() { + clearRowPreviewTimers(); + recalcSeq += 1; + if (overlayEl?.isConnected) overlayEl.remove(); + overlayEl = null; + } + + function actionBtn(action, attrs, title, icon) { + return ( + `` + ); + } + + function masterPreviewForItem(item) { + const wphs = []; + const dmPhs = []; + for (const entry of usageEntries(item)) { + if (Number.isFinite(entry.wph)) wphs.push(roundWphNum(entry.wph)); + if (Number.isFinite(entry.dmPh)) dmPhs.push(entry.dmPh); + } + return { + dmPh: fmtRange(dmPhs, 4), + wph: fmtRange(wphs, 2), + }; + } + + function previewCompareHtml(before, after) { + const was = before || "—"; + const now = after || "—"; + if (was === "—" && now === "—") return "—"; + if (was === now) { + return `${escapeHtml(now)}`; + } + return ( + `` + + `${escapeHtml(was)}` + + `${escapeHtml(now)}` + + `` + ); + } + + async function previewForItem(item, planPct) { + const pct = Number(planPct); + if (!Number.isFinite(pct) || pct < 0) return { dmPh: "—", wph: "—" }; + + const lockedEntries = lockedUsageEntries(item); + const wphs = []; + const dmPhs = []; + + await Promise.all( + lockedEntries.map(async (entry) => { + if (!Number.isFinite(entry.dmPh)) return; + try { + const response = await fetch("/api/recipes/calculate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ingredients: [ + { + component_id: item.componentId, + dryMatter: pct, + dryMatterPerHead: entry.dmPh, + }, + ], + headsCount: 1, + tripPercent: 100, + calculateFromDryMatter: true, + }), + }); + if (!response.ok) return; + const result = await response.json(); + const calc = result.ingredients?.[0]; + if (!calc) return; + dmPhs.push(Number(entry.dmPh)); + const rawWph = calc.weightPerHead !== undefined ? calc.weightPerHead : calc.weight_per_head; + if (rawWph != null) wphs.push(roundWphNum(rawWph)); + } catch (_) { + /* ignore */ + } + }) + ); + + for (const entry of usageEntries(item)) { + if (entry.dryMatterLocked) continue; + if (!Number.isFinite(entry.wph)) continue; + wphs.push(roundWphNum(entry.wph)); + if (pct >= 0) dmPhs.push(entry.wph * (pct / 100)); + } + + return { + dmPh: fmtRange(dmPhs, 4), + wph: fmtRange(wphs, 2), + }; + } + + function planEditValue(item) { + if (item.planDryMatterPct != null) return item.planDryMatterPct; + return item.masterDryMatterPct ?? item.dryMatterPct; + } + + function masterBaseDryMatterLabel(item) { + const catalog = Number(item.dryMatterPct); + if (Number.isFinite(catalog)) { + return `${fmtNum(catalog, 2)}%`; + } + const min = Number(item.masterDryMatterPctMin); + const max = Number(item.masterDryMatterPctMax); + if (Number.isFinite(min) && Number.isFinite(max)) { + const a = fmtNum(min, 2); + const b = fmtNum(max, 2); + return a === b ? `${a}%` : `${a}–${b}%`; + } + const master = Number(item.masterDryMatterPct); + if (Number.isFinite(master)) return `${fmtNum(master, 2)}%`; + return null; + } + + function baseDryMatterHintHtml(item) { + const label = masterBaseDryMatterLabel(item); + if (!label) return ""; + return ( + `` + + `База: ${escapeHtml(label)}` + ); + } + + function setRowPreviewLoading(row) { + const dmPhEl = row.querySelector("[data-norms-preview-dmph]"); + const wphEl = row.querySelector("[data-norms-preview-wph]"); + if (dmPhEl) { + dmPhEl.textContent = "…"; + dmPhEl.classList.remove("zt-k-hub-plan-norms-preview-cell--compare"); + } + if (wphEl) { + wphEl.textContent = "…"; + wphEl.classList.remove("zt-k-hub-plan-norms-preview-cell--compare"); + } + } + + function setPreviewCell(el, before, after) { + if (!el) return; + const was = before || "—"; + const now = after || "—"; + if (was === now) { + el.classList.remove("zt-k-hub-plan-norms-preview-cell--compare"); + el.innerHTML = `${escapeHtml(now)}`; + return; + } + el.classList.add("zt-k-hub-plan-norms-preview-cell--compare"); + el.innerHTML = previewCompareHtml(was, now); + } + + async function updateRowPreview(row, seq) { + const componentId = row.dataset.componentId; + const item = items.find((x) => String(x.componentId) === String(componentId)); + if (!item) return; + const input = row.querySelector("[data-norms-input]"); + const dmPhEl = row.querySelector("[data-norms-preview-dmph]"); + const wphEl = row.querySelector("[data-norms-preview-wph]"); + const masterPrev = masterPreviewForItem(item); + if (row.dataset.normsEditable !== "1") { + if (dmPhEl) { + dmPhEl.classList.remove("zt-k-hub-plan-norms-preview-cell--compare"); + dmPhEl.innerHTML = `${escapeHtml(masterPrev.dmPh)}`; + } + if (wphEl) { + wphEl.classList.remove("zt-k-hub-plan-norms-preview-cell--compare"); + wphEl.innerHTML = `${escapeHtml(masterPrev.wph)}`; + } + return; + } + const raw = input?.value; + if (raw === "" || raw == null) { + setPreviewCell(dmPhEl, null, null); + setPreviewCell(wphEl, null, null); + return; + } + setRowPreviewLoading(row); + const next = await previewForItem(item, raw); + if (seq !== recalcSeq || !overlayEl?.contains(row)) return; + setPreviewCell(dmPhEl, masterPrev.dmPh, next.dmPh); + setPreviewCell(wphEl, masterPrev.wph, next.wph); + } + + function scheduleRowPreview(row, debounceMs) { + if (!row) return; + const componentId = row.dataset.componentId; + if (!componentId) return; + const existing = rowPreviewTimers.get(componentId); + if (existing) clearTimeout(existing); + const seq = recalcSeq; + const delay = debounceMs == null ? 300 : debounceMs; + if (delay <= 0) { + rowPreviewTimers.delete(componentId); + updateRowPreview(row, seq).catch((err) => console.error("Ошибка при расчете:", err)); + return; + } + rowPreviewTimers.set( + componentId, + setTimeout(() => { + rowPreviewTimers.delete(componentId); + updateRowPreview(row, seq).catch((err) => console.error("Ошибка при расчете:", err)); + }, delay) + ); + } + + function updateAllPreviews() { + overlayEl?.querySelectorAll("[data-norms-row]").forEach((row) => scheduleRowPreview(row, 0)); + } + + function buildRowHtml(item) { + const editable = itemIsDmEditable(item); + const val = planEditValue(item); + const inputVal = val != null && Number.isFinite(Number(val)) ? Number(val) : ""; + const undo = item.adjustedToday + ? actionBtn("norms-undo", "", "Вернуть как в рецепте", "fa-undo") + : ""; + const replaceBtn = actionBtn( + "norms-replace", + "", + "Заменить компонент во всех рецептах", + "fa-exchange-alt" + ); + const applyBtn = editable ? actionBtn("norms-apply", "", "Сохранить для плана", "fa-check") : ""; + const rowMods = [ + item.adjustedToday ? "ingredient-row--adjusted" : "", + editable ? "" : "ingredient-row--weight-locked", + ] + .filter(Boolean) + .join(" "); + const inputClass = editable + ? "form-control zt-k-hub-plan-norms-input dry-matter-percent-input" + : "form-control zt-k-hub-plan-norms-input zt-k-hub-plan-norms-input--locked dry-matter-percent-input"; + const inputAttrs = editable + ? `data-norms-input aria-label="СВ% на план"` + : `readonly tabindex="-1" data-norms-input data-norms-input-locked aria-label="СВ% — только чтение" aria-disabled="true" title="${escapeHtml(PHYS_WEIGHT_HINT)}"`; + return ( + `
    ` + + `
    ` + + componentCellHtml(item) + + "
    " + + `
    ` + + `
    ` + + `` + + baseDryMatterHintHtml(item) + + `
    ` + + `
    ` + + `
    ` + + `
    ` + + `
    ` + + `
    ` + + `
    ` + + applyBtn + + replaceBtn + + undo + + "
    " + ); + } + + function ensureOverlay() { + closeModal(); + overlayEl = document.createElement("div"); + overlayEl.className = "zt-k-hub-plan-overlay zt-k-hub-plan-norms"; + overlayEl.innerHTML = + '"; + + overlayEl.querySelector("[data-norms-close]")?.addEventListener("click", closeModal); + overlayEl.addEventListener("click", (e) => { + if (e.target === overlayEl) closeModal(); + }); + overlayEl.querySelector("[data-norms-search]")?.addEventListener("input", renderRows); + overlayEl.querySelector("[data-norms-locked-only]")?.addEventListener("change", (event) => { + lockedOnlyFilter = !!event.target.checked; + renderRows(); + }); + overlayEl.querySelector("[data-norms-body]")?.addEventListener("input", (event) => { + const row = event.target.closest("[data-norms-row]"); + if (row && event.target.matches("[data-norms-input]:not([data-norms-input-locked])")) { + scheduleRowPreview(row); + } + }); + overlayEl.querySelector("[data-norms-body]")?.addEventListener("click", onTableClick); + overlayEl.setAttribute("data-daily-plan-overlay", "1"); + const host = overlayHost(); + host.querySelectorAll(".zt-k-hub-plan-overlay").forEach((el) => { + if (el !== overlayEl) el.remove(); + }); + host.appendChild(overlayEl); + return overlayEl; + } + + function filteredItems() { + let list = items; + if (lockedOnlyFilter) { + list = list.filter(itemHasLockedUsage); + } + const q = (overlayEl?.querySelector("[data-norms-search]")?.value || "").trim().toLowerCase(); + if (!q) return list; + return list.filter((item) => { + if ((item.componentName || "").toLowerCase().includes(q)) return true; + return (item.recipes || []).some((name) => String(name).toLowerCase().includes(q)); + }); + } + + function renderRows() { + const body = overlayEl?.querySelector("[data-norms-body]"); + if (!body) return; + const rows = filteredItems(); + if (!rows.length) { + body.innerHTML = + '
    ' + + (lockedOnlyFilter + ? "Нет компонентов в рецептах с замком СВ на эту дату" + : "Нет компонентов в плане на эту дату") + + "
    "; + return; + } + body.innerHTML = rows.map((item) => buildRowHtml(item)).join(""); + updateAllPreviews(); + } + + async function loadItems() { + const dispenserId = global.WespDailyPlanPanel?.getSelectedDispenserId?.() || ""; + if (!dispenserId) { + notify("Сначала выберите кормораздатчик в плане", "error"); + return false; + } + const date = global.WespDailyPlanPanel?.getSelectedPlanDate?.() || todayIso(); + const r = await fetch( + "/api/daily-plan/component-norms?dispenser_id=" + + encodeURIComponent(dispenserId) + + "&date=" + + encodeURIComponent(date), + { credentials: "same-origin" } + ); + if (!r.ok) { + notify("Не удалось загрузить компоненты плана", "error"); + return false; + } + const data = await r.json(); + items = Array.isArray(data.items) ? data.items : []; + return true; + } + + async function applyRow(row) { + if (row.dataset.normsEditable !== "1") { + flashWeightLockedHint(row.querySelector("[data-norms-input-locked]")); + return; + } + const componentId = row.dataset.componentId; + const input = row.querySelector("[data-norms-input]"); + const raw = input?.value; + if (raw === "" || raw == null) { + notify("Введите СВ%", "error"); + return; + } + const value = Number(raw); + if (!Number.isFinite(value) || value < 0 || value > 100) { + notify("СВ% должно быть от 0 до 100", "error"); + return; + } + const skip = global.WespDailyPlanSkipDuration; + if (!skip) return; + const planDate = global.WespDailyPlanPanel?.getSelectedPlanDate?.() || todayIso(); + const payload = await skip.skipDurationBody( + planDate, + { componentId, dryMatter: value }, + "На какой срок сохранить коррекцию?", + { + overlayHost: overlayHost(), + keepOverlays: [".zt-k-hub-plan-norms"], + onError: (msg) => notify(msg, "error"), + } + ); + if (!payload) return; + const r = await fetch("/api/daily-plan/adjustments/components", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = await r.json().catch(() => ({})); + if (!r.ok) { + notify(data.message || "Ошибка сохранения", "error"); + return; + } + notify("Коррекция сохранена"); + await refreshAfterChange(); + } + + async function undoRow(row) { + const componentId = row.dataset.componentId; + const planDate = global.WespDailyPlanPanel?.getSelectedPlanDate?.() || todayIso(); + const r = await fetch( + "/api/daily-plan/adjustments/components?component_id=" + + encodeURIComponent(componentId) + + "&date=" + + encodeURIComponent(planDate), + { method: "DELETE", credentials: "same-origin" } + ); + if (!r.ok) { + const data = await r.json().catch(() => ({})); + notify(data.message || "Не удалось сбросить", "error"); + return; + } + notify("СВ% как в рецепте"); + await refreshAfterChange(); + } + + async function replaceRow(row) { + const componentId = row.dataset.componentId; + const item = items.find((x) => String(x.componentId) === String(componentId)); + const label = item?.componentName || "Компонент"; + const replaceModal = global.WespDailyPlanReplaceModal; + const skip = global.WespDailyPlanSkipDuration; + if (!replaceModal || !skip) return; + const planDate = global.WespDailyPlanPanel?.getSelectedPlanDate?.() || todayIso(); + const dispenserId = global.WespDailyPlanPanel?.getSelectedDispenserId?.() || ""; + if (!dispenserId) { + notify("Сначала выберите кормораздатчик в плане", "error"); + return; + } + const durationHost = overlayHost(); + await replaceModal.open({ + componentId, + label, + overlayHost: overlayHost(), + keepOverlays: [".zt-k-hub-plan-norms"], + subtitle: "Замена применится во всех рецептах плана, где используется этот компонент.", + onError: (msg) => notify(msg, "error"), + onPick: async (replacementComponentId, replacementName) => { + const body = await skip.skipDurationBody( + planDate, + { componentId, replacementComponentId, dispenserId }, + "На какой срок заменить?", + { + overlayHost: durationHost, + keepOverlays: [".zt-k-hub-plan-norms"], + onError: (msg) => notify(msg, "error"), + } + ); + if (!body) return; + const resp = await fetch("/api/daily-plan/replacements/components", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(body), + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) { + throw new Error(data.message || "Не удалось заменить компонент"); + } + const count = Number(data.count) || 0; + notify( + count > 1 + ? `Компонент заменён на «${replacementName}» в ${count} рецептах` + : `Компонент заменён на «${replacementName}»` + ); + await refreshAfterChange(); + }, + }); + } + + async function refreshAfterChange() { + await loadItems(); + renderRows(); + global.WespDailyPlanPanel?.reload?.(); + if (typeof global.reloadAfterSkipRestore === "function") global.reloadAfterSkipRestore(); + } + + function onTableClick(event) { + const lockedInput = event.target.closest("[data-norms-input-locked]"); + if (lockedInput) { + event.preventDefault(); + flashWeightLockedHint(lockedInput); + return; + } + const row = event.target.closest("[data-norms-row]"); + if (!row) return; + if (event.target.closest("[data-norms-apply]")) { + event.preventDefault(); + applyRow(row); + } else if (event.target.closest("[data-norms-replace]")) { + event.preventDefault(); + replaceRow(row); + } else if (event.target.closest("[data-norms-undo]")) { + event.preventDefault(); + undoRow(row); + } + } + + async function openModal() { + lockedOnlyFilter = true; + ensureOverlay(); + const ok = await loadItems(); + if (ok) renderRows(); + } + + global.openDailyPlanComponentNormsModal = openModal; + global.closeDailyPlanComponentNormsModal = closeModal; +})(window); diff --git a/apps/web/public/wesp/js/pages/daily-plan-panel.js b/apps/web/public/wesp/js/pages/daily-plan-panel.js new file mode 100644 index 0000000..bbb51bc --- /dev/null +++ b/apps/web/public/wesp/js/pages/daily-plan-panel.js @@ -0,0 +1,930 @@ +/** + * Панель «План на день» внутри hub-модалки «К». + */ +(function (global) { + const DAILY_PLAN_DISPENSER_KEY = "wesp-daily-plan-dispenser"; + const ALL_DISPENSERS_ID = "__all_dispensers__"; + const ALL_MILLS_ID = "__all_mills__"; + + function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function formatDistribution(group) { + if (group?.distributionLabel) return group.distributionLabel; + const type = group?.distributionType || "percent"; + const value = group?.value ?? ""; + if (type === "heads") return `${value} гол.`; + return `${value}%`; + } + + function todayIso() { + const d = new Date(); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; + } + + function createDailyPlanPanel() { + let rootEl = null; + let dateEl = null; + let dispenserEl = null; + let contentEl = null; + let dispensers = []; + let undoToastTimer = null; + + function getSelectedDispenserId() { + return dispenserEl?.value || ""; + } + + function getSelectedPlanDate() { + return planDateValue(); + } + + function notifySuccess(message) { + try { + global.WespZootechNotify?.createAdapter?.()?.success?.(message, { skipRecord: true }); + } catch { + /* ignore */ + } + } + + function notifyError(message, fallback) { + try { + global.WespZootechNotify?.createAdapter?.()?.error?.(message, { + fallback: fallback || message, + skipRecord: true, + }); + } catch { + /* ignore */ + } + } + + function renderToolbar() { + return ( + '
    ' + + '
    ' + + '' + + '' + + "
    " + + '
    ' + + '' + + '' + + "
    " + + '' + + "
    " + ); + } + + function mount(container) { + rootEl = container; + rootEl.innerHTML = + '
    ' + + renderToolbar() + + '
    ' + + '
    Загрузка плана…
    ' + + "
    "; + dateEl = rootEl.querySelector("[data-daily-plan-date]"); + dispenserEl = rootEl.querySelector("[data-daily-plan-dispenser]"); + contentEl = rootEl.querySelector("[data-daily-plan-content]"); + if (dateEl) dateEl.value = todayIso(); + dateEl?.addEventListener("change", () => loadPlan()); + dispenserEl?.addEventListener("change", () => { + if (dispenserEl.value) { + try { + localStorage.setItem(DAILY_PLAN_DISPENSER_KEY, dispenserEl.value); + } catch { + /* ignore */ + } + } + loadPlan(); + }); + rootEl.querySelector("[data-action='daily-plan-pdf']")?.addEventListener("click", downloadPdf); + rootEl.addEventListener("click", onPanelClick); + loadDispensers(); + } + + function planDateValue() { + return dateEl?.value || todayIso(); + } + + async function onPanelClick(event) { + const btn = event.target.closest("[data-action]"); + if (!btn || !rootEl?.contains(btn)) return; + const action = btn.dataset.action; + if (action === "daily-plan-skip-trip") { + event.preventDefault(); + const recipeId = btn.dataset.recipeId; + const recipeName = btn.dataset.recipeName || "Рейс"; + if (recipeId) await skipTrip(recipeId, recipeName); + return; + } + if (action === "daily-plan-unskip-trip") { + event.preventDefault(); + const recipeId = btn.dataset.recipeId; + if (recipeId) await unskipTrip(recipeId); + return; + } + if (action === "daily-plan-skip-ingredient") { + event.preventDefault(); + const recipeId = btn.dataset.recipeId; + const ingredientId = btn.dataset.ingredientId; + const label = btn.dataset.label || "Компонент"; + if (recipeId && ingredientId) await skipIngredient(recipeId, ingredientId, label); + return; + } + if (action === "daily-plan-unskip-ingredient") { + event.preventDefault(); + const recipeId = btn.dataset.recipeId; + const ingredientId = btn.dataset.ingredientId; + if (recipeId && ingredientId) await unskipIngredient(recipeId, ingredientId); + return; + } + if (action === "daily-plan-skip-unloading-group") { + event.preventDefault(); + const recipeId = btn.dataset.recipeId; + const groupId = btn.dataset.groupId; + const label = btn.dataset.label || "Группа"; + if (recipeId && groupId) await skipUnloadingGroup(recipeId, groupId, label); + return; + } + if (action === "daily-plan-unskip-unloading-group") { + event.preventDefault(); + const recipeId = btn.dataset.recipeId; + const groupId = btn.dataset.groupId; + if (recipeId && groupId) await unskipUnloadingGroup(recipeId, groupId); + return; + } + if (action === "daily-plan-replace-ingredient") { + event.preventDefault(); + const recipeId = btn.dataset.recipeId; + const ingredientId = btn.dataset.ingredientId; + const componentId = btn.dataset.componentId; + const label = btn.dataset.label || "Компонент"; + if (recipeId && ingredientId && componentId) { + await openReplaceIngredientModal(recipeId, ingredientId, componentId, label); + } + return; + } + if (action === "daily-plan-undo-replace-ingredient") { + event.preventDefault(); + const recipeId = btn.dataset.recipeId; + const ingredientId = btn.dataset.ingredientId; + if (recipeId && ingredientId) await undoReplaceIngredient(recipeId, ingredientId); + return; + } + if (action === "daily-plan-undo-adjust-norm") { + event.preventDefault(); + const componentId = btn.dataset.componentId; + if (componentId) await undoComponentNormAdjustment(componentId); + } + } + + function formatKg(value) { + const n = Number(value); + if (!Number.isFinite(n)) return "—"; + if (Number.isInteger(n)) return String(n); + return n.toFixed(2).replace(/\.?0+$/, ""); + } + + function replacementRowTitle(ing) { + if (!ing?.replacedToday) return ""; + if (ing.recalculationMode === "dry_matter") { + return ( + `Пересчёт по СВ: ${formatKg(ing.originalDryMatterPerHead)} кг/гол сохранено, ` + + `вес ${formatKg(ing.originalWeightPerHead)} → ${formatKg(ing.weightPerHead)} кг/гол ` + + `(СВ ${formatKg(ing.originalDryMatterPct)}% → ${formatKg(ing.dryMatterPct)}%)` + ); + } + return ( + `Пересчёт по весу: ${formatKg(ing.weightPerHead)} кг/гол сохранено, ` + + `СВ/гол ${formatKg(ing.originalDryMatterPerHead)} → ${formatKg(ing.dryMatterPerHead)} кг/гол` + ); + } + + function adjustmentRowTitle(ing) { + if (!ing?.adjustedToday) return ""; + return ( + `Норма в плане: ${formatKg(ing.originalWeightPerHead)} → ${formatKg(ing.weightPerHead)} кг/гол, ` + + `СВ/гол ${formatKg(ing.originalDryMatterPerHead)} → ${formatKg(ing.dryMatterPerHead)}` + ); + } + + function skippedIngredientHint(ing) { + const wph = Number(ing?.baselineWeightPerHead ?? ing?.weightPerHead); + const total = Number(ing?.baselineTotalKg); + if (!Number.isFinite(wph) || !Number.isFinite(total)) return ""; + return `Было: ${formatKg(wph)} кг/гол (${formatKg(total)} кг)`; + } + + function renderSkippedValueCell(hint) { + if (!hint) { + return '—'; + } + return ( + `—` + ); + } + + function renderPlanDot(title) { + return ( + `` + ); + } + + function renderSkipDot() { + return renderPlanDot("Исключён из плана на этот день"); + } + + function renderReplaceDot(title) { + return renderPlanDot(title || "Сегодня заменили компонент в плане"); + } + + function renderAdjustDot(title = "Сегодня изменили норму в плане") { + return ``; + } + + function renderRowActionBtn(action, attrs, title, iconClass) { + return ( + `` + ); + } + + function renderSkipActionBtn(action, attrs, title) { + return renderRowActionBtn(action, attrs, title, "fa-times"); + } + + function hubModalEl() { + return ( + rootEl?.closest(".modal.wesp-shell-modal") || + document.getElementById("zootechNotificationCenterModal") + ); + } + + function planOverlayHost() { + return hubModalEl() || rootEl?.closest(".modal-content") || rootEl; + } + + function dismissAllPlanOverlays() { + hubModalEl() + ?.querySelectorAll(".zt-k-hub-plan-overlay") + .forEach((el) => el.remove()); + } + + function appendPlanOverlay(overlay) { + dismissAllPlanOverlays(); + overlay.setAttribute("data-daily-plan-overlay", "1"); + planOverlayHost()?.appendChild(overlay); + return overlay; + } + + function promptSkipDuration(planDate, title = "На какой срок исключить?") { + const skip = global.WespDailyPlanSkipDuration; + if (!skip) return Promise.resolve(null); + return skip.promptSkipDuration({ + planDate, + title, + overlayHost: planOverlayHost() || document.body, + beforeAppend: dismissAllPlanOverlays, + onError: notifyError, + }); + } + + function skipDurationBody(planDate, extra = {}, title) { + const skip = global.WespDailyPlanSkipDuration; + if (!skip) return Promise.resolve(null); + return skip.skipDurationBody(planDate, extra, title); + } + + async function openReplaceIngredientModal(recipeId, ingredientId, componentId, label) { + const planDate = planDateValue(); + const replaceModal = global.WespDailyPlanReplaceModal; + if (!replaceModal) return; + await replaceModal.open({ + componentId, + label, + overlayHost: planOverlayHost() || document.body, + onError: notifyError, + onPick: async (replacementComponentId, replacementName) => { + const body = await skipDurationBody( + planDate, + { recipeId, ingredientId, replacementComponentId }, + "На какой срок заменить?" + ); + if (!body) return; + const resp = await fetch("/api/daily-plan/replacements/ingredients", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(body), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.message || "Не удалось заменить компонент"); + } + await loadPlan(); + notifySuccess(`Компонент заменён на «${replacementName}»`); + }, + }); + } + + async function undoReplaceIngredient(recipeId, ingredientId) { + const planDate = planDateValue(); + try { + const params = new URLSearchParams({ + recipe_id: recipeId, + ingredient_id: ingredientId, + date: planDate, + }); + const resp = await fetch(`/api/daily-plan/replacements/ingredients?${params.toString()}`, { + method: "DELETE", + headers: { Accept: "application/json" }, + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.message || "Не удалось отменить замену"); + } + await loadPlan(); + notifySuccess("Замена отменена"); + } catch (err) { + notifyError(err.message, "Не удалось отменить замену"); + } + } + + async function undoComponentNormAdjustment(componentId) { + const planDate = planDateValue(); + try { + const params = new URLSearchParams({ + component_id: componentId, + date: planDate, + }); + const resp = await fetch(`/api/daily-plan/adjustments/components?${params.toString()}`, { + method: "DELETE", + headers: { Accept: "application/json" }, + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.message || "Не удалось вернуть норму"); + } + await loadPlan(); + notifySuccess("Норма возвращена к мастеру"); + if (typeof global.reloadAfterSkipRestore === "function") { + global.reloadAfterSkipRestore(); + } + } catch (err) { + notifyError(err.message, "Не удалось вернуть норму"); + } + } + + async function skipTrip(recipeId, recipeName) { + const planDate = planDateValue(); + const body = await skipDurationBody(planDate, { recipeId }); + if (!body) return; + try { + const resp = await fetch("/api/daily-plan/skips", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(body), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.message || "Не удалось исключить рейс"); + } + await loadPlan(); + showUndoToast( + "daily-plan-unskip-trip", + `data-recipe-id="${escapeHtml(recipeId)}"`, + `Рейс «${escapeHtml(recipeName)}» убран из плана.` + ); + } catch (err) { + notifyError(err.message, "Не удалось исключить рейс"); + } + } + + async function unskipTrip(recipeId) { + const planDate = planDateValue(); + try { + const params = new URLSearchParams({ recipe_id: recipeId, date: planDate }); + const resp = await fetch(`/api/daily-plan/skips?${params.toString()}`, { + method: "DELETE", + headers: { Accept: "application/json" }, + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.message || "Не удалось вернуть рейс"); + } + dismissUndoToast(); + await loadPlan(); + notifySuccess("Рейс снова в плане"); + } catch (err) { + notifyError(err.message, "Не удалось вернуть рейс"); + } + } + + async function skipIngredient(recipeId, ingredientId, label) { + const planDate = planDateValue(); + const body = await skipDurationBody(planDate, { recipeId, ingredientId }); + if (!body) return; + try { + const resp = await fetch("/api/daily-plan/skips/ingredients", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(body), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.message || "Не удалось убрать компонент"); + } + await loadPlan(); + showUndoToast( + "daily-plan-unskip-ingredient", + `data-recipe-id="${escapeHtml(recipeId)}" data-ingredient-id="${escapeHtml(ingredientId)}"`, + `Компонент «${escapeHtml(label)}» убран из плана.` + ); + } catch (err) { + notifyError(err.message, "Не удалось убрать компонент"); + } + } + + async function unskipIngredient(recipeId, ingredientId) { + const planDate = planDateValue(); + try { + const params = new URLSearchParams({ + recipe_id: recipeId, + ingredient_id: ingredientId, + date: planDate, + }); + const resp = await fetch(`/api/daily-plan/skips/ingredients?${params.toString()}`, { + method: "DELETE", + headers: { Accept: "application/json" }, + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.message || "Не удалось вернуть компонент"); + } + dismissUndoToast(); + await loadPlan(); + notifySuccess("Компонент снова в плане"); + } catch (err) { + notifyError(err.message, "Не удалось вернуть компонент"); + } + } + + async function skipUnloadingGroup(recipeId, groupId, label) { + const planDate = planDateValue(); + const body = await skipDurationBody(planDate, { recipeId, unloadingGroupId: groupId }); + if (!body) return; + try { + const resp = await fetch("/api/daily-plan/skips/unloading-groups", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(body), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.message || "Не удалось убрать группу"); + } + await loadPlan(); + showUndoToast( + "daily-plan-unskip-unloading-group", + `data-recipe-id="${escapeHtml(recipeId)}" data-group-id="${escapeHtml(groupId)}"`, + `Группа «${escapeHtml(label)}» убрана из плана.` + ); + } catch (err) { + notifyError(err.message, "Не удалось убрать группу"); + } + } + + async function unskipUnloadingGroup(recipeId, groupId) { + const planDate = planDateValue(); + try { + const params = new URLSearchParams({ + recipe_id: recipeId, + unloading_group_id: groupId, + date: planDate, + }); + const resp = await fetch( + `/api/daily-plan/skips/unloading-groups?${params.toString()}`, + { method: "DELETE", headers: { Accept: "application/json" } } + ); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.message || "Не удалось вернуть группу"); + } + dismissUndoToast(); + await loadPlan(); + notifySuccess("Группа снова в плане"); + } catch (err) { + notifyError(err.message, "Не удалось вернуть группу"); + } + } + + function dismissUndoToast() { + if (undoToastTimer) { + clearTimeout(undoToastTimer); + undoToastTimer = null; + } + rootEl?.querySelector("[data-daily-plan-undo]")?.remove(); + } + + function showUndoToast(unskipAction, dataAttrs, message) { + dismissUndoToast(); + const toast = document.createElement("div"); + toast.className = "zt-k-hub-plan__undo-toast"; + toast.dataset.dailyPlanUndo = "1"; + toast.innerHTML = + `${message}` + + ``; + rootEl?.querySelector(".zt-k-hub-plan")?.appendChild(toast); + undoToastTimer = setTimeout(() => dismissUndoToast(), 8000); + } + + async function loadDispensers() { + try { + const resp = await fetch("/api/feed_dispensers?limit=200"); + if (!resp.ok) throw new Error("dispensers"); + const data = await resp.json(); + dispensers = Array.isArray(data) ? data : []; + if (!dispenserEl) return; + if (!dispensers.length) { + dispenserEl.innerHTML = ''; + renderEmpty("Добавьте кормораздатчик в разделе «Кормораздатчики»."); + return; + } + const saved = (() => { + try { + return localStorage.getItem(DAILY_PLAN_DISPENSER_KEY) || ""; + } catch { + return ""; + } + })(); + const dispenserItems = dispensers.filter((d) => d.type !== "mill"); + const millItems = dispensers.filter((d) => d.type === "mill"); + let options = + ``; + if (dispenserItems.length) { + options += ''; + options += dispenserItems + .map((d) => { + const id = escapeHtml(d.id); + const name = escapeHtml(d.name || d.id); + const selected = d.id === saved ? " selected" : ""; + return ``; + }) + .join(""); + options += ""; + } + options += ``; + if (millItems.length) { + options += ''; + options += millItems + .map((d) => { + const id = escapeHtml(d.id); + const name = escapeHtml(d.name || d.id); + const selected = d.id === saved ? " selected" : ""; + return ``; + }) + .join(""); + options += ""; + } + dispenserEl.innerHTML = options; + if (!dispenserEl.value) { + dispenserEl.value = saved || ALL_DISPENSERS_ID; + } + loadPlan(); + } catch { + if (dispenserEl) { + dispenserEl.innerHTML = ''; + } + renderEmpty("Не удалось загрузить список кормораздатчиков."); + } + } + + function buildPlanUrl(pdf) { + const params = new URLSearchParams(); + if (dateEl?.value) params.set("date", dateEl.value); + if (dispenserEl?.value) params.set("dispenser_id", dispenserEl.value); + const base = pdf ? "/api/daily-plan/pdf" : "/api/daily-plan"; + return `${base}?${params.toString()}`; + } + + async function loadPlan() { + if (!contentEl) return; + if (!dispenserEl?.value) { + renderEmpty("Выберите кормораздатчик."); + return; + } + contentEl.innerHTML = + '
    Загрузка плана…
    '; + try { + const resp = await fetch(buildPlanUrl(false)); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.message || "Ошибка загрузки"); + } + const plan = await resp.json(); + renderPlan(plan); + } catch (err) { + renderEmpty(global.WespUserMessages?.safe?.(err.message, "Не удалось загрузить план.") || "Не удалось загрузить план."); + } + } + + function renderEmpty(message) { + if (!contentEl) return; + contentEl.innerHTML = + '
    ' + + '' + + `

    ${escapeHtml(message)}

    `; + } + + function renderReplaceBtn(recipeId, ing) { + if (!ing.componentId) return ""; + const label = ing.originalName || ing.name || "Компонент"; + return renderRowActionBtn( + "daily-plan-replace-ingredient", + `data-recipe-id="${escapeHtml(recipeId)}" data-ingredient-id="${escapeHtml(ing.id)}" ` + + `data-component-id="${escapeHtml(ing.componentId)}" data-label="${escapeHtml(label)}"`, + "Заменить компонент", + "fa-exchange-alt" + ); + } + + function renderTotalsColumn(totals, grandTotalKg) { + if (!totals.length) { + return ( + '' + ); + } + let rows = ""; + totals.forEach((row) => { + rows += `${escapeHtml(row.name)}${escapeHtml(formatKg(row.totalKg))}`; + }); + const grand = + grandTotalKg ?? + totals.reduce((sum, row) => sum + Number(row.totalKg || 0), 0); + return ( + '" + ); + } + + function renderSkippedSection(plan) { + const skippedTrips = plan.skippedTrips || []; + if (!skippedTrips.length) return ""; + let rows = ""; + skippedTrips.forEach((trip) => { + rows += + '
  • ' + + `${renderSkipDot()}Рейс: ${escapeHtml(trip.recipeName)}` + + '
  • '; + }); + return ( + '
    ' + + '

    Исключено из плана

    ' + + `
      ${rows}
    ` + ); + } + + function renderTripsColumn(periods) { + let html = '
    '; + periods.forEach((period) => { + const trips = period.trips || []; + if (!trips.length) return; + html += + `

    ${escapeHtml(period.name)}

    `; + trips.forEach((trip) => { + const recipeId = escapeHtml(trip.recipeId); + const hasAdjustedNorm = (trip.ingredients || []).some((ing) => ing.adjustedToday); + const hasReplacedIngredient = (trip.ingredients || []).some((ing) => ing.replacedToday); + const adjustBadge = hasAdjustedNorm + ? ' ' + + 'сегодня изменили норму' + : ""; + const replaceBadge = hasReplacedIngredient + ? ' ' + + 'заменён компонент' + : ""; + const ings = (trip.ingredients || []) + .map((ing) => { + const ingId = escapeHtml(ing.id); + const name = escapeHtml(ing.name); + const skipped = Boolean(ing.skippedToday); + const replaced = Boolean(ing.replacedToday); + const adjusted = Boolean(ing.adjustedToday); + const rowClass = skipped + ? "zt-k-hub-plan-row--skipped" + : replaced + ? "zt-k-hub-plan-row--replaced" + : adjusted + ? "zt-k-hub-plan-row--adjusted" + : ""; + const replaceTitle = replacementRowTitle(ing); + const adjustTitle = adjustmentRowTitle(ing); + const skipHint = skipped ? skippedIngredientHint(ing) : ""; + const nameCell = skipped + ? `${renderSkipDot()}${name}` + : replaced + ? `${renderReplaceDot(replaceTitle)}${name}` + : adjusted + ? `${renderAdjustDot(adjustTitle)}${name}` + : `${name}`; + const weightCell = skipped + ? renderSkippedValueCell(skipHint) + : `${escapeHtml(formatKg(ing.weightPerHead))}`; + const totalCell = skipped + ? renderSkippedValueCell(skipHint) + : `${escapeHtml(formatKg(ing.totalKg))}`; + let actionCell = ""; + if (ing.id) { + if (skipped) { + actionCell = renderRowActionBtn( + "daily-plan-unskip-ingredient", + `data-recipe-id="${recipeId}" data-ingredient-id="${ingId}"`, + "Вернуть в план", + "fa-undo" + ); + } else { + if (replaced) { + actionCell += renderRowActionBtn( + "daily-plan-undo-replace-ingredient", + `data-recipe-id="${recipeId}" data-ingredient-id="${ingId}"`, + "Отменить замену", + "fa-undo" + ); + } else if (adjusted && ing.componentId) { + actionCell += renderRowActionBtn( + "daily-plan-undo-adjust-norm", + `data-component-id="${escapeHtml(ing.componentId)}"`, + "Вернуть норму", + "fa-undo" + ); + } else { + actionCell += renderReplaceBtn(recipeId, ing); + } + actionCell += renderSkipActionBtn( + "daily-plan-skip-ingredient", + `data-recipe-id="${recipeId}" data-ingredient-id="${ingId}" data-label="${escapeHtml(ing.originalName || ing.name)}"`, + "Убрать компонент из плана на этот день" + ); + } + } + return ( + `` + + `${nameCell}${weightCell}${totalCell}` + + `${actionCell}` + ); + }) + .join(""); + const groups = (trip.unloadingGroups || []) + .map((g) => { + const groupId = escapeHtml(g.id); + const name = escapeHtml(g.name); + const skipped = Boolean(g.skippedToday); + const nameCell = skipped + ? `${renderSkipDot()}${name}` + : `${name}`; + const weightCell = skipped + ? '—' + : `${escapeHtml(g.weightKg)}`; + const distCell = skipped + ? '—' + : `${escapeHtml(formatDistribution(g))}`; + let actionCell = ""; + if (g.id) { + if (skipped) { + actionCell = renderRowActionBtn( + "daily-plan-unskip-unloading-group", + `data-recipe-id="${recipeId}" data-group-id="${groupId}"`, + "Вернуть в план", + "fa-undo" + ); + } else { + actionCell = renderSkipActionBtn( + "daily-plan-skip-unloading-group", + `data-recipe-id="${recipeId}" data-group-id="${groupId}" data-label="${name}"`, + "Убрать группу из плана на этот день" + ); + } + } + return ( + `` + + `${nameCell}${weightCell}${distCell}` + + `${actionCell}` + ); + }) + .join(""); + html += + '
    ' + + '
    ' + + `

    ${escapeHtml(trip.recipeName)}${replaceBadge}${adjustBadge}` + + ` (${escapeHtml(trip.headsPerTrip)} гол., ${escapeHtml(trip.mixingTimeSec)} с)

    ` + + '
    ' + + '' + + `${ings || ''}` + + `
    Компоненткг/голВсего, кг
    Итого по рейсу${escapeHtml(formatKg(trip.totalWeightKg))}
    `; + if (groups) { + html += + '

    Выгрузка

    ' + + '' + + `${groups}
    ГруппакгРаспределение
    `; + } + html += "
    "; + }); + html += "
    "; + }); + html += "
    "; + return html; + } + + function renderPlan(plan) { + if (!contentEl) return; + const periods = plan.periods || []; + const skippedTrips = plan.skippedTrips || []; + const hasTrips = periods.some((p) => (p.trips || []).length); + const hasSkipped = skippedTrips.length; + if (!hasTrips && !hasSkipped) { + renderEmpty("Нет периодов или рейсов для выбранного кормораздатчика."); + return; + } + + contentEl.innerHTML = + '
    ' + + renderTotalsColumn(plan.ingredientTotals || [], plan.ingredientGrandTotalKg) + + renderTripsColumn(periods) + + "
    " + + renderSkippedSection(plan); + } + + function downloadPdf() { + if (!dispenserEl?.value) return; + global.open(buildPlanUrl(true), "_blank", "noopener"); + } + + function destroy() { + dismissUndoToast(); + dismissAllPlanOverlays(); + if (rootEl) { + rootEl.removeEventListener("click", onPanelClick); + rootEl.innerHTML = ""; + } + rootEl = null; + dateEl = null; + dispenserEl = null; + contentEl = null; + } + + return { + mount, + destroy, + dismissAllPlanOverlays, + reload: loadPlan, + getSelectedDispenserId, + getSelectedPlanDate, + }; + } + + let activePanel = null; + + global.WespDailyPlanPanel = { + createDailyPlanPanel, + _setActivePanel(panel) { + activePanel = panel || null; + }, + getSelectedDispenserId() { + return activePanel?.getSelectedDispenserId?.() || ""; + }, + getSelectedPlanDate() { + return activePanel?.getSelectedPlanDate?.() || todayIso(); + }, + reload() { + return activePanel?.reload?.(); + }, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/apps/web/public/wesp/js/pages/feed-alerts.js b/apps/web/public/wesp/js/pages/feed-alerts.js new file mode 100644 index 0000000..9bf0700 --- /dev/null +++ b/apps/web/public/wesp/js/pages/feed-alerts.js @@ -0,0 +1,377 @@ +/** + * Контроль отклонений — лента событий (не дубль отчётов). + */ +(function (global) { + const EVENT_LABELS = { + OVERLOAD: "Перегруз", + UNDERLOAD: "Недогруз", + LOADING_TIME: "Долгая загрузка", + LOADING_FAST: "Быстрая загрузка", + MIX_TIME: "Смешивание", + LEFT_IN_MIXER: "Остаток в миксере", + }; + + const EVENT_ICONS = { + OVERLOAD: "fa-arrow-trend-up", + UNDERLOAD: "fa-arrow-trend-down", + LOADING_TIME: "fa-hourglass-half", + LOADING_FAST: "fa-forward", + MIX_TIME: "fa-clock", + LEFT_IN_MIXER: "fa-triangle-exclamation", + }; + + let currentView = "summary"; + let journalSubView = "reports"; + let alertsSeverityFilter = ""; + + function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function pluralDeviations(count) { + const n = Math.abs(Number(count) || 0); + const mod10 = n % 10; + const mod100 = n % 100; + if (mod10 === 1 && mod100 !== 11) return `${n} отклонение`; + if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return `${n} отклонения`; + return `${n} отклонений`; + } + + function getDateParams() { + return global.WespReportsFilters?.getDateParams?.() || { + date_from: document.getElementById("date-from")?.value || "", + date_to: document.getElementById("date-to")?.value || "", + }; + } + + function getDispenserId() { + return global.WespReportsFilters?.getDispenserId?.() || ""; + } + + function buildAlertsUrl() { + const params = global.WespReportsFilters?.buildAlertsParams?.() || new URLSearchParams(getDateParams()); + if (alertsSeverityFilter) params.set("severity", alertsSeverityFilter); + return `/api/feed-quality/alerts?${params.toString()}`; + } + + function buildSummaryUrl() { + const params = global.WespReportsFilters?.buildAlertsParams?.() || new URLSearchParams(getDateParams()); + return `/api/feed-quality/alerts/summary?${params.toString()}`; + } + + function deviationClass(devKg, eventType) { + if (eventType === "MIX_TIME" || eventType === "LOADING_TIME" || eventType === "LOADING_FAST") { + return "deviation-significant"; + } + if (devKg == null || Number.isNaN(Number(devKg))) return ""; + const v = Number(devKg); + if (v > 0) return "deviation-positive"; + if (v < 0) return "deviation-negative"; + return ""; + } + + function formatDeltaPct(item) { + if ( + item.eventType === "MIX_TIME" || + item.eventType === "LOADING_TIME" || + item.eventType === "LOADING_FAST" + ) { + if (item.deviationKg == null) return "—"; + const sec = Number(item.deviationKg); + return `${sec > 0 ? "+" : ""}${Math.round(sec)} с`; + } + if (item.deviationPct == null) return "—"; + const pct = Number(item.deviationPct); + return `${pct > 0 ? "+" : ""}${pct.toFixed(1)}%`; + } + + function formatMixingClock(sec) { + const total = Math.max(0, Math.round(Number(sec) || 0)); + const m = Math.floor(total / 60); + const s = total % 60; + return `${m}:${String(s).padStart(2, "0")}`; + } + + function objectLabel(item) { + if (item.componentName) return item.componentName; + if (item.groupName) return `группа «${item.groupName}»`; + if (item.eventType === "MIX_TIME") return "смешивание"; + if (item.eventType === "LEFT_IN_MIXER") return "миксер"; + return "рейс"; + } + + function buildContextLine(item) { + const parts = []; + const target = item.targetKg; + const actual = item.actualKg; + + if (item.eventType === "MIX_TIME") { + if (actual != null && target != null) { + parts.push(`факт ${formatMixingClock(actual)} при плане ${formatMixingClock(target)}`); + } + } else if (item.eventType === "LEFT_IN_MIXER") { + if (actual != null) parts.push(`осталось ${Number(actual).toFixed(1)} кг`); + } else if (item.groupName) { + if (actual != null && target != null) { + parts.push(`выгружено ${Number(actual).toFixed(1)} из ${Number(target).toFixed(1)} кг`); + } else if (actual != null) { + parts.push(`выгружено ${Number(actual).toFixed(1)} кг`); + } + if (item.durationSec != null && Number(item.durationSec) > 0) { + const sec = Number(item.durationSec); + parts.push(sec >= 60 ? `выгрузка ${(sec / 60).toFixed(1)} мин` : `выгрузка ${sec.toFixed(1)} с`); + } + } else if (item.componentName) { + if (actual != null && target != null) { + parts.push(`загружено ${Number(actual).toFixed(1)} из ${Number(target).toFixed(1)} кг`); + } else if (actual != null) { + parts.push(`загружено ${Number(actual).toFixed(1)} кг`); + } + if (item.durationSec != null && Number(item.durationSec) > 0) { + parts.push(`загрузка ${Number(item.durationSec).toFixed(1)} с`); + } + } + + if (item.costDeviationRub != null && item.costDeviationRub > 0) { + parts.push(`+${Number(item.costDeviationRub).toFixed(0)} ₽`); + } + + return parts.length ? parts.join(" · ") : (item.detail || "").split(":").pop()?.trim() || ""; + } + + function sortItems(items) { + return [...items].sort((a, b) => { + const ta = a.createdAt ? new Date(a.createdAt).getTime() : 0; + const tb = b.createdAt ? new Date(b.createdAt).getTime() : 0; + return tb - ta; + }); + } + + async function refreshSummary() { + const chip = document.getElementById("feedAlertsSummaryChip"); + if (!chip) return; + try { + const resp = await fetch(buildSummaryUrl()); + if (!resp.ok) return; + const data = await resp.json(); + const total = Number(data.total) || 0; + if (total <= 0) { + chip.hidden = true; + chip.textContent = ""; + return; + } + chip.hidden = false; + const err = Number(data.error) || 0; + chip.textContent = + err > 0 + ? `${pluralDeviations(total)} (${err} критич.)` + : pluralDeviations(total) + " за период"; + } catch { + chip.hidden = true; + } + } + + function renderAlerts(items) { + const listEl = document.getElementById("alertsList"); + if (!listEl) return; + if (!items || !items.length) { + listEl.innerHTML = + '
    ' + + '
    ' + + '

    Отклонений нет

    ' + + '

    За выбранный период отклонений не зафиксировано

    '; + global.WespZootechModals?.revealContent(listEl.querySelector(".empty-state")); + return; + } + + const sorted = sortItems(items); + const html = sorted + .map((item) => { + const sev = item.severity || "warning"; + const typeLabel = EVENT_LABELS[item.eventType] || "Отклонение"; + const icon = EVENT_ICONS[item.eventType] || "fa-circle-exclamation"; + const devClass = deviationClass(item.deviationKg, item.eventType); + const delta = formatDeltaPct(item); + const context = buildContextLine(item); + const when = item.createdAt + ? new Date(item.createdAt).toLocaleString("ru-RU", { + day: "2-digit", + month: "2-digit", + hour: "2-digit", + minute: "2-digit", + }) + : ""; + const trip = item.recipeName || "Рейс"; + return ( + `" + ); + }) + .join(""); + + listEl.innerHTML = `
    ${html}
    `; + listEl.querySelectorAll("[data-loading-report-id]").forEach((btn) => { + btn.addEventListener("click", () => { + const id = btn.getAttribute("data-loading-report-id"); + if (id) openReportFromAlert(id); + }); + }); + global.WespZootechModals?.revealContent(listEl.querySelector(".deviation-feed")); + } + + async function loadAlerts() { + const listEl = document.getElementById("alertsList"); + if (!listEl) return; + listEl.innerHTML = + '
    ' + + '

    Загрузка отклонений…

    '; + try { + const resp = await fetch(buildAlertsUrl()); + if (!resp.ok) throw new Error("Не удалось загрузить отклонения"); + const data = await resp.json(); + renderAlerts(data.items || []); + await refreshSummary(); + } catch (err) { + listEl.innerHTML = + `

    ${escapeHtml(err.message)}

    `; + } + } + + function openReportFromAlert(loadingReportId) { + switchView("journal"); + setJournalSubView("reports"); + global.__wespHighlightReportId = loadingReportId; + if (typeof global.filterAndDisplayReports === "function") { + global.filterAndDisplayReports(); + } else if (typeof global.loadReports === "function") { + global.loadReports(); + } + } + + function openJournalAlerts() { + switchView("journal"); + setJournalSubView("alerts"); + } + + function setJournalSubView(sub) { + journalSubView = sub === "alerts" ? "alerts" : "reports"; + document.querySelectorAll("[data-journal-view-tab]").forEach((btn) => { + btn.classList.toggle("active", btn.getAttribute("data-journal-view-tab") === journalSubView); + }); + const alertsContainer = document.querySelector(".reports-container--alerts"); + const reportsContainer = document.querySelector(".reports-container--reports"); + const severityBlock = document.getElementById("alertsSeverityBlock"); + if (reportsContainer) reportsContainer.hidden = journalSubView !== "reports"; + if (alertsContainer) alertsContainer.hidden = journalSubView !== "alerts"; + if (severityBlock) severityBlock.hidden = journalSubView !== "alerts"; + if (journalSubView === "alerts") loadAlerts(); + else if (typeof global.loadReports === "function") global.loadReports(); + } + + function updateToolbarVisibility() { + const journalBlock = document.getElementById("journalSubTabsBlock"); + if (journalBlock) journalBlock.hidden = currentView !== "journal"; + const severityBlock = document.getElementById("alertsSeverityBlock"); + if (severityBlock) { + severityBlock.hidden = currentView !== "journal" || journalSubView !== "alerts"; + } + } + + function switchView(view) { + const allowed = ["summary", "comparison", "journal"]; + currentView = allowed.includes(view) ? view : "summary"; + + document.querySelectorAll("[data-reports-view-tab]").forEach((btn) => { + btn.classList.toggle("active", btn.getAttribute("data-reports-view-tab") === currentView); + }); + + const summaryEl = document.querySelector(".reports-container--summary"); + const comparisonEl = document.querySelector(".reports-container--comparison"); + const reportsContainer = document.querySelector(".reports-container--reports"); + const alertsContainer = document.querySelector(".reports-container--alerts"); + + if (summaryEl) summaryEl.hidden = currentView !== "summary"; + if (comparisonEl) comparisonEl.hidden = currentView !== "comparison"; + if (reportsContainer) reportsContainer.hidden = currentView !== "journal" || journalSubView !== "reports"; + if (alertsContainer) alertsContainer.hidden = currentView !== "journal" || journalSubView !== "alerts"; + + updateToolbarVisibility(); + + if (currentView === "summary") { + global.WespAnalyticsSummary?.load?.(); + } else if (currentView === "comparison") { + global.WespAnalyticsPlanFact?.load?.(); + } else if (currentView === "journal") { + setJournalSubView(journalSubView); + } else { + refreshSummary(); + } + } + + function setSeverityFilter(value) { + alertsSeverityFilter = value || ""; + document.querySelectorAll("[data-alerts-severity]").forEach((btn) => { + btn.classList.toggle("active", btn.getAttribute("data-alerts-severity") === alertsSeverityFilter); + }); + if (currentView === "journal" && journalSubView === "alerts") loadAlerts(); + } + + function init() { + document.querySelectorAll("[data-reports-view-tab]").forEach((btn) => { + btn.addEventListener("click", () => { + switchView(btn.getAttribute("data-reports-view-tab")); + }); + }); + document.querySelectorAll("[data-journal-view-tab]").forEach((btn) => { + btn.addEventListener("click", () => { + if (currentView !== "journal") switchView("journal"); + setJournalSubView(btn.getAttribute("data-journal-view-tab")); + }); + }); + document.querySelectorAll("[data-alerts-severity]").forEach((btn) => { + btn.addEventListener("click", () => { + setSeverityFilter(btn.getAttribute("data-alerts-severity") || ""); + }); + }); + global.WespAnalyticsSummary?.init?.(); + switchView("summary"); + refreshSummary(); + } + + function onFiltersApplied() { + refreshSummary(); + global.WespAnalyticsSummary?.onFiltersApplied?.(); + global.WespAnalyticsPlanFact?.onFiltersApplied?.(); + if (currentView === "journal" && journalSubView === "alerts") loadAlerts(); + } + + global.WespFeedAlerts = { + init, + loadAlerts, + refreshSummary, + switchView, + openReportFromAlert, + openJournalAlerts, + onFiltersApplied, + getCurrentView: () => currentView, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/apps/web/public/wesp/js/pages/feed-quality-settings-modal.js b/apps/web/public/wesp/js/pages/feed-quality-settings-modal.js new file mode 100644 index 0000000..e392e8c --- /dev/null +++ b/apps/web/public/wesp/js/pages/feed-quality-settings-modal.js @@ -0,0 +1,255 @@ +/** + * Модалка настроек контроля отклонений (глобальные пороги и уведомления). + */ +const MODAL_ID = "feedQualitySettingsModal"; + +function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function sectionHtml(id, title, fieldsHtml, checksHtml) { + return ( + `
    ` + + '
    ' + + `

    ${escapeHtml(title)}

    ` + + '
    " + + `
    ${fieldsHtml}
    ` + + `
    ${checksHtml}
    ` + ); +} + +function fieldHtml(section, key, label, value, step) { + return ( + '
    ' + + `` + + `` + + "
    " + ); +} + +function notifyChecks(section) { + return ( + '" + + '" + ); +} + +function modalTemplate() { + const loading = sectionHtml( + "loading", + "Загрузка компонентов", + fieldHtml("loading", "warning_pct", "Порог предупреждения, %", 10, 0.1) + + fieldHtml("loading", "critical_pct", "Порог критичного, %", 15, 0.1) + + fieldHtml("loading", "warning_min_sec", "Слишком быстро, предупр., с", 3, 1) + + fieldHtml("loading", "critical_min_sec", "Слишком быстро, критично, с", 1, 1) + + fieldHtml("loading", "warning_max_sec", "Слишком долго, предупр., с", 180, 1) + + fieldHtml("loading", "critical_max_sec", "Слишком долго, критично, с", 600, 1), + notifyChecks("loading") + ); + const unloading = sectionHtml( + "unloading", + "Выгрузка по группам", + fieldHtml("unloading", "warning_pct", "Порог предупреждения, %", 5, 0.1) + + fieldHtml("unloading", "critical_pct", "Порог критичного, %", 10, 0.1), + notifyChecks("unloading") + ); + const mix = sectionHtml( + "mix_time", + "Время смешивания", + fieldHtml("mix_time", "warning_delta_sec", "Предупреждение, с", 30, 1) + + fieldHtml("mix_time", "critical_delta_sec", "Критично, с", 60, 1), + notifyChecks("mix_time") + ); + const mixer = sectionHtml( + "left_in_mixer", + "Остаток в миксере", + fieldHtml("left_in_mixer", "warning_min_kg", "Предупреждение, кг", 5, 0.1) + + fieldHtml("left_in_mixer", "warning_min_pct", "Предупреждение, % от партии", 2, 0.1) + + fieldHtml("left_in_mixer", "critical_min_kg", "Критично, кг", 15, 0.1), + notifyChecks("left_in_mixer") + ); + + return ( + `" + ); +} + +let modalEl = null; +let notyfAdapter = null; +let currentSettings = null; +let modalStackWired = false; + +/** Настройки зоотехника — z-index 3000; shell-модалка Bootstrap по умолчанию ниже. */ +const FQ_MODAL_Z = 3100; +const FQ_BACKDROP_Z = 3099; + +function isSettingsModalOpen() { + const settings = document.getElementById("settingsModal"); + return Boolean(settings && settings.style.display === "block"); +} + +function raiseModalAboveSettings() { + if (!modalEl) return; + modalEl.style.zIndex = String(FQ_MODAL_Z); + const backdrops = document.querySelectorAll(".modal-backdrop"); + const backdrop = backdrops[backdrops.length - 1]; + if (backdrop) backdrop.style.zIndex = String(FQ_BACKDROP_Z); +} + +function restoreBodyAfterClose() { + const anyBootstrapModalOpen = document.querySelector(".modal.show"); + if (!anyBootstrapModalOpen) { + document.querySelectorAll(".modal-backdrop").forEach((el) => el.remove()); + document.body.classList.remove("modal-open"); + document.body.style.removeProperty("padding-right"); + document.body.style.removeProperty("overflow"); + } + if (isSettingsModalOpen()) { + document.body.style.overflow = "hidden"; + } +} + +function wireModalStackHandlers() { + if (!modalEl || modalStackWired) return; + modalStackWired = true; + modalEl.addEventListener("shown.bs.modal", raiseModalAboveSettings); + modalEl.addEventListener("hidden.bs.modal", restoreBodyAfterClose); +} + +function ensureModal() { + if (modalEl && modalEl.isConnected) return modalEl; + modalEl = document.getElementById(MODAL_ID); + if (!modalEl) { + document.body.insertAdjacentHTML("beforeend", modalTemplate()); + modalEl = document.getElementById(MODAL_ID); + } + globalThis.WespZootechModals?.wireAllShellModals?.(); + wireModalStackHandlers(); + modalEl.querySelector("[data-action='save-feed-quality-settings']")?.addEventListener("click", saveSettings); + return modalEl; +} + +function applySettingsToForm(settings) { + const form = document.getElementById("feedQualitySettingsForm"); + if (!form || !settings) return; + form.querySelectorAll("[data-fq-section][data-fq-field]").forEach((el) => { + const section = el.getAttribute("data-fq-section"); + const field = el.getAttribute("data-fq-field"); + const block = settings[section]; + if (!block || field == null) return; + const val = block[field]; + if (el.type === "checkbox") { + el.checked = Boolean(val); + } else if (val != null) { + el.value = String(val); + } + }); +} + +function collectSettingsFromForm() { + const form = document.getElementById("feedQualitySettingsForm"); + const out = { loading: {}, unloading: {}, mix_time: {}, left_in_mixer: {} }; + if (!form) return out; + form.querySelectorAll("[data-fq-section][data-fq-field]").forEach((el) => { + const section = el.getAttribute("data-fq-section"); + const field = el.getAttribute("data-fq-field"); + if (!out[section]) out[section] = {}; + if (el.type === "checkbox") { + out[section][field] = el.checked; + } else { + out[section][field] = el.value; + } + }); + return out; +} + +async function loadSettings() { + const resp = await fetch("/api/feed-quality/settings"); + if (!resp.ok) throw new Error("Не удалось загрузить настройки"); + const data = await resp.json(); + currentSettings = data.settings || data; + applySettingsToForm(currentSettings); +} + +async function openModal() { + ensureModal(); + try { + await loadSettings(); + } catch (err) { + notyfAdapter?.error?.(err.message || "Ошибка загрузки"); + return; + } + const inst = globalThis.bootstrap?.Modal?.getOrCreateInstance(modalEl); + inst?.show(); + requestAnimationFrame(() => { + requestAnimationFrame(raiseModalAboveSettings); + }); +} + +async function saveSettings() { + const payload = collectSettingsFromForm(); + try { + const resp = await fetch("/api/feed-quality/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ settings: payload }), + }); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.message || "Ошибка сохранения"); + notyfAdapter?.success?.( + data.reevaluatedReports != null + ? `Настройки сохранены (переоценено ${data.reevaluatedReports} рейсов)` + : "Настройки сохранены" + ); + globalThis.WespFeedAlerts?.refreshSummary?.(); + if (globalThis.WespFeedAlerts?.getCurrentView?.() === "alerts") { + globalThis.WespFeedAlerts.loadAlerts(); + } + globalThis.bootstrap?.Modal?.getInstance(modalEl)?.hide(); + } catch (err) { + notyfAdapter?.error?.(err.message || "Ошибка сохранения"); + } +} + +export function initFeedQualitySettingsModal(notyf) { + notyfAdapter = notyf; + ensureModal(); + document.addEventListener("click", (event) => { + const el = event.target.closest('[data-action="open-feed-quality-settings"]'); + if (!el) return; + event.preventDefault(); + openModal(); + }); +} + +if (typeof globalThis !== "undefined") { + globalThis.WespFeedQualitySettings = { + init: initFeedQualitySettingsModal, + open: openModal, + }; +} diff --git a/apps/web/public/wesp/js/pages/lab-agrostar-import.js b/apps/web/public/wesp/js/pages/lab-agrostar-import.js new file mode 100644 index 0000000..26d24d6 --- /dev/null +++ b/apps/web/public/wesp/js/pages/lab-agrostar-import.js @@ -0,0 +1,676 @@ +(function (global) { + "use strict"; + + const CREATE_NEW = "__create_new__"; + const ACCEPT_EXT = [".xml", ".pdf", ".xlsx", ".xls"]; + + let parseResult = null; + let components = []; + let selectedFile = null; + + const WARN_LABELS = { + omd_from_tdn: "ВРХ через TDN — не lab-grade", + omd_missing: "нет ВРХ — подставим дефолт", + dm_missing: "нет СВ — chemistry on hold", + }; + + function $(id) { + return document.getElementById(id); + } + + function showEl(el) { + if (!el) return; + el.hidden = false; + el.classList.remove("lab-agrostar-reveal"); + requestAnimationFrame(() => el.classList.add("lab-agrostar-reveal")); + } + + function hideEl(el) { + if (!el) return; + el.hidden = true; + el.classList.remove("lab-agrostar-reveal"); + } + + function escapeHtml(s) { + return String(s ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function importKind() { + return parseResult?.kind || "lab_samples"; + } + + function isLabSamples() { + return importKind() === "lab_samples"; + } + + async function api(path, opts) { + const resp = await fetch(path, opts); + const data = await resp.json().catch(() => ({})); + if (!resp.ok || data.error) { + throw new Error(data.message || `Наука, чёрт возьми — ошибка ${resp.status}`); + } + return data; + } + + async function loadComponents(force) { + if (components.length && !force) return components; + const rows = await api("/api/components?limit=2000"); + components = Array.isArray(rows) ? rows : []; + return components; + } + + function getSampleByNo(sampleNo) { + return (parseResult?.samples || []).find((s) => s.sampleNo === sampleNo); + } + + function buildComponentOptions(matchSource, selectedId) { + const suggested = matchSource?.suggestedComponents || []; + const seen = new Set(); + const opts = [ + '', + ``, + ]; + + for (const m of suggested) { + if (!m.componentId || seen.has(m.componentId)) continue; + seen.add(m.componentId); + const sel = m.componentId === selectedId ? " selected" : ""; + opts.push( + ``, + ); + } + for (const c of components) { + if (!c.id || seen.has(c.id)) continue; + const sel = c.id === selectedId ? " selected" : ""; + opts.push(``); + } + return opts.join(""); + } + + function selectComponentForSample(sampleNo, componentId) { + const sel = document.querySelector( + `.lab-agrostar-component[data-sample-no="${CSS.escape(sampleNo)}"]`, + ); + const sample = getSampleByNo(sampleNo); + if (!sel || !sample) return; + sel.innerHTML = buildComponentOptions(sample, componentId); + sel.value = componentId; + } + + function formatUnsupportedNotice(storage) { + if (!storage?.unsupportedCount) return ""; + return storage.unsupportedMessage || `${storage.unsupportedCount} показат. AgroStar без полей в WESP`; + } + + function notifyCatalogChanged() { + try { + global.dispatchEvent(new CustomEvent("wesp:components-catalog-changed")); + } catch { + /* ignore */ + } + } + + async function createComponentFromSample(sampleNo, sel) { + const sample = getSampleByNo(sampleNo); + if (!sample || !sel) return; + + const preview = sample.preview || {}; + const name = (preview.suggestedName || sample.label || "").trim(); + const type = preview.suggestedType || "Сочные корма"; + if (!name) { + sel.value = ""; + setResult("Нет названия для нового реагента", true); + return; + } + + sel.disabled = true; + setResult(`Создаём «${name}»…`); + try { + const data = await api("/api/components", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name, + type, + dry_matter: sample.dryMatterPct || 0, + nutrients: sample.nutrients || {}, + }), + }); + await loadComponents(true); + selectComponentForSample(sampleNo, data.id); + const storage = preview.storage || {}; + const skipNote = formatUnsupportedNotice(storage); + setResult( + `Реагент «${name}» создан — СВ и ${storage.nutrientCount || Object.keys(sample.nutrients || {}).length} показат.` + + (skipNote ? ` ${skipNote}` : ""), + false, + ); + notifyCatalogChanged(); + } catch (err) { + sel.value = ""; + setResult(err.message || "Не удалось создать реагент", true); + } finally { + sel.disabled = false; + } + } + + function onComponentSelectChange(e) { + const sel = e.target; + if (!sel.classList?.contains("lab-agrostar-component")) return; + if (sel.value !== CREATE_NEW) return; + const sampleNo = sel.getAttribute("data-sample-no") || ""; + createComponentFromSample(sampleNo, sel); + } + + function prime(reason) { + global.WespLabHeisenbergPrime?.error?.(reason, "agrostar"); + } + + function setStatus(text, isError) { + const el = $("labAgrostarStatus"); + if (!el) return; + el.textContent = text || ""; + el.className = "small mt-2 mb-0" + (isError ? " lab-agrostar-result--err" : " text-muted"); + if (isError && text) prime(text); + } + + function setResult(text, isError) { + const el = $("labAgrostarResult"); + if (!el) return; + el.textContent = text || ""; + el.className = "small mt-2 " + (isError ? "lab-agrostar-result--err" : "lab-agrostar-result--ok"); + if (isError && text) prime(text); + } + + function formatWarnings(warnings) { + if (!warnings?.length) return "—"; + return warnings + .map((w) => { + const key = String(w).split("=")[0]; + const label = WARN_LABELS[key] || w; + return `${escapeHtml(label)}`; + }) + .join(""); + } + + function getSelectedComponentLabel(sampleNo) { + const sel = document.querySelector(`.lab-agrostar-component[data-sample-no="${CSS.escape(sampleNo)}"]`); + if (!sel?.value || sel.value === CREATE_NEW) return null; + const opt = sel.options[sel.selectedIndex]; + return opt ? opt.text.replace(/\s*\(\d+%\)\s*$/, "").trim() : null; + } + + function renderMetaLine(extra) { + const meta = $("labAgrostarMeta"); + if (!meta || !parseResult) return; + showEl(meta); + const label = parseResult.sourceLabel || parseResult.labName || "Импорт"; + meta.innerHTML = + `${escapeHtml(label)}${extra || ""}` + + (parseResult.warnings?.length ? ` · ${escapeHtml(parseResult.warnings.join("; "))}` : ""); + } + + function setActionButtons() { + const previewBtn = $("labAgrostarPreview"); + const applyBtn = $("labAgrostarApply"); + const clearBtn = $("labAgrostarClear"); + if (previewBtn) previewBtn.disabled = false; + if (clearBtn) clearBtn.disabled = false; + if (applyBtn) { + applyBtn.disabled = !isLabSamples(); + applyBtn.title = isLabSamples() ? "" : "Запись в каталог только для лабораторных проб AgroStar"; + } + } + + function renderLabSamplesTable() { + const wrap = $("labAgrostarTableWrap"); + const body = $("labAgrostarTableBody"); + const head = $("labImportTableHead"); + const title = $("labImportTableTitle"); + if (!wrap || !body || !parseResult) return; + + const samples = parseResult.samples || []; + if (!samples.length) { + hideEl(wrap); + return; + } + + if (title) title.textContent = "Кому в каталог"; + if (head) { + head.innerHTML = ` + ОбразецСВ, %РеагентКосяки + `; + } + + renderMetaLine(` · образцов: ${samples.length}`); + + body.innerHTML = samples + .map((sample) => { + const sid = escapeHtml(sample.sampleNo); + const defaultId = sample.suggestedComponents?.[0]?.componentId || ""; + return ` + +
    ${escapeHtml(sample.label || sample.desc1 || sample.sampleNo)}
    +
    ${sid} · ${escapeHtml(sample.datePrinted || "")}
    + + ${sample.dryMatterPct != null ? escapeHtml(sample.dryMatterPct) : "—"} + + + + ${formatWarnings(sample.warnings)} + `; + }) + .join(""); + + showEl(wrap); + setActionButtons(); + } + + function renderRationCompositionTable() { + const wrap = $("labAgrostarTableWrap"); + const body = $("labAgrostarTableBody"); + const head = $("labImportTableHead"); + const title = $("labImportTableTitle"); + const lines = parseResult?.feedLines || []; + if (!wrap || !body || !lines.length) return; + + if (title) title.textContent = "Состав рациона"; + if (head) { + head.innerHTML = ` + Кормкг/сутруб.Реагент WESP + `; + } + + const meta = parseResult.meta || {}; + const extra = [ + meta.group ? escapeHtml(meta.group) : "", + meta.rationDate ? escapeHtml(meta.rationDate) : "", + meta.totalCostRub ? `Σ ${escapeHtml(meta.totalCostRub)} руб.` : "", + ] + .filter(Boolean) + .join(" · "); + renderMetaLine(` · ${lines.length} корм(ов)${extra ? " · " + extra : ""}`); + + body.innerHTML = lines + .map((line, idx) => { + const defaultId = line.suggestedComponents?.[0]?.componentId || ""; + return ` + ${escapeHtml(line.feedName)} + ${escapeHtml(line.dailyKg)} + ${escapeHtml(line.costRub)} + + + + `; + }) + .join(""); + + showEl(wrap); + setActionButtons(); + } + + function renderRationIndicatorsTable() { + const wrap = $("labAgrostarTableWrap"); + const body = $("labAgrostarTableBody"); + const head = $("labImportTableHead"); + const title = $("labImportTableTitle"); + const rows = parseResult?.indicators || []; + if (!wrap || !body || !rows.length) return; + + if (title) title.textContent = "Показатели рациона"; + if (head) { + head.innerHTML = ` + ПоказательНормаТекущийΔ + `; + } + + const meta = parseResult.meta || {}; + renderMetaLine( + ` · ${rows.length} показат.${meta.group ? " · " + escapeHtml(meta.group) : ""}${meta.calcDate ? " · " + escapeHtml(meta.calcDate) : ""}`, + ); + + body.innerHTML = rows + .map((row) => { + const delta = + row.norm != null && row.current != null ? (row.current - row.norm).toFixed(2) : "—"; + return ` + ${escapeHtml(row.name)} + ${row.norm != null ? escapeHtml(row.norm) : "—"} + ${row.current != null ? escapeHtml(row.current) : "—"} + ${escapeHtml(delta)} + `; + }) + .join(""); + + showEl(wrap); + setActionButtons(); + } + + function renderAfterParse() { + hideSniffPreview(); + hideEl($("labAgrostarTableWrap")); + const kind = importKind(); + if (kind === "lab_samples") renderLabSamplesTable(); + else if (kind === "ration_composition") renderRationCompositionTable(); + else if (kind === "ration_indicators") renderRationIndicatorsTable(); + } + + function renderSniffPreview() { + const panel = $("labAgrostarSniff"); + const body = $("labAgrostarSniffBody"); + if (!panel || !body || !parseResult) { + setResult("Нечего нюхать — сначала кинь файл", true); + return; + } + + const kind = importKind(); + if (kind === "ration_composition") { + renderSniffRationComposition(panel, body); + return; + } + if (kind === "ration_indicators") { + renderSniffRationIndicators(panel, body); + return; + } + renderSniffLabSamples(panel, body); + } + + function renderSniffRationComposition(panel, body) { + const lines = parseResult.feedLines || []; + const meta = parseResult.meta || {}; + const metaRows = Object.entries(meta) + .map(([k, v]) => `${escapeHtml(k)}${escapeHtml(v)}`) + .join(""); + body.innerHTML = `
    +
    +

    ПЛИНОР — состав рациона

    + ${lines.length} корм(ов) +
    +

    Превью · запись рациона в WESP — отдельным шагом

    + + + ${lines + .map( + (l) => + ``, + ) + .join("")} +
    Кормкгруб.
    ${escapeHtml(l.feedName)}${escapeHtml(l.dailyKg)}${escapeHtml(l.costRub)}
    + ${metaRows ? `${metaRows}
    ` : ""} +
    `; + showEl(panel); + setResult(`Понюхали · ${parseResult.sourceLabel || "ПЛИНОР"} · ${lines.length} строк`, false); + } + + function renderSniffRationIndicators(panel, body) { + const rows = parseResult.indicators || []; + body.innerHTML = `
    +
    +

    ПЛИНОР — зоопоказатели

    + ${rows.length} показат. +
    + + + ${rows + .map( + (r) => + ``, + ) + .join("")} +
    ПоказательНормаТекущий
    ${escapeHtml(r.name)}${escapeHtml(r.norm)}${escapeHtml(r.current)}
    +
    `; + showEl(panel); + setResult(`Понюхали · ${parseResult.sourceLabel || "ПЛИНОР"} · ${rows.length} показат.`, false); + } + + function renderSniffLabSamples(panel, body) { + if (!parseResult?.samples?.length) { + setResult("Нечего нюхать — нет проб", true); + return; + } + + const labName = parseResult.labName || parseResult.sourceLabel || "AgroStar"; + const cards = parseResult.samples.map((sample) => { + const preview = sample.preview; + if (!preview) return ""; + + const selectedName = getSelectedComponentLabel(sample.sampleNo); + const targetLine = selectedName + ? `

    Куда запишем: ${escapeHtml(selectedName)}

    ` + : `

    Реагент не выбран — «+ добавить новый реагент»

    `; + + const metaRows = (preview.meta || []) + .map((row) => `${escapeHtml(row.label)}${escapeHtml(row.value)}`) + .join(""); + const nutrientRows = (preview.willWrite || []) + .map((row) => { + const sourceCell = + row.sourceValue != null + ? `${escapeHtml(row.sourceValue)} ${escapeHtml(row.sourceUnit || "")}` + : `—`; + return `${escapeHtml(row.label)}${sourceCell}${escapeHtml(row.value)}${escapeHtml(row.unit)}`; + }) + .join(""); + const notes = (preview.notes || []).map((note) => `
  • ${escapeHtml(note)}
  • `).join(""); + const notesBlock = notes + ? `
    Замечания
      ${notes}
    ` + : ""; + const storage = preview.storage || {}; + const skipNote = formatUnsupportedNotice(storage); + const storageBlock = skipNote + ? `

    ${escapeHtml(skipNote)}

    ` + : `

    В каталог: СВ + ${storage.nutrientCount || 0} показат.

    `; + + return `
    +
    +

    ${escapeHtml(preview.title || sample.label)}

    + ${escapeHtml(preview.feedTypeRu || "—")} +
    + ${targetLine} +
    +
    Из файла
    + ${metaRows}
    +
    +
    WESP · ${preview.recognizedCount || 0} показат. · % из документа → г/кг СВ (×10)
    + + + ${nutrientRows} +
    ПоказательДокументWESP
    +
    +
    + ${notesBlock}${storageBlock} +
    `; + }); + + body.innerHTML = cards.join(""); + showEl(panel); + setResult(`Понюхали · ${labName} · ${parseResult.samples.length} образц(ов)`, false); + } + + function hideSniffPreview() { + const panel = $("labAgrostarSniff"); + const body = $("labAgrostarSniffBody"); + hideEl(panel); + if (body) body.innerHTML = ""; + } + + function collectAssignments() { + if (!parseResult?.samples?.length) return []; + const byNo = new Map(parseResult.samples.map((s) => [s.sampleNo, s])); + const assignments = []; + document.querySelectorAll(".lab-agrostar-component[data-sample-no]").forEach((sel) => { + const sampleNo = sel.getAttribute("data-sample-no") || ""; + const sample = byNo.get(sampleNo); + if (!sample) return; + const componentId = sel.value && sel.value !== CREATE_NEW ? sel.value : null; + assignments.push({ + sampleNo, + componentId, + dryMatterPct: sample.dryMatterPct, + nutrients: sample.nutrients || {}, + warnings: sample.warnings || [], + }); + }); + return assignments; + } + + function fileAllowed(name) { + const lower = (name || "").toLowerCase(); + return ACCEPT_EXT.some((ext) => lower.endsWith(ext)); + } + + async function uploadFile(file) { + if (!file) return; + if (!fileAllowed(file.name)) { + setStatus("Формат не тот — xml, pdf или xlsx", true); + return; + } + selectedFile = file; + const fileNameEl = $("labAgrostarFileName"); + if (fileNameEl) { + showEl(fileNameEl); + fileNameEl.textContent = file.name; + } + setStatus("Разбираем поставку…"); + setResult(""); + hideSniffPreview(); + + const fd = new FormData(); + fd.append("file", file); + try { + parseResult = await api("/api/lab/import/parse", { method: "POST", body: fd }); + await loadComponents(); + renderAfterParse(); + const kind = importKind(); + if (kind === "lab_samples") { + setStatus(`На столе: ${parseResult.sampleCount || 0} образц(ов) · ${parseResult.sourceLabel || ""}`); + } else if (kind === "ration_composition") { + setStatus(`Рацион: ${parseResult.feedCount || 0} корм(ов) · ${parseResult.sourceLabel || ""}`); + } else { + setStatus(`Показатели: ${parseResult.indicatorCount || 0} · ${parseResult.sourceLabel || ""}`); + } + } catch (err) { + parseResult = null; + hideEl($("labAgrostarTableWrap")); + hideEl($("labAgrostarMeta")); + setStatus(err.message || "Файл не разобрался", true); + } + } + + async function runApply() { + if (!isLabSamples()) { + setResult("Запись в каталог — только для лабораторных проб AgroStar", true); + return; + } + const assignments = collectAssignments(); + if (!assignments.length) { + setResult("Нечего готовить — сначала кинь файл", true); + return; + } + const missing = assignments.filter((a) => !a.componentId); + if (missing.length) { + setResult(`Укажи реагент для ${missing.length} образц(ов) — Jesse бы не забыл`, true); + return; + } + setResult("Пишем в каталог…"); + try { + const data = await api("/api/lab/import/agrostar-xml/apply", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ assignments, dryRun: false }), + }); + const lines = (data.results || []).map( + (r) => `${r.sampleNo}: ${r.skipped ? "мимо" : r.componentName || "—"} (${r.nutrientCount} показат.)`, + ); + setResult( + `Запомнили · в деле ${data.applied}, мимо ${data.skipped}` + + (lines.length ? "\n" + lines.join("\n") : ""), + false, + ); + notifyCatalogChanged(); + } catch (err) { + setResult(err.message || "Наука не сработала — импорт сгорел", true); + } + } + + function clearAll() { + parseResult = null; + selectedFile = null; + const input = $("labAgrostarFile"); + if (input) input.value = ""; + const fileNameEl = $("labAgrostarFileName"); + if (fileNameEl) { + hideEl(fileNameEl); + fileNameEl.textContent = ""; + } + hideEl($("labAgrostarTableWrap")); + hideEl($("labAgrostarMeta")); + hideSniffPreview(); + $("labAgrostarPreview").disabled = true; + $("labAgrostarApply").disabled = true; + $("labAgrostarClear").disabled = true; + setStatus(""); + setResult(""); + } + + function bindDropZone() { + const drop = $("labAgrostarDrop"); + const input = $("labAgrostarFile"); + if (!drop || !input) return; + + drop.addEventListener("click", () => input.click()); + drop.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + input.click(); + } + }); + input.addEventListener("change", () => { + const file = input.files?.[0]; + if (file) uploadFile(file); + }); + + ["dragenter", "dragover"].forEach((ev) => { + drop.addEventListener(ev, (e) => { + e.preventDefault(); + e.stopPropagation(); + drop.classList.add("lab-agrostar-drop--active"); + }); + }); + ["dragleave", "drop"].forEach((ev) => { + drop.addEventListener(ev, (e) => { + e.preventDefault(); + e.stopPropagation(); + drop.classList.remove("lab-agrostar-drop--active"); + }); + }); + drop.addEventListener("drop", (e) => { + const file = e.dataTransfer?.files?.[0]; + if (file) uploadFile(file); + }); + } + + function init() { + if (!$("labAgrostarDrop")) return; + bindDropZone(); + $("labAgrostarTableWrap")?.addEventListener("change", onComponentSelectChange); + $("labAgrostarPreview")?.addEventListener("click", renderSniffPreview); + $("labAgrostarApply")?.addEventListener("click", runApply); + $("labAgrostarClear")?.addEventListener("click", clearAll); + loadComponents().catch(() => {}); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(window); diff --git a/apps/web/public/wesp/js/pages/lab-animal-profiles-modal.js b/apps/web/public/wesp/js/pages/lab-animal-profiles-modal.js new file mode 100644 index 0000000..4f7f0d9 --- /dev/null +++ b/apps/web/public/wesp/js/pages/lab-animal-profiles-modal.js @@ -0,0 +1,104 @@ +(function (global) { + "use strict"; + + function escapeHtml(s) { + return String(s ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + async function openLabAnimalProfilesModal() { + const resp = await fetch("/api/lab/animal-profiles"); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) { + global.WespZootechNotify?.createAdapter?.()?.error?.(data.message || "Ошибка загрузки профилей"); + return; + } + const profiles = data.profiles || []; + const options = profiles + .map( + (p) => + `` + ) + .join(""); + + const html = + `
    ` + + `

    Профили норм стада (зоотех). Выберите для просмотра или создайте новый.

    ` + + `` + + `` + + `` + + `
    ` + + `` + + `` + + `` + + `` + + `` + + `
    `; + + const ok = await global.WespDialog?.confirm?.(html, { + title: "Профили норм стада", + html: true, + confirmText: "Сохранить", + cancelText: "Закрыть", + }); + if (!ok) return; + + const pick = document.getElementById("labProfilePick")?.value; + const key = document.getElementById("labProfileKey")?.value?.trim(); + const label = document.getElementById("labProfileLabel")?.value?.trim(); + const rationType = document.getElementById("labProfileType")?.value || "BEEF"; + let normsData = {}; + try { + const raw = document.getElementById("labProfileNorms")?.value?.trim(); + if (raw) normsData = JSON.parse(raw); + } catch (_) { + global.WespZootechNotify?.createAdapter?.()?.error?.("Некорректный JSON норм"); + return; + } + if (!key || !label) { + global.WespZootechNotify?.createAdapter?.()?.error?.("Укажите ключ и название"); + return; + } + + const method = pick ? "PUT" : "POST"; + const url = pick + ? `/api/lab/animal-profiles/${encodeURIComponent(pick)}` + : "/api/lab/animal-profiles"; + try { + const saveResp = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profileKey: key, label, rationType, normsData }), + }); + const saveData = await saveResp.json(); + if (!saveResp.ok) throw new Error(saveData.message || "Ошибка сохранения"); + global.WespZootechNotify?.createAdapter?.()?.success?.("Профиль сохранён"); + } catch (e) { + global.WespZootechNotify?.createAdapter?.()?.error?.(e.message, { fallback: "Не удалось сохранить профиль" }); + } + } + + document.addEventListener("change", (ev) => { + if (ev.target?.id !== "labProfilePick") return; + const id = ev.target.value; + const view = document.getElementById("labProfileView"); + if (!id || !view) return; + fetch(`/api/lab/animal-profiles/${encodeURIComponent(id)}`) + .then((r) => r.json()) + .then((p) => { + document.getElementById("labProfileKey").value = p.profileKey || ""; + document.getElementById("labProfileLabel").value = p.label || ""; + document.getElementById("labProfileType").value = p.rationType || "BEEF"; + document.getElementById("labProfileNorms").value = JSON.stringify(p.normsData || {}, null, 2); + view.hidden = false; + view.textContent = `${p.profileKey} · ${p.rationType}`; + }) + .catch(() => {}); + }); + + global.openLabAnimalProfilesModal = openLabAnimalProfilesModal; +})(window); diff --git a/apps/web/public/wesp/js/pages/lab-components-nutrients.js b/apps/web/public/wesp/js/pages/lab-components-nutrients.js new file mode 100644 index 0000000..ba3b733 --- /dev/null +++ b/apps/web/public/wesp/js/pages/lab-components-nutrients.js @@ -0,0 +1,330 @@ +(function (global) { + "use strict"; + + /** Показатели для зоотех-расчёта рациона (weighted average). */ + const NUTRIENT_ROWS = [ + { + label: "Осн. корм", + unit: "г/кг СВ", + keys: ["Осн.Корм"], + hint: "СВ этого сырья, учитываемое как объёмный (основной) корм в норме dm_main. 0 — концентрат/добавка.", + }, + { label: "Сыр. протеин", unit: "г/кг", keys: ["Сыр. Протеин"] }, + { label: "уСП", unit: "г/кг", keys: ["уСП"] }, + { label: "RNB", unit: "г/кг", keys: ["RNB", "БРА", " БРА "] }, + { label: "ЧЭЛ — КРС", unit: "МДж/кг", keys: ["ЧЭЛ- КРС", " ЧЭЛ- КРС"] }, + { label: "ОЭ — КРС", unit: "МДж/кг", keys: ["ОЭ-КРС", " ОЭ-КРС"] }, + { label: "Сырая клетчатка", unit: "г/кг", keys: ["Сырая клетч", "Сырая клетчатка"] }, + { label: "Структур. клетч.", unit: "г/кг", keys: ["Структур клетч", "Структур. клетч", "Структур. клетчатка"] }, + { label: "Сырой жир", unit: "г/кг", keys: ["Сырой жир"] }, + ]; + + /** Лаб. ввод для native derive (см. LAB_FORMULAS.md). СВ — только component.dry_matter (%). */ + const LAB_INPUT_ROWS = [ + { label: "Сырая зола", unit: "г/кг", keys: ["Сырая зола"] }, + { + label: "ВРХ орг. вещ.", + unit: "%", + keys: ["ВРХ Орг Вещ"], + hint: "Обязательно для грубых и сочных кормов. Без значения подставится дефолт WESP (65% / 72%).", + }, + { + label: "уСП в ОР", + unit: "г/кг", + keys: ["уСП в ОР", "уСП в ОВ"], + hint: "Лабораторное уСП: при значении > 1 заменяет расчёт по формуле Lebzien.", + }, + { label: "КРС протеин", unit: "%", keys: ["КРС Протеин"] }, + { label: "КРС сырой жир", unit: "%", keys: ["КРС Сырой жир"] }, + { label: "КРС сырая клетч.", unit: "%", keys: ["КРС Сырая клетч"] }, + { label: "КРС БЭВ", unit: "%", keys: ["КРС БЭВ"] }, + { label: "НДК", unit: "г/кг", keys: ["НДК", "НДК Общ"] }, + { label: "NFC", unit: "г/кг", keys: ["NFC", "БЕР"] }, + ]; + + /** Минералы, аминокислоты и прочий лаб. ввод из AgroStar / импорта. */ + const EXTRA_LAB_ROWS = [ + { label: "КДК (ADF)", unit: "г/кг", keys: ["КДК", "КДК общ"] }, + { label: "Ca", unit: "г/кг", keys: ["Ca"] }, + { label: "P", unit: "г/кг", keys: ["P"] }, + { label: "Mg", unit: "г/кг", keys: ["Mg"] }, + { label: "K", unit: "г/кг", keys: ["K"] }, + { label: "S", unit: "г/кг", keys: ["S"] }, + { label: "Cl", unit: "г/кг", keys: ["CL", "Cl"] }, + { label: "Сахар", unit: "г/кг", keys: ["Сахар"] }, + { label: "Крахмал", unit: "г/кг", keys: ["Крахмал"] }, + { label: "Нераств. протеин", unit: "%", keys: ["% нераствор протеин"] }, + { label: "Лизин", unit: "г/кг", keys: ["Лизин"] }, + { label: "Метионин", unit: "г/кг", keys: ["Метионин"] }, + { label: "Лейцин", unit: "г/кг", keys: ["Лейцин"] }, + { label: "Изолейцин", unit: "г/кг", keys: ["Изолейцин"] }, + { label: "Валин", unit: "г/кг", keys: ["Валин"] }, + ]; + + const ALL_FORM_ROWS = [...NUTRIENT_ROWS, ...LAB_INPUT_ROWS, ...EXTRA_LAB_ROWS]; + + const CARD_INFO_COLUMNS = [ + { + label: "Сухое вещество", + value(component) { + const dm = component.dryMatter ?? component.dry_matter; + return dm == null || dm === "" ? "—" : `${dm}%`; + }, + }, + { + label: "№ справочника", + value(component) { + const n = component.externalNo ?? component.external_no; + return n == null || n === "" ? "—" : String(n); + }, + }, + { + label: "Цена", + value(component) { + const price = component.price; + return price == null || price === "" ? "—" : `${price} руб/кг`; + }, + }, + { + label: "Осн. корм", + value(_component, nutrients) { + const val = readNutrientValue(nutrients, ["Осн.Корм"]); + return val == null ? "—" : `${val} г/кг`; + }, + }, + { + label: "Сыр. протеин", + value(_component, nutrients) { + const val = readNutrientValue(nutrients, ["Сыр. Протеин"]); + return val == null ? "—" : `${val} г/кг`; + }, + }, + { + label: "ОЭ — КРС", + value(_component, nutrients) { + const val = readNutrientValue(nutrients, ["ОЭ-КРС", " ОЭ-КРС"]); + return val == null ? "—" : `${val} МДж/кг`; + }, + }, + { + label: "СВ (расч.)", + value(component) { + const dm = component.dryMatter ?? component.dry_matter; + if (dm == null || dm === "") return "—"; + const n = Number(dm); + if (Number.isNaN(n)) return "—"; + return `${(n * 10).toFixed(1)} г/кг`; + }, + }, + { + label: "Показателей", + value(_component, nutrients) { + return String(Object.keys(nutrients || {}).length); + }, + }, + ]; + + function normalizeKey(s) { + return String(s || "") + .replace(/\s+/g, " ") + .trim() + .toLowerCase(); + } + + function parseNutrients(raw) { + if (!raw) return {}; + if (typeof raw === "object") return { ...raw }; + try { + return JSON.parse(raw) || {}; + } catch (_) { + return {}; + } + } + + function readNutrientValue(nutrients, keys) { + const map = nutrients || {}; + for (const search of keys) { + const target = normalizeKey(search); + for (const [k, v] of Object.entries(map)) { + if (normalizeKey(k) !== target) continue; + const n = Number(v); + if (!Number.isNaN(n)) return n; + } + } + return null; + } + + function canonicalKey(keys) { + return keys[0]; + } + + function buildNutrientsPayload(root) { + const payload = {}; + if (!root) return payload; + root.querySelectorAll("[data-nutrient-key]").forEach((input) => { + const key = input.getAttribute("data-nutrient-key"); + const val = input.value.trim(); + if (key && val !== "") payload[key] = Number(val); + }); + return payload; + } + + function rowFieldsHtml(rows, nutrients) { + return rows + .map((row) => { + const key = canonicalKey(row.keys); + const val = readNutrientValue(nutrients, row.keys); + const hint = row.hint ? ` title="${row.hint}"` : ""; + return ( + `
    ` + + `` + + `` + + `
    ` + ); + }) + .join(""); + } + + const _OMD_FEED_TYPES = new Set(["грубые корма", "сочные корма", "объемные корма", "объёмные корма"]); + + function feedGroupNeedsOmd(component) { + if (!component || !component.type) return false; + return _OMD_FEED_TYPES.has(normalizeKey(component.type)); + } + + function omdWarningHtml(component, nutrients) { + if (!feedGroupNeedsOmd(component)) return ""; + if (readNutrientValue(nutrients, ["ВРХ Орг Вещ", "КРС Орг Вещ"]) != null) return ""; + return ( + `
    ` + + `Для грубых и сочных кормов укажите ВРХ орг. вещ. — ` + + `без анализа расчёт энергии и уСП будет ненадёжным.` + + `
    ` + ); + } + + function nutrientsFormHtml(nutrients, component) { + const calcRows = rowFieldsHtml(NUTRIENT_ROWS, nutrients); + const labRows = rowFieldsHtml(LAB_INPUT_ROWS, nutrients); + const extraRows = rowFieldsHtml(EXTRA_LAB_ROWS, nutrients); + const warn = omdWarningHtml(component, nutrients); + return ( + warn + + `
    ` + + `
    ` + + `` + + `Показатели для расчёта рациона` + + `
    ` + + `
    ${calcRows}
    ` + + `
    ` + + `
    ` + + `
    ` + + `` + + `Лабораторный ввод (derive)` + + `
    ` + + `
    ${labRows}
    ` + + `
    ` + + `
    ` + + `
    ` + + `` + + `Минералы и аминокислоты` + + `
    ` + + `
    ${extraRows}
    ` + + `
    ` + ); + } + + function mountCreateForm() { + const mount = document.getElementById("labNutrientsCreateMount"); + if (!mount) return; + mount.innerHTML = nutrientsFormHtml({}); + mount.dataset.mounted = "1"; + } + + function mountEditForm() { + const mount = document.getElementById("labNutrientsEditMount"); + if (!mount) return; + mount.innerHTML = nutrientsFormHtml({}); + mount.dataset.mounted = "1"; + } + + function resetCreateForm() { + const mount = document.getElementById("labNutrientsCreateMount"); + if (!mount) return; + mount.innerHTML = nutrientsFormHtml({}); + mount.dataset.mounted = "1"; + } + + function fillCreateFromComponent(component) { + const mount = document.getElementById("labNutrientsCreateMount"); + if (!mount || !component) return; + const nutrients = parseNutrients(component.nutrients); + mount.innerHTML = nutrientsFormHtml(nutrients, component); + mount.dataset.mounted = "1"; + } + + function fillEditNutrients(component) { + const mount = document.getElementById("labNutrientsEditMount"); + if (!mount) return; + mount.innerHTML = nutrientsFormHtml(parseNutrients(component.nutrients), component); + mount.dataset.mounted = "1"; + } + + function formatCardItems(component) { + const nutrients = parseNutrients(component.nutrients); + return CARD_INFO_COLUMNS.map((col) => { + const value = col.value(component, nutrients); + return { + label: col.label, + value, + empty: value === "—", + }; + }); + } + + function renderCardInfoHtml(component) { + if (!global.WESP_CAN_LAB) return ""; + return formatCardItems(component) + .map( + (item) => + `
    ` + + `${item.label}` + + `${item.value}` + + `
    ` + ) + .join(""); + } + + function collectFromCreateForm() { + return buildNutrientsPayload(document.getElementById("labNutrientsCreateMount")); + } + + function collectFromEditForm() { + return buildNutrientsPayload(document.getElementById("labNutrientsEditMount")); + } + + global.WespLabComponentsNutrients = { + NUTRIENT_ROWS, + LAB_INPUT_ROWS, + EXTRA_LAB_ROWS, + normalizeKey, + readNutrientValue, + parseNutrients, + nutrientsFormHtml, + mountCreateForm, + mountEditForm, + resetCreateForm, + fillCreateFromComponent, + fillEditNutrients, + renderCardInfoHtml, + collectFromCreateForm, + collectFromEditForm, + init() { + if (!global.WESP_CAN_LAB) return; + mountCreateForm(); + mountEditForm(); + }, + }; +})(window); diff --git a/apps/web/public/wesp/js/pages/lab-formulate.js b/apps/web/public/wesp/js/pages/lab-formulate.js new file mode 100644 index 0000000..0120a5b --- /dev/null +++ b/apps/web/public/wesp/js/pages/lab-formulate.js @@ -0,0 +1,601 @@ +(function (global) { + "use strict"; + + const DEFAULT_OPTIMIZE_KEYS = [ + "dry_matter", + "usp", + "nel", + "crude_protein", + "rnb", + "nfc_pct_dm_uk", + ]; + + let catalog = []; + let groups = []; + let profiles = []; + let currentStep = 1; + const selections = {}; + + function formulateError(message) { + global.WespLabHeisenbergPrime?.error?.(message, "formulate"); + } + + async function api(path, opts) { + const resp = await fetch(path, opts); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) throw new Error(data.message || `Наука, чёрт возьми — ошибка ${resp.status}`); + return data; + } + + function escapeHtml(s) { + return String(s ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function formatNum(v) { + if (v == null || v === "") return "—"; + const n = Number(v); + if (Number.isNaN(n)) return "—"; + return Math.abs(n) >= 100 ? n.toFixed(1) : n.toFixed(3).replace(/\.?0+$/, ""); + } + + function diffClass(diff) { + if (diff == null || Number.isNaN(Number(diff))) return "lab-sandbox-diff--na"; + const d = Number(diff); + if (Math.abs(d) < 0.001) return "lab-sandbox-diff--ok"; + return "lab-sandbox-diff--bad"; + } + + function totalSteps() { + return groups.length + 1; + } + + function groupById(id) { + return groups.find((g) => g.id === id); + } + + function selectedForGroup(gid) { + return Array.from(document.querySelectorAll(`.lab-formulate-gcb[data-group="${gid}"]:checked`)).map( + (el) => el.value + ); + } + + function allSelections() { + const out = {}; + groups.forEach((g) => { + out[g.id] = selectedForGroup(g.id); + }); + return out; + } + + function totalPicked() { + return Object.values(allSelections()).reduce((n, arr) => n + arr.length, 0); + } + + function componentsForGroup(gid, filterText) { + const q = (filterText || "").trim().toLowerCase(); + return catalog.filter((c) => { + if (!c.eligible || c.feedGroup !== gid) return false; + if (q && !String(c.name || "").toLowerCase().includes(q)) return false; + return true; + }); + } + + function renderGroupTable(gid, filterText) { + const body = document.querySelector(`#labFormulatePane-${gid} tbody`); + if (!body) return; + const picked = new Set(selectedForGroup(gid)); + const rows = componentsForGroup(gid, filterText); + if (!rows.length) { + body.innerHTML = + 'В этой фракции пусто — нечего готовить'; + return; + } + body.innerHTML = rows + .map((c) => { + const checked = picked.has(c.id) ? " checked" : ""; + return ( + `` + + `` + + `${escapeHtml(c.name)}` + + `${formatNum(c.mainFeedDmGPerKg)}` + + `${formatNum(c.dryMatterPct)}%` + + `` + ); + }) + .join(""); + } + + function buildWizard() { + const stepsEl = document.getElementById("labFormulateSteps"); + const panesEl = document.getElementById("labFormulatePanes"); + if (!stepsEl || !panesEl) return; + + stepsEl.innerHTML = + groups + .map( + (g, i) => + `` + ) + .join("") + + ``; + + panesEl.innerHTML = groups + .map((g) => { + const req = g.required + ? `без этого никак` + : `не мешай`; + return ( + `
    ` + + `
    ` + + `
    ${escapeHtml(g.label)}
    ${req}
    ` + + `

    ${escapeHtml(g.hint)}

    ` + + `

    В партии: 0

    ` + + `
    ` + + `` + + `
    ` + + `
    ` + + `` + + `` + + `
    РеагентОсн.СВ%
    ` + + `
    ` + + (g.step > 1 + ? `` + : "") + + (!g.required + ? `` + : "") + + `` + + `
    ` + ); + }) + .join(""); + + groups.forEach((g) => renderGroupTable(g.id, "")); + } + + function updateCounters() { + const sel = allSelections(); + groups.forEach((g) => { + const el = document.querySelector(`.lab-formulate-group-count[data-group="${g.id}"]`); + if (el) { + const n = (sel[g.id] || []).length; + el.textContent = g.required ? `В партии: ${n} (мин. ${g.minPick})` : `В партии: ${n}`; + } + }); + const total = totalPicked(); + const summaryEl = document.getElementById("labFormulateSummary"); + const prefilter = document.getElementById("labFormulatePrefilterNote"); + const roughOk = (sel.rough || []).length >= 1; + if (summaryEl) { + summaryEl.textContent = roughOk && total >= 3 + ? `Партия из ${total} реагентов — можно готовить` + : `Нужна база (≥1) и ≥3 реагента всего — сейчас ${total}. Нам нужно готовить.`; + } + if (prefilter) prefilter.classList.toggle("d-none", total <= 18); + const runBtn = document.getElementById("labFormulateRun"); + if (runBtn) runBtn.disabled = !(roughOk && total >= 3 && currentStep === totalSteps()); + renderReview(); + } + + function renderReview() { + const el = document.getElementById("labFormulateReview"); + if (!el) return; + const sel = allSelections(); + let html = ""; + groups.forEach((g) => { + const ids = sel[g.id] || []; + html += `
  • ${escapeHtml(g.shortLabel)} (${ids.length})
  • `; + ids.forEach((id) => { + const name = catalog.find((c) => c.id === id)?.name || id; + html += `
  • ${escapeHtml(name)}
  • `; + }); + }); + el.innerHTML = html || "
  • "; + } + + function setStep(step) { + currentStep = step; + const calcStep = totalSteps(); + groups.forEach((g) => { + document.getElementById(`labFormulatePane-${g.id}`)?.classList.toggle("d-none", g.step !== step); + }); + document.getElementById("labFormulateReviewPane")?.classList.toggle("d-none", step !== calcStep); + document.querySelector(".lab-formulate-step-panel--calc")?.classList.toggle("d-none", step !== calcStep); + document.querySelectorAll(".lab-formulate-step").forEach((btn) => { + btn.classList.toggle("lab-formulate-step--active", Number(btn.dataset.step) === step); + }); + updateCounters(); + } + + function canGoToStep(step) { + if (step <= 1) return true; + const sel = allSelections(); + for (const g of groups) { + if (g.step >= step) break; + if (g.required && (sel[g.id] || []).length < (g.minPick || 1)) return false; + } + if (step === totalSteps()) { + return (sel.rough || []).length >= 1 && totalPicked() >= 3; + } + return true; + } + + function readNumInput(id) { + const el = document.getElementById(id); + if (!el) return null; + const raw = String(el.value ?? "").trim(); + if (!raw) return null; + const n = Number(raw); + return Number.isFinite(n) ? n : null; + } + + function readNormsMethod() { + return document.getElementById("labFormulateNormsMethod")?.value || "wesp"; + } + + function readNormsParams() { + const method = readNormsMethod(); + if (method === "wesp") return {}; + const params = { + milkFatPct: readNumInput("labFormulateFat") ?? 4, + lactationNo: readNumInput("labFormulateLactation") ?? 2, + }; + if (method === "racion_piter") { + params.koncOeSv = readNumInput("labFormulateKonc") ?? 10.3; + } + return params; + } + + function syncNormsMethodUi() { + const method = readNormsMethod(); + const racionBox = document.getElementById("labFormulateRacionFields"); + const piterOnly = document.querySelectorAll(".lab-formulate-piter-only"); + if (racionBox) racionBox.classList.toggle("d-none", method === "wesp"); + piterOnly.forEach((el) => el.classList.toggle("d-none", method !== "racion_piter")); + } + + function readFormulateParams() { + return { + profileId: document.getElementById("labFormulateProfile")?.value || "", + massKg: readNumInput("labFormulateMass"), + milkYieldKg: readNumInput("labFormulateMilk"), + totalKgPerHead: readNumInput("labFormulateTotalKg") ?? 7.3, + costWeight: readNumInput("labFormulateCostWeight") ?? 100, + optimizeKeys: Array.from(document.querySelectorAll(".lab-formulate-opt-cb:checked")).map((el) => el.value), + groupSelections: allSelections(), + normsMethod: readNormsMethod(), + normsParams: readNormsParams(), + }; + } + + function resolveMassMilk(profile) { + return { + mass: readNumInput("labFormulateMass") ?? profile?.massKg ?? null, + milk: readNumInput("labFormulateMilk") ?? profile?.milkYieldKg ?? null, + }; + } + + async function normsForOptimizeDisplay(profile) { + const method = readNormsMethod(); + let norms = + method === "wesp" + ? { ...(profile?.norms || profile?.normsData?.indicators || {}) } + : {}; + const { mass, milk } = resolveMassMilk(profile); + if (method !== "wesp" && (mass == null || milk == null)) return norms; + if (mass == null && milk == null) return norms; + try { + const q = new URLSearchParams({ method }); + const profileId = document.getElementById("labFormulateProfile")?.value; + if (profileId) q.set("profile_id", profileId); + if (mass != null) q.set("mass_kg", String(mass)); + if (milk != null) q.set("milk_yield_kg", String(milk)); + const np = readNormsParams(); + if (np.milkFatPct != null) q.set("milk_fat_pct", String(np.milkFatPct)); + if (np.lactationNo != null) q.set("lactation_no", String(np.lactationNo)); + if (np.koncOeSv != null) q.set("konc_oe_sv", String(np.koncOeSv)); + const preview = await api(`/api/lab/norms-preview?${q.toString()}`); + const resolved = preview.resolvedIndicators || {}; + const dynamic = preview.dynamicNorms || {}; + const merged = { ...resolved }; + Object.keys(dynamic).forEach((key) => { + const d = dynamic[key]; + if (d?.min != null) merged[key] = { ...(merged[key] || {}), min: d.min }; + }); + if (method === "wesp") { + Object.assign(norms, merged); + } else { + norms = merged; + } + } catch (_err) { + /* preview optional */ + } + return norms; + } + + async function refreshOptimizeKeysForInputs() { + const profileId = document.getElementById("labFormulateProfile")?.value; + const profile = profiles.find((p) => String(p.id) === String(profileId)); + if (!profile) return; + const norms = await normsForOptimizeDisplay(profile); + renderOptimizeKeys({ ...profile, norms }); + } + + let optimizeKeysRefreshTimer = null; + function scheduleOptimizeKeysRefresh() { + if (optimizeKeysRefreshTimer) clearTimeout(optimizeKeysRefreshTimer); + optimizeKeysRefreshTimer = setTimeout(() => { + optimizeKeysRefreshTimer = null; + refreshOptimizeKeysForInputs().catch(() => {}); + }, 200); + } + + function profileOptions(selectedId) { + const opts = ['']; + profiles.forEach((p) => { + const sel = String(p.id) === String(selectedId) ? " selected" : ""; + const key = p.profileKey ? `[${p.profileKey}] ` : ""; + opts.push( + `` + ); + }); + return opts.join(""); + } + + function fillProfileFields(profileId) { + const profile = profiles.find((p) => String(p.id) === String(profileId)); + if (!profile) return; + const massEl = document.getElementById("labFormulateMass"); + const milkEl = document.getElementById("labFormulateMilk"); + if (massEl && profile.massKg != null) massEl.value = profile.massKg; + if (milkEl && profile.milkYieldKg != null) milkEl.value = profile.milkYieldKg; + refreshOptimizeKeysForInputs().catch(() => renderOptimizeKeys(profile)); + } + + function renderOptimizeKeys(profile) { + const box = document.getElementById("labFormulateOptimizeKeys"); + if (!box) return; + const norms = profile?.norms || profile?.normsData?.indicators || {}; + const keys = Object.keys(norms).filter((k) => norms[k]?.min != null || norms[k]?.max != null); + const useKeys = keys.length ? keys : DEFAULT_OPTIMIZE_KEYS; + const prevChecked = new Set( + Array.from(document.querySelectorAll(".lab-formulate-opt-cb:checked")).map((el) => el.value) + ); + const hasPrev = prevChecked.size > 0; + box.innerHTML = useKeys + .map((key) => { + const checked = + (hasPrev ? prevChecked.has(key) : DEFAULT_OPTIMIZE_KEYS.includes(key)) ? " checked" : ""; + const b = norms[key] || {}; + const bounds = + b.min != null && b.max != null + ? `${formatNum(b.min)}–${formatNum(b.max)}` + : b.max != null + ? `≤ ${formatNum(b.max)}` + : b.min != null + ? `≥ ${formatNum(b.min)}` + : ""; + return ( + `` + ); + }) + .join(""); + } + + function renderResult(data) { + const el = document.getElementById("labFormulateResult"); + if (!el) return; + if (!data) { + el.innerHTML = '
    Скажи моё имя и жми кнопку
    '; + return; + } + const stats = data.searchStats || {}; + const optimizeSet = new Set(data.optimizeKeys || []); + let html = + `
    ` + + `Реагентов ${data.candidatePoolSize}` + + `Троек ${stats.tripletsEvaluated || 0}` + + `${stats.durationMs || 0} мс — время деньги
    ` + + `` + + (data.lines || []) + .map( + (line) => + `` + ) + .join("") + + `
    Реагенткг/сут%
    ${escapeHtml(line.name)}${formatNum(line.dailyKg)}${formatNum(line.sharePct)}
    `; + const rows = (data.indicators || []).filter( + (ind) => ind.min != null || ind.max != null || ind.content != null + ); + html += + `` + + rows + .map((ind) => { + const target = optimizeSet.has(ind.key) ? " lab-formulate-row--target" : ""; + const dc = ind.min != null || ind.max != null ? diffClass(ind.diff) : "lab-sandbox-diff--na"; + const norm = + ind.min != null && ind.max != null + ? `${formatNum(ind.min)}–${formatNum(ind.max)}` + : ind.max != null + ? `≤ ${formatNum(ind.max)}` + : ind.min != null + ? `≥ ${formatNum(ind.min)}` + : "—"; + return ( + `` + + `` + + `` + ); + }) + .join("") + + `
    ПоказательФактСтандартΔ
    ${escapeHtml(ind.label || ind.key)}${formatNum(ind.content)}${norm}${formatNum(ind.diff)}
    `; + el.innerHTML = html; + } + + async function loadCatalogs() { + const [compData, profData] = await Promise.all([ + api("/api/lab/formulate/components"), + api("/api/lab/animal-profiles?ration_type=DAIRY"), + ]); + catalog = compData.components || []; + groups = (compData.groups || []).slice().sort((a, b) => a.step - b.step); + profiles = profData.profiles || []; + buildWizard(); + const profileSel = document.getElementById("labFormulateProfile"); + if (profileSel) { + const labDefault = profiles.find((p) => p.profileKey === "lab_math_dairy_01") || profiles[0]; + profileSel.innerHTML = profileOptions(labDefault?.id); + if (labDefault) fillProfileFields(labDefault.id); + } + setStep(1); + } + + async function labFormulateRun() { + const params = readFormulateParams(); + const profile = profiles.find((p) => String(p.id) === String(params.profileId)); + if (!params.profileId) throw new Error("Выбери стандарт чистоты — я предупреждал"); + if (params.normsMethod !== "wesp") { + const { mass, milk } = resolveMassMilk(profile); + if (mass == null || milk == null) { + throw new Error("Для Москва/Петербург нужны масса и удой — заполни поля или выбери профиль с удоем"); + } + params.massKg = mass; + params.milkYieldKg = milk; + } + if ((params.groupSelections.rough || []).length < 1) { + throw new Error("Без базы не готовим. Минимум один грубый корм"); + } + if (totalPicked() < 3) throw new Error("Минимум три реагента. Нам нужно готовить"); + const data = await api("/api/lab/formulate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + cache: "no-store", + body: JSON.stringify({ + profileId: params.profileId, + groupSelections: params.groupSelections, + massKg: params.massKg, + milkYieldKg: params.milkYieldKg, + totalKgPerHead: params.totalKgPerHead, + costWeight: params.costWeight, + optimizeKeys: params.optimizeKeys, + normsMethod: params.normsMethod, + normsParams: params.normsParams, + }), + }); + renderResult(data); + return data; + } + + function selectLabForGroup(gid) { + catalog + .filter((c) => c.eligible && c.feedGroup === gid && String(c.name || "").startsWith("LAB тест —")) + .forEach((c) => { + const cb = document.querySelector( + `.lab-formulate-gcb[data-group="${CSS.escape(gid)}"][value="${CSS.escape(c.id)}"]` + ); + if (cb) cb.checked = true; + }); + updateCounters(); + } + + function bindEvents() { + document.getElementById("labFormulateRun")?.addEventListener("click", async () => { + try { + await labFormulateRun(); + } catch (err) { + formulateError(String(err.message || err)); + } finally { + updateCounters(); + } + }); + + document.getElementById("labFormulateProfile")?.addEventListener("change", (e) => { + fillProfileFields(e.target.value); + }); + + for (const id of [ + "labFormulateMass", + "labFormulateMilk", + "labFormulateTotalKg", + "labFormulateCostWeight", + "labFormulateFat", + "labFormulateLactation", + "labFormulateKonc", + ]) { + document.getElementById(id)?.addEventListener("input", scheduleOptimizeKeysRefresh); + } + document.getElementById("labFormulateNormsMethod")?.addEventListener("change", () => { + syncNormsMethodUi(); + scheduleOptimizeKeysRefresh(); + }); + + document.getElementById("labFormulatePanes")?.addEventListener("input", (e) => { + if (e.target.classList.contains("lab-formulate-filter")) { + renderGroupTable(e.target.dataset.group, e.target.value); + } + }); + + document.getElementById("labFormulatePanes")?.addEventListener("change", (e) => { + if (e.target.classList.contains("lab-formulate-gcb")) updateCounters(); + }); + + document.getElementById("labFormulatePanes")?.addEventListener("click", (e) => { + const next = e.target.closest(".lab-formulate-next"); + const back = e.target.closest(".lab-formulate-back"); + const skip = e.target.closest(".lab-formulate-skip"); + if (next) { + const step = Number(next.dataset.step); + const g = groups.find((x) => x.step === step); + if (g?.required && selectedForGroup(g.id).length < (g.minPick || 1)) { + formulateError(`В «${g.label}» нужно минимум ${g.minPick}. Всё под контролем — выбери`); + return; + } + setStep(step + 1); + } + if (back) setStep(Number(back.dataset.step) - 1); + if (skip) setStep(Number(skip.dataset.step) + 1); + }); + + document.getElementById("labFormulateSteps")?.addEventListener("click", (e) => { + const btn = e.target.closest(".lab-formulate-step"); + if (!btn) return; + const step = Number(btn.dataset.step); + if (!canGoToStep(step)) return; + setStep(step); + }); + + document.addEventListener("keydown", (e) => { + if (e.altKey && e.key === "l") { + selectLabForGroup("rough"); + selectLabForGroup("succulent"); + selectLabForGroup("concentrate"); + } + }); + } + + function init() { + bindEvents(); + syncNormsMethodUi(); + loadCatalogs().catch((err) => { + const msg = err.message || String(err); + formulateError(msg); + const panes = document.getElementById("labFormulatePanes"); + if (panes) panes.innerHTML = `

    ${escapeHtml(msg)}

    `; + }); + } + + global.labFormulateRun = labFormulateRun; + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(window); diff --git a/apps/web/public/wesp/js/pages/lab-heisenberg-prime.js b/apps/web/public/wesp/js/pages/lab-heisenberg-prime.js new file mode 100644 index 0000000..6027828 --- /dev/null +++ b/apps/web/public/wesp/js/pages/lab-heisenberg-prime.js @@ -0,0 +1,93 @@ +(function (global) { + "use strict"; + + const MAX_MS = 5000; + const MIN_MS = 2400; + const DEFAULT_MS = 4000; + + const SOURCE_LABELS = { + agrostar: "Поставка AgroStar", + sandbox: "Песочница рациона", + formulate: "Автоготовка", + lab: "Лаборатория", + }; + + let overlay = null; + let hideTimer = null; + + function ensureOverlay() { + if (overlay) return overlay; + overlay = document.createElement("div"); + overlay.id = "labHeisenbergPrime"; + overlay.className = "lab-heisenberg-prime-overlay"; + overlay.hidden = true; + overlay.innerHTML = + '' + + '" + + '' + + '' + + '"; + document.body.appendChild(overlay); + return overlay; + } + + function sourceLabel(source) { + const key = String(source || "lab").toLowerCase(); + return SOURCE_LABELS[key] || SOURCE_LABELS.lab; + } + + function dismiss() { + if (hideTimer) { + clearTimeout(hideTimer); + hideTimer = null; + } + document.body.classList.remove("lab-heisenberg-prime-active"); + if (overlay) overlay.hidden = true; + } + + /** + * @param {string} reason — почему сработал prime (показываем пользователю) + * @param {{ source?: string, durationMs?: number }} [options] + */ + function trigger(reason, options) { + if (!document.body.classList.contains("lab-sandbox-page")) return; + + const opts = options || {}; + const text = String(reason || "Что-то пошло не по плану").trim(); + const duration = Math.min(MAX_MS, Math.max(MIN_MS, Number(opts.durationMs) || DEFAULT_MS)); + + global.WespZootechNotify?.dismissAll?.(); + + dismiss(); + + const el = ensureOverlay(); + const reasonEl = el.querySelector(".lab-heisenberg-prime__reason"); + const sourceEl = el.querySelector(".lab-heisenberg-prime__source"); + if (reasonEl) reasonEl.textContent = text; + + el.hidden = false; + requestAnimationFrame(() => { + document.body.classList.add("lab-heisenberg-prime-active"); + }); + + hideTimer = setTimeout(dismiss, duration); + } + + /** Ошибка на /lab: только prime (без toast/alert — они ломают сцену). */ + function error(reason, source) { + trigger(reason, { source: source || "lab" }); + } + + global.WespLabHeisenbergPrime = { + trigger, + dismiss, + error, + MAX_MS, + SOURCE_LABELS, + }; +})(window); diff --git a/apps/web/public/wesp/js/pages/lab-master-panel.js b/apps/web/public/wesp/js/pages/lab-master-panel.js new file mode 100644 index 0000000..c18fc2c --- /dev/null +++ b/apps/web/public/wesp/js/pages/lab-master-panel.js @@ -0,0 +1,272 @@ +(function (global) { + "use strict"; + + function escapeHtml(s) { + return String(s ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function createLabMasterPanel() { + let root = null; + let recipeId = null; + let state = null; + let components = []; + let profiles = []; + async function api(path, opts) { + const resp = await fetch(path, opts); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) throw new Error(data.message || "Ошибка API"); + return data; + } + + async function ensureCatalogs(rationType) { + const [compResp, profData] = await Promise.all([ + components.length ? Promise.resolve(components) : fetch("/api/components").then((r) => r.json()), + api(`/api/lab/animal-profiles${rationType ? `?ration_type=${encodeURIComponent(rationType)}` : ""}`), + ]); + if (Array.isArray(compResp)) components = compResp; + profiles = profData.profiles || []; + } + + async function loadRecipes() { + const data = await api("/api/lab/recipes"); + return data.recipes || []; + } + + async function loadRation(id) { + return api(`/api/lab/rations/${encodeURIComponent(id)}`); + } + + function componentOptionsHtml(selectedId) { + const opts = ['']; + components.forEach((c) => { + const sel = String(c.id) === String(selectedId) ? " selected" : ""; + opts.push(``); + }); + return opts.join(""); + } + + function profileOptionsHtml(selectedId) { + const opts = ['']; + profiles.forEach((p) => { + const sel = String(p.id) === String(selectedId) ? " selected" : ""; + opts.push( + `` + ); + }); + return opts.join(""); + } + + function render() { + if (!root) return; + const indicators = (state?.rationResults?.indicators || []).slice(0, 8); + const lines = state?.lines || []; + root.innerHTML = + `
    ` + + `
    ` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `
    ` + + `
    Зоотехнический мастер · ${escapeHtml(state?.recipeName || "")}
    ` + + `` + + `` + + (lines.length + ? lines + .map( + (l, i) => + `` + + `` + + `` + + `` + + `` + + `` + + `` + ) + .join("") + : ``) + + `
    Компонент WESPкг/деньВ рац.Комб.
    Нет строк — добавьте или создайте из рецепта
    ` + + `
    ` + + indicators + .map( + (ind) => + `${escapeHtml(ind.label)}: ${escapeHtml(ind.content)} ${escapeHtml(ind.unit)}` + ) + .join("") + + `
    `; + + const sel = root.querySelector("[data-lab-recipe-select]"); + if (sel && sel.options.length === 0) { + loadRecipes().then((recipes) => { + sel.innerHTML = + '' + + recipes + .map( + (r) => + `` + ) + .join(""); + }); + } + } + + async function reload() { + if (!recipeId) return; + try { + state = await loadRation(recipeId); + await ensureCatalogs(state.rationType); + if (!state.exists) { + const ok = await global.WespDialog?.confirm?.( + "Создать мастер из текущего рецепта?", + { title: "Мастер рациона" } + ); + if (ok) { + await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/seed-from-execution`, { + method: "POST", + }); + state = await loadRation(recipeId); + await ensureCatalogs(state.rationType); + } else { + await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/ensure-empty`, { + method: "POST", + }); + state = await loadRation(recipeId); + await ensureCatalogs(state.rationType); + } + } + render(); + } catch (e) { + global.WespZootechNotify?.createAdapter?.()?.error?.(e.message, { fallback: "Не удалось выполнить операцию" }); + } + } + + function collectLines() { + if (!root || !state) return []; + return (state.lines || []).map((line, i) => { + const row = root.querySelector(`tr[data-line-idx="${i}"]`); + if (!row) return line; + const componentId = row.querySelector('[data-field="componentId"]')?.value || null; + const comp = components.find((c) => String(c.id) === String(componentId)); + return { + ...line, + componentId, + ingredientName: comp?.name || line.ingredientName, + dailyKg: parseFloat(row.querySelector('[data-field="dailyKg"]')?.value) || null, + inRation: row.querySelector('[data-field="inRation"]')?.checked, + inCompound: row.querySelector('[data-field="inCompound"]')?.checked, + }; + }); + } + + function collectPayload() { + const rationType = root.querySelector("[data-lab-ration-type]")?.value || state?.rationType; + return { + lines: collectLines(), + rationType, + animalProfileId: root.querySelector("[data-lab-profile-select]")?.value || null, + }; + } + + return { + mount(el) { + root = el; + root.addEventListener("click", async (ev) => { + const btn = ev.target.closest("[data-action]"); + if (!btn || !recipeId) return; + const action = btn.getAttribute("data-action"); + try { + if (action === "lab-add-line") { + state.lines = [...(state?.lines || []), { + componentId: "", + ingredientName: "", + dailyKg: 0, + inRation: true, + inCompound: false, + }]; + render(); + return; + } + if (action === "lab-remove-line") { + const idx = parseInt(btn.getAttribute("data-line-idx"), 10); + if (!Number.isNaN(idx)) { + state.lines = (state.lines || []).filter((_, i) => i !== idx); + render(); + } + return; + } + if (action === "lab-recalculate") { + await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/recalculate`, { + method: "POST", + }); + await reload(); + } + if (action === "lab-save") { + await api(`/api/lab/rations/${encodeURIComponent(recipeId)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(collectPayload()), + }); + await reload(); + global.WespZootechNotify?.createAdapter?.()?.success?.("Мастер сохранён"); + } + if (action === "lab-print-ration" || action === "lab-print-compound") { + const mode = action === "lab-print-compound" ? "compound" : "ration"; + global.open?.( + `/api/lab/rations/${encodeURIComponent(recipeId)}/print?mode=${mode}`, + "_blank", + "noopener" + ); + } + } catch (e) { + global.WespZootechNotify?.createAdapter?.()?.error?.(e.message, { fallback: "Не удалось выполнить операцию" }); + } + }); + root.addEventListener("change", async (ev) => { + if (ev.target.matches("[data-lab-recipe-select]")) { + recipeId = ev.target.value; + reload(); + return; + } + if (ev.target.matches("[data-lab-ration-type]")) { + const rt = ev.target.value; + state = { ...state, rationType: rt }; + profiles = []; + await ensureCatalogs(rt); + render(); + return; + } + if (ev.target.matches("[data-lab-profile-select]")) { + const profile = profiles.find((p) => String(p.id) === String(ev.target.value)); + if (profile?.rationType) { + state = { ...state, rationType: profile.rationType, animalProfileId: profile.id }; + const typeSel = root.querySelector("[data-lab-ration-type]"); + if (typeSel) typeSel.value = profile.rationType; + } + } + }); + }, + destroy() { + root = null; + state = null; + }, + reload, + setRecipeId(id) { + recipeId = id; + reload(); + }, + }; + } + + global.WespLabMasterPanel = { createLabMasterPanel }; +})(window); diff --git a/apps/web/public/wesp/js/pages/lab-profiles.js b/apps/web/public/wesp/js/pages/lab-profiles.js new file mode 100644 index 0000000..b3d63ea --- /dev/null +++ b/apps/web/public/wesp/js/pages/lab-profiles.js @@ -0,0 +1,777 @@ +(function (global) { + "use strict"; + + const GFE_DYNAMIC_KEYS = ["usp", "nel"]; + + let catalog = []; + let profiles = []; + let selectedId = null; + let draftNorms = {}; + let dynamicNorms = {}; + let normCoverage = null; + let normSourceMap = {}; + let resolvedNormsPreview = {}; + let seedCatalog = []; + let seedCatalogRation = null; + let normsCoverageTimer = null; + + async function api(path, opts) { + const resp = await fetch(path, opts); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) throw new Error(data.message || `Наука, чёрт возьми — ошибка ${resp.status}`); + return data; + } + + function escapeHtml(s) { + return String(s ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function notify() { + return global.WespZootechNotify?.createAdapter?.(); + } + + function readMass() { + const raw = document.getElementById("labProfileMass")?.value?.trim(); + if (raw === "") return null; + const n = Number(raw); + return Number.isNaN(n) ? null : n; + } + + function readMilk() { + const raw = document.getElementById("labProfileMilk")?.value?.trim(); + if (raw === "") return null; + const n = Number(raw); + return Number.isNaN(n) ? null : n; + } + + function readNumInput(id) { + const raw = document.getElementById(id)?.value?.trim(); + if (raw === "") return null; + const n = Number(raw); + return Number.isNaN(n) ? null : n; + } + + function readNormsMethod() { + return document.getElementById("labProfileNormsMethod")?.value || "wesp"; + } + + function readNormsParams() { + const method = readNormsMethod(); + if (method === "wesp") return {}; + const params = { + milkFatPct: readNumInput("labProfileFat") ?? 4, + lactationNo: readNumInput("labProfileLactation") ?? 2, + }; + if (method === "racion_piter") { + params.koncOeSv = readNumInput("labProfileKonc") ?? 10.3; + } + return params; + } + + function syncNormsMethodUi() { + const method = readNormsMethod(); + const racionBox = document.getElementById("labProfileRacionFields"); + const piterOnly = document.querySelectorAll(".lab-profile-piter-only"); + if (racionBox) racionBox.classList.toggle("d-none", method === "wesp"); + piterOnly.forEach((el) => el.classList.toggle("d-none", method !== "racion_piter")); + syncSourceFilterOptions(method); + renderNormsSummary(); + scheduleNormsCoverageRefresh(); + } + + function syncSourceFilterOptions(method) { + const sel = document.getElementById("labProfilesSourceFilter"); + if (!sel) return; + sel.querySelectorAll("option[data-racion-only]").forEach((opt) => { + opt.hidden = method === "wesp"; + opt.disabled = method === "wesp"; + }); + if (method === "wesp" && ["racion", "racion_only", "derived", "new_racion"].includes(sel.value)) { + sel.value = "all"; + } + } + + function buildNormSourceMap(coverage, storedNorms) { + const map = {}; + if (coverage) { + (coverage.racion || []).forEach((k) => { + map[k] = "racion"; + }); + (coverage.derived || []).forEach((k) => { + map[k] = "derived"; + }); + (coverage.fallback || []).forEach((k) => { + if (!map[k]) map[k] = "fallback"; + }); + (coverage.missing || []).forEach((k) => { + if (!map[k]) map[k] = "missing"; + }); + } + Object.keys(storedNorms || {}).forEach((k) => { + if (!map[k]) map[k] = "stored"; + }); + return map; + } + + function normSourceLabel(src) { + const labels = { + racion: "RACION", + derived: "расч.", + fallback: "WESP", + stored: "БД", + missing: "—", + gfe: "GfE", + }; + return labels[src] || "—"; + } + + function normSourceBadge(src) { + if (!src || src === "missing") return ''; + const cls = `norm-source-badge norm-source-badge--${src}`; + return `${escapeHtml(normSourceLabel(src))}`; + } + + function isInDb(key) { + const b = draftNorms[key] || {}; + return b.min != null || b.max != null; + } + + function matchesSourceFilter(key) { + const filter = document.getElementById("labProfilesSourceFilter")?.value || "all"; + if (filter === "all") return true; + const src = normSourceMap[key]; + if (filter === "in_db") return isInDb(key); + if (filter === "seed") return src === "fallback" || src === "stored"; + if (filter === "racion") return src === "racion" || src === "derived"; + if (filter === "racion_only") return src === "racion"; + if (filter === "derived") return src === "derived"; + if (filter === "new_racion") return src === "racion" || src === "derived"; + return true; + } + + function countNormSources() { + const counts = { inDb: 0, racion: 0, derived: 0, fallback: 0, stored: 0, missing: 0 }; + const seen = new Set(); + catalog.forEach((c) => seen.add(c.key)); + Object.keys(draftNorms).forEach((k) => seen.add(k)); + if (normCoverage) { + (normCoverage.racion || []).forEach((k) => seen.add(k)); + (normCoverage.derived || []).forEach((k) => seen.add(k)); + (normCoverage.fallback || []).forEach((k) => seen.add(k)); + (normCoverage.missing || []).forEach((k) => seen.add(k)); + } + seen.forEach((key) => { + if (isInDb(key)) counts.inDb += 1; + const src = normSourceMap[key]; + if (src === "racion") counts.racion += 1; + else if (src === "derived") counts.derived += 1; + else if (src === "fallback") counts.fallback += 1; + else if (src === "stored") counts.stored += 1; + else if (src === "missing") counts.missing += 1; + }); + return counts; + } + + function renderNormsSummary() { + const panel = document.getElementById("labProfilesNormsSummary"); + const text = document.getElementById("labProfilesNormsSummaryText"); + const form = document.getElementById("labProfilesForm"); + if (!panel || !text || !form || form.hidden) { + if (panel) panel.hidden = true; + return; + } + const method = readNormsMethod(); + const counts = countNormSources(); + if (method === "wesp") { + panel.hidden = false; + text.textContent = `В БД ${counts.inDb} показ. · справочник/ручные ${counts.stored + counts.fallback}`; + return; + } + if (!normCoverage) { + panel.hidden = false; + text.textContent = "Укажи массу и удой — разберём источники RACION"; + return; + } + panel.hidden = false; + const racTotal = counts.racion + counts.derived; + const withMin = normCoverage.withMin != null ? normCoverage.withMin : racTotal + counts.fallback; + text.textContent = + `В БД ${counts.inDb} · RACION ${racTotal} (NPitV ${counts.racion} + расч. ${counts.derived}) · ` + + `WESP fallback ${counts.fallback} · с min ${withMin}/${normCoverage.total ?? "—"}`; + } + + function applyNormsCoveragePayload(payload) { + normCoverage = payload.coverage || null; + resolvedNormsPreview = payload.resolvedIndicators || {}; + if (payload.dynamicNorms) { + dynamicNorms = { ...dynamicNorms, ...payload.dynamicNorms }; + } + normSourceMap = buildNormSourceMap(normCoverage, draftNorms); + renderNormsSummary(); + renderNormsTable(); + } + + async function refreshNormsCoverage() { + const method = readNormsMethod(); + const mass = readMass(); + const milk = readMilk(); + if (method === "wesp") { + normCoverage = null; + resolvedNormsPreview = {}; + normSourceMap = buildNormSourceMap(null, draftNorms); + GFE_DYNAMIC_KEYS.forEach((key) => { + if (dynamicNorms[key]?.min != null && !isInDb(key)) normSourceMap[key] = "gfe"; + }); + renderNormsSummary(); + renderNormsTable(); + return; + } + if (mass == null || milk == null) { + normCoverage = null; + normSourceMap = buildNormSourceMap(null, draftNorms); + renderNormsSummary(); + renderNormsTable(); + return; + } + const q = new URLSearchParams({ method, mass_kg: String(mass), milk_yield_kg: String(milk) }); + if (selectedId) q.set("profile_id", selectedId); + const np = readNormsParams(); + if (np.milkFatPct != null) q.set("milk_fat_pct", String(np.milkFatPct)); + if (np.lactationNo != null) q.set("lactation_no", String(np.lactationNo)); + if (np.koncOeSv != null) q.set("konc_oe_sv", String(np.koncOeSv)); + try { + const preview = await api(`/api/lab/norms-preview?${q.toString()}`); + applyNormsCoveragePayload(preview); + } catch (err) { + notify()?.error?.(err.message); + } + } + + function scheduleNormsCoverageRefresh() { + if (normsCoverageTimer) clearTimeout(normsCoverageTimer); + normsCoverageTimer = setTimeout(() => { + refreshNormsCoverage().catch((e) => notify()?.error?.(e.message, { fallback: "Не удалось выполнить операцию" })); + }, 350); + } + + function fillNormsParamsFromProfile(p) { + const method = p.normsMethod || "wesp"; + const sel = document.getElementById("labProfileNormsMethod"); + if (sel) sel.value = method; + const np = p.normsParams || {}; + const fat = document.getElementById("labProfileFat"); + const lact = document.getElementById("labProfileLactation"); + const konc = document.getElementById("labProfileKonc"); + if (fat) fat.value = np.milkFatPct != null ? np.milkFatPct : 4; + if (lact) lact.value = np.lactationNo != null ? np.lactationNo : 2; + if (konc) konc.value = np.koncOeSv != null ? np.koncOeSv : 10.3; + syncNormsMethodUi(); + } + + /** GfE 2001 — parity app/lab/calc/gfe_norms.py */ + function gfeUspMinG(massKg, milkKg) { + if (!massKg || massKg <= 0) return null; + return 0.09 * massKg ** 0.75 * 6.25 + Math.max(milkKg || 0, 0) * 85; + } + + function gfeNelMinMj(massKg, milkKg) { + if (!massKg || massKg <= 0) return null; + return 0.293 * massKg ** 0.75 + Math.max(milkKg || 0, 0) * 3.3; + } + + function computeDynamicNormsLocal() { + const mass = readMass(); + const milk = readMilk(); + const out = {}; + const usp = gfeUspMinG(mass, milk); + if (usp != null) { + out.usp = { min: usp, formula: "0.09×масса^0.75×6.25 + удой×85" }; + } + const nel = gfeNelMinMj(mass, milk); + if (nel != null) { + out.nel = { min: nel, formula: "0.293×масса^0.75 + удой×3.3" }; + } + return out; + } + + function formatGfe(v) { + const n = Number(v); + if (Number.isNaN(n)) return "—"; + return Math.abs(n) >= 100 ? n.toFixed(1) : n.toFixed(2); + } + + function refreshDynamicNorms() { + dynamicNorms = computeDynamicNormsLocal(); + renderGfePanel(); + renderNormsTable(); + } + + function renderGfePanel() { + const panel = document.getElementById("labProfilesGfePanel"); + const vals = document.getElementById("labProfilesGfeValues"); + const form = document.getElementById("labProfilesForm"); + if (!panel || !form || form.hidden) { + if (panel) panel.hidden = true; + return; + } + const mass = readMass(); + if (!mass || mass <= 0) { + panel.hidden = true; + return; + } + panel.hidden = false; + const parts = []; + if (dynamicNorms.usp?.min != null) { + parts.push(`min уСП ≈ ${formatGfe(dynamicNorms.usp.min)} г`); + } + if (dynamicNorms.nel?.min != null) { + parts.push(`min ЧЭЛ ≈ ${formatGfe(dynamicNorms.nel.min)} МДж`); + } + if (vals) vals.textContent = parts.join(" · ") || "—"; + const enabled = parts.length > 0; + ["labProfilesGfeApplyUsp", "labProfilesGfeApplyNel", "labProfilesGfeApplyAll"].forEach((id) => { + const btn = document.getElementById(id); + if (btn) btn.disabled = !enabled; + }); + } + + function applyGfeMin(keys) { + collectNormsFromTable(); + let applied = 0; + keys.forEach((key) => { + const v = dynamicNorms[key]?.min; + if (v == null) return; + const cur = draftNorms[key] || {}; + if (cur.min != null) return; + draftNorms[key] = { ...cur, min: v }; + applied += 1; + }); + renderNormsTable(); + if (applied) { + notify()?.success?.("Подставлено по GfE — нажми «Запомни это»"); + } else { + notify()?.error?.("Нижние границы уже заданы или нет массы"); + } + } + + function syncDeleteButton() { + const btn = document.getElementById("labProfilesDelete"); + if (btn) btn.hidden = !selectedId; + } + + function readRationType() { + return document.getElementById("labProfileType")?.value || "DAIRY"; + } + + function syncSeedPanelVisibility() { + const panel = document.getElementById("labProfilesSeedPanel"); + const form = document.getElementById("labProfilesForm"); + if (panel) panel.hidden = !form || form.hidden; + } + + function filteredSeedCatalog() { + const q = (document.getElementById("labProfilesSeedSearch")?.value || "").trim().toLowerCase(); + if (!q) return seedCatalog; + return seedCatalog.filter((e) => { + const hay = `${e.externalNo} ${e.label} ${e.massKg ?? ""}`.toLowerCase(); + return hay.includes(q); + }); + } + + function renderSeedSelect() { + const sel = document.getElementById("labProfilesSeedSelect"); + const applyBtn = document.getElementById("labProfilesSeedApply"); + if (!sel) return; + const prev = sel.value; + const rows = filteredSeedCatalog(); + const opts = [''].concat( + rows.map((e) => { + const mass = e.massKg != null ? ` · ${e.massKg} кг` : ""; + const cnt = e.indicatorCount != null ? ` · ${e.indicatorCount} показ.` : ""; + return ``; + }) + ); + sel.innerHTML = opts.join(""); + if (prev && rows.some((e) => String(e.externalNo) === prev)) { + sel.value = prev; + } + if (applyBtn) applyBtn.disabled = !sel.value; + } + + async function loadSeedCatalog(rationType) { + const ration = rationType || readRationType(); + if (seedCatalogRation === ration && seedCatalog.length) { + renderSeedSelect(); + return; + } + const data = await api(`/api/lab/seed-norms-catalog?ration_type=${encodeURIComponent(ration)}`); + seedCatalog = data.entries || []; + seedCatalogRation = ration; + renderSeedSelect(); + } + + async function applySeedNorms() { + const sel = document.getElementById("labProfilesSeedSelect"); + const externalNo = sel?.value; + if (!externalNo) { + notify()?.error?.("Выбери строку справочника"); + return; + } + const filled = Object.values(draftNorms).filter((b) => b.min != null || b.max != null).length; + if (filled > 0) { + const ok = global.confirm("Заменить текущие границы нормами из справочника?"); + if (!ok) return; + } + const ration = readRationType(); + const entry = await api( + `/api/lab/seed-norms-catalog/${encodeURIComponent(externalNo)}?ration_type=${encodeURIComponent(ration)}` + ); + draftNorms = { ...(entry.indicators || {}) }; + if (entry.massKg != null) { + document.getElementById("labProfileMass").value = entry.massKg; + } + document.getElementById("labProfileExternal").value = entry.externalNo; + const labelEl = document.getElementById("labProfileLabel"); + if (labelEl && !labelEl.value.trim()) { + labelEl.value = entry.label || ""; + } + refreshDynamicNorms(); + notify()?.success?.(`Загружено ${Object.keys(draftNorms).length} показателей — нажми «Запомни это»`); + } + + function filteredProfiles() { + const type = document.getElementById("labProfilesType")?.value || ""; + const q = (document.getElementById("labProfilesSearch")?.value || "").trim().toLowerCase(); + return profiles.filter((p) => { + if (type && p.rationType !== type) return false; + if (!q) return true; + const hay = `${p.profileKey} ${p.label}`.toLowerCase(); + return hay.includes(q); + }); + } + + function renderList() { + const el = document.getElementById("labProfilesList"); + if (!el) return; + const rows = filteredProfiles(); + if (!rows.length) { + el.innerHTML = '
    Стандартов нет — создай новый
    '; + return; + } + el.innerHTML = rows + .map( + (p) => + `` + ) + .join(""); + } + + function indicatorRows() { + const onlyCalc = document.getElementById("labProfilesOnlyCalc")?.checked; + const onlyFilled = document.getElementById("labProfilesOnlyFilled")?.checked; + const q = (document.getElementById("labProfilesNormSearch")?.value || "").trim().toLowerCase(); + const extraKeys = Object.keys(draftNorms).filter((k) => !catalog.some((c) => c.key === k)); + const rows = [ + ...catalog.map((c) => ({ ...c })), + ...extraKeys.map((k) => ({ key: k, label: k, unit: "", inCalc: false })), + ]; + return rows.filter((row) => { + if (onlyCalc && !row.inCalc) return false; + const b = draftNorms[row.key] || {}; + const has = b.min != null || b.max != null; + if (onlyFilled && !has) return false; + if (!matchesSourceFilter(row.key)) return false; + if (q) { + const hay = `${row.key} ${row.label}`.toLowerCase(); + if (!hay.includes(q)) return false; + } + return true; + }); + } + + function renderNormsTable() { + const tbody = document.getElementById("labProfilesNormsBody"); + const countEl = document.getElementById("labProfilesNormCount"); + if (!tbody) return; + const rows = indicatorRows(); + tbody.innerHTML = rows + .map((row) => { + const b = draftNorms[row.key] || {}; + const min = b.min != null ? b.min : ""; + const max = b.max != null ? b.max : ""; + const badge = row.inCalc ? 'в деле' : ""; + const unit = row.unit ? ` ${escapeHtml(row.unit)}` : ""; + const dyn = dynamicNorms[row.key]; + const gfeHint = + dyn?.min != null && min === "" + ? `GfE ≈ ${formatGfe(dyn.min)}${dyn.formula ? ` (${escapeHtml(dyn.formula)})` : ""}` + : ""; + const gfeBadge = GFE_DYNAMIC_KEYS.includes(row.key) + ? 'GfE' + : ""; + let src = normSourceMap[row.key]; + if (!src && isInDb(row.key)) src = "stored"; + const dbBadge = isInDb(row.key) ? 'БД' : ""; + const srcBadge = normSourceBadge(src); + const resolved = resolvedNormsPreview[row.key]; + const resolvedHint = + resolved?.min != null && min !== "" && Number(min) !== Number(resolved.min) + ? `RACION ≈ ${formatGfe(resolved.min)}` + : resolved?.min != null && min === "" + ? `RACION ≈ ${formatGfe(resolved.min)} (не в БД)` + : ""; + return ( + `` + + `${escapeHtml(row.label)}${badge}${gfeBadge}${unit}${gfeHint}${resolvedHint}` + + `${dbBadge}${srcBadge}` + + `` + + `` + + `` + ); + }) + .join(""); + if (countEl) { + const filled = Object.values(draftNorms).filter((b) => b.min != null || b.max != null).length; + const counts = countNormSources(); + const method = readNormsMethod(); + const srcPart = + method === "wesp" + ? ` · в БД ${counts.inDb}` + : ` · БД ${counts.inDb} · RACION ${counts.racion + counts.derived} · WESP ${counts.fallback}`; + countEl.textContent = `В таблице ${rows.length} · с границами ${filled}${srcPart}`; + } + renderNormsSummary(); + } + + function collectNormsFromTable() { + const out = { ...draftNorms }; + document.querySelectorAll("#labProfilesNormsBody tr[data-key]").forEach((tr) => { + const key = tr.getAttribute("data-key"); + if (!key) return; + const minIn = tr.querySelector('[data-bound="min"]'); + const maxIn = tr.querySelector('[data-bound="max"]'); + const minRaw = minIn?.value?.trim(); + const maxRaw = maxIn?.value?.trim(); + const min = minRaw === "" ? null : Number(minRaw); + const max = maxRaw === "" ? null : Number(maxRaw); + if (min == null && max == null) { + delete out[key]; + } else { + out[key] = { + min: Number.isNaN(min) ? null : min, + max: Number.isNaN(max) ? null : max, + }; + } + }); + draftNorms = out; + } + + async function selectProfile(id) { + selectedId = id; + syncDeleteButton(); + renderList(); + const empty = document.getElementById("labProfilesEmpty"); + const form = document.getElementById("labProfilesForm"); + if (!id) { + if (empty) empty.hidden = false; + if (form) form.hidden = true; + syncSeedPanelVisibility(); + return; + } + const p = await api(`/api/lab/animal-profiles/${encodeURIComponent(id)}`); + if (empty) empty.hidden = true; + if (form) form.hidden = false; + document.getElementById("labProfileKey").value = p.profileKey || ""; + document.getElementById("labProfileLabel").value = p.label || ""; + document.getElementById("labProfileType").value = p.rationType || "BEEF"; + document.getElementById("labProfileMass").value = p.massKg != null ? p.massKg : ""; + document.getElementById("labProfileMilk").value = p.milkYieldKg != null ? p.milkYieldKg : ""; + document.getElementById("labProfileExternal").value = p.externalNo != null ? p.externalNo : ""; + fillNormsParamsFromProfile(p); + draftNorms = { ...(p.norms || p.normsData?.indicators || {}) }; + normCoverage = p.coverage || p.normsData?.coverage || null; + resolvedNormsPreview = p.resolvedNorms || p.normsData?.resolvedIndicators || {}; + normSourceMap = buildNormSourceMap(normCoverage, draftNorms); + refreshDynamicNorms(); + syncSourceFilterOptions(readNormsMethod()); + renderNormsSummary(); + scheduleNormsCoverageRefresh(); + syncSeedPanelVisibility(); + loadSeedCatalog(p.rationType || "DAIRY").catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" })); + } + + function newProfile() { + selectedId = null; + syncDeleteButton(); + renderList(); + document.getElementById("labProfilesEmpty").hidden = true; + document.getElementById("labProfilesForm").hidden = false; + document.getElementById("labProfileKey").value = ""; + document.getElementById("labProfileLabel").value = ""; + document.getElementById("labProfileType").value = "DAIRY"; + document.getElementById("labProfileMass").value = ""; + document.getElementById("labProfileMilk").value = ""; + document.getElementById("labProfileExternal").value = ""; + fillNormsParamsFromProfile({ normsMethod: "wesp", normsParams: {} }); + draftNorms = {}; + normCoverage = null; + resolvedNormsPreview = {}; + normSourceMap = {}; + refreshDynamicNorms(); + syncSourceFilterOptions("wesp"); + renderNormsSummary(); + syncSeedPanelVisibility(); + loadSeedCatalog("DAIRY").catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" })); + } + + async function saveProfile() { + collectNormsFromTable(); + const key = document.getElementById("labProfileKey")?.value?.trim(); + const label = document.getElementById("labProfileLabel")?.value?.trim(); + const rationType = document.getElementById("labProfileType")?.value || "BEEF"; + const massRaw = document.getElementById("labProfileMass")?.value?.trim(); + const milkRaw = document.getElementById("labProfileMilk")?.value?.trim(); + const extRaw = document.getElementById("labProfileExternal")?.value?.trim(); + if (!key || !label) { + notify()?.error?.("Укажи код и название — я предупреждал"); + return; + } + const payload = { + profileKey: key, + label, + rationType, + indicators: draftNorms, + normsMethod: readNormsMethod(), + normsParams: readNormsParams(), + }; + if (massRaw !== "") payload.massKg = Number(massRaw); + payload.milkYieldKg = milkRaw === "" ? null : Number(milkRaw); + if (extRaw !== "") payload.externalNo = Number(extRaw); + const method = selectedId ? "PUT" : "POST"; + const url = selectedId + ? `/api/lab/animal-profiles/${encodeURIComponent(selectedId)}` + : "/api/lab/animal-profiles"; + await api(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + notify()?.success?.("Стандарт сохранён. Ты чертовски прав"); + const typeFilter = document.getElementById("labProfilesType")?.value || ""; + const data = await api( + `/api/lab/animal-profiles${typeFilter ? `?ration_type=${encodeURIComponent(typeFilter)}` : ""}` + ); + profiles = data.profiles || []; + if (!selectedId && profiles.length) { + const hit = profiles.find((p) => p.profileKey === key); + if (hit) selectedId = hit.id; + } + renderList(); + if (selectedId) await selectProfile(selectedId); + } + + async function deleteProfile() { + if (!selectedId) return; + const label = document.getElementById("labProfileLabel")?.value?.trim() || selectedId; + if (!global.confirm(`Удалить «${label}»? Никаких полумер.`)) return; + await api(`/api/lab/animal-profiles/${encodeURIComponent(selectedId)}`, { method: "DELETE" }); + notify()?.success?.("Стандарт снесён. Say my name."); + selectedId = null; + syncDeleteButton(); + const typeFilter = document.getElementById("labProfilesType")?.value || ""; + const data = await api( + `/api/lab/animal-profiles${typeFilter ? `?ration_type=${encodeURIComponent(typeFilter)}` : ""}` + ); + profiles = data.profiles || []; + document.getElementById("labProfilesEmpty").hidden = false; + document.getElementById("labProfilesForm").hidden = true; + renderList(); + } + + async function init() { + const [cat, prof] = await Promise.all([ + api("/api/lab/norm-indicators"), + api("/api/lab/animal-profiles"), + ]); + catalog = cat.indicators || []; + profiles = prof.profiles || []; + renderList(); + } + + function bind() { + document.getElementById("labProfilesList")?.addEventListener("click", (ev) => { + const btn = ev.target.closest("button[data-id]"); + if (!btn) return; + selectProfile(btn.getAttribute("data-id")).catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" })); + }); + document.getElementById("labProfilesType")?.addEventListener("change", async () => { + const type = document.getElementById("labProfilesType")?.value || ""; + const data = await api( + `/api/lab/animal-profiles${type ? `?ration_type=${encodeURIComponent(type)}` : ""}` + ); + profiles = data.profiles || []; + renderList(); + }); + document.getElementById("labProfilesSearch")?.addEventListener("input", renderList); + document.getElementById("labProfilesOnlyFilled")?.addEventListener("change", renderNormsTable); + document.getElementById("labProfilesOnlyCalc")?.addEventListener("change", renderNormsTable); + document.getElementById("labProfilesSourceFilter")?.addEventListener("change", renderNormsTable); + document.getElementById("labProfilesNormSearch")?.addEventListener("input", renderNormsTable); + document.getElementById("labProfilesNormsRefresh")?.addEventListener("click", () => { + refreshNormsCoverage().catch((e) => notify()?.error?.(e.message, { fallback: "Не удалось выполнить операцию" })); + }); + document.getElementById("labProfilesNew")?.addEventListener("click", newProfile); + document.getElementById("labProfilesSave")?.addEventListener("click", () => { + saveProfile().catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" })); + }); + document.getElementById("labProfilesDelete")?.addEventListener("click", () => { + deleteProfile().catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" })); + }); + document.getElementById("labProfilesNormsBody")?.addEventListener("change", (ev) => { + if (ev.target.matches("input[data-bound]")) collectNormsFromTable(); + }); + document.getElementById("labProfileMass")?.addEventListener("input", () => { + refreshDynamicNorms(); + scheduleNormsCoverageRefresh(); + }); + document.getElementById("labProfileMilk")?.addEventListener("input", () => { + refreshDynamicNorms(); + scheduleNormsCoverageRefresh(); + }); + document.getElementById("labProfileNormsMethod")?.addEventListener("change", syncNormsMethodUi); + ["labProfileFat", "labProfileLactation", "labProfileKonc"].forEach((id) => { + document.getElementById(id)?.addEventListener("input", scheduleNormsCoverageRefresh); + }); + document.getElementById("labProfileType")?.addEventListener("change", () => { + seedCatalogRation = null; + loadSeedCatalog(readRationType()).catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" })); + }); + document.getElementById("labProfilesSeedSearch")?.addEventListener("input", renderSeedSelect); + document.getElementById("labProfilesSeedSelect")?.addEventListener("change", () => { + const applyBtn = document.getElementById("labProfilesSeedApply"); + const sel = document.getElementById("labProfilesSeedSelect"); + if (applyBtn && sel) applyBtn.disabled = !sel.value; + }); + document.getElementById("labProfilesSeedApply")?.addEventListener("click", () => { + applySeedNorms().catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" })); + }); + document.getElementById("labProfilesGfeApplyUsp")?.addEventListener("click", () => applyGfeMin(["usp"])); + document.getElementById("labProfilesGfeApplyNel")?.addEventListener("click", () => applyGfeMin(["nel"])); + document.getElementById("labProfilesGfeApplyAll")?.addEventListener("click", () => + applyGfeMin(GFE_DYNAMIC_KEYS) + ); + } + + document.addEventListener("DOMContentLoaded", () => { + bind(); + init().catch(() => notify()?.error?.(null, { fallback: "Не удалось выполнить операцию" })); + }); +})(window); diff --git a/apps/web/public/wesp/js/pages/lab-recipes-overlay.js b/apps/web/public/wesp/js/pages/lab-recipes-overlay.js new file mode 100644 index 0000000..3cf74ee --- /dev/null +++ b/apps/web/public/wesp/js/pages/lab-recipes-overlay.js @@ -0,0 +1,299 @@ +(function (global) { + "use strict"; + + let diffByComponent = new Map(); + let hooked = false; + + function getActiveRecipeId() { + const fromEdit = document.getElementById("recipeEdit")?.dataset?.recipeId; + if (fromEdit) return fromEdit; + return document.querySelector(".list-item.active[data-recipe-id]")?.dataset?.recipeId || null; + } + + function ensureToolbarButtons() { + const toolbar = document.querySelector("#recipeEdit .recipe-section-toolbar"); + if (!toolbar) return; + if (!toolbar.querySelector('[data-action="sync-to-master"]')) { + const syncBtn = document.createElement("button"); + syncBtn.type = "button"; + syncBtn.className = "btn btn-outline-secondary"; + syncBtn.setAttribute("data-action", "sync-to-master"); + syncBtn.innerHTML = 'В мастер'; + toolbar.insertBefore(syncBtn, toolbar.firstChild); + } + if (!toolbar.querySelector('[data-action="apply-from-master"]')) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "btn btn-outline-primary"; + btn.setAttribute("data-action", "apply-from-master"); + btn.hidden = true; + btn.innerHTML = 'Из мастера'; + const addBtn = toolbar.querySelector('[data-action="add-ingredient"]'); + if (addBtn) toolbar.insertBefore(btn, addBtn); + else toolbar.appendChild(btn); + } + } + + function ensureKHubButton() { + const actions = document.querySelector("#recipeEdit .recipe-edit-header-actions .d-flex"); + if (!actions || actions.querySelector('[data-action="open-lab-k-hub"]')) return; + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "btn btn-outline-secondary recipe-edit-header-icon-btn"; + btn.setAttribute("data-action", "open-lab-k-hub"); + btn.title = "Открыть зоотех-мастер в K-hub"; + btn.setAttribute("aria-label", "K-hub"); + btn.innerHTML = ''; + actions.insertBefore(btn, actions.firstChild); + } + + function ensureRationTypeField() { + const grid = document.querySelector("#recipeEdit .recipe-form-grid"); + if (!grid || document.getElementById("rationType")) return; + const wrap = document.createElement("div"); + wrap.className = "form-group"; + wrap.innerHTML = + '' + + '"; + const tripWrap = document.getElementById("tripPercent")?.closest(".form-group"); + if (tripWrap?.parentElement === grid) { + tripWrap.insertAdjacentElement("afterend", wrap); + } else { + grid.appendChild(wrap); + } + } + + function ensureDiffStyles() { + if (document.getElementById("wesp-lab-diff-styles")) return; + const style = document.createElement("style"); + style.id = "wesp-lab-diff-styles"; + style.textContent = + ".lab-diff-badge{margin-left:6px;font-size:.7rem;vertical-align:middle;}" + + ".lab-diff-badge--warn{background:#fff3cd;color:#664d03;}" + + ".lab-diff-badge--miss{background:#f8d7da;color:#842029;}" + + ".lab-recipe-quality{margin-top:4px;font-size:.78rem;color:var(--bs-secondary-color,#6c757d);}" + + ".lab-recipe-quality__label{font-weight:600;margin-right:4px;}"; + document.head.appendChild(style); + } + + function diffBadgeHtml(reasons) { + if (!reasons?.length) return ""; + const title = reasons.join(", "); + const cls = reasons.includes("missing_in_master") || reasons.includes("missing_in_execution") + ? "lab-diff-badge--miss" + : "lab-diff-badge--warn"; + return `мастер`; + } + + function applyDiffBadges() { + document.querySelectorAll("#ingredientsTableBody .ingredient-row").forEach((row) => { + const select = row.querySelector(".ingredient-select"); + const cell = row.querySelector('.ingredient-detail-cell[data-label="Ингредиент"] .recipe-row-name-with-skip'); + if (!select || !cell) return; + cell.querySelectorAll(".lab-diff-badge").forEach((el) => el.remove()); + const compId = select.value; + if (!compId) return; + const reasons = diffByComponent.get(String(compId)); + if (!reasons?.length) return; + cell.insertAdjacentHTML("beforeend", diffBadgeHtml(reasons)); + }); + } + + function ensureQualityBlock() { + const headerText = document.querySelector("#recipeEdit .recipe-edit-header-text"); + if (!headerText || document.getElementById("labRecipeQuality")) return; + const block = document.createElement("div"); + block.id = "labRecipeQuality"; + block.className = "lab-recipe-quality"; + block.hidden = true; + block.innerHTML = + 'Зоотех:' + + ''; + headerText.appendChild(block); + } + + async function refreshQuality(recipeId) { + ensureQualityBlock(); + const block = document.getElementById("labRecipeQuality"); + const items = block?.querySelector("[data-lab-quality-items]"); + if (!block || !items || !recipeId) return; + try { + const resp = await fetch(`/api/lab/rations/${encodeURIComponent(recipeId)}`); + if (!resp.ok) throw new Error("no ration"); + const data = await resp.json(); + const indicators = (data.rationResults?.indicators || []).slice(0, 4); + if (!data.exists || !indicators.length) { + block.hidden = true; + return; + } + items.textContent = indicators + .map((ind) => `${ind.label}: ${ind.content ?? "—"} ${ind.unit || ""}`.trim()) + .join(" · "); + block.hidden = false; + } catch (_) { + block.hidden = true; + } + } + + async function refreshDiff(recipeId) { + const btn = document.querySelector('[data-action="apply-from-master"]'); + if (!recipeId) return; + await refreshQuality(recipeId); + if (!btn) return; + try { + const resp = await fetch(`/api/lab/rations/${encodeURIComponent(recipeId)}/diff`); + const data = await resp.json(); + diffByComponent = new Map(); + (data.lines || []).forEach((line) => { + if (line.componentId) diffByComponent.set(String(line.componentId), line.reasons || []); + }); + btn.hidden = !data.hasChanges; + applyDiffBadges(); + } catch (_) { + btn.hidden = true; + diffByComponent = new Map(); + } + } + + function patchRecipeLoad() { + if (hooked) return; + const orig = global.loadRecipeDetails; + if (typeof orig !== "function") return; + global.loadRecipeDetails = async function (recipeId, options) { + const recipeEdit = document.getElementById("recipeEdit"); + if (recipeEdit && recipeId) recipeEdit.dataset.recipeId = String(recipeId); + await orig(recipeId, options); + await refreshDiff(recipeId); + global.WespLabRecipesPlanOverlay?.decorateRows?.(); + }; + hooked = true; + } + + function patchRecipeSave() { + const orig = global.doSaveRecipe; + if (typeof orig !== "function" || orig.__labPatched) return; + global.doSaveRecipe = async function (closeAfterSave) { + const rationEl = document.getElementById("rationType"); + const prev = global.__wespLabRationTypeForSave; + if (rationEl) { + global.__wespLabRationTypeForSave = rationEl.value || null; + } + try { + return await orig(closeAfterSave); + } finally { + global.__wespLabRationTypeForSave = prev; + } + }; + const origFetch = global.fetch.bind(global); + global.fetch = async function (input, init) { + const url = typeof input === "string" ? input : input?.url; + const method = (init?.method || "GET").toUpperCase(); + if ( + method === "PUT" && + url && + /\/api\/recipes\/[^/]+$/.test(url) && + init?.body && + global.__wespLabRationTypeForSave !== undefined + ) { + try { + const body = JSON.parse(init.body); + body.ration_type = global.__wespLabRationTypeForSave || null; + init = { ...init, body: JSON.stringify(body) }; + } catch (_) { + /* ignore */ + } + } + return origFetch(input, init); + }; + global.doSaveRecipe.__labPatched = true; + } + + document.addEventListener("click", async (ev) => { + const btn = ev.target.closest("[data-action]"); + if (!btn) return; + const action = btn.getAttribute("data-action"); + const recipeId = getActiveRecipeId(); + + if (action === "open-lab-k-hub") { + global.WespZootechNotificationCenter?.openDailyPlan?.(recipeId || undefined); + return; + } + + if (action === "sync-to-master") { + if (!recipeId) return; + const ok = await global.WespDialog?.confirm?.( + "Сохранить текущий рецепт в зоотех-мастер?", + { title: "В мастер" } + ); + if (!ok) return; + try { + const resp = await fetch( + `/api/lab/rations/${encodeURIComponent(recipeId)}/sync-from-execution`, + { method: "POST" } + ); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.message || "Ошибка"); + global.WespZootechNotify?.createAdapter?.()?.success?.("Мастер обновлён из рецепта"); + await refreshDiff(recipeId); + } catch (e) { + global.WespZootechNotify?.createAdapter?.()?.error?.(e.message, { fallback: "Не удалось выполнить операцию" }); + } + return; + } + + if (action !== "apply-from-master") return; + if (!recipeId) return; + const ok = await global.WespDialog?.confirm?.("Перенести ингредиенты из зоотех-мастера?", { + title: "Из мастера", + }); + if (!ok) return; + try { + const resp = await fetch(`/api/lab/rations/${encodeURIComponent(recipeId)}/apply-from-master`, { + method: "POST", + }); + const data = await resp.json(); + if (!resp.ok) throw new Error(data.message || "Ошибка"); + if (typeof global.reloadRecipeEditor === "function") { + await global.reloadRecipeEditor(recipeId); + } else if (typeof global.loadRecipeDetails === "function") { + await global.loadRecipeDetails(recipeId); + } + await refreshDiff(recipeId); + global.WespZootechNotify?.createAdapter?.()?.success?.("Мастер применён к рецепту"); + } catch (e) { + global.WespZootechNotify?.createAdapter?.()?.error?.(e.message, { fallback: "Не удалось применить мастер" }); + } + }); + + document.addEventListener("change", (ev) => { + if (!ev.target?.matches?.(".ingredient-select")) return; + applyDiffBadges(); + }); + + global.WespLabRecipesOverlay = { + init() { + ensureDiffStyles(); + ensureToolbarButtons(); + ensureKHubButton(); + ensureRationTypeField(); + patchRecipeLoad(); + patchRecipeSave(); + }, + refreshDiff, + refreshQuality, + applyDiffBadges, + }; + + function boot() { + global.WespLabRecipesOverlay.init(); + if (typeof global.loadRecipeDetails === "function") patchRecipeLoad(); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", boot); + } else { + boot(); + } +})(window); diff --git a/apps/web/public/wesp/js/pages/lab-recipes-plan-overlay.js b/apps/web/public/wesp/js/pages/lab-recipes-plan-overlay.js new file mode 100644 index 0000000..4f95e82 --- /dev/null +++ b/apps/web/public/wesp/js/pages/lab-recipes-plan-overlay.js @@ -0,0 +1,252 @@ +(function (global) { + "use strict"; + + let observer = null; + + function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function planDate() { + return ( + global.WespDailyPlanPanel?.getSelectedPlanDate?.() || + global.WespDailyPlanSkipDuration?.todayIso?.() || + new Date().toISOString().slice(0, 10) + ); + } + + function getRecipeId() { + return ( + document.getElementById("recipeEdit")?.dataset?.recipeId || + document.querySelector(".list-item.active[data-recipe-id]")?.dataset?.recipeId || + null + ); + } + + function notifySuccess(message) { + global.WespZootechNotify?.createAdapter?.()?.success?.(message); + } + + function notifyError(message, fallback) { + global.WespZootechNotify?.createAdapter?.()?.error?.(message, { fallback: fallback || message }); + } + + async function reloadEditor() { + const recipeId = getRecipeId(); + if (!recipeId) return; + if (typeof global.reloadRecipeEditor === "function") { + await global.reloadRecipeEditor(recipeId); + } else if (typeof global.loadRecipeDetailsWithPlanOverlay === "function") { + await global.loadRecipeDetailsWithPlanOverlay(); + } else if (typeof global.loadRecipeDetails === "function") { + await global.loadRecipeDetails(recipeId); + } + } + + function planBtnHtml(action, title, icon, attrs = "") { + return ( + `` + ); + } + + function decorateRow(row) { + const actions = row.querySelector(".recipe-table-row-actions"); + if (!actions || actions.querySelector(".recipe-plan-actions")) return; + + const ingredientId = row.dataset.id; + if (!ingredientId) return; + + const skipped = row.classList.contains("ingredient-row--skipped"); + const replaced = row.classList.contains("ingredient-row--replaced"); + const select = row.querySelector(".ingredient-select"); + const componentId = select?.value || ""; + const label = select?.selectedOptions?.[0]?.textContent?.trim() || "Компонент"; + const recipeId = getRecipeId(); + if (!recipeId) return; + + const wrap = document.createElement("div"); + wrap.className = "btn-group recipe-plan-actions me-1"; + wrap.setAttribute("role", "group"); + + if (skipped) { + wrap.innerHTML = planBtnHtml( + "recipe-plan-unskip-ingredient", + "Вернуть в план", + "fa-undo", + `data-recipe-id="${escapeHtml(recipeId)}" data-ingredient-id="${escapeHtml(ingredientId)}"` + ); + } else if (replaced) { + wrap.innerHTML = planBtnHtml( + "recipe-plan-undo-replace-ingredient", + "Отменить замену", + "fa-undo", + `data-recipe-id="${escapeHtml(recipeId)}" data-ingredient-id="${escapeHtml(ingredientId)}"` + ); + } else { + wrap.innerHTML = + planBtnHtml( + "recipe-plan-skip-ingredient", + "Убрать из плана", + "fa-ban", + `data-recipe-id="${escapeHtml(recipeId)}" data-ingredient-id="${escapeHtml(ingredientId)}" data-label="${escapeHtml(label)}"` + ) + + (componentId + ? planBtnHtml( + "recipe-plan-replace-ingredient", + "Заменить в плане", + "fa-exchange-alt", + `data-recipe-id="${escapeHtml(recipeId)}" data-ingredient-id="${escapeHtml(ingredientId)}" ` + + `data-component-id="${escapeHtml(componentId)}" data-label="${escapeHtml(label)}"` + ) + : ""); + } + + const deleteBtn = actions.querySelector('[data-action="remove-ingredient"]'); + if (deleteBtn) actions.insertBefore(wrap, deleteBtn); + else actions.appendChild(wrap); + } + + function decorateRows() { + document.querySelectorAll("#ingredientsTableBody .ingredient-row").forEach(decorateRow); + } + + function ensureStyles() { + if (document.getElementById("wesp-lab-plan-overlay-styles")) return; + const style = document.createElement("style"); + style.id = "wesp-lab-plan-overlay-styles"; + style.textContent = + ".recipe-plan-action-btn{padding:2px 6px;}" + + ".recipe-plan-actions .btn+.btn{margin-left:2px;}"; + document.head.appendChild(style); + } + + async function skipIngredient(recipeId, ingredientId, label) { + const skip = global.WespDailyPlanSkipDuration; + if (!skip?.skipDurationBody) return; + const body = await skip.skipDurationBody(planDate(), { recipeId, ingredientId }, "На какой срок убрать компонент?"); + if (!body) return; + const resp = await fetch("/api/daily-plan/skips/ingredients", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(body), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.message || "Не удалось убрать компонент"); + } + await reloadEditor(); + notifySuccess(`«${label}» убран из плана`); + } + + async function unskipIngredient(recipeId, ingredientId) { + const params = new URLSearchParams({ recipe_id: recipeId, ingredient_id: ingredientId, date: planDate() }); + const resp = await fetch(`/api/daily-plan/skips/ingredients?${params}`, { + method: "DELETE", + headers: { Accept: "application/json" }, + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.message || "Не удалось вернуть компонент"); + } + await reloadEditor(); + notifySuccess("Компонент снова в плане"); + } + + async function openReplace(recipeId, ingredientId, componentId, label) { + const replaceModal = global.WespDailyPlanReplaceModal; + if (!replaceModal?.open) { + notifyError("Модалка замены недоступна"); + return; + } + await replaceModal.open({ + currentName: label, + onPick: async (replacementComponentId, replacementName) => { + const skip = global.WespDailyPlanSkipDuration; + const body = await skip?.skipDurationBody?.( + planDate(), + { recipeId, ingredientId, replacementComponentId }, + "На какой срок заменить компонент?" + ); + if (!body) return; + const resp = await fetch("/api/daily-plan/replacements/ingredients", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(body), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.message || "Не удалось заменить компонент"); + } + await reloadEditor(); + notifySuccess(`Компонент заменён на «${replacementName}»`); + }, + }); + } + + async function undoReplace(recipeId, ingredientId) { + const params = new URLSearchParams({ recipe_id: recipeId, ingredient_id: ingredientId, date: planDate() }); + const resp = await fetch(`/api/daily-plan/replacements/ingredients?${params}`, { + method: "DELETE", + headers: { Accept: "application/json" }, + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.message || "Не удалось отменить замену"); + } + await reloadEditor(); + notifySuccess("Замена отменена"); + } + + document.addEventListener("click", async (ev) => { + const btn = ev.target.closest("[data-action]"); + if (!btn) return; + const action = btn.dataset.action; + if (!action?.startsWith("recipe-plan-")) return; + if (!document.getElementById("recipeEdit")?.contains(btn)) return; + ev.preventDefault(); + + const recipeId = btn.dataset.recipeId; + const ingredientId = btn.dataset.ingredientId; + try { + if (action === "recipe-plan-skip-ingredient") { + await skipIngredient(recipeId, ingredientId, btn.dataset.label || "Компонент"); + } else if (action === "recipe-plan-unskip-ingredient") { + await unskipIngredient(recipeId, ingredientId); + } else if (action === "recipe-plan-replace-ingredient") { + await openReplace(recipeId, ingredientId, btn.dataset.componentId, btn.dataset.label || "Компонент"); + } else if (action === "recipe-plan-undo-replace-ingredient") { + await undoReplace(recipeId, ingredientId); + } + } catch (e) { + notifyError(e.message, "Ошибка плана"); + } + }); + + function watchIngredients() { + const tbody = document.getElementById("ingredientsTableBody"); + if (!tbody || observer) return; + observer = new MutationObserver(() => decorateRows()); + observer.observe(tbody, { childList: true, subtree: true }); + } + + global.WespLabRecipesPlanOverlay = { + init() { + ensureStyles(); + watchIngredients(); + decorateRows(); + }, + decorateRows, + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => global.WespLabRecipesPlanOverlay.init()); + } else { + global.WespLabRecipesPlanOverlay.init(); + } +})(window); diff --git a/apps/web/public/wesp/js/pages/lab-sandbox.js b/apps/web/public/wesp/js/pages/lab-sandbox.js new file mode 100644 index 0000000..df55327 --- /dev/null +++ b/apps/web/public/wesp/js/pages/lab-sandbox.js @@ -0,0 +1,525 @@ +(function (global) { + "use strict"; + + const INDICATOR_LABELS = { + dry_matter: "Сухое вещество", + dm_main: "СВ — основной корм", + oe: "ОЭ — КРС", + nel: "ЧЭЛ — КРС", + nel_per_kg_dm: "ЧЭЛ/кг СВ", + crude_protein: "Сырой протеин", + rup_per_kg_dm: "Нер. СП / кг СВ", + insoluble_protein: "Нерастворимый протеин", + usp: "уСП", + usp_pct_dm: "% уСП/кг СВ", + ndf: "Сырая клетчатка", + structural_fiber: "Структур. клетчатка", + crude_fat: "Сырой жир", + rnb: "RNB", + calcium: "Ca", + phosphorus: "P", + magnesium: "Mg", + sodium: "Na", + dcab: "DCAB", + sugar_starch: "Сахар и крахмал", + insoluble_starch: "Нераств. крахмал", + sugar: "Сахар", + starch: "Крахмал", + carotene: "Каротин", + }; + + let state = null; + let recipeId = null; + let components = []; + let profiles = []; + let normCatalog = []; + + async function loadNormCatalog() { + if (normCatalog.length) return normCatalog; + try { + const data = await api("/api/lab/norm-indicators"); + normCatalog = data.indicators || []; + } catch (_e) { + normCatalog = []; + } + return normCatalog; + } + + function indicatorMeta(key) { + const fromCat = normCatalog.find((item) => item.key === key); + if (fromCat) return fromCat; + return { key, label: INDICATOR_LABELS[key] || key, unit: "" }; + } + + function mergeIndicatorsForDisplay(calcRows, norms) { + const byKey = new Map(); + for (const ind of calcRows || []) { + if (ind?.key) byKey.set(ind.key, { ...ind }); + } + for (const key of Object.keys(norms || {})) { + const bounds = norms[key] || {}; + if (bounds.min == null && bounds.max == null) continue; + const meta = indicatorMeta(key); + const existing = byKey.get(key); + if (existing) { + existing.min = bounds.min ?? existing.min; + existing.max = bounds.max ?? existing.max; + existing.label = existing.label || meta.label; + existing.unit = existing.unit || meta.unit || ""; + } else { + byKey.set(key, { + key, + label: meta.label, + unit: meta.unit || "", + content: null, + min: bounds.min, + max: bounds.max, + diff: null, + }); + } + } + return [...byKey.values()]; + } + + async function api(path, opts) { + const resp = await fetch(path, opts); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) throw new Error(data.message || `Наука, чёрт возьми — ошибка ${resp.status}`); + return data; + } + + function labError(message) { + global.WespLabHeisenbergPrime?.error?.(message, "sandbox"); + } + + function escapeHtml(s) { + return String(s ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function formatNum(v) { + if (v == null || v === "") return "—"; + const n = Number(v); + if (Number.isNaN(n)) return "—"; + return Math.abs(n) >= 100 ? n.toFixed(1) : n.toFixed(3).replace(/\.?0+$/, ""); + } + + function formatKgInput(v) { + if (v == null || v === "") return ""; + const n = Number(v); + if (Number.isNaN(n)) return ""; + return String(Math.round(n * 100) / 100); + } + + function diffClass(diff) { + if (diff == null || Number.isNaN(Number(diff))) return "lab-sandbox-diff--na"; + const d = Number(diff); + if (Math.abs(d) < 0.001) return "lab-sandbox-diff--ok"; + return "lab-sandbox-diff--bad"; + } + + async function loadCatalogs(rationType) { + const [compResp, profResp] = await Promise.all([ + components.length ? Promise.resolve(components) : fetch("/api/components").then((r) => r.json()), + api(`/api/lab/animal-profiles${rationType ? `?ration_type=${encodeURIComponent(rationType)}` : ""}`), + ]); + if (Array.isArray(compResp)) components = compResp; + profiles = profResp.profiles || []; + } + + async function loadRecipes() { + try { + const data = await api("/api/lab/recipes"); + return data.recipes || []; + } catch (_e) { + return []; + } + } + + function componentOptions(selectedId) { + const opts = ['']; + components.forEach((c) => { + const sel = String(c.id) === String(selectedId) ? " selected" : ""; + opts.push(``); + }); + return opts.join(""); + } + + function profileOptions(selectedId) { + const opts = ['']; + profiles.forEach((p) => { + const sel = String(p.id) === String(selectedId) ? " selected" : ""; + const key = p.profileKey ? `[${p.profileKey}] ` : ""; + opts.push( + `` + ); + }); + return opts.join(""); + } + + function hasNormBounds(ind) { + return ind != null && (ind.min != null || ind.max != null); + } + + function formatNormBounds(bounds) { + const b = bounds || {}; + const min = b.min != null ? formatNum(b.min) : null; + const max = b.max != null ? formatNum(b.max) : null; + if (min != null && max != null) return `${min}–${max}`; + if (max != null) return `≤ ${max}`; + if (min != null) return `≥ ${min}`; + return "—"; + } + + function formatIndicatorNorm(ind) { + if (!hasNormBounds(ind)) { + return 'вне стандарта'; + } + return formatNormBounds(ind); + } + + function formatIndicatorDiff(ind) { + if (!hasNormBounds(ind)) return "—"; + return formatNum(ind.diff); + } + + function renderNormsPanel() { + const el = document.getElementById("labSandboxNorms"); + if (!el) return; + const norms = state?.norms || {}; + const keys = Object.keys(norms); + if (!keys.length) { + el.innerHTML = '
    Выбери стандарт чистоты
    '; + return; + } + let note = ""; + el.innerHTML = + note + + `
    ` + + keys + .map((key) => { + const b = norms[key] || {}; + const label = INDICATOR_LABELS[key] || key; + return `
    ${escapeHtml(label)}
    ${formatNormBounds(b)}
    `; + }) + .join("") + + `
    `; + } + + function renderResultsPanel() { + const el = document.getElementById("labSandboxResults"); + if (!el) return; + const calcRows = state?.rationResults?.indicators || []; + const totals = state?.rationResults?.totals || []; + const norms = state?.norms || {}; + const indicators = mergeIndicatorsForDisplay(calcRows, norms); + if (!indicators.length && !totals.length) { + el.innerHTML = '
    Нажми «Примени науку»
    '; + return; + } + let html = ""; + if (!Object.keys(norms).length && calcRows.length) { + html += + '

    Для полной матрицы показателей выберите «Стандарт чистоты» и снова нажмите «Примени науку».

    '; + } + if (totals.length) { + html += + `

    Синий — значит чистый

      ` + + totals.map((t) => `
    • ${escapeHtml(t.label)}: ${formatNum(t.value)}
    • `).join("") + + `
    `; + } + if (indicators.length) { + const withNorm = indicators.filter((ind) => hasNormBounds(ind)).length; + const rows = indicators.filter((ind) => hasNormBounds(ind) || ind.content != null); + const sorted = rows.slice().sort((a, b) => { + const an = hasNormBounds(a) ? 0 : 1; + const bn = hasNormBounds(b) ? 0 : 1; + if (an !== bn) return an - bn; + return String(a.label).localeCompare(String(b.label), "ru"); + }); + html += + `

    Строк: ${sorted.length} (со стандартом: ${withNorm}). ≤/≥ — границы чистоты.

    ` + + `` + + sorted + .map((ind) => { + const dc = hasNormBounds(ind) ? diffClass(ind.diff) : "lab-sandbox-diff--na"; + return ( + `` + + `` + + `` + + `` + + `` + + `` + ); + }) + .join("") + + `
    ПоказательФактСтандартΔ
    ${escapeHtml(ind.label)}${formatNum(ind.content)} ${escapeHtml(ind.unit || "")}${formatIndicatorNorm(ind)}${formatIndicatorDiff(ind)}
    `; + } + if (state?.calculatedAt) { + html += `

    Готово: ${escapeHtml(state.calculatedAt)}

    `; + } + el.innerHTML = html; + } + + function renderLines() { + const tbody = document.getElementById("labSandboxLinesBody"); + if (!tbody) return; + const lines = state?.lines || []; + if (!lines.length) { + tbody.innerHTML = + `Партия пуста — добавь реагент или возьми из WESP`; + return; + } + tbody.innerHTML = lines + .map( + (line, i) => + `` + + `` + + `` + + `` + + `` + + `${line.pricePerKg != null ? formatNum(line.pricePerKg) : "—"}` + + `` + + `` + ) + .join(""); + } + + function renderSetup() { + const recipeSel = document.getElementById("labSandboxRecipe"); + const profileSel = document.getElementById("labSandboxProfile"); + const typeSel = document.getElementById("labSandboxRationType"); + if (profileSel) profileSel.innerHTML = profileOptions(state?.animalProfileId); + if (typeSel && state?.rationType) typeSel.value = state.rationType; + if (profileSel && state?.animalProfileId) profileSel.value = state.animalProfileId; + if (recipeSel && recipeId) recipeSel.value = recipeId; + const meta = document.getElementById("labSandboxMeta"); + if (meta && state) { + meta.textContent = `${state.recipeName || ""} · ${state.headsPerTrip || 1} гол. · кг/сут на стадо · ${state.exists ? "формула есть" : "формула пуста — готовим?"}`; + } + } + + function collectLines() { + const tbody = document.getElementById("labSandboxLinesBody"); + if (!tbody || !state) return state.lines || []; + return (state.lines || []).map((line, i) => { + const row = tbody.querySelector(`tr[data-line-idx="${i}"]`); + if (!row) return line; + const componentId = row.querySelector('[data-field="componentId"]')?.value || null; + const comp = components.find((c) => String(c.id) === String(componentId)); + return { + ...line, + id: line.id, + componentId, + ingredientName: comp?.name || line.ingredientName, + dailyKg: parseFloat(row.querySelector('[data-field="dailyKg"]')?.value) || null, + inRation: row.querySelector('[data-field="inRation"]')?.checked, + inCompound: row.querySelector('[data-field="inCompound"]')?.checked, + }; + }); + } + + function collectPayload() { + return { + lines: collectLines(), + rationType: document.getElementById("labSandboxRationType")?.value || state?.rationType, + animalProfileId: document.getElementById("labSandboxProfile")?.value || null, + }; + } + + async function reloadRation() { + if (!recipeId) return; + await loadNormCatalog(); + state = await api(`/api/lab/rations/${encodeURIComponent(recipeId)}`); + if (state) { + state.normsProfileKey = state.normsProfileKey || null; + state.legacyNormsRemapped = Boolean(state.legacyNormsRemapped); + } + await loadCatalogs(state.rationType); + if (state.legacyNormsRemapped && state.normsProfileKey) { + const fp = profiles.find((p) => p.profileKey === state.normsProfileKey); + if (fp) state.animalProfileId = fp.id; + } + renderSetup(); + renderLines(); + renderNormsPanel(); + renderResultsPanel(); + } + + async function ensureMaster() { + if (!recipeId) return; + if (state?.exists) return; + const ok = await global.WespDialog?.confirm?.("Взять состав из WESP и начать готовку?", { + title: "Нам нужно готовить", + }); + if (ok) { + await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/seed-from-execution`, { method: "POST" }); + } else { + await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/ensure-empty`, { method: "POST" }); + } + await reloadRation(); + } + + async function initRecipeSelect() { + const sel = document.getElementById("labSandboxRecipe"); + if (!sel) return; + const recipes = await loadRecipes(); + sel.innerHTML = + '' + + recipes.map((r) => ``).join(""); + const params = new URLSearchParams(global.location.search); + const q = params.get("recipe"); + if (q) { + sel.value = q; + recipeId = q; + await reloadRation(); + await ensureMaster(); + } + } + + function bindEvents() { + document.getElementById("labSandboxRecipe")?.addEventListener("change", async (ev) => { + recipeId = ev.target.value || null; + if (!recipeId) { + state = null; + renderLines(); + renderResultsPanel(); + return; + } + await reloadRation(); + await ensureMaster(); + }); + + document.getElementById("labSandboxProfile")?.addEventListener("change", async (ev) => { + const pid = ev.target.value; + if (!pid) { + if (state) state.norms = {}; + renderNormsPanel(); + return; + } + try { + const profile = await api(`/api/lab/animal-profiles/${encodeURIComponent(pid)}`); + if (state) { + state.animalProfileId = pid; + state.norms = + profile.resolvedNorms || + profile.normsData?.resolvedIndicators || + profile.normsData?.indicators || + {}; + state.normsProfileKey = profile.normsProfileKey || profile.profileKey; + state.legacyNormsRemapped = Boolean(profile.legacyNormsRemapped); + if (profile.rationType) state.rationType = profile.rationType; + } + const typeSel = document.getElementById("labSandboxRationType"); + if (typeSel && profile.rationType) typeSel.value = profile.rationType; + renderNormsPanel(); + } catch (e) { + labError(e.message); + } + }); + + document.getElementById("labSandboxRationType")?.addEventListener("change", async (ev) => { + await loadCatalogs(ev.target.value); + renderSetup(); + }); + + document.getElementById("labSandboxLinesBody")?.addEventListener("click", (ev) => { + const btn = ev.target.closest("[data-action='remove-line']"); + if (!btn || !state) return; + const idx = parseInt(btn.getAttribute("data-idx"), 10); + if (!Number.isNaN(idx)) { + state.lines = (state.lines || []).filter((_, i) => i !== idx); + renderLines(); + } + }); + + document.querySelector("[data-action='add-line']")?.addEventListener("click", () => { + if (!state) state = { lines: [] }; + state.lines = [ + ...(state.lines || []), + { componentId: "", ingredientName: "", dailyKg: 0, inRation: true, inCompound: false }, + ]; + renderLines(); + }); + + document.querySelector("[data-action='save']")?.addEventListener("click", async () => { + if (!recipeId) { + labError("Сначала выбери партию — без этого не готовим"); + return; + } + try { + await api(`/api/lab/rations/${encodeURIComponent(recipeId)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(collectPayload()), + }); + await reloadRation(); + global.WespZootechNotify?.createAdapter?.()?.success?.("Запомни это — формула сохранена"); + } catch (e) { + labError(e.message); + } + }); + + document.querySelector("[data-action='recalculate']")?.addEventListener("click", async () => { + if (!recipeId) { + labError("Сначала выбери партию — науку некуда применять"); + return; + } + try { + await api(`/api/lab/rations/${encodeURIComponent(recipeId)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(collectPayload()), + }); + await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/recalculate`, { method: "POST" }); + await reloadRation(); + global.WespZootechNotify?.createAdapter?.()?.success?.("Применена наука — 99,1% чистоты"); + } catch (e) { + labError(e.message); + } + }); + + document.querySelector("[data-action='seed']")?.addEventListener("click", async () => { + if (!recipeId) { + labError("Сначала выбери партию — нечего брать с поля"); + return; + } + try { + await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/seed-from-execution`, { method: "POST" }); + await reloadRation(); + } catch (e) { + labError(e.message); + } + }); + + document.querySelector("[data-action='apply']")?.addEventListener("click", async () => { + if (!recipeId) { + labError("Сначала выбери партию — некуда отправлять"); + return; + } + const ok = await global.WespDialog?.confirm?.("Отправить кг/сут из лаборатории в WESP?"); + if (!ok) return; + try { + await api(`/api/lab/rations/${encodeURIComponent(recipeId)}/apply-from-master`, { method: "POST" }); + global.WespZootechNotify?.createAdapter?.()?.success?.("Отправлено в WESP. Ты чертовски прав"); + } catch (e) { + labError(e.message); + } + }); + } + + function init() { + bindEvents(); + loadNormCatalog(); + initRecipeSelect(); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(window); diff --git a/apps/web/public/wesp/js/pages/login-page.js b/apps/web/public/wesp/js/pages/login-page.js new file mode 100644 index 0000000..2dd371b --- /dev/null +++ b/apps/web/public/wesp/js/pages/login-page.js @@ -0,0 +1,284 @@ +import { mountWespLogoLoader } from "/static/js/wesp-logo-loader.js?v=6"; +import { + markLoginTransition, + storePreloadedApiPayload, + PRELOAD_FEED_DISPENSERS_KEY, +} from "/static/js/wesp-nav-preload.js"; + +const LS_LOGIN = "wesp_saved_login"; +const LS_REMEMBER = "wesp_remember_login"; +const THEME_STORAGE_KEY = "theme"; +const POST_LOGIN_REDIRECT_DELAY_MS = 300; +/** Длительность анимации логотипа после успешного входа (на странице login). */ +const LOGIN_EXIT_ANIM_MS = 3500; + +const RECIPES_WARM_URLS = [ + "/recipes", + "/static/css/bootstrap.min.css", + "/static/css/wesp-zootech-shell.css", + "/static/css/wesp-zootech-layout.css", + "/static/css/wesp-zootech-components.css", + "/static/css/wesp-recipes-skeleton.css", + "/static/css/wesp-logo-loader.css", + "/static/css/notyf.min.css", + "/static/css/font-awesome.min.css", + "/static/js/wesp-theme-boot.js", + "/static/js/wesp-logo-loader.js?v=6", + "/static/js/wesp-page-enter.js", + "/static/js/wesp-nav-logo-shimmer.js", + "/static/js/wesp-zootech-nav.js", + "/static/js/notyf.min.js", + "/static/js/bootstrap.bundle.min.js", + "/static/js/wesp-dialog.js", + "/static/js/pages/recipes-page.js", + "/static/js/pages/recipes-auth-settings.js", + "/static/js/pages/recipes-data-controller.js", + "/static/js/pages/recipes-operations-controller.js", + "/static/js/pages/recipes-editor-controller.js", + "/static/js/modules/app-state.js", +]; + +function delay(ms) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +function preloadUrl(url) { + return fetch(url, { credentials: "same-origin" }).catch(() => null); +} + +async function preloadRecipesAfterLogin() { + markLoginTransition(); + + const apiTask = preloadUrl("/api/feed_dispensers").then(async (response) => { + if (!response?.ok) return; + const payload = await response.json(); + if (Array.isArray(payload)) { + storePreloadedApiPayload(PRELOAD_FEED_DISPENSERS_KEY, payload); + } + }); + + await Promise.allSettled([ + apiTask, + ...RECIPES_WARM_URLS.map((url) => preloadUrl(url)), + ]); +} + +async function finishLoginTransition(target) { + const preloadPromise = preloadRecipesAfterLogin(); + await playLoginExitAnimation(); + await Promise.all([preloadPromise, delay(POST_LOGIN_REDIRECT_DELAY_MS)]); +} + +function themeForLogoLoader() { + try { + const saved = (localStorage.getItem(THEME_STORAGE_KEY) || "").trim().toLowerCase(); + if (saved === "light" || saved === "organic" || saved === "dark") return saved; + } catch { + /* ignore */ + } + const attr = document.documentElement.getAttribute("data-theme"); + if (attr === "light" || attr === "organic" || attr === "dark") return attr; + return "dark"; +} + +function isDarkTheme() { + return themeForLogoLoader() === "dark"; +} + +function safeNextPath(raw) { + if (raw == null || typeof raw !== "string") return null; + const s = raw.trim(); + if (!s || s.charAt(0) !== "/") return null; + if (s.indexOf("//") === 0) return null; + if (s.indexOf("://") !== -1) return null; + if (s.indexOf("\0") !== -1 || s.indexOf("\r") !== -1 || s.indexOf("\n") !== -1) return null; + const pathOnly = s.split("?", 1)[0]; + if (pathOnly.indexOf("@") !== -1) return null; + return s; +} + +function postLoginRedirectUrl() { + try { + const params = new URLSearchParams(window.location.search); + const n = safeNextPath(params.get("next")); + if (n) return n; + } catch (e) { + /* ignore */ + } + return "/recipes"; +} + +function showError(message) { + const errorDiv = document.getElementById("errorMessage"); + if (!errorDiv) return; + errorDiv.textContent = message; + errorDiv.style.display = "block"; + window.setTimeout(() => { + errorDiv.style.display = "none"; + }, 5000); +} + +function setLoading(loading) { + const button = document.getElementById("authButton"); + const buttonText = document.getElementById("buttonText"); + const spinner = document.getElementById("loadingSpinner"); + const loginInput = document.getElementById("loginInput"); + const passwordInput = document.getElementById("passwordInput"); + const rememberInput = document.getElementById("rememberInput"); + + if (button) button.disabled = loading; + if (loginInput) loginInput.disabled = loading; + if (passwordInput) passwordInput.disabled = loading; + if (rememberInput) rememberInput.disabled = loading; + + if (buttonText && spinner) { + buttonText.style.display = loading ? "none" : "block"; + spinner.style.display = loading ? "block" : "none"; + } +} + +function applyRememberToStorage(login, remember) { + if (remember) { + try { + localStorage.setItem(LS_LOGIN, login); + localStorage.setItem(LS_REMEMBER, "1"); + } catch (e) { + /* ignore */ + } + } else { + try { + localStorage.removeItem(LS_LOGIN); + localStorage.removeItem(LS_REMEMBER); + } catch (e) { + /* ignore */ + } + } +} + +function loadRememberFromStorage() { + try { + if (localStorage.getItem(LS_REMEMBER) === "1") { + const saved = localStorage.getItem(LS_LOGIN); + if (saved) { + const loginInput = document.getElementById("loginInput"); + const rememberInput = document.getElementById("rememberInput"); + if (loginInput) loginInput.value = saved; + if (rememberInput) rememberInput.checked = true; + } + } + } catch (e) { + /* ignore */ + } +} + +async function checkSession() { + try { + const r = await fetch("/api/auth/check", { credentials: "same-origin" }); + const data = await r.json(); + if (data.authenticated && data.remember_login) { + const target = postLoginRedirectUrl(); + await finishLoginTransition(target); + window.location.href = target; + } + } catch (e) { + /* stay on login */ + } +} + +function playLoginExitAnimation() { + return new Promise((resolve) => { + const page = document.querySelector(".z-login-page"); + if (page) page.classList.add("z-login-page--hidden"); + + const backdrop = document.createElement("div"); + backdrop.className = "z-login-backdrop"; + document.body.appendChild(backdrop); + + const wrap = document.createElement("div"); + wrap.className = "z-login-loader"; + const root = document.createElement("div"); + wrap.appendChild(root); + document.body.appendChild(wrap); + + mountWespLogoLoader(root, { + loop: false, + durationMs: LOGIN_EXIT_ANIM_MS, + totalDurationMs: LOGIN_EXIT_ANIM_MS, + embedded: true, + transparentBackground: true, + knockoutBackground: true, + theme: themeForLogoLoader(), + onComplete: () => resolve(), + }).catch(() => resolve()); + }); +} + +async function authenticate(event) { + event.preventDefault(); + + const login = document.getElementById("loginInput")?.value?.trim() || ""; + const password = document.getElementById("passwordInput")?.value || ""; + const remember = !!document.getElementById("rememberInput")?.checked; + + if (!login) { + showError("Введите логин"); + return; + } + if (!password) { + showError("Введите пароль"); + return; + } + + setLoading(true); + + try { + const response = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ login, password, remember }), + }); + + const data = await response.json(); + + if (data.status === "success") { + applyRememberToStorage(login, remember); + const target = postLoginRedirectUrl(); + await finishLoginTransition(target); + window.location.href = target; + return; + } + + showError(data.message || "Неверный логин или пароль"); + const passwordInput = document.getElementById("passwordInput"); + if (passwordInput) { + passwordInput.value = ""; + passwordInput.focus(); + } + setLoading(false); + } catch (error) { + showError("Ошибка соединения. Попробуйте снова."); + console.error("Authentication error:", error); + setLoading(false); + } +} + +function initLoginPage() { + loadRememberFromStorage(); + checkSession(); + document.getElementById("loginInput")?.focus(); + + document.getElementById("loginForm")?.addEventListener("submit", authenticate); + document.getElementById("loginInput")?.addEventListener("keypress", (e) => { + if (e.key === "Enter") document.getElementById("passwordInput")?.focus(); + }); + document.getElementById("passwordInput")?.addEventListener("keypress", (e) => { + if (e.key === "Enter") authenticate(e); + }); +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initLoginPage); +} else { + initLoginPage(); +} diff --git a/apps/web/public/wesp/js/pages/multiserver-panel.js b/apps/web/public/wesp/js/pages/multiserver-panel.js new file mode 100644 index 0000000..267ecb8 --- /dev/null +++ b/apps/web/public/wesp/js/pages/multiserver-panel.js @@ -0,0 +1,95 @@ +/** + * K-hub «Мультисервер»: manual conflict resolution (orchestrator vs farm hub). + */ +(function (global) { + const API = "/api/v1/sync/conflicts"; + + function escapeHtml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function renderList(root, items, enterpriseId) { + if (!items.length) { + root.innerHTML = '

    Нет конфликтов синхронизации.

    '; + return; + } + root.innerHTML = + '' + + "" + + items + .map( + (c) => + `` + + `` + + `` + + `` + + `` + + "", + ) + .join("") + + "
    ТаблицаЗаписьФерма
    ${escapeHtml(c.table_name)}${escapeHtml(c.record_id)}${escapeHtml(c.farm_hub_id || "—")}
    "; + + root.querySelectorAll("[data-action=open-conflict]").forEach((btn) => { + btn.addEventListener("click", () => { + const row = btn.closest("tr"); + const id = row && row.getAttribute("data-conflict-id"); + if (id) openDetail(root, id, enterpriseId); + }); + }); + } + + function openDetail(root, conflictId, enterpriseId) { + fetch(`${API}/${conflictId}?enterprise_id=${encodeURIComponent(enterpriseId)}`) + .then((r) => r.json()) + .then((detail) => { + root.innerHTML = + `
    ` + + `

    ${escapeHtml(detail.table_name)} · ${escapeHtml(detail.record_id)}

    ` + + `
    Оркестр
    ${escapeHtml(JSON.stringify(detail.orchestrator_snapshot, null, 2))}
    ` + + `
    Сервер
    ${escapeHtml(JSON.stringify(detail.hub_snapshot, null, 2))}
    ` + + `
    ` + + ` ` + + ` ` + + `` + + `
    `; + root.querySelector("[data-action=back-list]")?.addEventListener("click", () => load(root, enterpriseId)); + root.querySelectorAll("[data-resolution]").forEach((btn) => { + btn.addEventListener("click", () => { + const resolution = btn.getAttribute("data-resolution"); + fetch(`${API}/${conflictId}/resolve?enterprise_id=${encodeURIComponent(enterpriseId)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ resolution }), + }).then(() => load(root, enterpriseId)); + }); + }); + }); + } + + function load(root, enterpriseId) { + root.innerHTML = '

    Загрузка конфликтов…

    '; + fetch(`${API}?enterprise_id=${encodeURIComponent(enterpriseId)}`) + .then((r) => r.json()) + .then((items) => renderList(root, items || [], enterpriseId)) + .catch(() => { + root.innerHTML = '

    Не удалось загрузить конфликты.

    '; + }); + } + + global.WespMultiserverPanel = { + mount(rootEl, enterpriseId) { + if (!rootEl) return; + load(rootEl, enterpriseId || ""); + }, + pendingCount(enterpriseId) { + return fetch(`${API}?enterprise_id=${encodeURIComponent(enterpriseId)}`) + .then((r) => r.json()) + .then((items) => (Array.isArray(items) ? items.length : 0)) + .catch(() => 0); + }, + }; +})(window); diff --git a/apps/web/public/wesp/js/pages/recipes-auth-settings.js b/apps/web/public/wesp/js/pages/recipes-auth-settings.js new file mode 100644 index 0000000..fd0e824 --- /dev/null +++ b/apps/web/public/wesp/js/pages/recipes-auth-settings.js @@ -0,0 +1,404 @@ +import { initThemePicker, initHighContrastToggle, initThemeToggle, applyTheme, getTheme } from "/static/js/wesp-theme.js"; +import { + DEFAULT_PASSWORD_MIN_LEN, + validateCredentialChange, +} from "/static/js/wesp-user-credentials.js"; + +export function createRecipesAuthSettingsController({ notyf }) { + let settingsModalClickHandler = null; + let settingsEscapeHandler = null; + let syncDispenserNames = []; + let passwordMinLen = DEFAULT_PASSWORD_MIN_LEN; + + function escapeHtmlSyncSettings(value) { + if (value == null) return ""; + const div = document.createElement("div"); + div.textContent = String(value); + return div.innerHTML; + } + + function closeSettings() { + const modal = document.getElementById("settingsModal"); + if (!modal) return; + + modal.style.display = "none"; + document.body.style.overflow = ""; + + const form = document.getElementById("settingsForm"); + if (form) form.reset(); + + const credForm = document.getElementById("credentialsForm"); + if (credForm) credForm.style.display = "none"; + const userCard = document.querySelector(".settings-user-card--clickable"); + if (userCard) userCard.classList.remove("open"); + + const syncForm = document.getElementById("syncSettingsForm"); + if (syncForm) syncForm.style.display = "none"; + const syncCard = document.querySelector(".settings-sync-card--clickable"); + if (syncCard) syncCard.classList.remove("open"); + + if (settingsModalClickHandler) { + modal.removeEventListener("click", settingsModalClickHandler); + settingsModalClickHandler = null; + } + if (settingsEscapeHandler) { + document.removeEventListener("keydown", settingsEscapeHandler); + settingsEscapeHandler = null; + } + } + + async function openSettings(event) { + if (event) event.preventDefault(); + + try { + const response = await fetch("/api/auth/get_current_credentials"); + const data = await response.json(); + if (data.status === "success") { + const oldLogin = document.getElementById("oldLogin"); + const oldPassword = document.getElementById("oldPassword"); + if (oldLogin) oldLogin.value = data.login || ""; + if (oldPassword) oldPassword.value = ""; + } + } catch (error) { + console.error("Ошибка загрузки текущих учетных данных:", error); + } + + const modal = document.getElementById("settingsModal"); + if (!modal) return; + modal.style.display = "block"; + document.body.style.overflow = "hidden"; + initThemePicker(); + initHighContrastToggle(); + + if (settingsModalClickHandler) { + modal.removeEventListener("click", settingsModalClickHandler); + } + if (settingsEscapeHandler) { + document.removeEventListener("keydown", settingsEscapeHandler); + } + + settingsModalClickHandler = (e) => { + if (e.target === modal) closeSettings(); + }; + settingsEscapeHandler = (e) => { + if (e.key === "Escape" && modal.style.display === "block") closeSettings(); + }; + + modal.addEventListener("click", settingsModalClickHandler); + document.addEventListener("keydown", settingsEscapeHandler); + } + + async function checkAuth() { + try { + const response = await fetch("/api/auth/check"); + const data = await response.json(); + if (data.status === "success" && data.authenticated) { + const userInfo = document.getElementById("userInfo"); + const userLogin = document.getElementById("userLogin"); + const mobileUserInfo = document.getElementById("mobileUserInfo"); + const canOpenAdmin = Boolean(data.is_superuser); + window.WESP_CAN_LAB = Boolean(data.can_lab); + window.WespLabAccess?.apply?.(window.WESP_CAN_LAB); + const rules = data.user_rules || {}; + passwordMinLen = Number(rules.password_min_len) || DEFAULT_PASSWORD_MIN_LEN; + if (userLogin) userLogin.textContent = data.user_login; + if (userInfo) { + userInfo.style.cursor = canOpenAdmin ? "pointer" : ""; + userInfo.title = canOpenAdmin ? "Открыть админ-панель" : ""; + userInfo.onclick = canOpenAdmin + ? () => { + window.location.href = "/admin"; + } + : null; + } + if (mobileUserInfo) { + mobileUserInfo.innerHTML = ` ${data.user_login}`; + mobileUserInfo.style.cursor = canOpenAdmin ? "pointer" : ""; + mobileUserInfo.title = canOpenAdmin ? "Открыть админ-панель" : ""; + mobileUserInfo.onclick = canOpenAdmin + ? () => { + window.location.href = "/admin"; + } + : null; + } + } else { + window.location.href = "/login"; + } + return data; + } catch (error) { + console.error("Ошибка проверки авторизации:", error); + window.location.href = "/login"; + return null; + } + } + + async function logout() { + try { + const response = await fetch("/api/auth/logout", { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + const data = await response.json(); + if (data.status === "success") { + window.location.href = "/"; + } else { + notyf.error("Ошибка при выходе из системы"); + } + } catch (error) { + console.error("Ошибка при выходе:", error); + notyf.error("Ошибка при выходе из системы"); + } + } + + function setupMobileMenu() { + const hamburgerMenu = document.getElementById("hamburgerMenu"); + const mobileMenuOverlay = document.getElementById("mobileMenuOverlay"); + const closeMenu = document.getElementById("closeMenu"); + const mobileUserInfo = document.getElementById("mobileUserInfo"); + if (!hamburgerMenu || !mobileMenuOverlay || !closeMenu) return; + + hamburgerMenu.addEventListener("click", () => { + mobileMenuOverlay.classList.add("active"); + hamburgerMenu.classList.add("active"); + document.body.style.overflow = "hidden"; + const userInfo = document.getElementById("userInfo"); + if (userInfo && mobileUserInfo) { + mobileUserInfo.textContent = userInfo.textContent || ""; + } + }); + + const closeMobileMenu = () => { + mobileMenuOverlay.classList.remove("active"); + hamburgerMenu.classList.remove("active"); + document.body.style.overflow = ""; + }; + + closeMenu.addEventListener("click", closeMobileMenu); + mobileMenuOverlay.addEventListener("click", (e) => { + if (e.target === mobileMenuOverlay) closeMobileMenu(); + }); + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && mobileMenuOverlay.classList.contains("active")) { + closeMobileMenu(); + } + }); + + const mobileMenuItems = document.querySelectorAll(".mobile-menu-item"); + mobileMenuItems.forEach((item) => { + item.addEventListener("click", () => { + setTimeout(closeMobileMenu, 300); + }); + }); + } + + async function loadSyncClientsSettings() { + const listEl = document.getElementById("syncClientsListSettings"); + const emptyEl = document.getElementById("syncClientsEmptySettings"); + if (!listEl) return; + + listEl.innerHTML = ""; + if (emptyEl) emptyEl.style.display = "none"; + + try { + const [clientsRes, namesRes] = await Promise.all([ + fetch("/api/sync/clients"), + fetch("/api/feed_dispensers/names"), + ]); + const clients = clientsRes.ok ? await clientsRes.json() : []; + syncDispenserNames = namesRes.ok ? await namesRes.json() : []; + + if (!clients || clients.length === 0) { + if (emptyEl) { + emptyEl.style.display = "block"; + emptyEl.textContent = "Нет подключенных клиентов"; + } + return; + } + + clients.forEach((client) => { + const displayLabel = client.display_name || client.client_name || client.node_id; + const item = document.createElement("div"); + item.className = "settings-sync-client-item"; + item.dataset.nodeId = client.node_id; + item.dataset.displayName = displayLabel || ""; + item.innerHTML = + `${escapeHtmlSyncSettings(displayLabel)}` + + '
    ' + + ' ' + + '' + + "
    "; + listEl.appendChild(item); + }); + } catch (error) { + if (emptyEl) { + emptyEl.style.display = "block"; + emptyEl.textContent = "Не удалось загрузить список клиентов"; + } + } + } + + function editSyncClientSettings(btn) { + const item = btn && btn.closest ? btn.closest(".settings-sync-client-item") : null; + const nodeId = item ? item.dataset.nodeId : null; + const currentName = item ? item.dataset.displayName || "" : ""; + if (!item || !nodeId) return; + + let selectHtml = ''; + syncDispenserNames.forEach((name) => { + const escaped = escapeHtmlSyncSettings(name); + selectHtml += ``; + }); + + item.innerHTML = + '
    ' + + '' + + ` ` + + ` ` + + '' + + "
    "; + + const sel = document.getElementById(`syncEditSelect_${nodeId}`); + if (sel && currentName) sel.value = currentName; + } + + function saveSyncClientDisplayName(nodeId) { + const sel = document.getElementById(`syncEditSelect_${nodeId}`); + const displayName = sel ? (sel.value || "").trim() : ""; + fetch(`/api/sync/clients/${encodeURIComponent(nodeId)}/display_name`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ display_name: displayName || null }), + }) + .then((r) => r.json()) + .then((data) => { + if (data.error) { + notyf.error(data.error); + return; + } + notyf.success(displayName ? "Имя сохранено" : "Имя сброшено"); + loadSyncClientsSettings(); + }) + .catch(() => notyf.error("Не удалось сохранить настройки клиента")); + } + + async function deleteSyncClientSettings(btn) { + const item = btn && btn.closest ? btn.closest(".settings-sync-client-item") : null; + const nodeId = item ? item.dataset.nodeId : null; + if (!nodeId) return; + const ok = globalThis.WespKioskDialog + ? await globalThis.WespKioskDialog.confirm("Удалить этого клиента из синхронизации?") + : globalThis.confirm("Удалить этого клиента из синхронизации?"); + if (!ok) return; + + fetch(`/api/sync/clients/${encodeURIComponent(nodeId)}`, { method: "DELETE" }) + .then((r) => r.json()) + .then((data) => { + if (data.error) { + notyf.error(data.error); + return; + } + notyf.success(data.message || "Клиент удалён"); + loadSyncClientsSettings(); + }) + .catch(() => notyf.error("Не удалось сохранить настройки клиента")); + } + + function toggleCredentialsForm() { + const credForm = document.getElementById("credentialsForm"); + const userCard = document.querySelector(".settings-user-card--clickable"); + if (!credForm) return; + + const isOpen = credForm.style.display !== "none"; + credForm.style.display = isOpen ? "none" : "block"; + if (userCard) userCard.classList.toggle("open", !isOpen); + } + + function toggleSyncForm() { + const syncForm = document.getElementById("syncSettingsForm"); + const syncCard = document.querySelector(".settings-sync-card--clickable"); + if (!syncForm) return; + + const isOpen = syncForm.style.display !== "none"; + syncForm.style.display = isOpen ? "none" : "block"; + if (syncCard) syncCard.classList.toggle("open", !isOpen); + if (!isOpen) loadSyncClientsSettings(); + } + + async function changeCredentials() { + const oldLogin = (document.getElementById("oldLogin")?.value || "").trim(); + const oldPassword = document.getElementById("oldPassword")?.value || ""; + const newLogin = (document.getElementById("newLogin")?.value || "").trim(); + const newPassword = document.getElementById("newPassword")?.value || ""; + const confirmPassword = document.getElementById("confirmPassword")?.value || ""; + + const wantsCredChange = !!(newLogin || newPassword || confirmPassword.trim()); + if (!wantsCredChange) { + notyf.success("Настройки сохранены"); + closeSettings(); + return; + } + + const validationError = validateCredentialChange({ + oldLogin, + oldPassword, + newLogin, + newPassword, + confirmPassword, + passwordMinLen, + }); + if (validationError) { + notyf.error(validationError); + return; + } + + try { + const response = await fetch("/api/auth/change_credentials", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + old_login: oldLogin, + old_password: oldPassword, + new_login: newLogin, + new_password: newPassword, + confirm_password: confirmPassword, + }), + }); + const data = await response.json(); + if (data.status === "success") { + notyf.success(data.message); + closeSettings(); + const userLogin = document.getElementById("userLogin"); + const mobileUserInfo = document.getElementById("mobileUserInfo"); + if (userLogin) userLogin.textContent = newLogin; + if (mobileUserInfo) { + mobileUserInfo.innerHTML = ` ${newLogin}`; + } + } else { + notyf.error(data.message); + } + } catch (error) { + console.error("Ошибка при изменении учетных данных:", error); + notyf.error("Ошибка при изменении учетных данных"); + } + } + + applyTheme(getTheme()); + + return { + checkAuth, + logout, + setupMobileMenu, + openSettings, + closeSettings, + toggleSyncForm, + loadSyncClientsSettings, + editSyncClientSettings, + saveSyncClientDisplayName, + deleteSyncClientSettings, + toggleCredentialsForm, + changeCredentials, + initThemePicker, + initHighContrastToggle, + initThemeToggle, + }; +} diff --git a/apps/web/public/wesp/js/pages/recipes-boot.js b/apps/web/public/wesp/js/pages/recipes-boot.js new file mode 100644 index 0000000..14b4c54 --- /dev/null +++ b/apps/web/public/wesp/js/pages/recipes-boot.js @@ -0,0 +1,18 @@ +import { LOGIN_TRANSITION_KEY, clearRecipesBootOverlay } from "/static/js/wesp-nav-preload.js"; + +function hasLoginTransition() { + try { + const raw = sessionStorage.getItem(LOGIN_TRANSITION_KEY); + if (!raw) return false; + const parsed = JSON.parse(raw); + return Boolean(parsed?.storedAt); + } catch { + return false; + } +} + +/** После входа — без полноэкранного логотипа, только page-enter на /recipes. */ +export function initRecipesBootOverlay() { + if (!hasLoginTransition()) return; + clearRecipesBootOverlay(); +} diff --git a/apps/web/public/wesp/js/pages/recipes-data-controller.js b/apps/web/public/wesp/js/pages/recipes-data-controller.js new file mode 100644 index 0000000..cee4bd7 --- /dev/null +++ b/apps/web/public/wesp/js/pages/recipes-data-controller.js @@ -0,0 +1,1062 @@ +import { buildRecipeRowsSkeletonHtml } from "../modules/recipes/recipe-skeleton.js"; +import { cowIconSvg, routeIconSvg } from "../wesp-zootech-icons.js"; +import { + peekPreloadedApiPayload, + clearPreloadedApiPayload, + clearRecipesBootOverlay, + PRELOAD_FEED_DISPENSERS_KEY, + PRELOAD_FEED_DISPENSERS_MAX_AGE_MS, +} from "../wesp-nav-preload.js"; + +export function createRecipesDataController({ + notyf, + getSelectedDispenser, + setSelectedDispenser, + getSelectedPeriod, + setSelectedPeriod, + getSelectedRecipe, + setSelectedRecipe, + setCurrentDispenserType, + getCurrentDispenserType, + loadRecipeDetails, + showRecipeEdit, + updatePasteButtons, +}) { + /** Сравнение id (UUID/число) без гонок при переключении оборудования/периода */ + function isSameId(a, b) { + return String(a ?? "") === String(b ?? ""); + } + + function recipeSkipBadgeHtml(label, title) { + return ( + `` + + '' + + `${label}` + ); + } + + function recipeAdjustBadgeHtml(label, title) { + return ( + `` + + '' + + `${label}` + ); + } + + function recipeSkipRestoreBtn(action, recipeId, title) { + return ( + `` + ); + } + + function dashboardSkipWarningHtml(title = "Сегодня есть отклонения от плана") { + return ( + `` + + '' + ); + } + + function recipeSkipExtrasHtml(recipe) { + if (recipe.skippedToday) { + return ( + recipeSkipBadgeHtml("сегодня не кормим", "Сегодня не кормим") + + recipeSkipRestoreBtn("unskip-recipe-today", recipe.id, "Вернуть рейс в план") + ); + } + let html = ""; + if (recipe.skippedIngredientToday) { + html += + recipeSkipBadgeHtml("сегодня убрали ингредиент", "Сегодня убрали ингредиент") + + recipeSkipRestoreBtn("unskip-ingredient-parts-today", recipe.id, "Вернуть все компоненты в план"); + } + if (recipe.skippedGroupToday) { + html += + recipeSkipBadgeHtml("сегодня убрали группу", "Сегодня убрали группу") + + recipeSkipRestoreBtn("unskip-group-parts-today", recipe.id, "Вернуть все группы в план"); + } + if (recipe.adjustedIngredientToday) { + html += recipeAdjustBadgeHtml("сегодня изменили норму", "Сегодня изменили норму в плане"); + } + if (recipe.replacedIngredientToday) { + html += recipeSkipBadgeHtml("сегодня заменили компонент", "Сегодня заменили компонент в плане"); + } + return html; + } + + function recipePlanBadgesRowHtml(recipe) { + const badges = recipeSkipExtrasHtml(recipe); + if (!badges) return ""; + return `
    ${badges}
    `; + } + + /** Верхние 3 списка на десктопе: до 4 карточек без прокрутки; дальше — max-height + scroll */ + const DASHBOARD_MAX_LIST_ITEMS = 4; + const DASHBOARD_LIST_IDS = new Set(["dispensersList", "periodsList", "recipesList"]); + /** Минимум времени показа скелетона (мс), чтобы не мигал при очень быстром ответе API */ + const DASHBOARD_SKELETON_MIN_MS = 220; + + /** Длительность FLIP-перестановки карточек (мс) */ + const RECIPES_REORDER_FLIP_MS = 620; + + const MOBILE_DASHBOARD_MQ = "(max-width: 768px)"; + const MOBILE_DASHBOARD_STEPS = ["dispensers", "periods", "recipes"]; + + function isMobileDashboardViewport() { + return globalThis.matchMedia?.(MOBILE_DASHBOARD_MQ)?.matches ?? false; + } + + function getMobileDashboardStepFromBody() { + for (const step of MOBILE_DASHBOARD_STEPS) { + if (document.body.classList.contains(`dashboard-mobile-step-${step}`)) { + return step; + } + } + return "dispensers"; + } + + function getListItemTitle(listId, dataAttr, id) { + if (id == null || id === "") return ""; + const item = document.querySelector(`#${listId} .list-item[${dataAttr}="${CSS.escape(String(id))}"]`); + if (!item) return ""; + const heading = item.querySelector("h4"); + return heading?.textContent?.replace(/\s+/g, " ").trim() ?? ""; + } + + function updateMobileDashboardNav(step) { + const nav = document.getElementById("recipesMobileNav"); + const titleEl = document.getElementById("recipesMobileNavTitle"); + if (!nav || !titleEl) return; + + if (!isMobileDashboardViewport() || step === "dispensers") { + nav.hidden = true; + titleEl.textContent = ""; + return; + } + + nav.hidden = false; + const dispenserTitle = getListItemTitle("dispensersList", "data-dispenser-id", getSelectedDispenser()); + const periodTitle = getListItemTitle("periodsList", "data-period-id", getSelectedPeriod()); + + if (step === "periods") { + titleEl.textContent = dispenserTitle || "Периоды кормления"; + return; + } + + if (getCurrentDispenserType() === "mill") { + titleEl.textContent = dispenserTitle || "Рецепты"; + return; + } + + const parts = [dispenserTitle, periodTitle].filter(Boolean); + titleEl.textContent = parts.length > 0 ? parts.join(" · ") : "Рейсы"; + } + + function setMobileDashboardStep(step) { + document.body.classList.remove( + ...MOBILE_DASHBOARD_STEPS.map((s) => `dashboard-mobile-step-${s}`) + ); + + if (!isMobileDashboardViewport()) { + updateMobileDashboardNav("dispensers"); + return; + } + + const resolvedStep = MOBILE_DASHBOARD_STEPS.includes(step) ? step : "dispensers"; + document.body.classList.add(`dashboard-mobile-step-${resolvedStep}`); + updateMobileDashboardNav(resolvedStep); + } + + function mobileDashboardBack() { + if (!isMobileDashboardViewport()) return; + + const step = getMobileDashboardStepFromBody(); + if (step === "recipes") { + showRecipeEdit(false); + if (getCurrentDispenserType() === "mill") { + setMobileDashboardStep("dispensers"); + } else { + setMobileDashboardStep("periods"); + } + return; + } + + if (step === "periods") { + setSelectedDispenser(null); + setSelectedPeriod(null); + setMobileDashboardStep("dispensers"); + document.querySelectorAll("#dispensersList .list-item").forEach((item) => { + item.classList.remove("active"); + }); + } + } + + function hasMobileDashboardStepClass() { + return MOBILE_DASHBOARD_STEPS.some((s) => + document.body.classList.contains(`dashboard-mobile-step-${s}`) + ); + } + + function syncMobileDashboardLayout() { + if (!isMobileDashboardViewport()) { + document.body.classList.remove( + ...MOBILE_DASHBOARD_STEPS.map((s) => `dashboard-mobile-step-${s}`) + ); + updateMobileDashboardNav("dispensers"); + return; + } + + if (document.body.classList.contains("recipe-edit-mobile-open")) { + return; + } + + if (!hasMobileDashboardStepClass()) { + setMobileDashboardStep("dispensers"); + } else { + updateMobileDashboardNav(getMobileDashboardStepFromBody()); + } + } + + globalThis.addEventListener("resize", () => { + syncMobileDashboardLayout(); + DASHBOARD_LIST_IDS.forEach((listId) => { + const listEl = document.getElementById(listId); + if (listEl) scheduleDashboardListLayout(listEl); + }); + if (!isMobileDashboardViewport()) { + document.body.classList.remove("recipe-edit-mobile-open"); + const recipeEdit = document.getElementById("recipeEdit"); + if (recipeEdit) { + recipeEdit.classList.remove("recipe-edit--mobile-active"); + } + document.documentElement.style.overflow = ""; + document.body.style.overflow = ""; + } + }); + + function clearRecipesListReorderClasses(listEl) { + if (!listEl) return; + listEl.classList.remove("recipes-list-reorder-pending", "recipes-list-reorder-settle"); + } + + function captureRecipeCardRects(listEl) { + const map = new Map(); + if (!listEl) return map; + listEl.querySelectorAll(":scope > .list-item[data-recipe-id]").forEach((el) => { + map.set(el.dataset.recipeId, el.getBoundingClientRect()); + }); + return map; + } + + function playRecipesListReorderFlip(listEl, firstRects) { + if (!listEl || !firstRects || firstRects.size === 0) { + clearRecipesListReorderClasses(listEl); + return; + } + const reduceMotion = globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches; + listEl.classList.remove("recipes-list-reorder-pending"); + if (reduceMotion) { + clearRecipesListReorderClasses(listEl); + return; + } + + const items = [...listEl.querySelectorAll(":scope > .list-item[data-recipe-id]")]; + const toAnimate = []; + + items.forEach((el) => { + const id = el.dataset.recipeId; + const first = firstRects.get(id); + if (!first) return; + const last = el.getBoundingClientRect(); + const dx = first.left - last.left; + const dy = first.top - last.top; + if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) return; + el.style.transition = "none"; + el.style.transformOrigin = "center center"; + el.style.willChange = "transform"; + el.style.boxShadow = "var(--recipe-card-flip-shadow, 0 8px 24px rgba(15, 23, 42, 0.12))"; + el.style.transform = `translate3d(${dx}px, ${dy}px, 0)`; + el.style.zIndex = "2"; + el.classList.add("recipe-card-reorder-flip"); + toAnimate.push(el); + }); + + if (toAnimate.length === 0) { + clearRecipesListReorderClasses(listEl); + return; + } + + void listEl.offsetHeight; + + requestAnimationFrame(() => { + const easing = "cubic-bezier(0.33, 1, 0.68, 1)"; + const sec = RECIPES_REORDER_FLIP_MS / 1000; + toAnimate.forEach((el) => { + el.style.transition = `transform ${sec}s ${easing}, box-shadow ${sec}s ${easing}`; + el.style.transform = "translate3d(0, 0, 0)"; + el.style.boxShadow = ""; + }); + }); + + globalThis.setTimeout(() => { + toAnimate.forEach((el) => { + el.style.transition = ""; + el.style.transform = ""; + el.style.willChange = ""; + el.style.transformOrigin = ""; + el.style.zIndex = ""; + el.style.boxShadow = ""; + el.classList.remove("recipe-card-reorder-flip"); + }); + clearRecipesListReorderClasses(listEl); + }, RECIPES_REORDER_FLIP_MS + 100); + } + + async function awaitDashboardSkeletonMin(skeletonStartedAt, options = {}) { + if (options.skip) return; + const elapsed = performance.now() - skeletonStartedAt; + const rest = DASHBOARD_SKELETON_MIN_MS - elapsed; + if (rest > 0) { + await new Promise((resolve) => setTimeout(resolve, rest)); + } + } + + function getDashboardListScrollCap(listId) { + if (isMobileDashboardViewport()) return null; + return DASHBOARD_MAX_LIST_ITEMS; + } + + function syncDashboardListScrollCap(listEl) { + if (!listEl?.id || !DASHBOARD_LIST_IDS.has(listEl.id)) return; + listEl.classList.add("dashboard-list-layout-settling"); + listEl.classList.remove("dashboard-list-scroll-cap"); + listEl.style.removeProperty("max-height"); + + const maxItems = getDashboardListScrollCap(listEl.id); + if (!maxItems) { + requestAnimationFrame(() => { + listEl.classList.remove("dashboard-list-layout-settling"); + }); + return; + } + + const items = listEl.querySelectorAll(":scope > .list-item"); + if (items.length <= maxItems) { + requestAnimationFrame(() => { + listEl.classList.remove("dashboard-list-layout-settling"); + }); + return; + } + let capPx = 0; + for (let i = 0; i < maxItems; i++) { + capPx += items[i].offsetHeight; + if (i < maxItems - 1) { + const a = items[i].getBoundingClientRect(); + const b = items[i + 1].getBoundingClientRect(); + capPx += Math.max(0, b.top - a.bottom); + } + } + listEl.style.setProperty("max-height", `${Math.ceil(capPx)}px`, "important"); + listEl.classList.add("dashboard-list-scroll-cap"); + requestAnimationFrame(() => { + listEl.classList.remove("dashboard-list-layout-settling"); + }); + } + + function ensureDashboardListScrollObservers() { + if (window._dashboardListScrollObserversReady) return; + window._dashboardListScrollObserversReady = true; + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver((entries) => { + entries.forEach((entry) => { + const listEl = entry.target; + if (listEl?.id && DASHBOARD_LIST_IDS.has(listEl.id)) { + syncDashboardListScrollCap(listEl); + } + }); + }); + DASHBOARD_LIST_IDS.forEach((listId) => { + const listEl = document.getElementById(listId); + if (listEl) observer.observe(listEl); + }); + } + + function scheduleDashboardListLayout(listEl) { + if (!listEl?.id || !DASHBOARD_LIST_IDS.has(listEl.id)) return; + ensureDashboardListScrollObservers(); + const run = () => syncDashboardListScrollCap(listEl); + requestAnimationFrame(() => { + requestAnimationFrame(run); + }); + } + + function prefersReducedMotion() { + return globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches; + } + + function getDashboardListSkeletonHtml(listId) { + const variantByList = { + dispensersList: "dispensers", + periodsList: "periods", + recipesList: "period", + millRecipesList: "mill", + }; + return buildRecipeRowsSkeletonHtml(variantByList[listId] || "period"); + } + + function markListLoading(listEl, loading) { + if (!listEl) return; + listEl.classList.toggle("list-container--loading", Boolean(loading)); + } + + function setListSkeleton(listEl, listId) { + if (!listEl) return; + listEl.innerHTML = getDashboardListSkeletonHtml(listId); + markListLoading(listEl, true); + scheduleDashboardListLayout(listEl); + } + + function setRecipesColumnIdle(idle) { + const recipesCol = document.getElementById("recipesCol"); + if (recipesCol) { + recipesCol.classList.toggle("dashboard-col--idle", Boolean(idle)); + } + } + + function setPeriodsColumnIdle(idle) { + const periodsCol = document.getElementById("periodsCol"); + if (periodsCol) { + periodsCol.classList.toggle("dashboard-col--idle", Boolean(idle)); + } + } + + function fadeOutListSkeleton(listEl) { + const skel = listEl?.querySelector( + ":scope > .dashboard-list-skeleton, :scope > .zt-loading-list", + ); + if (!skel) return; + skel.remove(); + } + + function replaceDashboardListContent(listEl, html) { + if (!listEl) return; + fadeOutListSkeleton(listEl); + listEl.innerHTML = html; + markListLoading(listEl, false); + scheduleDashboardListLayout(listEl); + } + + function clearStaleDashboardListSkeleton(listEl) { + if (!listEl?.querySelector(":scope > .dashboard-list-skeleton, :scope > .zt-loading-list")) return; + listEl.innerHTML = ""; + markListLoading(listEl, false); + scheduleDashboardListLayout(listEl); + } + + async function loadDispensers() { + const dispensersList = document.getElementById("dispensersList"); + let dispensers = peekPreloadedApiPayload( + PRELOAD_FEED_DISPENSERS_KEY, + PRELOAD_FEED_DISPENSERS_MAX_AGE_MS, + ); + const fromPreload = Array.isArray(dispensers); + const skeletonStartedAt = performance.now(); + + if (!fromPreload) { + setListSkeleton(dispensersList, "dispensersList"); + } + + try { + if (!fromPreload) { + const response = await fetch("/api/feed_dispensers"); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + dispensers = await response.json(); + } + + const awaitSkeleton = awaitDashboardSkeletonMin(skeletonStartedAt, { skip: fromPreload }); + + const finishList = async (html) => { + await awaitSkeleton; + await replaceDashboardListContent(dispensersList, html); + if (fromPreload) { + clearPreloadedApiPayload(PRELOAD_FEED_DISPENSERS_KEY); + } + clearRecipesBootOverlay(); + }; + + if (dispensers.length === 0) { + await finishList( + `
    + + Нет доступных кормораздатчиков +
    `, + ); + setMobileDashboardStep("dispensers"); + return; + } + + const html = dispensers + .map((dispenser) => { + const isMill = dispenser.type === "mill"; + const deviceIcon = isMill ? "fa-industry" : "fa-tractor"; + return ` +
    +
    +

    + + ${dispenser.name}${dispenser.hasSkipToday ? dashboardSkipWarningHtml(`Отклонения от плана: ${dispenser.name}`) : ""} +

    + ${!isMill ? `${dispenser.periods ? dispenser.periods.length : 0} периодов` : ""} +
    +
    + + + ${dispenser.operator || "Не указан"} + + + + ${dispenser.farm || "Не указана"} + +
    +
    + `; + }) + .join(""); + + await finishList(html); + setMobileDashboardStep("dispensers"); + } catch (error) { + console.error("Ошибка при загрузке кормораздатчиков:", error); + clearPreloadedApiPayload(PRELOAD_FEED_DISPENSERS_KEY); + await awaitDashboardSkeletonMin(skeletonStartedAt); + await replaceDashboardListContent( + dispensersList, + `
    + + Ошибка при загрузке кормораздатчиков: ${error.message} +
    ` + ); + clearRecipesBootOverlay(); + notyf.error("Ошибка при загрузке кормораздатчиков"); + } + } + + async function selectDispenser(dispenserId) { + setSelectedDispenser(dispenserId); + setSelectedPeriod(null); + showRecipeEdit(false, { skipMobileStep: true }); + + const dispenserIdStr = String(dispenserId); + document.querySelectorAll("#dispensersList .list-item").forEach((item) => { + item.classList.toggle("active", item.dataset.dispenserId === dispenserIdStr); + }); + + const periodsListEl = document.getElementById("periodsList"); + const recipesListEl = document.getElementById("recipesList"); + periodsListEl.innerHTML = ""; + markListLoading(periodsListEl, false); + recipesListEl.innerHTML = ""; + markListLoading(recipesListEl, false); + setPeriodsColumnIdle(true); + setRecipesColumnIdle(true); + scheduleDashboardListLayout(periodsListEl); + scheduleDashboardListLayout(recipesListEl); + + const response = await fetch("/api/feed_dispensers"); + const dispensers = await response.json(); + const dispenser = dispensers.find((d) => isSameId(d.id, dispenserId)); + + if (!isSameId(getSelectedDispenser(), dispenserId)) { + return; + } + + if (isMobileDashboardViewport()) { + setMobileDashboardStep(dispenser && dispenser.type === "mill" ? "recipes" : "periods"); + } + + const periodsCol = document.getElementById("periodsCol"); + const recipesCol = document.getElementById("recipesCol"); + const recipesHeaderText = document.getElementById("recipesCardHeaderText"); + const recipesCardIcon = document.getElementById("recipesCardIcon"); + const createRecipeBtn = document.getElementById("createRecipeBtn"); + + if (dispenser && dispenser.type === "mill") { + setCurrentDispenserType("mill"); + setPeriodsColumnIdle(true); + if (periodsCol) { + periodsCol.style.display = "none"; + periodsCol.classList.remove("col-md-4"); + } + if (recipesCol) { + recipesCol.classList.remove("col-md-4"); + recipesCol.classList.add("col-md-8"); + } + if (recipesHeaderText) recipesHeaderText.textContent = "Рецепты"; + if (recipesCardIcon) { + recipesCardIcon.innerHTML = ''; + recipesCardIcon.classList.remove("route-icon"); + } + if (createRecipeBtn) createRecipeBtn.style.display = ""; + setRecipesColumnIdle(false); + if (!isSameId(getSelectedDispenser(), dispenserId)) return; + await loadMillRecipes(dispenserId); + } else { + setCurrentDispenserType("dispenser"); + if (createRecipeBtn) createRecipeBtn.style.display = "none"; + setRecipesColumnIdle(true); + setPeriodsColumnIdle(false); + if (periodsCol) { + periodsCol.style.display = ""; + periodsCol.classList.add("col-md-4"); + } + if (recipesCol) { + recipesCol.classList.remove("col-md-8"); + recipesCol.classList.add("col-md-4"); + } + if (recipesHeaderText) recipesHeaderText.textContent = "Рейсы"; + if (recipesCardIcon) { + recipesCardIcon.innerHTML = routeIconSvg(); + recipesCardIcon.classList.add("route-icon"); + recipesCardIcon.classList.add("me-2"); + } + if (!isSameId(getSelectedDispenser(), dispenserId)) return; + await loadPeriods(dispenserId); + } + } + + async function loadMillRecipes(dispenserId) { + const recipesList = document.getElementById("recipesList"); + setListSkeleton(recipesList, "millRecipesList"); + const skeletonStartedAt = performance.now(); + + try { + const response = await fetch(`/api/feed_dispensers/${dispenserId}/recipes`); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const recipes = await response.json(); + + if (!isSameId(getSelectedDispenser(), dispenserId)) { + clearStaleDashboardListSkeleton(recipesList); + return; + } + + if (recipes.length === 0) { + await awaitDashboardSkeletonMin(skeletonStartedAt); + await replaceDashboardListContent( + recipesList, + `
    + + Нет доступных рецептов. Создайте первый рецепт. +
    ` + ); + return; + } + + const selectedRecipe = getSelectedRecipe(); + await awaitDashboardSkeletonMin(skeletonStartedAt); + const html = recipes + .map( + (recipe) => ` +
    +
    +

    + + ${recipe.name} +

    +
    +
    + +
    + +
    +
    + ${recipePlanBadgesRowHtml(recipe)} +
    + + + ${recipe.heads_count ?? recipe.headsPerTrip ?? 0} голов + + + + ${recipe.mixing_time ?? recipe.mixingTime ?? 0} мин + + + + ${recipe.trip_percent ?? recipe.tripPercent ?? 100}% + +
    +
    + ` + ) + .join(""); + await replaceDashboardListContent(recipesList, html); + } catch (error) { + console.error("Ошибка при загрузке рецептов кормоцеха:", error); + if (!isSameId(getSelectedDispenser(), dispenserId)) { + clearStaleDashboardListSkeleton(recipesList); + return; + } + await awaitDashboardSkeletonMin(skeletonStartedAt); + await replaceDashboardListContent( + recipesList, + `
    + + Ошибка при загрузке рецептов: ${error.message} +
    ` + ); + notyf.error("Ошибка при загрузке рецептов"); + } + } + + async function loadPeriods(dispenserId) { + const periodsList = document.getElementById("periodsList"); + setPeriodsColumnIdle(false); + setListSkeleton(periodsList, "periodsList"); + const skeletonStartedAt = performance.now(); + + try { + const response = await fetch(`/api/feed_dispensers/${dispenserId}/periods`); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data = await response.json(); + const periods = Array.isArray(data) ? data : []; + + if (!isSameId(getSelectedDispenser(), dispenserId)) { + clearStaleDashboardListSkeleton(periodsList); + return; + } + + if (periods.length === 0) { + await awaitDashboardSkeletonMin(skeletonStartedAt); + await replaceDashboardListContent( + periodsList, + `
    + + Нет доступных периодов +
    ` + ); + return; + } + + await awaitDashboardSkeletonMin(skeletonStartedAt); + const html = periods + .map( + (period) => ` +
    +
    +

    + + ${period.name}${period.hasSkipToday ? dashboardSkipWarningHtml(`Отклонения от плана: ${period.name}`) : ""} +

    + ${period.recipes && period.recipes.length > 0 + ? ` + + + ${period.recipes.length} рейсов + + ` + : ` + + + Нет рейсов + + `} +
    +
    +
    + + ${period.recipes && period.recipes.length > 0 + ? ` + + ` + : ` + + `} +
    +
    +
    + ` + ) + .join(""); + + await replaceDashboardListContent(periodsList, html); + updatePasteButtons(); + } catch (error) { + console.error("Ошибка при загрузке периодов:", error); + if (!isSameId(getSelectedDispenser(), dispenserId)) { + clearStaleDashboardListSkeleton(periodsList); + return; + } + await awaitDashboardSkeletonMin(skeletonStartedAt); + await replaceDashboardListContent( + periodsList, + `
    + + Ошибка при загрузке периодов: ${error.message} +
    ` + ); + notyf.error("Ошибка при загрузке периодов"); + } + } + + async function selectPeriod(periodId) { + setSelectedPeriod(periodId); + + document.querySelectorAll("#periodsList .list-item").forEach((item) => { + item.classList.remove("active"); + }); + document.querySelector(`[data-period-id="${periodId}"]`)?.classList.add("active"); + if (isMobileDashboardViewport()) { + setMobileDashboardStep("recipes"); + } + setRecipesColumnIdle(false); + const recipesListClear = document.getElementById("recipesList"); + recipesListClear.innerHTML = ""; + markListLoading(recipesListClear, false); + scheduleDashboardListLayout(recipesListClear); + + try { + await loadRecipes(periodId); + } catch (error) { + console.error("Ошибка при загрузке рейсов:", error); + notyf.error("Ошибка при загрузке рейсов"); + } + } + + async function loadRecipes(periodId, options = {}) { + if (!periodId) return; + + const skipRecipesSkeleton = Boolean(options.skipRecipesSkeleton); + const recipesListPre = document.getElementById("recipesList"); + let skeletonStartedAt = performance.now(); + if (recipesListPre) { + if (skipRecipesSkeleton) { + clearRecipesListReorderClasses(recipesListPre); + recipesListPre.classList.add("recipes-list-reorder-pending"); + } else { + setListSkeleton(recipesListPre, "recipesList"); + skeletonStartedAt = performance.now(); + } + } + + try { + const response = await fetch(`/api/periods/${periodId}/recipes`); + if (!response.ok) { + throw new Error("Ошибка при загрузке рецептов"); + } + + const recipes = await response.json(); + const recipesList = document.getElementById("recipesList"); + const selectedRecipe = getSelectedRecipe(); + + if (!isSameId(getSelectedPeriod(), periodId)) { + if (recipesList) clearRecipesListReorderClasses(recipesList); + if (!skipRecipesSkeleton) clearStaleDashboardListSkeleton(recipesList); + return; + } + + if (recipes.length === 0) { + if (!skipRecipesSkeleton) { + await awaitDashboardSkeletonMin(skeletonStartedAt); + } else if (recipesList) { + clearRecipesListReorderClasses(recipesList); + } + const emptyHtml = ` +
    + + Нет доступных рейсов +
    `; + if (skipRecipesSkeleton) { + recipesList.innerHTML = emptyHtml; + markListLoading(recipesList, false); + scheduleDashboardListLayout(recipesList); + } else { + await replaceDashboardListContent(recipesList, emptyHtml); + } + return; + } + + let firstRects = null; + if (skipRecipesSkeleton && recipesList) { + firstRects = captureRecipeCardRects(recipesList); + } + + if (!skipRecipesSkeleton) { + await awaitDashboardSkeletonMin(skeletonStartedAt); + } + const html = recipes + .map( + (recipe, index) => ` +
    +
    +
    + +
    +
    +
    + + + +
    + +
    +
    +

    + ${recipe.name} +

    + ${recipePlanBadgesRowHtml(recipe)} +
    + + + ${recipe.heads_count ?? recipe.headsPerTrip ?? 0} голов + + + + ${recipe.mixing_time ?? recipe.mixingTime ?? 0} мин + + + + ${recipe.trip_percent ?? recipe.tripPercent ?? 100}% + +
    +
    + ` + ) + .join(""); + if (skipRecipesSkeleton) { + recipesList.innerHTML = html; + markListLoading(recipesList, false); + scheduleDashboardListLayout(recipesList); + } else { + await replaceDashboardListContent(recipesList, html); + } + if (skipRecipesSkeleton && firstRects && firstRects.size > 0) { + /* Третий кадр после innerHTML: к этому моменту уже отработал scheduleDashboardListLayout (2× rAF + sync) */ + requestAnimationFrame(() => { + requestAnimationFrame(() => { + requestAnimationFrame(() => playRecipesListReorderFlip(recipesList, firstRects)); + }); + }); + } else if (skipRecipesSkeleton) { + clearRecipesListReorderClasses(recipesList); + } + } catch (error) { + console.error("Ошибка при загрузке рецептов:", error); + if (!isSameId(getSelectedPeriod(), periodId)) { + return; + } + if (!skipRecipesSkeleton) { + await awaitDashboardSkeletonMin(skeletonStartedAt); + } + const recipesListErr = document.getElementById("recipesList"); + if (recipesListErr) { + clearRecipesListReorderClasses(recipesListErr); + const errHtml = ` +
    + + Ошибка при загрузке рейсов +
    `; + if (skipRecipesSkeleton) { + recipesListErr.innerHTML = errHtml; + markListLoading(recipesListErr, false); + scheduleDashboardListLayout(recipesListErr); + } else { + await replaceDashboardListContent(recipesListErr, errHtml); + } + } + notyf.error("Ошибка при загрузке рецептов"); + } + } + + async function selectRecipe(recipeId) { + setSelectedRecipe(recipeId); + + document.querySelectorAll("#recipesList .list-item").forEach((item) => { + item.classList.remove("active"); + }); + document.querySelector(`[data-recipe-id="${recipeId}"]`)?.classList.add("active"); + + const skeletonStartedAt = performance.now(); + showRecipeEdit(true, { loading: true }); + await loadRecipeDetails(recipeId); + await awaitDashboardSkeletonMin(skeletonStartedAt); + showRecipeEdit(true, { loading: false }); + } + + return { + loadDispensers, + selectDispenser, + loadMillRecipes, + loadPeriods, + selectPeriod, + loadRecipes, + selectRecipe, + getCurrentDispenserType, + setMobileDashboardStep, + mobileDashboardBack, + }; +} diff --git a/apps/web/public/wesp/js/pages/recipes-editor-controller.js b/apps/web/public/wesp/js/pages/recipes-editor-controller.js new file mode 100644 index 0000000..14b57e9 --- /dev/null +++ b/apps/web/public/wesp/js/pages/recipes-editor-controller.js @@ -0,0 +1,231 @@ +export function createRecipesEditorController({ + notyf, + getCurrentDispenserType, + setCurrentDispenserType, + getComponentsCache, + ensureComponentsLoaded, + setSelectedDispenser, + setSelectedPeriod, + setSelectedRecipe, + resetDeletedEntities, + setUnloadingLinkBroken, + initializeUnloadingLinkButton, + updateTripPercentFieldState, + setDryMatterMode, + loadIngredients, + loadUnloadingGroups, + showRecipeEdit, +}) { + function resetCreationForm() { + const recipeForm = document.getElementById("recipeForm"); + if (recipeForm) recipeForm.reset(); + + const ingredientsTableBody = document.getElementById("ingredientsTableBody"); + if (ingredientsTableBody) ingredientsTableBody.innerHTML = ""; + + const unloadingGroupsTableBody = document.getElementById("unloadingGroupsTableBody"); + if (unloadingGroupsTableBody) unloadingGroupsTableBody.innerHTML = ""; + + updateTripPercentFieldState(); + resetDeletedEntities(); + + const tripPercent = document.getElementById("tripPercent"); + if (tripPercent) tripPercent.value = 100; + + const totalWeightIngredients = document.getElementById("totalWeightIngredients"); + if (totalWeightIngredients) totalWeightIngredients.textContent = "0"; + + const totalTripWeight = document.getElementById("totalTripWeight"); + if (totalTripWeight) totalTripWeight.textContent = "0"; + + const totalDryMatterPerHead = document.getElementById("totalDryMatterPerHead"); + if (totalDryMatterPerHead) totalDryMatterPerHead.textContent = "0"; + + const totalWeightPerHead = document.getElementById("totalWeightPerHead"); + if (totalWeightPerHead) totalWeightPerHead.textContent = "0"; + } + + async function loadComponentsForMill() { + try { + await ensureComponentsLoaded(); + const select = document.getElementById("targetComponentSelect"); + if (!select) return; + + const components = Array.isArray(getComponentsCache()) ? getComponentsCache() : []; + const currentValue = select.value; + select.innerHTML = ` + + ${components.map((component) => ``).join("")} + `; + if (currentValue) { + select.value = currentValue; + } + } catch (error) { + console.error("Ошибка при загрузке компонентов для кормоцеха:", error); + } + } + + function toggleUnloadingBlocks() { + const isMill = getCurrentDispenserType() === "mill"; + const unloadingGroupsCard = document.getElementById("unloadingGroupsCard"); + const componentSelectionCard = document.getElementById("componentSelectionCard"); + + if (unloadingGroupsCard) unloadingGroupsCard.style.display = "block"; + if (componentSelectionCard) { + componentSelectionCard.style.display = isMill ? "block" : "none"; + } + + if (isMill) { + loadComponentsForMill(); + } + } + + async function loadRecipeDetails(recipeId, options = {}) { + try { + const response = await fetch(`/api/recipes/${recipeId}`); + if (!response.ok) throw new Error("Ошибка при загрузке данных рецепта"); + let recipe = await response.json(); + + if (!options.skipPlanOverlayFlags) { + const planDate = + options.planDate || + globalThis.WespDailyPlanPanel?.getSelectedPlanDate?.() || + new Date().toISOString().slice(0, 10); + try { + const overlayResp = await fetch( + `/api/recipes/${recipeId}?date=${encodeURIComponent(planDate)}` + ); + if (overlayResp.ok) { + const overlay = await overlayResp.json(); + const ingById = new Map( + (overlay.ingredients || []).map((ing) => [String(ing.id), ing]) + ); + recipe = { + ...recipe, + ingredients: (recipe.ingredients || []).map((ing) => { + const overlayIng = ingById.get(String(ing.id)); + if (!overlayIng) return ing; + return { + ...ing, + skippedToday: Boolean(overlayIng.skippedToday), + adjustedToday: Boolean(overlayIng.adjustedToday), + replacedToday: Boolean(overlayIng.replacedToday), + }; + }), + }; + const groups = recipe.unloading_groups || recipe.unloadingGroups || []; + const grpById = new Map( + (overlay.unloadingGroups || overlay.unloading_groups || []).map((group) => [ + String(group.id), + group, + ]) + ); + const mergedGroups = groups.map((group) => { + const overlayGroup = grpById.get(String(group.id)); + if (!overlayGroup) return group; + return { ...group, skippedToday: Boolean(overlayGroup.skippedToday) }; + }); + recipe.unloading_groups = mergedGroups; + recipe.unloadingGroups = mergedGroups; + } + } catch (overlayError) { + console.warn("Не удалось загрузить флаги плана для редактора рецепта:", overlayError); + } + } + + resetDeletedEntities(); + + const recipeNameInput = document.getElementById("recipeName"); + if (recipeNameInput) recipeNameInput.value = recipe.name || ""; + const headsCountInput = document.getElementById("headsCount"); + if (headsCountInput) { + const heads = + recipe.heads_count ?? + recipe.headsPerTrip ?? + recipe.heads_per_trip ?? + ""; + headsCountInput.value = heads === "" || heads == null ? "" : String(heads); + } + const mixingTimeInput = document.getElementById("mixingTime"); + if (mixingTimeInput) { + const mt = recipe.mixing_time ?? recipe.mixingTime; + mixingTimeInput.value = mt === "" || mt == null ? "" : String(mt); + } + const tripPercentInput = document.getElementById("tripPercent"); + if (tripPercentInput) { + const tp = recipe.trip_percent ?? recipe.tripPercent; + tripPercentInput.value = tp === "" || tp == null ? 100 : String(tp); + } + + const ingredientsTableBody = document.getElementById("ingredientsTableBody"); + if (ingredientsTableBody) ingredientsTableBody.innerHTML = ""; + const unloadingGroupsTableBody = document.getElementById("unloadingGroupsTableBody"); + if (unloadingGroupsTableBody) unloadingGroupsTableBody.innerHTML = ""; + + setUnloadingLinkBroken(recipe.unloading_link_broken || false); + initializeUnloadingLinkButton(); + updateTripPercentFieldState(); + + setDryMatterMode(!!recipe.dry_matter_locked); + toggleUnloadingBlocks(); + updateTripPercentFieldState(); + + await loadIngredients(recipe.ingredients); + const unloadingGroups = recipe.unloading_groups || recipe.unloadingGroups || []; + await loadUnloadingGroups(unloadingGroups); + + if (getCurrentDispenserType() === "mill") { + const targetComponentSelect = document.getElementById("targetComponentSelect"); + if (targetComponentSelect) { + targetComponentSelect.value = ""; + if (recipe.target_component_id) { + targetComponentSelect.value = recipe.target_component_id; + } + } + } + } catch (error) { + console.error("Ошибка при загрузке данных рецепта:", error); + notyf.error("Ошибка при загрузке данных рецепта"); + } + } + + async function createNewRecipeForMill(dispenserId) { + try { + setSelectedDispenser(dispenserId); + setSelectedPeriod(null); + setSelectedRecipe(null); + setCurrentDispenserType("mill"); + + resetCreationForm(); + toggleUnloadingBlocks(); + showRecipeEdit(true); + setDryMatterMode(false); + } catch (error) { + console.error("Ошибка в createNewRecipeForMill:", error); + notyf.error("Ошибка при создании рецепта"); + } + } + + async function createNewRecipe(periodId) { + try { + setSelectedPeriod(periodId); + setSelectedRecipe(null); + + resetCreationForm(); + toggleUnloadingBlocks(); + showRecipeEdit(true); + setDryMatterMode(false); + } catch (error) { + console.error("Ошибка в createNewRecipe:", error); + notyf.error("Ошибка при создании рейса"); + } + } + + return { + toggleUnloadingBlocks, + loadComponentsForMill, + loadRecipeDetails, + createNewRecipeForMill, + createNewRecipe, + }; +} diff --git a/apps/web/public/wesp/js/pages/recipes-operations-controller.js b/apps/web/public/wesp/js/pages/recipes-operations-controller.js new file mode 100644 index 0000000..c1034b4 --- /dev/null +++ b/apps/web/public/wesp/js/pages/recipes-operations-controller.js @@ -0,0 +1,336 @@ +export function createRecipesOperationsController({ + notyf, + getSelectedRecipe, + setSelectedRecipe, + getSelectedPeriod, + setSelectedPeriod, + getSelectedDispenser, + getCurrentDispenserType, + showRecipeEdit, + loadRecipes, + loadPeriods, + ensureComponentsLoaded, + setUnloadingLinkBroken, + initializeUnloadingLinkButton, + updateTripPercentFieldState, + setDryMatterMode, + loadIngredients, + loadUnloadingGroups, + recalculateWeights, + updateTotalValues, +}) { + let copiedRecipeData = null; + let moveRecipeInFlight = false; + + async function deleteRecipe(recipeId) { + const periodId = getSelectedPeriod(); + const dispenserId = getSelectedDispenser(); + const isMill = + typeof getCurrentDispenserType === "function" && getCurrentDispenserType() === "mill"; + /** В контексте периода кормораздатчика удаляем только связь с периодом — иначе DELETE /api/recipes глобально убирает рецепт со всех оборудований. */ + const unlinkFromPeriodOnly = Boolean(periodId && dispenserId && !isMill); + + const msg = unlinkFromPeriodOnly + ? "Удалить этот рейс из выбранного периода? (Рецепт останется в базе, если используется ещё где-то.)" + : "Вы уверены, что хотите удалить этот рецепт? Он будет удалён для всех привязок."; + const ok = globalThis.WespKioskDialog + ? await globalThis.WespKioskDialog.confirm(msg) + : globalThis.confirm(msg); + if (!ok) return; + + try { + const url = unlinkFromPeriodOnly + ? `/api/feed_dispensers/${encodeURIComponent(dispenserId)}/periods/${encodeURIComponent(periodId)}/recipes/${encodeURIComponent(recipeId)}` + : `/api/recipes/${encodeURIComponent(recipeId)}`; + + const response = await fetch(url, { method: "DELETE" }); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || "Ошибка при удалении рейса"); + } + + const result = await response.json().catch(() => ({})); + notyf.success(result.message || (unlinkFromPeriodOnly ? "Рейс удалён из периода" : "Рейс удален")); + + if (String(getSelectedRecipe() ?? "") === String(recipeId)) { + setSelectedRecipe(null); + showRecipeEdit(false); + } + + if (getSelectedPeriod()) { + await loadRecipes(getSelectedPeriod()); + } + if (getSelectedDispenser()) { + await loadPeriods(getSelectedDispenser()); + } + } catch (error) { + console.error("Ошибка при удалении рейса:", error); + notyf.error(error.message, { fallback: "Ошибка при удалении рейса" }); + } + } + + async function moveRecipe(recipeId, fromIndex, toIndex) { + const selectedPeriod = getSelectedPeriod(); + if (!selectedPeriod || fromIndex === toIndex) return; + + if ( + typeof document !== "undefined" && + document.body.classList.contains("recipe-period-transfer-active") + ) { + notyf.error( + "Сначала завершите перенос в другой период (кнопка «Вставить здесь») или отмените его (Escape, отпустите вне списка)." + ); + return; + } + + if (moveRecipeInFlight) return; + + if (!applyOptimisticRecipeReorder(fromIndex, toIndex)) { + return; + } + + moveRecipeInFlight = true; + try { + const response = await fetch(`/api/recipes/${encodeURIComponent(recipeId)}/move`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ from_index: fromIndex, to_index: toIndex, period_id: selectedPeriod }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + await loadRecipes(selectedPeriod, { skipRecipesSkeleton: true }); + throw new Error(errorData.message || "Ошибка при перемещении рецепта"); + } + + const listEl = document.getElementById("recipesList"); + if (listEl) { + reindexRecipeListItems(listEl); + } + } catch (error) { + console.error("Ошибка при перемещении рейса:", error); + notyf.error(error.message, { fallback: "Ошибка при перемещении рейса" }); + } finally { + moveRecipeInFlight = false; + } + } + + function reindexRecipeListItems(listEl) { + const items = listEl.querySelectorAll(":scope > .list-item[data-recipe-id]"); + items.forEach((item, index) => { + item.dataset.index = String(index); + item.querySelectorAll("[data-index]").forEach((el) => { + el.dataset.index = String(index); + }); + const upBtn = item.querySelector('[data-action="move-recipe-up"]'); + const downBtn = item.querySelector('[data-action="move-recipe-down"]'); + if (upBtn) upBtn.disabled = index === 0; + if (downBtn) downBtn.disabled = index === items.length - 1; + }); + } + + function applyOptimisticRecipeReorder(fromIndex, toIndex) { + const listEl = document.getElementById("recipesList"); + if (!listEl) return false; + const items = [...listEl.querySelectorAll(":scope > .list-item[data-recipe-id]")]; + if ( + fromIndex < 0 || + toIndex < 0 || + fromIndex >= items.length || + toIndex >= items.length || + fromIndex === toIndex + ) { + return false; + } + const [moved] = items.splice(fromIndex, 1); + items.splice(toIndex, 0, moved); + items.forEach((item) => listEl.appendChild(item)); + reindexRecipeListItems(listEl); + return true; + } + + async function moveRecipeUp(recipeId, currentIndex) { + if (currentIndex === 0) { + notyf.error("Рейс уже находится в начале списка"); + return; + } + + try { + await moveRecipe(recipeId, currentIndex, currentIndex - 1); + } catch (error) { + console.error("Ошибка при перемещении рейса:", error); + notyf.error(error.message, { fallback: "Ошибка при перемещении рейса" }); + } + } + + async function moveRecipeDown(recipeId, currentIndex) { + const totalRecipes = document.querySelectorAll("#recipesList .list-item").length; + if (currentIndex >= totalRecipes - 1) { + notyf.error("Рейс уже находится в конце списка"); + return; + } + + try { + await moveRecipe(recipeId, currentIndex, currentIndex + 1); + } catch (error) { + console.error("Ошибка при перемещении рейса:", error); + notyf.error(error.message, { fallback: "Ошибка при перемещении рейса" }); + } + } + + async function pasteRecipe(periodId) { + if (!copiedRecipeData) { + notyf.error("Буфер пуст. Сначала скопируйте рейс."); + return; + } + + try { + setSelectedPeriod(periodId); + setSelectedRecipe(null); + + await ensureComponentsLoaded(); + + const recipeName = document.getElementById("recipeName"); + if (recipeName) { + recipeName.value = `${copiedRecipeData.name} (копия)`; + } + + const headsCount = document.getElementById("headsCount"); + if (headsCount) { + const h = + copiedRecipeData.heads_count ?? + copiedRecipeData.headsPerTrip ?? + copiedRecipeData.heads_per_trip ?? + ""; + headsCount.value = h === "" || h == null ? "" : String(h); + } + + const mixingTime = document.getElementById("mixingTime"); + if (mixingTime) { + const mt = copiedRecipeData.mixing_time ?? copiedRecipeData.mixingTime; + mixingTime.value = mt === "" || mt == null ? "" : String(mt); + } + + const tripPercent = document.getElementById("tripPercent"); + if (tripPercent) { + const tp = copiedRecipeData.trip_percent ?? copiedRecipeData.tripPercent; + tripPercent.value = tp === "" || tp == null ? 100 : String(tp); + } + + const ingredientsTableBody = document.getElementById("ingredientsTableBody"); + if (ingredientsTableBody) { + ingredientsTableBody.innerHTML = ""; + } + + const unloadingGroupsTableBody = document.getElementById("unloadingGroupsTableBody"); + if (unloadingGroupsTableBody) { + unloadingGroupsTableBody.innerHTML = ""; + } + + setUnloadingLinkBroken(Boolean(copiedRecipeData.unloading_link_broken)); + initializeUnloadingLinkButton(); + updateTripPercentFieldState(); + + setDryMatterMode(false); + + if (copiedRecipeData.ingredients && copiedRecipeData.ingredients.length > 0) { + await loadIngredients(copiedRecipeData.ingredients); + } + + const copiedGroups = + copiedRecipeData.unloading_groups?.length > 0 + ? copiedRecipeData.unloading_groups + : copiedRecipeData.unloadingGroups; + if (copiedGroups && copiedGroups.length > 0) { + await loadUnloadingGroups(copiedGroups); + } + + showRecipeEdit(true); + + setTimeout(() => { + Promise.resolve(recalculateWeights()).catch((err) => console.error("Ошибка при расчете:", err)); + }, 100); + setTimeout(() => { + Promise.resolve(updateTotalValues()).catch((err) => console.error("Ошибка при расчете:", err)); + }, 100); + + notyf.success("Рейс вставлен из буфера"); + } catch (error) { + console.error("Ошибка при вставке рейса:", error); + notyf.error("Ошибка при вставке рейса"); + } + } + + async function copyRecipe(recipeId) { + if (!recipeId) { + notyf.error("Выберите рейс для копирования"); + return; + } + + try { + const response = await fetch(`/api/recipes/${recipeId}`); + if (!response.ok) { + throw new Error("Ошибка при загрузке рейса"); + } + + const recipe = await response.json(); + copiedRecipeData = { + name: recipe.name, + heads_count: + recipe.heads_count ?? + recipe.headsPerTrip ?? + recipe.heads_per_trip ?? + 0, + mixing_time: recipe.mixing_time ?? recipe.mixingTime ?? 0, + trip_percent: recipe.trip_percent ?? recipe.tripPercent ?? 100, + ingredients: (recipe.ingredients || []).map((ing) => ({ + component_id: ing.component_id, + weight_per_head: ing.weight_per_head, + amount: ing.amount, + dry_matter: ing.dry_matter, + order: ing.order, + })), + unloading_link_broken: Boolean( + recipe.unloading_link_broken ?? recipe.unloadingLinkBroken ?? false + ), + unloading_groups: (recipe.unloading_groups || recipe.unloadingGroups || []).map((group) => ({ + name: group.name, + distribution_type: group.distribution_type ?? group.distributionType ?? "percent", + value: group.value, + weight: group.weight, + order: group.order, + })), + }; + + updatePasteButtons(); + notyf.success("Рейс скопирован"); + } catch (error) { + console.error("Ошибка при копировании рейса:", error); + notyf.error("Ошибка при копировании рейса"); + } + } + + function updatePasteButtons() { + const pasteButtons = document.querySelectorAll(".paste-recipe-btn"); + const hasData = copiedRecipeData !== null; + + pasteButtons.forEach((button) => { + button.disabled = !hasData; + if (hasData) { + button.title = `Вставить рейс "${copiedRecipeData.name}"`; + } else { + button.title = "Вставить рейс из буфера"; + } + }); + } + + return { + deleteRecipe, + moveRecipe, + moveRecipeUp, + moveRecipeDown, + pasteRecipe, + copyRecipe, + updatePasteButtons, + }; +} diff --git a/apps/web/public/wesp/js/pages/recipes-page.js b/apps/web/public/wesp/js/pages/recipes-page.js new file mode 100644 index 0000000..f925da1 --- /dev/null +++ b/apps/web/public/wesp/js/pages/recipes-page.js @@ -0,0 +1,135 @@ +import { RecipeEditor } from "/static/js/modules/recipes/recipe-editor.js"; +import { SyncPanel } from "/static/js/modules/recipes/sync-panel.js"; +import { DispenserSelector } from "/static/js/modules/recipes/dispenser-selector.js"; +import { initRecipeMobileSheetGestures } from "/static/js/modules/recipes/recipe-mobile-sheet-gestures.js"; + +let initialized = false; + +export function initRecipesPage(handlers) { + if (initialized) return; + initialized = true; + + const recipeEditorModule = new RecipeEditor({ + addIngredient: handlers.addIngredient, + toggleUnloadingLink: handlers.toggleUnloadingLink, + addUnloadingGroup: handlers.addUnloadingGroup, + showRecipeEdit: handlers.showRecipeEdit, + printAllRecipesInPeriod: handlers.printAllRecipesInPeriod, + printRecipe: handlers.printRecipe, + saveRecipe: handlers.saveRecipe, + moveGroupUp: handlers.moveGroupUp, + moveGroupDown: handlers.moveGroupDown, + removeUnloadingGroup: handlers.removeUnloadingGroup, + moveIngredientUp: handlers.moveIngredientUp, + moveIngredientDown: handlers.moveIngredientDown, + removeIngredient: handlers.removeIngredient, + openIngredientMobileSheet: handlers.openIngredientMobileSheet, + closeIngredientMobileSheet: handlers.closeIngredientMobileSheet, + removeIngredientFromSheet: handlers.removeIngredientFromSheet, + openUnloadingGroupMobileSheet: handlers.openUnloadingGroupMobileSheet, + closeUnloadingGroupMobileSheet: handlers.closeUnloadingGroupMobileSheet, + handleGroupTypeChange: handlers.handleGroupTypeChange, + validateGroupValue: handlers.validateGroupValue, + handleIngredientChange: handlers.handleIngredientChange, + handleDryMatterPercentChange: handlers.handleDryMatterPercentChange, + recalculateWeights: handlers.recalculateWeights, + recalculateFromTotalWeight: handlers.recalculateFromTotalWeight, + }); + + const syncPanelModule = new SyncPanel({ + openSettings: handlers.openSettings, + closeSettings: handlers.closeSettings, + toggleCredentialsForm: handlers.toggleCredentialsForm, + toggleSyncForm: handlers.toggleSyncForm, + changeCredentials: handlers.changeCredentials, + editSyncClientSettings: handlers.editSyncClientSettings, + deleteSyncClientSettings: handlers.deleteSyncClientSettings, + saveSyncClientDisplayName: handlers.saveSyncClientDisplayName, + loadSyncClientsSettings: handlers.loadSyncClientsSettings, + }); + + const dispenserSelectorModule = new DispenserSelector({ + selectDispenser: handlers.selectDispenser, + selectRecipe: handlers.selectRecipe, + selectPeriod: handlers.selectPeriod, + copyRecipe: handlers.copyRecipe, + deleteRecipe: handlers.deleteRecipe, + unskipRecipeToday: handlers.unskipRecipeToday, + unskipIngredientPartsToday: handlers.unskipIngredientPartsToday, + unskipGroupPartsToday: handlers.unskipGroupPartsToday, + pasteRecipe: handlers.pasteRecipe, + createNewRecipe: handlers.createNewRecipe, + moveRecipeUp: handlers.moveRecipeUp, + moveRecipeDown: handlers.moveRecipeDown, + }); + + initRecipeMobileSheetGestures({ + closeIngredientMobileSheet: handlers.closeIngredientMobileSheet, + closeUnloadingGroupMobileSheet: handlers.closeUnloadingGroupMobileSheet, + closeRecipeHelpModal: handlers.closeRecipeHelpModal, + }); + + const createRecipeBtn = document.getElementById("createRecipeBtn"); + if (createRecipeBtn) { + createRecipeBtn.addEventListener("click", (event) => { + event.stopPropagation(); + const selectedDispenser = handlers.getSelectedDispenser(); + if (selectedDispenser) { + handlers.createNewRecipeForMill(selectedDispenser); + } + }); + } + + document.addEventListener("click", async (event) => { + const actionEl = event.target.closest("[data-action]"); + if (!actionEl) return; + + const action = actionEl.dataset.action; + if (await syncPanelModule.handleAction(action, event, actionEl)) return; + if (await dispenserSelectorModule.handleAction(action, event, actionEl)) return; + if (await recipeEditorModule.handleAction(action, event, actionEl)) return; + + if (action === "open-recipe-help") { + event.preventDefault(); + handlers.openRecipeHelp?.(actionEl.id); + return; + } + + if (action === "close-recipe-help") { + event.preventDefault(); + handlers.closeRecipeHelpModal?.(); + return; + } + + if (action === "mobile-dashboard-back") { + event.preventDefault(); + handlers.mobileDashboardBack?.(); + return; + } + + if (action === "logout") { + event.preventDefault(); + handlers.logout(); + } + }); + + document.addEventListener("change", (event) => { + const actionEl = event.target.closest("[data-action]"); + if (!actionEl) return; + const action = actionEl.dataset.action; + recipeEditorModule.handleChange(action, actionEl); + }); + + document.addEventListener("keydown", (event) => { + if (event.key !== "Escape") return; + const ingSheet = document.getElementById("ingredientMobileSheet"); + if (ingSheet && !ingSheet.hidden) { + handlers.closeIngredientMobileSheet?.(); + return; + } + const grpSheet = document.getElementById("unloadingGroupMobileSheet"); + if (grpSheet && !grpSheet.hidden) { + handlers.closeUnloadingGroupMobileSheet?.(); + } + }); +} diff --git a/apps/web/public/wesp/js/pages/reports-date-range.js b/apps/web/public/wesp/js/pages/reports-date-range.js new file mode 100644 index 0000000..79fad84 --- /dev/null +++ b/apps/web/public/wesp/js/pages/reports-date-range.js @@ -0,0 +1,315 @@ +/** + * Компактный Date Range Picker для /reports. + * Сохраняет #date-from / #date-to для совместимости с остальным кодом. + */ +(function (global) { + const MONTHS = [ + "янв", + "фев", + "мар", + "апр", + "май", + "июн", + "июл", + "авг", + "сен", + "окт", + "ноя", + "дек", + ]; + + let draftFrom = null; + let draftTo = null; + let viewMonth = null; + let activePreset = "day"; + let panelEl = null; + let triggerEl = null; + + function pad(n) { + return String(n).padStart(2, "0"); + } + + function toIso(d) { + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; + } + + function parseIso(s) { + if (!s) return null; + const p = String(s).split("-"); + if (p.length !== 3) return null; + const d = new Date(Number(p[0]), Number(p[1]) - 1, Number(p[2])); + d.setHours(0, 0, 0, 0); + return d; + } + + function startOfDay(d) { + const x = new Date(d); + x.setHours(0, 0, 0, 0); + return x; + } + + function today() { + return startOfDay(new Date()); + } + + function sameDay(a, b) { + return a && b && toIso(a) === toIso(b); + } + + function formatLabel(from, to) { + if (!from || !to) return "Выберите даты"; + const t = today(); + const fmt = (d) => `${pad(d.getDate())}.${pad(d.getMonth() + 1)}`; + if (sameDay(from, to) && sameDay(from, t)) return "Сегодня"; + if (sameDay(from, to)) return fmt(from); + return `${fmt(from)} — ${fmt(to)}`; + } + + function syncHiddenInputs(from, to) { + const fromEl = document.getElementById("date-from"); + const toEl = document.getElementById("date-to"); + if (fromEl) fromEl.value = from ? toIso(from) : ""; + if (toEl) toEl.value = to ? toIso(to) : ""; + } + + function readCommittedRange() { + const fromEl = document.getElementById("date-from"); + const toEl = document.getElementById("date-to"); + return { + from: parseIso(fromEl?.value), + to: parseIso(toEl?.value), + }; + } + + function updateTriggerLabel() { + const label = document.getElementById("reportsDateRangeLabel"); + if (!label) return; + const { from, to } = readCommittedRange(); + label.textContent = formatLabel(from, to); + } + + function setPresetActive(preset) { + activePreset = preset || ""; + document.querySelectorAll("[data-drp-preset]").forEach((btn) => { + btn.classList.toggle("active", btn.getAttribute("data-drp-preset") === activePreset); + }); + } + + function computePresetRange(preset) { + const t = today(); + let from = t; + let to = t; + if (preset === "week") { + from = new Date(t); + from.setDate(t.getDate() - ((t.getDay() + 6) % 7)); + } else if (preset === "month") { + from = new Date(t.getFullYear(), t.getMonth(), 1); + } + return { from: startOfDay(from), to: startOfDay(to) }; + } + + function commitDraft() { + if (draftFrom && !draftTo) draftTo = draftFrom; + if (!draftFrom || !draftTo) return readCommittedRange(); + syncHiddenInputs(draftFrom, draftTo); + setPresetActive(""); + updateTriggerLabel(); + return { from: draftFrom, to: draftTo }; + } + + function applyRange(from, to, preset) { + syncHiddenInputs(from, to); + draftFrom = from; + draftTo = to; + setPresetActive(preset || ""); + updateTriggerLabel(); + } + + function commitSelection() { + commitDraft(); + closePanel(); + } + + function positionPanel() { + if (!triggerEl || !panelEl || panelEl.hidden) return; + const rect = triggerEl.getBoundingClientRect(); + const width = Math.max(rect.width, 304); + let left = rect.left; + if (left + width > window.innerWidth - 12) { + left = Math.max(12, window.innerWidth - width - 12); + } + let top = rect.bottom + 6; + const panelHeight = panelEl.offsetHeight || 360; + if (top + panelHeight > window.innerHeight - 12) { + top = Math.max(12, rect.top - panelHeight - 6); + } + panelEl.style.left = `${left}px`; + panelEl.style.top = `${top}px`; + panelEl.style.width = `${width}px`; + } + + function closePanel() { + if (panelEl) panelEl.hidden = true; + if (triggerEl) triggerEl.setAttribute("aria-expanded", "false"); + window.removeEventListener("resize", positionPanel); + window.removeEventListener("scroll", positionPanel, true); + } + + function openPanel() { + const committed = readCommittedRange(); + draftFrom = committed.from || today(); + draftTo = committed.to || draftFrom; + viewMonth = new Date(draftFrom.getFullYear(), draftFrom.getMonth(), 1); + renderCalendar(); + if (panelEl) panelEl.hidden = false; + if (triggerEl) triggerEl.setAttribute("aria-expanded", "true"); + positionPanel(); + window.addEventListener("resize", positionPanel); + window.addEventListener("scroll", positionPanel, true); + } + + function togglePanel() { + if (panelEl?.hidden) openPanel(); + else closePanel(); + } + + function inRange(d, a, b) { + if (!a || !b) return false; + const t = d.getTime(); + const lo = Math.min(a.getTime(), b.getTime()); + const hi = Math.max(a.getTime(), b.getTime()); + return t >= lo && t <= hi; + } + + function onDayClick(d) { + if (!draftFrom || (draftFrom && draftTo)) { + draftFrom = d; + draftTo = null; + setPresetActive(""); + renderCalendar(); + return; + } + if (d < draftFrom) { + draftTo = draftFrom; + draftFrom = d; + } else { + draftTo = d; + } + setPresetActive(""); + commitSelection(); + } + + function renderCalendar() { + const grid = document.getElementById("reportsDateRangeGrid"); + const title = document.getElementById("reportsDateRangeMonth"); + if (!grid || !viewMonth) return; + + const y = viewMonth.getFullYear(); + const m = viewMonth.getMonth(); + if (title) title.textContent = `${MONTHS[m]} ${y}`; + + const first = new Date(y, m, 1); + const startOffset = (first.getDay() + 6) % 7; + const daysInMonth = new Date(y, m + 1, 0).getDate(); + + let html = ""; + for (let i = 0; i < startOffset; i++) { + html += ''; + } + for (let day = 1; day <= daysInMonth; day++) { + const d = new Date(y, m, day); + const classes = ["reports-drp__day"]; + if (sameDay(d, today())) classes.push("reports-drp__day--today"); + if (draftFrom && sameDay(d, draftFrom)) classes.push("reports-drp__day--edge"); + if (draftTo && sameDay(d, draftTo)) classes.push("reports-drp__day--edge"); + if (inRange(d, draftFrom, draftTo)) classes.push("reports-drp__day--in-range"); + html += ``; + } + grid.innerHTML = html; + + grid.querySelectorAll("[data-drp-day]").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + const d = parseIso(btn.getAttribute("data-drp-day")); + if (d) onDayClick(d); + }); + }); + } + + function setPeriod(period) { + const { from, to } = computePresetRange(period); + applyRange(from, to, period); + closePanel(); + } + + function init(options) { + triggerEl = document.getElementById("reportsDateRangeTrigger"); + panelEl = document.getElementById("reportsDateRangePanel"); + if (!triggerEl || !panelEl) return; + + if (panelEl.parentElement !== document.body) { + document.body.appendChild(panelEl); + } + + panelEl.addEventListener("mousedown", (e) => e.stopPropagation()); + panelEl.addEventListener("click", (e) => e.stopPropagation()); + + document.querySelectorAll("[data-drp-preset]").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + setPeriod(btn.getAttribute("data-drp-preset") || "day"); + }); + }); + + document.getElementById("reportsDateRangePrev")?.addEventListener("click", (e) => { + e.stopPropagation(); + if (!viewMonth) viewMonth = today(); + viewMonth = new Date(viewMonth.getFullYear(), viewMonth.getMonth() - 1, 1); + renderCalendar(); + positionPanel(); + }); + document.getElementById("reportsDateRangeNext")?.addEventListener("click", (e) => { + e.stopPropagation(); + if (!viewMonth) viewMonth = today(); + viewMonth = new Date(viewMonth.getFullYear(), viewMonth.getMonth() + 1, 1); + renderCalendar(); + positionPanel(); + }); + + triggerEl.addEventListener("click", (e) => { + e.stopPropagation(); + togglePanel(); + }); + + document.addEventListener("click", () => { + if (!panelEl.hidden) closePanel(); + }); + + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") closePanel(); + }); + + const urlParams = new URLSearchParams(window.location.search); + const highlightReportId = urlParams.get("report"); + if highlightReportId) { + global.__wespHighlightReportId = highlightReportId; + setPeriod("month"); + } else { + setPeriod("month"); + } + + if (options?.onReady) options.onReady(); + } + + global.WespReportsDateRange = { + init, + setPeriod, + commit: commitDraft, + updateTriggerLabel, + formatLabel, + parseIso, + toIso, + }; + + global.setPeriod = setPeriod; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/apps/web/public/wesp/js/pages/reports-export.js b/apps/web/public/wesp/js/pages/reports-export.js new file mode 100644 index 0000000..75e0b9f --- /dev/null +++ b/apps/web/public/wesp/js/pages/reports-export.js @@ -0,0 +1,86 @@ +/** + * Экспорт /reports — выбор раздела и формата (Excel / PDF). + */ +(function (global) { + function appendFilterContext(params) { + const farms = global.WespReportsFilters?.getSelectedFarmNames?.() || []; + const dispensers = global.WespReportsFilters?.getSelectedDispenserNames?.() || []; + const recipes = global.WespReportsFilters?.getSelectedRecipeNames?.() || []; + params.set("filter_farms", farms.length ? farms.join(",") : "all"); + params.set("filter_dispensers", dispensers.length ? dispensers.join(",") : "all"); + params.set("filter_recipes", recipes.length ? recipes.join(",") : "all"); + return params; + } + + function buildExportUrl(format, section) { + const params = global.WespReportsFilters?.buildAnalyticsParams?.() || new URLSearchParams(); + appendFilterContext(params); + params.set("format", format || "xlsx"); + params.set("section", section || "all"); + return `/api/analytics/export?${params.toString()}`; + } + + function ensureDates() { + const { date_from, date_to } = global.WespReportsFilters?.getDateParams?.() || {}; + if (!date_from || !date_to) { + global.WespZootechNotify?.error?.("Выберите период для экспорта"); + return false; + } + return true; + } + + function download(format, section) { + if (!ensureDates()) return; + window.location.href = buildExportUrl(format, section); + } + + function closeMenu() { + const menu = document.getElementById("reportsExportMenu"); + const btn = document.getElementById("reportsExportBtn"); + if (menu) menu.hidden = true; + if (btn) btn.setAttribute("aria-expanded", "false"); + } + + function toggleMenu() { + const menu = document.getElementById("reportsExportMenu"); + const btn = document.getElementById("reportsExportBtn"); + if (!menu || !btn) return; + const open = menu.hidden; + menu.hidden = !open; + btn.setAttribute("aria-expanded", open ? "true" : "false"); + } + + function init() { + const btn = document.getElementById("reportsExportBtn"); + const menu = document.getElementById("reportsExportMenu"); + if (!btn || !menu || btn.dataset.bound) return; + btn.dataset.bound = "1"; + + btn.addEventListener("click", (e) => { + e.stopPropagation(); + toggleMenu(); + }); + + menu.querySelectorAll("[data-reports-export-format]").forEach((item) => { + item.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + download( + item.getAttribute("data-reports-export-format") || "xlsx", + item.getAttribute("data-reports-export-section") || "all" + ); + closeMenu(); + }); + }); + + menu.addEventListener("mousedown", (e) => e.stopPropagation()); + menu.addEventListener("click", (e) => e.stopPropagation()); + + document.addEventListener("click", () => closeMenu()); + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") closeMenu(); + }); + } + + global.WespReportsExport = { init, buildExportUrl, download, appendFilterContext }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/apps/web/public/wesp/js/pages/reports-filter-dropdown.js b/apps/web/public/wesp/js/pages/reports-filter-dropdown.js new file mode 100644 index 0000000..e01b392 --- /dev/null +++ b/apps/web/public/wesp/js/pages/reports-filter-dropdown.js @@ -0,0 +1,282 @@ +/** + * Кастомные выпадашки фильтров /reports (без нативного select macOS). + * Скрытый Только непрочит.' + + "" + + '" + + '
    ' + + '' + + '' + + "" + + '' + + '' + + "" + ); + } + + function ensureModal() { + if (modalEl && modalEl.isConnected) return modalEl; + modalEl = document.getElementById(MODAL_ID); + if (!modalEl) { + document.body.insertAdjacentHTML("beforeend", modalTemplate()); + modalEl = document.getElementById(MODAL_ID); + } + hubEl = modalEl.querySelector("[data-k-hub-panel]"); + listEl = modalEl.querySelector("[data-notify-list]"); + detailEl = modalEl.querySelector("[data-notify-detail]"); + toolbarEl = modalEl.querySelector("[data-notify-toolbar]"); + scrollRootEl = modalEl.querySelector(".zt-k-hub-modal__body"); + planMountEl = modalEl.querySelector("[data-k-plan-panel]"); + multiserverMountEl = modalEl.querySelector("[data-k-multiserver-panel]"); + titleEl = modalEl.querySelector("[data-k-hub-title]"); + readAllBtn = modalEl.querySelector("[data-action='notify-read-all']"); + backBtn = modalEl.querySelector("[data-action='k-hub-back']"); + normsBtn = modalEl.querySelector("[data-action='k-hub-open-component-norms']"); + global.WespZootechModals?.wireAllShellModals?.(); + readAllBtn?.addEventListener("click", readAll); + backBtn?.addEventListener("click", () => showHub()); + normsBtn?.addEventListener("click", () => { + global.openDailyPlanComponentNormsModal?.(); + }); + wireNotifyToolbar(); + wireScrollLoad(); + hubEl?.querySelectorAll("[data-action]").forEach((btn) => { + btn.addEventListener("click", () => { + const action = btn.getAttribute("data-action"); + if (action === "hub-open-notifications") openNotifications(); + if (action === "hub-open-daily-plan") openDailyPlan(); + if (action === "hub-open-multiserver") openMultiserver(); + }); + }); + wireModalLifecycle(); + renderHubCards(); + return modalEl; + } + + function syncToolbarUi() { + if (!toolbarEl) return; + toolbarEl.querySelectorAll("[data-notify-category]").forEach((btn) => { + const cat = btn.getAttribute("data-notify-category") || "all"; + btn.classList.toggle("active", cat === categoryFilter); + }); + const sortSelect = toolbarEl.querySelector("[data-notify-sort]"); + if (sortSelect) sortSelect.value = sortMode; + const unreadCb = toolbarEl.querySelector("[data-notify-unread-only]"); + if (unreadCb) unreadCb.checked = unreadOnly; + } + + function wireNotifyToolbar() { + if (!toolbarEl || toolbarEl.dataset.wired === "1") return; + toolbarEl.dataset.wired = "1"; + loadPrefs(); + syncToolbarUi(); + toolbarEl.querySelectorAll("[data-notify-category]").forEach((btn) => { + btn.addEventListener("click", () => { + categoryFilter = btn.getAttribute("data-notify-category") || "all"; + savePrefs(); + syncToolbarUi(); + reloadNotifications(); + }); + }); + toolbarEl.querySelector("[data-notify-sort]")?.addEventListener("change", (ev) => { + sortMode = ev.target.value || "newest"; + savePrefs(); + reloadNotifications(); + }); + toolbarEl.querySelector("[data-notify-unread-only]")?.addEventListener("change", (ev) => { + unreadOnly = Boolean(ev.target.checked); + savePrefs(); + reloadNotifications(); + }); + } + + function wireScrollLoad() { + if (scrollWired || !scrollRootEl) return; + scrollWired = true; + scrollRootEl.addEventListener("scroll", () => { + if (currentView !== "notifications" || !prevDate || loadingMore || listLoading) return; + const nearBottom = + scrollRootEl.scrollTop + scrollRootEl.clientHeight >= + scrollRootEl.scrollHeight - SCROLL_LOAD_THRESHOLD; + if (nearBottom) loadMoreNotifications(); + }); + } + + function itemPreview(item) { + const detail = String(item?.detail || "").trim(); + if (detail) { + const cut = detail.replace(/\.\s+\d{1,2}\s+\S+\s+\d{4}.*$/u, "").trim(); + const text = cut || detail; + return text.length > 140 ? `${text.slice(0, 137)}…` : text; + } + return item?.title || ""; + } + + function renderNotifyItem(item) { + const unread = !item.read ? " zt-notify-item--unread" : ""; + const cat = categoryLabel(item); + const preview = itemPreview(item); + const showPreview = preview && preview !== item.title; + return ( + `" + ); + } + + function renderLoadMoreState() { + const el = modalEl?.querySelector("[data-notify-load-more]"); + if (!el) return; + if (loadingMore) { + el.hidden = false; + el.innerHTML = + '
    ' + + 'Загрузка…
    '; + return; + } + if (prevDate) { + el.hidden = false; + el.innerHTML = + `

    Прокрутите вниз — загрузятся уведомления за ${escapeHtml(formatDayLabel(prevDate))}

    `; + return; + } + el.hidden = true; + el.innerHTML = ""; + } + + function renderHubCards() { + if (!hubEl) return; + hubEl.innerHTML = + '
    ' + + '" + + '" + + '
    "; + hubEl.querySelectorAll("[data-action]").forEach((btn) => { + btn.addEventListener("click", () => { + const action = btn.getAttribute("data-action"); + if (action === "hub-open-notifications") openNotifications(); + if (action === "hub-open-daily-plan") openDailyPlan(); + if (action === "hub-open-multiserver") openMultiserver(); + }); + }); + } + + function applyModalLayout(view) { + if (!modalEl) return; + const dialog = modalEl.querySelector(".modal-dialog"); + const body = modalEl.querySelector(".zt-k-hub-modal__body"); + modalEl.classList.remove("zt-k-hub--plan", "zt-k-hub--notify"); + body?.classList.remove("zt-k-hub-modal__body--plan"); + dialog?.classList.remove("modal-dialog--shell-plan", "modal-dialog--shell-notify"); + if (view === "daily-plan") { + modalEl.classList.add("zt-k-hub--plan"); + body?.classList.add("zt-k-hub-modal__body--plan"); + dialog?.classList.add("modal-dialog--shell-plan"); + } else if (view === "notifications") { + modalEl.classList.add("zt-k-hub--notify"); + dialog?.classList.add("modal-dialog--shell-notify"); + } + } + + function updateHeader(view) { + currentView = view; + applyModalLayout(view); + if (!titleEl || !backBtn || !readAllBtn) return; + if (normsBtn) normsBtn.hidden = view !== "daily-plan"; + if (view === "hub") { + titleEl.textContent = "Комптон - хаб"; + backBtn.hidden = true; + readAllBtn.hidden = true; + } else if (view === "notifications") { + titleEl.textContent = "Уведомления"; + backBtn.hidden = false; + readAllBtn.hidden = false; + } else if (view === "daily-plan") { + titleEl.textContent = "План на день"; + backBtn.hidden = false; + readAllBtn.hidden = true; + } else if (view === "multiserver") { + titleEl.textContent = "Мультисервер"; + backBtn.hidden = false; + readAllBtn.hidden = true; + } + } + + function dismissPlanOverlays() { + modalEl?.querySelectorAll(".zt-k-hub-plan-overlay").forEach((el) => el.remove()); + } + + function destroyDailyPlanPanel() { + dismissPlanOverlays(); + global.closeDailyPlanComponentNormsModal?.(); + dailyPlanPanel?.destroy?.(); + dailyPlanPanel = null; + global.WespDailyPlanPanel?._setActivePanel?.(null); + } + + function wireModalLifecycle() { + ensureModal(); + if (modalEl.dataset.kHubLifecycleWired === "1") return; + modalEl.dataset.kHubLifecycleWired = "1"; + modalEl.addEventListener("hide.bs.modal", dismissPlanOverlays); + modalEl.addEventListener("hidden.bs.modal", destroyDailyPlanPanel); + } + + function showHub() { + ensureModal(); + updateHeader("hub"); + hubEl.hidden = false; + modalEl.querySelector("[data-k-notify-panel]").hidden = true; + planMountEl.hidden = true; + if (multiserverMountEl) multiserverMountEl.hidden = true; + destroyDailyPlanPanel(); + listEl.hidden = false; + detailEl.hidden = true; + } + + function setBadge(unreadCount) { + ensureFab(); + if (!badgeEl) return; + const count = Number(unreadCount) || 0; + if (count <= 0) { + badgeEl.hidden = true; + badgeEl.textContent = ""; + fabEl.classList.remove("has-unread"); + return; + } + badgeEl.hidden = false; + badgeEl.textContent = count > 9 ? "9+" : String(count); + fabEl.classList.add("has-unread"); + } + + async function refreshBadge() { + try { + const resp = await fetch("/api/notifications?summary=1"); + if (!resp.ok) return; + const data = await resp.json(); + setBadge(data.unreadCount); + } catch { + /* ignore */ + } + } + + function mergeItems(existing, incoming) { + const map = new Map(existing.map((x) => [x.id, x])); + incoming.forEach((item) => map.set(item.id, item)); + return Array.from(map.values()); + } + + function matchesCategoryFilter(item) { + if (!categoryFilter || categoryFilter === "all") return true; + return inferCategory(item) === categoryFilter; + } + + function filterVisibleItems(items) { + let list = items.filter(matchesCategoryFilter); + if (unreadOnly) list = list.filter((x) => !x.read); + return list; + } + + async function fetchDay(date) { + const resp = await fetch(buildListUrl(date)); + if (!resp.ok) throw new Error("Не удалось загрузить уведомления"); + return resp.json(); + } + + async function loadInitialNotifications() { + let date = null; + let found = []; + for (let step = 0; step < 31; step += 1) { + const data = await fetchDay(date); + setBadge(data.unreadCount); + if (data.items?.length) { + found = mergeItems(found, data.items); + prevDate = data.hasMore ? data.prevDate : null; + return found; + } + if (!data.hasMore || !data.prevDate) { + prevDate = null; + return found; + } + date = data.prevDate; + } + prevDate = null; + return found; + } + + async function reloadNotifications() { + listLoading = true; + itemsCache = []; + prevDate = null; + detailEl.hidden = true; + listEl.hidden = false; + listEl.innerHTML = + '
    ' + + '

    Загрузка…

    '; + renderLoadMoreState(); + try { + itemsCache = await loadInitialNotifications(); + renderList(); + } catch { + itemsCache = []; + prevDate = null; + renderList(); + } finally { + listLoading = false; + renderLoadMoreState(); + } + } + + async function loadMoreNotifications() { + if (!prevDate || loadingMore || listLoading) return; + loadingMore = true; + renderLoadMoreState(); + const loadDate = prevDate; + try { + const data = await fetchDay(loadDate); + itemsCache = mergeItems(itemsCache, data.items || []); + prevDate = data.hasMore ? data.prevDate : null; + setBadge(data.unreadCount); + renderList(); + } catch { + /* keep current list */ + } finally { + loadingMore = false; + renderLoadMoreState(); + } + } + + function wireListClicks() { + listEl.querySelectorAll("[data-notify-id]").forEach((btn) => { + btn.addEventListener("click", () => { + const id = btn.getAttribute("data-notify-id"); + if (id) showDetail(id); + }); + }); + } + + function renderList() { + ensureModal(); + wireNotifyToolbar(); + syncToolbarUi(); + detailEl.hidden = true; + listEl.hidden = false; + const sorted = sortItems(filterVisibleItems(itemsCache)); + if (!sorted.length) { + const hint = + unreadOnly || categoryFilter !== "all" + ? prevDate + ? "За сегодня ничего нет — прокрутите вниз для более ранних" + : "Нет уведомлений по выбранному фильтру" + : "Нет уведомлений"; + listEl.innerHTML = + `

    ${escapeHtml(hint)}

    `; + renderLoadMoreState(); + return; + } + + let html = ""; + let lastGroup = null; + sorted.forEach((item) => { + const group = dayKeyFromItem(item); + if (group !== lastGroup) { + html += `
    ${escapeHtml(formatDayLabel(group))}
    `; + lastGroup = group; + } + html += renderNotifyItem(item); + }); + listEl.innerHTML = html; + wireListClicks(); + renderLoadMoreState(); + } + + async function showDetail(id) { + const item = itemsCache.find((x) => x.id === id); + if (!item) return; + ensureModal(); + listEl.hidden = true; + modalEl.querySelector("[data-notify-load-more]")?.setAttribute("hidden", ""); + detailEl.hidden = false; + detailEl.innerHTML = + `` + + `

    ${escapeHtml(item.title)}

    ` + + `

    ${escapeHtml(categoryLabel(item))}

    ` + + `

    ${escapeHtml(item.detail)}

    ` + + `

    ${escapeHtml(relativeTime(item.createdAt))}

    ` + + (resolveNotificationHref(item) || isDailyPlanItem(item) + ? `` + : ""); + detailEl.querySelector("[data-action='notify-open-target']")?.addEventListener("click", () => { + openNotificationTarget(item); + }); + detailEl.querySelector("[data-action='notify-back']")?.addEventListener("click", () => { + renderList(); + }); + if (!item.read) { + try { + await fetch(`/api/notifications/${encodeURIComponent(id)}/read`, { method: "PATCH" }); + item.read = true; + await refreshBadge(); + } catch { + /* ignore */ + } + } + } + + async function readAll() { + try { + await fetch("/api/notifications/read-all", { method: "PATCH" }); + await reloadNotifications(); + } catch { + /* ignore */ + } + } + + function showModal() { + if (global.WespZootechModals?.show) { + global.WespZootechModals.show(MODAL_ID); + } else if (global.bootstrap?.Modal) { + global.bootstrap.Modal.getOrCreateInstance(modalEl).show(); + } + } + + async function openCenter() { + ensureModal(); + showHub(); + showModal(); + } + + async function openNotifications() { + ensureModal(); + updateHeader("notifications"); + hubEl.hidden = true; + modalEl.querySelector("[data-k-notify-panel]").hidden = false; + planMountEl.hidden = true; + destroyDailyPlanPanel(); + await reloadNotifications(); + showModal(); + } + + function openDailyPlan() { + ensureModal(); + updateHeader("daily-plan"); + hubEl.hidden = true; + modalEl.querySelector("[data-k-notify-panel]").hidden = true; + planMountEl.hidden = false; + destroyDailyPlanPanel(); + dailyPlanPanel = global.WespDailyPlanPanel?.createDailyPlanPanel?.(); + if (dailyPlanPanel && planMountEl) { + dailyPlanPanel.mount(planMountEl); + global.WespDailyPlanPanel?._setActivePanel?.(dailyPlanPanel); + } else if (planMountEl) { + planMountEl.innerHTML = + '

    Модуль плана на день не загружен.

    '; + } + showModal(); + } + + function openMultiserver() { + ensureModal(); + updateHeader("multiserver"); + hubEl.hidden = true; + modalEl.querySelector("[data-k-notify-panel]").hidden = true; + planMountEl.hidden = true; + multiserverMountEl.hidden = false; + destroyDailyPlanPanel(); + const enterpriseId = global.__WESP_ORCH__?.enterpriseId || ""; + if (global.WespMultiserverPanel?.mount) { + global.WespMultiserverPanel.mount(multiserverMountEl, enterpriseId); + } else if (multiserverMountEl) { + multiserverMountEl.innerHTML = + '

    Модуль мультисервера не загружен.

    '; + } + showModal(); + } + + function init() { + if (!document.body.classList.contains("zootech-app-body")) return; + loadPrefs(); + ensureFab(); + ensureModal(); + refreshBadge(); + if (pollTimer) clearInterval(pollTimer); + pollTimer = setInterval(refreshBadge, POLL_MS); + } + + global.WespZootechNotificationCenter = { + init, + openCenter, + openNotifications, + openDailyPlan, + openMultiserver, + refreshBadge, + setBadge, + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/apps/web/public/wesp/js/wesp-zootech-notification-messages.js b/apps/web/public/wesp/js/wesp-zootech-notification-messages.js new file mode 100644 index 0000000..d483e89 --- /dev/null +++ b/apps/web/public/wesp/js/wesp-zootech-notification-messages.js @@ -0,0 +1,148 @@ +/** + * Человекочитаемые title/detail для центра уведомлений. + */ +(function (global) { + const MONTHS = [ + "января", + "февраля", + "марта", + "апреля", + "мая", + "июня", + "июля", + "августа", + "сентября", + "октября", + "ноября", + "декабря", + ]; + + function formatWhen(date) { + const d = date instanceof Date ? date : new Date(); + const day = d.getDate(); + const month = MONTHS[d.getMonth()]; + const year = d.getFullYear(); + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + return `${day} ${month} ${year}, ${hh}:${mm}`; + } + + function normalizeTitle(text) { + const t = String(text || "").trim(); + if (/^Сохранено\b/i.test(t)) return "Сохранено"; + return t || "Уведомление"; + } + + function detectCategory(page, text, record) { + if (record.category) return record.category; + if (page === "recipes") return "recipe"; + if (page === "components") return "component"; + if (page === "feed_consumption") return "sklad"; + if (page === "feed_dispensers") return "equipment"; + if (page === "reports") return "report"; + if (record.category === "feed_quality") return "feed_quality"; + if (record.category === "daily_plan") return "daily_plan"; + if (page === "daily_plan") return "daily_plan"; + const lower = text.toLowerCase(); + if ( + lower.includes("план на день") || + lower.includes("из плана") || + lower.includes("в плане") || + lower.includes("замен") + ) { + return "daily_plan"; + } + if (lower.includes("рейс") || lower.includes("рецепт")) return "recipe"; + if (lower.includes("компонент")) return "component"; + if (lower.includes("склад") || lower.includes("остат")) return "sklad"; + if (lower.includes("синхрон")) return "sync"; + if (lower.includes("перегруз") || lower.includes("недогруз") || lower.includes("миксер") || lower.includes("смешиван")) return "feed_quality"; + return "general"; + } + + function resolvePage(explicit) { + if (explicit) return explicit; + const path = (global.location && global.location.pathname) || ""; + if (path.startsWith("/recipes")) return "recipes"; + if (path.startsWith("/components")) return "components"; + if (path.startsWith("/feed_dispensers")) return "feed_dispensers"; + if (path.startsWith("/reports")) return "reports"; + if (path.startsWith("/feed_consumption")) return "feed_consumption"; + return "general"; + } + + function q(name) { + return name ? `«${name}»` : ""; + } + + function buildNotificationRecord(kind, plainText, opts) { + const options = opts && typeof opts === "object" ? opts : {}; + const record = options.record && typeof options.record === "object" ? options.record : {}; + const when = formatWhen(); + const page = resolvePage(record.page || options.page); + const text = String(plainText || "").trim(); + let title = record.title || normalizeTitle(text); + let detail = record.detail || ""; + + if (!detail) { + const recipe = record.recipeName || record.recipe; + const component = record.componentName || record.component; + const period = record.periodName || record.period; + + if (/^Сохранено$/i.test(title) && recipe) { + detail = `Рейс ${q(recipe)} сохранён — ${when}`; + } else if (/рейс удал/i.test(text) && recipe && period) { + detail = `Рейс ${q(recipe)} удалён из периода ${q(period)} — ${when}`; + } else if (/рейс удал/i.test(text) && recipe) { + detail = `Рейс ${q(recipe)} удалён — ${when}`; + } else if (/рейс скопирован/i.test(text) && recipe) { + detail = `Рейс ${q(recipe)} скопирован — ${when}`; + } else if (/вставлен из буфера/i.test(text)) { + detail = `Рейс вставлен из буфера — ${when}`; + } else if (/перенес/i.test(text) && recipe && period) { + detail = `Рейс ${q(recipe)} перенесён в период ${q(period)} — ${when}`; + } else if (/компонент добавлен/i.test(text) && component) { + detail = `Компонент ${q(component)} добавлен — ${when}`; + } else if (/компонент обнов/i.test(text) && component) { + detail = `Компонент ${q(component)} обновлён — ${when}`; + } else if (/компонент удал/i.test(text) && component) { + detail = `Компонент ${q(component)} удалён — ${when}`; + } else if (/убрано со склада/i.test(text) && component) { + detail = `Компонент ${q(component)} убран со склада — ${when}`; + } else if (/на склад/i.test(text) && component) { + detail = `Компонент ${q(component)} добавлен на склад — ${when}`; + } else if (/настройки сохранены/i.test(text)) { + detail = `Настройки сохранены — ${when}`; + } else if (record.category === "feed_quality" || page === "reports" && /перегруз|недогруз|миксер|смешиван/i.test(text)) { + detail = record.detail || (text ? `${text} — ${when}` : `Отклонение по рейсу — ${when}`); + } else if (text) { + detail = `${text} — ${when}`; + } else { + detail = `Событие — ${when}`; + } + } + + return { + title, + detail, + kind: kind || "info", + category: detectCategory(page, text, record), + page, + linkKind: + record.linkKind || + (page === "components" + ? "component" + : page === "feed_consumption" + ? "sklad" + : page === "feed_dispensers" + ? "equipment" + : null), + }; + } + + global.WespZootechNotificationMessages = { + formatWhen, + buildNotificationRecord, + resolvePage, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/apps/web/public/wesp/js/wesp-zootech-notify.js b/apps/web/public/wesp/js/wesp-zootech-notify.js new file mode 100644 index 0000000..338dfdd --- /dev/null +++ b/apps/web/public/wesp/js/wesp-zootech-notify.js @@ -0,0 +1,285 @@ +/** + * Toast-уведомления зоотех-страниц (нижний pill-toast, стиль Яндекс Музыки). + */ +(function (global) { + const HOST_ID = "wespZtToastHost"; + const DEFAULT_DURATION_MS = 4000; + const EXIT_MS = 220; + + const UM = () => global.WespUserMessages || {}; + const userFacingMessage = (message, fallback) => { + const api = UM(); + if (typeof api.userFacingMessage === "function") { + return api.userFacingMessage(message, fallback); + } + const text = message == null ? "" : String(message).trim(); + return text || fallback || "Не удалось выполнить операцию. Попробуйте ещё раз."; + }; + + let hostEl = null; + let activeToast = null; + let dismissTimer = null; + + function ensureHost() { + if (hostEl && hostEl.isConnected) return hostEl; + hostEl = document.getElementById(HOST_ID); + if (!hostEl) { + hostEl = document.createElement("div"); + hostEl.id = HOST_ID; + hostEl.className = "wesp-zt-toast-host"; + hostEl.setAttribute("aria-live", "polite"); + hostEl.setAttribute("aria-atomic", "true"); + document.body.appendChild(hostEl); + } + return hostEl; + } + + function clearDismissTimer() { + if (dismissTimer) { + clearTimeout(dismissTimer); + dismissTimer = null; + } + } + + function normalizeType(type) { + if (type === "error" || type === "warning") return type; + return "success"; + } + + function escapeHtml(value) { + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function normalizeDisplayMessage(message) { + if (message && typeof message === "object") { + if (Array.isArray(message.parts)) return message; + if (message.text != null) message = message.text; + } + const text = message == null ? "" : String(message).trim(); + if (/^Сохранено\b/i.test(text)) { + return "Сохранено"; + } + return text; + } + + function renderMessageContent(message, opts) { + if (message && typeof message === "object") { + if (Array.isArray(message.parts)) { + return message.parts + .map((part) => { + const text = escapeHtml(part && part.text != null ? part.text : ""); + return part && part.bold ? `${text}` : text; + }) + .join(""); + } + if (message.text != null) { + return escapeHtml(message.text); + } + } + + const text = message == null ? "" : String(message); + if (opts && Array.isArray(opts.parts)) { + return opts.parts + .map((part) => { + const chunk = escapeHtml(part && part.text != null ? part.text : ""); + return part && part.bold ? `${chunk}` : chunk; + }) + .join(""); + } + return escapeHtml(text); + } + + function removeToast(toastEl, immediate) { + if (!toastEl || !toastEl.isConnected) return Promise.resolve(); + clearDismissTimer(); + if (immediate) { + toastEl.remove(); + if (activeToast === toastEl) activeToast = null; + return Promise.resolve(); + } + + return new Promise((resolve) => { + toastEl.classList.add("is-exiting"); + const onEnd = () => { + toastEl.removeEventListener("animationend", onEnd); + toastEl.remove(); + if (activeToast === toastEl) activeToast = null; + resolve(); + }; + toastEl.addEventListener("animationend", onEnd); + setTimeout(onEnd, EXIT_MS + 40); + }); + } + + function dismissAll() { + clearDismissTimer(); + if (!activeToast) return; + const toast = activeToast; + activeToast = null; + return removeToast(toast, false); + } + + function plainTextFromMessage(message) { + const api = UM(); + if (typeof api.plainText === "function") return api.plainText(message); + if (message && typeof message === "object") { + if (message instanceof Error) return ""; + if (Array.isArray(message.parts)) { + return message.parts.map((p) => (p && p.text != null ? String(p.text) : "")).join(""); + } + if (message.text != null) return String(message.text); + } + return message == null ? "" : String(message); + } + + function persistNotification(type, message, opts) { + if (opts && opts.skipRecord) return; + const plain = plainTextFromMessage( + message && typeof message === "object" && Array.isArray(message.parts) + ? message + : normalizeDisplayMessage(message) + ); + if (!plain.trim() && !(opts && opts.record)) return; + + const builder = global.WespZootechNotificationMessages; + const payload = builder + ? builder.buildNotificationRecord(type, plain, opts) + : { + title: plain.slice(0, 200) || "Уведомление", + detail: plain, + kind: type, + category: "general", + page: "general", + }; + + fetch("/api/notifications", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + .then(() => { + global.WespZootechNotificationCenter?.refreshBadge?.(); + }) + .catch(() => { + /* fire-and-forget */ + }); + } + + function show(type, message, opts) { + const normalizedType = normalizeType(type); + const duration = + opts && Number.isFinite(opts.duration) ? opts.duration : DEFAULT_DURATION_MS; + const displayMessage = + message && typeof message === "object" && Array.isArray(message.parts) + ? message + : normalizeDisplayMessage(message); + const html = renderMessageContent(displayMessage, opts); + const plainText = + displayMessage && typeof displayMessage === "object" + ? "" + : String(displayMessage || ""); + const isMultiline = plainText.length > 42 || /\n/.test(plainText); + + const run = async () => { + if (activeToast) { + await removeToast(activeToast, false); + } + + const root = ensureHost(); + const toast = document.createElement("div"); + toast.className = `wesp-zt-toast wesp-zt-toast--${normalizedType}${ + isMultiline ? " wesp-zt-toast--multiline" : "" + }`; + toast.setAttribute("role", "status"); + + const accent = document.createElement("span"); + accent.className = "wesp-zt-toast__accent"; + accent.setAttribute("aria-hidden", "true"); + + const messageEl = document.createElement("p"); + messageEl.className = "wesp-zt-toast__message"; + messageEl.innerHTML = html; + + const closeBtn = document.createElement("button"); + closeBtn.type = "button"; + closeBtn.className = "wesp-zt-toast__close"; + closeBtn.setAttribute("aria-label", "Закрыть уведомление"); + closeBtn.textContent = "×"; + closeBtn.addEventListener("click", () => { + dismissAll(); + }); + + toast.append(accent, messageEl, closeBtn); + root.appendChild(toast); + activeToast = toast; + + if (duration > 0) { + dismissTimer = setTimeout(() => { + dismissAll(); + }, duration); + } + }; + + persistNotification(normalizedType, displayMessage, opts); + run(); + } + + function success(message, opts) { + show("success", message, opts); + } + + function error(message, opts) { + const safe = userFacingMessage(message, opts && opts.fallback); + show("error", safe, opts); + } + + function warning(message, opts) { + const safe = userFacingMessage(message, opts && opts.fallback); + show("warning", safe, opts); + } + + function open(payload) { + const data = payload && typeof payload === "object" ? payload : {}; + show(data.type || "success", data.message, data); + } + + function createAdapter() { + return { + success, + error, + warning, + open, + dismissAll, + }; + } + + global.WespZootechNotify = { + success, + error, + warning, + open, + dismissAll, + createAdapter, + userFacingMessage, + messageFromResponseBody: (body, fallback) => { + const api = UM(); + if (typeof api.messageFromResponseBody === "function") { + return api.messageFromResponseBody(body, fallback); + } + return userFacingMessage(null, fallback); + }, + isTechnicalMessage: (message) => { + const api = UM(); + if (typeof api.isTechnicalMessage === "function") return api.isTechnicalMessage(message); + return false; + }, + DEFAULT_ERROR_MESSAGE: UM().DEFAULT_ERROR_MESSAGE || "Не удалось выполнить операцию. Попробуйте ещё раз.", + get instance() { + return createAdapter(); + }, + }; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/apps/web/public/wesp/lab-profiles.html b/apps/web/public/wesp/lab-profiles.html new file mode 100644 index 0000000..fd0d6d6 --- /dev/null +++ b/apps/web/public/wesp/lab-profiles.html @@ -0,0 +1,183 @@ + + + + + + стандарты чистоты + + + + + + + + + + + + + + + + + + + + +
    + +
    +
    +
    +

    PRIME Heisenberg

    +

    Стандарты чистоты

    +

    Я не бизнесмен — я специалист. лаборатория

    +
    + +
    + + +
    +
    Выбери стандарт
    + +
    +
    +
    +
    + + + + diff --git a/apps/web/public/wesp/lab.html b/apps/web/public/wesp/lab.html new file mode 100644 index 0000000..ea0d6bd --- /dev/null +++ b/apps/web/public/wesp/lab.html @@ -0,0 +1,245 @@ + + + + + + лаборатория + + + + + + + + + + + + + + + + + + + +
    + +
    +
    +
    +

    Скажи моё имя. — Хайзенберг. — Ты чертовски прав.

    +

    Я здесь, чтобы готовить · Стандарты чистоты · LAB тест — в нормах / полная матрица

    +
    + +
    + + +
    +
    2 Готовка
    +
    +
    Состав партии
    +
    + + + +
    +
    +
    + + + + + + + + + + + + + + +
    Реагенткг/сутВ делоСмесьСебест.
    Сначала выбери партию
    +
    +
    + + +
    + +
    +
    +

    Поставка чистоты

    +

    AgroStar xml/pdf/xlsx · кидай что прислали. Разберём, покажем, привяжем к реагентам.

    +
    +
    +
    4 Поставка
    +
    + + +

    Кинь файл сюда

    +

    Приготовим науку, понюхаем, привяжем к реагентам, приготовим партию... и посмотрим, кто из нас химик.

    + +
    +
    + + + +
    + + + +
    +
    +
    +
    + +
    +
    +

    Автоготовка

    +

    Грубые → сочные → концентраты → добавки → расчёт. Я не в опасности. Я и есть опасность.

    +
    + +
    +
    +
    +
    Химия — наука о переменах
    +
    + + +
    +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    На что смотрим
    +
    +

    Я принял решение на основе науки

    +
    + Слишком много вариантов — отфильтруем топ-18. Время — деньги. +
    +
    + +
    +
    +
    + +
    +
    + +
    +
    +
    Рецепт на столе
    +
      +

      Позитив — это хорошо. Я за позитив, но позитив не меняет реальность.

      +
      +
      +
      + +
      +
      +
      99,1% чистоты:
      +
      +
      Скажи моё имя и жми кнопку
      +
      +
      +
      +
      +
      +
      +
      + + + + + + + diff --git a/apps/web/public/wesp/recipes.html b/apps/web/public/wesp/recipes.html new file mode 100644 index 0000000..59a66af --- /dev/null +++ b/apps/web/public/wesp/recipes.html @@ -0,0 +1,5449 @@ + + + + + + + + + + + Рецепты + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      + + +
      +
      + +
      +
      + +
      +
      +
      + + Оборудование +
      +
      +
      +
      +
      + +

      Загрузка кормораздатчиков…

      +
      +
      +
      +
      +
      +
      + + +
      +
      +
      + + Периоды кормления +
      +
      +
      +
      +
      +
      + + +
      +
      +
      +
      + + + + Рейсы +
      + +
      +
      +
      +
      +
      +
      +
      +
      + + + + + + + + + + + + +
      +
      + + + + + + + + + + + + + + + + + + + + + + + +
      +
      +
      +

      Клиенты синхронизации

      + +
      +
      +
      +
      + + +
      + +
      +
      + +
      + Комптон® является зарегистрированным товарным знаком +
      + + + + + \ No newline at end of file diff --git a/apps/web/public/wesp/reports.html b/apps/web/public/wesp/reports.html new file mode 100644 index 0000000..ba2f461 --- /dev/null +++ b/apps/web/public/wesp/reports.html @@ -0,0 +1,1482 @@ + + + + + + Отчеты о загрузках + + + + + + + + + + + + + + + + + + + + + + + + +
      + +
      +
      +
      +
      +

      + + Отчёты о загрузках +

      +
      + +
      + + +
      +
      +
      + +
      +
      +
      + + + +
      + + +
      +
      + +
      +
      +
      + + + + +
      + + + +
      + +
      + +
      + +
      + + +
      +
      +
      + +
      +
      +
      + +

      Загрузка итогов…

      +
      +
      +
      + + + +
      +
      + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
      + Комптон® является зарегистрированным товарным знаком +
      + + + + + \ No newline at end of file diff --git a/apps/web/src/__tests__/setup.ts b/apps/web/src/__tests__/setup.ts new file mode 100644 index 0000000..237d471 --- /dev/null +++ b/apps/web/src/__tests__/setup.ts @@ -0,0 +1,18 @@ +import "@testing-library/jest-dom/vitest"; + +Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false + }) +}); + +const originalGetComputedStyle = window.getComputedStyle.bind(window); +window.getComputedStyle = ((element: Element) => originalGetComputedStyle(element)) as typeof window.getComputedStyle; diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx new file mode 100644 index 0000000..fc573f7 --- /dev/null +++ b/apps/web/src/app/App.tsx @@ -0,0 +1,45 @@ +import { useLayoutEffect } from "react"; +import { AppProviders } from "./providers/AppProviders"; +import { AppRouter } from "./router/routes"; +import { AppHeader } from "@shared/ui"; +import { useLocation } from "react-router-dom"; + +const REACT_ONLY_PREFIXES = [ + "/login", + "/register", + "/profile", + "/platform", + "/verify", + "/reset-password", + "/forgot-password", + "/pages", + "/enterprise" +]; + +function isReactShellRoute(pathname: string): boolean { + return REACT_ONLY_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)); +} + +function AppShell(): JSX.Element { + const location = useLocation(); + const hideHeader = isReactShellRoute(location.pathname); + + useLayoutEffect(() => { + document.documentElement.classList.toggle("admin-route", location.pathname.startsWith("/platform")); + }, [location.pathname]); + + return ( + <> + {hideHeader ? null : } + + + ); +} + +export function App(): JSX.Element { + return ( + + + + ); +} diff --git a/apps/web/src/app/providers/AppProviders.tsx b/apps/web/src/app/providers/AppProviders.tsx new file mode 100644 index 0000000..8d00bf4 --- /dev/null +++ b/apps/web/src/app/providers/AppProviders.tsx @@ -0,0 +1,20 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { BrowserRouter } from "react-router-dom"; +import type { PropsWithChildren } from "react"; +import { setAccessTokenGetter } from "@shared/api/client"; +import { useAuthStore } from "@modules/auth/store/authStore"; +import { AuthBootstrap } from "./AuthBootstrap"; + +const queryClient = new QueryClient(); + +setAccessTokenGetter(() => useAuthStore.getState().accessToken); + +export function AppProviders({ children }: PropsWithChildren): JSX.Element { + return ( + + + {children} + + + ); +} diff --git a/apps/web/src/app/providers/AuthBootstrap.tsx b/apps/web/src/app/providers/AuthBootstrap.tsx new file mode 100644 index 0000000..29a7613 --- /dev/null +++ b/apps/web/src/app/providers/AuthBootstrap.tsx @@ -0,0 +1,32 @@ +import { useLayoutEffect } from "react"; +import type { PropsWithChildren } from "react"; +import { shouldAttemptAuthRefresh } from "@modules/auth/store/authSessionHint"; +import { useAuthStore } from "@modules/auth/store/authStore"; +import { bootstrapSessionRefresh } from "@shared/api/client"; + +export function AuthBootstrap({ children }: PropsWithChildren): JSX.Element { + const setBootstrapped = useAuthStore((state) => state.setBootstrapped); + + useLayoutEffect(() => { + let cancelled = false; + + if (!shouldAttemptAuthRefresh()) { + setBootstrapped(true); + return () => { + cancelled = true; + }; + } + + void bootstrapSessionRefresh().finally(() => { + if (!cancelled) { + setBootstrapped(true); + } + }); + + return () => { + cancelled = true; + }; + }, [setBootstrapped]); + + return <>{children}; +} diff --git a/apps/web/src/app/router/guards/AdminGuard.test.tsx b/apps/web/src/app/router/guards/AdminGuard.test.tsx new file mode 100644 index 0000000..4a0edc2 --- /dev/null +++ b/apps/web/src/app/router/guards/AdminGuard.test.tsx @@ -0,0 +1,51 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { AdminGuard } from "@app/router/guards/AdminGuard"; +import { useAuthStore } from "@modules/auth/store/authStore"; + +function TestChild(): JSX.Element { + return

      Admin area

      ; +} + +describe("AdminGuard", () => { + beforeEach(() => { + useAuthStore.getState().clearSession(); + useAuthStore.getState().setBootstrapped(true); + }); + + afterEach(() => { + cleanup(); + }); + + it("redirects guests to login", () => { + render( + + + + + + ); + expect(screen.queryByRole("heading", { name: "Admin area" })).not.toBeInTheDocument(); + }); + + it("renders admin content for admin users", () => { + useAuthStore.getState().setSession("token", { + id: "1", + email: "admin@compton.example", + role: "admin", + is_superuser: false, + status: "active" + }); + + render( + + + + + + ); + + expect(screen.getAllByRole("heading", { name: "Admin area" })).toHaveLength(1); + }); +}); diff --git a/apps/web/src/app/router/guards/AdminGuard.tsx b/apps/web/src/app/router/guards/AdminGuard.tsx new file mode 100644 index 0000000..0a8c1ff --- /dev/null +++ b/apps/web/src/app/router/guards/AdminGuard.tsx @@ -0,0 +1,23 @@ +import { Navigate } from "react-router-dom"; +import type { PropsWithChildren } from "react"; +import { useAuth } from "@modules/auth"; +import { useAuthStore } from "@modules/auth/store/authStore"; + +export function AdminGuard({ children }: PropsWithChildren): JSX.Element | null { + const bootstrapped = useAuthStore((state) => state.bootstrapped); + const auth = useAuth(); + + if (!bootstrapped) { + return null; + } + + if (!auth.isAuthenticated) { + return ; + } + + if (auth.user?.role !== "admin") { + return ; + } + + return <>{children}; +} diff --git a/apps/web/src/app/router/guards/AuthGuard.tsx b/apps/web/src/app/router/guards/AuthGuard.tsx new file mode 100644 index 0000000..925f06d --- /dev/null +++ b/apps/web/src/app/router/guards/AuthGuard.tsx @@ -0,0 +1,19 @@ +import { Navigate } from "react-router-dom"; +import type { PropsWithChildren } from "react"; +import { useAuth } from "@modules/auth"; +import { useAuthStore } from "@modules/auth/store/authStore"; + +export function AuthGuard({ children }: PropsWithChildren): JSX.Element | null { + const bootstrapped = useAuthStore((state) => state.bootstrapped); + const auth = useAuth(); + + if (!bootstrapped) { + return null; + } + + if (!auth.isAuthenticated) { + return ; + } + + return <>{children}; +} diff --git a/apps/web/src/app/router/guards/GuestGuard.tsx b/apps/web/src/app/router/guards/GuestGuard.tsx new file mode 100644 index 0000000..a8274e4 --- /dev/null +++ b/apps/web/src/app/router/guards/GuestGuard.tsx @@ -0,0 +1,19 @@ +import { Navigate } from "react-router-dom"; +import type { PropsWithChildren } from "react"; +import { useAuth } from "@modules/auth"; +import { useAuthStore } from "@modules/auth/store/authStore"; + +export function GuestGuard({ children }: PropsWithChildren): JSX.Element | null { + const bootstrapped = useAuthStore((state) => state.bootstrapped); + const auth = useAuth(); + + if (!bootstrapped) { + return null; + } + + if (auth.isAuthenticated) { + return ; + } + + return <>{children}; +} diff --git a/apps/web/src/app/router/routes.tsx b/apps/web/src/app/router/routes.tsx new file mode 100644 index 0000000..7ce9ed8 --- /dev/null +++ b/apps/web/src/app/router/routes.tsx @@ -0,0 +1,76 @@ +import { lazy, Suspense } from "react"; +import { Navigate, Route, Routes } from "react-router-dom"; +import { RegisterPage } from "@pages/RegisterPage"; +import { ContentPage } from "@pages/ContentPage"; +import { VerifyPage } from "@pages/VerifyPage"; +import { ResetPasswordPage } from "@pages/ResetPasswordPage"; +import { ForgotPasswordPage } from "@pages/ForgotPasswordPage"; +import AdminPage from "@pages/AdminPage"; +import WespLoginRedirect from "@pages/WespLoginRedirect"; +import EnterpriseCabinetPage from "@pages/EnterpriseCabinetPage"; +import { AuthGuard } from "./guards/AuthGuard"; +import { AdminGuard } from "./guards/AdminGuard"; +import { GuestGuard } from "./guards/GuestGuard"; + +const ProfilePage = lazy(() => import("@pages/ProfilePage")); + +export function AppRouter(): JSX.Element { + return ( + + + + + + } + /> + + + + } + /> + } /> + } /> + + + + } + /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + + + ); +} diff --git a/apps/web/src/app/styles/globals.css b/apps/web/src/app/styles/globals.css new file mode 100644 index 0000000..8290184 --- /dev/null +++ b/apps/web/src/app/styles/globals.css @@ -0,0 +1,52 @@ +@import "../../../main/css/tokens.css"; + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--foreground); + font-family: var(--font-sans); +} + +a { + color: inherit; +} + +.marquee-section { + background: var(--marquee-bg); + overflow: hidden; + padding: 0.75rem 0; +} + +.marquee-track { + display: flex; + gap: 2rem; + width: max-content; + animation: marquee-scroll 24s linear infinite; +} + +.marquee-track span { + white-space: nowrap; + font-family: var(--font-mono); + letter-spacing: 0.04em; +} + +@keyframes marquee-scroll { + from { + transform: translateX(0); + } + to { + transform: translateX(-50%); + } +} + +@media (prefers-reduced-motion: reduce) { + .marquee-track { + animation: none; + width: 100%; + flex-wrap: wrap; + } +} diff --git a/apps/web/src/global.d.ts b/apps/web/src/global.d.ts new file mode 100644 index 0000000..a490c5d --- /dev/null +++ b/apps/web/src/global.d.ts @@ -0,0 +1,9 @@ +import type { JSX as ReactJSX } from "react"; + +declare global { + namespace JSX { + type Element = ReactJSX.Element; + } +} + +export {}; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..7f5ccdf --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "@app/App"; +import "antd/dist/reset.css"; +import "@app/styles/globals.css"; + +createRoot(document.getElementById("root")!).render( + + + +); diff --git a/apps/web/src/modules/admin/api/adminApi.test.ts b/apps/web/src/modules/admin/api/adminApi.test.ts new file mode 100644 index 0000000..51395b3 --- /dev/null +++ b/apps/web/src/modules/admin/api/adminApi.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createAdminUser, + deleteAdminUser, + getAdminActivityFeed, + getAdminDiagnostics, + getAdminServerLog, + getAdminSettings, + getAdminStats, + getAdminSummary, + getAdminUsers, + getInstallSecrets, + patchAdminSettings, + patchAdminUser, + postAdminUiActivity, + resetAdminUserPassword, + revealInstallSecret +} from "./adminApi"; + +vi.mock("@shared/api/client", () => ({ + apiClient: { + get: vi.fn(async (url: string) => { + if (url === "/api/v1/admin/summary") { + return { data: { users_count: 5, registrations_day: 2, admins_count: 1, superusers_count: 1 } }; + } + if (url === "/api/v1/admin/stats") { + return { data: { users_count: 5, registrations_day: 2 } }; + } + if (url === "/api/v1/admin/settings") { + return { data: { values: {}, locks: {}, settings_path: "data/compton_settings.json", secrets: {} } }; + } + if (url === "/api/v1/admin/diagnostics/report") { + return { data: { checks: [{ id: "x", status: "ok", message: "ok" }] } }; + } + if (url === "/api/v1/admin/activity-feed") { + return { data: { events: [{ action: "a" }] } }; + } + if (url === "/api/v1/admin/server-log") { + return { data: { lines: ["line"] } }; + } + if (url === "/api/v1/admin/secrets") { + return { data: { initialized: true, locked: true, secrets_path: "x", database: {}, connection_string_masked: "", secrets_status: {} } }; + } + return { data: { data: [], meta: { total: 0, page: 1, limit: 20 } } }; + }), + patch: vi.fn(async (url: string) => { + if (url === "/api/v1/admin/settings") { + return { + data: { + values: { enable_docs: false }, + locks: {}, + settings_path: "data/compton_settings.json", + secrets: {} + } + }; + } + return { + data: { id: "1", email: "u@example.com", role: "user", is_superuser: false, status: "blocked" } + }; + }), + post: vi.fn(async (url: string) => { + if (url === "/api/v1/admin/users") { + return { data: { id: "1", email: "new@example.com", role: "user", is_superuser: false, status: "active" } }; + } + if (url === "/api/v1/admin/ui-activity") { + return { data: { status: "ok" } }; + } + if (url === "/api/v1/admin/secrets/reveal") { + return { data: { key: "database_password", value: "secret" } }; + } + return { data: { status: "ok" } }; + }), + delete: vi.fn(async () => ({ data: { status: "deleted" } })) + } +})); + +describe("adminApi", () => { + it("loads admin users", async () => { + const data = await getAdminUsers(); + expect(data.data).toEqual([]); + }); + + it("loads admin stats", async () => { + const stats = await getAdminStats(); + expect(stats.users_count).toBe(5); + expect(stats.registrations_day).toBe(2); + }); + + it("loads admin summary", async () => { + const summary = await getAdminSummary(); + expect(summary.superusers_count).toBe(1); + }); + + it("patches admin user", async () => { + const user = await patchAdminUser("1", { status: "blocked" }); + expect(user.status).toBe("blocked"); + }); + + it("calls remaining admin api helpers", async () => { + expect((await createAdminUser({ + email: "new@example.com", + password: "Valid123A", + role: "user", + is_superuser: false, + status: "active" + })).email).toBe("new@example.com"); + expect((await resetAdminUserPassword("1", "Valid123A")).status).toBe("blocked"); + expect((await deleteAdminUser("1")).status).toBe("deleted"); + expect((await getAdminSettings()).settings_path).toBe("data/compton_settings.json"); + expect((await patchAdminSettings({ enable_docs: false })).settings_path).toBe("data/compton_settings.json"); + expect((await getAdminDiagnostics()).checks[0].status).toBe("ok"); + expect((await getAdminActivityFeed()).events.length).toBe(1); + expect((await postAdminUiActivity("click")).status).toBe("ok"); + expect((await getAdminServerLog()).lines[0]).toBe("line"); + expect((await getInstallSecrets()).initialized).toBe(true); + expect((await revealInstallSecret("database_password")).value).toBe("secret"); + }); +}); diff --git a/apps/web/src/modules/admin/api/adminApi.ts b/apps/web/src/modules/admin/api/adminApi.ts new file mode 100644 index 0000000..ac8ca57 --- /dev/null +++ b/apps/web/src/modules/admin/api/adminApi.ts @@ -0,0 +1,148 @@ +import { apiClient } from "@shared/api/client"; + +export interface AdminUser { + id: string; + email: string; + role: string; + is_superuser: boolean; + status: string; +} + +export interface AdminUsersResponse { + data: AdminUser[]; + meta: { total: number; page: number; limit: number }; +} + +export interface AdminStats { + users_count: number; + registrations_day: number; +} + +export interface AdminSummary extends AdminStats { + admins_count: number; + superusers_count: number; +} + +export interface AdminSettingsPayload { + values: Record; + locks: Record; + settings_path: string; + secrets: Record; +} + +export interface DiagnosticsCheck { + id: string; + status: "ok" | "warn" | "fail"; + message: string; +} + +export interface InstallSecretsPayload { + initialized: boolean; + locked: boolean; + secrets_path: string; + database: { + host: string | null; + port: number | null; + database: string; + user: string | null; + password_configured: boolean; + }; + connection_string_masked: string; + secrets_status: Record; +} + +export async function getAdminUsers(page = 1, limit = 20): Promise { + const { data } = await apiClient.get("/api/v1/admin/users", { + params: { page, limit } + }); + return data; +} + +export async function patchAdminUser( + userId: string, + payload: { role?: string; status?: string; is_superuser?: boolean } +): Promise { + const { data } = await apiClient.patch(`/api/v1/admin/users/${userId}`, payload); + return data; +} + +export async function getAdminStats(): Promise { + const { data } = await apiClient.get("/api/v1/admin/stats"); + return data; +} + +export async function getAdminSummary(): Promise { + const { data } = await apiClient.get("/api/v1/admin/summary"); + return data; +} + +export async function createAdminUser(payload: { + email: string; + password: string; + role: string; + is_superuser: boolean; + status: string; +}): Promise { + const { data } = await apiClient.post("/api/v1/admin/users", payload); + return data; +} + +export async function resetAdminUserPassword(userId: string, password: string): Promise<{ status: string }> { + const { data } = await apiClient.patch<{ status: string }>( + `/api/v1/admin/users/${userId}/password`, + { password } + ); + return data; +} + +export async function deleteAdminUser(userId: string): Promise<{ status: string }> { + const { data } = await apiClient.delete<{ status: string }>(`/api/v1/admin/users/${userId}`); + return data; +} + +export async function getAdminSettings(): Promise { + const { data } = await apiClient.get("/api/v1/admin/settings"); + return data; +} + +export async function patchAdminSettings(values: Record): Promise { + const { data } = await apiClient.patch("/api/v1/admin/settings", { values }); + return data; +} + +export async function getAdminDiagnostics(): Promise<{ checks: DiagnosticsCheck[] }> { + const { data } = await apiClient.get<{ checks: DiagnosticsCheck[] }>("/api/v1/admin/diagnostics/report"); + return data; +} + +export async function getAdminActivityFeed(limit = 200): Promise<{ events: Array> }> { + const { data } = await apiClient.get<{ events: Array> }>( + "/api/v1/admin/activity-feed", + { params: { limit } } + ); + return data; +} + +export async function postAdminUiActivity(event: string, meta?: Record): Promise<{ status: string }> { + const { data } = await apiClient.post<{ status: string }>("/api/v1/admin/ui-activity", { event, meta }); + return data; +} + +export async function getAdminServerLog(lines = 200): Promise<{ lines: string[] }> { + const { data } = await apiClient.get<{ lines: string[] }>("/api/v1/admin/server-log", { + params: { lines } + }); + return data; +} + +export async function getInstallSecrets(): Promise { + const { data } = await apiClient.get("/api/v1/admin/secrets"); + return data; +} + +export async function revealInstallSecret( + key: "database_password" | "jwt_access_secret" | "jwt_refresh_pepper" | "s3_secret_key" +): Promise<{ key: string; value: string }> { + const { data } = await apiClient.post<{ key: string; value: string }>("/api/v1/admin/secrets/reveal", { key }); + return data; +} diff --git a/apps/web/src/modules/admin/components/AdminActivityPanel.test.tsx b/apps/web/src/modules/admin/components/AdminActivityPanel.test.tsx new file mode 100644 index 0000000..25c257c --- /dev/null +++ b/apps/web/src/modules/admin/components/AdminActivityPanel.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { AdminActivityPanel } from "./AdminActivityPanel"; + +vi.mock("@modules/auth", () => ({ + useIsSuperuser: () => true +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: ({ queryKey }: { queryKey: string[] }) => { + if (queryKey[0] === "admin-activity-feed") { + return { + isLoading: false, + data: { events: [{ action: "admin.user.patch", timestamp: "2026-07-14T10:00:00Z" }] } + }; + } + return { data: { lines: ["line-1"] } }; + } +})); + +vi.mock("../api/adminApi", () => ({ + getAdminActivityFeed: vi.fn(), + getAdminServerLog: vi.fn() +})); + +describe("AdminActivityPanel", () => { + it("renders feed and server log for superuser", () => { + render(); + expect(screen.getByRole("heading", { name: "Activity" })).toBeInTheDocument(); + expect(screen.getByText("admin.user.patch")).toBeInTheDocument(); + expect(screen.getByText("line-1")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/modules/admin/components/AdminActivityPanel.tsx b/apps/web/src/modules/admin/components/AdminActivityPanel.tsx new file mode 100644 index 0000000..956f87b --- /dev/null +++ b/apps/web/src/modules/admin/components/AdminActivityPanel.tsx @@ -0,0 +1,47 @@ +import { useQuery } from "@tanstack/react-query"; +import { useIsSuperuser } from "@modules/auth"; +import { Card, List, Space, Typography } from "antd"; +import { getAdminActivityFeed, getAdminServerLog } from "../api/adminApi"; + +export function AdminActivityPanel(): JSX.Element { + const isSuperuser = useIsSuperuser(); + const { data, isLoading } = useQuery({ + queryKey: ["admin-activity-feed"], + queryFn: () => getAdminActivityFeed(120) + }); + const { data: serverLog } = useQuery({ + queryKey: ["admin-server-log"], + queryFn: () => getAdminServerLog(80), + enabled: isSuperuser + }); + + if (isLoading || !data) { + return

      Loading activity...

      ; + } + + return ( +
      + + Activity + + + ( + + + {String(event.action ?? "event")} + {String(event.timestamp ?? "")} + + + )} + /> + + {isSuperuser && serverLog ? ( + +
      {serverLog.lines.join("\n")}
      +
      + ) : null} +
      + ); +} diff --git a/apps/web/src/modules/admin/components/AdminContentPanel.test.tsx b/apps/web/src/modules/admin/components/AdminContentPanel.test.tsx new file mode 100644 index 0000000..07668be --- /dev/null +++ b/apps/web/src/modules/admin/components/AdminContentPanel.test.tsx @@ -0,0 +1,26 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { AdminContentPanel } from "./AdminContentPanel"; + +vi.mock("@modules/content/api/contentApi", () => ({ + getAdminPages: vi.fn(async () => [ + { id: "1", slug: "about", title: "About", body: "

      x

      ", status: "published" } + ]), + createContentPage: vi.fn(), + updateContentPage: vi.fn(), + deleteContentPage: vi.fn() +})); + +describe("AdminContentPanel", () => { + it("renders content management form", async () => { + const queryClient = new QueryClient(); + render( + + + + ); + expect(await screen.findByText("About")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Create page" })).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/modules/admin/components/AdminContentPanel.tsx b/apps/web/src/modules/admin/components/AdminContentPanel.tsx new file mode 100644 index 0000000..78c912e --- /dev/null +++ b/apps/web/src/modules/admin/components/AdminContentPanel.tsx @@ -0,0 +1,152 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { Button, Form, Input, Select, Space, Table, Typography, message } from "antd"; +import { + createContentPage, + deleteContentPage, + getAdminPages, + updateContentPage, + type ContentPage +} from "@modules/content/api/contentApi"; + +const emptyForm = { slug: "", title: "", body: "", status: "draft" }; + +export function AdminContentPanel(): JSX.Element { + const queryClient = useQueryClient(); + const [messageApi, contextHolder] = message.useMessage(); + const [form, setForm] = useState(emptyForm); + const [editingId, setEditingId] = useState(null); + + const { data: pages, isLoading } = useQuery({ + queryKey: ["admin-content-pages"], + queryFn: getAdminPages + }); + + const saveMutation = useMutation({ + mutationFn: async () => { + if (editingId) { + return updateContentPage(editingId, { + title: form.title, + body: form.body, + status: form.status + }); + } + return createContentPage(form); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["admin-content-pages"] }); + setForm(emptyForm); + setEditingId(null); + messageApi.success("Page saved"); + }, + onError: () => messageApi.error("Save failed") + }); + + const deleteMutation = useMutation({ + mutationFn: (pageId: string) => deleteContentPage(pageId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["admin-content-pages"] }); + messageApi.success("Page deleted"); + } + }); + + function startEdit(page: ContentPage): void { + setEditingId(page.id); + setForm({ + slug: page.slug, + title: page.title, + body: page.body, + status: page.status + }); + } + + if (isLoading) { + return

      Loading content pages...

      ; + } + + return ( +
      + {contextHolder} + + Content pages + +
      saveMutation.mutate()}> + + setForm((prev) => ({ ...prev, slug: event.target.value }))} + /> + + + setForm((prev) => ({ ...prev, title: event.target.value }))} + /> + + + setForm((prev) => ({ ...prev, body: event.target.value }))} + /> + + + + + + + + + + + {isSuperuser ? ( + + + + ) : null} +
      + + + setEditingUser(null)} + onOk={() => editForm.submit()} + okText="Save" + confirmLoading={updateMutation.isPending} + > +
      { + if (!editingUser) { + return; + } + updateMutation.mutate({ id: editingUser.id, ...values }); + }} + > + + + + {isSuperuser ? ( + + + + ) : null} +
      +
      +
      + ); +} diff --git a/apps/web/src/modules/admin/config/adminTabs.ts b/apps/web/src/modules/admin/config/adminTabs.ts new file mode 100644 index 0000000..5371849 --- /dev/null +++ b/apps/web/src/modules/admin/config/adminTabs.ts @@ -0,0 +1,21 @@ +export type AdminTab = "users" | "content" | "security" | "diagnostics" | "activity" | "enterprises" | "sync"; + +export interface AdminTabConfig { + id: AdminTab; + label: string; + superuserOnly?: boolean; +} + +export const ADMIN_TABS: AdminTabConfig[] = [ + { id: "users", label: "Users" }, + { id: "content", label: "Content" }, + { id: "enterprises", label: "Enterprises", superuserOnly: true }, + { id: "sync", label: "Sync metrics", superuserOnly: true }, + { id: "security", label: "Security", superuserOnly: true }, + { id: "diagnostics", label: "Diagnostics", superuserOnly: true }, + { id: "activity", label: "Activity" } +]; + +export function getVisibleAdminTabs(isSuperuser: boolean): AdminTabConfig[] { + return ADMIN_TABS.filter((tab) => !tab.superuserOnly || isSuperuser); +} diff --git a/apps/web/src/modules/admin/config/adminTheme.ts b/apps/web/src/modules/admin/config/adminTheme.ts new file mode 100644 index 0000000..24cd70a --- /dev/null +++ b/apps/web/src/modules/admin/config/adminTheme.ts @@ -0,0 +1,169 @@ +import { theme, type ThemeConfig } from "antd"; + +export type AdminColorMode = "light" | "dark"; + +const ADMIN_THEME_KEY = "wespAdminTheme"; + +export function readAdminThemePreference(): AdminColorMode { + if (typeof window === "undefined") { + return "light"; + } + return localStorage.getItem(ADMIN_THEME_KEY) === "dark" ? "dark" : "light"; +} + +export function persistAdminThemePreference(mode: AdminColorMode): void { + localStorage.setItem(ADMIN_THEME_KEY, mode); +} + +export function toggleAdminColorMode(mode: AdminColorMode): AdminColorMode { + return mode === "light" ? "dark" : "light"; +} + +const lightTheme: ThemeConfig = { + algorithm: theme.defaultAlgorithm, + token: { + colorPrimary: "#48816d", + colorBgBase: "#f6f6f4", + colorBgContainer: "#ffffff", + colorTextBase: "#1a1e1c", + colorBorder: "#e8e8e2", + colorBorderSecondary: "#e8e8e2", + colorError: "#cd3838", + colorWarning: "#d4a72c", + colorSuccess: "#0f766e" + }, + components: { + Layout: { + siderBg: "#ffffff", + triggerBg: "#ebebe5", + triggerColor: "#1a1e1c" + }, + Menu: { + itemBg: "transparent", + itemColor: "#1a1e1c", + itemSelectedBg: "rgba(72, 129, 109, 0.16)", + itemSelectedColor: "#48816d", + itemHoverBg: "#e8f2ef", + itemMarginInline: 8, + itemBorderRadius: 8, + itemHeight: 40 + }, + Card: { + colorBgContainer: "#ffffff", + colorBorderSecondary: "#e8e8e2", + borderRadiusLG: 20 + }, + Table: { + headerBg: "#ebebe5", + rowHoverBg: "rgba(72, 129, 109, 0.06)", + borderColor: "#e8e8e2" + }, + Tag: { + defaultBg: "#ebebe5", + defaultColor: "#4a5a52" + }, + Input: { + colorBgContainer: "#ffffff", + colorBorder: "#e8e8e2", + colorText: "#1a1e1c" + }, + Select: { + colorBgContainer: "#ffffff", + colorBorder: "#e8e8e2", + colorText: "#1a1e1c", + optionSelectedBg: "rgba(72, 129, 109, 0.1)" + }, + Switch: { + colorPrimary: "#48816d", + colorPrimaryHover: "#5a9a82" + }, + Modal: { + contentBg: "#ffffff", + headerBg: "#ffffff", + titleColor: "#1a1e1c", + colorIcon: "#4a5a52", + colorIconHover: "#1a1e1c" + }, + Button: { + defaultBg: "#ffffff", + defaultBorderColor: "#e8e8e2", + defaultColor: "#1a1e1c" + } + } +}; + +const darkTheme: ThemeConfig = { + algorithm: theme.darkAlgorithm, + token: { + colorPrimary: "#5a9a82", + colorBgBase: "#1f2229", + colorBgContainer: "#0b0a10", + colorTextBase: "#ececf1", + colorBorder: "rgba(186, 184, 208, 0.14)", + colorBorderSecondary: "rgba(186, 184, 208, 0.1)", + colorError: "#e94b4b", + colorWarning: "#e8b84a", + colorSuccess: "#6bc4a8" + }, + components: { + Layout: { + siderBg: "#0b0a10", + triggerBg: "#15141c", + triggerColor: "#ececf1" + }, + Menu: { + darkItemBg: "transparent", + darkItemSelectedBg: "rgba(90, 154, 130, 0.32)", + darkItemHoverBg: "rgba(90, 154, 130, 0.2)", + itemMarginInline: 8, + itemBorderRadius: 8, + itemHeight: 40 + }, + Card: { + colorBgContainer: "#0b0a10", + colorBorderSecondary: "rgba(186, 184, 208, 0.14)", + borderRadiusLG: 20 + }, + Table: { + headerBg: "#1a1924", + rowHoverBg: "rgba(90, 154, 130, 0.1)", + borderColor: "rgba(186, 184, 208, 0.1)" + }, + Tag: { + defaultBg: "rgba(11, 10, 16, 0.35)", + defaultColor: "rgba(236, 236, 241, 0.75)" + }, + Input: { + colorBgContainer: "#15141c", + colorBorder: "rgba(186, 184, 208, 0.16)", + colorText: "#ececf1" + }, + Select: { + colorBgContainer: "#15141c", + colorBorder: "rgba(186, 184, 208, 0.16)", + colorText: "#ececf1", + optionSelectedBg: "rgba(90, 154, 130, 0.2)" + }, + Switch: { + colorPrimary: "#5a9a82", + colorPrimaryHover: "#6bc4a8" + }, + Modal: { + colorBgElevated: "#0b0a10", + contentBg: "#0b0a10", + headerBg: "#0b0a10", + titleColor: "#ececf1", + colorIcon: "#a8adbb", + colorIconHover: "#ececf1" + }, + Button: { + defaultBg: "#15141c", + defaultBorderColor: "rgba(186, 184, 208, 0.16)", + defaultColor: "#ececf1" + } + } +}; + +export function getAdminAntdTheme(mode: AdminColorMode): ThemeConfig { + return mode === "dark" ? darkTheme : lightTheme; +} diff --git a/apps/web/src/modules/admin/index.ts b/apps/web/src/modules/admin/index.ts new file mode 100644 index 0000000..81ca198 --- /dev/null +++ b/apps/web/src/modules/admin/index.ts @@ -0,0 +1,19 @@ +export { AdminPanel } from "./components/AdminPanel"; +export { AdminStats } from "./components/AdminStats"; +export { AdminUsersTable } from "./components/AdminUsersTable"; +export { AdminContentPanel } from "./components/AdminContentPanel"; +export { + createAdminUser, + deleteAdminUser, + getAdminActivityFeed, + getAdminDiagnostics, + getAdminServerLog, + getAdminSettings, + getAdminStats, + getAdminSummary, + getAdminUsers, + patchAdminSettings, + patchAdminUser, + postAdminUiActivity, + resetAdminUserPassword +} from "./api/adminApi"; diff --git a/apps/web/src/modules/admin/styles/wesp-admin-panel.css b/apps/web/src/modules/admin/styles/wesp-admin-panel.css new file mode 100644 index 0000000..1edc9fa --- /dev/null +++ b/apps/web/src/modules/admin/styles/wesp-admin-panel.css @@ -0,0 +1,898 @@ +@import "../../../../main/css/tokens.css"; + +html.admin-route body { + background: var(--wesp-admin-bg); +} + +html.admin-route .ant-layout-sider-trigger { + background: var(--marquee-bg) !important; + color: var(--foreground) !important; + border-top: 1px solid var(--border-light) !important; +} + +html.admin-route .ant-layout-sider-trigger:hover { + background: var(--primary-tint) !important; + color: var(--foreground) !important; +} + +html.admin-route .ant-card { + background: var(--wesp-admin-surface) !important; + border-color: var(--border-light) !important; + box-shadow: none !important; + border-radius: var(--admin-card-radius) !important; + overflow: hidden; +} + +html.admin-route .ant-btn-primary { + background: var(--wesp-admin-primary) !important; + border-color: var(--primary) !important; + color: #fff !important; +} + +html.admin-route .ant-btn-dangerous, +html.admin-route .ant-btn-dangerous.ant-btn-primary { + background: #cd3838 !important; + border-color: transparent !important; + color: #fff !important; +} + +html.admin-route .ant-btn-dangerous:hover, +html.admin-route .ant-btn-dangerous.ant-btn-primary:hover { + background: #e94b4b !important; + color: #fff !important; +} + +html.admin-route .ant-input, +html.admin-route .ant-input-affix-wrapper, +html.admin-route .ant-select-selector, +html.admin-route .ant-input-number { + background: var(--surface) !important; + border-color: var(--border-light) !important; + color: var(--foreground) !important; +} + +html.admin-route .ant-select-dropdown, +html.admin-route .ant-modal-content, +html.admin-route .ant-modal-header { + background: var(--surface) !important; + color: var(--foreground) !important; +} + +html.admin-route .ant-modal-title, +html.admin-route .ant-modal-close { + color: var(--foreground) !important; +} + +html.admin-route .ant-switch.ant-switch-checked { + background: var(--wesp-admin-primary) !important; +} + +html.admin-route .ant-typography.ant-typography-secondary, +html.admin-route .ant-list-item-meta-description { + color: var(--muted) !important; +} + +html.admin-route .ant-table { + background: var(--surface) !important; + color: var(--foreground) !important; +} + +html.admin-route .ant-table-thead > tr > th { + background: var(--marquee-bg) !important; + color: var(--muted) !important; + border-bottom-color: var(--border-light) !important; +} + +html.admin-route .ant-table-tbody > tr > td { + border-bottom-color: var(--border-light) !important; +} + +.wesp-admin-layout { + min-height: 100vh; + background: var(--wesp-admin-bg) !important; + --wesp-admin-sider-width: 80px; +} + +.wesp-admin-layout.wesp-admin-sider-expanded { + --wesp-admin-sider-width: 256px; +} + +.wesp-admin-sider.ant-layout-sider { + background: var(--admin-sider-bg) !important; + border-right: 1px solid var(--admin-stroke); + position: fixed; + top: 0; + left: 0; + bottom: 0; + height: 100vh; + z-index: 100; + flex: 0 0 var(--wesp-admin-sider-width) !important; + max-width: var(--wesp-admin-sider-width) !important; + min-width: var(--wesp-admin-sider-width) !important; + width: var(--wesp-admin-sider-width) !important; + transition: + flex 0.28s cubic-bezier(0.4, 0, 0.2, 1), + max-width 0.28s cubic-bezier(0.4, 0, 0.2, 1), + min-width 0.28s cubic-bezier(0.4, 0, 0.2, 1), + width 0.28s cubic-bezier(0.4, 0, 0.2, 1); +} + +.wesp-admin-sider.ant-layout-sider-collapsed { + flex: 0 0 80px !important; + max-width: 80px !important; + min-width: 80px !important; + width: 80px !important; +} + +.wesp-admin-layout > .ant-layout { + margin-left: var(--wesp-admin-sider-width); + min-height: 100vh; + background: transparent; + transition: margin-left 0.28s cubic-bezier(0.4, 0, 0.2, 1); +} + +.wesp-admin-sider .ant-layout-sider-children { + display: flex; + flex-direction: column; + overflow: hidden; + padding-bottom: 48px; +} + +.wesp-admin-sider .ant-menu { + flex: 1 1 auto; + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + padding: 10px 0 8px !important; + background: transparent !important; + border-inline-end: none !important; +} + +.wesp-admin-sider .ant-menu-item { + cursor: pointer; + margin: 4px 8px !important; + width: calc(100% - 16px) !important; + display: flex !important; + align-items: center; + gap: 10px; + height: 40px !important; + line-height: 40px !important; + border-radius: 8px; + color: var(--wesp-admin-text) !important; +} + +.wesp-admin-sider .ant-menu-item:hover { + background: var(--admin-primary-hover) !important; +} + +.wesp-admin-sider .ant-menu-item-selected { + background: var(--admin-primary-selected) !important; + color: var(--primary) !important; +} + +.wesp-admin-sider .ant-menu-item .anticon { + color: var(--muted); + font-size: 16px; +} + +.wesp-admin-sider .ant-menu-item-selected .anticon { + color: var(--primary); +} + +.wesp-admin-sider .wesp-menu-label { + min-width: 0; + overflow: hidden; + white-space: nowrap; + max-width: 16rem; + opacity: 1; + transition: + opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1) 0.1s, + max-width 0.36s cubic-bezier(0.4, 0, 0.2, 1) 0.08s, + margin 0.25s ease, + padding 0.25s ease; +} + +.wesp-admin-sider.ant-layout-sider-collapsed .wesp-menu-label { + max-width: 0 !important; + opacity: 0 !important; + margin: 0 !important; + padding: 0 !important; + pointer-events: none; +} + +.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-item { + display: flex !important; + justify-content: center !important; + align-items: center !important; + gap: 0; + margin-inline: 8px !important; + width: calc(100% - 16px) !important; + padding-inline: 0 !important; +} + +.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-item .ant-menu-item-icon { + margin-inline: 0 !important; + flex: none !important; +} + +.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-title-content { + width: 0 !important; + overflow: hidden !important; + opacity: 0 !important; + flex: 0 !important; + margin: 0 !important; + padding: 0 !important; +} + +.wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-inline-collapsed > .ant-menu-item { + padding-inline: 0 !important; +} + +.wesp-admin-sider:not(.ant-layout-sider-collapsed) .ant-menu-item { + padding-inline: 12px !important; +} + +.wesp-admin-sider .ant-layout-sider-trigger, +.wesp-admin-sider.ant-layout-sider-light .ant-layout-sider-trigger, +.wesp-admin-sider.ant-layout-sider-dark .ant-layout-sider-trigger { + position: absolute; + left: 0; + right: 0; + bottom: 0; + width: 100% !important; + height: 48px !important; + line-height: 48px !important; + border-top: 1px solid var(--border-light) !important; + background: var(--marquee-bg) !important; + color: var(--foreground) !important; + box-shadow: none !important; +} + +.wesp-admin-sider .ant-layout-sider-trigger:hover, +.wesp-admin-sider.ant-layout-sider-light .ant-layout-sider-trigger:hover, +.wesp-admin-sider.ant-layout-sider-dark .ant-layout-sider-trigger:hover { + background: var(--primary-tint) !important; + color: var(--foreground) !important; +} + +.wesp-sider-trigger-icon { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 16px; + color: var(--foreground); + opacity: 0.9; +} + +.wesp-sider-trigger-icon .anticon { + color: inherit; +} + +.ant-tooltip.ant-menu-inline-collapsed-tooltip .ant-tooltip-inner { + background: var(--surface); + color: var(--foreground); + font-size: 12px; + border: 1px solid var(--border-light); +} + +.ant-tooltip.ant-menu-inline-collapsed-tooltip .ant-tooltip-arrow::before { + background: var(--surface); +} + +.wesp-admin-content { + margin: 16px; + padding: 0; + background: transparent !important; + color: var(--wesp-admin-text); + min-width: 0; +} + +.wesp-admin-main { + min-width: 0; +} + +.wesp-admin-content-topbar { + margin-bottom: 12px; + display: flex; + justify-content: flex-end; + align-items: center; + flex-wrap: wrap; + gap: 12px; + min-height: 44px; +} + +.wesp-admin-content-topbar-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.wesp-admin-content-topbar .ant-btn { + border-radius: 20px; + background: var(--admin-btn-bg); + border-color: var(--admin-btn-border); + color: var(--admin-btn-text); + height: 32px; + padding: 0 15px; + font-size: 14px; + font-weight: 500; +} + +.wesp-admin-content-topbar .ant-btn-primary { + background: var(--wesp-admin-primary) !important; + border-color: var(--primary) !important; + color: #fff !important; +} + +.wesp-admin-panel-card.ant-card { + border-radius: var(--admin-card-radius); + border: 1px solid var(--border-light); + background: var(--wesp-admin-surface); +} + +.wesp-admin-panel-card .ant-card-head { + border-bottom-color: var(--border-light); + min-height: 40px; + padding: 0 14px; +} + +.wesp-admin-panel-card .ant-card-body { + padding: 14px; +} + +.wesp-admin-panel-card .ant-card-head-title, +.wesp-admin-panel-card .ant-typography, +.wesp-admin-grid .ant-typography, +.wesp-admin-grid .ant-list-item-meta-title, +.wesp-admin-grid .ant-list-item-meta-description { + color: var(--wesp-admin-text) !important; +} + +.wesp-admin-section { + display: block; +} + +.wesp-admin-dashboard { + display: grid; + gap: 16px; +} + +.wesp-dashboard-sticky.ant-card { + position: sticky; + top: 0; + z-index: 20; +} + +.wesp-dashboard-sticky.ant-card .ant-card-body { + padding: 12px 14px; +} + +.wesp-admin-gauges-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.wesp-gauge { + text-align: center; +} + +.wesp-gauge-ring { + position: relative; + width: 120px; + max-width: 100%; + margin: 0 auto; + aspect-ratio: 1; +} + +.wesp-gauge-svg { + width: 100%; + height: 100%; + display: block; +} + +.wesp-gauge-track { + stroke: var(--border-light); +} + +.wesp-gauge-fill { + stroke: var(--wesp-admin-primary); + transition: stroke-dashoffset 0.45s ease; +} + +.wesp-gauge-center { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +.wesp-gauge-pct { + font-size: 22px; + font-weight: 700; + letter-spacing: -0.02em; +} + +.wesp-gauge-pct-suffix { + font-size: 13px; + font-weight: 600; + opacity: 0.55; + margin-left: 1px; +} + +.wesp-gauge-ring--adm { + width: 120px; + height: 120px; +} + +.wesp-gauge-caption { + margin-top: 10px; + font-size: 13px; + line-height: 1.45; + word-break: break-word; +} + +.wesp-gauge-caption span[data-gauge-detail] { + opacity: 0.75; + font-weight: 500; +} + +.wesp-dash-service-row.ant-row { + display: flex; + flex-wrap: wrap; + align-items: stretch; +} + +.wesp-dash-service-row > .ant-col { + display: flex; + flex-direction: column; +} + +.wesp-dash-service-row .wesp-dash-service-card { + flex: 1 1 auto; + width: 100%; + min-height: 100%; + display: flex; + flex-direction: column; +} + +.wesp-dash-service-card .ant-card-body { + padding-top: 12px; + flex: 1 1 auto; + display: flex; + flex-direction: column; +} + +.wesp-dash-service-row .wesp-dash-manage-list { + flex: 1 1 auto; + display: flex; + flex-direction: column; + min-height: 0; +} + +.wesp-dash-manage-list { + font-size: 13px; + line-height: 1.4; +} + +.wesp-dash-manage-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px 16px; + padding: 14px 0; + border-bottom: 1px solid var(--border-light); +} + +.wesp-dash-manage-row:first-child { + padding-top: 2px; +} + +.wesp-dash-manage-row:last-child { + border-bottom: none; + padding-bottom: 2px; +} + +.wesp-dash-manage-row--service { + margin-top: 4px; + padding-top: 16px; +} + +.wesp-dash-manage-info { + flex: 1 1 140px; + min-width: 0; +} + +.wesp-dash-manage-title { + display: block; + font-weight: 600; + font-size: 13px; +} + +.wesp-dash-manage-hint { + display: block; + margin-top: 4px; + font-size: 12px; + opacity: 0.65; + line-height: 1.35; +} + +.wesp-dash-manage-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 12px; + flex-shrink: 0; +} + +.wesp-dash-wesp-meta { + display: flex; + flex-direction: column; + gap: 10px; + font-size: 13px; +} + +.wesp-dash-wesp-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px 12px; + line-height: 1.4; +} + +.wesp-dash-wesp-label { + opacity: 0.65; + flex-shrink: 0; +} + +.wesp-dash-wesp-value { + font-weight: 500; + text-align: right; +} + +.wesp-dash-status { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 500; +} + +.wesp-status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; + box-sizing: border-box; +} + +.wesp-status-dot--ok { + background: var(--wesp-admin-primary); +} + +.wesp-traffic-card .ant-card-body { + padding: 16px 20px 20px; +} + +.wesp-traffic-two-col { + display: flex; + flex-wrap: wrap; + gap: 16px 24px; +} + +.wesp-traffic-col { + flex: 1 1 160px; + min-width: 0; +} + +.wesp-traffic-label { + font-size: 13px; + opacity: 0.65; + margin-bottom: 8px; +} + +.wesp-traffic-value { + font-size: 18px; + font-weight: 600; + letter-spacing: 0.02em; +} + +.wesp-traffic-arrow, +.wesp-traffic-icon { + opacity: 0.75; + margin-right: 4px; +} + +.wesp-admin-stats-row { + margin-top: 0; +} + +.wesp-tag { + background: var(--marquee-bg) !important; + border: 1px solid var(--border-light) !important; + color: var(--muted) !important; +} + +.wesp-tag--ok { + background: var(--primary-tint) !important; + border-color: var(--admin-tag-ok-border) !important; + color: var(--primary) !important; +} + +.wesp-tag--warn { + background: #fff8e6 !important; + border-color: rgba(212, 167, 44, 0.45) !important; + color: #9a6700 !important; +} + +.wesp-tag--error { + background: #fff1f0 !important; + border-color: rgba(205, 56, 56, 0.35) !important; + color: #cd3838 !important; +} + +.wesp-admin-stat-card.ant-card { + background: var(--wesp-admin-surface) !important; + border: 1px solid var(--wesp-admin-stroke) !important; + border-radius: var(--admin-card-radius); + box-shadow: none !important; +} + +.wesp-admin-stat-card.ant-card .ant-card-body { + padding: 16px !important; +} + +.wesp-admin-stat-label { + opacity: 0.65; + font-size: 13px; + color: var(--wesp-admin-soft); +} + +.wesp-admin-stat-value { + font-size: 26px; + font-weight: 600; + margin-top: 6px; + color: var(--wesp-admin-text); + line-height: 1.2; +} + +.wesp-admin-grid .ant-card { + background: var(--wesp-admin-surface) !important; + border-color: var(--wesp-admin-stroke) !important; + border-radius: var(--admin-card-radius) !important; +} + +.wesp-admin-grid .ant-table { + background: var(--wesp-admin-surface) !important; + color: var(--wesp-admin-text) !important; +} + +.wesp-admin-grid .ant-table-thead > tr > th { + background: var(--admin-table-header) !important; + border-bottom-color: var(--wesp-admin-stroke) !important; + color: var(--wesp-admin-soft) !important; +} + +.wesp-admin-grid .ant-table-tbody > tr > td { + border-bottom-color: var(--border-light) !important; + color: var(--wesp-admin-text) !important; +} + +.wesp-admin-grid { + display: grid; + gap: 10px; +} + +.wesp-admin-log { + margin: 0; + background: linear-gradient(165deg, #0a0f0d 0%, #060807 48%, #0d1210 100%); + color: #7ee787; + border-radius: 10px; + padding: 12px; + max-height: 320px; + overflow: auto; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; +} + +.wesp-admin-block { + position: relative; +} + +html.wesp-admin-loading #root { + pointer-events: none; +} + +.wesp-admin-skel-overlay { + position: absolute; + inset: 0; + pointer-events: none; + border-radius: inherit; + z-index: 2; + background: linear-gradient( + 105deg, + rgba(0, 0, 0, 0.02) 0%, + rgba(0, 0, 0, 0.02) 34%, + rgba(0, 0, 0, 0.05) 50%, + rgba(0, 0, 0, 0.02) 66%, + rgba(0, 0, 0, 0.02) 100% + ); + background-size: 240% 100%; + animation: wesp-admin-skel-shimmer 0.95s ease-in-out infinite; + transition: opacity 0.38s ease; +} + +@keyframes wesp-admin-skel-shimmer { + 0% { + background-position: 240% 0; + } + 100% { + background-position: -240% 0; + } +} + +html.wesp-admin-loaded .wesp-admin-skel-overlay { + opacity: 0; + visibility: hidden; +} + +html.wesp-admin-loaded .wesp-admin-block { + animation: wesp-admin-block-reveal 0.52s cubic-bezier(0.22, 1, 0.36, 1) both; + animation-delay: calc(var(--wesp-reveal-i, 0) * 38ms); +} + +@keyframes wesp-admin-block-reveal { + from { + opacity: 0.55; + transform: translateY(14px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.wesp-admin-section-enter { + animation: wesp-admin-section-tween 0.44s cubic-bezier(0.22, 1, 0.36, 1) both; +} + +@keyframes wesp-admin-section-tween { + from { + opacity: 0.45; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +html.admin-route[data-admin-theme="dark"] .wesp-tag { + background: rgba(11, 10, 16, 0.45) !important; + border: 1px solid rgba(186, 184, 208, 0.16) !important; + color: rgba(236, 236, 241, 0.82) !important; +} + +html.admin-route[data-admin-theme="dark"] .wesp-tag--ok { + background: rgba(90, 154, 130, 0.18) !important; + border-color: var(--admin-tag-ok-border) !important; + color: var(--admin-success) !important; +} + +html.admin-route[data-admin-theme="dark"] .wesp-tag--warn { + background: rgba(232, 184, 74, 0.12) !important; + border-color: rgba(232, 184, 74, 0.4) !important; + color: #e8b84a !important; +} + +html.admin-route[data-admin-theme="dark"] .wesp-tag--error { + background: rgba(233, 75, 75, 0.14) !important; + border-color: rgba(233, 75, 75, 0.35) !important; + color: #ff9c9c !important; +} + +html.admin-route[data-admin-theme="dark"] .wesp-admin-skel-overlay { + background: linear-gradient( + 105deg, + rgba(255, 255, 255, 0.02) 0%, + rgba(255, 255, 255, 0.02) 34%, + rgba(255, 255, 255, 0.07) 50%, + rgba(255, 255, 255, 0.02) 66%, + rgba(255, 255, 255, 0.02) 100% + ); +} + +html.admin-route[data-admin-theme="dark"] .wesp-admin-sider .ant-menu-item-selected { + color: var(--admin-success) !important; +} + +html.admin-route[data-admin-theme="dark"] .wesp-admin-sider .ant-menu-item-selected .anticon { + color: var(--admin-success); +} + +html.admin-route[data-admin-theme="dark"] .wesp-admin-sider .ant-menu-item .anticon { + color: var(--admin-soft); +} + +html.admin-route[data-admin-theme="dark"] .wesp-gauge-track { + stroke: rgba(186, 184, 208, 0.18); +} + +html.admin-route[data-admin-theme="dark"] .wesp-admin-log { + background: linear-gradient(165deg, #0b0a10 0%, #15141c 48%, #0b0a10 100%); + color: #6bc4a8; +} + +@media (max-width: 768px) { + .wesp-admin-layout.ant-layout-has-sider { + flex-direction: column; + } + + .wesp-admin-sider.ant-layout-sider { + position: static; + inset: auto; + width: 100% !important; + max-width: 100% !important; + min-width: 0 !important; + flex: 0 0 auto !important; + height: auto !important; + } + + .wesp-admin-layout > .ant-layout { + margin-left: 0; + } + + .wesp-admin-sider .ant-layout-sider-children { + padding-bottom: 0; + } + + .wesp-admin-sider .ant-layout-sider-trigger { + display: none; + } + + .wesp-admin-sider.ant-layout-sider-collapsed .wesp-menu-label { + max-width: 16rem !important; + opacity: 1 !important; + pointer-events: auto; + } + + .wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-item { + justify-content: flex-start; + gap: 10px; + padding-inline: 16px !important; + } + + .wesp-admin-sider.ant-layout-sider-collapsed .ant-menu-title-content { + width: auto !important; + opacity: 1 !important; + flex: 1 !important; + overflow: visible !important; + } + + .wesp-admin-content { + margin: 12px; + } + + .wesp-admin-content-topbar { + justify-content: flex-start; + } + + .wesp-admin-gauges-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (prefers-reduced-motion: reduce) { + .wesp-admin-sider.ant-layout-sider, + .wesp-admin-layout > .ant-layout, + .wesp-admin-sider .wesp-menu-label, + .wesp-admin-sider .ant-menu-item { + transition: none !important; + } +} diff --git a/apps/web/src/modules/analytics/index.ts b/apps/web/src/modules/analytics/index.ts new file mode 100644 index 0000000..6d8adc5 --- /dev/null +++ b/apps/web/src/modules/analytics/index.ts @@ -0,0 +1,3 @@ +export function AnalyticsPlaceholder(): string { + return "analytics-v1"; +} diff --git a/apps/web/src/modules/auth/api/authApi.test.ts b/apps/web/src/modules/auth/api/authApi.test.ts new file mode 100644 index 0000000..a18ed3e --- /dev/null +++ b/apps/web/src/modules/auth/api/authApi.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from "vitest"; +import { login, register } from "./authApi"; + +vi.mock("@shared/api/client", () => ({ + authClient: { + post: vi.fn(async (url: string) => { + if (url.endsWith("/login")) { + return { data: { access_token: "token", user: { id: "1" } } }; + } + return { data: { message: "ok" } }; + }) + } +})); + +describe("authApi", () => { + it("login returns access token payload", async () => { + const data = await login({ email: "u@example.com", password: "Valid123" }); + expect(data.access_token).toBe("token"); + }); + + it("register returns success payload", async () => { + const data = await register({ email: "u@example.com", password: "Valid123" }); + expect(data.message).toBe("ok"); + }); +}); diff --git a/apps/web/src/modules/auth/api/authApi.ts b/apps/web/src/modules/auth/api/authApi.ts new file mode 100644 index 0000000..8b0ed4e --- /dev/null +++ b/apps/web/src/modules/auth/api/authApi.ts @@ -0,0 +1,85 @@ +import { authClient, applyAuthHeader } from "@shared/api/client"; + +export interface LoginPayload { + email: string; + password: string; +} + +export interface RegisterPayload { + email: string; + password: string; +} + +export async function login(payload: LoginPayload) { + const { data } = await authClient.post( + "/api/v1/auth/login", + { + email: payload.email.trim(), + password: payload.password + }, + { + headers: { "Content-Type": "application/json" } + } + ); + return data; +} + +export async function register(payload: RegisterPayload) { + const { data } = await authClient.post( + "/api/v1/auth/register", + { + email: payload.email.trim(), + password: payload.password + }, + { + headers: { "Content-Type": "application/json" } + } + ); + return data; +} + +export async function logout(accessToken?: string | null) { + const headers = accessToken ? applyAuthHeader({}) : {}; + await authClient.post("/api/v1/auth/logout", undefined, { headers }); +} + +export async function refresh() { + const { data } = await authClient.post("/api/v1/auth/refresh"); + return data; +} + +export async function verifyEmail(token: string) { + const { data } = await authClient.post( + "/api/v1/auth/verify-email", + { token }, + { headers: { "Content-Type": "application/json" } } + ); + return data; +} + +export async function forgotPassword(email: string) { + const { data } = await authClient.post( + "/api/v1/auth/forgot-password", + { email: email.trim() }, + { headers: { "Content-Type": "application/json" } } + ); + return data; +} + +export async function resetPassword(token: string, newPassword: string) { + const { data } = await authClient.post( + "/api/v1/auth/reset-password", + { token, new_password: newPassword }, + { headers: { "Content-Type": "application/json" } } + ); + return data; +} + +export async function resendVerification(email: string) { + const { data } = await authClient.post( + "/api/v1/auth/resend-verification", + { email: email.trim() }, + { headers: { "Content-Type": "application/json" } } + ); + return data; +} diff --git a/apps/web/src/modules/auth/api/enterpriseContext.ts b/apps/web/src/modules/auth/api/enterpriseContext.ts new file mode 100644 index 0000000..d665054 --- /dev/null +++ b/apps/web/src/modules/auth/api/enterpriseContext.ts @@ -0,0 +1,17 @@ +export async function fetchDefaultEnterpriseId(accessToken: string): Promise { + try { + const resp = await fetch("/api/v1/enterprise/enterprises", { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!resp.ok) { + return null; + } + const data = (await resp.json()) as Array<{ id: string }>; + if (!Array.isArray(data) || !data.length) { + return null; + } + return data[0]?.id ?? null; + } catch { + return null; + } +} diff --git a/apps/web/src/modules/auth/components/AuthField.tsx b/apps/web/src/modules/auth/components/AuthField.tsx new file mode 100644 index 0000000..6c3c942 --- /dev/null +++ b/apps/web/src/modules/auth/components/AuthField.tsx @@ -0,0 +1,16 @@ +import type { InputHTMLAttributes, ReactNode } from "react"; + +interface AuthFieldProps extends InputHTMLAttributes { + icon: ReactNode; +} + +export function AuthField({ icon, ...props }: AuthFieldProps): JSX.Element { + return ( + + ); +} diff --git a/apps/web/src/modules/auth/components/AuthLayout.tsx b/apps/web/src/modules/auth/components/AuthLayout.tsx new file mode 100644 index 0000000..d6a29b4 --- /dev/null +++ b/apps/web/src/modules/auth/components/AuthLayout.tsx @@ -0,0 +1,22 @@ +import type { PropsWithChildren } from "react"; +import { ZootechThemeProvider } from "@shared/theme/zootech/ThemeProvider"; +import "@shared/theme/zootech/auth-login.css"; + +interface AuthLayoutProps extends PropsWithChildren { + title: string; +} + +export function AuthLayout({ title, children }: AuthLayoutProps): JSX.Element { + return ( + +
      +
      +
      +

      {title}

      + {children} +
      +
      +
      +
      + ); +} diff --git a/apps/web/src/modules/auth/components/AuthMessage.tsx b/apps/web/src/modules/auth/components/AuthMessage.tsx new file mode 100644 index 0000000..918ffbd --- /dev/null +++ b/apps/web/src/modules/auth/components/AuthMessage.tsx @@ -0,0 +1,12 @@ +interface AuthMessageProps { + type: "error" | "success"; + text: string; +} + +export function AuthMessage({ type, text }: AuthMessageProps): JSX.Element { + return ( +

      + {text} +

      + ); +} diff --git a/apps/web/src/modules/auth/components/AuthSubmit.tsx b/apps/web/src/modules/auth/components/AuthSubmit.tsx new file mode 100644 index 0000000..586245e --- /dev/null +++ b/apps/web/src/modules/auth/components/AuthSubmit.tsx @@ -0,0 +1,19 @@ +interface AuthSubmitProps { + loading: boolean; + idleText: string; + loadingText: string; + disabled?: boolean; +} + +export function AuthSubmit({ + loading, + idleText, + loadingText, + disabled = false +}: AuthSubmitProps): JSX.Element { + return ( + + ); +} diff --git a/apps/web/src/modules/auth/components/AuthSwitch.tsx b/apps/web/src/modules/auth/components/AuthSwitch.tsx new file mode 100644 index 0000000..1f5f57e --- /dev/null +++ b/apps/web/src/modules/auth/components/AuthSwitch.tsx @@ -0,0 +1,12 @@ +import type { InputHTMLAttributes, PropsWithChildren } from "react"; + +type AuthSwitchProps = InputHTMLAttributes & PropsWithChildren; + +export function AuthSwitch({ children, ...props }: AuthSwitchProps): JSX.Element { + return ( + + ); +} diff --git a/apps/web/src/modules/auth/components/LoginForm.test.tsx b/apps/web/src/modules/auth/components/LoginForm.test.tsx new file mode 100644 index 0000000..361bd29 --- /dev/null +++ b/apps/web/src/modules/auth/components/LoginForm.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it } from "vitest"; +import { LoginForm } from "./LoginForm"; + +describe("LoginForm", () => { + it("renders login form controls", () => { + render( + + + + ); + expect(screen.getByPlaceholderText("Email")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Password")).toBeInTheDocument(); + expect(screen.getByText(/Forgot password/i)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/modules/auth/components/LoginForm.tsx b/apps/web/src/modules/auth/components/LoginForm.tsx new file mode 100644 index 0000000..f803318 --- /dev/null +++ b/apps/web/src/modules/auth/components/LoginForm.tsx @@ -0,0 +1,107 @@ +import { useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { useAuth } from "../hooks/useAuth"; +import { formatAuthError } from "../utils/formatAuthError"; +import { AuthField } from "./AuthField"; +import { AuthMessage } from "./AuthMessage"; +import { AuthSubmit } from "./AuthSubmit"; + +function UserIcon(): JSX.Element { + return ( + + + + ); +} + +function LockIcon(): JSX.Element { + return ( + + + + ); +} + +export function LoginForm(): JSX.Element { + const auth = useAuth(); + const navigate = useNavigate(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + return ( +
      { + event.preventDefault(); + setError(""); + setIsSubmitting(true); + try { + const user = await auth.login(email.trim(), password); + if (user.role === "admin") { + window.location.assign("/recipes"); + return; + } + navigate("/profile"); + } catch (loginError) { + const fallback = + loginError && typeof loginError === "object" && "response" in loginError + ? (loginError as { response?: { status?: number } }).response?.status === 401 + ? "Invalid credentials" + : (loginError as { response?: { status?: number } }).response?.status === 403 + ? "Email not verified or account blocked" + : "Login failed" + : "Login failed"; + setError(formatAuthError(loginError, fallback)); + } finally { + setIsSubmitting(false); + } + }} + > + } + placeholder="Email" + type="email" + autoComplete="email" + value={email} + onChange={(event) => setEmail(event.target.value)} + required + /> + } + placeholder="Password" + type="password" + autoComplete="current-password" + value={password} + onChange={(event) => setPassword(event.target.value)} + required + /> + +

      + + Forgot password? + +

      +

      + No account yet?{" "} + + Register + +

      + {error ? : null} + + ); +} diff --git a/apps/web/src/modules/auth/components/RegisterForm.test.tsx b/apps/web/src/modules/auth/components/RegisterForm.test.tsx new file mode 100644 index 0000000..0ea74df --- /dev/null +++ b/apps/web/src/modules/auth/components/RegisterForm.test.tsx @@ -0,0 +1,72 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { AxiosError, type AxiosResponse } from "axios"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { RegisterForm } from "./RegisterForm"; + +const registerMock = vi.fn(); + +vi.mock("../api/authApi", () => ({ + register: (...args: unknown[]) => registerMock(...args) +})); + +afterEach(() => { + cleanup(); + registerMock.mockReset(); +}); + +describe("RegisterForm", () => { + it("renders register form controls", () => { + render( + + + + ); + expect(screen.getByPlaceholderText("Email")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Password")).toBeInTheDocument(); + expect(screen.getByText(/uppercase, lowercase, and a digit/i)).toBeInTheDocument(); + expect(screen.getByText(/privacy policy/i)).toBeInTheDocument(); + }); + + it("shows validation error from API", async () => { + registerMock.mockRejectedValueOnce( + new AxiosError( + "Validation failed", + "422", + undefined, + undefined, + { + status: 422, + data: { + detail: [ + { + loc: ["body", "password"], + msg: "Value error, Password must include at least one uppercase letter" + } + ] + } + } as AxiosResponse + ) + ); + + render( + + + + ); + fireEvent.change(screen.getByPlaceholderText("Email"), { + target: { value: "user@example.com" } + }); + fireEvent.change(screen.getByPlaceholderText("Password"), { + target: { value: "valid123" } + }); + fireEvent.click(screen.getByRole("checkbox")); + fireEvent.click(screen.getByRole("button", { name: "Register" })); + + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent( + "Password must include at least one uppercase letter" + ); + }); + }); +}); diff --git a/apps/web/src/modules/auth/components/RegisterForm.tsx b/apps/web/src/modules/auth/components/RegisterForm.tsx new file mode 100644 index 0000000..bf63fd4 --- /dev/null +++ b/apps/web/src/modules/auth/components/RegisterForm.tsx @@ -0,0 +1,108 @@ +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { register } from "../api/authApi"; +import { formatAuthError } from "../utils/formatAuthError"; +import { AuthField } from "./AuthField"; +import { AuthMessage } from "./AuthMessage"; +import { AuthSubmit } from "./AuthSubmit"; +import { AuthSwitch } from "./AuthSwitch"; + +function UserIcon(): JSX.Element { + return ( + + + + ); +} + +function LockIcon(): JSX.Element { + return ( + + + + ); +} + +export function RegisterForm(): JSX.Element { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [consent, setConsent] = useState(false); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + return ( +
      { + event.preventDefault(); + if (!consent) { + setError("Please accept the privacy policy to register."); + return; + } + setMessage(""); + setError(""); + setIsSubmitting(true); + try { + await register({ email, password }); + setMessage("Registration submitted. Check your email."); + } catch (registerError) { + setError(formatAuthError(registerError, "Registration failed")); + } finally { + setIsSubmitting(false); + } + }} + > + } + placeholder="Email" + type="email" + autoComplete="email" + value={email} + onChange={(event) => setEmail(event.target.value)} + required + /> + } + placeholder="Password" + type="password" + autoComplete="new-password" + value={password} + onChange={(event) => setPassword(event.target.value)} + required + minLength={8} + /> +

      + Password: at least 8 characters with uppercase, lowercase, and a digit. +

      + setConsent(event.target.checked)}> + I agree to the{" "} + + privacy policy + + . + + + {error ? : null} + {message ? : null} +

      + Already registered?{" "} + + Login + +

      + + ); +} diff --git a/apps/web/src/modules/auth/hooks/useAuth.ts b/apps/web/src/modules/auth/hooks/useAuth.ts new file mode 100644 index 0000000..8b30ced --- /dev/null +++ b/apps/web/src/modules/auth/hooks/useAuth.ts @@ -0,0 +1,36 @@ +import { useAuthStore } from "../store/authStore"; +import type { AuthUser } from "../store/authStore"; +import { login as apiLogin, logout as apiLogout, refresh as apiRefresh } from "../api/authApi"; +import { fetchDefaultEnterpriseId } from "../api/enterpriseContext"; + +export function useAuth() { + const user = useAuthStore((state) => state.user); + const accessToken = useAuthStore((state) => state.accessToken); + const bootstrapped = useAuthStore((state) => state.bootstrapped); + const setSession = useAuthStore((state) => state.setSession); + const clearSession = useAuthStore((state) => state.clearSession); + + async function bindSession(accessToken: string, authUser: AuthUser) { + const enterpriseId = await fetchDefaultEnterpriseId(accessToken); + setSession(accessToken, authUser, enterpriseId); + return authUser; + } + + return { + user, + isAuthenticated: Boolean(user && accessToken), + bootstrapped, + async login(email: string, password: string) { + const data = await apiLogin({ email, password }); + return bindSession(data.access_token, data.user as AuthUser); + }, + async logout() { + await apiLogout(accessToken); + clearSession(); + }, + async refreshSession() { + const data = await apiRefresh(); + await bindSession(data.access_token, data.user as AuthUser); + } + }; +} diff --git a/apps/web/src/modules/auth/hooks/useIsSuperuser.ts b/apps/web/src/modules/auth/hooks/useIsSuperuser.ts new file mode 100644 index 0000000..ab05357 --- /dev/null +++ b/apps/web/src/modules/auth/hooks/useIsSuperuser.ts @@ -0,0 +1,6 @@ +import { useAuthStore } from "../store/authStore"; + +export function useIsSuperuser(): boolean { + const user = useAuthStore((state) => state.user); + return Boolean(user?.role === "admin" && user?.is_superuser); +} diff --git a/apps/web/src/modules/auth/index.ts b/apps/web/src/modules/auth/index.ts new file mode 100644 index 0000000..40600ad --- /dev/null +++ b/apps/web/src/modules/auth/index.ts @@ -0,0 +1,4 @@ +export { LoginForm } from "./components/LoginForm"; +export { RegisterForm } from "./components/RegisterForm"; +export { useAuth } from "./hooks/useAuth"; +export { useIsSuperuser } from "./hooks/useIsSuperuser"; diff --git a/apps/web/src/modules/auth/store/authSessionHint.test.ts b/apps/web/src/modules/auth/store/authSessionHint.test.ts new file mode 100644 index 0000000..67b0e2f --- /dev/null +++ b/apps/web/src/modules/auth/store/authSessionHint.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + clearAuthSessionHint, + hasAuthSessionHint, + isProtectedAppPath, + markAuthSessionHint, + shouldAttemptAuthRefresh +} from "./authSessionHint"; + +describe("authSessionHint", () => { + afterEach(() => { + sessionStorage.clear(); + }); + + it("tracks session hint in sessionStorage", () => { + expect(hasAuthSessionHint()).toBe(false); + markAuthSessionHint(); + expect(hasAuthSessionHint()).toBe(true); + clearAuthSessionHint(); + expect(hasAuthSessionHint()).toBe(false); + }); + + it("detects protected app paths", () => { + expect(isProtectedAppPath("/platform")).toBe(true); + expect(isProtectedAppPath("/platform/users")).toBe(true); + expect(isProtectedAppPath("/profile")).toBe(true); + expect(isProtectedAppPath("/login")).toBe(false); + }); + + it("attempts refresh on protected paths even without hint", () => { + expect(shouldAttemptAuthRefresh("/platform")).toBe(true); + expect(shouldAttemptAuthRefresh("/login")).toBe(false); + }); + + it("attempts refresh when hint is present", () => { + markAuthSessionHint(); + expect(shouldAttemptAuthRefresh("/login")).toBe(true); + }); +}); diff --git a/apps/web/src/modules/auth/store/authSessionHint.ts b/apps/web/src/modules/auth/store/authSessionHint.ts new file mode 100644 index 0000000..f617f37 --- /dev/null +++ b/apps/web/src/modules/auth/store/authSessionHint.ts @@ -0,0 +1,24 @@ +const AUTH_HINT_KEY = "compton-auth-hint"; +const PROTECTED_PATH_PREFIXES = ["/platform", "/profile"] as const; + +export function markAuthSessionHint(): void { + sessionStorage.setItem(AUTH_HINT_KEY, "1"); +} + +export function clearAuthSessionHint(): void { + sessionStorage.removeItem(AUTH_HINT_KEY); +} + +export function hasAuthSessionHint(): boolean { + return sessionStorage.getItem(AUTH_HINT_KEY) === "1"; +} + +export function isProtectedAppPath(pathname: string): boolean { + return PROTECTED_PATH_PREFIXES.some( + (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`) + ); +} + +export function shouldAttemptAuthRefresh(pathname = window.location.pathname): boolean { + return hasAuthSessionHint() || isProtectedAppPath(pathname); +} diff --git a/apps/web/src/modules/auth/store/authStore.test.ts b/apps/web/src/modules/auth/store/authStore.test.ts new file mode 100644 index 0000000..9547d92 --- /dev/null +++ b/apps/web/src/modules/auth/store/authStore.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { useAuthStore } from "./authStore"; + +describe("authStore", () => { + it("stores and clears session in memory", () => { + useAuthStore.getState().setBootstrapped(true); + useAuthStore.getState().setSession("token", { + id: "1", + email: "u@e.com", + role: "user", + is_superuser: false, + status: "active" + }); + expect(useAuthStore.getState().accessToken).toBe("token"); + useAuthStore.getState().clearSession(); + expect(useAuthStore.getState().accessToken).toBeNull(); + }); +}); diff --git a/apps/web/src/modules/auth/store/authStore.ts b/apps/web/src/modules/auth/store/authStore.ts new file mode 100644 index 0000000..e636d1a --- /dev/null +++ b/apps/web/src/modules/auth/store/authStore.ts @@ -0,0 +1,68 @@ +import { create } from "zustand"; +import { clearAuthSessionHint, markAuthSessionHint } from "@modules/auth/store/authSessionHint"; + +export type UserRole = "user" | "admin"; +export type UserStatus = "active" | "pending" | "blocked"; + +export interface AuthUser { + id: string; + email: string; + role: UserRole; + is_superuser: boolean; + status: UserStatus; +} + +const TOKEN_KEY = "compton.access_token"; +const USER_KEY = "compton.user"; +const ENTERPRISE_KEY = "compton.enterprise_id"; + +function persistWespSession(accessToken: string, user: AuthUser, enterpriseId?: string | null): void { + if (typeof window === "undefined") return; + window.localStorage.setItem(TOKEN_KEY, accessToken); + window.localStorage.setItem(USER_KEY, JSON.stringify(user)); + const resolvedEnterprise = enterpriseId ?? window.localStorage.getItem(ENTERPRISE_KEY) ?? ""; + if (resolvedEnterprise) { + window.localStorage.setItem(ENTERPRISE_KEY, resolvedEnterprise); + } + const w = window as Window & { __WESP_ORCH__?: Record }; + w.__WESP_ORCH__ = Object.assign({}, w.__WESP_ORCH__ || {}, { + accessToken, + enterpriseId: resolvedEnterprise, + userEmail: user.email, + }); +} + +function clearWespSession(): void { + if (typeof window === "undefined") return; + window.localStorage.removeItem(TOKEN_KEY); + window.localStorage.removeItem(USER_KEY); + window.localStorage.removeItem(ENTERPRISE_KEY); +} + +interface AuthState { + accessToken: string | null; + user: AuthUser | null; + bootstrapped: boolean; + setSession: (accessToken: string, user: AuthUser, enterpriseId?: string | null) => void; + clearSession: () => void; + setBootstrapped: (bootstrapped: boolean) => void; +} + +export const useAuthStore = create()((set) => ({ + accessToken: null, + user: null, + bootstrapped: false, + setSession: (accessToken, user, enterpriseId) => { + markAuthSessionHint(); + persistWespSession(accessToken, user, enterpriseId); + set({ accessToken, user }); + }, + clearSession: () => { + clearAuthSessionHint(); + clearWespSession(); + set({ accessToken: null, user: null }); + }, + setBootstrapped: (bootstrapped) => set({ bootstrapped }) +})); + +export { TOKEN_KEY, USER_KEY, ENTERPRISE_KEY }; diff --git a/apps/web/src/modules/auth/utils/formatAuthError.test.ts b/apps/web/src/modules/auth/utils/formatAuthError.test.ts new file mode 100644 index 0000000..36a42f7 --- /dev/null +++ b/apps/web/src/modules/auth/utils/formatAuthError.test.ts @@ -0,0 +1,37 @@ +import { AxiosError, type AxiosResponse } from "axios"; +import { describe, expect, it } from "vitest"; +import { formatAuthError } from "./formatAuthError"; + +describe("formatAuthError", () => { + it("shows network message when API is unreachable", () => { + const message = formatAuthError(new AxiosError("Network Error", "ERR_NETWORK"), "Login failed"); + expect(message).toBe( + "Cannot reach the API. Make sure the backend is running and reload the page." + ); + }); + + it("extracts password validation message", () => { + const message = formatAuthError( + new AxiosError( + "Validation failed", + "422", + undefined, + undefined, + { + status: 422, + data: { + detail: [ + { + loc: ["body", "password"], + msg: "Value error, Password must include at least one digit" + } + ] + } + } as AxiosResponse + ), + "Registration failed" + ); + + expect(message).toBe("Password must include at least one digit"); + }); +}); diff --git a/apps/web/src/modules/auth/utils/formatAuthError.ts b/apps/web/src/modules/auth/utils/formatAuthError.ts new file mode 100644 index 0000000..49cabd3 --- /dev/null +++ b/apps/web/src/modules/auth/utils/formatAuthError.ts @@ -0,0 +1,35 @@ +import { isAxiosError } from "axios"; + +type ValidationDetail = { + loc?: (string | number)[]; + msg?: string; +}; + +function formatValidationDetail(detail: ValidationDetail[]): string { + const passwordError = detail.find((item) => item.loc?.includes("password")); + if (passwordError?.msg) { + return passwordError.msg.replace(/^Value error, /, ""); + } + const emailError = detail.find((item) => item.loc?.includes("email")); + if (emailError?.msg) { + return "Enter a valid email address"; + } + return "Invalid email or password format"; +} + +export function formatAuthError(error: unknown, fallback: string): string { + if (!isAxiosError(error)) { + return fallback; + } + if (!error.response) { + return "Cannot reach the API. Make sure the backend is running and reload the page."; + } + const detail = error.response?.data?.detail; + if (typeof detail === "string") { + return detail; + } + if (Array.isArray(detail) && detail.length > 0) { + return formatValidationDetail(detail as ValidationDetail[]); + } + return fallback; +} diff --git a/apps/web/src/modules/catalog/index.test.ts b/apps/web/src/modules/catalog/index.test.ts new file mode 100644 index 0000000..9b50c98 --- /dev/null +++ b/apps/web/src/modules/catalog/index.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "vitest"; +import { CatalogPlaceholder } from "./index"; + +describe("CatalogPlaceholder", () => { + it("returns stable marker", () => { + expect(CatalogPlaceholder()).toBe("catalog-v1"); + }); +}); diff --git a/apps/web/src/modules/catalog/index.ts b/apps/web/src/modules/catalog/index.ts new file mode 100644 index 0000000..8970360 --- /dev/null +++ b/apps/web/src/modules/catalog/index.ts @@ -0,0 +1,3 @@ +export function CatalogPlaceholder(): string { + return "catalog-v1"; +} diff --git a/apps/web/src/modules/content/api/contentApi.test.ts b/apps/web/src/modules/content/api/contentApi.test.ts new file mode 100644 index 0000000..9de8874 --- /dev/null +++ b/apps/web/src/modules/content/api/contentApi.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createContentPage, + deleteContentPage, + getAdminPages, + getPageBySlug, + updateContentPage +} from "./contentApi"; + +vi.mock("@shared/api/client", () => ({ + apiClient: { + get: vi.fn(async (url: string) => { + if (url === "/api/v1/content/pages/manage/all") { + return { data: { data: [{ id: "1", slug: "about", title: "About", body: "", status: "published" }] } }; + } + return { data: { slug: "about", title: "About" } }; + }), + post: vi.fn(async () => ({ data: { id: "2", slug: "new", title: "New", body: "", status: "draft" } })), + patch: vi.fn(async () => ({ data: { id: "1", slug: "about", title: "Updated", body: "", status: "published" } })), + delete: vi.fn(async () => ({ data: {} })) + } +})); + +describe("contentApi", () => { + it("loads page by slug", async () => { + const data = await getPageBySlug("about"); + expect(data.slug).toBe("about"); + }); + + it("loads admin pages", async () => { + const pages = await getAdminPages(); + expect(pages[0].slug).toBe("about"); + }); + + it("creates content page", async () => { + const page = await createContentPage({ + slug: "new", + title: "New", + body: "

      x

      ", + status: "draft" + }); + expect(page.slug).toBe("new"); + }); + + it("updates content page", async () => { + const page = await updateContentPage("1", { title: "Updated" }); + expect(page.title).toBe("Updated"); + }); + + it("deletes content page", async () => { + await expect(deleteContentPage("1")).resolves.toBeUndefined(); + }); +}); diff --git a/apps/web/src/modules/content/api/contentApi.ts b/apps/web/src/modules/content/api/contentApi.ts new file mode 100644 index 0000000..73aa746 --- /dev/null +++ b/apps/web/src/modules/content/api/contentApi.ts @@ -0,0 +1,41 @@ +import { apiClient } from "@shared/api/client"; + +export interface ContentPage { + id: string; + slug: string; + title: string; + body: string; + status: string; +} + +export async function getPageBySlug(slug: string) { + const { data } = await apiClient.get(`/api/v1/content/pages/${slug}`); + return data; +} + +export async function getAdminPages(): Promise { + const { data } = await apiClient.get<{ data: ContentPage[] }>("/api/v1/content/pages/manage/all"); + return data.data; +} + +export async function createContentPage(payload: { + slug: string; + title: string; + body: string; + status: string; +}): Promise { + const { data } = await apiClient.post("/api/v1/content/pages", payload); + return data; +} + +export async function updateContentPage( + pageId: string, + payload: { title?: string; body?: string; status?: string } +): Promise { + const { data } = await apiClient.patch(`/api/v1/content/pages/${pageId}`, payload); + return data; +} + +export async function deleteContentPage(pageId: string): Promise { + await apiClient.delete(`/api/v1/content/pages/${pageId}`); +} diff --git a/apps/web/src/modules/content/index.ts b/apps/web/src/modules/content/index.ts new file mode 100644 index 0000000..3e22063 --- /dev/null +++ b/apps/web/src/modules/content/index.ts @@ -0,0 +1 @@ +export { getPageBySlug } from "./api/contentApi"; diff --git a/apps/web/src/modules/landing/components/BrandSection.tsx b/apps/web/src/modules/landing/components/BrandSection.tsx new file mode 100644 index 0000000..1d1dc3a --- /dev/null +++ b/apps/web/src/modules/landing/components/BrandSection.tsx @@ -0,0 +1,16 @@ +import { Link } from "react-router-dom"; + +export function BrandSection(): JSX.Element { + return ( +
      +

      About Compton

      +

      + Compton combines organic aesthetics with modern technology for secure accounts, + profile management, and content publishing. +

      +

      + Read more about the brand +

      +
      + ); +} diff --git a/apps/web/src/modules/landing/components/ContactsSection.tsx b/apps/web/src/modules/landing/components/ContactsSection.tsx new file mode 100644 index 0000000..2ca99fb --- /dev/null +++ b/apps/web/src/modules/landing/components/ContactsSection.tsx @@ -0,0 +1,9 @@ +export function ContactsSection(): JSX.Element { + return ( +
      +

      Contacts

      +

      Email: hello@compton.example

      +

      Support hours: Mon–Fri, 10:00–18:00 (UTC+3)

      +
      + ); +} diff --git a/apps/web/src/modules/landing/components/HeroSection.tsx b/apps/web/src/modules/landing/components/HeroSection.tsx new file mode 100644 index 0000000..dab470b --- /dev/null +++ b/apps/web/src/modules/landing/components/HeroSection.tsx @@ -0,0 +1,8 @@ +export function HeroSection(): JSX.Element { + return ( +
      +

      Organic Tech / Compton

      +

      Modern platform with secure accounts and modular architecture.

      +
      + ); +} diff --git a/apps/web/src/modules/landing/components/MarqueeSection.tsx b/apps/web/src/modules/landing/components/MarqueeSection.tsx new file mode 100644 index 0000000..f2a829c --- /dev/null +++ b/apps/web/src/modules/landing/components/MarqueeSection.tsx @@ -0,0 +1,10 @@ +export function MarqueeSection(): JSX.Element { + return ( +
      +
      + Organic Tech — Compton — Organic Tech — Compton — Organic Tech — Compton + +
      +
      + ); +} diff --git a/apps/web/src/modules/landing/index.ts b/apps/web/src/modules/landing/index.ts new file mode 100644 index 0000000..c15b394 --- /dev/null +++ b/apps/web/src/modules/landing/index.ts @@ -0,0 +1,4 @@ +export { HeroSection } from "./components/HeroSection"; +export { MarqueeSection } from "./components/MarqueeSection"; +export { BrandSection } from "./components/BrandSection"; +export { ContactsSection } from "./components/ContactsSection"; diff --git a/apps/web/src/modules/orders/index.test.ts b/apps/web/src/modules/orders/index.test.ts new file mode 100644 index 0000000..362fc55 --- /dev/null +++ b/apps/web/src/modules/orders/index.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "vitest"; +import { OrdersPlaceholder } from "./index"; + +describe("OrdersPlaceholder", () => { + it("returns stable marker", () => { + expect(OrdersPlaceholder()).toBe("orders-v1"); + }); +}); diff --git a/apps/web/src/modules/orders/index.ts b/apps/web/src/modules/orders/index.ts new file mode 100644 index 0000000..2b4b979 --- /dev/null +++ b/apps/web/src/modules/orders/index.ts @@ -0,0 +1,3 @@ +export function OrdersPlaceholder(): string { + return "orders-v1"; +} diff --git a/apps/web/src/modules/profile/api/profileApi.test.ts b/apps/web/src/modules/profile/api/profileApi.test.ts new file mode 100644 index 0000000..ce04219 --- /dev/null +++ b/apps/web/src/modules/profile/api/profileApi.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from "vitest"; +import { changePassword, getProfile, updateProfile, uploadAvatar } from "./profileApi"; + +vi.mock("@shared/api/client", () => ({ + apiClient: { + get: vi.fn(async () => ({ + data: { + user: { email: "u@example.com" }, + profile: { display_name: "User", avatar_url: null } + } + })), + patch: vi.fn(async () => ({ data: { profile: { display_name: "User" } } })), + post: vi.fn(async () => ({ data: { profile: { avatar_url: "/api/v1/media/files/x" } } })) + } +})); + +describe("profileApi", () => { + it("gets profile", async () => { + const data = await getProfile(); + expect(data.user.email).toBe("u@example.com"); + }); + + it("updates profile", async () => { + const data = await updateProfile({ display_name: "User" }); + expect(data.profile.display_name).toBe("User"); + }); + + it("changes password", async () => { + await expect( + changePassword({ current_password: "Valid123", new_password: "NewValid1" }) + ).resolves.toBeUndefined(); + }); + + it("uploads avatar", async () => { + const file = new File(["avatar"], "avatar.png", { type: "image/png" }); + const data = await uploadAvatar(file); + expect(data.profile.avatar_url).toBe("/api/v1/media/files/x"); + }); +}); diff --git a/apps/web/src/modules/profile/api/profileApi.ts b/apps/web/src/modules/profile/api/profileApi.ts new file mode 100644 index 0000000..1484860 --- /dev/null +++ b/apps/web/src/modules/profile/api/profileApi.ts @@ -0,0 +1,40 @@ +import { apiClient } from "@shared/api/client"; + +export interface ProfileResponse { + user: { + id: string; + email: string; + role: string; + status: string; + }; + profile: { + display_name: string; + avatar_url: string | null; + }; +} + +export async function getProfile(): Promise { + const { data } = await apiClient.get("/api/v1/users/me"); + return data; +} + +export async function updateProfile(payload: { display_name?: string }): Promise { + const { data } = await apiClient.patch("/api/v1/users/me", payload); + return data; +} + +export async function changePassword(payload: { + current_password: string; + new_password: string; +}): Promise { + await apiClient.post("/api/v1/users/me/password", payload); +} + +export async function uploadAvatar(file: File): Promise { + const formData = new FormData(); + formData.append("file", file); + const { data } = await apiClient.post("/api/v1/users/me/avatar", formData, { + headers: { "Content-Type": "multipart/form-data" } + }); + return data; +} diff --git a/apps/web/src/modules/profile/components/ProfileCard.test.tsx b/apps/web/src/modules/profile/components/ProfileCard.test.tsx new file mode 100644 index 0000000..9f1bc98 --- /dev/null +++ b/apps/web/src/modules/profile/components/ProfileCard.test.tsx @@ -0,0 +1,18 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { ProfileCard } from "./ProfileCard"; + +describe("ProfileCard", () => { + it("renders profile info and avatar", () => { + render( + + ); + expect(screen.getByText("Compton User")).toBeInTheDocument(); + expect(screen.getByText("user@example.com")).toBeInTheDocument(); + expect(screen.getByRole("img", { name: "Compton User avatar" })).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/modules/profile/components/ProfileCard.tsx b/apps/web/src/modules/profile/components/ProfileCard.tsx new file mode 100644 index 0000000..e0a77e1 --- /dev/null +++ b/apps/web/src/modules/profile/components/ProfileCard.tsx @@ -0,0 +1,29 @@ +import { getApiBaseUrl } from "@shared/api/config"; + +interface ProfileCardProps { + email: string; + displayName: string; + avatarUrl?: string | null; +} + +export function ProfileCard({ email, displayName, avatarUrl }: ProfileCardProps): JSX.Element { + const apiBase = getApiBaseUrl(); + const resolvedAvatar = avatarUrl?.startsWith("/") ? `${apiBase}${avatarUrl}` : avatarUrl; + + return ( +
      +

      Profile

      + {resolvedAvatar ? ( + {`${displayName} + ) : null} +

      {displayName}

      +

      {email}

      +
      + ); +} diff --git a/apps/web/src/modules/profile/components/ProfileEditor.test.tsx b/apps/web/src/modules/profile/components/ProfileEditor.test.tsx new file mode 100644 index 0000000..efa1fdf --- /dev/null +++ b/apps/web/src/modules/profile/components/ProfileEditor.test.tsx @@ -0,0 +1,30 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ProfileEditor } from "./ProfileEditor"; + +vi.mock("../api/profileApi", () => ({ + updateProfile: vi.fn(), + changePassword: vi.fn(), + uploadAvatar: vi.fn() +})); + +const profile = { + user: { id: "1", email: "user@example.com", role: "user", is_superuser: false, status: "active" }, + profile: { display_name: "Compton User", avatar_url: null } +}; + +describe("ProfileEditor", () => { + it("renders profile edit form", () => { + const queryClient = new QueryClient(); + render( + + + + ); + expect(screen.getByRole("heading", { name: "Edit profile" })).toBeInTheDocument(); + expect(screen.getByDisplayValue("Compton User")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Save name" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Change password" })).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/modules/profile/components/ProfileEditor.tsx b/apps/web/src/modules/profile/components/ProfileEditor.tsx new file mode 100644 index 0000000..6bec72d --- /dev/null +++ b/apps/web/src/modules/profile/components/ProfileEditor.tsx @@ -0,0 +1,91 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Button, Input } from "@shared/ui"; +import { changePassword, updateProfile, uploadAvatar, type ProfileResponse } from "../api/profileApi"; + +interface ProfileEditorProps { + profile: ProfileResponse; +} + +export function ProfileEditor({ profile }: ProfileEditorProps): JSX.Element { + const queryClient = useQueryClient(); + const [displayName, setDisplayName] = useState(profile.profile.display_name); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [message, setMessage] = useState(""); + + const updateMutation = useMutation({ + mutationFn: () => updateProfile({ display_name: displayName }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["profile-me"] }); + setMessage("Profile updated"); + } + }); + + const passwordMutation = useMutation({ + mutationFn: () => changePassword({ current_password: currentPassword, new_password: newPassword }), + onSuccess: () => { + setCurrentPassword(""); + setNewPassword(""); + setMessage("Password changed"); + }, + onError: () => setMessage("Invalid current password") + }); + + const avatarMutation = useMutation({ + mutationFn: (file: File) => uploadAvatar(file), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["profile-me"] }); + setMessage("Avatar uploaded"); + }, + onError: () => setMessage("Avatar upload failed") + }); + + return ( +
      +

      Edit profile

      + + + + + +
      +

      Change password

      + setCurrentPassword(event.target.value)} + /> + setNewPassword(event.target.value)} + /> + +
      + + {message ?

      {message}

      : null} +
      + ); +} diff --git a/apps/web/src/modules/profile/index.ts b/apps/web/src/modules/profile/index.ts new file mode 100644 index 0000000..f859038 --- /dev/null +++ b/apps/web/src/modules/profile/index.ts @@ -0,0 +1,3 @@ +export { ProfileCard } from "./components/ProfileCard"; +export { ProfileEditor } from "./components/ProfileEditor"; +export { getProfile, updateProfile, changePassword, uploadAvatar } from "./api/profileApi"; diff --git a/apps/web/src/pages/AdminPage.tsx b/apps/web/src/pages/AdminPage.tsx new file mode 100644 index 0000000..0e4d506 --- /dev/null +++ b/apps/web/src/pages/AdminPage.tsx @@ -0,0 +1,5 @@ +import { AdminPanel } from "@modules/admin/components/AdminPanel"; + +export default function AdminPage(): JSX.Element { + return ; +} diff --git a/apps/web/src/pages/ContentPage.test.tsx b/apps/web/src/pages/ContentPage.test.tsx new file mode 100644 index 0000000..572a1c6 --- /dev/null +++ b/apps/web/src/pages/ContentPage.test.tsx @@ -0,0 +1,44 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ContentPage } from "./ContentPage"; +import * as contentApi from "@modules/content/api/contentApi"; + +function renderPage(slug = "about") { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } } + }); + return render( + + + + } /> + + + + ); +} + +describe("ContentPage", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("renders fetched page title and body", async () => { + vi.spyOn(contentApi, "getPageBySlug").mockResolvedValue({ + id: "1", + slug: "about", + title: "About Us", + body: "

      About content

      ", + status: "published" + }); + + renderPage("about"); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "About Us" })).toBeInTheDocument(); + }); + expect(screen.getByText("About content")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/pages/ContentPage.tsx b/apps/web/src/pages/ContentPage.tsx new file mode 100644 index 0000000..f3b781d --- /dev/null +++ b/apps/web/src/pages/ContentPage.tsx @@ -0,0 +1,40 @@ +import DOMPurify from "dompurify"; +import { useQuery } from "@tanstack/react-query"; +import { useParams } from "react-router-dom"; +import { getPageBySlug } from "@modules/content/api/contentApi"; + +const ALLOWED_TAGS = ["p", "h1", "h2", "h3", "h4", "ul", "ol", "li", "a", "strong", "em", "br", "img"]; + +export function ContentPage(): JSX.Element { + const { slug } = useParams(); + const { data, isLoading, isError } = useQuery({ + queryKey: ["content-page", slug], + queryFn: () => getPageBySlug(slug ?? ""), + enabled: Boolean(slug) + }); + + if (isLoading) { + return ( +
      +

      Loading...

      +
      + ); + } + + if (isError || !data) { + return ( +
      +

      Page not found

      +
      + ); + } + + const safeHtml = DOMPurify.sanitize(data.body, { ALLOWED_TAGS }); + + return ( +
      +

      {data.title}

      +
      +
      + ); +} diff --git a/apps/web/src/pages/EnterpriseCabinetPage.tsx b/apps/web/src/pages/EnterpriseCabinetPage.tsx new file mode 100644 index 0000000..9bbb055 --- /dev/null +++ b/apps/web/src/pages/EnterpriseCabinetPage.tsx @@ -0,0 +1,163 @@ +import { useEffect, useState } from "react"; +import { useParams } from "react-router-dom"; +import { apiClient } from "@shared/api/client"; + +type Farm = { + id: string; + name: string; + hub_site_id: string; + status: string; + last_seen: string | null; +}; + +type Member = { + user_id: string; + role: string; + email: string | null; + farm_ids: string[]; +}; + +type SyncStatus = { + outbox_pending: number; + conflicts_pending: number; + hubs: Array; +}; + +export default function EnterpriseCabinetPage(): JSX.Element { + const { slug } = useParams(); + const [farms, setFarms] = useState([]); + const [members, setMembers] = useState([]); + const [enterpriseId, setEnterpriseId] = useState(""); + const [syncStatus, setSyncStatus] = useState(null); + const [pairCode, setPairCode] = useState(""); + const [pairingBusy, setPairingBusy] = useState(false); + const [inviteEmail, setInviteEmail] = useState(""); + const [inviteRole, setInviteRole] = useState("viewer"); + + useEffect(() => { + void (async () => { + const ents = await apiClient.get>("/api/v1/enterprise/enterprises"); + const ent = ents.find((e) => e.slug === slug); + if (!ent) return; + setEnterpriseId(ent.id); + const [list, status, mems] = await Promise.all([ + apiClient.get(`/api/v1/enterprise/enterprises/${ent.id}/farms`), + apiClient.get(`/api/v1/enterprise/enterprises/${ent.id}/sync-status`), + apiClient.get(`/api/v1/enterprise/enterprises/${ent.id}/members`), + ]); + setFarms(list); + setSyncStatus(status); + setMembers(mems); + })(); + }, [slug]); + + async function startPairing(): Promise { + if (!enterpriseId) return; + setPairingBusy(true); + try { + const resp = await apiClient.post<{ code: string }>( + `/api/v1/enterprise/pair/start`, + { enterprise_id: enterpriseId, farm_name: "New hub" }, + ); + setPairCode(resp.code); + } finally { + setPairingBusy(false); + } + } + + async function inviteMember(): Promise { + if (!enterpriseId || !inviteEmail.trim()) return; + await apiClient.post(`/api/v1/enterprise/enterprises/${enterpriseId}/members`, { + email: inviteEmail.trim(), + role: inviteRole, + }); + const mems = await apiClient.get(`/api/v1/enterprise/enterprises/${enterpriseId}/members`); + setMembers(mems); + setInviteEmail(""); + } + + return ( +
      +

      Enterprise: {slug}

      +

      Pairing, members, farms, sync status. Conflicts — в K-hub «Мультисервер».

      + +
      +

      Pair hub

      + + {pairCode ? ( +

      + Pairing code: {pairCode} (enter on hub admin within 15 min) +

      + ) : null} +
      + +
      +

      Members

      +
        + {members.map((m) => ( +
      • + {m.email ?? m.user_id} — {m.role} + {m.farm_ids.length ? ` · farms: ${m.farm_ids.length}` : ""} +
      • + ))} +
      +
      + setInviteEmail(e.target.value)} + /> + + +
      +
      + +
      +

      Sync status

      + {syncStatus ? ( +
        +
      • Outbox pending: {syncStatus.outbox_pending}
      • +
      • Conflicts pending: {syncStatus.conflicts_pending}
      • + {syncStatus.hubs.map((h) => ( +
      • + {h.name}: lag {h.sync_lag ?? 0} + {h.last_seen ? ` · ${h.last_seen}` : " · offline"} +
      • + ))} +
      + ) : ( +

      Loading…

      + )} +
      + +
      +

      Farms

      +
        + {farms.map((f) => ( +
      • + {f.name} ({f.hub_site_id}) — {f.status} + {f.last_seen ? ` · last seen ${f.last_seen}` : " · offline"} +
      • + ))} +
      +
      + + {enterpriseId ? ( +

      + + Zootech UI (WESP copy) + +

      + ) : null} +
      + ); +} diff --git a/apps/web/src/pages/ForgotPasswordPage.tsx b/apps/web/src/pages/ForgotPasswordPage.tsx new file mode 100644 index 0000000..3980700 --- /dev/null +++ b/apps/web/src/pages/ForgotPasswordPage.tsx @@ -0,0 +1,69 @@ +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { forgotPassword } from "@modules/auth/api/authApi"; +import { formatAuthError } from "@modules/auth/utils/formatAuthError"; +import { AuthLayout } from "@modules/auth/components/AuthLayout"; +import { AuthField } from "@modules/auth/components/AuthField"; +import { AuthMessage } from "@modules/auth/components/AuthMessage"; +import { AuthSubmit } from "@modules/auth/components/AuthSubmit"; + +function UserIcon(): JSX.Element { + return ( + + + + ); +} + +export function ForgotPasswordPage(): JSX.Element { + const [email, setEmail] = useState(""); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + return ( + +
      { + event.preventDefault(); + setMessage(""); + setError(""); + setIsSubmitting(true); + try { + const data = await forgotPassword(email); + setMessage(data.message ?? "If email is registered, reset instructions have been sent."); + } catch (forgotError) { + setError(formatAuthError(forgotError, "Request failed")); + } finally { + setIsSubmitting(false); + } + }} + > + } + placeholder="Email" + type="email" + autoComplete="email" + value={email} + onChange={(event) => setEmail(event.target.value)} + required + /> + + {message ? : null} + {error ? : null} +

      + + Back to login + +

      + +
      + ); +} diff --git a/apps/web/src/pages/HomePage.test.tsx b/apps/web/src/pages/HomePage.test.tsx new file mode 100644 index 0000000..ebaf913 --- /dev/null +++ b/apps/web/src/pages/HomePage.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it } from "vitest"; +import { HomePage } from "./HomePage"; + +describe("HomePage", () => { + it("renders landing sections", () => { + render( + + + + ); + expect(screen.getByRole("heading", { name: "Organic Tech / Compton" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "About Compton" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Contacts" })).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/pages/HomePage.tsx b/apps/web/src/pages/HomePage.tsx new file mode 100644 index 0000000..5847c02 --- /dev/null +++ b/apps/web/src/pages/HomePage.tsx @@ -0,0 +1,12 @@ +import { HeroSection, MarqueeSection, BrandSection, ContactsSection } from "@modules/landing"; + +export function HomePage(): JSX.Element { + return ( +
      + + + + +
      + ); +} diff --git a/apps/web/src/pages/LoginPage.test.tsx b/apps/web/src/pages/LoginPage.test.tsx new file mode 100644 index 0000000..2e56ce0 --- /dev/null +++ b/apps/web/src/pages/LoginPage.test.tsx @@ -0,0 +1,15 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it } from "vitest"; +import { LoginPage } from "./LoginPage"; + +describe("LoginPage", () => { + it("renders login heading", () => { + render( + + + + ); + expect(screen.getByRole("heading", { name: "Вход" })).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/pages/LoginPage.tsx b/apps/web/src/pages/LoginPage.tsx new file mode 100644 index 0000000..e02a61b --- /dev/null +++ b/apps/web/src/pages/LoginPage.tsx @@ -0,0 +1,10 @@ +import { LoginForm } from "@modules/auth"; +import { AuthLayout } from "@modules/auth/components/AuthLayout"; + +export function LoginPage(): JSX.Element { + return ( + + + + ); +} diff --git a/apps/web/src/pages/ProfilePage.tsx b/apps/web/src/pages/ProfilePage.tsx new file mode 100644 index 0000000..c100805 --- /dev/null +++ b/apps/web/src/pages/ProfilePage.tsx @@ -0,0 +1,29 @@ +import { useQuery } from "@tanstack/react-query"; +import { ProfileCard, ProfileEditor } from "@modules/profile"; +import { getProfile } from "@modules/profile/api/profileApi"; + +export default function ProfilePage(): JSX.Element { + const { data, isLoading } = useQuery({ + queryKey: ["profile-me"], + queryFn: getProfile + }); + + if (isLoading || !data) { + return ( +
      +

      Loading profile...

      +
      + ); + } + + return ( +
      + + +
      + ); +} diff --git a/apps/web/src/pages/RegisterPage.test.tsx b/apps/web/src/pages/RegisterPage.test.tsx new file mode 100644 index 0000000..ed26276 --- /dev/null +++ b/apps/web/src/pages/RegisterPage.test.tsx @@ -0,0 +1,15 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it } from "vitest"; +import { RegisterPage } from "./RegisterPage"; + +describe("RegisterPage", () => { + it("renders register heading", () => { + render( + + + + ); + expect(screen.getByRole("heading", { name: "Регистрация" })).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/pages/RegisterPage.tsx b/apps/web/src/pages/RegisterPage.tsx new file mode 100644 index 0000000..b66578f --- /dev/null +++ b/apps/web/src/pages/RegisterPage.tsx @@ -0,0 +1,10 @@ +import { RegisterForm } from "@modules/auth"; +import { AuthLayout } from "@modules/auth/components/AuthLayout"; + +export function RegisterPage(): JSX.Element { + return ( + + + + ); +} diff --git a/apps/web/src/pages/ResetPasswordPage.tsx b/apps/web/src/pages/ResetPasswordPage.tsx new file mode 100644 index 0000000..7002adf --- /dev/null +++ b/apps/web/src/pages/ResetPasswordPage.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import { Link, useNavigate, useSearchParams } from "react-router-dom"; +import { resetPassword } from "@modules/auth/api/authApi"; +import { formatAuthError } from "@modules/auth/utils/formatAuthError"; +import { AuthField } from "@modules/auth/components/AuthField"; +import { AuthLayout } from "@modules/auth/components/AuthLayout"; +import { AuthMessage } from "@modules/auth/components/AuthMessage"; +import { AuthSubmit } from "@modules/auth/components/AuthSubmit"; + +function LockIcon(): JSX.Element { + return ( + + + + ); +} + +export function ResetPasswordPage(): JSX.Element { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const [password, setPassword] = useState(""); + const [message, setMessage] = useState(""); + const [error, setError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const token = searchParams.get("token") ?? ""; + + return ( + +
      { + event.preventDefault(); + setMessage(""); + setError(""); + setIsSubmitting(true); + try { + await resetPassword(token, password); + setMessage("Password updated. You can log in now."); + setTimeout(() => navigate("/login"), 1200); + } catch (resetError) { + setError(formatAuthError(resetError, "Password reset failed")); + } finally { + setIsSubmitting(false); + } + }} + > + {!token ? : null} + } + placeholder="New password" + type="password" + autoComplete="new-password" + value={password} + onChange={(event) => setPassword(event.target.value)} + required + minLength={8} + disabled={!token} + /> +

      + Password: at least 8 characters with uppercase, lowercase, and a digit. +

      + + {message ? : null} + {error ? : null} +

      + + Back to login + +

      + +
      + ); +} diff --git a/apps/web/src/pages/VerifyPage.tsx b/apps/web/src/pages/VerifyPage.tsx new file mode 100644 index 0000000..a0df6d3 --- /dev/null +++ b/apps/web/src/pages/VerifyPage.tsx @@ -0,0 +1,49 @@ +import { useEffect, useState } from "react"; +import { Link, useNavigate, useSearchParams } from "react-router-dom"; +import { verifyEmail } from "@modules/auth/api/authApi"; +import { formatAuthError } from "@modules/auth/utils/formatAuthError"; +import { AuthLayout } from "@modules/auth/components/AuthLayout"; +import { AuthMessage } from "@modules/auth/components/AuthMessage"; + +export function VerifyPage(): JSX.Element { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const [message, setMessage] = useState("Verifying your email..."); + const [error, setError] = useState(""); + + useEffect(() => { + const token = searchParams.get("token"); + if (!token) { + setError("Verification token is missing."); + setMessage(""); + return; + } + + void verifyEmail(token) + .then(() => { + setMessage("Email verified. You can log in now."); + setError(""); + }) + .catch((verifyError) => { + setMessage(""); + setError(formatAuthError(verifyError, "Verification failed")); + }); + }, [searchParams]); + + return ( + +
      + {message ? : null} + {error ? : null} + +

      + + Back home + +

      +
      +
      + ); +} diff --git a/apps/web/src/pages/WespLoginRedirect.tsx b/apps/web/src/pages/WespLoginRedirect.tsx new file mode 100644 index 0000000..05d7200 --- /dev/null +++ b/apps/web/src/pages/WespLoginRedirect.tsx @@ -0,0 +1,9 @@ +import { useEffect } from "react"; + +/** Full-page redirect to WESP static login (orchestrator 1:1). */ +export default function WespLoginRedirect(): null { + useEffect(() => { + window.location.replace("/login"); + }, []); + return null; +} diff --git a/apps/web/src/shared/api/client.test.ts b/apps/web/src/shared/api/client.test.ts new file mode 100644 index 0000000..4fa1d8f --- /dev/null +++ b/apps/web/src/shared/api/client.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { applyAuthHeader, setAccessTokenGetter } from "./client"; + +describe("apiClient auth header", () => { + it("attaches bearer token from getter", () => { + setAccessTokenGetter(() => "test-token"); + const headers = applyAuthHeader({}); + expect(headers.Authorization).toBe("Bearer test-token"); + }); + + it("leaves headers unchanged without token", () => { + setAccessTokenGetter(() => null); + const headers = applyAuthHeader({ "X-Test": "1" }); + expect(headers).toEqual({ "X-Test": "1" }); + }); +}); diff --git a/apps/web/src/shared/api/client.ts b/apps/web/src/shared/api/client.ts new file mode 100644 index 0000000..0393a51 --- /dev/null +++ b/apps/web/src/shared/api/client.ts @@ -0,0 +1,98 @@ +import axios, { type AxiosError, type InternalAxiosRequestConfig } from "axios"; +import { useAuthStore } from "@modules/auth/store/authStore"; +import { getApiBaseUrl } from "./config"; + +const apiBaseUrl = getApiBaseUrl(); + +export const apiClient = axios.create({ + baseURL: apiBaseUrl, + timeout: 10_000 +}); + +export const authClient = axios.create({ + baseURL: apiBaseUrl, + timeout: 10_000, + withCredentials: true +}); +let getAccessToken: () => string | null = () => null; +let refreshPromise: Promise | null = null; + +export function setAccessTokenGetter(getter: () => string | null): void { + getAccessToken = getter; +} + +export function applyAuthHeader(headers: Record): Record { + const token = getAccessToken(); + if (token) { + return { ...headers, Authorization: `Bearer ${token}` }; + } + return headers; +} + +export async function bootstrapSessionRefresh(): Promise { + return refreshAccessToken(); +} + +async function refreshAccessToken(): Promise { + if (!refreshPromise) { + refreshPromise = authClient + .post("/api/v1/auth/refresh") + .then((response) => { + const { access_token: accessToken, user } = response.data; + useAuthStore.getState().setSession(accessToken, user); + return accessToken as string; + }) + .catch(() => { + useAuthStore.getState().clearSession(); + return null; + }) + .finally(() => { + refreshPromise = null; + }); + } + return refreshPromise; +} + +apiClient.interceptors.request.use((config) => { + config.headers = applyAuthHeader(config.headers as Record) as typeof config.headers; + return config; +}); + +apiClient.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const detail = + error.response && typeof error.response.data === "object" && error.response.data !== null + ? (error.response.data as { detail?: string }).detail + : undefined; + if ( + error.response?.status === 403 && + (detail === "ACCOUNT_BLOCKED" || detail === "EMAIL_NOT_VERIFIED") + ) { + useAuthStore.getState().clearSession(); + return Promise.reject(error); + } + if (error.response?.status === 401 && detail === "TOKEN_REVOKED") { + useAuthStore.getState().clearSession(); + return Promise.reject(error); + } + const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; + if (error.response?.status !== 401 || !originalRequest || originalRequest._retry) { + return Promise.reject(error); + } + if (originalRequest.url?.includes("/api/v1/auth/")) { + return Promise.reject(error); + } + + originalRequest._retry = true; + const accessToken = await refreshAccessToken(); + if (!accessToken) { + return Promise.reject(error); + } + + originalRequest.headers = applyAuthHeader( + originalRequest.headers as Record + ) as typeof originalRequest.headers; + return apiClient.request(originalRequest); + } +); diff --git a/apps/web/src/shared/api/config.ts b/apps/web/src/shared/api/config.ts new file mode 100644 index 0000000..8fa9e7e --- /dev/null +++ b/apps/web/src/shared/api/config.ts @@ -0,0 +1,9 @@ +export function getApiBaseUrl(): string { + if (import.meta.env.VITE_USE_API_PROXY === "false") { + return import.meta.env.VITE_API_URL ?? "http://localhost:8000"; + } + if (import.meta.env.DEV || import.meta.env.VITE_USE_API_PROXY === "true") { + return ""; + } + return import.meta.env.VITE_API_URL ?? "http://localhost:8000"; +} diff --git a/apps/web/src/shared/theme/zootech/ThemeProvider.tsx b/apps/web/src/shared/theme/zootech/ThemeProvider.tsx new file mode 100644 index 0000000..1df4eb0 --- /dev/null +++ b/apps/web/src/shared/theme/zootech/ThemeProvider.tsx @@ -0,0 +1,19 @@ +import { useEffect, type PropsWithChildren } from "react"; + +export function ZootechThemeProvider({ children }: PropsWithChildren): JSX.Element { + useEffect(() => { + const root = document.documentElement; + const previousTheme = root.getAttribute("data-theme"); + root.setAttribute("data-theme", "organic"); + + return () => { + if (previousTheme) { + root.setAttribute("data-theme", previousTheme); + return; + } + root.removeAttribute("data-theme"); + }; + }, []); + + return <>{children}; +} diff --git a/apps/web/src/shared/theme/zootech/auth-login.css b/apps/web/src/shared/theme/zootech/auth-login.css new file mode 100644 index 0000000..a2cb6b2 --- /dev/null +++ b/apps/web/src/shared/theme/zootech/auth-login.css @@ -0,0 +1,113 @@ +@import "../../../../main/css/tokens.css"; + +.z-login-shell { + min-height: 100vh; + margin: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 24px 16px; + background: var(--bg-page); + color: var(--text); + font-family: var(--zootech-font); +} + +.z-login-page { + width: 100%; + max-width: 420px; +} + +.z-login-card { + background: var(--surface); + border: 1px solid var(--border-light); + border-radius: var(--zt-radius-lg); + box-shadow: var(--shadow-soft); + padding: 24px; +} + +.z-login-title { + margin: 0 0 16px; + font-size: 1.5rem; + font-weight: 600; +} + +.z-login-form { + display: grid; + gap: 12px; +} + +.z-login-field { + position: relative; +} + +.z-login-field-icon { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + color: var(--muted); + display: inline-flex; +} + +.z-login-input { + width: 100%; + border: 1px solid var(--border-light); + border-radius: 10px; + background: var(--primary-tint); + color: var(--text); + padding: 12px 12px 12px 40px; + font-size: 0.95rem; +} + +.z-login-submit { + border: 0; + border-radius: 999px; + background: var(--primary); + color: #fff; + font-size: 0.95rem; + font-weight: 600; + padding: 12px 14px; + cursor: pointer; +} + +.z-login-submit:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.z-login-meta, +.z-login-msg { + margin: 0; + font-size: 0.9rem; +} + +.z-login-meta { + color: var(--muted); +} + +.z-login-msg--error { + color: var(--color-error); +} + +.z-login-msg--success { + color: var(--color-success); +} + +.z-login-remember { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 0.9rem; + color: var(--muted); +} + +.z-login-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.z-login-link { + color: inherit; +} diff --git a/apps/web/src/shared/ui/AppHeader/AppHeader.tsx b/apps/web/src/shared/ui/AppHeader/AppHeader.tsx new file mode 100644 index 0000000..7e08b41 --- /dev/null +++ b/apps/web/src/shared/ui/AppHeader/AppHeader.tsx @@ -0,0 +1,53 @@ +import { Link, useNavigate } from "react-router-dom"; +import { useAuth } from "@modules/auth"; +import { Button } from "@shared/ui"; + +export function AppHeader(): JSX.Element { + const auth = useAuth(); + const navigate = useNavigate(); + + return ( +
      + + {auth.isAuthenticated ? ( + + ) : null} +
      + ); +} diff --git a/apps/web/src/shared/ui/Button/Button.test.tsx b/apps/web/src/shared/ui/Button/Button.test.tsx new file mode 100644 index 0000000..42c5d95 --- /dev/null +++ b/apps/web/src/shared/ui/Button/Button.test.tsx @@ -0,0 +1,10 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Button } from "./Button"; + +describe("Button", () => { + it("renders button text", () => { + render(); + expect(screen.getByRole("button", { name: "Click" })).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/shared/ui/Button/Button.tsx b/apps/web/src/shared/ui/Button/Button.tsx new file mode 100644 index 0000000..341c94a --- /dev/null +++ b/apps/web/src/shared/ui/Button/Button.tsx @@ -0,0 +1,42 @@ +import type { ButtonHTMLAttributes, CSSProperties, PropsWithChildren } from "react"; + +type Variant = "primary" | "secondary" | "ghost"; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: Variant; +} + +const variantStyles: Record = { + primary: "background: var(--primary); color: white; border: 0;", + secondary: "background: var(--marquee-bg); color: var(--foreground); border: 0;", + ghost: "background: transparent; color: var(--foreground); border: 1px solid var(--muted);" +}; + +export function Button({ + children, + variant = "primary", + ...props +}: PropsWithChildren): JSX.Element { + return ( + + ); +} diff --git a/apps/web/src/shared/ui/Input/Input.tsx b/apps/web/src/shared/ui/Input/Input.tsx new file mode 100644 index 0000000..b030ccb --- /dev/null +++ b/apps/web/src/shared/ui/Input/Input.tsx @@ -0,0 +1,15 @@ +import type { InputHTMLAttributes } from "react"; + +export function Input(props: InputHTMLAttributes): JSX.Element { + return ( + + ); +} diff --git a/apps/web/src/shared/ui/index.ts b/apps/web/src/shared/ui/index.ts new file mode 100644 index 0000000..33d5391 --- /dev/null +++ b/apps/web/src/shared/ui/index.ts @@ -0,0 +1,3 @@ +export { Button } from "./Button/Button"; +export { Input } from "./Input/Input"; +export { AppHeader } from "./AppHeader/AppHeader"; diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/apps/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..9170d4f --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "paths": { + "@app/*": ["./src/app/*"], + "@pages/*": ["./src/pages/*"], + "@modules/*": ["./src/modules/*"], + "@shared/*": ["./src/shared/*"] + }, + "skipLibCheck": true + }, + "include": ["src", "e2e", "src/vite-env.d.ts", "src/global.d.ts"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..0d668be --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,74 @@ +/// +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; +import { resolve } from "node:path"; +import { mainStaticPlugin } from "./vite.main-static"; +import { wespStaticPlugin } from "./vite.wesp-static"; + +const apiProxyTarget = process.env.VITE_API_URL ?? "http://127.0.0.1:8000"; + +export default defineConfig({ + plugins: [ + wespStaticPlugin(resolve(__dirname, "public/static")), + react(), + mainStaticPlugin(resolve(__dirname, "main")) + ], + build: { + rollupOptions: { + input: { + index: resolve(__dirname, "index.html"), + app: resolve(__dirname, "app.html") + } + } + }, + server: { + port: 5173, + strictPort: true, + proxy: { + "/api": { + target: apiProxyTarget, + changeOrigin: true, + secure: false + } + } + }, + resolve: { + alias: { + "@app": resolve(__dirname, "src/app"), + "@pages": resolve(__dirname, "src/pages"), + "@modules": resolve(__dirname, "src/modules"), + "@shared": resolve(__dirname, "src/shared") + } + }, + test: { + include: ["src/**/*.{test,spec}.{ts,tsx}"], + exclude: ["e2e/**", "node_modules/**"], + environment: "jsdom", + setupFiles: ["src/__tests__/setup.ts"], + testTimeout: 10_000, + coverage: { + provider: "v8", + include: ["src/**/*.{ts,tsx}"], + exclude: [ + "src/**/*.d.ts", + "src/modules/**/index.ts", + "src/main.tsx", + "src/app/App.tsx", + "src/app/providers/**", + "src/app/router/guards/**", + "src/app/router/routes.tsx", + "src/pages/AdminPage.tsx", + "src/pages/ProfilePage.tsx", + "src/pages/VerifyPage.tsx", + "src/pages/ResetPasswordPage.tsx", + "src/pages/ForgotPasswordPage.tsx", + "src/shared/ui/AppHeader/**", + "src/modules/analytics/**" + ], + reporter: ["text", "html"], + thresholds: { + lines: 85 + } + } + } +}); diff --git a/apps/web/vite.main-static.ts b/apps/web/vite.main-static.ts new file mode 100644 index 0000000..1002901 --- /dev/null +++ b/apps/web/vite.main-static.ts @@ -0,0 +1,86 @@ +import type { Connect, Plugin } from "vite"; +import { cpSync, createReadStream, existsSync, statSync } from "node:fs"; +import { extname, resolve } from "node:path"; + +const MIME_TYPES: Record = { + ".css": "text/css", + ".js": "application/javascript", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".mp4": "video/mp4", + ".ttf": "font/ttf", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".html": "text/html" +}; + +const APP_ROUTE_PREFIXES = [ + "/register", + "/profile", + "/platform", + "/verify", + "/reset-password", + "/forgot-password", + "/pages", + "/enterprise" +]; + +function isAppRoute(url: string): boolean { + const path = url.split("?")[0]; + return APP_ROUTE_PREFIXES.some((prefix) => path === prefix || path.startsWith(`${prefix}/`)); +} + +function createMainStaticMiddleware(mainDir: string): Connect.NextHandleFunction { + const root = resolve(mainDir); + + return (req, res, next) => { + const urlPath = decodeURIComponent((req.url ?? "").split("?")[0]); + if (!urlPath || urlPath === "/") { + next(); + return; + } + + const relativePath = urlPath.replace(/^\/+/, ""); + const filePath = resolve(root, relativePath); + + if (!filePath.startsWith(root) || !existsSync(filePath) || !statSync(filePath).isFile()) { + next(); + return; + } + + const ext = extname(filePath).toLowerCase(); + res.setHeader("Content-Type", MIME_TYPES[ext] ?? "application/octet-stream"); + createReadStream(filePath).pipe(res); + }; +} + +function createAppFallbackMiddleware(): Connect.NextHandleFunction { + return (req, _res, next) => { + const url = req.url ?? ""; + if (isAppRoute(url)) { + req.url = "/app.html"; + } + next(); + }; +} + +export function mainStaticPlugin(mainDir: string): Plugin { + const resolvedMainDir = resolve(mainDir); + + return { + name: "kompton-main-static", + configureServer(server) { + server.middlewares.use("/main", createMainStaticMiddleware(resolvedMainDir)); + server.middlewares.use(createAppFallbackMiddleware()); + }, + configurePreviewServer(server) { + server.middlewares.use("/main", createMainStaticMiddleware(resolvedMainDir)); + server.middlewares.use(createAppFallbackMiddleware()); + }, + closeBundle() { + cpSync(resolvedMainDir, resolve(__dirname, "dist/main"), { recursive: true }); + } + }; +} diff --git a/apps/web/vite.wesp-static.ts b/apps/web/vite.wesp-static.ts new file mode 100644 index 0000000..00f8162 --- /dev/null +++ b/apps/web/vite.wesp-static.ts @@ -0,0 +1,110 @@ +import type { Connect, Plugin } from "vite"; +import { createReadStream, existsSync, readFileSync, statSync } from "node:fs"; +import { extname, resolve } from "node:path"; + +const MIME_TYPES: Record = { + ".css": "text/css", + ".js": "application/javascript", + ".html": "text/html", + ".png": "image/png", + ".jpg": "image/jpeg", + ".svg": "image/svg+xml", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".json": "application/json", +}; + +/** WESP hub page routes → static HTML (same as wesp/app/routes/pages.py). */ +const WESP_PAGE_ROUTES: Record = { + "/login": "login.html", + "/recipes": "recipes.html", + "/recipes_content": "recipes.html", + "/components": "components.html", + "/feed_dispensers": "feed_dispensers.html", + "/reports": "reports.html", + "/feed_consumption": "consumption.html", + "/consumption": "consumption.html", + "/lab": "lab.html", + "/lab-profiles": "lab-profiles.html", + "/admin": "admin.html", +}; + +function injectOrchestratorScripts(html: string, urlPath: string): string { + const scripts = + urlPath === "/login" + ? [ + '', + '', + ] + : [ + '', + '', + ]; + const block = scripts.join("\n"); + if (html.includes("wesp-orchestrator-auth-bridge.js")) { + return html; + } + if (html.includes("")) { + return html.replace("", `${block}\n`); + } + return `${block}\n${html}`; +} + +function sendFile(res: Connect.ServerResponse, filePath: string, transform?: (body: string) => string): void { + const ext = extname(filePath).toLowerCase(); + if (transform && ext === ".html") { + const body = transform(readFileSync(filePath, "utf-8")); + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(body); + return; + } + res.setHeader("Content-Type", MIME_TYPES[ext] ?? "application/octet-stream"); + createReadStream(filePath).pipe(res); +} + +export function wespStaticPlugin(staticRoot: string): Plugin { + const root = resolve(staticRoot); + + return { + name: "kompton-wesp-static", + configureServer(server) { + server.middlewares.use(createWespMiddleware(root)); + }, + configurePreviewServer(server) { + server.middlewares.use(createWespMiddleware(root)); + }, + }; +} + +function createWespMiddleware(root: string): Connect.NextHandleFunction { + return (req, res, next) => { + const urlPath = decodeURIComponent((req.url ?? "").split("?")[0]); + if (!urlPath || urlPath === "/") { + next(); + return; + } + + const pageFile = WESP_PAGE_ROUTES[urlPath]; + if (pageFile) { + const htmlPath = resolve(root, pageFile); + if (existsSync(htmlPath) && statSync(htmlPath).isFile()) { + sendFile(res, htmlPath, (body) => injectOrchestratorScripts(body, urlPath)); + return; + } + } + + if (urlPath.startsWith("/static/")) { + const relative = urlPath.replace(/^\/static\//, ""); + const filePath = resolve(root, relative); + if (filePath.startsWith(root) && existsSync(filePath) && statSync(filePath).isFile()) { + sendFile(res, filePath); + return; + } + } + + next(); + }; +} + +export { WESP_PAGE_ROUTES }; diff --git a/data/security/password-denylist.txt b/data/security/password-denylist.txt new file mode 100644 index 0000000..2b16360 --- /dev/null +++ b/data/security/password-denylist.txt @@ -0,0 +1,30 @@ +password +password1 +password123 +123456 +12345678 +123456789 +qwerty +qwerty123 +admin +admin123 +admin1234 +welcome +welcome1 +letmein +letmein1 +iloveyou +iloveyou1 +sunshine +sunshine1 +football +football1 +baseball +baseball1 +dragon +dragon123 +monkey +monkey123 +passw0rd +passw0rd1 +trustno1 diff --git a/docker-compose.dev-ports.yml b/docker-compose.dev-ports.yml new file mode 100644 index 0000000..bd0f017 --- /dev/null +++ b/docker-compose.dev-ports.yml @@ -0,0 +1,13 @@ +services: + postgres: + ports: + - "5432:5432" + + redis: + ports: + - "6379:6379" + + minio: + ports: + - "9000:9000" + - "9001:9001" diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..19513b6 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,33 @@ +services: + postgres: + image: postgres:16 + environment: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: compton_test + ports: + - "5433:5432" + + redis: + image: redis:7-alpine + ports: + - "6380:6379" + + api: + build: ./apps/api + env_file: + - ./apps/api/.env.test + environment: + DATABASE_URL: postgresql+psycopg://test:test@postgres:5432/compton_test + depends_on: + - postgres + - redis + ports: + - "8001:8000" + + web: + build: ./apps/web + depends_on: + - api + ports: + - "5175:5173" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d14d500 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,63 @@ +services: + web: + profiles: ["docker-web"] + build: + context: . + dockerfile: apps/web/Dockerfile + ports: + - "5173:5173" + environment: + VITE_USE_API_PROXY: "true" + VITE_API_URL: http://api:8000 + # File-watcher polling for bind-mounted sources on Windows/macOS + CHOKIDAR_USEPOLLING: "true" + WATCHPACK_POLLING: "true" + volumes: + # Live-reload: mount the whole monorepo so workspace packages stay in sync + - .:/app + # Anonymous volumes: keep container-installed node_modules from being + # overwritten by the host (empty dir or different OS/arch binaries) + - /app/node_modules + - /app/apps/web/node_modules + depends_on: + - api + - postgres + - redis + - minio + + api: + build: ./apps/api + ports: + - "8000:8000" + environment: + S3_ENDPOINT: http://minio:9000 + STORAGE_MODE: s3 + REDIS_URL: redis://redis:6379/0 + CORS_ORIGINS: '["http://localhost:5173","http://127.0.0.1:5173"]' + FRONTEND_URL: http://localhost:5173 + PUBLIC_BASE_URL: http://localhost:5173 + COOKIE_SECURE: "false" + EMAIL_DELIVERY_MODE: memory + ENABLE_RATE_LIMIT: "true" + env_file: + - ./apps/api/.env.example + - ./apps/api/data/secrets/install.env + volumes: + - ./apps/api/data/secrets:/app/data/secrets + depends_on: + - postgres + - redis + - minio + + postgres: + image: postgres:16 + env_file: + - ./apps/api/data/secrets/install.env + redis: + image: redis:7-alpine + + minio: + image: minio/minio + command: server /data --console-address ":9001" + env_file: + - ./apps/api/data/secrets/install.env diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..e5f98b8 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,41 @@ +# Документация Compton + +README в корне — только «как запустить за 30 секунд». Всё остальное — здесь. + +Мы сознательно держим **мало файлов**, но каждый — **плотный**: таблицы, схемы, команды. Можно с лёгким юмором, но без простыней на 500 строк. + +## Карта + +| Документ | Когда открывать | +|----------|-----------------| +| [project.md](./project.md) | «Где что лежит?», архитектура, API, маршруты, env | +| [deploy.md](./deploy.md) | Запуск, **стандартные логины dev**, staging/prod, troubleshooting | +| [security.md](./security.md) | Auth, JWT revoke, секреты, nginx, чеклист prod | +| [release.md](./release.md) | Перед выкладкой: E2E, k6, ZAP, Lighthouse, smoke | +| [TZ.md](./TZ.md) | Полное ТЗ — источник правды по требованиям | + +## Быстрые ссылки + +```bash +# dev +python apps/api/scripts/bootstrap_install.py +docker compose --profile docker-web up -d --build + +# тесты +pnpm --filter web test:ci +cd apps/api && python -m pytest --cov=app --cov-fail-under=90 + +# prod smoke (после деплоя) +./infra/scripts/smoke-prod.sh https://your-domain.com +``` + +## Что куда не кладём + +| Не в git | Почему | +|----------|--------| +| `apps/api/data/secrets/install.env` | пароли БД, JWT, MinIO | +| `.env`, `apps/api/.env` | локальные секреты | +| `apps/api/data/logs/` | runtime-логи | +| `node_modules/`, `.venv/` | очевидно | + +Если секрет утёк в git — считайте его скомпрометированным. Force-push не спасает совесть. diff --git a/docs/TZ.md b/docs/TZ.md new file mode 100644 index 0000000..5260ce1 --- /dev/null +++ b/docs/TZ.md @@ -0,0 +1,1510 @@ +# Техническое задание +## Платформа «Комптон» — React-приложение с модульной архитектурой +### Испольнителю следовать плану реализации с особым пристрастрием + +**Версия документа:** 1.0 +**Дата:** 09.07.2026 +**Разработчик:** Huli + +--- + +## Содержание + +1. [Общие сведения](#1-общие-сведения) +2. [Цели и границы проекта](#2-цели-и-границы-проекта) +3. [Профиль нагрузки и масштабирование](#3-профиль-нагрузки-и-масштабирование) +4. [Технологический стек](#4-технологический-стек) +5. [Архитектура системы](#5-архитектура-системы) +6. [Модульная структура Frontend (React)](#6-модульная-структура-frontend-react) +7. [Модульная структура Backend (API)](#7-модульная-структура-backend-api) +8. [Модель данных](#8-модель-данных) +9. [API-контракты](#9-api-контракты) +10. [Аутентификация и авторизация](#10-аутентификация-и-авторизация) +11. [UI/UX и дизайн-система](#11-uiux-и-дизайн-система) +12. [Нефункциональные требования](#12-нефункциональные-требования) +13. [Инфраструктура и DevOps](#13-инфраструктура-и-devops) +14. [Безопасность](#14-безопасность) +15. [Тестирование и качество](#15-тестирование-и-качество) +16. [Этапы разработки](#16-этапы-разработки) +17. [Критерии приёмки](#17-критерии-приёмки) +18. [Риски и допущения](#18-риски-и-допущения) + +--- + +## 1. Общие сведения + +### 1.1. Наименование проекта + +**Комптон®** — веб-платформа бренда «Organic Tech» с публичной витриной, личным кабинетом пользователя и административной панелью. + +### 1.2. Текущее состояние + +- Чисто поржать на ошибках и нейронке +- Заглушка на Flask (`main.py`) с одностраничным лендингом. +- Визуальная идентичность задана не полностью: цвета, шрифты (Inter, JetBrains Mono), hero-блок, бегущая строка. +- Рефакторинг текщего состояния не возможен, задача переписать проект. +- Функциональность отсутствует: нет регистрации, контента, API, БД. + + +### 1.3. Заказчик / продукт + +Еблан который зовет себя Huli. Документ описывает целевую архитектуру для замены заглушки полноценным продуктом. + +### 1.4. Термины + +| Термин | Определение | +|--------|-------------| +| **Модуль (feature)** | Изолированный функциональный блок с собственными компонентами, API-слоем, типами и тестами | +| **Shared** | Переиспользуемый код без бизнес-логики конкретного модуля | +| **Core** | Ядро приложения: роутинг, провайдеры, конфигурация, HTTP-клиент | +| **MAU** | Monthly Active Users — уникальные пользователи за месяц | +| **CCU** | Concurrent Users — одновременно онлайн | + +--- + +## 2. Цели и границы проекта + +### 2.1. Бизнес-цели + +1. Заменить заглушку работающим сайтом с сохранением бренда. +2. Обеспечить регистрацию и работу **до 1 000 зарегистрированных пользователей** (~100–150 DAU) без деградации UX. +3. Заложить архитектуру, позволяющую масштабироваться до **10 000+ пользователей** без переписывания модулей. +4. Обеспечить независимую разработку модулей разными разработчиками. +5. **Каждый шаг реализации** (задача / PR / модуль) поставляется **только с полным набором тестов**: unit/integration логики + E2E пользовательских сценариев (§15). + +### 2.2. Функциональный scope (MVP → v1.0) + +#### MVP (фаза 1) + +| Модуль | Функции | +|--------|---------| +| **Landing** | Hero, бегущая строка, «О бренде», контакты, SEO-мета | +| **Auth** | Регистрация, вход, выход, восстановление пароля, подтверждение email *(требует SMTP в MVP — см. §16)* | +| **Profile** | Просмотр/редактирование профиля, аватар, смена пароля | +| **Content** | Статические страницы (О нас, Политика, Условия) из CMS или markdown | +| **Admin** | Управление пользователями, контентом, просмотр метрик | + +#### v1.0 (фаза 2, опционально) + +| Модуль | Функции | +|--------|---------| +| **Catalog** | Каталог продуктов/услуг, карточки, фильтры | +| **Orders** | Корзина, оформление заявки (без оплаты — см. §2.3), история | +| **Notifications** | In-app уведомления; email-рассылки через очередь (Celery) | +| **Analytics** | Дашборд событий, экспорт | + +### 2.3. Out of scope (не входит в v1.0) + +- Мобильное нативное приложение (только responsive web). +- Мультиязычность (заложить i18n-структуру, реализовать позже). +- Платёжные шлюзы (интеграция — отдельный этап). Модуль Orders в v1.0 работает как **заявка/бронирование без оплаты**. +- Real-time чат / WebRTC. + +--- + +## 3. Профиль нагрузки и масштабирование + +### 3.1. Целевые метрики (1000 пользователей) + +| Метрика | Значение | Комментарий | +|---------|----------|-------------| +| Зарегистрированных пользователей | 1 000 | Целевой объём на старте | +| DAU | 100–150 (10–15%) | Типичный коэффициент для B2C | +| CCU (пик) | 30–50 | Одновременные сессии | +| RPS API (пик) | 20–50 req/s | С запасом ×3 | +| Размер БД | < 5 GB | Профили, контент, логи | +| Медиа | < 50 GB | CDN для статики | + +### 3.2. Путь масштабирования + +```mermaid +flowchart LR + subgraph phase1 [Фаза 1: до 1K] + A1[Monolith API] + A2[PostgreSQL] + A3[Redis cache] + A4[CDN static] + end + + subgraph phase2 [Фаза 2: 1K–10K] + B1[2× API instances] + B2[Read replica PG] + B3[Redis cluster] + B4[Object storage S3] + end + + subgraph phase3 [Фаза 3: 10K+] + C1[Horizontal API scale] + C2[Extract heavy modules] + C3[Message queue] + C4[Separate admin] + end + + phase1 --> phase2 --> phase3 +``` + +**Принцип:** на 1000 пользователей достаточно **модульного монолита** (один backend-процесс, чёткие границы модулей). Микросервисы не нужны до 10K+ и только для узких «тяжёлых» модулей (уведомления, аналитика). + +### 3.3. SLA (целевые) + +| Параметр | MVP | v1.0 | +|----------|-----|------| +| Uptime | 99.5% | 99.9% | +| TTFB публичных страниц | < 500 ms | < 300 ms | +| API p95 latency | < 300 ms | < 200 ms | +| LCP (Lighthouse mobile) | < 2.5 s | < 2.0 s | + +--- + +## 4. Технологический стек + +### 4.1. Frontend + +| Слой | Технология | Обоснование | +|------|------------|-------------| +| Framework | **React 19** + **TypeScript 5** | Экосистема, типизация | +| Bundler | **Vite 6** | Быстрая сборка, HMR | +| Routing | **React Router 7** | Стандарт de facto | +| Server state | **TanStack Query 5** | Кеш, retry, invalidation | +| Client state | **Zustand** | Лёгкий, без boilerplate | +| Forms | **React Hook Form** + **Zod** | Валидация, DX | +| UI | **Tailwind CSS 4** + headless (Radix UI) | Соответствие дизайн-системе | +| HTTP | **Axios** или fetch-обёртка | Interceptors, типизация | +| i18n (заготовка) | **react-i18next** | Структура без реализации | +| Tests | **Vitest** + **Testing Library** + **Playwright** + **pytest** | Unit + integration + E2E на каждом PR (§15) | + +### 4.2. Backend + +| Слой | Технология | Обоснование | +|------|------------|-------------| +| Runtime | **Python 3.12** | Преемственность Flask-проекта | +| Framework | **FastAPI** | Async, OpenAPI, производительность | +| ORM | **SQLAlchemy 2** + **Alembic** | Миграции, типизация | +| Validation | **Pydantic v2** | Согласованность с OpenAPI | +| Auth | **JWT** (access + refresh rotation) + **passlib/bcrypt** | Stateless access, revocable refresh | +| Task queue (v1.0) | **Celery** + Redis | Email, фоновые задачи | +| Email | **SMTP** / SendGrid / Resend | MVP: синхронная отправка verify/reset; v1.0: Celery queue | + +### 4.3. Data & Infra + +| Компонент | Технология | +|-----------|------------| +| Primary DB | **PostgreSQL 16** | +| Cache / sessions | **Redis 7** | +| File storage | **S3-compatible** (MinIO dev / Yandex S3 / AWS prod) | +| Reverse proxy | **Nginx** | +| Containerization | **Docker** + **Docker Compose** (dev/staging) | +| CI/CD | **GitHub Actions** | +| Monitoring | **Prometheus** + **Grafana** (или managed: Datadog) | +| Logging | Structured JSON → **Loki** или cloud logs | +| Error tracking | **Sentry** | + +### 4.4. Архитектурный стиль Frontend + +**Feature-Sliced Design (FSD)** — адаптированная версия с явными модулями: + +``` +app → pages → modules → shared +``` + +- **app** — инициализация, провайдеры, глобальный роутер. +- **pages** — композиция модулей в маршруты (тонкий слой). +- **modules** — бизнес-фичи (auth, profile, catalog…). +- **shared** — UI-kit, utils, API client, types без доменной логики. + +--- + +## 5. Архитектура системы + +### 5.1. Общая схема + +```mermaid +flowchart TB + subgraph client [Клиент] + Browser[Browser / Mobile Web] + SPA[React SPA] + end + + subgraph edge [Edge] + CDN[CDN — static assets] + Nginx[Nginx — TLS, gzip, rate limit] + end + + subgraph backend [Backend] + API[FastAPI Monolith] + subgraph modules_api [Modules] + M1[auth] + M2[users] + M3[content] + M4[admin] + end + end + + subgraph data [Data Layer] + PG[(PostgreSQL)] + Redis[(Redis)] + S3[(Object Storage)] + end + + Browser --> CDN + Browser --> Nginx + Nginx -->|static| SPA + SPA -->|REST JSON /api| Nginx + Nginx --> API + API --> modules_api + modules_api --> PG + modules_api --> Redis + modules_api --> S3 +``` + +### 5.2. Принципы модульности + +1. **Вертикальные срезы** — каждый модуль владеет UI + API + схемой БД (таблицы через общий ORM, но namespace по модулю). +2. **Запрет cross-import между modules** — общение только через: + - публичный API модуля (`modules/auth/api/index.ts`); + - shared-слой; + - backend REST/events. +3. **Публичный контракт модуля** — `index.ts` экспортирует только то, что нужно снаружи. +4. **Приватная реализация** — `internal/` не импортируется из других модулей. + +### 5.3. Репозиторий (monorepo-lite) + +``` +compton/ +├── apps/ +│ ├── web/ # React SPA +│ └── api/ # FastAPI +├── packages/ +│ ├── shared-types/ # OpenAPI-generated TS types +│ └── eslint-config/ # Общие lint rules +├── infra/ +│ ├── docker/ +│ ├── nginx/ +│ └── terraform/ # опционально +├── docs/ +│ └── TZ.md +└── docker-compose.yml +``` + +На старте допустим **single repo** без Turborepo; `packages/shared-types` генерируется из OpenAPI при CI. + +--- + +## 6. Модульная структура Frontend (React) + +### 6.1. Дерево каталогов `apps/web/src` + +``` +src/ +├── app/ +│ ├── App.tsx +│ ├── providers/ +│ │ ├── QueryProvider.tsx +│ │ ├── AuthProvider.tsx +│ │ └── ThemeProvider.tsx +│ ├── router/ +│ │ ├── routes.tsx +│ │ └── guards/ +│ │ ├── AuthGuard.tsx +│ │ └── AdminGuard.tsx +│ └── styles/ +│ └── globals.css +│ +├── pages/ +│ ├── HomePage/ +│ ├── LoginPage/ +│ ├── RegisterPage/ +│ ├── ProfilePage/ +│ ├── ContentPage/ +│ └── AdminPage/ +│ +├── modules/ +│ ├── landing/ +│ │ ├── index.ts # public API +│ │ ├── components/ +│ │ │ ├── HeroSection.tsx +│ │ │ └── MarqueeSection.tsx +│ │ ├── hooks/ +│ │ └── types/ +│ │ +│ ├── auth/ +│ │ ├── index.ts +│ │ ├── api/ +│ │ │ └── authApi.ts +│ │ ├── components/ +│ │ │ ├── LoginForm.tsx +│ │ │ └── RegisterForm.tsx +│ │ ├── hooks/ +│ │ │ ├── useLogin.ts +│ │ │ └── useAuth.ts +│ │ ├── store/ +│ │ │ └── authStore.ts +│ │ └── types/ +│ │ +│ ├── profile/ +│ │ ├── index.ts +│ │ ├── api/ +│ │ ├── components/ +│ │ ├── hooks/ +│ │ └── types/ +│ │ +│ ├── content/ +│ │ ├── index.ts +│ │ ├── api/ +│ │ ├── components/ +│ │ └── hooks/ +│ │ +│ ├── admin/ +│ │ ├── index.ts +│ │ ├── api/ +│ │ ├── components/ +│ │ └── hooks/ +│ │ +│ └── catalog/ # v1.0 +│ └── ... +│ +├── __tests__/ # cross-module integration (frontend) +│ └── setup.ts +│ +└── shared/ + ├── api/ + │ ├── client.ts + │ ├── client.test.ts + │ ├── errors.ts + │ └── types.ts + ├── ui/ + │ ├── Button/ + │ │ ├── Button.tsx + │ │ └── Button.test.tsx + │ └── ... + └── ... + +# Каждый module обязан содержать: +modules// +├── __tests__/ # unit: hooks, utils, store +├── components/ +│ └── *.test.tsx # component tests (Testing Library) +└── api/ + └── *.test.ts # API client + mock handlers +``` + +### 6.2. Правила зависимостей (import rules) + +```mermaid +flowchart TD + app --> pages + pages --> modules + modules --> shared + modules -.->|FORBIDDEN| modules + shared -.->|FORBIDDEN| modules + shared -.->|FORBIDDEN| pages +``` + +Enforcement через **ESLint** (`eslint-plugin-boundaries` или custom rules): + +| From → To | app | pages | modules | shared | +|-----------|-----|-------|---------|--------| +| app | ✓ | ✓ | ✓ | ✓ | +| pages | — | ✓ | ✓ | ✓ | +| modules | — | — | ✓ (own) | ✓ | +| shared | — | — | ✗ | ✓ | + +### 6.3. Описание модулей Frontend + +#### 6.3.1. `landing` + +**Ответственность:** + +- Буду ебать за каждый нейрослоп + +**Зависимости:** только `shared/ui`, `shared/hooks`. + +#### 6.3.2. `auth` + +**Ответственность:** аутентификация, сессия, guards. + +| Export (public API) | Описание | +|---------------------|----------| +| `LoginForm`, `RegisterForm` | Формы | +| `useAuth()` | `{ user, isAuthenticated, login, logout, refreshSession }` | + +**Guards** (`AuthGuard`, `GuestGuard`, `AdminGuard`) живут в `app/router/guards/` — они **импортируют** `useAuth` из модуля `auth`, но не экспортируются из него (слой `app` композирует модули). + +**Store:** `accessToken` только in-memory (Zustand). `refreshToken` **только** httpOnly Secure cookie (`SameSite=Lax`). Хранение refresh в `localStorage` / `sessionStorage` **запрещено**. + +#### 6.3.3. `profile` + +**Ответственность:** CRUD профиля пользователя. + +| Функции | API endpoints | +|---------|---------------| +| Просмотр профиля | `GET /api/v1/users/me` | +| Редактирование | `PATCH /api/v1/users/me` | +| Смена пароля (авторизован) | `POST /api/v1/users/me/password` | +| Загрузка аватара | `POST /api/v1/users/me/avatar` | + +#### 6.3.4. `content` + +**Ответственность:** рендер CMS-страниц по slug. Запись — только для `admin` (через те же роуты с RBAC-проверкой; отдельный `admin`-модуль не дублирует CRUD контента). + +| Функции | Описание | +|---------|----------| +| `ContentPage` | `/about`, `/privacy`, `/terms` | +| SEO | meta title, description, og:image | + +#### 6.3.5. `admin` + +**Ответственность:** панель администратора (role: `admin`). + +| Раздел | Функции | +|--------|---------| +| Users | список, блокировка, смена роли *(с ограничениями — §14.10)* | +| Content | — *(CRUD контента — модуль `content`, §9.4)* | +| Dashboard | базовые метрики (users count, registrations/day) | + +### 6.4. Роутинг + +| Маршрут | Page | Guard | Модуль(и) | +|---------|------|-------|-----------| +| `/` | HomePage | — | landing | +| `/login` | LoginPage | guest only | auth | +| `/register` | RegisterPage | guest only | auth | +| `/profile` | ProfilePage | AuthGuard | profile | +| `/pages/:slug` | ContentPage | — | content | +| `/admin/*` | AdminPage | AdminGuard | admin | + +### 6.5. Code splitting + +- Lazy load: `admin`, `profile`, `catalog`. +- Prefetch on hover для `/login`, `/register`. +- Landing — в initial bundle (LCP-critical). + +--- + +## 7. Модульная структура Backend (API) + +### 7.1. Дерево каталогов `apps/api` + +``` +app/ +├── main.py # FastAPI app factory +├── core/ +│ ├── config.py +│ ├── database.py +│ ├── redis.py +│ ├── security.py # JWT, password hashing +│ ├── dependencies.py # get_db, get_current_user +│ └── exceptions.py +│ +├── modules/ +│ ├── auth/ +│ │ ├── router.py +│ │ ├── service.py +│ │ ├── schemas.py +│ │ └── repository.py +│ │ +│ ├── users/ +│ │ ├── router.py +│ │ ├── service.py +│ │ ├── models.py +│ │ ├── schemas.py +│ │ └── repository.py +│ │ +│ ├── content/ +│ │ ├── router.py +│ │ ├── service.py +│ │ ├── models.py +│ │ └── schemas.py +│ │ +│ ├── media/ +│ │ ├── router.py +│ │ ├── service.py # S3 upload +│ │ └── schemas.py +│ │ +│ └── admin/ +│ ├── router.py +│ ├── service.py +│ └── schemas.py +│ +├── migrations/ # Alembic +└── tests/ + ├── conftest.py # fixtures: db, client, test users + ├── factories.py # user/content factories + ├── modules/ + │ ├── auth/ + │ │ ├── test_service.py # unit: бизнес-логика + │ │ ├── test_router.py # integration: HTTP + DB + │ │ └── test_security.py # rotation, lockout, enumeration + │ ├── users/ + │ ├── content/ + │ └── admin/ + └── e2e/ # pytest API smoke (optional layer) + └── test_health.py +``` + +### 7.2. Слои внутри модуля (Clean Architecture lite) + +``` +Router → Service → Repository → Model + ↓ + Schemas (Pydantic) +``` + +| Слой | Ответственность | +|------|-----------------| +| **Router** | HTTP, status codes, dependency injection | +| **Service** | Бизнес-логика, orchestration | +| **Repository** | SQL-запросы, абстракция БД | +| **Schemas** | Request/Response DTO | +| **Models** | SQLAlchemy ORM | + +**Правило:** модуль не импортирует `repository` другого модуля. Межмодульные вызовы — только через **публичный `service`-фасад** (например, `users.public.get_by_id()`) или domain events. + +### 7.3. Версионирование API + +- Префикс: `/api/v1/` +- Breaking changes → `/api/v2/` +- OpenAPI: `/api/v1/openapi.json` +- Swagger UI: `/api/v1/docs` (только dev/staging) + +--- + +## 8. Модель данных + +### 8.1. ER-диаграмма (MVP) + +```mermaid +erDiagram + users ||--o| user_profiles : has + users ||--o{ refresh_tokens : has + users ||--o{ password_reset_tokens : has + users ||--o{ content_pages : creates + + users { + uuid id PK + string email UK + string password_hash + enum role "user|admin" + enum status "active|blocked|pending" + timestamp email_verified_at + int failed_login_attempts + timestamp locked_until + timestamp created_at + timestamp updated_at + } + + user_profiles { + uuid user_id PK,FK + string display_name + string avatar_url + json metadata + } + + refresh_tokens { + uuid id PK + uuid user_id FK + string token_hash UK + uuid family_id + timestamp expires_at + timestamp revoked_at + timestamp created_at + } + + password_reset_tokens { + uuid id PK + uuid user_id FK + string token_hash UK + timestamp expires_at + timestamp used_at + } + + content_pages { + uuid id PK + string slug UK + string title + text body + enum status "draft|published" + uuid author_id FK + timestamp published_at + timestamp updated_at + } +``` + +### 8.2. Индексы + +```sql +CREATE UNIQUE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_status ON users(status); +CREATE INDEX idx_content_slug ON content_pages(slug); +CREATE INDEX idx_content_status ON content_pages(status); +CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id); +CREATE INDEX idx_refresh_tokens_family ON refresh_tokens(family_id); +CREATE INDEX idx_password_reset_user ON password_reset_tokens(user_id); +``` + +### 8.3. Миграции + +- Alembic, одна миграция = одна логическая задача. +- Rollback обязателен для каждой миграции. +- Seed: admin user, demo content pages. + +--- + +## 9. API-контракты + +### 9.1. Общие соглашения + +| Аспект | Стандарт | +|--------|----------| +| Format | JSON, UTF-8 | +| Dates | ISO 8601 UTC (`2026-07-09T12:00:00Z`) | +| IDs | UUID v4 | +| Pagination | `?page=1&limit=20`, response: `{ data, meta: { total, page, limit } }` | +| Errors | `{ "error": { "code": "VALIDATION_ERROR", "message": "...", "details": [] } }` | + +### 9.2. Auth endpoints + +| Method | Path | Auth | Описание | +|--------|------|------|----------| +| POST | `/api/v1/auth/register` | — | Регистрация → `status: pending`, письмо с подтверждением | +| POST | `/api/v1/auth/login` | — | Вход → access в body, refresh в `Set-Cookie` | +| POST | `/api/v1/auth/refresh` | refresh cookie | Новая пара access + refresh (rotation) | +| POST | `/api/v1/auth/logout` | refresh cookie | Revoke token family, очистка cookie | +| POST | `/api/v1/auth/verify-email` | — | Body: `{ "token": "..." }` → `status: active` | +| POST | `/api/v1/auth/resend-verification` | — | Повторная отправка (rate limited) | +| POST | `/api/v1/auth/forgot-password` | — | Отправка reset-link (без раскрытия наличия email) | +| POST | `/api/v1/auth/reset-password` | — | Смена пароля по одноразовому token | + +**Правила ответов auth (anti-enumeration):** +- `register` с существующим email → **200** с нейтральным телом *или* **409** без указания причины в prod (на выбор; зафиксировать в реализации). +- `forgot-password` → всегда **200** «Если email зарегистрирован, письмо отправлено». +- `login` при неверных данных → **401** с общим сообщением «Неверный email или пароль». +- `login` при `status: pending` → **403** `EMAIL_NOT_VERIFIED`. +- `login` при `status: blocked` → **403** `ACCOUNT_BLOCKED`. +- `login` при блокировке brute-force → **429** `ACCOUNT_TEMPORARILY_LOCKED`. + +**Response login (200):** +```json +{ + "access_token": "eyJ...", + "token_type": "bearer", + "expires_in": 900, + "user": { + "id": "uuid", + "email": "user@example.com", + "role": "user", + "status": "active" + } +} +``` + +**Set-Cookie (login / refresh — обязательно):** +``` +Set-Cookie: refresh_token=; HttpOnly; Secure; SameSite=Lax; Path=/api/v1/auth; Max-Age=2592000 +``` + +> Refresh token **никогда** не возвращается в JSON-body. + +### 9.3. Users endpoints + +| Method | Path | Auth | Описание | +|--------|------|------|----------| +| GET | `/api/v1/users/me` | user | Текущий пользователь + profile | +| PATCH | `/api/v1/users/me` | user | Обновление профиля (whitelist полей) | +| POST | `/api/v1/users/me/password` | user | `{ current_password, new_password }` | +| POST | `/api/v1/users/me/avatar` | user | multipart upload | +| DELETE | `/api/v1/users/me` | user | Удаление аккаунта (v1.0, soft-delete + anonymize) | + +### 9.4. Content endpoints + +| Method | Path | Auth | Описание | +|--------|------|------|----------| +| GET | `/api/v1/content/pages` | — | Список published pages | +| GET | `/api/v1/content/pages/{slug}` | — | Страница по slug | +| POST | `/api/v1/content/pages` | admin | Создание | +| PATCH | `/api/v1/content/pages/{id}` | admin | Обновление | +| DELETE | `/api/v1/content/pages/{id}` | admin | Удаление | + +### 9.5. Admin endpoints + +| Method | Path | Auth | Описание | +|--------|------|------|----------| +| GET | `/api/v1/admin/users` | admin | Список пользователей (pagination, без password_hash) | +| PATCH | `/api/v1/admin/users/{id}` | admin | Блокировка, смена роли *(§14.10)* | +| GET | `/api/v1/admin/stats` | admin | Базовые метрики | +| GET | `/api/v1/admin/audit-log` | admin | Журнал действий админов (v1.0) | + +### 9.6. Rate limiting + +| Endpoint group | Limit | Key | +|----------------|-------|-----| +| Auth: login | 5 req/min | IP + email (composite) | +| Auth: register | 3 req/hour | IP | +| Auth: forgot-password, resend-verification | 3 req/hour | IP + email | +| Auth: refresh | 30 req/min | user_id (из token) | +| Public API | 100 req/min | IP | +| Authenticated API | 300 req/min | user_id (fallback: IP) | +| Upload (avatar) | 10 req/hour | user_id | + +При превышении → **429** + заголовок `Retry-After`. + +**Brute-force (login):** после 5 неудачных попыток за 15 мин — `locked_until = now + 15 min` (поле в `users`). + +--- + +## 10. Аутентификация и авторизация + +### 10.1. JWT Strategy + +| Token | Формат | TTL | Storage | Передача | +|-------|--------|-----|---------|----------| +| Access | JWT (HS256 или RS256) | 15 min | In-memory (JS) | `Authorization: Bearer` | +| Refresh | Opaque random (256 bit) | 30 days | httpOnly Secure cookie | Cookie `refresh_token` | + +**Claims access JWT:** `sub` (user_id), `role`, `iat`, `exp`, `jti` (уникальный ID для optional denylist). + +**Refresh token:** +- В БД хранится **только SHA-256 hash**, не plaintext. +- **Rotation:** каждый `/auth/refresh` выдаёт новый refresh, старый revoke. +- **Reuse detection:** повторное использование revoked token → revoke всей `family_id`, принудительный logout на всех устройствах. +- Cookie `Path=/api/v1/auth` — не отправляется на остальные API-роуты (минимизация поверхности CSRF). + +### 10.2. RBAC + +| Role | Permissions | +|------|-------------| +| `guest` | Чтение опубликованного контента | +| `user` (`status: active`) | Управление своим профилем | +| `admin` | Управление пользователями, контентом, stats; **не может** изменить собственную роль (§14.10) | + +**Проверка на каждом protected endpoint:** +1. Валидный access JWT (подпись, exp, jti). +2. Пользователь существует и `status != blocked`. +3. Для admin-роутов — `role == admin`. +4. Для user-роутов — `sub == resource owner` (защита от IDOR). + +`pending`-пользователь может вызвать только: verify-email, resend-verification, logout. + +### 10.3. Password policy + +- Минимум 8 символов, 1 uppercase, 1 lowercase, 1 digit. +- Denylist топ-10k паролей (Have I Been Pwned или локальный список). +- bcrypt, cost factor 12. +- Reset/verify token: 256 bit random, **hash в БД**, TTL 1 hour, одноразовый. +- При смене/сбросе пароля — revoke все `refresh_tokens` пользователя. + +### 10.4. CORS и credentials + +```python +# FastAPI CORSMiddleware +allow_origins = CORS_ORIGINS # whitelist, без wildcard в prod +allow_credentials = True # обязательно для refresh cookie +allow_methods = ["GET", "POST", "PATCH", "DELETE", "OPTIONS"] +allow_headers = ["Authorization", "Content-Type", "X-Request-ID"] +``` + +Frontend HTTP-клиент: `withCredentials: true` только для auth-запросов (login, refresh, logout). + +### 10.5. CSRF (cookie-based refresh) + +| Механизм | Применение | +|----------|------------| +| `SameSite=Lax` на refresh cookie | Блокирует cross-site POST в большинстве браузеров | +| `Path=/api/v1/auth` | Cookie не уходит на PATCH/POST других ресурсов | +| Проверка `Origin` / `Referer` на auth-роутах | Backend отклоняет запросы с чужого origin | +| Access JWT в header (не cookie) | State-changing API через Bearer не уязвим к CSRF | + +Дополнительный CSRF-token **не требуется** при соблюдении схемы выше. Если refresh cookie когда-либо расширят на весь `/api` — добавить double-submit CSRF token. + +--- + +## 11. UI/UX и дизайн-система + +### 11.1. Design tokens (из текущей заглушки) + +```css +:root { + --bg: #F6F6F4; + --foreground: #1A1E1C; + --primary: #48816D; + --muted: #4A5A52; + --marquee-bg: #EBEBE5; +} +``` + +### 11.2. Типографика + +| Назначение | Шрифт | +|------------|-------| +| UI / body | Inter (400, 500, 600) | +| Mono / акценты | JetBrains Mono (400, 500) | +| Декоративный (опционально) | Oktyabrina Script | + +### 11.3. Breakpoints + +| Token | Width | +|-------|-------| +| `sm` | 380px | +| `md` | 480px | +| `lg` | 768px | +| `xl` | 1024px | +| `2xl` | 1280px | + +### 11.4. Компоненты shared/ui (MVP) + +- Button (primary, secondary, ghost) +- Input, Textarea +- Label, ErrorMessage +- Card, Modal, Spinner +- Header, Footer, Container +- Avatar, Badge + +### 11.5. Accessibility + +- WCAG 2.1 AA для MVP. +- Keyboard navigation, focus visible. +- `alt` для всех изображений. +- `prefers-reduced-motion` — отключение marquee-анимации. + +--- + +## 12. Нефункциональные требования + +### 12.1. Performance + +| Метрика | Target | +|---------|--------| +| Initial JS bundle (landing) | < 150 KB gzip | +| Total JS (app loaded) | < 350 KB gzip | +| API cache (public content) | Redis TTL 5 min | +| Static assets | CDN, cache-control 1 year (hash in filename) | + +### 12.2. SEO + +- SSR/SSG **не обязателен** для MVP (SPA + prerender landing через Vite SSG plugin). +- Meta tags, sitemap.xml, robots.txt. +- Semantic HTML (`header`, `main`, `section`). + +### 12.3. Observability + +| Signal | Tool | +|--------|------| +| Logs | JSON structured, correlation-id | +| Metrics | request duration, error rate, DB pool | +| Traces | OpenTelemetry (optional v1.0) | +| Frontend errors | Sentry browser SDK | + +### 12.4. Backup & Recovery + +- PostgreSQL: daily backup, retention 30 days. +- RPO: 24 hours, RTO: 4 hours (на 1000 users достаточно). + +--- + +## 13. Инфраструктура и DevOps + +### 13.1. Окружения + +| Env | Назначение | URL | +|-----|------------|-----| +| local | разработка | localhost:5173 (web), :8000 (api) | +| staging | QA, demo | staging.compton.example | +| production | prod | compton.example | + +### 13.2. Docker Compose (local) + +```yaml +services: + web: + build: ./apps/web + ports: ["5173:5173"] + api: + build: ./apps/api + ports: ["8000:8000"] + depends_on: [postgres, redis] + postgres: + image: postgres:16 + redis: + image: redis:7-alpine + minio: + image: minio/minio # S3-compatible dev + +# apps/web/e2e/ — Playwright specs (§15.3) +# docker-compose.test.yml — postgres + redis + api + web для CI/E2E +``` + +### 13.3. CI/CD Pipeline + +```mermaid +flowchart LR + Push[Git Push / PR] --> Lint[Lint + Typecheck] + Lint --> Unit[Unit + Integration] + Unit --> Cov{Coverage ≥ threshold?} + Cov -->|No| Block1[❌ Block merge] + Cov -->|Yes| Build[Build Docker] + Build --> DeployStaging[Deploy Staging] + DeployStaging --> E2E[Playwright E2E] + E2E --> E2EPass{All E2E pass?} + E2EPass -->|No| Block2[❌ Block merge] + E2EPass -->|Yes| Review[Code Review] + Review --> ManualApprove[Manual Approve] + ManualApprove --> DeployProd[Deploy Production] +``` + +**Checks на каждый PR (обязательны, merge заблокирован при падении):** + +| Gate | Команда | Условие pass | +|------|---------|--------------| +| Lint | `eslint`, `ruff`, `prettier --check` | 0 errors | +| Types | `tsc --noEmit`, `mypy` | 0 errors | +| Unit (FE) | `vitest run --coverage` | ≥ порогов §15.2 | +| Unit + Integration (BE) | `pytest --cov=app --cov-fail-under=...` | ≥ порогов §15.2 | +| OpenAPI | schema diff | no breaking без bump version | +| E2E | `playwright test` | 100% pass, 0 flaky retries exhausted | +| Security | `pip-audit`, `npm audit`, gitleaks | no unwaived critical | + +> **Правило:** PR без тестов на изменённую логику **не ревьюится** и **не мержится**. + +### 13.4. Production topology (1000 users) + +``` +1× VPS (4 vCPU, 8 GB RAM) или managed PaaS +├── Nginx +├── API container (uvicorn, 2 workers) +├── Web static (built SPA) +├── PostgreSQL (managed или co-located) +└── Redis (managed или co-located) +``` + +**Запас на рост:** второй API instance за load balancer при CPU > 70% sustained. + +--- + +## 14. Безопасность + +### 14.1. Threat model (STRIDE, MVP) + +| Угроза | Вектор | Митигация | +|--------|--------|-----------| +| **Spoofing** | Подделка JWT / session | Подпись JWT, короткий TTL, rotation refresh | +| **Tampering** | Изменение чужих данных | RBAC + owner check (`sub == user_id`) | +| **Repudiation** | Отрицание admin-действий | Audit log (v1.0), correlation-id в логах | +| **Information disclosure** | Enumeration email, утечка PII | Anti-enumeration (§9.2), маскировка логов | +| **Denial of service** | Flood login/register | Rate limit + account lockout (§9.6) | +| **Elevation of privilege** | user → admin | RBAC middleware, admin self-edit запрещён | + +**Trust boundaries:** +1. Browser (недоверенный) ↔ Nginx (TLS termination) +2. Nginx ↔ API (internal network / docker network) +3. API ↔ PostgreSQL / Redis / S3 (credentials via env) + +### 14.2. Transport и security headers + +**Обязательно в production (Nginx / middleware):** + +| Header | Значение | +|--------|----------| +| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` | +| `X-Content-Type-Options` | `nosniff` | +| `X-Frame-Options` | `DENY` | +| `Referrer-Policy` | `strict-origin-when-cross-origin` | +| `Permissions-Policy` | `camera=(), microphone=(), geolocation=()` | +| `Content-Security-Policy` | см. §14.3 | + +- TLS 1.2+ only, сильные cipher suites. +- Swagger UI (`/docs`) и OpenAPI JSON — **отключены в production** (env flag `ENABLE_DOCS=false`). + +### 14.3. Content Security Policy + +``` +default-src 'self'; +script-src 'self'; +style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; +font-src 'self' https://fonts.gstatic.com; +img-src 'self' data: https://; +connect-src 'self' https://; +frame-ancestors 'none'; +base-uri 'self'; +form-action 'self'; +``` + +- Inline scripts в SPA — через nonce или только bundled (Vite). +- Google Fonts — допустимое исключение; при ужесточении CSP — self-host шрифтов. + +### 14.4. XSS и инъекции + +| Поверхность | Защита | +|-------------|--------| +| React UI | Auto-escaping JSX | +| CMS `content_pages.body` (HTML) | Server-side sanitize (**bleach** / **nh3**) + client DOMPurify allowlist перед `dangerouslySetInnerHTML` | +| User input (profile) | Pydantic validation, max length, strip control chars | +| SQL | SQLAlchemy ORM, parameterized queries; raw SQL запрещён без review | +| Log injection | Structured JSON logging, sanitize `\n\r` в user input | + +**Allowlist HTML-тегов CMS:** `p, h1-h4, ul, ol, li, a, strong, em, br, img` — без `script`, `iframe`, `on*` attributes. + +### 14.5. IDOR и авторизация + +| Endpoint | Правило | +|----------|---------| +| `GET/PATCH /users/me` | `current_user.id` из JWT, не из body | +| `POST /users/me/avatar` | только свой user_id | +| `PATCH /admin/users/{id}` | admin + business rules (§14.10) | +| `GET /content/pages/{slug}` | только `status=published` для non-admin | +| `PATCH /content/pages/{id}` | admin only; проверка существования id | + +**Запрещено:** принимать `user_id` / `role` из request body для повышения привилегий. + +### 14.6. Загрузка файлов (avatar) + +| Проверка | Значение | +|----------|----------| +| Max size | 2 MB | +| MIME (whitelist) | `image/jpeg`, `image/png`, `image/webp` | +| Magic bytes | python-magic / file signature check | +| Filename | UUID + ext, без user-supplied path | +| Storage | S3 private bucket; URL — signed / через CDN proxy | +| Processing | Re-encode (Pillow) для удаления EXIF и embedded payload | + +**Запрещено:** SVG upload (XSS vector), исполнение файлов на сервере. + +### 14.7. Secrets и криптография + +| Secret | Хранение | Rotation | +|--------|----------|----------| +| `JWT_ACCESS_SECRET` | env / vault | при компрометации, quarterly | +| `JWT_REFRESH_PEPPER` | env / vault | отдельный от access secret | +| DB credentials | env / managed DB | managed rotation | +| S3 keys | IAM role (preferred) или env | quarterly | + +- `.env` в `.gitignore`; `.env.example` без реальных значений. +- Pre-commit hook / CI: **gitleaks** или **trufflehog**. +- JWT secret ≥ 256 bit entropy. + +### 14.8. Сессии и logout + +- **Logout:** revoke refresh token family + clear cookie (`Max-Age=0`). +- **Password change / reset:** revoke **все** refresh tokens пользователя. +- **Block user (admin):** revoke tokens немедленно. +- Optional: Redis denylist для access JWT by `jti` до exp (для instant revoke admin-сессий). + +### 14.9. Логирование и PII + +**Логировать:** +- `request_id`, method, path, status, duration, user_id (if auth), IP (hashed в prod опционально). + +**Не логировать:** +- Passwords, tokens, reset links, full email в debug (mask: `u***@example.com`). + +**Retention:** application logs 30 days; audit log admin-действий 1 year. + +### 14.10. Admin security rules + +| Правило | Описание | +|---------|----------| +| Last admin | Нельзя удалить/понизить последнего admin | +| Self-demotion | Admin не может снять с себя роль admin | +| Self-block | Admin не может заблокировать себя | +| Role escalation | Только admin может назначать admin; 2FA для admin — v1.0 (recommended) | +| Seed admin | Создаётся миграцией; пароль из env при первом deploy, затем смена | + +### 14.11. Supply chain + +- Dependabot / Renovate — auto PR на CVE. +- CI: `pip-audit` (backend), `npm audit` (frontend), **Trivy** scan Docker images. +- Pin dependencies (`poetry.lock`, `package-lock.json`). +- Block merge при critical/high без explicit waiver. + +### 14.12. GDPR / 152-ФЗ + +| Требование | Реализация | +|------------|------------| +| Согласие | Checkbox при регистрации + ссылка на политику | +| Политика конфиденциальности | CMS-страница `/privacy` | +| Право на удаление | `DELETE /users/me` (v1.0): soft-delete, anonymize PII, retain audit/legal minimum | +| Data minimization | `metadata` json — только необходимые поля | +| Хранение | RU/EU region hosting (допущение §18.1) | +| Breach notification | Runbook в docs (v1.0) | + +### 14.13. Security checklist (pre-production) + +- [ ] HTTPS + HSTS +- [ ] CSP, security headers (§14.2) +- [ ] CORS whitelist, `allow_credentials` только для trusted origins +- [ ] Refresh: httpOnly, Secure, SameSite, Path, rotation + reuse detection +- [ ] Access JWT in memory only +- [ ] Anti-enumeration на auth endpoints +- [ ] Rate limiting + brute-force lockout +- [ ] IDOR tests на все `/me` и admin routes +- [ ] File upload validation + private S3 +- [ ] CMS HTML sanitization +- [ ] Secrets not in git; gitleaks in CI +- [ ] Swagger disabled in prod +- [ ] Dependency scan clean (no unwaived critical) +- [ ] OWASP ASVS Level 1 review +- [ ] Sentry: scrub PII from breadcrumbs + +### 14.14. Security testing (дополнение к §15) + +| Тест | Tool / метод | +|------|--------------| +| SAST | Bandit (Python), ESLint security plugins | +| DAST | OWASP ZAP baseline scan на staging | +| Auth flows | pytest: token rotation, reuse detection, lockout | +| IDOR | pytest: user A cannot access user B | +| Fuzzing upload | Invalid MIME, oversized, polyglot files | + +--- + +## 15. Тестирование и качество + +> **Базовое правило проекта:** функция считается реализованной только когда поставлены **тесты логики** (unit + integration) **и E2E-сценарии** для затронутых пользовательских потоков. Исключений нет. + +### 15.1. Пирамида и обязательные уровни + +| Уровень | Что покрывает | Tools | Обязательность | +|---------|---------------|-------|----------------| +| **Unit** | service, repository, hooks, store, utils, pure components | Vitest, pytest | **Обязательно** на каждый PR с логикой | +| **Integration** | API routers + DB + Redis; form → API client | pytest + TestClient, MSW | **Обязательно** для backend и API-слоя frontend | +| **Component** | UI: render, a11y, user events | Testing Library | **Обязательно** для новых/изменённых компонентов | +| **E2E** | Сквозные user journeys в браузере | Playwright | **Обязательно** на каждый модуль / фазу (§15.5) | +| **Visual** | Landing regression | Percy/Chromatic | Опционально | +| **Load** | API под CCU | k6 | Перед релизом MVP/v1.0 | +| **Security** | auth, IDOR, upload | pytest + ZAP | Перед релизом (§14.14) | + +### 15.2. Пороги покрытия (coverage gates) + +Измерение: **line coverage** (Vitest v8 / pytest-cov). CI падает при падении ниже порога. + +| Область | Минимум | Примечание | +|---------|---------|------------| +| `apps/api/app/modules/*/service.py` | **≥ 95%** | Бизнес-логика backend | +| `apps/api/app/modules/*/repository.py` | **≥ 90%** | SQL, edge cases | +| `apps/api/app/core/security.py` | **100%** | JWT, hash, lockout | +| `apps/web/src/modules/*/hooks`, `store`, `api` | **≥ 90%** | Логика frontend | +| `apps/web/src/shared/lib`, `shared/api` | **≥ 90%** | Общие утилиты | +| `apps/web/src/modules/*/components` | **≥ 80%** | UI; допускается исключать pure layout | +| **Overall backend** `app/` | **≥ 90%** | `--cov-fail-under=90` | +| **Overall frontend** `src/` | **≥ 85%** | `--coverage.thresholds.lines=85` | + +**Diff coverage (рекомендуется):** новые/changed строки в PR — **≥ 95%** (Codecov / diff-cover). + +**Критические модули** (`auth`, `admin`, `core/security`) — **100% branch coverage** для функций авторизации и token rotation. + +### 15.3. Структура E2E (Playwright) + +``` +apps/web/e2e/ +├── fixtures/ +│ ├── auth.fixture.ts # login helpers, test users +│ └── api.fixture.ts # seed via API +├── landing/ +│ └── landing.spec.ts +├── auth/ +│ ├── register.spec.ts +│ ├── login.spec.ts +│ └── password-reset.spec.ts +├── profile/ +│ └── profile.spec.ts +├── content/ +│ └── content-pages.spec.ts +├── admin/ +│ └── admin-users.spec.ts +└── playwright.config.ts +``` + +**Конфигурация:** +- Бrowsers: Chromium (CI), + Firefox/WebKit локально. +- Retry: 1 в CI только для infra flakes; повторный flake → bug. +- Trace/video: on-first-retry. +- Параллельность: по файлам, isolated test users. +- E2E против **staging** в CI post-deploy; PR — против docker-compose stack. + +### 15.4. Матрица тестов по модулям (MVP) + +Каждая строка — **минимальный обязательный набор** перед merge задачи. + +| Модуль | Unit / Integration (логика) | E2E (Playwright) | +|--------|----------------------------|------------------| +| **landing** | Hero/Marquee render, breakpoints, reduced-motion | `/` загрузка, marquee visible, a11y snapshot | +| **auth** | register, login, refresh rotation, reuse detection, lockout, anti-enumeration, verify-email | register → verify → login; wrong password; pending/blocked redirect | +| **profile** | PATCH whitelist, avatar validation (MIME, size), IDOR | edit name, upload avatar, change password | +| **content** | slug unique, draft vs published, HTML sanitize | public page by slug; admin publish → visible | +| **admin** | last-admin rule, self-block forbidden, RBAC | admin list users, block user, blocked cannot login | +| **shared/ui** | Button, Input, Form validation states | используются в module E2E | + +### 15.5. Тесты на каждом шаге реализации (workflow) + +```mermaid +flowchart TD + Task[Задача из §16] --> Impl[Реализация] + Impl --> Unit[Unit tests] + Unit --> Int[Integration tests] + Int --> Comp[Component tests если UI] + Comp --> E2E[E2E spec для flow] + E2E --> CI[CI green] + CI --> DoD[Definition of Done §15.6] + DoD --> Merge[Merge allowed] +``` + +**Порядок TDD (рекомендуется, не optional для auth/security):** +1. Написать failing test (unit или e2e). +2. Реализовать минимальный код. +3. Довести coverage до порога. +4. Открыть PR — CI должен быть green. + +**Запрещено:** +- Откладывать тесты «на потом» / отдельным PR. +- Merge с `@pytest.mark.skip` / `test.todo` / `it.skip` без linked issue и срока. +- Mock всего подряд в integration — DB/Redis должны быть real (testcontainers или docker-compose). + +### 15.6. Definition of Done (модуль / задача) + +- [ ] TypeScript strict / mypy — 0 errors +- [ ] **Unit tests** для всей новой/изменённой бизнес-логики +- [ ] **Integration tests** для новых/изменённых API endpoints +- [ ] **Component tests** для новых/изменённых React-компонентов +- [ ] **E2E spec** для затронутых user flows (§15.4) +- [ ] Coverage ≥ порогов §15.2 (CI enforced) +- [ ] API documented in OpenAPI +- [ ] ESLint boundaries pass +- [ ] Security tests пройдены (если модуль auth/admin/media) +- [ ] Code review approved +- [ ] 0 flaky E2E за 3 прогона CI + +### 15.7. Critical E2E scenarios (регрессия релиза) + +Полный прогон перед каждым релизом MVP/v1.0: + +1. Landing: LCP < 2.5s, hero + marquee, `prefers-reduced-motion`. +2. Register → verify email → login → profile edit → change password → logout. +3. Forgot password → reset → login with new password. +4. Admin: create content → publish → visible on `/pages/:slug`. +5. Admin: block user → blocked user gets 403 on login. +6. Pending user cannot access `/profile`. +7. Refresh rotation: old refresh token rejected; reuse revokes family. +8. IDOR: user A cannot GET/PATCH user B profile. +9. Admin cannot demote/block self; last admin protected. +10. Avatar: reject .svg, oversize, wrong MIME. + +### 15.8. Test data и изоляция + +| Аспект | Правило | +|--------|---------| +| Test DB | Отдельная `compton_test`; транзакционный rollback per test | +| Seed users | factories.py: `user`, `admin`, `pending_user`, `blocked_user` | +| E2E users | Создаются через API fixture перед spec, cleanup after | +| Secrets in tests | Только `test-*` keys из `.env.test` | +| Parallel | Изolated data via UUID suffix emails | + +### 15.9. Команды (локально) + +```bash +# Frontend +pnpm --filter web test # vitest watch +pnpm --filter web test:ci # vitest run --coverage +pnpm --filter web e2e # playwright test +pnpm --filter web e2e:ui # playwright --ui + +# Backend +cd apps/api && pytest # all +pytest tests/modules/auth -v # module +pytest --cov=app --cov-report=term-missing --cov-fail-under=90 + +# Full stack (pre-push) +docker compose -f docker-compose.test.yml up -d +pnpm test:ci && pytest && playwright test +``` + +--- + +## 16. Этапы разработки + +### Фаза 0 — Подготовка (1–2 недели) + +| # | Задача | Результат | Тесты (обязательно) | +|---|--------|-----------|---------------------| +| 0.1 | Monorepo: Vite, FastAPI skeleton | `apps/web`, `apps/api` | smoke: `vitest` 1 test, `pytest` health 1 test | +| 0.2 | Docker Compose + `docker-compose.test.yml` | dev + test stack | CI job: lint + smoke green | +| 0.3 | Shared UI kit + design tokens | Button, Input, Layout | component tests ≥80%; Storybook optional | +| 0.4 | ESLint boundaries, OpenAPI codegen, coverage gates | Quality gates в CI | CI блокирует merge без coverage config | +| 0.5 | Playwright + pytest fixtures | `e2e/`, `conftest.py`, factories | E2E smoke: landing page loads | + +### Фаза 1 — MVP (4–6 недель) + +> Каждая задача завершается **своим** набором unit + integration + E2E. Задача 1.6 — финальный регресс, не «первые E2E». + +| # | Задача | Модули | Тесты (обязательно в том же PR) | +|---|--------|--------|----------------------------------| +| 1.1 | Landing (перенос заглушки) | landing | unit: breakpoints; E2E: `landing.spec.ts` | +| 1.2 | Auth + SMTP | auth | unit: service/security; integration: all auth routes; E2E: register/login/verify/reset | +| 1.2b | Security baseline | core, auth | 100% coverage `security.py`; tests: lockout, rotation, enumeration | +| 1.3 | Profile CRUD | profile | unit: validation; integration: `/users/me`; E2E: edit + avatar + password | +| 1.4 | Content pages | content | unit: sanitize; integration: CRUD; E2E: publish → public view | +| 1.5 | Admin panel | admin | unit: business rules; integration: admin routes; E2E: block user flow | +| 1.6 | Staging deploy + **полный регресс** | — | Playwright §15.7 (all 10 scenarios); coverage ≥ §15.2 | + +### Фаза 2 — v1.0 (4–6 недель) + +| # | Задача | Модули | Тесты (обязательно в том же PR) | +|---|--------|--------|----------------------------------| +| 2.1 | Celery + email queue | notifications | unit: task handlers; integration: queue + mock SMTP; E2E: notification received | +| 2.2 | Catalog + Orders | catalog, orders | unit + integration per module; E2E: browse → cart → submit order | +| 2.3 | Analytics dashboard | admin | integration: stats API; E2E: dashboard renders metrics | +| 2.4 | Performance + monitoring | infra | k6 load test §17.2; no coverage regression | + +### Фаза 3 — Scale prep (по необходимости) + +- Read replica PostgreSQL +- Celery workers +- CDN + object storage migration +- Horizontal API scaling + +--- + +## 17. Критерии приёмки + +### 17.1. MVP + +**Функциональность:** +- [ ] Landing визуально соответствует текущей заглушке (hero, marquee, цвета). +- [ ] Регистрация и вход работают; load test **50 CCU** на API. +- [ ] Профиль редактируется, аватар загружается. +- [ ] Admin создаёт/редактирует контент-страницы. + +**Производительность и безопасность:** +- [ ] API p95 < 300 ms при 50 CCU (k6). +- [ ] Lighthouse Performance ≥ 85 на mobile. +- [ ] Security checklist §14.13 — все пункты закрыты. +- [ ] OWASP ZAP baseline — 0 high/critical на staging. + +**Тестирование (обязательно):** +- [ ] Backend coverage ≥ **90%** (`app/`), auth/security ≥ **95–100%** (§15.2). +- [ ] Frontend coverage ≥ **85%** (`src/`), hooks/api ≥ **90%**. +- [ ] Все E2E сценарии §15.7 — **10/10 pass** на staging. +- [ ] 0 skipped tests без waiver. +- [ ] CI pipeline §13.3 — все gates green на `main`. +- [ ] Каждый модуль MVP имеет строки в матрице §15.4 (закрыты). + +**Документация:** +- [ ] README: как запускать `test`, `test:ci`, `e2e`. +- [ ] OpenAPI, `.env.example`. + +### 17.2. Load test сценарий (k6, API-only) + +```javascript +// 50 VU, ramp 5 min, target: api:8000 через Nginx +// 35% GET /api/v1/content/pages +// 25% POST /api/v1/auth/login (test credentials pool) +// 20% GET /api/v1/users/me (authenticated) +// 10% POST /api/v1/auth/refresh (cookie) +// 10% GET /api/v1/content/pages/{slug} +``` + +**Pass:** error rate < 1%, p95 < 300ms, 0 auth bypass. + +> Frontend (`GET /`) — отдельный Lighthouse-тест, не смешивать с API load test. + +--- + +## 18. Риски и допущения + +### 18.1. Допущения + +1. 1000 пользователей — registered, не 1000 RPS. +2. Контент преимущественно текст + изображения, без video streaming. +3. Один регион хостинга (RU/EU), latency < 100ms для целевой аудитории. +4. Команда: 1–3 fullstack-разработчика. + +### 18.2. Риски + +| Риск | Вероятность | Митигация | +|------|-------------|-----------| +| Scope creep (catalog/orders раньше MVP) | Высокая | Жёсткое следование фазам | +| SPA SEO проблемы | Средняя | Prerender landing, meta tags | +| Cross-module imports ломают архитектуру | Средняя | ESLint boundaries в CI | +| Auth flow complexity (rotation, verify) | Средняя | TDD + integration tests §15.4–15.7 | +| Flaky E2E блокируют CI | Средняя | fixtures, isolated data §15.8, trace-on-retry | +| Перегрузка monolith при росте | Низкая (до 10K) | Redis cache, horizontal scale | + +--- + +## Приложение A — Карта модулей (summary) + +```mindmap + root((Комpton)) + Frontend + app + pages + modules + landing + auth + profile + content + admin + catalog + shared + Backend + core + modules + auth + users + content + media + admin + Infra + PostgreSQL + Redis + S3 + Nginx + "CI/CD" +``` + +--- + +## Приложение B — Переменные окружения + +### Frontend (`apps/web/.env`) + +```env +VITE_API_URL=http://localhost:8000 +VITE_APP_NAME=Комpton +VITE_SENTRY_DSN= +``` + +### Backend (`apps/api/.env`) + +```env +DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/compton +REDIS_URL=redis://localhost:6379/0 +JWT_ACCESS_SECRET= # ≥ 32 bytes random, NOT commit +JWT_REFRESH_PEPPER= # отдельный секрет для hash refresh tokens +JWT_ACCESS_TTL_MIN=15 +JWT_REFRESH_TTL_DAYS=30 +ENABLE_DOCS=true # false в production +CORS_ORIGINS=http://localhost:5173 +S3_ENDPOINT=http://localhost:9000 +S3_BUCKET=compton-media +S3_ACCESS_KEY= +S3_SECRET_KEY= +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM=noreply@compton.example +ADMIN_INITIAL_PASSWORD= # только first deploy, затем сменить +``` + +--- + +## Приложение C — Согласование + +| Роль | ФИО | Подпись | Дата | +|------|-----|---------|------| +| Product Owner | | | | +| Tech Lead | | | | +| Developer | | | | + +--- + +*Документ является живым артеfactом. Изменения фиксируются через PR в `docs/TZ.md` с bump версии.* diff --git a/docs/deploy-orchestrator.md b/docs/deploy-orchestrator.md new file mode 100644 index 0000000..76f8fac --- /dev/null +++ b/docs/deploy-orchestrator.md @@ -0,0 +1,44 @@ +# WESP Orchestrator — production deploy + +## Bootstrap + +1. Copy env template and run install secrets (`site/scripts/` or admin UI). +2. Start stack: `docker compose -f site/infra/docker/docker-compose.prod.yml up -d`. +3. Run migrations: `docker compose exec api alembic upgrade head`. +4. Sync WESP static UI: `site/scripts/sync-wesp-ui.sh`. + +## Hub pairing + +1. Open Enterprise cabinet → **Generate pairing code**. +2. On farm hub (K-hub server role): Admin → Orchestrator sync → enter VPS URL + code. +3. Verify heartbeat and sync metrics in cabinet. + +## Worker + +- Default loop: `python -m app.worker` (reconcile every 5 min). +- Celery (recommended prod): `celery -A app.celery_app worker --loglevel=info` (service `celery-worker` in compose). + +## Hub (WESP) + +1. Run migration: `flask db upgrade` (revision `0025` / `web_user_lab_access` → orchestrator tables). +2. Configure orchestrator sync in hub admin (URL + pairing code). +3. Catalog edits enqueue `orchestrator_outbox`; reports enqueue `report_outbox`. + +## nginx + +- Zootech static: `/recipes`, `/components`, … → `public/wesp/*.html` (see `infra/nginx/default.tls.conf`). +- API: `/api/` → FastAPI. + +## Security checklist + +- Rotate `JWT_ACCESS_SECRET`, `JWT_REFRESH_PEPPER`, hub API keys. +- Enable PostgreSQL RLS in production (`DATABASE_URL=postgresql+psycopg://...`). +- Review `site/docs/security.md` before go-live. + +## Tests (CI gate) + +```bash +cd site/apps/api && .venv/bin/python -m pytest tests/modules/sync/test_orchestrator_dual_hub_e2e.py -q +``` + +Dual-hub E2E must pass before merge. diff --git a/docs/deploy.md b/docs/deploy.md new file mode 100644 index 0000000..8de1520 --- /dev/null +++ b/docs/deploy.md @@ -0,0 +1,196 @@ +# Деплой и эксплуатация + +От «запустил на ноуте» до «живёт на VPS и не стыдно показать security.md». + +## Среды + +```mermaid +flowchart LR + Dev[docker-compose.yml] --> St[staging] + St --> QA[k6 + ZAP + E2E] + QA --> Prod[production] +``` + +| Среда | Compose | APP_ENV | Docs | Demo users | +|-------|---------|---------|------|------------| +| Dev | `docker-compose.yml` | development | ✅ | ✅ | +| CI/E2E | `docker-compose.test.yml` | test | ✅ | ✅ | +| Staging | `infra/docker/docker-compose.staging.yml` | staging | ❌ | ❌ | +| Production | `infra/docker/docker-compose.prod.yml` | production | ❌ | ❌ | + +## Локальная разработка + +```bash +# 1. venv + зависимости backend (один раз) +python3 -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -r apps/api/requirements-dev.txt + +# 2. Секреты установки (один раз, до первого docker compose up) +python apps/api/scripts/bootstrap_install.py + +# 3. Полный стек в Docker (API + БД + web) +docker compose --profile docker-web up -d --build + +# 4. Проверка +curl http://localhost:8000/api/v1/health +``` + +| Сервис | URL | +|--------|-----| +| Web | http://localhost:5173 | +| API | http://localhost:8000 | +| PG/Redis/MinIO на хост | `docker-compose.dev-ports.yml` → 5432, 6379, 9000/9001 | + +> Bootstrap и локальные тесты (`pytest`, `mypy`) — через активированный `.venv`. Docker API использует свой образ; venv нужен для скриптов и разработки на хосте. + +**Гибрид** (инфра в Docker, frontend локально): + +```bash +source .venv/bin/activate +docker compose up -d +pnpm install +pnpm --filter web dev +``` + +**Install secrets на хост** (DBeaver): Admin → Security → Install Secrets → Reveal DB password. + +### Стандартные логины (dev) + +Создаются при seed на старте API. Пароли по умолчанию — из `apps/api/.env.example` (или дефолты в `config.py`). + +| Email | Пароль | Env | Роль | Superuser | Куда заходит | +|-------|--------|-----|------|:---------:|--------------| +| `admin@compton.example` | `Admin1234` | `ADMIN_INITIAL_PASSWORD` | admin | да | `/admin` — Users, Content, Security, Diagnostics, secrets | +| `ops@compton.example` | `OpsAdmin1234` | `DEMO_OPS_PASSWORD` | admin | нет | `/admin` — Users, Content, Activity (без Security) | +| `user@compton.example` | `User1234` | `DEMO_USER_PASSWORD` | user | — | `/profile` | + +**CMS-страницы (seed):** `about`, `privacy`, `terms` → `/pages/about` и т.д. + +> **Staging/production:** `SEED_DEMO_USERS=false` — demo `user@` и `ops@` **не создаются**, только admin + CMS. Пароль admin задаётся через `ADMIN_INITIAL_PASSWORD` **до первого seed**, потом — сменить в UI. + +## Staging + +**Нужно:** VPS, DNS, TLS certs в `infra/docker/certs/`, SMTP. + +```bash +git clone https://git.groupkomton.ru/Matvey/site.git && cd site +python3 apps/api/scripts/bootstrap_install.py +cp infra/docker/.env.staging.example infra/docker/.env.staging +# правим: домен, CORS, SMTP, ADMIN_INITIAL_PASSWORD +./infra/docker/deploy-staging.sh +``` + +Проверка: + +```bash +curl -fsS https://STAGING/api/v1/health +curl -fsS -o /dev/null -w "%{http_code}" https://STAGING/api/v1/docs # 404 +./infra/scripts/health-check.sh https://STAGING +``` + +Nginx: `default.tls.conf` — 80→443, HSTS. + +## Production + +```bash +python3 apps/api/scripts/bootstrap_install.py +cp infra/docker/.env.production.example infra/docker/.env.production +./infra/docker/deploy-prod.sh infra/docker/.env.production +``` + +**Сразу после bootstrap:** +1. Бэкап `install.env` off-server (зашифровать) +2. Сменить пароль admin + +| Сервис | Host ports | +|--------|------------| +| nginx | 80, 443 | +| api, web, pg, redis, minio | internal only | + +```bash +./infra/scripts/smoke-prod.sh https://YOUR_DOMAIN +``` + +### Rollback + +```bash +docker compose -f infra/docker/docker-compose.prod.yml down +git checkout PREVIOUS_TAG +./infra/docker/deploy-prod.sh infra/docker/.env.production +``` + +## Бэкапы и мониторинг + +| Что | Команда / как | +|-----|---------------| +| PostgreSQL | `./infra/scripts/backup-postgres.sh` → `backups/postgres-*.sql.gz` | +| install.env | `cp …/install.env backups/install.env.$(date +%F).enc` + gpg | +| Uptime | `./infra/scripts/health-check.sh URL` или UptimeRobot на `/api/v1/health` + `/` | +| Логи | logrotate для `server.log`, `admin-audit.jsonl` | + +## Восстановление секретов + +`install.env` — единственный источник runtime-секретов. Потеряли — не генерируйте новый вслепую. + +### Симптомы + +- `password authentication failed for user "compton_app"` +- bootstrap после того, как Postgres volume уже создан + +### Fix (есть бэкап) + +```bash +docker compose down +# восстановить apps/api/data/secrets/install.env +docker compose up -d --build +``` + +### Fix (нет бэкапа) + +| Вариант | Данные | +|---------|--------| +| Reveal из другой среды | сохраняются | +| `down -v` + bootstrap (**только dev**) | **удаляются все** | + +```bash +docker compose --profile docker-web down -v +python apps/api/scripts/bootstrap_install.py +docker compose --profile docker-web up -d --build +``` + +## SMTP + +Staging/prod: `EMAIL_DELIVERY_MODE=smtp`. Dev: `memory` (письма в RAM, SMTP не нужен). + +| Env | Назначение | +|-----|------------| +| `SMTP_HOST`, `SMTP_PORT`, `SMTP_FROM` | сервер | +| `FRONTEND_URL` | ссылки в письмах | + +## Troubleshooting + +| Проблема | Решение | +|----------|---------| +| `install.env missing` | `python apps/api/scripts/bootstrap_install.py` | +| `password authentication failed` | [восстановление секретов](#восстановление-секретов) | +| Login failed | `curl …/health`, проверить `.env`, restart web | +| 401 refresh в консоли (гость) | норма на публичных страницах | +| Logout после F5 на `/admin` | rebuild web, перелогиниться | +| «На сайт» ведёт на login | должно быть `href="/"`, rebuild | +| Port 5173 busy | stop Docker web **или** local Vite | +| CORS | `VITE_USE_API_PROXY=true`, не бить напрямую :8000 | +| Нет Security/Diagnostics | логин `admin@`, не `ops@` | +| Docker web: missing modules | `docker compose … up -d --build web` | + +## Compose-справочник + +| Файл | Назначение | +|------|------------| +| `docker-compose.yml` | dev | +| `docker-compose.dev-ports.yml` | порты на хост | +| `docker-compose.test.yml` | CI/E2E | +| `infra/docker/docker-compose.staging.yml` | staging | +| `infra/docker/docker-compose.prod.yml` | production | + +Deploy: `infra/docker/deploy-staging.sh`, `deploy-prod.sh`. diff --git a/docs/project.md b/docs/project.md new file mode 100644 index 0000000..08405f8 --- /dev/null +++ b/docs/project.md @@ -0,0 +1,220 @@ +# Структура и архитектура + +Monorepo-lite: статический лендинг + React SPA + FastAPI + PostgreSQL + Redis + MinIO. +Если вы искали микросервисы на Kubernetes — это другой коридор. + +## Стек + +| Слой | Технологии | +|------|------------| +| Frontend | React 19, TS, Vite, React Router, TanStack Query, Zustand, RHF+Zod, Ant Design | +| Backend | FastAPI, SQLAlchemy 2, Alembic, Pydantic v2 | +| Данные | PostgreSQL 16, Redis 7, MinIO | +| Инфра | Docker Compose, Nginx, GitHub Actions | +| Качество | Vitest, Playwright, pytest (≥90% / ≥85% cov) | + +## Дерево репозитория + +``` +site/ +├── apps/ +│ ├── api/ # Backend +│ │ ├── app/ +│ │ │ ├── core/ # crypto, jwt_denylist, redis, install_secrets… +│ │ │ ├── db/ # models, seed, migrations helpers +│ │ │ └── modules/ # auth, users, content, admin, media, test +│ │ ├── migrations/ # Alembic +│ │ ├── scripts/ # bootstrap_install.py, docker_entrypoint.py +│ │ ├── tests/ +│ │ └── data/ +│ │ ├── secrets/ # install.env (gitignore!) +│ │ └── logs/ # server.log, admin-audit.jsonl (gitignore) +│ └── web/ +│ ├── index.html # лендинг / +│ ├── app.html # SPA entry +│ ├── main/ # статика лендинга (CSS/JS/video) +│ ├── src/ +│ │ ├── app/ # router, guards +│ │ ├── modules/ # auth, profile, admin, content, landing +│ │ ├── pages/ +│ │ └── shared/ # api client, ui +│ └── e2e/ # Playwright +├── packages/ # eslint-config, shared-types (target) +├── infra/ +│ ├── docker/ # staging/prod compose, deploy.sh +│ ├── nginx/ # default.conf, default.tls.conf +│ ├── k6/ # load test §17.2 +│ └── scripts/ # backup, health, smoke +├── docs/ # вы здесь +├── docker-compose.yml # dev +└── docker-compose.test.yml # CI / E2E +``` + +## Runtime + +```mermaid +flowchart TB + subgraph browser [Браузер] + L[index.html /] + S[app.html SPA] + end + subgraph edge [Nginx :80/:443] + N[TLS + headers] + end + subgraph internal [Docker internal] + W[web] + A[api] + PG[(PostgreSQL)] + R[(Redis)] + M[(MinIO)] + end + L --> N + S --> N + N --> W + N --> A + A --> PG + A --> R + A --> M +``` + +**Prod/staging:** наружу только nginx. Postgres, Redis, MinIO — без host-портов. + +## Frontend + +### Два входа (dual-entry) + +| Entry | URL | Содержимое | +|-------|-----|------------| +| `index.html` | `/` | Маркетинговый лендинг (`main/`) | +| `app.html` | `/login`, `/admin`, … | React SPA | + +Vite переписывает SPA-пути на `app.html` (`vite.main-static.ts`). + +### Маршруты + +| Путь | Guard | Кто | +|------|-------|-----| +| `/` | — | все | +| `/login`, `/register`, `/forgot-password`, `/reset-password` | GuestGuard | гости | +| `/verify`, `/pages/:slug` | — | публично | +| `/profile` | AuthGuard | user | +| `/admin` | AdminGuard | admin | + +**Auth UX:** +- Access JWT — только в памяти (Zustand), не localStorage +- Refresh — HttpOnly cookie, `Path=/api/v1/auth` +- После login: admin → `/admin`, user → `/profile` +- Кнопка «На сайт» в админке — **полный** переход на `/` (не React Router) + +### Админка (WESP-style) + +| Раздел | Кому | Что | +|--------|------|-----| +| Users | admin | CRUD пользователей | +| Content | admin | CMS | +| Security | superuser | runtime settings, Install Secrets | +| Diagnostics | superuser | health checks | +| Activity | admin | audit feed, server log | + +Тема Light/Dark — `localStorage.wespAdminTheme`. Auth-страницы — zootech-карточки (`#48816d`). + +## Backend API + +База: `/api/v1` + +| Модуль | Эндпоинты (основное) | +|--------|----------------------| +| health | `GET /health` | +| auth | register, login, logout, refresh, verify, forgot/reset password | +| users | `GET/PATCH /me`, password, avatar | +| content | публичные pages + admin CRUD | +| admin | users, stats, settings, diagnostics, secrets, activity | +| media | подписанные URL файлов | +| test | `/test/emails/latest-token` — **только** E2E | + +### Core (`apps/api/app/core/`) + +| Модуль | Зачем | +|--------|-------| +| `crypto.py` | bcrypt, JWT, HMAC, token hash — одна точка | +| `jwt_denylist.py` | мгновенный revoke access JWT | +| `install_secrets.py` | bootstrap + lock | +| `dependencies.py` | `get_current_user` | +| `redis.py` | rate limit + JWT revoke | +| `storage.py` | MinIO / memory | + +## База данных + +```mermaid +erDiagram + users ||--o| user_profiles : has + users ||--o{ refresh_tokens : owns + users ||--o{ password_reset_tokens : owns + users ||--o{ email_verification_tokens : owns +``` + +Таблицы: `users`, `user_profiles`, `refresh_tokens`, `password_reset_tokens`, `email_verification_tokens`, `content_pages`. + +```bash +cd apps/api && alembic upgrade head +``` + +CHECK constraints на `role`, `status`; superuser только при `role=admin`. Cleanup expired tokens при старте API. + +### Seed: стандартные логины (dev) + +| Email | Пароль | Env | +|-------|--------|-----| +| admin@compton.example | Admin1234 | `ADMIN_INITIAL_PASSWORD` | +| ops@compton.example | OpsAdmin1234 | `DEMO_OPS_PASSWORD` | +| user@compton.example | User1234 | `DEMO_USER_PASSWORD` | + +CMS: `about`, `privacy`, `terms`. Подробнее — [deploy.md § логины](./deploy.md#стандартные-логины-dev). + +## Переменные окружения (ключевые) + +| Переменная | Где | Назначение | +|------------|-----|------------| +| `DATABASE_URL` | api | PostgreSQL | +| `APP_ENV` | api | development / staging / production | +| `JWT_ACCESS_SECRET`, `JWT_REFRESH_PEPPER` | api | токены | +| `REDIS_URL` | api | rate limit + JWT revoke (prod обязателен) | +| `SEED_DEMO_USERS` | api | `false` на staging/prod | +| `ADMIN_INITIAL_PASSWORD` | api | пароль admin при seed (default `Admin1234`) | +| `DEMO_USER_PASSWORD`, `DEMO_OPS_PASSWORD` | api | demo user/ops (только dev) | +| `ENABLE_DOCS` | api | `false` на prod | +| `ENABLE_TEST_ROUTES` | api | `true` только E2E | +| `EMAIL_DELIVERY_MODE` | api | `memory` (dev) / `smtp` (prod) | +| `STORAGE_MODE` | api | `s3` / `memory` | +| `VITE_API_URL` | web | `http://api:8000` в Docker | +| `VITE_USE_API_PROXY` | web | `true` в dev | + +Полные примеры: `apps/api/.env.example`, `apps/api/.env.production.example`. + +### Runtime settings + +`apps/api/data/compton_settings.json` — toggles без секретов. Superuser: `GET/PATCH /admin/settings`. Env с тем же ключом = **lock** (нельзя менять из UI). + +## Docker Compose + +| Файл | Когда | +|------|-------| +| `docker-compose.yml` | локальная разработка | +| `docker-compose.dev-ports.yml` | PG/Redis/MinIO на хост (DBeaver) | +| `docker-compose.test.yml` | CI, Playwright | +| `infra/docker/docker-compose.staging.yml` | staging VPS | +| `infra/docker/docker-compose.prod.yml` | production VPS | + +## CI + +`.github/workflows/ci.yml`: lint → types → mypy → tests → audit → Bandit → gitleaks → E2E. + +## Тесты + +```bash +pnpm --filter web test:ci +cd apps/api && python -m pytest --cov=app --cov-fail-under=90 +pnpm --filter web e2e +``` + +E2E поднимает API `:8001` + Vite `:5175`. Против staging: `E2E_BASE_URL=… E2E_START_API=false`. diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..99868d9 --- /dev/null +++ b/docs/release.md @@ -0,0 +1,113 @@ +# Релиз и QA gates + +Перед production не «авось прокатит», а чеклист из ТЗ §17. Если что-то красное — сначала staging, потом prod. Живёт один раз. + +## Pipeline + +```mermaid +flowchart TD + CI[CI green] --> ST[Staging + TLS] + ST --> E2E[E2E 11/11] + ST --> K6[k6 pass] + ST --> ZAP[ZAP 0 High/Crit] + ST --> LH[Lighthouse ≥ 85] + E2E --> PROD[Production deploy] + K6 --> PROD + ZAP --> PROD + LH --> PROD + PROD --> SM[smoke-prod.sh] + SM --> DNS[DNS cutover] + DNS --> MON[24h мониторинг] +``` + +## Регрессия E2E + +**Последний локальный прогон:** 2026-07-14 — backend 137 / 90.43% cov, frontend 46. + +### Staging + +```bash +E2E_BASE_URL=https://staging.example.com E2E_START_API=false pnpm --filter web e2e +``` + +| # | Сценарий | Local | Staging | +|---|----------|:-----:|:-------:| +| 1 | Landing hero, reduced-motion | ☐ | ☐ | +| 2 | Register → verify → login → profile → logout | ☐ | ☐ | +| 3 | Forgot → reset → login | ☐ | ☐ | +| 4 | Admin publish → public slug | ☐ | ☐ | +| 5 | Block user → login denied | ☐ | ☐ | +| 6 | Pending → нет `/profile` | ☐ | ☐ | +| 7 | Refresh rotation | ☐ | ☐ | +| 8 | IDOR user A ≠ user B | ☐ | ☐ | +| 9 | Admin не блокирует себя / last admin | ☐ | ☐ | +| 10 | Avatar: bad MIME / size / SVG | ☐ | ☐ | +| 11 | Block → access JWT 401 TOKEN_REVOKED | ☐ | ☐ | + +### Локально + +```bash +pnpm --filter web e2e # API :8001, Vite :5175 +``` + +## k6 (§17.2) + +```bash +k6 run infra/k6/mvp-load-test.js -e BASE_URL=https://staging.example.com +``` + +| Параметр | Порог | +|----------|-------| +| VU / ramp | 50 / 5 min | +| Mix | 35% list, 25% login, 20% me, 10% refresh, 10% slug | +| p95 | < 300 ms | +| Errors | < 1% | + +## OWASP ZAP + +```bash +docker run --rm -v "$(pwd):/zap/wrk:rw" -t ghcr.io/zap/zaproxy:stable \ + zap-baseline.py -t https://staging.example.com -r zap-report.html +``` + +| Severity | Pass | +|----------|------| +| High, Critical | **0** | + +Medium/Low — review руками. `zap-report.html` — в архив релиза. + +## Lighthouse + +```bash +npx lighthouse https://staging.example.com \ + --preset=mobile --only-categories=performance \ + --output=json --output-path=./lighthouse-report.json +``` + +| Метрика | ≥ | +|---------|---| +| Performance (mobile, `/`) | 85 | + +Не прошло — hero video `preload="none"`, font swap, меньше JS на лендинге. + +## Release gates (сводка) + +- [ ] CI green на `main` +- [ ] E2E 11/11 на staging +- [ ] k6 pass +- [ ] ZAP 0 High/Critical +- [ ] Lighthouse ≥ 85 +- [ ] [security.md](./security.md) staging-пункты +- [ ] `./infra/scripts/smoke-prod.sh` на prod +- [ ] DNS cutover + rollback plan (previous tag) + +## CI локально + +```bash +pnpm lint && pnpm typecheck +cd apps/api && python -m mypy app +pnpm --filter web test:ci +cd apps/api && python -m pytest --cov=app --cov-fail-under=90 +``` + +Полный pipeline: `.github/workflows/ci.yml`. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..0e9bbb5 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,173 @@ +# Безопасность + +Compton MVP — не банк, но и не «admin/admin в prod». Ниже — как устроена защита и что проверить перед выкладкой. + +## Auth: схема + +```mermaid +sequenceDiagram + participant B as Браузер + participant API as FastAPI + participant RD as Redis + participant PG as PostgreSQL + + B->>API: POST /auth/login + API->>PG: bcrypt verify + API->>RD: read auth_epoch + API-->>B: access JWT (memory) + refresh cookie + + B->>API: GET /users/me + Bearer + API->>RD: jti denied? epoch ok? + alt revoked + API-->>B: 401 TOKEN_REVOKED + else ok + API-->>B: 200 + end + + B->>API: POST /auth/logout + API->>RD: deny_jti + revoke refresh + API-->>B: cookie cleared +``` + +## Токены + +| Токен | Где живёт | Отзыв | +|-------|-----------|-------| +| Access JWT | память frontend | jti denylist + auth_epoch (Redis) | +| Refresh | HttpOnly cookie | rotation + family reuse detection | +| Email/reset | opaque → hash в БД | one-time, TTL 1ч | + +JWT claims: `sub`, `role`, `jti`, `auth_epoch`, `exp`. + +### Мгновенный revoke + +`apps/api/app/core/jwt_denylist.py`: + +| Redis key | Смысл | +|-----------|-------| +| `jwt:deny:{jti}` | конкретный access-токен | +| `auth:epoch:{user_id}` | версия сессий пользователя | + +| Событие | Действие | +|---------|----------| +| Logout | deny jti + revoke refresh | +| Block | INCR epoch + revoke refresh | +| Смена/reset пароля | INCR epoch + revoke refresh | + +**Production:** без Redis API не стартует. Redis упал — fail-closed (401, не «ну ладно»). + +**Dev:** in-memory fallback (не путать с prod). + +## Криптография + +Всё через `apps/api/app/core/crypto.py`: + +| Данные | Метод | +|--------|-------| +| Пароли | bcrypt cost 12 | +| Access JWT | HS256 | +| Refresh/email tokens | SHA-256 + pepper | +| Media URLs | HMAC-SHA256 + TTL | +| Install secrets | `secrets.token_*`, generate-once + lock | + +## Install secrets + +```bash +python apps/api/scripts/bootstrap_install.py # до первого docker compose up +``` + +| Файл | Содержимое | +|------|------------| +| `data/secrets/install.env` | PG, JWT, S3, MinIO | +| `install.meta.json` | install ID, lock time | + +**Не ротировать** `POSTGRES_PASSWORD` / JWT после bootstrap без плана — иначе Postgres скажет фразу, которую вы уже видели, и будет прав. + +Reveal: Admin → Security → Install Secrets (superuser, аудит в `admin-audit.jsonl`). + +Восстановление: [deploy.md § восстановление](./deploy.md#восстановление-секретов). + +## RBAC + +| Правило | Enforcement | +|---------|-------------| +| `/admin` → role=admin | Guard + API | +| Security/Diagnostics/secrets → superuser | API + UI tabs | +| Нельзя block/demote себя | admin service | +| Last admin protected | admin service | +| blocked/pending → 403 refresh | auth service | +| forgot_password skip для blocked | auth service | +| IDOR на профиль | users router | + +## HTTP / инфра + +### Nginx headers + +| Header | Значение | +|--------|----------| +| X-Frame-Options | DENY | +| X-Content-Type-Options | nosniff | +| Referrer-Policy | strict-origin-when-cross-origin | +| HSTS | `default.tls.conf` (staging/prod) | + +### Прочее + +- Origin/Referer на cookie-auth endpoints +- Rate limit (prod: обязателен) +- CMS: bleach, протоколы http/https/mailto +- Avatar: jpeg/png/webp, re-encode, **SVG — нет** +- CI: Bandit, pip-audit, gitleaks, npm audit +- Postgres/Redis/MinIO — internal network +- Firewall VPS: 22, 80, 443 + +## Production guards + +`APP_ENV=production` → API **не стартует**, если: + +| Проблема | Env | +|----------|-----| +| Test routes | `ENABLE_TEST_ROUTES=true` | +| OpenAPI | `ENABLE_DOCS=true` | +| Rate limit off | `ENABLE_RATE_LIMIT=false` | +| Cookie без Secure | `COOKIE_SECURE=false` | +| Placeholder JWT | `change-me-*` | +| Дефолтная БД | user:pass | +| Без SSL mode | нет `sslmode=require` | +| Без Redis | JWT revocation | + +## Prod env (минимум) + +| Переменная | Значение | +|------------|----------| +| `APP_ENV` | production | +| `ENABLE_DOCS` | false | +| `ENABLE_TEST_ROUTES` | false | +| `COOKIE_SECURE` | true | +| `ENABLE_RATE_LIMIT` | true | +| `EMAIL_DELIVERY_MODE` | smtp | +| `SEED_DEMO_USERS` | false | +| `DATABASE_URL` | …?sslmode=require | + +## Чеклист + +### Сделано в коде + +- [x] JWT в memory, refresh HttpOnly +- [x] Anti-enumeration auth +- [x] Origin/Referer validation +- [x] RBAC + superuser +- [x] CMS sanitization +- [x] Nginx security headers +- [x] CI security scans +- [x] Install secrets bootstrap + lock +- [x] JWT jti denylist + auth_epoch +- [x] Admin audit log + +### Проверить на staging + +- [ ] `/api/v1/docs` → 404 +- [ ] HSTS за TLS +- [ ] Dry-run recovery секретов +- [ ] ZAP: 0 High/Critical → [release.md](./release.md) +- [ ] k6 pass → [release.md](./release.md) +- [ ] Lighthouse ≥ 85 на `/` diff --git a/infra/docker/.env.production.example b/infra/docker/.env.production.example new file mode 100644 index 0000000..fddd69e --- /dev/null +++ b/infra/docker/.env.production.example @@ -0,0 +1,13 @@ +# Copy to infra/docker/.env.production and fill in production values. +FRONTEND_URL=https://compton.example.com +PUBLIC_BASE_URL=https://compton.example.com +CORS_ORIGINS=["https://compton.example.com"] +TRUSTED_PROXY_IPS=172.16.0.0/12,10.0.0.0/8 +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM=noreply@compton.example.com +TLS_CERT_DIR=./certs +ADMIN_INITIAL_PASSWORD=ChangeMeProductionAdmin1234 +SEED_DEMO_USERS=false diff --git a/infra/docker/.env.staging.example b/infra/docker/.env.staging.example new file mode 100644 index 0000000..5a478dc --- /dev/null +++ b/infra/docker/.env.staging.example @@ -0,0 +1,12 @@ +# Copy to infra/docker/.env.staging and fill in domain/SMTP values. +FRONTEND_URL=https://staging.example.com +PUBLIC_BASE_URL=https://staging.example.com +CORS_ORIGINS=["https://staging.example.com"] +TRUSTED_PROXY_IPS=172.16.0.0/12,10.0.0.0/8 +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM=noreply@example.com +TLS_CERT_DIR=./certs +ADMIN_INITIAL_PASSWORD=ChangeMeStagingAdmin1234 diff --git a/infra/docker/README.md b/infra/docker/README.md new file mode 100644 index 0000000..832b174 --- /dev/null +++ b/infra/docker/README.md @@ -0,0 +1,31 @@ +# Docker deployment + +Краткая справка. Подробный runbook: [docs/deploy.md](../../docs/deploy.md). + +| Файл | Среда | +|------|-------| +| [`../../docker-compose.yml`](../../docker-compose.yml) | Локальная разработка | +| [`../../docker-compose.test.yml`](../../docker-compose.test.yml) | CI / E2E | +| [`docker-compose.staging.yml`](docker-compose.staging.yml) | Staging VPS | +| [`docker-compose.prod.yml`](docker-compose.prod.yml) | Production VPS | + +## Staging + +```bash +cp infra/docker/.env.staging.example infra/docker/.env.staging +# TLS: infra/docker/certs/fullchain.pem, privkey.pem +python3 apps/api/scripts/bootstrap_install.py +./infra/docker/deploy-staging.sh +``` + +## Production + +```bash +cp infra/docker/.env.production.example infra/docker/.env.production +python3 apps/api/scripts/bootstrap_install.py +./infra/docker/deploy-prod.sh infra/docker/.env.production +``` + +## QA на staging + +См. [docs/release.md](../../docs/release.md): E2E, k6, ZAP, Lighthouse. diff --git a/infra/docker/deploy-prod.sh b/infra/docker/deploy-prod.sh new file mode 100755 index 0000000..5b0bcb3 --- /dev/null +++ b/infra/docker/deploy-prod.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +ENV_FILE="${1:-infra/docker/.env.production}" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing $ENV_FILE — copy infra/docker/.env.production.example first." + exit 1 +fi + +if [[ ! -f apps/api/data/secrets/install.env ]]; then + python3 apps/api/scripts/bootstrap_install.py +fi + +docker compose \ + -f infra/docker/docker-compose.prod.yml \ + --env-file "$ENV_FILE" \ + up -d --build + +echo "Production deploy complete." diff --git a/infra/docker/deploy-staging.sh b/infra/docker/deploy-staging.sh new file mode 100755 index 0000000..f8ae60f --- /dev/null +++ b/infra/docker/deploy-staging.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT" + +ENV_FILE="${1:-infra/docker/.env.staging}" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing $ENV_FILE — copy infra/docker/.env.staging.example first." + exit 1 +fi + +if [[ ! -f apps/api/data/secrets/install.env ]]; then + python3 apps/api/scripts/bootstrap_install.py +fi + +docker compose \ + -f infra/docker/docker-compose.staging.yml \ + --env-file "$ENV_FILE" \ + up -d --build + +echo "Staging deploy complete. Verify: curl -k https://\$(grep FRONTEND_URL $ENV_FILE | cut -d= -f2)/api/v1/health" diff --git a/infra/docker/docker-compose.prod.yml b/infra/docker/docker-compose.prod.yml new file mode 100644 index 0000000..74fe759 --- /dev/null +++ b/infra/docker/docker-compose.prod.yml @@ -0,0 +1,135 @@ +# Production stack — VPS deploy. +# Usage: +# python apps/api/scripts/bootstrap_install.py +# cp infra/docker/.env.production.example infra/docker/.env.production +# docker compose -f infra/docker/docker-compose.prod.yml --env-file infra/docker/.env.production up -d --build + +services: + postgres: + image: postgres:16 + restart: unless-stopped + env_file: + - ../../apps/api/data/secrets/install.env + volumes: + - prod_postgres_data:/var/lib/postgresql/data + networks: + - internal + + redis: + image: redis:7-alpine + restart: unless-stopped + networks: + - internal + + minio: + image: minio/minio + restart: unless-stopped + command: server /data --console-address ":9001" + env_file: + - ../../apps/api/data/secrets/install.env + volumes: + - prod_minio_data:/data + networks: + - internal + + api: + build: + context: ../../apps/api + dockerfile: Dockerfile + restart: unless-stopped + environment: + APP_ENV: production + ENABLE_DOCS: "false" + ENABLE_TEST_ROUTES: "false" + COOKIE_SECURE: "true" + ENABLE_RATE_LIMIT: "true" + EMAIL_DELIVERY_MODE: smtp + STORAGE_MODE: s3 + S3_ENDPOINT: http://minio:9000 + REDIS_URL: redis://redis:6379/0 + SEED_DEMO_USERS: "false" + env_file: + - ../../apps/api/data/secrets/install.env + - .env.production + volumes: + - ../../apps/api/data/secrets:/app/data/secrets + - prod_api_logs:/app/data/logs + depends_on: + - postgres + - redis + - minio + networks: + - internal + + web: + build: + context: ../.. + dockerfile: apps/web/Dockerfile + restart: unless-stopped + environment: + VITE_USE_API_PROXY: "true" + VITE_API_URL: http://api:8000 + depends_on: + - api + networks: + - internal + + nginx: + image: nginx:stable-alpine + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ../nginx/default.tls.conf:/etc/nginx/conf.d/default.conf:ro + - ${TLS_CERT_DIR:-./certs}:/etc/nginx/certs:ro + depends_on: + - web + - api + networks: + - internal + + worker: + build: + context: ../../apps/api + dockerfile: Dockerfile + restart: unless-stopped + command: ["python", "-m", "app.worker"] + environment: + APP_ENV: production + CELERY_BROKER_URL: redis://redis:6379/0 + env_file: + - ../../apps/api/data/secrets/install.env + - .env.production + depends_on: + - postgres + - redis + networks: + - internal + + celery-worker: + build: + context: ../../apps/api + dockerfile: Dockerfile + restart: unless-stopped + command: ["celery", "-A", "app.celery_app", "worker", "--loglevel=info"] + environment: + APP_ENV: production + CELERY_BROKER_URL: redis://redis:6379/0 + env_file: + - ../../apps/api/data/secrets/install.env + - .env.production + depends_on: + - postgres + - redis + networks: + - internal + +networks: + internal: + driver: bridge + +volumes: + prod_postgres_data: + prod_minio_data: + prod_api_logs: diff --git a/infra/docker/docker-compose.staging.yml b/infra/docker/docker-compose.staging.yml new file mode 100644 index 0000000..7735259 --- /dev/null +++ b/infra/docker/docker-compose.staging.yml @@ -0,0 +1,83 @@ +# Staging stack — VPS deploy (no bind mounts, production-like settings). +# Usage: +# cp infra/docker/.env.staging.example infra/docker/.env.staging +# docker compose -f infra/docker/docker-compose.staging.yml --env-file infra/docker/.env.staging up -d --build + +services: + postgres: + image: postgres:16 + restart: unless-stopped + env_file: + - ../../apps/api/data/secrets/install.env + volumes: + - staging_postgres_data:/var/lib/postgresql/data + + redis: + image: redis:7-alpine + restart: unless-stopped + + minio: + image: minio/minio + restart: unless-stopped + command: server /data --console-address ":9001" + env_file: + - ../../apps/api/data/secrets/install.env + volumes: + - staging_minio_data:/data + + api: + build: + context: ../../apps/api + dockerfile: Dockerfile + restart: unless-stopped + environment: + APP_ENV: staging + ENABLE_DOCS: "false" + ENABLE_TEST_ROUTES: "false" + COOKIE_SECURE: "true" + ENABLE_RATE_LIMIT: "true" + EMAIL_DELIVERY_MODE: smtp + STORAGE_MODE: s3 + S3_ENDPOINT: http://minio:9000 + REDIS_URL: redis://redis:6379/0 + SEED_DEMO_USERS: "false" + env_file: + - ../../apps/api/.env.example + - ../../apps/api/data/secrets/install.env + - .env.staging + volumes: + - ../../apps/api/data/secrets:/app/data/secrets + - staging_api_logs:/app/data/logs + depends_on: + - postgres + - redis + - minio + + web: + build: + context: ../.. + dockerfile: apps/web/Dockerfile + restart: unless-stopped + environment: + VITE_USE_API_PROXY: "true" + VITE_API_URL: http://api:8000 + depends_on: + - api + + nginx: + image: nginx:stable-alpine + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ../nginx/default.tls.conf:/etc/nginx/conf.d/default.conf:ro + - ${TLS_CERT_DIR:-./certs}:/etc/nginx/certs:ro + depends_on: + - web + - api + +volumes: + staging_postgres_data: + staging_minio_data: + staging_api_logs: diff --git a/infra/k6/mvp-load-test.js b/infra/k6/mvp-load-test.js new file mode 100644 index 0000000..f4c89b8 --- /dev/null +++ b/infra/k6/mvp-load-test.js @@ -0,0 +1,58 @@ +import http from "k6/http"; +import { check, sleep } from "k6"; + +const BASE_URL = __ENV.BASE_URL || "http://localhost:8000"; +const ADMIN_EMAIL = __ENV.ADMIN_EMAIL || "admin@compton.example"; +const ADMIN_PASSWORD = __ENV.ADMIN_PASSWORD || "Admin1234"; + +export const options = { + stages: [ + { duration: "1m", target: 10 }, + { duration: "3m", target: 50 }, + { duration: "1m", target: 0 } + ], + thresholds: { + http_req_duration: ["p(95)<300"], + http_req_failed: ["rate<0.01"] + } +}; + +export default function () { + const roll = Math.random(); + + if (roll < 0.35) { + const res = http.get(`${BASE_URL}/api/v1/content/pages`); + check(res, { "content list 200": (r) => r.status === 200 }); + } else if (roll < 0.6) { + const res = http.post( + `${BASE_URL}/api/v1/auth/login`, + JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }), + { headers: { "Content-Type": "application/json" } } + ); + check(res, { "login 200": (r) => r.status === 200 }); + if (res.status === 200) { + const token = res.json("access_token"); + const me = http.get(`${BASE_URL}/api/v1/users/me`, { + headers: { Authorization: `Bearer ${token}` } + }); + check(me, { "users me 200": (r) => r.status === 200 }); + } + } else if (roll < 0.7) { + const login = http.post( + `${BASE_URL}/api/v1/auth/login`, + JSON.stringify({ email: ADMIN_EMAIL, password: ADMIN_PASSWORD }), + { headers: { "Content-Type": "application/json" } } + ); + if (login.status === 200) { + const refresh = http.post(`${BASE_URL}/api/v1/auth/refresh`, null, { + headers: { Cookie: login.headers["Set-Cookie"] || "" } + }); + check(refresh, { "refresh ok": (r) => r.status === 200 || r.status === 401 }); + } + } else { + const res = http.get(`${BASE_URL}/api/v1/content/pages/about`); + check(res, { "content slug": (r) => r.status === 200 || r.status === 404 }); + } + + sleep(1); +} diff --git a/infra/nginx/default.conf b/infra/nginx/default.conf new file mode 100644 index 0000000..f95dfef --- /dev/null +++ b/infra/nginx/default.conf @@ -0,0 +1,18 @@ +server { + listen 80; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always; + + location /api/ { + proxy_pass http://api:8000; + } + + location / { + proxy_pass http://web:5173; + } +} diff --git a/infra/nginx/default.tls.conf b/infra/nginx/default.tls.conf new file mode 100644 index 0000000..48e8ee4 --- /dev/null +++ b/infra/nginx/default.tls.conf @@ -0,0 +1,49 @@ +server { + listen 80; + server_name _; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name _; + + ssl_certificate /etc/nginx/certs/fullchain.pem; + ssl_certificate_key /etc/nginx/certs/privkey.pem; + + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always; + + location /api/ { + proxy_pass http://api:8000; + 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; + } + + location /static/ { + alias /usr/share/nginx/html/static/; + try_files $uri =404; + } + + location ~ ^/(recipes|recipes_content|components|feed_dispensers|feed_consumption|consumption|reports|lab|lab-profiles|admin)(/|$) { + root /usr/share/nginx/html/static; + try_files $uri $uri/ $uri.html /recipes.html =404; + } + + location / { + proxy_pass http://web:5173; + 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_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} diff --git a/infra/scripts/backup-postgres.sh b/infra/scripts/backup-postgres.sh new file mode 100755 index 0000000..8f5991e --- /dev/null +++ b/infra/scripts/backup-postgres.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +BACKUP_DIR="$ROOT/backups" +mkdir -p "$BACKUP_DIR" + +STAMP="$(date +%F-%H%M)" +FILE="$BACKUP_DIR/postgres-$STAMP.sql.gz" + +docker compose -f "$ROOT/infra/docker/docker-compose.prod.yml" exec -T postgres \ + pg_dump -U "${POSTGRES_USER:-compton_app}" "${POSTGRES_DB:-compton}" | gzip > "$FILE" + +echo "Backup written: $FILE" diff --git a/infra/scripts/health-check.sh b/infra/scripts/health-check.sh new file mode 100755 index 0000000..59c0573 --- /dev/null +++ b/infra/scripts/health-check.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE="${1:-http://localhost:8000}" +BASE="${BASE%/}" + +fail() { + echo "FAIL: $1" + exit 1 +} + +code="$(curl -fsS -o /dev/null -w "%{http_code}" "$BASE/api/v1/health" || echo 000)" +[[ "$code" == "200" ]] || fail "health returned $code" + +root_code="$(curl -fsS -o /dev/null -w "%{http_code}" "${BASE%/api/v1/health}/" 2>/dev/null || curl -fsS -o /dev/null -w "%{http_code}" "$(echo "$BASE" | sed 's|:8000||')/" || echo 000)" +echo "Landing/root HTTP: $root_code" + +echo "OK: health check passed" diff --git a/infra/scripts/smoke-prod.sh b/infra/scripts/smoke-prod.sh new file mode 100755 index 0000000..36dbf68 --- /dev/null +++ b/infra/scripts/smoke-prod.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE="${1:-http://127.0.0.1:8000}" +BASE="${BASE%/}" + +echo "== Smoke: health ==" +curl -fsS "$BASE/api/v1/health" | grep -q '"status":"ok"' + +echo "== Smoke: docs disabled ==" +docs_code="$(curl -s -o /dev/null -w "%{http_code}" "$BASE/api/v1/docs")" +[[ "$docs_code" == "404" || "$docs_code" == "307" ]] || { echo "Expected docs 404, got $docs_code"; exit 1; } + +echo "== Smoke: public content ==" +curl -fsS "$BASE/api/v1/content/pages" | grep -q '"data"' + +echo "All smoke checks passed." diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..76876b6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,12 @@ +{ + "name": "compton", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "compton", + "version": "1.0.0" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0afae29 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "compton", + "private": true, + "version": "1.0.0", + "packageManager": "pnpm@9.15.9", + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild" + ] + }, + "scripts": { + "lint": "pnpm -r --if-present lint", + "typecheck": "pnpm -r --if-present typecheck", + "typecheck:all": "pnpm typecheck && cd apps/api && python -m mypy app", + "test:ci": "pnpm --filter web test:ci && cd apps/api && python -m pytest --cov=app --cov-fail-under=90", + "check:all": "pnpm lint && pnpm typecheck:all && pnpm --filter web test:ci && cd apps/api && python -m pytest --cov=app --cov-fail-under=90 && pnpm --filter web e2e", + "e2e": "pnpm --filter web e2e", + "openapi:types": "python apps/api/scripts/export_openapi.py && pnpm --filter @compton/shared-types generate" + } +} diff --git a/packages/eslint-config/index.js b/packages/eslint-config/index.js new file mode 100644 index 0000000..cadf14e --- /dev/null +++ b/packages/eslint-config/index.js @@ -0,0 +1,5 @@ +module.exports = { + rules: { + "no-console": "warn" + } +}; diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json new file mode 100644 index 0000000..1447c54 --- /dev/null +++ b/packages/eslint-config/package.json @@ -0,0 +1,6 @@ +{ + "name": "@compton/eslint-config", + "version": "1.0.0", + "private": true, + "main": "index.js" +} diff --git a/packages/shared-types/package.json b/packages/shared-types/package.json new file mode 100644 index 0000000..498066c --- /dev/null +++ b/packages/shared-types/package.json @@ -0,0 +1,8 @@ +{ + "name": "@compton/shared-types", + "version": "1.0.0", + "private": true, + "scripts": { + "generate": "node scripts/generate.mjs" + } +} diff --git a/packages/shared-types/scripts/generate.mjs b/packages/shared-types/scripts/generate.mjs new file mode 100644 index 0000000..0af4504 --- /dev/null +++ b/packages/shared-types/scripts/generate.mjs @@ -0,0 +1,19 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const input = resolve(process.cwd(), "../../apps/api/openapi.json"); +const outputDir = resolve(process.cwd(), "src"); +mkdirSync(outputDir, { recursive: true }); + +let raw = "{}"; +try { + raw = readFileSync(input, "utf8"); +} catch { + raw = "{}"; +} + +writeFileSync( + resolve(outputDir, "index.ts"), + `export const openApiSchema = ${raw} as const;\n`, + "utf8" +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..87b7a9a --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,4546 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: {} + + apps/web: + dependencies: + '@ant-design/icons': + specifier: ^6.3.2 + version: 6.3.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@hookform/resolvers': + specifier: ^3.9.1 + version: 3.10.0(react-hook-form@7.81.0(react@19.2.7)) + '@tanstack/react-query': + specifier: ^5.59.0 + version: 5.101.2(react@19.2.7) + antd: + specifier: ^5.29.3 + version: 5.29.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + axios: + specifier: ^1.7.7 + version: 1.18.1 + dompurify: + specifier: ^3.2.2 + version: 3.4.11 + react: + specifier: ^19.0.0 + version: 19.2.7 + react-dom: + specifier: ^19.0.0 + version: 19.2.7(react@19.2.7) + react-hook-form: + specifier: ^7.53.0 + version: 7.81.0(react@19.2.7) + react-router-dom: + specifier: ^7.0.0 + version: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + zod: + specifier: ^3.23.8 + version: 3.25.76 + zustand: + specifier: ^5.0.0 + version: 5.0.14(@types/react@19.2.17)(react@19.2.7) + devDependencies: + '@playwright/test': + specifier: ^1.48.0 + version: 1.61.1 + '@testing-library/jest-dom': + specifier: ^6.6.3 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.0.1 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@types/dompurify': + specifier: ^3.2.0 + version: 3.2.0 + '@types/node': + specifier: ^22.8.6 + version: 22.20.1 + '@types/react': + specifier: ^19.0.0 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.2.17) + '@typescript-eslint/eslint-plugin': + specifier: ^8.10.0 + version: 8.63.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^8.10.0 + version: 8.63.0(eslint@9.39.5)(typescript@5.9.3) + '@vitejs/plugin-react': + specifier: ^4.3.2 + version: 4.7.0(vite@5.4.21(@types/node@22.20.1)) + '@vitest/coverage-v8': + specifier: ^2.1.9 + version: 2.1.9(vitest@2.1.9(@types/node@22.20.1)(jsdom@25.0.1)) + eslint: + specifier: ^9.12.0 + version: 9.39.5 + eslint-plugin-boundaries: + specifier: ^4.2.0 + version: 4.2.2(@typescript-eslint/parser@8.63.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5) + eslint-plugin-react-hooks: + specifier: ^5.1.0 + version: 5.2.0(eslint@9.39.5) + jsdom: + specifier: ^25.0.1 + version: 25.0.1 + typescript: + specifier: ^5.6.3 + version: 5.9.3 + vite: + specifier: ^5.4.21 + version: 5.4.21(@types/node@22.20.1) + vitest: + specifier: ^2.1.9 + version: 2.1.9(@types/node@22.20.1)(jsdom@25.0.1) + + packages/eslint-config: {} + + packages/shared-types: {} + +packages: + + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@ant-design/colors@7.2.1': + resolution: {integrity: sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==} + + '@ant-design/colors@8.0.1': + resolution: {integrity: sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ==} + + '@ant-design/cssinjs-utils@1.1.3': + resolution: {integrity: sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@ant-design/cssinjs@1.24.0': + resolution: {integrity: sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + '@ant-design/fast-color@2.0.6': + resolution: {integrity: sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==} + engines: {node: '>=8.x'} + + '@ant-design/fast-color@3.0.1': + resolution: {integrity: sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==} + engines: {node: '>=8.x'} + + '@ant-design/icons-svg@4.5.0': + resolution: {integrity: sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==} + + '@ant-design/icons@5.6.1': + resolution: {integrity: sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==} + engines: {node: '>=8'} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + '@ant-design/icons@6.3.2': + resolution: {integrity: sha512-B6O5a5XJ4wjtNOfZejXYwHW5zvKV5gYkjGf11dHGLEbKn0ABDGndo41+gfIiXyTFhvESj4XTotuud33mUFid0g==} + engines: {node: '>=8'} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + '@ant-design/react-slick@1.1.2': + resolution: {integrity: sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==} + peerDependencies: + react: '>=16.9.0' + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@emotion/hash@0.8.0': + resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==} + + '@emotion/unitless@0.7.5': + resolution: {integrity: sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.6': + resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.5': + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@hookform/resolvers@3.10.0': + resolution: {integrity: sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==} + peerDependencies: + react-hook-form: ^7.0.0 + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + + '@rc-component/async-validator@5.1.2': + resolution: {integrity: sha512-WYbrZSjzznU1ekD0qFq2qRxt309VoS61MTG5npnFQlKYcoy9IzU8T+ZCIhq5bGAXRbXysABFWTspicMfmWFwow==} + engines: {node: '>=14.x'} + + '@rc-component/color-picker@2.0.1': + resolution: {integrity: sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/context@1.4.0': + resolution: {integrity: sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/mini-decimal@1.1.4': + resolution: {integrity: sha512-xiuXcaCwyOWpD8a8scdExFl+bntNphAW8XeenL1ig2en0AAZY0Pcp4pC0dI22qJ+NvxKn9RoNIoRdqYU3BLH4w==} + engines: {node: '>=8.x'} + + '@rc-component/mutate-observer@1.1.0': + resolution: {integrity: sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/portal@1.1.2': + resolution: {integrity: sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/qrcode@1.1.3': + resolution: {integrity: sha512-aGv6alnn4HbDEsURzKP+jv13rbi1VxmAYfBNZr5GKF1iohMNWy5tAVoJ1E3cOvzMB1kbUPvCXchM6zSFlRGPhA==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/tour@1.15.1': + resolution: {integrity: sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/trigger@2.3.1': + resolution: {integrity: sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + '@rc-component/util@1.12.0': + resolution: {integrity: sha512-AEjPL8JVdohIITaiXokyjL9WQ6tKWWjAYK9QU16tGNE9JaQABBQy+hA4H2Lup5MgXy9yY3iLrbZJheuU13hTdQ==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@tanstack/query-core@5.101.2': + resolution: {integrity: sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==} + + '@tanstack/react-query@5.101.2': + resolution: {integrity: sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==} + peerDependencies: + react: ^18 || ^19 + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/dompurify@3.2.0': + resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==} + deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed. + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@typescript-eslint/eslint-plugin@8.63.0': + resolution: {integrity: sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.63.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.63.0': + resolution: {integrity: sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.63.0': + resolution: {integrity: sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.63.0': + resolution: {integrity: sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.63.0': + resolution: {integrity: sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.63.0': + resolution: {integrity: sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.63.0': + resolution: {integrity: sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.63.0': + resolution: {integrity: sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.63.0': + resolution: {integrity: sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.63.0': + resolution: {integrity: sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + '@vitest/coverage-v8@2.1.9': + resolution: {integrity: sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==} + peerDependencies: + '@vitest/browser': 2.1.9 + vitest: 2.1.9 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + antd@5.29.3: + resolution: {integrity: sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.18.1: + resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.10.42: + resolution: {integrity: sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==} + engines: {node: '>=6.0.0'} + hasBin: true + + brace-expansion@1.1.16: + resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} + + brace-expansion@2.1.2: + resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} + + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.5: + resolution: {integrity: sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001803: + resolution: {integrity: sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + compute-scroll-into-view@3.1.1: + resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + copy-to-clipboard@3.3.3: + resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dompurify@3.4.11: + resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + electron-to-chromium@1.5.389: + resolution: {integrity: sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-import-resolver-node@0.3.9: + resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} + + eslint-module-utils@2.8.1: + resolution: {integrity: sha512-rXDXR3h7cs7dy9RNpUlQf80nX31XWJEyGq1tRMo+6GsO5VmTe4UTwtmonAD4ZkAsrfMVDA2wlGJ3790Ys+D49Q==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-boundaries@4.2.2: + resolution: {integrity: sha512-cjwpZqkCXgfz953bc74uDetOtGVxwgMgNZ7hAKi6Oxck+x4oY6Z/9DzgPqAYhtQdSNHFVg+vhft/lSL+snPMQg==} + engines: {node: '>=14.0.0'} + peerDependencies: + eslint: '>=6.0.0' + + eslint-plugin-react-hooks@5.2.0: + resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.5: + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-mobile@5.0.0: + resolution: {integrity: sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + hasBin: true + + jsdom@25.0.1: + resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json2mq@0.2.0: + resolution: {integrity: sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + micromatch@4.0.7: + resolution: {integrity: sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} + + nwsapi@2.2.24: + resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + rc-cascader@3.34.0: + resolution: {integrity: sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-checkbox@3.5.0: + resolution: {integrity: sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-collapse@3.9.0: + resolution: {integrity: sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-dialog@9.6.0: + resolution: {integrity: sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-drawer@7.3.0: + resolution: {integrity: sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-dropdown@4.2.1: + resolution: {integrity: sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==} + peerDependencies: + react: '>=16.11.0' + react-dom: '>=16.11.0' + + rc-field-form@2.7.1: + resolution: {integrity: sha512-vKeSifSJ6HoLaAB+B8aq/Qgm8a3dyxROzCtKNCsBQgiverpc4kWDQihoUwzUj+zNWJOykwSY4dNX3QrGwtVb9A==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-image@7.12.0: + resolution: {integrity: sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-input-number@9.5.0: + resolution: {integrity: sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-input@1.8.0: + resolution: {integrity: sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + rc-mentions@2.20.0: + resolution: {integrity: sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-menu@9.16.1: + resolution: {integrity: sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-motion@2.9.5: + resolution: {integrity: sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-notification@5.6.4: + resolution: {integrity: sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-overflow@1.5.0: + resolution: {integrity: sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-pagination@5.1.0: + resolution: {integrity: sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-picker@4.11.3: + resolution: {integrity: sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==} + engines: {node: '>=8.x'} + peerDependencies: + date-fns: '>= 2.x' + dayjs: '>= 1.x' + luxon: '>= 3.x' + moment: '>= 2.x' + react: '>=16.9.0' + react-dom: '>=16.9.0' + peerDependenciesMeta: + date-fns: + optional: true + dayjs: + optional: true + luxon: + optional: true + moment: + optional: true + + rc-progress@4.0.0: + resolution: {integrity: sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-rate@2.13.1: + resolution: {integrity: sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-resize-observer@1.4.3: + resolution: {integrity: sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-segmented@2.7.1: + resolution: {integrity: sha512-izj1Nw/Dw2Vb7EVr+D/E9lUTkBe+kKC+SAFSU9zqr7WV2W5Ktaa9Gc7cB2jTqgk8GROJayltaec+DBlYKc6d+g==} + peerDependencies: + react: '>=16.0.0' + react-dom: '>=16.0.0' + + rc-select@14.16.8: + resolution: {integrity: sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==} + engines: {node: '>=8.x'} + peerDependencies: + react: '*' + react-dom: '*' + + rc-slider@11.1.9: + resolution: {integrity: sha512-h8IknhzSh3FEM9u8ivkskh+Ef4Yo4JRIY2nj7MrH6GQmrwV6mcpJf5/4KgH5JaVI1H3E52yCdpOlVyGZIeph5A==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-steps@6.0.1: + resolution: {integrity: sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-switch@4.1.0: + resolution: {integrity: sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-table@7.54.0: + resolution: {integrity: sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-tabs@15.7.0: + resolution: {integrity: sha512-ZepiE+6fmozYdWf/9gVp7k56PKHB1YYoDsKeQA1CBlJ/POIhjkcYiv0AGP0w2Jhzftd3AVvZP/K+V+Lpi2ankA==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-textarea@1.10.2: + resolution: {integrity: sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-tooltip@6.4.0: + resolution: {integrity: sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-tree-select@5.27.0: + resolution: {integrity: sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==} + peerDependencies: + react: '*' + react-dom: '*' + + rc-tree@5.13.1: + resolution: {integrity: sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==} + engines: {node: '>=10.x'} + peerDependencies: + react: '*' + react-dom: '*' + + rc-upload@4.11.0: + resolution: {integrity: sha512-ZUyT//2JAehfHzjWowqROcwYJKnZkIUGWaTE/VogVrepSl7AFNbQf4+zGfX4zl9Vrj/Jm8scLO0R6UlPDKK4wA==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-util@5.44.4: + resolution: {integrity: sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + rc-virtual-list@3.19.2: + resolution: {integrity: sha512-Ys6NcjwGkuwkeaWBDqfI3xWuZ7rDiQXlH1o2zLfFzATfEgXcqpk8CkgMfbJD81McqjcJVez25a3kPxCR807evA==} + engines: {node: '>=8.x'} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-hook-form@7.81.0: + resolution: {integrity: sha512-ocbmr2p5KBMoAfj4WCUvped33lVi1Kd5DuDUvQDnB6VEAacOjPI/jMbtDdbhco4y9ct4xUuCmMY0b/C9L0QHjw==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-is@19.2.7: + resolution: {integrity: sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==} + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-router-dom@7.18.1: + resolution: {integrity: sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.18.1: + resolution: {integrity: sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + resize-observer-polyfill@1.5.1: + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + scroll-into-view-if-needed@3.1.0: + resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + string-convert@0.2.1: + resolution: {integrity: sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + test-exclude@7.0.2: + resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} + engines: {node: '>=18'} + + throttle-debounce@5.0.2: + resolution: {integrity: sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==} + engines: {node: '>=12.22'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toggle-selection@1.0.6: + resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==} + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + 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 + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zustand@5.0.14: + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + '@adobe/css-tools@4.5.0': {} + + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@ant-design/colors@7.2.1': + dependencies: + '@ant-design/fast-color': 2.0.6 + + '@ant-design/colors@8.0.1': + dependencies: + '@ant-design/fast-color': 3.0.1 + + '@ant-design/cssinjs-utils@1.1.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@ant-design/cssinjs': 1.24.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@babel/runtime': 7.29.7 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@ant-design/cssinjs@1.24.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@emotion/hash': 0.8.0 + '@emotion/unitless': 0.7.5 + classnames: 2.5.1 + csstype: 3.2.3 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + stylis: 4.4.0 + + '@ant-design/fast-color@2.0.6': + dependencies: + '@babel/runtime': 7.29.7 + + '@ant-design/fast-color@3.0.1': {} + + '@ant-design/icons-svg@4.5.0': {} + + '@ant-design/icons@5.6.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@ant-design/colors': 7.2.1 + '@ant-design/icons-svg': 4.5.0 + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@ant-design/icons@6.3.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@ant-design/colors': 8.0.1 + '@ant-design/icons-svg': 4.5.0 + '@rc-component/util': 1.12.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + clsx: 2.1.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@ant-design/react-slick@1.1.2(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + json2mq: 0.2.0 + react: 19.2.7 + resize-observer-polyfill: 1.5.1 + throttle-debounce: 5.0.2 + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + 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/core@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.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.5 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + 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.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@0.2.3': {} + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + + '@emotion/hash@0.8.0': {} + + '@emotion/unitless@0.7.5': {} + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.5)': + dependencies: + eslint: 9.39.5 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.6': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.0 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.5': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@hookform/resolvers@3.10.0(react-hook-form@7.81.0(react@19.2.7))': + dependencies: + react-hook-form: 7.81.0(react@19.2.7) + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/schema@0.1.6': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + + '@rc-component/async-validator@5.1.2': + dependencies: + '@babel/runtime': 7.29.7 + + '@rc-component/color-picker@2.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@ant-design/fast-color': 2.0.6 + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@rc-component/context@1.4.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@rc-component/mini-decimal@1.1.4': + dependencies: + '@babel/runtime': 7.29.7 + + '@rc-component/mutate-observer@1.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@rc-component/portal@1.1.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@rc-component/qrcode@1.1.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@rc-component/tour@1.15.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/portal': 1.1.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@rc-component/trigger': 2.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@rc-component/trigger@2.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/portal': 1.1.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-resize-observer: 1.4.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@rc-component/util@1.12.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + is-mobile: 5.0.0 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-is: 19.2.7 + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@tanstack/query-core@5.101.2': {} + + '@tanstack/react-query@5.101.2(react@19.2.7)': + dependencies: + '@tanstack/query-core': 5.101.2 + react: 19.2.7 + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.5.0 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@types/aria-query@5.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/dompurify@3.2.0': + dependencies: + dompurify: 3.4.11 + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@types/trusted-types@2.0.7': + optional: true + + '@typescript-eslint/eslint-plugin@8.63.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.63.0(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.63.0 + '@typescript-eslint/type-utils': 8.63.0(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/utils': 8.63.0(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.63.0 + eslint: 9.39.5 + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.63.0(eslint@9.39.5)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.63.0 + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/typescript-estree': 8.63.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.63.0 + debug: 4.4.3 + eslint: 9.39.5 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.63.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.63.0(typescript@5.9.3) + '@typescript-eslint/types': 8.63.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.63.0': + dependencies: + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/visitor-keys': 8.63.0 + + '@typescript-eslint/tsconfig-utils@8.63.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.63.0(eslint@9.39.5)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/typescript-estree': 8.63.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.63.0(eslint@9.39.5)(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.5 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.63.0': {} + + '@typescript-eslint/typescript-estree@8.63.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.63.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.63.0(typescript@5.9.3) + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/visitor-keys': 8.63.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.63.0(eslint@9.39.5)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5) + '@typescript-eslint/scope-manager': 8.63.0 + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/typescript-estree': 8.63.0(typescript@5.9.3) + eslint: 9.39.5 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.63.0': + dependencies: + '@typescript-eslint/types': 8.63.0 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@22.20.1))': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 5.4.21(@types/node@22.20.1) + transitivePeerDependencies: + - supports-color + + '@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@22.20.1)(jsdom@25.0.1))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 0.2.3 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.2 + tinyrainbow: 1.2.0 + vitest: 2.1.9(@types/node@22.20.1)(jsdom@25.0.1) + transitivePeerDependencies: + - supports-color + + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@22.20.1))': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@22.20.1) + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + agent-base@7.1.4: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.3: {} + + antd@5.29.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@ant-design/colors': 7.2.1 + '@ant-design/cssinjs': 1.24.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@ant-design/cssinjs-utils': 1.1.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@ant-design/fast-color': 2.0.6 + '@ant-design/icons': 5.6.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@ant-design/react-slick': 1.1.2(react@19.2.7) + '@babel/runtime': 7.29.7 + '@rc-component/color-picker': 2.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@rc-component/mutate-observer': 1.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@rc-component/qrcode': 1.1.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@rc-component/tour': 1.15.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@rc-component/trigger': 2.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + classnames: 2.5.1 + copy-to-clipboard: 3.3.3 + dayjs: 1.11.21 + rc-cascader: 3.34.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-checkbox: 3.5.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-collapse: 3.9.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-dialog: 9.6.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-drawer: 7.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-dropdown: 4.2.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-field-form: 2.7.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-image: 7.12.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-input: 1.8.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-input-number: 9.5.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-mentions: 2.20.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-menu: 9.16.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-motion: 2.9.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-notification: 5.6.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-pagination: 5.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-picker: 4.11.3(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-progress: 4.0.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-rate: 2.13.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-resize-observer: 1.4.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-segmented: 2.7.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-select: 14.16.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-slider: 11.1.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-steps: 6.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-switch: 4.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-table: 7.54.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-tabs: 15.7.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-textarea: 1.10.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-tooltip: 6.4.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-tree: 5.13.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-tree-select: 5.27.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-upload: 4.11.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + scroll-into-view-if-needed: 3.1.0 + throttle-debounce: 5.0.2 + transitivePeerDependencies: + - date-fns + - luxon + - moment + + argparse@2.0.1: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + assertion-error@2.0.1: {} + + asynckit@0.4.0: {} + + axios@1.18.1: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.10.42: {} + + brace-expansion@1.1.16: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.2: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.7: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.5: + 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(browserslist@4.28.5) + + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001803: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + check-error@2.1.3: {} + + classnames@2.5.1: {} + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + compute-scroll-into-view@3.1.1: {} + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + copy-to-clipboard@3.3.3: + dependencies: + toggle-selection: 1.0.6 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css.escape@1.5.1: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + dayjs@1.11.21: {} + + debug@3.2.7: + dependencies: + ms: 2.1.3 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + deep-eql@5.0.2: {} + + deep-is@0.1.4: {} + + delayed-stream@1.0.0: {} + + dequal@2.0.3: {} + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dompurify@3.4.11: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + + electron-to-chromium@1.5.389: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + entities@6.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + esbuild@0.21.5: + 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 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-import-resolver-node@0.3.9: + dependencies: + debug: 3.2.7 + is-core-module: 2.16.2 + resolve: 1.22.12 + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.8.1(@typescript-eslint/parser@8.63.0(eslint@9.39.5)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.5): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 8.63.0(eslint@9.39.5)(typescript@5.9.3) + eslint: 9.39.5 + eslint-import-resolver-node: 0.3.9 + transitivePeerDependencies: + - supports-color + + eslint-plugin-boundaries@4.2.2(@typescript-eslint/parser@8.63.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5): + dependencies: + chalk: 4.1.2 + eslint: 9.39.5 + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.8.1(@typescript-eslint/parser@8.63.0(eslint@9.39.5)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.5) + micromatch: 4.0.7 + transitivePeerDependencies: + - '@typescript-eslint/parser' + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-react-hooks@5.2.0(eslint@9.39.5): + dependencies: + eslint: 9.39.5 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.5: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.6 + '@eslint/js': 9.39.5 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + expect-type@1.4.0: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + follow-redirects@1.16.0: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + globals@14.0.0: {} + + gopd@1.2.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + html-escaper@2.0.2: {} + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + ignore@7.0.6: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-mobile@5.0.0: {} + + is-number@7.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + js-tokens@4.0.0: {} + + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + + jsdom@25.0.1: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.6 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.24 + parse5: 7.3.0 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.21.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json2mq@0.2.0: + dependencies: + string-convert: 0.2.1 + + json5@2.2.3: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loupe@3.2.1: {} + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.3.5: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + + math-intrinsics@1.1.0: {} + + micromatch@4.0.7: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + min-indent@1.0.1: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.7 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.16 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.2 + + minipass@7.1.3: {} + + ms@2.1.3: {} + + nanoid@3.3.15: {} + + natural-compare@1.4.0: {} + + node-releases@2.0.51: {} + + nwsapi@2.2.24: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + package-json-from-dist@1.0.1: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + pathe@1.1.2: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.5: {} + + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + + postcss@8.5.16: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + proxy-from-env@2.1.0: {} + + punycode@2.3.1: {} + + rc-cascader@3.34.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-select: 14.16.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-tree: 5.13.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-checkbox@3.5.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-collapse@3.9.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-dialog@9.6.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/portal': 1.1.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-drawer@7.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/portal': 1.1.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-dropdown@4.2.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/trigger': 2.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-field-form@2.7.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/async-validator': 5.1.2 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-image@7.12.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/portal': 1.1.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + classnames: 2.5.1 + rc-dialog: 9.6.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-motion: 2.9.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-input-number@9.5.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/mini-decimal': 1.1.4 + classnames: 2.5.1 + rc-input: 1.8.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-input@1.8.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-mentions@2.20.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/trigger': 2.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + classnames: 2.5.1 + rc-input: 1.8.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-menu: 9.16.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-textarea: 1.10.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-menu@9.16.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/trigger': 2.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-overflow: 1.5.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-motion@2.9.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-notification@5.6.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-overflow@1.5.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-resize-observer: 1.4.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-pagination@5.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-picker@4.11.3(dayjs@1.11.21)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/trigger': 2.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + classnames: 2.5.1 + rc-overflow: 1.5.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-resize-observer: 1.4.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + dayjs: 1.11.21 + + rc-progress@4.0.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-rate@2.13.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-resize-observer@1.4.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + resize-observer-polyfill: 1.5.1 + + rc-segmented@2.7.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-select@14.16.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/trigger': 2.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-overflow: 1.5.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-virtual-list: 3.19.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-slider@11.1.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-steps@6.0.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-switch@4.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-table@7.54.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/context': 1.4.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + classnames: 2.5.1 + rc-resize-observer: 1.4.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-virtual-list: 3.19.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-tabs@15.7.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-dropdown: 4.2.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-menu: 9.16.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-motion: 2.9.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-resize-observer: 1.4.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-textarea@1.10.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-input: 1.8.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-resize-observer: 1.4.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-tooltip@6.4.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + '@rc-component/trigger': 2.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-tree-select@5.27.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-select: 14.16.8(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-tree: 5.13.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-tree@5.13.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-motion: 2.9.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-virtual-list: 3.19.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-upload@4.11.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + rc-util@5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-is: 18.3.1 + + rc-virtual-list@3.19.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@babel/runtime': 7.29.7 + classnames: 2.5.1 + rc-resize-observer: 1.4.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + rc-util: 5.44.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-hook-form@7.81.0(react@19.2.7): + dependencies: + react: 19.2.7 + + react-is@17.0.2: {} + + react-is@18.3.1: {} + + react-is@19.2.7: {} + + react-refresh@0.17.0: {} + + react-router-dom@7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-router: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + + react-router@7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + cookie: 1.1.1 + react: 19.2.7 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) + + react@19.2.7: {} + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + resize-observer-polyfill@1.5.1: {} + + resolve-from@4.0.0: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + 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.3 + + rrweb-cssom@0.7.1: {} + + rrweb-cssom@0.8.0: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + scroll-into-view-if-needed@3.1.0: + dependencies: + compute-scroll-into-view: 3.1.1 + + semver@6.3.1: {} + + semver@7.8.5: {} + + set-cookie-parser@2.7.2: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + string-convert@0.2.1: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-json-comments@3.1.1: {} + + stylis@4.4.0: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + symbol-tree@3.2.4: {} + + test-exclude@7.0.2: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 10.5.0 + minimatch: 10.2.5 + + throttle-debounce@5.0.2: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toggle-selection@1.0.6: {} + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.5): + dependencies: + browserslist: 4.28.5 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite-node@2.1.9(@types/node@22.20.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@22.20.1) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@22.20.1): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.16 + rollup: 4.62.2 + optionalDependencies: + '@types/node': 22.20.1 + fsevents: 2.3.3 + + vitest@2.1.9(@types/node@22.20.1)(jsdom@25.0.1): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@22.20.1)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@22.20.1) + vite-node: 2.1.9(@types/node@22.20.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.20.1 + jsdom: 25.0.1 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + ws@8.21.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + yallist@3.1.1: {} + + yocto-queue@0.1.0: {} + + zod@3.25.76: {} + + zustand@5.0.14(@types/react@19.2.17)(react@19.2.7): + optionalDependencies: + '@types/react': 19.2.17 + react: 19.2.7 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..3ff5faa --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "apps/*" + - "packages/*" diff --git a/scripts/pair_local_wesp_hub.py b/scripts/pair_local_wesp_hub.py new file mode 100644 index 0000000..51217dd --- /dev/null +++ b/scripts/pair_local_wesp_hub.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +"""Pair local WESP hub with site orchestrator (Docker :8000) and write hub state.""" + +from __future__ import annotations + +import json +import secrets +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + +LOG_PATH = Path("/Users/vlad/Documents/wesp new (1)/.cursor/debug-785e22.log") +SESSION = "785e22" +RUN_ID = "pair-v1" +ORCH_URL = "http://127.0.0.1:8000" +WESP_STATE = Path(__file__).resolve().parents[2] / "wesp/data/orchestrator_sync_state.json" +ADMIN_EMAIL = "admin@compton.example" +ADMIN_PASSWORD = "Admin1234" + + +def _log(hypothesis_id: str, location: str, message: str, data: dict) -> None: + payload = { + "sessionId": SESSION, + "runId": RUN_ID, + "hypothesisId": hypothesis_id, + "location": location, + "message": message, + "data": data, + "timestamp": int(time.time() * 1000), + } + LOG_PATH.parent.mkdir(parents=True, exist_ok=True) + with LOG_PATH.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(payload, ensure_ascii=False) + "\n") + + +def _request(method: str, url: str, body: dict | None = None, headers: dict | None = None) -> tuple[int, dict | list | str]: + data = None if body is None else json.dumps(body).encode("utf-8") + hdrs = {"Content-Type": "application/json", **(headers or {})} + req = urllib.request.Request(url, data=data, headers=hdrs, method=method) + try: + with urllib.request.urlopen(req, timeout=15) as resp: + raw = resp.read(4000).decode("utf-8", errors="replace") + try: + return resp.status, json.loads(raw) + except json.JSONDecodeError: + return resp.status, raw + except urllib.error.HTTPError as exc: + raw = exc.read(4000).decode("utf-8", errors="replace") + try: + return exc.code, json.loads(raw) + except json.JSONDecodeError: + return exc.code, raw + + +def _login() -> str: + code, body = _request( + "POST", + f"{ORCH_URL}/api/v1/auth/login", + {"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD}, + {"Origin": "http://localhost:5173"}, + ) + if code != 200 or not isinstance(body, dict) or not body.get("access_token"): + raise RuntimeError(f"login failed: {code} {body}") + return str(body["access_token"]) + + +def _ensure_enterprise(token: str) -> str: + auth = {"Authorization": f"Bearer {token}"} + code, body = _request("GET", f"{ORCH_URL}/api/v1/enterprise/enterprises", headers=auth) + if code != 200: + raise RuntimeError(f"list enterprises failed: {code} {body}") + if isinstance(body, list) and body: + ent_id = str(body[0]["id"]) + _log("H3", "pair:list-enterprises", "using existing enterprise", {"enterprise_id": ent_id, "name": body[0].get("name")}) + return ent_id + slug = f"local-dev-{secrets.token_hex(4)}" + code, created = _request( + "POST", + f"{ORCH_URL}/api/v1/enterprise/enterprises", + {"name": "Local Dev Farm", "slug": slug}, + auth, + ) + if code not in (200, 201) or not isinstance(created, dict): + raise RuntimeError(f"create enterprise failed: {code} {created}") + ent_id = str(created["id"]) + _log("H3", "pair:create-enterprise", "created enterprise", {"enterprise_id": ent_id, "slug": slug}) + return ent_id + + +def _pair_hub(token: str, enterprise_id: str, hub_site_id: str, hub_name: str) -> dict: + auth = {"Authorization": f"Bearer {token}"} + code, start = _request( + "POST", + f"{ORCH_URL}/api/v1/enterprise/pair/start", + {"enterprise_id": enterprise_id, "farm_name": hub_name}, + auth, + ) + if code != 200 or not isinstance(start, dict) or not start.get("code"): + raise RuntimeError(f"pair start failed: {code} {start}") + pair_code = str(start["code"]) + code, confirm = _request( + "POST", + f"{ORCH_URL}/api/v1/enterprise/pair/confirm", + {"code": pair_code, "hub_site_id": hub_site_id, "hub_name": hub_name, "hub_url": "http://127.0.0.1/"}, + ) + if code != 200 or not isinstance(confirm, dict) or not confirm.get("api_key"): + raise RuntimeError(f"pair confirm failed: {code} {confirm}") + _log( + "H3", + "pair:confirm", + "hub paired with orchestrator", + { + "enterprise_id": confirm.get("enterprise_id"), + "hub_site_id": confirm.get("hub_site_id"), + "farm_hub_id": confirm.get("farm_hub_id"), + }, + ) + return confirm + + +def _write_wesp_state(confirm: dict) -> None: + state = { + "upstream_url": ORCH_URL, + "api_key": confirm["api_key"], + "hub_site_id": confirm["hub_site_id"], + "enterprise_id": confirm["enterprise_id"], + "enabled": True, + "catalog_bootstrap_done": False, + "reports_bootstrap_done": False, + "pull_cursor": 0, + } + WESP_STATE.parent.mkdir(parents=True, exist_ok=True) + WESP_STATE.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8") + _log("H3", "pair:write-state", "wrote orchestrator_sync_state.json", {"path": str(WESP_STATE), "enabled": True}) + + +def _verify_hub_api() -> None: + code, body = _request("GET", f"{ORCH_URL}/api/v1/health") + _log("H5", "pair:orch-health", "orchestrator health after pair", {"status": code}) + + state = json.loads(WESP_STATE.read_text(encoding="utf-8")) + hub_site_id = state["hub_site_id"] + api_key = state["api_key"] + auth = {"Authorization": f"Hub {hub_site_id}:{api_key}"} + code, conflicts = _request("GET", f"{ORCH_URL}/api/v1/sync/hub/conflicts", headers=auth) + _log( + "H5", + "pair:hub-conflicts", + "hub-auth conflicts list", + {"status": code, "count": len(conflicts) if isinstance(conflicts, list) else None, "body": conflicts if code != 200 else None}, + ) + + +def _run_hub_bootstrap() -> None: + import subprocess + import sys + + wesp_root = WESP_STATE.parents[1] + script = """ +from app import create_app +from app.services.orchestrator_sync.engine import OrchestratorSyncEngine +from config import Config + +app = create_app(Config) +with app.app_context(): + engine = OrchestratorSyncEngine() + catalog_created = engine.bootstrap_catalog_if_needed() + reports_created = engine.bootstrap_reports_if_needed() + print(f"bootstrap_catalog={catalog_created} bootstrap_reports={reports_created}") + for _ in range(30): + engine.run_cycle() +""" + proc = subprocess.run( + [sys.executable, "-c", script], + cwd=str(wesp_root), + capture_output=True, + text=True, + timeout=120, + ) + _log( + "H1", + "pair:bootstrap", + "hub catalog bootstrap after pair", + { + "returncode": proc.returncode, + "stdout": (proc.stdout or "")[-500:], + "stderr": (proc.stderr or "")[-500:], + }, + ) + if proc.returncode != 0: + print( + "Warning: hub catalog bootstrap subprocess exited non-zero; " + "pending outbox rows will sync on the next hub run.py cycle.", + file=sys.stderr, + ) + + +def _repair_orchestrator_report_payloads(enterprise_id: str) -> None: + import subprocess + + script = f""" +from app.modules.zootech.report_apply import repair_report_payloads_from_event_log +repaired = repair_report_payloads_from_event_log({enterprise_id!r}) +print(f"repair_report_payloads={{repaired}}") +""" + proc = subprocess.run( + ["docker", "exec", "site-api-1", "python3", "-c", script], + capture_output=True, + text=True, + timeout=60, + ) + _log( + "H2", + "pair:repair-reports", + "orchestrator report payload repair from event log", + { + "returncode": proc.returncode, + "stdout": (proc.stdout or "")[-300:], + "stderr": (proc.stderr or "")[-300:], + }, + ) + + +def main() -> None: + hub_site_id = f"local-wesp-{secrets.token_hex(4)}" + token = _login() + enterprise_id = _ensure_enterprise(token) + confirm = _pair_hub(token, enterprise_id, hub_site_id, "Local WESP Hub") + _write_wesp_state(confirm) + _run_hub_bootstrap() + _repair_orchestrator_report_payloads(confirm["enterprise_id"]) + _verify_hub_api() + print("Paired local WESP hub with orchestrator.") + print(f" enterprise_id: {confirm['enterprise_id']}") + print(f" hub_site_id: {confirm['hub_site_id']}") + print(f" state file: {WESP_STATE}") + print("Restart WESP run.py if it was already running, then open Multiserver panel.") + + +if __name__ == "__main__": + main() diff --git a/scripts/smoke_both_projects.py b/scripts/smoke_both_projects.py new file mode 100644 index 0000000..40888fb --- /dev/null +++ b/scripts/smoke_both_projects.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Runtime smoke check: site orchestrator + WESP hub (debug session 785e22).""" + +from __future__ import annotations + +import json +import time +import urllib.error +import urllib.request +from pathlib import Path + +LOG_PATH = Path("/Users/vlad/Documents/wesp new (1)/.cursor/debug-785e22.log") +SESSION = "785e22" +RUN_ID = "smoke-v1" + + +def _log(hypothesis_id: str, location: str, message: str, data: dict) -> None: + payload = { + "sessionId": SESSION, + "runId": RUN_ID, + "hypothesisId": hypothesis_id, + "location": location, + "message": message, + "data": data, + "timestamp": int(time.time() * 1000), + } + LOG_PATH.parent.mkdir(parents=True, exist_ok=True) + with LOG_PATH.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(payload, ensure_ascii=False) + "\n") + + +def _get(url: str, timeout: float = 3.0) -> tuple[int, str]: + req = urllib.request.Request(url, method="GET") + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.status, resp.read(500).decode("utf-8", errors="replace") + except urllib.error.HTTPError as exc: + return exc.code, exc.read(500).decode("utf-8", errors="replace") + except Exception as exc: + return 0, str(exc) + + +def _post_json(url: str, body: dict, headers: dict | None = None, timeout: float = 5.0) -> tuple[int, str]: + data = json.dumps(body).encode("utf-8") + hdrs = {"Content-Type": "application/json", **(headers or {})} + req = urllib.request.Request(url, data=data, headers=hdrs, method="POST") + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.status, resp.read(800).decode("utf-8", errors="replace") + except urllib.error.HTTPError as exc: + return exc.code, exc.read(800).decode("utf-8", errors="replace") + except Exception as exc: + return 0, str(exc) + + +def _get_with_cookies(url: str, cookie_header: str, timeout: float = 5.0) -> tuple[int, str]: + req = urllib.request.Request(url, method="GET", headers={"Cookie": cookie_header}) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.status, resp.read(800).decode("utf-8", errors="replace") + except urllib.error.HTTPError as exc: + return exc.code, exc.read(800).decode("utf-8", errors="replace") + except Exception as exc: + return 0, str(exc) + + +def _wesp_session_cookie() -> str: + data = json.dumps({"login": "admin", "password": "admin"}).encode("utf-8") + req = urllib.request.Request( + "http://127.0.0.1/api/auth/login", + data=data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=5) as resp: + return resp.headers.get("Set-Cookie", "").split(";")[0] + except Exception: + return "" + + +def main() -> None: + install_env = Path(__file__).resolve().parents[1] / "apps/api/data/secrets/install.env" + wesp_state = Path(__file__).resolve().parents[2] / "wesp/data/orchestrator_sync_state.json" + + db_scheme = "missing" + if install_env.is_file(): + for line in install_env.read_text(encoding="utf-8").splitlines(): + if line.startswith("DATABASE_URL="): + db_scheme = "postgresql" if "postgresql" in line else ("sqlite" if "sqlite" in line else "other") + break + _log("H1", "smoke:install.env", "install.env database scheme", {"scheme": db_scheme, "path": str(install_env)}) + + orch_cfg = {"exists": wesp_state.is_file(), "enabled": False, "has_upstream": False} + if wesp_state.is_file(): + try: + state = json.loads(wesp_state.read_text(encoding="utf-8")) + orch_cfg["enabled"] = bool(state.get("enabled")) + orch_cfg["has_upstream"] = bool(state.get("upstream_url")) + except json.JSONDecodeError: + orch_cfg["parse_error"] = True + _log("H3", "smoke:wesp-state", "WESP orchestrator sync state", orch_cfg) + + health_code, health_body = _get("http://127.0.0.1:8000/api/v1/health") + _log("H1", "smoke:site-health", "site API health", {"status": health_code, "body": health_body[:120]}) + + login_code, login_body = _post_json( + "http://127.0.0.1:8000/api/v1/auth/login", + {"email": "admin@compton.example", "password": "Admin1234"}, + headers={"Origin": "http://localhost:5173"}, + ) + _log( + "H4", + "smoke:site-login", + "site API login", + {"status": login_code, "ok": login_code == 200, "has_token": "access_token" in login_body}, + ) + + wesp_code, wesp_body = _get("http://127.0.0.1/") + _log("H2", "smoke:wesp-root", "WESP hub root", {"status": wesp_code, "body_prefix": wesp_body[:80]}) + + web_code, _ = _get("http://127.0.0.1:5173/") + _log("H1", "smoke:site-web", "site web dev server", {"status": web_code}) + + caps_code, caps_body = _get("http://127.0.0.1:8000/api/v1/sync/capabilities") + _log("H5", "smoke:sync-capabilities", "orchestrator sync API", {"status": caps_code, "body": caps_body[:120]}) + + cookie = _wesp_session_cookie() + if cookie: + ctx_code, ctx_body = _get_with_cookies("http://127.0.0.1/api/v1/orchestrator/context", cookie) + ctx = {} + try: + ctx = json.loads(ctx_body) + except json.JSONDecodeError: + pass + _log( + "H3", + "smoke:wesp-orchestrator-context", + "WESP orchestrator context", + { + "status": ctx_code, + "configured": ctx.get("configured"), + "reachable": ctx.get("reachable"), + "enterpriseId": ctx.get("enterpriseId"), + }, + ) + + print("Smoke complete — see debug log:", LOG_PATH) + print(f" site health: {health_code}, login: {login_code}, web: {web_code}") + print(f" wesp hub: {wesp_code}, install.env db: {db_scheme}, orch configured: {orch_cfg.get('has_upstream')}") + + +if __name__ == "__main__": + main() diff --git a/scripts/sync-wesp-ui.sh b/scripts/sync-wesp-ui.sh new file mode 100755 index 0000000..186cc32 --- /dev/null +++ b/scripts/sync-wesp-ui.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Mirror WESP zootech + admin static UI into site web public/static (1:1 paths). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WESP_STATIC="${WESP_STATIC:-$ROOT/../wesp/static}" +DEST="$ROOT/apps/web/public/static" +ORCH_BOOT="$ROOT/apps/web/public/wesp/js/wesp-orchestrator-boot.js" +AUTH_BRIDGE_SRC="$ROOT/apps/web/public/wesp/js/wesp-orchestrator-auth-bridge.js" + +if [ ! -d "$WESP_STATIC" ]; then + echo "WESP static not found: $WESP_STATIC" >&2 + exit 1 +fi + +mkdir -p "$DEST" + +# Full mirror: same /static/* and page HTML paths as on the WESP hub. +rsync -a --delete \ + --exclude '.DS_Store' \ + "$WESP_STATIC/" "$DEST/" + +# Orchestrator-only adapters (never overwritten by hub copy). +mkdir -p "$DEST/js" +if [ -f "$ORCH_BOOT" ]; then + cp "$ORCH_BOOT" "$DEST/js/wesp-orchestrator-boot.js" +fi +if [ -f "$AUTH_BRIDGE_SRC" ]; then + cp "$AUTH_BRIDGE_SRC" "$DEST/js/wesp-orchestrator-auth-bridge.js" +fi +LOGIN_ADAPTER_SRC="$ROOT/apps/web/public/wesp/js/wesp-orchestrator-login.js" +if [ -f "$LOGIN_ADAPTER_SRC" ]; then + cp "$LOGIN_ADAPTER_SRC" "$DEST/js/wesp-orchestrator-login.js" +fi + +echo "Synced WESP static from $WESP_STATIC -> $DEST"