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 { 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" }}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue