gendesign/frontend/src/components/site-finder/VelocityBlock.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

363 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 { Velocity } from "@/types/site-finder";
import { SectionLabel } from "@/components/ui/SectionLabel";
import { EmptyState } from "@/components/ui/EmptyState";
const BUCKET_ORDER = ["студия", "1", "2", "3", "4", "5+"] as const;
type KnownBucket = (typeof BUCKET_ORDER)[number];
const BUCKET_LABEL: Record<KnownBucket, string> = {
студия: "Студия",
"1": "1-к",
"2": "2-к",
"3": "3-к",
"4": "4-к",
"5+": "5+",
};
interface VelocityBlockProps {
velocity: Velocity | null | undefined;
}
const CONFIDENCE_COLOR: Record<
Velocity["confidence"],
{ bg: string; fg: string }
> = {
high: { bg: "#dcfce7", fg: "#166534" },
medium: { bg: "#fef3c7", fg: "#854d0e" },
low: { bg: "#fee2e2", fg: "#991b1b" },
};
const CONFIDENCE_LABEL: Record<Velocity["confidence"], string> = {
high: "Высокая",
medium: "Средняя",
low: "Низкая",
};
function formatPercent(score: number): string {
return `${Math.round(score * 100)}%`;
}
export function VelocityBlock({ velocity }: VelocityBlockProps) {
if (!velocity) {
return (
<div>
<SectionLabel style={{ marginBottom: 8 }}>
Темп продаж конкурентов
</SectionLabel>
<EmptyState message="Данные о продажах не найдены (нет конкурентов в радиусе 3км или нет sale_graph данных)" />
</div>
);
}
const dataAvailable = velocity.velocity_data_available !== false;
const isRosreestrFallback = velocity.velocity_source === "rosreestr_fallback";
const confColor = CONFIDENCE_COLOR[velocity.confidence];
const scorePct = formatPercent(velocity.velocity_score);
const ratio = velocity.monthly_velocity_sqm / velocity.ekb_median_sqm;
return (
<div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 8,
}}
>
<SectionLabel>Темп продаж конкурентов</SectionLabel>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
{!dataAvailable && (
<span className="bg-slate-100 text-slate-500 text-xs px-2 py-0.5 rounded">
нет данных velocity
</span>
)}
{isRosreestrFallback && (
<span className="bg-amber-50 text-amber-800 text-xs px-2 py-0.5 rounded">
Источник: квартальные сделки
</span>
)}
<span
style={{
padding: "2px 8px",
background: confColor.bg,
color: confColor.fg,
borderRadius: 4,
fontSize: 11,
fontWeight: 600,
}}
title={`Уверенность: ${CONFIDENCE_LABEL[velocity.confidence]}`}
>
{CONFIDENCE_LABEL[velocity.confidence]}
</span>
</div>
</div>
{/* Score gauge — показываем только если данные есть */}
{dataAvailable && (
<div style={{ marginBottom: 12 }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 12,
color: "#6b7280",
marginBottom: 4,
}}
>
<span>Velocity-score</span>
<span style={{ fontWeight: 600, color: "#111827" }}>
{scorePct}
</span>
</div>
<div
style={{
background: "#e5e7eb",
borderRadius: 4,
height: 8,
overflow: "hidden",
}}
>
<div
style={{
background:
velocity.velocity_score >= 0.66
? "#10b981"
: velocity.velocity_score >= 0.33
? "#f59e0b"
: "#ef4444",
width: `${velocity.velocity_score * 100}%`,
height: "100%",
}}
/>
</div>
<div style={{ fontSize: 11, color: "#9ca3af", marginTop: 4 }}>
{Math.round(velocity.monthly_velocity_sqm)} м²/мес vs{" "}
{Math.round(velocity.ekb_median_sqm)} м²/мес (медиана ЕКБ) &middot;{" "}
{ratio >= 1
? `x${ratio.toFixed(1)} выше`
: `${formatPercent(ratio)} от среднего`}
</div>
</div>
)}
{/* Period + competitors meta */}
<div style={{ fontSize: 12, color: "#6b7280", marginBottom: 8 }}>
В радиусе 3 км: <b>{velocity.competitors_count}</b> ЖК
{dataAvailable && (
<>
{" "}
&middot; период{" "}
<b>
{velocity.period.start} &rarr; {velocity.period.end}
</b>{" "}
({velocity.months_observed} мес)
</>
)}
</div>
{/* By room bucket aggregate */}
{velocity.by_room_bucket && (
<div style={{ marginBottom: 12 }}>
<SectionLabel>По комнатности</SectionLabel>
<table
style={{
width: "100%",
fontSize: 12,
borderCollapse: "collapse",
marginTop: 4,
}}
>
<thead>
<tr
style={{ color: "#6b7280", borderBottom: "1px solid #e5e7eb" }}
>
<th
style={{
textAlign: "left",
padding: "4px 8px",
fontWeight: 500,
}}
>
Комната
</th>
<th
style={{
textAlign: "right",
padding: "4px 8px",
fontWeight: 500,
}}
>
Сделок
</th>
<th
style={{
textAlign: "right",
padding: "4px 8px",
fontWeight: 500,
}}
>
м²
</th>
<th
style={{
textAlign: "right",
padding: "4px 8px",
fontWeight: 500,
}}
>
ЖК
</th>
</tr>
</thead>
<tbody>
{BUCKET_ORDER.map((b) => {
const stat = velocity.by_room_bucket?.[b];
if (!stat) return null;
return (
<tr key={b} style={{ borderBottom: "1px solid #f3f4f6" }}>
<td style={{ padding: "4px 8px" }}>{BUCKET_LABEL[b]}</td>
<td
style={{
padding: "4px 8px",
textAlign: "right",
fontWeight: 500,
}}
>
{stat.units.toLocaleString("ru")}
</td>
<td
style={{
padding: "4px 8px",
textAlign: "right",
color: "#6b7280",
}}
>
{Math.round(stat.sqm).toLocaleString("ru")}
</td>
<td
style={{
padding: "4px 8px",
textAlign: "right",
color: "#6b7280",
}}
>
{stat.complexes_count}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* Top competitors */}
{dataAvailable && velocity.sample_competitors.length > 0 && (
<div>
<SectionLabel style={{ marginBottom: 6 }}>Топ продавцов</SectionLabel>
<table
style={{
width: "100%",
fontSize: 12,
borderCollapse: "collapse",
marginTop: 4,
}}
>
<thead>
<tr
style={{
color: "#6b7280",
borderBottom: "1px solid #e5e7eb",
}}
>
<th
style={{
textAlign: "left",
padding: "4px 8px",
fontWeight: 500,
}}
>
Название
</th>
<th
style={{
textAlign: "right",
padding: "4px 8px",
fontWeight: 500,
}}
>
Расст.
</th>
<th
style={{
textAlign: "right",
padding: "4px 8px",
fontWeight: 500,
}}
>
Продано м²
</th>
</tr>
</thead>
<tbody>
{velocity.sample_competitors.map((c) => (
<tr
key={c.obj_id}
style={{ borderBottom: "1px solid #f3f4f6" }}
>
<td style={{ padding: "4px 8px" }}>
{c.name ?? `obj#${c.obj_id}`}
{c.obj_class && (
<span
style={{
marginLeft: 6,
fontSize: 10,
color: "#9ca3af",
}}
>
{c.obj_class}
</span>
)}
{c.by_room_bucket && (
<div
style={{ fontSize: 10, color: "#9ca3af", marginTop: 2 }}
>
{BUCKET_ORDER.filter(
(b) => (c.by_room_bucket?.[b] ?? 0) > 0,
)
.map(
(b) => `${BUCKET_LABEL[b]} ${c.by_room_bucket![b]}`,
)
.join(" · ")}
</div>
)}
</td>
<td
style={{
padding: "4px 8px",
textAlign: "right",
color: "#6b7280",
}}
>
{Math.round(c.distance_m)}м
</td>
<td
style={{
padding: "4px 8px",
textAlign: "right",
fontWeight: 500,
}}
>
{Math.round(c.total_sqm_period).toLocaleString("ru")}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}