From 088c1ab2cf82bd946c55bf38c6850840eb2c4595 Mon Sep 17 00:00:00 2001 From: lekss361 <47113017+lekss361@users.noreply.github.com> Date: Fri, 15 May 2026 01:44:16 +0300 Subject: [PATCH] =?UTF-8?q?feat(site-finder):=20VelocityBlock=20=D0=B2=20M?= =?UTF-8?q?arketTab=20(#34=20sub-PR=202/2=20FINAL)=20(#147)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(site-finder): VelocityBlock в MarketTab (#34 sub-PR 2/2 FINAL) D2 velocity-score frontend: score gauge, period meta, top 5 competitors table. TS types (Velocity/VelocityCompetitor/VelocityPeriod) добавлены. Guard "velocity" in data для backward-compat pre-#146. tsc 0 errors, ESLint 0 warnings. Closes #34 * fix(site-finder): align VelocityBlock TS types with backend response shape Per #147 bot review — cross-stack contract mismatch: Backend velocity.py (PR #146 merged) returns: obj_id, name, dev_name, obj_class, district_name, distance_m, total_sqm_period Frontend types had: obj_id, name, class, distance_m, total_sqm Result: c.class undefined → never showed class chip; c.total_sqm undefined → Math.round(NaN) = NaN, 'не число' в table column для всех 5 строк. ## Fix - VelocityCompetitor.class → obj_class - VelocityCompetitor.total_sqm → total_sqm_period - Added optional dev_name, district_name fields - Updated VelocityBlock.tsx — все 3 references (guard, chip render, table cell) Refs: #34, #147 --------- Co-authored-by: lekss361 --- .../src/components/site-finder/MarketTab.tsx | 22 +- .../components/site-finder/VelocityBlock.tsx | 218 ++++++++++++++++++ frontend/src/types/site-finder.ts | 29 +++ 3 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/site-finder/VelocityBlock.tsx diff --git a/frontend/src/components/site-finder/MarketTab.tsx b/frontend/src/components/site-finder/MarketTab.tsx index 0ce61c46..4b408e1b 100644 --- a/frontend/src/components/site-finder/MarketTab.tsx +++ b/frontend/src/components/site-finder/MarketTab.tsx @@ -7,6 +7,7 @@ import { MarketTrendBlock } from "./MarketTrendBlock"; import { CompetitorTable } from "./CompetitorTable"; import { Pipeline24moBlock } from "./Pipeline24moBlock"; import { SuccessRecommendationBlock } from "./SuccessRecommendationBlock"; +import { VelocityBlock } from "./VelocityBlock"; interface Props { data: ParcelAnalysis; @@ -16,8 +17,13 @@ export function MarketTab({ data }: Props) { const hasTrend = "market_trend" in data; const hasRecommendation = "success_recommendation" in data; const hasPipeline = data.pipeline_24mo !== undefined; + const hasVelocity = "velocity" in data; const hasAny = - hasTrend || hasRecommendation || hasPipeline || data.competitors.length > 0; + hasTrend || + hasRecommendation || + hasPipeline || + hasVelocity || + data.competitors.length > 0; return (
@@ -38,6 +44,20 @@ export function MarketTab({ data }: Props) {
)} + {/* D2 (#34) — Velocity-score */} + {hasVelocity && ( +
+ +
+ )} + {/* Competitors */} {data.competitors.length > 0 && (
diff --git a/frontend/src/components/site-finder/VelocityBlock.tsx b/frontend/src/components/site-finder/VelocityBlock.tsx new file mode 100644 index 00000000..d38f628b --- /dev/null +++ b/frontend/src/components/site-finder/VelocityBlock.tsx @@ -0,0 +1,218 @@ +"use client"; +import type { Velocity } from "@/types/site-finder"; +import { SectionLabel } from "@/components/ui/SectionLabel"; +import { EmptyState } from "@/components/ui/EmptyState"; + +interface VelocityBlockProps { + velocity: Velocity | null | undefined; +} + +const CONFIDENCE_COLOR: Record< + Velocity["confidence"], + { bg: string; fg: string } +> = { + high: { bg: "#dcfce7", fg: "#166534" }, + medium: { bg: "#fef3c7", fg: "#854d0e" }, + low: { bg: "#fee2e2", fg: "#991b1b" }, +}; + +const CONFIDENCE_LABEL: Record = { + high: "Высокая", + medium: "Средняя", + low: "Низкая", +}; + +function formatPercent(score: number): string { + return `${Math.round(score * 100)}%`; +} + +export function VelocityBlock({ velocity }: VelocityBlockProps) { + if (!velocity) { + return ( +
+ + Темп продаж конкурентов + + +
+ ); + } + + const confColor = CONFIDENCE_COLOR[velocity.confidence]; + const scorePct = formatPercent(velocity.velocity_score); + const ratio = velocity.monthly_velocity_sqm / velocity.ekb_median_sqm; + + return ( +
+
+ Темп продаж конкурентов + + {CONFIDENCE_LABEL[velocity.confidence]} + +
+ + {/* Score gauge */} +
+
+ Velocity-score + {scorePct} +
+
+
= 0.66 + ? "#10b981" + : velocity.velocity_score >= 0.33 + ? "#f59e0b" + : "#ef4444", + width: `${velocity.velocity_score * 100}%`, + height: "100%", + }} + /> +
+
+ {Math.round(velocity.monthly_velocity_sqm)} м²/мес vs{" "} + {Math.round(velocity.ekb_median_sqm)} м²/мес (медиана ЕКБ) ·{" "} + {ratio >= 1 + ? `x${ratio.toFixed(1)} выше` + : `${formatPercent(ratio)} от среднего`} +
+
+ + {/* Period + competitors meta */} +
+ В радиусе 3 км: {velocity.competitors_count} ЖК · период{" "} + + {velocity.period.start} → {velocity.period.end} + {" "} + ({velocity.months_observed} мес) +
+ + {/* Top competitors */} + {velocity.sample_competitors.length > 0 && ( +
+ Топ продавцов + + + + + + + + + + {velocity.sample_competitors.map((c) => ( + + + + + + ))} + +
+ Название + + Расст. + + Продано м² +
+ {c.name ?? `obj#${c.obj_id}`} + {c.obj_class && ( + + {c.obj_class} + + )} + + {Math.round(c.distance_m)}м + + {Math.round(c.total_sqm_period).toLocaleString("ru")} +
+
+ )} +
+ ); +} diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index 8674699b..b43c5aeb 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -204,6 +204,33 @@ export interface Pipeline24mo { note?: string; } +// D2 (#34) — velocity-score: темп продаж конкурентов +export interface VelocityCompetitor { + obj_id: number; + name: string | null; + obj_class: string | null; + distance_m: number; + total_sqm_period: number; + dev_name?: string | null; + district_name?: string | null; +} + +export interface VelocityPeriod { + start: string; // YYYY-MM + end: string; +} + +export interface Velocity { + competitors_count: number; + monthly_velocity_sqm: number; + ekb_median_sqm: number; + velocity_score: number; // 0..1 + confidence: "high" | "medium" | "low"; + months_observed: number; + period: VelocityPeriod; + sample_competitors: VelocityCompetitor[]; +} + // G5 (#32) — Gate verdict: can_build_mkd export type GateVerdictSource = "nspd_dump" | "nspd_dump_partial" | "no_data"; @@ -342,6 +369,8 @@ export interface ParcelAnalysis { pipeline_24mo?: Pipeline24mo; // G5 (#32) — gate verdict: МКД buildability gate_verdict?: GateVerdict; + // D2 (#34) — velocity-score: темп продаж конкурентов + velocity?: Velocity | null; } export type PoiCategory =