fix(site-finder): report microcopy batch A — gate titles, breadcrumb area, §4.2/4.3 (#1953) #2095

Merged
bot-backend merged 1 commit from fix/report-microcopy-batchA-1953 into main 2026-06-29 08:29:35 +00:00
4 changed files with 190 additions and 100 deletions

View file

@ -14,7 +14,7 @@ import { Section4Estimate } from "@/components/site-finder/analysis/Section4Esti
import { Section5Atmosphere } from "@/components/site-finder/analysis/Section5Atmosphere"; import { Section5Atmosphere } from "@/components/site-finder/analysis/Section5Atmosphere";
import { Section6Forecast } from "@/components/site-finder/analysis/Section6Forecast"; import { Section6Forecast } from "@/components/site-finder/analysis/Section6Forecast";
import { Section7Concept } from "@/components/site-finder/analysis/Section7Concept"; import { Section7Concept } from "@/components/site-finder/analysis/Section7Concept";
import { useParcelAnalyzeQuery } from "@/lib/site-finder-api"; import { adaptEgrn, useParcelAnalyzeQuery } from "@/lib/site-finder-api";
import type { ParcelAnalysis } from "@/types/site-finder"; import type { ParcelAnalysis } from "@/types/site-finder";
// ── Props ───────────────────────────────────────────────────────────────────── // ── Props ─────────────────────────────────────────────────────────────────────
@ -114,8 +114,15 @@ export function AnalysisPageContent({ cad }: Props) {
const analysis = data as unknown as ParcelAnalysis; const analysis = data as unknown as ParcelAnalysis;
const districtName = analysis.district?.district_name ?? "—"; const districtName = analysis.district?.district_name ?? "—";
const areaHa = analysis.geometry_suitability?.area_ha; // Площадь крошки — из ТОГО ЖЕ источника, что KPI «Площадь» и ЕГРН-таблица в
const areaStr = areaHa != null ? `${areaHa.toFixed(2)} га` : null; // Section1: ЕГРН-area (egrn_block.area_m2 через adaptEgrn), а НЕ geometry-area
// (geometry_suitability.area_ha считается по геометрии кадастрового квартала и
// расходится с зарегистрированной площадью участка — давало 6.61 vs 11.68 га).
const egrnAreaM2 = adaptEgrn(analysis.egrn, cad)?.area_m2;
const areaStr =
egrnAreaM2 != null && Number.isFinite(egrnAreaM2) && egrnAreaM2 > 0
? `${(egrnAreaM2 / 10000).toFixed(2)} га`
: null;
return ( return (
<main style={{ padding: "24px 20px", maxWidth: 1400, margin: "0 auto" }}> <main style={{ padding: "24px 20px", maxWidth: 1400, margin: "0 auto" }}>

View file

@ -47,8 +47,8 @@ const ROOM_BUCKET_LABELS: Record<string, string> = {
function DataQualityCard({ dq }: { dq: BestLayoutsResponse["data_quality"] }) { function DataQualityCard({ dq }: { dq: BestLayoutsResponse["data_quality"] }) {
const conf = CONFIDENCE_STYLES[dq.confidence]; const conf = CONFIDENCE_STYLES[dq.confidence];
return ( return (
<div className="border border-gray-200 rounded-xl px-[18px] py-[14px] bg-white flex items-center gap-4 flex-wrap"> <div className="border border-gray-200 rounded-xl px-[18px] py-[14px] bg-white flex items-center gap-x-2 gap-y-1 flex-wrap">
<span className="font-semibold text-[13px] text-gray-700 mr-1"> <span className="font-semibold text-[13px] text-gray-700">
Качество данных: Качество данных:
</span> </span>
<span <span
@ -56,8 +56,13 @@ function DataQualityCard({ dq }: { dq: BestLayoutsResponse["data_quality"] }) {
> >
{conf.label} {conf.label}
</span> </span>
{/* Явный разделитель + неразрывное «покрытие » иначе при flex-wrap бейдж
качества и текст покрытия слипались («НизкоеПокрытие 18%», #1953). */}
<span className="text-xs text-gray-400" aria-hidden>
·
</span>
<span className="text-xs text-gray-500"> <span className="text-xs text-gray-500">
Покрытие {dq.velocity_coverage_pct.toFixed(0)}% ( покрытие {dq.velocity_coverage_pct.toFixed(0)}% (
{dq.objects_with_velocity_data} из {dq.objects_total_in_radius} ЖК) {dq.objects_with_velocity_data} из {dq.objects_total_in_radius} ЖК)
</span> </span>
</div> </div>

View file

@ -31,7 +31,12 @@ const COLOR_BY_LABEL: Record<GateVerdictLabel, VerdictColor> = {
Icon: CheckCircle2, Icon: CheckCircle2,
iconBg: "#10b981", iconBg: "#10b981",
}, },
Нельзя: { bg: "#fef2f2", border: "#f87171", Icon: XCircle, iconBg: "#ef4444" }, Нельзя: {
bg: "#fef2f2",
border: "#f87171",
Icon: XCircle,
iconBg: "#ef4444",
},
"С ограничениями": { "С ограничениями": {
bg: "#fffbeb", bg: "#fffbeb",
border: "#fbbf24", border: "#fbbf24",
@ -52,6 +57,44 @@ const SOURCE_LABEL: Record<string, string> = {
no_data: "данные NSPD не подгружены", 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 { function blockerCountLabel(n: number): string {
if (n === 1) return "1 блокер"; if (n === 1) return "1 блокер";
if (n >= 2 && n <= 4) return `${n} блокера`; if (n >= 2 && n <= 4) return `${n} блокера`;
@ -64,6 +107,33 @@ function warningCountLabel(n: number): string {
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) { export function GateVerdictBanner({ verdict }: GateVerdictBannerProps) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
@ -208,9 +278,7 @@ export function GateVerdictBanner({ verdict }: GateVerdictBannerProps) {
}} }}
> >
{verdict.blockers.map((b) => ( {verdict.blockers.map((b) => (
<li key={b.code}> <GateItem key={b.code} code={b.code} detail={b.detail} />
<b>{b.code}:</b> {b.detail}
</li>
))} ))}
</ul> </ul>
</div> </div>
@ -243,9 +311,7 @@ export function GateVerdictBanner({ verdict }: GateVerdictBannerProps) {
}} }}
> >
{verdict.warnings.map((w) => ( {verdict.warnings.map((w) => (
<li key={w.code}> <GateItem key={w.code} code={w.code} detail={w.detail} />
<b>{w.code}:</b> {w.detail}
</li>
))} ))}
</ul> </ul>
</div> </div>

View file

@ -139,7 +139,7 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) {
<div style={{ display: "flex", alignItems: "center", gap: 6 }}> <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
{!dataAvailable && ( {!dataAvailable && (
<span className="bg-slate-100 text-slate-500 text-xs px-2 py-0.5 rounded"> <span className="bg-slate-100 text-slate-500 text-xs px-2 py-0.5 rounded">
нет данных velocity нет данных о темпе продаж
</span> </span>
)} )}
{isObjectiveSource && ( {isObjectiveSource && (
@ -328,95 +328,107 @@ export function VelocityBlock({ velocity }: VelocityBlockProps) {
{velocity.by_room_bucket && ( {velocity.by_room_bucket && (
<div style={{ marginBottom: 12 }}> <div style={{ marginBottom: 12 }}>
<SectionLabel>По комнатности</SectionLabel> <SectionLabel>По комнатности</SectionLabel>
<table {/* Пустой объект / все бакеты отсутствуют таблица отрисовывала
style={{ голые заголовки без строк (выглядело сломанной, #1953). Показываем
width: "100%", аккуратный empty-state вместо пустой шапки. */}
fontSize: 12, {BUCKET_ORDER.every((b) => !velocity.by_room_bucket?.[b]) ? (
borderCollapse: "collapse", <div style={{ marginTop: 4 }}>
marginTop: 4, <EmptyState message="Нет данных по комнатности в радиусе" />
}} </div>
> ) : (
<thead> <table
<tr style={{
style={{ color: "#6b7280", borderBottom: "1px solid #e5e7eb" }} width: "100%",
> fontSize: 12,
<th borderCollapse: "collapse",
marginTop: 4,
}}
>
<thead>
<tr
style={{ style={{
textAlign: "left", color: "#6b7280",
padding: "4px 8px", borderBottom: "1px solid #e5e7eb",
fontWeight: 500,
}} }}
> >
Комната <th
</th> style={{
<th textAlign: "left",
style={{ padding: "4px 8px",
textAlign: "right", fontWeight: 500,
padding: "4px 8px", }}
fontWeight: 500, >
}} Комната
> </th>
Сделок <th
</th> style={{
<th textAlign: "right",
style={{ padding: "4px 8px",
textAlign: "right", fontWeight: 500,
padding: "4px 8px", }}
fontWeight: 500, >
}} Сделок
> </th>
м² <th
</th> style={{
<th textAlign: "right",
style={{ padding: "4px 8px",
textAlign: "right", fontWeight: 500,
padding: "4px 8px", }}
fontWeight: 500, >
}} м²
> </th>
ЖК <th
</th> style={{
</tr> textAlign: "right",
</thead> padding: "4px 8px",
<tbody> fontWeight: 500,
{BUCKET_ORDER.map((b) => { }}
const stat = velocity.by_room_bucket?.[b]; >
if (!stat) return null; ЖК
return ( </th>
<tr key={b} style={{ borderBottom: "1px solid #f3f4f6" }}> </tr>
<td style={{ padding: "4px 8px" }}>{BUCKET_LABEL[b]}</td> </thead>
<td <tbody>
style={{ {BUCKET_ORDER.map((b) => {
padding: "4px 8px", const stat = velocity.by_room_bucket?.[b];
textAlign: "right", if (!stat) return null;
fontWeight: 500, return (
}} <tr key={b} style={{ borderBottom: "1px solid #f3f4f6" }}>
> <td style={{ padding: "4px 8px" }}>{BUCKET_LABEL[b]}</td>
{stat.units.toLocaleString("ru")} <td
</td> style={{
<td padding: "4px 8px",
style={{ textAlign: "right",
padding: "4px 8px", fontWeight: 500,
textAlign: "right", }}
color: "#6b7280", >
}} {stat.units.toLocaleString("ru")}
> </td>
{Math.round(stat.sqm).toLocaleString("ru")} <td
</td> style={{
<td padding: "4px 8px",
style={{ textAlign: "right",
padding: "4px 8px", color: "#6b7280",
textAlign: "right", }}
color: "#6b7280", >
}} {Math.round(stat.sqm).toLocaleString("ru")}
> </td>
{stat.complexes_count} <td
</td> style={{
</tr> padding: "4px 8px",
); textAlign: "right",
})} color: "#6b7280",
</tbody> }}
</table> >
{stat.complexes_count}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div> </div>
)} )}