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

249
assets/app.js Normal file
View File

@@ -0,0 +1,249 @@
/* App-Controller — UI-Bindings, State-Sync, Excel */
/* global window, document */
(() => {
const { Calc, Store, Excel } = window;
/** @type {Array} */
let projects = Store.load();
let currentId = Store.getCurrentId();
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
const fmtEur = (v) =>
new Intl.NumberFormat('de-DE', {
style: 'currency', currency: 'EUR', minimumFractionDigits: 2,
}).format(v || 0);
const ensureProject = () => {
if (projects.length === 0) {
const p = Store.makeDefault('Neues Projekt');
projects.push(p);
currentId = p.id;
Store.save(projects);
Store.setCurrentId(currentId);
} else if (!currentId || !projects.find((p) => p.id === currentId)) {
currentId = projects[0].id;
Store.setCurrentId(currentId);
}
};
const getCurrent = () => projects.find((p) => p.id === currentId);
const renderProjects = () => {
const nav = $('#projects');
nav.innerHTML = '';
projects.forEach((p) => {
const el = document.createElement('div');
el.className = 'project-item' + (p.id === currentId ? ' active' : '');
el.textContent = p.projectName || 'Unbenannt';
el.title = p.projectName || '';
el.addEventListener('click', () => {
currentId = p.id;
Store.setCurrentId(currentId);
renderProjects();
syncFormFromState();
recalc();
});
nav.appendChild(el);
});
};
const syncFormFromState = () => {
const p = getCurrent();
if (!p) return;
Store.FIELDS.forEach((f) => {
const el = document.querySelector(`[data-field="${f}"]`);
if (!el) return;
el.value = p[f] ?? '';
});
$('#current-project-name').textContent = p.projectName || 'Unbenanntes Projekt';
};
const syncStateFromForm = () => {
const p = getCurrent();
if (!p) return;
Store.FIELDS.forEach((f) => {
const el = document.querySelector(`[data-field="${f}"]`);
if (!el) return;
const raw = el.value;
if (el.type === 'number') {
p[f] = raw === '' ? 0 : parseFloat(raw);
} else {
p[f] = raw;
}
});
p.updatedAt = new Date().toISOString();
Store.save(projects);
$('#current-project-name').textContent = p.projectName || 'Unbenanntes Projekt';
};
const recalc = () => {
const p = getCurrent();
if (!p) return;
const r = Calc.calculate(p);
$('#r-unitNet').textContent = fmtEur(r.unitNet);
$('#r-unitGross').textContent = fmtEur(r.unitGross);
$('#r-totalGross').textContent = fmtEur(r.totalGross);
$('#r-margin').textContent = fmtEur(r.margin);
$('#r-c1').textContent = fmtEur(r.materialCost);
$('#r-c2').textContent = fmtEur(r.machineCost);
$('#r-c3').textContent = fmtEur(r.energyCost);
$('#r-c4').textContent = fmtEur(r.postCost);
$('#r-c5').textContent = fmtEur(r.totalProduction);
$('#r-c6').textContent = fmtEur(r.scrapSurcharge);
$('#r-c7').textContent = fmtEur(r.subtotalNet);
$('#r-c9').textContent = fmtEur(r.customerNet);
};
const attachFieldListeners = () => {
$$('[data-field]').forEach((el) => {
el.addEventListener('input', () => {
syncStateFromForm();
if (el.dataset.field !== 'projectName') {
recalc();
} else {
renderProjects();
}
});
});
};
const newProject = () => {
const p = Store.makeDefault('Neues Projekt ' + (projects.length + 1));
projects.push(p);
currentId = p.id;
Store.save(projects);
Store.setCurrentId(currentId);
renderProjects();
syncFormFromState();
recalc();
};
const duplicateProject = () => {
const cur = getCurrent();
if (!cur) return;
const clone = { ...cur, id: 'p_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7), projectName: (cur.projectName || 'Projekt') + ' (Kopie)', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() };
projects.push(clone);
currentId = clone.id;
Store.save(projects);
Store.setCurrentId(currentId);
renderProjects();
syncFormFromState();
recalc();
};
const deleteProject = () => {
if (projects.length === 0) return;
const cur = getCurrent();
const ok = confirm(`Projekt "${cur?.projectName || 'Unbenannt'}" wirklich loeschen?`);
if (!ok) return;
projects = projects.filter((p) => p.id !== currentId);
if (projects.length === 0) {
ensureProject();
} else {
currentId = projects[0].id;
Store.setCurrentId(currentId);
}
Store.save(projects);
renderProjects();
syncFormFromState();
recalc();
};
const exportCurrent = () => {
const p = getCurrent();
if (!p) return;
const r = Calc.calculate(p);
try {
Excel.exportXlsx(p, r);
} catch (e) {
alert('Export fehlgeschlagen: ' + e.message);
}
};
const importFile = async (file) => {
try {
const updates = await Excel.importXlsx(file);
const p = getCurrent();
if (!p) return;
Object.entries(updates).forEach(([k, v]) => {
if (Store.FIELDS.includes(k)) {
if (typeof p[k] === 'number' || ['materialCostPerKg','materialUsageG','printTimeH','machineRate','powerKwh','powerPrice','postMin','postRate','packagingCost','shippingCost','setupCost','scrapPct','marginPct','quantity','individualAdjustment','vatPct'].includes(k)) {
const n = parseFloat(v);
p[k] = Number.isFinite(n) ? n : 0;
} else {
p[k] = String(v ?? '');
}
}
});
p.updatedAt = new Date().toISOString();
Store.save(projects);
renderProjects();
syncFormFromState();
recalc();
$('#dropzone').classList.remove('visible');
} catch (e) {
alert('Import fehlgeschlagen: ' + e.message);
}
};
const initDragDrop = () => {
const dz = $('#dropzone');
const show = () => dz.classList.add('visible');
const hide = () => dz.classList.remove('visible');
['dragenter', 'dragover'].forEach((ev) => {
window.addEventListener(ev, (e) => {
if (e.dataTransfer && Array.from(e.dataTransfer.types).includes('Files')) {
e.preventDefault();
show();
dz.classList.add('dragover');
}
});
});
['dragleave', 'dragend'].forEach((ev) => {
dz.addEventListener(ev, () => dz.classList.remove('dragover'));
});
dz.addEventListener('drop', (e) => {
e.preventDefault();
dz.classList.remove('dragover');
hide();
const f = e.dataTransfer?.files?.[0];
if (f) importFile(f);
});
window.addEventListener('drop', (e) => { e.preventDefault(); hide(); });
window.addEventListener('dragleave', (e) => {
if (e.clientX === 0 && e.clientY === 0) hide();
});
};
const initButtons = () => {
$('#btn-new').addEventListener('click', newProject);
$('#btn-duplicate').addEventListener('click', duplicateProject);
$('#btn-delete').addEventListener('click', deleteProject);
$('#btn-export').addEventListener('click', exportCurrent);
$('#btn-import').addEventListener('click', () => $('#file-input').click());
$('#file-input').addEventListener('change', (e) => {
const f = e.target.files?.[0];
if (f) importFile(f);
e.target.value = '';
});
};
const init = () => {
ensureProject();
renderProjects();
syncFormFromState();
attachFieldListeners();
initButtons();
initDragDrop();
recalc();
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();