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>
404 lines
13 KiB
TypeScript
404 lines
13 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 "—";
|
||
// Прочнее чем 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>
|
||
);
|
||
}
|