gendesign/frontend/src/app/analytics/objects/[id]/page.tsx
Light1YT adc6dde015
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m59s
Deploy / deploy (push) Successful in 1m7s
feat(site-finder): multi-cad сравнение (#50) + analytics поиск/cross-links (#65)
#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
2026-06-13 17:14:15 +00:00

454 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
)}
</>
);
}