gendesign/frontend/src/app/analytics/recommend/page.tsx
lekss361 cc03ffa32d fix(velocity): cross-stack contract + NULL safety per pre-push review
3 pre-push code-reviewer findings fixed:

1. TS union extended ('objective'|'sale_graph'|'rosreestr_fallback')
   + UI conditions handle both objective и sale_graph как valid sources.
2. COALESCE(deals_total_vol_m2, deals_total_count * 45.0) — NULL safety
   for DKP-only rows (vol_m2 nullable, count > 0).
3. room_bucket parking filter — verified false positive (все 5 buckets
   apartments: студия/1/2/3/4+).

Refs: PR #157 pre-push code-reviewer
2026-05-15 09:24:32 +03:00

411 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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<RecommendMixInput>(DEFAULT_INPUT);
const [overrideShares, setOverrideShares] = useState<number[] | null>(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 (
<>
<h1 style={{ margin: "8px 0 4px", fontSize: 22 }}>
Рекомендатор квартирографии
</h1>
<p style={{ margin: "0 0 16px", color: "#5b6066", fontSize: 13 }}>
Уровень 1 rule-based: city-wide ДДУ-сделки Свердл (Rosreestr)
распределение по 5 бакетам комнатности с поправкой на район и класс.
Цены, площади, юниты и выручка считаются по медиане сделок за выбранное
окно.
</p>
<div
style={{
display: "grid",
gridTemplateColumns: "minmax(300px, 360px) 1fr",
gap: 16,
alignItems: "start",
}}
>
<RecommendForm
value={input}
onChange={setInput}
onSubmit={() => mutation.mutate(input)}
isPending={mutation.isPending}
error={errMsg}
/>
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{!data ? (
<div
style={{
background: "#fff",
border: "1px dashed #d1d5db",
borderRadius: 12,
padding: 40,
textAlign: "center",
color: "#73767e",
fontSize: 14,
}}
>
Заполни форму слева и нажми «Рассчитать» чтобы увидеть
рекомендацию.
</div>
) : data.scope.error ? (
<div
style={{
background: "#fef2f2",
border: "1px solid #fecaca",
borderRadius: 12,
padding: 20,
color: "#991b1b",
fontSize: 14,
}}
>
{data.summary.warnings[0] ??
"Не удалось рассчитать рекомендацию."}
</div>
) : (
<>
{/* Headline для CEO — одна строка с тремя главными цифрами */}
{data.summary.headline ? (
<div
style={{
background: "#0f172a",
color: "#e2e8f0",
borderRadius: 12,
padding: "14px 18px",
fontSize: 15,
fontWeight: 500,
lineHeight: 1.4,
}}
>
<div>
💼 <strong>«{data.scope.district}</strong>
{data.scope.target_class
? ` · ${data.scope.target_class}`
: ""}
{input.area_total_m2
? ` · ${input.area_total_m2.toLocaleString("ru")} м²`
: ""}
<strong>»:</strong> {data.summary.headline}
<span
style={{
marginLeft: 8,
fontSize: 11,
color: "#94a3b8",
fontWeight: 400,
whiteSpace: "nowrap",
}}
title="Базовый расчёт — без movements слайдером. KPI ниже live по ползунку цены."
>
(базовая цена)
</span>
</div>
<div
style={{
marginTop: 8,
paddingTop: 8,
borderTop: "1px solid rgba(255,255,255,0.1)",
fontSize: 12,
color: "#94a3b8",
fontWeight: 400,
}}
>
📊
{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 ? "выше" : "ниже"} среднего)`
: ""}
</div>
</div>
) : null}
{/* Velocity panel — главный калькулятор */}
<Section
title="Цена · Темп · Срок · Ликвидность"
subtitle="Двигай слайдер цены — KPI пересчитываются live по эластичности sale_graph. Введи целевой срок в форме слева — система предложит требуемый коэффициент."
>
<RecommendVelocityPanel
scope={data.scope}
derivedRows={derivedRows}
priceFactor={priceFactor}
onPriceFactorChange={setPriceFactor}
targetMonths={input.target_months ?? null}
hasAllocation={hasAllocation}
/>
</Section>
{/* Mix sliders — сразу под KPI/слайдером цены, чтобы при движении
было видно как меняются метрики и графики live. */}
<Section
title="Подстрой распределение под проект"
subtitle="Двигай слайдер любого бакета — остальные авто-нормируются, чтобы сумма = 100%. KPI выше и графики ниже обновятся live."
>
<RecommendShareSliders
bucketLabels={data.buckets.map((b) => b.bucket)}
shares={effectiveShares}
recommended={recommendedShares}
onChange={setOverrideShares}
onReset={() => setOverrideShares(null)}
/>
</Section>
{/* Liquidity chart — кумулятивная кривая продаж */}
{hasAllocation ? (
<Section
title="Кумулятивные продажи (горизонт 36 мес)"
subtitle="Сколько % инвентаря продастся к месяцу X при текущей цене. Пунктир — горизонт 24 мес = liquidity score."
>
<RecommendLiquidityChart
rows={derivedRows}
priceFactor={priceFactor}
elasticity={data.scope.elasticity}
/>
</Section>
) : null}
{/* District-specific KPIs — что отличается между районами:
competitors, sale_graph покрытие, district/class факторы.
Раньше тут были city-wide дубли (Сделок 3815 / Weighted /
Выручка) — все одинаковые независимо от района. */}
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<KpiCard
label="Конкуренты в районе"
value={data.scope.competitors_count.toString()}
hint={
data.scope.competitors_scope === "district+class"
? `строящихся ${data.scope.target_class || ""}`
: data.scope.competitors_scope === "district"
? "строящихся (любого класса)"
: data.scope.competitors_scope === "region"
? "по региону (нет в районе)"
: "нет данных"
}
/>
<KpiCard
label="Покрытие velocity"
value={
data.scope.velocity_source === "objective" ||
data.scope.velocity_source === "sale_graph"
? `${data.scope.velocity_objects} ЖК`
: "fallback"
}
hint={
data.scope.velocity_source === "objective"
? `${data.scope.velocity_observations} точек · objective (свежее)`
: data.scope.velocity_source === "sale_graph"
? `${data.scope.velocity_observations} точек · sale_graph`
: "rosreestr city-wide"
}
/>
<KpiCard
label="Sold% медиана"
value={
data.scope.saturation_median != null
? `${data.scope.saturation_median.toFixed(0)}%`
: "—"
}
hint={
data.scope.saturation_median == null
? "<5 ЖК с данными"
: data.scope.saturation_median > 50
? `зрелый рынок (${data.scope.saturation_n} ЖК)`
: data.scope.saturation_median < 25
? `свежий, мало распродано (${data.scope.saturation_n} ЖК)`
: `умеренная зрелость (${data.scope.saturation_n} ЖК)`
}
/>
<KpiCard
label="Тренд 6 мес"
value={
data.scope.velocity_trend_ratio != null
? `${data.scope.velocity_trend_ratio > 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
? "остывает"
: "стабильный"
}
/>
<KpiCard
label="Class × District"
value={`×${(data.scope.class_multiplier * data.scope.district_factor * data.scope.poi_factor).toFixed(2)}`}
hint={`Class ×${data.scope.class_multiplier.toFixed(2)} · District ×${data.scope.district_factor.toFixed(2)} · POI ×${data.scope.poi_factor.toFixed(2)}`}
/>
</div>
<Section
title="Распределение по бакетам"
subtitle="Серым — рекомендация Rosreestr-сделок. Синим — твоё текущее распределение (можно подвинуть слайдерами выше)."
>
<RecommendBucketsChart
bucketLabels={data.buckets.map((b) => b.bucket)}
recommended={recommendedShares}
current={effectiveShares}
/>
</Section>
{hasAllocation ? (
<Section
title="Выручка по бакетам"
subtitle="Stacked-bar показывает, где деньги. Большой сегмент 80+ м² часто перевешивает мелкие, даже если их доля по штукам мала."
>
<RecommendRevenueChart
bucketLabels={data.buckets.map((b) => b.bucket)}
revenuesRub={derivedRows.map(
(r) => r.effective_revenue_rub,
)}
/>
</Section>
) : null}
<Section
title="Таблица бакетов"
subtitle="p25/median/p75 цен — диапазон рыночной медианы. Цена медиана уже скорректирована на район (district_factor) и класс (class_multiplier)."
>
<RecommendBucketsTable
rows={derivedRows}
hasAllocation={hasAllocation}
priceFactor={priceFactor}
elasticity={data.scope.elasticity}
/>
</Section>
<Section
title="Comparable ЖК"
subtitle="Топ-5 крупнейших ЖК в этом районе того же класса. Клик — drill-in."
>
<RecommendComparables items={data.comparables} />
</Section>
{data.summary.warnings.length > 0 || data.scope.data_caveat ? (
<Section title="Caveats">
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 8,
alignItems: "flex-start",
}}
>
<span
style={{
display: "inline-block",
padding: "4px 10px",
background: "#f1f5f9",
border: "1px solid #e2e8f0",
borderRadius: 6,
fontSize: 11,
color: "#5b6066",
lineHeight: 1.4,
}}
>
{data.scope.data_caveat ??
"Рекомендация основана на городском распределении сделок"}
</span>
</div>
{data.summary.warnings.length > 0 ? (
<ul
style={{
fontSize: 12,
color: "#9a6700",
margin: "8px 0 0",
paddingLeft: 18,
}}
>
{data.summary.warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
) : null}
</Section>
) : null}
</>
)}
</div>
</div>
</>
);
}