"use client"; import { useState, type ReactNode } from "react"; import { X, Building2, MapPin, Users, Ruler } from "lucide-react"; import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel"; import { BestLayoutsBlock } from "@/components/site-finder/BestLayoutsBlock"; import { Pipeline24moBlock } from "@/components/site-finder/Pipeline24moBlock"; import { VelocityBlock } from "@/components/site-finder/VelocityBlock"; import { MarketTrendBlock } from "@/components/site-finder/MarketTrendBlock"; import { CompetitorTable } from "@/components/site-finder/CompetitorTable"; import { Drawer } from "@/components/ui/Drawer"; import { Badge } from "@/components/ui/Badge"; import { POI_DEFAULT_WEIGHTS, type PoiCategoryKey, } from "@/lib/api/weightProfiles"; import type { ParcelAnalysis, ParcelAnalysisCompetitor, } from "@/types/site-finder"; // ── Types ───────────────────────────────────────────────────────────────────── interface Props { /** Cadastral number — passed for context/future backend pass-through. */ cad: string; /** Full analysis data — used for Section 3.2/3.3 placeholders, competitors. */ data: ParcelAnalysis; } interface FilterState { /** Radius in km, 1–5. */ radiusKm: number; /** Show only buildings under construction. */ onlyUnderConstruction: boolean; /** Completion deadline ≥ 3 months from now. */ minDeadline3mo: boolean; /** Project started ≤ 6 months ago. */ maxAge6mo: boolean; /** Apartments only (excludes commercial / parking-only). */ onlyApartments: boolean; } // ── Chip component ───────────────────────────────────────────────────────────── interface ChipProps { label: string; selected: boolean; onToggle: () => void; } function FilterChip({ label, selected, onToggle }: ChipProps) { return ( ); } // ── Section 3.1 «Настройки выборки» ─────────────────────────────────────────── function Section31Settings({ filters, onFiltersChange, }: { filters: FilterState; onFiltersChange: (f: FilterState) => void; }) { const [weights, setWeights] = useState>( () => ({ ...POI_DEFAULT_WEIGHTS }), ); function toggleChip(key: keyof Omit) { onFiltersChange({ ...filters, [key]: !filters[key] }); } function handleWeightsChange( newWeights: Record, _profileId: number | null, ) { setWeights(newWeights); } const chips: Array<{ key: keyof Omit; label: string; }> = [ { key: "onlyUnderConstruction", label: "Только строящиеся" }, { key: "minDeadline3mo", label: "+3 мес срок" }, { key: "maxAge6mo", label: "−6 мес от сегодня" }, { key: "onlyApartments", label: "Только квартиры" }, ]; return (
{/* Sub-section title */}

4.1 Настройки выборки

Фильтры применяются к конкурентам локально — без повторного запроса к бэкенду

{/* Radius slider */}
{filters.radiusKm} км
onFiltersChange({ ...filters, radiusKm: parseFloat(e.target.value), }) } style={{ width: "100%", accentColor: "var(--accent, #1D4ED8)", }} />
1 км 5 км
{/* Filter chips */}
Фильтры объектов
{chips.map(({ key, label }) => ( toggleChip(key)} /> ))}
{/* Weight profile — reuse existing panel */}
Профиль весов POI
); } // ── Section 3.2 «Планировки» ─────────────────────────────────────────────────── function Section32Layouts({ cad }: { cad: string }) { return (

4.2 Планировки

