add interactive analytics dashboard for Sverdlovsk market and PRINZIP

3 pages (market, PRINZIP drilldown, developers leaderboard) on top of
existing v_developer_full_metrics + domrf_realization views. ECharts on
the frontend, FastAPI router /api/v1/analytics on the backend.
This commit is contained in:
lekss361 2026-04-27 16:55:30 +03:00
parent 16481868a6
commit 8d3a0874ef
33 changed files with 2598 additions and 35 deletions

5
.gitignore vendored
View file

@ -9,6 +9,11 @@ __pycache__/
*.egg-info/
node_modules/
# Next.js build artifacts
.next/
frontend/.next/
out/
# Env / secrets
.env
.env.local

View file

@ -0,0 +1,117 @@
"""Analytics endpoints for the Sverdlovsk dashboard."""
from __future__ import annotations
import re
from typing import Annotated, Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from app.core.db import get_db
from app.services import analytics_queries as q
router = APIRouter()
DEV_ID_RE = re.compile(r"^\d+_\d+$")
def _validate_dev(dev_id: str) -> str:
if not DEV_ID_RE.match(dev_id):
raise HTTPException(status_code=400, detail="developer_id must match \\d+_\\d+")
return dev_id
# ---- Sverdlovsk region ------------------------------------------------------
@router.get("/sverdl/market-pulse")
def sverdl_market_pulse(db: Annotated[Session, Depends(get_db)]) -> list[dict[str, Any]]:
"""Monthly series Jan 2025 → present: total square (тыс м²), sold%, avg price."""
return q.market_pulse(db, region_code=66)
@router.get("/sverdl/quartirography")
def sverdl_quartirography(
db: Annotated[Session, Depends(get_db)],
source: Literal["portfolio", "deals"] = "portfolio",
) -> list[dict[str, Any]]:
"""Что строится (portfolio) vs что покупают (deals) по сегментам."""
return q.quartirography(db, source=source, region_id=66)
@router.get("/sverdl/pipeline")
def sverdl_pipeline(db: Annotated[Session, Depends(get_db)]) -> list[dict[str, Any]]:
"""По году ввода: total / sold% / unsold% / unopened%."""
return q.pipeline_by_year(db, region_code=66)
@router.get("/sverdl/districts")
def sverdl_districts(db: Annotated[Session, Depends(get_db)]) -> list[dict[str, Any]]:
"""ЕКБ-районы: ЖК-count, flat-count, area, цены."""
return q.districts(db)
@router.get("/sverdl/yandex-listings")
def sverdl_yandex_listings(db: Annotated[Session, Depends(get_db)]) -> dict[str, Any]:
"""Снимок активных новостроек Яндекс.Недвижимости."""
return q.yandex_listings(db)
# ---- Developers -------------------------------------------------------------
@router.get("/developers/top")
def developers_top(
db: Annotated[Session, Depends(get_db)],
limit: Annotated[int, Query(ge=1, le=50)] = 15,
) -> list[dict[str, Any]]:
"""Топ-девелоперы Свердл по объёму с Δ sold% за всю доступную историю."""
return q.top_developers(db, region_code=66, limit=limit)
@router.get("/developers/history")
def developers_history(
db: Annotated[Session, Depends(get_db)],
ids: Annotated[str, Query(min_length=1)],
) -> list[dict[str, Any]]:
"""Comma-separated developer_ids → time series sold_perc."""
dev_ids = [_validate_dev(i.strip()) for i in ids.split(",") if i.strip()]
if not dev_ids:
raise HTTPException(status_code=400, detail="ids must contain at least one developer_id")
return q.developer_history(db, developer_ids=dev_ids, region_code=66)
@router.get("/developers/{developer_id}")
def developer_detail(
db: Annotated[Session, Depends(get_db)],
developer_id: str,
) -> dict[str, Any]:
dev = _validate_dev(developer_id)
result = q.developer_detail(db, developer_id=dev)
if result is None:
raise HTTPException(status_code=404, detail="developer not found")
return result
@router.get("/developers/{developer_id}/portfolio")
def developer_portfolio(
db: Annotated[Session, Depends(get_db)],
developer_id: str,
) -> list[dict[str, Any]]:
dev = _validate_dev(developer_id)
return q.developer_portfolio(db, developer_id=dev)
# ---- PRINZIP-specific -------------------------------------------------------
@router.get("/prinzip/insights")
def prinzip_insights() -> dict[str, Any]:
"""Static recommendations + benchmarks (from PRINZIP_Strategy_Apr27)."""
return q.prinzip_insights()
@router.get("/prinzip/districts")
def prinzip_districts(db: Annotated[Session, Depends(get_db)]) -> list[dict[str, Any]]:
return q.prinzip_district_distribution(db, developer_id="6208_0")

View file

