"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 = { 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 (
Будущее предложение конкурентов (24 мес) прогноз {data.severity_label ?? data.severity} ·{" "} {data.flats_total.toLocaleString("ru")} лотов
{data.objects_count === 0 ? (
Нет ЖК-конкурентов со сроком сдачи в ближайшие 24 месяца в радиусе 5 км.
) : ( <> {/* Summary numbers */}
ЖК
{data.objects_count.toLocaleString("ru")} ЖК
Лотов суммарно
{data.flats_total.toLocaleString("ru")} лотов
Горизонт
{data.horizon_months ?? 24} мес · {data.radius_km ?? 5} км
{/* By-class breakdown */} {Object.keys(data.by_class).length > 0 && (
{Object.entries(data.by_class) .sort(([, a], [, b]) => b - a) .map(([cls, flats]) => (
{fmtClass(cls)}:{" "} {flats.toLocaleString("ru")} лотов
))}
)} {/* By-quarter pipeline bar */} {data.by_quarter.length > 0 && (
По кварталам сдачи (прогноз)
{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 (
{q.quarter}
); })}
)} {/* Top objects toggle */} {data.top_objects.length > 0 && (
{expanded && (
{data.top_objects.map((obj) => ( ))}
ЖК Класс Квартир Сдача
{obj.comm_name ?? obj.dev_name ?? "—"} {obj.obj_class ? fmtClass(obj.obj_class) : "—"} {obj.flat_count != null ? `${obj.flat_count.toLocaleString("ru")} лотов` : "—"} {fmtMonth(obj.ready_dt)}
)}
)} )} {data.note && (
{data.note}
)}
); }