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/
|
||||
node_modules/
|
||||
|
||||
# Next.js build artifacts
|
||||
.next/
|
||||
frontend/.next/
|
||||
out/
|
||||
|
||||
# Env / secrets
|
||||
.env
|
||||
.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.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.v1 import concepts, parcels
|
||||
from app.api.v1 import analytics, concepts, parcels
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
|
|
@ -32,6 +32,7 @@ app.add_middleware(
|
|||
|
||||
app.include_router(concepts.router, prefix="/api/v1/concepts", tags=["concepts"])
|
||||
app.include_router(parcels.router, prefix="/api/v1/parcels", tags=["parcels"])
|
||||
app.include_router(analytics.router, prefix="/api/v1/analytics", tags=["analytics"])
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
|
|
|||
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",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.50.0",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.6",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet-draw": "^1.0.4",
|
||||
"next": "^15.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-leaflet": "^5.0.0"
|
||||
"react-leaflet": "^5.0.0",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
|
|
@ -979,12 +982,6 @@
|
|||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/helpers/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz",
|
||||
|
|
@ -1680,7 +1677,11 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"dev": true
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.21",
|
||||
|
|
@ -2598,6 +2599,36 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/echarts": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
|
||||
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0",
|
||||
"zrender": "6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/echarts-for-react": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.6.tgz",
|
||||
"integrity": "sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"size-sensor": "^1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0",
|
||||
"react": "^15.0.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/echarts/node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
|
|
@ -4878,14 +4909,6 @@
|
|||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-import-resolver-typescript/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/eslint-import-resolver-typescript/node_modules/unrs-resolver": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
|
||||
|
|
@ -10659,7 +10682,6 @@
|
|||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-entry-cache": {
|
||||
|
|
@ -11887,12 +11909,11 @@
|
|||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/sharp/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
"node_modules/size-sensor": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.3.tgz",
|
||||
"integrity": "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/styled-jsx": {
|
||||
"version": "5.1.6",
|
||||
|
|
@ -11931,8 +11952,10 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"dev": true,
|
||||
"optional": true
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
|
|
@ -11964,6 +11987,21 @@
|
|||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/zrender": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
|
||||
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"tslib": "2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zrender/node_modules/tslib": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
|
||||
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
|
||||
"license": "0BSD"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,26 +11,29 @@
|
|||
"codegen": "openapi-typescript http://localhost:8000/openapi.json -o src/lib/api-types.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.50.0",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.6",
|
||||
"leaflet": "^1.9.4",
|
||||
"leaflet-draw": "^1.0.4",
|
||||
"next": "^15.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"@tanstack/react-query": "^5.50.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"leaflet-draw": "^1.0.4"
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@types/leaflet": "^1.9.0",
|
||||
"@types/leaflet-draw": "^1.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"postcss": "^8.4.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-next": "^15.0.0",
|
||||
"openapi-typescript": "^7.0.0"
|
||||
"openapi-typescript": "^7.0.0",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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 { Providers } from "./providers";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "GenDesign",
|
||||
description: "Generative Design + Site Finder",
|
||||
|
|
@ -19,7 +21,7 @@ export default function RootLayout({
|
|||
"system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ export default function HomePage() {
|
|||
<li>
|
||||
<Link href="/site-finder">Site Finder</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/analytics">Аналитика — Свердл рынок & PRINZIP</Link>
|
||||
</li>
|
||||
</ul>
|
||||
{/* TODO: REMOVE BEFORE INVESTOR / CLIENT DEMO — internal easter egg for Anton */}
|
||||
<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