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>
472 lines
17 KiB
JavaScript
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();
|
|
});
|