gendesign/frontend/src/components/concept/ConceptExportButtons.tsx
Light1YT 3bbbf25412
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 2m45s
Deploy / deploy (push) Successful in 1m3s
feat(concept): concept-компоненты (DrawMap/ParamsForm/VariantsResult/ResultMap/ExportButtons) (#57)
Дополняет UI: 5 компонентов которые импортит page.tsx (Leaflet draw, форма ConceptInput,
табы вариантов, placement-карта, экспорт). Не вошли в предыдущий commit (untracked-dir).
ConceptResultMap: Leaflet stroke #D1D5DB (CSS var не резолвится в SVG).
2026-06-13 16:42:19 +00:00

210 lines
7 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.

"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>
);
}