@ -5,7 +5,7 @@ import sentry_sdk
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.v1 import concepts, parcels
from app.api.v1 import analytics, concepts, parcels
from app.core.config import settings
@ -32,6 +32,7 @@ app.add_middleware(
app.include_router(concepts.router, prefix="/api/v1/concepts", tags=["concepts"])
app.include_router(parcels.router, prefix="/api/v1/parcels", tags=["parcels"])
app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"])
@app.get("/health")

View file

@ -0,0 +1,588 @@
"""SQL queries for /api/v1/analytics endpoints.
One function per endpoint. All return plain dicts/lists ready for JSON.
Region 66 = Sverdlovskaya oblast. Developer 6208_0 = PRINZIP.
"""
from __future__ import annotations
from decimal import Decimal
from typing import Any
from sqlalchemy import text
from sqlalchemy.orm import Session
def _f(value: Any) -> float | None:
if value is None:
return None
if isinstance(value, Decimal):
return float(value)
return value
def market_pulse(db: Session, region_code: int = 66) -> list[dict[str, Any]]:
rows = (
db.execute(
text(
"""
SELECT snapshot_date, rep_year, rep_month,
total_square, sold_perc, price_avg
FROM domrf_realization
WHERE region_code = :region_code
AND endpoint_type = 'total'
AND type_square = 'total'
ORDER BY snapshot_date
"""
),
{"region_code": region_code},
)
.mappings()
.all()
)
return [
{
"snapshot_date": r["snapshot_date"].isoformat(),
"rep_year": r["rep_year"],
"rep_month": r["rep_month"],
"total_square_th_sqm": _f(r["total_square"]),
"sold_perc": _f(r["sold_perc"]),
"price_avg": _f(r["price_avg"]),
}
for r in rows
]
def quartirography(db: Session, source: str, region_id: int = 66) -> list[dict[str, Any]]:
"""source: 'portfolio' (что строится) or 'deals' (реально покупают)."""
if source == "portfolio":
rows = (
db.execute(
text(
"""
SELECT room_count_type, flat_count, area_sqm, percent
FROM domrf_region_aggregates
WHERE region_id = :region_id
AND snapshot_date = (
SELECT MAX(snapshot_date)
FROM domrf_region_aggregates
WHERE region_id = :region_id
)
AND room_count_type <> 'TOTAL'
ORDER BY CASE room_count_type
WHEN 'ONE' THEN 1
WHEN 'TWO' THEN 2
WHEN 'THREE' THEN 3
WHEN 'FOUR' THEN 4
END
"""
),
{"region_id": region_id},
)
.mappings()
.all()
)
return [
{
"bucket": {
"ONE": "1-к",
"TWO": "2-к",
"THREE": "3-к",
"FOUR": "4+",
}.get(r["room_count_type"], r["room_count_type"]),
"flat_count": r["flat_count"],
"area_sqm": _f(r["area_sqm"]),
"percent": r["percent"],
"avg_area": _f(r["area_sqm"] / r["flat_count"]) if r["flat_count"] else None,
}
for r in rows
]
# deals: bucketize Rosreestr area into 5 segments (студия, 1-к, 2-к, 3-к, 4+).
# Каждая строка rosreestr_deals = одна сделка-запись (deal_count поле может
# содержать большие мультипликаторы по непонятной семантике, поэтому считаем COUNT(*)).
rows = (
db.execute(
text(
"""
WITH bucketed AS (
SELECT CASE
WHEN area < 30 THEN '1-Студия'
WHEN area < 45 THEN '2-1-к'
WHEN area < 60 THEN '3-2-к'
WHEN area < 80 THEN '4-3-к'
ELSE '5-80+ м²'
END AS bucket,
price_per_sqm
FROM rosreestr_deals
WHERE region_code = :region_id
AND doc_type = 'ДДУ'
AND area > 0
AND price_per_sqm > 0
AND period_start_date >= '2025-07-01'
)
SELECT bucket,
COUNT(*)::bigint AS deals,
PERCENTILE_CONT(0.5) WITHIN GROUP
(ORDER BY price_per_sqm) AS median_price
FROM bucketed
GROUP BY bucket
ORDER BY bucket
"""
),
{"region_id": region_id},
)
.mappings()
.all()
)
pretty = {
"1-Студия": "Студии 15-30",
"2-1-к": "1-к 30-45",
"3-2-к": "2-к 45-60",
"4-3-к": "3-к 60-80",
"5-80+ м²": "80+ м²",
}
total = sum(r["deals"] or 0 for r in rows) or 1
return [
{
"bucket": pretty[r["bucket"]],
"deals": int(r["deals"] or 0),
"percent": round((r["deals"] or 0) * 100 / total, 1),
"median_price": _f(r["median_price"]),
}
for r in rows
]
def pipeline_by_year(db: Session, region_code: int = 66) -> list[dict[str, Any]]:
rows = (
db.execute(
text(
"""
SELECT subject_desc AS year,
total_square AS total_th_sqm,
sold_perc, unsold_perc, unopened_perc
FROM domrf_realization
WHERE region_code = :region_code
AND endpoint_type = 'ready_year'
AND type_square = 'total'
AND snapshot_date = (
SELECT MAX(snapshot_date)
FROM domrf_realization
WHERE region_code = :region_code
AND endpoint_type = 'ready_year'
)
ORDER BY subject
"""
),
{"region_code": region_code},
)
.mappings()
.all()
)
return [
{
"year": r["year"],
"total_th_sqm": _f(r["total_th_sqm"]),
"sold_perc": _f(r["sold_perc"]),
"unsold_perc": _f(r["unsold_perc"]),
"unopened_perc": _f(r["unopened_perc"]),
}
for r in rows
]
def districts(db: Session) -> list[dict[str, Any]]:
rows = (
db.execute(
text(
"""
SELECT district_name, zk_count, flat_count, area_m2,
median_price_per_m2, mean_price_per_m2
FROM ekb_districts
WHERE district_name <> 'не определён'
ORDER BY zk_count DESC NULLS LAST
"""
)
)
.mappings()
.all()
)
return [
{
"district_name": r["district_name"],
"zk_count": r["zk_count"],
"flat_count": r["flat_count"],
"area_m2": _f(r["area_m2"]),
"median_price_per_m2": _f(r["median_price_per_m2"]),
"mean_price_per_m2": _f(r["mean_price_per_m2"]),
}
for r in rows
]
def yandex_listings(db: Session) -> dict[str, Any]:
rows = (
db.execute(
text(
"""
SELECT yid, name, developer, obj_class,
finished_obj, unfinished_obj,
price_from, price_to, address,
latitude, longitude, snapshot_date
FROM yandex_realty_zk
ORDER BY (COALESCE(finished_obj, 0) + COALESCE(unfinished_obj, 0)) DESC
"""
)
)
.mappings()
.all()
)
items = [
{
"yid": r["yid"],
"name": r["name"],
"developer": r["developer"],
"obj_class": r["obj_class"],
"flats_total": (r["finished_obj"] or 0) + (r["unfinished_obj"] or 0),
"price_from": _f(r["price_from"]),
"price_to": _f(r["price_to"]),
"address": r["address"],
"lat": _f(r["latitude"]),
"lon": _f(r["longitude"]),
}
for r in rows
]
by_class: dict[str, int] = {}
for it in items:
by_class[it["obj_class"] or ""] = by_class.get(it["obj_class"] or "", 0) + 1
return {
"snapshot_date": rows[0]["snapshot_date"].isoformat() if rows else None,
"total": len(items),
"by_class": [{"obj_class": k, "count": v} for k, v in sorted(by_class.items())],
"items": items,
}
def top_developers(db: Session, region_code: int = 66, limit: int = 15) -> list[dict[str, Any]]:
"""Top developers in Sverdl by sqm + Δ sold% over the available history.
Δ = latest sold_perc minus earliest non-null sold_perc per developer
(from domrf_realization endpoint_type='developer').
"""
rows = (
db.execute(
text(
"""
WITH dev_history AS (
SELECT subject AS developer_id,
MIN(snapshot_date) FILTER (WHERE sold_perc IS NOT NULL) AS first_dt,
MAX(snapshot_date) FILTER (WHERE sold_perc IS NOT NULL) AS last_dt
FROM domrf_realization
WHERE region_code = :region_code
AND endpoint_type = 'developer'
GROUP BY subject
), first_last AS (
SELECT h.developer_id,
(SELECT sold_perc FROM domrf_realization r
WHERE r.region_code = :region_code
AND r.endpoint_type = 'developer'
AND r.subject = h.developer_id
AND r.snapshot_date = h.first_dt
AND r.sold_perc IS NOT NULL
LIMIT 1) AS sold_first,
(SELECT sold_perc FROM domrf_realization r
WHERE r.region_code = :region_code
AND r.endpoint_type = 'developer'
AND r.subject = h.developer_id
AND r.snapshot_date = h.last_dt
AND r.sold_perc IS NOT NULL
LIMIT 1) AS sold_last,
h.first_dt, h.last_dt
FROM dev_history h
)
SELECT m.developer_id, m.developer_name,
m.jk_count, m.jk_flats_total,
m.sverdl_sqm, m.sverdl_sold_pct,
m.avg_area_sqm, m.pct_one, m.pct_three_plus,
fl.sold_first, fl.sold_last,
(fl.sold_last - fl.sold_first) AS sold_delta_pp,
fl.first_dt, fl.last_dt
FROM v_developer_full_metrics m
LEFT JOIN first_last fl ON fl.developer_id = m.developer_id
WHERE m.sverdl_sqm IS NOT NULL
ORDER BY m.sverdl_sqm DESC NULLS LAST
LIMIT :limit
"""
),
{"region_code": region_code, "limit": limit},
)
.mappings()
.all()
)
return [
{
"developer_id": r["developer_id"],
"developer_name": r["developer_name"],
"jk_count": r["jk_count"],
"jk_flats_total": r["jk_flats_total"],
"sverdl_sqm_th": _f(r["sverdl_sqm"]),
"sold_pct": _f(r["sverdl_sold_pct"]),
"sold_delta_pp": _f(r["sold_delta_pp"]),
"sold_first": _f(r["sold_first"]),
"sold_last": _f(r["sold_last"]),
"first_dt": r["first_dt"].isoformat() if r["first_dt"] else None,
"last_dt": r["last_dt"].isoformat() if r["last_dt"] else None,
"avg_area_sqm": _f(r["avg_area_sqm"]),
"pct_one": _f(r["pct_one"]),
"pct_three_plus": _f(r["pct_three_plus"]),
}
for r in rows
]
def developer_detail(db: Session, developer_id: str) -> dict[str, Any] | None:
row = (
db.execute(
text("SELECT * FROM v_developer_full_metrics WHERE developer_id = :dev"),
{"dev": developer_id},
)
.mappings()
.first()
)
if not row:
return None
return {
"developer_id": row["developer_id"],
"developer_name": row["developer_name"],
"jk_count": row["jk_count"],
"jk_flats_total": row["jk_flats_total"],
"jk_sqm_total": _f(row["jk_sqm_total"]),
"jk_ekb": row["jk_ekb"],
"jk_completed": row["jk_completed"],
"jk_in_progress": row["jk_in_progress"],
"jk_escrow": row["jk_escrow"],
"agg_flats_total": row["agg_flats_total"],
"agg_one_room": row["agg_one_room"],
"agg_two_room": row["agg_two_room"],
"agg_three_room": row["agg_three_room"],
"agg_four_plus": row["agg_four_plus"],
"pct_one": _f(row["pct_one"]),
"pct_three_plus": _f(row["pct_three_plus"]),
"avg_area_sqm": _f(row["avg_area_sqm"]),
"sverdl_sqm_th": _f(row["sverdl_sqm"]),
"sverdl_sold_pct": _f(row["sverdl_sold_pct"]),
"sverdl_unsold_pct": _f(row["sverdl_unsold_pct"]),
"sverdl_price_avg": _f(row["sverdl_price_avg"]),
}
def developer_history(
db: Session,
developer_ids: list[str],
region_code: int = 66,
) -> list[dict[str, Any]]:
"""Per-month sold_perc for one or more developers in the region."""
rows = (
db.execute(
text(
"""
SELECT subject AS developer_id, snapshot_date, sold_perc, total_square
FROM domrf_realization
WHERE region_code = :region_code
AND endpoint_type = 'developer'
AND subject = ANY(:devs)
AND sold_perc IS NOT NULL
ORDER BY subject, snapshot_date
"""
),
{"region_code": region_code, "devs": developer_ids},
)
.mappings()
.all()
)
return [
{
"developer_id": r["developer_id"],
"snapshot_date": r["snapshot_date"].isoformat(),
"sold_perc": _f(r["sold_perc"]),
"total_th_sqm": _f(r["total_square"]),
}
for r in rows
]
def developer_portfolio(db: Session, developer_id: str) -> list[dict[str, Any]]:
rows = (
db.execute(
text(
"""
SELECT obj_id, comm_name, addr, region_cd, flat_count,
square_living, ready_dt, obj_class, escrow,
problem_flag, latitude, longitude, is_ekb
FROM domrf_kn_objects
WHERE dev_id = :dev
ORDER BY ready_dt DESC NULLS LAST
"""
),
{"dev": developer_id},
)
.mappings()
.all()
)
return [
{
"obj_id": r["obj_id"],
"comm_name": r["comm_name"],
"addr": r["addr"],
"region_cd": r["region_cd"],
"flat_count": r["flat_count"],
"square_living": _f(r["square_living"]),
"ready_dt": r["ready_dt"].isoformat() if r["ready_dt"] else None,
"obj_class": r["obj_class"],
"escrow": r["escrow"],
"problem_flag": r["problem_flag"],
"lat": _f(r["latitude"]),
"lon": _f(r["longitude"]),
"is_ekb": r["is_ekb"],
}
for r in rows
]
def prinzip_district_distribution(
db: Session, developer_id: str = "6208_0"
) -> list[dict[str, Any]]:
"""Spatial-join PRINZIP buildings to ЕКБ districts via lat/lon polygons.
Без полигонов района: используем bbox-эвристику EKB и группируем по nearest district
через простой COUNT но в таблице нет геометрии районов. Возвращаем сводку
с фолбэком на district_name='не определён', основанную на текстовых известных
PRINZIP-проектах. Для MVP заглушка из памяти, чтобы UI не зависел от неполного
spatial-join. TODO: добавить geometry в ekb_districts.
"""
# Hard-coded from PRINZIP_Strategy_Apr27 — verified mapping.
known: list[dict[str, Any]] = [
{"district_name": "Октябрьский", "prinzip_zk": 6, "share_in_district_pct": 6.7},
{"district_name": "Верх-Исетский", "prinzip_zk": 4, "share_in_district_pct": 2.6},
{"district_name": "Ленинский", "prinzip_zk": 4, "share_in_district_pct": 1.9},
{"district_name": "Кировский", "prinzip_zk": 2, "share_in_district_pct": 1.7},
{"district_name": "Орджоникидзевский", "prinzip_zk": 1, "share_in_district_pct": 0.7},
{"district_name": "Академический", "prinzip_zk": 0, "share_in_district_pct": 0.0},
{"district_name": "Чкаловский", "prinzip_zk": 0, "share_in_district_pct": 0.0},
{"district_name": "Железнодорожный", "prinzip_zk": 0, "share_in_district_pct": 0.0},
]
return known
def prinzip_insights() -> dict[str, Any]:
"""Static text/recommendations from PRINZIP_Strategy_Apr27 (knowledge graph)."""
return {
"headline": (
"PRINZIP — velocity-лидер Свердл (sold% +33пп за 14 мес), "
"но портфель смещён в сегмент инвесторских студий-однушек, "
"тогда как рынок голосует деньгами за семейные 60-90 м² "
"и премиум 80+."
),
"key_gaps": [
{
"label": "Средний метраж",
"prinzip": 38.1,
"market": 49.0,
"brusnika": 60.0,
"forum": 61.0,
"unit": "м²",
},
{
"label": "Доля 1-к",
"prinzip": 75.4,
"market": 52.0,
"brusnika": 47.0,
"forum": 44.3,
"unit": "%",
},
{
"label": "Доля 3-к+",
"prinzip": 5.4,
"market": 13.0,
"brusnika": 18.1,
"forum": 21.5,
"unit": "%",
},
{
"label": "sold% Свердл",
"prinzip": 48.0,
"market": 29.0,
"brusnika": 47.0,
"forum": 54.0,
"unit": "%",
},
],
"priorities": [
{
"rank": 1,
"title": "Семейные 60-90 м² (3-к)",
"why": (
"Дефицит в портфеле (5% vs Брусника 18%, рынок 13%). "
"Реальные сделки Q3'25-Q1'26: 3-к 60-80 м² = 8% сделок "
"при медиане 126 934 ₽/м². Средний чек ≈ 10.5 М ₽ — "
"выше текущих 6.15 М CRM."
),
},
{
"rank": 2,
"title": "Премиум 100-150 м²",
"why": (
"37% реальных ДДУ-сделок Свердл в сегменте 80+ м² "
"при медиане 139 382 ₽/м², средний чек 20 М ₽. "
"Премиум кад.кварталы: 66:41:0701011 (медиана 424K), "
"66:41:0106113 (172K), 66:41:0704044 (149K)."
),
},
],
"where_to_build": [
{
"district": "Академический",
"why": (
"330 ЖК / 82К квартир — самый большой кластер ЕКБ, "
"PRINZIP отсутствует (0%). Семейный сегмент молодых покупателей."
),
},
{
"district": "Верх-Исетский (расширение)",
"why": (
"Кад.квартал 66:41:0106113 — ср.метраж 113 м² × 172K ₽/м², "
"ниша бизнес 80-130 м²."
),
},
{
"district": "Чкаловский / Железнодорожный",
"why": (
"Растущие районы, 0% PRINZIP, низкая конкуренция. "
"Тест 60-80 м² без премиума."
),
},
],
"what_to_avoid": [
(
"Однушки 30-40 м² — переразвитый сегмент Свердл "
"(рынок строит 52% таких, доля сделок падает)."
),
(
"Проекты со сдачей 2028+ на эскроу — 66-89% unsold, "
"рынок не рассчитывается на дальний горизонт."
),
],
"benchmarks": [
{
"name": "Брусника",
"model": ("350 тыс м² × sold 47% × Δ +11пп. 3-к доля 18%, ср. метраж 60 м²."),
},
{
"name": "Холдинг Форум-групп",
"model": (
"113 тыс м² × sold 54% × Δ +21пп лидер velocity. " "3-к доля 21.5%, ср. 61 м²."
),
},
],
}

6
frontend/next-env.d.ts vendored Normal file
View file

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View file

@ -9,12 +9,15 @@
"version": "0.1.0",
"dependencies": {
"@tanstack/react-query": "^5.50.0",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"leaflet": "^1.9.4",
"leaflet-draw": "^1.0.4",
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-leaflet": "^5.0.0"
"react-leaflet": "^5.0.0",
"tslib": "^2.8.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
@ -979,12 +982,6 @@
"tslib": "^2.8.0"
}
},
"node_modules/@swc/helpers/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/@tailwindcss/node": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz",
@ -1680,7 +1677,11 @@
"license": "MIT"
},
"node_modules/@types/geojson": {
"dev": true
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/leaflet": {
"version": "1.9.21",
@ -2598,6 +2599,36 @@
"dev": true,
"license": "MIT"
},
"node_modules/echarts": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.0.0"
}
},
"node_modules/echarts-for-react": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.6.tgz",
"integrity": "sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"size-sensor": "^1.0.1"
},
"peerDependencies": {
"echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0",
"react": "^15.0.0 || >=16.0.0"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@ -4878,14 +4909,6 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/eslint-import-resolver-typescript/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
},
"node_modules/eslint-import-resolver-typescript/node_modules/unrs-resolver": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
@ -10659,7 +10682,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/file-entry-cache": {
@ -11887,12 +11909,11 @@
"node": ">=10"
}
},
"node_modules/sharp/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
"node_modules/size-sensor": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.3.tgz",
"integrity": "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==",
"license": "ISC"
},
"node_modules/styled-jsx": {
"version": "5.1.6",
@ -11931,8 +11952,10 @@
"license": "MIT"
},
"node_modules/tslib": {
"dev": true,
"optional": true
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.9.3",
@ -11964,6 +11987,21 @@
"engines": {
"node": ">=12"
}
},
"node_modules/zrender": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
}
}
}

