gendesign/frontend/src/components/site-finder/SuccessRecommendationBlock.tsx
lekss361 989d4e5b7d fix(sf-20): adaptive district threshold 30→15 with data_confidence flag
- v_bucket_success_score HAVING >= 30 → >= 15 so sparse districts
  (Кировский etc.) are no longer silently excluded from the block
- Backend: SUCCESS_REC_MIN_DEALS=15, SUCCESS_REC_STRONG_DEALS=30;
  data_confidence='weak' (15-29 deals) or 'strong' (≥30) in response
- TS type ParcelSuccessRecommendation: add data_confidence literal
- SuccessRecommendationBlock: amber badge for weak, all hex → CSS tokens,
  emoji star removed (ui-ux.md), empty-state text min 30→15
2026-05-17 16:34:52 +03:00

223 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import type {
ParcelSuccessRecommendation,
SuccessRankingBucket,
} from "@/types/site-finder";
interface Props {
recommendation: ParcelSuccessRecommendation | null | undefined;
}
function successColor(score: number): string {
if (score > 0.5) return "var(--success)";
if (score >= 0) return "var(--warn)";
return "var(--fg-tertiary)";
}
function successBg(score: number): string {
if (score > 0.5) return "var(--success-soft)";
if (score >= 0) return "var(--warn-soft)";
return "var(--bg-card-alt)";
}
function formatPrice(v: number | null): string {
if (v == null) return "—";
return (v / 1000).toFixed(0) + " тыс";
}
function formatArea(v: number | null): string {
if (v == null) return "—";
return v.toFixed(0) + " м²";
}
function SuccessBadge({ bucket }: { bucket: SuccessRankingBucket }) {
const color = successColor(bucket.success_score);
const bg = successBg(bucket.success_score);
return (
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
borderRadius: 6,
padding: "2px 8px",
background: bg,
border: "1px solid var(--border-soft)",
}}
>
<div
style={{
width: 36,
height: 8,
borderRadius: 4,
background: "var(--border-strong)",
overflow: "hidden",
position: "relative",
}}
>
<div
style={{
position: "absolute",
left: 0,
top: 0,
height: "100%",
width: `${Math.max(0, Math.min(1, bucket.success_score)) * 100}%`,
background: color,
borderRadius: 4,
}}
/>
</div>
<span style={{ fontSize: 12, fontWeight: 600, color }}>
{bucket.success_score.toFixed(2)}
</span>
</div>
);
}
export function SuccessRecommendationBlock({ recommendation }: Props) {
if (!recommendation || recommendation.ranking.length === 0) {
return (
<div
style={{
borderRadius: 10,
padding: "14px 16px",
background: "var(--bg-card-alt)",
color: "var(--fg-tertiary)",
fontSize: 13,
}}
>
Недостаточно сделок в районе для рейтинга (мин 15)
</div>
);
}
const { district, ranking, top_bucket, note, data_confidence } =
recommendation;
const isWeak = data_confidence === "weak";
const top5 = ranking.slice(0, 5);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
flexWrap: "wrap",
}}
>
<div
style={{ fontSize: 14, fontWeight: 700, color: "var(--fg-primary)" }}
>
Что хорошо продаётся в районе {district}
</div>
{isWeak && (
<span
style={{
fontSize: 11,
fontWeight: 600,
padding: "2px 8px",
borderRadius: 6,
background: "var(--warn-soft)",
color: "var(--warn)",
border: "1px solid var(--warn-soft)",
whiteSpace: "nowrap",
}}
>
слабые данные, ориентировочно
</span>
)}
</div>
<div style={{ overflowX: "auto" }}>
<table
style={{
width: "100%",
borderCollapse: "collapse",
fontSize: 13,
}}
>
<thead>
<tr>
{[
"Бакет",
"Сделок",
"Ср. цена ₽/м²",
"Ср. площадь м²",
"Успех",
].map((h) => (
<th
key={h}
style={{
textAlign: "left",
fontSize: 11,
fontWeight: 600,
color: "var(--fg-tertiary)",
textTransform: "uppercase",
letterSpacing: "0.04em",
paddingBottom: 6,
paddingRight: 12,
whiteSpace: "nowrap",
borderBottom: "1px solid var(--border-soft)",
}}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{top5.map((row) => {
const isTop = row.bucket === top_bucket.bucket;
return (
<tr key={row.bucket}>
<td
style={{
padding: "6px 12px 6px 0",
color: "var(--fg-primary)",
fontWeight: isTop ? 600 : 400,
whiteSpace: "nowrap",
}}
>
{isTop ? "* " : ""}
{row.bucket}
</td>
<td
style={{
padding: "6px 12px 6px 0",
color: "var(--fg-secondary)",
}}
>
{row.n_deals}
</td>
<td
style={{
padding: "6px 12px 6px 0",
color: "var(--fg-secondary)",
}}
>
{formatPrice(row.avg_price_per_m2)}
</td>
<td
style={{
padding: "6px 12px 6px 0",
color: "var(--fg-secondary)",
}}
>
{formatArea(row.avg_area_m2)}
</td>
<td style={{ padding: "6px 0 6px 0" }}>
<SuccessBadge bucket={row} />
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div style={{ fontSize: 11, color: "var(--fg-tertiary)" }}>{note}</div>
</div>
);
}