AUDIT A6: Bauteil-Schedule als CSV exportieren
Neuer Datei-Menü-Eintrag „Bauteilliste (CSV)": eine Zeile je Wand/Decke (Typ, ID, Bauteil, Geschoss, Länge, Höhe, Dicke, Fläche) plus Aggregat je Bauteil-Typ, als CSV-Download. Kennwerte aus dem Modell abgeleitet (Wandlänge aus Achse, wallTypeThickness, polygonArea), RFC-4180-Escaping.
This commit is contained in:
+18
@@ -54,6 +54,7 @@ import { savePlanPdf } from "./export/exportPdf";
|
|||||||
import { ExportDxfDialog } from "./ui/ExportDxfDialog";
|
import { ExportDxfDialog } from "./ui/ExportDxfDialog";
|
||||||
import type { ExportDxfDecision } from "./ui/ExportDxfDialog";
|
import type { ExportDxfDecision } from "./ui/ExportDxfDialog";
|
||||||
import { savePlanDxf } from "./export/exportDxf";
|
import { savePlanDxf } from "./export/exportDxf";
|
||||||
|
import { exportScheduleCsv } from "./export/exportSchedule";
|
||||||
import { SettingsDialog } from "./ui/SettingsDialog";
|
import { SettingsDialog } from "./ui/SettingsDialog";
|
||||||
import { PlanView } from "./plan/PlanView";
|
import { PlanView } from "./plan/PlanView";
|
||||||
import type {
|
import type {
|
||||||
@@ -2048,6 +2049,22 @@ export default function App() {
|
|||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
// Bauteilliste (Bauteil-Schedule) als CSV herunterladen — gleiche
|
||||||
|
// Blob+Anchor-Download-Technik wie onSaveProject/savePlanDxf. Der CSV-Inhalt
|
||||||
|
// kommt aus dem reinen Kern (export/exportScheduleCsv); BOM voranstellen,
|
||||||
|
// damit Excel UTF-8 (Umlaute/„m²") korrekt liest (wie der Raum-CSV).
|
||||||
|
const onExportSchedule = () => {
|
||||||
|
const csv = exportScheduleCsv(project);
|
||||||
|
const blob = new Blob(["" + csv], { type: "text/csv;charset=utf-8;" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "bauteilliste.csv";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
const onOpenProject = () => projectFileInputRef.current?.click();
|
const onOpenProject = () => projectFileInputRef.current?.click();
|
||||||
const onProjectFileChosen = (file: File) => {
|
const onProjectFileChosen = (file: File) => {
|
||||||
const r = new FileReader();
|
const r = new FileReader();
|
||||||
@@ -3073,6 +3090,7 @@ export default function App() {
|
|||||||
onZoom100={zoomToApplied}
|
onZoom100={zoomToApplied}
|
||||||
onExportPdf={() => setExportOpen(true)}
|
onExportPdf={() => setExportOpen(true)}
|
||||||
onExportDxf={() => setExportDxfOpen(true)}
|
onExportDxf={() => setExportDxfOpen(true)}
|
||||||
|
onExportSchedule={onExportSchedule}
|
||||||
renderMode={renderMode}
|
renderMode={renderMode}
|
||||||
onRenderMode={setRenderMode}
|
onRenderMode={setRenderMode}
|
||||||
renderModeEnabled={viewType === "perspektive"}
|
renderModeEnabled={viewType === "perspektive"}
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// Unit-Tests für die Bauteilliste (Bauteil-Schedule CSV, AUDIT A6).
|
||||||
|
// • Kopfzeile + eine Zeile je Bauteil-Vorkommen (2 Wände + 1 Decke).
|
||||||
|
// • korrekte Kennwerte (Länge/Höhe/Dicke/Fläche) aus dem Modell abgeleitet.
|
||||||
|
// • CSV-Escaping bei einem Typnamen mit Semikolon/Anführungszeichen.
|
||||||
|
// • leeres Projekt ⇒ nur Kopfzeile (kein Crash).
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { exportScheduleCsv, scheduleRows } from "./exportSchedule";
|
||||||
|
import type { Project, Wall, Ceiling } from "../model/types";
|
||||||
|
|
||||||
|
/** Minimalprojekt: 2 Wände (1 Wandtyp T=0.4) + 1 Decke (Deckentyp T=0.2). */
|
||||||
|
function fixtureProject(): Project {
|
||||||
|
const walls: Wall[] = [
|
||||||
|
{
|
||||||
|
id: "W1",
|
||||||
|
type: "wall",
|
||||||
|
floorId: "eg",
|
||||||
|
categoryCode: "20",
|
||||||
|
start: { x: 0, y: 0 },
|
||||||
|
end: { x: 5, y: 0 }, // Länge 5
|
||||||
|
wallTypeId: "aw",
|
||||||
|
height: 2.6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "W2",
|
||||||
|
type: "wall",
|
||||||
|
floorId: "eg",
|
||||||
|
categoryCode: "20",
|
||||||
|
start: { x: 5, y: 0 },
|
||||||
|
end: { x: 5, y: 4 }, // Länge 4
|
||||||
|
wallTypeId: "aw",
|
||||||
|
height: 2.6,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const ceilings: Ceiling[] = [
|
||||||
|
{
|
||||||
|
id: "D1",
|
||||||
|
type: "ceiling",
|
||||||
|
floorId: "eg",
|
||||||
|
categoryCode: "30",
|
||||||
|
outline: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 5, y: 0 },
|
||||||
|
{ x: 5, y: 4 },
|
||||||
|
{ x: 0, y: 4 },
|
||||||
|
], // 5×4 = 20 m²
|
||||||
|
wallTypeId: "dt",
|
||||||
|
ceilingTypeId: "dt",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
id: "t",
|
||||||
|
name: "T",
|
||||||
|
lineStyles: [],
|
||||||
|
hatches: [],
|
||||||
|
components: [{ id: "c", name: "C", color: "#ccc", hatchId: "none", joinPriority: 10 }],
|
||||||
|
wallTypes: [{ id: "aw", name: "Aussenwand", layers: [{ componentId: "c", thickness: 0.4 }] }],
|
||||||
|
ceilingTypes: [{ id: "dt", name: "Betondecke", layers: [{ componentId: "c", thickness: 0.2 }] }],
|
||||||
|
drawingLevels: [
|
||||||
|
{ id: "eg", name: "EG", kind: "floor", visible: true, locked: false, floorHeight: 2.6, cutHeight: 1.0, baseElevation: 0 },
|
||||||
|
],
|
||||||
|
layers: [{ code: "20", name: "Wände", color: "#0a0a0a", lw: 0.5, visible: true, locked: false }],
|
||||||
|
walls,
|
||||||
|
doors: [],
|
||||||
|
openings: [],
|
||||||
|
ceilings,
|
||||||
|
stairs: [],
|
||||||
|
rooms: [],
|
||||||
|
drawings2d: [],
|
||||||
|
context: [],
|
||||||
|
} as Project;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("exportScheduleCsv — Bauteilliste", () => {
|
||||||
|
it("liefert Kopfzeile + je Bauteil eine Zeile mit korrekten Kennwerten", () => {
|
||||||
|
const csv = exportScheduleCsv(fixtureProject(), { includeSummary: false });
|
||||||
|
const lines = csv.split("\r\n");
|
||||||
|
|
||||||
|
// Kopfzeile + 3 Bauteil-Zeilen (2 Wände, 1 Decke).
|
||||||
|
expect(lines).toHaveLength(4);
|
||||||
|
expect(lines[0]).toBe("Typ;ID;Bauteil;Geschoss;Länge [m];Höhe [m];Dicke [m];Fläche [m²]");
|
||||||
|
|
||||||
|
// Wand W1: Länge 5, Höhe 2.6, Dicke 0.4, Ansichtsfläche 13.
|
||||||
|
expect(lines[1]).toBe("Wand;W1;Aussenwand;EG;5.00;2.60;0.40;13.00");
|
||||||
|
// Wand W2: Länge 4, Ansichtsfläche 10.40.
|
||||||
|
expect(lines[2]).toBe("Wand;W2;Aussenwand;EG;4.00;2.60;0.40;10.40");
|
||||||
|
// Decke D1: keine Länge/Höhe, Dicke 0.2, Grundrissfläche 20.
|
||||||
|
expect(lines[3]).toBe("Decke;D1;Betondecke;EG;;;0.20;20.00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hängt bei includeSummary einen Aggregat-Block je Bauteil-Typ an", () => {
|
||||||
|
const csv = exportScheduleCsv(fixtureProject());
|
||||||
|
const lines = csv.split("\r\n");
|
||||||
|
// Kopf(1) + 3 Zeilen + Leerzeile + Titel + 2 Aggregate (Aussenwand, Betondecke) = 8.
|
||||||
|
expect(lines).toHaveLength(8);
|
||||||
|
expect(lines[4]).toBe("");
|
||||||
|
expect(lines[5]).toContain("Zusammenfassung");
|
||||||
|
// Wand-Aggregat: 2 Stk, Gesamtlänge 9, Gesamtfläche 23.40.
|
||||||
|
expect(lines[6]).toBe("Wand;;Aussenwand;2 Stk;9.00;;;23.40");
|
||||||
|
// Decken-Aggregat: 1 Stk, keine Länge, Fläche 20.
|
||||||
|
expect(lines[7]).toBe("Decke;;Betondecke;1 Stk;;;;20.00");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("quotet Felder mit Sonderzeichen (Semikolon/Anführungszeichen) RFC-4180-konform", () => {
|
||||||
|
const proj = fixtureProject();
|
||||||
|
proj.wallTypes[0].name = 'Wand; "Spezial"';
|
||||||
|
const csv = exportScheduleCsv(proj, { includeSummary: false });
|
||||||
|
const line = csv.split("\r\n")[1];
|
||||||
|
// Semikolon + verdoppelte Anführungszeichen, Feld in "..." eingefasst.
|
||||||
|
expect(line).toContain('"Wand; ""Spezial"""');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leeres Projekt ⇒ nur Kopfzeile (kein Crash)", () => {
|
||||||
|
const proj = fixtureProject();
|
||||||
|
proj.walls = [];
|
||||||
|
proj.ceilings = [];
|
||||||
|
const csv = exportScheduleCsv(proj);
|
||||||
|
expect(csv.split("\r\n")).toHaveLength(1);
|
||||||
|
expect(scheduleRows(proj)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Binary file not shown.
@@ -815,6 +815,7 @@ export const de = {
|
|||||||
"file.import": "DXF/DWG importieren…",
|
"file.import": "DXF/DWG importieren…",
|
||||||
"file.exportPdf": "Als PDF exportieren…",
|
"file.exportPdf": "Als PDF exportieren…",
|
||||||
"file.exportDxf": "Als DXF exportieren…",
|
"file.exportDxf": "Als DXF exportieren…",
|
||||||
|
"file.exportSchedule": "Bauteilliste (CSV)…",
|
||||||
|
|
||||||
// ── Einstellungs-Fenster ──────────────────────────────────────────────────
|
// ── Einstellungs-Fenster ──────────────────────────────────────────────────
|
||||||
"topbar.settings": "Einstellungen",
|
"topbar.settings": "Einstellungen",
|
||||||
|
|||||||
@@ -806,6 +806,7 @@ export const en: Record<TranslationKey, string> = {
|
|||||||
"file.import": "Import DXF/DWG…",
|
"file.import": "Import DXF/DWG…",
|
||||||
"file.exportPdf": "Export as PDF…",
|
"file.exportPdf": "Export as PDF…",
|
||||||
"file.exportDxf": "Export as DXF…",
|
"file.exportDxf": "Export as DXF…",
|
||||||
|
"file.exportSchedule": "Component schedule (CSV)…",
|
||||||
|
|
||||||
// Settings dialog.
|
// Settings dialog.
|
||||||
"topbar.settings": "Settings",
|
"topbar.settings": "Settings",
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ export interface TopBarProps {
|
|||||||
onExportPdf: () => void;
|
onExportPdf: () => void;
|
||||||
/** Öffnet den DXF-Export-Dialog (nur im Grundriss sinnvoll). */
|
/** Öffnet den DXF-Export-Dialog (nur im Grundriss sinnvoll). */
|
||||||
onExportDxf: () => void;
|
onExportDxf: () => void;
|
||||||
|
/** Lädt die Bauteilliste des Projekts als CSV herunter. */
|
||||||
|
onExportSchedule: () => void;
|
||||||
|
|
||||||
// Darstellungsart — kontextabhängig: Perspektive nutzt `renderMode`,
|
// Darstellungsart — kontextabhängig: Perspektive nutzt `renderMode`,
|
||||||
// Grundriss nutzt `planColorMode`. Genau einer der beiden ist je View aktiv.
|
// Grundriss nutzt `planColorMode`. Genau einer der beiden ist je View aktiv.
|
||||||
@@ -918,6 +920,7 @@ function AppMenu({
|
|||||||
onImport,
|
onImport,
|
||||||
onExportPdf,
|
onExportPdf,
|
||||||
onExportDxf,
|
onExportDxf,
|
||||||
|
onExportSchedule,
|
||||||
onSaveProject,
|
onSaveProject,
|
||||||
onOpenProject,
|
onOpenProject,
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
@@ -926,6 +929,7 @@ function AppMenu({
|
|||||||
onImport: () => void;
|
onImport: () => void;
|
||||||
onExportPdf: () => void;
|
onExportPdf: () => void;
|
||||||
onExportDxf: () => void;
|
onExportDxf: () => void;
|
||||||
|
onExportSchedule: () => void;
|
||||||
onSaveProject: () => void;
|
onSaveProject: () => void;
|
||||||
onOpenProject: () => void;
|
onOpenProject: () => void;
|
||||||
onOpenSettings: () => void;
|
onOpenSettings: () => void;
|
||||||
@@ -939,6 +943,7 @@ function AppMenu({
|
|||||||
{ divider: true },
|
{ divider: true },
|
||||||
{ label: t("file.exportPdf"), onSelect: onExportPdf, disabled: !exportEnabled },
|
{ label: t("file.exportPdf"), onSelect: onExportPdf, disabled: !exportEnabled },
|
||||||
{ label: t("file.exportDxf"), onSelect: onExportDxf, disabled: !exportEnabled },
|
{ label: t("file.exportDxf"), onSelect: onExportDxf, disabled: !exportEnabled },
|
||||||
|
{ label: t("file.exportSchedule"), onSelect: onExportSchedule },
|
||||||
{ divider: true },
|
{ divider: true },
|
||||||
{ label: t("topbar.settings"), onSelect: onOpenSettings },
|
{ label: t("topbar.settings"), onSelect: onOpenSettings },
|
||||||
];
|
];
|
||||||
@@ -975,6 +980,7 @@ export function TopBar({
|
|||||||
onZoom100,
|
onZoom100,
|
||||||
onExportPdf,
|
onExportPdf,
|
||||||
onExportDxf,
|
onExportDxf,
|
||||||
|
onExportSchedule,
|
||||||
renderMode,
|
renderMode,
|
||||||
onRenderMode,
|
onRenderMode,
|
||||||
renderModeEnabled,
|
renderModeEnabled,
|
||||||
@@ -1314,6 +1320,7 @@ export function TopBar({
|
|||||||
onImport={onImport}
|
onImport={onImport}
|
||||||
onExportPdf={onExportPdf}
|
onExportPdf={onExportPdf}
|
||||||
onExportDxf={onExportDxf}
|
onExportDxf={onExportDxf}
|
||||||
|
onExportSchedule={onExportSchedule}
|
||||||
onSaveProject={onSaveProject}
|
onSaveProject={onSaveProject}
|
||||||
onOpenProject={onOpenProject}
|
onOpenProject={onOpenProject}
|
||||||
onOpenSettings={onOpenSettings}
|
onOpenSettings={onOpenSettings}
|
||||||
|
|||||||
Reference in New Issue
Block a user