Files
3ddruckkalkulation/static/script.js
Erik Thiele 1b3ef82db4 Knopf "Neues Projekt" zum Leeren des Arbeitsstands
Bisher liess sich ein neuer Auftrag nur von Hand beginnen: Namen
ueberschreiben, jede Position neu ausfuellen und auf der Rechnungsseite
Kundendaten und Rechnungsnummer leeren. Wurde der letzte Schritt
vergessen, stand der alte Kunde auf der neuen Rechnung.

- POST /projekte/neu setzt nur PROJEKT_FELDER zurueck (Name, Positionen,
  Kundendaten, Rechnungsnummer, Datum auf heute). Betriebswerte wie
  Marge, Stundenlohn, Drucker, Steuer und Firmendaten bleiben.
- GET /projekte liefert zusaetzlich 'ungespeichert'. Der Knopf fragt nur
  nach, wenn der offene Stand wirklich nicht als Projekt gesichert ist.
- Der Vergleich ignoriert 'datum' und 'geaendert', sonst waere ein
  gespeichertes Projekt am naechsten Tag faelschlich "ungespeichert".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 11:59:20 +02:00

472 lines
17 KiB
JavaScript

/**
* 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;
}
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;
}
// Neu laden: die Positionskarten baut der Server-Datenblock auf.
location.reload();
} catch (e) {
showNotification('Laden fehlgeschlagen');
}
}
/** 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;
}
try {
const res = await fetch('/projekte/neu', { method: 'POST' });
if (!res.ok) {
showNotification('Zuruecksetzen fehlgeschlagen');
return;
}
location.reload();
} 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();
});