fix(sf-13): MarketTab above-the-fold headline + 3 KPI cards #294

Merged
lekss361 merged 1 commit from fix/sf-13-above-fold-kpi into main 2026-05-17 14:10:07 +00:00

View file

@ -14,6 +14,35 @@ interface Props {
data: ParcelAnalysis;
}
/** Derive radius_km from available nested fields. */
function getRadiusKm(data: ParcelAnalysis): number {
return data.market_trend?.radius_km ?? data.pipeline_24mo?.radius_km ?? 3;
}
/**
* Max success score (%) from success_recommendation ranking.
* Returns null when recommendation is absent or ranking is empty.
*/
function getMaxMixPct(data: ParcelAnalysis): number | null {
const ranking = data.success_recommendation?.ranking;
if (!ranking || ranking.length === 0) return null;
const maxScore = Math.max(...ranking.map((r) => r.success_score));
return Math.round(maxScore * 100);
}
/**
* Estimated sales period in months (supply / flats_per_month).
* Uses pipeline_24mo.flats_total as supply proxy, velocity for rate.
* Assumes ~50 m² avg flat for unit conversion.
*/
function getSalesPeriodMonths(data: ParcelAnalysis): number | null {
const supply = data.pipeline_24mo?.flats_total;
const velocitySqm = data.velocity?.monthly_velocity_sqm;
if (!supply || !velocitySqm || velocitySqm <= 0) return null;
const flatsPerMonth = velocitySqm / 50;
return Math.round(supply / flatsPerMonth);
}
export function MarketTab({ data }: Props) {
const hasTrend = "market_trend" in data;
const hasRecommendation = "success_recommendation" in data;
@ -26,8 +55,186 @@ export function MarketTab({ data }: Props) {
hasVelocity ||
data.competitors.length > 0;
// ── Above-the-fold values ────────────────────────────────────────────────
// Note: site_status field pending fix #2/#3 (backend PR). Using total
// competitors count as proxy for active (строящихся) count for now.
const activeCount = data.competitors.length;
const radiusKm = getRadiusKm(data);
const velocityPerMonth = data.velocity?.monthly_velocity_sqm ?? null;
const maxMixPct = getMaxMixPct(data);
const salesPeriodMonths = getSalesPeriodMonths(data);
const showHeadlineBar = activeCount > 0 || velocityPerMonth !== null;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
{/* ── Above-the-fold: Headline-bar + 3 KPI ─────────────────────────── */}
{showHeadlineBar && (
<>
{/* Headline-bar — one sentence verdict */}
<div
style={{
background: "var(--bg-headline, #0F172A)",
color: "var(--fg-on-dark, #E2E8F0)",
borderRadius: 12,
padding: "14px 18px",
fontSize: 15,
fontWeight: 500,
lineHeight: 1.4,
}}
>
<strong>
{activeCount} строящихся в {radiusKm}км
</strong>
{" · велосити "}
<strong>
{velocityPerMonth !== null
? `${velocityPerMonth.toFixed(1)} м²/мес`
: "—"}
</strong>
{" · рекомендуемый mix "}
<strong>{maxMixPct !== null ? `${maxMixPct}%` : "—"}</strong>
</div>
{/* 3 KPI cards */}
<div className="grid grid-cols-3 gap-3 mb-4">
{/* KPI 1: Активные ЖК */}
<div
style={{
background: "#fff",
border: "1px solid #e6e8ec",
borderRadius: 12,
padding: "12px 16px",
}}
>
<div
style={{
fontSize: 11,
textTransform: "uppercase" as const,
letterSpacing: "0.04em",
color: "#5b6066",
fontWeight: 500,
}}
>
Активные ЖК
</div>
<div
style={{
marginTop: 6,
fontSize: 28,
fontWeight: 600,
color: "#111",
fontVariantNumeric: "tabular-nums",
}}
>
{activeCount}
</div>
<div style={{ marginTop: 4, fontSize: 12, color: "#73767e" }}>
строящихся в радиусе {radiusKm}км
</div>
</div>
{/* KPI 2: Велосити */}
<div
style={{
background: "#fff",
border: "1px solid #e6e8ec",
borderRadius: 12,
padding: "12px 16px",
}}
>
<div
style={{
fontSize: 11,
textTransform: "uppercase" as const,
letterSpacing: "0.04em",
color: "#5b6066",
fontWeight: 500,
}}
>
Велосити
</div>
<div
style={{
marginTop: 6,
display: "flex",
alignItems: "baseline",
gap: 4,
}}
>
<span
style={{
fontSize: 28,
fontWeight: 600,
color: "#111",
fontVariantNumeric: "tabular-nums",
}}
>
{velocityPerMonth !== null
? velocityPerMonth.toFixed(1)
: "—"}
</span>
{velocityPerMonth !== null && (
<span style={{ fontSize: 13, color: "#5b6066" }}>м²/мес</span>
)}
</div>
<div style={{ marginTop: 4, fontSize: 12, color: "#73767e" }}>
ср. темп продаж конкурентов
</div>
</div>
{/* KPI 3: Срок продаж */}
<div
style={{
background: "#fff",
border: "1px solid #e6e8ec",
borderRadius: 12,
padding: "12px 16px",
}}
>
<div
style={{
fontSize: 11,
textTransform: "uppercase" as const,
letterSpacing: "0.04em",
color: "#5b6066",
fontWeight: 500,
}}
>
Срок продаж
</div>
<div
style={{
marginTop: 6,
display: "flex",
alignItems: "baseline",
gap: 4,
}}
>
<span
style={{
fontSize: 28,
fontWeight: 600,
color: "#111",
fontVariantNumeric: "tabular-nums",
}}
>
{salesPeriodMonths !== null ? salesPeriodMonths : "—"}
</span>
{salesPeriodMonths !== null && (
<span style={{ fontSize: 13, color: "#5b6066" }}>мес</span>
)}
</div>
<div style={{ marginTop: 4, fontSize: 12, color: "#73767e" }}>
supply / велосити (оценка)
</div>
</div>
</div>
</>
)}
{/* ── Main content blocks ───────────────────────────────────────────── */}
{/* Competitors */}
{data.competitors.length > 0 && (
<div>