/** * 3D-Druck Kostenkalkulation — Frontend-Logik. * * - Live-Berechnung bei jeder Eingabe (via POST /calculate) * - Automatisches Speichern in YAML (debounced 500ms via POST /save_config) * - Dark-Mode-Umschaltung (persistiert in YAML) * - Export als TXT (POST /export -> Blob-Download) */ let saveTimeout = null; let calcTimeout = null; /** Liest ein einzelnes Feld gemaess seines data-type. */ function feldWert(el) { const type = el.dataset.type || 'text'; if (type === 'number') return parseFloat(el.value) || 0; if (type === 'checkbox') return el.checked; return el.value; } /** Sammelt alle Formularwerte anhand ihrer data-var Attribute. */ function getFormData() { const data = {}; document.querySelectorAll('[data-var]').forEach(el => { data[el.dataset.var] = feldWert(el); }); const positionen = sammlePositionen(); if (positionen) data.positionen = positionen; data.dark_mode = document.documentElement.getAttribute('data-bs-theme') === 'dark'; return data; } /** Liest die Positionskarten aus. null, wenn die Seite keine hat * (Einstellungen, Rechnung) -- dann bleibt die Config unangetastet. */ function sammlePositionen() { const liste = document.getElementById('positionen-liste'); if (!liste) return null; return [...liste.querySelectorAll('.position-karte')].map(karte => { const pos = {}; karte.querySelectorAll('[data-pos-var]').forEach(el => { pos[el.dataset.posVar] = feldWert(el); }); return pos; }); } /** Haengt eine Positionskarte an die Liste an. */ function positionAnlegen(werte = {}) { const liste = document.getElementById('positionen-liste'); const vorlage = document.getElementById('position-vorlage'); if (!liste || !vorlage) return; const karte = vorlage.content.firstElementChild.cloneNode(true); karte.querySelectorAll('[data-pos-var]').forEach(el => { const key = el.dataset.posVar; el.value = werte[key] !== undefined ? werte[key] : (key === 'stueckzahl' ? 1 : ''); }); karte.querySelector('.position-entfernen') .addEventListener('click', () => positionEntfernen(karte)); liste.appendChild(karte); nummeriereNeu(); } /** Neue, leere Position -- speichert und rechnet sofort neu. */ function positionHinzufuegen() { positionAnlegen({ materialPreisProKg: standardMaterialpreis(), stueckzahl: 1 }); onInputChange(); } /** Uebernimmt den Materialpreis der letzten Position als Vorgabe. */ function standardMaterialpreis() { const felder = document.querySelectorAll('[data-pos-var="materialPreisProKg"]'); if (!felder.length) return 25; return parseFloat(felder[felder.length - 1].value) || 25; } /** Entfernt eine Position. Die letzte bleibt stehen -- ohne Position * gaebe es nichts zu rechnen und keine Eingabefelder mehr. */ function positionEntfernen(karte) { const liste = document.getElementById('positionen-liste'); if (liste.querySelectorAll('.position-karte').length <= 1) { showNotification('Mindestens eine Position wird benoetigt'); return; } karte.remove(); nummeriereNeu(); onInputChange(); } /** Haelt die sichtbare Nummerierung nach Hinzufuegen/Entfernen aktuell. */ function nummeriereNeu() { document.querySelectorAll('#positionen-liste .position-karte') .forEach((karte, i) => { karte.querySelector('.position-nummer').textContent = i + 1; }); } /** Sendet Formulardaten an /calculate und aktualisiert die Ergebnisanzeige. * Auf Seiten ohne Ergebnisblock (Einstellungen, Rechnung) entfaellt beides. */ async function recalculate() { if (!document.getElementById('result-text')) return; const data = getFormData(); try { const res = await fetch('/calculate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); const calc = await res.json(); renderResult(calc); } catch (e) { console.error('Calculate error', e); } } /** Baut die textuelle Ergebnisanzeige aus den JSON-Berechnungswerten. */ function renderResult(calc) { const ziel = document.getElementById('result-text'); if (!ziel) return; const eur = (v) => `${String(v.toFixed(2)).padStart(10)} EUR`; const zeile = (label, wert) => `${label.padEnd(23)}${eur(wert)}`; const lines = []; calc.positionen.forEach((pos, i) => { const titel = pos.name ? pos.name : `Position ${i + 1}`; lines.push(`${(i + 1)}. ${titel.toUpperCase()}`); lines.push(zeile(' Material:', pos.materialkosten)); lines.push(zeile(' Strom:', pos.stromkosten)); lines.push(zeile(' Abschreibung:', pos.abschreibung)); lines.push(zeile(' Arbeit:', pos.arbeitskosten)); lines.push(zeile(' Kosten je Stuck:', pos.kosten)); lines.push(zeile(` Gewinn (${calc.gewinnmarge}%):`, pos.gewinn)); lines.push(zeile(' Stueckpreis:', pos.stueckpreis)); lines.push(` ${String(pos.stueckzahl).padStart(3)} x ${pos.stueckpreis.toFixed(2)}` + ` = ${String(pos.zeilensumme.toFixed(2)).padStart(10)} EUR`); lines.push(''); }); lines.push('\u2500'.repeat(40)); lines.push(`AUFTRAG (${calc.anzahl_positionen} Pos., ${calc.teile_gesamt} Teile)`); lines.push(zeile('Materialkosten:', calc.summe_materialkosten)); lines.push(zeile('Stromkosten:', calc.summe_stromkosten)); lines.push(zeile('Abschreibung:', calc.summe_abschreibung)); lines.push(zeile('Arbeitskosten:', calc.summe_arbeitskosten)); lines.push(zeile('Kosten gesamt:', calc.summe_kosten)); lines.push(zeile('Gewinn gesamt:', calc.summe_gewinn)); lines.push('\u2500'.repeat(40)); lines.push(zeile('Auftrag netto:', calc.gesamt_netto)); if (calc.mwst_ausweisen) { lines.push(zeile(`MwSt. (${calc.mwst_satz}%):`, calc.gesamt_mwst)); } lines.push('\u2550'.repeat(40)); lines.push(zeile('GESAMT:', calc.gesamt_endpreis)); ziel.textContent = lines.join('\n'); const details = [ `Druckzeit gesamt: ${calc.druckzeit_stunden} Stunden`, `Materialgewicht gesamt: ${calc.material_kg} kg`, `Abschreibung pro Stunde: ${calc.abschreibung_pro_stunde} EUR`, `Stromkosten pro Stunde: ${calc.strom_pro_stunde} EUR`, ]; const detailZiel = document.getElementById('detail-text'); if (detailZiel) detailZiel.textContent = details.join('\n'); } /** Schreibt den aktuellen Formularstand sofort weg. */ async function sofortSpeichern() { clearTimeout(saveTimeout); try { await fetch('/save_config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(getFormData()), }); } catch (e) { console.error('Save error', e); } } /** Plant ein Auto-Save in die YAML-Konfiguration (debounced 500ms). */ function scheduleSave() { clearTimeout(saveTimeout); saveTimeout = setTimeout(sofortSpeichern, 500); } /** Wird bei jeder Eingabe aufgerufen: speichert + berechnet neu. * Beides entprellt, damit Tippen nicht pro Zeichen einen Request ausloest. */ function onInputChange() { scheduleSave(); clearTimeout(calcTimeout); calcTimeout = setTimeout(recalculate, 150); } /** Schaltet zwischen Light- und Dark-Mode um und persistiert die Einstellung. */ function toggleDarkMode() { const html = document.documentElement; const isDark = html.getAttribute('data-bs-theme') === 'dark'; const newTheme = isDark ? 'light' : 'dark'; html.setAttribute('data-bs-theme', newTheme); // Icon spiegelt den NEUEN Zustand: dunkel -> Sonne (zum Zurueckschalten). const icon = document.getElementById('dark-mode-icon'); if (icon) { icon.className = `ti ti-${newTheme === 'dark' ? 'sun' : 'moon'} icon icon-1`; } scheduleSave(); } /** Sendet die aktuellen Werte an /export und lost einen TXT-Download aus. */ async function exportData() { const data = getFormData(); try { const res = await fetch('/export', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'kalkulation.txt'; a.click(); URL.revokeObjectURL(url); showNotification('Export erfolgreich!'); } catch (e) { console.error('Export error', e); } } /* --------------------------------------------------------------------------- * Projekte: benannte Staende speichern und wieder laden * ------------------------------------------------------------------------- */ let bekannteProjekte = []; /** Holt die Projektliste und fuellt die Auswahl. */ async function projekteAktualisieren() { const auswahl = document.getElementById('projekt-auswahl'); if (!auswahl) return; try { const res = await fetch('/projekte'); const daten = await res.json(); bekannteProjekte = daten.projekte || []; } catch (e) { console.error('Projektliste nicht ladbar', e); return; } const vorher = auswahl.value; auswahl.innerHTML = ''; const info = document.getElementById('projekt-info'); if (!bekannteProjekte.length) { const opt = new Option('— noch keine Projekte gespeichert —', ''); opt.disabled = true; auswahl.add(opt); if (info) info.textContent = 'Noch keine Projekte gespeichert.'; return; } bekannteProjekte.forEach(p => { const teile = `${p.anzahl_positionen} Pos., ${p.teile_gesamt} Teile`; const preis = p.gesamt_endpreis.toFixed(2) + ' EUR'; auswahl.add(new Option(`${p.name} (${teile}, ${preis})`, p.name)); }); // Nach Moeglichkeit das aktuell geladene Projekt vorauswaehlen. const aktuell = document.getElementById('projekt-name'); const wunsch = [vorher, aktuell && aktuell.value].find( v => v && bekannteProjekte.some(p => p.name === v)); if (wunsch) auswahl.value = wunsch; if (info) { const gewaehlt = bekannteProjekte.find(p => p.name === auswahl.value); info.textContent = gewaehlt ? `Zuletzt geändert: ${gewaehlt.geaendert}` + (gewaehlt.kundenName ? ` · Kunde: ${gewaehlt.kundenName}` : '') : `${bekannteProjekte.length} Projekte gespeichert.`; } } /** Speichert den aktuellen Stand unter dem eingetragenen Projektnamen. */ async function projektSpeichern() { const feld = document.getElementById('projekt-name'); const name = (feld.value || '').trim(); if (!name) { showNotification('Bitte zuerst einen Projektnamen vergeben'); feld.focus(); return; } if (bekannteProjekte.some(p => p.name === name) && !confirm(`Projekt "${name}" existiert bereits.\n\nMit dem aktuellen Stand ueberschreiben?`)) { return; } // Erst den offenen Autosave durchdruecken, damit der Server den // wirklich aktuellen Stand wegschreibt. await sofortSpeichern(); try { const res = await fetch('/projekte/speichern', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }), }); const daten = await res.json(); if (!res.ok) { showNotification(daten.error || 'Speichern fehlgeschlagen'); return; } showNotification(daten.neu ? `Projekt "${name}" angelegt` : `Projekt "${name}" aktualisiert`); await projekteAktualisieren(); } catch (e) { showNotification('Speichern fehlgeschlagen'); } } /** Laedt das ausgewaehlte Projekt in den Arbeitsstand. */ async function projektLaden() { const auswahl = document.getElementById('projekt-auswahl'); const name = auswahl.value; if (!name) { showNotification('Kein Projekt ausgewaehlt'); return; } const offen = (document.getElementById('projekt-name').value || '').trim(); if (offen && offen !== name && !confirm(`Der aktuelle Stand "${offen}" wird durch "${name}" ersetzt.\n\n` + `Ungespeicherte Aenderungen gehen dabei verloren. Fortfahren?`)) { return; } // Ausstehenden Autosave verwerfen: er wuerde sonst mit den ALTEN // Formulardaten feuern und den gerade geladenen Stand ueberschreiben. clearTimeout(saveTimeout); clearTimeout(calcTimeout); try { const res = await fetch('/projekte/laden', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }), }); const daten = await res.json(); if (!res.ok) { showNotification(daten.error || 'Laden fehlgeschlagen'); return; } standUebernehmen(daten.stand); showNotification(`Projekt "${name}" geladen`); } catch (e) { showNotification('Laden fehlgeschlagen'); } } /** Uebernimmt einen vom Server gelieferten Projektstand in die Felder. * * Bewusst ohne location.reload(): Firefox und Safari stellen beim Reload * von Hand geaenderte Formularfelder wieder her und wuerden damit den * gerade geladenen Projektnamen sofort wieder ueberschreiben. */ function standUebernehmen(stand) { if (!stand) return; const nameFeld = document.getElementById('projekt-name'); if (nameFeld) nameFeld.value = stand.projektName || ''; const liste = document.getElementById('positionen-liste'); if (liste) { liste.innerHTML = ''; const positionen = (stand.positionen && stand.positionen.length) ? stand.positionen : [{ materialPreisProKg: 25, stueckzahl: 1 }]; positionen.forEach(positionAnlegen); } recalculate(); projekteAktualisieren(); } /** Beginnt einen leeren Auftrag. Betriebswerte bleiben erhalten. */ async function projektNeu() { // Offenen Autosave durchdruecken, damit der Server den wirklich // aktuellen Stand kennt, bevor wir nach Ungespeichertem fragen. await sofortSpeichern(); let offen = false; try { const daten = await (await fetch('/projekte')).json(); offen = !!daten.ungespeichert; } catch (e) { offen = true; // Im Zweifel lieber nachfragen. } if (offen && !confirm( 'Der aktuelle Stand ist nicht als Projekt gespeichert.\n\n' + 'Projektname, Positionen, Kundendaten und Rechnungsnummer werden ' + 'geleert. Betriebswerte (Marge, Drucker, Steuer) bleiben erhalten.\n\n' + 'Trotzdem neu anfangen?')) { return; } clearTimeout(saveTimeout); clearTimeout(calcTimeout); try { const res = await fetch('/projekte/neu', { method: 'POST' }); const daten = await res.json(); if (!res.ok) { showNotification('Zuruecksetzen fehlgeschlagen'); return; } standUebernehmen(daten.stand); showNotification('Neues Projekt begonnen'); } catch (e) { showNotification('Zuruecksetzen fehlgeschlagen'); } } /** Loescht das ausgewaehlte Projekt (der Arbeitsstand bleibt bestehen). */ async function projektLoeschen() { const auswahl = document.getElementById('projekt-auswahl'); const name = auswahl.value; if (!name) { showNotification('Kein Projekt ausgewaehlt'); return; } if (!confirm(`Projekt "${name}" endgueltig loeschen?\n\n` + `Der aktuell geoeffnete Stand bleibt erhalten.`)) return; try { const res = await fetch('/projekte/loeschen', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }), }); const daten = await res.json(); if (!res.ok) { showNotification(daten.error || 'Loeschen fehlgeschlagen'); return; } showNotification(`Projekt "${name}" geloescht`); await projekteAktualisieren(); } catch (e) { showNotification('Loeschen fehlgeschlagen'); } } /** Haelt die Info-Zeile zur Auswahl aktuell. */ document.addEventListener('DOMContentLoaded', () => { const auswahl = document.getElementById('projekt-auswahl'); if (auswahl) auswahl.addEventListener('change', projekteAktualisieren); }); /** Zeigt eine kurze Toast-Notification oben rechts an. */ function showNotification(msg) { const container = document.getElementById('notification'); document.getElementById('notification-msg').textContent = msg; container.style.display = 'block'; setTimeout(() => { container.style.display = 'none'; }, 2500); } /** Logo hochladen via AJAX. */ async function uploadLogo() { const input = document.getElementById('logo-upload'); if (!input.files || !input.files[0]) { showNotification('Bitte eine Datei auswaehlen'); return; } const formData = new FormData(); formData.append('logo', input.files[0]); try { const res = await fetch('/upload_logo', { method: 'POST', body: formData }); const data = await res.json(); if (data.status === 'ok') { showNotification('Logo gespeichert!'); setTimeout(() => location.reload(), 800); } } catch (e) { showNotification('Fehler beim Hochladen'); } } /** Logo loeschen. */ async function deleteLogo() { try { const res = await fetch('/delete_logo', { method: 'POST' }); const data = await res.json(); if (data.status === 'ok') { showNotification('Logo entfernt'); setTimeout(() => location.reload(), 800); } } catch (e) { showNotification('Fehler beim Loeschen'); } } /** Positionen aus dem Server-JSON aufbauen, dann initial rechnen. */ document.addEventListener('DOMContentLoaded', () => { const daten = document.getElementById('positionen-daten'); if (daten) { let positionen = []; try { positionen = JSON.parse(daten.textContent) || []; } catch (e) { console.error('Positionen nicht lesbar', e); } if (!positionen.length) positionen = [{ materialPreisProKg: 25, stueckzahl: 1 }]; positionen.forEach(positionAnlegen); } recalculate(); projekteAktualisieren(); });