gendesign/frontend/src/components/concept/ConceptExportButtons.tsx
Light1YT 86e9ea2937 fix(week-review): автофиксы код-ревью — 169 issue (label «week ревью 1»)
Многоагентный аудит + имплементация: один воркер на файл, точечные правки.
Верификация: py_compile (47/47 .py) + tsc --noEmit (0 ошибок). Unit-тесты
не прогонялись (окружение не поднято: rollup native dep / нет pytest-venv).

Полностью исправлено (169): #1336, #1337, #1339, #1340, #1341, #1342, #1343, #1345, #1346, #1348, #1349, #1350, #1351, #1354, #1356, #1358, #1359, #1360, #1362, #1364, #1365, #1366, #1367, #1368, #1369, #1370, #1371, #1372, #1373, #1374, #1375, #1376, #1377, #1378, #1379, #1380, #1381, #1382, #1384, #1385, #1386, #1387, #1388, #1389, #1390, #1391, #1392, #1394, #1395, #1396, #1397, #1399, #1400, #1401, #1402, #1403, #1404, #1408, #1409, #1410, #1411, #1412, #1413, #1414, #1415, #1416, #1417, #1418, #1420, #1423, #1425, #1426, #1427, #1428, #1429, #1430, #1431, #1432, #1433, #1434, #1435, #1437, #1438, #1439, #1440, #1441, #1442, #1443, #1444, #1445, #1446, #1447, #1448, #1449, #1450, #1451, #1452, #1453, #1454, #1455, #1456, #1457, #1458, #1459, #1460, #1461, #1462, #1463, #1464, #1465, #1466, #1467, #1468, #1469, #1471, #1472, #1473, #1474, #1476, #1478, #1479, #1481, #1482, #1483, #1484, #1485, #1487, #1488, #1489, #1490, #1491, #1492, #1493, #1494, #1495, #1496, #1497, #1499, #1500, #1501, #1502, #1504, #1505, #1506, #1507, #1510, #1514, #1515, #1516, #1517, #1518, #1519, #1521, #1522, #1523, #1524, #1525, #1526, #1527, #1528, #1529, #1531, #1532, #1533, #1534, #1535, #1536, #1537, #1538

Частично (9, in-file часть, остаток cross-file): #1361, #1419, #1422, #1424, #1470, #1475, #1477, #1480, #1498
Требуют cross-file (3, не тронуты): #1338, #1363, #1421
Пропущено (1): #1539

Не входило в партию: 22 needs-Leha issue (нужны решения владельца).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:21:11 +05:00

221 lines
7.5 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);
// The parent renders one reused instance per tab (no `key`), so switching
// variants only changes this prop without remounting. Reset stale export
// state during render when the variant changes, otherwise an error message
// or in-flight disabled state would leak onto another variant's controls.
const [prevStrategy, setPrevStrategy] = useState(variant.strategy);
if (prevStrategy !== variant.strategy) {
setPrevStrategy(variant.strategy);
setServerLoading(null);
setError(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>
);
}