Beim Laden eines Projekts blieb der alte Projektname stehen, wenn kurz zuvor getippt wurde. Ursache: projektLaden() brach den laufenden Autosave-Timer nicht ab. Der feuerte 500 ms nach dem letzten Tastendruck, las die noch alten Formularfelder aus und schrieb sie ueber den gerade geladenen Stand. Anschliessend folgte ein location.reload(), der den ueberschriebenen Stand anzeigte. Auf localhost war das Zeitfenster meist zu klein, ueber LAN gegen den Container dagegen zuverlaessig zu treffen. - projektLaden() und projektNeu() brechen saveTimeout und calcTimeout ab. - /projekte/laden und /projekte/neu liefern den neuen Stand zurueck, das Frontend setzt die Felder direkt (neue Funktion standUebernehmen). - Kein location.reload() mehr. Das beseitigt zusaetzlich die Formular-Wiederherstellung, mit der Firefox und Safari von Hand geaenderte Felder nach einem Reload zurueckholen -- betroffen waere genau das Projektnamen-Feld gewesen. Reproduziert und gegengeprueft: mit der alten Reihenfolge schreibt der Autosave "Projekt B" ueber das geladene "Projekt A"; mit dem Fix bleiben Feld und Serverstand ueber mehrere Wechsel hinweg stabil. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
504 lines
18 KiB
JavaScript
504 lines
18 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;
|
|
}
|
|
// 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();
|
|
});
|