feat(site-finder): G5 gate verdict banner (#32 sub-PR 2/2 FINAL) #143
3 changed files with 224 additions and 0 deletions
190
frontend/src/components/site-finder/GateVerdictBanner.tsx
Normal file
190
frontend/src/components/site-finder/GateVerdictBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue