Дополняет UI: 5 компонентов которые импортит page.tsx (Leaflet draw, форма ConceptInput, табы вариантов, placement-карта, экспорт). Не вошли в предыдущий commit (untracked-dir). ConceptResultMap: Leaflet stroke #D1D5DB (CSS var не резолвится в SVG).
210 lines
7 KiB
TypeScript
210 lines
7 KiB
TypeScript
"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<string | null>(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 (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||
<button
|
||
type="button"
|
||
onClick={handleGeojson}
|
||
aria-label="Скачать геометрию зданий GeoJSON"
|
||
style={secondaryBtn}
|
||
>
|
||
<MapIcon size={14} strokeWidth={1.5} />
|
||
GeoJSON
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={handleCsv}
|
||
aria-label="Скачать ТЭП и финмодель CSV"
|
||
style={secondaryBtn}
|
||
>
|
||
<Download size={14} strokeWidth={1.5} />
|
||
CSV
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleServerExport("dxf")}
|
||
disabled={serverLoading !== null}
|
||
aria-label="Скачать DXF"
|
||
style={{
|
||
...secondaryBtn,
|
||
opacity: serverLoading !== null ? 0.6 : 1,
|
||
cursor: serverLoading !== null ? "not-allowed" : "pointer",
|
||
}}
|
||
>
|
||
<FileCode size={14} strokeWidth={1.5} />
|
||
{serverLoading === "dxf" ? "Экспорт…" : "DXF"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => void handleServerExport("pdf")}
|
||
disabled={serverLoading !== null}
|
||
aria-label="Скачать PDF"
|
||
style={{
|
||
...secondaryBtn,
|
||
opacity: serverLoading !== null ? 0.6 : 1,
|
||
cursor: serverLoading !== null ? "not-allowed" : "pointer",
|
||
}}
|
||
>
|
||
<FileText size={14} strokeWidth={1.5} />
|
||
{serverLoading === "pdf" ? "Экспорт…" : "PDF"}
|
||
</button>
|
||
</div>
|
||
{error && (
|
||
<p
|
||
style={{ margin: 0, fontSize: 12, color: "var(--warn)" }}
|
||
role="status"
|
||
>
|
||
{error}
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|