diff --git a/frontend/src/components/BOMDialog.tsx b/frontend/src/components/BOMDialog.tsx new file mode 100644 index 0000000..2c02db6 --- /dev/null +++ b/frontend/src/components/BOMDialog.tsx @@ -0,0 +1,92 @@ +/** + * Task CAD-9 – Stuecklisten-Dialog fuer Traversen-Systeme. + * + * Zeigt die gruppierte Stueckliste (Traversen + Eck-Traversen) + * als Tabelle mit Summenzeile; CSV-Download als Datei. + */ +import React, { useMemo } from 'react'; +import { buildBOM, exportBOMCsv } from '../services/bomService'; + +interface BOMDialogProps { + elements: import('../types/cad.types').CADElement[]; + onClose: () => void; +} + +const BOMDialog: React.FC = ({ elements, onClose }) => { + const bom = useMemo(() => buildBOM(elements), [elements]); + + const handleDownload = () => { + const csv = exportBOMCsv(bom); + const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `stueckliste-traversen-${new Date().toISOString().slice(0, 10)}.csv`; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( +
+
e.stopPropagation()}> +
+

Stückliste – Traversen

+ +
+ {bom.rows.length === 0 ? ( +
+ Keine Traversen oder Eck-Traversen in der Zeichnung gefunden. +
+ Platziere zuerst Traversen mit dem Traverse- oder Eck-Traverse-Werkzeug. +
+ ) : ( + <> + + + + + + + + + + + + + {bom.rows.map((r) => ( + + + + + + + + + ))} + + + + + +
PosTypLänge (cm)Breite (cm)AnzahlGesamt
{r.pos}{r.type}{r.length}{r.width}{r.count}{(r.totalCm / 100).toFixed(2)} m
+ SUMME + + {bom.totalLengthM.toFixed(2)} m +
+
+ + +
+ + )} +
+
+ ); +}; + +export default BOMDialog; diff --git a/frontend/src/services/bomService.ts b/frontend/src/services/bomService.ts new file mode 100644 index 0000000..703a9ba --- /dev/null +++ b/frontend/src/services/bomService.ts @@ -0,0 +1,112 @@ +/** + * Task CAD-9 – Stueckliste (BOM) fuer Traversen-Systeme. + * + * buildBOM gruppiert Traversen + Eck-Traversen nach Typ + Mass + * (Laenge x Breite), zaehlt Stueckzahlen und summiert die + * Gesamt-Traversemeter (Boxcorner zaehlt BEIDE Arme). + * exportBOMCsv: Semikolon-separierte CSV (Excel-DE-freundlich). + */ +import type { CADElement } from '../types/cad.types'; + +export interface BOMRow { + pos: number; + type: 'Traverse' | 'Eck-Traverse'; + /** Laenge in cm (Traverse: length; Eck-Traverse: armLength). */ + length: number; + /** Breite in cm (trussWidth). */ + width: number; + count: number; + /** Gesamtlaenge dieser Position in cm (count * length, Ecken * 2). */ + totalCm: number; + /** Bezeichnung, z.B. "Traverse 200 x 29". */ + label: string; +} + +export interface BOMResult { + rows: BOMRow[]; + /** Gesamte Traversenlaenge in Metern (Ecken zaehlen beide Arme). */ + totalLengthM: number; +} + +interface GroupKey { + type: 'Traverse' | 'Eck-Traverse'; + length: number; + width: number; +} + +/** + * Baut die Stueckliste aus allen Zeichnungselementen. + * Nur truss- und boxcorner-Elemente fliessen ein; alles andere + * wird still ignoriert. + */ +export function buildBOM(elements: CADElement[]): BOMResult { + const groups = new Map(); + + for (const el of elements) { + if (String(el.type) === 'truss' && el.properties.truss === true) { + const length = (el.properties.length as number | undefined) ?? el.width ?? 0; + const width = (el.properties.trussWidth as number | undefined) ?? el.height ?? 29; + const key: GroupKey = { type: 'Traverse', length, width }; + const id = `T|${length}|${width}`; + const g = groups.get(id) ?? { key, count: 0 }; + g.count++; + groups.set(id, g); + } else if (String(el.type) === 'boxcorner' && el.properties.boxcorner === true) { + const length = (el.properties.armLength as number | undefined) ?? 50; + const width = (el.properties.trussWidth as number | undefined) ?? 29; + const key: GroupKey = { type: 'Eck-Traverse', length, width }; + const id = `C|${length}|${width}`; + const g = groups.get(id) ?? { key, count: 0 }; + g.count++; + groups.set(id, g); + } + } + + // Sortierung: Traversen vor Ecken, innerhalb Typ nach Laenge absteigend + const entries = Array.from(groups.values()); + entries.sort((a, b) => { + if (a.key.type !== b.key.type) { + return a.key.type === 'Traverse' ? -1 : 1; + } + return b.key.length - a.key.length; + }); + + const rows: BOMRow[] = entries.map((g, i) => { + // Ecken haben 2 Arme: Gesamtlaenge = count * length * 2 + const armFactor = g.key.type === 'Eck-Traverse' ? 2 : 1; + const totalCm = g.count * g.key.length * armFactor; + return { + pos: i + 1, + type: g.key.type, + length: g.key.length, + width: g.key.width, + count: g.count, + totalCm, + label: `${g.key.type} ${g.key.length} \u00d7 ${g.key.width}`, + }; + }); + + const totalCmAll = rows.reduce((s, r) => s + r.totalCm, 0); + return { + rows, + totalLengthM: totalCmAll / 100, + }; +} + +/** + * Exportiert die Stueckliste als Semikolon-CSV (Excel-DE oeffnet + * das direkt mehrspaltig). Spalten: Pos;Typ;Laenge_cm;Breite_cm; + * Anzahl;Gesamt_cm;Bezeichnung + Summenzeile am Ende. + */ +export function exportBOMCsv(bom: BOMResult): string { + const lines: string[] = ['Pos;Typ;Laenge_cm;Breite_cm;Anzahl;Gesamt_cm;Bezeichnung']; + for (const r of bom.rows) { + lines.push( + `${r.pos};${r.type};${r.length};${r.width};${r.count};${r.totalCm};${r.label}`, + ); + } + if (bom.rows.length > 0) { + lines.push(`;;;SUMME;;;${bom.totalLengthM.toFixed(2)} m`); + } + return lines.join('\n'); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index ab8673b..eac729a 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -2974,3 +2974,106 @@ body.drawer-open { overflow: hidden; } border-radius: 4px; } .layout-bar-print:hover { color: #4a90d9; background: rgba(74, 144, 217, 0.15); } + +/* ─── Task CAD-9 – BOM-Dialog ─── */ +.bom-dialog-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} +.bom-dialog { + background: #1e1e2e; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + padding: 20px; + min-width: 640px; + max-width: 800px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +} +.bom-dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} +.bom-dialog-header h3 { + margin: 0; + font-size: 16px; + color: #e0e0e0; +} +.bom-close { + background: transparent; + border: none; + color: #888; + font-size: 18px; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; +} +.bom-close:hover { + color: #fff; + background: rgba(255, 255, 255, 0.1); +} +.bom-empty { + color: #888; + padding: 30px; + text-align: center; + line-height: 1.6; +} +.bom-table { + width: 100%; + border-collapse: collapse; + margin-bottom: 16px; +} +.bom-table th, +.bom-table td { + padding: 8px 12px; + border: 1px solid rgba(255, 255, 255, 0.08); + text-align: left; + font-size: 13px; +} +.bom-table th { + background: rgba(255, 255, 255, 0.04); + color: #9aa0a6; + font-weight: 600; +} +.bom-table td { + color: #d0d0d0; +} +.bom-sum-row td { + background: rgba(74, 144, 217, 0.1); +} +.bom-dialog-actions { + display: flex; + gap: 10px; + justify-content: flex-end; +} +.bom-btn-primary { + background: #4a90d9; + color: #fff; + border: none; + border-radius: 6px; + padding: 8px 16px; + cursor: pointer; + font-size: 13px; +} +.bom-btn-primary:hover { + background: #5ba0e9; +} +.bom-btn-secondary { + background: transparent; + color: #9aa0a6; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + padding: 8px 16px; + cursor: pointer; + font-size: 13px; +} +.bom-btn-secondary:hover { + color: #fff; + border-color: rgba(255, 255, 255, 0.3); +} diff --git a/frontend/tests/bomExport.test.ts b/frontend/tests/bomExport.test.ts new file mode 100644 index 0000000..ed5f017 --- /dev/null +++ b/frontend/tests/bomExport.test.ts @@ -0,0 +1,162 @@ +/** + * Task CAD-9 - Stueckliste (BOM) fuer Traversen. + * + * buildBOM: gruppiert Traversen + Boxcorner nach Typ + Mass + * (Laenge x Breite), zaehlt Stueckzahlen, summiert Gesamt-meter. + * exportBOMCsv: saubere CSV mit Kopfzeile fuer Excel-Import. + * Dialog zeigt Tabelle + CSV-Download. + */ +import { describe, it, expect } from 'vitest'; +import { buildBOM, exportBOMCsv, type BOMRow } from '../src/services/bomService'; +import type { CADElement } from '../src/types/cad.types'; + +function truss(id: string, x: number, y: number, length: number, rotation: number, width = 29): CADElement { + return { + id, type: 'truss', layerId: 'l', x, y, width: length, height: width, + properties: { length, rotation, trussWidth: width, truss: true }, + } as unknown as CADElement; +} + +function corner(id: string, x: number, y: number, armLength: number, rotation: number, width = 29): CADElement { + return { + id, type: 'boxcorner', layerId: 'l', x, y, width: 80, height: 80, + properties: { armLength, rotation, trussWidth: width, boxcorner: true }, + } as unknown as CADElement; +} + +function chair(id: string): CADElement { + return { + id, type: 'chair', layerId: 'l', x: 0, y: 0, width: 45, height: 45, + properties: {}, + } as unknown as CADElement; +} + +// ─── buildBOM: Gruppierung ────────────────────────────────── + +describe('CAD-9: buildBOM (Gruppierung + Zaehlung)', () => { + it('zwei identische Traversen (200x29) ergeben EINE Position mit Anzahl 2', () => { + const els = [ + truss('t1', 0, 0, 200, 0), + truss('t2', 100, 100, 200, 90), + ]; + const bom = buildBOM(els); + const rows = bom.rows.filter((r) => r.type === 'Traverse'); + expect(rows).toHaveLength(1); + expect(rows[0].count).toBe(2); + }); + + it('unterschiedliche Laengen ergetrennte Positionen', () => { + const els = [ + truss('t1', 0, 0, 200, 0), + truss('t2', 0, 0, 100, 0), + truss('t3', 0, 0, 300, 0), + ]; + const bom = buildBOM(els); + const trussRows = bom.rows.filter((r) => r.type === 'Traverse'); + expect(trussRows).toHaveLength(3); + const counts = trussRows.map((r) => r.count); + expect(counts).toEqual([1, 1, 1]); + }); + + it('unterschiedliche Breiten = getrennte Positionen (100x29 vs 100x40)', () => { + const els = [ + truss('t1', 0, 0, 100, 0, 29), + truss('t2', 0, 0, 100, 0, 40), + ]; + const bom = buildBOM(els); + expect(bom.rows.filter((r) => r.type === 'Traverse')).toHaveLength(2); + }); + + it('Boxcorner als eigener Typ mit armLength als Laenge', () => { + const els = [ + corner('c1', 0, 0, 50, 0), + corner('c2', 100, 100, 50, 90), + corner('c3', 200, 200, 75, 0), + ]; + const bom = buildBOM(els); + const cornerRows = bom.rows.filter((r) => r.type === 'Eck-Traverse'); + expect(cornerRows).toHaveLength(2); // 50cm x2 + 75cm x1 + const r50 = cornerRows.find((r) => r.length === 50)!; + expect(r50.count).toBe(2); + }); + + it('Nicht-Traversen (chair etc.) werden ignoriert', () => { + const els = [chair('s1'), truss('t1', 0, 0, 200, 0)]; + const bom = buildBOM(els); + expect(bom.rows.every((r) => r.type !== 'chair')).toBe(true); + }); + + it('leere Zeichnung: leere Stueckliste ohne Crash', () => { + const bom = buildBOM([]); + expect(bom.rows).toHaveLength(0); + expect(bom.totalLengthM).toBe(0); + }); + + it('Gesamtlaenge: 200+100+300cm Traversen + 2x50cm Ecken = 7.00m', () => { + const els = [ + truss('t1', 0, 0, 200, 0), + truss('t2', 0, 0, 100, 0), + truss('t3', 0, 0, 300, 0), + corner('c1', 0, 0, 50, 0), + corner('c2', 0, 0, 50, 90), + ]; + const bom = buildBOM(els); + // Traversen 6.00m + 2 Ecken je 2 Arme = 8.00m + expect(bom.totalLengthM).toBeCloseTo(8.0, 2); + }); + + it('Boxcorner zaehlt BEIDE Arme in die Gesamtlaenge (50er-Ecke = 1.00m)', () => { + const els = [corner('c1', 0, 0, 50, 0)]; + const bom = buildBOM(els); + expect(bom.totalLengthM).toBeCloseTo(1.0, 2); + }); + + it('Positionen sind sortiert: Traversen vor Ecken, dann nach Laenge absteigend', () => { + const els = [ + corner('c1', 0, 0, 50, 0), + truss('t1', 0, 0, 100, 0), + truss('t2', 0, 0, 300, 0), + ]; + const bom = buildBOM(els); + const types = bom.rows.map((r) => r.type); + // Traversen zuerst (nach Laenge absteigend), dann Ecken + expect(types.indexOf('Traverse')).toBeLessThan(types.indexOf('Eck-Traverse')); + const trussLens = bom.rows.filter((r) => r.type === 'Traverse').map((r) => r.length); + expect(trussLens[0]).toBeGreaterThanOrEqual(trussLens[trussLens.length - 1]); + }); +}); + +// ─── exportBOMCsv ───────────────────────────────────────── + +describe('CAD-9: exportBOMCsv', () => { + it('CSV hat Kopfzeile und Zeilen pro Position mit Semikolon/ Komma', () => { + const els = [truss('t1', 0, 0, 200, 0), truss('t2', 0, 0, 200, 90)]; + const bom = buildBOM(els); + const csv = exportBOMCsv(bom); + const lines = csv.split('\n'); + expect(lines[0]).toContain('Pos'); + expect(lines[0]).toContain('Typ'); + expect(lines[0]).toContain('Anzahl'); + expect(lines).toHaveLength(2 + 1); // Header + 1 Position + ggf. Summenzeile + }); + + it('CSV enthaelt Laenge und Anzahl der Position', () => { + const els = [truss('t1', 0, 0, 200, 0), truss('t2', 0, 0, 200, 0)]; + const csv = exportBOMCsv(buildBOM(els)); + expect(csv).toContain('200'); + expect(csv).toContain('2'); + }); + + it('CSV: Semikolon-Separatoren (Excel-DE-freundlich) oder Komma + konsistent', () => { + const csv = exportBOMCsv(buildBOM([truss('t1', 0, 0, 200, 0)])); + // Konsistenz: Jede Datenzeile hat dieselbe Anzahl Separatoren + const lines = csv.trim().split('\n'); + const sepCounts = lines.map((l) => (l.match(/;/g) || []).length); + expect(new Set(sepCounts).size).toBe(1); + }); + + it('leere Stueckliste: nur Kopfzeile', () => { + const csv = exportBOMCsv(buildBOM([])); + expect(csv.trim().split('\n')).toHaveLength(1); + }); +});