Files
3ddruckkalkulation/static/script.js
Erik Thiele c42fecacb9 Mehrere Positionen pro Auftrag und Projektverwaltung
Bisher liess sich pro Rechnung nur ein Teil kalkulieren. Ein Objekt aus
mehreren Teilen musste in getrennten Rechnungen abgebildet werden.

Positionen:
- Die Kalkulation arbeitet mit einer Positionsliste statt einem
  Einzelteil. Bezeichnung, Material, Preis/kg, Druckzeit, Arbeitszeit
  und Stueckzahl gelten je Position -- verschiedene Teile nutzen oft
  unterschiedliche Filamente und brauchen unterschiedlich viel Nacharbeit.
- Drucker, Strom, Stundenlohn, Marge und Steuer bleiben auftragsweit.
- Jede Position wird eine eigene Rechnungszeile mit Menge, Einzelpreis
  und Zeilensumme. Bei vielen Positionen bricht das PDF auf weitere
  Seiten um.
- Die Auftragssumme ist die Summe der bereits gerundeten Zeilen, damit
  die Rechnung zeilenweise nachrechenbar bleibt.
- saubere_positionen() normalisiert jede Liste aus Config oder Client
  (Typen, Wertebereiche, Obergrenze 50); es bleibt immer mindestens
  eine Position bestehen.
- Alte Configs mit den Flachfeldern werden in eine einzelne Position
  migriert, die Altfelder entfernt.
- sammlePositionen() liefert null auf Seiten ohne Positionsliste, damit
  ein Tastendruck auf der Einstellungs- oder Rechnungsseite die
  Positionen nicht loescht.

Projekte:
- Benannte Kalkulationen lassen sich speichern und wieder laden,
  Ablage in druckkalkulation_projekte.yaml (ein Mapping fuer alle
  Projekte, Schluessel ist der Projektname).
- Gesichert werden nur PROJEKT_FELDER: Name, Positionen, Kundendaten,
  Rechnungsnummer, Rechnungs- und Leistungsdatum. Betriebswerte gehoeren
  bewusst nicht dazu -- sonst wuerde das Laden eines alten Projekts den
  aktuellen Stundenlohn oder das Steuerregime ueberschreiben.
- Neue Routen: GET /projekte sowie POST /projekte/{speichern,laden,loeschen}.
- Eigenes Lock, gleiche atomare Schreibtechnik wie die Config.
- Ueberschreiben, Laden bei ungespeicherten Aenderungen und Loeschen
  fragen jeweils nach.
- Der offene Autosave-Debounce wird vor dem Speichern durchgedrueckt,
  damit wirklich der aktuelle Stand ins Projekt geht.

Die beiden Teile stehen in einem Commit, weil die Aenderungen in app.py,
script.js und index.html ineinandergreifen und ein aufgetrennter
Zwischenstand nicht als solcher getestet wurde.

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

441 lines
16 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');
}
}
/** 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();
});