Многоагентный аудит + имплементация: один воркер на файл, точечные правки. Верификация: 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>
221 lines
7.5 KiB
TypeScript
221 lines
7.5 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);
|
||
|
||
// 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>
|
||
);
|
||
}
|