From c42fecacb9d927e5bdb5d8eb16bac27c761d2a44 Mon Sep 17 00:00:00 2001 From: Erik Thiele Date: Sat, 22 Aug 2026 11:38:26 +0200 Subject: [PATCH] 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 --- .gitignore | 1 + AGENTS.md | 21 ++ README.md | 17 +- app.py | 448 +++++++++++++++++++++++++++++++++-------- static/script.js | 333 +++++++++++++++++++++++++----- templates/index.html | 99 ++++++--- templates/invoice.html | 25 +-- 7 files changed, 762 insertions(+), 182 deletions(-) diff --git a/.gitignore b/.gitignore index e92971e..b7ce114 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__/ .DS_Store druckkalkulation_config.yaml static/logo.* +druckkalkulation_projekte.yaml diff --git a/AGENTS.md b/AGENTS.md index b344f6f..21dad6b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,27 @@ Kein Test, Lint, Typecheck oder CI vorhanden. README.md und AGENTS.md enthalten die Ableitung aus Alt-Configs nicht) und in `/save_config` nach dem Update (sonst steht Widerspruechliches auf der Platte). - Der MwSt.-Satz kommt aus `mwst_satz`, nicht mehr hartcodiert. +- **Positionen**: `config['positionen']` ist eine Liste von Dicts mit den + Feldern aus `DEFAULT_POSITION`. `calculate()` liefert `calc['positionen']` + (je Position `stueckpreis` und `zeilensumme`) plus die Auftragssummen. + Jede Liste aus Config oder Client geht durch `saubere_positionen()` -- + nie ungeprueft uebernehmen. Es gibt immer mindestens eine Position. +- Die Auftragssumme ist die Summe der bereits gerundeten Zeilen, damit die + Rechnung zeilenweise nachrechenbar bleibt. +- `sammlePositionen()` im Frontend liefert `null` auf Seiten ohne + Positionsliste (Einstellungen, Rechnung). Dann darf `positionen` NICHT + mitgesendet werden -- sonst wuerde ein Tastendruck dort die Positionen + loeschen. +- **Projekte**: `druckkalkulation_projekte.yaml` haelt benannte Staende, + Schluessel ist der Projektname. Nur `PROJEKT_FELDER` werden gesichert -- + Betriebswerte (Marge, Steuer, Drucker, Firmendaten) gehoeren bewusst NICHT + dazu, sonst wuerde das Laden eines alten Projekts sie ueberschreiben. + Eigenes Lock `_projekte_lock`, gleiche atomare Schreibtechnik wie die Config. + Die Preise in der Projektliste werden mit den AKTUELLEN Betriebswerten + gerechnet, nicht mit denen von damals. +- `druckkalkulation_config.yaml` ist der Arbeitsstand, nicht das Archiv. +- Alte Configs mit den Flachfeldern (`materialGewicht` etc.) migriert + `_migriere()` in eine einzelne Position und entfernt die Altfelder. - **Saeamtliche UI-Texte sind Deutsch** — Labels, Fehlermeldungen, Rechnungsinhalte. - Nur `.venv/`, `druckkalkulation_config.yaml` und `static/logo.*` werden ignoriert. - `3dDruckKalkulator.py` ist die alte Tkinter-Version und wird nicht mehr verwendet (nicht im Tracking). diff --git a/README.md b/README.md index ecf6450..ed5c9b7 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,14 @@ sich ueber `HOST` und `PORT` ueberschreiben. ## Funktionen +- **Mehrere Positionen pro Auftrag** — ein Objekt aus mehreren Teilen wird als + Positionsliste kalkuliert und als eigene Rechnungszeilen ausgewiesen. + Material, Preis/kg, Druckzeit, Arbeitszeit und Stückzahl gelten je Position; + Drucker, Strom, Stundenlohn, Marge und Steuer gelten für den ganzen Auftrag. +- **Projekte speichern und laden** — benannte Kalkulationen bleiben erhalten + und lassen sich jederzeit wieder aufrufen (`druckkalkulation_projekte.yaml`). + Betriebswerte (Drucker, Strom, Stundenlohn, Marge, Steuer) gehören *nicht* + zum Projekt und bleiben beim Laden unangetastet. - **Live-Berechnung** bei jeder Eingabe (Material, Strom, Abschreibung, Arbeit, Gewinn) - **Dark Mode** (persistiert in YAML-Konfiguration) - **PDF-Rechnung** mit Logo, Kundendaten und Positionstabelle (via fpdf2) @@ -112,7 +120,8 @@ static/style.css # Minimales Custom-CSS static/script.js # Live-Berechnung + Auto-Save proxmox-setup.sh # Ersteinrichtung LXC-Container proxmox-update.sh # Update LXC-Container -druckkalkulation_config.yaml # Automatisch erstellte Konfiguration (ignoriert) +druckkalkulation_config.yaml # Aktueller Arbeitsstand (ignoriert) +druckkalkulation_projekte.yaml # Gespeicherte Projekte (ignoriert) ``` ## Routen @@ -120,12 +129,16 @@ druckkalkulation_config.yaml # Automatisch erstellte Konfiguration (ignoriert) | Methode | Pfad | Beschreibung | |---------|------|-------------| | GET | `/` | Hauptseite mit Formular | -| POST | `/calculate` | JSON-Berechnung (AJAX) | +| POST | `/calculate` | JSON-Berechnung aller Positionen (AJAX) | | POST | `/save_config` | Speichert Werte in YAML | | GET | `/invoice` | Rechnungsseite | | POST | `/invoice/pdf` | PDF-Download | | POST | `/export` | TXT-Download | | GET | `/settings` | Einstellungsseite | +| GET | `/projekte` | Liste der gespeicherten Projekte (JSON) | +| POST | `/projekte/speichern` | Aktuellen Stand als Projekt sichern | +| POST | `/projekte/laden` | Projekt in den Arbeitsstand laden | +| POST | `/projekte/loeschen` | Projekt entfernen | | POST | `/upload_logo` | Logo hochladen | | POST | `/delete_logo` | Logo loschen | diff --git a/app.py b/app.py index 6090bdf..c90b018 100644 --- a/app.py +++ b/app.py @@ -111,25 +111,51 @@ def pdf_text(value): app = Flask(__name__) app.config['MAX_CONTENT_LENGTH'] = 5 * 1024 * 1024 CONFIG_FILE = 'druckkalkulation_config.yaml' +PROJEKTE_FILE = 'druckkalkulation_projekte.yaml' + +# Felder, die zu einem Projekt gehoeren. Alles andere (Drucker, Strom, +# Stundenlohn, Marge, Steuer, Firmendaten) ist werkstattweit und wird +# bewusst NICHT mitgespeichert -- sonst wuerde das Laden eines alten +# Projekts die aktuellen Betriebswerte ueberschreiben. +PROJEKT_FELDER = ('projektName', 'positionen', 'kundenName', 'kundenAdresse', + 'rechnungsnummer', 'datum', 'leistungsdatum') + +MAX_PROJEKTE = 500 LOGO_EXTS = ('png', 'jpg', 'webp') +# Obergrenze, damit ein fehlerhafter Client die Config nicht aufblaeht. +MAX_POSITIONEN = 50 + +# Eine einzelne Kalkulationsposition. Material, Arbeitszeit und Stueckzahl +# gehoeren hierher: verschiedene Teile eines Objekts nutzen oft +# unterschiedliche Filamente und brauchen unterschiedlich viel Nacharbeit. +DEFAULT_POSITION = { + 'name': '', + 'materialGewicht': 0, + 'materialPreisProKg': 25, + 'druckzeit': 0, + 'arbeitszeit': 0, + 'stueckzahl': 1, +} + +# Felder, die frueher direkt in der Config lagen und jetzt zu einer +# Position gehoeren. Wird fuer die Migration alter Staende gebraucht. +LEGACY_POSITIONSFELDER = ('materialGewicht', 'materialPreisProKg', + 'druckzeit', 'arbeitszeit', 'stueckzahl') + DEFAULT_VALUES = { 'projektName': '', 'kundenName': '', 'kundenAdresse': '', - 'materialGewicht': 0, - 'materialPreisProKg': 25, - 'druckzeit': 0, + 'positionen': [dict(DEFAULT_POSITION)], 'stromverbrauch': 0.15, 'strompreis': 0.35, 'druckerAnschaffung': 500, 'druckerNutzungsdauer': 3, 'druckerBetriebsstunden': 2000, - 'arbeitszeit': 0, 'stundenlohn': 25, 'gewinnmarge': 20, - 'stueckzahl': 1, # 'regel' = regelbesteuert (MwSt. wird ausgewiesen), # 'kleinunternehmer' = §19 UStG (keine MwSt., dafuer Pflichthinweis). 'steuerregime': 'regel', @@ -209,9 +235,52 @@ def _migriere(data): # 'mwst_ausweisen' bleibt als abgeleitetes Feld erhalten, damit # Templates und Export nicht an zwei Stellen entscheiden muessen. data['mwst_ausweisen'] = data['steuerregime'] == 'regel' + + # Positionen: frueher lag genau ein Teil flach in der Config. + if 'positionen' not in data: + alt = {k: data[k] for k in LEGACY_POSITIONSFELDER if k in data} + if alt: + alt.setdefault('name', data.get('projektName', '')) + data['positionen'] = [alt] + else: + data['positionen'] = [dict(DEFAULT_POSITION)] + data['positionen'] = saubere_positionen(data['positionen']) + # Die alten Flachfelder nicht mitschleppen -- sonst driften zwei + # Quellen fuer dieselbe Angabe auseinander. + for k in LEGACY_POSITIONSFELDER: + data.pop(k, None) return data +def saubere_positionen(rohliste): + """Normalisiert eine Positionsliste aus Config oder Client-JSON. + + Nimmt nur bekannte Felder an, erzwingt Zahlentypen und liefert immer + mindestens eine Position -- eine leere Kalkulation waere sinnlos und + wuerde die Oberflaeche in einen Zustand ohne Eingabefelder bringen. + """ + if not isinstance(rohliste, list): + rohliste = [] + sauber = [] + for eintrag in rohliste: + if not isinstance(eintrag, dict): + continue + pos = dict(DEFAULT_POSITION) + pos['name'] = str(eintrag.get('name', '') or '')[:200] + for feld in ('materialGewicht', 'materialPreisProKg', + 'druckzeit', 'arbeitszeit'): + try: + wert = float(eintrag.get(feld, DEFAULT_POSITION[feld])) + except (TypeError, ValueError): + wert = float(DEFAULT_POSITION[feld]) + pos[feld] = max(0.0, wert) + pos['stueckzahl'] = as_stueckzahl(eintrag.get('stueckzahl', 1)) + sauber.append(pos) + if len(sauber) >= MAX_POSITIONEN: + break + return sauber or [dict(DEFAULT_POSITION)] + + def save_config(data): """Schreibt das uebergebene Dict atomar als YAML-Datei. @@ -234,33 +303,59 @@ def save_config(data): raise -def calculate(data): - """Berechnet alle Kosten aus den Formularwerten. +_projekte_lock = threading.Lock() - Rueckgabe: Dict mit Einzelposten, Gesamtsummen und Detailwerten. + +def lade_projekte(): + """Liest die Projektdatei. Fehlt sie, gibt es eben noch keine Projekte.""" + if not os.path.exists(PROJEKTE_FILE): + return {} + try: + with open(PROJEKTE_FILE, 'r', encoding='utf-8') as f: + data = yaml.safe_load(f) + except (OSError, yaml.YAMLError) as e: + raise ConfigError(f"{PROJEKTE_FILE} ist nicht lesbar: {e}") from e + if data is None: + return {} + if not isinstance(data, dict): + raise ConfigError(f"{PROJEKTE_FILE} enthaelt kein YAML-Mapping") + return data + + +def speichere_projekte(projekte): + """Schreibt die Projektdatei atomar (gleiche Technik wie save_config).""" + verzeichnis = os.path.dirname(os.path.abspath(PROJEKTE_FILE)) + fd, tmp_pfad = tempfile.mkstemp( + prefix='.druckkalkulation_projekte_', suffix='.tmp', dir=verzeichnis) + try: + with os.fdopen(fd, 'w', encoding='utf-8') as f: + yaml.dump(projekte, f, default_flow_style=False, allow_unicode=True) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_pfad, PROJEKTE_FILE) + except Exception: + if os.path.exists(tmp_pfad): + os.remove(tmp_pfad) + raise + + +def projekt_name(roh): + """Normalisiert einen Projektnamen zum Schluessel. + + Der Name ist der Schluessel -- er landet aber nie in einem Dateipfad, + daher reicht Trimmen und Laengenbegrenzung. """ - # Umrechnungen - druckzeit_stunden = float(data.get('druckzeit', 0)) / 60 - arbeitszeit_stunden = float(data.get('arbeitszeit', 0)) / 60 - material_kg = float(data.get('materialGewicht', 0)) / 1000 + return str(roh or '').strip()[:200] - # Material-, Strom-, Abschreibungs- und Arbeitskosten - materialkosten = material_kg * float(data.get('materialPreisProKg', 0)) - stromkosten = druckzeit_stunden * float(data.get('stromverbrauch', 0)) * float(data.get('strompreis', 0)) - anschaffung = float(data.get('druckerAnschaffung', 0)) - nutzung = float(data.get('druckerNutzungsdauer', 1)) - betrieb = float(data.get('druckerBetriebsstunden', 1)) - abschreibung_pro_stunde = anschaffung / (nutzung * betrieb) if (nutzung * betrieb) > 0 else 0 - abschreibung = abschreibung_pro_stunde * druckzeit_stunden +def projekt_aus_config(config): + """Schneidet die Projektfelder aus dem aktuellen Arbeitsstand heraus.""" + return {feld: config.get(feld, DEFAULT_VALUES.get(feld)) + for feld in PROJEKT_FELDER} - arbeitskosten = arbeitszeit_stunden * float(data.get('stundenlohn', 0)) - # Gewinn, MwSt. und Bruttopreis - gesamtkosten_netto = materialkosten + stromkosten + abschreibung + arbeitskosten - gewinn = gesamtkosten_netto * (float(data.get('gewinnmarge', 0)) / 100) - verkaufspreis = gesamtkosten_netto + gewinn - # Das Steuerregime entscheidet, nicht mehr ein loser Bool-Schalter. +def steuer_kontext(data): + """Liest Regime und MwSt.-Satz robust aus den Formularwerten.""" regime = str(data.get('steuerregime', 'regel')).lower() if regime not in STEUERREGIME: regime = 'regel' @@ -269,50 +364,121 @@ def calculate(data): mwst_satz = float(data.get('mwst_satz', 19)) except (TypeError, ValueError): mwst_satz = 19.0 - if mwst_satz < 0: - mwst_satz = 0.0 - mwst_faktor = mwst_satz / 100 if mwst_ausweisen else 0 - mwst = verkaufspreis * mwst_faktor - gesamt_brutto = verkaufspreis + mwst + mwst_satz = max(0.0, mwst_satz) + return regime, mwst_ausweisen, mwst_satz, (mwst_satz / 100 if mwst_ausweisen else 0) - material_preis_pro_kg = float(data.get('materialPreisProKg', 0)) - # Gesamtwerte fuer den kompletten Auftrag. Basis ist der auf Cent - # gerundete Stueckpreis, damit die Rechnungszeile nachrechenbar - # bleibt (Einzelpreis x Menge == Zeilensumme). - stueckzahl = as_stueckzahl(data.get('stueckzahl', 1)) - stueckpreis_netto = round(verkaufspreis, 2) - gesamt_netto = round(stueckpreis_netto * stueckzahl, 2) - gesamt_mwst = round(gesamt_netto * mwst_faktor, 2) - gesamt_endpreis = round(gesamt_netto + gesamt_mwst, 2) +def positions_label(pos, nummer, projekt, anzahl): + """Beschriftung einer Rechnungszeile. + + Bei einer einzelnen namenlosen Position bleibt es beim bisherigen + "3D-Druck: ". Sobald mehrere Positionen im Spiel sind, + braucht jede Zeile eine eigene, unterscheidbare Bezeichnung. + """ + name = (pos.get('name') or '').strip() + if name: + return f"3D-Druck: {name}" + if anzahl == 1: + return f"3D-Druck: {projekt}" if projekt else "3D-Druck" + return f"3D-Druck: Position {nummer}" + + +def berechne_position(pos, data, abschreibung_pro_stunde): + """Berechnet eine einzelne Position. + + 'data' liefert die auftragsweiten Werte (Strom, Stundenlohn, Marge), + 'pos' die positionsspezifischen (Material, Zeiten, Stueckzahl). + """ + druckzeit_stunden = float(pos.get('druckzeit', 0)) / 60 + arbeitszeit_stunden = float(pos.get('arbeitszeit', 0)) / 60 + material_kg = float(pos.get('materialGewicht', 0)) / 1000 + material_preis_pro_kg = float(pos.get('materialPreisProKg', 0)) + + materialkosten = material_kg * material_preis_pro_kg + stromkosten = (druckzeit_stunden * float(data.get('stromverbrauch', 0)) + * float(data.get('strompreis', 0))) + abschreibung = abschreibung_pro_stunde * druckzeit_stunden + arbeitskosten = arbeitszeit_stunden * float(data.get('stundenlohn', 0)) + + kosten = materialkosten + stromkosten + abschreibung + arbeitskosten + gewinn = kosten * (float(data.get('gewinnmarge', 0)) / 100) + verkaufspreis = kosten + gewinn + + # Auf Cent gerundeter Stueckpreis ist die Basis der Zeilensumme, + # damit die Rechnungszeile nachrechenbar bleibt. + stueckzahl = as_stueckzahl(pos.get('stueckzahl', 1)) + stueckpreis = round(verkaufspreis, 2) + zeilensumme = round(stueckpreis * stueckzahl, 2) return { + 'name': str(pos.get('name', '') or ''), 'stueckzahl': stueckzahl, - 'gewinnmarge': float(data.get('gewinnmarge', 0)), - 'steuerregime': regime, - 'mwst_satz': mwst_satz, - 'kleinunternehmer': regime == 'kleinunternehmer', - 'privat': regime == 'privat', - # Steuernummer/USt-IdNr. gehoeren nur auf einen Unternehmer-Beleg. - 'ist_unternehmer': regime != 'privat', - 'gesamt_netto': gesamt_netto, - 'gesamt_mwst': gesamt_mwst, - 'gesamt_endpreis': gesamt_endpreis, 'materialkosten': round(materialkosten, 2), 'stromkosten': round(stromkosten, 2), 'abschreibung': round(abschreibung, 2), 'arbeitskosten': round(arbeitskosten, 2), - 'gesamtkosten_netto': round(gesamtkosten_netto, 2), + 'kosten': round(kosten, 2), 'gewinn': round(gewinn, 2), - 'verkaufspreis': round(verkaufspreis, 2), - 'mwst': round(mwst, 2), - 'mwst_ausweisen': mwst_ausweisen, - 'gesamt_brutto': round(gesamt_brutto, 2), + 'stueckpreis': stueckpreis, + 'zeilensumme': zeilensumme, 'druckzeit_stunden': round(druckzeit_stunden, 2), + 'arbeitszeit_stunden': round(arbeitszeit_stunden, 2), 'material_kg': round(material_kg, 3), - 'abschreibung_pro_stunde': round(abschreibung_pro_stunde, 4), 'kosten_pro_gramm': round(material_preis_pro_kg / 1000, 3), - 'strom_pro_stunde': round(float(data.get('stromverbrauch', 0)) * float(data.get('strompreis', 0)), 3), + } + + +def calculate(data): + """Berechnet alle Positionen und die Auftragssummen. + + Rueckgabe: Dict mit der Positionsliste, den Summen und den + auftragsweiten Detailwerten. + """ + anschaffung = float(data.get('druckerAnschaffung', 0)) + nutzung = float(data.get('druckerNutzungsdauer', 1)) + betrieb = float(data.get('druckerBetriebsstunden', 1)) + abschreibung_pro_stunde = (anschaffung / (nutzung * betrieb) + if (nutzung * betrieb) > 0 else 0) + + positionen = saubere_positionen(data.get('positionen')) + berechnet = [berechne_position(p, data, abschreibung_pro_stunde) + for p in positionen] + + regime, mwst_ausweisen, mwst_satz, mwst_faktor = steuer_kontext(data) + + # Summe der bereits gerundeten Zeilen -- so stimmt die Rechnung + # auch dann, wenn der Kunde sie Zeile fuer Zeile nachrechnet. + netto = round(sum(p['zeilensumme'] for p in berechnet), 2) + mwst = round(netto * mwst_faktor, 2) + brutto = round(netto + mwst, 2) + + return { + 'positionen': berechnet, + 'anzahl_positionen': len(berechnet), + 'teile_gesamt': sum(p['stueckzahl'] for p in berechnet), + 'gesamt_netto': netto, + 'gesamt_mwst': mwst, + 'gesamt_endpreis': brutto, + # Auftragsweite Kostensummen (fuer die Aufstellung im Frontend) + 'summe_materialkosten': round(sum(p['materialkosten'] * p['stueckzahl'] for p in berechnet), 2), + 'summe_stromkosten': round(sum(p['stromkosten'] * p['stueckzahl'] for p in berechnet), 2), + 'summe_abschreibung': round(sum(p['abschreibung'] * p['stueckzahl'] for p in berechnet), 2), + 'summe_arbeitskosten': round(sum(p['arbeitskosten'] * p['stueckzahl'] for p in berechnet), 2), + 'summe_kosten': round(sum(p['kosten'] * p['stueckzahl'] for p in berechnet), 2), + 'summe_gewinn': round(sum(p['gewinn'] * p['stueckzahl'] for p in berechnet), 2), + 'gewinnmarge': float(data.get('gewinnmarge', 0)), + 'steuerregime': regime, + 'mwst_satz': mwst_satz, + 'mwst_ausweisen': mwst_ausweisen, + 'kleinunternehmer': regime == 'kleinunternehmer', + 'privat': regime == 'privat', + # Steuernummer/USt-IdNr. gehoeren nur auf einen Unternehmer-Beleg. + 'ist_unternehmer': regime != 'privat', + 'druckzeit_stunden': round(sum(p['druckzeit_stunden'] * p['stueckzahl'] for p in berechnet), 2), + 'material_kg': round(sum(p['material_kg'] * p['stueckzahl'] for p in berechnet), 3), + 'abschreibung_pro_stunde': round(abschreibung_pro_stunde, 4), + 'strom_pro_stunde': round(float(data.get('stromverbrauch', 0)) + * float(data.get('strompreis', 0)), 3), } @@ -373,6 +539,10 @@ def save_config_route(): # Nur bekannte Schluessel uebernehmen -- sonst kann ein beliebiger # Client die Config mit fremden Feldern vollschreiben. gefiltert = {k: v for k, v in data.items() if k in DEFAULT_VALUES} + # Positionen sind eine Liste von Dicts -- vor dem Speichern + # normalisieren, damit kein Client-Muell in der Config landet. + if 'positionen' in gefiltert: + gefiltert['positionen'] = saubere_positionen(gefiltert['positionen']) with _config_lock: existing = load_config() existing.update(gefiltert) @@ -383,6 +553,95 @@ def save_config_route(): return jsonify({'status': 'ok', 'gespeichert': len(gefiltert)}) +@app.route('/projekte') +def projekte_liste(): + """Liefert die gespeicherten Projekte als JSON, neueste zuerst.""" + projekte = lade_projekte() + config = load_config() + eintraege = [] + for name, daten in projekte.items(): + if not isinstance(daten, dict): + continue + positionen = saubere_positionen(daten.get('positionen')) + # Preis mit den AKTUELLEN Betriebswerten rechnen, nicht mit den + # Werten von damals -- die Marge liegt bewusst nicht im Projekt. + calc = calculate({**config, 'positionen': positionen}) + eintraege.append({ + 'name': name, + 'geaendert': daten.get('geaendert', ''), + 'kundenName': daten.get('kundenName', ''), + 'anzahl_positionen': calc['anzahl_positionen'], + 'teile_gesamt': calc['teile_gesamt'], + 'gesamt_endpreis': calc['gesamt_endpreis'], + }) + eintraege.sort(key=lambda e: e['geaendert'], reverse=True) + return jsonify({'projekte': eintraege, 'aktuell': config.get('projektName', '')}) + + +@app.route('/projekte/speichern', methods=['POST']) +def projekt_speichern(): + """Sichert den aktuellen Arbeitsstand unter seinem Projektnamen.""" + daten = request.get_json(silent=True) or {} + with _config_lock: + config = load_config() + name = projekt_name(daten.get('name') or config.get('projektName')) + if not name: + return jsonify({'error': 'Bitte zuerst einen Projektnamen vergeben'}), 400 + + with _projekte_lock: + projekte = lade_projekte() + neu_angelegt = name not in projekte + if neu_angelegt and len(projekte) >= MAX_PROJEKTE: + return jsonify({'error': f'Maximal {MAX_PROJEKTE} Projekte'}), 400 + eintrag = projekt_aus_config(config) + eintrag['projektName'] = name + eintrag['geaendert'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + projekte[name] = eintrag + speichere_projekte(projekte) + + # Der Arbeitsstand traegt danach denselben Namen wie das Projekt. + with _config_lock: + config = load_config() + config['projektName'] = name + save_config(_migriere(config)) + return jsonify({'status': 'ok', 'name': name, 'neu': neu_angelegt}) + + +@app.route('/projekte/laden', methods=['POST']) +def projekt_laden(): + """Laedt ein gespeichertes Projekt in den Arbeitsstand.""" + daten = request.get_json(silent=True) or {} + name = projekt_name(daten.get('name')) + with _projekte_lock: + projekte = lade_projekte() + eintrag = projekte.get(name) + if not isinstance(eintrag, dict): + return jsonify({'error': f'Projekt "{name}" nicht gefunden'}), 404 + + with _config_lock: + config = load_config() + for feld in PROJEKT_FELDER: + if feld in eintrag: + config[feld] = eintrag[feld] + config['projektName'] = name + save_config(_migriere(config)) + return jsonify({'status': 'ok', 'name': name}) + + +@app.route('/projekte/loeschen', methods=['POST']) +def projekt_loeschen(): + """Entfernt ein gespeichertes Projekt. Der Arbeitsstand bleibt.""" + daten = request.get_json(silent=True) or {} + name = projekt_name(daten.get('name')) + with _projekte_lock: + projekte = lade_projekte() + if name not in projekte: + return jsonify({'error': f'Projekt "{name}" nicht gefunden'}), 404 + del projekte[name] + speichere_projekte(projekte) + return jsonify({'status': 'ok', 'name': name}) + + @app.route('/upload_logo', methods=['POST']) def upload_logo(): """Logo-Datei hochladen, auf gueltiges Bild pruefen und speichern.""" @@ -454,7 +713,6 @@ def invoice_pdf(): data = request.form config = load_config() calc = calculate(config) - stueckzahl = calc['stueckzahl'] gesamt_netto = calc['gesamt_netto'] gesamt_mwst = calc['gesamt_mwst'] gesamt_brutto = calc['gesamt_endpreis'] @@ -593,12 +851,18 @@ def invoice_pdf(): pdf.set_draw_color(0, 0, 0) pdf.set_font("Helvetica", "", 10) - pdf.set_x(left) - pos_label = f"3D-Druck: {projekt}" if projekt else "3D-Druck" - pdf.cell(85, 7, pos_label) - pdf.cell(20, 7, str(stueckzahl), align='R') - pdf.cell(30, 7, f"{calc['verkaufspreis']:.2f} EUR", align='R') - pdf.cell(30, 7, f"{gesamt_netto:.2f} EUR", new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='R') + for nummer, pos in enumerate(calc['positionen'], start=1): + # Seitenumbruch, bevor die Zeile in den Fussbereich laufen wuerde. + if pdf.get_y() > 250: + pdf.add_page() + pdf.set_y(20) + pdf.set_x(left) + pdf.cell(85, 7, pdf_text(positions_label(pos, nummer, projekt, + len(calc['positionen'])))) + pdf.cell(20, 7, str(pos['stueckzahl']), align='R') + pdf.cell(30, 7, f"{pos['stueckpreis']:.2f} EUR", align='R') + pdf.cell(30, 7, f"{pos['zeilensumme']:.2f} EUR", + new_x=XPos.LMARGIN, new_y=YPos.NEXT, align='R') pdf.line(left, pdf.get_y() + 0.5, 195, pdf.get_y() + 0.5) @@ -642,35 +906,51 @@ def invoice_pdf(): @app.route('/export', methods=['POST']) def export(): """Exportiert die Kalkulation als TXT-Datei.""" - data = request.get_json() + data = request.get_json(silent=True) or {} config = load_config() merged = {**config, **data} calc = calculate(merged) lines = [ "3D-DRUCK KOSTENKALKULATION", - "=" * 50 + "\n", - f"Projekt: {data.get('projektName', '')}", - f"Datum: {data.get('datum', '')}\n", - "KOSTENAUFSTELLUNG:", - f"Materialkosten: {calc['materialkosten']:.2f} EUR", - f"Stromkosten: {calc['stromkosten']:.2f} EUR", - f"Abschreibung: {calc['abschreibung']:.2f} EUR", - f"Arbeitskosten: {calc['arbeitskosten']:.2f} EUR", - f"Gesamtkosten: {calc['gesamtkosten_netto']:.2f} EUR", - f"Gewinn: {calc['gewinn']:.2f} EUR", - f"Verkaufspreis (netto): {calc['verkaufspreis']:.2f} EUR", + "=" * 58 + "", + "", + f"Projekt: {merged.get('projektName', '')}", + f"Datum: {merged.get('datum', '')}", + "", ] + + for nummer, pos in enumerate(calc['positionen'], start=1): + label = positions_label(pos, nummer, merged.get('projektName', ''), + calc['anzahl_positionen']) + lines.append(f"POSITION {nummer}: {label}") + lines.append("-" * 58) + lines.append(f" Materialkosten: {pos['materialkosten']:>10.2f} EUR") + lines.append(f" Stromkosten: {pos['stromkosten']:>10.2f} EUR") + lines.append(f" Abschreibung: {pos['abschreibung']:>10.2f} EUR") + lines.append(f" Arbeitskosten: {pos['arbeitskosten']:>10.2f} EUR") + lines.append(f" Kosten je Stuck: {pos['kosten']:>10.2f} EUR") + lines.append(f" Gewinn je Stuck: {pos['gewinn']:>10.2f} EUR") + lines.append(f" Stueckpreis (netto): {pos['stueckpreis']:>10.2f} EUR") + lines.append(f" Stueckzahl: {pos['stueckzahl']:>10}") + lines.append(f" Zeilensumme: {pos['zeilensumme']:>10.2f} EUR") + lines.append("") + satz = mwst_satz_label(calc['mwst_satz']) + lines.append("=" * 58) + lines.append(f"AUFTRAG ({calc['anzahl_positionen']} Positionen, " + f"{calc['teile_gesamt']} Teile)") + lines.append(f" Materialkosten gesamt: {calc['summe_materialkosten']:>10.2f} EUR") + lines.append(f" Stromkosten gesamt: {calc['summe_stromkosten']:>10.2f} EUR") + lines.append(f" Abschreibung gesamt: {calc['summe_abschreibung']:>10.2f} EUR") + lines.append(f" Arbeitskosten gesamt: {calc['summe_arbeitskosten']:>10.2f} EUR") + lines.append(f" Kosten gesamt: {calc['summe_kosten']:>10.2f} EUR") + lines.append(f" Gewinn gesamt: {calc['summe_gewinn']:>10.2f} EUR") + lines.append("-" * 58) + lines.append(f" Auftrag netto: {calc['gesamt_netto']:>10.2f} EUR") if calc.get('mwst_ausweisen'): - lines.append(f"MwSt. ({satz}%):{'':<{max(0, 16 - len(satz))}}{calc['mwst']:.2f} EUR") - lines.append(f"Preis pro Stuck: {calc['gesamt_brutto']:.2f} EUR") - lines.append("") - lines.append(f"Stuckzahl: {calc['stueckzahl']}") - lines.append(f"Auftrag netto: {calc['gesamt_netto']:.2f} EUR") - if calc.get('mwst_ausweisen'): - lines.append(f"MwSt. ({satz}%) gesamt: {calc['gesamt_mwst']:.2f} EUR") - lines.append(f"GESAMTPREIS: {calc['gesamt_endpreis']:.2f} EUR") + lines.append(f" MwSt. ({satz}%):{'':<{max(1, 16 - len(satz))}}{calc['gesamt_mwst']:>10.2f} EUR") + lines.append(f" GESAMTPREIS: {calc['gesamt_endpreis']:>10.2f} EUR") if calc.get('kleinunternehmer'): lines.append("") lines.append(KLEINUNTERNEHMER_HINWEIS) diff --git a/static/script.js b/static/script.js index e5b3e51..a48032c 100644 --- a/static/script.js +++ b/static/script.js @@ -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(); }); diff --git a/templates/index.html b/templates/index.html index 39df1a7..fede732 100644 --- a/templates/index.html +++ b/templates/index.html @@ -44,46 +44,38 @@
-
Projektinformationen
+
Projekt
-
- - + +
+ +
-
- - +
Übergreifender Name des Auftrags. Die einzelnen Teile werden unten als Positionen erfasst.
+ +
+ + +
+ + +
+
Noch keine Projekte gespeichert.
-
-
Material
-
-
- - -
-
- - -
-
-
+
-
-
Druckparameter
-
-
- - -
-
- - -
-
-
+
@@ -123,6 +115,47 @@
+ + + diff --git a/templates/invoice.html b/templates/invoice.html index 5c163d5..03b19e1 100644 --- a/templates/invoice.html +++ b/templates/invoice.html @@ -74,22 +74,17 @@
Vorschau
- {% set stueckzahl = calc.stueckzahl %} - {% set gesamt_netto = calc.gesamt_netto %} - {% set gesamt_mwst = calc.gesamt_mwst %} - {% set gesamt_brutto = calc.gesamt_endpreis %}
LEISTUNGSBESCHREIBUNG: -{{ '\u2500' * 50 }} -Position: 3D-Druck - {{ config.projektName }} -Menge: {{ stueckzahl }} -Einzelpreis: {{ "%.2f"|format(calc.verkaufspreis) }} EUR -Gesamt: {{ "%.2f"|format(gesamt_netto) }} EUR - -{{ '\u2500' * 50 }} -Nettobetrag: {{ "%.2f"|format(gesamt_netto) }} EUR -{% if calc.mwst_ausweisen %}MwSt. ({{ '%g'|format(calc.mwst_satz) }}%): {{ "%.2f"|format(gesamt_mwst) }} EUR -{% endif %}{{ '\u2550' * 50 }} -GESAMTBETRAG{% if calc.mwst_ausweisen %} (brutto){% endif %}: {{ "%.2f"|format(gesamt_brutto) }} EUR +{{ '\u2500' * 58 }} +{% for pos in calc.positionen -%} +{{ loop.index }}. {{ pos.name if pos.name else (config.projektName if calc.anzahl_positionen == 1 else 'Position ' ~ loop.index) }} + {{ pos.stueckzahl }} x {{ "%.2f"|format(pos.stueckpreis) }} EUR = {{ "%.2f"|format(pos.zeilensumme) }} EUR +{% endfor %} +{{ '\u2500' * 58 }} +Nettobetrag: {{ "%.2f"|format(calc.gesamt_netto) }} EUR +{% if calc.mwst_ausweisen %}MwSt. ({{ '%g'|format(calc.mwst_satz) }}%): {{ "%.2f"|format(calc.gesamt_mwst) }} EUR +{% endif %}{{ '\u2550' * 58 }} +GESAMTBETRAG{% if calc.mwst_ausweisen %} (brutto){% endif %}: {{ "%.2f"|format(calc.gesamt_endpreis) }} EUR {% if calc.kleinunternehmer %} {{ kleinunternehmer_hinweis }} {% elif calc.privat %}