diff --git a/frontend/src/components/site-finder/CompetitorTable.tsx b/frontend/src/components/site-finder/CompetitorTable.tsx index dff8cb49..00b1a9e0 100644 --- a/frontend/src/components/site-finder/CompetitorTable.tsx +++ b/frontend/src/components/site-finder/CompetitorTable.tsx @@ -8,6 +8,7 @@ type StatusFilter = "all" | "building" | "ready"; interface Props { competitors: ParcelAnalysisCompetitor[]; districtName: string | null | undefined; + onRowClick?: (competitor: ParcelAnalysisCompetitor) => void; } const CLASS_COLORS: Record = { @@ -69,7 +70,11 @@ function StatusBadge({ status }: { status: string | null | undefined }) { ); } -export function CompetitorTable({ competitors, districtName }: Props) { +export function CompetitorTable({ + competitors, + districtName, + onRowClick, +}: Props) { const [filter, setFilter] = useState("all"); const buildingCount = competitors.filter( @@ -193,7 +198,7 @@ export function CompetitorTable({ competitors, districtName }: Props) { "Квартир", "Район", "Расст., м", - ].map((h) => ( + ].map((h, idx) => ( {h} @@ -214,16 +229,31 @@ export function CompetitorTable({ competitors, districtName }: Props) { {top20.map((c, i) => { const sameDistrict = districtName && c.district_name === districtName; + const rowBg = sameDistrict + ? "#eff6ff" + : i % 2 + ? "#fafbfc" + : "#fff"; return ( onRowClick?.(c)} style={{ - background: sameDistrict - ? "#eff6ff" - : i % 2 - ? "#fafbfc" - : "#fff", + background: rowBg, borderBottom: "1px solid #f3f4f6", + cursor: onRowClick ? "pointer" : "default", + }} + onMouseEnter={(e) => { + if (onRowClick) { + ( + e.currentTarget as HTMLTableRowElement + ).style.background = "#e0e7ff"; + } + }} + onMouseLeave={(e) => { + ( + e.currentTarget as HTMLTableRowElement + ).style.background = rowBg; }} > {c.comm_name ?? `ЖК #${c.obj_id}`} diff --git a/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx b/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx index bb1818ac..8aa08650 100644 --- a/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx +++ b/frontend/src/components/site-finder/analysis/Section3SettingsAndCompetitors.tsx @@ -1,13 +1,24 @@ "use client"; -import { useState } from "react"; +import { useState, type ReactNode } from "react"; +import { X, Building2, MapPin, Users, Ruler } from "lucide-react"; import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel"; +import { BestLayoutsBlock } from "@/components/site-finder/BestLayoutsBlock"; +import { Pipeline24moBlock } from "@/components/site-finder/Pipeline24moBlock"; +import { VelocityBlock } from "@/components/site-finder/VelocityBlock"; +import { MarketTrendBlock } from "@/components/site-finder/MarketTrendBlock"; +import { CompetitorTable } from "@/components/site-finder/CompetitorTable"; +import { Drawer } from "@/components/ui/Drawer"; +import { Badge } from "@/components/ui/Badge"; import { POI_DEFAULT_WEIGHTS, type PoiCategoryKey, } from "@/lib/api/weightProfiles"; -import type { ParcelAnalysis } from "@/types/site-finder"; +import type { + ParcelAnalysis, + ParcelAnalysisCompetitor, +} from "@/types/site-finder"; // ── Types ───────────────────────────────────────────────────────────────────── @@ -256,65 +267,333 @@ function Section31Settings({ ); } -// ── Section 3.2 placeholder ──────────────────────────────────────────────────── +// ── Section 3.2 «Планировки» ─────────────────────────────────────────────────── -function Section32Placeholder() { +function Section32Layouts({ cad }: { cad: string }) { return (
-

- 3.2 Планировки конкурентов -

-
- Раздел появится в A8 — планировки конкурентов (BestLayoutsBlock) +
+

+ 3.2 Планировки +

+

+ Data-driven ТЗ на планировочный микс — на основе сделок конкурентов в + радиусе +

+
); } -// ── Section 3.3 placeholder ──────────────────────────────────────────────────── +// ── Section 3.3 «Остатки и скорость» ────────────────────────────────────────── + +function Section33RemainVelocity({ data }: { data: ParcelAnalysis }) { + const hasPipeline = data.pipeline_24mo != null; + const hasVelocity = data.velocity != null; + const hasTrend = data.market_trend != null; + + const isEmpty = !hasPipeline && !hasVelocity && !hasTrend; -function Section33Placeholder() { return (
-

- 3.3 Остатки и скорость продаж -

+
+

+ 3.3 Остатки и скорость +

+

+ Pipeline конкурентов на 24 мес · темп продаж · тренд цен +

+
+ + {isEmpty ? ( +
+ Данные по остаткам и скорости продаж отсутствуют для этого участка +
+ ) : ( +
+ {hasPipeline && } + {hasVelocity && ( +
+ +
+ )} + {hasTrend && } +
+ )} +
+ ); +} + +// ── Competitor detail drawer ─────────────────────────────────────────────────── + +const STATUS_BADGE_MAP: Record< + string, + { variant: "success" | "neutral" | "warning"; label: string } +> = { + Строящиеся: { variant: "warning", label: "Строящийся" }, + Сданные: { variant: "success", label: "Сдан" }, +}; + +function CompetitorDetailDrawer({ + competitor, + onClose, +}: { + competitor: ParcelAnalysisCompetitor | null; + onClose: () => void; +}) { + const isOpen = competitor !== null; + + return ( + + {competitor && ( + <> + {/* Header */} +
+ +
+
+ {competitor.comm_name ?? `ЖК #${competitor.obj_id}`} +
+ {competitor.dev_name && ( +
+ {competitor.dev_name} +
+ )} +
+ +
+ + {/* Status + badges row */} +
+ {competitor.site_status && ( + + {STATUS_BADGE_MAP[competitor.site_status]?.label ?? + competitor.site_status} + + )} + {competitor.obj_class && ( + {competitor.obj_class} + )} +
+ + {/* KPI grid */} +
+ } + label="Расстояние" + value={`${Math.round(competitor.distance_m).toLocaleString("ru-RU")} м`} + /> + } + label="Квартир" + value={ + competitor.flat_count != null + ? competitor.flat_count.toLocaleString("ru-RU") + : "—" + } + /> + {competitor.district_name && ( + } + label="Район" + value={competitor.district_name} + /> + )} + {competitor.ready_dt && ( + } + label="Срок сдачи" + value={new Date(competitor.ready_dt).toLocaleDateString( + "ru-RU", + { + year: "numeric", + month: "2-digit", + }, + )} + /> + )} +
+ + {/* Caveat */} +
+ Детальные данные (sold%, цены, план. продажи) появятся после + расширения B5 +
+ + )} +
+ ); +} + +function KpiCell({ + icon, + label, + value, +}: { + icon: ReactNode; + label: string; + value: string; +}) { + return ( +
- Раздел появится в A8 — Pipeline24mo / Velocity / MarketTrend + + {icon} + + {label} +
+
+ {value}
); @@ -348,8 +627,11 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) { maxAge6mo: false, onlyApartments: false, }); + const [selectedCompetitor, setSelectedCompetitor] = + useState(null); const filteredCompetitors = applyFilters(data, filters); + const districtName = data.district?.district_name ?? null; return (
@@ -387,8 +669,18 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) { {/* Sub-sections */}
- - + + {/* Competitor table — moved before 3.2/3.3 for context */} + {filteredCompetitors.length > 0 && ( + + )} + + +
{/* Filtered competitors count hint */} @@ -405,10 +697,15 @@ export function Section3SettingsAndCompetitors({ cad, data }: Props) { }} > Фильтр по радиусу: показано {filteredCompetitors.length} из{" "} - {data.competitors.length} конкурентов. Разделы 3.2 и 3.3 отобразят - данные с учётом выборки после A8. + {data.competitors.length} конкурентов
)} + + {/* Competitor detail drawer */} + setSelectedCompetitor(null)} + /> ); }