feat(22k): object page — full exposure 9 blocks (sales/quartirography/metro/photos/docs/specs/checks) #310
10 changed files with 1113 additions and 16 deletions
|
|
@ -160,6 +160,45 @@ def object_detail(
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/object/{obj_id}/full")
|
||||||
|
def object_full_detail(
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
obj_id: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Extended object info — все 30+ новых полей (22begh: specs/accessibility/metro/scores)."""
|
||||||
|
result = q.object_full_detail(db, obj_id=obj_id)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(status_code=404, detail="object not found")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/object/{obj_id}/quartirography")
|
||||||
|
def object_quartirography(
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
obj_id: int,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Квартирография ЖК: группировка flats по комнатности с count/area/price."""
|
||||||
|
return q.object_flats_quartirography(db, obj_id=obj_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/object/{obj_id}/checks")
|
||||||
|
def object_checks(
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
obj_id: int,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""6 проверок «Проверено на наш.дом.рф» (22f)."""
|
||||||
|
return q.object_obj_checks(db, obj_id=obj_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/object/{obj_id}/documents")
|
||||||
|
def object_documents(
|
||||||
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
obj_id: int,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""PDF документы из domrf_kn_documents (22i): декларации, разрешения, отчётность."""
|
||||||
|
return q.object_documents(db, obj_id=obj_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/object/{obj_id}/buildings", response_model=list[ComplexBuilding])
|
@router.get("/object/{obj_id}/buildings", response_model=list[ComplexBuilding])
|
||||||
def object_buildings(
|
def object_buildings(
|
||||||
db: Annotated[Session, Depends(get_db)],
|
db: Annotated[Session, Depends(get_db)],
|
||||||
|
|
|
||||||
|
|
@ -842,6 +842,251 @@ def object_photos(db: Session, obj_id: int, limit: int = 100) -> list[dict[str,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def object_full_detail(db: Session, obj_id: int) -> dict[str, Any] | None:
|
||||||
|
"""Extended object detail — adds all Wave A+B columns (22begh).
|
||||||
|
|
||||||
|
Returns the same base fields as object_detail() PLUS the 30 new columns
|
||||||
|
from 113_22begh_kn_schema_extension.sql. Falls back gracefully: columns
|
||||||
|
that haven't been scraped yet return NULL.
|
||||||
|
"""
|
||||||
|
row = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT o.obj_id, o.hobj_id, o.comm_name, o.addr, o.short_addr, o.region_cd,
|
||||||
|
o.dev_id, o.dev_name, o.dev_group_name,
|
||||||
|
o.floor_min, o.floor_max, o.flat_count,
|
||||||
|
o.square_living, o.ready_dt, o.site_status, o.escrow, o.obj_class,
|
||||||
|
o.latitude, o.longitude, o.obj_status, o.snapshot_date,
|
||||||
|
o.energy_eff, o.wall_type,
|
||||||
|
-- Building specs (22e)
|
||||||
|
o.first_floor_type, o.section_count,
|
||||||
|
o.elevators_passenger_count, o.elevators_cargo_count,
|
||||||
|
o.parking_total_slots, o.guest_parking_inside_count,
|
||||||
|
o.guest_parking_outside_count, o.ceiling_height_m,
|
||||||
|
-- Apartment summary (22e)
|
||||||
|
o.finishing_variants_count, o.has_free_planning, o.avg_flat_area_m2,
|
||||||
|
-- Yard (22e)
|
||||||
|
o.playground_kids_count, o.playground_sport_count,
|
||||||
|
o.has_bike_paths, o.trash_areas_count,
|
||||||
|
-- OVZ (22e)
|
||||||
|
o.has_ramp, o.has_low_platforms, o.has_wheelchair_lift,
|
||||||
|
-- Catalog/UI (22e)
|
||||||
|
o.flat_area_min, o.flat_area_max,
|
||||||
|
o.price_min_rub, o.price_max_rub,
|
||||||
|
o.price_per_m2_min, o.price_per_m2_max,
|
||||||
|
o.parking_provision_pct, o.project_published_at,
|
||||||
|
o.project_declaration_num,
|
||||||
|
-- Metro & scores (22e/22h)
|
||||||
|
o.metro_nearest_name, o.metro_nearest_walk_minutes, o.metro_top3,
|
||||||
|
o.domrf_score_location, o.domrf_score_transport,
|
||||||
|
o.domrf_score_infrastructure,
|
||||||
|
COALESCE(cb.buildings_count, 0) AS buildings_count
|
||||||
|
FROM domrf_kn_objects o
|
||||||
|
LEFT JOIN v_complex_buildings cb ON cb.complex_id = o.obj_id
|
||||||
|
WHERE o.obj_id = :obj
|
||||||
|
ORDER BY o.snapshot_date DESC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"obj": obj_id},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
|
||||||
|
metro_top3 = row["metro_top3"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"obj_id": row["obj_id"],
|
||||||
|
"hobj_id": row["hobj_id"],
|
||||||
|
"comm_name": row["comm_name"],
|
||||||
|
"addr": row["addr"],
|
||||||
|
"short_addr": row["short_addr"],
|
||||||
|
"region_cd": row["region_cd"],
|
||||||
|
"dev_id": row["dev_id"],
|
||||||
|
"dev_name": row["dev_name"],
|
||||||
|
"dev_group_name": row["dev_group_name"],
|
||||||
|
"floor_min": row["floor_min"],
|
||||||
|
"floor_max": row["floor_max"],
|
||||||
|
"flat_count": row["flat_count"],
|
||||||
|
"square_living": _f(row["square_living"]),
|
||||||
|
"ready_dt": row["ready_dt"].isoformat() if row["ready_dt"] else None,
|
||||||
|
"site_status": row["site_status"],
|
||||||
|
"escrow": row["escrow"],
|
||||||
|
"obj_class": row["obj_class"],
|
||||||
|
"latitude": _f(row["latitude"]),
|
||||||
|
"longitude": _f(row["longitude"]),
|
||||||
|
"obj_status": row["obj_status"],
|
||||||
|
"snapshot_date": row["snapshot_date"].isoformat() if row["snapshot_date"] else None,
|
||||||
|
"energy_eff": row["energy_eff"],
|
||||||
|
"wall_type": row["wall_type"],
|
||||||
|
# Building specs
|
||||||
|
"first_floor_type": row["first_floor_type"],
|
||||||
|
"section_count": row["section_count"],
|
||||||
|
"elevators_passenger_count": row["elevators_passenger_count"],
|
||||||
|
"elevators_cargo_count": row["elevators_cargo_count"],
|
||||||
|
"parking_total_slots": row["parking_total_slots"],
|
||||||
|
"guest_parking_inside_count": row["guest_parking_inside_count"],
|
||||||
|
"guest_parking_outside_count": row["guest_parking_outside_count"],
|
||||||
|
"ceiling_height_m": _f(row["ceiling_height_m"]),
|
||||||
|
# Apartment summary
|
||||||
|
"finishing_variants_count": row["finishing_variants_count"],
|
||||||
|
"has_free_planning": row["has_free_planning"],
|
||||||
|
"avg_flat_area_m2": _f(row["avg_flat_area_m2"]),
|
||||||
|
# Yard
|
||||||
|
"playground_kids_count": row["playground_kids_count"],
|
||||||
|
"playground_sport_count": row["playground_sport_count"],
|
||||||
|
"has_bike_paths": row["has_bike_paths"],
|
||||||
|
"trash_areas_count": row["trash_areas_count"],
|
||||||
|
# OVZ
|
||||||
|
"has_ramp": row["has_ramp"],
|
||||||
|
"has_low_platforms": row["has_low_platforms"],
|
||||||
|
"has_wheelchair_lift": row["has_wheelchair_lift"],
|
||||||
|
# Catalog/UI
|
||||||
|
"flat_area_min": _f(row["flat_area_min"]),
|
||||||
|
"flat_area_max": _f(row["flat_area_max"]),
|
||||||
|
"price_min_rub": row["price_min_rub"],
|
||||||
|
"price_max_rub": row["price_max_rub"],
|
||||||
|
"price_per_m2_min": _f(row["price_per_m2_min"]),
|
||||||
|
"price_per_m2_max": _f(row["price_per_m2_max"]),
|
||||||
|
"parking_provision_pct": _f(row["parking_provision_pct"]),
|
||||||
|
"project_published_at": (
|
||||||
|
row["project_published_at"].isoformat() if row["project_published_at"] else None
|
||||||
|
),
|
||||||
|
"project_declaration_num": row["project_declaration_num"],
|
||||||
|
# Metro & scores
|
||||||
|
"metro_nearest_name": row["metro_nearest_name"],
|
||||||
|
"metro_nearest_walk_minutes": row["metro_nearest_walk_minutes"],
|
||||||
|
"metro_top3": metro_top3, # already jsonb → dict/list from psycopg3
|
||||||
|
"domrf_score_location": row["domrf_score_location"],
|
||||||
|
"domrf_score_transport": row["domrf_score_transport"],
|
||||||
|
"domrf_score_infrastructure": row["domrf_score_infrastructure"],
|
||||||
|
"buildings_count": int(row["buildings_count"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def object_flats_quartirography(db: Session, obj_id: int) -> list[dict[str, Any]]:
|
||||||
|
"""Per-rooms aggregation из domrf_kn_flats для объекта.
|
||||||
|
|
||||||
|
Группирует по rooms: 1/2/3/Нежилые (rooms IS NULL).
|
||||||
|
Возвращает count total, count 'free', min/max area, min/max price.
|
||||||
|
"""
|
||||||
|
rows = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
WITH latest AS (
|
||||||
|
SELECT MAX(snapshot_date) AS snap
|
||||||
|
FROM domrf_kn_flats
|
||||||
|
WHERE obj_id = :obj
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN f.rooms IS NULL OR LOWER(f.type) LIKE '%нежил%'
|
||||||
|
OR LOWER(f.type) LIKE '%nonliv%' THEN 'Нежилые'
|
||||||
|
WHEN f.rooms = 0 THEN 'Студия'
|
||||||
|
WHEN f.rooms = 1 THEN '1-комн.'
|
||||||
|
WHEN f.rooms = 2 THEN '2-комн.'
|
||||||
|
WHEN f.rooms = 3 THEN '3-комн.'
|
||||||
|
ELSE (f.rooms::text || '-комн.')
|
||||||
|
END AS room_label,
|
||||||
|
COALESCE(f.rooms, -1) AS sort_key,
|
||||||
|
COUNT(*) AS total_count,
|
||||||
|
COUNT(*) FILTER (WHERE LOWER(f.status) = 'free'
|
||||||
|
OR LOWER(f.status) LIKE '%свобод%')
|
||||||
|
AS free_count,
|
||||||
|
MIN(f.total_area) AS area_min,
|
||||||
|
MAX(f.total_area) AS area_max,
|
||||||
|
MIN(f.price_rub) FILTER (WHERE f.price_rub > 0)
|
||||||
|
AS price_min,
|
||||||
|
MAX(f.price_rub) FILTER (WHERE f.price_rub > 0)
|
||||||
|
AS price_max
|
||||||
|
FROM domrf_kn_flats f
|
||||||
|
CROSS JOIN latest l
|
||||||
|
WHERE f.obj_id = :obj
|
||||||
|
AND f.snapshot_date = l.snap
|
||||||
|
GROUP BY room_label, sort_key
|
||||||
|
ORDER BY sort_key
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"obj": obj_id},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"room_label": r["room_label"],
|
||||||
|
"total_count": r["total_count"],
|
||||||
|
"free_count": r["free_count"],
|
||||||
|
"area_min": _f(r["area_min"]),
|
||||||
|
"area_max": _f(r["area_max"]),
|
||||||
|
"price_min": r["price_min"],
|
||||||
|
"price_max": r["price_max"],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def object_obj_checks(db: Session, obj_id: int) -> list[dict[str, Any]]:
|
||||||
|
"""6 «Проверено на наш.дом.рф» checks из domrf_obj_checks (22f)."""
|
||||||
|
rows = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT check_type, passed, checked_at
|
||||||
|
FROM domrf_obj_checks
|
||||||
|
WHERE obj_id = :obj
|
||||||
|
ORDER BY check_type
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"obj": obj_id},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"check_type": r["check_type"],
|
||||||
|
"passed": r["passed"],
|
||||||
|
"checked_at": r["checked_at"].isoformat() if r["checked_at"] else None,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def object_documents(db: Session, obj_id: int) -> list[dict[str, Any]]:
|
||||||
|
"""PDF documents из domrf_kn_documents (22i), сортировка по doc_type + posted_at."""
|
||||||
|
rows = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT doc_type, doc_num, posted_at, file_url, size_bytes
|
||||||
|
FROM domrf_kn_documents
|
||||||
|
WHERE obj_id = :obj
|
||||||
|
ORDER BY doc_type, posted_at DESC NULLS LAST
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"obj": obj_id},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"doc_type": r["doc_type"],
|
||||||
|
"doc_num": r["doc_num"],
|
||||||
|
"posted_at": r["posted_at"].isoformat() if r["posted_at"] else None,
|
||||||
|
"file_url": r["file_url"],
|
||||||
|
"size_bytes": r["size_bytes"],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def prinzip_funnel_monthly(db: Session, months: int = 24) -> list[dict[str, Any]]:
|
def prinzip_funnel_monthly(db: Session, months: int = 24) -> list[dict[str, Any]]:
|
||||||
"""Воронка по месяцам из materialized view."""
|
"""Воронка по месяцам из materialized view."""
|
||||||
rows = (
|
rows = (
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,23 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
|
|
||||||
import { KpiCard } from "@/components/analytics/KpiCard";
|
import { KpiCard } from "@/components/analytics/KpiCard";
|
||||||
import { LazyMount } from "@/components/analytics/LazyMount";
|
import { LazyMount } from "@/components/analytics/LazyMount";
|
||||||
|
import { ObjectChecksBadges } from "@/components/analytics/ObjectChecksBadges";
|
||||||
|
import { ObjectDocumentsList } from "@/components/analytics/ObjectDocumentsList";
|
||||||
|
import { ObjectMetroList } from "@/components/analytics/ObjectMetroList";
|
||||||
|
import { ObjectQuartirographyTable } from "@/components/analytics/ObjectQuartirographyTable";
|
||||||
|
import { ObjectSpecsTable } from "@/components/analytics/ObjectSpecsTable";
|
||||||
import { Section } from "@/components/analytics/Section";
|
import { Section } from "@/components/analytics/Section";
|
||||||
import {
|
import {
|
||||||
useObjectBuildings,
|
useObjectBuildings,
|
||||||
useObjectDetail,
|
useObjectChecks,
|
||||||
|
useObjectDocuments,
|
||||||
|
useObjectFullDetail,
|
||||||
|
useObjectQuartirography,
|
||||||
useObjectSalesAgg,
|
useObjectSalesAgg,
|
||||||
} from "@/lib/analytics-api";
|
} from "@/lib/analytics-api";
|
||||||
|
|
||||||
|
|
@ -69,15 +77,18 @@ export default function ObjectDrillInPage() {
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
const objId = params.id;
|
const objId = params.id;
|
||||||
|
|
||||||
const detail = useObjectDetail(objId);
|
const fullDetail = useObjectFullDetail(objId);
|
||||||
const agg = useObjectSalesAgg(objId);
|
const agg = useObjectSalesAgg(objId);
|
||||||
const buildings = useObjectBuildings(objId);
|
const buildings = useObjectBuildings(objId);
|
||||||
|
const quartirography = useObjectQuartirography(objId);
|
||||||
|
const checks = useObjectChecks(objId);
|
||||||
|
const documents = useObjectDocuments(objId);
|
||||||
|
|
||||||
const apartments = agg.data?.find((r) => r.type === "apartments");
|
const apartments = agg.data?.find((r) => r.type === "apartments");
|
||||||
const parking = agg.data?.find((r) => r.type === "parking");
|
const parking = agg.data?.find((r) => r.type === "parking");
|
||||||
const nonliv = agg.data?.find((r) => r.type === "nonliv");
|
const nonliv = agg.data?.find((r) => r.type === "nonliv");
|
||||||
|
|
||||||
const d = detail.data;
|
const d = fullDetail.data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -109,9 +120,10 @@ export default function ObjectDrillInPage() {
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ margin: "0 0 16px", color: "#5b6066", fontSize: 13 }}>
|
<p style={{ margin: "0 0 16px", color: "#5b6066", fontSize: 13 }}>
|
||||||
{d?.addr ?? ""} · obj_id={d?.obj_id} · {d?.dev_name ?? ""}
|
{d?.addr ?? ""} · obj_id={d?.obj_id} · {d?.dev_name ?? ""}
|
||||||
{d?.escrow ? " · ✅ эскроу" : ""}
|
{d?.escrow ? " · эскроу" : ""}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* ── KPI row — распроданность ────────────────────────────────────────── */}
|
||||||
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="Квартир"
|
label="Квартир"
|
||||||
|
|
@ -142,15 +154,6 @@ export default function ObjectDrillInPage() {
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<KpiCard
|
|
||||||
label="Машино-места"
|
|
||||||
value={parking?.total?.toString() ?? "—"}
|
|
||||||
hint={
|
|
||||||
parking?.perc != null
|
|
||||||
? `${parking.realised} продано (${parking.perc}%)`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="Нежилые"
|
label="Нежилые"
|
||||||
value={nonliv?.total?.toString() ?? "—"}
|
value={nonliv?.total?.toString() ?? "—"}
|
||||||
|
|
@ -160,6 +163,15 @@ export default function ObjectDrillInPage() {
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Машино-места"
|
||||||
|
value={parking?.total?.toString() ?? "—"}
|
||||||
|
hint={
|
||||||
|
parking?.perc != null
|
||||||
|
? `${parking.realised} продано (${parking.perc}%)`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
label="Этажность"
|
label="Этажность"
|
||||||
value={d?.floor_max?.toString() ?? "—"}
|
value={d?.floor_max?.toString() ?? "—"}
|
||||||
|
|
@ -170,8 +182,58 @@ export default function ObjectDrillInPage() {
|
||||||
value={d?.ready_dt?.slice(0, 7) ?? "—"}
|
value={d?.ready_dt?.slice(0, 7) ?? "—"}
|
||||||
hint={d?.obj_class ?? undefined}
|
hint={d?.obj_class ?? undefined}
|
||||||
/>
|
/>
|
||||||
|
{d?.price_min_rub != null ? (
|
||||||
|
<KpiCard
|
||||||
|
label="Цена от"
|
||||||
|
value={(d.price_min_rub / 1_000_000).toLocaleString("ru-RU", {
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
})}
|
||||||
|
unit="млн ₽"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{d?.price_max_rub != null ? (
|
||||||
|
<KpiCard
|
||||||
|
label="Цена до"
|
||||||
|
value={(d.price_max_rub / 1_000_000).toLocaleString("ru-RU", {
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
})}
|
||||||
|
unit="млн ₽"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── DOM.РФ scores ───────────────────────────────────────────────────── */}
|
||||||
|
{(d?.domrf_score_location != null ||
|
||||||
|
d?.domrf_score_transport != null ||
|
||||||
|
d?.domrf_score_infrastructure != null) && (
|
||||||
|
<div
|
||||||
|
style={{ display: "flex", gap: 12, flexWrap: "wrap", marginTop: 12 }}
|
||||||
|
>
|
||||||
|
{d?.domrf_score_location != null && (
|
||||||
|
<KpiCard
|
||||||
|
label="DOM.РФ: Расположение"
|
||||||
|
value={String(d.domrf_score_location)}
|
||||||
|
unit="/ 10"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{d?.domrf_score_transport != null && (
|
||||||
|
<KpiCard
|
||||||
|
label="DOM.РФ: Транспорт"
|
||||||
|
value={String(d.domrf_score_transport)}
|
||||||
|
unit="/ 10"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{d?.domrf_score_infrastructure != null && (
|
||||||
|
<KpiCard
|
||||||
|
label="DOM.РФ: Инфраструктура"
|
||||||
|
value={String(d.domrf_score_infrastructure)}
|
||||||
|
unit="/ 10"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 1. Динамика продаж ──────────────────────────────────────────────── */}
|
||||||
<Section
|
<Section
|
||||||
title="Динамика продаж"
|
title="Динамика продаж"
|
||||||
subtitle="DOM.РФ /sale_graph: реализовано (бары) + средняя цена ₽/м² (линия). Apartments + parking."
|
subtitle="DOM.РФ /sale_graph: реализовано (бары) + средняя цена ₽/м² (линия). Apartments + parking."
|
||||||
|
|
@ -181,6 +243,34 @@ export default function ObjectDrillInPage() {
|
||||||
</LazyMount>
|
</LazyMount>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{/* ── 2. Квартирография ──────────────────────────────────────────────── */}
|
||||||
|
<Section
|
||||||
|
title="Квартирография"
|
||||||
|
subtitle="Распределение по комнатности: count, площадь, цена. Из domrf_kn_flats (последний снимок)."
|
||||||
|
>
|
||||||
|
{quartirography.isLoading ? (
|
||||||
|
<div style={{ height: 80, background: "#f3f4f6", borderRadius: 6 }} />
|
||||||
|
) : (
|
||||||
|
<ObjectQuartirographyTable rows={quartirography.data ?? []} />
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* ── 3. Ближайшее метро ──────────────────────────────────────────────── */}
|
||||||
|
{(d?.metro_nearest_name != null ||
|
||||||
|
(d?.metro_top3 != null && d.metro_top3.length > 0)) && (
|
||||||
|
<Section
|
||||||
|
title="Метро"
|
||||||
|
subtitle="Ближайшие станции метро из kn-API. Время — пешком."
|
||||||
|
>
|
||||||
|
<ObjectMetroList
|
||||||
|
nearest_name={d.metro_nearest_name}
|
||||||
|
nearest_walk_minutes={d.metro_nearest_walk_minutes}
|
||||||
|
top3={d.metro_top3}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 4. Инфраструктура (POI) ─────────────────────────────────────────── */}
|
||||||
<Section
|
<Section
|
||||||
title="Инфраструктура (POI вокруг ЖК)"
|
title="Инфраструктура (POI вокруг ЖК)"
|
||||||
subtitle="Из DOM.РФ /infrastructure. Категории кликабельны для фильтра."
|
subtitle="Из DOM.РФ /infrastructure. Категории кликабельны для фильтра."
|
||||||
|
|
@ -194,6 +284,7 @@ export default function ObjectDrillInPage() {
|
||||||
</LazyMount>
|
</LazyMount>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{/* ── 5. Фото прогресса строительства ─────────────────────────────────── */}
|
||||||
<Section
|
<Section
|
||||||
title="Фото прогресса строительства"
|
title="Фото прогресса строительства"
|
||||||
subtitle="DOM.РФ /construction/progress/photo. Клик по фото — увеличение."
|
subtitle="DOM.РФ /construction/progress/photo. Клик по фото — увеличение."
|
||||||
|
|
@ -203,7 +294,37 @@ export default function ObjectDrillInPage() {
|
||||||
</LazyMount>
|
</LazyMount>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Корпуса ЖК — показываем только если есть данные */}
|
{/* ── 6. Проверено на наш.дом.рф ─────────────────────────────────────── */}
|
||||||
|
{!checks.isLoading && (checks.data?.length ?? 0) > 0 && (
|
||||||
|
<Section
|
||||||
|
title="Проверено на наш.дом.рф"
|
||||||
|
subtitle="6 проверок безопасности от DOM.РФ."
|
||||||
|
>
|
||||||
|
<ObjectChecksBadges checks={checks.data ?? []} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 7. Документы ────────────────────────────────────────────────────── */}
|
||||||
|
{!documents.isLoading && (documents.data?.length ?? 0) > 0 && (
|
||||||
|
<Section
|
||||||
|
title="Документы"
|
||||||
|
subtitle="PDF-документы (декларации, разрешения, отчётность) из domrf_kn_documents."
|
||||||
|
>
|
||||||
|
<ObjectDocumentsList docs={documents.data ?? []} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 8. Все характеристики ───────────────────────────────────────────── */}
|
||||||
|
{d && (
|
||||||
|
<Section
|
||||||
|
title="Все характеристики"
|
||||||
|
subtitle="Полный набор полей из domrf_kn_objects (22e+22g): конструктив, дворовая инфраструктура, ОВЗ."
|
||||||
|
>
|
||||||
|
<ObjectSpecsTable d={d} />
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 9. Корпуса ЖК ───────────────────────────────────────────────────── */}
|
||||||
{(d?.buildings_count ?? 0) > 0 &&
|
{(d?.buildings_count ?? 0) > 0 &&
|
||||||
buildings.data &&
|
buildings.data &&
|
||||||
buildings.data.length > 0 && (
|
buildings.data.length > 0 && (
|
||||||
|
|
@ -290,7 +411,6 @@ export default function ObjectDrillInPage() {
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Placeholder when buildings_count > 0 but data not yet loaded */}
|
|
||||||
{(d?.buildings_count ?? 0) > 0 &&
|
{(d?.buildings_count ?? 0) > 0 &&
|
||||||
buildings.data &&
|
buildings.data &&
|
||||||
buildings.data.length === 0 && (
|
buildings.data.length === 0 && (
|
||||||
|
|
|
||||||
45
frontend/src/components/analytics/ObjectChecksBadges.tsx
Normal file
45
frontend/src/components/analytics/ObjectChecksBadges.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import type { ObjectCheckRow } from "@/types/analytics";
|
||||||
|
|
||||||
|
const CHECK_LABELS: Record<string, string> = {
|
||||||
|
no_problems: "Нет проблемных объектов",
|
||||||
|
docs: "Документы опубликованы",
|
||||||
|
timing: "Сроки соблюдены",
|
||||||
|
photos: "Актуальные фото",
|
||||||
|
bankruptcy: "Не банкрот",
|
||||||
|
declaration: "Декларация обновлена",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ObjectChecksBadges({ checks }: { checks: ObjectCheckRow[] }) {
|
||||||
|
if (checks.length === 0) {
|
||||||
|
return (
|
||||||
|
<p style={{ color: "#9ca3af", fontSize: 13 }}>
|
||||||
|
Данные проверок ещё не загружены.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
|
||||||
|
{checks.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.check_type}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
padding: "6px 12px",
|
||||||
|
borderRadius: 20,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
|
background: c.passed ? "#dcfce7" : "#fee2e2",
|
||||||
|
color: c.passed ? "#065f46" : "#991b1b",
|
||||||
|
border: `1px solid ${c.passed ? "#bbf7d0" : "#fecaca"}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 15 }}>{c.passed ? "✓" : "✗"}</span>
|
||||||
|
{CHECK_LABELS[c.check_type] ?? c.check_type}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
frontend/src/components/analytics/ObjectDocumentsList.tsx
Normal file
106
frontend/src/components/analytics/ObjectDocumentsList.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import type { ObjectDocumentRow } from "@/types/analytics";
|
||||||
|
|
||||||
|
function safeUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
if (parsed.protocol === "https:" || parsed.protocol === "http:") {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through
|
||||||
|
}
|
||||||
|
return "#";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtSize(bytes: number | null): string {
|
||||||
|
if (bytes === null) return "";
|
||||||
|
if (bytes < 1024) return `${bytes} Б`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} КБ`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOC_TYPE_ORDER: Record<string, number> = {
|
||||||
|
декларация: 1,
|
||||||
|
разрешение: 2,
|
||||||
|
проектная: 3,
|
||||||
|
отчётность: 4,
|
||||||
|
прочее: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ObjectDocumentsList({ docs }: { docs: ObjectDocumentRow[] }) {
|
||||||
|
if (docs.length === 0) {
|
||||||
|
return (
|
||||||
|
<p style={{ color: "#9ca3af", fontSize: 13 }}>Документы отсутствуют.</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by doc_type
|
||||||
|
const byType = new Map<string, ObjectDocumentRow[]>();
|
||||||
|
for (const d of docs) {
|
||||||
|
const group = byType.get(d.doc_type) ?? [];
|
||||||
|
group.push(d);
|
||||||
|
byType.set(d.doc_type, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedTypes = [...byType.keys()].sort((a, b) => {
|
||||||
|
const oa = DOC_TYPE_ORDER[a.toLowerCase()] ?? 99;
|
||||||
|
const ob = DOC_TYPE_ORDER[b.toLowerCase()] ?? 99;
|
||||||
|
return oa - ob || a.localeCompare(b, "ru");
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
|
{sortedTypes.map((type) => {
|
||||||
|
const group = byType.get(type)!;
|
||||||
|
return (
|
||||||
|
<div key={type}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
color: "#5b6066",
|
||||||
|
marginBottom: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{type}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||||
|
{group.map((doc, i) => (
|
||||||
|
<div
|
||||||
|
key={`${doc.doc_type}-${doc.doc_num ?? i}`}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "baseline",
|
||||||
|
gap: 8,
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 16, lineHeight: 1 }}>📄</span>
|
||||||
|
<a
|
||||||
|
href={safeUrl(doc.file_url)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ color: "#1e40af", textDecoration: "none" }}
|
||||||
|
>
|
||||||
|
{doc.doc_num ? `${doc.doc_num}` : `Документ ${i + 1}`}
|
||||||
|
</a>
|
||||||
|
{doc.posted_at ? (
|
||||||
|
<span style={{ color: "#9ca3af", fontSize: 12 }}>
|
||||||
|
{doc.posted_at.slice(0, 10)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{doc.size_bytes ? (
|
||||||
|
<span style={{ color: "#9ca3af", fontSize: 12 }}>
|
||||||
|
{fmtSize(doc.size_bytes)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
frontend/src/components/analytics/ObjectMetroList.tsx
Normal file
85
frontend/src/components/analytics/ObjectMetroList.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
import type { MetroEntry } from "@/types/analytics";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
nearest_name: string | null;
|
||||||
|
nearest_walk_minutes: number | null;
|
||||||
|
top3: MetroEntry[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ObjectMetroList({
|
||||||
|
nearest_name,
|
||||||
|
nearest_walk_minutes,
|
||||||
|
top3,
|
||||||
|
}: Props) {
|
||||||
|
// Build display list: prefer top3 array, fallback to nearest fields
|
||||||
|
const entries: MetroEntry[] =
|
||||||
|
top3 && top3.length > 0
|
||||||
|
? top3
|
||||||
|
: nearest_name
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
name: nearest_name,
|
||||||
|
time: nearest_walk_minutes,
|
||||||
|
line: null,
|
||||||
|
color: null,
|
||||||
|
isWalk: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return (
|
||||||
|
<p style={{ color: "#9ca3af", fontSize: 13 }}>
|
||||||
|
Данные о метро отсутствуют.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
|
{entries.map((m, i) => (
|
||||||
|
<div
|
||||||
|
key={`${m.name}-${i}`}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
padding: "10px 14px",
|
||||||
|
background: "#f9fafb",
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid #e6e8ec",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: 6,
|
||||||
|
background: m.color ?? "#6b7280",
|
||||||
|
flexShrink: 0,
|
||||||
|
display: "inline-block",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontWeight: 500, fontSize: 14 }}>{m.name}</div>
|
||||||
|
{m.line ? (
|
||||||
|
<div style={{ color: "#5b6066", fontSize: 12 }}>{m.line}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{m.time != null ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "#5b6066",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{m.isWalk !== false ? "🚶" : "🚗"} {m.time} мин
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
115
frontend/src/components/analytics/ObjectQuartirographyTable.tsx
Normal file
115
frontend/src/components/analytics/ObjectQuartirographyTable.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
import type { ObjectQuartirographyRow } from "@/types/analytics";
|
||||||
|
|
||||||
|
function fmtPrice(v: number | null): string {
|
||||||
|
if (v === null) return "—";
|
||||||
|
return (
|
||||||
|
(v / 1_000_000).toLocaleString("ru-RU", {
|
||||||
|
minimumFractionDigits: 1,
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
}) + " млн"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtArea(v: number | null): string {
|
||||||
|
if (v === null) return "—";
|
||||||
|
return v.toLocaleString("ru-RU", { maximumFractionDigits: 1 }) + " м²";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ObjectQuartirographyTable({
|
||||||
|
rows,
|
||||||
|
}: {
|
||||||
|
rows: ObjectQuartirographyRow[];
|
||||||
|
}) {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<p style={{ color: "#9ca3af", fontSize: 13 }}>
|
||||||
|
Данные квартирографии отсутствуют.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = ["Тип", "Всего", "В продаже", "Площадь", "Цена (от–до)"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ overflowX: "auto" }}>
|
||||||
|
<table
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
borderCollapse: "collapse",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ background: "#f9fafb" }}>
|
||||||
|
{headers.map((h) => (
|
||||||
|
<th
|
||||||
|
key={h}
|
||||||
|
style={{
|
||||||
|
padding: "8px 10px",
|
||||||
|
textAlign: "left",
|
||||||
|
fontWeight: 600,
|
||||||
|
borderBottom: "1px solid #e6e8ec",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r.room_label} style={{ borderTop: "1px solid #f3f4f6" }}>
|
||||||
|
<td style={{ padding: "8px 10px", fontWeight: 500 }}>
|
||||||
|
{r.room_label}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "8px 10px",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.total_count.toLocaleString("ru-RU")}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "8px 10px",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.free_count > 0 ? (
|
||||||
|
<span style={{ color: "#0a7a3a", fontWeight: 500 }}>
|
||||||
|
{r.free_count.toLocaleString("ru-RU")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: "#9ca3af" }}>0</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "8px 10px",
|
||||||
|
color: "#5b6066",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.area_min !== null || r.area_max !== null
|
||||||
|
? `${fmtArea(r.area_min)} – ${fmtArea(r.area_max)}`
|
||||||
|
: "—"}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "8px 10px",
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.price_min !== null || r.price_max !== null
|
||||||
|
? `${fmtPrice(r.price_min)} – ${fmtPrice(r.price_max)}`
|
||||||
|
: "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
204
frontend/src/components/analytics/ObjectSpecsTable.tsx
Normal file
204
frontend/src/components/analytics/ObjectSpecsTable.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
import type { ObjectFullDetail } from "@/types/analytics";
|
||||||
|
|
||||||
|
interface SpecRow {
|
||||||
|
label: string;
|
||||||
|
value: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function yn(v: boolean | null | undefined): string | null {
|
||||||
|
if (v === null || v === undefined) return null;
|
||||||
|
return v ? "Да" : "Нет";
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(v: number | null | undefined, suffix = ""): string | null {
|
||||||
|
if (v === null || v === undefined) return null;
|
||||||
|
return `${v.toLocaleString("ru-RU")}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numFmt(
|
||||||
|
v: number | null | undefined,
|
||||||
|
decimals = 0,
|
||||||
|
suffix = "",
|
||||||
|
): string | null {
|
||||||
|
if (v === null || v === undefined) return null;
|
||||||
|
return (
|
||||||
|
v.toLocaleString("ru-RU", {
|
||||||
|
minimumFractionDigits: decimals,
|
||||||
|
maximumFractionDigits: decimals,
|
||||||
|
}) + suffix
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function price(v: number | null | undefined): string | null {
|
||||||
|
if (v === null || v === undefined) return null;
|
||||||
|
return (
|
||||||
|
(v / 1_000_000).toLocaleString("ru-RU", {
|
||||||
|
minimumFractionDigits: 1,
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
}) + " млн ₽"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function priceM2(v: number | null | undefined): string | null {
|
||||||
|
if (v === null || v === undefined) return null;
|
||||||
|
return v.toLocaleString("ru-RU", { maximumFractionDigits: 0 }) + " ₽/м²";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ObjectSpecsTable({ d }: { d: ObjectFullDetail }) {
|
||||||
|
const specs: SpecRow[] = [
|
||||||
|
// Identity
|
||||||
|
{ label: "Класс объекта", value: d.obj_class },
|
||||||
|
{ label: "Класс энергоэффективности", value: d.energy_eff },
|
||||||
|
{ label: "Материал стен", value: d.wall_type },
|
||||||
|
{ label: "Первый этаж", value: d.first_floor_type },
|
||||||
|
// Building
|
||||||
|
{ label: "Подъездов", value: num(d.section_count) },
|
||||||
|
{ label: "Этажей (max)", value: num(d.floor_max) },
|
||||||
|
{ label: "Высота потолков", value: numFmt(d.ceiling_height_m, 2, " м") },
|
||||||
|
{
|
||||||
|
label: "Лифты пассажирские",
|
||||||
|
value:
|
||||||
|
d.elevators_passenger_count === 0
|
||||||
|
? "Нет"
|
||||||
|
: num(d.elevators_passenger_count),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Лифты грузовые",
|
||||||
|
value:
|
||||||
|
d.elevators_cargo_count === 0 ? "Нет" : num(d.elevators_cargo_count),
|
||||||
|
},
|
||||||
|
// Parking
|
||||||
|
{
|
||||||
|
label: "Машино-мест (парковка)",
|
||||||
|
value: num(d.parking_total_slots),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Гостевые (приказ.)",
|
||||||
|
value: num(d.guest_parking_inside_count),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Гостевые (улица)",
|
||||||
|
value: num(d.guest_parking_outside_count),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Обеспеченность парковкой",
|
||||||
|
value: numFmt(d.parking_provision_pct, 1, "%"),
|
||||||
|
},
|
||||||
|
// Apartments
|
||||||
|
{ label: "Квартир всего", value: num(d.flat_count) },
|
||||||
|
{
|
||||||
|
label: "Площадь квартир",
|
||||||
|
value:
|
||||||
|
d.flat_area_min != null && d.flat_area_max != null
|
||||||
|
? `${numFmt(d.flat_area_min, 1)} – ${numFmt(d.flat_area_max, 1)} м²`
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Средняя площадь квартиры",
|
||||||
|
value: numFmt(d.avg_flat_area_m2, 1, " м²"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Вариантов отделки",
|
||||||
|
value: num(d.finishing_variants_count),
|
||||||
|
},
|
||||||
|
{ label: "Свободная планировка", value: yn(d.has_free_planning) },
|
||||||
|
// Price
|
||||||
|
{
|
||||||
|
label: "Цена (от)",
|
||||||
|
value: price(d.price_min_rub),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Цена (до)",
|
||||||
|
value: price(d.price_max_rub),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Цена/м² (от)",
|
||||||
|
value: priceM2(d.price_per_m2_min),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Цена/м² (до)",
|
||||||
|
value: priceM2(d.price_per_m2_max),
|
||||||
|
},
|
||||||
|
// Yard
|
||||||
|
{
|
||||||
|
label: "Детских площадок",
|
||||||
|
value: num(d.playground_kids_count),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Спортивных площадок",
|
||||||
|
value: num(d.playground_sport_count),
|
||||||
|
},
|
||||||
|
{ label: "Велодорожки", value: yn(d.has_bike_paths) },
|
||||||
|
{
|
||||||
|
label: "Мусорные площадки",
|
||||||
|
value: num(d.trash_areas_count),
|
||||||
|
},
|
||||||
|
// OVZ
|
||||||
|
{ label: "Пандус", value: yn(d.has_ramp) },
|
||||||
|
{ label: "Понижающие платформы (ОВЗ)", value: yn(d.has_low_platforms) },
|
||||||
|
{
|
||||||
|
label: "Инвалидный подъёмник",
|
||||||
|
value: yn(d.has_wheelchair_lift),
|
||||||
|
},
|
||||||
|
// Developer
|
||||||
|
{ label: "Девелопер", value: d.dev_name },
|
||||||
|
{ label: "Группа компаний", value: d.dev_group_name },
|
||||||
|
{ label: "Эскроу", value: yn(d.escrow) },
|
||||||
|
{
|
||||||
|
label: "Декларация №",
|
||||||
|
value: d.project_declaration_num,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Дата публикации проекта",
|
||||||
|
value: d.project_published_at?.slice(0, 10) ?? null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const filtered = specs.filter((s) => s.value !== null);
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
return (
|
||||||
|
<p style={{ color: "#9ca3af", fontSize: 13 }}>
|
||||||
|
Характеристики ещё не загружены.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ overflowX: "auto" }}>
|
||||||
|
<table
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
borderCollapse: "collapse",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<tbody>
|
||||||
|
{filtered.map((s) => (
|
||||||
|
<tr key={s.label} style={{ borderTop: "1px solid #f3f4f6" }}>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "7px 12px 7px 0",
|
||||||
|
color: "#5b6066",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
verticalAlign: "top",
|
||||||
|
width: "50%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
style={{
|
||||||
|
padding: "7px 0",
|
||||||
|
fontWeight: 500,
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{s.value}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -11,9 +11,13 @@ import type {
|
||||||
DistrictRow,
|
DistrictRow,
|
||||||
MarketPulsePoint,
|
MarketPulsePoint,
|
||||||
ObjectBuilding,
|
ObjectBuilding,
|
||||||
|
ObjectCheckRow,
|
||||||
ObjectDetail,
|
ObjectDetail,
|
||||||
|
ObjectDocumentRow,
|
||||||
|
ObjectFullDetail,
|
||||||
ObjectInfraPoi,
|
ObjectInfraPoi,
|
||||||
ObjectPhoto,
|
ObjectPhoto,
|
||||||
|
ObjectQuartirographyRow,
|
||||||
ObjectSaleGraphPoint,
|
ObjectSaleGraphPoint,
|
||||||
ObjectSalesAggRow,
|
ObjectSalesAggRow,
|
||||||
PipelineRow,
|
PipelineRow,
|
||||||
|
|
@ -219,6 +223,42 @@ export function useObjectBuildings(objId: number | string) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useObjectFullDetail(objId: number | string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["analytics", "object-full", objId],
|
||||||
|
queryFn: () => apiFetch<ObjectFullDetail>(`${BASE}/object/${objId}/full`),
|
||||||
|
enabled: !!objId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useObjectQuartirography(objId: number | string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["analytics", "object-quartirography", objId],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<ObjectQuartirographyRow[]>(
|
||||||
|
`${BASE}/object/${objId}/quartirography`,
|
||||||
|
),
|
||||||
|
enabled: !!objId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useObjectChecks(objId: number | string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["analytics", "object-checks", objId],
|
||||||
|
queryFn: () => apiFetch<ObjectCheckRow[]>(`${BASE}/object/${objId}/checks`),
|
||||||
|
enabled: !!objId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useObjectDocuments(objId: number | string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["analytics", "object-documents", objId],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<ObjectDocumentRow[]>(`${BASE}/object/${objId}/documents`),
|
||||||
|
enabled: !!objId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Recommender (Уровень 1) ─────────────────────────────────────────────────
|
// ── Recommender (Уровень 1) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
export function useRecommendMix() {
|
export function useRecommendMix() {
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,104 @@ export interface ObjectInfraPoi {
|
||||||
distance_m: number | null;
|
distance_m: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MetroEntry {
|
||||||
|
name: string;
|
||||||
|
time: number | null;
|
||||||
|
line: string | null;
|
||||||
|
color: string | null;
|
||||||
|
isWalk: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObjectFullDetail {
|
||||||
|
obj_id: number;
|
||||||
|
hobj_id: number | null;
|
||||||
|
comm_name: string | null;
|
||||||
|
addr: string | null;
|
||||||
|
short_addr: string | null;
|
||||||
|
region_cd: number | null;
|
||||||
|
dev_id: string | null;
|
||||||
|
dev_name: string | null;
|
||||||
|
dev_group_name: string | null;
|
||||||
|
floor_min: number | null;
|
||||||
|
floor_max: number | null;
|
||||||
|
flat_count: number | null;
|
||||||
|
square_living: number | null;
|
||||||
|
ready_dt: string | null;
|
||||||
|
site_status: string | null;
|
||||||
|
escrow: boolean | null;
|
||||||
|
obj_class: string | null;
|
||||||
|
latitude: number | null;
|
||||||
|
longitude: number | null;
|
||||||
|
obj_status: number | null;
|
||||||
|
snapshot_date: string | null;
|
||||||
|
energy_eff: string | null;
|
||||||
|
wall_type: string | null;
|
||||||
|
// Building specs
|
||||||
|
first_floor_type: string | null;
|
||||||
|
section_count: number | null;
|
||||||
|
elevators_passenger_count: number | null;
|
||||||
|
elevators_cargo_count: number | null;
|
||||||
|
parking_total_slots: number | null;
|
||||||
|
guest_parking_inside_count: number | null;
|
||||||
|
guest_parking_outside_count: number | null;
|
||||||
|
ceiling_height_m: number | null;
|
||||||
|
// Apartment summary
|
||||||
|
finishing_variants_count: number | null;
|
||||||
|
has_free_planning: boolean | null;
|
||||||
|
avg_flat_area_m2: number | null;
|
||||||
|
// Yard
|
||||||
|
playground_kids_count: number | null;
|
||||||
|
playground_sport_count: number | null;
|
||||||
|
has_bike_paths: boolean | null;
|
||||||
|
trash_areas_count: number | null;
|
||||||
|
// OVZ
|
||||||
|
has_ramp: boolean | null;
|
||||||
|
has_low_platforms: boolean | null;
|
||||||
|
has_wheelchair_lift: boolean | null;
|
||||||
|
// Catalog/UI
|
||||||
|
flat_area_min: number | null;
|
||||||
|
flat_area_max: number | null;
|
||||||
|
price_min_rub: number | null;
|
||||||
|
price_max_rub: number | null;
|
||||||
|
price_per_m2_min: number | null;
|
||||||
|
price_per_m2_max: number | null;
|
||||||
|
parking_provision_pct: number | null;
|
||||||
|
project_published_at: string | null;
|
||||||
|
project_declaration_num: string | null;
|
||||||
|
// Metro & scores
|
||||||
|
metro_nearest_name: string | null;
|
||||||
|
metro_nearest_walk_minutes: number | null;
|
||||||
|
metro_top3: MetroEntry[] | null;
|
||||||
|
domrf_score_location: number | null;
|
||||||
|
domrf_score_transport: number | null;
|
||||||
|
domrf_score_infrastructure: number | null;
|
||||||
|
buildings_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObjectQuartirographyRow {
|
||||||
|
room_label: string;
|
||||||
|
total_count: number;
|
||||||
|
free_count: number;
|
||||||
|
area_min: number | null;
|
||||||
|
area_max: number | null;
|
||||||
|
price_min: number | null;
|
||||||
|
price_max: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObjectCheckRow {
|
||||||
|
check_type: string;
|
||||||
|
passed: boolean;
|
||||||
|
checked_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ObjectDocumentRow {
|
||||||
|
doc_type: string;
|
||||||
|
doc_num: string | null;
|
||||||
|
posted_at: string | null;
|
||||||
|
file_url: string;
|
||||||
|
size_bytes: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ObjectPhoto {
|
export interface ObjectPhoto {
|
||||||
obj_file_id: string;
|
obj_file_id: string;
|
||||||
ord_num: number | null;
|
ord_num: number | null;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue