feat(22k): object page full exposure — 9 blocks (sales/quartirography/metro/photos/docs/specs/checks)

Adds backend queries + 4 API endpoints + 5 frontend components to expose all
Wave A+B data on the per-object drill-in page (/analytics/objects/[id]).

Backend (analytics_queries.py + analytics.py):
- object_full_detail(): extended SELECT with all 30 new cols from 113_22begh
  (building specs, yard, OVZ, catalog UI, metro, scores, dev_group_name)
- object_flats_quartirography(): GROUP BY rooms from domrf_kn_flats
- object_obj_checks(): SELECT from domrf_obj_checks (22f)
- object_documents(): SELECT from domrf_kn_documents (22i)
- 4 new endpoints: /object/{id}/full, /quartirography, /checks, /documents

Frontend:
- ObjectSpecsTable.tsx — 30+ spec rows, conditional (nulls hidden)
- ObjectQuartirographyTable.tsx — rooms x count/free/area/price
- ObjectChecksBadges.tsx — 6 badge pills (passed/failed)
- ObjectDocumentsList.tsx — PDF links grouped by doc_type with safeUrl()
- ObjectMetroList.tsx — top3 metro entries with line color dot
- page.tsx: 9 blocks incl. DOM.RF scores KPI row, checks, documents, specs

Closes #297 (Phase 6 — 22k frontend exposure)
This commit is contained in:
lekss361 2026-05-17 18:50:41 +03:00
parent 4bed58b43c
commit cac06b2ff9
10 changed files with 1113 additions and 16 deletions

View file

@ -160,6 +160,45 @@ def object_detail(
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])
def object_buildings(
db: Annotated[Session, Depends(get_db)],

View file

@ -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]]:
"""Воронка по месяцам из materialized view."""
rows = (

View file

@ -1,15 +1,23 @@
"use client";
import Link from "next/link";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useParams } from "next/navigation";
import { KpiCard } from "@/components/analytics/KpiCard";
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 {
useObjectBuildings,
useObjectDetail,
useObjectChecks,
useObjectDocuments,
useObjectFullDetail,
useObjectQuartirography,
useObjectSalesAgg,
} from "@/lib/analytics-api";
@ -69,15 +77,18 @@ export default function ObjectDrillInPage() {
const params = useParams<{ id: string }>();
const objId = params.id;
const detail = useObjectDetail(objId);
const fullDetail = useObjectFullDetail(objId);
const agg = useObjectSalesAgg(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 parking = agg.data?.find((r) => r.type === "parking");
const nonliv = agg.data?.find((r) => r.type === "nonliv");
const d = detail.data;
const d = fullDetail.data;
return (
<>
@ -109,9 +120,10 @@ export default function ObjectDrillInPage() {
</h1>
<p style={{ margin: "0 0 16px", color: "#5b6066", fontSize: 13 }}>
{d?.addr ?? ""} · obj_id={d?.obj_id} · {d?.dev_name ?? ""}
{d?.escrow ? " · эскроу" : ""}
{d?.escrow ? " · эскроу" : ""}
</p>
{/* ── KPI row — распроданность ────────────────────────────────────────── */}
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<KpiCard
label="Квартир"
@ -142,15 +154,6 @@ export default function ObjectDrillInPage() {
: undefined
}
/>
<KpiCard
label="Машино-места"
value={parking?.total?.toString() ?? "—"}
hint={
parking?.perc != null
? `${parking.realised} продано (${parking.perc}%)`
: undefined
}
/>
<KpiCard
label="Нежилые"
value={nonliv?.total?.toString() ?? "—"}
@ -160,6 +163,15 @@ export default function ObjectDrillInPage() {
: undefined
}
/>
<KpiCard
label="Машино-места"
value={parking?.total?.toString() ?? "—"}
hint={
parking?.perc != null
? `${parking.realised} продано (${parking.perc}%)`
: undefined
}
/>
<KpiCard
label="Этажность"
value={d?.floor_max?.toString() ?? "—"}
@ -170,8 +182,58 @@ export default function ObjectDrillInPage() {
value={d?.ready_dt?.slice(0, 7) ?? "—"}
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>
{/* ── 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
title="Динамика продаж"
subtitle="DOM.РФ /sale_graph: реализовано (бары) + средняя цена ₽/м² (линия). Apartments + parking."
@ -181,6 +243,34 @@ export default function ObjectDrillInPage() {
</LazyMount>
</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
title="Инфраструктура (POI вокруг ЖК)"
subtitle="Из DOM.РФ /infrastructure. Категории кликабельны для фильтра."
@ -194,6 +284,7 @@ export default function ObjectDrillInPage() {
</LazyMount>
</Section>
{/* ── 5. Фото прогресса строительства ─────────────────────────────────── */}
<Section
title="Фото прогресса строительства"
subtitle="DOM.РФ /construction/progress/photo. Клик по фото — увеличение."
@ -203,7 +294,37 @@ export default function ObjectDrillInPage() {
</LazyMount>
</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 &&
buildings.data &&
buildings.data.length > 0 && (
@ -290,7 +411,6 @@ export default function ObjectDrillInPage() {
</Section>
)}
{/* Placeholder when buildings_count > 0 but data not yet loaded */}
{(d?.buildings_count ?? 0) > 0 &&
buildings.data &&
buildings.data.length === 0 && (

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -11,9 +11,13 @@ import type {
DistrictRow,
MarketPulsePoint,
ObjectBuilding,
ObjectCheckRow,
ObjectDetail,
ObjectDocumentRow,
ObjectFullDetail,
ObjectInfraPoi,
ObjectPhoto,
ObjectQuartirographyRow,
ObjectSaleGraphPoint,
ObjectSalesAggRow,
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) ─────────────────────────────────────────────────
export function useRecommendMix() {

View file

@ -191,6 +191,104 @@ export interface ObjectInfraPoi {
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 {
obj_file_id: string;
ord_num: number | null;