From 1b65f55802a107f19674b0f1f4716e1918eec4b0 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Mon, 11 May 2026 16:03:16 +0300 Subject: [PATCH] feat(analytics): buildings block + dynamic developer comparison MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ObjectBuilding type + useObjectBuildings hook - Objects detail page: render Корпуса ЖК table (cad_num, floors, area, purpose, name, address) when buildings_count > 0 - Developers page: drop hardcoded compareIds, use top-3 from useTopDevelopers sorted by jk_count DESC (fallback to legacy static array when API has no data yet) --- .../src/app/analytics/developers/page.tsx | 31 +++--- .../src/app/analytics/objects/[id]/page.tsx | 105 +++++++++++++++++- frontend/src/lib/analytics-api.ts | 10 ++ frontend/src/types/analytics.ts | 11 ++ 4 files changed, 141 insertions(+), 16 deletions(-) diff --git a/frontend/src/app/analytics/developers/page.tsx b/frontend/src/app/analytics/developers/page.tsx index 551001ea..e61d011d 100644 --- a/frontend/src/app/analytics/developers/page.tsx +++ b/frontend/src/app/analytics/developers/page.tsx @@ -21,9 +21,20 @@ function DevelopersInner() { (top.data ?? []).map((r) => [r.developer_id, r.developer_name]), ); + // Top-3 by jk_count (complexes count) from the leaderboard, falling back to + // static defaults if data is not yet available. + const top3Ids = (top.data ?? []) + .slice() + .sort((a, b) => (b.jk_count ?? 0) - (a.jk_count ?? 0)) + .slice(0, 3) + .map((r) => r.developer_id); + + const defaultTop3 = + top3Ids.length >= 3 ? top3Ids : ["6208_0", "5791_0", "5832_0"]; + const compareIds = focusedId - ? [focusedId, ...["5791_0", "5832_0"].filter((x) => x !== focusedId)] - : ["6208_0", "5791_0", "5832_0"]; + ? [focusedId, ...defaultTop3.filter((x) => x !== focusedId).slice(0, 2)] + : defaultTop3; return ( <> @@ -71,7 +82,7 @@ function DevelopersInner() { title={ focusedId && focused ? `Сравнение sold% — ${focused.developer_name} vs benchmarks` - : "Сравнение sold% — PRINZIP vs Брусника vs Форум" + : `Сравнение sold% — топ-3 девелопера` } subtitle="Ежемесячная история DOM.РФ realization (endpoint=developer)." > @@ -79,18 +90,8 @@ function DevelopersInner() { developerIds={compareIds} developerNames={ focused - ? { - ...topNames, - [focused.developer_id]: focused.developer_name, - "6208_0": "PRINZIP", - "5791_0": "Брусника", - "5832_0": "Холдинг Форум-групп", - } - : { - "6208_0": "PRINZIP", - "5791_0": "Брусника", - "5832_0": "Холдинг Форум-групп", - } + ? { ...topNames, [focused.developer_id]: focused.developer_name } + : topNames } /> diff --git a/frontend/src/app/analytics/objects/[id]/page.tsx b/frontend/src/app/analytics/objects/[id]/page.tsx index dce7e623..3e9c8869 100644 --- a/frontend/src/app/analytics/objects/[id]/page.tsx +++ b/frontend/src/app/analytics/objects/[id]/page.tsx @@ -7,7 +7,11 @@ import { useParams } from "next/navigation"; import { KpiCard } from "@/components/analytics/KpiCard"; import { LazyMount } from "@/components/analytics/LazyMount"; import { Section } from "@/components/analytics/Section"; -import { useObjectDetail, useObjectSalesAgg } from "@/lib/analytics-api"; +import { + useObjectBuildings, + useObjectDetail, + useObjectSalesAgg, +} from "@/lib/analytics-api"; // Heavy sections deferred so initial paint and main-thread are not blocked. const ObjectSaleChart = dynamic( @@ -67,6 +71,7 @@ export default function ObjectDrillInPage() { const detail = useObjectDetail(objId); const agg = useObjectSalesAgg(objId); + const buildings = useObjectBuildings(objId); const apartments = agg.data?.find((r) => r.type === "apartments"); const parking = agg.data?.find((r) => r.type === "parking"); @@ -197,6 +202,104 @@ export default function ObjectDrillInPage() { + + {/* Корпуса ЖК — показываем только если есть данные */} + {(d?.buildings_count ?? 0) > 0 && + buildings.data && + buildings.data.length > 0 && ( +
+
+ + + + {[ + "Кад. номер", + "Этажей", + "Площадь, м²", + "Назначение", + "Название", + "Адрес", + ].map((h) => ( + + ))} + + + + {buildings.data.map((b, i) => ( + + + + + + + + + ))} + +
+ {h} +
+ {b.cad_num ?? "—"} + {b.floors ?? "—"} + {b.area != null + ? b.area.toLocaleString("ru-RU", { + maximumFractionDigits: 1, + }) + : "—"} + + {b.purpose ?? "—"} + + {b.building_name ?? "—"} + + {b.readable_address ?? "—"} +
+
+
+ )} + + {/* Placeholder when buildings_count > 0 but data not yet loaded */} + {(d?.buildings_count ?? 0) > 0 && + buildings.data && + buildings.data.length === 0 && ( +
+

+ Геометрия корпусов ещё не подгружена с NSPD. +

+
+ )} ); } diff --git a/frontend/src/lib/analytics-api.ts b/frontend/src/lib/analytics-api.ts index c2bc0d72..f1508634 100644 --- a/frontend/src/lib/analytics-api.ts +++ b/frontend/src/lib/analytics-api.ts @@ -10,6 +10,7 @@ import type { DeveloperTopRow, DistrictRow, MarketPulsePoint, + ObjectBuilding, ObjectDetail, ObjectInfraPoi, ObjectPhoto, @@ -209,6 +210,15 @@ export function useObjectPhotos(objId: number | string, limit = 100) { }); } +export function useObjectBuildings(objId: number | string) { + return useQuery({ + queryKey: ["analytics", "object-buildings", objId], + queryFn: () => + apiFetch(`${BASE}/object/${objId}/buildings`), + enabled: !!objId, + }); +} + // ── Recommender (Уровень 1) ───────────────────────────────────────────────── export function useRecommendMix() { diff --git a/frontend/src/types/analytics.ts b/frontend/src/types/analytics.ts index 308bc525..810e533f 100644 --- a/frontend/src/types/analytics.ts +++ b/frontend/src/types/analytics.ts @@ -150,6 +150,17 @@ export interface ObjectDetail { longitude: number | null; obj_status: number | null; snapshot_date: string | null; + buildings_count: number | null; +} + +export interface ObjectBuilding { + cad_num: string | null; + floors: number | null; + area: number | null; + purpose: string | null; + building_name: string | null; + readable_address: string | null; + geom_geojson: unknown; } export interface ObjectSaleGraphPoint {