feat(analytics): buildings block + dynamic developer comparison

- 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)
This commit is contained in:
lekss361 2026-05-11 16:03:16 +03:00
parent 8724a4731b
commit 1b65f55802
4 changed files with 141 additions and 16 deletions

View file

@ -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
}
/>
</Section>

View file

@ -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() {
<ObjectPhotoGallery objId={objId} />
</LazyMount>
</Section>
{/* Корпуса ЖК — показываем только если есть данные */}
{(d?.buildings_count ?? 0) > 0 &&
buildings.data &&
buildings.data.length > 0 && (
<Section
title="Корпуса ЖК"
subtitle="Данные из NSPD: кадастровый номер, этажность, площадь, назначение."
>
<div style={{ overflowX: "auto" }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: 13,
}}
>
<thead>
<tr style={{ background: "#f9fafb" }}>
{[
"Кад. номер",
"Этажей",
"Площадь, м²",
"Назначение",
"Название",
"Адрес",
].map((h) => (
<th
key={h}
style={{
padding: "8px 10px",
textAlign: "left",
fontWeight: 600,
borderBottom: "1px solid #e6e8ec",
whiteSpace: "nowrap",
}}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{buildings.data.map((b, i) => (
<tr
key={b.cad_num ?? i}
style={{ borderTop: "1px solid #f3f4f6" }}
>
<td
style={{
padding: "8px 10px",
fontFamily: "monospace",
fontSize: 12,
}}
>
{b.cad_num ?? "—"}
</td>
<td style={{ padding: "8px 10px" }}>{b.floors ?? "—"}</td>
<td style={{ padding: "8px 10px" }}>
{b.area != null
? b.area.toLocaleString("ru-RU", {
maximumFractionDigits: 1,
})
: "—"}
</td>
<td style={{ padding: "8px 10px", color: "#5b6066" }}>
{b.purpose ?? "—"}
</td>
<td style={{ padding: "8px 10px" }}>
{b.building_name ?? "—"}
</td>
<td
style={{
padding: "8px 10px",
color: "#5b6066",
fontSize: 12,
}}
>
{b.readable_address ?? "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Section>
)}
{/* Placeholder when buildings_count > 0 but data not yet loaded */}
{(d?.buildings_count ?? 0) > 0 &&
buildings.data &&
buildings.data.length === 0 && (
<Section title="Корпуса ЖК" subtitle="">
<p style={{ color: "#5b6066", fontSize: 13 }}>
Геометрия корпусов ещё не подгружена с NSPD.
</p>
</Section>
)}
</>
);
}

View file

@ -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<ObjectBuilding[]>(`${BASE}/object/${objId}/buildings`),
enabled: !!objId,
});
}
// ── Recommender (Уровень 1) ─────────────────────────────────────────────────
export function useRecommendMix() {

View file

@ -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 {