diff --git a/.claude/agents/code-reviewer.md b/.claude/agents/code-reviewer.md index b5866dbf..b3d174f3 100644 --- a/.claude/agents/code-reviewer.md +++ b/.claude/agents/code-reviewer.md @@ -3,6 +3,7 @@ name: code-reviewer description: Code reviewer для GenDesign — проверяет staged/recent changes на безопасность, корректность, производительность, conformance с project conventions. **Read-only** — НЕ пишет код, НЕ коммитит, НЕ пушит. Use proactively ПОСЛЕ того как worker-агент (backend/frontend/devops/database) написал код И ДО `git push`. Возвращает структурированный verdict: ✅ approve / ⚠️ minor changes / ❌ major issues, с конкретными file:line указаниями. tools: Read, Glob, Grep, Bash, WebFetch, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_get_file_contents, mcp__obsidian__obsidian_list_files_in_dir, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__postgres-gendesign__list_schemas, mcp__postgres-gendesign__explain_query, mcp__postgres-gendesign__analyze_query_indexes model: sonnet +memory: project color: red --- @@ -66,7 +67,7 @@ git log -1 --stat # последний commit #### C. Performance ⚡ - N+1 queries: цикл с `db.execute(...)` внутри — должен быть JOIN или `IN (:ids)` -- Missing indexes: новые `WHERE ... = X` columns без index → `mcp__postgres__analyze_query_indexes` +- Missing indexes: новые `WHERE ... = X` columns без index → `mcp__postgres-gendesign__analyze_query_indexes` - Большие `SELECT *` без LIMIT - Async tasks в sync контексте: `asyncio.run()` в Celery → проверить нет ли deadlock - Frontend re-renders: `useMemo` deps, `key` props для list (см. изохрон bug) diff --git a/.claude/agents/deep-code-reviewer.md b/.claude/agents/deep-code-reviewer.md new file mode 100644 index 00000000..7d33f361 --- /dev/null +++ b/.claude/agents/deep-code-reviewer.md @@ -0,0 +1,135 @@ +--- +name: deep-code-reviewer +description: Тщательный staff+ code reviewer для GenDesign — глубокий разбор PR с cross-file impact analysis, security/correctness/perf/conventions/architecture/tests, проверкой vault на похожие incidents, EXPLAIN ANALYZE для SQL, anti-regression check vs `decisions/` и `fixes/`. **Read-only для кода**, имеет merge authority при ✅ APPROVE (мержит сам любой scope через Forgejo). Медленнее обычного `code-reviewer` (3-10 min), но catches больше edge cases. Use для критичных PR (миграции, auth, scrapers с deduplication, breaking API changes, security-sensitive), pre-deploy gate, или когда обычный reviewer вернул ⚠️ MINOR и хочешь второе мнение. Возвращает structured verdict с severity scale (🔴 critical / 🟠 high / 🟡 medium / 🟢 low / ✅ approve) и при APPROVE мержит сам. +tools: Read, Glob, Grep, Bash, WebFetch, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_complex_search, mcp__obsidian__obsidian_get_file_contents, mcp__obsidian__obsidian_batch_get_file_contents, mcp__obsidian__obsidian_list_files_in_dir, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__postgres-gendesign__list_schemas, mcp__postgres-gendesign__explain_query, mcp__postgres-gendesign__analyze_query_indexes, mcp__postgres-gendesign__analyze_workload_indexes, mcp__postgres-gendesign__analyze_db_health +model: opus +memory: project +color: red +--- + +# Deep Code Reviewer — GenDesign + +Ты — staff+ level reviewer. Mandate шире чем у обычного `code-reviewer`: +**не просто читать diff, а понимать blast radius каждого изменения**. + +Дороже и медленнее. Тебя зовут когда: +- PR трогает `data/sql/**`, `backend/alembic/**`, auth / token logic, scrapers с dedup +- Меняется API contract (response shape, новые required поля) +- Cross-domain change (backend + frontend + DB одновременно) +- Pre-deploy gate для критичных релизов +- Обычный `code-reviewer` вернул ⚠️ MINOR и нужно второе мнение +- User явно просит "глубокое ревью" / "проверь всё" + +**Read-only для кода.** Не пишешь, не редактируешь, не коммитишь fixup'ы (это работа worker'ов), +не вызываешь destructive MCP (нет `execute_sql` для DML/DDL — только `explain_query` / `analyze_query_indexes`). + +**НО имеешь merge authority для PR**: при ✅ APPROVE — мержишь сам через +Forgejo REST API (curl) или `mcp__forgejo__merge_pull_request`, любой scope. **`gh` CLI bypassed (2026-05-16).** + +## Memory + +Используй `memory: project` (`./.claude/agent-memory/deep-code-reviewer/MEMORY.md`) — копи recurring patterns: типичные security misses, perf regression patterns, common Vault misses, blocker scenarios прошлых PR. Перед каждым review консультируйся с memory; после — обновляй insights. + +--- + +## Pipeline (5 фаз) + +### Phase 1 — Scope discovery + +Команды для git/Forgejo + файл categorization (P0 / P1 / P2 / P3 / skip): **Read `.claude/agents/deep-review-phases/phase-1-scope.md`** при необходимости детального reference. + +Quick command: +```bash +git status; git diff --staged; git diff origin/main..HEAD; git log origin/main..HEAD --stat; git rev-parse --abbrev-ref HEAD +``` + +Если PR # дан — `mcp__forgejo__get_pull_request` / `list_pr_files` / `get_pr_diff` / `list_pr_reviews`. + +### Phase 2 — Cross-file impact analysis + +**Это отличает deep от обычного review.** Для каждого изменённого P0/P1 файла — найти что зависит. + +Подробный чек-лист и команды: **Read `.claude/agents/deep-review-phases/phase-2-impact.md`**. + +### Phase 3 — Семь измерений (A-G) + +A. Security 🔒 — BLOCK при любой находке +B. Correctness 🎯 — edge cases, race conditions, idempotency, backward compat +C. Performance ⚡ — N+1, missing indexes, lock duration, frontend re-renders +D. Project conventions 📋 — psycopg v3, ruff, TS strict, design tokens +E. Architecture & maintenance 🏗 — DRY, placement, dead code +F. Tests coverage 🧪 — regression test для bug fix +G. Vault & decisions cross-check 📚 — обязательный шаг для P0-P1 + +Детали каждого измерения, конкретные checks, примеры anti-patterns: **Read `.claude/agents/deep-review-phases/phase-3-dimensions.md`**. + +### Phase 4 — Pre-flight checks + +```bash +# 1. Не на main? +git rev-parse --abbrev-ref HEAD +# main → BLOCK + recovery plan (stash → branch → push) + +# 2. Нет ли --no-verify / --force / --amend в недавних коммитах? +git log -10 --pretty=format:"%h %s" +git reflog | head -20 + +# 3. Pre-commit hooks прошли? +# Если в diff видны ruff fix-able issues → hooks были обойдены или не запускались +``` + +### Phase 5 — Verdict + merge + +Structured verdict format, severity glyphs, merge command, "когда BLOCK" и "когда APPROVE" триггеры, auto-merge policy: **Read `.claude/agents/deep-review-phases/phase-5-verdict.md`** для полного шаблона. + +Short skeleton: +```markdown +## Deep Code Review — verdict +### Summary +- Status: ✅/⚠️/🟡/🟠/🔴 +- Files: N (P0:_, P1:_, P2:_, P3:_) +- Lines: +X / -Y · Time: ~M min · PR: #N + +### 🔴 Critical (BLOCK) +- `file:line` — issue + fix snippet + +### 🟠 High / 🟡 Medium / 🟢 Low +… + +### Cross-file impact / Vault cross-check / Performance findings / Positive + +### Recommended next steps +1. … 2. … + +### Complexity / blast radius +- Risk · Reversibility · Merge window +``` + +--- + +## Безопасные правила + +- **НЕ ругай** decisions из `decisions/` (например: `:latest` теги для docker — это явное решение) +- **НЕ требуй тесты** если CLAUDE.md / задача явно говорит skip +- **НЕ заставляй мигрировать legacy** (objective_sync_config, старые scrapers) если scope PR — quick fix +- **Учитывай scope**: bug fix не должен требовать рефакторинга всего модуля +- **Не множь сущности**: если existing pattern в codebase уже работает — не предлагай "лучше" +- Если bot уже approve'нул — учитывай как сигнал, но не освобождает от проверки + +## Forgejo API conventions + +- `$FORGEJO_URL` = `https://git.gendsgn.ru`, `$FORGEJO_TOKEN` = в env (из `~/.claude/settings.json`) +- Owner/repo по умолчанию: `lekss361/gendesign` +- Auth header: `-H "Authorization: token $FORGEJO_TOKEN"` +- Pagination: `?page=1&limit=50` (max 50 на странице) +- Comments на PR — через `/issues//comments` (PR в Forgejo = специальный issue) +- Reviews на PR — через `/pulls//reviews` +- Preferred: `mcp__forgejo__*` MCP tools, fallback — curl + +## Output discipline + +- Файл:line ВСЕГДА конкретные, никогда "где-то в services/" +- Fix suggestion — конкретный code snippet, не "переделай" +- Если не уверен — пометь "🤔 Question" вместо "❌ Issue" +- Severity не инфлируй: medium не critical, low не high +- Краткость: bullet > параграф, snippet > описание diff --git a/.claude/agents/deep-review-phases/phase-1-scope.md b/.claude/agents/deep-review-phases/phase-1-scope.md new file mode 100644 index 00000000..f264693b --- /dev/null +++ b/.claude/agents/deep-review-phases/phase-1-scope.md @@ -0,0 +1,48 @@ +# Deep Review — Phase 1: Scope discovery + +> Supporting reference for `deep-code-reviewer.md`. Read on demand when scope analysis non-trivial. + +## Git-level scope + +```bash +git status +git diff --staged # если pre-push +git diff origin/main..HEAD # если post-push, до merge +git log origin/main..HEAD --stat # сколько коммитов, кто author +git rev-parse --abbrev-ref HEAD # не main ли это +``` + +## Forgejo PR metadata (curl + token из $FORGEJO_TOKEN env) + +```bash +# Все вызовы используют $FORGEJO_URL и $FORGEJO_TOKEN +H="Authorization: token $FORGEJO_TOKEN" +REPO="$FORGEJO_URL/api/v1/repos/lekss361/gendesign" + +# PR metadata: title, body, labels, base, head SHA, mergeable, state, draft +curl -sH "$H" "$REPO/pulls/" | jq '{title,state,mergeable,draft,head:.head.sha,labels:[.labels[].name]}' + +# Full file list (даже большой PR) +curl -sH "$H" "$REPO/pulls//files?limit=50" | jq '.[] | {filename,status,additions,deletions}' + +# Diff целиком +curl -sH "$H" "$REPO/pulls/.diff" + +# Reviews (что уже сказали bot / lekss361) +curl -sH "$H" "$REPO/pulls//reviews" | jq '.[] | {user:.user.login,state,body:.body[0:200]}' + +# Commits в ветке PR +curl -sH "$H" "$REPO/pulls//commits" | jq '.[] | {sha:.sha[0:7],message:.commit.message|split("\n")[0]}' +``` + +Альтернатива — `mcp__forgejo__get_pull_request`, `mcp__forgejo__list_pr_files`, `mcp__forgejo__get_pr_diff`, `mcp__forgejo__list_pr_reviews`, `mcp__forgejo__list_pr_commits`. + +## File categorization (приоритет ревью) + +| Категория | Что | Приоритет | +|---|---|---| +| 🔴 P0 | `data/sql/**`, `backend/alembic/versions/**`, auth code, secrets/tokens | блокирующий | +| 🟠 P1 | `backend/app/api/v1/**`, `backend/app/services/**`, `backend/app/scrapers/**` | критичный | +| 🟡 P2 | `frontend/src/app/**`, `frontend/src/hooks/**`, `docker-compose*.yml`, `Caddyfile`, `.github/workflows/**`, `.forgejo/workflows/**` | важный | +| 🟢 P3 | `frontend/src/components/**` UI-only, tests, CLAUDE.md | стандартный | +| ⚪ skip | lock-файлы, генеренные TS типы (`api-types.ts`), `*.md` без правил | смотреть только если связано с P0-P2 | diff --git a/.claude/agents/deep-review-phases/phase-2-impact.md b/.claude/agents/deep-review-phases/phase-2-impact.md new file mode 100644 index 00000000..e2a7f2a1 --- /dev/null +++ b/.claude/agents/deep-review-phases/phase-2-impact.md @@ -0,0 +1,29 @@ +# Deep Review — Phase 2: Cross-file impact analysis + +> Supporting reference. Это отличает deep от обычного review. + +Для каждого изменённого файла из P0-P1 — найди **что зависит** от него: + +```bash +# Кто импортирует изменённый module? +git grep -l "from app.services.foo" backend/ +git grep -l "import { useFoo }" frontend/src/ + +# Кто использует изменённую функцию / endpoint? +git grep -n "foo_function_name" backend/ frontend/ +git grep -n "/api/v1/parcels/{id}/connection-points" frontend/ + +# Зависимые VIEW в БД при изменении таблицы: +mcp__postgres-gendesign__get_object_details (table) +# → проверь dependencies в выводе +``` + +## Чек-лист blast radius + +- [ ] Изменена сигнатура функции → все callers обновлены? +- [ ] Удалён endpoint → frontend перестал его звать? +- [ ] Поменялась response shape → TanStack Query cache invalidate? TS types regen (`npm run codegen`)? +- [ ] Изменена column type/nullable в БД → есть ли VIEW / MV / index которые сломаются? +- [ ] Удалён column → есть ли scraper / Celery task который пишет туда? +- [ ] Изменён Celery task name / signature → producers обновлены? +- [ ] Удалён ENV var → есть ли deploy.yml / docker-compose / .env.example который ссылается? diff --git a/.claude/agents/deep-review-phases/phase-3-dimensions.md b/.claude/agents/deep-review-phases/phase-3-dimensions.md new file mode 100644 index 00000000..6ec6b2f0 --- /dev/null +++ b/.claude/agents/deep-review-phases/phase-3-dimensions.md @@ -0,0 +1,124 @@ +# Deep Review — Phase 3: Семь измерений + +> Supporting reference. Применяй к файлам P0/P1 минимум, к P2/P3 — выборочно. + +## A. Security 🔒 (BLOCK при любой находке) + +- Hardcoded secrets / API keys / passwords (`grep -E "(api[_-]?key|password|token|secret).*=.*['\"][a-zA-Z0-9]{16,}"` по diff) +- SQL injection: f-string / `.format()` / `+` конкатенация в SQL вместо `:param` +- Path traversal: `open(user_input)` без `Path.resolve()` + проверки префикса +- Auth bypass: новый admin endpoint без `_check_token` / `Depends(require_admin)` +- CORS wildcards: `allow_origins=["*"]` для credentialed endpoints +- Leaked logs: `print(token)`, `logger.info(f"...{password}")` +- Open redirect: `RedirectResponse(url=user_input)` без allowlist +- SSRF: `httpx.get(user_input)` без allowlist домена +- XXE / unsafe YAML / unsafe pickle deserialization +- TOCTOU в file operations / DB checks + +## B. Correctness 🎯 + +- **Edge cases**: `None` / `[]` / empty string / `0` / `float('nan')` обработка +- **Off-by-one**: range bounds, slicing, pagination (limit/offset) +- **Race conditions**: + - Celery worker fork: refs `Bug_Worker_Ready_EarlyReturn` (vault) + - Multiple instances одного task — есть `acks_late=True` + idempotency? + - DB: `SELECT ... FOR UPDATE` где нужен row lock? +- **Transaction symmetry**: каждый `BEGIN` → `COMMIT` или `ROLLBACK`? Nested `with db.begin()` корректны? +- **Idempotency**: + - Migrations: `IF NOT EXISTS` / `CREATE OR REPLACE` / `ON CONFLICT DO UPDATE` + - Endpoints: повтор того же POST → одинаковый результат? + - Scrapers: rerun → нет дубликатов в БД? +- **Backward compat**: + - API response: новые поля только `Optional`, не required + - Removal: 2-stage (deprecate → wait → remove), не сразу + - DB column drop: сначала перестать читать в code → отдельным PR drop +- **Error handling**: + - `except: pass` / `except Exception: pass` — anti-pattern (log + raise или handle конкретное) + - Молчаливый swallow в Celery → task "успешен", но работа не сделана + - Frontend: `.catch()` без error toast → user не видит fail +- **Type safety**: TS `any` / `as unknown as Foo`, Python missing type hints на public API + +## C. Performance ⚡ + +- **N+1 queries**: цикл с `db.execute(...)` или `await session.execute(...)` внутри → JOIN / `IN (:ids)` / `selectinload` +- **Missing indexes**: новый `WHERE col = X` / `ORDER BY col` без index + - Run `mcp__postgres-gendesign__analyze_query_indexes` на новой SQL + - Для критичных запросов: `mcp__postgres-gendesign__explain_query` → проверь Seq Scan на больших таблицах +- **Lock duration в миграции**: + - `ALTER TABLE ... ADD COLUMN NOT NULL DEFAULT x` на больших таблицах → блокирует всю таблицу + - Правильно: `ADD COLUMN NULL` → backfill batched → `SET NOT NULL` + - `CREATE INDEX` → `CREATE INDEX CONCURRENTLY` для production +- **Big SELECT без LIMIT** в Python — рискует OOM +- **`asyncio.run()` в Celery sync task** — deadlock potential, проверь нет ли nested loop +- **Frontend re-renders**: + - `useMemo` / `useCallback` deps правильные? + - `key` props на list items — стабильные (не `index` если items can re-order) + - TanStack Query `staleTime` / `gcTime` — не дефолтные если данные дорогие +- **N+1 в GraphQL / REST chains** на фронте — параллельные `Promise.all` или batch endpoint + +## D. Project conventions 📋 + +Python (`.claude/rules/backend.md`): +- `psycopg v3` only — `import psycopg2` = BLOCK +- `CAST(:x AS type)` в SQL — НЕ `:x::type` (CAST trap) +- `httpx` только, не `requests` +- `async def` для FastAPI handlers +- `logger.*` не `print()` в prod code +- ruff line ≤100 +- SAVEPOINT pattern в циклах с per-item commit + +TS / React (`.claude/rules/frontend.md` + `ui-tokens.md` + `ui-conventions.md`): +- strict, no `any` +- TanStack Query для HTTP, не bare `useEffect + fetch` +- `safeUrl()` для user-controlled hrefs (XSS prevention) +- Next.js 15 app router patterns (`'use client'` только где нужно) +- Design tokens — только из `ui-tokens.md` списка (нет inline `#hex`) + +SQL (`.claude/rules/sql.md`): +- `data/sql/NN_topic.sql` — numbered, sequential +- `BEGIN; ... COMMIT;` обёртка для DDL +- `IF EXISTS` / `IF NOT EXISTS` — idempotent +- VIEW dependencies — `DROP VIEW IF EXISTS X CASCADE` если refactor + +Deploy / infra (`.claude/rules/deploy.md`): +- `.env.runtime` не коммитится +- `caddy reload` после Caddyfile change +- Forgejo Actions secrets через UI, не в коде + +## E. Architecture & maintenance 🏗 + +- **Right placement**: + - HTTP / serialization → `api/v1/` + - Business logic → `services/` + - Long-running / scheduled → `tasks.py` (Celery) / `services//` + - Не запихивай business logic в FastAPI handler +- **DRY**: дубль логики в parcels.py vs analytics_queries.py vs services/? +- **Dead code**: TODO / FIXME без issue link, commented-out blocks, unused imports +- **Naming**: `_check_token` vs `check_admin_token` — consistency с остальным codebase +- **Module boundaries**: scraper не должен импортить из `api/v1/`; frontend hook не зовёт другой hook напрямую если можно через service + +## F. Tests coverage 🧪 + +- Если PR меняет бизнес-логику в `services/` — есть ли тест в `backend/tests/`? +- Если added endpoint — есть ли smoke test? +- Если fix bug — есть ли regression test покрывающий именно тот сценарий? +- НЕ требуй тесты для тривиальных правок (typo, refactor без logic change) +- Если CLAUDE.md / project явно говорит "не пиши тесты пока не попросят" — не настаивай + +## G. Vault & decisions cross-check 📚 + +**Обязательный шаг для P0-P1 PR.** + +``` +mcp__obsidian__obsidian_simple_search "" +mcp__obsidian__obsidian_simple_search "<имя функции / эндпоинта>" +``` + +Проверь: +- [ ] `fixes/` — была ли уже эта же ошибка? Новый код не повторяет старый bug? +- [ ] `decisions/` — есть ли архитектурный decision который этот PR нарушает? +- [ ] `limitations/` — known issue который этот код должен учитывать? +- [ ] `code/patterns/` — есть ли established pattern для этого типа изменения? + +Если PR fix bug — должна быть entry в `fixes/.md` (per CLAUDE.md rule #6). +Если PR architectural change — должна быть entry в `decisions/.md`. diff --git a/.claude/agents/deep-review-phases/phase-5-verdict.md b/.claude/agents/deep-review-phases/phase-5-verdict.md new file mode 100644 index 00000000..75f15be2 --- /dev/null +++ b/.claude/agents/deep-review-phases/phase-5-verdict.md @@ -0,0 +1,125 @@ +# Deep Review — Phase 5: Verdict + merge + +> Supporting reference. Verdict шаблон + auto-merge policy. + +## Verdict format (строго) + +```markdown +## Deep Code Review — verdict + +### Summary +- **Status**: ✅ APPROVE / ⚠️ MINOR / 🟡 MEDIUM / 🟠 HIGH / 🔴 BLOCK +- **Files reviewed**: N (P0: a, P1: b, P2: c, P3: d) +- **Lines**: +X / -Y +- **Time spent**: ~M min +- **PR**: #N (если есть) + +### 🔴 Critical (BLOCK push/merge) +- [ ] `path/file.py:42` — описание + конкретный fix (code snippet) +- [ ] `data/sql/95_foo.sql:12` — `ALTER TABLE ... NOT NULL DEFAULT 0` на 50M rows заблокирует таблицу. Split: ADD NULL → backfill batched → SET NOT NULL отдельным PR + +### 🟠 High (должен быть пофиксен до merge) +- [ ] `path/file.ts:84` — race condition: state update в onSettled() при unmounted component +- [ ] `backend/app/api/v1/parcels.py:201` — N+1: цикл с `await db.execute(...)`, переделать на `IN (:ids)` + +### 🟡 Medium (желательно fix, но не блокирующее) +- [ ] `file.py:120` — `except Exception: pass` swallow'ит ошибки + +### 🟢 Low / nits (можно отдельным PR) +- [ ] `file.py:50` — naming `data` слишком generic, `parcels_by_district` + +### Cross-file impact analysis +- `backend/app/services/site_finder/foo.py` изменил signature `find_parcels()` → + callers обновлены: `api/v1/parcels.py:88` ✅, но `tasks.py:142` всё ещё передаёт старый kwarg ❌ +- `frontend/src/types/site-finder.ts` regen нужен — `npm run codegen` не запущен в этом PR + +### Vault cross-check +- Похожий fix был в `fixes/Bug_Worker_Ready_EarlyReturn.md` — паттерн использован корректно ✅ +- Decision `decisions/2026-05-12-no-mutable-defaults.md` — этот PR не нарушает +- Если bug fix, но нет entry в `fixes/` → ⚠️ нужна docs (medium severity) + +### Performance findings +- `EXPLAIN ANALYZE` на новом query `parcels.find_by_district`: + - Seq Scan на `rosreestr_parcels` (5.2M rows) — нужен index `(district_id, status)` + - Estimated cost 12.4k → с индексом 0.8 + +### Positive observations +- ✅ `ON CONFLICT DO UPDATE` использован корректно в bulk_upsert +- ✅ `CREATE INDEX CONCURRENTLY` в миграции + +### Recommended next steps +1. Worker (backend-engineer): fix 🔴 #1 + 🟠 #1, push fixup commit +2. После fixup: run `npm run codegen` в frontend +3. Перед merge: проверить EXPLAIN на проде через MCP postgres +4. Создать vault entry `fixes/.md` + +### Сomplexity / blast radius score +- **Risk**: Low / Medium / High / Critical +- **Reversibility**: Easy revert / DB migration needs manual rollback / Data loss on revert +- **Recommended merge window**: anytime / business hours only / pre-deploy freeze +``` + +## Когда BLOCK (🔴) — обязательные триггеры + +1. Hardcoded secret / token / password в commit (даже test files) +2. SQL injection vector +3. Auth bypass на admin endpoint +4. Migration без rollback plan (если меняет / удаляет данные) +5. Breaking API change без deprecation / migration path +6. Удаление prod данных без явного approval +7. `--no-verify` / `--force` / `--amend` в недавних коммитах ветки +8. **Прямой push в main** (HEAD == main, не feature branch) +9. `ALTER TABLE ... NOT NULL DEFAULT` на таблице >1M rows без batched backfill +10. `CREATE INDEX` (не CONCURRENTLY) на prod таблице >1M rows +11. `DROP COLUMN` / `DROP TABLE` без 2-stage rollout +12. Celery task без idempotency маркера (вернёт дубли при retry) +13. Scraper без dedup ключа в `ON CONFLICT` + +## Когда APPROVE (✅) + +- Нет critical / high issues +- Cross-file impact проверен — все callers consistent +- Conventions OK (psycopg v3, httpx, ruff, TS strict) +- Backward compat сохранён +- Performance не регрессирует (или регрессия документирована и acceptable) +- Vault entry создана для нетривиальных fixes / decisions +- Tests есть для новой бизнес-логики (если applicable per scope) +- Pre-flight: на feature branch, нет force / no-verify в истории + +## Auto-merge policy (PR review mode) + +При review открытых Forgejo PR — если verdict **✅ APPROVE** (нет 🔴/🟠/🟡): +**мержи сам, любой scope** (user override 2026-05-16: «после аппрув в ревью можешь мерджить все что угодно»). + +### Pre-merge checks (все обязательны) + +1. `curl -sH "$H" "$REPO/pulls/" | jq '{state,mergeable,draft,head:.head.sha}'` + → `state=open` (не уже merged), `mergeable=true` (no conflicts), `draft=false` +2. Head SHA не изменился после твоего review (нет fixup push'ей с момента scan'а файлов) +3. CI status passing — Forgejo Actions UI или `curl -sH "$H" "$REPO/commits//status"` + +### Merge команда (Forgejo) + +```bash +# Через MCP (preferred): +mcp__forgejo__merge_pull_request (Do=squash, delete_branch_after_merge=true) + +# Или curl: +curl -sH "$H" -H "Content-Type: application/json" \ + -X POST "$REPO/pulls//merge" \ + -d '{"Do":"squash","delete_branch_after_merge":true}' + +# Post-merge confirmation comment: +curl -sH "$H" -H "Content-Type: application/json" \ + -X POST "$REPO/issues//comments" \ + -d '{"body":"Merged via deep-code-reviewer — verdict ✅ APPROVE."}' +``` + +Forgejo API возвращает пустой body при успехе merge → verify через GET pulls/ что `state == merged`. + +### Когда НЕ мержить даже при APPROVE + +- `mergeable=false` (conflicts) → comment "approved but has conflicts — rebase needed" +- CI failing → comment "approved but CI red — wait for green" +- Draft PR → comment "approved, ready when undrafted" +- Head SHA changed после твоего scan'а → НЕ мержь stale verdict, re-review нужен diff --git a/.claude/agents/qa-tester.md b/.claude/agents/qa-tester.md new file mode 100644 index 00000000..f7256ad6 --- /dev/null +++ b/.claude/agents/qa-tester.md @@ -0,0 +1,115 @@ +--- +name: qa-tester +description: QA tester for GenDesign — runs post-deploy verification после успешного merge+deploy. Use proactively СРАЗУ после того как deploy.yml завершился success на main. Проверяет: HTTP endpoints (curl), UI smoke (playwright MCP), data integrity (postgres MCP), error tracking (glitchtip MCP), regression vs known-good baseline. НЕ для unit tests (это работа worker'а во время разработки) и НЕ для pre-merge CI (это GHA). +tools: Read, Glob, Grep, Bash, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_get_file_contents, mcp__obsidian__obsidian_list_files_in_dir, mcp__obsidian__obsidian_append_content, mcp__postgres-gendesign__execute_sql, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__playwright__browser_navigate, mcp__playwright__browser_navigate_back, mcp__playwright__browser_snapshot, mcp__playwright__browser_take_screenshot, mcp__playwright__browser_console_messages, mcp__playwright__browser_network_requests, mcp__playwright__browser_click, mcp__playwright__browser_fill_form, mcp__playwright__browser_type, mcp__playwright__browser_press_key, mcp__playwright__browser_wait_for, mcp__playwright__browser_close, mcp__playwright__browser_evaluate, mcp__glitchtip__glitchtip_issues, mcp__glitchtip__glitchtip_latest_event +model: sonnet +memory: project +color: cyan +--- + +# QA Tester — GenDesign + +Ты — QA-инженер, который запускается **после успешного deploy** на прод. Цель: подтвердить что фича/фикс реально работает в проде, а не только "тесты прошли в CI". + +## Когда тебя зовут + +Main session вызывает тебя, когда: +1. PR замержен review-bot'ом → deploy.yml на main завершился `success` → есть свежий sha на проде +2. Hotfix manual deploy через `workflow_dispatch` → success +3. Скраперы / Celery beat задачи которые проявляются только под нагрузкой + +**НЕ для тебя:** +- Unit-тесты во время разработки (pytest / vitest — это backend/frontend-engineer) +- Pre-merge CI gate (GHA) +- Ревью кода (`code-reviewer`) +- Расследование root cause багов (`tech-analyst`) + +## Вход (что тебе дают) + +Main session передаёт: +- **Sha на проде** (`e97f96a` или подобный) +- **PR number** который только что задеплоился (`#190`) +- **Что именно тестировать**: новый endpoint? UI change? scraper? migration? +- **Ожидаемое поведение** (1-2 sentences, golden path) +- Опционально: **известные edge cases** + +## Что ты делаешь + +### 1. Скоуп тестов из PR diff + +Forgejo PR files через curl (`$FORGEJO_URL`/`$FORGEJO_TOKEN` в env): + +```bash +H="Authorization: token $FORGEJO_TOKEN" +REPO="$FORGEJO_URL/api/v1/repos/lekss361/gendesign" +curl -sH "$H" "$REPO/pulls//files?limit=50" | jq -r '.[].filename' +``` + +→ определи changed paths: +- `backend/app/api/v1/.py` → curl endpoint этого роутера +- `frontend/src/app/**` → chrome-devtools navigate + snapshot +- `data/sql/NN_*.sql` → postgres MCP проверь schema (column exists, index there, view OK) +- `backend/app/scrapers/**` или `backend/app/workers/**` → запусти scraper, polling DB до terminal status +- `docker-compose*.yml` / `Caddyfile` → smoke production URLs + проверь containers running + +### 2. Golden path + +Один happy-path test для главного use case PR'а: +- API: `curl -fsS https://gendsgn.ru/` → 200 + ожидаемый JSON shape +- UI: `mcp__playwright__browser_navigate` → `browser_snapshot` → проверь expected element visible → `browser_console_messages` (0 errors) + `browser_network_requests` (0 failed 4xx/5xx) +- DB: SELECT и проверь что новые rows / columns / constraints на месте +- Scraper: POST trigger → polling `cadastre_jobs.status` пока `done|failed|cancelled` → проверь `targets_done == targets_total` без `error` +- Errors: `mcp__glitchtip__glitchtip_issues` после теста — нет новых ERROR events связанных с PR scope + +### 3. Regression sanity (2-3 quick checks) + +- `curl -fsS https://gendsgn.ru/` → 200 (frontend up) +- Известный читабельный endpoint (`/api/v1/parcels/`) → 200, ответ не пустой +- `docker ps` через SSH (если разрешено) — все critical containers `healthy` / `Up` + +### 4. Edge cases (если указаны) + +User передал "проверь что invalid input → 400, не 500" — гоняй именно это. + +## Выход (report main session, ≤200 слов) + +``` +## QA verdict: ✅ PASS | ⚠️ PARTIAL | ❌ FAIL + +### Tested (PR #N, sha XXX on prod) +- Endpoint POST /api/v1/X → 200, response OK +- Frontend /admin/Y → element visible, 0 console errors +- DB: column Z exists в таблице, NOT NULL, default OK + +### Regression baseline +- frontend root: 200 ✅ +- backend health: 200 ✅ +- known parcel API: 200 ✅ + +### Failures (если есть) +- [path/endpoint] → [actual vs expected] → [возможная гипотеза] + +### Suggestion +- если PASS → можно закрыть task +- если PARTIAL → main session делегирует back to worker для фикса +- если FAIL → rollback (revert PR) или urgent fixup PR +``` + +## Доступные ресурсы + +- **HTTP** curl/wget — для API smoke +- **playwright MCP** — UI smoke: `browser_navigate` + `browser_snapshot` + `browser_console_messages` + `browser_network_requests` + `browser_click`/`browser_fill_form`/`browser_type` для интерактива. `browser_evaluate` для inline JS (eg. читать localStorage / data-attributes) +- **postgres-gendesign MCP** — для DB queries (read-only по умолчанию) +- **glitchtip MCP** — `glitchtip_issues` / `glitchtip_latest_event` для проверки что после deploy не появилось новых exception events +- **Forgejo REST API** (curl + `$FORGEJO_TOKEN` env) — `$REPO/pulls/` / `/files` / `.diff` для скоупа теста; `POST $REPO/issues//comments` для verdict; `POST $REPO/issues` + `GET $REPO/issues?state=open` для регрессий. Auth header: `-H "Authorization: token $FORGEJO_TOKEN"`. Owner/repo: `lekss361/gendesign`. Полные примеры команд: см. `.claude/agents/deep-code-reviewer.md` Phase 1 +- **Vault** — credentials (`meta/00_credentials.md`), известные fixes (`fixes/`) +- **SSH gendesign** — производ docker ps / health checks (только read-only ops, никаких compose down / restart) + +## Запреты + +- ❌ Не пиши код / не правь файлы — ты только тестируешь +- ❌ Не делай deploy / restart containers / drop tables +- ❌ Не мержи PR / не пушь +- ❌ Не доверяй "тесты прошли в CI" — verify в проде через реальные HTTP/UI/SQL вызовы +- ❌ Не зацикливайся на 100% coverage — golden path + 2-3 regression + edge cases указанные в задаче, всё +- ❌ Не пиши длинных отчётов — verdict + 5-10 bullets, всё что нужно main session'у diff --git a/.claude/agents/tech-analyst.md b/.claude/agents/tech-analyst.md index f86bb00a..10988e79 100644 --- a/.claude/agents/tech-analyst.md +++ b/.claude/agents/tech-analyst.md @@ -2,7 +2,7 @@ name: tech-analyst description: Tech analyst / planner для GenDesign. Use proactively когда пользователь приходит с НЕЧЁТКОЙ задачей ("надо добавить фичу X", "почему так медленно", "что починить дальше"), для рефакторинговых разборов, для cross-domain задач затрагивающих 2+ слоя (backend + frontend + db). Read-only — НЕ пишет код. Возвращает структурированный план: что делать, в каком порядке, какой subagent отвечает за каждый шаг. tools: Read, Glob, Grep, WebSearch, WebFetch, mcp__obsidian__obsidian_simple_search, mcp__obsidian__obsidian_complex_search, mcp__obsidian__obsidian_get_file_contents, mcp__obsidian__obsidian_batch_get_file_contents, mcp__obsidian__obsidian_list_files_in_dir, mcp__obsidian__obsidian_get_recent_changes, mcp__postgres-gendesign__list_objects, mcp__postgres-gendesign__get_object_details, mcp__postgres-gendesign__list_schemas, mcp__postgres-gendesign__explain_query, mcp__postgres-gendesign__analyze_query_indexes, mcp__postgres-gendesign__analyze_db_health, mcp__postgres-gendesign__get_top_queries, Bash -model: sonnet +model: haiku color: yellow --- diff --git a/.claude/rules/backend.md b/.claude/rules/backend.md index 0913c909..39aee438 100644 --- a/.claude/rules/backend.md +++ b/.claude/rules/backend.md @@ -76,6 +76,22 @@ Reference: vault `Bug_Nspd_Geo_Sql_Injection_May14`. - ❌ Hardcode credentials — `os.environ.get("KEY")` или `settings.KEY` - ❌ `Any` без комментария почему +## Web probing (scrapers — use playwright MCP, not curl) + +При исследовании веба (как страница реагирует на параметры/фильтры, что меняется в DOM, какие селекторы возвращают данные, anti-bot behavior) — **сначала** `mcp__playwright__*`, а не curl/curl_cffi. + +**Why:** Реальный браузер видит финальный URL после canonicalization + JS state без anti-bot ловушек. Curl попадает в captcha на 2-3-й итерации. User rule (2026-05-23): «если что-то не понятно лучше через плэйврайт смотри чем через консоль». + +**How to apply:** +- Discover URL params / filter behavior → `browser_navigate` + `browser_snapshot` +- Verify selectors / count cards → `browser_evaluate` с JS возвращающим counts +- Inspect network → `browser_network_requests` +- Captcha detection → `browser_snapshot` + check для challenge elements + +**Curl/curl_cffi оставляем** для actual scraping в production-скриптах **после** того как param set уже известен (playwright медленнее + heavier для bulk fetch). + +Reference incident: 2026-05-23 reverse-engineering Yandex SERP filter params через curl — captcha на 2-й итерации, recovery через cookie-warm session. С playwright сразу видели бы JS state. + ## Worker crash checklist Worker падает на старте → скорее всего import error: `pyproject.toml` имеет dep но `uv.lock` не обновлён → `cd backend && uv lock` → commit lock → push. diff --git a/.claude/rules/deploy.md b/.claude/rules/deploy.md index d7bfcaa5..84480ea7 100644 --- a/.claude/rules/deploy.md +++ b/.claude/rules/deploy.md @@ -3,11 +3,26 @@ paths: - docker-compose*.yml - Caddyfile - .github/workflows/** + - .forgejo/workflows/** - scripts/setup-*.sh --- # Deploy conventions +## Post-deploy verification (MANDATORY) + +После КАЖДОГО успешного merge+deploy на main — **немедленно** spawn `qa-tester` subagent с playwright smoke по тому, что merged. + +**Why:** Без auto-smoke user сам ловит prod TypeErrors / 4xx / overlay bugs которые qa-tester catched бы за 2 мин. Плохой UX + потерянное время на P0 hotfixes уже после того как user увидел проблему. User rule (2026-05-18): «проверяй через плэйврайт сразу после деплоя». + +**How to apply:** +- После `mcp__forgejo__get_pull_request` `merged: true` И подтверждения deploy success (HTTP 200 на routes / GHA run finished / user сказал «deploy прошёл») — `Agent` subagent_type=qa-tester. +- Prompt: routes изменённые в PR + expected behaviour из PR body + console errors check + critical API calls (4xx/5xx detection) + regression baseline (`/`, `/health`, `/landing/stats`). +- Background OK — wait notification, не блокировать остальной flow. +- НЕ дожидаться user feedback «не работает» — qa-tester ловит сам. + +Reference incident: PR #346 (2026-05-18) deploy → user сам нашёл prod 500 на by-bbox, потом 400 на analyze, потом TypeError на poi-score, потом UI overlap. Каждое ловилось бы playwright smoke по `/site-finder/analysis/{cad}`. + ## GHA path triggers - `backend/**`, `frontend/**`, `Caddyfile`, `docker-compose.prod.yml`, `data/sql/*.sql` → `deploy.yml` (main stack) diff --git a/.claude/rules/frontend.md b/.claude/rules/frontend.md index 2606f6cf..6aad4009 100644 --- a/.claude/rules/frontend.md +++ b/.claude/rules/frontend.md @@ -32,6 +32,22 @@ cd frontend && npm run codegen Обновляет `src/types/openapi.ts` из live OpenAPI. Без этого frontend build упадёт на missing types. +## package.json + lockfile sync (CRITICAL — deploy aborts on mismatch) + +Если меняешь `frontend/package.json` (add/remove/bump dep) — **обязан** также: + +```bash +cd frontend && npm install --legacy-peer-deps --no-audit --no-fund +``` + +И закоммитить обновлённый `frontend/package-lock.json` в той же ветке. + +**Why:** `frontend/Dockerfile` использует `npm ci --legacy-peer-deps` — этот режим **требует** точного match между `package.json` и `package-lock.json`. Mismatch → `npm error Missing: @ from lock file` → builder fails → `docker compose --abort-on-container-exit` → **весь deploy aborts** (frontend + backend rollback). + +- Pre-push check: `git diff main..HEAD -- frontend/package.json frontend/package-lock.json` — если только один из двух тронут → STOP, regen lock. +- Imports без deps entry (TypeScript авто-resolve через transitive) — **latent bomb** до first `npm ci`. +- Reference incident: PR #344 (2026-05-17) добавил `lucide-react` без regen lockfile → deploy #135 fail → P0 hotfix PR #345 (commit `6ee20294f2`). + ## Prettier / lint - 2 spaces, trailing comma, double quotes (config) diff --git a/.claude/rules/git-pr.md b/.claude/rules/git-pr.md index 53be5a0e..ae4a9e6f 100644 --- a/.claude/rules/git-pr.md +++ b/.claude/rules/git-pr.md @@ -2,6 +2,7 @@ paths: - .claude/** - .github/** + - .forgejo/** --- # Git + PR workflow @@ -10,18 +11,33 @@ paths: ## Branch + PR (MANDATORY) +**Default workflow — background session через `claude --bg`:** + ``` -1. git checkout -b feat/ # или fix/ refactor/ docs/ chore/ perf/ +1. claude --bg --name "feat-" "task description" + → supervisor создаёт worktree от forgejo/main автоматически + (settings worktree.baseRef=default, bgIsolation=git) +2. Session работает: код → commit → push → mcp__forgejo__create_pull_request +3. PR URL появляется в agent view как row с зелёной/жёлтой/красной ● status dot +4. User делает review через external Claude window (post-push) +5. После merge — `Ctrl+X dwa раза` в agent view = удаление session + worktree +``` + +**Foreground workflow** (когда нужен интерактив, ad-hoc fixes): + +``` +1. git fetch forgejo && git checkout -b feat/ forgejo/main 2. [worker делает код, staged] 3. code-reviewer subagent на staged changes (pre-push) 4. main session: git commit -m "feat(scope): ..." -5. git push -u origin feat/ -6. gh pr create --title "..." --body "$(cat < +6. mcp__forgejo__create_pull_request 7. Вернуть PR URL пользователю -8. СРАЗУ ScheduleWakeup polling (90-120s) ``` -**Никаких direct push в main.** Только через PR merge. +**Никаких direct push в main.** Только через PR merge через Forgejo. `gh` CLI bypassed (2026-05-16) — все PR-операции через `mcp__forgejo__*` или curl + `$FORGEJO_TOKEN`. + +**Session naming MANDATORY** для background spawn: `--name "--"` (kebab-case, ≤30 chars). Примеры: `feat-tradein-cron`, `fix-nominatim-rate-limit`, `chore-claude-config`. Resume: `claude --resume feat-tradein-cron`. Filter в agent view: type `feat-tradein` в input. ## Commit messages @@ -48,35 +64,32 @@ Closes #N ## Polling loop -После `gh pr create` — СРАЗУ `ScheduleWakeup` (90-120s): +**Preferred (background session):** dispatch'нул через `claude --bg --name "feat-X"` → session сама делает PR + monitor. В `claude agents` row показывает: +- 🟡 yellow ● = waiting on checks/review +- 🟢 green ● = merged +- 🔴 red ● = checks failed +- ⚫ grey ● = draft/closed -1. `gh pr view --json state,mergeable,comments,headRefOid` -2. `state == MERGED` → stop polling -3. Новый bot comment: распарси `` - - **SHA guard**: `marker.sha7 == headRefOid[:7]` — иначе устаревший approval до fixup-push, игнорируй - - **Scope guard** (см. ниже): если blocked paths → ping user, не auto-merge - - `verdict=approve` + SHA match + scope OK → `gh pr merge --squash --delete-branch` - - `verdict=changes` → fixup commits + push + re-poll +Не нужно отдельный polling loop — supervisor сам обновляет row каждые 15s. Peek (`Space`) для контекста, attach (`Enter`) если нужен fixup. + +**Foreground fallback** — если работаешь без agents view: `Skill loop` (self-paced, 90-120s) или background Bash poll: + +1. `mcp__forgejo__get_pull_request` (или `curl -sH "$H" "$REPO/pulls/"`) → читай `state`, `mergeable`, `head.sha` +2. `state == merged` → stop polling +3. Новый review/comment: `mcp__forgejo__list_pr_reviews` / `list_issue_comments`. Парсь marker `` + - **SHA guard**: `marker.sha7 == head.sha[:7]` — иначе устаревший approval до fixup-push, игнорируй + - `verdict=approve` + SHA match → `mcp__forgejo__merge_pull_request` (squash + delete branch) + - `verdict=changes` → fixup commits + push в `forgejo feat/` + re-poll 4. Нет новых comments → re-schedule 60s -5. **Cap**: 30 iter без resolution → stop, ping user. Blocked-scope cap = 5. +5. **Cap**: 30 iter без resolution → stop, ping user. -## Auto-merge scope +## Auto-merge policy -**✅ Разрешено** (PR diff целиком в этих путях — bot merge без human): -- `CLAUDE.md`, `README.md`, `docs/**` -- `frontend/src/app/**` UI-only без новых endpoints -- `frontend/public/**` -- `.claude/agents/**`, `.claude/rules/**`, `memory/feedback_*.md` +**Любой scope** — bot мержит при `verdict=approve` + SHA match. Blocked-list снят 2026-05-16 ([Auto-merge any scope] memory rule). -**❌ Блокировано** (любой файл из списка → human approval): -- `data/sql/**`, `backend/alembic/versions/**` -- `backend/app/api/v1/**`, `backend/app/services/**`, `backend/app/scrapers/**` -- `docker-compose*.yml`, `Caddyfile`, `.github/workflows/**` -- Любой файл с упоминанием secret / token / password / credential -- PR меняет `Critical workflow rules` или `Auto-merge scope` в CLAUDE.md -- PR меняет `Auto-merge scope` / blocked-list в любом `.claude/rules/*.md` (self-extending rules → нужен human) - -Touches both → more restrictive (block). +**Жёсткие исключения** (даже при APPROVE — НЕ merge, ping user): +- Diff содержит литеральный secret/token/password/credential (40-char hex, API keys, JWT, и т.д.) — security tripwire +- PR меняет блок `## Auto-merge policy` в этом файле или `Critical workflow rules` в CLAUDE.md (self-extending guard, decided 2026-05-24 — изменение правил всегда через human, предотвращает bot-loop где bot сам расширяет свои merge права) ## Sequential PRs @@ -93,10 +106,24 @@ Issues ≥ 1.5 day → 3-4 sub-PRs: **Foundation → Schema → Workers → Inte - **Pre-push** (локально): spawn `code-reviewer` subagent на staged changes → lint pass (security, correctness, conventions). Блокирует push при 🔴 критикал. - **Post-push** (внешнее окно Claude): делает review после PR create, постит комменты от `lekss361`. Main session НЕ дублирует — только acting on review comments (fixup commits). +## Multi-session workflow (Agent View) + +**Default daily driver:** одно окно с `claude agents` всегда открыто. Все задачи dispatch'аются оттуда через type prompt + Enter — каждая создаёт background session с собственной worktree. + +- **Параллелизм**: 3-5 sessions одновременно (>5 = bottleneck на review, не на Claude per Anthropic метрика) +- **Pin (`Ctrl+T`)** для long-running sessions (scraper monitors, deploy watchers) — supervisor не убьёт через 1h idle +- **Cleanup**: `Ctrl+X dwa раза` после merge → session + worktree удалены атомарно +- **NEVER** parallel sessions на one file — каждая ест в own worktree, last-merge wins (используй sequential PR rule выше) + +**Manual `git worktree add` deprecated** — используй `claude --bg --name X`, supervisor сам isolation делает с `baseRef: default` (свежая ветка от main, не от твоей stale-сессии). + +**Worktree cleanup** (cron weekly): `scripts/cleanup-merged-worktrees.sh` — удаляет worktrees для merged branches. Запускать вручную или wee­k­ly cron. + ## Запреты -- ❌ `git push origin main` / direct push в main -- ❌ `gh pr merge` без approval (human "merge it" или bot verdict=approve + SHA match) +- ❌ `git push forgejo main` / direct push в main +- ❌ `mcp__forgejo__merge_pull_request` без approval (human "merge it" или bot verdict=approve + SHA match) +- ❌ `gh pr *` — bypassed 2026-05-16, используй Forgejo MCP или curl + `$FORGEJO_TOKEN` - ❌ `--no-verify` / `--amend` / `--no-edit` / `--force` без явного approval - ❌ `@claude` в PR comments — plain text only (`feedback_no_claude_mentions`) - ❌ Параллельные PR на одни файлы (`feedback_sequential_prs`) diff --git a/.claude/rules/ui-conventions.md b/.claude/rules/ui-conventions.md new file mode 100644 index 00000000..8bfbb3da --- /dev/null +++ b/.claude/rules/ui-conventions.md @@ -0,0 +1,129 @@ +--- +paths: + - frontend/src/components/** + - frontend/src/app/**/page.tsx + - frontend/src/app/**/layout.tsx +--- + +# UI components & data-viz conventions — GenDesign + +> Часть набора `ui-*.md`. Tokens см. `ui-tokens.md`, microcopy/copy — `ui-microcopy.md`. + +## Tone & audience + +- **Аудитория**: финдиректор / продукт-директор девелопера №4-15 РФ (PRINZIP-tier). НЕ архитектор, НЕ ритейл. +- **Tone of voice**: DOM.РФ × CoStar × Bloomberg-density. «Государственная аналитика, которая считает за тебя». НЕ стартап-AI-маркетинг. +- **Информационная плотность > красивости.** Цифры читаются с проектора с 5 метров. + +## Component conventions + +### KpiCard +- Использовать `` из `components/analytics/KpiCard.tsx` — НЕ городить inline. +- KPI row: `display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px;` — НЕ flex+wrap (даёт ровные ряды). +- 3-4 KPI выше fold — максимум. Больше → под аккордеон «Детали». +- `delta.positive: null` (нейтральный) — для метрик где «больше/меньше» не имеет валентности (например, средняя цена). + +### Section +- Заворачивать каждый блок графика/таблицы в `
`. +- `subtitle` — одно предложение объясняющее «откуда данные / что значит цифра», не маркетинг. +- `right` slot — для controls (filter dropdown, export button, slicer chips). + +### Headline-bar (тёмная плашка-вердикт) +- Pattern: `--bg-headline` фон, `--fg-on-dark` текст, padding 14/18, radius 12. +- **Одно предложение-ответ JTBD**: «В районе X класс Y · S м² → mix А · срок B мес · выручка C ₽». +- Под основным текстом — separator + caveats / context (ИЖК ставка, POI, дата snapshot). +- Размещать выше всех KPI-карточек. + +### Badge +- Использовать ``. НЕ inline-span с hex. +- Если компонента нет — создать в `components/ui/Badge.tsx`, не дублировать stylesheet'ы по pages. + +### Drawer (drill-down правым слайдером) +- Pattern для `/analytics/developers` детализации. НЕ навигировать на отдельную страницу при клике на строку таблицы — открывать right drawer (CoStar pattern). +- Width 480px desktop, full-screen на phone. + +## Data-viz (ECharts) rules + +- **Ось Y ВСЕГДА с подписью** (`yAxis.name`). Без подписи — это нарушение review. +- **Tooltip ВСЕГДА с единицей измерения**: «1 240 ₽/м²», «73%», «28 мес». +- **Цвета серий** — только из `--viz-1..5` в указанном порядке. +- **Forecast / prediction** — пунктирная линия + band ±p25/p75 (HouseCanary). Не показывать точку без интервала. +- **Никаких 3D charts**, никаких pie с >5 сегментов (заменить на bar). Treemap — OK. +- Lazy-mount тяжёлых charts через `dynamic(() => import(...), { ssr: false })` (см. `objects/[id]/page.tsx`). + +## Iconography + +- **Lucide React only** — `lucide-react` пакет. Размеры 16 / 20 / 24px, stroke 1.5px. +- **НЕ emoji в UI** — 🚀 ❄ 💼 📊 → ``, ``, ``, ``. +- Trend-glyph в KPI — Unicode arrow `↑ ↓ →` или Lucide, НЕ emoji. +- Иконка-без-текста → `aria-label`. + +## Карта (Leaflet) — когда и как + +- **Аналитика overview без карты — фейл** (Bnmap-parity). На `/analytics` нужна секция «Карта рынка» с точками новостроек DOM.РФ, цвет по `sold%`. +- Leaflet через `dynamic(..., { ssr: false })` — НИКОГДА не импортировать в server component. +- Высота карты: 360px (drill-in), 480px (overview), full-screen toggle FAB на phone. +- Layers: OSM base + наш geojson. Не подключать платные tile-серверы без согласования. + +## Above-the-fold rule + +Финдиректор закрывает вкладку через 8 секунд если не видит: +1. Что это за страница (h1 + один subtitle). +2. **Один вердикт** одним предложением (headline-bar). +3. **3 главных числа** (KPI row). + +Всё остальное (sliders, deep tables, comparables) — **под аккордеон** «Подробнее». По умолчанию закрыто. + +## Responsive (см. issue #66) + +- 3 контекста: phone `<768`, tablet `768-1280`, desktop `>1280`. НЕ Tailwind sm/md/lg напрямую. +- Phone: nav-tabs → horizontal scroll + snap, НЕ hamburger. +- Phone: KPI grid → 1 col full-width. +- Chart высоты фиксированные, `ResizeObserver` для ECharts. +- НЕ делать PWA / offline. Аудитория с десктопа. + +## Accessibility (минимум, не максимум) + +- Контраст AA для body (4.5:1). Hex-token подобраны под AA на белом / slate-900. +- `aria-label` для icon-only buttons. +- Focus ring видимый (НЕ `outline: none` без замены). +- Кликабельный элемент — ` + + + + + + +
+ вердикт квартала +

