feat: MVP 3D-Druck Kostenkalkulator

- Single-Page HTML-App mit allen 18 Eingabefeldern
- 12 Berechnungen live (calc.js, reine Funktionen)
- LocalStorage-Persistenz, Mehrfach-Projekte via Sidebar
- Excel Im-/Export ueber SheetJS (vendored, MIT)
- Drag&Drop + File-Picker-Import
- Apple-Swiss-Styling, responsive
- Vorlagen-Excel mit 3 Reitern (Eingabe/Kalkulation/Angebot), Formeln referenzieren Eingabe
- openpyxl-Script fuer reproduzierbaren Template-Build
- 5 Test-Szenarien validiert

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 21:15:25 +02:00
commit 7507f768a3
12 changed files with 1308 additions and 0 deletions

109
assets/excel.js Normal file
View File

@@ -0,0 +1,109 @@
/* Excel Import/Export via SheetJS */
/* global window, XLSX */
const FIELD_LABELS = {
projectName: 'Projektname',
customer: 'Kunde / Auftrag',
materialType: 'Materialtyp',
materialCostPerKg: 'Materialkosten pro kg (EUR)',
materialUsageG: 'Materialverbrauch (g)',
printTimeH: 'Druckzeit (h)',
machineRate: 'Maschinenstundensatz (EUR/h)',
powerKwh: 'Stromverbrauch (kWh)',
powerPrice: 'Strompreis (EUR/kWh)',
postMin: 'Nachbearbeitungszeit (min)',
postRate: 'Nachbearbeitungs-Stundensatz (EUR/h)',
packagingCost: 'Verpackungskosten (EUR)',
shippingCost: 'Versandkosten (EUR)',
setupCost: 'Ruestkosten (EUR)',
scrapPct: 'Ausschussrisiko (%)',
marginPct: 'Gewinnaufschlag (%)',
quantity: 'Stueckzahl',
individualAdjustment: 'Individueller Zuschlag/Rabatt (EUR)',
vatPct: 'MwSt (%)',
notes: 'Notizen',
};
const LABEL_TO_FIELD = Object.fromEntries(
Object.entries(FIELD_LABELS).map(([k, v]) => [v, k])
);
const exportXlsx = (project, results) => {
const wb = XLSX.utils.book_new();
/* Reiter Eingabe */
const inputRows = [['Feld', 'Wert']];
window.Store.FIELDS.forEach((f) => {
inputRows.push([FIELD_LABELS[f] || f, project[f] ?? '']);
});
const wsInput = XLSX.utils.aoa_to_sheet(inputRows);
wsInput['!cols'] = [{ wch: 40 }, { wch: 24 }];
XLSX.utils.book_append_sheet(wb, wsInput, 'Eingabe');
/* Reiter Kalkulation */
const calcRows = [
['Position', 'Betrag (EUR)'],
['1. Materialkosten', results.materialCost],
['2. Maschinenkosten', results.machineCost],
['3. Energiekosten', results.energyCost],
['4. Nachbearbeitungskosten', results.postCost],
['5. Gesamtherstellungskosten', results.totalProduction],
['6. Ausschuss-Zuschlag', results.scrapSurcharge],
['7. Zwischensumme netto', results.subtotalNet],
['8. Marge', results.margin],
['9. Kundenpreis netto', results.customerNet],
['10. Stueckpreis netto', results.unitNet],
['11. Stueckpreis brutto', results.unitGross],
['12. Gesamtpreis brutto', results.totalGross],
];
const wsCalc = XLSX.utils.aoa_to_sheet(calcRows);
wsCalc['!cols'] = [{ wch: 36 }, { wch: 18 }];
XLSX.utils.book_append_sheet(wb, wsCalc, 'Kalkulation');
/* Reiter Angebot */
const offerRows = [
['Angebot'],
[],
['Projekt', project.projectName || ''],
['Kunde', project.customer || ''],
['Datum', new Date().toLocaleDateString('de-DE')],
[],
['Position', 'Menge', 'Stueckpreis brutto', 'Gesamt'],
[project.projectName || 'Leistung', project.quantity || 1, results.unitGross, results.totalGross],
[],
['Gesamt brutto', '', '', results.totalGross],
];
const wsOffer = XLSX.utils.aoa_to_sheet(offerRows);
wsOffer['!cols'] = [{ wch: 30 }, { wch: 12 }, { wch: 20 }, { wch: 14 }];
XLSX.utils.book_append_sheet(wb, wsOffer, 'Angebot');
const filename = `Kalkulation_${(project.projectName || 'Projekt').replace(/[^\w\-]+/g, '_')}.xlsx`;
XLSX.writeFile(wb, filename);
};
const importXlsx = (file) => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('Datei konnte nicht gelesen werden.'));
reader.onload = (ev) => {
try {
const data = new Uint8Array(ev.target.result);
const wb = XLSX.read(data, { type: 'array' });
const sheetName = wb.SheetNames.includes('Eingabe') ? 'Eingabe' : wb.SheetNames[0];
const ws = wb.Sheets[sheetName];
const rows = XLSX.utils.sheet_to_json(ws, { header: 1 });
const updates = {};
rows.forEach((r) => {
if (!r || r.length < 2) return;
const label = String(r[0]).trim();
const field = LABEL_TO_FIELD[label];
if (field) updates[field] = r[1];
});
resolve(updates);
} catch (e) {
reject(e);
}
};
reader.readAsArrayBuffer(file);
});
window.Excel = { exportXlsx, importXlsx, FIELD_LABELS };