From 73631b415db3662f0b76e60cb5357d2c5653bb5d Mon Sep 17 00:00:00 2001 From: Erik Thiele Date: Tue, 30 Jun 2026 20:46:52 +0200 Subject: [PATCH] Flask-Web-App statt Tkinter: Live-Berechnung, PDF-Rechnung, Dark Mode, Tabler CSS --- .gitignore | 5 + 3dDruckKalkulator.py | 645 +++++++++++++++++++++++++++++++++++++++++ AGENTS.md | 51 ++++ DESIGN.md | 333 +++++++++++++++++++++ app.py | 237 +++++++++++++++ static/script.js | 142 +++++++++ static/style.css | 13 + templates/index.html | 166 +++++++++++ templates/invoice.html | 98 +++++++ 9 files changed, 1690 insertions(+) create mode 100644 .gitignore create mode 100644 3dDruckKalkulator.py create mode 100644 AGENTS.md create mode 100644 DESIGN.md create mode 100644 app.py create mode 100644 static/script.js create mode 100644 static/style.css create mode 100644 templates/index.html create mode 100644 templates/invoice.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..61466cf --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.venv/ +__pycache__/ +*.pyc +.DS_Store +druckkalkulation_config.yaml diff --git a/3dDruckKalkulator.py b/3dDruckKalkulator.py new file mode 100644 index 0000000..a7cd27d --- /dev/null +++ b/3dDruckKalkulator.py @@ -0,0 +1,645 @@ +import tkinter as tk +from tkinter import ttk, messagebox, filedialog +from datetime import datetime +from fpdf import FPDF +import os +import yaml + +class DruckKalkulation: + def __init__(self, root): + self.root = root + self.root.title("3D-Druck Kostenkalkulation") + self.root.geometry("1200x800") + + # Konfigurationsdatei + self.config_file = 'druckkalkulation_config.yaml' + + # Dark Mode Status + self.dark_mode = tk.BooleanVar(value=False) + + # Theme-Farben + self.themes = { + 'light': { + 'bg': '#f0f4f8', + 'fg': '#1f2937', + 'card_bg': 'white', + 'card_fg': '#1f2937', + 'section_bg': { + 'projekt': '#e0e7ff', + 'material': '#dbeafe', + 'druck': '#d1fae5', + 'abschreibung': '#e9d5ff', + 'arbeit': '#fed7aa', + 'gewinn': '#fef3c7' + }, + 'result_bg': '#4f46e5', + 'result_fg': 'white', + 'result_text_bg': '#6366f1', + 'detail_bg': '#e5e7eb', + 'detail_text_bg': '#f3f4f6', + 'entry_bg': 'white', + 'entry_fg': '#1f2937' + }, + 'dark': { + 'bg': '#111827', + 'fg': '#f9fafb', + 'card_bg': '#1f2937', + 'card_fg': '#f9fafb', + 'section_bg': { + 'projekt': '#312e81', + 'material': '#1e3a8a', + 'druck': '#064e3b', + 'abschreibung': '#581c87', + 'arbeit': '#92400e', + 'gewinn': '#713f12' + }, + 'result_bg': '#6366f1', + 'result_fg': 'white', + 'result_text_bg': '#4f46e5', + 'detail_bg': '#374151', + 'detail_text_bg': '#1f2937', + 'entry_bg': '#374151', + 'entry_fg': '#f9fafb' + } + } + + self.root.configure(bg=self.themes['light']['bg']) + + # Variablen + self.vars = { + 'projektName': tk.StringVar(), + 'kundenName': tk.StringVar(), + 'kundenAdresse': tk.StringVar(value=''), + 'materialGewicht': tk.DoubleVar(value=0), + 'materialPreisProKg': tk.DoubleVar(value=25), + 'druckzeit': tk.DoubleVar(value=0), + 'stromverbrauch': tk.DoubleVar(value=0.15), + 'strompreis': tk.DoubleVar(value=0.35), + 'druckerAnschaffung': tk.DoubleVar(value=500), + 'druckerNutzungsdauer': tk.DoubleVar(value=3), + 'druckerBetriebsstunden': tk.DoubleVar(value=2000), + 'arbeitszeit': tk.DoubleVar(value=0), + 'stundenlohn': tk.DoubleVar(value=25), + 'gewinnmarge': tk.DoubleVar(value=20), + 'rechnungsnummer': tk.StringVar(), + 'datum': tk.StringVar(value=datetime.now().strftime('%Y-%m-%d')) + } + + # Konfiguration laden + self.load_config() + + self.create_widgets() + self.update_calculation() + + # Automatisches Speichern bei Änderungen + for var in self.vars.values(): + var.trace_add('write', lambda *args: self.save_config()) + + # Dark Mode Status speichern/laden + self.dark_mode.trace_add('write', lambda *args: self.save_config()) + + def get_current_theme(self): + """Gibt das aktuelle Theme zurück""" + return self.themes['dark'] if self.dark_mode.get() else self.themes['light'] + + def toggle_dark_mode(self): + """Wechselt zwischen Light und Dark Mode""" + self.dark_mode.set(not self.dark_mode.get()) + + # Button Text aktualisieren + if hasattr(self, 'dark_mode_btn'): + if self.dark_mode.get(): + self.dark_mode_btn.config(text="☀️ Light Mode", bg='#1f2937', fg='white') + else: + self.dark_mode_btn.config(text="🌙 Dark Mode", bg='#4f46e5', fg='white') + + self.apply_theme() + + def apply_theme(self): + """Wendet das aktuelle Theme auf alle Widgets an""" + theme = self.get_current_theme() + + # Root + self.root.configure(bg=theme['bg']) + + # Alle Frames durchgehen und Theme anwenden + for widget in self.root.winfo_children(): + self.apply_theme_recursive(widget, theme) + + self.update_calculation() + + def apply_theme_recursive(self, widget, theme): + """Wendet Theme rekursiv auf alle Widgets an""" + widget_type = widget.winfo_class() + + try: + if widget_type in ['Frame', 'Labelframe']: + current_bg = widget.cget('bg') + # Bestimme welche Farbe das Widget haben sollte + if current_bg == 'white' or current_bg.startswith('#f') or current_bg.startswith('#e') or current_bg.startswith('#d'): + if hasattr(self, 'title_frame') and widget == self.title_frame: + widget.configure(bg=theme['card_bg']) + elif current_bg in ['#e0e7ff', '#312e81']: + widget.configure(bg=theme['section_bg']['projekt']) + elif current_bg in ['#dbeafe', '#1e3a8a']: + widget.configure(bg=theme['section_bg']['material']) + elif current_bg in ['#d1fae5', '#064e3b']: + widget.configure(bg=theme['section_bg']['druck']) + elif current_bg in ['#e9d5ff', '#581c87']: + widget.configure(bg=theme['section_bg']['abschreibung']) + elif current_bg in ['#fed7aa', '#92400e']: + widget.configure(bg=theme['section_bg']['arbeit']) + elif current_bg in ['#fef3c7', '#713f12']: + widget.configure(bg=theme['section_bg']['gewinn']) + elif current_bg in ['#e5e7eb', '#374151']: + widget.configure(bg=theme['detail_bg']) + else: + widget.configure(bg=theme['bg']) + elif current_bg in ['#4f46e5', '#6366f1']: + widget.configure(bg=theme['result_bg']) + elif current_bg in ['#111827', '#f0f4f8']: + widget.configure(bg=theme['bg']) + + elif widget_type == 'Label': + current_bg = widget.cget('bg') + if current_bg in ['#4f46e5', '#6366f1']: + widget.configure(bg=theme['result_bg'], fg=theme['result_fg']) + elif current_bg == 'white' or current_bg.startswith('#f') and widget != self.title_frame: + widget.configure(bg=theme['card_bg'], fg=theme['card_fg']) + elif current_bg in ['#e0e7ff', '#312e81']: + widget.configure(bg=theme['section_bg']['projekt'], fg=theme['fg']) + elif current_bg in ['#dbeafe', '#1e3a8a']: + widget.configure(bg=theme['section_bg']['material'], fg=theme['fg']) + elif current_bg in ['#d1fae5', '#064e3b']: + widget.configure(bg=theme['section_bg']['druck'], fg=theme['fg']) + elif current_bg in ['#e9d5ff', '#581c87']: + widget.configure(bg=theme['section_bg']['abschreibung'], fg=theme['fg']) + elif current_bg in ['#fed7aa', '#92400e']: + widget.configure(bg=theme['section_bg']['arbeit'], fg=theme['fg']) + elif current_bg in ['#fef3c7', '#713f12']: + widget.configure(bg=theme['section_bg']['gewinn'], fg=theme['fg']) + elif current_bg in ['#e5e7eb', '#374151']: + widget.configure(bg=theme['detail_bg'], fg=theme['fg']) + else: + widget.configure(bg=theme['bg'], fg=theme['fg']) + + elif widget_type == 'Entry': + widget.configure(bg=theme['entry_bg'], fg=theme['entry_fg'], + insertbackground=theme['entry_fg']) + + elif widget_type == 'Text': + if hasattr(self, 'result_text') and widget == self.result_text: + widget.configure(bg=theme['result_text_bg'], fg=theme['result_fg']) + elif hasattr(self, 'detail_text') and widget == self.detail_text: + widget.configure(bg=theme['detail_text_bg'], fg=theme['fg']) + else: + widget.configure(bg=theme['entry_bg'], fg=theme['entry_fg']) + + elif widget_type == 'Canvas': + widget.configure(bg=theme['bg']) + + except tk.TclError: + pass + + # Rekursiv für alle Kinder + for child in widget.winfo_children(): + self.apply_theme_recursive(child, theme) + + def create_widgets(self): + # Hauptcontainer + main_frame = tk.Frame(self.root, bg=self.get_current_theme()['bg']) + main_frame.pack(fill=tk.BOTH, expand=True, padx=20, pady=20) + + # Titel mit Dark Mode Toggle + self.title_frame = tk.Frame(main_frame, bg=self.get_current_theme()['card_bg'], relief=tk.RAISED, bd=2) + self.title_frame.pack(fill=tk.X, pady=(0, 20)) + + title_content = tk.Frame(self.title_frame, bg=self.get_current_theme()['card_bg']) + title_content.pack(fill=tk.X, padx=15, pady=15) + + tk.Label(title_content, text="🖨️ 3D-Druck Kostenkalkulation", + font=('Arial', 24, 'bold'), bg=self.get_current_theme()['card_bg'], + fg='#4f46e5').pack(side=tk.LEFT) + + # Dark Mode Toggle Button + dark_mode_frame = tk.Frame(title_content, bg=self.get_current_theme()['card_bg']) + dark_mode_frame.pack(side=tk.RIGHT, padx=10) + + self.dark_mode_btn = tk.Button( + dark_mode_frame, + text="☀️ Light Mode" if self.dark_mode.get() else "🌙 Dark Mode", + command=self.toggle_dark_mode, + font=('Arial', 11, 'bold'), + bg='#4f46e5', + fg='white', + padx=15, + pady=8, + relief=tk.RAISED, + cursor='hand2', + borderwidth=2 + ) + self.dark_mode_btn.pack() + + # Container für linke und rechte Spalte + content_frame = tk.Frame(main_frame, bg=self.get_current_theme()['bg']) + content_frame.pack(fill=tk.BOTH, expand=True) + + # Linke Spalte - Eingaben + left_frame = tk.Frame(content_frame, bg=self.get_current_theme()['bg']) + left_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 10)) + + # Scrollbarer Bereich für Eingaben + canvas = tk.Canvas(left_frame, bg=self.get_current_theme()['bg'], highlightthickness=0) + scrollbar = ttk.Scrollbar(left_frame, orient="vertical", command=canvas.yview) + scrollable_frame = tk.Frame(canvas, bg=self.get_current_theme()['bg']) + + scrollable_frame.bind( + "", + lambda e: canvas.configure(scrollregion=canvas.bbox("all")) + ) + + canvas.create_window((0, 0), window=scrollable_frame, anchor="nw") + canvas.configure(yscrollcommand=scrollbar.set) + + canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + # Projektinformationen + self.create_section(scrollable_frame, "Projektinformationen", 'projekt', [ + ('Projektname', 'projektName', 'entry') + ]) + + # Material + self.create_section(scrollable_frame, "Material", 'material', [ + ('Gewicht (Gramm)', 'materialGewicht', 'number'), + ('Preis pro kg (€)', 'materialPreisProKg', 'number') + ]) + + # Druckparameter + self.create_section(scrollable_frame, "Druckparameter", 'druck', [ + ('Druckzeit (Minuten)', 'druckzeit', 'number'), + ('Stromverbrauch (kW)', 'stromverbrauch', 'number'), + ('Strompreis (€/kWh)', 'strompreis', 'number') + ]) + + # Drucker Abschreibung + self.create_section(scrollable_frame, "Drucker Abschreibung", 'abschreibung', [ + ('Anschaffungspreis (€)', 'druckerAnschaffung', 'number'), + ('Nutzungsdauer (Jahre)', 'druckerNutzungsdauer', 'number'), + ('Betriebsstunden pro Jahr', 'druckerBetriebsstunden', 'number') + ]) + + # Arbeitskosten + self.create_section(scrollable_frame, "Arbeitskosten", 'arbeit', [ + ('Arbeitszeit (Minuten)', 'arbeitszeit', 'number'), + ('Stundenlohn (€)', 'stundenlohn', 'number') + ]) + + # Gewinnmarge + self.create_section(scrollable_frame, "Gewinnmarge", 'gewinn', [ + ('Gewinnmarge (%)', 'gewinnmarge', 'number') + ]) + + # Rechte Spalte - Ergebnisse + right_frame = tk.Frame(content_frame, bg=self.get_current_theme()['bg']) + right_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=(10, 0)) + + # Ergebnisbereich + self.result_frame = tk.Frame(right_frame, bg=self.get_current_theme()['result_bg'], relief=tk.RAISED, bd=3) + self.result_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) + + tk.Label(self.result_frame, text="Kostenaufstellung", + font=('Arial', 18, 'bold'), bg=self.get_current_theme()['result_bg'], + fg=self.get_current_theme()['result_fg']).pack(pady=15) + + self.result_text = tk.Text(self.result_frame, height=20, width=40, + font=('Courier', 11), bg=self.get_current_theme()['result_text_bg'], + fg=self.get_current_theme()['result_fg'], + relief=tk.FLAT, padx=15, pady=10) + self.result_text.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 10)) + + # Detaillierte Informationen + detail_frame = tk.Frame(right_frame, bg=self.get_current_theme()['detail_bg'], relief=tk.RAISED, bd=2) + detail_frame.pack(fill=tk.X, pady=(0, 10)) + + tk.Label(detail_frame, text="Detaillierte Informationen", + font=('Arial', 12, 'bold'), bg=self.get_current_theme()['detail_bg'], + fg=self.get_current_theme()['fg']).pack(pady=10) + + self.detail_text = tk.Text(detail_frame, height=5, width=40, + font=('Arial', 10), bg=self.get_current_theme()['detail_text_bg'], + fg=self.get_current_theme()['fg'], + relief=tk.FLAT, padx=10, pady=5) + self.detail_text.pack(fill=tk.X, padx=10, pady=(0, 10)) + + # Buttons + button_frame = tk.Frame(right_frame, bg='#f0f4f8') + button_frame.pack(fill=tk.X) + + tk.Button(button_frame, text="📄 Rechnung erstellen", + command=self.create_invoice, bg='#4f46e5', fg='white', + font=('Arial', 12, 'bold'), padx=20, pady=10, + cursor='hand2').pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 5)) + + tk.Button(button_frame, text="💾 Export", + command=self.export_data, bg='#10b981', fg='white', + font=('Arial', 12, 'bold'), padx=20, pady=10, + cursor='hand2').pack(side=tk.RIGHT, fill=tk.X, expand=True, padx=(5, 0)) + + def save_config(self): + """Speichert alle Parameter in YAML-Datei""" + try: + config_data = {} + for key, var in self.vars.items(): + value = var.get() + config_data[key] = value + + # Dark Mode Status speichern + config_data['dark_mode'] = self.dark_mode.get() + + with open(self.config_file, 'w', encoding='utf-8') as f: + yaml.dump(config_data, f, default_flow_style=False, allow_unicode=True) + except Exception as e: + print(f"Fehler beim Speichern der Konfiguration: {e}") + + def load_config(self): + """Lädt alle Parameter aus YAML-Datei""" + try: + if os.path.exists(self.config_file): + with open(self.config_file, 'r', encoding='utf-8') as f: + config_data = yaml.safe_load(f) + + if config_data: + for key, value in config_data.items(): + if key == 'dark_mode': + self.dark_mode.set(value) + elif key in self.vars: + self.vars[key].set(value) + print(f"✓ Konfiguration geladen aus: {self.config_file}") + else: + print(f"ℹ Keine Konfigurationsdatei gefunden. Standard-Werte werden verwendet.") + except Exception as e: + print(f"Fehler beim Laden der Konfiguration: {e}") + messagebox.showwarning("Warnung", + f"Konfiguration konnte nicht geladen werden.\nStandard-Werte werden verwendet.") + + def create_section(self, parent, title, theme_key, fields): + theme = self.get_current_theme() + bg_color = theme['section_bg'][theme_key] + + frame = tk.Frame(parent, bg=bg_color, relief=tk.RAISED, bd=2) + frame.pack(fill=tk.X, pady=(0, 10)) + + tk.Label(frame, text=title, font=('Arial', 12, 'bold'), + bg=bg_color, fg=theme['fg']).pack(anchor='w', padx=10, pady=(10, 5)) + + for label, var_name, field_type in fields: + field_frame = tk.Frame(frame, bg=bg_color) + field_frame.pack(fill=tk.X, padx=10, pady=5) + + tk.Label(field_frame, text=label, bg=bg_color, fg=theme['fg'], + font=('Arial', 10)).pack(anchor='w') + + if field_type == 'entry': + entry = tk.Entry(field_frame, textvariable=self.vars[var_name], + font=('Arial', 10), bg=theme['entry_bg'], + fg=theme['entry_fg'], insertbackground=theme['entry_fg']) + else: + entry = tk.Entry(field_frame, textvariable=self.vars[var_name], + font=('Arial', 10), bg=theme['entry_bg'], + fg=theme['entry_fg'], insertbackground=theme['entry_fg']) + + entry.pack(fill=tk.X, pady=(2, 0)) + entry.bind('', lambda e: self.update_calculation()) + + def get_calculations(self): + try: + # Umrechnung + druckzeit_stunden = self.vars['druckzeit'].get() / 60 + arbeitszeit_stunden = self.vars['arbeitszeit'].get() / 60 + material_kg = self.vars['materialGewicht'].get() / 1000 + + # Berechnungen + materialkosten = material_kg * self.vars['materialPreisProKg'].get() + stromkosten = druckzeit_stunden * self.vars['stromverbrauch'].get() * self.vars['strompreis'].get() + + abschreibung_pro_stunde = self.vars['druckerAnschaffung'].get() / ( + self.vars['druckerNutzungsdauer'].get() * self.vars['druckerBetriebsstunden'].get() + ) + abschreibung = abschreibung_pro_stunde * druckzeit_stunden + + arbeitskosten = arbeitszeit_stunden * self.vars['stundenlohn'].get() + + gesamtkosten_netto = materialkosten + stromkosten + abschreibung + arbeitskosten + gewinn = gesamtkosten_netto * (self.vars['gewinnmarge'].get() / 100) + verkaufspreis = gesamtkosten_netto + gewinn + mwst = verkaufspreis * 0.19 + gesamt_brutto = verkaufspreis + mwst + + return { + 'materialkosten': materialkosten, + 'stromkosten': stromkosten, + 'abschreibung': abschreibung, + 'arbeitskosten': arbeitskosten, + 'gesamtkosten_netto': gesamtkosten_netto, + 'gewinn': gewinn, + 'verkaufspreis': verkaufspreis, + 'mwst': mwst, + 'gesamt_brutto': gesamt_brutto, + 'druckzeit_stunden': druckzeit_stunden, + 'material_kg': material_kg, + 'abschreibung_pro_stunde': abschreibung_pro_stunde + } + except: + return None + + def update_calculation(self): + calc = self.get_calculations() + if not calc: + return + + # Ergebnis-Text + self.result_text.delete(1.0, tk.END) + result_str = f""" +Materialkosten: {calc['materialkosten']:>10.2f} € +Stromkosten: {calc['stromkosten']:>10.2f} € +Abschreibung Drucker: {calc['abschreibung']:>10.2f} € +Arbeitskosten: {calc['arbeitskosten']:>10.2f} € +{'─' * 40} +Gesamtkosten: {calc['gesamtkosten_netto']:>10.2f} € + +Gewinn ({self.vars['gewinnmarge'].get():.0f}%): {calc['gewinn']:>10.2f} € +{'─' * 40} +Verkaufspreis (netto): {calc['verkaufspreis']:>10.2f} € +MwSt. (19%): {calc['mwst']:>10.2f} € +{'═' * 40} +GESAMT (brutto): {calc['gesamt_brutto']:>10.2f} € +""" + self.result_text.insert(1.0, result_str) + + # Detail-Text + self.detail_text.delete(1.0, tk.END) + detail_str = f"""Druckzeit: {calc['druckzeit_stunden']:.2f} Stunden +Materialgewicht: {calc['material_kg']:.3f} kg +Abschreibung pro Stunde: {calc['abschreibung_pro_stunde']:.4f} € +Kosten pro Gramm: {self.vars['materialPreisProKg'].get()/1000:.3f} € +Stromkosten pro Stunde: {self.vars['stromverbrauch'].get() * self.vars['strompreis'].get():.3f} €""" + self.detail_text.insert(1.0, detail_str) + + def export_data(self): + calc = self.get_calculations() + if not calc: + messagebox.showerror("Fehler", "Bitte füllen Sie alle Felder aus") + return + + filename = filedialog.asksaveasfilename( + defaultextension=".txt", + filetypes=[("Text Datei", "*.txt"), ("Alle Dateien", "*.*")] + ) + + if filename: + with open(filename, 'w', encoding='utf-8') as f: + f.write("3D-DRUCK KOSTENKALKULATION\n") + f.write("=" * 50 + "\n\n") + f.write(f"Projekt: {self.vars['projektName'].get()}\n") + f.write(f"Datum: {self.vars['datum'].get()}\n\n") + f.write("KOSTENAUFSTELLUNG:\n") + f.write(f"Materialkosten: {calc['materialkosten']:.2f} €\n") + f.write(f"Stromkosten: {calc['stromkosten']:.2f} €\n") + f.write(f"Abschreibung: {calc['abschreibung']:.2f} €\n") + f.write(f"Arbeitskosten: {calc['arbeitskosten']:.2f} €\n") + f.write(f"Gesamtkosten: {calc['gesamtkosten_netto']:.2f} €\n") + f.write(f"Gewinn: {calc['gewinn']:.2f} €\n") + f.write(f"Verkaufspreis (netto): {calc['verkaufspreis']:.2f} €\n") + f.write(f"MwSt. (19%): {calc['mwst']:.2f} €\n") + f.write(f"GESAMTPREIS (brutto): {calc['gesamt_brutto']:.2f} €\n") + + messagebox.showinfo("Erfolg", "Daten erfolgreich exportiert!") + + def create_invoice(self): + # Rechnungsfenster + invoice_window = tk.Toplevel(self.root) + invoice_window.title("Rechnung erstellen") + invoice_window.geometry("600x700") + invoice_window.configure(bg='white') + + frame = tk.Frame(invoice_window, bg='white', padx=20, pady=20) + frame.pack(fill=tk.BOTH, expand=True) + + tk.Label(frame, text="RECHNUNG", font=('Arial', 24, 'bold'), + bg='white').pack(pady=(0, 20)) + + # Kundendaten + tk.Label(frame, text="Rechnungsnummer:", bg='white', + font=('Arial', 10)).pack(anchor='w') + tk.Entry(frame, textvariable=self.vars['rechnungsnummer'], + font=('Arial', 10)).pack(fill=tk.X, pady=(0, 10)) + + tk.Label(frame, text="Datum:", bg='white', font=('Arial', 10)).pack(anchor='w') + tk.Entry(frame, textvariable=self.vars['datum'], + font=('Arial', 10)).pack(fill=tk.X, pady=(0, 10)) + + tk.Label(frame, text="Kundenname:", bg='white', font=('Arial', 10)).pack(anchor='w') + tk.Entry(frame, textvariable=self.vars['kundenName'], + font=('Arial', 10)).pack(fill=tk.X, pady=(0, 10)) + + tk.Label(frame, text="Kundenadresse:", bg='white', font=('Arial', 10)).pack(anchor='w') + address_text = tk.Text(frame, height=3, font=('Arial', 10)) + address_text.pack(fill=tk.X, pady=(0, 20)) + address_text.insert(1.0, self.vars['kundenAdresse'].get()) + + calc = self.get_calculations() + if calc: + # Rechnungsvorschau + preview = tk.Text(frame, height=15, font=('Courier', 10), bg='#f9fafb') + preview.pack(fill=tk.BOTH, expand=True, pady=(0, 20)) + + preview_text = f""" +LEISTUNGSBESCHREIBUNG: +{'─' * 50} +Position: 3D-Druck - {self.vars['projektName'].get()} +Menge: 1 +Einzelpreis: {calc['verkaufspreis']:.2f} € +Gesamt: {calc['verkaufspreis']:.2f} € + +{'─' * 50} +Nettobetrag: {calc['verkaufspreis']:.2f} € +MwSt. (19%): {calc['mwst']:.2f} € +{'═' * 50} +GESAMTBETRAG: {calc['gesamt_brutto']:.2f} € + +Zahlbar innerhalb von 14 Tagen ohne Abzug. +Vielen Dank für Ihren Auftrag! +""" + preview.insert(1.0, preview_text) + preview.config(state='disabled') + + tk.Button(frame, text="💾 Rechnung als PDF speichern", + command=lambda: self.save_pdf_invoice(address_text.get(1.0, tk.END)), + bg='#1f2937', fg='white', font=('Arial', 12, 'bold'), + padx=20, pady=10, cursor='hand2').pack(fill=tk.X) + + def save_pdf_invoice(self, address): + try: + calc = self.get_calculations() + if not calc: + messagebox.showerror("Fehler", "Berechnung fehlgeschlagen") + return + + filename = filedialog.asksaveasfilename( + defaultextension=".pdf", + filetypes=[("PDF Datei", "*.pdf")] + ) + + if not filename: + return + + pdf = FPDF() + pdf.add_page() + pdf.set_font("Arial", "B", 24) + pdf.cell(0, 15, "RECHNUNG", ln=True, align='C') + pdf.ln(10) + + pdf.set_font("Arial", "", 11) + pdf.cell(0, 6, f"Rechnungsnummer: {self.vars['rechnungsnummer'].get()}", ln=True) + pdf.cell(0, 6, f"Datum: {self.vars['datum'].get()}", ln=True) + pdf.ln(10) + + pdf.set_font("Arial", "B", 12) + pdf.cell(0, 6, "Rechnungsempfänger:", ln=True) + pdf.set_font("Arial", "", 11) + pdf.cell(0, 6, self.vars['kundenName'].get(), ln=True) + pdf.multi_cell(0, 6, address) + pdf.ln(5) + + pdf.set_font("Arial", "B", 12) + pdf.cell(0, 8, "Leistungsbeschreibung:", ln=True) + pdf.set_font("Arial", "", 11) + pdf.cell(100, 8, f"3D-Druck: {self.vars['projektName'].get()}") + pdf.cell(30, 8, "1", align='R') + pdf.cell(30, 8, f"{calc['verkaufspreis']:.2f} EUR", align='R') + pdf.cell(30, 8, f"{calc['verkaufspreis']:.2f} EUR", ln=True, align='R') + pdf.ln(10) + + pdf.cell(130, 6, "Nettobetrag:") + pdf.cell(60, 6, f"{calc['verkaufspreis']:.2f} EUR", ln=True, align='R') + pdf.cell(130, 6, "MwSt. (19%):") + pdf.cell(60, 6, f"{calc['mwst']:.2f} EUR", ln=True, align='R') + pdf.set_font("Arial", "B", 12) + pdf.cell(130, 8, "GESAMTBETRAG:") + pdf.cell(60, 8, f"{calc['gesamt_brutto']:.2f} EUR", ln=True, align='R') + + pdf.ln(10) + pdf.set_font("Arial", "", 10) + pdf.multi_cell(0, 5, "Zahlbar innerhalb von 14 Tagen ohne Abzug.\nVielen Dank für Ihren Auftrag!") + + pdf.output(filename) + messagebox.showinfo("Erfolg", "PDF-Rechnung erfolgreich erstellt!") + + except Exception as e: + messagebox.showerror("Fehler", f"PDF konnte nicht erstellt werden.\nBitte installieren Sie: pip install fpdf\n\nFehler: {str(e)}") + +if __name__ == "__main__": + root = tk.Tk() + app = DruckKalkulation(root) + root.mainloop() \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..62c0d3a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,51 @@ +# AGENTS.md — 3D-Druck Kostenkalkulation + +## Project structure + +- `app.py` — Flask web app (ersetzt das fruehere Tkinter-GUI) +- `3dDruckKalkulator.py` — alte Tkinter-Version (nicht mehr verwenden) +- `templates/index.html` — Hauptseite mit Formular und Live-Ergebnissen +- `templates/invoice.html` — Rechnungsseite mit PDF-Download +- `static/style.css` — Light-/Dark-Mode-Theming +- `static/script.js` — Live-Berechnung + Auto-Save beim Tippen +- `druckkalkulation_config.yaml` — auto-created/auto-saved state +- `.venv/` — virtual environment with all deps + +## Setup + +```bash +source .venv/bin/activate +``` + +**Dependencies**: `flask`, `fpdf2` (imported as `fpdf`), `PyYAML`. Alle in `.venv`. + +## Run + +```bash +python app.py +# -> http://127.0.0.1:5000 +``` + +Kein Test, Lint, Typecheck oder CI vorhanden. README.md und AGENTS.md enthalten die Projektdokumentation. + +## Routes + +| Methode | Pfad | Beschreibung | +|---------|------|-------------| +| GET | `/` | Hauptseite mit Formular | +| POST | `/calculate` | JSON-Berechnung (AJAX) | +| POST | `/save_config` | Speichert Werte in YAML | +| GET | `/invoice` | Rechnungsseite | +| POST | `/invoice/pdf` | PDF-Download | +| POST | `/export` | TXT-Download | + +## Config + +`druckkalkulation_config.yaml` wird beim Start gelesen und bei jeder Eingabe automatisch gespeichert (debounced 500ms via AJAX). Enthaelt alle Formularwerte plus `dark_mode`. Wird automatisch erstellt — kein manuelles Setup. + +## Wichtige Fallstricke + +- `fpdf` kommt via `fpdf2`-Paket, Import ist `from fpdf import FPDF` (alte API). +- **Saeamtliche UI-Texte sind Deutsch** — Labels, Fehlermeldungen, Rechnungsinhalte. +- Nur `.venv/` und `druckkalkulation_config.yaml` ignorieren (kein `.gitignore` vorhanden). +- `3dDruckKalkulator.py` ist die alte Tkinter-Version und wird nicht mehr verwendet. diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..8bec77e --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,333 @@ +--- +version: alpha +name: CANCOM Simple Signage +description: "Digital-Signage-Plattform für CANCOM: Admin-UI zur Verwaltung von Screens, Playlists, Medien und Standorten sowie ein kioskartiger Player-Vollbildmodus." + +colors: + primary: "#DA002D" + primary-hover: "#B00024" + header: "#2B2F36" + header-dark: "#0F1720" + surface: "#FFFFFF" + surface-dark: "#15181D" + bg: "#F4F6F8" + bg-dark: "#15181D" + text: "#212121" + text-dark: "#E5E7EB" + border: "#D9DEE3" + border-dark: "#2B3440" + muted: "#6B7280" + muted-dark: "#9AA4B2" + online: "#2FB344" + offline: "#D63939" + badge-admin: "#DA002D" + badge-superuser: "#8B5CF6" + badge-user: "#3B82F6" + newsticker-bg: "#DA002D" + newsticker-text: "#FFFFFF" + card-header-light: "#EEEEEE" + card-header-dark: "#1A1E24" + input-bg-dark: "#11161D" + +typography: + body: + fontFamily: "Segoe UI, Arial, sans-serif" + fontSize: 16px + fontWeight: 400 + lineHeight: 1.5 + heading: + fontFamily: "Segoe UI, Arial, sans-serif" + fontWeight: 600 + heading-lg: + fontFamily: "Segoe UI, Arial, sans-serif" + fontSize: 1.75rem + fontWeight: 600 + heading-md: + fontFamily: "Segoe UI, Arial, sans-serif" + fontSize: 1.25rem + fontWeight: 600 + player-clock: + fontFamily: monospace + fontSize: 1.1em + newsticker-track: + fontFamily: "Segoe UI, Arial, sans-serif" + fontSize: 16px + fontWeight: 400 + mute: + fontFamily: "Segoe UI, Arial, sans-serif" + fontSize: 0.82rem + +rounded: + card: 16px + button: 20px + small: 4px + logo-box: 12px + full: 9999px + +spacing: + xs: 4px + sm: 8px + md: 16px + lg: 24px + xl: 32px + xxl: 64px + gutter: 24px + page-top: 24px + footer-padding-y: 0.65rem + footer-padding-x: 1.5rem + card-header-padding: 16px + card-margin-bottom: 64px + +components: + card: + backgroundColor: "{colors.surface}" + borderColor: "{colors.border}" + borderLeft: "6px solid {colors.primary}" + rounded: "{rounded.card}" + card-dark: + backgroundColor: "{colors.bg-dark}" + borderColor: "{colors.border-dark}" + card-header: + backgroundColor: "{colors.card-header-light}" + card-header-dark: + backgroundColor: "{colors.card-header-dark}" + btn-primary: + backgroundColor: "{colors.primary}" + textColor: "#FFFFFF" + rounded: "{rounded.button}" + btn-primary-hover: + backgroundColor: "{colors.primary-hover}" + action-button: + backgroundColor: "{colors.surface}" + textColor: "{colors.text}" + rounded: "{rounded.button}" + fontWeight: 600 + online-badge: + backgroundColor: "{colors.online}" + textColor: "#FFFFFF" + offline-badge: + backgroundColor: "{colors.offline}" + textColor: "#FFFFFF" + newsticker-bar: + backgroundColor: "{colors.newsticker-bg}" + textColor: "{colors.newsticker-text}" + height: 40px + overlay-button: + backgroundColor: "{colors.surface}" + textColor: "{colors.text}" + rounded: "{rounded.button}" + fontWeight: 600 + boxShadow: "0 2px 8px rgba(0,0,0,0.08)" +--- + +## Overview + +CANCOM Simple Signage is a corporate digital-signage platform. The UI follows a **keyadmin dashboard** aesthetic: clean, card-based layouts with a dark header bar, a red navigation bar (CANCOM brand red #DA002D), and generous white space. The tone is professional, trustworthy, and functional — suitable for a B2B enterprise environment. + +The admin UI manages screens, playlists, media uploads, and multi-site locations. The player UI is a fullscreen, kiosk-style view with no browser chrome, designed for unattended TV/monitor displays. + +Two themes are supported: **light** (default, `data-bs-theme="light"`) and **dark** (`data-bs-theme="dark"`), persisted via `localStorage("signage-theme")`. + +## Colors + +The palette is rooted in CANCOM's corporate red and high-contrast neutrals. + +- **Primary (#DA002D):** CANCOM signature red. Used for navigation bar, button backgrounds, card left-border accent, form-check focus rings, newsticker bar, and all primary interactive elements. The single most important action per screen uses this color. +- **Primary Hover (#B00024):** Darkened red for button hover/focus states. +- **Header (#2B2F36 / #0F1720 Dark):** Dark surface for top header bar. White text and icons on this surface. +- **Surface (#FFFFFF / #15181D Dark):** Card backgrounds and content areas. +- **Background (#F4F6F8 / #15181D Dark):** Page-level background. +- **Border (#D9DEE3 / #2B3440 Dark):** Card borders, table borders, form borders, list-group dividers. +- **Muted (#6B7280 / #9AA4B2 Dark):** Secondary text, captions, metadata, placeholders. +- **Online/Offline:** Green (#2FB344) for online status badge, Red (#D63939) for offline. +- **Role Badges:** Red for Admin, Purple (#8B5CF6) for Superuser, Blue (#3B82F6) for User. +- **Newsticker:** Red bar (#DA002D) with white text. + +### States + +Interactive elements use the following hover/active color shifts: +- **Primary button hover:** `primary-hover` (#B00024) +- **Secondary/outline buttons:** inherit background, use border with slight darkening +- **Dark-mode form controls:** input background #11161D + +## Typography + +The typography strategy uses the system font stack **Segoe UI** for all text, ensuring consistent rendering across Windows-based signage players. + +- **Body:** Segoe UI Regular at 16px base. Line-height 1.5 for readability. This is the default for all body text, form labels, table cells. +- **Headings:** Segoe UI Semi-Bold (600). Three levels: page-title (1.75rem), card-title (1.25rem), and section headings. +- **Player Clock:** Monospace font at 1.1em for the live clock in the newsticker bar. +- **Newsticker Track:** Same as body (16px, Segoe UI), scrolling horizontally in the red bar. +- **Footer/Muted Text:** 0.82rem for copyright and secondary information. +- **Button Labels:** 1rem (action buttons), 1.05rem (voice agent button), both weight 600. +- **Code/Monospace:** Used for IP addresses and file listings in info tabs. + +## Layout & Spacing + +The layout follows a **single-column fluid** model with a fixed max-width container (1320px) on the admin pages. + +### Layout Structure + +**Admin pages** follow a two-tier horizontal bar layout: +1. **Upper bar (brand-surface):** Logo, site dropdown, theme toggle, user menu +2. **Lower bar (nav-surface):** Red (#DA002D) navigation with action buttons + +**Player pages** have no layout bars — the entire viewport is used for media display. + +### Spacing Scale + +A strict spacing scale is used with 4px as the base unit: +- `xs` (4px) — tight gaps, micro-adjustments +- `sm` (8px) — tight gaps between related elements +- `md` (16px) — standard padding inside cards, gaps between buttons +- `lg` (24px) — generous card internal padding, gap between sections +- `xl` (32px) — major section spacing +- `xxl` (64px) — spacing between screen cards + +### Card Layout + +Screen management cards have: +- Internal padding: 1rem (16px) on body +- Card-header padding: 1rem (16px) left, with font-weight 600 +- Gap below each card: 64px (`mb-6`) +- Tab content offset: 1rem (16px) top padding + +### Grid Areas + +The playlist row uses a fixed grid: `28px 1fr 90px 80px 90px 32px` for checkbox, name, type badge, size, delete button, drag handle. + +## Elevation & Depth + +Elevation is conveyed primarily through **border distinction** rather than shadows. Cards use a 1px solid border plus a distinctive 6px left-border accent in primary red (#DA002D). This provides clear visual separation without heavy drop shadows. + +- **Cards:** 1px border + 6px left red accent + 1rem border-radius +- **Logo preview boxes:** 2px dashed border with 0.75rem border-radius +- **Player overlay buttons:** Light box-shadow (0 2px 8px rgba(0,0,0,0.08)), elevated to 0 4px 12px on hover +- **Willkommensseite card:** Elevated with 0 20px 60px rgba(0,0,0,0.08) shadow for premium feel + +In dark mode, depth is achieved through tonal layering: card backgrounds match the page background, but card headers use a slightly lighter/different shade (#1A1E24) to create subtle hierarchy. + +## Shapes + +The shape language is defined by **consistent generous rounding**. + +- **Cards:** 1rem (16px) border-radius with 1rem inner border-radius +- **Buttons (admin):** Tabler default rounded (approximately 4px) +- **Player action buttons (custom URL + voice agent):** 20px border-radius for a pill-like appearance +- **Logo preview boxes:** 0.75rem (12px) border-radius +- **Interactive overlays:** 20px border-radius on the back button +- **Small avatars/badges:** Fully rounded (9999px) +- **Form controls:** Tabler default (approximately 4px) + +### Consistency Rule + +All interactive player buttons (custom URL, voice agent, overlay back button) share identical shape: 20px border-radius, white background, black text, font-weight 600. This ensures visual consistency across all floating buttons on the player screen. + +## Components + +### Header (brand-surface) + +Dark background (`--ccm-header` / `#0F1720` in dark mode). Contains: +- **Logo:** CANCOM SVG (height: 1.85rem, width: auto) +- **Wordmark:** Two-line (brand name + subtitle), 0.92rem, white +- **Site Dropdown:** `btn-outline-secondary` style +- **Theme Toggle:** Square button (2.5rem), moon/sun icon +- **User Dropdown:** Email + role badge (Admin=red, Superuser=purple, User=blue) + +### Navigation Bar (nav-surface) + +Red background (`--ccm-primary`). Contains white outline buttons (`btn-white`): +- Active state: white background, red text, inset shadow +- Icons: 1rem, vertically centered at -2px +- Gap: 8px between buttons, 8px vertical padding +- Used on admin, customer, dashboard, priority pages + +### Cards + +- `rounded: 1rem`, `border: 1px solid var(--ccm-border)` +- `border-left: 6px solid var(--ccm-primary)` +- Header: `background: #eee` (light) / `#1a1e24` (dark), font-weight 600 +- Body contains Tabler tabs (6 tabs for screen cards) +- Tab background matches card surface; tab link text uses muted color; active tab uses surface background + +### Player Media Elements + +- img, video, iframe: fullscreen (`100vw × 100vh`), fixed position, centered via `top:50%; left:50%; transform:translate(-50%,-50%)` +- Only one element visible at a time (`display:none` default) +- `object-fit: contain` for images and video +- Background: pure black (`#000`) + +### Action Button (Custom URL / Voice Agent) + +The player supports floating action buttons with identical styling: +- `background: #fff; color: #000; border: none; border-radius: 20px; font-size: 1rem; font-weight: 600; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.08)` +- 9 fixed positions: top/middle/bottom × left/center/right +- Bottom positions offset 56px from bottom when newsticker is active, 16px otherwise +- Two target behaviors: overlay (iframe with back button) or redirect (direct navigation) + +### Voice Agent Button + +Same base as action button plus: +- Integrated animated bars (5 bars, staggered delay 0–0.4s, 0.6s wave animation) +- Typewriter tagline: 11 multilingual phrases, character-by-character (50ms + 30ms random), 2.5s pause, 0.8s fade-out +- Optional DVA image (200px, aspect-ratio 648/596) above button +- Min-width: 280px, height: 56px, gap: 12px between icon and text + +### Overlay (iframe Modal) + +- Fullscreen (`position:fixed; inset:0; z-index:99999; background:#000`) +- iframe fills entire viewport +- Back button positioned at the same location as the clicked trigger button (9 positions) +- Closing the overlay resumes player (`playNext()`) + +### Newsticker + +- Fixed bottom bar: `height:40px; background:#DA002D; color:#fff; z-index:10000` +- Scrolling text: `animation: ticker-scroll 40s linear infinite` +- Live clock: monospace, right-aligned, hh:mm:ss, updated every second + +### Online/Offline Status + +- Online: green filled button (`btn-success`), not interactive +- Offline: red filled button (`btn-danger`), not interactive +- Tooltip on hover: browser, resolution, IP, last seen timestamp +- Determined by heartbeat file mtime (< 60s) + +### Charts (Admin Dashboard) + +- Sparkline area charts using ApexCharts +- Height: 32px, width: 100%, stroke width: 1.5, curve: straight +- Color: primary red (#DA002D), fill opacity: 0.25 +- No tooltip, no animation + +### Form Controls + +- Switches use primary red for checked state +- Focus ring: `0 0 0 0.2rem rgba(218, 0, 45, 0.25)` +- Dark mode: background `#11161D`, text `#E5E7EB`, border `#2B3440` + +### Dropdown Menus + +- Hover/focus: `background: #e9ecef` (light) / `#3a3f45` (dark) +- Active item: primary red background with white text + +### Alerts (Dark Mode) + +- Danger: background `#2d0d0d`, text `#f0a0a0`, border `#4a1a1a` +- Success: background `#0d2b1d`, text `#a3d9b1`, border `#1a4730` + +## Do's and Don'ts + +- Do use primary red (#DA002D) only for the most important interactive elements and the nav bar +- Do maintain the two-tier header pattern (dark brand bar + red nav bar) on all admin pages +- Do keep player pages completely chromeless — no headers, navs, or footers +- Don't add shadows to cards; use border + left accent instead +- Don't use more than one red accent per card (the 6px left border is sufficient) +- Don't mix corner radii: cards use 1rem, player buttons use 20px — keep each category consistent +- Do set `data-bs-theme` synchronously before rendering to prevent dark-mode flash +- Do persist theme preference in `localStorage("signage-theme")` +- Don't use emojis in the UI; use Tabler Icons (`ti-*`) consistently +- Do respect the 9-position system for all floating player buttons +- Do offset bottom-positioned buttons 56px above the newsticker bar +- Do hide all media elements by default; show only the active one +- Do ensure WCAG AA contrast ratios for all text-on-background combinations diff --git a/app.py b/app.py new file mode 100644 index 0000000..93d98e1 --- /dev/null +++ b/app.py @@ -0,0 +1,237 @@ +""" +3D-Druck Kostenkalkulation — Flask Web-App. + +Ersetzt die alte Tkinter-Version (3dDruckKalkulator.py). +Bietet eine Weboberflaeche zur Eingabe von Druckparametern, +Live-Berechnung der Kosten, PDF-Rechnung und TXT-Export. +""" +import os +import yaml +import io +from datetime import datetime +from flask import Flask, render_template, request, jsonify, redirect, url_for, send_file +from fpdf import FPDF + +app = Flask(__name__) +CONFIG_FILE = 'druckkalkulation_config.yaml' + +DEFAULT_VALUES = { + 'projektName': '', + 'kundenName': '', + 'kundenAdresse': '', + 'materialGewicht': 0, + 'materialPreisProKg': 25, + 'druckzeit': 0, + 'stromverbrauch': 0.15, + 'strompreis': 0.35, + 'druckerAnschaffung': 500, + 'druckerNutzungsdauer': 3, + 'druckerBetriebsstunden': 2000, + 'arbeitszeit': 0, + 'stundenlohn': 25, + 'gewinnmarge': 20, + 'rechnungsnummer': '', + 'datum': datetime.now().strftime('%Y-%m-%d'), + 'dark_mode': False, +} + + +def load_config(): + """Liest die YAML-Konfiguration von Disk. Fehlt die Datei, + werden DEFAULT_VALUES verwendet.""" + try: + if os.path.exists(CONFIG_FILE): + with open(CONFIG_FILE, 'r', encoding='utf-8') as f: + data = yaml.safe_load(f) + if data: + for k in DEFAULT_VALUES: + if k not in data: + data[k] = DEFAULT_VALUES[k] + return data + except Exception as e: + print(f"Fehler beim Laden: {e}") + return dict(DEFAULT_VALUES) + + +def save_config(data): + """Schreibt das uebergebene Dict als YAML-Datei.""" + try: + with open(CONFIG_FILE, 'w', encoding='utf-8') as f: + yaml.dump(data, f, default_flow_style=False, allow_unicode=True) + except Exception as e: + print(f"Fehler beim Speichern: {e}") + + +def calculate(data): + """Berechnet alle Kosten aus den Formularwerten. + + Rueckgabe: Dict mit Einzelposten, Gesamtsummen und Detailwerten. + """ + # 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 + + # 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 + + 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 + mwst = verkaufspreis * 0.19 + gesamt_brutto = verkaufspreis + mwst + + material_preis_pro_kg = float(data.get('materialPreisProKg', 0)) + + return { + 'materialkosten': round(materialkosten, 2), + 'stromkosten': round(stromkosten, 2), + 'abschreibung': round(abschreibung, 2), + 'arbeitskosten': round(arbeitskosten, 2), + 'gesamtkosten_netto': round(gesamtkosten_netto, 2), + 'gewinn': round(gewinn, 2), + 'verkaufspreis': round(verkaufspreis, 2), + 'mwst': round(mwst, 2), + 'gesamt_brutto': round(gesamt_brutto, 2), + 'druckzeit_stunden': round(druckzeit_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), + } + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + +@app.route('/') +def index(): + """Hauptseite mit Eingabeformular und Ergebnissen.""" + config = load_config() + calc = calculate(config) + return render_template('index.html', config=config, calc=calc) + + +@app.route('/calculate', methods=['POST']) +def calc_json(): + """AJAX-Endpunkt: Nimmt Formular-JSON entgegen, gibt Berechnung zurueck.""" + data = request.get_json() + calc = calculate(data) + return jsonify(calc) + + +@app.route('/save_config', methods=['POST']) +def save_config_route(): + """AJAX-Endpunkt: Speichert aktuelle Formularwerte in YAML.""" + data = request.get_json() + save_config(data) + return jsonify({'status': 'ok'}) + + +@app.route('/invoice') +def invoice(): + """Rechnungsseite mit Kundendaten und Vorschau.""" + config = load_config() + calc = calculate(config) + return render_template('invoice.html', config=config, calc=calc) + + +@app.route('/invoice/pdf', methods=['POST']) +def invoice_pdf(): + """Generiert eine PDF-Rechnung via fpdf2 und sendet sie zum Download.""" + data = request.form + config = load_config() + calc = calculate(config) + + pdf = FPDF() + pdf.add_page() + pdf.set_font("Arial", "B", 24) + pdf.cell(0, 15, "RECHNUNG", ln=True, align='C') + pdf.ln(10) + + pdf.set_font("Arial", "", 11) + pdf.cell(0, 6, f"Rechnungsnummer: {data.get('rechnungsnummer', config.get('rechnungsnummer', ''))}", ln=True) + pdf.cell(0, 6, f"Datum: {data.get('datum', config.get('datum', ''))}", ln=True) + pdf.ln(10) + + pdf.set_font("Arial", "B", 12) + pdf.cell(0, 6, "Rechnungsempfanger:", ln=True) + pdf.set_font("Arial", "", 11) + pdf.cell(0, 6, data.get('kundenName', config.get('kundenName', '')), ln=True) + pdf.multi_cell(0, 6, data.get('kundenAdresse', config.get('kundenAdresse', ''))) + pdf.ln(5) + + pdf.set_font("Arial", "B", 12) + pdf.cell(0, 8, "Leistungsbeschreibung:", ln=True) + pdf.set_font("Arial", "", 11) + pdf.cell(100, 8, f"3D-Druck: {config.get('projektName', '')}") + pdf.cell(30, 8, "1", align='R') + pdf.cell(30, 8, f"{calc['verkaufspreis']:.2f} EUR", align='R') + pdf.cell(30, 8, f"{calc['verkaufspreis']:.2f} EUR", ln=True, align='R') + pdf.ln(10) + + pdf.cell(130, 6, "Nettobetrag:") + pdf.cell(60, 6, f"{calc['verkaufspreis']:.2f} EUR", ln=True, align='R') + pdf.cell(130, 6, "MwSt. (19%):") + pdf.cell(60, 6, f"{calc['mwst']:.2f} EUR", ln=True, align='R') + pdf.set_font("Arial", "B", 12) + pdf.cell(130, 8, "GESAMTBETRAG:") + pdf.cell(60, 8, f"{calc['gesamt_brutto']:.2f} EUR", ln=True, align='R') + + pdf.ln(10) + pdf.set_font("Arial", "", 10) + pdf.multi_cell(0, 5, "Zahlbar innerhalb von 14 Tagen ohne Abzug.\nVielen Dank fur Ihren Auftrag!") + + return send_file( + io.BytesIO(pdf.output(dest='S').encode('latin-1')), + mimetype='application/pdf', + as_attachment=True, + download_name='rechnung.pdf' + ) + + +@app.route('/export', methods=['POST']) +def export(): + """Exportiert die Kalkulation als TXT-Datei.""" + data = request.get_json() + calc = calculate(data) + + 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", + f"MwSt. (19%): {calc['mwst']:.2f} EUR", + f"GESAMTPREIS (brutto): {calc['gesamt_brutto']:.2f} EUR", + ] + text = '\n'.join(lines) + + return send_file( + io.BytesIO(text.encode('utf-8')), + mimetype='text/plain', + as_attachment=True, + download_name='kalkulation.txt' + ) + + +if __name__ == '__main__': + app.run(debug=True) diff --git a/static/script.js b/static/script.js new file mode 100644 index 0000000..93860ca --- /dev/null +++ b/static/script.js @@ -0,0 +1,142 @@ +/** + * 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; + +/** 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; + data[key] = val; + }); + data.dark_mode = document.documentElement.getAttribute('data-bs-theme') === 'dark'; + return data; +} + +/** Sendet Formulardaten an /calculate und aktualisiert die Ergebnisanzeige. */ +async function recalculate() { + 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 lines = [ + `Materialkosten: ${String(calc.materialkosten.toFixed(2)).padStart(10)} EUR`, + `Stromkosten: ${String(calc.stromkosten.toFixed(2)).padStart(10)} EUR`, + `Abschreibung Drucker: ${String(calc.abschreibung.toFixed(2)).padStart(10)} EUR`, + `Arbeitskosten: ${String(calc.arbeitskosten.toFixed(2)).padStart(10)} EUR`, + `${'\u2500'.repeat(40)}`, + `Gesamtkosten: ${String(calc.gesamtkosten_netto.toFixed(2)).padStart(10)} EUR`, + '', + `Gewinn (${((document.querySelector('[data-var="gewinnmarge"]')?.value) || 20)}%): ${String(calc.gewinn.toFixed(2)).padStart(10)} EUR`, + `${'\u2500'.repeat(40)}`, + `Verkaufspreis (netto): ${String(calc.verkaufspreis.toFixed(2)).padStart(10)} EUR`, + `MwSt. (19%): ${String(calc.mwst.toFixed(2)).padStart(10)} EUR`, + `${'\u2550'.repeat(40)}`, + `GESAMT (brutto): ${String(calc.gesamt_brutto.toFixed(2)).padStart(10)} EUR`, + ]; + document.getElementById('result-text').textContent = lines.join('\n'); + + const details = [ + `Druckzeit: ${calc.druckzeit_stunden} Stunden`, + `Materialgewicht: ${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`, + ]; + document.getElementById('detail-text').textContent = details.join('\n'); +} + +/** 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); +} + +/** Wird bei jeder Eingabe aufgerufen: speichert + berechnet neu. */ +async function onInputChange() { + scheduleSave(); + await recalculate(); +} + +/** 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); + + const btn = document.getElementById('dark-mode-btn'); + const icon = btn.querySelector('i'); + icon.className = `ti ti-${isDark ? 'sun' : 'moon'} me-1`; + btn.childNodes[2].textContent = isDark ? 'Light' : 'Dark'; + + 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); + } +} + +/** 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); +} + +/** Initiale Berechnung beim Seitenladen. */ +document.addEventListener('DOMContentLoaded', () => { + recalculate(); +}); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..b5de207 --- /dev/null +++ b/static/style.css @@ -0,0 +1,13 @@ +/* Grossere Schrift in Ueberschriften */ +.card-header, .navbar-brand span { + font-size: 1.1rem; +} + +/* Monospace-Ergebnisblock (Kostenaufstellung) */ +.result-box { + font-family: 'Courier New', Courier, monospace; + font-size: 14px; + line-height: 1.8; + white-space: pre; + overflow-x: auto; +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..bc5d6d9 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,166 @@ + + + + + + 3D-Druck Kostenkalkulation + + + + + + +
+ + + +
+
+ + +
+
+ +
+
Projektinformationen
+
+
+ + +
+
+
+ +
+
Material
+
+
+ + +
+
+ + +
+
+
+ +
+
Druckparameter
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
Drucker Abschreibung
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
Arbeitskosten
+
+
+ + +
+
+ + +
+
+
+ +
+
Gewinnmarge
+
+
+ + +
+
+
+ +
+
+ + +
+
+
Kostenaufstellung
+
+
+
+
+ +
+
Detaillierte Informationen
+
+
+ +
+ + Rechnung + + +
+
+ +
+
+ + + +
+ + + + + diff --git a/templates/invoice.html b/templates/invoice.html new file mode 100644 index 0000000..4464193 --- /dev/null +++ b/templates/invoice.html @@ -0,0 +1,98 @@ + + + + + + Rechnung erstellen + + + + + + +
+ + + +
+
+
+ +
+ +
+
Rechnungsdaten
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+
Vorschau
+
+
LEISTUNGSBESCHREIBUNG: +{{ '\u2500' * 50 }} +Position: 3D-Druck - {{ config.projektName }} +Menge: 1 +Einzelpreis: {{ "%.2f"|format(calc.verkaufspreis) }} EUR +Gesamt: {{ "%.2f"|format(calc.verkaufspreis) }} EUR + +{{ '\u2500' * 50 }} +Nettobetrag: {{ "%.2f"|format(calc.verkaufspreis) }} EUR +MwSt. (19%): {{ "%.2f"|format(calc.mwst) }} EUR +{{ '\u2550' * 50 }} +GESAMTBETRAG: {{ "%.2f"|format(calc.gesamt_brutto) }} EUR + +Zahlbar innerhalb von 14 Tagen ohne Abzug. +Vielen Dank fur Ihren Auftrag!
+
+
+ + +
+ +
+
+
+ +
+ + + +