Flask-Web-App statt Tkinter: Live-Berechnung, PDF-Rechnung, Dark Mode, Tabler CSS

This commit is contained in:
Erik Thiele
2026-06-30 20:46:52 +02:00
parent fd04d3dace
commit 73631b415d
9 changed files with 1690 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.venv/
__pycache__/
*.pyc
.DS_Store
druckkalkulation_config.yaml

645
3dDruckKalkulator.py Normal file
View File

@@ -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(
"<Configure>",
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('<KeyRelease>', 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()

51
AGENTS.md Normal file
View File

@@ -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.

333
DESIGN.md Normal file
View File

@@ -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 00.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

237
app.py Normal file
View File

@@ -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)

142
static/script.js Normal file
View File

@@ -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();
});

13
static/style.css Normal file
View File

@@ -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;
}

166
templates/index.html Normal file
View File

@@ -0,0 +1,166 @@
<!DOCTYPE html>
<html lang="de" data-bs-theme="{% if config.dark_mode %}dark{% else %}light{% endif %}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>3D-Druck Kostenkalkulation</title>
<link href="https://cdn.jsdelivr.net/npm/@tabler/core@latest/dist/css/tabler.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/dist/tabler-icons.min.css" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<style>
@import url("https://rsms.me/inter/inter.css");
</style>
</head>
<body>
<div class="page">
<header class="navbar navbar-expand-md">
<div class="container-xl">
<div class="navbar-brand navbar-brand-autodark pe-0 pe-md-3 py-2 d-flex align-items-center gap-2">
<span class="fw-bold">JET Design</span>
<span class="text-secondary" style="opacity:0.5;">|</span>
<span class="fw-semibold">3D Druck Kostenkalkulator</span>
</div>
<div class="navbar-nav flex-row order-md-last">
<div class="nav-item">
<button class="nav-link px-0" id="dark-mode-btn" onclick="toggleDarkMode()" title="Dark Mode umschalten">
<i class="ti ti-{% if config.dark_mode %}sun{% else %}moon{% endif %} icon icon-1" id="dark-mode-icon"></i>
</button>
</div>
</div>
</div>
</header>
<div class="container-xl my-4">
<div class="row g-4">
<!-- Linke Spalte: Formular -->
<div class="col-lg-6">
<form id="calc-form" onchange="onInputChange()" onkeyup="onInputChange()">
<div class="card mb-3">
<div class="card-header"><i class="ti ti-info-circle me-2"></i>Projektinformationen</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Projektname</label>
<input type="text" class="form-control" data-var="projektName" value="{{ config.projektName }}">
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header"><i class="ti ti-box me-2"></i>Material</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Gewicht (Gramm)</label>
<input type="number" step="0.01" class="form-control" data-var="materialGewicht" data-type="number" value="{{ config.materialGewicht }}">
</div>
<div class="mb-3">
<label class="form-label">Preis pro kg (EUR)</label>
<input type="number" step="0.01" class="form-control" data-var="materialPreisProKg" data-type="number" value="{{ config.materialPreisProKg }}">
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header"><i class="ti ti-printer me-2"></i>Druckparameter</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Druckzeit (Minuten)</label>
<input type="number" step="1" class="form-control" data-var="druckzeit" data-type="number" value="{{ config.druckzeit }}">
</div>
<div class="mb-3">
<label class="form-label">Stromverbrauch (kW)</label>
<input type="number" step="0.01" class="form-control" data-var="stromverbrauch" data-type="number" value="{{ config.stromverbrauch }}">
</div>
<div class="mb-3">
<label class="form-label">Strompreis (EUR/kWh)</label>
<input type="number" step="0.01" class="form-control" data-var="strompreis" data-type="number" value="{{ config.strompreis }}">
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header"><i class="ti ti-tools me-2"></i>Drucker Abschreibung</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Anschaffungspreis (EUR)</label>
<input type="number" step="1" class="form-control" data-var="druckerAnschaffung" data-type="number" value="{{ config.druckerAnschaffung }}">
</div>
<div class="mb-3">
<label class="form-label">Nutzungsdauer (Jahre)</label>
<input type="number" step="0.5" class="form-control" data-var="druckerNutzungsdauer" data-type="number" value="{{ config.druckerNutzungsdauer }}">
</div>
<div class="mb-3">
<label class="form-label">Betriebsstunden pro Jahr</label>
<input type="number" step="1" class="form-control" data-var="druckerBetriebsstunden" data-type="number" value="{{ config.druckerBetriebsstunden }}">
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header"><i class="ti ti-users me-2"></i>Arbeitskosten</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Arbeitszeit (Minuten)</label>
<input type="number" step="1" class="form-control" data-var="arbeitszeit" data-type="number" value="{{ config.arbeitszeit }}">
</div>
<div class="mb-3">
<label class="form-label">Stundenlohn (EUR)</label>
<input type="number" step="0.5" class="form-control" data-var="stundenlohn" data-type="number" value="{{ config.stundenlohn }}">
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header"><i class="ti ti-currency-euro me-2"></i>Gewinnmarge</div>
<div class="card-body">
<div class="mb-0">
<label class="form-label">Gewinnmarge (%)</label>
<input type="number" step="0.5" class="form-control" data-var="gewinnmarge" data-type="number" value="{{ config.gewinnmarge }}">
</div>
</div>
</div>
</form>
</div>
<!-- Rechte Spalte: Ergebnisse -->
<div class="col-lg-6">
<div class="card mb-3">
<div class="card-header"><i class="ti ti-chart-bar me-2"></i>Kostenaufstellung</div>
<div class="card-body p-0">
<div class="result-box p-3" id="result-text"></div>
</div>
</div>
<div class="card mb-3">
<div class="card-header"><i class="ti ti-info-square me-2"></i>Detaillierte Informationen</div>
<div class="card-body text-secondary" id="detail-text" style="font-size:0.9rem;white-space:pre"></div>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('invoice') }}" class="btn btn-primary flex-fill">
<i class="ti ti-file-invoice me-1"></i>Rechnung
</a>
<button class="btn btn-outline-primary flex-fill" onclick="exportData()">
<i class="ti ti-download me-1"></i>Export
</button>
</div>
</div>
</div>
</div>
<div class="toast-container position-fixed top-0 end-0 p-3" id="notification" style="z-index:9999;display:none">
<div class="toast show bg-primary text-white">
<div class="toast-body fw-semibold" id="notification-msg"></div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/@tabler/core@latest/dist/js/tabler.min.js"></script>
<script src="{{ url_for('static', filename='script.js') }}"></script>
</body>
</html>

