add rec ui

This commit is contained in:
lekss361 2026-04-27 21:38:37 +03:00
parent ecc0dbafd5
commit 62659887e9
13 changed files with 1027 additions and 50 deletions

View file

@ -970,6 +970,58 @@ _BUCKET_PRETTY: dict[str, str] = {
}
_BUCKET_SQL = text(
"""
WITH bucketed AS (
SELECT CASE
WHEN area < 30 THEN '1-Студия'
WHEN area < 45 THEN '2-1-к'
WHEN area < 60 THEN '3-2-к'
WHEN area < 80 THEN '4-3-к'
ELSE '5-80+ м²'
END AS bucket,
area,
price_per_sqm
FROM rosreestr_deals
WHERE region_code = :rc
AND doc_type = 'ДДУ'
-- realestate_type_code 002001003000 = квартиры (жилые помещения).
-- 001 = земельные участки, 002 = нежилые помещения.
AND realestate_type_code = '002001003000'
AND area > 10
AND area <= 200 -- отсечь выбросы (коммерческие площади)
AND price_per_sqm BETWEEN 30000 AND 1000000
AND period_start_date >= NOW()
- (:months_window || ' months')::INTERVAL
)
SELECT bucket,
COUNT(*)::bigint AS deals,
AVG(area) AS area_avg,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY area) AS area_median,
PERCENTILE_CONT(0.5) WITHIN GROUP
(ORDER BY price_per_sqm) AS price_median,
PERCENTILE_CONT(0.25) WITHIN GROUP
(ORDER BY price_per_sqm) AS price_p25,
PERCENTILE_CONT(0.75) WITHIN GROUP
(ORDER BY price_per_sqm) AS price_p75
FROM bucketed
GROUP BY bucket
ORDER BY bucket
"""
)
def _bucket_distribution(db: Session, region_code: int, months_window: int) -> list[Any]:
return list(
db.execute(
_BUCKET_SQL,
{"rc": region_code, "months_window": months_window},
)
.mappings()
.all()
)
def recommend_mix(
db: Session,
*,
@ -998,6 +1050,7 @@ def recommend_mix(
median_price_per_m2, mean_price_per_m2
FROM ekb_districts
WHERE district_name ILIKE :dn
AND district_name <> 'не определён'
LIMIT 1
"""
),
@ -1073,53 +1126,20 @@ def recommend_mix(
" коэффициент класса = 1.0"
)
# 4) Bucket distribution from rosreestr_deals — city-wide, last N months
bucket_rows = (
db.execute(
text(
"""
WITH bucketed AS (
SELECT CASE
WHEN area < 30 THEN '1-Студия'
WHEN area < 45 THEN '2-1-к'
WHEN area < 60 THEN '3-2-к'
WHEN area < 80 THEN '4-3-к'
ELSE '5-80+ м²'
END AS bucket,
area,
price_per_sqm
FROM rosreestr_deals
WHERE region_code = :rc
AND doc_type = 'ДДУ'
-- realestate_type_code 002001003000 = квартиры (жилые помещения).
-- 001 = земельные участки, 002 = нежилые помещения.
AND realestate_type_code = '002001003000'
AND area > 10
AND area <= 200 -- отсечь выбросы (коммерческие площади)
AND price_per_sqm BETWEEN 30000 AND 1000000
AND period_start_date >= NOW()
- (:months_window || ' months')::INTERVAL
)
SELECT bucket,
COUNT(*)::bigint AS deals,
AVG(area) AS area_avg,
PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY area) AS area_median,
PERCENTILE_CONT(0.5) WITHIN GROUP
(ORDER BY price_per_sqm) AS price_median,
PERCENTILE_CONT(0.25) WITHIN GROUP
(ORDER BY price_per_sqm) AS price_p25,
PERCENTILE_CONT(0.75) WITHIN GROUP
(ORDER BY price_per_sqm) AS price_p75
FROM bucketed
GROUP BY bucket
ORDER BY bucket
"""
),
{"rc": region_code, "months_window": months_window},
)
.mappings()
.all()
)
# 4) Bucket distribution from rosreestr_deals — city-wide, last N months.
# Если в каком-либо бакете <30 сделок и окно < 24 мес, расширяем до 24 мес
# для всех бакетов и проставляем warning. Это даёт более устойчивые медианы.
bucket_rows = _bucket_distribution(db, region_code, months_window)
effective_window = months_window
if months_window < 24 and bucket_rows and any(int(r["deals"] or 0) < 30 for r in bucket_rows):
bucket_rows_24 = _bucket_distribution(db, region_code, 24)
if bucket_rows_24:
bucket_rows = bucket_rows_24
effective_window = 24
warnings.append(
f"Окно расширено до 24 мес: при {months_window} мес хотя бы один"
" бакет имел <30 сделок — оценка была бы шумной"
)
total_deals = sum(int(r["deals"] or 0) for r in bucket_rows) or 1
# 5) Build buckets with adjusted prices + optional allocation
@ -1158,7 +1178,7 @@ def recommend_mix(
if deals < 30:
warnings.append(
f"Бакет '{_BUCKET_PRETTY.get(bid, bid)}': только {deals} сделок"
f" за {months_window} мес — оценка с большой погрешностью"
f" за {effective_window} мес — оценка с большой погрешностью"
)
buckets.append(
@ -1219,6 +1239,7 @@ def recommend_mix(
"class_multiplier": round(class_multiplier, 4),
"target_class": target_class,
"months_window": months_window,
"effective_window_months": effective_window,
"region_code": region_code,
"total_deals": total_deals if bucket_rows else 0,
"data_caveat": (

View file

@ -0,0 +1,266 @@
"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 { RecommendRevenueChart } from "@/components/analytics/RecommendRevenueChart";
import { RecommendShareSliders } from "@/components/analytics/RecommendShareSliders";
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: 12,
};
export default function RecommendPage() {
const [input, setInput] = useState<RecommendMixInput>(DEFAULT_INPUT);
const [overrideShares, setOverrideShares] = useState<number[] | null>(null);
const mutation = useRecommendMix();
const data = mutation.data;
// reset slider override whenever a fresh API response arrives
useEffect(() => {
if (data) setOverrideShares(null);
}, [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]);
const totalRevenue = derivedRows.reduce(
(a, r) => a + (r.effective_revenue_rub ?? 0),
0,
);
const weightedAvgPrice = useMemo(() => {
let num = 0;
let den = 0;
for (const r of derivedRows) {
const w = r.effective_share_pct * r.area_avg_m2;
num += w * r.price_median_per_m2;
den += w;
}
return den > 0 ? num / den : null;
}, [derivedRows]);
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>
) : (
<>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
<KpiCard label="Район" value={data.scope.district} />
<KpiCard
label="Сделок в выборке"
value={data.scope.total_deals.toString()}
hint={`окно ${data.scope.effective_window_months} мес`}
/>
<KpiCard
label="Weighted ₽/м²"
value={
weightedAvgPrice
? Math.round(weightedAvgPrice).toLocaleString("ru")
: "—"
}
unit="₽"
/>
<KpiCard
label="Выручка"
value={
hasAllocation
? `${(totalRevenue / 1_000_000).toFixed(1)}`
: "—"
}
unit="млн ₽"
hint={
hasAllocation
? "при текущем распределении"
: "укажи площадь, чтобы посчитать"
}
/>
</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="Двигай слайдер любого бакета — остальные авто-нормируются, чтобы сумма = 100%. Цифры в таблице и графиках обновятся live."
>
<RecommendShareSliders
bucketLabels={data.buckets.map((b) => b.bucket)}
shares={effectiveShares}
recommended={recommendedShares}
onChange={setOverrideShares}
onReset={() => setOverrideShares(null)}
/>
</Section>
<Section
title="Таблица бакетов"
subtitle="p25/median/p75 цен — диапазон рыночной медианы. Цена медиана уже скорректирована на район (district_factor) и класс (class_multiplier)."
>
<RecommendBucketsTable
rows={derivedRows}
hasAllocation={hasAllocation}
/>
</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">
{data.scope.data_caveat ? (
<p
style={{
fontSize: 12,
color: "#5b6066",
marginTop: 0,
}}
>
{data.scope.data_caveat}
</p>
) : null}
{data.summary.warnings.length > 0 ? (
<ul
style={{
fontSize: 12,
color: "#9a6700",
margin: 0,
paddingLeft: 18,
}}
>
{data.summary.warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
) : null}
</Section>
) : null}
</>
)}
</div>
</div>
</>
);
}

View file

@ -30,6 +30,11 @@ const SECTIONS: { title: string; routes: Route[] }[] = [
label: "Карточка ЖК (пример: Парк Победы)",
desc: "KPI · sale-chart · POI-таблица · фото-галерея. /analytics/objects/{obj_id}.",
},
{
href: "/analytics/recommend",
label: "Рекомендатор квартирографии",
desc: "Район + площадь + класс → распределение по 5 бакетам, цены p25/median/p75, выручка, comparable ЖК. Слайдеры под проект.",
},
],
},
{

View file

@ -7,6 +7,7 @@ const TABS = [
{ href: "/analytics", label: "Свердл рынок" },
{ href: "/analytics/prinzip", label: "PRINZIP" },
{ href: "/analytics/developers", label: "Девелоперы" },
{ href: "/analytics/recommend", label: "Рекомендатор" },
];
export function AnalyticsNav() {

View file

@ -0,0 +1,63 @@
"use client";
import { useMemo } from "react";
import { ChartShell } from "./ChartShell";
interface Props {
bucketLabels: string[];
recommended: number[];
current: number[];
}
export function RecommendBucketsChart({
bucketLabels,
recommended,
current,
}: Props) {
const option = useMemo(
() => ({
tooltip: {
trigger: "axis",
axisPointer: { type: "shadow" },
valueFormatter: (v: unknown) =>
typeof v === "number" ? `${v.toFixed(1)}%` : "—",
},
legend: { data: ["Рекомендация", "Ваше распределение"], top: 0 },
grid: { left: 100, right: 24, top: 32, bottom: 32 },
xAxis: {
type: "value",
max: 100,
axisLabel: { formatter: "{value}%" },
},
yAxis: { type: "category", data: bucketLabels, inverse: true },
series: [
{
name: "Рекомендация",
type: "bar",
data: recommended,
itemStyle: { color: "#cbd5e1" },
barGap: "-100%",
z: 1,
},
{
name: "Ваше распределение",
type: "bar",
data: current,
itemStyle: { color: "#1d4ed8" },
z: 2,
label: {
show: true,
position: "right",
formatter: (p: { value: number }) =>
p.value > 0 ? `${p.value.toFixed(1)}%` : "",
fontSize: 11,
},
},
],
}),
[bucketLabels, recommended, current],
);
return <ChartShell option={option} height={280} notMerge />;
}

View file

@ -0,0 +1,91 @@
"use client";
import type { RecommendBucket } from "@/types/analytics";
interface DerivedBucket extends RecommendBucket {
effective_share_pct: number;
effective_units: number | null;
effective_revenue_rub: number | null;
}
interface Props {
rows: DerivedBucket[];
hasAllocation: boolean;
}
const fmtInt = (n: number | null | undefined) =>
n == null ? "—" : Math.round(n).toLocaleString("ru");
const fmtMln = (rub: number | null | undefined) =>
rub == null ? "—" : `${(rub / 1_000_000).toFixed(1)} млн ₽`;
export function RecommendBucketsTable({ rows, hasAllocation }: Props) {
return (
<div style={{ overflowX: "auto" }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: 13,
}}
>
<thead>
<tr style={{ background: "#f6f7f9" }}>
{[
"Бакет",
"Доля",
"Сделок",
"Площадь ср., м²",
"Цена p25, ₽/м²",
"Цена медиана, ₽/м²",
"Цена p75, ₽/м²",
...(hasAllocation ? ["Юнитов", "Выручка"] : []),
].map((h) => (
<th
key={h}
style={{
padding: "8px 10px",
textAlign: "left",
fontWeight: 600,
borderBottom: "1px solid #e6e8ec",
}}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr
key={r.bucket}
style={{
borderBottom: "1px solid #eef0f3",
background: i % 2 ? "#fafbfc" : "#fff",
}}
>
<td style={td}>
<strong>{r.bucket}</strong>
</td>
<td style={td}>{r.effective_share_pct.toFixed(1)}%</td>
<td style={td}>{fmtInt(r.deal_count)}</td>
<td style={td}>{r.area_avg_m2.toFixed(1)}</td>
<td style={td}>{fmtInt(r.price_p25_per_m2)}</td>
<td style={{ ...td, fontWeight: 600 }}>
{fmtInt(r.price_median_per_m2)}
</td>
<td style={td}>{fmtInt(r.price_p75_per_m2)}</td>
{hasAllocation ? (
<>
<td style={td}>{fmtInt(r.effective_units)}</td>
<td style={td}>{fmtMln(r.effective_revenue_rub)}</td>
</>
) : null}
</tr>
))}
</tbody>
</table>
</div>
);
}
const td: React.CSSProperties = { padding: "8px 10px" };

View file

@ -0,0 +1,86 @@
"use client";
import Link from "next/link";
import type { RecommendComparable } from "@/types/analytics";
export function RecommendComparables({
items,
}: {
items: RecommendComparable[];
}) {
if (items.length === 0) {
return (
<p style={{ color: "#9ca3af", fontSize: 13 }}>
Нет comparable ЖК с такими фильтрами.
</p>
);
}
return (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))",
gap: 12,
}}
>
{items.map((c) => {
const sold = c.sold_perc;
const soldColor =
sold == null
? "#9ca3af"
: sold >= 50
? "#0a7a3a"
: sold >= 30
? "#9a6700"
: "#b3261e";
return (
<Link
key={c.obj_id}
href={`/analytics/objects/${c.obj_id}`}
style={{
border: "1px solid #e6e8ec",
borderRadius: 8,
padding: 14,
background: "#fff",
textDecoration: "none",
color: "inherit",
display: "block",
transition: "border-color 120ms",
}}
>
<div
style={{
fontSize: 14,
fontWeight: 600,
marginBottom: 6,
lineHeight: 1.2,
}}
>
{c.comm_name ?? `ЖК #${c.obj_id}`}
</div>
<div style={{ fontSize: 12, color: "#5b6066", marginBottom: 8 }}>
{c.dev_name ?? "—"}
{c.obj_class ? ` · ${c.obj_class}` : ""}
</div>
<div
style={{
display: "flex",
gap: 12,
alignItems: "center",
fontSize: 13,
}}
>
<span>
Квартир: <strong>{c.flat_count ?? "—"}</strong>
</span>
<span style={{ color: soldColor, fontWeight: 600 }}>
Sold: {sold == null ? "—" : `${sold.toFixed(0)}%`}
</span>
</div>
</Link>
);
})}
</div>
);
}

View file

@ -0,0 +1,197 @@
"use client";
import { useDistricts } from "@/lib/analytics-api";
import type { RecommendClass, RecommendMixInput } from "@/types/analytics";
const CLASSES: RecommendClass[] = ["Comfort", "Comfort+", "Business", "Elite"];
const MONTHS_OPTIONS = [12, 18, 24];
interface Props {
value: RecommendMixInput;
onChange: (next: RecommendMixInput) => void;
onSubmit: () => void;
isPending: boolean;
error: string | null;
}
export function RecommendForm({
value,
onChange,
onSubmit,
isPending,
error,
}: Props) {
const districts = useDistricts();
const districtOptions = (districts.data ?? []).filter(
(d) => d.district_name !== "не определён",
);
const valid = value.district_name.length >= 2;
return (
<form
onSubmit={(e) => {
e.preventDefault();
if (valid && !isPending) onSubmit();
}}
style={{
background: "#fff",
border: "1px solid #e6e8ec",
borderRadius: 12,
padding: 20,
display: "flex",
flexDirection: "column",
gap: 12,
}}
>
<h2 style={{ margin: 0, fontSize: 18 }}>Параметры участка</h2>
<label>
<span style={labelStyle}>Район ЕКБ</span>
<select
value={value.district_name}
onChange={(e) =>
onChange({ ...value, district_name: e.target.value })
}
style={inputStyle}
required
>
<option value=""> выберите район </option>
{districtOptions.map((d) => (
<option key={d.district_name} value={d.district_name}>
{d.district_name} · {d.zk_count} ЖК ·{" "}
{d.flat_count?.toLocaleString("ru")} кв
</option>
))}
</select>
</label>
<label>
<span style={labelStyle}>Площадь продаваемых м² (опц.)</span>
<input
type="number"
inputMode="numeric"
min={100}
max={500_000}
step={100}
placeholder="50000"
value={value.area_total_m2 ?? ""}
onChange={(e) =>
onChange({
...value,
area_total_m2:
e.target.value === "" ? null : Number(e.target.value),
})
}
style={inputStyle}
/>
<span style={hintStyle}>
Без этого поля посчитаем только распределение и цены, без
allocation/выручки.
</span>
</label>
<label>
<span style={labelStyle}>Класс ЖК (опц.)</span>
<select
value={value.target_class ?? ""}
onChange={(e) =>
onChange({
...value,
target_class:
e.target.value === ""
? null
: (e.target.value as RecommendClass),
})
}
style={inputStyle}
>
<option value=""> любой </option>
{CLASSES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</label>
<label>
<span style={labelStyle}>Окно сделок</span>
<select
value={value.months_window}
onChange={(e) =>
onChange({ ...value, months_window: Number(e.target.value) })
}
style={inputStyle}
>
{MONTHS_OPTIONS.map((m) => (
<option key={m} value={m}>
последние {m} мес
</option>
))}
</select>
<span style={hintStyle}>
При &lt;30 сделок в любом бакете окно автоматически расширяется до 24
мес.
</span>
</label>
<button
type="submit"
disabled={!valid || isPending}
style={primaryBtn(valid && !isPending)}
>
{isPending ? "Считаю…" : "Рассчитать"}
</button>
{error ? (
<div
style={{
padding: 10,
background: "#fef2f2",
border: "1px solid #fecaca",
borderRadius: 6,
color: "#991b1b",
fontSize: 13,
}}
>
{error}
</div>
) : null}
</form>
);
}
const labelStyle: React.CSSProperties = {
display: "block",
fontSize: 12,
color: "#5b6066",
marginBottom: 4,
textTransform: "uppercase",
letterSpacing: 0.4,
};
const inputStyle: React.CSSProperties = {
padding: "8px 10px",
border: "1px solid #d1d5db",
borderRadius: 6,
fontSize: 14,
width: "100%",
boxSizing: "border-box",
};
const hintStyle: React.CSSProperties = {
display: "block",
fontSize: 11,
color: "#73767e",
marginTop: 4,
};
const primaryBtn = (enabled: boolean): React.CSSProperties => ({
padding: "10px 18px",
background: enabled ? "#1d4ed8" : "#9ca3af",
color: "#fff",
border: "none",
borderRadius: 6,
fontSize: 14,
fontWeight: 600,
cursor: enabled ? "pointer" : "not-allowed",
marginTop: 4,
});

View file

@ -0,0 +1,48 @@
"use client";
import { useMemo } from "react";
import { ChartShell } from "./ChartShell";
interface Props {
bucketLabels: string[];
revenuesRub: (number | null)[];
}
const COLORS = ["#94a3b8", "#1d4ed8", "#0891b2", "#0a7a3a", "#c2410c"];
export function RecommendRevenueChart({ bucketLabels, revenuesRub }: Props) {
const option = useMemo(() => {
const valuesMln = revenuesRub.map((v) =>
v == null ? 0 : Math.round((v / 1_000_000) * 10) / 10,
);
const total = valuesMln.reduce((a, b) => a + b, 0);
return {
tooltip: {
trigger: "axis",
valueFormatter: (v: unknown) =>
typeof v === "number" ? `${v.toFixed(1)} млн ₽` : "—",
},
grid: { left: 60, right: 32, top: 24, bottom: 32 },
xAxis: { type: "value", axisLabel: { formatter: "{value} млн" } },
yAxis: { type: "category", data: ["Выручка"] },
series: bucketLabels.map((label, i) => ({
name: label,
type: "bar",
stack: "rev",
data: [valuesMln[i] ?? 0],
itemStyle: { color: COLORS[i % COLORS.length] },
emphasis: { focus: "series" },
label: {
show: (valuesMln[i] ?? 0) >= total * 0.05,
formatter: (p: { value: number; seriesName: string }) =>
`${p.seriesName}\n${p.value.toFixed(1)} млн`,
color: "#fff",
fontSize: 11,
},
})),
};
}, [bucketLabels, revenuesRub]);
return <ChartShell option={option} height={120} notMerge />;
}

View file

@ -0,0 +1,127 @@
"use client";
interface Props {
bucketLabels: string[];
shares: number[]; // длина = bucketLabels.length, сумма ≈ 100
recommended: number[]; // оригинальные share_pct из API
onChange: (next: number[]) => void;
onReset: () => void;
}
/**
* Auto-rescale slider group. Когда пользователь двигает один слайдер до X%,
* остальные пересчитываются пропорционально к (100 - X)%, сохраняя их
* относительные доли. Если у других нет массы (sum=0) распределяем поровну.
*/
export function RecommendShareSliders({
bucketLabels,
shares,
recommended,
onChange,
onReset,
}: Props) {
const handle = (idx: number, raw: number) => {
const v = Math.max(0, Math.min(100, raw));
const next = [...shares];
next[idx] = v;
const remaining = 100 - v;
const others = shares.filter((_, i) => i !== idx);
const otherSum = others.reduce((a, b) => a + b, 0);
if (otherSum <= 0) {
const each = remaining / others.length;
for (let i = 0; i < next.length; i++) {
if (i !== idx) next[i] = each;
}
} else {
for (let i = 0; i < next.length; i++) {
if (i !== idx) next[i] = (shares[i] * remaining) / otherSum;
}
}
// round to 1 decimal, fix sum-rounding drift on the changed slider
const rounded = next.map((x) => Math.round(x * 10) / 10);
const drift = 100 - rounded.reduce((a, b) => a + b, 0);
rounded[idx] = Math.round((rounded[idx] + drift) * 10) / 10;
onChange(rounded);
};
const dirty = shares.some(
(s, i) => Math.abs(s - (recommended[i] ?? 0)) > 0.05,
);
return (
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: 8,
}}
>
<span style={{ fontSize: 13, color: "#5b6066" }}>
Сумма: {shares.reduce((a, b) => a + b, 0).toFixed(1)} %
</span>
<button
onClick={onReset}
disabled={!dirty}
style={{
padding: "4px 10px",
fontSize: 12,
border: "1px solid #d1d5db",
borderRadius: 12,
background: dirty ? "#fff" : "#f6f7f9",
color: dirty ? "#1f2937" : "#9ca3af",
cursor: dirty ? "pointer" : "not-allowed",
}}
>
Сбросить к рекомендации
</button>
</div>
<div style={{ display: "grid", gap: 12 }}>
{bucketLabels.map((label, i) => {
const recValue = recommended[i] ?? 0;
const v = shares[i] ?? 0;
const delta = v - recValue;
return (
<div
key={label}
style={{
display: "grid",
gridTemplateColumns: "140px 1fr 90px",
gap: 12,
alignItems: "center",
}}
>
<span style={{ fontSize: 13, fontWeight: 500 }}>{label}</span>
<input
type="range"
min={0}
max={100}
step={0.5}
value={v}
onChange={(e) => handle(i, Number(e.target.value))}
style={{ width: "100%" }}
/>
<div style={{ textAlign: "right", fontSize: 13 }}>
<span style={{ fontWeight: 600 }}>{v.toFixed(1)}%</span>
{Math.abs(delta) > 0.1 ? (
<span
style={{
marginLeft: 6,
fontSize: 11,
color: delta > 0 ? "#0a7a3a" : "#b3261e",
}}
>
{delta > 0 ? "+" : ""}
{delta.toFixed(1)}
</span>
) : null}
</div>
</div>
);
})}
</div>
</div>
);
}

View file

@ -1,6 +1,6 @@
"use client";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
import { apiFetch } from "./api";
import type {
@ -21,6 +21,8 @@ import type {
PrinzipObjectRow,
QuartirographyDealsRow,
QuartirographyPortfolioRow,
RecommendMixInput,
RecommendMixOutput,
YandexListingsSummary,
} from "@/types/analytics";
@ -206,3 +208,16 @@ export function useObjectPhotos(objId: number | string, limit = 100) {
enabled: !!objId,
});
}
// ── Recommender (Уровень 1) ─────────────────────────────────────────────────
export function useRecommendMix() {
return useMutation({
mutationFn: (input: RecommendMixInput) =>
apiFetch<RecommendMixOutput>(`${BASE}/recommend/mix`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
}),
});
}

View file

@ -194,6 +194,63 @@ export interface ObjectPhoto {
local_path: string | null;
}
// ── Recommender (Уровень 1) ─────────────────────────────────────────────────
export type RecommendClass = "Comfort" | "Comfort+" | "Business" | "Elite";
export interface RecommendMixInput {
district_name: string;
area_total_m2: number | null;
target_class: RecommendClass | null;
months_window: number;
}
export interface RecommendBucket {
bucket: string;
share_pct: number;
deal_count: number;
area_avg_m2: number;
area_median_m2: number;
price_median_per_m2: number;
price_p25_per_m2: number;
price_p75_per_m2: number;
units_planned: number | null;
revenue_planned_rub: number | null;
}
export interface RecommendComparable {
obj_id: number;
comm_name: string | null;
dev_name: string | null;
obj_class: string | null;
flat_count: number | null;
sold_perc: number | null;
}
export interface RecommendMixOutput {
scope: {
district: string;
district_zk_count: number | null;
district_median_price_per_m2: number | null;
district_factor: number;
class_multiplier: number;
target_class: string | null;
months_window: number;
effective_window_months: number;
region_code: number;
total_deals: number;
data_caveat?: string;
error?: string;
};
buckets: RecommendBucket[];
summary: {
total_revenue_rub: number | null;
weighted_avg_price_per_m2: number | null;
warnings: string[];
};
comparables: RecommendComparable[];
}
export interface PrinzipFunnelMonthRow {
month: string | null;
source: string;

File diff suppressed because one or more lines are too long