"use client"; import { useEffect, useMemo, useState } from "react"; import { KpiCard } from "@/components/analytics/KpiCard"; import { RecommendBucketsChart } from "@/components/analytics/RecommendBucketsChart"; import { RecommendBucketsTable } from "@/components/analytics/RecommendBucketsTable"; import { RecommendComparables } from "@/components/analytics/RecommendComparables"; import { RecommendForm } from "@/components/analytics/RecommendForm"; import { RecommendLiquidityChart } from "@/components/analytics/RecommendLiquidityChart"; import { RecommendRevenueChart } from "@/components/analytics/RecommendRevenueChart"; import { RecommendShareSliders } from "@/components/analytics/RecommendShareSliders"; import { RecommendVelocityPanel } from "@/components/analytics/RecommendVelocityPanel"; import { Section } from "@/components/analytics/Section"; import { useRecommendMix } from "@/lib/analytics-api"; import type { RecommendMixInput } from "@/types/analytics"; const DEFAULT_INPUT: RecommendMixInput = { district_name: "", area_total_m2: null, target_class: null, months_window: 24, price_factor: 1.0, target_months: null, }; export default function RecommendPage() { const [input, setInput] = useState(DEFAULT_INPUT); const [overrideShares, setOverrideShares] = useState(null); // priceFactor живёт отдельно от input.price_factor, чтобы слайдер двигался // мгновенно (client-side recompute), а не дёргал API на каждое движение. const [priceFactor, setPriceFactor] = useState(1.0); const mutation = useRecommendMix(); const data = mutation.data; // reset slider override whenever a fresh API response arrives useEffect(() => { if (data) { setOverrideShares(null); setPriceFactor(1.0); } }, [data]); const recommendedShares = useMemo( () => (data ? data.buckets.map((b) => b.share_pct) : []), [data], ); const effectiveShares = overrideShares ?? recommendedShares; const derivedRows = useMemo(() => { if (!data) return []; return data.buckets.map((b, i) => { const share = effectiveShares[i] ?? b.share_pct; const areaTotal = input.area_total_m2 ?? null; let units: number | null = null; let revenue: number | null = null; if (areaTotal && b.area_avg_m2 > 0) { const allocated = areaTotal * (share / 100); units = Math.max(1, Math.round(allocated / b.area_avg_m2)); revenue = Math.round(units * b.area_avg_m2 * b.price_median_per_m2); } return { ...b, effective_share_pct: share, effective_units: units, effective_revenue_rub: revenue, }; }); }, [data, effectiveShares, input.area_total_m2]); // totalRevenue и weightedAvgPrice сейчас уже отображаются внутри // RecommendVelocityPanel и headline summary — здесь больше не считаем, // чтобы не дублировать вычисления и держать одну точку правды для KPI. const errMsg = mutation.error ? mutation.error instanceof Error ? mutation.error.message : String(mutation.error) : null; const hasAllocation = !!input.area_total_m2; return ( <>

Рекомендатор квартирографии

Уровень 1 — rule-based: city-wide ДДУ-сделки Свердл (Rosreestr) → распределение по 5 бакетам комнатности с поправкой на район и класс. Цены, площади, юниты и выручка считаются по медиане сделок за выбранное окно.

mutation.mutate(input)} isPending={mutation.isPending} error={errMsg} />
{!data ? (
Заполни форму слева и нажми «Рассчитать» чтобы увидеть рекомендацию.
) : data.scope.error ? (
{data.summary.warnings[0] ?? "Не удалось рассчитать рекомендацию."}
) : ( <> {/* Headline для CEO — одна строка с тремя главными цифрами */} {data.summary.headline ? (
💼 «{data.scope.district} {data.scope.target_class ? ` · ${data.scope.target_class}` : ""} {input.area_total_m2 ? ` · ${input.area_total_m2.toLocaleString("ru")} м²` : ""} »: {data.summary.headline} (базовая цена)
📊 {data.scope.mortgage_rate_pct != null ? ` Средневзв. ИЖК ${data.scope.mortgage_rate_pct.toFixed(2)}% (${data.scope.mortgage_rate_period}, со льготами; рыночная ~20%)` : " ставка ЦБ нет данных"} {data.scope.poi_score != null && data.scope.poi_score_city_avg != null ? ` · POI ${data.scope.poi_score.toFixed(0)}/${data.scope.poi_score_city_avg.toFixed(0)} (${data.scope.poi_score > data.scope.poi_score_city_avg ? "выше" : "ниже"} среднего)` : ""}
) : null} {/* Velocity panel — главный калькулятор */}
{/* Mix sliders — сразу под KPI/слайдером цены, чтобы при движении было видно как меняются метрики и графики live. */}
b.bucket)} shares={effectiveShares} recommended={recommendedShares} onChange={setOverrideShares} onReset={() => setOverrideShares(null)} />
{/* Liquidity chart — кумулятивная кривая продаж */} {hasAllocation ? (
) : null} {/* District-specific KPIs — что отличается между районами: competitors, sale_graph покрытие, district/class факторы. Раньше тут были city-wide дубли (Сделок 3815 / Weighted / Выручка) — все одинаковые независимо от района. */}
50 ? `зрелый рынок (${data.scope.saturation_n} ЖК)` : data.scope.saturation_median < 25 ? `свежий, мало распродано (${data.scope.saturation_n} ЖК)` : `умеренная зрелость (${data.scope.saturation_n} ЖК)` } /> 1 ? "🚀" : data.scope.velocity_trend_ratio < 0.8 ? "❄" : "→"} ×${data.scope.velocity_trend_ratio.toFixed(2)}` : "—" } hint={ data.scope.velocity_trend_ratio == null ? "нет sale_graph" : data.scope.velocity_trend_ratio > 1.5 ? "рынок ускоряется" : data.scope.velocity_trend_ratio < 0.8 ? "остывает" : "стабильный" } />
b.bucket)} recommended={recommendedShares} current={effectiveShares} />
{hasAllocation ? (
b.bucket)} revenuesRub={derivedRows.map( (r) => r.effective_revenue_rub, )} />
) : null}
{data.summary.warnings.length > 0 || data.scope.data_caveat ? (
{data.scope.data_caveat ?? "Рекомендация основана на городском распределении сделок"}
{data.summary.warnings.length > 0 ? (
    {data.summary.warnings.map((w, i) => (
  • {w}
  • ))}
) : null}
) : null} )}
); }