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

1048 lines
33 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 { 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%`}
>
&gt;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>
);
}