From fde27f68389198e93743e564b09cbc7574b0c119 Mon Sep 17 00:00:00 2001 From: Karim Date: Sat, 4 Jul 2026 12:47:00 +0200 Subject: [PATCH] AUDIT A6: Bauteil-Schedule als CSV exportieren MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/App.tsx | 18 +++++ src/export/exportSchedule.test.ts | 121 ++++++++++++++++++++++++++++++ src/export/exportSchedule.ts | Bin 0 -> 7461 bytes src/i18n/de.ts | 1 + src/i18n/en.ts | 1 + src/ui/TopBar.tsx | 7 ++ 6 files changed, 148 insertions(+) create mode 100644 src/export/exportSchedule.test.ts create mode 100644 src/export/exportSchedule.ts diff --git a/src/App.tsx b/src/App.tsx index 13cd5ec..282cd19 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -54,6 +54,7 @@ import { savePlanPdf } from "./export/exportPdf"; import { ExportDxfDialog } from "./ui/ExportDxfDialog"; import type { ExportDxfDecision } from "./ui/ExportDxfDialog"; import { savePlanDxf } from "./export/exportDxf"; +import { exportScheduleCsv } from "./export/exportSchedule"; import { SettingsDialog } from "./ui/SettingsDialog"; import { PlanView } from "./plan/PlanView"; import type { @@ -2048,6 +2049,22 @@ export default function App() { document.body.removeChild(a); 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 onProjectFileChosen = (file: File) => { const r = new FileReader(); @@ -3073,6 +3090,7 @@ export default function App() { onZoom100={zoomToApplied} onExportPdf={() => setExportOpen(true)} onExportDxf={() => setExportDxfOpen(true)} + onExportSchedule={onExportSchedule} renderMode={renderMode} onRenderMode={setRenderMode} renderModeEnabled={viewType === "perspektive"} diff --git a/src/export/exportSchedule.test.ts b/src/export/exportSchedule.test.ts new file mode 100644 index 0000000..8fbd871 --- /dev/null +++ b/src/export/exportSchedule.test.ts @@ -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); + }); +}); diff --git a/src/export/exportSchedule.ts b/src/export/exportSchedule.ts new file mode 100644 index 0000000000000000000000000000000000000000..89afc2e5ecc07f005451fe68d6d2efc11bc9a2cb GIT binary patch literal 7461 zcmcgxU2ojR745Tr#SIZ8q_fL)f}$v-%Em@oC#v;fNQw+zy9YONxLz zweLZIzV#`;pbve@Q~gi;OM32|87?WyPKp*0Z0(XWb7$txx#ym_8Vu;mq&6zc`zPa> zO6y!v=-%X6WmH6OzyFbTPM-Ehua~7Zq_RS3KPagu=#%GlF{M;h8-oEo(dD;lZYm0g z-|X(4((n&G+C4fs*&7}ApCmdnZ+4qohdka!ObAf{v4V(T5kc&DGM@ElKRH)9o(5i&MCdY0sQ zk9M$QKC~N}=yE|p91j+Jssrv`1zUdf^#;*Y86I?smY#idqy3weYdxK1<9VT~io1>N z46S#+I2SF;{Bl|ra=NYF?;JhW$3A=e>8vUg%jVd+~FaL<+IG}TtQI7(8QP2xb zTLRpGR+&!eLg}s`7q)K8e@?VJ0C7VzPlMpo=q7lz1#36>@48FZ zt&JPI!{^B?CvXmZL5InBHU}6J;U87>=t*774JCPn=(x%jNp7?%0OFYT6`;&JN3`YL z%}o~OuwMAB74_mAUB8o#_&^w|2?!{Ccek=I+TM0uzoU;}2ZQ6RdX#~ERI`1zlK@C* zwiO;EW)|xb6WD%qk3PSDKcazr#HM_dy;f=XMP&Eh0PHcgv;tH0hvzv$jCnUq>I%WQ za9n7aQjyX)!BiE>fJmB0C^n8J?my--hhpuj8$W6GNX-r!8QXddqthjBtQE{r1707O1U4TtvU@Hy7 zD|qw)UBy`nJZ*b((Zg8Q{_vrH(}V#6T_FsrsbVPLOkCFBJ+*T;g~%g(a+cvGC2$5U zs>EE`Eh?MTGd{KnqLI7`I4)NbxJ)e0<~&E51Ob3c*l0lFL_A6U2*hPXbL?r80^8#| z3Tz`|UpOjPoIl`l3UQj}oa`9W0J2>WxTNuv&FfO|ISSSthc2`hx-^K$cRl8CPRJYX@E6XH~uk0M{ zSY{z*gHt?|rS^s2k$rN3bO&EL&URcG+3t2F_Im?+F>jyT6y7=Z(Spit;+{RPb@!Ww zCEGZ&IQ>5Y&B;!G$gOPP#O9{gwMYlQfH*fI_A*s)N8o+m;1OZbxCWm0y#?NmQ@I%K zm(}woq6TXp0+D!$IkXKU;kK3{>mWl$1om8*xBZI$dvy4bcL9Vc1UCf%U;~r&s}`#{ zE9=m4d3WuIHilXnKlJmLwPE=p0Bx|e0eR-&G~C}GHF@Z1sab=9nZjpaMi3K%jw!K- zgx{+5Z5VXsF1>7Qt=_ugO&nhc=UP$D_Xc}wgeu3K8JA_g=OL>p4blNkDOsGIJ=`tl zHRo5o?g^GoA!($mTU?AGgAJGZ8=VhvB=>)0{>lTW!F3nKXD@|>ES+fd?_eCQ+jRDl znQc;P3LQUEIUz5D7||6&{On}}3AU=@WnIm}+Y}Dch=i3|cE)Wx?$q96YR5)|-wZ%t ztvQb4d2h*Ue__6TRsJ=~`8V!^TXiGaP7(8d>9}FT@onbDMW&^~JZZG$&KMUFC|aw= z`P^8lSt-GqlGT8{4FKrw4!~6*F2Fe48=Y{1#wE+8ep8ynN&fd6cCrIjp*k9o!Xx?K zy&mV3!*QA8{>-Xfsw4%UQutU@DFP2pV8I-r@Iud5T(y97U^!Ia4MY>Z$l4mfC$2i8 zzPw}yhIarNuf>u2 z^U>Y>j=%Y%x{+J4)gf3kI%4V=S|h|TnZVw_KSHB7OJsXqJ1AJYy+(m-(%L=(!vX+> ziq5(DXL}m=353WZfMov4&W|z~X0;8RCS>AR4AN)Y^to+8A6#l1NN|geT@D8gh@!G1 zr58_V`y}|rCR!Qj43!LafLy_=FapysK$<%;7c0B<#9wvu{f*=I-RU+wC9eb9D+d%6 zZK5Ya*j1CHMr`0RwiQq~dOcQoe|OZMXa!-OBcXsw5jPyoP3_4=DoSl! zuu}SAn-X{mqiY11vyD&~ez2Tq%Z?nK`>aV(J)OyI!A2brT8#5LRZJ_1zQknbWtpp_ zXaw8I5;YrDy!jbr6hyDiCU}x1cP<9uBzuiSCdJ41?aBt#*Q4R?=$Mlc02CmQMX3aP zyY|yrwGaMUXrJsoht3u+?X%o>){jK9dRV7tyQRzy`QZ4x4iL;DgOI%)CZ;0Oh?FY#WjD*MKa)L4p|4~7zm#NuxjbJB~$o5>qf>6Fpi^RJ&-9K-8t*ENYqILSK4~;&02W z2ve3X=-g66@1&EKA@t-Q2EYCB=0ckc+$D_LS}j?6_j{j=?rs&?-fD7nRnPHvwKyez zS5u?t?`q_X(7b=?J!5sfxEjg8=EGZO_Q@w!YPz)ZfKYF}7U#5_76j22A(t?yEpmlq zjU%-S6vTZ{+#Y(?crkeH1jhswGpW(G!WKLf1yJQd@V@`erd(QWoj39*3kZD3A9mW( zrKz-c(9|6M8V0T7h8@uxjXxO-SXeXaR^K~7(CPvC!UiSx-=H=;f7P+;FyZHvVyCMU yKI9#B1cJTub;fts)qQH&ZUjVYaN|+GGv`@W6#^G`C9pt{U<+R87X`ooZSf!DLH)}B literal 0 HcmV?d00001 diff --git a/src/i18n/de.ts b/src/i18n/de.ts index eb37a8a..19b6459 100644 --- a/src/i18n/de.ts +++ b/src/i18n/de.ts @@ -815,6 +815,7 @@ export const de = { "file.import": "DXF/DWG importieren…", "file.exportPdf": "Als PDF exportieren…", "file.exportDxf": "Als DXF exportieren…", + "file.exportSchedule": "Bauteilliste (CSV)…", // ── Einstellungs-Fenster ────────────────────────────────────────────────── "topbar.settings": "Einstellungen", diff --git a/src/i18n/en.ts b/src/i18n/en.ts index aed937c..065fec8 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -806,6 +806,7 @@ export const en: Record = { "file.import": "Import DXF/DWG…", "file.exportPdf": "Export as PDF…", "file.exportDxf": "Export as DXF…", + "file.exportSchedule": "Component schedule (CSV)…", // Settings dialog. "topbar.settings": "Settings", diff --git a/src/ui/TopBar.tsx b/src/ui/TopBar.tsx index 9ad7622..439e664 100644 --- a/src/ui/TopBar.tsx +++ b/src/ui/TopBar.tsx @@ -131,6 +131,8 @@ export interface TopBarProps { onExportPdf: () => void; /** Öffnet den DXF-Export-Dialog (nur im Grundriss sinnvoll). */ onExportDxf: () => void; + /** Lädt die Bauteilliste des Projekts als CSV herunter. */ + onExportSchedule: () => void; // Darstellungsart — kontextabhängig: Perspektive nutzt `renderMode`, // Grundriss nutzt `planColorMode`. Genau einer der beiden ist je View aktiv. @@ -918,6 +920,7 @@ function AppMenu({ onImport, onExportPdf, onExportDxf, + onExportSchedule, onSaveProject, onOpenProject, onOpenSettings, @@ -926,6 +929,7 @@ function AppMenu({ onImport: () => void; onExportPdf: () => void; onExportDxf: () => void; + onExportSchedule: () => void; onSaveProject: () => void; onOpenProject: () => void; onOpenSettings: () => void; @@ -939,6 +943,7 @@ function AppMenu({ { divider: true }, { label: t("file.exportPdf"), onSelect: onExportPdf, disabled: !exportEnabled }, { label: t("file.exportDxf"), onSelect: onExportDxf, disabled: !exportEnabled }, + { label: t("file.exportSchedule"), onSelect: onExportSchedule }, { divider: true }, { label: t("topbar.settings"), onSelect: onOpenSettings }, ]; @@ -975,6 +980,7 @@ export function TopBar({ onZoom100, onExportPdf, onExportDxf, + onExportSchedule, renderMode, onRenderMode, renderModeEnabled, @@ -1314,6 +1320,7 @@ export function TopBar({ onImport={onImport} onExportPdf={onExportPdf} onExportDxf={onExportDxf} + onExportSchedule={onExportSchedule} onSaveProject={onSaveProject} onOpenProject={onOpenProject} onOpenSettings={onOpenSettings}