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>
This commit is contained in:
Erik Thiele
2026-08-22 11:38:26 +02:00
parent b6e5a59daa
commit c42fecacb9
7 changed files with 762 additions and 182 deletions

View File

@@ -10,21 +10,91 @@
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 => {
const key = el.dataset.var;
const type = el.dataset.type || 'text';
let val = el.value;
if (type === 'number') val = parseFloat(val) || 0;
else if (type === 'checkbox') val = el.checked;
data[key] = val;
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() {
@@ -48,64 +118,69 @@ function renderResult(calc) {
const ziel = document.getElementById('result-text');
if (!ziel) return;
const eur = (v) => `${String(v.toFixed(2)).padStart(10)} EUR`;
const marge = `Gewinn (${calc.gewinnmarge}%):`.padEnd(23);
const zeile = (label, wert) => `${label.padEnd(23)}${eur(wert)}`;
const lines = [];
const lines = [
`PRO STUCK`,
`Materialkosten: ${eur(calc.materialkosten)}`,
`Stromkosten: ${eur(calc.stromkosten)}`,
`Abschreibung Drucker: ${eur(calc.abschreibung)}`,
`Arbeitskosten: ${eur(calc.arbeitskosten)}`,
`${'\u2500'.repeat(40)}`,
`Gesamtkosten: ${eur(calc.gesamtkosten_netto)}`,
'',
`${marge}${eur(calc.gewinn)}`,
`${'\u2500'.repeat(40)}`,
`Verkaufspreis (netto): ${eur(calc.verkaufspreis)}`,
];
if (calc.mwst_ausweisen) {
lines.push(`MwSt. (19%): ${eur(calc.mwst)}`);
}
lines.push(`Preis pro Stuck: ${eur(calc.gesamt_brutto)}`);
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('');
});
// Auftragssumme: bei Stueckzahl 1 identisch, aber explizit ausgewiesen,
// damit der Endbetrag nicht mit dem Stueckpreis verwechselt wird.
lines.push('');
lines.push(`AUFTRAG (${calc.stueckzahl} Stuck)`);
lines.push(`Auftrag netto: ${eur(calc.gesamt_netto)}`);
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(`MwSt. (19%): ${eur(calc.gesamt_mwst)}`);
lines.push(zeile(`MwSt. (${calc.mwst_satz}%):`, calc.gesamt_mwst));
}
lines.push(`${'\u2550'.repeat(40)}`);
lines.push(`GESAMT: ${eur(calc.gesamt_endpreis)}`);
lines.push('\u2550'.repeat(40));
lines.push(zeile('GESAMT:', calc.gesamt_endpreis));
ziel.textContent = lines.join('\n');
const details = [
`Druckzeit: ${calc.druckzeit_stunden} Stunden`,
`Materialgewicht: ${calc.material_kg} kg`,
`Druckzeit gesamt: ${calc.druckzeit_stunden} Stunden`,
`Materialgewicht gesamt: ${calc.material_kg} kg`,
`Abschreibung pro Stunde: ${calc.abschreibung_pro_stunde} EUR`,
`Kosten pro Gramm: ${calc.kosten_pro_gramm} 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(async () => {
const data = getFormData();
try {
await fetch('/save_config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
} catch (e) {
console.error('Save error', e);
}
}, 500);
saveTimeout = setTimeout(sofortSpeichern, 500);
}
/** Wird bei jeder Eingabe aufgerufen: speichert + berechnet neu.
@@ -154,6 +229,156 @@ async function exportData() {
}
}
/* ---------------------------------------------------------------------------
* 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');
@@ -197,7 +422,19 @@ async function deleteLogo() {
}
}
/** Initiale Berechnung beim Seitenladen. */
/** 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();
});