"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(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 (
{/* PDF button — secondary (orange per design token) */} {/* CSV button — secondary (orange) */} {/* Full-report PDF — async job (#2259 PR-E): enqueue → poll → download */}
{/* Full-report timeout / enqueue-error note (contract has no "failed") */} {fullReport.uiState === "timeout" && (

Сборка затянулась — попробуйте позже

)} {fullReport.uiState === "error" && (

Не удалось поставить сборку отчёта — повторите

)} {/* Error message */} {error && (

{error}

)}
); }