gendesign/frontend/src/components/site-finder/GateVerdictBanner.tsx
bot-backend 86f0797499
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m8s
Deploy / deploy (push) Successful in 1m19s
fix(site-finder): report microcopy batch A — gate titles, breadcrumb area, §4.2/4.3 (#1953) (#2095)
2026-06-29 08:29:34 +00:00

328 lines
11 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 { useState } from "react";
import type { ComponentType } from "react";
import {
AlertOctagon,
AlertTriangle,
CheckCircle2,
Clock,
HelpCircle,
XCircle,
type LucideProps,
} from "lucide-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: ComponentType<LucideProps>;
iconBg: string;
}
const COLOR_BY_LABEL: Record<GateVerdictLabel, VerdictColor> = {
Можно: {
bg: "#ecfdf5",
border: "#34d399",
Icon: CheckCircle2,
iconBg: "#10b981",
},
Нельзя: {
bg: "#fef2f2",
border: "#f87171",
Icon: XCircle,
iconBg: "#ef4444",
},
"С ограничениями": {
bg: "#fffbeb",
border: "#fbbf24",
Icon: AlertTriangle,
iconBg: "#f59e0b",
},
"Нужна проверка": {
bg: "#f9fafb",
border: "#9ca3af",
Icon: HelpCircle,
iconBg: "#6b7280",
},
};
const SOURCE_LABEL: Record<string, string> = {
nspd_dump: "данные NSPD актуальны",
nspd_dump_partial: "данные NSPD устарели (>180 дней)",
no_data: "данные NSPD не подгружены",
};
// Человеческий ЗАГОЛОВОК для сырых gate-кодов из backend (gate_verdict.py).
// Бэкенд отдаёт {code, detail}; detail уже человекочитаемый — мы поверх него
// показываем короткий жирный заголовок по коду, а сам код оставляем мелким
// тех-тегом (для отладки). Семантика 1:1 с compute_gate_verdict.
const GATE_CODE_TITLE: Record<string, string> = {
PZZ_NOT_RESIDENTIAL: "Зона не под жильё",
PZZ_UNKNOWN: "Зона ПЗЗ не определена",
ZOUIT_CAD_SZZ: "Санитарно-защитная зона (СЗЗ)",
ZOUIT_CAD_OTHER: "Охранная зона (ЗОУИТ)",
ZOUIT_CAD_BLOCKER: "Охранная зона инж.сетей",
ZOUIT_NETWORK_OBREMENENIE: "Сетевое обременение (инж.сети)",
ZOUIT_ENGINEERING_PARTIAL: "Охранная зона инж.сетей (частично)",
ZOUIT_OVERLAP_SUB17: "Охранная зона инж.сетей",
NO_NSPD_DUMP: "Данные НСПД не подгружены",
NO_ENGINEERING_NEARBY: "Инж.сети рядом не найдены",
};
// Динамические ZOUIT-коды (ZOUIT_SUB17, ZOUIT_OVERLAP_SUB17, ZOUIT_OVERLAP_SUBxx)
// несут суффикс subcategory — резолвим по префиксу к одному заголовку.
const GATE_CODE_PREFIX_TITLE: Array<[string, string]> = [
["ZOUIT_OVERLAP_SUB", "Охранная зона инж.сетей"],
["ZOUIT_SUB", "Зона с особыми условиями (ЗОУИТ)"],
];
/**
* Человеческий заголовок для gate-кода. Сначала точное совпадение, затем
* префиксная резолюция (ZOUIT_SUB.. / ZOUIT_OVERLAP_SUB..), и graceful fallback —
* сам код как заголовок (как было до #1953), чтобы новый backend-код не «терялся».
*/
function gateCodeTitle(code: string): string {
const exact = GATE_CODE_TITLE[code];
if (exact) return exact;
for (const [prefix, title] of GATE_CODE_PREFIX_TITLE) {
if (code.startsWith(prefix)) return title;
}
return code;
}
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} предупреждений`;
}
/**
* Один пункт блокера/предупреждения: жирный человеческий заголовок (по коду) +
* detail из backend (полное предложение) + сам код мелким приглушённым моно-тегом
* (для отладки, не в глаза). Цвет текста наследуется от родительского <ul>.
*/
function GateItem({ code, detail }: { code: string; detail: string }) {
const title = gateCodeTitle(code);
return (
<li style={{ marginBottom: 6 }}>
<div style={{ fontWeight: 600 }}>{title}</div>
{detail && <div style={{ marginTop: 1 }}>{detail}</div>}
<code
style={{
display: "inline-block",
marginTop: 2,
fontSize: 10,
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
color: "var(--fg-tertiary, #73767E)",
}}
>
{code}
</code>
</li>
);
}
export function GateVerdictBanner({ verdict }: GateVerdictBannerProps) {
const [expanded, setExpanded] = useState(false);
if (!verdict) return null;
const color = COLOR_BY_LABEL[verdict.verdict_label];
const Icon = color.Icon;
const hasDetails = verdict.blockers.length > 0 || verdict.warnings.length > 0;
const iconSx = { flexShrink: 0 } as const;
const sourceHint =
verdict.source === "nspd_dump_partial" ? (
<>
<Clock size={14} strokeWidth={1.5} aria-hidden style={iconSx} />
устарели
</>
) : verdict.source === "no_data" ? (
<>
<HelpCircle size={14} strokeWidth={1.5} aria-hidden style={iconSx} />
нет данных
</>
) : (
<>
<CheckCircle2
size={14}
strokeWidth={1.5}
aria-hidden
style={{ ...iconSx, color: "var(--success, #0A7A3A)" }}
/>
свежие
</>
);
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",
flexShrink: 0,
}}
>
<Icon size={20} strokeWidth={1.5} aria-hidden />
</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}
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
verticalAlign: "middle",
}}
>
{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>
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
}}
>
<AlertOctagon
size={16}
strokeWidth={1.5}
aria-hidden
style={{ flexShrink: 0, color: "var(--danger, #B3261E)" }}
/>
Блокеры
</span>
</SectionLabel>
<ul
style={{
marginTop: 4,
marginLeft: 20,
fontSize: 13,
color: "#7f1d1d",
}}
>
{verdict.blockers.map((b) => (
<GateItem key={b.code} code={b.code} detail={b.detail} />
))}
</ul>
</div>
)}
{verdict.warnings.length > 0 && (
<div style={{ marginBottom: 12 }}>
<SectionLabel>
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
}}
>
<AlertTriangle
size={16}
strokeWidth={1.5}
aria-hidden
style={{ flexShrink: 0, color: "var(--warn, #9A6700)" }}
/>
Предупреждения
</span>
</SectionLabel>
<ul
style={{
marginTop: 4,
marginLeft: 20,
fontSize: 13,
color: "#78350f",
}}
>
{verdict.warnings.map((w) => (
<GateItem key={w.code} code={w.code} detail={w.detail} />
))}
</ul>
</div>
)}
{verdict.checks_performed.length > 0 && (
<div style={{ fontSize: 12, color: "#6b7280" }}>
Проверки: {verdict.checks_performed.join(" · ")}
</div>
)}
</div>
)}
</div>
);
}