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:
@@ -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;
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user