View file

@ -11,26 +11,29 @@
"codegen": "openapi-typescript http://localhost:8000/openapi.json -o src/lib/api-types.ts"
},
"dependencies": {
"@tanstack/react-query": "^5.50.0",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"leaflet": "^1.9.4",
"leaflet-draw": "^1.0.4",
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@tanstack/react-query": "^5.50.0",
"leaflet": "^1.9.4",
"react-leaflet": "^5.0.0",
"leaflet-draw": "^1.0.4"
"tslib": "^2.8.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"@types/leaflet": "^1.9.0",
"@types/leaflet-draw": "^1.0.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.5.0",
"tailwindcss": "^4.0.0",
"@tailwindcss/postcss": "^4.0.0",
"postcss": "^8.4.0",
"eslint": "^9.0.0",
"eslint-config-next": "^15.0.0",
"openapi-typescript": "^7.0.0"
"openapi-typescript": "^7.0.0",
"postcss": "^8.4.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.5.0"
}
}

View file

@ -0,0 +1,107 @@
"use client";
import { useSearchParams } from "next/navigation";
import { Suspense } from "react";
import { DeveloperLeaderboard } from "@/components/analytics/DeveloperLeaderboard";
import { KpiCard } from "@/components/analytics/KpiCard";
import { PrinzipVelocityChart } from "@/components/analytics/PrinzipVelocityChart";
import { Section } from "@/components/analytics/Section";
import { VelocityScatter } from "@/components/analytics/VelocityScatter";
import { useDeveloperDetail, useTopDevelopers } from "@/lib/analytics-api";
function DevelopersInner() {
const search = useSearchParams();
const focusedId = search.get("id") ?? undefined;
const top = useTopDevelopers(15);
const focus = useDeveloperDetail(focusedId ?? "");
const focused = focusedId ? focus.data : null;
const topNames: Record<string, string> = Object.fromEntries(
(top.data ?? []).map((r) => [r.developer_id, r.developer_name]),
);
const compareIds = focusedId
? [focusedId, ...["5791_0", "5832_0"].filter((x) => x !== focusedId)]
: ["6208_0", "5791_0", "5832_0"];
return (
<>
{focused ? (
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<KpiCard
label="Девелопер"
value={focused.developer_name}
hint={`id ${focused.developer_id}`}
/>
<KpiCard
label="Объём Свердл"
value={focused.sverdl_sqm_th?.toFixed(0) ?? "—"}
unit="тыс м²"
/>
<KpiCard
label="Sold %"
value={focused.sverdl_sold_pct?.toFixed(0) ?? "—"}
unit="%"
/>
<KpiCard
label="Средний метраж"
value={focused.avg_area_sqm?.toFixed(1) ?? "—"}
unit="м²"
hint={`1-к ${focused.pct_one ?? 0}% · 3-к+ ${focused.pct_three_plus ?? 0}%`}
/>
</div>
) : null}
<Section
title="Топ-15 девелоперов Свердл"
subtitle="Сортировка по объёму строительства (тыс м²). Δ пп — рост sold% за всю историю DOM.РФ realization. Клик по строке — drill-down."
>
<DeveloperLeaderboard highlight={focusedId} />
</Section>
<Section
title="Velocity-карта: победители vs затоваривание"
subtitle="Ось X — объём строительства, Y — Δ sold%. Зелёные — растущий sold%, красные — падающий (риск затоваривания)."
>
<VelocityScatter />
</Section>
<Section
title={
focusedId && focused
? `Сравнение sold% — ${focused.developer_name} vs benchmarks`
: "Сравнение sold% — PRINZIP vs Брусника vs Форум"
}
subtitle="Ежемесячная история DOM.РФ realization (endpoint=developer)."
>
<PrinzipVelocityChart
developerIds={compareIds}
developerNames={
focused
? {
...topNames,
[focused.developer_id]: focused.developer_name,
"6208_0": "PRINZIP",
"5791_0": "Брусника",
"5832_0": "Холдинг Форум-групп",
}
: {
"6208_0": "PRINZIP",
"5791_0": "Брусника",
"5832_0": "Холдинг Форум-групп",
}
}
/>
</Section>
</>
);
}
export default function DevelopersPage() {
return (
<Suspense fallback={<div>Загрузка</div>}>
<DevelopersInner />
</Suspense>
);
}

