diff --git a/frontend/src/components/site-finder/BestLayoutsBlock.tsx b/frontend/src/components/site-finder/BestLayoutsBlock.tsx
index 1cd4d337..fc01b9cc 100644
--- a/frontend/src/components/site-finder/BestLayoutsBlock.tsx
+++ b/frontend/src/components/site-finder/BestLayoutsBlock.tsx
@@ -60,7 +60,88 @@ function DataQualityCard({ dq }: { dq: BestLayoutsResponse["data_quality"] }) {
);
}
+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 —;
+ if (months < 12)
+ return (
+
+ {months} мес
+
+ );
+ if (months <= 24)
+ return {months} мес;
+ return {months} мес;
+}
+
+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 (
+
+
+
+
+ ЖК-источники:
+
+ {visible.map((id) => (
+
+ {id}
+
+ ))}
+ {!showAll && hidden > 0 && (
+
+ )}
+ {showAll && ids.length > COMPETITOR_PREVIEW_LIMIT && (
+
+ )}
+
+ |
+
+ );
+}
+
function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) {
+ const [expandedRows, setExpandedRows] = useState>(new Set());
+
if (rows.length === 0) {
return (
@@ -69,6 +150,20 @@ function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) {
);
}
+ 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 = [
"#",
"Тип",
@@ -76,7 +171,9 @@ function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) {
"Скорость / мес",
"Средн. площадь, м²",
"Средн. цена, ₽/м²",
+ "Срок продажи (мес)",
"Продано, %",
+ "",
];
return (
@@ -90,9 +187,9 @@ function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) {
>
- {headers.map((h) => (
+ {headers.map((h, idx) => (
|
{h}
@@ -101,109 +198,168 @@ function TopLayoutsTable({ rows }: { rows: TopLayoutRow[] }) {
|
- {rows.map((row, i) => (
-
- |
- {row.rank}
- |
-
- {ROOM_BUCKET_LABELS[row.room_bucket] ?? row.room_bucket}
- |
-
- {row.area_bin} м²
- |
-
- {row.velocity_per_month.toFixed(2)}
- |
-
- {row.avg_area_m2.toFixed(1)}
- |
-
- {row.avg_price_per_m2_rub != null
- ? Math.round(row.avg_price_per_m2_rub).toLocaleString(
- "ru-RU",
- )
- : "—"}
- |
-
- {row.sold_pct_of_supply != null ? (
- {
+ 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 (
+ <>
+ {
+ if (hasCompetitors) toggleRow(row.signature);
+ }}
+ style={{
+ background: i % 2 === 0 ? "#fff" : "#fafbfc",
+ borderBottom: isExpanded ? "none" : "1px solid #f3f4f6",
+ cursor: hasCompetitors ? "pointer" : "default",
+ }}
+ title={
+ hasCompetitors
+ ? "Нажмите, чтобы увидеть ЖК-источники"
+ : undefined
+ }
+ >
+ |
- {`${(row.sold_pct_of_supply ?? 0).toFixed(0)}%`}
- {row.is_oversold && (
+ {row.rank}
+ |
+
+ {ROOM_BUCKET_LABELS[row.room_bucket] ?? row.room_bucket}
+ |
+
+ {row.area_bin} м²
+ |
+
+ {row.velocity_per_month.toFixed(2)}
+ |
+
+ {row.avg_area_m2.toFixed(1)}
+ |
+
+ {row.avg_price_per_m2_rub != null
+ ? Math.round(row.avg_price_per_m2_rub).toLocaleString(
+ "ru-RU",
+ )
+ : "—"}
+ |
+
+
+ |
+
+ {row.sold_pct_of_supply != null ? (
- >100%
+ {`${(row.sold_pct_of_supply ?? 0).toFixed(0)}%`}
+ {row.is_oversold && (
+
+ >100%
+
+ )}
+
+ ) : (
+ "—"
+ )}
+ |
+
+ {hasCompetitors && (
+
+ ›
)}
-
- ) : (
- "—"
+ |
+
+ {isExpanded && hasCompetitors && (
+
)}
- |
-
- ))}
+ >
+ );
+ })}