feat(site-finder): VelocityBlock в MarketTab (#34 sub-PR 2/2 FINAL) #147
3 changed files with 268 additions and 1 deletions
|
|
@ -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>
|
||||
|
|
|
|||
218
frontend/src/components/site-finder/VelocityBlock.tsx
Normal file
218
frontend/src/components/site-finder/VelocityBlock.tsx
Normal 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)} м²/мес (медиана ЕКБ) ·{" "}
|
||||
{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> ЖК · период{" "}
|
||||
<b>
|
||||
{velocity.period.start} → {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.obj_class && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 6,
|
||||
fontSize: 10,
|
||||
color: "#9ca3af",
|
||||
}}
|
||||
>
|
||||
{c.obj_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_period).toLocaleString("ru")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue