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:
parent
16481868a6
commit
8d3a0874ef
33 changed files with 2598 additions and 35 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -9,6 +9,11 @@ __pycache__/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
|
# Next.js build artifacts
|
||||||
|
.next/
|
||||||
|
frontend/.next/
|
||||||
|
out/
|
||||||
|
|
||||||
# Env / secrets
|
# Env / secrets
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|
|
||||||
117
backend/app/api/v1/analytics.py
Normal file
117
backend/app/api/v1/analytics.py
Normal 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")
|
||||||
|
|
@ -5,7 +5,7 @@ import sentry_sdk
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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
|
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(concepts.router, prefix="/api/v1/concepts", tags=["concepts"])
|
||||||
app.include_router(parcels.router, prefix="/api/v1/parcels", tags=["parcels"])
|
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")
|
@app.get("/health")
|
||||||
|
|
|
||||||
588
backend/app/services/analytics_queries.py
Normal file
588
backend/app/services/analytics_queries.py
Normal 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
6
frontend/next-env.d.ts
vendored
Normal 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.
|
||||||
88
frontend/package-lock.json
generated
88
frontend/package-lock.json
generated
|
|
@ -9,12 +9,15 @@
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.50.0",
|
"@tanstack/react-query": "^5.50.0",
|
||||||
|
"echarts": "^6.0.0",
|
||||||
|
"echarts-for-react": "^3.0.6",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"leaflet-draw": "^1.0.4",
|
"leaflet-draw": "^1.0.4",
|
||||||
"next": "^15.0.0",
|
"next": "^15.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-leaflet": "^5.0.0"
|
"react-leaflet": "^5.0.0",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.0.0",
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
|
|
@ -979,12 +982,6 @@
|
||||||
"tslib": "^2.8.0"
|
"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": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.2.4",
|
"version": "4.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz",
|
||||||
|
|
@ -1680,7 +1677,11 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/geojson": {
|
"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": {
|
"node_modules/@types/leaflet": {
|
||||||
"version": "1.9.21",
|
"version": "1.9.21",
|
||||||
|
|
@ -2598,6 +2599,36 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/escape-string-regexp": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
"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"
|
"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": {
|
"node_modules/eslint-import-resolver-typescript/node_modules/unrs-resolver": {
|
||||||
"version": "1.11.1",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
|
||||||
|
|
@ -10659,7 +10682,6 @@
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/file-entry-cache": {
|
"node_modules/file-entry-cache": {
|
||||||
|
|
@ -11887,12 +11909,11 @@
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sharp/node_modules/tslib": {
|
"node_modules/size-sensor": {
|
||||||
"version": "2.8.1",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.3.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==",
|
||||||
"license": "0BSD",
|
"license": "ISC"
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/styled-jsx": {
|
"node_modules/styled-jsx": {
|
||||||
"version": "5.1.6",
|
"version": "5.1.6",
|
||||||
|
|
@ -11931,8 +11952,10 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"dev": true,
|
"version": "2.8.1",
|
||||||
"optional": true
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
|
|
@ -11964,6 +11987,21 @@
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,26 +11,29 @@
|
||||||
"codegen": "openapi-typescript http://localhost:8000/openapi.json -o src/lib/api-types.ts"
|
"codegen": "openapi-typescript http://localhost:8000/openapi.json -o src/lib/api-types.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"next": "^15.0.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"@tanstack/react-query": "^5.50.0",
|
|
||||||
"leaflet": "^1.9.4",
|
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"leaflet-draw": "^1.0.4"
|
"tslib": "^2.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
"@types/leaflet": "^1.9.0",
|
"@types/leaflet": "^1.9.0",
|
||||||
"@types/leaflet-draw": "^1.0.0",
|
"@types/leaflet-draw": "^1.0.0",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^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": "^9.0.0",
|
||||||
"eslint-config-next": "^15.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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
107
frontend/src/app/analytics/developers/page.tsx
Normal file
107
frontend/src/app/analytics/developers/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
frontend/src/app/analytics/layout.tsx
Normal file
47
frontend/src/app/analytics/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
frontend/src/app/analytics/page.tsx
Normal file
154
frontend/src/app/analytics/page.tsx
Normal 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 2025–Q1 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="Чем дальше год — тем выше доля «ещё не открыто к продаже» (красный). Окно инвестиций: ввод 2026–2027."
|
||||||
|
>
|
||||||
|
<PipelineChart />
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Section
|
||||||
|
title="Активные новостройки (Я.Недв)"
|
||||||
|
subtitle="Распределение по классу"
|
||||||
|
>
|
||||||
|
<YandexClassPie />
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Section
|
||||||
|
title="Концентрация ЖК по районам ЕКБ"
|
||||||
|
subtitle="ЖК-count из реестра DOM.РФ. Академический — лидер, Октябрьский — наименьшая конкуренция среди крупных."
|
||||||
|
>
|
||||||
|
<DistrictTreemap />
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
frontend/src/app/analytics/prinzip/page.tsx
Normal file
144
frontend/src/app/analytics/prinzip/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
import { Providers } from "./providers";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "GenDesign",
|
title: "GenDesign",
|
||||||
description: "Generative Design + Site Finder",
|
description: "Generative Design + Site Finder",
|
||||||
|
|
@ -19,7 +21,7 @@ export default function RootLayout({
|
||||||
"system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
"system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
<Providers>{children}</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ export default function HomePage() {
|
||||||
<li>
|
<li>
|
||||||
<Link href="/site-finder">Site Finder</Link>
|
<Link href="/site-finder">Site Finder</Link>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/analytics">Аналитика — Свердл рынок & PRINZIP</Link>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
{/* TODO: REMOVE BEFORE INVESTOR / CLIENT DEMO — internal easter egg for Anton */}
|
{/* TODO: REMOVE BEFORE INVESTOR / CLIENT DEMO — internal easter egg for Anton */}
|
||||||
<p style={{ marginTop: 48, color: "#f30909", fontSize: 12 }}>
|
<p style={{ marginTop: 48, color: "#f30909", fontSize: 12 }}>
|
||||||
|
|
|
||||||
19
frontend/src/app/providers.tsx
Normal file
19
frontend/src/app/providers.tsx
Normal 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>;
|
||||||
|
}
|
||||||
46
frontend/src/components/analytics/AnalyticsNav.tsx
Normal file
46
frontend/src/components/analytics/AnalyticsNav.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
frontend/src/components/analytics/ChartShell.tsx
Normal file
33
frontend/src/components/analytics/ChartShell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
frontend/src/components/analytics/DeveloperLeaderboard.tsx
Normal file
152
frontend/src/components/analytics/DeveloperLeaderboard.tsx
Normal 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,
|
||||||
|
};
|
||||||
50
frontend/src/components/analytics/DistrictTreemap.tsx
Normal file
50
frontend/src/components/analytics/DistrictTreemap.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
106
frontend/src/components/analytics/InsightCards.tsx
Normal file
106
frontend/src/components/analytics/InsightCards.tsx
Normal 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,
|
||||||
|
};
|
||||||
65
frontend/src/components/analytics/KpiCard.tsx
Normal file
65
frontend/src/components/analytics/KpiCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
frontend/src/components/analytics/MarketPulseChart.tsx
Normal file
70
frontend/src/components/analytics/MarketPulseChart.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
51
frontend/src/components/analytics/PipelineChart.tsx
Normal file
51
frontend/src/components/analytics/PipelineChart.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
77
frontend/src/components/analytics/PrinzipDistrictsBar.tsx
Normal file
77
frontend/src/components/analytics/PrinzipDistrictsBar.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
frontend/src/components/analytics/PrinzipGapBar.tsx
Normal file
70
frontend/src/components/analytics/PrinzipGapBar.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
|
|
@ -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} />;
|
||||||
|
}
|
||||||
65
frontend/src/components/analytics/PrinzipVelocityChart.tsx
Normal file
65
frontend/src/components/analytics/PrinzipVelocityChart.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
71
frontend/src/components/analytics/QuartirographyChart.tsx
Normal file
71
frontend/src/components/analytics/QuartirographyChart.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
frontend/src/components/analytics/Section.tsx
Normal file
43
frontend/src/components/analytics/Section.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
frontend/src/components/analytics/VelocityScatter.tsx
Normal file
76
frontend/src/components/analytics/VelocityScatter.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
41
frontend/src/components/analytics/YandexClassPie.tsx
Normal file
41
frontend/src/components/analytics/YandexClassPie.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
125
frontend/src/lib/analytics-api.ts
Normal file
125
frontend/src/lib/analytics-api.ts
Normal 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`),
|
||||||
|
});
|
||||||
|
}
|
||||||
146
frontend/src/types/analytics.ts
Normal file
146
frontend/src/types/analytics.ts
Normal 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 }[];
|
||||||
|
}
|
||||||
1
frontend/tsconfig.tsbuildinfo
Normal file
1
frontend/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Reference in a new issue