diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 55c1e0d4..fb9a9d9b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -32,7 +32,7 @@ "openapi-typescript": "^7.0.0", "postcss": "^8.4.0", "tailwindcss": "^4.0.0", - "typescript": "^5.5.0" + "typescript": "5.9.3" } }, "node_modules/@alloc/quick-lru": { diff --git a/frontend/package.json b/frontend/package.json index f1c8daac..f79f586c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,6 +35,6 @@ "openapi-typescript": "^7.0.0", "postcss": "^8.4.0", "tailwindcss": "^4.0.0", - "typescript": "^5.5.0" + "typescript": "5.9.3" } } diff --git a/frontend/src/components/site-finder/BestLayoutsBlock.tsx b/frontend/src/components/site-finder/BestLayoutsBlock.tsx index 1cd4d337..99394dbc 100644 --- a/frontend/src/components/site-finder/BestLayoutsBlock.tsx +++ b/frontend/src/components/site-finder/BestLayoutsBlock.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { Fragment, useState } from "react"; import { useBestLayouts } from "@/hooks/useBestLayouts"; import { API_BASE_URL } from "@/lib/api"; import type { @@ -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,178 @@ 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); + }} + 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 ? "#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 && ( + )} - - - ))} + + ); + })}