gendesign/frontend/src/components/site-finder/Pipeline24moBlock.tsx
Light1YT 8cd0620543
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
feat(site-finder): продуктовые решения по фидбеку analyze (#1741 #1742 #1743 #1744 #1745)
После ролевой дискуссии (аналитик×комдир×собственник):
- #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
2026-06-18 12:18:04 +05:00

439 lines
14 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 { 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>
);
}