fix(site-finder): report microcopy batch A — gate titles, breadcrumb area, §4.2/4.3 (#1953) #2095
4 changed files with 190 additions and 100 deletions
|
|
@ -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" }}>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue