task(CAD-9): bill of materials - buildBOM grouping by type+size with counts and total truss meters (corners count both arms), CSV export excel-friendly, dialog with table and download button

This commit is contained in:
Agent Zero
2026-08-31 22:36:21 +02:00
parent 748ba01083
commit 4398c675e4
4 changed files with 469 additions and 0 deletions
+92
View File
@@ -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<BOMDialogProps> = ({ 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 (
<div className="bom-dialog-overlay" onClick={onClose}>
<div className="bom-dialog" onClick={(e) => e.stopPropagation()}>
<div className="bom-dialog-header">
<h3>Stückliste Traversen</h3>
<button className="bom-close" onClick={onClose} title="Schließen">
</button>
</div>
{bom.rows.length === 0 ? (
<div className="bom-empty">
Keine Traversen oder Eck-Traversen in der Zeichnung gefunden.
<br />
Platziere zuerst Traversen mit dem Traverse- oder Eck-Traverse-Werkzeug.
</div>
) : (
<>
<table className="bom-table">
<thead>
<tr>
<th>Pos</th>
<th>Typ</th>
<th>Länge (cm)</th>
<th>Breite (cm)</th>
<th>Anzahl</th>
<th>Gesamt</th>
</tr>
</thead>
<tbody>
{bom.rows.map((r) => (
<tr key={`${r.pos}-${r.label}`}>
<td>{r.pos}</td>
<td>{r.type}</td>
<td>{r.length}</td>
<td>{r.width}</td>
<td>{r.count}</td>
<td>{(r.totalCm / 100).toFixed(2)} m</td>
</tr>
))}
<tr className="bom-sum-row">
<td colSpan={5}>
<strong>SUMME</strong>
</td>
<td>
<strong>{bom.totalLengthM.toFixed(2)} m</strong>
</td>
</tr>
</tbody>
</table>
<div className="bom-dialog-actions">
<button className="bom-btn-primary" onClick={handleDownload}>
CSV herunterladen
</button>
<button className="bom-btn-secondary" onClick={onClose}>
Schließen
</button>
</div>
</>
)}
</div>
</div>
);
};
export default BOMDialog;
+112
View File
@@ -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<string, { key: GroupKey; count: number }>();
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');
}
+103
View File
@@ -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);
}
+162
View File
@@ -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);
});
});