Три honesty/correctness-фикса из audit-issue #1871 (🟠 P2). Все факты подтверждены prod-DB + разведкой. ETL не трогаем (отдельная задача). velocity.py: - _EKB_MEDIAN_FALLBACK 4500→750 (audit-verified медиана ЕКБ 593-766; 4500 завышало знаменатель нормализации 7.6× → занижало velocity_score при DB-error fallback, latent). Консервативно 750 (верх диапазона). - VelocityResult + as_dict() раскрывают: objective_coverage_pct, proxy_used, proxy_sqm_per_deal — теперь видно что объём rosreestr-пути фабрикуется count×45 м²/сделка при низком покрытии Objective (<50%). Проброшено во все 4 конструктора, additive. parcels.py (market_trend producer): - Различать «источник устарел» от «нет сделок». rosreestr_deals MAX(period_start_date)=2026-01-01 (stale 5.5 мес) → recent-окно пусто → раньше голый null. Теперь status ∈ {ok, source_stale, no_deals} + as_of_date + label + recent_deals_count. MAX(deal_date) добавлен в CTE. Consumer scoring читает recent_deals_count защищённо (без регрессии). frontend (types + MarketTrendBlock + VelocityBlock + ptica market-drawer + Section3 verdict + legacy guard): - staleness/no_deals → честный caveat вместо фейкового тренда. - velocity → caption «объём оценочный (прокси N м²/сделка, покрытие P%)». - formatAsOfMonth парсит YYYY-MM напрямую (TZ-safe). - Типы MarketTrend/Velocity расширены (optional, backward-compat; отсутствие status = "ok"). Тесты: +3 velocity unit + extended as_dict contract. 17 velocity passed, 138 frontend vitest passed. Refs #1871
836 lines
26 KiB
TypeScript
836 lines
26 KiB
TypeScript
"use client";
|
||
|
||
import { useState, type ReactNode } from "react";
|
||
import { X, Building2, MapPin, Users, Ruler } from "lucide-react";
|
||
|
||
import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel";
|
||
import { BestLayoutsBlock } from "@/components/site-finder/BestLayoutsBlock";
|
||
import { Pipeline24moBlock } from "@/components/site-finder/Pipeline24moBlock";
|
||
import { VelocityBlock } from "@/components/site-finder/VelocityBlock";
|
||
import { MarketTrendBlock } from "@/components/site-finder/MarketTrendBlock";
|
||
import { CompetitorTable } from "@/components/site-finder/CompetitorTable";
|
||
import { Drawer } from "@/components/ui/Drawer";
|
||
import { Badge } from "@/components/ui/Badge";
|
||
import {
|
||
POI_DEFAULT_WEIGHTS,
|
||
type PoiCategoryKey,
|
||
} from "@/lib/api/weightProfiles";
|
||
import type {
|
||
ParcelAnalysis,
|
||
ParcelAnalysisCompetitor,
|
||
} from "@/types/site-finder";
|
||
|
||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||
|
||
interface Props {
|
||
/** Cadastral number — passed for context/future backend pass-through. */
|
||
cad: string;
|
||
/** Full analysis data — used for Section 3.2/3.3 placeholders, competitors. */
|
||
data: ParcelAnalysis;
|
||
}
|
||
|
||
interface FilterState {
|
||
/** Radius in km, 1–5. */
|
||
radiusKm: number;
|
||
/** Show only buildings under construction. */
|
||
onlyUnderConstruction: boolean;
|
||
/** Completion deadline ≥ 3 months from now. */
|
||
minDeadline3mo: boolean;
|
||
/** Project started ≤ 6 months ago. */
|
||
maxAge6mo: boolean;
|
||
/** Apartments only (excludes commercial / parking-only). */
|
||
onlyApartments: boolean;
|
||
}
|
||
|
||
// ── Chip component ─────────────────────────────────────────────────────────────
|
||
|
||
interface ChipProps {
|
||
label: string;
|
||
selected: boolean;
|
||
onToggle: () => void;
|
||
}
|
||
|
||
function FilterChip({ label, selected, onToggle }: ChipProps) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onToggle}
|
||
style={{
|
||
padding: "5px 12px",
|
||
fontSize: 12,
|
||
fontWeight: 500,
|
||
borderRadius: 999,
|
||
border: selected
|
||
? "1px solid var(--accent, #1D4ED8)"
|
||
: "1px solid var(--border-card, #E6E8EC)",
|
||
background: selected
|
||
? "var(--accent-soft, #DBEAFE)"
|
||
: "var(--bg-card, #FFFFFF)",
|
||
color: selected
|
||
? "var(--accent, #1D4ED8)"
|
||
: "var(--fg-secondary, #5B6066)",
|
||
cursor: "pointer",
|
||
whiteSpace: "nowrap",
|
||
transition: "background 0.12s, color 0.12s, border-color 0.12s",
|
||
}}
|
||
aria-pressed={selected}
|
||
>
|
||
{label}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
// ── Section 3.1 «Настройки выборки» ───────────────────────────────────────────
|
||
|
||
function Section31Settings({
|
||
filters,
|
||
onFiltersChange,
|
||
}: {
|
||
filters: FilterState;
|
||
onFiltersChange: (f: FilterState) => void;
|
||
}) {
|
||
const [weights, setWeights] = useState<Record<PoiCategoryKey, number>>(
|
||
() => ({ ...POI_DEFAULT_WEIGHTS }),
|
||
);
|
||
|
||
function toggleChip(key: keyof Omit<FilterState, "radiusKm">) {
|
||
onFiltersChange({ ...filters, [key]: !filters[key] });
|
||
}
|
||
|
||
function handleWeightsChange(
|
||
newWeights: Record<PoiCategoryKey, number>,
|
||
_profileId: number | null,
|
||
) {
|
||
setWeights(newWeights);
|
||
}
|
||
|
||
const chips: Array<{
|
||
key: keyof Omit<FilterState, "radiusKm">;
|
||
label: string;
|
||
}> = [
|
||
{ key: "onlyUnderConstruction", label: "Только строящиеся" },
|
||
{ key: "minDeadline3mo", label: "+3 мес срок" },
|
||
{ key: "maxAge6mo", label: "−6 мес от сегодня" },
|
||
{ key: "onlyApartments", label: "Только квартиры" },
|
||
];
|
||
|
||
return (
|
||
<div id="section-3-1" style={{ scrollMarginTop: 72 }}>
|
||
{/* Sub-section title */}
|
||
<div style={{ marginBottom: 16 }}>
|
||
<h3
|
||
style={{
|
||
fontSize: 16,
|
||
fontWeight: 600,
|
||
color: "var(--fg-primary, #111111)",
|
||
margin: 0,
|
||
}}
|
||
>
|
||
4.1 Настройки выборки
|
||
</h3>
|
||
<p
|
||
style={{
|
||
fontSize: 13,
|
||
color: "var(--fg-secondary, #5B6066)",
|
||
margin: "4px 0 0",
|
||
}}
|
||
>
|
||
Фильтры применяются к конкурентам локально — без повторного запроса к
|
||
бэкенду
|
||
</p>
|
||
</div>
|
||
|
||
<div
|
||
style={{
|
||
background: "var(--bg-card, #FFFFFF)",
|
||
border: "1px solid var(--border-card, #E6E8EC)",
|
||
borderRadius: 12,
|
||
padding: "16px 18px",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 16,
|
||
}}
|
||
>
|
||
{/* Radius slider */}
|
||
<div>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
alignItems: "center",
|
||
marginBottom: 8,
|
||
}}
|
||
>
|
||
<label
|
||
htmlFor="radius-slider"
|
||
style={{
|
||
fontSize: 12,
|
||
fontWeight: 500,
|
||
color: "var(--fg-secondary, #5B6066)",
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.04em",
|
||
}}
|
||
>
|
||
Радиус выборки
|
||
</label>
|
||
<span
|
||
style={{
|
||
fontSize: 14,
|
||
fontWeight: 600,
|
||
color: "var(--fg-primary, #111111)",
|
||
fontVariantNumeric: "tabular-nums",
|
||
}}
|
||
>
|
||
{filters.radiusKm} км
|
||
</span>
|
||
</div>
|
||
<input
|
||
id="radius-slider"
|
||
type="range"
|
||
min={1}
|
||
max={5}
|
||
step={0.5}
|
||
value={filters.radiusKm}
|
||
onChange={(e) =>
|
||
onFiltersChange({
|
||
...filters,
|
||
radiusKm: parseFloat(e.target.value),
|
||
})
|
||
}
|
||
style={{
|
||
width: "100%",
|
||
accentColor: "var(--accent, #1D4ED8)",
|
||
}}
|
||
/>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
fontSize: 11,
|
||
color: "var(--fg-tertiary, #73767E)",
|
||
marginTop: 4,
|
||
fontVariantNumeric: "tabular-nums",
|
||
}}
|
||
>
|
||
<span>1 км</span>
|
||
<span>5 км</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Filter chips */}
|
||
<div>
|
||
<div
|
||
style={{
|
||
fontSize: 12,
|
||
fontWeight: 500,
|
||
color: "var(--fg-secondary, #5B6066)",
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.04em",
|
||
marginBottom: 8,
|
||
}}
|
||
>
|
||
Фильтры объектов
|
||
</div>
|
||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
|
||
{chips.map(({ key, label }) => (
|
||
<FilterChip
|
||
key={key}
|
||
label={label}
|
||
selected={filters[key]}
|
||
onToggle={() => toggleChip(key)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Weight profile — reuse existing panel */}
|
||
<div>
|
||
<div
|
||
style={{
|
||
fontSize: 12,
|
||
fontWeight: 500,
|
||
color: "var(--fg-secondary, #5B6066)",
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.04em",
|
||
marginBottom: 8,
|
||
}}
|
||
>
|
||
Профиль весов POI
|
||
</div>
|
||
<WeightProfilePanel
|
||
currentWeights={weights}
|
||
onWeightsChange={handleWeightsChange}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Section 3.2 «Планировки» ───────────────────────────────────────────────────
|
||
|
||
function Section32Layouts({ cad }: { cad: string }) {
|
||
return (
|
||
<div id="section-3-2" style={{ scrollMarginTop: 72 }}>
|
||
<div style={{ marginBottom: 16 }}>
|
||
<h3
|
||
style={{
|
||
fontSize: 16,
|
||
fontWeight: 600,
|
||
color: "var(--fg-primary, #111111)",
|
||
margin: 0,
|
||
}}
|
||
>
|
||
4.2 Планировки
|
||
</h3>
|
||
<p
|
||
style={{
|
||
fontSize: 13,
|
||
color: "var(--fg-secondary, #5B6066)",
|
||
margin: "4px 0 0",
|
||
}}
|
||
>
|
||
Data-driven ТЗ на планировочный микс — на основе сделок конкурентов в
|
||
радиусе
|
||
</p>
|
||
</div>
|
||
<BestLayoutsBlock cadNum={cad} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Section 3.3 «Как продаётся рынок рядом» ───────────────────────────────────
|
||
|
||
/** Small uppercase label-заголовок над каждым подблоком 3.3. */
|
||
function SubBlockLabel({ children }: { children: ReactNode }) {
|
||
return (
|
||
<div
|
||
style={{
|
||
fontSize: 12,
|
||
fontWeight: 500,
|
||
color: "var(--fg-secondary, #5B6066)",
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.04em",
|
||
marginBottom: 8,
|
||
}}
|
||
>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Собирает одно предложение-вердикт для headline-bar из уже доступных данных
|
||
* velocity / pipeline / market_trend. Возвращает null если данных совсем нет.
|
||
*
|
||
* - ratio = темп продаж конкурентов / медиана ЕКБ (м²/мес).
|
||
* - months_of_inventory — за сколько месяцев при текущем темпе разойдётся
|
||
* будущее предложение конкурентов (24 мес). Темп в лотах/мес выводим из
|
||
* velocity.by_room_bucket (units за months_observed); без него клаузу
|
||
* пропускаем, чтобы не выдумывать цифру.
|
||
* - price change — market_trend.delta_6m_pct в процентных пунктах.
|
||
*/
|
||
function buildVerdict(data: ParcelAnalysis): string | null {
|
||
const v = data.velocity;
|
||
const pipeline = data.pipeline_24mo;
|
||
const trend = data.market_trend;
|
||
|
||
const parts: string[] = [];
|
||
|
||
if (v && v.ekb_median_sqm > 0 && v.velocity_data_available !== false) {
|
||
const ratio = v.monthly_velocity_sqm / v.ekb_median_sqm;
|
||
const pace = ratio >= 1 ? "быстрее рынка" : "медленнее рынка";
|
||
parts.push(
|
||
`Конкуренты продают ×${ratio.toFixed(1)} к медиане ЕКБ (${pace})`,
|
||
);
|
||
|
||
// Темп в лотах/мес из наблюдённых сделок по комнатности.
|
||
if (
|
||
pipeline &&
|
||
pipeline.flats_total > 0 &&
|
||
v.by_room_bucket &&
|
||
v.months_observed > 0
|
||
) {
|
||
const unitsSold = Object.values(v.by_room_bucket).reduce(
|
||
(sum, b) => sum + b.units,
|
||
0,
|
||
);
|
||
const lotsPerMonth = unitsSold / v.months_observed;
|
||
if (lotsPerMonth > 0) {
|
||
const moi = pipeline.flats_total / lotsPerMonth;
|
||
parts.push(
|
||
`при текущем темпе остаток разойдётся за ~${Math.round(
|
||
moi,
|
||
).toLocaleString("ru")} мес`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// P2 (#1871): дельту цен добавляем только когда тренд посчитан (status "ok"
|
||
// или старый ответ без status); при source_stale/no_deals — пропускаем.
|
||
if (
|
||
trend &&
|
||
(trend.status == null || trend.status === "ok") &&
|
||
trend.delta_6m_pct != null
|
||
) {
|
||
const d = trend.delta_6m_pct;
|
||
const sign = d > 0 ? "+" : d < 0 ? "−" : "";
|
||
const abs = Math.abs(d).toLocaleString("ru", {
|
||
minimumFractionDigits: 1,
|
||
maximumFractionDigits: 1,
|
||
});
|
||
parts.push(`цены за период ${sign}${abs} п.п.`);
|
||
}
|
||
|
||
if (parts.length === 0) return null;
|
||
return `${parts.join("; ")}.`;
|
||
}
|
||
|
||
function Section33MarketSales({ data }: { data: ParcelAnalysis }) {
|
||
const hasPipeline = data.pipeline_24mo != null;
|
||
const hasVelocity = data.velocity != null;
|
||
const hasTrend = data.market_trend != null;
|
||
|
||
const isEmpty = !hasPipeline && !hasVelocity && !hasTrend;
|
||
const verdict = buildVerdict(data);
|
||
|
||
return (
|
||
<div id="section-3-3" style={{ scrollMarginTop: 72 }}>
|
||
<div style={{ marginBottom: 16 }}>
|
||
<h3
|
||
style={{
|
||
fontSize: 16,
|
||
fontWeight: 600,
|
||
color: "var(--fg-primary, #111111)",
|
||
margin: 0,
|
||
}}
|
||
>
|
||
4.3 Как продаётся рынок рядом
|
||
</h3>
|
||
<p
|
||
style={{
|
||
fontSize: 13,
|
||
color: "var(--fg-secondary, #5B6066)",
|
||
margin: "4px 0 0",
|
||
}}
|
||
>
|
||
Остатки конкурентов · темп продаж · динамика цен
|
||
</p>
|
||
</div>
|
||
|
||
{isEmpty ? (
|
||
<div
|
||
style={{
|
||
background: "var(--bg-card-alt, #FAFBFC)",
|
||
border: "1px solid var(--border-card, #E6E8EC)",
|
||
borderRadius: 12,
|
||
padding: "24px",
|
||
color: "var(--fg-tertiary, #73767E)",
|
||
fontSize: 13,
|
||
}}
|
||
>
|
||
Данные по продажам рынка рядом отсутствуют для этого участка
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* Headline-bar — один вердикт-предложение */}
|
||
{verdict && (
|
||
<div
|
||
style={{
|
||
background: "var(--bg-headline, #0F172A)",
|
||
borderRadius: 12,
|
||
padding: "14px 18px",
|
||
marginBottom: 16,
|
||
fontSize: 14,
|
||
lineHeight: 1.4,
|
||
color: "var(--fg-on-dark, #E2E8F0)",
|
||
}}
|
||
>
|
||
{verdict}
|
||
</div>
|
||
)}
|
||
|
||
<div
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))",
|
||
gap: 16,
|
||
}}
|
||
>
|
||
{hasVelocity && (
|
||
<div>
|
||
<SubBlockLabel>Темп продаж</SubBlockLabel>
|
||
<div
|
||
style={{
|
||
background: "var(--bg-card, #FFFFFF)",
|
||
border: "1px solid var(--border-card, #E6E8EC)",
|
||
borderRadius: 10,
|
||
padding: "14px 18px",
|
||
}}
|
||
>
|
||
<VelocityBlock velocity={data.velocity} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
{hasPipeline && (
|
||
<div>
|
||
<SubBlockLabel>
|
||
Будущее предложение конкурентов (24 мес)
|
||
</SubBlockLabel>
|
||
<Pipeline24moBlock data={data.pipeline_24mo!} />
|
||
</div>
|
||
)}
|
||
{hasTrend && (
|
||
<div>
|
||
<SubBlockLabel>Динамика цен</SubBlockLabel>
|
||
<MarketTrendBlock trend={data.market_trend} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Competitor detail drawer ───────────────────────────────────────────────────
|
||
|
||
const STATUS_BADGE_MAP: Record<
|
||
string,
|
||
{ variant: "success" | "neutral" | "warning"; label: string }
|
||
> = {
|
||
Строящиеся: { variant: "warning", label: "Строящийся" },
|
||
Сданные: { variant: "success", label: "Сдан" },
|
||
};
|
||
|
||
function CompetitorDetailDrawer({
|
||
competitor,
|
||
onClose,
|
||
}: {
|
||
competitor: ParcelAnalysisCompetitor | null;
|
||
onClose: () => void;
|
||
}) {
|
||
const isOpen = competitor !== null;
|
||
|
||
return (
|
||
<Drawer open={isOpen} onClose={onClose} side="right" width="420px">
|
||
{competitor && (
|
||
<>
|
||
{/* Header */}
|
||
<div
|
||
style={{
|
||
padding: "16px 20px 12px",
|
||
borderBottom: "1px solid var(--border-soft, #EEF0F3)",
|
||
display: "flex",
|
||
alignItems: "flex-start",
|
||
gap: 8,
|
||
}}
|
||
>
|
||
<Building2
|
||
size={16}
|
||
strokeWidth={1.5}
|
||
style={{
|
||
color: "var(--fg-secondary, #5B6066)",
|
||
marginTop: 3,
|
||
flexShrink: 0,
|
||
}}
|
||
/>
|
||
<div style={{ flex: 1, minWidth: 0 }}>
|
||
<div
|
||
style={{
|
||
fontSize: 15,
|
||
fontWeight: 600,
|
||
color: "var(--fg-primary, #111111)",
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{competitor.comm_name ?? `ЖК #${competitor.obj_id}`}
|
||
</div>
|
||
{competitor.dev_name && (
|
||
<div
|
||
style={{
|
||
fontSize: 12,
|
||
color: "var(--fg-secondary, #5B6066)",
|
||
marginTop: 2,
|
||
}}
|
||
>
|
||
{competitor.dev_name}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
aria-label="Закрыть"
|
||
style={{
|
||
background: "none",
|
||
border: "none",
|
||
cursor: "pointer",
|
||
padding: 4,
|
||
color: "var(--fg-tertiary, #73767E)",
|
||
flexShrink: 0,
|
||
}}
|
||
>
|
||
<X size={18} strokeWidth={1.5} />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Status + badges row */}
|
||
<div
|
||
style={{
|
||
padding: "12px 20px",
|
||
borderBottom: "1px solid var(--border-soft, #EEF0F3)",
|
||
display: "flex",
|
||
flexWrap: "wrap",
|
||
gap: 8,
|
||
alignItems: "center",
|
||
}}
|
||
>
|
||
{competitor.site_status && (
|
||
<Badge
|
||
variant={
|
||
STATUS_BADGE_MAP[competitor.site_status]?.variant ?? "neutral"
|
||
}
|
||
>
|
||
{STATUS_BADGE_MAP[competitor.site_status]?.label ??
|
||
competitor.site_status}
|
||
</Badge>
|
||
)}
|
||
{competitor.obj_class && (
|
||
<Badge variant="info">{competitor.obj_class}</Badge>
|
||
)}
|
||
</div>
|
||
|
||
{/* KPI grid */}
|
||
<div
|
||
style={{
|
||
padding: "16px 20px",
|
||
display: "grid",
|
||
gridTemplateColumns: "1fr 1fr",
|
||
gap: 12,
|
||
}}
|
||
>
|
||
<KpiCell
|
||
icon={<MapPin size={14} strokeWidth={1.5} />}
|
||
label="Расстояние"
|
||
value={`${Math.round(competitor.distance_m).toLocaleString("ru-RU")} м`}
|
||
/>
|
||
<KpiCell
|
||
icon={<Users size={14} strokeWidth={1.5} />}
|
||
label="Квартир"
|
||
value={
|
||
competitor.flat_count != null
|
||
? competitor.flat_count.toLocaleString("ru-RU")
|
||
: "—"
|
||
}
|
||
/>
|
||
{competitor.district_name && (
|
||
<KpiCell
|
||
icon={<MapPin size={14} strokeWidth={1.5} />}
|
||
label="Район"
|
||
value={competitor.district_name}
|
||
/>
|
||
)}
|
||
{competitor.ready_dt && (
|
||
<KpiCell
|
||
icon={<Ruler size={14} strokeWidth={1.5} />}
|
||
label="Срок сдачи"
|
||
value={new Date(competitor.ready_dt).toLocaleDateString(
|
||
"ru-RU",
|
||
{
|
||
year: "numeric",
|
||
month: "2-digit",
|
||
},
|
||
)}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{/* Caveat */}
|
||
<div
|
||
style={{
|
||
margin: "0 20px 20px",
|
||
padding: "10px 14px",
|
||
background: "var(--bg-card-alt, #FAFBFC)",
|
||
border: "1px solid var(--border-soft, #EEF0F3)",
|
||
borderRadius: 8,
|
||
fontSize: 12,
|
||
color: "var(--fg-tertiary, #73767E)",
|
||
}}
|
||
>
|
||
Детальные данные (sold%, цены, план. продажи) появятся после
|
||
расширения B5
|
||
</div>
|
||
</>
|
||
)}
|
||
</Drawer>
|
||
);
|
||
}
|
||
|
||
function KpiCell({
|
||
icon,
|
||
label,
|
||
value,
|
||
}: {
|
||
icon: ReactNode;
|
||
label: string;
|
||
value: string;
|
||
}) {
|
||
return (
|
||
<div
|
||
style={{
|
||
background: "var(--bg-card-alt, #FAFBFC)",
|
||
border: "1px solid var(--border-card, #E6E8EC)",
|
||
borderRadius: 8,
|
||
padding: "10px 12px",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 4,
|
||
fontSize: 11,
|
||
fontWeight: 500,
|
||
textTransform: "uppercase" as const,
|
||
letterSpacing: "0.04em",
|
||
color: "var(--fg-secondary, #5B6066)",
|
||
marginBottom: 4,
|
||
}}
|
||
>
|
||
<span style={{ display: "flex", color: "var(--fg-tertiary, #73767E)" }}>
|
||
{icon}
|
||
</span>
|
||
{label}
|
||
</div>
|
||
<div
|
||
style={{
|
||
fontSize: 14,
|
||
fontWeight: 600,
|
||
color: "var(--fg-primary, #111111)",
|
||
fontVariantNumeric: "tabular-nums",
|
||
}}
|
||
>
|
||
{value}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Derived: filter competitors client-side ───────────────────────────────────
|
||
|
||
function applyFilters(
|
||
data: ParcelAnalysis,
|
||
filters: FilterState,
|
||
): ParcelAnalysis["competitors"] {
|
||
// Guard: a 202 on-demand-fetch stub (#1001) or older backend may omit the
|
||
// array entirely — fall back to empty so we render an empty state, not throw.
|
||
let result = [...(data.competitors ?? [])];
|
||
|
||
// Radius filter — distance_m is in metres from the parcel centroid
|
||
result = result.filter((c) => c.distance_m <= filters.radiusKm * 1000);
|
||
|
||
// Note: the competitor type doesn't carry status/date fields yet (B6 extension).
|
||
// The chips are wired to state but can't filter until backend enriches data.
|
||
// They're kept in state so backend pass-through (Wave 4) can use them.
|
||
|
||
return result;
|
||
}
|
||
|
||
// ── Section 3 wrapper ─────────────────────────────────────────────────────────
|
||
|
||
export function Section3SettingsAndCompetitors({ cad, data }: Props) {
|
||
const [filters, setFilters] = useState<FilterState>({
|
||
radiusKm: 2,
|
||
onlyUnderConstruction: false,
|
||
minDeadline3mo: false,
|
||
maxAge6mo: false,
|
||
onlyApartments: false,
|
||
});
|
||
const [selectedCompetitor, setSelectedCompetitor] =
|
||
useState<ParcelAnalysisCompetitor | null>(null);
|
||
|
||
const filteredCompetitors = applyFilters(data, filters);
|
||
// Normalise once — a 202 stub / older backend may omit `competitors` (#1001).
|
||
const competitorCount = (data.competitors ?? []).length;
|
||
const districtName = data.district?.district_name ?? null;
|
||
|
||
return (
|
||
<section id="section-3" style={{ scrollMarginTop: 72 }}>
|
||
{/* Section headline bar */}
|
||
<div
|
||
style={{
|
||
background: "var(--bg-headline, #0F172A)",
|
||
borderRadius: 12,
|
||
padding: "14px 18px",
|
||
marginBottom: 20,
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
fontSize: 15,
|
||
fontWeight: 600,
|
||
color: "var(--fg-on-dark, #E2E8F0)",
|
||
}}
|
||
>
|
||
3. Продажи конкурентов
|
||
</div>
|
||
<div
|
||
style={{
|
||
marginTop: 6,
|
||
fontSize: 12,
|
||
color: "var(--fg-on-dark-muted, #94A3B8)",
|
||
}}
|
||
>
|
||
{competitorCount > 0
|
||
? `${filteredCompetitors.length} из ${competitorCount} объектов в радиусе ${filters.radiusKm} км`
|
||
: "Конкуренты не найдены в радиусе анализа"}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Sub-sections */}
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
|
||
<Section31Settings filters={filters} onFiltersChange={setFilters} />
|
||
|
||
{/* Competitor table — moved before 3.2/3.3 for context */}
|
||
{filteredCompetitors.length > 0 && (
|
||
<CompetitorTable
|
||
competitors={filteredCompetitors}
|
||
districtName={districtName}
|
||
onRowClick={setSelectedCompetitor}
|
||
/>
|
||
)}
|
||
|
||
<Section32Layouts cad={cad} />
|
||
<Section33MarketSales data={data} />
|
||
</div>
|
||
|
||
{/* Filtered competitors count hint */}
|
||
{competitorCount > 0 &&
|
||
filteredCompetitors.length !== competitorCount && (
|
||
<div
|
||
style={{
|
||
marginTop: 16,
|
||
padding: "10px 14px",
|
||
background: "var(--warn-soft, #FEF3C7)",
|
||
borderRadius: 8,
|
||
fontSize: 12,
|
||
color: "var(--warn, #9A6700)",
|
||
}}
|
||
>
|
||
Фильтр по радиусу: показано {filteredCompetitors.length} из{" "}
|
||
{competitorCount} конкурентов
|
||
</div>
|
||
)}
|
||
|
||
{/* Competitor detail drawer */}
|
||
<CompetitorDetailDrawer
|
||
competitor={selectedCompetitor}
|
||
onClose={() => setSelectedCompetitor(null)}
|
||
/>
|
||
</section>
|
||
);
|
||
}
|