276 lines
9.3 KiB
TypeScript
276 lines
9.3 KiB
TypeScript
"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>
|
||
);
|
||
}
|