gendesign/frontend/src/components/site-finder/GateVerdictBanner.tsx
lekss361 95647a1c82
feat(site-finder): G5 gate verdict banner in OverviewTab (#32 sub-PR 2/2 FINAL) (#143)
GateVerdictBanner с 4 visual states + expand/collapse details.
Types: GateVerdict/GateBlocker/GateWarning/GateVerdictSource/GateVerdictLabel.
Renders at top of OverviewTab driven by backend aggregator (#142).

tsc 0 errors, ESLint 0 warnings.

Vault: code/modules/Module_Gate_Verdict_Banner.md NEW.

Closes #32

Co-authored-by: lekss361 <claudestars@proton.me>
2026-05-15 01:09:34 +03:00

190 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { 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>
);
}