gendesign/frontend/src/components/site-finder/MarketTab.tsx
Light1YT 86e9ea2937 fix(week-review): автофиксы код-ревью — 169 issue (label «week ревью 1»)
Многоагентный аудит + имплементация: один воркер на файл, точечные правки.
Верификация: py_compile (47/47 .py) + tsc --noEmit (0 ошибок). Unit-тесты
не прогонялись (окружение не поднято: rollup native dep / нет pytest-venv).

Полностью исправлено (169): #1336, #1337, #1339, #1340, #1341, #1342, #1343, #1345, #1346, #1348, #1349, #1350, #1351, #1354, #1356, #1358, #1359, #1360, #1362, #1364, #1365, #1366, #1367, #1368, #1369, #1370, #1371, #1372, #1373, #1374, #1375, #1376, #1377, #1378, #1379, #1380, #1381, #1382, #1384, #1385, #1386, #1387, #1388, #1389, #1390, #1391, #1392, #1394, #1395, #1396, #1397, #1399, #1400, #1401, #1402, #1403, #1404, #1408, #1409, #1410, #1411, #1412, #1413, #1414, #1415, #1416, #1417, #1418, #1420, #1423, #1425, #1426, #1427, #1428, #1429, #1430, #1431, #1432, #1433, #1434, #1435, #1437, #1438, #1439, #1440, #1441, #1442, #1443, #1444, #1445, #1446, #1447, #1448, #1449, #1450, #1451, #1452, #1453, #1454, #1455, #1456, #1457, #1458, #1459, #1460, #1461, #1462, #1463, #1464, #1465, #1466, #1467, #1468, #1469, #1471, #1472, #1473, #1474, #1476, #1478, #1479, #1481, #1482, #1483, #1484, #1485, #1487, #1488, #1489, #1490, #1491, #1492, #1493, #1494, #1495, #1496, #1497, #1499, #1500, #1501, #1502, #1504, #1505, #1506, #1507, #1510, #1514, #1515, #1516, #1517, #1518, #1519, #1521, #1522, #1523, #1524, #1525, #1526, #1527, #1528, #1529, #1531, #1532, #1533, #1534, #1535, #1536, #1537, #1538

Частично (9, in-file часть, остаток cross-file): #1361, #1419, #1422, #1424, #1470, #1475, #1477, #1480, #1498
Требуют cross-file (3, не тронуты): #1338, #1363, #1421
Пропущено (1): #1539

Не входило в партию: 22 needs-Leha issue (нужны решения владельца).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:21:11 +05:00