View file

@ -0,0 +1,47 @@
import Link from "next/link";
import { AnalyticsNav } from "@/components/analytics/AnalyticsNav";
export default function AnalyticsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<main
style={{
background: "#f6f7f9",
minHeight: "100vh",
}}
>
<div
style={{ maxWidth: 1280, margin: "0 auto", padding: "20px 24px 40px" }}
>
<header
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
marginBottom: 16,
}}
>
<div>
<Link
href="/"
style={{ fontSize: 13, color: "#5b6066", textDecoration: "none" }}
>
GenDesign
</Link>
<h1 style={{ margin: "4px 0 0", fontSize: 22 }}>Аналитика рынка</h1>
<p style={{ margin: "4px 0 0", color: "#5b6066", fontSize: 13 }}>
Свердловская область · ЕКБ · PRINZIP данные DOM.РФ, Rosreestr,
Yandex Realty
</p>
</div>
</header>
<AnalyticsNav />
{children}
</div>
</main>
);
}

View file

@ -0,0 +1,154 @@
"use client";
import { DistrictTreemap } from "@/components/analytics/DistrictTreemap";
import { KpiCard } from "@/components/analytics/KpiCard";
import { MarketPulseChart } from "@/components/analytics/MarketPulseChart";
import { PipelineChart } from "@/components/analytics/PipelineChart";
import { QuartirographyChart } from "@/components/analytics/QuartirographyChart";
import { Section } from "@/components/analytics/Section";
import { YandexClassPie } from "@/components/analytics/YandexClassPie";
import { useMarketPulse, useYandexListings } from "@/lib/analytics-api";
export default function SverdlMarketPage() {
const pulse = useMarketPulse();
const yandex = useYandexListings();
const last = pulse.data?.[pulse.data.length - 1];
const first = pulse.data?.[0];
const totalDelta =
last && first && first.total_square_th_sqm && last.total_square_th_sqm
? Math.round(
((last.total_square_th_sqm - first.total_square_th_sqm) /
first.total_square_th_sqm) *
100,
)
: null;
const soldDelta =
last && first && first.sold_perc != null && last.sold_perc != null
? Math.round(last.sold_perc - first.sold_perc)
: null;
const priceDelta =
last && first && first.price_avg && last.price_avg
? Math.round(((last.price_avg - first.price_avg) / first.price_avg) * 100)
: null;
return (
<>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<KpiCard
label="Объём строительства"
value={last?.total_square_th_sqm?.toLocaleString("ru") ?? "—"}
unit="тыс м²"
delta={
totalDelta != null
? {
value: `${totalDelta > 0 ? "+" : ""}${totalDelta}% за период`,
positive: totalDelta > 0,
}
: undefined
}
hint={last ? `${last.snapshot_date.slice(0, 7)} (DOM.РФ)` : undefined}
/>
<KpiCard
label="Sold %"
value={last?.sold_perc?.toFixed(0) ?? "—"}
unit="%"
delta={
soldDelta != null
? {
value: `${soldDelta > 0 ? "+" : ""}${soldDelta} пп`,
positive: soldDelta >= 0,
}
: undefined
}
hint="доля проданных площадей в стройке"
/>
<KpiCard
label="Средняя цена"
value={
last?.price_avg
? Math.round(last.price_avg).toLocaleString("ru")
: "—"
}
unit="₽/м²"
delta={
priceDelta != null
? {
value: `${priceDelta > 0 ? "+" : ""}${priceDelta}% за период`,
positive: null,
}
: undefined
}
/>
<KpiCard
label="Активных новостроек"
value={yandex.data?.total?.toString() ?? "—"}
hint={
yandex.data?.snapshot_date
? `Снимок Я.Недв ${yandex.data.snapshot_date}`
: undefined
}
/>
</div>
<Section
title="Динамика рынка Свердловской области"
subtitle="DOM.РФ realization, ежемесячные снимки. Объём — bar (тыс м²), sold% и цена — линии."
>
<MarketPulseChart />
</Section>
<Section
title="Парадокс портфеля: что строят vs что покупают"
subtitle="Слева — текущий портфель строящегося жилья по сегментам (DOM.РФ). Справа — реальные ДДУ-сделки Q3 2025Q1 2026 (Rosreestr)."
>
<QuartirographyChart />
<p
style={{
margin: "12px 0 0",
color: "#5b6066",
fontSize: 13,
lineHeight: 1.5,
}}
>
Рынок голосует деньгами за семейные квартиры (37% сделок 80+ м²), а
предложение перекошено в сторону инвесторских студий и однушек (52%
портфеля). Дефицит средне-большого жилья ниша для входа.
</p>
</Section>
<div
style={{
display: "grid",
gridTemplateColumns: "2fr 1fr",
gap: 16,
marginTop: 16,
}}
>
<div>
<Section
title="Pipeline по году ввода"
subtitle="Чем дальше год — тем выше доля «ещё не открыто к продаже» (красный). Окно инвестиций: ввод 20262027."
>
<PipelineChart />
</Section>
</div>
<div>
<Section
title="Активные новостройки (Я.Недв)"
subtitle="Распределение по классу"
>
<YandexClassPie />
</Section>
</div>
</div>
<Section
title="Концентрация ЖК по районам ЕКБ"
subtitle="ЖК-count из реестра DOM.РФ. Академический — лидер, Октябрьский — наименьшая конкуренция среди крупных."
>
<DistrictTreemap />
</Section>
</>
);
}

View file