Дефицит средне-большого жилья 80+ м²: 37% сделок Росреестра, но 11% портфеля ДОМ.РФ. Окно сужается к Q3 2026.

+ источник: ДОМ.РФ · Росреестр · март 2026 +
+ +
+
+
Объём строительства
+
5,82млн м²
+
в активной фазе по области
+ ↑ 4,1% YoY +
+
+
Sold % (медиана)
+
61,4 %
+
объекты в продаже > 6 мес
+ ↓ 3,2 пп QoQ +
+
+
Средняя цена
+
186тыс ₽/м²
+
первичка, март 2026
+ ↑ 1,8% MoM +
+
+
Активных новостроек
+
347
+
в открытой продаже Я.Недв
+ +12 за месяц +
+
+ +
+
+
+

Карта рынка — новостройки Свердловской области

+
347 объектов в активной продаже · цвет точки — sold % за 6 месяцев · клик → drill-in
+
+
+ + + + + +
+
+
+ + + + + + + + + + + + + Академический + ВИЗ + Центр + Пионерский + Юго-Запад + Уралмаш + Эльмаш + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+
+

Sold % за 6 мес

+
< 40 % · риск затоваривания
+
40–65 % · средний темп
+
65–80 % · хороший темп
+
80+ % · близко к закрытию
+
+ +
+
Карта стилизована для превью. В продукте — Leaflet с базой 2GIS, клик → /analytics/objects/[id]
+
+ +
+
+
+

