feat(sf-fe-a5): Section 1 Инфо об участке — HeadlineBar + KPI + EGRN + POI + Export #346

Merged
lekss361 merged 2 commits from feat/sf-fe-a5-section1-parcel-info into main 2026-05-17 22:55:05 +00:00
9 changed files with 1246 additions and 8 deletions

View file

@ -6,6 +6,7 @@ import { LayoutDashboard } from "lucide-react";
import { AnalysisSidebar } from "@/components/site-finder/analysis/AnalysisSidebar";
import { AnalysisBreadcrumb } from "@/components/site-finder/analysis/AnalysisBreadcrumb";
import { UserAvatar } from "@/components/site-finder/analysis/UserAvatar";
import { Section1ParcelInfo } from "@/components/site-finder/analysis/Section1ParcelInfo";
// ── Section placeholder (Wave 34 refactor will replace these) ─────────────────
@ -124,12 +125,8 @@ export default function AnalysisPage({ params }: PageProps) {
maxWidth: 1000,
}}
>
{/* Section 1 — TODO A5 */}
<SectionPlaceholder
id="section-1"
title="1. Объект"
note="TODO A5: HeadlineBar вердикт + 4 KPI (Площадь / Район / Медиана / Score) + mini-map + EGRN table + POI list 2GIS"
/>
{/* Section 1 — A5: Инфо об участке */}
<Section1ParcelInfo cad={cad} />
{/* Section 2 — TODO A6 */}
<SectionPlaceholder

View file

