Flask-Web-App statt Tkinter: Live-Berechnung, PDF-Rechnung, Dark Mode, Tabler CSS
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
druckkalkulation_config.yaml
|
||||
645
3dDruckKalkulator.py
Normal file
645
3dDruckKalkulator.py
Normal 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
51
AGENTS.md
Normal 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
333
DESIGN.md
Normal 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 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
|
||||
237
app.py
Normal file
237
app.py
Normal 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
142
static/script.js
Normal 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
13
static/style.css
Normal 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
166
templates/index.html
Normal 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
98
templates/invoice.html
Normal 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>
|
||||
Reference in New Issue
Block a user