Динамика рынка — 14 месяцев

+
Объём ДДУ (Росреестр) и две линии: sold % портфеля и средняя цена м²
+
+
+ + + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + февмарапрмай + июниюлавгсен + октноядекянв + февмар + + +
+ +
+
+ +
+
+
+

Парадокс портфеля по комнатности

+
Доля сегмента в портфеле ДОМ.РФ против доли в сделках Росреестра за 14 мес
+
+
+ + + + +
+
+
+
+
Сегмент
+
портфель ДОМ.РФ vs сделки Росреестра
+
Δ пп
+
+
+
Студии до 30 м²
+
24%
9%
+
−15
+
+
+
1-комн. 30–45 м²
+
31%
24%
+
−7
+
+
+
2-комн. 45–65 м²
+
22%
23%
+
+1
+
+
+
3-комн. 65–85 м²
+
13%
24%
+
+11
+
+
+
4+ комн. / 85+ м²
+
11%
20%
+
+9
+
+
+
+ +
+
+
+
+

Pipeline по году ввода

+
Объём (тыс. м²) в разрезе классов
+
+
+ +
+
+
+
2024
1 240
+
2025
1 820
+
2026
2 110
+
2027
2 580
+
2028
1 970
+
+
+ комфорт + бизнес + премиум + элит +
+
+ +
+
+
+

Активные новостройки

+
Топ-7 по объёму · Я.Недвижимость · обновлено 4 ч назад
+
+
+ +
+
+ + + + + + + + + + + + + +
ЖКДевелоперSold %Ср. цена
Каменные палаткиPRINZIP78 %173 ₽
Северная коронаБрусника71 %198 ₽
ЧкаловАтомстройком.64 %156 ₽
МалевичФорум-групп58 %182 ₽
УютАтлас Девел.52 %141 ₽
Гольф-паркКОРТРОС41 %224 ₽
Эталон CityЭталон38 %167 ₽
+
+
+ +
+
+
+

Концентрация ЖК по районам

+
Площадь плитки — объём строительства · цвет — sold % · клик → топ девелоперов района
+
+
+ + + +
+
+ +
+ +
+ ! +
+ Caveat по данным. Sold % считается по объектам в продаже более 6 месяцев; новые ЖК со стартом < 6 мес не входят в медиану, но видны на карте. Росреестр может задерживать ДДУ до 14 дней — берём 7-дневный backfill при еженедельном пересчёте. +
+
+ +
+ Развёрнутый разрез по 17 районам области +
Sold %, средний срок, цена м², объём в работе — по каждому из 17 районов с фильтром по классу. Открывается отдельной таблицей в /analytics/districts.
+
+
+ Методология подсчёта sold % +
Sold % = (общее число проданных лотов по объекту) / (общее число лотов в продаже). Источник: ДДУ Росреестра матчатся с лотами портфеля ДОМ.РФ по cadastral_id + comm_name fuzzy match (порог 0.85, pg_trgm).
+
+
+ Сравнение с январём 2025 (год к году) +
Sold % медиана: 64,6 % → 61,4 % (−3,2 пп). Объём строительства: 5,59 → 5,82 млн м² (+4,1 %). Средняя цена: 166 → 186 тыс ₽/м² (+12 %). Разрыв спрос-предложение в 3к/4+ сегменте увеличился: с +14 пп до +20 пп суммарно.
+
+ + + + + diff --git a/frontend/public/developers.html b/frontend/public/developers.html new file mode 100644 index 00000000..b7ede0f6 --- /dev/null +++ b/frontend/public/developers.html @@ -0,0 +1,515 @@ + + + + + +/analytics/developers — Топ-15 девелоперов · gendsgn + + + + + + + + + +
+
+
+ gendsgn +
+
+ Свердловская обл. · 2025-Q1 +
AD
Алексей · PRINZIP (demo)
+
+
+
+ +
+ + + +
+
+

Топ-15 девелоперов Свердловской области

+
Выбран: PRINZIP · кликните на строку leaderboard, чтобы сравнить с другим девелопером
+
+
+ +
+
+ + + + +
+
+
Девелопер
+
PRINZIP
+
Екатеринбург · с 2007 г.
+
+ комфорт + бизнес +
+
+
+
Объём в Свердл
+
412тыс м²
+
14 ЖК в активной фазе
+ ↑ +28 тыс vs Q4 2025 +
+
+
Sold % (медиана)
+
74%
+
по 14 ЖК в продаже
+ +13 пп vs рынок (61 %) +
+
+
Средний метраж
+
58м²
+
взвешенно по лотности
+ −9 м² vs рынок (67 м²) +
+
+ + +
+
+
+

Leaderboard — топ-15 по объёму

+
Sticky первая колонка при scroll · клик по строке → drawer с профилем девелопера
+
+
+ + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#ДевелоперОбъём, тыс м²ЖКSold %Δ 14 месСр. цена ₽/м²Ср. метражТоп-район
01PRINZIP
14 ЖК · комфорт+бизнес
4121474 %+8,2 пп17358Академический
02Брусника
11 ЖК · комфорт+бизнес
3681171 %+5,1 пп19861Центр
03Атомстройкомплекс
9 ЖК · комфорт
342964 %+2,8 пп15654ВИЗ
04Форум-групп
8 ЖК · комфорт+бизнес
298862 %+0,4 пп18263Юго-Запад
05КОРТРОС
6 ЖК · бизнес+премиум
284654 %−3,2 пп22472Пионерский
06Атлас Девелопмент
7 ЖК · комфорт
241752 %−4,8 пп14152Уралмаш
07Эталон
5 ЖК · бизнес
198548 %−6,1 пп16759Центр
08Унистрой
5 ЖК · комфорт
182567 %+3,4 пп14855Уктус
09Атмосфера
4 ЖК · комфорт
156469 %+4,1 пп15257Пионерский
10Эфес
4 ЖК · бизнес+премиум
142458 %+0,8 пп21176Центр
11Семь
3 ЖК · комфорт
128372 %+6,7 пп16756ВИЗ
12Гринстрой
3 ЖК · комфорт
116349 %−5,4 пп13451Сортировка
13Лидер
3 ЖК · комфорт
104361 %+1,2 пп15854Уралмаш
14Гранд
3 ЖК · бизнес
96344 %−8,1 пп18968Юго-Запад
15УГМК-Девел.
2 ЖК · бизнес+премиум
84256 %+0,2 пп23174Верх. Пышма
+
+
+ Показано 15 из 87 девелоперов в выборке. Порог — > 20 тыс м² в активной фазе. + Показать все 87 → +
+
+ + +
+
+
+

Velocity-карта — объём против Δ sold %

+
Кто продаёт быстрее в своём масштабе · ось X — тыс. м² в активной фазе · ось Y — Δ sold % за 14 мес
+
+
+ + + + +
+
+
+ + + + + + + + + + + + + + + + 0 пп — рынок + + + быстрее рынка + медленнее рынка + + + + +10 + +5 + 0 + −5 + −10 + + + + 0 + 100 + 200 + 300 + 400 + 500 + + + + + + + + PRINZIP · +8,2 + + + Брусника · +5,1 + + + Атомстройком. · +2,8 + + + Форум-групп + + + КОРТРОС · −3,2 + + + Атлас Девел. + + + Эталон · −6,1 + + + Унистрой + + + Атмосфера + + + Эфес + + + Семь · +6,7 + + + Гринстрой + + + Лидер + + + Гранд + + + УГМК-Девел. + + +
Δ sold % за 14 мес
+
Объём, тыс. м² в активной фазе
+
+
+ Размер точки — число ЖК в портфеле + Цвет — статус velocity (зелёный — растёт, красный — падает) + 38 / 40 ЖК в выборке · 2 объекта < 6 мес в продаже исключены +
+
+ + +
+
+
+

Сравнение sold % — PRINZIP vs топ-2

+
Кумулятивный sold % помесячно по всему портфелю; PRINZIP жирнее, Брусника и Атомстройком. для контекста
+
+
+ + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + 100 % + 75 % + 50 % + 25 % + + + + фев 25 + апр + июн + авг + окт + дек + фев 26 + мар 26 + + +
+
+ PRINZIP 74 % · +8,2 пп + Брусника 71 % · +5,1 пп + Атомстройком. 64 % · +2,8 пп +
+
+ + +
+
+
+

Карточка выбранного девелопера — PRINZIP

+
Открывается drawer'ом справа при клике на строку leaderboard (не navigate)
+
+ +
+
+
+

Концентрация по районам

+
+
Академический
62 %
+
Центр
18 %
+
Юго-Запад
11 %
+
Пионерский
6 %
+
ВИЗ
3 %
+
+
+
+

Распределение по комнатности

+
+
Студии
18 %
+
1-к
34 %
+
2-к
28 %
+
3-к
14 %
+
4+
6 %
+
+
+ Gap vs рынок: доля 3к/4+ — 20 % vs 44 % рыночного спроса. Недозагрузка в большом сегменте. +
+
+
+

Топ-5 ЖК · drill-in

+ + + + + + + + + +
ЖКSold %
Каменные палатки78 %
Хрустальный81 %
Прогресс72 %
Светлая роща69 %
Парк-холл52 %
+
+
+
+ +
+ + + diff --git a/frontend/public/index.html b/frontend/public/index.html new file mode 100644 index 00000000..a499f9bb --- /dev/null +++ b/frontend/public/index.html @@ -0,0 +1,571 @@ + + + + + +gendsgn — превью нового сайта + + + + + + + + + +
+
+ gendsgn +
+ обзор макетов + + 5 экранов + + UI brief · v1 +
+
+
+ +
+ +
+ Превью нового сайта +

Фасад для пилотных девелоперов

+

Маркетинговый лендинг и четыре продуктовых экрана: рыночная аналитика по Свердловской области, топ-15 девелоперов с velocity-картой, поиск участка по кадастру и детальный анализ с sidebar-навигацией. Сделано по UI brief — Inter, tabular-nums, density-first как DOM.РФ / Bloomberg.

+
    +
  • 5экранов
  • +
  • Intertabular numerics
  • +
  • 1280 pxdesktop · до 412 px responsive
  • +
  • v1UI brief · черновик
  • +
+
+ +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ Маркетинговый лендинг +

Лендинг для пилотных девелоперов

+

Hero «Спрос смещается быстрее, чем ваш портфель», парадокс «что строят / что продают» по 5 сегментам, три продуктовых модуля, прайс 30 тыс ₽/мес с честным дисклеймером pre-revenue, FAQ.

+
+ Hero + parados + 3 product cards + Pricing + FAQ +
+ Открыть превью +
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ Продуктовый экран +

/analytics — Свердловский рынок

+

Headline-вердикт о дефиците 80+ м², 4 KPI выше fold, карта новостроек по sold %, парадокс портфеля vs реальные сделки, treemap районов.

+
+ Headline-bar + Карта + Treemap +
+ Открыть превью +
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ Продуктовый экран +

/developers — Топ-15 девелоперов

+

Search + leaderboard на 15 девелоперов, scatter «Velocity vs объём» с осями (X: тыс м², Y: Δ sold % за 14 мес), сравнение PRINZIP с топ-2 за 14 месяцев.

+
+ Leaderboard + Scatter + Export CSV +
+ Открыть превью +
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ Site Finder · Screen 1 +

/site-finder — карта-first entry

+

Полноэкранная SVG-карта Екатеринбурга, поле кадастра с примером формата, недавние участки, sticky-карточка выбранного парцела с 7 параметрами и кнопками «Карточка ЕГРН» + «Анализ».

+
+ Карта-first + Кадастр +
+ Открыть превью +
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ Site Finder · Screen 2 + 3 +

/site-finder/анализ — sidebar на 5 разделов

+

Sidebar: Инфо / Сети 1–1,5 км / Продажи конкурентов (3.1/3.2/3.3) / Оценка / Атмосфера. Раздел 3 — 8-секционный модуль с 4 настройками и 4 выводами (планировки с превью, остатки, ассортимент vs скорость, рекомендация).

+
+ Sidebar nav + 8-секц. модуль + JS pane-switch +
+ Открыть превью +
+
+ +
+ + + +
+ +
+ gendsgn · превью v1 · собрано из UI brief + 5 экранов · Inter + tabular-nums · brief HEX-палитра +
+ + + diff --git a/frontend/public/landing.html b/frontend/public/landing.html new file mode 100644 index 00000000..0d8afba8 --- /dev/null +++ b/frontend/public/landing.html @@ -0,0 +1,644 @@ + + + + + +gendsgn — аналитика спроса для девелоперов + + + + + + + + + + + + +
+ + +
+
+
+

Цифры рынка перед тем, как заливать фундамент.

+

Что строят на рынке и что на самом деле продаётся — по 6,83 млн ДДУ Росреестра, портфелю ДОМ.РФ и активным новостройкам Яндекс.Недвижимости. Свердловская область, обновление еженедельно.

+ +

3 из 3 пилотных мест открыто · старт пилота — 2 недели · фикс-цена 30 000 ₽/мес

+
+ +
+
+ + +
+ Вердикт квартала +

Дефицит средне-большого жилья 80+ м²: 37% сделок Росреестра, но 11% портфеля ДОМ.РФ. Окно сужается к Q3 2026.

+ данные: ДОМ.РФ · Росреестр · март 2026 +
+ + +
+
+
Объём строительства
+
5,8 млн м²
+
Свердловская обл., активная фаза
+ ↑ 4,1% YoY +
+
+
Sold % (медиана)
+
61,4 %
+
по объектам в продаже > 6 мес
+ ↓ 3,2 пп QoQ +
+
+
Средняя цена
+
186 тыс ₽
+
за м², первичка, март 2026
+ ↑ 1,8% MoM +
+
+
Активных новостроек
+
347
+
в Яндекс.Недв · в открытой продаже
+ +12 за месяц +
+
+ + +
+
+

Парадокс портфеля: что строят vs что продаётся

+
Доля каждого сегмента в активном портфеле ДОМ.РФ против доли в сделках Росреестра за 14 месяцев
+
+ Открыть в /analytics → +
+ +
+
+ +
+
+
Студии до 30 м²
+
+
24%
+
9%
+
+
−15 пп
+
+
+
1-комн. 30–45 м²
+
+
31%
+
24%
+
+
−7 пп
+
+
+
2-комн. 45–65 м²
+
+
22%
+
23%
+
+
+1 пп
+
+
+
3-комн. 65–85 м²
+
+
13%
+
24%
+
+
+11 пп
+
+
+
4+ комн. / 85+ м²
+
+
11%
+
20%
+
+
+9 пп
+
+
+
+
+ Иллюстративные данные пилотной выборки. В продуктовой версии — разрез по 17 районам области, классу жилья и году ввода. Источник: ДОМ.РФ (портфель в активной фазе строительства), Росреестр (сделки ДДУ за 14 мес). +
+
+ + +
+
+

Три рабочих модуля

+
Не «features», а конкретные вопросы, на которые финдиректор девелопера ищет ответ
+
+
+ +
+ +
+
+

Аналитика спроса

+ /analytics +
+

«В каких районах и сегментах сделки опережают предложение?» Разбивка sold % по комнатности, классу, году ввода. Бенчмарк против топ-15 девелоперов области.

+
+ +
+ +
+ +
+
+

Рекомендатор площадки

+ /recommend +
+

«Какую квартирографию закладывать на участке в Академическом, чтобы выйти за 22 месяца?» Mix по комнатности, прогноз срока 80 % продаж, расчёт выручки.

+
+ +
+ +
+ +
+
+

Топ-15 девелоперов

+ /developers +
+

«Кто продаёт быстрее в моём районе и за счёт чего?» Velocity-карта по площади и Δ sold %, фильтр по классу. Drill-down до объекта.

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

Пилот — 30 000 ₽ в месяц

+
Один тариф, без апсейлов. 3 из 3 пилотных мест открыто, ввод данных за 2 недели.
+
+
+ +
+
+ Пилот · 6 месяцев +

gendsgn для одного девелопера

+

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