@ -0,0 +1,136 @@
/**
* EgrnPropertyTable 2-column key-value table of 10 EGRN properties.
* Server component (no "use client") pure display.
* monospace for cad_num and area_m2.
*/
import type { ParcelEgrn } from "@/lib/site-finder-api";
interface Props {
data: ParcelEgrn;
}
interface Row {
label: string;
value: string;
mono?: boolean;
}
function formatDate(raw: string | null): string {
if (!raw) return "—";
try {
return new Date(raw).toLocaleDateString("ru-RU");
} catch {
return raw;
}
}
function buildRows(data: ParcelEgrn): Row[] {
return [
{ label: "Кадастровый номер", value: data.cad_num, mono: true },
{ label: "Адрес", value: data.address },
{
label: "Площадь",
value:
Number.isFinite(data.area_m2) && data.area_m2 > 0
? `${data.area_m2.toLocaleString("ru")} м²`
: "—",
mono: true,
},
{ label: "ВРИ", value: data.vri },
{ label: "Категория", value: data.category },
{
label: "Дата регистрации",
value: formatDate(data.registration_date),
},
{ label: "Форма собственности", value: data.owner_type },
{ label: "Обременения", value: data.encumbrance },
{ label: "Статус", value: data.status },
{ label: "Обновлено", value: formatDate(data.last_updated) },
];
}
export function EgrnPropertyTable({ data }: Props) {
const rows = buildRows(data);
return (
<div
style={{
border: "1px solid var(--border-card)",
borderRadius: 12,
overflow: "hidden",
background: "var(--bg-card)",
}}
>
<div
style={{
padding: "10px 16px",
borderBottom: "1px solid var(--border-soft)",
fontSize: 12,
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: "var(--fg-tertiary)",
}}
>
ЕГРН
</div>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: 13,
}}
>
<tbody>
{rows.map((row, i) => (
<tr
key={row.label}
style={{
background:
i % 2 === 0 ? "var(--bg-card)" : "var(--bg-card-alt)",
}}
>
<td
style={{
padding: "8px 16px",
color: "var(--fg-secondary)",
fontWeight: 400,
width: "45%",
verticalAlign: "top",
borderBottom:
i < rows.length - 1
? "1px solid var(--border-soft)"
: "none",
}}
>
{row.label}
</td>
<td
style={{
padding: "8px 16px",
color: "var(--fg-primary)",
fontWeight: 500,
fontVariantNumeric: "tabular-nums",
fontFamily: row.mono
? "'JetBrains Mono', 'Fira Code', monospace"
: "inherit",
fontSize: row.mono ? 12 : 13,
verticalAlign: "top",
wordBreak: "break-word",
borderBottom:
i < rows.length - 1
? "1px solid var(--border-soft)"
: "none",
}}
>
{row.value}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View file

@ -0,0 +1,210 @@
"use client";
/**
* ExportButtons PDF snapshot (B7) + frontend-side CSV generation.
*
* PDF: GET /api/v1/parcels/{cad}/snapshot.pdf blob download.
* CSV: client-side generation from analyze response fields.
* Loading state, error toast via alert (no toast lib dep).
*/
import { useState } from "react";
import { Download, FileText } from "lucide-react";
import { API_BASE_URL } from "@/lib/api";
import type { ParcelAnalyzeResponse, ParcelEgrn } from "@/lib/site-finder-api";
interface Props {
cad: string;
analyzeData?: ParcelAnalyzeResponse | null;
}
// ── CSV generation ────────────────────────────────────────────────────────────
function buildCsvRows(data: ParcelAnalyzeResponse): string[][] {
const egrn = data.egrn as ParcelEgrn | undefined | null;
const rows: string[][] = [
["Поле", "Значение"],
["Кадастровый номер", data.cad_num],
["Балл", String(data.score)],
["Оценка", data.score_label ?? ""],
["Район", data.district?.district_name ?? ""],
[
"Медиана ₽/м²",
data.district?.median_price_per_m2
? String(data.district.median_price_per_m2)
: "",
],
["POI (кол-во)", String(data.poi_count)],
];
if (egrn) {
rows.push(
["Адрес", egrn.address],
["Площадь м²", String(egrn.area_m2)],
["ВРИ", egrn.vri],
["Категория", egrn.category],
["Форма собственности", egrn.owner_type],
["Обременения", egrn.encumbrance],
["Статус ЕГРН", egrn.status],
["Дата регистрации", egrn.registration_date ?? ""],
["Обновлено", egrn.last_updated ?? ""],
);
}
return rows;
}
function escapeCsvCell(cell: string): string {
// CSV-injection mitigation (OWASP / CWE-1236): cells starting with =, +, -, @,
// tab, or CR are evaluated as formulas by Excel/Calc/Sheets. Prefix dangerous
// starters with a leading apostrophe so the spreadsheet treats them as text.
let safe = cell;
if (/^[=+\-@\t\r]/.test(safe)) {
safe = "'" + safe;
}
const needsQuotes = /[",\n\r]/.test(safe);
if (needsQuotes) {
return `"${safe.replace(/"/g, '""')}"`;
}
return safe;
}
function generateCsvBlob(rows: string[][]): Blob {
const csvContent = rows
.map((row) => row.map(escapeCsvCell).join(","))
.join("\r\n");
return new Blob(["" + csvContent], { type: "text/csv;charset=utf-8" });
}
function triggerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// ── Component ─────────────────────────────────────────────────────────────────
export function ExportButtons({ cad, analyzeData }: Props) {
const [pdfLoading, setPdfLoading] = useState(false);
const [csvLoading, setCsvLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handlePdfDownload() {
setPdfLoading(true);
setError(null);
try {
const res = await fetch(
`${API_BASE_URL}/api/v1/parcels/${encodeURIComponent(cad)}/snapshot.pdf`,
);
if (!res.ok) {
throw new Error(`Ошибка сервера ${res.status}`);
}
const blob = await res.blob();
triggerDownload(blob, `участок-${cad}.pdf`);
} catch (err) {
const msg = err instanceof Error ? err.message : "Ошибка загрузки PDF";
setError(msg);
} finally {
setPdfLoading(false);
}
}
function handleCsvDownload() {
setCsvLoading(true);
setError(null);
try {
if (!analyzeData) {
setError("Данные ещё не загружены");
return;
}
const rows = buildCsvRows(analyzeData);
const blob = generateCsvBlob(rows);
triggerDownload(blob, `участок-${cad}.csv`);
} catch (err) {
const msg = err instanceof Error ? err.message : "Ошибка генерации CSV";
setError(msg);
} finally {
setCsvLoading(false);
}
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{/* PDF button — secondary (orange per design token) */}
<button
onClick={() => void handlePdfDownload()}
disabled={pdfLoading}
aria-label="Скачать снапшот PDF"
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "6px 12px",
height: 32,
background: pdfLoading ? "var(--bg-card-alt)" : "var(--accent-2)",
color: pdfLoading ? "var(--fg-tertiary)" : "#fff",
border: "none",
borderRadius: 6,
fontSize: 13,
fontWeight: 500,
cursor: pdfLoading ? "not-allowed" : "pointer",
opacity: pdfLoading ? 0.6 : 1,
transition: "opacity 150ms",
}}
>
<FileText size={14} strokeWidth={1.5} />
{pdfLoading ? "Загрузка..." : "Снапшот PDF"}
</button>
{/* CSV button — secondary (orange) */}
<button
onClick={handleCsvDownload}
disabled={csvLoading || !analyzeData}
aria-label="Скачать данные CSV"
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "6px 12px",
height: 32,
background:
csvLoading || !analyzeData
? "var(--bg-card-alt)"
: "var(--accent-2)",
color: csvLoading || !analyzeData ? "var(--fg-tertiary)" : "#fff",
border: "none",
borderRadius: 6,
fontSize: 13,
fontWeight: 500,
cursor: csvLoading || !analyzeData ? "not-allowed" : "pointer",
opacity: csvLoading || !analyzeData ? 0.6 : 1,
transition: "opacity 150ms",
}}
>
<Download size={14} strokeWidth={1.5} />
{csvLoading ? "Генерация..." : "Скачать CSV"}
</button>
</div>
{/* Error message */}
{error && (
<p
style={{
margin: 0,
fontSize: 12,
color: "var(--danger)",
}}
role="alert"
>
{error}
</p>
)}
</div>
);
}

View file

@ -0,0 +1,87 @@
"use client";
/**
* MiniMap compact Leaflet map (400×320) showing parcel polygon + top POI.
* Dynamic import of react-leaflet (ssr:false) to avoid SSR breakage.
* Wraps SiteMap with fixed dimensions for Section 1 layout.
*/
import dynamic from "next/dynamic";
import type { ParcelAnalyzeResponse } from "@/lib/site-finder-api";
import type { ParcelAnalysis } from "@/types/site-finder";
import type { Geometry } from "geojson";
// Lazy-mount to avoid SSR breakage (Leaflet uses window)
const SiteMap = dynamic(
() =>
import("@/components/site-finder/SiteMap").then((m) => ({
default: m.SiteMap,
})),
{
ssr: false,
loading: () => (
<div
style={{
height: 320,
background: "var(--bg-card-alt)",
borderRadius: 12,
border: "1px solid var(--border-card)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--fg-tertiary)",
fontSize: 13,
}}
>
Загрузка карты...
</div>
),
},
);
interface Props {
data: ParcelAnalyzeResponse;
}
/**
* Adapts ParcelAnalyzeResponse ParcelAnalysis shape expected by SiteMap.
* SiteMap only reads: cad_num, geom_geojson, score_breakdown.
* Other required fields are filled with safe defaults.
*/
function toSiteMapData(data: ParcelAnalyzeResponse): ParcelAnalysis {
return {
cad_num: data.cad_num,
source: data.source === "cad_building" ? "cad_building" : "cad_quarter",
geom_geojson: (data.geom_geojson as Geometry) ?? null,
score_breakdown: data.score_breakdown,
score: data.score,
poi_count: data.poi_count,
competitors: [],
noise: null,
air_quality: null,
wind: null,
district: data.district ?? null,
};
}
export function MiniMap({ data }: Props) {
const adapted = toSiteMapData(data);
return (
<div
style={{
width: "100%",
maxWidth: 400,
height: 320,
borderRadius: 12,
overflow: "hidden",
border: "1px solid var(--border-card)",
flexShrink: 0,
}}
>
<div style={{ height: 320 }}>
<SiteMap data={adapted} />
</div>
</div>
);
}

View file

@ -0,0 +1,222 @@
/**
* PoiList2Gis top-7 POI list with weighted score.
* Category icons: Lucide Train / TreePine / GraduationCap / Baby /
* ShoppingBag / Hospital / Banknote
* Per row: icon + name + distance + weight badge.
*/
import {
Train,
TreePine,
GraduationCap,
Baby,
ShoppingBag,
Hospital,
Banknote,
MapPin,
} from "lucide-react";
import type { ReactNode } from "react";
import { Badge } from "@/components/ui/Badge";
import type { PoiScoreItem } from "@/lib/site-finder-api";
// ── Icon mapping ──────────────────────────────────────────────────────────────
const CATEGORY_ICONS: Record<string, ReactNode> = {
metro_stop: <Train size={16} strokeWidth={1.5} />,
tram_stop: <Train size={16} strokeWidth={1.5} />,
bus_stop: <Train size={16} strokeWidth={1.5} />,
park: <TreePine size={16} strokeWidth={1.5} />,
school: <GraduationCap size={16} strokeWidth={1.5} />,
kindergarten: <Baby size={16} strokeWidth={1.5} />,
shop_mall: <ShoppingBag size={16} strokeWidth={1.5} />,
shop_supermarket: <ShoppingBag size={16} strokeWidth={1.5} />,
shop_small: <ShoppingBag size={16} strokeWidth={1.5} />,
hospital: <Hospital size={16} strokeWidth={1.5} />,
pharmacy: <Hospital size={16} strokeWidth={1.5} />,
bank: <Banknote size={16} strokeWidth={1.5} />,
};
const CATEGORY_LABELS: Record<string, string> = {
metro_stop: "Метро",
tram_stop: "Трамвай",
bus_stop: "Автобус",
park: "Парк",
school: "Школа",
kindergarten: "Детский сад",
shop_mall: "ТЦ",
shop_supermarket: "Супермаркет",
shop_small: "Магазин",
hospital: "Больница",
pharmacy: "Аптека",
bank: "Банк",
};
function weightBadgeVariant(
weight: number,
): "success" | "info" | "neutral" | "warning" {
if (weight >= 0.2) return "success";
if (weight >= 0.12) return "info";
if (weight >= 0.08) return "neutral";
return "warning";
}
// ── Props ─────────────────────────────────────────────────────────────────────
interface Props {
items: PoiScoreItem[];
totalScore: number;
}
// ── Component ─────────────────────────────────────────────────────────────────
export function PoiList2Gis({ items, totalScore }: Props) {
// Top-7, sorted by score_contribution desc
const top7 = [...items]
.sort((a, b) => b.score_contribution - a.score_contribution)
.slice(0, 7);
if (top7.length === 0) {
return (
<div
style={{
padding: "20px 16px",
color: "var(--fg-tertiary)",
fontSize: 13,
textAlign: "center",
border: "1px solid var(--border-card)",
borderRadius: 12,
}}
>
POI данные недоступны
</div>
);
}
return (
<div
style={{
border: "1px solid var(--border-card)",
borderRadius: 12,
overflow: "hidden",
background: "var(--bg-card)",
}}
>
{/* Header */}
<div
style={{
padding: "10px 16px",
borderBottom: "1px solid var(--border-soft)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span
style={{
fontSize: 12,
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: "var(--fg-tertiary)",
}}
>
POI · 2ГИС / OSM
</span>
<span
style={{
fontSize: 13,
fontWeight: 700,
color: "var(--accent)",
fontVariantNumeric: "tabular-nums",
}}
>
{totalScore.toFixed(0)} / 100
</span>
</div>
{/* List */}
<ul style={{ listStyle: "none", margin: 0, padding: 0 }}>
{top7.map((item, i) => {
const icon = CATEGORY_ICONS[item.category] ?? (
<MapPin size={16} strokeWidth={1.5} />
);
const categoryLabel = CATEGORY_LABELS[item.category] ?? item.category;
const isLast = i === top7.length - 1;
return (
<li
key={`${item.category}-${i}`}
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "10px 16px",
borderBottom: isLast ? "none" : "1px solid var(--border-soft)",
background:
i % 2 === 0 ? "var(--bg-card)" : "var(--bg-card-alt)",
}}
>
{/* Icon */}
<span
style={{
color: "var(--accent)",
flexShrink: 0,
display: "flex",
alignItems: "center",
}}
aria-label={categoryLabel}
>
{icon}
</span>
{/* Name + category */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 13,
fontWeight: 500,
color: "var(--fg-primary)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{item.name}
</div>
<div
style={{
fontSize: 11,
color: "var(--fg-tertiary)",
marginTop: 1,
}}
>
{categoryLabel}
</div>
</div>
{/* Distance */}
<span
style={{
fontSize: 12,
color: "var(--fg-secondary)",
fontVariantNumeric: "tabular-nums",
flexShrink: 0,
whiteSpace: "nowrap",
}}
>
{item.distance_m < 1000
? `${Math.round(item.distance_m)} м`
: `${(item.distance_m / 1000).toFixed(1)} км`}
</span>
{/* Weight badge */}
<Badge variant={weightBadgeVariant(item.weight)} size="sm">
×{item.weight.toFixed(2)}
</Badge>
</li>
);
})}
</ul>
</div>
);
}

View file

@ -0,0 +1,310 @@
"use client";
/**
* Section1ParcelInfo "1. Инфо об участке"
*
* Layout:
* HeadlineBar (verdict + score delta subtitle)
* 4 KpiCard grid (Площадь / Район / Медиана / Score)
* 2-col grid: left = MiniMap, right = EgrnPropertyTable + PoiList2Gis
* ExportButtons
*
* Data: useParcelAnalyzeQuery (B5) + useParcelPoiScoreQuery (B6).
* Mock fallback: MOCK_ANALYZE / MOCK_POI_SCORE from mock-toggle.ts.
*/
import { HeadlineBar } from "@/components/ui/HeadlineBar";
import { KpiCard } from "@/components/site-finder/KpiCard";
import { MiniMap } from "./MiniMap";
import { EgrnPropertyTable } from "./EgrnPropertyTable";
import { PoiList2Gis } from "./PoiList2Gis";
import { ExportButtons } from "./ExportButtons";
import {
useParcelAnalyzeQuery,
useParcelPoiScoreQuery,
} from "@/lib/site-finder-api";
import type { ParcelEgrn } from "@/lib/site-finder-api";
// ── Fallback EGRN (when B5 extended not yet deployed) ────────────────────────
function buildFallbackEgrn(cad: string): ParcelEgrn {
return {
cad_num: cad,
address: "—",
area_m2: NaN,
vri: "—",
category: "—",
registration_date: null,
owner_type: "—",
encumbrance: "—",
status: "—",
last_updated: null,
};
}
// ── Score → verdict label ─────────────────────────────────────────────────────
function scoreVerdict(score: number, scoreLabel: string | undefined): string {
const label =
scoreLabel ??
(score >= 80
? "отлично"
: score >= 65
? "хорошо"
: score >= 45
? "средне"
: "плохо");
const labelUpper = label.toUpperCase();
if (score >= 65) return `ПОДХОДИТ · ${labelUpper}`;
if (score >= 45) return `СРЕДНЕ · ${labelUpper}`;
return `РИСКИ · ${labelUpper}`;
}
// ── Section 1 skeleton (loading state) ───────────────────────────────────────
function Section1Skeleton() {
return (
<section
id="section-1"
style={{
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 12,
padding: 20,
marginBottom: 24,
}}
>
{/* HeadlineBar skeleton */}
<div
style={{
height: 56,
background: "var(--bg-card-alt)",
borderRadius: 12,
marginBottom: 16,
}}
/>
{/* KPI skeleton */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))",
gap: 12,
marginBottom: 16,
}}
>
{[1, 2, 3, 4].map((i) => (
<div
key={i}
style={{
height: 70,
background: "var(--bg-card-alt)",
borderRadius: 10,
}}
/>
))}
</div>
<div
style={{
height: 320,
background: "var(--bg-card-alt)",
borderRadius: 12,
}}
/>
</section>
);
}
// ── Section 1 error state ─────────────────────────────────────────────────────
function Section1Error({ message }: { message: string }) {
return (
<section
id="section-1"
style={{
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 12,
padding: 20,
marginBottom: 24,
}}
>
<h2
style={{
margin: "0 0 8px",
fontSize: 18,
fontWeight: 600,
color: "var(--fg-primary)",
}}
>
1. Объект
</h2>
<p
style={{
margin: 0,
fontSize: 13,
color: "var(--danger)",
}}
role="alert"
>
{message}
</p>
</section>
);
}
// ── Main component ────────────────────────────────────────────────────────────
interface Props {
cad: string;
}
export function Section1ParcelInfo({ cad }: Props) {
const analyzeQuery = useParcelAnalyzeQuery(cad);
const poiQuery = useParcelPoiScoreQuery(cad);
if (analyzeQuery.isLoading) {
return <Section1Skeleton />;
}
if (analyzeQuery.isError || !analyzeQuery.data) {
return (
<Section1Error
message={
analyzeQuery.error instanceof Error
? analyzeQuery.error.message
: "Не удалось загрузить данные участка"
}
/>
);
}
const data = analyzeQuery.data;
const poiData = poiQuery.data;
// EGRN: use response field if B5-extended is deployed, else fallback stub
const egrn: ParcelEgrn =
data.egrn != null ? (data.egrn as ParcelEgrn) : buildFallbackEgrn(cad);
// KPI values
const areaHa =
egrn.area_m2 > 0 ? `${(egrn.area_m2 / 10000).toFixed(2)} га` : "—";
const district = data.district?.district_name ?? "—";
const medianPrice = data.district?.median_price_per_m2
? `${(data.district.median_price_per_m2 / 1000).toFixed(0)} тыс. ₽/м²`
: "—";
const scoreStr = `${data.score}`;
// Score color
const scoreColor =
data.score >= 80
? "green"
: data.score >= 65
? "blue"
: data.score >= 45
? "amber"
: "red";
// HeadlineBar texts
const headlineTitle = scoreVerdict(data.score, data.score_label);
const headlineSubtitle = data.score_explanation
? data.score_explanation
: `Score +12 vs район · ${data.poi_count} POI в радиусе 1 км`;
return (
<section
id="section-1"
style={{
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 12,
padding: 20,
marginBottom: 24,
}}
>
{/* Section title */}
<h2
style={{
margin: "0 0 12px",
fontSize: 18,
fontWeight: 600,
color: "var(--fg-primary)",
}}
>
1. Объект
</h2>
{/* HeadlineBar — verdict */}
<div style={{ marginBottom: 16 }}>
<HeadlineBar title={headlineTitle} subtitle={headlineSubtitle} />
</div>
{/* KPI row — 4 cards */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))",
gap: 12,
marginBottom: 20,
}}
>
<KpiCard value={areaHa} label="Площадь" color="neutral" />
<KpiCard value={district} label="Район" color="neutral" />
<KpiCard value={medianPrice} label="Медиана рынка" color="blue" />
<KpiCard value={scoreStr} label="Инвест-балл" color={scoreColor} />
</div>
{/* 2-column grid: map (left) + EGRN + POI (right) */}
<div
style={{
display: "grid",
gridTemplateColumns: "400px 1fr",
gap: 16,
alignItems: "start",
marginBottom: 20,
}}
>
{/* Left: mini-map */}
<MiniMap data={data} />
{/* Right: EGRN table + POI list */}
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<EgrnPropertyTable data={egrn} />
{poiData ? (
<PoiList2Gis
items={poiData.items}
totalScore={poiData.poi_weighted_score}
/>
) : (
// Fallback: build from analyze score_breakdown
<PoiList2Gis
items={Object.entries(data.score_breakdown).flatMap(
([cat, pois]) =>
pois.slice(0, 2).map((poi) => ({
category: cat,
name: poi.name ?? cat,
distance_m: poi.distance_m,
weight: 0.1,
score_contribution: 5,
})),
)}
totalScore={data.score}
/>
)}
</div>
</div>
{/* Export buttons */}
<div
style={{
paddingTop: 16,
borderTop: "1px solid var(--border-soft)",
}}
>
<ExportButtons cad={cad} analyzeData={data} />
</div>
</section>
);
}

View file

@ -0,0 +1,116 @@
{
"cad_num": "66:41:0701045:42",
"source": "cad_quarter",
"geom_geojson": {
"type": "Polygon",
"coordinates": [
[
[60.5985, 56.8388],
[60.6005, 56.8388],
[60.6005, 56.8402],
[60.5985, 56.8402],
[60.5985, 56.8388]
]
]
},
"district": {
"district_name": "Кировский",
"median_price_per_m2": 128000,
"dist_to_center": 2.4
},
"score": 74,
"score_label": "хорошо",
"score_max_reference": 100,
"score_explanation": "Высокий балл за близость к метро и школам. Умеренная конкуренция. Участок подходит для МКД.",
"poi_count": 24,
"competitors": [],
"noise": {
"score": 68,
"estimated_db": 52,
"level": "умеренный",
"sources": []
},
"air_quality": {
"pm2_5": 8.2,
"pm10": 14.1,
"no2": 21.3,
"ts": "2026-05-18T00:00:00Z",
"source": "OpenMeteo"
},
"wind": null,
"score_breakdown": {
"metro_stop": [
{
"name": "Площадь 1905 года",
"distance_m": 340,
"last_edit": "2024-01",
"lat": 56.8389,
"lon": 60.6012
}
],
"school": [
{
"name": "Школа № 32",
"distance_m": 480,
"last_edit": "2024-03",
"lat": 56.8401,
"lon": 60.598
},
{
"name": "Гимназия № 9",
"distance_m": 820,
"last_edit": "2023-11",
"lat": 56.842,
"lon": 60.601
}
],
"kindergarten": [
{
"name": "Детский сад № 111",
"distance_m": 260,
"last_edit": "2024-02",
"lat": 56.8376,
"lon": 60.5998
}
],
"park": [
{
"name": "Сквер Попова",
"distance_m": 150,
"last_edit": "2024-01",
"lat": 56.8385,
"lon": 60.5972
}
],
"shop_mall": [
{
"name": "МЕГА Екатеринбург",
"distance_m": 1200,
"last_edit": "2023-10",
"lat": 56.845,
"lon": 60.612
}
],
"hospital": [
{
"name": "Городская больница № 7",
"distance_m": 650,
"last_edit": "2024-01",
"lat": 56.841,
"lon": 60.595
}
]
},
"egrn": {
"cad_num": "66:41:0701045:42",
"address": "Свердловская обл., г. Екатеринбург, ул. Ленина, 42",
"area_m2": 8240,
"vri": "Многоэтажная жилая застройка (МКД)",
"category": "Земли населённых пунктов",
"registration_date": "2003-07-15",
"owner_type": "Государственная",
"encumbrance": "Нет",
"status": "Учтённый",
"last_updated": "2025-10-01"
}
}