319 lines
11 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 type { ParcelAnalysis } from "@/types/site-finder";
import { SectionLabel } from "@/components/ui/SectionLabel";
import { EmptyState } from "@/components/ui/EmptyState";
import { MarketTrendBlock } from "./MarketTrendBlock";
import { BestLayoutsBlock } from "./BestLayoutsBlock";
import { CompetitorTable } from "./CompetitorTable";
import { Pipeline24moBlock } from "./Pipeline24moBlock";
import { SuccessRecommendationBlock } from "./SuccessRecommendationBlock";
import { VelocityBlock } from "./VelocityBlock";
interface Props {
data: ParcelAnalysis;
}
/** Derive radius_km from available nested fields. */
function getRadiusKm(data: ParcelAnalysis): number {
return data.market_trend?.radius_km ?? data.pipeline_24mo?.radius_km ?? 3;
}
/**
* Max success score (%) from success_recommendation ranking.
* Returns null when recommendation is absent or ranking is empty.
*/
function getMaxMixPct(data: ParcelAnalysis): number | null {
const ranking = data.success_recommendation?.ranking;
if (!ranking || ranking.length === 0) return null;
const maxScore = Math.max(...ranking.map((r) => r.success_score));
return Math.round(maxScore * 100);
}
/**
* Estimated sales period in months (supply / flats_per_month).
* Uses pipeline_24mo.flats_total as supply proxy, velocity for rate.
* Assumes ~50 m² avg flat for unit conversion.
*/
function getSalesPeriodMonths(data: ParcelAnalysis): number | null {
const supply = data.pipeline_24mo?.flats_total;
const velocitySqm = data.velocity?.monthly_velocity_sqm;
if (!supply || !velocitySqm || velocitySqm <= 0) return null;
const flatsPerMonth = velocitySqm / 50;
return Math.round(supply / flatsPerMonth);
}
export function MarketTab({ data }: Props) {
const hasTrend = "market_trend" in data;
const hasRecommendation = "success_recommendation" in data;
const hasPipeline = data.pipeline_24mo !== undefined;
const hasVelocity = "velocity" in data;
const hasAny =
hasTrend ||
hasRecommendation ||
hasPipeline ||
hasVelocity ||
data.competitors.length > 0;
// ── Above-the-fold values ────────────────────────────────────────────────
// Maxim's spec: «ЖК старше года → не конкурент». Filter active competitors:
// - site_status === 'Строящиеся' → активен
// - либо ready_dt в будущем → активен (готовность ещё не наступила)
// - либо ready_dt в пределах последнего года → активен (свежие продажи)
// - иначе (Сданные >1y назад) → не конкурент, скрыт
const REL_MS = 365 * 24 * 60 * 60 * 1000;
const yearAgoTs = Date.now() - REL_MS;
const relevantCompetitors = data.competitors.filter((c) => {
if (c.site_status === "Строящиеся") return true;
if (!c.ready_dt) return false;
const ts = new Date(c.ready_dt).getTime();
if (Number.isNaN(ts)) return false;
return ts >= yearAgoTs;
});
const activeCount = relevantCompetitors.length;
const radiusKm = getRadiusKm(data);
// Treat zero velocity as "no data" — backend returns 0 when competitors
// are unmapped to Objective ground truth (OBJ-3). Showing "0.0 м²/мес"
// is misleading; "—" makes it clear data is unavailable.
const velocityRaw = data.velocity?.monthly_velocity_sqm;
const velocityPerMonth =
velocityRaw != null && velocityRaw > 0 ? velocityRaw : null;
const maxMixPct = getMaxMixPct(data);
const salesPeriodMonths = getSalesPeriodMonths(data);
const showHeadlineBar = activeCount > 0 || velocityPerMonth !== null;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
{/* ── Above-the-fold: Headline-bar + 3 KPI ─────────────────────────── */}
{showHeadlineBar && (
<>
{/* Headline-bar — one sentence verdict */}
<div
style={{
background: "var(--bg-headline, #0F172A)",
color: "var(--fg-on-dark, #E2E8F0)",
borderRadius: 12,
padding: "14px 18px",
fontSize: 15,
fontWeight: 500,
lineHeight: 1.4,
}}
>
<strong>
{activeCount} активных в {radiusKm}км
</strong>
{" · велосити "}
<strong>
{velocityPerMonth !== null
? `${velocityPerMonth.toFixed(1)} м²/мес`
: "—"}
</strong>
{" · рекомендуемый mix "}
<strong>{maxMixPct !== null ? `${maxMixPct}%` : "—"}</strong>
</div>
{/* 3 KPI cards */}
<div className="grid grid-cols-3 gap-3 mb-4">
{/* KPI 1: Активные ЖК */}
<div
style={{
background: "#fff",
border: "1px solid #e6e8ec",
borderRadius: 12,
padding: "12px 16px",
}}
>
<div
style={{
fontSize: 11,
textTransform: "uppercase" as const,
letterSpacing: "0.04em",
color: "#5b6066",
fontWeight: 500,
}}
>
Активные ЖК
</div>
<div
style={{
marginTop: 6,
fontSize: 28,
fontWeight: 600,
color: "#111",
fontVariantNumeric: "tabular-nums",
}}
>
{activeCount}
</div>
<div style={{ marginTop: 4, fontSize: 12, color: "#73767e" }}>
строящихся и недавно сданных в радиусе {radiusKm}км
</div>
</div>
{/* KPI 2: Велосити */}
<div
style={{
background: "#fff",
border: "1px solid #e6e8ec",
borderRadius: 12,
padding: "12px 16px",
}}
>
<div
style={{
fontSize: 11,
textTransform: "uppercase" as const,
letterSpacing: "0.04em",
color: "#5b6066",
fontWeight: 500,
}}
>
Велосити
</div>
<div
style={{
marginTop: 6,
display: "flex",
alignItems: "baseline",
gap: 4,
}}
>
<span
style={{
fontSize: 28,
fontWeight: 600,
color: "#111",
fontVariantNumeric: "tabular-nums",
}}
>
{velocityPerMonth !== null
? velocityPerMonth.toFixed(1)
: "—"}
</span>
{velocityPerMonth !== null && (
<span style={{ fontSize: 13, color: "#5b6066" }}>м²/мес</span>
)}
</div>
<div style={{ marginTop: 4, fontSize: 12, color: "#73767e" }}>
ср. темп продаж конкурентов
</div>
</div>
{/* KPI 3: Срок продаж */}
<div
style={{
background: "#fff",
border: "1px solid #e6e8ec",
borderRadius: 12,
padding: "12px 16px",
}}
>
<div
style={{
fontSize: 11,
textTransform: "uppercase" as const,
letterSpacing: "0.04em",
color: "#5b6066",
fontWeight: 500,
}}
>
Срок продаж
</div>
<div
style={{
marginTop: 6,
display: "flex",
alignItems: "baseline",
gap: 4,
}}
>
<span
style={{
fontSize: 28,
fontWeight: 600,
color: "#111",
fontVariantNumeric: "tabular-nums",
}}
>
{salesPeriodMonths !== null ? salesPeriodMonths : "—"}
</span>
{salesPeriodMonths !== null && (
<span style={{ fontSize: 13, color: "#5b6066" }}>мес</span>
)}
</div>
<div style={{ marginTop: 4, fontSize: 12, color: "#73767e" }}>
supply / велосити (оценка)
</div>
</div>
</div>
</>
)}
{/* ── Main content blocks ───────────────────────────────────────────── */}
{/* Competitors */}
{relevantCompetitors.length > 0 && (
<div>
<SectionLabel style={{ marginBottom: 12 }}>
Конкуренты ({relevantCompetitors.length} из{" "}
{data.competitors.length}
{" — фильтр: строящиеся или сданы ≤1 года)"}
</SectionLabel>
<CompetitorTable
competitors={relevantCompetitors}
districtName={data.district?.district_name}
/>
</div>
)}
{/* Issue #113 — data-driven ТЗ на проектирование */}
<BestLayoutsBlock cadNum={data.cad_num} />
{/* D2 (#34) — Velocity-score */}
{hasVelocity && (
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "14px 18px",
background: "#fff",
}}
>
<VelocityBlock velocity={data.velocity} />
</div>
)}
{/* Market trend */}
{hasTrend && (
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "14px 18px",
background: "#fff",
}}
>
<MarketTrendBlock trend={data.market_trend} />
</div>
)}
{/* D4 (#36) — Pipeline 24mo */}
{data.pipeline_24mo && <Pipeline24moBlock data={data.pipeline_24mo} />}
{/* Success recommendation */}
{hasRecommendation && (
<div>
<SectionLabel style={{ marginBottom: 12 }}>
Что хорошо продаётся
</SectionLabel>
<SuccessRecommendationBlock
recommendation={data.success_recommendation}
/>
</div>
)}
{!hasAny && <EmptyState message="Рыночные данные недоступны" />}
</div>
);
}