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 { Section6Forecast } from "@/components/site-finder/analysis/Section6Forecast";
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";
// ── Props ─────────────────────────────────────────────────────────────────────
@ -114,8 +114,15 @@ export function AnalysisPageContent({ cad }: Props) {
const analysis = data as unknown as ParcelAnalysis;
const districtName = analysis.district?.district_name ?? "—";
const areaHa = analysis.geometry_suitability?.area_ha;
const areaStr = areaHa != null ? `${areaHa.toFixed(2)} га` : null;
// Площадь крошки — из ТОГО ЖЕ источника, что KPI «Площадь» и ЕГРН-таблица в
// 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 (
<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"] }) {
const conf = CONFIDENCE_STYLES[dq.confidence];
return (
<div className="border border-gray-200 rounded-xl px-[18px] py-[14px] bg-white flex items-center gap-4 flex-wrap">
<span className="font-semibold text-[13px] text-gray-700 mr-1">
<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">
Качество данных:
</span>
<span
@ -56,8 +56,13 @@ function DataQualityCard({ dq }: { dq: BestLayoutsResponse["data_quality"] }) {
>
{conf.label}
</span>
{/* Явный разделитель + неразрывное «покрытие » иначе при flex-wrap бейдж
качества и текст покрытия слипались («НизкоеПокрытие 18%», #1953). */}
<span className="text-xs text-gray-400" aria-hidden>
·
</span>
<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} ЖК)
</span>
</div>

View file

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

View file

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