add rec ui
This commit is contained in:
parent
ecc0dbafd5
commit
62659887e9
13 changed files with 1027 additions and 50 deletions
|
|
@ -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(
|
def recommend_mix(
|
||||||
db: Session,
|
db: Session,
|
||||||
*,
|
*,
|
||||||
|
|
@ -998,6 +1050,7 @@ def recommend_mix(
|
||||||
median_price_per_m2, mean_price_per_m2
|
median_price_per_m2, mean_price_per_m2
|
||||||
FROM ekb_districts
|
FROM ekb_districts
|
||||||
WHERE district_name ILIKE :dn
|
WHERE district_name ILIKE :dn
|
||||||
|
AND district_name <> 'не определён'
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
|
|
@ -1073,53 +1126,20 @@ def recommend_mix(
|
||||||
" коэффициент класса = 1.0"
|
" коэффициент класса = 1.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 4) Bucket distribution from rosreestr_deals — city-wide, last N months
|
# 4) Bucket distribution from rosreestr_deals — city-wide, last N months.
|
||||||
bucket_rows = (
|
# Если в каком-либо бакете <30 сделок и окно < 24 мес, расширяем до 24 мес
|
||||||
db.execute(
|
# для всех бакетов и проставляем warning. Это даёт более устойчивые медианы.
|
||||||
text(
|
bucket_rows = _bucket_distribution(db, region_code, months_window)
|
||||||
"""
|
effective_window = months_window
|
||||||
WITH bucketed AS (
|
if months_window < 24 and bucket_rows and any(int(r["deals"] or 0) < 30 for r in bucket_rows):
|
||||||
SELECT CASE
|
bucket_rows_24 = _bucket_distribution(db, region_code, 24)
|
||||||
WHEN area < 30 THEN '1-Студия'
|
if bucket_rows_24:
|
||||||
WHEN area < 45 THEN '2-1-к'
|
bucket_rows = bucket_rows_24
|
||||||
WHEN area < 60 THEN '3-2-к'
|
effective_window = 24
|
||||||
WHEN area < 80 THEN '4-3-к'
|
warnings.append(
|
||||||
ELSE '5-80+ м²'
|
f"Окно расширено до 24 мес: при {months_window} мес хотя бы один"
|
||||||
END AS bucket,
|
" бакет имел <30 сделок — оценка была бы шумной"
|
||||||
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()
|
|
||||||
)
|
|
||||||
total_deals = sum(int(r["deals"] or 0) for r in bucket_rows) or 1
|
total_deals = sum(int(r["deals"] or 0) for r in bucket_rows) or 1
|
||||||
|
|
||||||
# 5) Build buckets with adjusted prices + optional allocation
|
# 5) Build buckets with adjusted prices + optional allocation
|
||||||
|
|
@ -1158,7 +1178,7 @@ def recommend_mix(
|
||||||
if deals < 30:
|
if deals < 30:
|
||||||
warnings.append(
|
warnings.append(
|
||||||
f"Бакет '{_BUCKET_PRETTY.get(bid, bid)}': только {deals} сделок"
|
f"Бакет '{_BUCKET_PRETTY.get(bid, bid)}': только {deals} сделок"
|
||||||
f" за {months_window} мес — оценка с большой погрешностью"
|
f" за {effective_window} мес — оценка с большой погрешностью"
|
||||||
)
|
)
|
||||||
|
|
||||||
buckets.append(
|
buckets.append(
|
||||||
|
|
@ -1219,6 +1239,7 @@ def recommend_mix(
|
||||||
"class_multiplier": round(class_multiplier, 4),
|
"class_multiplier": round(class_multiplier, 4),
|
||||||
"target_class": target_class,
|
"target_class": target_class,
|
||||||
"months_window": months_window,
|
"months_window": months_window,
|
||||||
|
"effective_window_months": effective_window,
|
||||||
"region_code": region_code,
|
"region_code": region_code,
|
||||||
"total_deals": total_deals if bucket_rows else 0,
|
"total_deals": total_deals if bucket_rows else 0,
|
||||||
"data_caveat": (
|
"data_caveat": (
|
||||||
|
|
|
||||||
266
frontend/src/app/analytics/recommend/page.tsx
Normal file
266
frontend/src/app/analytics/recommend/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,11 @@ const SECTIONS: { title: string; routes: Route[] }[] = [
|
||||||
label: "Карточка ЖК (пример: Парк Победы)",
|
label: "Карточка ЖК (пример: Парк Победы)",
|
||||||
desc: "KPI · sale-chart · POI-таблица · фото-галерея. /analytics/objects/{obj_id}.",
|
desc: "KPI · sale-chart · POI-таблица · фото-галерея. /analytics/objects/{obj_id}.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: "/analytics/recommend",
|
||||||
|
label: "Рекомендатор квартирографии",
|
||||||
|
desc: "Район + площадь + класс → распределение по 5 бакетам, цены p25/median/p75, выручка, comparable ЖК. Слайдеры под проект.",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ const TABS = [
|
||||||
{ href: "/analytics", label: "Свердл рынок" },
|
{ href: "/analytics", label: "Свердл рынок" },
|
||||||
{ href: "/analytics/prinzip", label: "PRINZIP" },
|
{ href: "/analytics/prinzip", label: "PRINZIP" },
|
||||||
{ href: "/analytics/developers", label: "Девелоперы" },
|
{ href: "/analytics/developers", label: "Девелоперы" },
|
||||||
|
{ href: "/analytics/recommend", label: "Рекомендатор" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AnalyticsNav() {
|
export function AnalyticsNav() {
|
||||||
|
|
|
||||||
63
frontend/src/components/analytics/RecommendBucketsChart.tsx
Normal file
63
frontend/src/components/analytics/RecommendBucketsChart.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
91
frontend/src/components/analytics/RecommendBucketsTable.tsx
Normal file
91
frontend/src/components/analytics/RecommendBucketsTable.tsx
Normal 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" };
|
||||||
86
frontend/src/components/analytics/RecommendComparables.tsx
Normal file
86
frontend/src/components/analytics/RecommendComparables.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
197
frontend/src/components/analytics/RecommendForm.tsx
Normal file
197
frontend/src/components/analytics/RecommendForm.tsx
Normal 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}>
|
||||||
|
При <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,
|
||||||
|
});
|
||||||
48
frontend/src/components/analytics/RecommendRevenueChart.tsx
Normal file
48
frontend/src/components/analytics/RecommendRevenueChart.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
127
frontend/src/components/analytics/RecommendShareSliders.tsx
Normal file
127
frontend/src/components/analytics/RecommendShareSliders.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
import { keepPreviousData, useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { apiFetch } from "./api";
|
import { apiFetch } from "./api";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -21,6 +21,8 @@ import type {
|
||||||
PrinzipObjectRow,
|
PrinzipObjectRow,
|
||||||
QuartirographyDealsRow,
|
QuartirographyDealsRow,
|
||||||
QuartirographyPortfolioRow,
|
QuartirographyPortfolioRow,
|
||||||
|
RecommendMixInput,
|
||||||
|
RecommendMixOutput,
|
||||||
YandexListingsSummary,
|
YandexListingsSummary,
|
||||||
} from "@/types/analytics";
|
} from "@/types/analytics";
|
||||||
|
|
||||||
|
|
@ -206,3 +208,16 @@ export function useObjectPhotos(objId: number | string, limit = 100) {
|
||||||
enabled: !!objId,
|
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),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,63 @@ export interface ObjectPhoto {
|
||||||
local_path: string | null;
|
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 {
|
export interface PrinzipFunnelMonthRow {
|
||||||
month: string | null;
|
month: string | null;
|
||||||
source: string;
|
source: string;
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Add table
Reference in a new issue