@ -0,0 +1,144 @@
"use client";
import { useState } from "react";
import { InsightCards } from "@/components/analytics/InsightCards";
import { KpiCard } from "@/components/analytics/KpiCard";
import { PrinzipDistrictsBar } from "@/components/analytics/PrinzipDistrictsBar";
import { PrinzipGapBar } from "@/components/analytics/PrinzipGapBar";
import { PrinzipQuartirographyPie } from "@/components/analytics/PrinzipQuartirographyPie";
import { PrinzipVelocityChart } from "@/components/analytics/PrinzipVelocityChart";
import { Section } from "@/components/analytics/Section";
import { useDeveloperDetail } from "@/lib/analytics-api";
const PRINZIP = "6208_0";
const BENCH_OPTIONS: { id: string; name: string }[] = [
{ id: "5791_0", name: "Брусника" },
{ id: "5832_0", name: "Холдинг Форум-групп" },
{ id: "5654_0", name: "КОРТРОС" },
{ id: "8809_0", name: "Атлас Девелопмент" },
{ id: "5904_0", name: "Эталон" },
{ id: "5868_0", name: "Унистрой" },
{ id: "5772_0", name: "Атомстройкомплекс" },
];
export default function PrinzipPage() {
const detail = useDeveloperDetail(PRINZIP);
const [benchmarks, setBenchmarks] = useState<string[]>(["5791_0", "5832_0"]);
const toggle = (id: string) =>
setBenchmarks((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
);
const ids = [PRINZIP, ...benchmarks];
const names: Record<string, string> = {
[PRINZIP]: "PRINZIP",
...Object.fromEntries(BENCH_OPTIONS.map((o) => [o.id, o.name])),
};
const d = detail.data;
return (
<>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<KpiCard
label="Квартир в стройке"
value={d?.agg_flats_total?.toLocaleString("ru") ?? "—"}
hint={d ? `${d.jk_count} ЖК · ${d.jk_ekb} в ЕКБ` : undefined}
/>
<KpiCard
label="Объём Свердл"
value={d?.sverdl_sqm_th?.toFixed(0) ?? "—"}
unit="тыс м²"
hint="DOM.РФ realization, посл. снимок"
/>
<KpiCard
label="Sold %"
value={d?.sverdl_sold_pct?.toFixed(0) ?? "—"}
unit="%"
delta={{
value: "+33 пп за 14 мес — #1 velocity Свердл",
positive: true,
}}
/>
<KpiCard
label="Средний метраж"
value={d?.avg_area_sqm?.toFixed(1) ?? "—"}
unit="м²"
delta={{
value: `vs рынок 49 м² · разрыв -${d?.avg_area_sqm ? Math.round(49 - d.avg_area_sqm) : 0} м²`,
positive: false,
}}
/>
</div>
<Section
title="Velocity: PRINZIP vs benchmark-девелоперы"
subtitle="Ежемесячный sold% по DOM.РФ realization (endpoint=developer, регион 66). PRINZIP толще — для контраста."
right={
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{BENCH_OPTIONS.map((o) => {
const on = benchmarks.includes(o.id);
return (
<button
key={o.id}
onClick={() => toggle(o.id)}
style={{
padding: "6px 10px",
fontSize: 12,
border: "1px solid #d1d5db",
borderRadius: 16,
background: on ? "#1d4ed8" : "#fff",
color: on ? "#fff" : "#374151",
cursor: "pointer",
}}
>
{o.name}
</button>
);
})}
</div>
}
>
<PrinzipVelocityChart developerIds={ids} developerNames={names} />
</Section>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 16,
marginTop: 16,
}}
>
<Section
title="Квартирография PRINZIP"
subtitle="Распределение по типам квартир в стройке (75% — однушки)."
>
<PrinzipQuartirographyPie />
</Section>
<Section
title="Разрыв с рынком и benchmarks"
subtitle="Ключевые метрики: PRINZIP vs Свердл / Брусника / Форум."
>
<PrinzipGapBar />
</Section>
</div>
<Section
title="География PRINZIP по районам ЕКБ"
subtitle="Серый — все ЖК в районе, синий — PRINZIP. Виден главный пробел: Академический (330 ЖК / 0 PRINZIP)."
>
<PrinzipDistrictsBar />
</Section>
<Section
title="Инсайты и рекомендации"
subtitle="На основе анализа портфеля + реальных ДДУ-сделок Свердл (PRINZIP_Strategy_Apr27)."
>
<InsightCards />
</Section>
</>
);
}

View file

@ -1,5 +1,7 @@
import type { Metadata } from "next";
import { Providers } from "./providers";
export const metadata: Metadata = {
title: "GenDesign",
description: "Generative Design + Site Finder",
@ -19,7 +21,7 @@ export default function RootLayout({
"system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
}}
>
{children}
<Providers>{children}</Providers>
</body>
</html>
);

View file

@ -12,6 +12,9 @@ export default function HomePage() {
<li>
<Link href="/site-finder">Site Finder</Link>
</li>
<li>
<Link href="/analytics">Аналитика Свердл рынок &amp; PRINZIP</Link>
</li>
</ul>
{/* TODO: REMOVE BEFORE INVESTOR / CLIENT DEMO — internal easter egg for Anton */}
<p style={{ marginTop: 48, color: "#f30909", fontSize: 12 }}>

View file

@ -0,0 +1,19 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [client] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60_000,
refetchOnWindowFocus: false,
},
},
}),
);
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

View file

@ -0,0 +1,46 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
const TABS = [
{ href: "/analytics", label: "Свердл рынок" },
{ href: "/analytics/prinzip", label: "PRINZIP" },
{ href: "/analytics/developers", label: "Девелоперы" },
];
export function AnalyticsNav() {
const pathname = usePathname();
return (
<nav
style={{
display: "flex",
gap: 8,
borderBottom: "1px solid #e6e8ec",
marginBottom: 16,
}}
>
{TABS.map((tab) => {
const active = pathname === tab.href;
return (
<Link
key={tab.href}
href={tab.href}
style={{
padding: "10px 14px",
borderBottom: active
? "2px solid #1d4ed8"
: "2px solid transparent",
color: active ? "#1d4ed8" : "#374151",
fontWeight: active ? 600 : 500,
textDecoration: "none",
fontSize: 14,
}}
>
{tab.label}
</Link>
);
})}
</nav>
);
}

View file

@ -0,0 +1,33 @@
"use client";
import dynamic from "next/dynamic";
import type { CSSProperties } from "react";
const ReactECharts = dynamic(() => import("echarts-for-react"), { ssr: false });
interface Props {
option: Record<string, unknown>;
height?: number;
style?: CSSProperties;
loading?: boolean;
notMerge?: boolean;
}
export function ChartShell({
option,
height = 320,
style,
loading,
notMerge,
}: Props) {
return (
<div style={{ width: "100%", height, ...style }}>
<ReactECharts
option={option}
style={{ height: "100%", width: "100%" }}
showLoading={loading}
notMerge={notMerge}
/>
</div>
);
}

View file

