feat(site-finder): G5 gate verdict banner (#32 sub-PR 2/2 FINAL) #143

Merged
lekss361 merged 1 commit from feat/32-gate-verdict-banner into main 2026-05-14 22:09:35 +00:00
3 changed files with 224 additions and 0 deletions

View file

@ -0,0 +1,190 @@
"use client";
import { useState } from "react";
import type { GateVerdict, GateVerdictLabel } from "@/types/site-finder";
import { SectionLabel } from "@/components/ui/SectionLabel";
interface GateVerdictBannerProps {
verdict: GateVerdict | null | undefined;
}
interface VerdictColor {
bg: string;
border: string;
icon: string;
iconBg: string;
}
const COLOR_BY_LABEL: Record<GateVerdictLabel, VerdictColor> = {
Можно: { bg: "#ecfdf5", border: "#34d399", icon: "✅", iconBg: "#10b981" },
Нельзя: { bg: "#fef2f2", border: "#f87171", icon: "🚫", iconBg: "#ef4444" },
"С ограничениями": {
bg: "#fffbeb",
border: "#fbbf24",
icon: "⚠️",
iconBg: "#f59e0b",
},
"Нужна проверка": {
bg: "#f9fafb",
border: "#9ca3af",
icon: "❓",
iconBg: "#6b7280",
},
};
const SOURCE_LABEL: Record<string, string> = {
nspd_dump: "данные NSPD актуальны",
nspd_dump_partial: "данные NSPD устарели (>180 дней)",
no_data: "данные NSPD не подгружены",
};
function blockerCountLabel(n: number): string {
if (n === 1) return "1 блокер";
if (n >= 2 && n <= 4) return `${n} блокера`;
return `${n} блокеров`;
}
function warningCountLabel(n: number): string {
if (n === 1) return "1 предупреждение";
if (n >= 2 && n <= 4) return `${n} предупреждения`;
return `${n} предупреждений`;
}
export function GateVerdictBanner({ verdict }: GateVerdictBannerProps) {
const [expanded, setExpanded] = useState(false);
if (!verdict) return null;
const color = COLOR_BY_LABEL[verdict.verdict_label];
const hasDetails = verdict.blockers.length > 0 || verdict.warnings.length > 0;
const sourceHint =
verdict.source === "nspd_dump_partial"
? "⏱ устарели"
: verdict.source === "no_data"
? "❓ нет данных"
: "🟢 свежие";
const summaryParts: string[] = [];
if (verdict.blockers.length > 0)
summaryParts.push(blockerCountLabel(verdict.blockers.length));
if (verdict.warnings.length > 0)
summaryParts.push(warningCountLabel(verdict.warnings.length));
const summaryText =
summaryParts.length > 0 ? summaryParts.join(" · ") : "Без замечаний";
return (
<div
style={{
background: color.bg,
border: `2px solid ${color.border}`,
borderRadius: 12,
padding: 16,
marginBottom: 4,
}}
>
{/* Header row */}
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<div
style={{
background: color.iconBg,
color: "white",
borderRadius: "50%",
width: 40,
height: 40,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 20,
flexShrink: 0,
}}
>
{color.icon}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 18, fontWeight: 700, color: "#111827" }}>
МКД: {verdict.verdict_label}
</div>
<div style={{ fontSize: 12, color: "#6b7280", marginTop: 2 }}>
{summaryText}
{" · "}
<span title={SOURCE_LABEL[verdict.source] ?? verdict.source}>
{sourceHint}
</span>
</div>
</div>
{hasDetails && (
<button
onClick={() => setExpanded((v) => !v)}
style={{
background: "transparent",
border: `1px solid ${color.border}`,
color: "#374151",
padding: "6px 12px",
borderRadius: 6,
cursor: "pointer",
fontSize: 13,
flexShrink: 0,
}}
>
{expanded ? "Свернуть" : "Подробнее"}
</button>
)}
</div>
{/* Expanded details */}
{expanded && (
<div
style={{
marginTop: 16,
paddingTop: 16,
borderTop: `1px solid ${color.border}`,
}}
>
{verdict.blockers.length > 0 && (
<div style={{ marginBottom: 12 }}>
<SectionLabel>🚫 Блокеры</SectionLabel>
<ul
style={{
marginTop: 4,
marginLeft: 20,
fontSize: 13,
color: "#7f1d1d",
}}
>
{verdict.blockers.map((b) => (
<li key={b.code}>
<b>{b.code}:</b> {b.detail}
</li>
))}
</ul>
</div>
)}
{verdict.warnings.length > 0 && (
<div style={{ marginBottom: 12 }}>
<SectionLabel> Предупреждения</SectionLabel>
<ul
style={{
marginTop: 4,
marginLeft: 20,
fontSize: 13,
color: "#78350f",
}}
>
{verdict.warnings.map((w) => (
<li key={w.code}>
<b>{w.code}:</b> {w.detail}
</li>
))}
</ul>
</div>
)}
{verdict.checks_performed.length > 0 && (
<div style={{ fontSize: 12, color: "#6b7280" }}>
Проверки: {verdict.checks_performed.join(" · ")}
</div>
)}
</div>
)}
</div>
);
}

View file

@ -4,6 +4,7 @@ import type { FeatureCollection } from "geojson";
import type { ParcelAnalysis } from "@/types/site-finder";
import { SectionLabel } from "@/components/ui/SectionLabel";
import { ConfidenceBadge } from "./ConfidenceBadge";
import { GateVerdictBanner } from "./GateVerdictBanner";
import { IsochronesPanel } from "./IsochronesPanel";
import { ScoreBreakdownPanel } from "./ScoreBreakdownPanel";
@ -40,6 +41,9 @@ export function OverviewTab({ data, onIsochronesResult }: Props) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
{/* G5 (#32): gate verdict banner — МКД buildability */}
{data.gate_verdict && <GateVerdictBanner verdict={data.gate_verdict} />}
{/* X2 (#48): confidence indicator на самом верху Overview */}
{data.confidence !== undefined && data.confidence_label && (
<ConfidenceBadge

View file

@ -204,6 +204,34 @@ export interface Pipeline24mo {
note?: string;
}
// G5 (#32) — Gate verdict: can_build_mkd
export type GateVerdictSource = "nspd_dump" | "nspd_dump_partial" | "no_data";
export type GateVerdictLabel =
| "Можно"
| "Нельзя"
| "С ограничениями"
| "Нужна проверка";
export interface GateBlocker {
code: string;
detail: string;
}
export interface GateWarning {
code: string;
detail: string;
}
export interface GateVerdict {
can_build_mkd: boolean | "unknown";
verdict_label: GateVerdictLabel;
blockers: GateBlocker[];
warnings: GateWarning[];
checks_performed: string[];
source: GateVerdictSource;
}
export interface ParcelLocation {
distance_to_center_km: number;
center_bonus: number;
@ -312,6 +340,8 @@ export interface ParcelAnalysis {
confidence_caveats?: string[];
// D4 (#36) — 24-month project pipeline competition
pipeline_24mo?: Pipeline24mo;
// G5 (#32) — gate verdict: МКД buildability
gate_verdict?: GateVerdict;
}
export type PoiCategory =