Многоагентный аудит + имплементация: один воркер на файл, точечные правки. Верификация: 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>
1048 lines
33 KiB
TypeScript
1048 lines
33 KiB
TypeScript
"use client";
|
||
|
||
import { Fragment, useState } from "react";
|
||
import { useBestLayouts } from "@/hooks/useBestLayouts";
|
||
import { API_BASE_URL } from "@/lib/api";
|
||
import type {
|
||
BestLayoutsRequest,
|
||
BestLayoutsResponse,
|
||
Confidence,
|
||
LayoutTzMixRow,
|
||
TimeWindow,
|
||
TopLayoutRow,
|
||
} from "@/types/best-layouts";
|
||
|
||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||
|
||
const CONFIDENCE_STYLES: Record<Confidence, { cls: string; label: string }> = {
|
||
high: { cls: "bg-emerald-100 text-emerald-800", label: "Высокое" },
|
||
medium: { cls: "bg-amber-100 text-amber-800", label: "Среднее" },
|
||
low: { cls: "bg-rose-100 text-rose-800", label: "Низкое" },
|
||
};
|
||
|
||
const TIME_WINDOW_LABELS: Record<TimeWindow, string> = {
|
||
last_month: "Последний месяц",
|
||
last_quarter: "Последний квартал",
|
||
last_year: "Последний год",
|
||
};
|
||
|
||
// Период «распроданности» для провенанса — соответствует выбранному time_window
|
||
// (backend считает sold_pct_of_supply по сделкам этого окна).
|
||
const TIME_WINDOW_OVERSOLD_PERIOD: Record<TimeWindow, string> = {
|
||
last_month: "за последний месяц",
|
||
last_quarter: "за последний квартал",
|
||
last_year: "за последний год",
|
||
};
|
||
|
||
const ROOM_BUCKET_LABELS: Record<string, string> = {
|
||
studio: "Студия",
|
||
"1": "1-комн.",
|
||
"2": "2-комн.",
|
||
"3": "3-комн.",
|
||
"4+": "4+ комн.",
|
||
};
|
||
|
||
// ── Sub-components ────────────────────────────────────────────────────────────
|
||
|
||
function DataQualityCard({ dq }: { dq: BestLayoutsResponse["data_quality"] }) {
|
||
const conf = CONFIDENCE_STYLES[dq.confidence];
|
||
return (
|
||
<div className="border border-gray-200 rounded-xl px-[18px] py-[14px] bg-white flex items-center gap-4 flex-wrap">
|
||
<span className="font-semibold text-[13px] text-gray-700 mr-1">
|
||
Качество данных:
|
||
</span>
|
||
<span
|
||
className={`px-[10px] py-[2px] rounded-md text-xs font-semibold ${conf.cls}`}
|
||
>
|
||
{conf.label}
|
||
</span>
|
||
<span className="text-xs text-gray-500">
|
||
Покрытие {dq.velocity_coverage_pct.toFixed(0)}% (
|
||
{dq.objects_with_velocity_data} из {dq.objects_total_in_radius} ЖК)
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const COMPETITOR_PREVIEW_LIMIT = 5;
|
||
|
||
function salesPeriodMonths(supply: number, velocity: number): number | null {
|
||
if (velocity <= 0) return null;
|
||
return Math.ceil(supply / velocity);
|
||
}
|
||
|
||
function SalesPeriodCell({ months }: { months: number | null }) {
|
||
if (months === null) return <span className="text-gray-400">—</span>;
|
||
if (months < 12)
|
||
return (
|
||
<span className="text-emerald-600 font-semibold tabular-nums">
|
||
{months} мес
|
||
</span>
|
||
);
|
||
if (months <= 24)
|
||
return <span className="text-slate-600 tabular-nums">{months} мес</span>;
|
||
return <span className="text-amber-600 tabular-nums">{months} мес</span>;
|
||
}
|
||
|
||
function CompetitorDrillIn({
|
||
ids,
|
||
colSpan,
|
||
}: {
|
||
ids: number[];
|
||
colSpan: number;
|
||
}) {
|
||
const [showAll, setShowAll] = useState(false);
|
||
const visible = showAll ? ids : ids.slice(0, COMPETITOR_PREVIEW_LIMIT);
|
||
const hidden = ids.length - COMPETITOR_PREVIEW_LIMIT;
|
||
|
||
return (
|
||
<tr className="bg-blue-50">
|
||
<td colSpan={colSpan} style={{ padding: "8px 16px" }}>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
flexWrap: "wrap",
|
||
gap: 6,
|
||
fontSize: 11,
|
||
}}
|
||
>
|
||
<span
|
||
className="text-gray-500 font-semibold"
|
||
style={{ marginRight: 4 }}
|
||
>
|
||
ЖК-источники:
|
||
</span>
|
||
{visible.map((id) => (
|
||
<a
|
||
key={id}
|
||
href={`https://наш.дом.рф/services/catalog-newbuildings/object/${id}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="bg-blue-100 text-blue-700 rounded px-2 py-0.5 font-mono hover:bg-blue-200 transition-colors"
|
||
>
|
||
{id}
|
||
</a>
|
||
))}
|
||
{!showAll && hidden > 0 && (
|
||
<button
|
||
onClick={() => setShowAll(true)}
|
||
className="text-blue-600 underline text-[11px] cursor-pointer bg-transparent border-none p-0"
|
||
>
|
||
и ещё {hidden}
|
||
</button>
|
||
)}
|
||
{showAll && ids.length > COMPETITOR_PREVIEW_LIMIT && (
|
||
<button
|
||
onClick={() => setShowAll(false)}
|
||
className="text-gray-400 underline text-[11px] cursor-pointer bg-transparent border-none p-0"
|
||
>
|
||
свернуть
|
||
</button>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
function TopLayoutsTable({
|
||
rows,
|
||
oversoldPeriodLabel,
|
||
}: {
|
||
rows: TopLayoutRow[];
|
||
oversoldPeriodLabel: string;
|
||
}) {
|
||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
|
||
|
||
if (rows.length === 0) {
|
||
return (
|
||
<div className="text-gray-400 text-[13px] py-3">
|
||
Данных недостаточно для ранжирования планировок
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function toggleRow(signature: string) {
|
||
setExpandedRows((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(signature)) {
|
||
next.delete(signature);
|
||
} else {
|
||
next.add(signature);
|
||
}
|
||
return next;
|
||
});
|
||
}
|
||
|
||
const TOTAL_COLS = 9; // # Тип Площадь Скорость СрПлощадь СрЦена СрокПродажи Продано Chevron
|
||
|
||
const headers = [
|
||
"#",
|
||
"Тип",
|
||
"Площадь",
|
||
"Скорость / мес",
|
||
"Средн. площадь, м²",
|
||
"Средн. цена, ₽/м²",
|
||
"Срок продажи (мес)",
|
||
"Продано, %",
|
||
"",
|
||
];
|
||
|
||
return (
|
||
<div className="border border-gray-200 rounded-xl overflow-hidden bg-white">
|
||
<div className="px-[18px] py-3 bg-gray-50 border-b border-gray-200 font-semibold text-[13px] text-gray-700">
|
||
Топ планировок ({rows.length})
|
||
</div>
|
||
<div style={{ overflowX: "auto" }}>
|
||
<table
|
||
style={{ width: "100%", borderCollapse: "collapse", fontSize: 12 }}
|
||
>
|
||
<thead>
|
||
<tr className="bg-gray-50">
|
||
{headers.map((h, idx) => (
|
||
<th
|
||
key={idx}
|
||
className="px-3 py-2 text-left border-b border-gray-200 font-semibold text-gray-700 whitespace-nowrap"
|
||
>
|
||
{h}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((row, i) => {
|
||
const isExpanded = expandedRows.has(row.signature);
|
||
const months = salesPeriodMonths(
|
||
row.supply_units_in_radius,
|
||
row.velocity_per_month,
|
||
);
|
||
const hasCompetitors = row.competitor_obj_ids.length > 0;
|
||
|
||
return (
|
||
<Fragment key={row.signature}>
|
||
<tr
|
||
onClick={() => {
|
||
if (hasCompetitors) toggleRow(row.signature);
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (
|
||
hasCompetitors &&
|
||
(e.key === "Enter" || e.key === " ")
|
||
) {
|
||
e.preventDefault();
|
||
toggleRow(row.signature);
|
||
}
|
||
}}
|
||
tabIndex={hasCompetitors ? 0 : undefined}
|
||
role={hasCompetitors ? "button" : undefined}
|
||
aria-expanded={hasCompetitors ? isExpanded : undefined}
|
||
style={{
|
||
background:
|
||
i % 2 === 0 ? "var(--bg-card)" : "var(--bg-card-alt)",
|
||
borderBottom: isExpanded
|
||
? "none"
|
||
: "1px solid var(--border-soft)",
|
||
cursor: hasCompetitors ? "pointer" : "default",
|
||
}}
|
||
title={
|
||
hasCompetitors
|
||
? "Нажмите, чтобы увидеть ЖК-источники"
|
||
: undefined
|
||
}
|
||
>
|
||
<td
|
||
style={{
|
||
padding: "7px 12px",
|
||
fontWeight: 700,
|
||
color: "var(--accent)",
|
||
fontVariantNumeric: "tabular-nums",
|
||
}}
|
||
>
|
||
{row.rank}
|
||
</td>
|
||
<td
|
||
style={{
|
||
padding: "7px 12px",
|
||
fontWeight: 500,
|
||
color: "var(--fg-primary)",
|
||
}}
|
||
>
|
||
{ROOM_BUCKET_LABELS[row.room_bucket] ?? row.room_bucket}
|
||
</td>
|
||
<td
|
||
style={{
|
||
padding: "7px 12px",
|
||
color: "var(--fg-secondary)",
|
||
}}
|
||
>
|
||
{row.area_bin} м²
|
||
</td>
|
||
<td
|
||
style={{
|
||
padding: "7px 12px",
|
||
color: "var(--fg-secondary)",
|
||
fontVariantNumeric: "tabular-nums",
|
||
}}
|
||
>
|
||
{row.velocity_per_month.toFixed(2)}
|
||
</td>
|
||
<td
|
||
style={{
|
||
padding: "7px 12px",
|
||
textAlign: "right",
|
||
color: "var(--fg-secondary)",
|
||
fontVariantNumeric: "tabular-nums",
|
||
}}
|
||
>
|
||
{row.avg_area_m2.toFixed(1)}
|
||
</td>
|
||
<td
|
||
style={{
|
||
padding: "7px 12px",
|
||
textAlign: "right",
|
||
color: "var(--fg-secondary)",
|
||
fontVariantNumeric: "tabular-nums",
|
||
}}
|
||
>
|
||
{row.avg_price_per_m2_rub != null
|
||
? Math.round(row.avg_price_per_m2_rub).toLocaleString(
|
||
"ru-RU",
|
||
)
|
||
: "—"}
|
||
</td>
|
||
<td
|
||
style={{
|
||
padding: "7px 12px",
|
||
textAlign: "right",
|
||
}}
|
||
>
|
||
<SalesPeriodCell months={months} />
|
||
</td>
|
||
<td
|
||
style={{
|
||
padding: "7px 12px",
|
||
textAlign: "right",
|
||
color: "var(--fg-secondary)",
|
||
fontVariantNumeric: "tabular-nums",
|
||
}}
|
||
>
|
||
{row.sold_pct_of_supply != null ? (
|
||
<span
|
||
style={{
|
||
display: "inline-flex",
|
||
alignItems: "center",
|
||
gap: 4,
|
||
}}
|
||
>
|
||
{`${(row.sold_pct_of_supply ?? 0).toFixed(0)}%`}
|
||
{row.is_oversold && (
|
||
<span
|
||
style={{
|
||
background: "var(--warn-soft)",
|
||
color: "var(--warn)",
|
||
fontSize: 10,
|
||
fontWeight: 600,
|
||
padding: "1px 5px",
|
||
borderRadius: 4,
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
title={`Сделки ${oversoldPeriodLabel} превышают текущий snapshot supply — значение обрезано до 100%`}
|
||
>
|
||
>100%
|
||
</span>
|
||
)}
|
||
</span>
|
||
) : (
|
||
"—"
|
||
)}
|
||
</td>
|
||
<td
|
||
style={{
|
||
padding: "7px 10px",
|
||
color: "var(--fg-tertiary)",
|
||
width: 24,
|
||
}}
|
||
>
|
||
{hasCompetitors && (
|
||
<span
|
||
style={{
|
||
display: "inline-block",
|
||
transform: isExpanded
|
||
? "rotate(90deg)"
|
||
: "rotate(0deg)",
|
||
transition: "transform 0.15s ease",
|
||
fontSize: 14,
|
||
lineHeight: 1,
|
||
userSelect: "none",
|
||
}}
|
||
>
|
||
›
|
||
</span>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
{isExpanded && hasCompetitors && (
|
||
<CompetitorDrillIn
|
||
ids={row.competitor_obj_ids}
|
||
colSpan={TOTAL_COLS}
|
||
/>
|
||
)}
|
||
</Fragment>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function UnitMixBar({ mix }: { mix: LayoutTzMixRow[] }) {
|
||
const COLORS = [
|
||
"var(--viz-1)",
|
||
"var(--viz-5)",
|
||
"var(--viz-3)",
|
||
"var(--viz-4)",
|
||
"var(--viz-2)",
|
||
"var(--viz-3)",
|
||
];
|
||
|
||
return (
|
||
<div>
|
||
{/* Horizontal stacked bar */}
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
height: 24,
|
||
borderRadius: 6,
|
||
overflow: "hidden",
|
||
border: "1px solid var(--border-card)",
|
||
marginBottom: 10,
|
||
}}
|
||
>
|
||
{mix.map((row, i) => (
|
||
<div
|
||
key={row.room_bucket}
|
||
title={`${ROOM_BUCKET_LABELS[row.room_bucket] ?? row.room_bucket}: ${row.pct}%`}
|
||
style={{
|
||
width: `${row.pct}%`,
|
||
background: COLORS[i % COLORS.length],
|
||
transition: "width 0.3s ease",
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
{/* Legend */}
|
||
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
|
||
{mix.map((row, i) => (
|
||
<div
|
||
key={row.room_bucket}
|
||
style={{ display: "flex", alignItems: "center", gap: 4 }}
|
||
>
|
||
<div
|
||
style={{
|
||
width: 10,
|
||
height: 10,
|
||
borderRadius: 2,
|
||
background: COLORS[i % COLORS.length],
|
||
flexShrink: 0,
|
||
}}
|
||
/>
|
||
<span style={{ fontSize: 11, color: "var(--fg-secondary)" }}>
|
||
{ROOM_BUCKET_LABELS[row.room_bucket] ?? row.room_bucket} {row.pct}
|
||
%
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MixTable({ mix }: { mix: LayoutTzMixRow[] }) {
|
||
return (
|
||
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 12 }}>
|
||
<thead>
|
||
<tr className="bg-gray-50">
|
||
{["Тип", "Доля, %", "Кол-во квартир", "Ср. площадь, м²"].map((h) => (
|
||
<th
|
||
key={h}
|
||
className="px-3 py-[7px] text-left border-b border-gray-200 font-semibold text-gray-700 whitespace-nowrap"
|
||
>
|
||
{h}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{mix.map((row, i) => (
|
||
<tr
|
||
key={row.room_bucket}
|
||
style={{
|
||
background: i % 2 === 0 ? "var(--bg-card)" : "var(--bg-card-alt)",
|
||
borderBottom: "1px solid var(--border-soft)",
|
||
}}
|
||
>
|
||
<td
|
||
style={{
|
||
padding: "7px 12px",
|
||
fontWeight: 500,
|
||
color: "var(--fg-primary)",
|
||
}}
|
||
>
|
||
{ROOM_BUCKET_LABELS[row.room_bucket] ?? row.room_bucket}
|
||
</td>
|
||
<td
|
||
style={{
|
||
padding: "7px 12px",
|
||
fontVariantNumeric: "tabular-nums",
|
||
color: "var(--fg-secondary)",
|
||
}}
|
||
>
|
||
{row.pct}%
|
||
</td>
|
||
<td
|
||
style={{
|
||
padding: "7px 12px",
|
||
fontVariantNumeric: "tabular-nums",
|
||
color: "var(--fg-secondary)",
|
||
}}
|
||
>
|
||
{row.abs_units != null
|
||
? row.abs_units.toLocaleString("ru-RU")
|
||
: "—"}
|
||
</td>
|
||
<td
|
||
style={{
|
||
padding: "7px 12px",
|
||
fontVariantNumeric: "tabular-nums",
|
||
color: "var(--fg-secondary)",
|
||
}}
|
||
>
|
||
{row.avg_target_area_m2 != null
|
||
? row.avg_target_area_m2.toFixed(1)
|
||
: "—"}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
);
|
||
}
|
||
|
||
function RecommendationCard({
|
||
rec,
|
||
}: {
|
||
rec: BestLayoutsResponse["recommendation_for_tz"];
|
||
}) {
|
||
const maxPct =
|
||
rec.mix.length > 0 ? Math.max(...rec.mix.map((r) => r.pct)) : 0;
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
border: "1px solid var(--border-card)",
|
||
borderRadius: 10,
|
||
background: "var(--bg-card)",
|
||
overflow: "hidden",
|
||
}}
|
||
>
|
||
<div className="px-[18px] py-3 bg-gray-50 border-b border-gray-200 font-semibold text-[13px] text-gray-700">
|
||
Рекомендация ТЗ
|
||
</div>
|
||
<div
|
||
style={{
|
||
padding: "14px 18px",
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 16,
|
||
}}
|
||
>
|
||
{/* SF-09: предупреждение о перекосе рынка — показываем только если cap не смог применить */}
|
||
{rec.cap_skipped && (
|
||
<div className="bg-amber-50 border border-amber-200 text-amber-800 rounded p-3 text-sm">
|
||
Рекомендация имеет сильный перекос ({maxPct}% в одном формате) —
|
||
рынок настолько асимметричен, что cap не применён. Проверьте
|
||
competitive density и district pipeline.
|
||
</div>
|
||
)}
|
||
|
||
{/* Rationale text — plain text only, no dangerouslySetInnerHTML */}
|
||
<p
|
||
style={{
|
||
fontSize: 13,
|
||
color: "var(--fg-secondary)",
|
||
margin: 0,
|
||
lineHeight: 1.6,
|
||
}}
|
||
>
|
||
{rec.rationale_text}
|
||
</p>
|
||
|
||
{/* Unit-mix bar chart */}
|
||
{rec.mix.length > 0 && (
|
||
<div>
|
||
<div
|
||
style={{
|
||
fontSize: 12,
|
||
fontWeight: 600,
|
||
color: "var(--fg-tertiary)",
|
||
marginBottom: 8,
|
||
textTransform: "uppercase",
|
||
letterSpacing: "0.04em",
|
||
}}
|
||
>
|
||
Unit-mix
|
||
</div>
|
||
<UnitMixBar mix={rec.mix} />
|
||
</div>
|
||
)}
|
||
|
||
{/* Mix table */}
|
||
{rec.mix.length > 0 && (
|
||
<div
|
||
style={{
|
||
border: "1px solid var(--border-card)",
|
||
borderRadius: 8,
|
||
overflow: "hidden",
|
||
}}
|
||
>
|
||
<MixTable mix={rec.mix} />
|
||
</div>
|
||
)}
|
||
|
||
{/* Weighted avg price */}
|
||
{rec.weighted_avg_price_per_m2_rub != null && (
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 8,
|
||
padding: "10px 14px",
|
||
background: "var(--accent-soft)",
|
||
borderRadius: 8,
|
||
fontSize: 13,
|
||
}}
|
||
>
|
||
<span style={{ color: "var(--fg-tertiary)" }}>
|
||
Средневзвешенная цена:
|
||
</span>
|
||
<span
|
||
style={{
|
||
fontWeight: 700,
|
||
color: "var(--accent)",
|
||
fontVariantNumeric: "tabular-nums",
|
||
}}
|
||
>
|
||
{Math.round(rec.weighted_avg_price_per_m2_rub).toLocaleString(
|
||
"ru-RU",
|
||
)}{" "}
|
||
₽/м²
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Meta */}
|
||
<div style={{ fontSize: 11, color: "var(--fg-tertiary)" }}>
|
||
Основано на {rec.based_on_obj_count} ЖК ·{" "}
|
||
{rec.based_on_total_deals.toLocaleString("ru-RU")} сделках · период{" "}
|
||
{new Date(rec.data_window_start).toLocaleDateString("ru-RU")} —{" "}
|
||
{new Date(rec.data_window_end).toLocaleDateString("ru-RU")}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Main component ─────────────────────────────────────────────────────────────
|
||
|
||
interface Props {
|
||
cadNum: string;
|
||
selectedCompetitorObjIds?: number[];
|
||
}
|
||
|
||
export function BestLayoutsBlock({ cadNum, selectedCompetitorObjIds }: Props) {
|
||
const [radiusKm, setRadiusKm] = useState(1.0);
|
||
const [timeWindow, setTimeWindow] = useState<TimeWindow>("last_quarter");
|
||
const [targetTotalFlats, setTargetTotalFlats] = useState<string>("300");
|
||
const [minVelocity, setMinVelocity] = useState(0.5);
|
||
const [isPdfLoading, setIsPdfLoading] = useState(false);
|
||
const [pdfError, setPdfError] = useState<string | null>(null);
|
||
// Request that produced the currently shown `data` — PDF must use this,
|
||
// not live control state (controls may have changed without re-running «Рассчитать»).
|
||
const [lastRequest, setLastRequest] = useState<BestLayoutsRequest | null>(
|
||
null,
|
||
);
|
||
|
||
const { mutate, data, isPending, error } = useBestLayouts(cadNum);
|
||
|
||
function buildRequest(): BestLayoutsRequest {
|
||
const parsed = parseInt(targetTotalFlats, 10);
|
||
return {
|
||
radius_km: radiusKm,
|
||
time_window: timeWindow,
|
||
filter_competitor_obj_ids:
|
||
selectedCompetitorObjIds && selectedCompetitorObjIds.length > 0
|
||
? selectedCompetitorObjIds
|
||
: null,
|
||
min_velocity_per_month: minVelocity,
|
||
target_total_flats:
|
||
!Number.isNaN(parsed) && parsed > 0
|
||
? Math.min(Math.max(parsed, 1), 10000)
|
||
: null,
|
||
};
|
||
}
|
||
|
||
function handleCalculate() {
|
||
const req = buildRequest();
|
||
setLastRequest(req);
|
||
mutate(req);
|
||
}
|
||
|
||
async function handleDownloadPdf() {
|
||
// Use the request that produced the shown `data`, not live control state.
|
||
if (!lastRequest) return;
|
||
setIsPdfLoading(true);
|
||
try {
|
||
const req = lastRequest;
|
||
const res = await fetch(
|
||
`${API_BASE_URL}/api/v1/parcels/${encodeURIComponent(cadNum)}/best-layouts/pdf`,
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(req),
|
||
},
|
||
);
|
||
if (!res.ok) {
|
||
throw new Error(`Ошибка генерации PDF: ${res.status}`);
|
||
}
|
||
const blob = await res.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = `tz-layout-${cadNum.replace(/:/g, "-")}-${new Date().toISOString().split("T")[0]}.pdf`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
} catch (e) {
|
||
setPdfError(e instanceof Error ? e.message : "Не удалось скачать PDF");
|
||
} finally {
|
||
setIsPdfLoading(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div
|
||
style={{
|
||
border: "1px solid var(--border-card)",
|
||
borderRadius: 12,
|
||
background: "var(--bg-card)",
|
||
overflow: "hidden",
|
||
}}
|
||
>
|
||
{/* Header */}
|
||
<div
|
||
style={{
|
||
padding: "14px 20px",
|
||
background: "var(--bg-app)",
|
||
borderBottom: "1px solid var(--border-card)",
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
alignItems: "center",
|
||
flexWrap: "wrap",
|
||
gap: 10,
|
||
}}
|
||
>
|
||
<div>
|
||
<span
|
||
style={{
|
||
fontWeight: 600,
|
||
fontSize: 14,
|
||
color: "var(--fg-primary)",
|
||
}}
|
||
>
|
||
Анализ планировок
|
||
</span>
|
||
<span
|
||
style={{ fontSize: 12, color: "var(--fg-tertiary)", marginLeft: 8 }}
|
||
>
|
||
data-driven ТЗ на проектирование
|
||
</span>
|
||
</div>
|
||
{data && (
|
||
<div className="flex flex-col items-end gap-1">
|
||
<button
|
||
onClick={() => {
|
||
setPdfError(null);
|
||
void handleDownloadPdf();
|
||
}}
|
||
disabled={isPdfLoading}
|
||
style={{
|
||
padding: "7px 16px",
|
||
background: isPdfLoading
|
||
? "var(--fg-tertiary)"
|
||
: "var(--accent)",
|
||
color: "var(--fg-on-dark)",
|
||
border: "none",
|
||
borderRadius: 7,
|
||
fontSize: 13,
|
||
fontWeight: 500,
|
||
cursor: isPdfLoading ? "not-allowed" : "pointer",
|
||
whiteSpace: "nowrap",
|
||
}}
|
||
>
|
||
{isPdfLoading ? "Генерация…" : "Скачать ТЗ (PDF)"}
|
||
</button>
|
||
{pdfError && (
|
||
<span className="text-red-600 text-xs">PDF: {pdfError}</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Controls */}
|
||
<div
|
||
style={{
|
||
padding: "16px 20px",
|
||
borderBottom: "1px solid var(--border-soft)",
|
||
display: "flex",
|
||
flexWrap: "wrap",
|
||
gap: 20,
|
||
alignItems: "flex-end",
|
||
}}
|
||
>
|
||
{/* Radius slider */}
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 4,
|
||
minWidth: 160,
|
||
}}
|
||
>
|
||
<label
|
||
style={{
|
||
fontSize: 12,
|
||
color: "var(--fg-tertiary)",
|
||
fontWeight: 500,
|
||
}}
|
||
>
|
||
Радиус поиска: {radiusKm.toFixed(1)} км
|
||
</label>
|
||
<input
|
||
type="range"
|
||
min={0.1}
|
||
max={1.5}
|
||
step={0.1}
|
||
value={radiusKm}
|
||
onChange={(e) => setRadiusKm(parseFloat(e.target.value))}
|
||
style={{ width: 160, accentColor: "var(--accent)" }}
|
||
/>
|
||
</div>
|
||
|
||
{/* Min velocity slider */}
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: 4,
|
||
minWidth: 160,
|
||
}}
|
||
>
|
||
<label
|
||
style={{
|
||
fontSize: 12,
|
||
color: "var(--fg-tertiary)",
|
||
fontWeight: 500,
|
||
}}
|
||
>
|
||
Мин. скорость: {minVelocity.toFixed(1)} кв/мес
|
||
</label>
|
||
<input
|
||
type="range"
|
||
min={0}
|
||
max={5}
|
||
step={0.1}
|
||
value={minVelocity}
|
||
onChange={(e) => setMinVelocity(parseFloat(e.target.value))}
|
||
style={{ width: 160, accentColor: "var(--accent)" }}
|
||
/>
|
||
</div>
|
||
|
||
{/* Time window radio */}
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||
<span
|
||
style={{
|
||
fontSize: 12,
|
||
color: "var(--fg-tertiary)",
|
||
fontWeight: 500,
|
||
}}
|
||
>
|
||
Период анализа
|
||
</span>
|
||
<div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
|
||
{(Object.keys(TIME_WINDOW_LABELS) as TimeWindow[]).map((tw) => (
|
||
<label
|
||
key={tw}
|
||
style={{
|
||
display: "flex",
|
||
alignItems: "center",
|
||
gap: 4,
|
||
fontSize: 12,
|
||
cursor: "pointer",
|
||
color:
|
||
timeWindow === tw ? "var(--accent)" : "var(--fg-secondary)",
|
||
fontWeight: timeWindow === tw ? 600 : 400,
|
||
}}
|
||
>
|
||
<input
|
||
type="radio"
|
||
name="time-window"
|
||
value={tw}
|
||
checked={timeWindow === tw}
|
||
onChange={() => setTimeWindow(tw)}
|
||
style={{ accentColor: "var(--accent)" }}
|
||
/>
|
||
{TIME_WINDOW_LABELS[tw]}
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Target flats input */}
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||
<label
|
||
style={{
|
||
fontSize: 12,
|
||
color: "var(--fg-tertiary)",
|
||
fontWeight: 500,
|
||
}}
|
||
>
|
||
Целевой объём (квартир)
|
||
</label>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={10000}
|
||
value={targetTotalFlats}
|
||
onChange={(e) => setTargetTotalFlats(e.target.value)}
|
||
placeholder="300"
|
||
style={{
|
||
padding: "5px 10px",
|
||
border: "1px solid var(--border-strong)",
|
||
borderRadius: 6,
|
||
fontSize: 13,
|
||
width: 110,
|
||
color: "var(--fg-primary)",
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{/* Calculate button */}
|
||
<button
|
||
onClick={handleCalculate}
|
||
disabled={isPending}
|
||
style={{
|
||
padding: "7px 20px",
|
||
background: isPending ? "var(--fg-tertiary)" : "var(--accent)",
|
||
color: "var(--fg-on-dark)",
|
||
border: "none",
|
||
borderRadius: 7,
|
||
fontSize: 13,
|
||
fontWeight: 600,
|
||
cursor: isPending ? "not-allowed" : "pointer",
|
||
whiteSpace: "nowrap",
|
||
alignSelf: "flex-end",
|
||
}}
|
||
>
|
||
{isPending ? "Расчёт…" : "Рассчитать"}
|
||
</button>
|
||
|
||
{selectedCompetitorObjIds && selectedCompetitorObjIds.length > 0 && (
|
||
<span
|
||
style={{
|
||
fontSize: 11,
|
||
color: "var(--accent)",
|
||
background: "var(--accent-soft)",
|
||
padding: "3px 8px",
|
||
borderRadius: 4,
|
||
alignSelf: "flex-end",
|
||
marginBottom: 2,
|
||
}}
|
||
>
|
||
Фильтр: {selectedCompetitorObjIds.length} выбр. ЖК
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Content area */}
|
||
<div style={{ padding: "16px 20px" }}>
|
||
{/* Loading skeleton */}
|
||
{isPending && (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||
{[80, 60, 40].map((w) => (
|
||
<div
|
||
key={w}
|
||
style={{
|
||
height: 18,
|
||
borderRadius: 6,
|
||
background: "var(--bg-card-alt)",
|
||
width: `${w}%`,
|
||
animation: "pulse 1.5s ease-in-out infinite",
|
||
}}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Error */}
|
||
{error && !isPending && (
|
||
<div
|
||
style={{
|
||
padding: "12px 16px",
|
||
background: "var(--danger-soft)",
|
||
border: "1px solid var(--danger-soft)",
|
||
borderRadius: 8,
|
||
color: "var(--danger)",
|
||
fontSize: 13,
|
||
}}
|
||
>
|
||
{error instanceof Error ? error.message : "Ошибка получения данных"}
|
||
</div>
|
||
)}
|
||
|
||
{/* Results */}
|
||
{data && !isPending && (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||
<DataQualityCard dq={data.data_quality} />
|
||
<TopLayoutsTable
|
||
rows={data.top_layouts}
|
||
oversoldPeriodLabel={
|
||
TIME_WINDOW_OVERSOLD_PERIOD[
|
||
(lastRequest ?? buildRequest()).time_window
|
||
]
|
||
}
|
||
/>
|
||
<RecommendationCard rec={data.recommendation_for_tz} />
|
||
</div>
|
||
)}
|
||
|
||
{/* Idle state */}
|
||
{!isPending && !error && !data && (
|
||
<div
|
||
style={{
|
||
padding: "24px 0",
|
||
textAlign: "center",
|
||
color: "var(--fg-tertiary)",
|
||
fontSize: 13,
|
||
}}
|
||
>
|
||
Настройте параметры и нажмите «Рассчитать»
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|