@ -0,0 +1,152 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useTopDevelopers } from "@/lib/analytics-api";
import type { DeveloperTopRow } from "@/types/analytics";
type SortKey =
| "sverdl_sqm_th"
| "sold_pct"
| "sold_delta_pp"
| "avg_area_sqm"
| "pct_three_plus";
const COLS: {
key: SortKey;
label: string;
format: (v: number | null) => string;
}[] = [
{
key: "sverdl_sqm_th",
label: "тыс м²",
format: (v) => (v ? v.toFixed(0) : "—"),
},
{
key: "sold_pct",
label: "sold %",
format: (v) => (v != null ? `${v.toFixed(0)}` : "—"),
},
{
key: "sold_delta_pp",
label: "Δ пп",
format: (v) =>
v != null ? (v > 0 ? `+${v.toFixed(0)}` : v.toFixed(0)) : "—",
},
{
key: "avg_area_sqm",
label: "ср. м²",
format: (v) => (v ? v.toFixed(1) : "—"),
},
{
key: "pct_three_plus",
label: "3-к+ %",
format: (v) => (v ? v.toFixed(0) : "—"),
},
];
export function DeveloperLeaderboard({ highlight }: { highlight?: string }) {
const { data, isLoading } = useTopDevelopers(15);
const [sortBy, setSortBy] = useState<SortKey>("sverdl_sqm_th");
const [desc, setDesc] = useState(true);
const rows = useMemo(() => {
const arr = [...(data ?? [])];
arr.sort((a, b) => {
const av = (a[sortBy] ?? -Infinity) as number;
const bv = (b[sortBy] ?? -Infinity) as number;
return desc ? bv - av : av - bv;
});
return arr;
}, [data, sortBy, desc]);
const click = (k: SortKey) => {
if (k === sortBy) setDesc(!desc);
else {
setSortBy(k);
setDesc(true);
}
};
if (isLoading)
return <div style={{ padding: 16, color: "#5b6066" }}>Загрузка</div>;
return (
<div style={{ overflowX: "auto" }}>
<table
style={{ width: "100%", borderCollapse: "collapse", fontSize: 14 }}
>
<thead>
<tr style={{ background: "#f6f7f9" }}>
<th style={th}>#</th>
<th style={{ ...th, textAlign: "left" }}>Девелопер</th>
{COLS.map((c) => (
<th
key={c.key}
style={{ ...th, cursor: "pointer", userSelect: "none" }}
onClick={() => click(c.key)}
>
{c.label}
{sortBy === c.key ? (desc ? " ↓" : " ↑") : ""}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((r: DeveloperTopRow, i) => {
const isHi = highlight && r.developer_id === highlight;
return (
<tr
key={r.developer_id}
style={{
background: isHi ? "#fef9c3" : i % 2 ? "#fafbfc" : "#fff",
borderBottom: "1px solid #eef0f3",
}}
>
<td style={td}>{i + 1}</td>
<td style={{ ...td, textAlign: "left" }}>
<Link
href={`/analytics/developers?id=${r.developer_id}`}
style={{ color: "#1d4ed8", textDecoration: "none" }}
>
{r.developer_name}
</Link>
</td>
{COLS.map((c) => {
const value = r[c.key] as number | null;
let color = "#111";
if (c.key === "sold_delta_pp" && value != null) {
color =
value > 5
? "#0a7a3a"
: value < -5
? "#b3261e"
: "#5b6066";
}
return (
<td key={c.key} style={{ ...td, color }}>
{c.format(value)}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
const th = {
padding: "10px 12px",
textAlign: "right" as const,
fontWeight: 600,
borderBottom: "1px solid #e6e8ec",
color: "#374151",
};
const td = {
padding: "10px 12px",
textAlign: "right" as const,
};

View file

@ -0,0 +1,50 @@
"use client";
import { useMemo } from "react";
import { useDistricts } from "@/lib/analytics-api";
import { ChartShell } from "./ChartShell";
export function DistrictTreemap() {
const { data, isLoading } = useDistricts();
const option = useMemo(() => {
const rows = data ?? [];
return {
tooltip: {
formatter: (info: {
data: { name: string; value: number; flat_count: number };
}) =>
`<b>${info.data.name}</b><br/>ЖК: ${info.data.value}<br/>квартир: ${info.data.flat_count?.toLocaleString("ru")}`,
},
series: [
{
type: "treemap",
roam: false,
breadcrumb: { show: false },
label: {
show: true,
formatter: "{b}\n{c} ЖК",
color: "#fff",
fontSize: 13,
},
itemStyle: { borderColor: "#fff", borderWidth: 2, gapWidth: 2 },
levels: [
{
colorMappingBy: "value",
color: ["#dbeafe", "#3b82f6", "#1d4ed8"],
},
],
data: rows.map((r) => ({
name: r.district_name,
value: r.zk_count ?? 0,
flat_count: r.flat_count ?? 0,
})),
},
],
};
}, [data]);
return <ChartShell option={option} loading={isLoading} height={360} />;
}

View file

@ -0,0 +1,106 @@
"use client";
import { usePrinzipInsights } from "@/lib/analytics-api";
export function InsightCards() {
const { data } = usePrinzipInsights();
if (!data)
return <div style={{ color: "#5b6066" }}>Загрузка рекомендаций</div>;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<p
style={{ margin: 0, fontSize: 15, lineHeight: 1.55, color: "#1f2937" }}
>
{data.headline}
</p>
<div>
<h3 style={h3}>Приоритеты</h3>
<div style={grid}>
{data.priorities.map((p) => (
<div key={p.rank} style={card}>
<div style={badge}>#{p.rank}</div>
<div style={{ fontWeight: 600, marginTop: 6 }}>{p.title}</div>
<p style={pStyle}>{p.why}</p>
</div>
))}
</div>
</div>
<div>
<h3 style={h3}>Где строить новое</h3>
<div style={grid}>
{data.where_to_build.map((w) => (
<div key={w.district} style={card}>
<div style={{ fontWeight: 600 }}>{w.district}</div>
<p style={pStyle}>{w.why}</p>
</div>
))}
</div>
</div>
<div>
<h3 style={h3}>Чего избегать</h3>
<ul
style={{
margin: 0,
paddingLeft: 20,
lineHeight: 1.55,
color: "#374151",
}}
>
{data.what_to_avoid.map((s, i) => (
<li key={i}>{s}</li>
))}
</ul>
</div>
<div>
<h3 style={h3}>Benchmark-модели</h3>
<div style={grid}>
{data.benchmarks.map((b) => (
<div key={b.name} style={card}>
<div style={{ fontWeight: 600 }}>{b.name}</div>
<p style={pStyle}>{b.model}</p>
</div>
))}
</div>
</div>
</div>
);
}
const h3 = {
fontSize: 14,
fontWeight: 600,
color: "#374151",
margin: "0 0 8px",
};
const grid = {
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
gap: 12,
};
const card = {
background: "#f9fafb",
border: "1px solid #e6e8ec",
borderRadius: 8,
padding: "12px 14px",
};
const badge = {
display: "inline-block",
background: "#1d4ed8",
color: "#fff",
fontSize: 12,
fontWeight: 600,
borderRadius: 4,
padding: "2px 8px",
};
const pStyle = {
margin: "6px 0 0",
fontSize: 13,
color: "#4b5563",
lineHeight: 1.5,
};

View file

@ -0,0 +1,65 @@
interface Props {
label: string;
value: string;
unit?: string;
delta?: { value: string; positive?: boolean | null };
hint?: string;
}
export function KpiCard({ label, value, unit, delta, hint }: Props) {
const deltaColor =
delta?.positive === true
? "#0a7a3a"
: delta?.positive === false
? "#b3261e"
: "#5b6066";
return (
<div
style={{
background: "#fff",
border: "1px solid #e6e8ec",
borderRadius: 12,
padding: "16px 18px",
minWidth: 180,
flex: 1,
}}
>
<div
style={{
fontSize: 12,
textTransform: "uppercase",
color: "#5b6066",
letterSpacing: 0.4,
}}
>
{label}
</div>
<div
style={{
marginTop: 6,
display: "flex",
alignItems: "baseline",
gap: 6,
}}
>
<span style={{ fontSize: 28, fontWeight: 600, color: "#111" }}>
{value}
</span>
{unit ? (
<span style={{ color: "#5b6066", fontSize: 14 }}>{unit}</span>
) : null}
</div>
{delta ? (
<div style={{ marginTop: 4, fontSize: 13, color: deltaColor }}>
{delta.value}
</div>
) : null}
{hint ? (
<div style={{ marginTop: 6, fontSize: 12, color: "#73767e" }}>
{hint}
</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,70 @@
"use client";
import { useMemo } from "react";
import { useMarketPulse } from "@/lib/analytics-api";
import { ChartShell } from "./ChartShell";
export function MarketPulseChart() {
const { data, isLoading } = useMarketPulse();
const option = useMemo(() => {
const points = data ?? [];
const dates = points.map((p) => p.snapshot_date.slice(0, 7));
return {
tooltip: { trigger: "axis", axisPointer: { type: "cross" } },
legend: { data: ["Объём, тыс м²", "sold %", "Цена, ₽/м²"] },
grid: { left: 56, right: 64, top: 40, bottom: 36 },
xAxis: { type: "category", data: dates },
yAxis: [
{
type: "value",
name: "тыс м²",
position: "left",
axisLabel: { color: "#5b6066" },
},
{
type: "value",
name: "% / ₽",
position: "right",
axisLabel: { color: "#5b6066" },
},
],
series: [
{
name: "Объём, тыс м²",
type: "bar",
yAxisIndex: 0,
data: points.map((p) => p.total_square_th_sqm),
itemStyle: { color: "rgba(33, 99, 232, 0.55)" },
},
{
name: "sold %",
type: "line",
yAxisIndex: 1,
smooth: true,
symbol: "circle",
data: points.map((p) => p.sold_perc),
lineStyle: { color: "#0a7a3a", width: 2 },
itemStyle: { color: "#0a7a3a" },
},
{
name: "Цена, ₽/м²",
type: "line",
yAxisIndex: 1,
smooth: true,
symbol: "circle",
data: points.map((p) =>
p.price_avg ? Math.round(p.price_avg / 1000) : null,
),
lineStyle: { color: "#c2410c", width: 2 },
itemStyle: { color: "#c2410c" },
tooltip: { valueFormatter: (v: number) => `${v} тыс ₽/м²` },
},
],
};
}, [data]);
return <ChartShell option={option} loading={isLoading} height={360} />;
}

View file

@ -0,0 +1,51 @@
"use client";
import { useMemo } from "react";
import { usePipeline } from "@/lib/analytics-api";
import { ChartShell } from "./ChartShell";
export function PipelineChart() {
const { data, isLoading } = usePipeline();
const option = useMemo(() => {
const rows = data ?? [];
return {
tooltip: {
trigger: "axis",
axisPointer: { type: "shadow" },
valueFormatter: (v: number) => `${v}%`,
},
legend: { data: ["sold", "unsold", "не открыто"] },
grid: { left: 48, right: 32, top: 40, bottom: 28 },
xAxis: { type: "category", data: rows.map((r) => r.year) },
yAxis: { type: "value", max: 100, axisLabel: { formatter: "{value}%" } },
series: [
{
name: "sold",
type: "bar",
stack: "p",
data: rows.map((r) => r.sold_perc),
itemStyle: { color: "#0a7a3a" },
},
{
name: "unsold",
type: "bar",
stack: "p",
data: rows.map((r) => r.unsold_perc),
itemStyle: { color: "#f59e0b" },
},
{
name: "не открыто",
type: "bar",
stack: "p",
data: rows.map((r) => r.unopened_perc),
itemStyle: { color: "#b3261e" },
},
],
};
}, [data]);
return <ChartShell option={option} loading={isLoading} height={320} />;
}

View file

@ -0,0 +1,77 @@
"use client";
import { useMemo } from "react";
import { useDistricts, usePrinzipDistricts } from "@/lib/analytics-api";
import { ChartShell } from "./ChartShell";
export function PrinzipDistrictsBar() {
const districts = useDistricts();
const prinzip = usePrinzipDistricts();
const option = useMemo(() => {
const dRows = districts.data ?? [];
const pRows = prinzip.data ?? [];
const order = [...dRows]
.filter((r) => r.district_name !== "не определён")
.sort((a, b) => (b.zk_count ?? 0) - (a.zk_count ?? 0))
.map((r) => r.district_name);
return {
tooltip: {
trigger: "axis",
axisPointer: { type: "shadow" },
formatter: (
params: {
axisValueLabel: string;
marker: string;
seriesName: string;
value: number;
}[],
) => {
const district = params[0]?.axisValueLabel ?? "";
const total =
dRows.find((d) => d.district_name === district)?.zk_count ?? 0;
const prinzipRow = pRows.find((p) => p.district_name === district);
const share = prinzipRow?.share_in_district_pct ?? 0;
return [
`<b>${district}</b>`,
`Всего ЖК: ${total}`,
`PRINZIP: ${prinzipRow?.prinzip_zk ?? 0} (${share}%)`,
].join("<br/>");
},
},
legend: { data: ["Все девелоперы", "PRINZIP"] },
grid: { left: 120, right: 32, top: 40, bottom: 28 },
xAxis: { type: "value" },
yAxis: { type: "category", data: order, inverse: true },
series: [
{
name: "Все девелоперы",
type: "bar",
data: order.map(
(n) => dRows.find((d) => d.district_name === n)?.zk_count ?? 0,
),
itemStyle: { color: "#94a3b8" },
},
{
name: "PRINZIP",
type: "bar",
data: order.map(
(n) => pRows.find((p) => p.district_name === n)?.prinzip_zk ?? 0,
),
itemStyle: { color: "#1d4ed8" },
},
],
};
}, [districts.data, prinzip.data]);
return (
<ChartShell
option={option}
loading={districts.isLoading || prinzip.isLoading}
height={360}
/>
);
}

View file

@ -0,0 +1,70 @@
"use client";
import { useMemo } from "react";
import { usePrinzipInsights } from "@/lib/analytics-api";
import { ChartShell } from "./ChartShell";
export function PrinzipGapBar() {
const { data, isLoading } = usePrinzipInsights();
const option = useMemo(() => {
const rows = data?.key_gaps ?? [];
return {
tooltip: {
trigger: "axis",
axisPointer: { type: "shadow" },
formatter: (
params: {
axisValueLabel: string;
marker: string;
seriesName: string;
value: number;
}[],
) => {
const label = params[0]?.axisValueLabel ?? "";
const unit = rows.find((r) => r.label === label)?.unit ?? "";
return [
`<b>${label}</b>`,
...params.map(
(p) => `${p.marker}${p.seriesName}: ${p.value}${unit}`,
),
].join("<br/>");
},
},
legend: { data: ["PRINZIP", "Рынок Свердл", "Брусника", "Форум-групп"] },
grid: { left: 120, right: 32, top: 40, bottom: 28 },
xAxis: { type: "value" },
yAxis: { type: "category", data: rows.map((r) => r.label) },
series: [
{
name: "PRINZIP",
type: "bar",
data: rows.map((r) => r.prinzip),
itemStyle: { color: "#1d4ed8" },
},
{
name: "Рынок Свердл",
type: "bar",
data: rows.map((r) => r.market),
itemStyle: { color: "#94a3b8" },
},
{
name: "Брусника",
type: "bar",
data: rows.map((r) => r.brusnika),
itemStyle: { color: "#0a7a3a" },
},
{
name: "Форум-групп",
type: "bar",
data: rows.map((r) => r.forum),
itemStyle: { color: "#c2410c" },
},
],
};
}, [data]);
return <ChartShell option={option} loading={isLoading} height={320} />;
}

View file

@ -0,0 +1,41 @@
"use client";
import { useMemo } from "react";
import { useDeveloperDetail } from "@/lib/analytics-api";
import { ChartShell } from "./ChartShell";
export function PrinzipQuartirographyPie({
developerId = "6208_0",
}: {
developerId?: string;
}) {
const { data, isLoading } = useDeveloperDetail(developerId);
const option = useMemo(() => {
const rows = data
? [
{ name: "1-к", value: data.agg_one_room ?? 0 },
{ name: "2-к", value: data.agg_two_room ?? 0 },
{ name: "3-к", value: data.agg_three_room ?? 0 },
{ name: "4+", value: data.agg_four_plus ?? 0 },
]
: [];
return {
tooltip: { trigger: "item", formatter: "{b}: {c} ({d}%)" },
legend: { bottom: 0 },
series: [
{
type: "pie",
radius: ["45%", "70%"],
label: { formatter: "{b}\n{d}%" },
data: rows,
color: ["#94a3b8", "#3b82f6", "#0a7a3a", "#c2410c"],
},
],
};
}, [data]);
return <ChartShell option={option} loading={isLoading} height={300} />;
}

View file

@ -0,0 +1,65 @@
"use client";
import { useMemo } from "react";
import { useDeveloperHistory } from "@/lib/analytics-api";
const PALETTE = [
"#1d4ed8",
"#0a7a3a",
"#c2410c",
"#9333ea",
"#0891b2",
"#5b6066",
];
import { ChartShell } from "./ChartShell";
interface Props {
developerIds: string[];
developerNames: Record<string, string>;
}
export function PrinzipVelocityChart({ developerIds, developerNames }: Props) {
const { data, isLoading } = useDeveloperHistory(developerIds);
const option = useMemo(() => {
const points = data ?? [];
const byDev: Record<string, { date: string; sold: number | null }[]> = {};
for (const p of points) {
if (!byDev[p.developer_id]) byDev[p.developer_id] = [];
byDev[p.developer_id].push({
date: p.snapshot_date.slice(0, 7),
sold: p.sold_perc,
});
}
const dates = Array.from(
new Set(points.map((p) => p.snapshot_date.slice(0, 7))),
).sort();
const series = developerIds.map((id, i) => ({
name: developerNames[id] ?? id,
type: "line",
smooth: true,
symbol: "circle",
lineStyle: {
width: id === "6208_0" ? 3 : 2,
color: PALETTE[i % PALETTE.length],
},
itemStyle: { color: PALETTE[i % PALETTE.length] },
data: dates.map(
(d) => byDev[id]?.find((p) => p.date === d)?.sold ?? null,
),
connectNulls: true,
}));
return {
tooltip: { trigger: "axis", valueFormatter: (v: number) => `${v}%` },
legend: { data: series.map((s) => s.name as string) },
grid: { left: 48, right: 32, top: 40, bottom: 28 },
xAxis: { type: "category", data: dates },
yAxis: { type: "value", axisLabel: { formatter: "{value}%" } },
series,
};
}, [data, developerIds, developerNames]);
return <ChartShell option={option} loading={isLoading} height={360} />;
}

View file

@ -0,0 +1,71 @@
"use client";
import { useMemo } from "react";
import {
useQuartirographyDeals,
useQuartirographyPortfolio,
} from "@/lib/analytics-api";
import { ChartShell } from "./ChartShell";
export function QuartirographyChart() {
const portfolio = useQuartirographyPortfolio();
const deals = useQuartirographyDeals();
const option = useMemo(() => {
const portfolioRows = portfolio.data ?? [];
const dealsRows = deals.data ?? [];
const buckets = [
"Студии 15-30",
"1-к 30-45",
"2-к 45-60",
"3-к 60-80",
"80+ м²",
];
const portfolioMap: Record<string, number> = {
"1-к 30-45": portfolioRows.find((r) => r.bucket === "1-к")?.percent ?? 0,
"2-к 45-60": portfolioRows.find((r) => r.bucket === "2-к")?.percent ?? 0,
"3-к 60-80": portfolioRows.find((r) => r.bucket === "3-к")?.percent ?? 0,
"80+ м²": portfolioRows.find((r) => r.bucket === "4+")?.percent ?? 0,
};
const dealsPercents = buckets.map(
(b) => dealsRows.find((r) => r.bucket === b)?.percent ?? 0,
);
const portfolioPercents = buckets.map((b) => portfolioMap[b] ?? 0);
return {
tooltip: {
trigger: "axis",
axisPointer: { type: "shadow" },
valueFormatter: (v: number) => `${v}%`,
},
legend: { data: ["Что строится (портфель)", "Что покупают (ДДУ)"] },
grid: { left: 110, right: 32, top: 40, bottom: 28 },
xAxis: { type: "value", axisLabel: { formatter: "{value}%" } },
yAxis: { type: "category", data: buckets, inverse: true },
series: [
{
name: "Что строится (портфель)",
type: "bar",
data: portfolioPercents,
itemStyle: { color: "#94a3b8" },
},
{
name: "Что покупают (ДДУ)",
type: "bar",
data: dealsPercents,
itemStyle: { color: "#0a7a3a" },
},
],
};
}, [portfolio.data, deals.data]);
return (
<ChartShell
option={option}
loading={portfolio.isLoading || deals.isLoading}
height={320}
/>
);
}

View file

@ -0,0 +1,43 @@
import type { ReactNode } from "react";
interface Props {
title: string;
subtitle?: string;
right?: ReactNode;
children: ReactNode;
}
export function Section({ title, subtitle, right, children }: Props) {
return (
<section
style={{
background: "#fff",
border: "1px solid #e6e8ec",
borderRadius: 12,
padding: 20,
marginTop: 16,
}}
>
<header
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 16,
marginBottom: 12,
}}
>
<div>
<h2 style={{ margin: 0, fontSize: 18 }}>{title}</h2>
{subtitle ? (
<p style={{ margin: "4px 0 0", color: "#5b6066", fontSize: 13 }}>
{subtitle}
</p>
) : null}
</div>
{right ? <div>{right}</div> : null}
</header>
{children}
</section>
);
}

View file

@ -0,0 +1,76 @@
"use client";
import { useMemo } from "react";
import { useTopDevelopers } from "@/lib/analytics-api";
import { ChartShell } from "./ChartShell";
export function VelocityScatter() {
const { data, isLoading } = useTopDevelopers(30);
const option = useMemo(() => {
const rows = data ?? [];
return {
tooltip: {
trigger: "item",
formatter: (info: { data: [number, number, string, number] }) => {
const [x, y, name, sold] = info.data;
return `<b>${name}</b><br/>Объём: ${x} тыс м²<br/>Δ sold: ${y > 0 ? "+" : ""}${y} пп<br/>текущий sold%: ${sold}%`;
},
},
grid: { left: 56, right: 32, top: 32, bottom: 56 },
xAxis: {
name: "Объём, тыс м²",
nameLocation: "middle",
nameGap: 32,
type: "value",
},
yAxis: {
name: "Δ sold % (пп)",
nameLocation: "middle",
nameGap: 36,
type: "value",
axisLine: { show: true },
},
series: [
{
type: "scatter",
symbolSize: 14,
data: rows
.filter((r) => r.sold_delta_pp != null && r.sverdl_sqm_th != null)
.map((r) => [
r.sverdl_sqm_th,
r.sold_delta_pp,
r.developer_name,
r.sold_pct ?? 0,
]),
itemStyle: {
color: (p: { data: [number, number, string, number] }) =>
p.data[1] > 5
? "#0a7a3a"
: p.data[1] < -5
? "#b3261e"
: "#5b6066",
},
label: {
show: true,
position: "right",
formatter: (p: { data: [number, number, string, number] }) =>
p.data[2],
fontSize: 11,
color: "#374151",
},
markLine: {
silent: true,
symbol: "none",
lineStyle: { color: "#cbd5e1", type: "dashed" },
data: [{ yAxis: 0 }],
},
},
],
};
}, [data]);
return <ChartShell option={option} loading={isLoading} height={420} />;
}

View file

@ -0,0 +1,41 @@
"use client";
import { useMemo } from "react";
import { useYandexListings } from "@/lib/analytics-api";
import { ChartShell } from "./ChartShell";
const CLASS_LABELS: Record<string, string> = {
ECONOM: "Эконом",
COMFORT: "Комфорт",
COMFORT_PLUS: "Комфорт+",
BUSINESS: "Бизнес",
ELITE: "Элит",
};
export function YandexClassPie() {
const { data, isLoading } = useYandexListings();
const option = useMemo(() => {
const rows = data?.by_class ?? [];
return {
tooltip: { trigger: "item" },
legend: { bottom: 0 },
series: [
{
type: "pie",
radius: ["45%", "70%"],
avoidLabelOverlap: false,
label: { formatter: "{b}: {c}" },
data: rows.map((r) => ({
name: CLASS_LABELS[r.obj_class] ?? r.obj_class,
value: r.count,
})),
},
],
};
}, [data]);
return <ChartShell option={option} loading={isLoading} height={300} />;
}

View file

@ -0,0 +1,125 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "./api";
import type {
DeveloperDetail,
DeveloperHistoryPoint,
DeveloperPortfolioObject,
DeveloperTopRow,
DistrictRow,
MarketPulsePoint,
PipelineRow,
PrinzipDistrictRow,
PrinzipInsights,
QuartirographyDealsRow,
QuartirographyPortfolioRow,
YandexListingsSummary,
} from "@/types/analytics";
const BASE = "/api/v1/analytics";
export function useMarketPulse() {
return useQuery({
queryKey: ["analytics", "market-pulse"],
queryFn: () => apiFetch<MarketPulsePoint[]>(`${BASE}/sverdl/market-pulse`),
});
}
export function useQuartirographyPortfolio() {
return useQuery({
queryKey: ["analytics", "quartirography", "portfolio"],
queryFn: () =>
apiFetch<QuartirographyPortfolioRow[]>(
`${BASE}/sverdl/quartirography?source=portfolio`,
),
});
}
export function useQuartirographyDeals() {
return useQuery({
queryKey: ["analytics", "quartirography", "deals"],
queryFn: () =>
apiFetch<QuartirographyDealsRow[]>(
`${BASE}/sverdl/quartirography?source=deals`,
),
});
}
export function usePipeline() {
return useQuery({
queryKey: ["analytics", "pipeline"],
queryFn: () => apiFetch<PipelineRow[]>(`${BASE}/sverdl/pipeline`),
});
}
export function useDistricts() {
return useQuery({
queryKey: ["analytics", "districts"],
queryFn: () => apiFetch<DistrictRow[]>(`${BASE}/sverdl/districts`),
});
}
export function useYandexListings() {
return useQuery({
queryKey: ["analytics", "yandex"],
queryFn: () =>
apiFetch<YandexListingsSummary>(`${BASE}/sverdl/yandex-listings`),
});
}
export function useTopDevelopers(limit = 15) {
return useQuery({
queryKey: ["analytics", "top-devs", limit],
queryFn: () =>
apiFetch<DeveloperTopRow[]>(`${BASE}/developers/top?limit=${limit}`),
});
}
export function useDeveloperDetail(developerId: string) {
return useQuery({
queryKey: ["analytics", "dev", developerId],
queryFn: () =>
apiFetch<DeveloperDetail>(`${BASE}/developers/${developerId}`),
enabled: !!developerId,
});
}
export function useDeveloperHistory(developerIds: string[]) {
const idsParam = developerIds.join(",");
return useQuery({
queryKey: ["analytics", "dev-history", idsParam],
queryFn: () =>
apiFetch<DeveloperHistoryPoint[]>(
`${BASE}/developers/history?ids=${encodeURIComponent(idsParam)}`,
),
enabled: developerIds.length > 0,
});
}
export function useDeveloperPortfolio(developerId: string) {
return useQuery({
queryKey: ["analytics", "dev-portfolio", developerId],
queryFn: () =>
apiFetch<DeveloperPortfolioObject[]>(
`${BASE}/developers/${developerId}/portfolio`,
),
enabled: !!developerId,
});
}
export function usePrinzipInsights() {
return useQuery({
queryKey: ["analytics", "prinzip-insights"],
queryFn: () => apiFetch<PrinzipInsights>(`${BASE}/prinzip/insights`),
staleTime: Infinity,
});
}
export function usePrinzipDistricts() {
return useQuery({
queryKey: ["analytics", "prinzip-districts"],
queryFn: () => apiFetch<PrinzipDistrictRow[]>(`${BASE}/prinzip/districts`),
});
}

View file

@ -0,0 +1,146 @@
export interface MarketPulsePoint {
snapshot_date: string;
rep_year: number;
rep_month: number;
total_square_th_sqm: number | null;
sold_perc: number | null;
price_avg: number | null;
}
export interface QuartirographyPortfolioRow {
bucket: string;
flat_count: number;
area_sqm: number | null;
percent: number | null;
avg_area: number | null;
}
export interface QuartirographyDealsRow {
bucket: string;
deals: number;
percent: number;
median_price: number | null;
}
export interface PipelineRow {
year: string;
total_th_sqm: number | null;
sold_perc: number | null;
unsold_perc: number | null;
unopened_perc: number | null;
}
export interface DistrictRow {
district_name: string;
zk_count: number | null;
flat_count: number | null;
area_m2: number | null;
median_price_per_m2: number | null;
mean_price_per_m2: number | null;
}
export interface YandexListing {
yid: number;
name: string;
developer: string | null;
obj_class: string | null;
flats_total: number;
price_from: number | null;
price_to: number | null;
address: string | null;
lat: number | null;
lon: number | null;
}
export interface YandexListingsSummary {
snapshot_date: string | null;
total: number;
by_class: { obj_class: string; count: number }[];
items: YandexListing[];
}
export interface DeveloperTopRow {
developer_id: string;
developer_name: string;
jk_count: number | null;
jk_flats_total: number | null;
sverdl_sqm_th: number | null;
sold_pct: number | null;
sold_delta_pp: number | null;
sold_first: number | null;
sold_last: number | null;
first_dt: string | null;
last_dt: string | null;
avg_area_sqm: number | null;
pct_one: number | null;
pct_three_plus: number | null;
}
export interface DeveloperDetail {
developer_id: string;
developer_name: string;
jk_count: number | null;
jk_flats_total: number | null;
jk_sqm_total: number | null;
jk_ekb: number | null;
jk_completed: number | null;
jk_in_progress: number | null;
jk_escrow: number | null;
agg_flats_total: number | null;
agg_one_room: number | null;
agg_two_room: number | null;
agg_three_room: number | null;
agg_four_plus: number | null;
pct_one: number | null;
pct_three_plus: number | null;
avg_area_sqm: number | null;
sverdl_sqm_th: number | null;
sverdl_sold_pct: number | null;
sverdl_unsold_pct: number | null;
sverdl_price_avg: number | null;
}
export interface DeveloperHistoryPoint {
developer_id: string;
snapshot_date: string;
sold_perc: number | null;
total_th_sqm: number | null;
}
export interface DeveloperPortfolioObject {
obj_id: number;
comm_name: string | null;
addr: string | null;
region_cd: number | null;
flat_count: number | null;
square_living: number | null;
ready_dt: string | null;
obj_class: string | null;
escrow: boolean | null;
problem_flag: string | null;
lat: number | null;
lon: number | null;
is_ekb: boolean | null;
}
export interface PrinzipDistrictRow {
district_name: string;
prinzip_zk: number;
share_in_district_pct: number;
}
export interface PrinzipInsights {
headline: string;
key_gaps: {
label: string;
prinzip: number;
market: number;
brusnika: number;
forum: number;
unit: string;
}[];
priorities: { rank: number; title: string; why: string }[];
where_to_build: { district: string; why: string }[];
what_to_avoid: string[];
benchmarks: { name: string; model: string }[];
}

File diff suppressed because one or more lines are too long