gendesign/frontend/src/components/site-finder/Pipeline24moBlock.tsx
lekss361 b4327635fa
fix(site-finder): visible by-quarter pipeline bars (#167)
Bars rendered as ~1-15px because outer flex container had
align-items:flex-end which made each column shrink to label
height (~15.8px), so bar percentage heights resolved against
that instead of the intended 80px container.

Switched to align-items:stretch + fixed-pixel bar heights
anchored to a 60px area (reserves ~20px for label row). Each
column now uses justify-content:flex-end so bars + labels sit
at the bottom baseline.

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 10:35:51 +03:00

404 lines
13 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 "—";
// Прочнее чем substring(0,7) — игнорирует timezone и datetime suffix
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={{
fontSize: 11,
fontWeight: 700,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.06em",
}}
title="Pipeline 24 месяца — ЖК, которые сдаются в окне 24мес в радиусе 5км"
>
Конкуренты на 24 мес (5 км)
</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-RU")} квартир
</span>
</div>
{data.objects_count === 0 ? (
<div style={{ fontSize: 13, color: "#9ca3af" }}>
Нет ЖК-конкурентов с planned_commissioning в окне 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: "#9ca3af", fontSize: 11 }}>Объектов</div>
<div
style={{
fontWeight: 600,
fontSize: 14,
fontVariantNumeric: "tabular-nums",
}}
>
{data.objects_count}
</div>
</div>
<div>
<div style={{ color: "#9ca3af", fontSize: 11 }}>
Квартир суммарно
</div>
<div
style={{
fontWeight: 600,
fontSize: 14,
fontVariantNumeric: "tabular-nums",
}}
>
{data.flats_total.toLocaleString("ru-RU")}
</div>
</div>
<div>
<div style={{ color: "#9ca3af", 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-RU")}
</strong>
</div>
))}
</div>
)}
{/* By-quarter pipeline bar */}
{data.by_quarter.length > 0 && (
<div>
<div
style={{
fontSize: 11,
fontWeight: 600,
color: "#6b7280",
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} ЖК / ${q.flats} квартир`}
/>
<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: "#1d4ed8",
fontSize: 13,
cursor: "pointer",
fontWeight: 500,
}}
>
{expanded
? "Скрыть список"
: `Топ-${data.top_objects.length} ЖК pipeline`}
</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?.toLocaleString("ru-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>
);
}