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
This commit is contained in:
lekss361 2026-05-15 01:31:23 +03:00
parent e93bb6121e
commit 33b6148cc5
3 changed files with 266 additions and 1 deletions

View file

@ -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 (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
@ -38,6 +44,20 @@ export function MarketTab({ data }: Props) {
</div>
)}
{/* D2 (#34) — Velocity-score */}
{hasVelocity && (
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "14px 18px",
background: "#fff",
}}
>
<VelocityBlock velocity={data.velocity} />
</div>
)}
{/* Competitors */}
{data.competitors.length > 0 && (
<div>

View file

@ -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<Velocity["confidence"], string> = {
high: "Высокая",
medium: "Средняя",
low: "Низкая",
};
function formatPercent(score: number): string {
return `${Math.round(score * 100)}%`;
}
export function VelocityBlock({ velocity }: VelocityBlockProps) {
if (!velocity) {
return (
<div>
<SectionLabel style={{ marginBottom: 8 }}>
Темп продаж конкурентов
</SectionLabel>
<EmptyState message="Данные о продажах не найдены (нет конкурентов в радиусе 3км или нет sale_graph данных)" />
</div>
);
}
const confColor = CONFIDENCE_COLOR[velocity.confidence];
const scorePct = formatPercent(velocity.velocity_score);
const ratio = velocity.monthly_velocity_sqm / velocity.ekb_median_sqm;
return (
<div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 8,
}}
>
<SectionLabel>Темп продаж конкурентов</SectionLabel>
<span
style={{
padding: "2px 8px",
background: confColor.bg,
color: confColor.fg,
borderRadius: 4,
fontSize: 11,
fontWeight: 600,
}}
title={`Уверенность: ${CONFIDENCE_LABEL[velocity.confidence]}`}
>
{CONFIDENCE_LABEL[velocity.confidence]}
</span>
</div>
{/* Score gauge */}
<div style={{ marginBottom: 12 }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 12,
color: "#6b7280",
marginBottom: 4,
}}
>
<span>Velocity-score</span>
<span style={{ fontWeight: 600, color: "#111827" }}>{scorePct}</span>
</div>
<div
style={{
background: "#e5e7eb",
borderRadius: 4,
height: 8,
overflow: "hidden",
}}
>
<div
style={{
background:
velocity.velocity_score >= 0.66
? "#10b981"
: velocity.velocity_score >= 0.33
? "#f59e0b"
: "#ef4444",
width: `${velocity.velocity_score * 100}%`,
height: "100%",
}}
/>
</div>
<div style={{ fontSize: 11, color: "#9ca3af", marginTop: 4 }}>
{Math.round(velocity.monthly_velocity_sqm)} м²/мес vs{" "}
{Math.round(velocity.ekb_median_sqm)} м²/мес (медиана ЕКБ) &middot;{" "}
{ratio >= 1
? `x${ratio.toFixed(1)} выше`
: `${formatPercent(ratio)} от среднего`}
</div>
</div>
{/* Period + competitors meta */}
<div style={{ fontSize: 12, color: "#6b7280", marginBottom: 8 }}>
В радиусе 3 км: <b>{velocity.competitors_count}</b> ЖК &middot; период{" "}
<b>
{velocity.period.start} &rarr; {velocity.period.end}
</b>{" "}
({velocity.months_observed} мес)
</div>
{/* Top competitors */}
{velocity.sample_competitors.length > 0 && (
<div>
<SectionLabel style={{ marginBottom: 6 }}>Топ продавцов</SectionLabel>
<table
style={{
width: "100%",
fontSize: 12,
borderCollapse: "collapse",
marginTop: 4,
}}
>
<thead>
<tr
style={{
color: "#6b7280",
borderBottom: "1px solid #e5e7eb",
}}
>
<th
style={{
textAlign: "left",
padding: "4px 8px",
fontWeight: 500,
}}
>
Название
</th>
<th
style={{
textAlign: "right",
padding: "4px 8px",
fontWeight: 500,
}}
>
Расст.
</th>
<th
style={{
textAlign: "right",
padding: "4px 8px",
fontWeight: 500,
}}
>
Продано м²
</th>
</tr>
</thead>
<tbody>
{velocity.sample_competitors.map((c) => (
<tr
key={c.obj_id}
style={{ borderBottom: "1px solid #f3f4f6" }}
>
<td style={{ padding: "4px 8px" }}>
{c.name ?? `obj#${c.obj_id}`}
{c.class && (
<span
style={{
marginLeft: 6,
fontSize: 10,
color: "#9ca3af",
}}
>
{c.class}
</span>
)}
</td>
<td
style={{
padding: "4px 8px",
textAlign: "right",
color: "#6b7280",
}}
>
{Math.round(c.distance_m)}м
</td>
<td
style={{
padding: "4px 8px",
textAlign: "right",
fontWeight: 500,
}}
>
{Math.round(c.total_sqm).toLocaleString("ru")}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -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 =