252 lines
6.9 KiB
TypeScript
252 lines
6.9 KiB
TypeScript
"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>
|
||
{value.district_name.length >= 2
|
||
? (() => {
|
||
const selected = districtOptions.find(
|
||
(d) => d.district_name === value.district_name,
|
||
);
|
||
if (!selected) return null;
|
||
return (
|
||
<span style={hintStyle}>
|
||
{selected.zk_count ?? 0} ЖК · {selected.cad_quarter_count} с
|
||
кад. кварталом
|
||
</span>
|
||
);
|
||
})()
|
||
: null}
|
||
</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
|
||
style={{
|
||
background: "#eff6ff",
|
||
color: "#1d4ed8",
|
||
padding: "1px 6px",
|
||
borderRadius: 4,
|
||
fontSize: 10,
|
||
fontWeight: 600,
|
||
textTransform: "none",
|
||
letterSpacing: 0,
|
||
marginLeft: 4,
|
||
}}
|
||
title="ЦБ менял ключевую ставку, льготная ипотека, программы — данные >2 лет нерелевантны для прогноза"
|
||
>
|
||
📊 Данные: последние 24 месяца
|
||
</span>
|
||
</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 сделок в любом бакете окно расширяется до max 24 мес.
|
||
</span>
|
||
</label>
|
||
|
||
<label>
|
||
<span style={labelStyle}>Целевой срок реализации (опц.)</span>
|
||
<input
|
||
type="number"
|
||
inputMode="numeric"
|
||
min={3}
|
||
max={120}
|
||
step={1}
|
||
placeholder="например 18"
|
||
value={value.target_months ?? ""}
|
||
onChange={(e) =>
|
||
onChange({
|
||
...value,
|
||
target_months:
|
||
e.target.value === "" ? null : Number(e.target.value),
|
||
})
|
||
}
|
||
style={inputStyle}
|
||
/>
|
||
<span style={hintStyle}>
|
||
Если задан — система предложит требуемый коэффициент к рынку.
|
||
</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,
|
||
});
|