From cac06b2ff9383e8f69c23a37deb3093e3368b360 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sun, 17 May 2026 18:50:41 +0300 Subject: [PATCH] =?UTF-8?q?feat(22k):=20object=20page=20full=20exposure=20?= =?UTF-8?q?=E2=80=94=209=20blocks=20(sales/quartirography/metro/photos/doc?= =?UTF-8?q?s/specs/checks)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/app/api/v1/analytics.py | 39 +++ backend/app/services/analytics_queries.py | 245 ++++++++++++++++++ .../src/app/analytics/objects/[id]/page.tsx | 152 +++++++++-- .../analytics/ObjectChecksBadges.tsx | 45 ++++ .../analytics/ObjectDocumentsList.tsx | 106 ++++++++ .../components/analytics/ObjectMetroList.tsx | 85 ++++++ .../analytics/ObjectQuartirographyTable.tsx | 115 ++++++++ .../components/analytics/ObjectSpecsTable.tsx | 204 +++++++++++++++ frontend/src/lib/analytics-api.ts | 40 +++ frontend/src/types/analytics.ts | 98 +++++++ 10 files changed, 1113 insertions(+), 16 deletions(-) create mode 100644 frontend/src/components/analytics/ObjectChecksBadges.tsx create mode 100644 frontend/src/components/analytics/ObjectDocumentsList.tsx create mode 100644 frontend/src/components/analytics/ObjectMetroList.tsx create mode 100644 frontend/src/components/analytics/ObjectQuartirographyTable.tsx create mode 100644 frontend/src/components/analytics/ObjectSpecsTable.tsx diff --git a/backend/app/api/v1/analytics.py b/backend/app/api/v1/analytics.py index 61028b56..5568364f 100644 --- a/backend/app/api/v1/analytics.py +++ b/backend/app/api/v1/analytics.py @@ -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)], diff --git a/backend/app/services/analytics_queries.py b/backend/app/services/analytics_queries.py index 0b3855df..2ea75ba5 100644 --- a/backend/app/services/analytics_queries.py +++ b/backend/app/services/analytics_queries.py @@ -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 = ( diff --git a/frontend/src/app/analytics/objects/[id]/page.tsx b/frontend/src/app/analytics/objects/[id]/page.tsx index 3e9c8869..e57fdee5 100644 --- a/frontend/src/app/analytics/objects/[id]/page.tsx +++ b/frontend/src/app/analytics/objects/[id]/page.tsx @@ -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() {

{d?.addr ?? ""} · obj_id={d?.obj_id} · {d?.dev_name ?? ""} - {d?.escrow ? " · ✅ эскроу" : ""} + {d?.escrow ? " · эскроу" : ""}

+ {/* ── KPI row — распроданность ────────────────────────────────────────── */}
- + + {d?.price_min_rub != null ? ( + + ) : null} + {d?.price_max_rub != null ? ( + + ) : null}
+ {/* ── DOM.РФ scores ───────────────────────────────────────────────────── */} + {(d?.domrf_score_location != null || + d?.domrf_score_transport != null || + d?.domrf_score_infrastructure != null) && ( +
+ {d?.domrf_score_location != null && ( + + )} + {d?.domrf_score_transport != null && ( + + )} + {d?.domrf_score_infrastructure != null && ( + + )} +
+ )} + + {/* ── 1. Динамика продаж ──────────────────────────────────────────────── */}
+ {/* ── 2. Квартирография ──────────────────────────────────────────────── */} +
+ {quartirography.isLoading ? ( +
+ ) : ( + + )} +
+ + {/* ── 3. Ближайшее метро ──────────────────────────────────────────────── */} + {(d?.metro_nearest_name != null || + (d?.metro_top3 != null && d.metro_top3.length > 0)) && ( +
+ +
+ )} + + {/* ── 4. Инфраструктура (POI) ─────────────────────────────────────────── */}
+ {/* ── 5. Фото прогресса строительства ─────────────────────────────────── */}
- {/* Корпуса ЖК — показываем только если есть данные */} + {/* ── 6. Проверено на наш.дом.рф ─────────────────────────────────────── */} + {!checks.isLoading && (checks.data?.length ?? 0) > 0 && ( +
+ +
+ )} + + {/* ── 7. Документы ────────────────────────────────────────────────────── */} + {!documents.isLoading && (documents.data?.length ?? 0) > 0 && ( +
+ +
+ )} + + {/* ── 8. Все характеристики ───────────────────────────────────────────── */} + {d && ( +
+ +
+ )} + + {/* ── 9. Корпуса ЖК ───────────────────────────────────────────────────── */} {(d?.buildings_count ?? 0) > 0 && buildings.data && buildings.data.length > 0 && ( @@ -290,7 +411,6 @@ export default function ObjectDrillInPage() { )} - {/* Placeholder when buildings_count > 0 but data not yet loaded */} {(d?.buildings_count ?? 0) > 0 && buildings.data && buildings.data.length === 0 && ( diff --git a/frontend/src/components/analytics/ObjectChecksBadges.tsx b/frontend/src/components/analytics/ObjectChecksBadges.tsx new file mode 100644 index 00000000..f565ca8c --- /dev/null +++ b/frontend/src/components/analytics/ObjectChecksBadges.tsx @@ -0,0 +1,45 @@ +import type { ObjectCheckRow } from "@/types/analytics"; + +const CHECK_LABELS: Record = { + no_problems: "Нет проблемных объектов", + docs: "Документы опубликованы", + timing: "Сроки соблюдены", + photos: "Актуальные фото", + bankruptcy: "Не банкрот", + declaration: "Декларация обновлена", +}; + +export function ObjectChecksBadges({ checks }: { checks: ObjectCheckRow[] }) { + if (checks.length === 0) { + return ( +

+ Данные проверок ещё не загружены. +

+ ); + } + + return ( +
+ {checks.map((c) => ( +
+ {c.passed ? "✓" : "✗"} + {CHECK_LABELS[c.check_type] ?? c.check_type} +
+ ))} +
+ ); +} diff --git a/frontend/src/components/analytics/ObjectDocumentsList.tsx b/frontend/src/components/analytics/ObjectDocumentsList.tsx new file mode 100644 index 00000000..b7817558 --- /dev/null +++ b/frontend/src/components/analytics/ObjectDocumentsList.tsx @@ -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 = { + декларация: 1, + разрешение: 2, + проектная: 3, + отчётность: 4, + прочее: 5, +}; + +export function ObjectDocumentsList({ docs }: { docs: ObjectDocumentRow[] }) { + if (docs.length === 0) { + return ( +

Документы отсутствуют.

+ ); + } + + // Group by doc_type + const byType = new Map(); + 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 ( +
+ {sortedTypes.map((type) => { + const group = byType.get(type)!; + return ( +
+
+ {type} +
+
+ {group.map((doc, i) => ( +
+ 📄 + + {doc.doc_num ? `${doc.doc_num}` : `Документ ${i + 1}`} + + {doc.posted_at ? ( + + {doc.posted_at.slice(0, 10)} + + ) : null} + {doc.size_bytes ? ( + + {fmtSize(doc.size_bytes)} + + ) : null} +
+ ))} +
+
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/analytics/ObjectMetroList.tsx b/frontend/src/components/analytics/ObjectMetroList.tsx new file mode 100644 index 00000000..63ba85cb --- /dev/null +++ b/frontend/src/components/analytics/ObjectMetroList.tsx @@ -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 ( +

+ Данные о метро отсутствуют. +

+ ); + } + + return ( +
+ {entries.map((m, i) => ( +
+ +
+
{m.name}
+ {m.line ? ( +
{m.line}
+ ) : null} +
+ {m.time != null ? ( +
+ {m.isWalk !== false ? "🚶" : "🚗"} {m.time} мин +
+ ) : null} +
+ ))} +
+ ); +} diff --git a/frontend/src/components/analytics/ObjectQuartirographyTable.tsx b/frontend/src/components/analytics/ObjectQuartirographyTable.tsx new file mode 100644 index 00000000..0d57cee3 --- /dev/null +++ b/frontend/src/components/analytics/ObjectQuartirographyTable.tsx @@ -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 ( +

+ Данные квартирографии отсутствуют. +

+ ); + } + + const headers = ["Тип", "Всего", "В продаже", "Площадь", "Цена (от–до)"]; + + return ( +
+ + + + {headers.map((h) => ( + + ))} + + + + {rows.map((r) => ( + + + + + + + + ))} + +
+ {h} +
+ {r.room_label} + + {r.total_count.toLocaleString("ru-RU")} + + {r.free_count > 0 ? ( + + {r.free_count.toLocaleString("ru-RU")} + + ) : ( + 0 + )} + + {r.area_min !== null || r.area_max !== null + ? `${fmtArea(r.area_min)} – ${fmtArea(r.area_max)}` + : "—"} + + {r.price_min !== null || r.price_max !== null + ? `${fmtPrice(r.price_min)} – ${fmtPrice(r.price_max)}` + : "—"} +
+
+ ); +} diff --git a/frontend/src/components/analytics/ObjectSpecsTable.tsx b/frontend/src/components/analytics/ObjectSpecsTable.tsx new file mode 100644 index 00000000..3b0ebb34 --- /dev/null +++ b/frontend/src/components/analytics/ObjectSpecsTable.tsx @@ -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 ( +

+ Характеристики ещё не загружены. +

+ ); + } + + return ( +
+ + + {filtered.map((s) => ( + + + + + ))} + +
+ {s.label} + + {s.value} +
+
+ ); +} diff --git a/frontend/src/lib/analytics-api.ts b/frontend/src/lib/analytics-api.ts index f1508634..73b7287c 100644 --- a/frontend/src/lib/analytics-api.ts +++ b/frontend/src/lib/analytics-api.ts @@ -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(`${BASE}/object/${objId}/full`), + enabled: !!objId, + }); +} + +export function useObjectQuartirography(objId: number | string) { + return useQuery({ + queryKey: ["analytics", "object-quartirography", objId], + queryFn: () => + apiFetch( + `${BASE}/object/${objId}/quartirography`, + ), + enabled: !!objId, + }); +} + +export function useObjectChecks(objId: number | string) { + return useQuery({ + queryKey: ["analytics", "object-checks", objId], + queryFn: () => apiFetch(`${BASE}/object/${objId}/checks`), + enabled: !!objId, + }); +} + +export function useObjectDocuments(objId: number | string) { + return useQuery({ + queryKey: ["analytics", "object-documents", objId], + queryFn: () => + apiFetch(`${BASE}/object/${objId}/documents`), + enabled: !!objId, + }); +} + // ── Recommender (Уровень 1) ───────────────────────────────────────────────── export function useRecommendMix() { diff --git a/frontend/src/types/analytics.ts b/frontend/src/types/analytics.ts index 01a2c7e7..d8b9c80f 100644 --- a/frontend/src/types/analytics.ts +++ b/frontend/src/types/analytics.ts @@ -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;