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:
parent
8724a4731b
commit
1b65f55802
4 changed files with 141 additions and 16 deletions
|
|
@ -21,9 +21,20 @@ function DevelopersInner() {
|
||||||
(top.data ?? []).map((r) => [r.developer_id, r.developer_name]),
|
(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
|
const compareIds = focusedId
|
||||||
? [focusedId, ...["5791_0", "5832_0"].filter((x) => x !== focusedId)]
|
? [focusedId, ...defaultTop3.filter((x) => x !== focusedId).slice(0, 2)]
|
||||||
: ["6208_0", "5791_0", "5832_0"];
|
: defaultTop3;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -71,7 +82,7 @@ function DevelopersInner() {
|
||||||
title={
|
title={
|
||||||
focusedId && focused
|
focusedId && focused
|
||||||
? `Сравнение sold% — ${focused.developer_name} vs benchmarks`
|
? `Сравнение sold% — ${focused.developer_name} vs benchmarks`
|
||||||
: "Сравнение sold% — PRINZIP vs Брусника vs Форум"
|
: `Сравнение sold% — топ-3 девелопера`
|
||||||
}
|
}
|
||||||
subtitle="Ежемесячная история DOM.РФ realization (endpoint=developer)."
|
subtitle="Ежемесячная история DOM.РФ realization (endpoint=developer)."
|
||||||
>
|
>
|
||||||
|
|
@ -79,18 +90,8 @@ function DevelopersInner() {
|
||||||
developerIds={compareIds}
|
developerIds={compareIds}
|
||||||
developerNames={
|
developerNames={
|
||||||
focused
|
focused
|
||||||
? {
|
? { ...topNames, [focused.developer_id]: focused.developer_name }
|
||||||
...topNames,
|
: topNames
|
||||||
[focused.developer_id]: focused.developer_name,
|
|
||||||
"6208_0": "PRINZIP",
|
|
||||||
"5791_0": "Брусника",
|
|
||||||
"5832_0": "Холдинг Форум-групп",
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
"6208_0": "PRINZIP",
|
|
||||||
"5791_0": "Брусника",
|
|
||||||
"5832_0": "Холдинг Форум-групп",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,11 @@ 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 { Section } from "@/components/analytics/Section";
|
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.
|
// Heavy sections deferred so initial paint and main-thread are not blocked.
|
||||||
const ObjectSaleChart = dynamic(
|
const ObjectSaleChart = dynamic(
|
||||||
|
|
@ -67,6 +71,7 @@ export default function ObjectDrillInPage() {
|
||||||
|
|
||||||
const detail = useObjectDetail(objId);
|
const detail = useObjectDetail(objId);
|
||||||
const agg = useObjectSalesAgg(objId);
|
const agg = useObjectSalesAgg(objId);
|
||||||
|
const buildings = useObjectBuildings(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");
|
||||||
|
|
@ -197,6 +202,104 @@ export default function ObjectDrillInPage() {
|
||||||
<ObjectPhotoGallery objId={objId} />
|
<ObjectPhotoGallery objId={objId} />
|
||||||
</LazyMount>
|
</LazyMount>
|
||||||
</Section>
|
</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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import type {
|
||||||
DeveloperTopRow,
|
DeveloperTopRow,
|
||||||
DistrictRow,
|
DistrictRow,
|
||||||
MarketPulsePoint,
|
MarketPulsePoint,
|
||||||
|
ObjectBuilding,
|
||||||
ObjectDetail,
|
ObjectDetail,
|
||||||
ObjectInfraPoi,
|
ObjectInfraPoi,
|
||||||
ObjectPhoto,
|
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) ─────────────────────────────────────────────────
|
// ── Recommender (Уровень 1) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
export function useRecommendMix() {
|
export function useRecommendMix() {
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,17 @@ export interface ObjectDetail {
|
||||||
longitude: number | null;
|
longitude: number | null;
|
||||||
obj_status: number | null;
|
obj_status: number | null;
|
||||||
snapshot_date: string | 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 {
|
export interface ObjectSaleGraphPoint {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue