gendesign/frontend/src/components/site-finder/analysis/ExportButtons.tsx
bot-backend d6d4a1c94a
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m16s
Deploy / deploy (push) Successful in 1m18s
fix(site-finder): «Скачать отчёт» без пустых скобок на fast-path из кэша (#2289)
2026-07-03 11:32:04 +00:00

276 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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";
/**
* ExportButtons — PDF snapshot (B7) + frontend-side CSV generation.
*
* PDF: GET /api/v1/parcels/{cad}/snapshot.pdf → blob download.
* CSV: client-side generation from analyze response fields.
* Loading state, error toast via alert (no toast lib dep).
*/
import { useState } from "react";
import { Download, FileText, FileBarChart } from "lucide-react";
import { API_BASE_URL } from "@/lib/api";
import { triggerDownload } from "@/lib/download";
import {
useFullReport,
formatElapsed,
readyReportLabel,
} from "@/hooks/useFullReport";
import type { ParcelAnalyzeResponse, ParcelEgrn } from "@/lib/site-finder-api";
interface Props {
cad: string;
analyzeData?: ParcelAnalyzeResponse | null;
/**
* Adapted EGRN (frontend ParcelEgrn shape). Passed in by the parent
* (Section1ParcelInfo) which runs `adaptEgrn` on `analyzeData.egrn` to
* translate backend snake_case columns (permitted_use_text / land_category /
* etc.) → frontend labels (vri / category / etc.). Without this, the CSV's
* 9 ЕГРН columns rendered empty in prod (#1217).
*/
egrn?: ParcelEgrn | null;
}
// ── CSV generation ────────────────────────────────────────────────────────────
function buildCsvRows(
data: ParcelAnalyzeResponse,
egrn: ParcelEgrn | null | undefined,
): string[][] {
const rows: string[][] = [
["Поле", "Значение"],
["Кадастровый номер", data.cad_num],
["Балл", String(data.score)],
["Оценка", data.score_label ?? ""],
["Район", data.district?.district_name ?? ""],
[
"Медиана ₽/м²",
data.district?.median_price_per_m2
? String(data.district.median_price_per_m2)
: "",
],
["POI (кол-во)", String(data.poi_count)],
];
if (egrn) {
rows.push(
["Адрес", egrn.address],
["Площадь м²", Number.isFinite(egrn.area_m2) ? String(egrn.area_m2) : ""],
["ВРИ", egrn.vri],
["Категория", egrn.category],
["Форма собственности", egrn.owner_type],
["Обременения", egrn.encumbrance],
["Статус ЕГРН", egrn.status],
["Дата регистрации", egrn.registration_date ?? ""],
["Обновлено", egrn.last_updated ?? ""],
);
}
return rows;
}
function escapeCsvCell(cell: string): string {
// CSV-injection mitigation (OWASP / CWE-1236): cells starting with =, +, -, @,
// tab, or CR are evaluated as formulas by Excel/Calc/Sheets. Prefix dangerous
// starters with a leading apostrophe so the spreadsheet treats them as text.
let safe = cell;
if (/^[=+\-@\t\r]/.test(safe)) {
safe = "'" + safe;
}
const needsQuotes = /[",\n\r]/.test(safe);
if (needsQuotes) {
return `"${safe.replace(/"/g, '""')}"`;
}
return safe;
}
function generateCsvBlob(rows: string[][]): Blob {
const csvContent = rows
.map((row) => row.map(escapeCsvCell).join(","))
.join("\r\n");
return new Blob(["" + csvContent], { type: "text/csv;charset=utf-8" });
}
// ── Component ─────────────────────────────────────────────────────────────────
export function ExportButtons({ cad, analyzeData, egrn }: Props) {
const [pdfLoading, setPdfLoading] = useState(false);
const [csvLoading, setCsvLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Полный PDF-отчёт (#2259 PR-E) — общий хук enqueue → poll → download.
const fullReport = useFullReport(cad);
const reportBuilding = fullReport.uiState === "building";
const reportReady = fullReport.uiState === "ready";
async function handlePdfDownload() {
setPdfLoading(true);
setError(null);
try {
const res = await fetch(
`${API_BASE_URL}/api/v1/parcels/${encodeURIComponent(cad)}/snapshot.pdf`,
);
if (!res.ok) {
throw new Error(`Ошибка сервера ${res.status}`);
}
const blob = await res.blob();
triggerDownload(blob, `участок-${cad}.pdf`);
} catch (err) {
const msg = err instanceof Error ? err.message : "Ошибка загрузки PDF";
setError(msg);
} finally {
setPdfLoading(false);
}
}
function handleCsvDownload() {
setCsvLoading(true);
setError(null);
try {
if (!analyzeData) {
setError("Данные ещё не загружены");
return;
}
const rows = buildCsvRows(analyzeData, egrn);
const blob = generateCsvBlob(rows);
triggerDownload(blob, `участок-${cad}.csv`);
} catch (err) {
const msg = err instanceof Error ? err.message : "Ошибка генерации CSV";
setError(msg);
} finally {
setCsvLoading(false);
}
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{/* PDF button — secondary (orange per design token) */}
<button
onClick={() => void handlePdfDownload()}
disabled={pdfLoading}
aria-label="Скачать снапшот PDF"
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "6px 12px",
height: 32,
background: pdfLoading ? "var(--bg-card-alt)" : "var(--accent-2)",
color: pdfLoading ? "var(--fg-tertiary)" : "#fff",
border: "none",
borderRadius: 6,
fontSize: 13,
fontWeight: 500,
cursor: pdfLoading ? "not-allowed" : "pointer",
opacity: pdfLoading ? 0.6 : 1,
transition: "opacity 150ms",
}}
>
<FileText size={14} strokeWidth={1.5} />
{pdfLoading ? "Загрузка..." : "Снапшот PDF"}
</button>
{/* CSV button — secondary (orange) */}
<button
onClick={handleCsvDownload}
disabled={csvLoading || !analyzeData}
aria-label="Скачать данные CSV"
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "6px 12px",
height: 32,
background:
csvLoading || !analyzeData
? "var(--bg-card-alt)"
: "var(--accent-2)",
color: csvLoading || !analyzeData ? "var(--fg-tertiary)" : "#fff",
border: "none",
borderRadius: 6,
fontSize: 13,
fontWeight: 500,
cursor: csvLoading || !analyzeData ? "not-allowed" : "pointer",
opacity: csvLoading || !analyzeData ? 0.6 : 1,
transition: "opacity 150ms",
}}
>
<Download size={14} strokeWidth={1.5} />
{csvLoading ? "Генерация..." : "Скачать CSV"}
</button>
{/* Full-report PDF — async job (#2259 PR-E): enqueue → poll → download */}
<button
onClick={() =>
reportReady ? fullReport.download() : fullReport.start()
}
disabled={reportBuilding}
aria-label={
reportReady
? "Скачать полный отчёт PDF"
: "Собрать и скачать полный отчёт PDF"
}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "6px 12px",
height: 32,
background: reportBuilding
? "var(--bg-card-alt)"
: "var(--accent-2)",
color: reportBuilding ? "var(--fg-tertiary)" : "#fff",
border: "none",
borderRadius: 6,
fontSize: 13,
fontWeight: 500,
cursor: reportBuilding ? "not-allowed" : "pointer",
opacity: reportBuilding ? 0.6 : 1,
transition: "opacity 150ms",
}}
>
<FileBarChart size={14} strokeWidth={1.5} />
{reportBuilding
? `Готовится… ${formatElapsed(fullReport.elapsedMs)}`
: reportReady
? readyReportLabel(fullReport.reportGeneratedAt)
: "Полный отчёт (PDF)"}
</button>
</div>
{/* Full-report timeout / enqueue-error note (contract has no "failed") */}
{fullReport.uiState === "timeout" && (
<p
style={{ margin: 0, fontSize: 12, color: "var(--warn)" }}
role="alert"
>
Сборка затянулась попробуйте позже
</p>
)}
{fullReport.uiState === "error" && (
<p
style={{ margin: 0, fontSize: 12, color: "var(--danger)" }}
role="alert"
>
Не удалось поставить сборку отчёта повторите
</p>
)}
{/* Error message */}
{error && (
<p
style={{
margin: 0,
fontSize: 12,
color: "var(--danger)",
}}
role="alert"
>
{error}
</p>
)}
</div>
);
}