"use client"; /** * ConceptExportButtons — export controls for one concept variant. * * The Stage-1a backend contract (`backend/app/api/v1/concepts.py`) ships only * `POST /concepts`; no export endpoint exists yet. So: * - GeoJSON (buildings) + CSV (ТЭП + финмодель) are generated client-side * here, re-using `triggerDownload` and the CSV-injection-safe escaping from * the Site Finder ExportButtons. * - DXF / PDF are produced server-side (ezdxf / WeasyPrint per the stack). * We POST the variant to the documented `/api/v1/concepts/export` path and * download the blob; until that endpoint lands the button reports a clear * "не готово" message instead of failing silently. */ import { useState } from "react"; import { Download, FileCode, FileText, Map as MapIcon } from "lucide-react"; import { API_BASE_URL } from "@/lib/api"; import { triggerDownload } from "@/lib/download"; import { STRATEGY_LABELS, type ConceptVariant } from "@/lib/concept-api"; // ── CSV helpers (mirrors site-finder ExportButtons) ─────────────────────────── 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 buildCsvRows(variant: ConceptVariant): string[][] { const t = variant.teap; const f = variant.financial; return [ ["Показатель", "Значение"], ["Стратегия", STRATEGY_LABELS[variant.strategy]], ["Площадь застройки, м²", String(t.built_area_sqm)], ["Общая площадь, м²", String(t.total_floor_area_sqm)], ["Жилая площадь, м²", String(t.residential_area_sqm)], ["Квартир, шт", String(t.apartments_count)], ["Плотность", String(t.density)], ["Машино-мест", String(t.parking_spaces)], ["Выручка, ₽", String(f.revenue_rub)], ["Затраты, ₽", String(f.cost_rub)], ["Валовая прибыль, ₽", String(f.gross_margin_rub)], ["IRR", String(f.irr)], ]; } function generateCsvBlob(rows: string[][]): Blob { const csv = rows.map((row) => row.map(escapeCsvCell).join(",")).join("\r\n"); // UTF-8 BOM so Excel renders Cyrillic correctly. return new Blob(["" + csv], { type: "text/csv;charset=utf-8" }); } // ── Component ───────────────────────────────────────────────────────────────── const today = () => new Date().toISOString().slice(0, 10); interface Props { variant: ConceptVariant; } export function ConceptExportButtons({ variant }: Props) { const [serverLoading, setServerLoading] = useState<"dxf" | "pdf" | null>( null, ); const [error, setError] = useState(null); const fileStem = `gendesign_concept_${variant.strategy}_${today()}`; function handleGeojson() { setError(null); const blob = new Blob( [JSON.stringify(variant.buildings_geojson, null, 2)], { type: "application/geo+json", }, ); triggerDownload(blob, `${fileStem}.geojson`); } function handleCsv() { setError(null); const blob = generateCsvBlob(buildCsvRows(variant)); triggerDownload(blob, `${fileStem}.csv`); } async function handleServerExport(format: "dxf" | "pdf") { setServerLoading(format); setError(null); try { const res = await fetch( `${API_BASE_URL}/api/v1/concepts/export?format=${format}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(variant), }, ); if (res.status === 404 || res.status === 405) { setError( format === "dxf" ? "Экспорт DXF появится после публикации движка генерации." : "Экспорт PDF появится после публикации движка генерации.", ); return; } if (!res.ok) { throw new Error(`Ошибка сервера ${res.status}`); } const blob = await res.blob(); triggerDownload(blob, `${fileStem}.${format}`); } catch (err) { const msg = err instanceof Error ? err.message : `Ошибка экспорта ${format.toUpperCase()}`; setError(msg); } finally { setServerLoading(null); } } const secondaryBtn: React.CSSProperties = { display: "inline-flex", alignItems: "center", gap: 6, padding: "6px 12px", height: 32, background: "var(--accent-2)", color: "#fff", border: "none", borderRadius: 6, fontSize: 13, fontWeight: 500, cursor: "pointer", }; return (
{error && (

{error}

)}
); }