All checks were successful
CI / changes (pull_request) Successful in 8s
CI / changes (push) Successful in 9s
CI / frontend-tests (push) Successful in 1m43s
CI / frontend-tests (pull_request) Successful in 1m59s
CI / openapi-codegen-check (push) Successful in 2m43s
CI / openapi-codegen-check (pull_request) Successful in 1m55s
CI / backend-tests (push) Successful in 9m28s
CI / backend-tests (pull_request) Successful in 9m28s
После ролевой дискуссии (аналитик×комдир×собственник): - #1741 секции в 2 группы «Участок»→«Стройка и рынок» (Section1→4→2→3→5→6), gate-баннер вынесен НАД группой (убран дубль из Section4Estimate), nav-группы - #1742 объяснение выбора класса: фраза-причина + мини-таблица 3 классов по deficit_index («+1 острый дефицит, −1 затоварка»). POI «обеспеченность»/market-overstock relabel — N/A (в UI не выводятся) - #1743 «Остатки и скорость»→«Как продаётся рынок рядом» + headline-вердикт + «Темп продаж»/«Будущее предложение конкурентов (24 мес)» (убран англицизм Velocity-score/Pipeline) + интерпретированные единицы - #1744 горизонт: caption+tooltip «пересчёт ~10-30с» + статус «Пересчитываем…»; колонки прогноза «…, квартир» + caveat advisory - #1745 фаза1: таблица «Прогноз по всем форматам» (студия/1-5к × индекс дефицита/прогноз спроса/сигнал); backend пробросил per-format projected_demand_units + signal. Слайдеры/рубли — фаза2 после бэктеста #951 Verify: tsc 0 ошибок; py_compile; what_to_build/recommendation/report/exporter тесты pass. Closes #1741 Closes #1742 Closes #1743 Closes #1744 Closes #1745
439 lines
14 KiB
TypeScript
439 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
|
||
import type { Pipeline24mo } from "@/types/site-finder";
|
||
|
||
interface Props {
|
||
data: Pipeline24mo;
|
||
}
|
||
|
||
const SEVERITY_COLOR: Record<
|
||
Pipeline24mo["severity"],
|
||
{ bg: string; fg: string; border: string }
|
||
> = {
|
||
none: { bg: "#f3f4f6", fg: "#6b7280", border: "#e5e7eb" },
|
||
low: { bg: "#dcfce7", fg: "#15803d", border: "#86efac" },
|
||
medium: { bg: "#fef9c3", fg: "#a16207", border: "#fde68a" },
|
||
high: { bg: "#fee2e2", fg: "#b91c1c", border: "#fca5a5" },
|
||
};
|
||
|
||
const CLASS_LABEL: Record<string, string> = {
|
||
economy: "Эконом",
|
||
econom: "Эконом",
|
||
comfort: "Комфорт",
|
||
business: "Бизнес",
|
||
premium: "Премиум",
|
||
elite: "Элит",
|
||
standart: "Стандарт",
|
||
standard: "Стандарт",
|
||
unknown: "Не указан",
|
||
// NB: JSON null приходит как absent key, не "null" string — отдельный
|
||
// обработчик не нужен.
|
||
};
|
||
|
||
function fmtMonth(iso: string | null): string {
|
||
if (!iso) return "—";
|
||
// Regex-парс год/месяц напрямую — без таймзонных сюрпризов от Date()
|
||
// (new Date('2026-07-01') = UTC-полночь → в UTC-локали уезжает на 06.2026).
|
||
// Тот же приём, что в MarketLayers.formatReadyDt.
|
||
const m = /^(\d{4})-(\d{2})/.exec(iso);
|
||
if (m) return `${m[2]}.${m[1]}`;
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return iso.substring(0, 7);
|
||
return d.toLocaleDateString("ru-RU", { year: "numeric", month: "2-digit" });
|
||
}
|
||
|
||
function fmtClass(cls: string): string {
|
||
return CLASS_LABEL[cls.toLowerCase()] ?? cls;
|
||
}
|
||
|
||
export function Pipeline24moBlock({ data }: Props) {
|
||
const [expanded, setExpanded] = useState(false);
|
||
const c = SEVERITY_COLOR[data.severity];
|
||
|
||
// Max flats per quarter — для нормализации высоты bar'а
|
||
const maxFlatsPerQuarter = data.by_quarter.reduce(
|
||
(m, q) => (q.flats > m ? q.flats : m),
|
||
0,
|
||
);
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
border: `1px solid ${c.border}`,
|
||
background: "#fff",
|
||
borderRadius: 10,
|
||
padding: "14px 18px",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 12,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "space-between",
|
||
gap: 12,
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 6,
|
||
fontSize: 11,
|
||
fontWeight: 700,
|
||
color: "var(--fg-secondary, #5B6066)",
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.06em",
|
||
}}
|
||
title="Прогноз будущего предложения: ЖК-конкуренты со сроком сдачи в ближайшие 24 месяца в радиусе 5 км. Это оценка, а не факт продаж."
|
||
>
|
||
Будущее предложение конкурентов (24 мес)
|
||
<span
|
||
style={{
|
||
padding: "1px 6px",
|
||
borderRadius: 6,
|
||
fontSize: 10,
|
||
fontWeight: 600,
|
||
letterSpacing: "0.02em",
|
||
color: "var(--fg-tertiary, #73767E)",
|
||
background: "var(--bg-card-alt, #FAFBFC)",
|
||
border: "1px solid var(--border-card, #E6E8EC)",
|
||
textTransform: "none",
|
||
}}
|
||
>
|
||
прогноз
|
||
</span>
|
||
</span>
|
||
<span
|
||
style={{
|
||
padding: "2px 10px",
|
||
borderRadius: 12,
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
color: c.fg,
|
||
background: c.bg,
|
||
border: `1px solid ${c.border}`,
|
||
}}
|
||
>
|
||
{data.severity_label ?? data.severity} ·{" "}
|
||
{data.flats_total.toLocaleString("ru")} лотов
|
||
</span>
|
||
</div>
|
||
|
||
{data.objects_count === 0 ? (
|
||
<div style={{ fontSize: 13, color: "var(--fg-tertiary, #73767E)" }}>
|
||
Нет ЖК-конкурентов со сроком сдачи в ближайшие 24 месяца в радиусе 5
|
||
км.
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* Summary numbers */}
|
||
<div
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: "repeat(auto-fit, minmax(110px, 1fr))",
|
||
gap: 10,
|
||
fontSize: 12,
|
||
color: "#374151",
|
||
}}
|
||
>
|
||
<div>
|
||
<div
|
||
style={{ color: "var(--fg-tertiary, #73767E)", fontSize: 11 }}
|
||
>
|
||
ЖК
|
||
</div>
|
||
<div
|
||
style={{
|
||
fontWeight: 600,
|
||
fontSize: 14,
|
||
fontVariantNumeric: "tabular-nums",
|
||
}}
|
||
>
|
||
{data.objects_count.toLocaleString("ru")} ЖК
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div
|
||
style={{ color: "var(--fg-tertiary, #73767E)", fontSize: 11 }}
|
||
>
|
||
Лотов суммарно
|
||
</div>
|
||
<div
|
||
style={{
|
||
fontWeight: 600,
|
||
fontSize: 14,
|
||
fontVariantNumeric: "tabular-nums",
|
||
}}
|
||
>
|
||
{data.flats_total.toLocaleString("ru")} лотов
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<div
|
||
style={{ color: "var(--fg-tertiary, #73767E)", fontSize: 11 }}
|
||
>
|
||
Горизонт
|
||
</div>
|
||
<div
|
||
style={{
|
||
fontWeight: 600,
|
||
fontSize: 14,
|
||
fontVariantNumeric: "tabular-nums",
|
||
}}
|
||
>
|
||
{data.horizon_months ?? 24} мес · {data.radius_km ?? 5} км
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* By-class breakdown */}
|
||
{Object.keys(data.by_class).length > 0 && (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexWrap: "wrap",
|
||
gap: 8,
|
||
fontSize: 12,
|
||
}}
|
||
>
|
||
{Object.entries(data.by_class)
|
||
.sort(([, a], [, b]) => b - a)
|
||
.map(([cls, flats]) => (
|
||
<div
|
||
key={cls}
|
||
style={{
|
||
padding: "3px 10px",
|
||
borderRadius: 6,
|
||
background: "#f3f4f6",
|
||
color: "#374151",
|
||
}}
|
||
>
|
||
{fmtClass(cls)}:{" "}
|
||
<strong style={{ fontVariantNumeric: "tabular-nums" }}>
|
||
{flats.toLocaleString("ru")} лотов
|
||
</strong>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* By-quarter pipeline bar */}
|
||
{data.by_quarter.length > 0 && (
|
||
<div>
|
||
<div
|
||
style={{
|
||
fontSize: 11,
|
||
fontWeight: 600,
|
||
color: "var(--fg-secondary, #5B6066)",
|
||
marginBottom: 6,
|
||
}}
|
||
>
|
||
По кварталам сдачи (прогноз)
|
||
</div>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "stretch",
|
||
gap: 4,
|
||
height: 80,
|
||
}}
|
||
>
|
||
{data.by_quarter.map((q) => {
|
||
// Bar height in pixels of the 60px area above label row
|
||
// (reserves ~16px for label). Using fixed px avoids the
|
||
// align-items:flex-end shrink-to-content trap where %-heights
|
||
// would resolve to the column's content height instead of
|
||
// the outer 80px container.
|
||
const BAR_AREA_PX = 60;
|
||
const barPx =
|
||
maxFlatsPerQuarter > 0
|
||
? Math.max(
|
||
4,
|
||
(q.flats / maxFlatsPerQuarter) * BAR_AREA_PX,
|
||
)
|
||
: 4;
|
||
return (
|
||
<div
|
||
key={q.quarter}
|
||
style={{
|
||
flex: 1,
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
justifyContent: "flex-end",
|
||
alignItems: "center",
|
||
gap: 3,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
width: "100%",
|
||
height: barPx,
|
||
background: c.fg,
|
||
opacity: 0.5,
|
||
borderRadius: "3px 3px 0 0",
|
||
}}
|
||
title={`${q.quarter}: ${q.objects.toLocaleString("ru")} ЖК / ${q.flats.toLocaleString("ru")} лотов (прогноз)`}
|
||
/>
|
||
<div
|
||
style={{
|
||
fontSize: 10,
|
||
color: "#6b7280",
|
||
whiteSpace: "nowrap",
|
||
fontVariantNumeric: "tabular-nums",
|
||
}}
|
||
>
|
||
{q.quarter}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Top objects toggle */}
|
||
{data.top_objects.length > 0 && (
|
||
<div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setExpanded((e) => !e)}
|
||
aria-expanded={expanded}
|
||
style={{
|
||
background: "none",
|
||
border: "none",
|
||
padding: 0,
|
||
color: "var(--accent, #1D4ED8)",
|
||
fontSize: 13,
|
||
cursor: "pointer",
|
||
fontWeight: 500,
|
||
}}
|
||
>
|
||
{expanded
|
||
? "Скрыть список"
|
||
: `Топ-${data.top_objects.length} ЖК в прогнозе`}
|
||
</button>
|
||
{expanded && (
|
||
<div
|
||
style={{
|
||
marginTop: 10,
|
||
maxHeight: 240,
|
||
overflowY: "auto",
|
||
border: "1px solid #f3f4f6",
|
||
borderRadius: 6,
|
||
}}
|
||
>
|
||
<table
|
||
style={{
|
||
width: "100%",
|
||
borderCollapse: "collapse",
|
||
fontSize: 12,
|
||
}}
|
||
>
|
||
<thead
|
||
style={{
|
||
background: "#f9fafb",
|
||
position: "sticky",
|
||
top: 0,
|
||
}}
|
||
>
|
||
<tr>
|
||
<th
|
||
style={{
|
||
padding: "6px 10px",
|
||
textAlign: "left",
|
||
color: "#6b7280",
|
||
fontWeight: 500,
|
||
}}
|
||
>
|
||
ЖК
|
||
</th>
|
||
<th
|
||
style={{
|
||
padding: "6px 10px",
|
||
textAlign: "left",
|
||
color: "#6b7280",
|
||
fontWeight: 500,
|
||
}}
|
||
>
|
||
Класс
|
||
</th>
|
||
<th
|
||
style={{
|
||
padding: "6px 10px",
|
||
textAlign: "right",
|
||
color: "#6b7280",
|
||
fontWeight: 500,
|
||
}}
|
||
>
|
||
Квартир
|
||
</th>
|
||
<th
|
||
style={{
|
||
padding: "6px 10px",
|
||
textAlign: "right",
|
||
color: "#6b7280",
|
||
fontWeight: 500,
|
||
}}
|
||
>
|
||
Сдача
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.top_objects.map((obj) => (
|
||
<tr
|
||
key={obj.obj_id}
|
||
style={{ borderTop: "1px solid #f3f4f6" }}
|
||
>
|
||
<td style={{ padding: "6px 10px", color: "#374151" }}>
|
||
{obj.comm_name ?? obj.dev_name ?? "—"}
|
||
</td>
|
||
<td style={{ padding: "6px 10px", color: "#6b7280" }}>
|
||
{obj.obj_class ? fmtClass(obj.obj_class) : "—"}
|
||
</td>
|
||
<td
|
||
style={{
|
||
padding: "6px 10px",
|
||
textAlign: "right",
|
||
fontVariantNumeric: "tabular-nums",
|
||
color: "#374151",
|
||
}}
|
||
>
|
||
{obj.flat_count != null
|
||
? `${obj.flat_count.toLocaleString("ru")} лотов`
|
||
: "—"}
|
||
</td>
|
||
<td
|
||
style={{
|
||
padding: "6px 10px",
|
||
textAlign: "right",
|
||
color: "#6b7280",
|
||
fontVariantNumeric: "tabular-nums",
|
||
}}
|
||
>
|
||
{fmtMonth(obj.ready_dt)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{data.note && (
|
||
<div style={{ fontSize: 11, color: "#9ca3af", fontStyle: "italic" }}>
|
||
{data.note}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|