gendesign/frontend/src/components/site-finder/NspdZoningBlock.tsx
lekss361 990c109756 fix(#234): NSPD harvest ETA badge + SETNX dedupe (Sub-PR C)
Backend (quarter_dump_lookup.py):
- _acquire_harvest_lock: Redis SETNX TTL=120s на quarter, защищает от burst
  N concurrent analyze, ставящих N одинаковых harvest task в очередь
- _trigger_harvest: использует lock перед apply_async, возвращает False если
  lock уже взят (другой запрос триггернул раньше)
- make_empty_result/EMPTY_DUMP_RESULT: новое поле harvest_eta_seconds в
  nspd_dump dict, типичный harvest_quarter = 60с
- /analyze: пробрасывает поле через nspd_dump dict (нет typed schema —
  response_model=None для /analyze endpoint, dict уходит как есть)

Frontend (NspdFreshnessBadge, NspdZoningBlock):
- Countdown «НСПД: загрузка ~Nс» вместо бесконечного спиннера
- После остановки countdown (remaining=0) NspdZoningBlock показывает
  «загрузка дольше обычного» + ссылку на ПКК вместо infinite skeleton

Tests: 5 новых unit + 2 для empty_result schema (всего +7, pass)

Closes #234 (UX-side; data-side resolves когда Sub-PR B + D merged).
2026-05-17 09:13:06 +03:00

275 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useState } from "react";
import type { NspdDumpMeta, NspdZoning } from "@/types/nspd";
interface Props {
data: NspdZoning | null | undefined;
dump: NspdDumpMeta | null | undefined;
cadNum: string;
}
export function NspdZoningBlock({ data, dump, cadNum }: Props) {
const [expanded, setExpanded] = useState(false);
const isHarvesting = dump?.harvest_triggered && !dump?.available;
const eta = dump?.harvest_eta_seconds ?? null;
// Issue #234: countdown пока harvest идёт.
const [remaining, setRemaining] = useState<number | null>(eta);
useEffect(() => {
setRemaining(eta);
}, [eta]);
useEffect(() => {
if (!isHarvesting) return;
if (remaining === null || remaining <= 0) return;
const id = setInterval(() => {
setRemaining((v) => (v === null || v <= 1 ? 0 : v - 1));
}, 1000);
return () => clearInterval(id);
}, [isHarvesting, remaining]);
// Issue #234: после ETA*1.5 показываем «не дождались», fallthrough к no-data UI.
// remaining === 0 значит таймер успел истечь (eta был известен).
const harvestTimedOut =
isHarvesting && !data && remaining !== null && remaining <= 0;
if (harvestTimedOut) {
return (
<div
style={{
background: "#fef3c7",
border: "1px solid #fde68a",
borderRadius: 10,
padding: "14px 18px",
}}
>
<div style={{ fontSize: 13, color: "#92400e", marginBottom: 6 }}>
Данные НСПД загружаются дольше обычного. Попробуйте обновить страницу
через минуту.
</div>
<a
href={`https://pkk.rosreestr.ru/#/search/${encodeURIComponent(cadNum)}`}
target="_blank"
rel="noopener noreferrer"
style={{
display: "inline-block",
marginTop: 6,
padding: "6px 14px",
background: "#92400e",
color: "#fff",
borderRadius: 6,
fontSize: 13,
fontWeight: 500,
textDecoration: "none",
}}
>
Открыть в ПКК
</a>
</div>
);
}
// Loading skeleton when harvest is in progress
if (isHarvesting && !data) {
const label =
remaining !== null && remaining > 0
? `Загружаем данные НСПД, осталось ~${remaining} с…`
: "Загружаем данные НСПД…";
return (
<div
style={{
background: "#eff6ff",
border: "1px solid #bfdbfe",
borderRadius: 10,
padding: "14px 18px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 8,
}}
>
<span
style={{
display: "inline-block",
width: 10,
height: 10,
borderRadius: "50%",
background: "#3b82f6",
animation: "pulse 1.5s infinite",
}}
/>
<span style={{ fontSize: 13, color: "#1d4ed8", fontWeight: 500 }}>
{label}
</span>
</div>
<div
style={{
height: 12,
background: "#bfdbfe",
borderRadius: 4,
width: "60%",
marginBottom: 6,
}}
/>
<div
style={{
height: 12,
background: "#bfdbfe",
borderRadius: 4,
width: "40%",
}}
/>
</div>
);
}
// Data not available, no harvest triggered
if (!data) {
return (
<div
style={{
background: "#f9fafb",
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "14px 18px",
}}
>
<div style={{ fontSize: 13, color: "#6b7280" }}>
Данные ПЗЗ из НСПД недоступны. Используйте{" "}
<a
href={`https://pkk.rosreestr.ru/#/search/${encodeURIComponent(cadNum)}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: "#1d4ed8", textDecoration: "none" }}
>
Публичную кадастровую карту
</a>{" "}
для кад. номера <strong style={{ color: "#374151" }}>{cadNum}</strong>
.
</div>
<a
href={`https://pkk.rosreestr.ru/#/search/${encodeURIComponent(cadNum)}`}
target="_blank"
rel="noopener noreferrer"
style={{
display: "inline-block",
marginTop: 10,
padding: "6px 14px",
background: "#1d4ed8",
color: "#fff",
borderRadius: 6,
fontSize: 13,
fontWeight: 500,
textDecoration: "none",
}}
>
Открыть в ПКК
</a>
</div>
);
}
return (
<div
style={{
background: "#f9fafb",
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "14px 18px",
}}
>
<div
style={{
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: 8,
marginBottom: 10,
}}
>
{data.zone_code && (
<span
style={{
background: "#dbeafe",
color: "#1e40af",
borderRadius: 6,
padding: "2px 10px",
fontSize: 13,
fontWeight: 600,
letterSpacing: 0.2,
}}
>
{data.zone_code}
</span>
)}
{data.zone_name && (
<span style={{ fontSize: 13, color: "#374151" }}>
{data.zone_name}
</span>
)}
{!data.zone_code && !data.zone_name && (
<span style={{ fontSize: 13, color: "#6b7280" }}>
Зона не определена
</span>
)}
</div>
{data.raw_props && Object.keys(data.raw_props).length > 0 && (
<div style={{ marginTop: 8 }}>
<button
onClick={() => setExpanded((v) => !v)}
style={{
fontSize: 12,
color: "#6b7280",
background: "none",
border: "none",
cursor: "pointer",
padding: 0,
textDecoration: "underline",
}}
>
{expanded ? "Скрыть свойства" : "Показать все свойства НСПД"}
</button>
{expanded && (
<div
style={{
marginTop: 8,
background: "#f3f4f6",
borderRadius: 6,
padding: "8px 12px",
fontSize: 12,
color: "#374151",
maxHeight: 200,
overflowY: "auto",
}}
>
{Object.entries(data.raw_props).map(([k, v]) => (
<div key={k} style={{ marginBottom: 2 }}>
<span style={{ color: "#6b7280" }}>{k}: </span>
<span>{String(v ?? "—")}</span>
</div>
))}
</div>
)}
</div>
)}
{dump?.fetched_at_utc && (
<div style={{ marginTop: 8, fontSize: 11, color: "#9ca3af" }}>
НСПД: данные от{" "}
{new Date(dump.fetched_at_utc).toLocaleDateString("ru-RU")}
{dump.stale && (
<span style={{ color: "#f59e0b", marginLeft: 4 }}>
(устаревшие)
</span>
)}
</div>
)}
</div>
);
}