diff --git a/frontend/src/app/site-finder/analysis/[cad]/page.tsx b/frontend/src/app/site-finder/analysis/[cad]/page.tsx index 6e1d6d47..e73f256d 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/page.tsx +++ b/frontend/src/app/site-finder/analysis/[cad]/page.tsx @@ -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 */} - + {/* Section 1 — A5: Инфо об участке */} + {/* Section 2 — TODO A6 */} 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 ( +
+
+ ЕГРН +
+ + + + {rows.map((row, i) => ( + + + + + ))} + +
+ {row.label} + + {row.value} +
+
+ ); +} diff --git a/frontend/src/components/site-finder/analysis/ExportButtons.tsx b/frontend/src/components/site-finder/analysis/ExportButtons.tsx new file mode 100644 index 00000000..b0aadf7f --- /dev/null +++ b/frontend/src/components/site-finder/analysis/ExportButtons.tsx @@ -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(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 ( +
+
+ {/* PDF button — secondary (orange per design token) */} + + + {/* CSV button — secondary (orange) */} + +
+ + {/* Error message */} + {error && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/frontend/src/components/site-finder/analysis/MiniMap.tsx b/frontend/src/components/site-finder/analysis/MiniMap.tsx new file mode 100644 index 00000000..93738953 --- /dev/null +++ b/frontend/src/components/site-finder/analysis/MiniMap.tsx @@ -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: () => ( +
+ Загрузка карты... +
+ ), + }, +); + +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 ( +
+
+ +
+
+ ); +} diff --git a/frontend/src/components/site-finder/analysis/PoiList2Gis.tsx b/frontend/src/components/site-finder/analysis/PoiList2Gis.tsx new file mode 100644 index 00000000..0cd5f54f --- /dev/null +++ b/frontend/src/components/site-finder/analysis/PoiList2Gis.tsx @@ -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 = { + metro_stop: , + tram_stop: , + bus_stop: , + park: , + school: , + kindergarten: , + shop_mall: , + shop_supermarket: , + shop_small: , + hospital: , + pharmacy: , + bank: , +}; + +const CATEGORY_LABELS: Record = { + 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 ( +
+ POI данные недоступны +
+ ); + } + + return ( +
+ {/* Header */} +
+ + POI · 2ГИС / OSM + + + {totalScore.toFixed(0)} / 100 + +
+ + {/* List */} +
    + {top7.map((item, i) => { + const icon = CATEGORY_ICONS[item.category] ?? ( + + ); + const categoryLabel = CATEGORY_LABELS[item.category] ?? item.category; + const isLast = i === top7.length - 1; + + return ( +
  • + {/* Icon */} + + {icon} + + + {/* Name + category */} +
    +
    + {item.name} +
    +
    + {categoryLabel} +
    +
    + + {/* Distance */} + + {item.distance_m < 1000 + ? `${Math.round(item.distance_m)} м` + : `${(item.distance_m / 1000).toFixed(1)} км`} + + + {/* Weight badge */} + + ×{item.weight.toFixed(2)} + +
  • + ); + })} +
+
+ ); +} diff --git a/frontend/src/components/site-finder/analysis/Section1ParcelInfo.tsx b/frontend/src/components/site-finder/analysis/Section1ParcelInfo.tsx new file mode 100644 index 00000000..82b482d2 --- /dev/null +++ b/frontend/src/components/site-finder/analysis/Section1ParcelInfo.tsx @@ -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 ( +
+ {/* HeadlineBar skeleton */} +
+ {/* KPI skeleton */} +
+ {[1, 2, 3, 4].map((i) => ( +
+ ))} +
+
+
+ ); +} + +// ── Section 1 error state ───────────────────────────────────────────────────── + +function Section1Error({ message }: { message: string }) { + return ( +
+

+ 1. Объект +

+

+ {message} +

+
+ ); +} + +// ── Main component ──────────────────────────────────────────────────────────── + +interface Props { + cad: string; +} + +export function Section1ParcelInfo({ cad }: Props) { + const analyzeQuery = useParcelAnalyzeQuery(cad); + const poiQuery = useParcelPoiScoreQuery(cad); + + if (analyzeQuery.isLoading) { + return ; + } + + if (analyzeQuery.isError || !analyzeQuery.data) { + return ( + + ); + } + + 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 title */} +

+ 1. Объект +

+ + {/* HeadlineBar — verdict */} +
+ +
+ + {/* KPI row — 4 cards */} +
+ + + + +
+ + {/* 2-column grid: map (left) + EGRN + POI (right) */} +
+ {/* Left: mini-map */} + + + {/* Right: EGRN table + POI list */} +
+ + + {poiData ? ( + + ) : ( + // Fallback: build from analyze score_breakdown + + 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} + /> + )} +
+
+ + {/* Export buttons */} +
+ +
+
+ ); +} diff --git a/frontend/src/lib/mocks/parcel-analyze.json b/frontend/src/lib/mocks/parcel-analyze.json new file mode 100644 index 00000000..0012bc05 --- /dev/null +++ b/frontend/src/lib/mocks/parcel-analyze.json @@ -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" + } +} diff --git a/frontend/src/lib/mocks/poi-score.json b/frontend/src/lib/mocks/poi-score.json new file mode 100644 index 00000000..f0b910f7 --- /dev/null +++ b/frontend/src/lib/mocks/poi-score.json @@ -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 + } + ] +} diff --git a/frontend/src/lib/site-finder-api.ts b/frontend/src/lib/site-finder-api.ts index 1139e619..c0a6a70f 100644 --- a/frontend/src/lib/site-finder-api.ts +++ b/frontend/src/lib/site-finder-api.ts @@ -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 => { + if (MOCK_ANALYZE) { + // Return fixture data regardless of cad — for dev only + return fixtureAnalyze as ParcelAnalyzeResponse; + } + return apiFetch( + `/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 => { + if (MOCK_POI_SCORE) { + return fixturePoiScore as PoiScoreResponse; + } + return apiFetch( + `/api/v1/parcels/${encodeURIComponent(cad)}/poi-score`, + ); + }, + staleTime: 5 * 60_000, + retry: 1, + }); +}