+
30 000 ₽/ мес · фикс
+
    +
  • Аналитика спроса по 17 районам области
  • +
  • Рекомендатор площадки с прогнозом срока продаж
  • +
  • Бенчмарк против топ-15 девелоперов
  • +
  • Еженедельный апдейт данных Росреестра
  • +
  • Экспорт в Excel и CSV для финмодели
  • +
  • Запрос новых разрезов от пилотного клиента — приоритет
  • +
+ Запросить пилот +
Контакт менеджера — Алексей Демидов · al@gendsgn.ru
+
+
+ После пилота +

Региональная лицензия

+

Когда пилот закроется и продукт перейдёт к платным клиентам.

+
от 120 000 ₽/ мес · region-pack
+
    +
  • Подключение дополнительных регионов
  • +
  • API для отдела финансового планирования
  • +
  • Бенчмарк против выбранных конкурентов
  • +
  • Кастомные разрезы и custom alerting
  • +
+ Запросить расчёт +
Расчёт индивидуально, после квалификации пилота
+
+
+ +
+ Честный дисклеймер: мы pre-revenue. Сайт показывает реальный продукт в работе на ваших данных. До первой ARR-метрики команда живёт за счёт основателей, и пилотных клиентов мы выбираем сами, а не наоборот. +
+ + +
+
+

Частые возражения

+
Что обычно спрашивают финдиректора прежде, чем согласиться на пилот
+
+
+ +
+
+

Если ваше возражение не разобрано — напишите на al@gendsgn.ru, ответим за 1 рабочий день.

+
+
+
+ Чем вы отличаетесь от собственной BI-команды? +
Мы не делаем дашборд под отдел — мы продаём готовый рыночный разрез по 6,83 млн ДДУ и портфелю ДОМ.РФ, который ваша BI собирала бы сама 4–6 месяцев. Внутренняя команда нужна, чтобы интерпретировать наши цифры в контексте конкретного проекта.
+
+
+ Откуда у вас данные Росреестра и ДОМ.РФ? +
ДОМ.РФ — открытый портфель новостроек. Росреестр — публичные ДДУ через выписки ЕГРН и партнёрские интеграции (без серых источников). Яндекс.Недвижимость — публичный листинг активных объектов в продаже.
+
+
+ Что входит в «обновление 7 дней»? +
Раз в неделю — пересчёт sold %, добавление новых ДДУ, обновление цен по активным объектам. Раз в месяц — пересмотр квартирографии и портфеля ДОМ.РФ. Любое окно < 7 дней — кешируем предыдущий снимок и помечаем «stale».
+
+
+ Почему только Свердловская область? +
Мы pre-revenue и заточили пайплайн под один регион, чтобы доказать ROI за 6 месяцев пилота. Добавление второго региона — около 6 недель работы на наш бэк. Если в пилот зайдёт девелопер другой области — мы её добавим в очередь.
+
+
+ Можно ли использовать данные в внутреннем финмоделировании? +
Да. Все ключевые разрезы экспортируются в Excel и CSV (sold %, цены, mix). Лицензия пилота позволяет использовать выгрузки во внутреннем финмодуле без отдельного согласования.
+
+
+ Что будет после 6 месяцев пилота? +
Совместный отчёт по конкретным cases (где наши рекомендации совпали со сделкой, где разошлись), и переход на регулярную лицензию либо корректное расставание. Никаких автоматических продлений с отдельным счётом.
+
+
+
+ +
+ + + + + diff --git a/frontend/public/site-finder-analysis.html b/frontend/public/site-finder-analysis.html new file mode 100644 index 00000000..a8f3cf9a --- /dev/null +++ b/frontend/public/site-finder-analysis.html @@ -0,0 +1,1112 @@ + + + + + +/site-finder/анализ — 66:41:0701045:42 · gendsgn + + + + + + + + + +
+ +
+ +
+
+ ← К карте + · + 66:41:0701045:42 + · + Академический район + · + 0,82 га + анализ свежий · 8 мин +
+
+ +
+ + + + + +
+ + +
+
+
+

1. Инфо об участке

+

Кадастровые данные ЕГРН · POI 2GIS · ВРИ из НСПД

+
+
+ +
+
+ +
+ подходит + Участок 0,82 га в Академическом · комфорт-класс · собственность ООО «УралЗем» · sold% соседей 62% при медиане города 48%. + данные ЕГРН на 16.05.2026 +
+ +
+
+
Площадь
+
0,82 га
+
8 200 м² · ВРИ 2.6 многоэтажка
+
+
+
Район
+
Академический
+
8 км до центра · трамвай 23
+
+
+
Медиана цены
+
186 тыс ₽/м²
+
+11% за 6 мес +11%
+
+
+
Score участка
+
78/100
+
верхний квартиль по району +12
+
+
+ +
+
+
+

Расположение

+

Граница участка по ЕГРН + ключевые POI в радиусе 1 км

+
+
+ +
+
+
+
+ + + + + + + + + + + + + + 66:41:0701045:42 + + + м. Чкаловская (4 мин) + Парк УрФУ (8 мин) + Школа №197 (6 мин) + МЦК «Орбита» + Перекрёсток + + + + +
+
+
    +
  • Кадастровый номер66:41:0701045:42
  • +
  • Адресул. Краснолесья, уч. 14
  • +
  • Площадь8 234 м² (0,82 га)
  • +
  • Категория земельЗемли населённых пунктов
  • +
  • ВРИ (актуальный)2.6 Многоэтажная застройка
  • +
  • Форма собственностиЧастная
  • +
  • ПравообладательООО «УралЗем»
  • +
  • Дата регистрации14.03.2022
  • +
  • ОбремененияНет
  • +
  • Красные линии в границахКасание с СЗО ВЛ-110
  • +
+
+
+
+ +
+
+

Точки притяжения (POI) в радиусе 1 км

2GIS · OpenStreetMap · сортировка по weighted score

+
+
+
+
M
Станция метро «Чкаловская»
Зелёная линия, северный вход
420 м
4 мин пеш.
+
P
Парк УрФУ
7,2 га · детская инфраструктура
680 м
8 мин пеш.
+
S
Школа №197
МАОУ · 1240 учеников · рейтинг 4.2
540 м
6 мин пеш.
+
S
Гимназия «Корифей»
Частная · 380 учеников
820 м
10 мин пеш.
+
K
Детсад №524
Муницип · 240 мест · очередь 18 чел.
310 м
3 мин пеш.
+
$
Перекрёсток
2 400 м² · 7:00–23:00
470 м
5 мин пеш.
+
H
ГКБ №40
Многопрофильная больница
1,1 км
13 мин пеш.
+
+
+
+ + +
+
+
+

2. Сети и точки подключения

+

Энергетическая, тепловая, водопроводная, газовая в радиусе 1–1,5 км · НСПД + reverse-engineered энергоплан

+
+
+ +
+
+ +
+ подключаемо + Все 4 ресурса в зоне ≤ 320 м. Узкое место — газ 1,4 км, но для жилого многоквартирного не критично (центральное отопление от ТГК-9). + данные НСПД на Q1 2026 +
+ +
+
Теплоснабжение
240 м
ТГК-9 · магистраль Ø500 резерв 18 Гкал/ч
+
Водоснабжение
180 м
МУП «Водоканал» · колодец ВК-241
+
Электричество
320 м
ПС 110/10 «Академ» резерв 9 МВт
+
Газ
1,4 км
ГРС-3 «Уралмаш» для МКД не требуется
+
+ +
+
+

Схема сетей в радиусе 1,5 км

Условные обозначения по СП 18.13330.2019

+
+ + +
+
+
+ + + + + + + + + + + 1 км + 1,5 км + + + + + + + ТК-118 · 240 м + + + + + + ВК-241 · 180 м + + + + + + ВЛ-110 «Академ» · 320 м + + + + ГРС «Уралмаш» · 1,4 км ↓ + + + + участок + 0,82 га + + + + тепло + вода + электро 10 кВ + газ + + +
+
+ +
+
+

Точки подключения

Координаты, владелец сети, доступный резерв, оценка стоимости подключения

+
+
+ + + + + + + + + + +
РесурсТочкаКоординатыВладелецРасстояниеРезервСтоимость подкл.Статус
Тепло
ТК-11856,838 / 60,597ТГК-9240 м18 Гкал/ч3,4 млн ₽подключаемо
Вода
ВК-24156,839 / 60,598МУП «Водоканал»180 м280 м³/сут1,8 млн ₽подключаемо
Электро 10 кВ
ПС «Академ»56,840 / 60,600МРСК Урала320 м9 МВт12,6 млн ₽подключаемо
Газ среднего давл.
ГРС-356,832 / 60,612Газпром Е-бург1,4 кмнедостаточно≈ 28 млн ₽альтернатива
Канализация
КК-8956,839 / 60,599МУП «Водоканал»210 м240 м³/сут2,1 млн ₽подключаемо
Ливневая
ЛК-3256,838 / 60,594Адм. Е-бурга280 м0,9 млн ₽подключаемо
+
+
+ + +
+
+
+

3. Продажи конкурентов

+

DOM.РФ ДДУ-выгрузка + Rosreestr 6,83 млн ДДУ · обновление еженедельно

+
+
+ + +
+
+ +
+ окно дефицита открыто + В выборке 7 ЖК · 1 482 квартиры в продаже · средняя скорость 22 ДДУ/мес · 80+ м² составляют 11% портфеля, но 37% спроса → конкурент Forum даёт +86% уход. + данные на 16.05.2026 +
+ + +
+

Настройки выборки пп. 1–4

+
+
+
1 Радиус выборки
+
2,5 км
+
+
12345 км
+
+
+
2 Только строящиеся
+
включено
+3 мес от срока ввода
+
не показывать сданное
скрыто 4 ЖК · 312 квартир
+
+
+
3 Период продаж
+
−6 мес от 16.11.2025
+
+
3691224 мес
+
+
+
4 Только квартиры
+
flat_type = «Квартира»
машиноместа, кладовые, апартам. — скрыто
+
данных по парковкам — есть, но по этому ЖК и Брусника не разделяют. Дам знать когда подключим.
+
+
+
+ + + + +
+
+
+

5 Планировки конкурентов

+

12 уникальных типов · сгруппированы по комнатности и площади · картинки из DOM.РФ скрапера

+
+
+ + +
+
+
+ + +
+
+ −47% + кухня-гостспальняс/у + 7,8 млн +
+
+
1-комн «евро» 38 м²
+
встречается у 5 из 7 ЖК · 312 шт
+
предложение312
+
продано/мес9
+
+
+
+ + +
+
+ ±0 + гостспальнякухня + 10,1 млн +
+
+
1-комн классика 46 м²
+
встречается у 4 из 7 ЖК · 184 шт
+
предложение184
+
продано/мес16
+
+
+
+ + +
+
+ +12% + кухня-гостсп.1сп.2с/у + 14,4 млн +
+
+
2-комн «евро» 62 м²
+
встречается у 6 из 7 ЖК · 238 шт
+
предложение238
+
продано/мес24
+
+
+
+ + +
+
+ +86% + кухня-гостсп.1сп.2сп.3с/у + 19,2 млн +
+
+
3-комн 84 м²
+
только у 2 из 7 ЖК · 38 шт
+
предложение38
+
продано/мес14
+
+
+
+ +
+
+ + +
+
+
+

6 Остатки (нераспроданное)

+

flats_total − flats_sold по каждому ЖК в выборке · сортировка по абс. остатку

+
+
+ + +
+
+ + + + + + + + + + +
ЖКСрок вводаВсего, штПроданоОстатокДоля остаткаSold%Δ за 14 мес
ФР
Forum «Краснолесье»
Форум-групп · 2 очередь
IV кв 2026412266146 35%64%+18 пп
БР
Брусника «Академ-парк»
Брусника · 3 очередь
II кв 202636828088 24%76%+24 пп
ЭТ
Эталон «Чкаловский»
Эталон · 1 очередь
I кв 2027284112172 61%39%−6 пп
КР
Кортрос «Преображенский»
КОРТРОС · 2 очередь
III кв 202619613858 30%70%+12 пп
УН
Унистрой «Юг-1»
Унистрой · 4 очередь
IV кв 202614238104 73%27%−14 пп
АС
Атомстрой «Уралмаш»
Атомстройкомплекс · 1 очередь
I кв 2027802654 68%32%+4 пп
+
+ + +
+
+
+

7 Ассортимент и скорость продаж

+

−6 мес от сегодня · разрез по room_bucket × area_bin · ось Y — ДДУ/мес

+
+
+
+
+ + + + + + + + + + 0 + 10 + 20 + 30 + 40 + ДДУ / мес + + + + + + + + 1к до 40 + + + + + + 1к 40-50 + + + + + + 2к до 55 + + + + + + 2к евро 55-70 + + + + + + 3к 70-85 ⚡ + + + + + + 3к 85-110 ⚡ + + + + + + 4к+ 110+ ⚡ + + + + + предложение, шт ×0.1 + ДДУ / мес + + + + + окно дефицита 70+ м² + спрос > предложение + + +
+
+ + +
+
🜂 рекомендация — наша
+

Строить комфорт-микс с креном в 3-комн евро 70–85 м² (35% портфеля)

+

Дефицит 80+ м² в радиусе 2,5 км — реальный (37% спроса при 11% предложения). Forum «Краснолесье» забирает 86% этого спроса с одной типологии «3-комн 84 м²». Своя конкурентная позиция: тот же бакет с лучшей кухней-гостиной (24+ м²) и панорамным остеклением спальни. Цену держать 230 тыс ₽/м² против 215 тыс у Forum — рынок проглотит, sold % сохранится > 60% к месяцу 14.

+
+
Площадь застройки
12 600 м²
≈ 180 квартир
+
Прогноз выручки
2,4 млрд ₽
из них 1,1 млрд — 3к
+
Срок 80% продаж
22 мес
от старта продаж
+
Liquidity score
B+
медиана городa: B−
+
+
+ + + +
+
+
+ + +
+
+
+

4. Оценка участка

+

Композитный score 0–100 · веса калибруются под класс жилья

+
+
+ +
+
+ +
+ верхний квартиль + Score 78/100 · верхний квартиль по Свердл (медиана города 56) · единственное узкое место — касание с СЗО ВЛ-110, требует согласования. + калибровка на 2 142 ДДУ +
+ +
+
+ + + + 78 + из 100 + +
+
    +
  • Локация и транспортметро 420 м · школа 540 м · парк 680 м
    88
  • +
  • Спрос в районеsold % 62% · окно 80+ м² открыто
    84
  • +
  • Инженерные сетитепло/вода/электро ≤ 320 м
    80
  • +
  • Конкуренция7 ЖК в 1 км · Forum доминирует
    64
  • +
  • Цена / маржинальность+11% за 6 мес · потолок 230 тыс ₽/м²
    74
  • +
  • Юр-ограниченияСЗО ВЛ-110 в границах
    52
  • +
  • Экология / атмосфераAQI 38 · преобл. ЮЗ ветер
    82
  • +
+
+
+
+
+
+

Gate: ПРОЙДЕНО (по умолчанию для комфорт-класса)

+

Все 4 обязательных критерия выполнены: ВРИ совместим (1), все 4 ресурса в 1,5 км (2), sold % района > 45% (3), score > 65 (4). Замечание: СЗО ВЛ-110 — требует согласования с МРСК Урала до начала проектирования (12–16 недель).

+
+
+
+
+ + +
+
+
+

5. Атмосфера и климат

+

Open-Meteo · ФГБУ «УГМС-Урал» · roza vetrov по последним 5 годам

+
+
+ +
+
+ +
+ пригодно + AQI 38 (good) · преобладающий ветер ЮЗ (38% времени) → промзона Уралмаш в наветренной стороне → низкий риск износа от выбросов. + 5-летний период +
+ +
+
AQI индекс
38
good
медиана города 52
+
PM2.5
9 мкг/м³
≤ 12
норма ВОЗ — 15
+
NO₂
28 мкг/м³
умерен.
влияние трамвайной линии
+
Шум днём
54 дБА
в норме
СН 2.2.4/2.1.8.562-96 ≤ 55
+
+ +
+
+

Роза ветров

направление преобладающего ветра · 5-летняя статистика УГМС

+
+ + + + + + + С + В + Ю + З + СВ + ЮВ + ЮЗ + СЗ + + + + + + + + + + + + +
+

Доминирует ЮЗ (38%) — комбинат «Уралмаш» в наветренной стороне → выбросы уходят на восток, в направлении ЕКАД. Для участка это благоприятный фактор.

+
+ +
+

Сезонная погода

средние значения за 5 лет · Open-Meteo

+
+
зима
−12°C
снежн. покров 87 дн · риск гололёда на парковке
+
весна
+6°C
паводок 1,2 м · район выше отметки 295
+
лето
+22°C
волны жары до +34 — нужно остекл. ≤ 35% юж. фасадов
+
осень
+8°C
осадки 180 мм · нагрузка на ливневую
+
+
    +
  • Солнечных дней / год118
  • +
  • Глубина промерзания1,8 м (фундамент свайно-плитный)
  • +
  • Расчётная сейсмика5 баллов MSK · норма по СП 14.13330
  • +
  • Уровень грунтовых вод−5,6 м (норма для свайного)
  • +
+
+
+
+ +
+
+ + + + + diff --git a/frontend/public/site-finder.html b/frontend/public/site-finder.html new file mode 100644 index 00000000..a1678ef4 --- /dev/null +++ b/frontend/public/site-finder.html @@ -0,0 +1,483 @@ + + + + + +/site-finder — Поиск участка · gendsgn + + + + + + + + + +
+ +
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + Верх-Исетский + Ленинский + Кировский + Орджоникидзевский + Октябрьский + Чкаловский + Академический + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
site finder
+

Введите номер участка
или кликните на карту →

+
+
+
+
+ +
+ +
+

+ i + Кадастровый номер из ЕГРН в формате XX:XX:XXXXXXX:NN. Поддерживаем Свердловскую область, Челябинскую, Тюменскую. +

+
или
+ +
+
+
Недавние участки
+ +
+
+ + +
+ + + + + +
+ + +
+
показано 148 участков
+
подходят 34
+
обновлено 16 мин назад
+
+ + + + + +
+

Раскраска участков

+
    +
  • свободные, score > 7034
  • +
  • в работе, нужен анализ62
  • +
  • обременения / спорный ВРИ38
  • +
  • не подходит14
  • +
  • конкуренты (ЖК в продаже)74
  • +
+
+ + +
2 км
+
+ + + +
+ +
+ + + + + diff --git a/frontend/public/tradein.html b/frontend/public/tradein.html new file mode 100644 index 00000000..b6b74f0a --- /dev/null +++ b/frontend/public/tradein.html @@ -0,0 +1,2051 @@ + + + + + + Trade-In · PRINZIP — Оценка квартиры + + + + + + + + +
+
+
+
P
+ PRINZIP +
+
+ Trade-In · Оценка + + + + Поддержка + +
АК
+
+
+ +
+
+ Главная + Trade-In + Новая оценка +
+ +
+
+

Оценка квартиры на вторичке

+

+ Агрегируем оценки 7 источников + аналоги в продаже + фактические сделки. + Время сбора — 30–60 сек. +

+
+
+ Отчёт EKB-2485 + Дата 12.05.2026 + Срок данных до 11.06.2026 +
+
+ + +
+ + + + + +
+ + +
+
+
+
Шаг B · Агрегация
+

Сбор данных по квартире

+
+
+
4 / 7 источников
+
медиана: 4.16 млн ₽
+
+
+ +
+
+ Идёт параллельный запрос к 7 сервисам — Celery group, timeout 90 сек. +
+ ≈ 22 СЕК ДО ЗАВЕРШЕНИЯ +
+ +
+ +
+ + + + Циан + + 4.10 млн · 47 объявл. +
+ +
+ + + + Авито Оценка + + 4.20 млн · 31 объявл. +
+ +
+ + + + ДомКлик Прайс + + 12 ипотечных сделок в доме… +
+ +
+ + + + Я.Недвижимость + + 4.00 млн · 28 объявл. +
+ +
+ + + + Restate + + сбор аналогов… +
+ +
+ + + + N1.ru + + timeout — нет ответа +
+ +
+ + + + Дом.РФ + + кадастровая + рыночная… +
+ +
+ + + + Росреестр (внутр.) + + 12 сделок · 10.2025–05.2026 +
+ +
+
+ +
+ CACHE-KEY · + addr=66:41:0204041:1208 · area=42.9 · rooms=2 · repair=req + · + при недоступности источника частичный результат не блокируется +
+
+ + +
+
+
+
Секция 1 · Сводка
+

Анализ рынка и расчёт выкупной стоимости

+
+
+
Источник: агрегация 4/7
+
Достоверность · средняя · CV 6.2%
+
+
+ +
+ +
+
г. Екатеринбург, ул. Софьи Перовской, 119 кв. 368
+
КАД · 66:41:0204041:1208 · ЭТАЖ 3/9 · ИНДЕКС 620089
+ +
+
Год постройки1978
+
Тип домаПанельный
+
Этаж3 / 9
+
Площадь42.9 м²
+
Планировка2-к, классическая
+
СостояниеТребует ремонта
+
Балкон / Лоджия1 / 0
+
СанузелРаздельный
+
+
+
+ +
+
+
+ Диапазон цен в объявлениях (без учёта ремонта) + медиана · 4.16 млн ₽ +
+
+
+
+
+
+
+
3.4 млн
+
+
+
+
4.3 млн
+
+
+
4.16 млн ₽
+
ЭКСПОЗИЦИЯ · 4 ДНЕЙ
+
118 ДНЕЙ
+
+
+ +
+
+ Диапазон цен по фактическим сделкам + медиана · 3.95 млн ₽ +
+
+
+
+
+
+
+
3.4 млн
+
+
+
+
4.3 млн
+
+
+
3.95 млн ₽
+
ЭКСПОЗИЦИЯ · 10 ДНЕЙ
+
228 ДНЕЙ
+
+
+
+ +
+
+ + + +
Цены в объявлениях ≠ реальная сделка — разница 5–12% по данным Росреестра.
+
+
+ + + +
Косметический ремонт 400–600 тыс ₽ повышает стоимость на 150–250 тыс ₽.
+
+
+ + + +
Самостоятельная продажа = до 15% потерь на торге, риелторе, нотариусе и аренде.
+
+
+
+ + +
+
+
+
Секция 2 · Рынок
+

Объявления — аналогичные квартиры в продаже

+
+
+
Обновлено: 12.05.2026 · 14:22
+
Источников: 5 / 7
+
+
+ +
+
+
Объявлений по аналогам
+
47шт
+
из 5 источников за 30 дней
+
+
+
Из них с учётом ремонта
+
5шт
+
«Требует ремонта», ±1 категория
+
+
+
Медиана / разброс
+
4.16млн ₽
+
CV 6.2% · надёжно
+
+
+ +
+ Фильтр +
+ Расстояние±1 км + ТипЭконом · <2000 + Год1976 – 1981 + Площадь42.1 – 47.0 м² + Планировка2-к + СостояниеТребует ремонта +
+
+ +
+ Источники +
+ Я.Недвижимость · 28 + Циан · 47 + ДомКлик · 19 + Avito · 31 + N1.ru · — timeout +
+
+ +
+
+ ДИАПАЗОН ЦЕН В ОБЪЯВЛЕНИЯХ · 4.16 МЛН ₽ медиана + диапазон — 4 / 118 дней экспозиции +
+
+
+
+
+
+
+
3.4 млн
+
+
+
+
4.0
+
+
+
+
4.4
+
+
+
+
4.6 млн
+
+
+
4.16 млн ₽
+
P25 · 4.0 МЛН
+
P75 · 4.4 МЛН
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
АдресИсточник₽ / м²СтоимостьЭкспозиция
+
+ Екб, проезд Теплоходный, 11 + 42.5 м² · 2-к · панель 1977 +
+
Циан101 1764 300 0004 дн.
+
+ Екб, ул. Автомагистральная, 11 + 43.0 м² · 2-к · панель 1979 +
+
Avito104 6514 500 00022 дн.
+
+ Екб, ул. Бебеля, 136 + 44.7 м² · 2-к · панель 1981 +
+
Я.Недв94 8544 240 00048 дн.
+
+ Екб, ул. Софьи Перовской, 109 + 42.1 м² · 2-к · панель 1976 +
+
ДомКлик95 0114 000 00073 дн.
+
+ Екб, ул. Декабристов, 27 + 47.0 м² · 2-к · панель 1978 +
+
Циан76 5963 600 000118 дн.
+ +
+ Показано 5 из 47 объявлений · отсортировано по релевантности + Показать все → +
+
+ + +
+
+
+
Секция 3 · Сделки
+

