feat(sf-fe-a5): Section 1 "Инфо об участке" — HeadlineBar + KPI + EGRN + POI + Export
- Section1ParcelInfo: wrapper с HeadlineBar вердикт, 4 KpiCard (Площадь/Район/Медиана/Score), 2-col grid (MiniMap + EgrnPropertyTable + PoiList2Gis), ExportButtons. Anchor id="section-1" для scrollspy.
- MiniMap: dynamic import SiteMap (ssr:false), 400×320, адаптер ParcelAnalyzeResponse → ParcelAnalysis.
- EgrnPropertyTable: 10 EGRN полей key-value, monospace для cad_num/area_m2, zebra-stripe.
- PoiList2Gis: top-7 POI weighted score, Lucide иконки (Train/TreePine/GraduationCap/Baby/ShoppingBag/Hospital), distance + weight Badge, fallback из score_breakdown.
- ExportButtons: PDF blob download (B7 /snapshot.pdf) + frontend CSV из analyze response, loading state, error display.
- site-finder-api.ts: useParcelAnalyzeQuery (B5) + useParcelPoiScoreQuery (B6) с MOCK_ANALYZE/MOCK_POI_SCORE fallback.
- mocks: parcel-analyze.json + poi-score.json fixtures для dev.
- page.tsx: Section 1 placeholder заменён на <Section1ParcelInfo cad={cad} />.
This commit is contained in:
parent
6ee20294f2
commit
6fe8d637fe
9 changed files with 1238 additions and 8 deletions
|
|
@ -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 3–4 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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
/**
|
||||
* 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: `${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>
|
||||
);
|
||||
}
|
||||
204
frontend/src/components/site-finder/analysis/ExportButtons.tsx
Normal file
204
frontend/src/components/site-finder/analysis/ExportButtons.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
"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 {
|
||||
// Wrap in quotes if contains comma, quote, or newline
|
||||
const needsQuotes = /[",\n\r]/.test(cell);
|
||||
if (needsQuotes) {
|
||||
return `"${cell.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return cell;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
87
frontend/src/components/site-finder/analysis/MiniMap.tsx
Normal file
87
frontend/src/components/site-finder/analysis/MiniMap.tsx
Normal 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 as ParcelAnalysis["source"]) ?? "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>
|
||||
);
|
||||
}
|
||||
222
frontend/src/components/site-finder/analysis/PoiList2Gis.tsx
Normal file
222
frontend/src/components/site-finder/analysis/PoiList2Gis.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,311 @@
|
|||
"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: 0,
|
||||
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 >= 80) return `ПОДХОДИТ · ${labelUpper}`;
|
||||
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>
|
||||
);
|
||||
}
|
||||
116
frontend/src/lib/mocks/parcel-analyze.json
Normal file
116
frontend/src/lib/mocks/parcel-analyze.json
Normal 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"
|
||||
}
|
||||
}
|
||||
55
frontend/src/lib/mocks/poi-score.json
Normal file
55
frontend/src/lib/mocks/poi-score.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue