#50: /site-finder/compare — shortlist (localStorage, SSR-guard, cap 2-5) + CompareTable (per-row winner-highlight, reuse useParcelAnalyzeQuery per cad, hook-order stable в .map, cache-shared). Cross-link с site-finder. #65: debounced search (300ms, client-side filter top-15) в DeveloperLeaderboard + cross-link объект→Site Finder analyze. Existing developers compare не дублирован. Closes #50 Closes #65
454 lines
17 KiB
TypeScript
454 lines
17 KiB
TypeScript
"use client";
|
||
|
||
import { ArrowUpRight } from "lucide-react";
|
||
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,
|
||
useObjectChecks,
|
||
useObjectDocuments,
|
||
useObjectFullDetail,
|
||
useObjectQuartirography,
|
||
useObjectSalesAgg,
|
||
} from "@/lib/analytics-api";
|
||
|
||
// Heavy sections deferred so initial paint and main-thread are not blocked.
|
||
const ObjectSaleChart = dynamic(
|
||
() =>
|
||
import("@/components/analytics/ObjectSaleChart").then(
|
||
(m) => m.ObjectSaleChart,
|
||
),
|
||
{
|
||
ssr: false,
|
||
loading: () => (
|
||
<div
|
||
style={{
|
||
height: 300,
|
||
background: "#fafbfc",
|
||
border: "1px solid #eef0f3",
|
||
borderRadius: 8,
|
||
}}
|
||
/>
|
||
),
|
||
},
|
||
);
|
||
|
||
const ObjectInfraMap = dynamic(
|
||
() =>
|
||
import("@/components/analytics/ObjectInfraMap").then(
|
||
(m) => m.ObjectInfraMap,
|
||
),
|
||
{
|
||
ssr: false,
|
||
loading: () => (
|
||
<div
|
||
style={{
|
||
height: 360,
|
||
background: "#fafbfc",
|
||
border: "1px solid #eef0f3",
|
||
borderRadius: 8,
|
||
}}
|
||
/>
|
||
),
|
||
},
|
||
);
|
||
|
||
const ObjectPhotoGallery = dynamic(
|
||
() =>
|
||
import("@/components/analytics/ObjectPhotoGallery").then(
|
||
(m) => m.ObjectPhotoGallery,
|
||
),
|
||
{
|
||
ssr: false,
|
||
loading: () => <div style={{ height: 320 }} />,
|
||
},
|
||
);
|
||
|
||
export default function ObjectDrillInPage() {
|
||
const params = useParams<{ id: string }>();
|
||
const objId = params.id;
|
||
|
||
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 = fullDetail.data;
|
||
|
||
// Cross-link target (#65): first building's cadastral number — a real parcel
|
||
// Site Finder can analyze. Объект сам по себе не несёт cad_quarter, но корпуса
|
||
// из NSPD несут cad_num, что и есть точка входа в анализ участка.
|
||
const siteFinderCad = buildings.data?.find(
|
||
(b) => b.cad_num != null && b.cad_num !== "",
|
||
)?.cad_num;
|
||
|
||
return (
|
||
<>
|
||
<Link
|
||
href="/analytics/prinzip"
|
||
style={{ fontSize: 13, color: "#5b6066", textDecoration: "none" }}
|
||
>
|
||
← К PRINZIP
|
||
</Link>
|
||
|
||
<h1 style={{ margin: "8px 0", fontSize: 22 }}>
|
||
{d?.comm_name ?? "Загрузка…"}{" "}
|
||
{d?.site_status ? (
|
||
<span
|
||
style={{
|
||
fontSize: 13,
|
||
fontWeight: 400,
|
||
padding: "3px 8px",
|
||
borderRadius: 4,
|
||
marginLeft: 8,
|
||
background:
|
||
d.site_status === "Строящиеся" ? "#dbeafe" : "#dcfce7",
|
||
color: d.site_status === "Строящиеся" ? "#1e40af" : "#065f46",
|
||
}}
|
||
>
|
||
{d.site_status}
|
||
</span>
|
||
) : null}
|
||
</h1>
|
||
<p style={{ margin: "0 0 16px", color: "#5b6066", fontSize: 13 }}>
|
||
{d?.addr ?? ""} · obj_id={d?.obj_id} · {d?.dev_name ?? ""}
|
||
{d?.escrow ? " · эскроу" : ""}
|
||
</p>
|
||
|
||
{/* ── KPI row — распроданность ────────────────────────────────────────── */}
|
||
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
||
<KpiCard
|
||
label="Квартир"
|
||
value={
|
||
apartments?.total?.toString() ?? d?.flat_count?.toString() ?? "—"
|
||
}
|
||
hint={
|
||
apartments?.realised != null
|
||
? `${apartments.realised} продано (${apartments.perc}%)`
|
||
: undefined
|
||
}
|
||
/>
|
||
<KpiCard
|
||
label="Sold % (квартиры)"
|
||
value={apartments?.perc != null ? `${apartments.perc}` : "—"}
|
||
unit="%"
|
||
delta={
|
||
apartments?.perc != null
|
||
? {
|
||
value:
|
||
apartments.perc >= 50
|
||
? "выше среднего рынка"
|
||
: apartments.perc >= 30
|
||
? "около среднего"
|
||
: "ниже среднего",
|
||
positive: apartments.perc >= 30,
|
||
}
|
||
: undefined
|
||
}
|
||
/>
|
||
<KpiCard
|
||
label="Нежилые"
|
||
value={nonliv?.total?.toString() ?? "—"}
|
||
hint={
|
||
nonliv?.perc != null
|
||
? `${nonliv.realised} продано (${nonliv.perc}%)`
|
||
: undefined
|
||
}
|
||
/>
|
||
<KpiCard
|
||
label="Машино-места"
|
||
value={parking?.total?.toString() ?? "—"}
|
||
hint={
|
||
parking?.perc != null
|
||
? `${parking.realised} продано (${parking.perc}%)`
|
||
: undefined
|
||
}
|
||
/>
|
||
<KpiCard
|
||
label="Этажность"
|
||
value={d?.floor_max?.toString() ?? "—"}
|
||
hint={d?.floor_min ? `min ${d.floor_min}` : undefined}
|
||
/>
|
||
<KpiCard
|
||
label="Сдача"
|
||
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."
|
||
>
|
||
<LazyMount eager minHeight={300}>
|
||
<ObjectSaleChart objId={objId} />
|
||
</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. Категории кликабельны для фильтра."
|
||
>
|
||
<LazyMount minHeight={360} rootMargin="400px">
|
||
<ObjectInfraMap
|
||
objId={objId}
|
||
centerLat={d?.latitude ?? null}
|
||
centerLon={d?.longitude ?? null}
|
||
/>
|
||
</LazyMount>
|
||
</Section>
|
||
|
||
{/* ── 5. Фото прогресса строительства ─────────────────────────────────── */}
|
||
<Section
|
||
title="Фото прогресса строительства"
|
||
subtitle="DOM.РФ /construction/progress/photo. Клик по фото — увеличение."
|
||
>
|
||
<LazyMount minHeight={320} rootMargin="500px">
|
||
<ObjectPhotoGallery objId={objId} />
|
||
</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 && (
|
||
<Section
|
||
title="Корпуса ЖК"
|
||
subtitle="Данные из NSPD: кадастровый номер, этажность, площадь, назначение."
|
||
right={
|
||
siteFinderCad ? (
|
||
<Link
|
||
href={`/site-finder/analysis/${encodeURIComponent(siteFinderCad)}`}
|
||
style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: 6,
|
||
fontSize: 13,
|
||
fontWeight: 500,
|
||
color: "#1d4ed8",
|
||
textDecoration: "none",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
title={`Анализ участка ${siteFinderCad} в Site Finder`}
|
||
>
|
||
Посмотреть участок в Site Finder
|
||
<ArrowUpRight size={16} strokeWidth={1.5} />
|
||
</Link>
|
||
) : undefined
|
||
}
|
||
>
|
||
<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>
|
||
)}
|
||
|
||
{(d?.buildings_count ?? 0) > 0 &&
|
||
buildings.data &&
|
||
buildings.data.length === 0 && (
|
||
<Section title="Корпуса ЖК" subtitle="">
|
||
<p style={{ color: "#5b6066", fontSize: 13 }}>
|
||
Геометрия корпусов ещё не подгружена с NSPD.
|
||
</p>
|
||
</Section>
|
||
)}
|
||
</>
|
||
);
|
||
}
|