98
templates/invoice.html Normal file
View File

@@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="de" data-bs-theme="{% if config.dark_mode %}dark{% else %}light{% endif %}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Rechnung erstellen</title>
<link href="https://cdn.jsdelivr.net/npm/@tabler/core@latest/dist/css/tabler.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/dist/tabler-icons.min.css" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<style>
@import url("https://rsms.me/inter/inter.css");
</style>
</head>
<body>
<div class="page">
<header class="navbar navbar-expand-md">
<div class="container-xl">
<div class="navbar-brand navbar-brand-autodark pe-0 pe-md-3 py-2 d-flex align-items-center gap-2">
<span class="fw-bold">JET Design</span>
<span class="text-secondary" style="opacity:0.5;">|</span>
<span class="fw-semibold"><i class="ti ti-file-invoice me-1"></i>Rechnung erstellen</span>
</div>
<div class="navbar-nav flex-row order-md-last">
<div class="nav-item">
<a href="{{ url_for('index') }}" class="nav-link px-0" title="Zuruck zur Kalkulation">
<i class="ti ti-arrow-left icon icon-1"></i>
</a>
</div>
</div>
</div>
</header>
<div class="container-xl my-4">
<div class="row justify-content-center">
<div class="col-lg-8">
<form method="POST" action="{{ url_for('invoice_pdf') }}" target="_blank">
<div class="card mb-3">
<div class="card-header"><i class="ti ti-user me-2"></i>Rechnungsdaten</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Rechnungsnummer</label>
<input type="text" class="form-control" name="rechnungsnummer" value="{{ config.rechnungsnummer }}">
</div>
<div class="col-md-6">
<label class="form-label">Datum</label>
<input type="text" class="form-control" name="datum" value="{{ config.datum }}">
</div>
<div class="col-12">
<label class="form-label">Kundenname</label>
<input type="text" class="form-control" name="kundenName" value="{{ config.kundenName }}">
</div>
<div class="col-12">
<label class="form-label">Kundenadresse</label>
<textarea class="form-control" name="kundenAdresse" rows="3">{{ config.kundenAdresse }}</textarea>
</div>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header"><i class="ti ti-eye me-2"></i>Vorschau</div>
<div class="card-body p-0">
<div class="p-3" style="font-family:'Courier New',Courier,monospace;font-size:13px;line-height:1.8;white-space:pre;overflow-x:auto;background:var(--tblr-bg-surface);">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!</div>
</div>
</div>
<button type="submit" class="btn btn-primary w-100 btn-lg">
<i class="ti ti-file-download me-2"></i>PDF speichern
</button>
</form>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/@tabler/core@latest/dist/js/tabler.min.js"></script>
</body>
</html>