Data-driven ТЗ на планировочный микс — на основе сделок конкурентов в радиусе

); } // ── Section 3.3 «Как продаётся рынок рядом» ─────────────────────────────────── /** Small uppercase label-заголовок над каждым подблоком 3.3. */ function SubBlockLabel({ children }: { children: ReactNode }) { return (
{children}
); } /** * Собирает одно предложение-вердикт для headline-bar из уже доступных данных * velocity / pipeline / market_trend. Возвращает null если данных совсем нет. * * - ratio = темп продаж конкурентов / медиана ЕКБ (м²/мес). * - months_of_inventory — за сколько месяцев при текущем темпе разойдётся * будущее предложение конкурентов (24 мес). Темп в лотах/мес выводим из * velocity.by_room_bucket (units за months_observed); без него клаузу * пропускаем, чтобы не выдумывать цифру. * - price change — market_trend.delta_6m_pct в процентных пунктах. */ function buildVerdict(data: ParcelAnalysis): string | null { const v = data.velocity; const pipeline = data.pipeline_24mo; const trend = data.market_trend; const parts: string[] = []; if (v && v.ekb_median_sqm > 0 && v.velocity_data_available !== false) { const ratio = v.monthly_velocity_sqm / v.ekb_median_sqm; const pace = ratio >= 1 ? "быстрее рынка" : "медленнее рынка"; parts.push( `Конкуренты продают ×${ratio.toFixed(1)} к медиане ЕКБ (${pace})`, ); // Темп в лотах/мес из наблюдённых сделок по комнатности. if ( pipeline && pipeline.flats_total > 0 && v.by_room_bucket && v.months_observed > 0 ) { const unitsSold = Object.values(v.by_room_bucket).reduce( (sum, b) => sum + b.units, 0, ); const lotsPerMonth = unitsSold / v.months_observed; if (lotsPerMonth > 0) { const moi = pipeline.flats_total / lotsPerMonth; parts.push( `при текущем темпе остаток разойдётся за ~${Math.round( moi, ).toLocaleString("ru")} мес`, ); } } } // P2 (#1871): дельту цен добавляем только когда тренд посчитан (status "ok" // или старый ответ без status); при source_stale/no_deals — пропускаем. if ( trend && (trend.status == null || trend.status === "ok") && trend.delta_6m_pct != null ) { const d = trend.delta_6m_pct; const sign = d > 0 ? "+" : d < 0 ? "−" : ""; const abs = Math.abs(d).toLocaleString("ru", { minimumFractionDigits: 1, maximumFractionDigits: 1, }); parts.push(`цены за период ${sign}${abs} п.п.`); } if (parts.length === 0) return null; return `${parts.join("; ")}.`; } function Section33MarketSales({ data }: { data: ParcelAnalysis }) { const hasPipeline = data.pipeline_24mo != null; const hasVelocity = data.velocity != null; const hasTrend = data.market_trend != null; const isEmpty = !hasPipeline && !hasVelocity && !hasTrend; const verdict = buildVerdict(data); return (

4.3 Как продаётся рынок рядом

Остатки конкурентов · темп продаж · динамика цен

{isEmpty ? (
Данные по продажам рынка рядом отсутствуют для этого участка
) : ( <> {/* Headline-bar — один вердикт-предложение */} {verdict && (
{verdict}
)}
{hasVelocity && (
Темп продаж
)} {hasPipeline && (
Будущее предложение конкурентов (24 мес)
)} {hasTrend && (
Динамика цен
)}
)}
); } // ── Competitor detail drawer ─────────────────────────────────────────────────── const STATUS_BADGE_MAP: Record< string, { variant: "success" | "neutral" | "warning"; label: string } > = { Строящиеся: { variant: "warning", label: "Строящийся" }, Сданные: { variant: "success", label: "Сдан" }, }; function CompetitorDetailDrawer({ competitor, onClose, }: { competitor: ParcelAnalysisCompetitor | null; onClose: () => void; }) { const isOpen = competitor !== null; return ( {competitor && ( <> {/* Header */}
{competitor.comm_name ?? `ЖК #${competitor.obj_id}`}
{competitor.dev_name && (
{competitor.dev_name}
)}
{/* Status + badges row */}
{competitor.site_status && ( {STATUS_BADGE_MAP[competitor.site_status]?.label ?? competitor.site_status} )} {competitor.obj_class && ( {competitor.obj_class} )}
{/* KPI grid */}
} label="Расстояние" value={`${Math.round(competitor.distance_m).toLocaleString("ru-RU")} м`} /> } label="Квартир" value={ competitor.flat_count != null ? competitor.flat_count.toLocaleString("ru-RU") : "—" } /> {competitor.district_name && ( } label="Район" value={competitor.district_name} /> )} {competitor.ready_dt && ( } label="Срок сдачи" value={new Date(competitor.ready_dt).toLocaleDateString( "ru-RU", { year: "numeric", month: "2-digit", }, )} /> )}
{/* Caveat */}
Детальные данные (sold%, цены, план. продажи) появятся после расширения B5
)}
); } function KpiCell({ icon, label, value, }: { icon: ReactNode; label: string; value: string; }) { return (
{icon} {label}
{value}
); } // ── Derived: filter competitors client-side ─────────────────────────────────── function applyFilters( data: ParcelAnalysis, filters: FilterState, ): ParcelAnalysis["competitors"] { // Guard: a 202 on-demand-fetch stub (#1001) or older backend may omit the // array entirely — fall back to empty so we render an empty state, not throw. let result = [...(data.competitors ?? [])]; // Radius filter — distance_m is in metres from the parcel centroid result = result.filter((c) => c.distance_m <= filters.radiusKm * 1000); // Note: the competitor type doesn't carry status/date fields yet (B6 extension). // The chips are wired to state but can't filter until backend enriches data. // They're kept in state so backend pass-through (Wave 4) can use them. return result; } // ── Section 3 wrapper ───────────────────────────────────────────────────────── export function Section3SettingsAndCompetitors({ cad, data }: Props) { const [filters, setFilters] = useState({ radiusKm: 2, onlyUnderConstruction: false, minDeadline3mo: false, maxAge6mo: false, onlyApartments: false, }); const [selectedCompetitor, setSelectedCompetitor] = useState(null); const filteredCompetitors = applyFilters(data, filters); // Normalise once — a 202 stub / older backend may omit `competitors` (#1001). const competitorCount = (data.competitors ?? []).length; const districtName = data.district?.district_name ?? null; return (
{/* Section headline bar */}
3. Продажи конкурентов
{competitorCount > 0 ? `${filteredCompetitors.length} из ${competitorCount} объектов в радиусе ${filters.radiusKm} км` : "Конкуренты не найдены в радиусе анализа"}
{/* Sub-sections */}
{/* Competitor table — moved before 3.2/3.3 for context */} {filteredCompetitors.length > 0 && ( )}
{/* Filtered competitors count hint */} {competitorCount > 0 && filteredCompetitors.length !== competitorCount && (
Фильтр по радиусу: показано {filteredCompetitors.length} из{" "} {competitorCount} конкурентов
)} {/* Competitor detail drawer */} setSelectedCompetitor(null)} />
); }