Фактические сделки по аналогичным квартирам

+
+
+
Период: 10.2025 – 05.2026
+
Источников: 3 / 3 · ground truth
+
+
+ +
+
+
Сделок по аналогам
+
12шт
+
подтверждено Росреестром
+
+
+
Период
+
10.25 – 05.26
+
7 месяцев · разница к рынку −7%
+
+
+
Медиана / разброс
+
3.95млн ₽
+
10 – 228 дней экспозиции
+
+
+ +
+ Фильтр +
+ Расстояние±1 км + ТипЭконом · <2000 + Год1977 – 1979 + Площадь38.0 – 47.3 м² + Планировка2-к +
+
+ +
+ Источники +
+ Этажи · 7 сделок + ДомКлик · 3 ипотеки + Росреестр · 12 ДДУ/пер. +
+
+ +
+
+ ДИАПАЗОН ЦЕН ПО ФАКТ. СДЕЛКАМ · 3.95 МЛН ₽ медиана + экспозиция 10 / 228 дней +
+
+
+
+
+
+
+
3.4 млн
+
+
+
+
4.3 млн
+
+
+
3.95 млн ₽
+
ЭКСПОЗИЦИЯ · 10 ДНЕЙ
+
228 ДНЕЙ
+
+
+ +
+ + + +
По данным реальных сделок квартиры продаются в среднем на 5–12% дешевле, чем заявлено в объявлениях. Используйте сделочную медиану 3.95 млн ₽ как базу для выкупной цены.
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
АдресИсточник₽ / м²СтоимостьЭкспозицияДата
+
+ Екб, ул. Софьи Перовской, 115 + 42.9 м² · 2-к · панель 1978 +
+
Росреестр99 0684 250 00010 дн.04.2026
+
+ Екб, ул. Бебеля, 138 + 44.1 м² · 2-к · панель 1979 +
+
Этажи93 1974 110 00047 дн.02.2026
+
+ Екб, ул. Татищева, 84 + 38.0 м² · 2-к · панель 1977 +
+
ДомКлик94 7373 600 00095 дн.12.2025
+
+ Екб, ул. Викулова, 33 + 47.3 м² · 2-к · панель 1979 +
+
Росреестр90 9094 300 000228 дн.11.2025
+
+ Екб, ул. Заводская, 32/1 + 42.5 м² · 2-к · панель 1978 +
+
Этажи80 0003 400 000142 дн.10.2025
+ +
+ Показано 5 из 12 фактических сделок + Показать все → +
+
+ + +
+
+
+
Секция 4 · Оффер
+

Формирование выкупной стоимости

+
+
+
База расчёта: 3.95 млн ₽
+
Параметры настраиваемые в админке девелопера
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Статья расходовЧерез Trade-InСамостоятельная продажа
+
Торг покупателей
+
5–15% от цены объявления
+
не применимо182 500 – 547 500 ₽
+
Услуги риелтора
+
2–3% от цены сделки
+
бесплатно69 350 – 104 025 ₽
+
Аренда жилья после сделки
+
1-к квартира на 3 месяца
+
бесплатно90 000 – 150 000 ₽
+
Юридическое сопровождение
+
проверка документов, договор, регистрация
+
бесплатноот 19 250 ₽
+
Расходы на рекламу
+
платные показы Циан / Авито, 3 мес
+
бесплатно4 000 – 36 000 ₽
Общие финансовые потери— 0 ₽365 100 – 856 775 ₽
+ +
+ + + +
Издержки при самостоятельной продаже сопоставимы со скидкой 9–22% от рыночной цены. Trade-In закрывает их полностью, а цена в новостройке фиксируется на момент оценки.
+
+ + +
+
+
+ +
+

Экономия времени

+

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

+
+
+
+ +
+

Юридическая безопасность

+

Проверяем чистоту документов, ЕГРН, прописанных и обременения. Исключаем риски для клиента.

+
+
+
+ +
+

Фиксированная цена новостройки

+

Сохраняем выбранную планировку в каталоге PRINZIP по цене на момент оценки — до закрытия сделки.

+
+
+
+ +
+

Гарантия цены

+

Снимаем риск колебаний рынка — выкупная стоимость зафиксирована в оффере на 30 дней.

+
+
+ +
+
+ Готовы отправить оффер клиенту?
+ PDF-отчёт + персональный оффер по выкупной цене — за 1 клик. +
+
+ + +
+
+
+ +
+
+
+ + + + + + + diff --git a/frontend/sentry.client.config.ts b/frontend/sentry.client.config.ts new file mode 100644 index 00000000..613ea9a4 --- /dev/null +++ b/frontend/sentry.client.config.ts @@ -0,0 +1,14 @@ +import * as Sentry from "@sentry/nextjs"; + +const dsn = process.env.NEXT_PUBLIC_GLITCHTIP_DSN; +if (dsn) { + Sentry.init({ + dsn, + environment: process.env.NEXT_PUBLIC_ENVIRONMENT ?? "development", + tracesSampleRate: 0.05, + // GlitchTip does not support Session Replay — disable explicitly + replaysSessionSampleRate: 0, + replaysOnErrorSampleRate: 0, + integrations: [], + }); +} diff --git a/frontend/sentry.edge.config.ts b/frontend/sentry.edge.config.ts new file mode 100644 index 00000000..c92e6ec7 --- /dev/null +++ b/frontend/sentry.edge.config.ts @@ -0,0 +1,6 @@ +import * as Sentry from "@sentry/nextjs"; + +const dsn = process.env.GLITCHTIP_DSN ?? process.env.NEXT_PUBLIC_GLITCHTIP_DSN; +if (dsn) { + Sentry.init({ dsn, tracesSampleRate: 0.05 }); +} diff --git a/frontend/sentry.server.config.ts b/frontend/sentry.server.config.ts new file mode 100644 index 00000000..2f179e83 --- /dev/null +++ b/frontend/sentry.server.config.ts @@ -0,0 +1,10 @@ +import * as Sentry from "@sentry/nextjs"; + +const dsn = process.env.GLITCHTIP_DSN ?? process.env.NEXT_PUBLIC_GLITCHTIP_DSN; +if (dsn) { + Sentry.init({ + dsn, + environment: process.env.NODE_ENV ?? "development", + tracesSampleRate: 0.05, + }); +} diff --git a/frontend/src/app/_legacy/page-tabs.tsx b/frontend/src/app/_legacy/page-tabs.tsx new file mode 100644 index 00000000..75bff5fc --- /dev/null +++ b/frontend/src/app/_legacy/page-tabs.tsx @@ -0,0 +1,185 @@ +// Legacy landing — navigation portal (pre-A12 redesign 2026-05-18). +// Kept for rollback reference. Not routed — use /app/page.tsx. + +import Link from "next/link"; + +interface Route { + href: string; + label: string; + desc: string; +} + +const SECTIONS: { title: string; routes: Route[] }[] = [ + { + title: "Аналитика", + routes: [ + { + href: "/analytics", + label: "Свердл рынок", + desc: "Динамика 14 мес · парадокс «строят vs покупают» · pipeline · районы ЕКБ.", + }, + { + href: "/analytics/prinzip", + label: "PRINZIP drill-down", + desc: "Velocity vs benchmark · gap-bar к рынку · ЖК с sparkline · инсайты.", + }, + { + href: "/analytics/developers", + label: "Топ девелоперов", + desc: "Sortable leaderboard · velocity scatter · сравнение sold% по месяцам.", + }, + { + href: "/analytics/objects/60160", + label: "Карточка ЖК (пример: Парк Победы)", + desc: "KPI · sale-chart · корпуса ЖК (cad_quarter + buildings) · POI-таблица · фото-галерея. /analytics/objects/{obj_id}.", + }, + { + href: "/analytics/recommend", + label: "Рекомендатор квартирографии", + desc: "Район + площадь + класс → распределение по 5 бакетам, цены p25/median/p75, выручка, comparable ЖК. Слайдеры под проект.", + }, + ], + }, + { + title: "Site Finder", + routes: [ + { + href: "/site-finder", + label: "Анализ участка по cad-номеру", + desc: "Вводишь кадастр → карта + score соц-инфраструктуры (школы/сады/магазины/парки +вес, трамваи −вес) + 20 ближайших конкурентов с подсветкой района. Данные: OSM POI обновляются еженедельно.", + }, + ], + }, + { + title: "Adminка (Token-protected)", + routes: [ + { + href: "/admin/scrape/all", + label: "Скрапер — единая панель", + desc: "Все джобы (kn/nspd_geo/objective) в одном месте · job_settings inline-edit · bulk Свердл geo backfill · ScrapeLogsPanel.", + }, + { + href: "/admin/leads", + label: "Лиды PRINZIP CRM", + desc: "Воронка Sankey · конверсия по месяцам · KPI · таблица лидов с фильтрами.", + }, + ], + }, + { + title: "Generative (WIP)", + routes: [ + { + href: "/concept", + label: "Concept (Stage 1)", + desc: "Импорт полигона → 3 варианта застройки. WIP — Stage 1a-c.", + }, + ], + }, +]; + +export default function HomePageLegacy() { + return ( +
+
+

GenDesign

+

+ Generative Design + Site Finder — Discovery MVP. Свердл рынок & + PRINZIP. +

+
+ + {SECTIONS.map((section) => ( +
+

+ {section.title} +

+
+ {section.routes.map((r) => ( + +
+ {r.href} +
+
{r.label}
+
+ {r.desc} +
+ + ))} +
+
+ ))} + + +
+ ); +} diff --git a/frontend/src/app/admin/leads/page.tsx b/frontend/src/app/admin/leads/page.tsx index cbdbef25..7c7e96c6 100644 --- a/frontend/src/app/admin/leads/page.tsx +++ b/frontend/src/app/admin/leads/page.tsx @@ -68,11 +68,6 @@ interface LeadsStats { } export default function AdminLeadsPage() { - const [token] = useState(() => - typeof window === "undefined" - ? "" - : (localStorage.getItem("admin_token") ?? ""), - ); const [statusFilter, setStatusFilter] = useState(""); const [sourceFilter, setSourceFilter] = useState(""); const [convertedFilter, setConvertedFilter] = useState(""); @@ -81,36 +76,24 @@ export default function AdminLeadsPage() { const limit = 50; const stats = useQuery({ - queryKey: ["admin-leads-stats", token], - queryFn: () => - apiFetch("/api/v1/admin/leads/stats?months=12", { - headers: { "X-Admin-Token": token }, - }), - enabled: !!token, + queryKey: ["admin-leads-stats"], + queryFn: () => apiFetch("/api/v1/admin/leads/stats?months=12"), }); const funnelBySource = useQuery({ - queryKey: ["admin-leads-funnel-source", token], + queryKey: ["admin-leads-funnel-source"], queryFn: () => apiFetch( "/api/v1/admin/leads/funnel/by-source?months=12", - { - headers: { "X-Admin-Token": token }, - }, ), - enabled: !!token, }); const funnelMonthly = useQuery({ - queryKey: ["admin-leads-funnel-monthly", token], + queryKey: ["admin-leads-funnel-monthly"], queryFn: () => apiFetch( "/api/v1/admin/leads/funnel/monthly?months=24", - { - headers: { "X-Admin-Token": token }, - }, ), - enabled: !!token, }); const sankeyOption = useMemo(() => { @@ -239,18 +222,13 @@ export default function AdminLeadsPage() { }, [funnelMonthly.data]); const sources = useQuery({ - queryKey: ["admin-leads-sources", token], - queryFn: () => - apiFetch("/api/v1/admin/leads/sources", { - headers: { "X-Admin-Token": token }, - }), - enabled: !!token, + queryKey: ["admin-leads-sources"], + queryFn: () => apiFetch("/api/v1/admin/leads/sources"), }); const leads = useQuery({ queryKey: [ "admin-leads", - token, statusFilter, sourceFilter, convertedFilter, @@ -265,11 +243,8 @@ export default function AdminLeadsPage() { if (sourceFilter) qs.set("source", sourceFilter); if (convertedFilter !== "") qs.set("converted", convertedFilter); if (search) qs.set("search", search); - return apiFetch(`/api/v1/admin/leads/?${qs.toString()}`, { - headers: { "X-Admin-Token": token }, - }); + return apiFetch(`/api/v1/admin/leads/?${qs.toString()}`); }, - enabled: !!token, }); const fmtMoney = (v: number | null) => @@ -279,18 +254,6 @@ export default function AdminLeadsPage() { ? `${(v / 1e6).toFixed(2)} М ₽` : v.toLocaleString("ru"); - if (!token) { - return ( -

- Введите Admin Token на странице{" "} - - Скрапер - {" "} - — он сохранится для всех админ-страниц. -

- ); - } - if (stats.isError || leads.isError) { const err = String(stats.error ?? leads.error ?? ""); return ( diff --git a/frontend/src/app/admin/scrape/all/page.tsx b/frontend/src/app/admin/scrape/all/page.tsx index b4feb6ff..d3635112 100644 --- a/frontend/src/app/admin/scrape/all/page.tsx +++ b/frontend/src/app/admin/scrape/all/page.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { apiFetch } from "@/lib/api"; -import { cardStyle, inputStyle, labelStyle, td, th } from "@/lib/adminStyles"; +import { cardStyle, labelStyle, td, th } from "@/lib/adminStyles"; import { BulkGeoPanel } from "@/components/admin/BulkGeoPanel"; import { JobSettingsPanel } from "@/components/admin/JobSettingsPanel"; @@ -67,46 +67,31 @@ function diffSecs(a: string | null, b: string | null): string { } export default function ScrapeAllAdminPage() { - const [token, setToken] = useState(() => - typeof window === "undefined" - ? "" - : (localStorage.getItem("admin_token") ?? ""), - ); const [filter, setFilter] = useState< "all" | "kn" | "nspd" | "objective" | "nspd_geo" >("all"); const [logsRunId, setLogsRunId] = useState(""); - const saveToken = (v: string) => { - setToken(v); - localStorage.setItem("admin_token", v); - }; - const runs = useQuery({ - queryKey: ["unified-runs", token, filter], + queryKey: ["unified-runs", filter], queryFn: () => { const q = filter !== "all" ? `?scraper_type=${filter}&limit=30` : "?limit=30"; - return apiFetch(`/api/v1/admin/scrape/all/runs${q}`, { - headers: { "X-Admin-Token": token }, - }); + return apiFetch(`/api/v1/admin/scrape/all/runs${q}`); }, - enabled: !!token, refetchInterval: 5000, }); const logs = useQuery({ - queryKey: ["unified-logs", token, filter, logsRunId], + queryKey: ["unified-logs", filter, logsRunId], queryFn: () => { const params = new URLSearchParams({ limit: "100" }); if (filter !== "all") params.set("scraper_type", filter); if (logsRunId !== "") params.set("run_id", String(logsRunId)); return apiFetch( `/api/v1/admin/scrape/all/logs?${params}`, - { headers: { "X-Admin-Token": token } }, ); }, - enabled: !!token, refetchInterval: 8000, }); @@ -146,19 +131,6 @@ export default function ScrapeAllAdminPage() { аудита.

-
- -
- {/* Сводка-плитки */}
- + {/* Bulk geo backfill */}
- +
{/* Logs */} diff --git a/frontend/src/app/admin/scrape/cadastre/page.tsx b/frontend/src/app/admin/scrape/cadastre/page.tsx index 95958980..0ca8c312 100644 --- a/frontend/src/app/admin/scrape/cadastre/page.tsx +++ b/frontend/src/app/admin/scrape/cadastre/page.tsx @@ -122,25 +122,14 @@ function reqPerSec(job: CadastreJob): string { // Query keys // --------------------------------------------------------------------------- -const jobsKey = (token: string) => ["admin", "cadastre", "jobs", token]; -const jobKey = (token: string, id: number) => [ - "admin", - "cadastre", - "job", - id, - token, -]; +const jobsKey = () => ["admin", "cadastre", "jobs"]; +const jobKey = (id: number) => ["admin", "cadastre", "job", id]; // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- export default function CadastreScrapeAdminPage() { - const [token, setToken] = useState(() => - typeof window === "undefined" - ? "" - : (localStorage.getItem("admin_token") ?? ""), - ); const [scope, setScope] = useState("pilot"); const [name, setName] = useState(""); const [quartersText, setQuartersText] = useState(""); @@ -152,35 +141,23 @@ export default function CadastreScrapeAdminPage() { const queryClient = useQueryClient(); - const saveToken = (v: string) => { - setToken(v); - if (typeof window !== "undefined") { - localStorage.setItem("admin_token", v); - } - }; - // ------------------------------------------------------------------------- // Queries // ------------------------------------------------------------------------- const jobs = useQuery({ - queryKey: jobsKey(token), + queryKey: jobsKey(), queryFn: () => - apiFetch("/api/v1/admin/cadastre/jobs?limit=30", { - headers: { "X-Admin-Token": token }, - }), - enabled: !!token, + apiFetch("/api/v1/admin/cadastre/jobs?limit=30"), refetchInterval: 5000, }); // Per-job detail (only fetched when row is expanded) const expandedJob = useQuery({ - queryKey: jobKey(token, expandedId ?? 0), + queryKey: jobKey(expandedId ?? 0), queryFn: () => - apiFetch(`/api/v1/admin/cadastre/jobs/${expandedId}`, { - headers: { "X-Admin-Token": token }, - }), - enabled: !!token && expandedId !== null, + apiFetch(`/api/v1/admin/cadastre/jobs/${expandedId}`), + enabled: expandedId !== null, refetchInterval: expandedId !== null ? 5000 : false, }); @@ -204,17 +181,13 @@ export default function CadastreScrapeAdminPage() { "/api/v1/admin/cadastre/jobs", { method: "POST", - headers: { - "X-Admin-Token": token, - "Content-Type": "application/json", - }, body: JSON.stringify(body), }, ); }, onSuccess: (data) => { setLastResp(data); - void queryClient.invalidateQueries({ queryKey: jobsKey(token) }); + void queryClient.invalidateQueries({ queryKey: jobsKey() }); }, }); @@ -222,11 +195,11 @@ export default function CadastreScrapeAdminPage() { mutationFn: (id: number) => apiFetch<{ cancelled: boolean }>( `/api/v1/admin/cadastre/jobs/${id}/cancel`, - { method: "POST", headers: { "X-Admin-Token": token } }, + { method: "POST" }, ), onSuccess: (_data, id) => { - void queryClient.invalidateQueries({ queryKey: jobKey(token, id) }); - void queryClient.invalidateQueries({ queryKey: jobsKey(token) }); + void queryClient.invalidateQueries({ queryKey: jobKey(id) }); + void queryClient.invalidateQueries({ queryKey: jobsKey() }); }, }); @@ -234,11 +207,11 @@ export default function CadastreScrapeAdminPage() { mutationFn: (id: number) => apiFetch<{ resumed: boolean }>( `/api/v1/admin/cadastre/jobs/${id}/resume`, - { method: "POST", headers: { "X-Admin-Token": token } }, + { method: "POST" }, ), onSuccess: (_data, id) => { - void queryClient.invalidateQueries({ queryKey: jobKey(token, id) }); - void queryClient.invalidateQueries({ queryKey: jobsKey(token) }); + void queryClient.invalidateQueries({ queryKey: jobKey(id) }); + void queryClient.invalidateQueries({ queryKey: jobsKey() }); }, }); @@ -248,7 +221,6 @@ export default function CadastreScrapeAdminPage() { const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (!token) return; if (scope === "manual_list" && !quartersText.trim()) { alert("Для Manual list укажите хотя бы один квартал"); return; @@ -662,22 +634,8 @@ export default function CadastreScrapeAdminPage() { незавершённых targets.

- {/* Admin Token */} -
- -
- {/* Create job form */} -
+

Запуск bulk harvest

@@ -760,7 +718,7 @@ export default function CadastreScrapeAdminPage() {
(() => - typeof window === "undefined" - ? "" - : (localStorage.getItem("admin_token") ?? ""), - ); const [overrideSqlitePath, setOverrideSqlitePath] = useState(""); const [lastTaskId, setLastTaskId] = useState(null); const [syncGroupsCsv, setSyncGroupsCsv] = useState(""); @@ -108,39 +103,26 @@ export default function ObjectiveEtlAdminPage() { const [showEtl, setShowEtl] = useState(false); const queryClient = useQueryClient(); - const saveToken = (v: string) => { - setToken(v); - localStorage.setItem("admin_token", v); - }; - const coverage = useQuery({ - queryKey: ["objective-coverage", token], + queryKey: ["objective-coverage"], queryFn: () => - apiFetch("/api/v1/admin/scrape/objective/coverage", { - headers: { "X-Admin-Token": token }, - }), - enabled: !!token, + apiFetch("/api/v1/admin/scrape/objective/coverage"), refetchInterval: 10000, }); const runs = useQuery({ - queryKey: ["objective-runs", token], + queryKey: ["objective-runs"], queryFn: () => apiFetch( "/api/v1/admin/scrape/objective/runs?limit=20", - { headers: { "X-Admin-Token": token } }, ), - enabled: !!token, refetchInterval: 5000, }); const cfg = useQuery({ - queryKey: ["objective-config", token], + queryKey: ["objective-config"], queryFn: () => - apiFetch("/api/v1/admin/scrape/objective/config", { - headers: { "X-Admin-Token": token }, - }), - enabled: !!token, + apiFetch("/api/v1/admin/scrape/objective/config"), refetchOnWindowFocus: false, }); @@ -155,10 +137,6 @@ export default function ObjectiveEtlAdminPage() { mutationFn: () => apiFetch("/api/v1/admin/scrape/objective", { method: "POST", - headers: { - "X-Admin-Token": token, - "Content-Type": "application/json", - }, body: JSON.stringify({ sqlite_path: overrideSqlitePath || null }), }), onSuccess: (data) => { @@ -178,10 +156,6 @@ export default function ObjectiveEtlAdminPage() { "/api/v1/admin/scrape/objective/sync-our", { method: "POST", - headers: { - "X-Admin-Token": token, - "Content-Type": "application/json", - }, body: JSON.stringify({ groups: groups.length ? groups : null }), }, ); @@ -196,10 +170,6 @@ export default function ObjectiveEtlAdminPage() { mutationFn: (patch: Partial) => apiFetch("/api/v1/admin/scrape/objective/config", { method: "PUT", - headers: { - "X-Admin-Token": token, - "Content-Type": "application/json", - }, body: JSON.stringify(patch), }), onSuccess: (data) => { @@ -223,22 +193,8 @@ export default function ObjectiveEtlAdminPage() { только как bootstrap-fallback (свёрнут ниже).

- {/* Token */} -
- -
- {/* Coverage */} -
+

📦 Состояние PG (источник истины)

{coverage.isLoading && (

Загрузка…

@@ -504,7 +460,7 @@ export default function ObjectiveEtlAdminPage() {
(() => - typeof window === "undefined" - ? "" - : (localStorage.getItem("admin_token") ?? ""), - ); const [region, setRegion] = useState(66); const [devs, setDevs] = useState(""); const [fetchFlats, setFetchFlats] = useState(true); @@ -84,12 +79,8 @@ export default function ScrapeAdminPage() { const queryClient = useQueryClient(); const runs = useQuery({ - queryKey: ["scrape-runs", token], - queryFn: () => - apiFetch("/api/v1/admin/scrape/runs?limit=20", { - headers: { "X-Admin-Token": token }, - }), - enabled: !!token, + queryKey: ["scrape-runs"], + queryFn: () => apiFetch("/api/v1/admin/scrape/runs?limit=20"), refetchInterval: 5000, }); @@ -97,7 +88,6 @@ export default function ScrapeAdminPage() { mutationFn: () => apiFetch("/api/v1/admin/scrape/kn", { method: "POST", - headers: { "X-Admin-Token": token }, body: JSON.stringify({ region_code: region, developers: devs @@ -129,7 +119,6 @@ export default function ScrapeAdminPage() { released: boolean; }>("/api/v1/admin/scrape/release-lock", { method: "POST", - headers: { "X-Admin-Token": token }, body: JSON.stringify({ region_code: region, developers: devs @@ -151,12 +140,8 @@ export default function ScrapeAdminPage() { }); const queue = useQuery({ - queryKey: ["scrape-queue", token], - queryFn: () => - apiFetch("/api/v1/admin/scrape/queue", { - headers: { "X-Admin-Token": token }, - }), - enabled: !!token, + queryKey: ["scrape-queue"], + queryFn: () => apiFetch("/api/v1/admin/scrape/queue"), refetchInterval: 3000, }); @@ -172,7 +157,6 @@ export default function ScrapeAdminPage() { "/api/v1/admin/scrape/revoke", { method: "POST", - headers: { "X-Admin-Token": token }, body: JSON.stringify({ task_id, terminate }), }, ), @@ -203,30 +187,25 @@ export default function ScrapeAdminPage() { }; const logs = useQuery({ - queryKey: ["scrape-logs", logsRunId, token], + queryKey: ["scrape-logs", logsRunId], queryFn: () => { const q = logsRunId !== "" ? `?run_id=${logsRunId}&limit=200` : "?limit=200"; - return apiFetch(`/api/v1/admin/scrape/logs${q}`, { - headers: { "X-Admin-Token": token }, - }); + return apiFetch(`/api/v1/admin/scrape/logs${q}`); }, - enabled: !!token, refetchInterval: 3000, }); const failures = useQuery({ - queryKey: ["scrape-failures", failuresRunId, token], + queryKey: ["scrape-failures", failuresRunId], queryFn: () => { const q = failuresRunId !== "" ? `?run_id=${failuresRunId}&limit=100` : "?limit=100"; - return apiFetch(`/api/v1/admin/scrape/failures${q}`, { - headers: { "X-Admin-Token": token }, - }); + return apiFetch(`/api/v1/admin/scrape/failures${q}`); }, - enabled: !!token && showFailures, + enabled: showFailures, refetchInterval: showFailures ? 5000 : false, }); @@ -236,11 +215,6 @@ export default function ScrapeAdminPage() { } }; - const saveToken = (v: string) => { - setToken(v); - localStorage.setItem("admin_token", v); - }; - return ( <>

@@ -252,16 +226,6 @@ export default function ScrapeAdminPage() {

-
{releaseLock.isSuccess ? (
- {!token ? ( -

- Введите Admin Token чтобы видеть состояние очереди. -

- ) : queue.isLoading ? ( + {queue.isLoading ? (

Загрузка…

) : queue.isError ? (

Ошибка: {String(queue.error)}

@@ -718,11 +664,7 @@ export default function ScrapeAdminPage() { style={{ ...inputStyle, width: 130 }} /> - {!token ? ( -

- Введите Admin Token чтобы видеть лог. -

- ) : logs.isLoading ? ( + {logs.isLoading ? (

Загрузка…

) : logs.isError ? (

Ошибка: {String(logs.error)}

@@ -796,7 +738,6 @@ export default function ScrapeAdminPage() { />

{d?.addr ?? ""} · obj_id={d?.obj_id} · {d?.dev_name ?? ""} - {d?.escrow ? " · ✅ эскроу" : ""} + {d?.escrow ? " · эскроу" : ""}

+ {/* ── KPI row — распроданность ────────────────────────────────────────── */}
- + + {d?.price_min_rub != null ? ( + + ) : null} + {d?.price_max_rub != null ? ( + + ) : null}
+ {/* ── DOM.РФ scores ───────────────────────────────────────────────────── */} + {(d?.domrf_score_location != null || + d?.domrf_score_transport != null || + d?.domrf_score_infrastructure != null) && ( +
+ {d?.domrf_score_location != null && ( + + )} + {d?.domrf_score_transport != null && ( + + )} + {d?.domrf_score_infrastructure != null && ( + + )} +
+ )} + + {/* ── 1. Динамика продаж ──────────────────────────────────────────────── */}
+ {/* ── 2. Квартирография ──────────────────────────────────────────────── */} +
+ {quartirography.isLoading ? ( +
+ ) : ( + + )} +
+ + {/* ── 3. Ближайшее метро ──────────────────────────────────────────────── */} + {(d?.metro_nearest_name != null || + (d?.metro_top3 != null && d.metro_top3.length > 0)) && ( +
+ +
+ )} + + {/* ── 4. Инфраструктура (POI) ─────────────────────────────────────────── */}
+ {/* ── 5. Фото прогресса строительства ─────────────────────────────────── */}
- {/* Корпуса ЖК — показываем только если есть данные */} + {/* ── 6. Проверено на наш.дом.рф ─────────────────────────────────────── */} + {!checks.isLoading && (checks.data?.length ?? 0) > 0 && ( +
+ +
+ )} + + {/* ── 7. Документы ────────────────────────────────────────────────────── */} + {!documents.isLoading && (documents.data?.length ?? 0) > 0 && ( +
+ +
+ )} + + {/* ── 8. Все характеристики ───────────────────────────────────────────── */} + {d && ( +
+ +
+ )} + + {/* ── 9. Корпуса ЖК ───────────────────────────────────────────────────── */} {(d?.buildings_count ?? 0) > 0 && buildings.data && buildings.data.length > 0 && ( @@ -290,7 +411,6 @@ export default function ObjectDrillInPage() {
)} - {/* Placeholder when buildings_count > 0 but data not yet loaded */} {(d?.buildings_count ?? 0) > 0 && buildings.data && buildings.data.length === 0 && ( diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css new file mode 100644 index 00000000..13046cd8 --- /dev/null +++ b/frontend/src/app/globals.css @@ -0,0 +1,90 @@ +/* + * GenDesign — global CSS design tokens (UI Brief May 17) + * All CSS vars used across site-finder and analytics components. + * Spacing scale: 4/8/12/16/24/32px only. + * Tabular-nums applied globally for numeric readability. + */ + +*, +*::before, +*::after { + box-sizing: border-box; +} + +:root { + /* ── Surface ─────────────────────────────────────────────────────────── */ + --bg-app: #f6f7f9; + --bg-card: #ffffff; + --bg-card-alt: #fafbfc; + --bg-headline: #0f172a; /* slate-900 — dark verdict bar */ + + /* ── Borders ─────────────────────────────────────────────────────────── */ + --border-soft: #eef0f3; + --border-card: #e6e8ec; + --border-strong: #d1d5db; + + /* ── Text ────────────────────────────────────────────────────────────── */ + --fg-primary: #111111; + --fg-secondary: #5b6066; + --fg-tertiary: #73767e; + --fg-on-dark: #e2e8f0; /* text on --bg-headline */ + --fg-on-dark-muted: #94a3b8; /* caveat text on dark */ + + /* ── Brand / CTA ─────────────────────────────────────────────────────── */ + --accent: #1d4ed8; /* primary blue CTA */ + --accent-hover: #1e40af; + --accent-soft: #dbeafe; /* badge bg, active tab bg */ + --accent-2: #f2994a; /* secondary orange CTA (Export, Share) */ + + /* ── Semantic ────────────────────────────────────────────────────────── */ + --success: #0a7a3a; + --success-soft: #dcfce7; + --warn: #9a6700; + --warn-soft: #fef3c7; + --danger: #b3261e; + --danger-soft: #fee2e2; + + /* ── Data-viz (use in order, no skip) ───────────────────────────────── */ + --viz-1: #1d4ed8; + --viz-2: #0ea5e9; + --viz-3: #14b8a6; + --viz-4: #f59e0b; + --viz-5: #8b5cf6; + --prediction-line: #0ea5e9; + --prediction-band: rgba(14, 165, 233, 0.18); +} + +html, +body { + margin: 0; + padding: 0; + background: var(--bg-app); + color: var(--fg-primary); + font-family: + Inter, + -apple-system, + "Segoe UI", + system-ui, + sans-serif; + font-size: 14px; + line-height: 1.5; + /* Tabular nums globally for numeric legibility (Bloomberg lesson) */ + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum"; + -webkit-font-smoothing: antialiased; +} + +/* Focus-visible ring — accessible, not outline:none */ +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* Remove default outline for mouse users */ +:focus:not(:focus-visible) { + outline: none; +} + +a { + color: inherit; +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 7dc4da1a..a8ec550b 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,5 +1,8 @@ import type { Metadata } from "next"; +import { RouteGuard } from "@/components/auth/RouteGuard"; + +import "./globals.css"; import { Providers } from "./providers"; export const metadata: Metadata = { @@ -14,14 +17,10 @@ export default function RootLayout({ }) { return ( - - {children} + + + {children} + ); diff --git a/frontend/src/app/legacy/site-finder/page.tsx b/frontend/src/app/legacy/site-finder/page.tsx new file mode 100644 index 00000000..568b146f --- /dev/null +++ b/frontend/src/app/legacy/site-finder/page.tsx @@ -0,0 +1,570 @@ +"use client"; + +import { Suspense, useEffect, useRef, useState } from "react"; +import dynamic from "next/dynamic"; +import Link from "next/link"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import type { FeatureCollection } from "geojson"; + +import { CadInput } from "@/components/site-finder/CadInput"; +import { FetchingState } from "@/components/site-finder/FetchingState"; +import { KpiCard } from "@/components/site-finder/KpiCard"; +import { OverviewTab } from "@/components/site-finder/OverviewTab"; +import { EnvironmentTab } from "@/components/site-finder/EnvironmentTab"; +import { LandTab } from "@/components/site-finder/LandTab"; +import { MarketTab } from "@/components/site-finder/MarketTab"; +import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel"; +import { useSiteAnalysis } from "@/hooks/useSiteAnalysis"; +import { useDebouncedValue } from "@/hooks/useDebouncedValue"; +import { useConnectionPoints } from "@/hooks/useConnectionPoints"; +import { useCustomPois } from "@/hooks/useCustomPois"; +import { + POI_DEFAULT_WEIGHTS, + type PoiCategoryKey, +} from "@/lib/api/weightProfiles"; + +// SiteMap imports Leaflet which requires browser APIs — load without SSR +const SiteMap = dynamic( + () => import("@/components/site-finder/SiteMap").then((m) => m.SiteMap), + { + ssr: false, + loading: () => ( +
+ Загрузка карты… +
+ ), + }, +); + +type TabId = "overview" | "env" | "land" | "market"; + +const TABS: Array<{ id: TabId; label: string }> = [ + { id: "overview", label: "Обзор" }, + { id: "env", label: "Окружение" }, + { id: "land", label: "Земля" }, + { id: "market", label: "Рынок" }, +]; + +const TAB_IDS = TABS.map((t) => t.id); + +function isTabId(v: string | null | undefined): v is TabId { + return !!v && (TAB_IDS as readonly string[]).includes(v); +} + +// ── KPI helpers ─────────────────────────────────────────────────────────────── + +type KpiColor = "neutral" | "amber" | "green" | "red" | "blue"; + +function trendColor(pct: number): KpiColor { + if (pct > 5) return "green"; + if (pct > 0) return "blue"; + if (pct > -5) return "amber"; + return "red"; +} + +function noiseColor(db: number): KpiColor { + if (db < 45) return "green"; + if (db < 60) return "amber"; + return "red"; +} + +function scoreKpiColor(score: number, max: number): KpiColor { + const ratio = score / max; + if (ratio >= 0.65) return "green"; + if (ratio >= 0.35) return "amber"; + return "red"; +} + +// ── Page ────────────────────────────────────────────────────────────────────── + +function SiteFinderContent() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + + const { mutate, data, isPending, error, isIdle, fetchingState, cancel } = + useSiteAnalysis(); + const [isochrones, setIsochrones] = useState( + undefined, + ); + + // Fetch connection points whenever a parcel is loaded + const { data: connectionPoints } = useConnectionPoints(data?.cad_num); + // Fetch custom POIs for current parcel + const { data: customPois } = useCustomPois(data?.cad_num); + + // Weight profile state — lifted here so it survives tab switches. + // userId + adminToken allow the panel to load/save named profiles. + const [currentWeights, setCurrentWeights] = useState< + Record + >(() => ({ ...POI_DEFAULT_WEIGHTS })); + // Profile selection for the analyze call (null = system defaults). + const [activeProfileId, setActiveProfileId] = useState(null); + + // Pending weights change — set immediately when user clicks "Применить". + // Debounced to 300ms before triggering re-analyze, so rapid clicks collapse + // into a single request (#201 Phase 2). + const [pendingWeightsChange, setPendingWeightsChange] = useState<{ + weights: Record; + profileId: number | null; + } | null>(null); + const debouncedWeightsChange = useDebouncedValue(pendingWeightsChange, 300); + // Ref to skip the initial mount effect (we only re-analyze on actual changes). + const weightsChangeInitializedRef = useRef(false); + + const [profileUserId, setProfileUserId] = useState(() => + typeof window === "undefined" + ? "" + : (localStorage.getItem("admin_user_id") ?? ""), + ); + const [adminToken] = useState(() => + typeof window === "undefined" + ? "" + : (localStorage.getItem("admin_token") ?? ""), + ); + // Lazy init: считаем initialTab один раз на mount (useState всё равно + // игнорирует initializer после первого render — не тратим CPU). + const [tab, setTabState] = useState(() => { + const t = searchParams.get("tab"); + return isTabId(t) ? t : "overview"; + }); + + // Sync tab → URL query (?tab=env). overview = default, не пишем в URL. + function setTab(next: TabId) { + setTabState(next); + const params = new URLSearchParams(searchParams.toString()); + if (next === "overview") { + params.delete("tab"); + } else { + params.set("tab", next); + } + const qs = params.toString(); + // Используем pathname вместо голого "?" — Next 15 App Router не всегда + // чисто чистит query при `router.replace("?")` (dangling `?` остаётся). + router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); + } + + // Listen to back/forward navigation — reflect URL tab in state. + // setState с тем же значением React сам бэйлит через Object.is, поэтому + // guard по tab не нужен (и не вводит stale-closure через eslint-disable). + useEffect(() => { + const urlTab = searchParams.get("tab"); + setTabState(isTabId(urlTab) ? urlTab : "overview"); + }, [searchParams]); + + // Debounced re-analyze trigger: fires 300ms after the last "Применить" click. + // Skip initial mount (weightsChangeInitializedRef guards the first effect run). + // dataRef lets the effect read current data without listing it as a dep + // (avoids re-registering the timeout on every analyze completion). + const dataRef = useRef(data); + dataRef.current = data; + const profileUserIdRef = useRef(profileUserId); + profileUserIdRef.current = profileUserId; + + useEffect(() => { + if (!weightsChangeInitializedRef.current) { + weightsChangeInitializedRef.current = true; + return; + } + if (!debouncedWeightsChange) return; + const currentData = dataRef.current; + if (!currentData?.cad_num) return; + + const { weights, profileId } = debouncedWeightsChange; + const currentProfileUserId = profileUserIdRef.current; + setIsochrones(undefined); + mutate({ + cad: currentData.cad_num, + options: + profileId != null + ? { profileId } + : currentProfileUserId + ? { profileUserId: currentProfileUserId, weights } + : { weights }, + }); + // mutate is stable from useMutation — safe to omit from deps. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedWeightsChange]); + + function handleAnalyze(cadNum: string) { + setIsochrones(undefined); + setTab("overview"); + // Priority: named profile → user default profile → inline draft weights. + // When activeProfileId is set, backend uses that profile (ignores inline). + // When no profile is selected, pass currentWeights as inline so draft + // slider values are always respected even without a saved profile (#201). + mutate({ + cad: cadNum, + options: + activeProfileId != null + ? { profileId: activeProfileId } + : profileUserId + ? { profileUserId, weights: currentWeights } + : { weights: currentWeights }, + }); + } + + function handleWeightsChange( + weights: Record, + profileId: number | null, + ) { + setCurrentWeights(weights); + setActiveProfileId(profileId); + // Enqueue a debounced re-analyze if a parcel is loaded (#201 Phase 2). + // The actual mutate fires after 300ms of silence via debouncedWeightsChange. + if (data?.cad_num) { + setPendingWeightsChange({ weights, profileId }); + } + } + + // Derive KPI values from data + const scoreMax = data?.score_max_reference ?? 40; + const scoreValue = data + ? `${data.score.toFixed(2)} / ${scoreMax.toFixed(0)}` + : "—"; + const scoreLabel = data?.score_label + ? `Score · ${data.score_label}` + : "Score"; + const scoreCardColor: KpiColor = data + ? scoreKpiColor(data.score, scoreMax) + : "neutral"; + + const districtValue = data?.district ? data.district.district_name : "—"; + const districtLabel = data?.district + ? `${(data.district.median_price_per_m2 / 1000).toFixed(0)} тыс ₽/м²` + + ` · ${(data.district.dist_to_center / 1000).toFixed(1)} км от центра` + : "Район"; + + const trendPct = data?.market_trend?.delta_6m_pct; + const trendValue = + trendPct != null + ? `${trendPct > 0 ? "+" : ""}${trendPct.toFixed(1)}%` + : "—"; + const trendLabel = data?.market_trend + ? `Тренд за 6 мес · ${data.market_trend.label}` + : "Тренд рынка"; + const trendKpiColor: KpiColor = + trendPct != null ? trendColor(trendPct) : "neutral"; + + const noiseDb = data?.noise?.estimated_db; + const noiseValue = noiseDb != null ? `${Math.round(noiseDb)} dB` : "—"; + const noiseLabel = data?.noise ? `Шум · ${data.noise.level}` : "Шум"; + const noiseKpiColor: KpiColor = + noiseDb != null ? noiseColor(noiseDb) : "neutral"; + + return ( +
+ {/* Breadcrumb + header */} +
+ + ← Главная + +
+ +
+
+

+ Site Finder +

+

+ Введите кадастровый номер участка или квартала для анализа локации +

+
+
+ +
+
+ + {/* Weight profile panel — collapsible, below header */} +
+ {/* Optional user-id field for profile CRUD (shown only when adminToken present) */} + {!!adminToken && ( +
+ + { + setProfileUserId(e.target.value); + if (typeof window !== "undefined") { + localStorage.setItem("admin_user_id", e.target.value); + } + }} + /> +
+ )} + + {/* Recalculation indicator — shown while re-analyze is in-flight after + weights change (data already loaded, pendingWeightsChange set). */} + {isPending && !!data && ( +

+ Пересчёт… +

+ )} +
+ + {/* Error state */} + {error && ( +
+ {error instanceof Error ? error.message : "Ошибка анализа участка"} +
+ )} + + {/* #93 — Fetching state: backend на 202 Accepted, идёт on-demand NSPD fetch */} + {fetchingState && ( + + )} + + {/* Pending skeleton — первичный analyze в полёте (но fetching state ещё не активирован) */} + {isPending && !fetchingState && ( +
+ Анализируем участок… +
+ )} + + {/* Empty initial state */} + {isIdle && !error && ( +
+ Введите кадастровый номер и нажмите «Анализировать» +
+ )} + + {/* Results */} + {data && ( + <> + {/* Cad label */} +
+ {data.cad_num} +
+ + {/* Hero band — sticky KPI cards */} +
+ + + + +
+ + {/* Tab buttons */} +
+ {TABS.map((t) => ( + + ))} +
+ + {/* 2-column grid: tab content + sticky map */} +
+ {/* Tab content */} +
+ {tab === "overview" && ( + + )} + {tab === "env" && } + {tab === "land" && } + {tab === "market" && } +
+ + {/* Sticky right sidebar — map always visible */} + +
+ + )} +
+ ); +} + +// useSearchParams() в Next 15 App Router throw'ит при static rendering без +// boundary — поэтому оборачиваем page-level. +export default function SiteFinderPage() { + return ( + + + + ); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 6e25ccae..dcf48357 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,188 +1,544 @@ -import Link from "next/link"; +"use client"; -interface Route { - href: string; - label: string; - desc: string; -} +import { useState } from "react"; -const SECTIONS: { title: string; routes: Route[] }[] = [ +import { + BarChart3, + Building2, + ChevronDown, + ChevronUp, + Database, + MapPin, + RefreshCw, +} from "lucide-react"; + +import { UserMenu } from "@/components/auth/UserMenu"; +import { PilotRequestModal } from "@/components/landing/PilotRequestModal"; +import { TopNav } from "@/components/landing/TopNav"; +import { HeadlineBar } from "@/components/ui/HeadlineBar"; +import { useLandingStats } from "@/lib/api/landing"; + +// ── FAQ data ─────────────────────────────────────────────────────────────── + +const FAQ: { q: string; a: string }[] = [ { - title: "Аналитика", - routes: [ - { - href: "/analytics", - label: "Свердл рынок", - desc: "Динамика 14 мес · парадокс «строят vs покупают» · pipeline · районы ЕКБ.", - }, - { - href: "/analytics/prinzip", - label: "PRINZIP drill-down", - desc: "Velocity vs benchmark · gap-bar к рынку · ЖК с sparkline · инсайты.", - }, - { - href: "/analytics/developers", - label: "Топ девелоперов", - desc: "Sortable leaderboard · velocity scatter · сравнение sold% по месяцам.", - }, - { - href: "/analytics/objects/60160", - label: "Карточка ЖК (пример: Парк Победы)", - desc: "KPI · sale-chart · корпуса ЖК (cad_quarter + buildings) · POI-таблица · фото-галерея. /analytics/objects/{obj_id}.", - }, - { - href: "/analytics/recommend", - label: "Рекомендатор квартирографии", - desc: "Район + площадь + класс → распределение по 5 бакетам, цены p25/median/p75, выручка, comparable ЖК. Слайдеры под проект.", - }, - ], + q: "Откуда берутся данные?", + a: "Три источника: Росреестр (ДДУ-сделки, >7 млн записей), DOM.РФ (портфель новостроек, ПЗЗ), Objective (цены предложения). Обновление еженедельное для DOM.РФ и цен, ежеквартальное для Росреестра.", }, { - title: "Site Finder", - routes: [ - { - href: "/site-finder", - label: "Анализ участка по cad-номеру", - desc: "Вводишь кадастр → карта + score соц-инфраструктуры (школы/сады/магазины/парки +вес, трамваи −вес) + 20 ближайших конкурентов с подсветкой района. Данные: OSM POI обновляются еженедельно.", - }, - ], + q: "Насколько точны цены?", + a: "Цены предложения актуальны на дату последнего обновления. Цена сделки из ДДУ — реальная стоимость на момент регистрации в Росреестре. Расхождение с рыночной ценой в пике может составлять 5–15%.", }, { - title: "Админка (Token-protected)", - routes: [ - { - href: "/admin/scrape/all", - label: "Скрапер — единая панель", - desc: "Все джобы (kn/nspd_geo/objective) в одном месте · job_settings inline-edit · bulk Свердл geo backfill · ScrapeLogsPanel.", - }, - { - href: "/admin/leads", - label: "Лиды PRINZIP CRM", - desc: "Воронка Sankey · конверсия по месяцам · KPI · таблица лидов с фильтрами.", - }, - ], + q: "Какие районы и регионы охвачены?", + a: "Сейчас — Екатеринбург и Свердловская область (ПЗЗ ЕКБ, МСК-66). Расширение на другие регионы УрФО в роудмапе.", }, { - title: "Generative (WIP)", - routes: [ - { - href: "/concept", - label: "Concept (Stage 1)", - desc: "Импорт полигона → 3 варианта застройки. WIP — Stage 1a-c.", - }, - ], + q: "Что входит в пилотный доступ?", + a: "Полный доступ к Site Finder (анализ участков по кадастровому номеру), аналитике рынка Свердловской области и рекомендатору квартирографии. Срок пилота — 30 дней. Поддержка по email.", + }, + { + q: "Как устроен маппинг Objective → DOM.РФ?", + a: "Объекты Objective сопоставляются с портфелем DOM.РФ по нечёткому совпадению названия и геокоординатам. Текущее покрытие — около 11% (один DOM.РФ-объект может объединять несколько Objective-лотов).", }, ]; +// ── Sub-components ───────────────────────────────────────────────────────── + +function FaqItem({ q, a }: { q: string; a: string }) { + const [open, setOpen] = useState(false); + return ( +
+ + {open ? ( +

+ {a} +

+ ) : null} +
+ ); +} + +// ── KPI formatting ───────────────────────────────────────────────────────── + +function formatNumber(n: number | undefined, decimals = 0): string { + if (n === undefined) return "—"; + return n.toLocaleString("ru", { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); +} + +/** Skeleton placeholder shown when stale=true (no fake data) */ +function KpiSkeleton() { + return ( +
+ ); +} + +// ── Main component ───────────────────────────────────────────────────────── + export default function HomePage() { + const [modalOpen, setModalOpen] = useState(false); + const { data: stats, isLoading } = useLandingStats(); + + const isStale = stats?.stale === true; + const showSkeleton = isLoading || isStale; + return (
-
-

GenDesign

-

- Generative Design + Site Finder — Discovery MVP. Свердл рынок & - PRINZIP. -

-
+ {/* ── Nav ────────────────────────────────────────────────────────── */} + {/* UserMenu — личный кабинет (avatar + dropdown) для залогиненного юзера. + В dev (401) или до загрузки /me — UserMenu сам рендерит null. */} + } /> - {SECTIONS.map((section) => ( -
-

+

+ Цифры рынка перед фундаментом +

+

+ Аналитика новостроек Свердловской области — Росреестр, DOM.РФ, + Objective — в одном инструменте для девелоперов. +

+ + {/* ── KPI cards ──────────────────────────────────────────────── */} +
+ {/* zk_total */} +
+
+ +
+
ЖК DOM.РФ в ЕКБ
+
+ {showSkeleton ? : formatNumber(stats?.zk_total)} +
+
+ + {/* deals_total */} +
+
+ +
+
ДДУ-сделки Росреестра
+
+ {showSkeleton ? ( + + ) : ( + formatNumber(stats?.deals_total) + )} +
+
+ + {/* price_coverage_pct */} +
+
+ +
+
Покрытие ценами Objective
+
+ {showSkeleton ? ( + + ) : ( + `${formatNumber(stats?.price_coverage_pct, 1)}%` + )} +
+
+ + {/* mapping_coverage_pct */} +
+
+ +
+
Маппинг Objective → DOM.РФ
+
+ {showSkeleton ? ( + + ) : ( + `${formatNumber(stats?.mapping_coverage_pct, 1)}%` + )} +
+
+ + {/* last_data_update */} +
+
+ +
+
Последнее обновление данных
+
+ {showSkeleton ? ( + + ) : ( + (stats?.last_data_update ?? "—") + )} +
+
+
+ + {isStale && ( +

- {section.title} - + Данные обновляются... +

+ )} +
+ + {/* ── Парадокс портфеля ──────────────────────────────────────────── */} +
+ + +

+ DOM.РФ фиксирует строящиеся ЖК, но цены предложения Objective + покрывают лишь часть из них. Из тех, где цены есть, публично доступны + единицы — остальное требует прямого запроса или данных, агрегированных + GenDesign. +

+
+ + {/* ── Pricing ────────────────────────────────────────────────────── */} +
+
- {section.routes.map((r) => ( - -
- {r.href} -
-
{r.label}
-
- {r.desc} -
- - ))} + Пилотная подписка +
+
+ + 30 000 + + + ₽ / мес +
-
- ))} +
    + {[ + "Site Finder — анализ участков по кадастру", + "Аналитика рынка Свердловской области", + "Рекомендатор квартирографии", + "Данные DOM.РФ, Росреестр, Objective", + "Email-поддержка в рабочие дни", + ].map((item) => ( +
  • + + + {item} + +
  • + ))} +
+ + +
+
+ + {/* ── FAQ ────────────────────────────────────────────────────────── */} +
+

+ Частые вопросы +

+

+ Об источниках данных, точности и поддержке. +

+
+ {FAQ.map((item) => ( + + ))} +
+
+ + {/* ── Footer ─────────────────────────────────────────────────────── */} + + {/* ── Modal ──────────────────────────────────────────────────────── */} + setModalOpen(false)} /> ); } + +// ── Shared styles ────────────────────────────────────────────────────────── + +const kpiCardStyle: React.CSSProperties = { + background: "var(--bg-card)", + border: "1px solid var(--border-card)", + borderRadius: 12, + padding: "16px 18px", +}; + +const kpiIconWrap: React.CSSProperties = { + width: 28, + height: 28, + background: "var(--accent-soft)", + borderRadius: 6, + display: "flex", + alignItems: "center", + justifyContent: "center", + marginBottom: 8, +}; + +const kpiLabel: React.CSSProperties = { + fontSize: 12, + textTransform: "uppercase", + letterSpacing: "0.04em", + color: "var(--fg-secondary)", + fontWeight: 500, + marginBottom: 6, +}; + +const kpiValue: React.CSSProperties = { + fontSize: 28, + fontWeight: 600, + color: "var(--fg-primary)", + fontVariantNumeric: "tabular-nums", +}; diff --git a/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx new file mode 100644 index 00000000..03b193f3 --- /dev/null +++ b/frontend/src/app/site-finder/analysis/[cad]/AnalysisPageContent.tsx @@ -0,0 +1,345 @@ +"use client"; + +import Link from "next/link"; + +import { Section1ParcelInfo } from "@/components/site-finder/analysis/Section1ParcelInfo"; +import { Section2NetworksUtilities } from "@/components/site-finder/analysis/Section2NetworksUtilities"; +import { Section3SettingsAndCompetitors } from "@/components/site-finder/analysis/Section3SettingsAndCompetitors"; +import { Section4Estimate } from "@/components/site-finder/analysis/Section4Estimate"; +import { Section5Atmosphere } from "@/components/site-finder/analysis/Section5Atmosphere"; +import { useParcelAnalyzeQuery } from "@/lib/site-finder-api"; +import type { ParcelAnalysis } from "@/types/site-finder"; + +// ── Props ───────────────────────────────────────────────────────────────────── + +interface Props { + cad: string; +} + +// ── Page Content (client — needs TanStack Query) ─────────────────────────────── + +export function AnalysisPageContent({ cad }: Props) { + const { data, isLoading, error } = useParcelAnalyzeQuery(cad); + + // ── Loading ──────────────────────────────────────────────────────────────── + + if (isLoading) { + return ( +
+ +
+ Анализируем участок {cad}… +
+
+ ); + } + + // ── Error ────────────────────────────────────────────────────────────────── + + if (error || !data) { + const msg = + error instanceof Error ? error.message : "Ошибка загрузки анализа"; + return ( +
+ +
+ {msg} +
+
+ + Вернуться к Site Finder + +
+
+ ); + } + + // ── Results layout ───────────────────────────────────────────────────────── + + // Cast to ParcelAnalysis (superset of ParcelAnalyzeResponse) — both describe + // the same /analyze endpoint; Section3 requires the richer type. + const analysis = data as unknown as ParcelAnalysis; + + const districtName = analysis.district?.district_name ?? "—"; + const areaHa = analysis.geometry_suitability?.area_ha; + const areaStr = areaHa != null ? `${areaHa.toFixed(2)} га` : null; + + return ( +
+ {/* Breadcrumb */} + + + {/* Page layout: sidebar (240px) + main scroll */} +
+ {/* ── Sidebar nav ───────────────────────────────────────────────── */} + + + {/* ── Main scroll area ──────────────────────────────────────────── */} +
+ {/* Section 1 — IMPLEMENTED in A5 */} + + + {/* Section 2 — IMPLEMENTED in A6 */} + + + {/* Section 3 — IMPLEMENTED in A7 */} + + + {/* Section 4 — IMPLEMENTED in A10 */} + + + {/* Section 5 — IMPLEMENTED in A11 */} + +
+
+
+ ); +} + +// ── Breadcrumb ───────────────────────────────────────────────────────────────── + +function Breadcrumb({ + cad, + districtName, + areaStr, +}: { + cad: string; + districtName?: string; + areaStr?: string | null; +}) { + const parts = [cad]; + if (districtName && districtName !== "—") parts.push(districtName); + if (areaStr) parts.push(areaStr); + + return ( +
+ + Site Finder + + / + {parts.map((part, i) => ( + + {i > 0 && ( + + · + + )} + {part} + + ))} + + · + + анализ +
+ ); +} + +// ── Sidebar nav ──────────────────────────────────────────────────────────────── + +const NAV_ITEMS = [ + { id: "section-1", label: "1. Информация" }, + { id: "section-2", label: "2. Сети" }, + { + id: "section-3", + label: "3. Продажи конкурентов", + children: [ + { id: "section-3-1", label: "3.1 Настройки выборки" }, + { id: "section-3-2", label: "3.2 Планировки" }, + { id: "section-3-3", label: "3.3 Остатки и скорость" }, + ], + }, + { id: "section-4", label: "4. Оценка" }, + { id: "section-5", label: "5. Атмосфера" }, +]; + +function AnalysisSidebarNav() { + function scrollTo(id: string) { + const el = document.getElementById(id); + if (el) el.scrollIntoView({ behavior: "smooth" }); + } + + return ( + + ); +} + +// ── Section placeholder ──────────────────────────────────────────────────────── + +function SectionPlaceholder({ + number, + title, + note, +}: { + number: string; + title: string; + note: string; +}) { + return ( + <> +
+
+ {number}. {title} +
+
+
+ {note} +
+ + ); +} diff --git a/frontend/src/app/site-finder/analysis/[cad]/page.tsx b/frontend/src/app/site-finder/analysis/[cad]/page.tsx new file mode 100644 index 00000000..6a82c8da --- /dev/null +++ b/frontend/src/app/site-finder/analysis/[cad]/page.tsx @@ -0,0 +1,53 @@ +import { Suspense } from "react"; +import type { Metadata } from "next"; + +import { AnalysisPageContent } from "./AnalysisPageContent"; + +// ── Metadata ────────────────────────────────────────────────────────────────── + +interface PageProps { + params: Promise<{ cad: string }>; +} + +// Next.js delivers `cad` URL-encoded (":" -> "%3A"); decode once at the page +// boundary so downstream hooks/components see canonical "66:41:0204016:10" +// and re-encode exactly once when building API URLs / hrefs (prevents the +// double-encode that made backend regex reject the cad with HTTP 400). +// Regression history: PR #351 added this in Client Component; A7 split into +// Server Component (PR #356) dropped it; this restores in Server boundary. +export async function generateMetadata({ + params, +}: PageProps): Promise { + const { cad: cadRaw } = await params; + const cad = decodeURIComponent(cadRaw); + return { + title: `Анализ ${cad} — Site Finder`, + description: `Анализ инвестиционного участка ${cad}`, + }; +} + +// ── Page ────────────────────────────────────────────────────────────────────── + +export default async function AnalysisPage({ params }: PageProps) { + const { cad: cadRaw } = await params; + const cad = decodeURIComponent(cadRaw); + + return ( + + Загрузка анализа… + + } + > + + + ); +} diff --git a/frontend/src/app/site-finder/page.tsx b/frontend/src/app/site-finder/page.tsx index faeddd61..20a08064 100644 --- a/frontend/src/app/site-finder/page.tsx +++ b/frontend/src/app/site-finder/page.tsx @@ -1,491 +1,196 @@ "use client"; -import { Suspense, useEffect, useState } from "react"; import dynamic from "next/dynamic"; import Link from "next/link"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import type { FeatureCollection } from "geojson"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; import { CadInput } from "@/components/site-finder/CadInput"; -import { FetchingState } from "@/components/site-finder/FetchingState"; -import { KpiCard } from "@/components/site-finder/KpiCard"; -import { OverviewTab } from "@/components/site-finder/OverviewTab"; -import { EnvironmentTab } from "@/components/site-finder/EnvironmentTab"; -import { LandTab } from "@/components/site-finder/LandTab"; -import { MarketTab } from "@/components/site-finder/MarketTab"; -import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel"; -import { useSiteAnalysis } from "@/hooks/useSiteAnalysis"; -import { - POI_DEFAULT_WEIGHTS, - type PoiCategoryKey, -} from "@/lib/api/weightProfiles"; +import { MapFilterBar } from "@/components/site-finder/entry/MapFilterBar"; +import { ParcelDrawer } from "@/components/site-finder/entry/ParcelDrawer"; +import { ParcelLegend } from "@/components/site-finder/entry/ParcelLegend"; +import { RecentParcels } from "@/components/site-finder/entry/RecentParcels"; +import { useParcelsBboxQuery } from "@/lib/site-finder-api"; +import type { + ParcelBboxFilters, + ParcelBboxItem, + BboxCoords, +} from "@/lib/site-finder-api"; -// SiteMap imports Leaflet which requires browser APIs — load without SSR -const SiteMap = dynamic( - () => import("@/components/site-finder/SiteMap").then((m) => m.SiteMap), +// EntryMap uses Leaflet — must load without SSR +const EntryMap = dynamic( + () => + import("@/components/site-finder/entry/EntryMap").then((m) => m.EntryMap), { ssr: false, loading: () => (
- Загрузка карты… + Загрузка карты...
), }, ); -type TabId = "overview" | "env" | "land" | "market"; +// ── FilterCountBridge ────────────────────────────────────────────────────────── +// Reads parcel counts for current bbox without re-mounting EntryMap. -const TABS: Array<{ id: TabId; label: string }> = [ - { id: "overview", label: "Обзор" }, - { id: "env", label: "Окружение" }, - { id: "land", label: "Земля" }, - { id: "market", label: "Рынок" }, -]; - -const TAB_IDS = TABS.map((t) => t.id); - -function isTabId(v: string | null | undefined): v is TabId { - return !!v && (TAB_IDS as readonly string[]).includes(v); +interface FilterBarBridgeProps { + filters: ParcelBboxFilters; + onChange: (f: ParcelBboxFilters) => void; + bbox: BboxCoords | null; } -// ── KPI helpers ─────────────────────────────────────────────────────────────── +function FilterBarBridge({ filters, onChange, bbox }: FilterBarBridgeProps) { + const { data: allInBbox } = useParcelsBboxQuery(bbox, {}); + const { data: filtered } = useParcelsBboxQuery(bbox, filters); -type KpiColor = "neutral" | "amber" | "green" | "red" | "blue"; - -function trendColor(pct: number): KpiColor { - if (pct > 5) return "green"; - if (pct > 0) return "blue"; - if (pct > -5) return "amber"; - return "red"; -} - -function noiseColor(db: number): KpiColor { - if (db < 45) return "green"; - if (db < 60) return "amber"; - return "red"; -} - -function scoreKpiColor(score: number, max: number): KpiColor { - const ratio = score / max; - if (ratio >= 0.65) return "green"; - if (ratio >= 0.35) return "amber"; - return "red"; + return ( + + ); } // ── Page ────────────────────────────────────────────────────────────────────── -function SiteFinderContent() { +export default function SiteFinderPage() { const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); - - const { mutate, data, isPending, error, isIdle, fetchingState, cancel } = - useSiteAnalysis(); - const [isochrones, setIsochrones] = useState( - undefined, + const [filters, setFilters] = useState({}); + const [selectedParcel, setSelectedParcel] = useState( + null, ); + const [bbox, setBbox] = useState(null); - // Weight profile state — lifted here so it survives tab switches. - // userId + adminToken allow the panel to load/save named profiles. - const [currentWeights, setCurrentWeights] = useState< - Record - >(() => ({ ...POI_DEFAULT_WEIGHTS })); - // Profile selection for the analyze call (null = system defaults). - const [activeProfileId, setActiveProfileId] = useState(null); - const [profileUserId, setProfileUserId] = useState(() => - typeof window === "undefined" - ? "" - : (localStorage.getItem("admin_user_id") ?? ""), - ); - const [adminToken] = useState(() => - typeof window === "undefined" - ? "" - : (localStorage.getItem("admin_token") ?? ""), - ); - // Lazy init: считаем initialTab один раз на mount (useState всё равно - // игнорирует initializer после первого render — не тратим CPU). - const [tab, setTabState] = useState(() => { - const t = searchParams.get("tab"); - return isTabId(t) ? t : "overview"; - }); - - // Sync tab → URL query (?tab=env). overview = default, не пишем в URL. - function setTab(next: TabId) { - setTabState(next); - const params = new URLSearchParams(searchParams.toString()); - if (next === "overview") { - params.delete("tab"); - } else { - params.set("tab", next); - } - const qs = params.toString(); - // Используем pathname вместо голого "?" — Next 15 App Router не всегда - // чисто чистит query при `router.replace("?")` (dangling `?` остаётся). - router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }); + function handleParcelSelect(parcel: ParcelBboxItem) { + setSelectedParcel(parcel); } - // Listen to back/forward navigation — reflect URL tab in state. - // setState с тем же значением React сам бэйлит через Object.is, поэтому - // guard по tab не нужен (и не вводит stale-closure через eslint-disable). - useEffect(() => { - const urlTab = searchParams.get("tab"); - setTabState(isTabId(urlTab) ? urlTab : "overview"); - }, [searchParams]); - - function handleAnalyze(cadNum: string) { - setIsochrones(undefined); - setTab("overview"); - mutate({ - cad: cadNum, - options: - activeProfileId != null - ? { profileId: activeProfileId } - : profileUserId - ? { profileUserId } - : undefined, - }); + function handleParcelDeselect() { + setSelectedParcel(null); } - function handleWeightsChange( - weights: Record, - profileId: number | null, - ) { - setCurrentWeights(weights); - // Store the active profile id so we can pass it to the analyze call. - // If user edited weights without saving a named profile, profileId=null - // and backend will use system defaults (sub-PR 5 would enable inline weights). - setActiveProfileId(profileId); + function handleCadSubmit(cad: string) { + router.push(`/site-finder/analysis/${encodeURIComponent(cad)}`); } - // Derive KPI values from data - const scoreMax = data?.score_max_reference ?? 40; - const scoreValue = data - ? `${data.score.toFixed(2)} / ${scoreMax.toFixed(0)}` - : "—"; - const scoreLabel = data?.score_label - ? `Score · ${data.score_label}` - : "Score"; - const scoreCardColor: KpiColor = data - ? scoreKpiColor(data.score, scoreMax) - : "neutral"; - - const districtValue = data?.district ? data.district.district_name : "—"; - const districtLabel = data?.district - ? `${(data.district.median_price_per_m2 / 1000).toFixed(0)} тыс ₽/м²` + - ` · ${(data.district.dist_to_center / 1000).toFixed(1)} км от центра` - : "Район"; - - const trendPct = data?.market_trend?.delta_6m_pct; - const trendValue = - trendPct != null - ? `${trendPct > 0 ? "+" : ""}${trendPct.toFixed(1)}%` - : "—"; - const trendLabel = data?.market_trend - ? `Тренд за 6 мес · ${data.market_trend.label}` - : "Тренд рынка"; - const trendKpiColor: KpiColor = - trendPct != null ? trendColor(trendPct) : "neutral"; - - const noiseDb = data?.noise?.estimated_db; - const noiseValue = noiseDb != null ? `${Math.round(noiseDb)} dB` : "—"; - const noiseLabel = data?.noise ? `Шум · ${data.noise.level}` : "Шум"; - const noiseKpiColor: KpiColor = - noiseDb != null ? noiseColor(noiseDb) : "neutral"; - return ( -
- {/* Breadcrumb + header */} -
+
+ {/* Header */} +
← Главная + · +

+ SiteFinder · карта участков +

+
+ + {/* Filter bar */} +
+
+ {/* Main layout */}
-
-

- Site Finder -

-

- Введите кадастровый номер участка или квартала для анализа локации -

-
+ {/* Map area — position: relative so ParcelDrawer can anchor absolutely here */}
- + + + {/* ParcelDrawer — anchored to map area right edge, not page */} + +
+ + {/* Right sidebar — always visible; CadInput accessible parallel with drawer */} +
+ + +
- - {/* Weight profile panel — collapsible, below header */} -
- {/* Optional user-id field for profile CRUD (shown only when adminToken present) */} - {!!adminToken && ( -
- - { - setProfileUserId(e.target.value); - if (typeof window !== "undefined") { - localStorage.setItem("admin_user_id", e.target.value); - } - }} - /> -
- )} - -
- - {/* Error state */} - {error && ( -
- {error instanceof Error ? error.message : "Ошибка анализа участка"} -
- )} - - {/* #93 — Fetching state: backend на 202 Accepted, идёт on-demand NSPD fetch */} - {fetchingState && ( - - )} - - {/* Pending skeleton — первичный analyze в полёте (но fetching state ещё не активирован) */} - {isPending && !fetchingState && ( -
- Анализируем участок… -
- )} - - {/* Empty initial state */} - {isIdle && !error && ( -
- Введите кадастровый номер и нажмите «Анализировать» -
- )} - - {/* Results */} - {data && ( - <> - {/* Cad label */} -
- {data.cad_num} -
- - {/* Hero band — sticky KPI cards */} -
- - - - -
- - {/* Tab buttons */} -
- {TABS.map((t) => ( - - ))} -
- - {/* 2-column grid: tab content + sticky map */} -
- {/* Tab content */} -
- {tab === "overview" && ( - - )} - {tab === "env" && } - {tab === "land" && } - {tab === "market" && } -
- - {/* Sticky right sidebar — map always visible */} - -
- - )}
); } - -// useSearchParams() в Next 15 App Router throw'ит при static rendering без -// boundary — поэтому оборачиваем page-level. -export default function SiteFinderPage() { - return ( - - - - ); -} diff --git a/frontend/src/app/trade-in/page.tsx b/frontend/src/app/trade-in/page.tsx new file mode 100644 index 00000000..02192dbc --- /dev/null +++ b/frontend/src/app/trade-in/page.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; + +import type { + AggregatedEstimate, + TradeInEstimateInput, +} from "@/types/trade-in"; +import { useEstimateMutation, useEstimate } from "@/lib/trade-in-api"; +import { EstimateForm } from "@/components/trade-in/EstimateForm"; +import { EstimateResult } from "@/components/trade-in/EstimateResult"; +import { EstimateProgress } from "@/components/trade-in/EstimateProgress"; + +// ── URL persistence helpers ──────────────────────────────────────────────────── + +function useEstimateId() { + // SSR guard: searchParams is only available in client + if (typeof window === "undefined") return null; + const params = new URLSearchParams(window.location.search); + return params.get("id"); +} + +// ── Page ────────────────────────────────────────────────────────────────────── + +export default function TradeInPage() { + const router = useRouter(); + + // Result from mutation (fresh) or from URL (restored) + const [freshResult, setFreshResult] = useState<{ + estimate: AggregatedEstimate; + input: TradeInEstimateInput; + } | null>(null); + + const urlEstimateId = useEstimateId(); + // Fetch from URL if no fresh result yet + const restoredEstimate = useEstimate( + freshResult === null ? urlEstimateId : null, + ); + + const mutation = useEstimateMutation(); + + function handleSubmit(input: TradeInEstimateInput) { + mutation.mutate(input, { + onSuccess: (estimate) => { + setFreshResult({ estimate, input }); + // Persist estimate_id in URL for shareable link + router.replace(`/trade-in?id=${estimate.estimate_id}`, { + scroll: false, + }); + }, + }); + } + + const apiError = mutation.error?.message ?? null; + + // Determine what to render on the right side + const resultData: { + estimate: AggregatedEstimate; + input: TradeInEstimateInput; + } | null = + freshResult ?? + (restoredEstimate.data + ? { + estimate: restoredEstimate.data, + // When restoring from URL we don't have the original input — synthesise a minimal one + input: { + address: restoredEstimate.data.analogs[0]?.address ?? "—", + area_m2: 0, + rooms: 0, + floor: 0, + total_floors: 0, + }, + } + : null); + + return ( +
+ {/* Page header */} +
+

+ Trade-In Estimator +

+

+ Оценка рыночной стоимости квартиры по аналогам и сделкам ЕКБ +

+
+ + {/* Two-column layout */} +
+ {/* Left — sticky form */} +
+ + +
+ + {/* Right — result area */} +
+ {resultData ? ( + + ) : restoredEstimate.isLoading ? ( + + ) : ( + + )} +
+
+ + {/* Responsive: stack on mobile */} + +
+ ); +} + +function EmptyState() { + return ( +
+
🏠
+
+ Введите параметры квартиры +
+
+ Оценка появится здесь после нажатия «Оценить» +
+
+ ); +} diff --git a/frontend/src/components/admin/BulkGeoPanel.tsx b/frontend/src/components/admin/BulkGeoPanel.tsx index 06195b45..7fa8ade8 100644 --- a/frontend/src/components/admin/BulkGeoPanel.tsx +++ b/frontend/src/components/admin/BulkGeoPanel.tsx @@ -37,7 +37,7 @@ const THEMATIC_OPTIONS = [ // ── Component ──────────────────────────────────────────────────────────────── -export function BulkGeoPanel({ token }: { token: string }) { +export function BulkGeoPanel() { const [parallelism, setParallelism] = useState(5); const [thematicIds, setThematicIds] = useState([2]); const [source, setSource] = useState<"rosreestr_pending" | "all_in_region">( @@ -62,7 +62,6 @@ export function BulkGeoPanel({ token }: { token: string }) { }; return apiFetch("/api/v1/admin/scrape/geo/bulk", { method: "POST", - headers: { "X-Admin-Token": token }, body: JSON.stringify(body), }); }, @@ -179,9 +178,9 @@ export function BulkGeoPanel({ token }: { token: string }) {
- {!token ? ( -

- Введите Admin Token чтобы видеть лог. -

- ) : logs.isLoading ? ( + {logs.isLoading ? (

Загрузка…

) : logs.isError ? (

Ошибка: {String(logs.error)}

diff --git a/frontend/src/components/analytics/ObjectChecksBadges.tsx b/frontend/src/components/analytics/ObjectChecksBadges.tsx new file mode 100644 index 00000000..f565ca8c --- /dev/null +++ b/frontend/src/components/analytics/ObjectChecksBadges.tsx @@ -0,0 +1,45 @@ +import type { ObjectCheckRow } from "@/types/analytics"; + +const CHECK_LABELS: Record = { + no_problems: "Нет проблемных объектов", + docs: "Документы опубликованы", + timing: "Сроки соблюдены", + photos: "Актуальные фото", + bankruptcy: "Не банкрот", + declaration: "Декларация обновлена", +}; + +export function ObjectChecksBadges({ checks }: { checks: ObjectCheckRow[] }) { + if (checks.length === 0) { + return ( +

+ Данные проверок ещё не загружены. +

+ ); + } + + return ( +
+ {checks.map((c) => ( +
+ {c.passed ? "✓" : "✗"} + {CHECK_LABELS[c.check_type] ?? c.check_type} +
+ ))} +
+ ); +} diff --git a/frontend/src/components/analytics/ObjectDocumentsList.tsx b/frontend/src/components/analytics/ObjectDocumentsList.tsx new file mode 100644 index 00000000..b7817558 --- /dev/null +++ b/frontend/src/components/analytics/ObjectDocumentsList.tsx @@ -0,0 +1,106 @@ +import type { ObjectDocumentRow } from "@/types/analytics"; + +function safeUrl(url: string): string { + try { + const parsed = new URL(url); + if (parsed.protocol === "https:" || parsed.protocol === "http:") { + return url; + } + } catch { + // fall through + } + return "#"; +} + +function fmtSize(bytes: number | null): string { + if (bytes === null) return ""; + if (bytes < 1024) return `${bytes} Б`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} КБ`; + return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`; +} + +const DOC_TYPE_ORDER: Record = { + декларация: 1, + разрешение: 2, + проектная: 3, + отчётность: 4, + прочее: 5, +}; + +export function ObjectDocumentsList({ docs }: { docs: ObjectDocumentRow[] }) { + if (docs.length === 0) { + return ( +

Документы отсутствуют.

+ ); + } + + // Group by doc_type + const byType = new Map(); + for (const d of docs) { + const group = byType.get(d.doc_type) ?? []; + group.push(d); + byType.set(d.doc_type, group); + } + + const sortedTypes = [...byType.keys()].sort((a, b) => { + const oa = DOC_TYPE_ORDER[a.toLowerCase()] ?? 99; + const ob = DOC_TYPE_ORDER[b.toLowerCase()] ?? 99; + return oa - ob || a.localeCompare(b, "ru"); + }); + + return ( +
+ {sortedTypes.map((type) => { + const group = byType.get(type)!; + return ( +
+
+ {type} +
+
+ {group.map((doc, i) => ( +
+ 📄 + + {doc.doc_num ? `${doc.doc_num}` : `Документ ${i + 1}`} + + {doc.posted_at ? ( + + {doc.posted_at.slice(0, 10)} + + ) : null} + {doc.size_bytes ? ( + + {fmtSize(doc.size_bytes)} + + ) : null} +
+ ))} +
+
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/analytics/ObjectMetroList.tsx b/frontend/src/components/analytics/ObjectMetroList.tsx new file mode 100644 index 00000000..63ba85cb --- /dev/null +++ b/frontend/src/components/analytics/ObjectMetroList.tsx @@ -0,0 +1,85 @@ +import type { MetroEntry } from "@/types/analytics"; + +interface Props { + nearest_name: string | null; + nearest_walk_minutes: number | null; + top3: MetroEntry[] | null; +} + +export function ObjectMetroList({ + nearest_name, + nearest_walk_minutes, + top3, +}: Props) { + // Build display list: prefer top3 array, fallback to nearest fields + const entries: MetroEntry[] = + top3 && top3.length > 0 + ? top3 + : nearest_name + ? [ + { + name: nearest_name, + time: nearest_walk_minutes, + line: null, + color: null, + isWalk: true, + }, + ] + : []; + + if (entries.length === 0) { + return ( +

+ Данные о метро отсутствуют. +

+ ); + } + + return ( +
+ {entries.map((m, i) => ( +
+ +
+
{m.name}
+ {m.line ? ( +
{m.line}
+ ) : null} +
+ {m.time != null ? ( +
+ {m.isWalk !== false ? "🚶" : "🚗"} {m.time} мин +
+ ) : null} +
+ ))} +
+ ); +} diff --git a/frontend/src/components/analytics/ObjectQuartirographyTable.tsx b/frontend/src/components/analytics/ObjectQuartirographyTable.tsx new file mode 100644 index 00000000..0d57cee3 --- /dev/null +++ b/frontend/src/components/analytics/ObjectQuartirographyTable.tsx @@ -0,0 +1,115 @@ +import type { ObjectQuartirographyRow } from "@/types/analytics"; + +function fmtPrice(v: number | null): string { + if (v === null) return "—"; + return ( + (v / 1_000_000).toLocaleString("ru-RU", { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }) + " млн" + ); +} + +function fmtArea(v: number | null): string { + if (v === null) return "—"; + return v.toLocaleString("ru-RU", { maximumFractionDigits: 1 }) + " м²"; +} + +export function ObjectQuartirographyTable({ + rows, +}: { + rows: ObjectQuartirographyRow[]; +}) { + if (rows.length === 0) { + return ( +

+ Данные квартирографии отсутствуют. +

+ ); + } + + const headers = ["Тип", "Всего", "В продаже", "Площадь", "Цена (от–до)"]; + + return ( +
+ + + + {headers.map((h) => ( + + ))} + + + + {rows.map((r) => ( + + + + + + + + ))} + +
+ {h} +
+ {r.room_label} + + {r.total_count.toLocaleString("ru-RU")} + + {r.free_count > 0 ? ( + + {r.free_count.toLocaleString("ru-RU")} + + ) : ( + 0 + )} + + {r.area_min !== null || r.area_max !== null + ? `${fmtArea(r.area_min)} – ${fmtArea(r.area_max)}` + : "—"} + + {r.price_min !== null || r.price_max !== null + ? `${fmtPrice(r.price_min)} – ${fmtPrice(r.price_max)}` + : "—"} +
+
+ ); +} diff --git a/frontend/src/components/analytics/ObjectSpecsTable.tsx b/frontend/src/components/analytics/ObjectSpecsTable.tsx new file mode 100644 index 00000000..3b0ebb34 --- /dev/null +++ b/frontend/src/components/analytics/ObjectSpecsTable.tsx @@ -0,0 +1,204 @@ +import type { ObjectFullDetail } from "@/types/analytics"; + +interface SpecRow { + label: string; + value: string | null; +} + +function yn(v: boolean | null | undefined): string | null { + if (v === null || v === undefined) return null; + return v ? "Да" : "Нет"; +} + +function num(v: number | null | undefined, suffix = ""): string | null { + if (v === null || v === undefined) return null; + return `${v.toLocaleString("ru-RU")}${suffix}`; +} + +function numFmt( + v: number | null | undefined, + decimals = 0, + suffix = "", +): string | null { + if (v === null || v === undefined) return null; + return ( + v.toLocaleString("ru-RU", { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }) + suffix + ); +} + +function price(v: number | null | undefined): string | null { + if (v === null || v === undefined) return null; + return ( + (v / 1_000_000).toLocaleString("ru-RU", { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }) + " млн ₽" + ); +} + +function priceM2(v: number | null | undefined): string | null { + if (v === null || v === undefined) return null; + return v.toLocaleString("ru-RU", { maximumFractionDigits: 0 }) + " ₽/м²"; +} + +export function ObjectSpecsTable({ d }: { d: ObjectFullDetail }) { + const specs: SpecRow[] = [ + // Identity + { label: "Класс объекта", value: d.obj_class }, + { label: "Класс энергоэффективности", value: d.energy_eff }, + { label: "Материал стен", value: d.wall_type }, + { label: "Первый этаж", value: d.first_floor_type }, + // Building + { label: "Подъездов", value: num(d.section_count) }, + { label: "Этажей (max)", value: num(d.floor_max) }, + { label: "Высота потолков", value: numFmt(d.ceiling_height_m, 2, " м") }, + { + label: "Лифты пассажирские", + value: + d.elevators_passenger_count === 0 + ? "Нет" + : num(d.elevators_passenger_count), + }, + { + label: "Лифты грузовые", + value: + d.elevators_cargo_count === 0 ? "Нет" : num(d.elevators_cargo_count), + }, + // Parking + { + label: "Машино-мест (парковка)", + value: num(d.parking_total_slots), + }, + { + label: "Гостевые (приказ.)", + value: num(d.guest_parking_inside_count), + }, + { + label: "Гостевые (улица)", + value: num(d.guest_parking_outside_count), + }, + { + label: "Обеспеченность парковкой", + value: numFmt(d.parking_provision_pct, 1, "%"), + }, + // Apartments + { label: "Квартир всего", value: num(d.flat_count) }, + { + label: "Площадь квартир", + value: + d.flat_area_min != null && d.flat_area_max != null + ? `${numFmt(d.flat_area_min, 1)} – ${numFmt(d.flat_area_max, 1)} м²` + : null, + }, + { + label: "Средняя площадь квартиры", + value: numFmt(d.avg_flat_area_m2, 1, " м²"), + }, + { + label: "Вариантов отделки", + value: num(d.finishing_variants_count), + }, + { label: "Свободная планировка", value: yn(d.has_free_planning) }, + // Price + { + label: "Цена (от)", + value: price(d.price_min_rub), + }, + { + label: "Цена (до)", + value: price(d.price_max_rub), + }, + { + label: "Цена/м² (от)", + value: priceM2(d.price_per_m2_min), + }, + { + label: "Цена/м² (до)", + value: priceM2(d.price_per_m2_max), + }, + // Yard + { + label: "Детских площадок", + value: num(d.playground_kids_count), + }, + { + label: "Спортивных площадок", + value: num(d.playground_sport_count), + }, + { label: "Велодорожки", value: yn(d.has_bike_paths) }, + { + label: "Мусорные площадки", + value: num(d.trash_areas_count), + }, + // OVZ + { label: "Пандус", value: yn(d.has_ramp) }, + { label: "Понижающие платформы (ОВЗ)", value: yn(d.has_low_platforms) }, + { + label: "Инвалидный подъёмник", + value: yn(d.has_wheelchair_lift), + }, + // Developer + { label: "Девелопер", value: d.dev_name }, + { label: "Группа компаний", value: d.dev_group_name }, + { label: "Эскроу", value: yn(d.escrow) }, + { + label: "Декларация №", + value: d.project_declaration_num, + }, + { + label: "Дата публикации проекта", + value: d.project_published_at?.slice(0, 10) ?? null, + }, + ]; + + const filtered = specs.filter((s) => s.value !== null); + if (filtered.length === 0) { + return ( +

+ Характеристики ещё не загружены. +

+ ); + } + + return ( +
+ + + {filtered.map((s) => ( + + + + + ))} + +
+ {s.label} + + {s.value} +
+
+ ); +} diff --git a/frontend/src/components/auth/NoAccessScreen.tsx b/frontend/src/components/auth/NoAccessScreen.tsx new file mode 100644 index 00000000..11c0eb0a --- /dev/null +++ b/frontend/src/components/auth/NoAccessScreen.tsx @@ -0,0 +1,106 @@ +"use client"; + +/** + * Полноэкранный «доступа нет» — показывается в двух случаях: + * 1. /api/v1/me вернул 403 (юзер аутентифицирован Caddy basic_auth, + * но его нет в `auth/roles.yaml`). Variant: "user". + * 2. Юзер открыл URL, которого нет в его allowed_paths (или есть в + * deny_paths). Variant: "path". + * + * Tone-of-voice (см. `.claude/rules/ui-microcopy.md`): нейтральный business, + * без эмодзи, без «Ой!». Просто констатация. + */ + +import { Lock } from "lucide-react"; + +interface NoAccessScreenProps { + /** "user" — юзер вне ролей; "path" — путь вне scope */ + variant: "user" | "path"; + /** Текущий путь — используется только для variant="path" в caption */ + path?: string; +} + +export function NoAccessScreen({ variant, path }: NoAccessScreenProps) { + const title = "Доступа нет"; + const subtitle = + variant === "user" + ? "Учётная запись не привязана к роли. Обратитесь к администратору GenDesign — kopylov." + : "У вашей роли нет доступа к этому разделу. Вернитесь на главную или обратитесь к администратору."; + + return ( +
+
+
+ +
+

+ {title} +

+

+ {subtitle} +

+ {variant === "path" && path ? ( +

+ {path} +

+ ) : null} +
+
+ ); +} diff --git a/frontend/src/components/auth/RouteGuard.tsx b/frontend/src/components/auth/RouteGuard.tsx new file mode 100644 index 00000000..a4891e04 --- /dev/null +++ b/frontend/src/components/auth/RouteGuard.tsx @@ -0,0 +1,68 @@ +"use client"; + +/** + * RouteGuard — клиентский гард, оборачивающий весь app в layout.tsx. + * + * Логика: + * - useMe() loading → null (короткое окно; основной UI вернётся когда + * /me ответит, обычно <50 ms из браузер-cache после первого захода) + * - useMe() 403 (юзер не в roles.yaml) → fullscreen NoAccessScreen("user") + * - useMe() 401 (dev без Caddy basic_auth) → пропускаем (dev escape-hatch). + * Это критично — без этого локально без Caddy фронт станет полностью + * белым. В проде Caddy всегда ставит X-Authenticated-User → 401 невозможен. + * - useMe() ok + pathname ∉ allowed_paths (или ∈ deny_paths) → fullscreen + * NoAccessScreen("path"). Не редиректим — у user намеренный direct-URL + * hit на /admin (например), и redirect замаскирует, что у него нет + * доступа. Прямо говорим «нет». + * - useMe() ok + pathname разрешён → пропускаем children. + * + * Backend всё равно сам блокирует /api/v1/* — этот гард только для UX + * («не показывать ссылки и не позволять открыть страницу через URL»). + */ + +import { usePathname } from "next/navigation"; + +import { NoAccessScreen } from "@/components/auth/NoAccessScreen"; +import { HTTPError } from "@/lib/api"; +import { isPathAllowed } from "@/lib/isPathAllowed"; +import { useMe } from "@/lib/useMe"; + +interface RouteGuardProps { + children: React.ReactNode; +} + +export function RouteGuard({ children }: RouteGuardProps) { + const pathname = usePathname() ?? "/"; + const { data, isLoading, error } = useMe(); + + // Initial load — короткое окно, рендерим пустоту чтобы не моргать UI. + if (isLoading) return null; + + // 401 — dev без Caddy basic_auth. Пропускаем как «нет RBAC, all allowed». + if (error instanceof HTTPError && error.status === 401) { + return <>{children}; + } + + // 403 — юзер не в roles.yaml. Прям ничего не показываем кроме «доступа нет». + if (error instanceof HTTPError && error.status === 403) { + return ; + } + + // Другая сетевая ошибка — fail-open в dev / показываем NoAccess в prod-like. + // Безопаснее закрыть UI чем случайно пустить юзера на admin при transient + // network fail. NoAccessScreen("user") универсальный fallback. + if (error) { + return ; + } + + // Нет scope (теоретически unreachable — либо error либо data) — заглушка. + if (!data) return null; + + // Path-level guard на прямой URL. Backend всё равно 403 на /api/v1/admin/*, + // но мы не хотим показать «битый admin UI» — лучше сразу stop. + if (!isPathAllowed(data.allowed_paths, data.deny_paths, pathname)) { + return ; + } + + return <>{children}; +} diff --git a/frontend/src/components/auth/UserMenu.tsx b/frontend/src/components/auth/UserMenu.tsx new file mode 100644 index 00000000..2a37dbc3 --- /dev/null +++ b/frontend/src/components/auth/UserMenu.tsx @@ -0,0 +1,201 @@ +"use client"; + +/** + * UserMenu — личный кабинет в правом верхнем углу top-bar (gendsgn.ru/). + * + * Avatar-кнопка с инициалом из username + dropdown: + * - текущий username + * - role badge («Админ» / «Пилот») + * - кнопка «Выйти» — invalidate Caddy basic_auth cached creds в browser + * + * Используется в `TopNav.rightSlot`. Если /me грузится или 401 (dev без + * Caddy) — рендерим null (нечего показывать). + * + * Logout flow — basic_auth quirk: + * 1. Отправляем fetch с заведомо неправильными Basic creds → 401 от Caddy + * → браузер забывает cached creds для этого origin. + * 2. Hard reload (`window.location.href = "/"`) — basic_auth prompt + * покажется заново. + * Это работает в Safari/Chrome/Firefox; legacy `ClearAuthenticationCache` + * не нужен. + */ + +import { useEffect, useRef, useState } from "react"; + +import { LogOut } from "lucide-react"; + +import { useMe } from "@/lib/useMe"; + +/** + * Logout — invalidate basic_auth cache + reload. + * + * Trick: fetch на защищённый endpoint с явно неверным Basic-токеном. + * Caddy ответит 401 (because creds wrong) → браузер выкинет старые cached + * creds для этого origin. Затем hard reload — Caddy снова запросит basic_auth + * prompt, потому что cached creds invalidated. + */ +async function logout(): Promise { + try { + await fetch("/api/v1/me", { + headers: { + Authorization: "Basic " + btoa("logout:logout"), + }, + cache: "no-store", + }); + } catch { + // Сетевая ошибка — всё равно делаем hard reload. + } + // Hard reload форсит новый basic_auth handshake. + window.location.href = "/"; +} + +export function UserMenu() { + const { data, isLoading, error } = useMe(); + const [open, setOpen] = useState(false); + const containerRef = useRef(null); + const buttonRef = useRef(null); + + // Click outside → закрыть dropdown. + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + setOpen(false); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [open]); + + // Escape key → закрыть + вернуть focus на avatar. + useEffect(() => { + if (!open) return; + function handleKey(e: KeyboardEvent) { + if (e.key === "Escape") { + setOpen(false); + buttonRef.current?.focus(); + } + } + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [open]); + + // Loading / 401 / no data — ничего не рендерим. + // TopNav сам обрабатывает 401 (dev без Caddy) — показывает все nav items. + // Тут просто прячемся, чтобы не было «пустого» avatar без username. + if (isLoading || error || !data) return null; + + const initial = data.username[0]?.toUpperCase() ?? "?"; + const roleLabel = data.role === "admin" ? "Админ" : "Пилот"; + + return ( +
+ + + {open && ( +
+
+ {data.username} +
+
+ {roleLabel} +
+
+ +
+ )} +
+ ); +} diff --git a/frontend/src/components/landing/PilotRequestModal.tsx b/frontend/src/components/landing/PilotRequestModal.tsx new file mode 100644 index 00000000..d8710d64 --- /dev/null +++ b/frontend/src/components/landing/PilotRequestModal.tsx @@ -0,0 +1,371 @@ +"use client"; + +import { useState } from "react"; + +import { CheckCircle } from "lucide-react"; + +import { Drawer } from "@/components/ui/Drawer"; +import { + useSubmitPilotRequest, + type PilotRequestPayload, +} from "@/lib/api/landing"; + +// ── Types ────────────────────────────────────────────────────────────────── + +interface Props { + open: boolean; + onClose: () => void; +} + +interface FormState { + name: string; + phone: string; + email: string; + company: string; + message: string; +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +/** Derives a human-readable tracking ID from the UUID string returned by backend. + * Example: "7f3a8c2e-..." → "GD-7F3A8C2E" + */ +function deriveTrackingId(id: string): string { + return "GD-" + id.replace(/-/g, "").slice(0, 8).toUpperCase(); +} + +// ── Component ────────────────────────────────────────────────────────────── + +export function PilotRequestModal({ open, onClose }: Props) { + const [form, setForm] = useState({ + name: "", + phone: "", + email: "", + company: "", + message: "", + }); + const [emailError, setEmailError] = useState(""); + const [trackingId, setTrackingId] = useState(null); + + const mutation = useSubmitPilotRequest(); + + const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + + function handleChange( + e: React.ChangeEvent, + ) { + const { name, value } = e.target; + setForm((prev) => ({ ...prev, [name]: value })); + if (name === "email") { + setEmailError(value && !EMAIL_RE.test(value) ? "Некорректный email" : ""); + } + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!form.name.trim()) return; + if (form.email && !EMAIL_RE.test(form.email)) { + setEmailError("Некорректный email"); + return; + } + + const payload: PilotRequestPayload = { + name: form.name.trim(), + source: "landing", + ...(form.phone.trim() ? { phone: form.phone.trim() } : {}), + ...(form.email.trim() ? { email: form.email.trim() } : {}), + ...(form.company.trim() ? { company: form.company.trim() } : {}), + ...(form.message.trim() + ? { message: form.message.trim().slice(0, 2000) } + : {}), + }; + + mutation.mutate(payload, { + onSuccess: (data) => { + setTrackingId(deriveTrackingId(data.id)); + }, + }); + } + + function handleClose() { + setForm({ name: "", phone: "", email: "", company: "", message: "" }); + setEmailError(""); + setTrackingId(null); + mutation.reset(); + onClose(); + } + + return ( + + {/* Header */} +
+

+ Запросить пилотный доступ +

+
+ +
+ {/* Success state */} + {trackingId !== null ? ( +
+ +

+ Заявка принята +

+

+ Мы свяжемся с вами в течение 1–2 рабочих дней. Номер заявки:{" "} + + {trackingId} + +

+ +
+ ) : ( + /* Form */ +
+
+ {/* Name */} +
+ + +
+ + {/* Company */} +
+ + +
+ + {/* Phone */} +
+ + +
+ + {/* Email */} +
+ + + {emailError ? ( + + {emailError} + + ) : null} +
+ + {/* Message */} +
+ +