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..268f8587 --- /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.class && ( + + {c.class} + + )} + + {Math.round(c.distance_m)}м + + {Math.round(c.total_sqm).toLocaleString("ru")} +
+
+ )} +
+ ); +} diff --git a/frontend/src/types/site-finder.ts b/frontend/src/types/site-finder.ts index 8674699b..7245c602 100644 --- a/frontend/src/types/site-finder.ts +++ b/frontend/src/types/site-finder.ts @@ -204,6 +204,31 @@ export interface Pipeline24mo { note?: string; } +// D2 (#34) — velocity-score: темп продаж конкурентов +export interface VelocityCompetitor { + obj_id: number; + name: string | null; + class: string | null; + distance_m: number; + total_sqm: number; +} + +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 +367,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 =