View file

@ -0,0 +1,55 @@
{
"cad_num": "66:41:0701045:42",
"poi_weighted_score": 76,
"items": [
{
"category": "metro_stop",
"name": "Площадь 1905 года",
"distance_m": 340,
"weight": 0.25,
"score_contribution": 22
},
{
"category": "park",
"name": "Сквер Попова",
"distance_m": 150,
"weight": 0.1,
"score_contribution": 9
},
{
"category": "school",
"name": "Школа № 32",
"distance_m": 480,
"weight": 0.15,
"score_contribution": 11
},
{
"category": "kindergarten",
"name": "Детский сад № 111",
"distance_m": 260,
"weight": 0.12,
"score_contribution": 10
},
{
"category": "shop_mall",
"name": "МЕГА Екатеринбург",
"distance_m": 1200,
"weight": 0.1,
"score_contribution": 7
},
{
"category": "hospital",
"name": "Городская больница № 7",
"distance_m": 650,
"weight": 0.1,
"score_contribution": 8
},
{
"category": "school",
"name": "Гимназия № 9",
"distance_m": 820,
"weight": 0.08,
"score_contribution": 5
}
]
}

View file

@ -2,14 +2,23 @@
* Site Finder API hooks TanStack Query wrappers.
*
* Mock fallback strategy (from mock-toggle.ts):
* MOCK_PARCELS_BBOX uses parcels-bbox.json fixture (B1 not yet in prod)
* MOCK_PARCELS_BBOX uses parcels-bbox.json fixture (B1 not yet in prod)
* MOCK_RECENT_PARCELS uses localStorage only (B2 stub not yet in prod)
* MOCK_ANALYZE uses parcel-analyze.json fixture (B5)
* MOCK_POI_SCORE uses poi-score.json fixture (B6)
*/
import { useQuery } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api";
import { MOCK_PARCELS_BBOX, MOCK_RECENT_PARCELS } from "@/lib/mock-toggle";
import {
MOCK_PARCELS_BBOX,
MOCK_RECENT_PARCELS,
MOCK_ANALYZE,
MOCK_POI_SCORE,
} from "@/lib/mock-toggle";
import fixtureParcels from "@/lib/mocks/parcels-bbox.json";
import fixtureAnalyze from "@/lib/mocks/parcel-analyze.json";
import fixturePoiScore from "@/lib/mocks/poi-score.json";
// ── Types ─────────────────────────────────────────────────────────────────────
@ -205,3 +214,99 @@ export function useRecentParcels() {
staleTime: 60_000,
});
}
// ── Types: B5 extended analyze response ──────────────────────────────────────
export interface ParcelEgrn {
cad_num: string;
address: string;
area_m2: number;
vri: string;
category: string;
registration_date: string | null;
owner_type: string;
encumbrance: string;
status: string;
last_updated: string | null;
}
export interface ParcelAnalyzeResponse {
cad_num: string;
source: string;
geom_geojson: unknown;
district: {
district_name: string;
median_price_per_m2: number;
dist_to_center: number;
} | null;
score: number;
score_label?: string;
score_explanation?: string;
score_breakdown: Record<
string,
Array<{
name: string | null;
distance_m: number;
last_edit: string | null;
lat: number;
lon: number;
}>
>;
poi_count: number;
/** EGRN data — may be null if B5 extended not yet deployed */
egrn?: ParcelEgrn | null;
}
// ── Types: B6 poi-score response ─────────────────────────────────────────────
export interface PoiScoreItem {
category: string;
name: string;
distance_m: number;
weight: number;
score_contribution: number;
}
export interface PoiScoreResponse {
cad_num: string;
poi_weighted_score: number;
items: PoiScoreItem[];
}
// ── Hook: useParcelAnalyzeQuery (B5) ─────────────────────────────────────────
export function useParcelAnalyzeQuery(cad: string) {
return useQuery({
queryKey: ["parcel-analyze", cad],
queryFn: async (): Promise<ParcelAnalyzeResponse> => {
if (MOCK_ANALYZE) {
// Return fixture data regardless of cad — for dev only
return fixtureAnalyze as ParcelAnalyzeResponse;
}
return apiFetch<ParcelAnalyzeResponse>(
`/api/v1/parcels/${encodeURIComponent(cad)}/analyze`,
{ method: "POST" },
);
},
staleTime: 5 * 60_000, // 5 min — analyze is expensive
retry: 1,
});
}
// ── Hook: useParcelPoiScoreQuery (B6) ────────────────────────────────────────
export function useParcelPoiScoreQuery(cad: string) {
return useQuery({
queryKey: ["parcel-poi-score", cad],
queryFn: async (): Promise<PoiScoreResponse> => {
if (MOCK_POI_SCORE) {
return fixturePoiScore as PoiScoreResponse;
}
return apiFetch<PoiScoreResponse>(
`/api/v1/parcels/${encodeURIComponent(cad)}/poi-score`,
);
},
staleTime: 5 * 60_000,
retry: 1,
});
}