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>
190 lines
5.5 KiB
TypeScript
190 lines
5.5 KiB
TypeScript
"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>
|
||
);
|
||
}
|