328 lines
11 KiB
TypeScript
328 lines
11 KiB
TypeScript
"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>
|
||
);
|
||
}
|