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:
249
assets/app.js
Normal file
249
assets/app.js
Normal 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();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user