nebenkostenabrechnung/nebenkostenabrechnung.py
data 6ee34055f1 refactor: Code-Cleanup nach Review
- Ungenutzte Variable `self.letzte_pdf` entfernt
- Inline-Imports nach oben verschoben (QTextEdit, QAction, etc.)
- `shutil` und `subprocess` zentral importiert
- Keine funktionalen Änderungen

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-05 09:38:15 +01:00

1590 lines
58 KiB
Python

#!/usr/bin/env python3
"""
Nebenkostenabrechnung - PySide6 GUI-Anwendung
"""
import sys
import os
import json
from pathlib import Path
from datetime import datetime
from io import BytesIO
from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QFormLayout, QGridLayout, QStackedWidget, QListWidget, QListWidgetItem,
QLineEdit, QDoubleSpinBox, QSpinBox, QPushButton, QLabel, QGroupBox,
QTableWidget, QTableWidgetItem, QHeaderView, QMessageBox, QFileDialog,
QComboBox, QFrame, QSplitter, QScrollArea, QSizePolicy, QTextEdit
)
from PySide6.QtCore import Qt, QSize
from PySide6.QtGui import QFont, QIcon, QColor, QAction, QKeySequence
from PySide6.QtPrintSupport import QPrinter, QPrintDialog
import shutil
import subprocess
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import cm
from reportlab.pdfgen import canvas
from reportlab.lib import colors
try:
from PyPDF2 import PdfReader, PdfWriter
PYPDF2_AVAILABLE = True
except ImportError:
PYPDF2_AVAILABLE = False
class DatenManager:
"""Verwaltet Stammdaten und Historie"""
# Konfigurations-Datei im Home-Verzeichnis
CONFIG_DATEI = Path.home() / ".config" / "nebenkostenabrechnung" / "config.json"
def __init__(self):
# Arbeitsverzeichnis setzen
self.basis_pfad = Path(__file__).parent
os.chdir(self.basis_pfad)
# Standard-Pfade
self.daten_ordner = str(self.basis_pfad / "daten")
self.dokumente_ordner = str(self.basis_pfad / "dokumente")
self.vorlagen_ordner = str(self.basis_pfad / "vorlagen")
# Konfig laden (überschreibt Standard-Pfade)
self.lade_config()
# Ordner erstellen
Path(self.daten_ordner).mkdir(parents=True, exist_ok=True)
Path(self.dokumente_ordner).mkdir(parents=True, exist_ok=True)
Path(self.vorlagen_ordner).mkdir(parents=True, exist_ok=True)
# Dynamische Pfade
self.stammdaten_datei = Path(self.daten_ordner) / "stammdaten.json"
self.historie_datei = Path(self.daten_ordner) / "historie.json"
self.vorlage_datei = Path(self.vorlagen_ordner) / "vorlage.pdf"
# Standard-Daten
self.vermieter = {
"name": "", "adresse": "", "iban": "", "bic": "", "bank": ""
}
self.mieter = {
"name": "", "adresse": "", "iban": "", "bic": "", "bank": "", "email": ""
}
self.objekt_adresse = ""
self.vorauszahlung_monat = 50.0
self.kostenarten = ["Wasser/Abwasser", "Müllabfuhr", "Schornsteinfeger"]
self.briefkopf_hoehe = 4.5
self.historie = {}
self.lade_stammdaten()
self.lade_historie()
def lade_config(self):
"""Lädt Pfad-Konfiguration"""
if self.CONFIG_DATEI.exists():
try:
with open(self.CONFIG_DATEI, "r", encoding="utf-8") as f:
config = json.load(f)
self.daten_ordner = config.get("daten_ordner", self.daten_ordner)
self.dokumente_ordner = config.get("dokumente_ordner", self.dokumente_ordner)
self.vorlagen_ordner = config.get("vorlagen_ordner", self.vorlagen_ordner)
except:
pass
def speichere_config(self):
"""Speichert Pfad-Konfiguration"""
self.CONFIG_DATEI.parent.mkdir(parents=True, exist_ok=True)
config = {
"daten_ordner": self.daten_ordner,
"dokumente_ordner": self.dokumente_ordner,
"vorlagen_ordner": self.vorlagen_ordner,
}
with open(self.CONFIG_DATEI, "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2)
def aktualisiere_pfade(self):
"""Aktualisiert die Dateipfade nach Änderung der Ordner"""
Path(self.daten_ordner).mkdir(parents=True, exist_ok=True)
Path(self.dokumente_ordner).mkdir(parents=True, exist_ok=True)
Path(self.vorlagen_ordner).mkdir(parents=True, exist_ok=True)
self.stammdaten_datei = Path(self.daten_ordner) / "stammdaten.json"
self.historie_datei = Path(self.daten_ordner) / "historie.json"
self.vorlage_datei = Path(self.vorlagen_ordner) / "vorlage.pdf"
def lade_stammdaten(self):
if self.stammdaten_datei.exists():
try:
with open(self.stammdaten_datei, "r", encoding="utf-8") as f:
daten = json.load(f)
self.vermieter = daten.get("vermieter", self.vermieter)
self.mieter = daten.get("mieter", self.mieter)
self.objekt_adresse = daten.get("objekt_adresse", "")
self.vorauszahlung_monat = daten.get("vorauszahlung_monat", 50.0)
self.kostenarten = daten.get("kostenarten", self.kostenarten)
self.briefkopf_hoehe = daten.get("briefkopf_hoehe", 4.5)
except Exception as e:
print(f"Fehler beim Laden: {e}")
def speichere_stammdaten(self):
daten = {
"vermieter": self.vermieter,
"mieter": self.mieter,
"objekt_adresse": self.objekt_adresse,
"vorauszahlung_monat": self.vorauszahlung_monat,
"kostenarten": self.kostenarten,
"briefkopf_hoehe": self.briefkopf_hoehe,
}
with open(self.stammdaten_datei, "w", encoding="utf-8") as f:
json.dump(daten, f, ensure_ascii=False, indent=2)
def lade_historie(self):
if self.historie_datei.exists():
try:
with open(self.historie_datei, "r", encoding="utf-8") as f:
self.historie = json.load(f)
except:
pass
def get_abrechnung_ordner(self, jahr):
"""Gibt den Ordner für eine Abrechnung zurück"""
ordner = Path(self.dokumente_ordner) / str(jahr)
ordner.mkdir(parents=True, exist_ok=True)
return ordner
def get_belege_ordner(self, jahr):
"""Gibt den Belege-Ordner für eine Abrechnung zurück"""
ordner = self.get_abrechnung_ordner(jahr) / "belege"
ordner.mkdir(parents=True, exist_ok=True)
return ordner
def speichere_entwurf(self, jahr, kosten, belege, anzahl_monate, bemerkungen=""):
"""Speichert einen Entwurf (ohne PDF zu erstellen)"""
belege_ordner = self.get_belege_ordner(jahr)
gespeicherte_belege = {}
# Belege kopieren
for kostenart, dateien in belege.items():
gespeicherte_belege[kostenart] = []
for i, datei in enumerate(dateien):
if datei and Path(datei).exists():
# Dateiname: Kostenart_Nr.pdf
ext = Path(datei).suffix
ziel_name = f"{kostenart.replace('/', '_')}_{i+1}{ext}"
ziel = belege_ordner / ziel_name
if str(datei) != str(ziel): # Nur kopieren wenn nicht gleich
shutil.copy2(datei, ziel)
gespeicherte_belege[kostenart].append(str(ziel))
summe_kosten = sum(kosten.values())
summe_vz = self.vorauszahlung_monat * anzahl_monate
self.historie[str(jahr)] = {
"jahr": jahr,
"mieter": self.mieter["name"],
"kosten": kosten,
"belege": gespeicherte_belege,
"bemerkungen": bemerkungen,
"summe_kosten": summe_kosten,
"vorauszahlung_monat": self.vorauszahlung_monat,
"anzahl_monate": anzahl_monate,
"summe_vorauszahlung": summe_vz,
"differenz": summe_kosten - summe_vz,
"status": "entwurf",
"datum": datetime.now().strftime("%Y-%m-%d %H:%M"),
}
with open(self.historie_datei, "w", encoding="utf-8") as f:
json.dump(self.historie, f, ensure_ascii=False, indent=2)
return gespeicherte_belege
def lade_entwurf(self, jahr):
"""Lädt einen gespeicherten Entwurf"""
if str(jahr) in self.historie:
return self.historie[str(jahr)]
return None
def speichere_abrechnung(self, jahr, kosten, belege, anzahl_monate, bemerkungen=""):
"""Speichert eine fertige Abrechnung"""
# Erst als Entwurf speichern (kopiert Belege)
gespeicherte_belege = self.speichere_entwurf(jahr, kosten, belege, anzahl_monate, bemerkungen)
summe_kosten = sum(kosten.values())
summe_vz = self.vorauszahlung_monat * anzahl_monate
self.historie[str(jahr)] = {
"jahr": jahr,
"mieter": self.mieter["name"],
"kosten": kosten,
"belege": gespeicherte_belege,
"summe_kosten": summe_kosten,
"vorauszahlung_monat": self.vorauszahlung_monat,
"anzahl_monate": anzahl_monate,
"summe_vorauszahlung": summe_vz,
"differenz": summe_kosten - summe_vz,
"status": "fertig",
"datum": datetime.now().strftime("%Y-%m-%d %H:%M"),
}
with open(self.historie_datei, "w", encoding="utf-8") as f:
json.dump(self.historie, f, ensure_ascii=False, indent=2)
return gespeicherte_belege
def get_vorjahr_kosten(self, jahr):
vorjahr = str(jahr - 1)
if vorjahr in self.historie:
return self.historie[vorjahr].get("kosten", {})
return {}
class StammdatenSeite(QWidget):
"""Seite für Stammdaten-Eingabe"""
def __init__(self, daten: DatenManager):
super().__init__()
self.daten = daten
self.setup_ui()
self.lade_daten()
def setup_ui(self):
layout = QVBoxLayout(self)
layout.setSpacing(20)
# Titel
titel = QLabel("Stammdaten")
titel.setFont(QFont("", 18, QFont.Bold))
layout.addWidget(titel)
# Scroll-Bereich
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.NoFrame)
content = QWidget()
content_layout = QVBoxLayout(content)
content_layout.setSpacing(15)
# Vermieter
self.vermieter_group = QGroupBox("Vermieter")
vl = QFormLayout(self.vermieter_group)
self.v_name = QLineEdit()
self.v_adresse = QLineEdit()
self.v_iban = QLineEdit()
self.v_bic = QLineEdit()
self.v_bank = QLineEdit()
vl.addRow("Name:", self.v_name)
vl.addRow("Adresse:", self.v_adresse)
vl.addRow("IBAN:", self.v_iban)
vl.addRow("BIC:", self.v_bic)
vl.addRow("Bank:", self.v_bank)
content_layout.addWidget(self.vermieter_group)
# Mieter
self.mieter_group = QGroupBox("Mieter")
ml = QFormLayout(self.mieter_group)
self.m_name = QLineEdit()
self.m_adresse = QLineEdit()
self.m_email = QLineEdit()
self.m_email.setPlaceholderText("Nur intern - nicht im PDF")
self.m_iban = QLineEdit()
self.m_iban.setPlaceholderText("Für Rückzahlung bei Guthaben")
self.m_bic = QLineEdit()
self.m_bank = QLineEdit()
ml.addRow("Name:", self.m_name)
ml.addRow("Adresse:", self.m_adresse)
ml.addRow("E-Mail:", self.m_email)
ml.addRow("IBAN:", self.m_iban)
ml.addRow("BIC:", self.m_bic)
ml.addRow("Bank:", self.m_bank)
content_layout.addWidget(self.mieter_group)
# Objekt
self.objekt_group = QGroupBox("Mietobjekt")
ol = QFormLayout(self.objekt_group)
self.objekt_adresse = QLineEdit()
self.vorauszahlung = QDoubleSpinBox()
self.vorauszahlung.setRange(0, 10000)
self.vorauszahlung.setSuffix(" €/Monat")
self.vorauszahlung.setDecimals(2)
ol.addRow("Adresse:", self.objekt_adresse)
ol.addRow("Vorauszahlung:", self.vorauszahlung)
content_layout.addWidget(self.objekt_group)
content_layout.addStretch()
scroll.setWidget(content)
layout.addWidget(scroll)
# Speichern-Button
btn_layout = QHBoxLayout()
btn_layout.addStretch()
self.btn_speichern = QPushButton("Speichern")
self.btn_speichern.setMinimumWidth(150)
self.btn_speichern.clicked.connect(self.speichern)
btn_layout.addWidget(self.btn_speichern)
layout.addLayout(btn_layout)
def lade_daten(self):
v = self.daten.vermieter
self.v_name.setText(v.get("name", ""))
self.v_adresse.setText(v.get("adresse", ""))
self.v_iban.setText(v.get("iban", ""))
self.v_bic.setText(v.get("bic", ""))
self.v_bank.setText(v.get("bank", ""))
m = self.daten.mieter
self.m_name.setText(m.get("name", ""))
self.m_adresse.setText(m.get("adresse", ""))
self.m_email.setText(m.get("email", ""))
self.m_iban.setText(m.get("iban", ""))
self.m_bic.setText(m.get("bic", ""))
self.m_bank.setText(m.get("bank", ""))
self.objekt_adresse.setText(self.daten.objekt_adresse)
self.vorauszahlung.setValue(self.daten.vorauszahlung_monat)
def speichern(self):
self.daten.vermieter = {
"name": self.v_name.text(),
"adresse": self.v_adresse.text(),
"iban": self.v_iban.text(),
"bic": self.v_bic.text(),
"bank": self.v_bank.text(),
}
self.daten.mieter = {
"name": self.m_name.text(),
"adresse": self.m_adresse.text(),
"email": self.m_email.text(),
"iban": self.m_iban.text(),
"bic": self.m_bic.text(),
"bank": self.m_bank.text(),
}
self.daten.objekt_adresse = self.objekt_adresse.text()
self.daten.vorauszahlung_monat = self.vorauszahlung.value()
self.daten.speichere_stammdaten()
QMessageBox.information(self, "Gespeichert", "Stammdaten wurden gespeichert.")
class KostenartenSeite(QWidget):
"""Seite für Kostenarten-Verwaltung"""
def __init__(self, daten: DatenManager):
super().__init__()
self.daten = daten
self.setup_ui()
self.lade_daten()
def setup_ui(self):
layout = QVBoxLayout(self)
layout.setSpacing(20)
titel = QLabel("Kostenarten verwalten")
titel.setFont(QFont("", 18, QFont.Bold))
layout.addWidget(titel)
info = QLabel("Diese Kostenarten werden bei jeder Abrechnung abgefragt.")
info.setStyleSheet("color: gray;")
layout.addWidget(info)
# Tabelle
self.tabelle = QTableWidget()
self.tabelle.setColumnCount(1)
self.tabelle.setHorizontalHeaderLabels(["Kostenart"])
self.tabelle.horizontalHeader().setStretchLastSection(True)
self.tabelle.setSelectionBehavior(QTableWidget.SelectRows)
layout.addWidget(self.tabelle)
# Buttons
btn_layout = QHBoxLayout()
self.input_neu = QLineEdit()
self.input_neu.setPlaceholderText("Neue Kostenart eingeben...")
self.input_neu.returnPressed.connect(self.hinzufuegen)
btn_layout.addWidget(self.input_neu)
self.btn_add = QPushButton("Hinzufügen")
self.btn_add.clicked.connect(self.hinzufuegen)
btn_layout.addWidget(self.btn_add)
self.btn_del = QPushButton("Löschen")
self.btn_del.clicked.connect(self.loeschen)
btn_layout.addWidget(self.btn_del)
layout.addLayout(btn_layout)
def lade_daten(self):
self.tabelle.setRowCount(len(self.daten.kostenarten))
for i, art in enumerate(self.daten.kostenarten):
self.tabelle.setItem(i, 0, QTableWidgetItem(art))
def hinzufuegen(self):
text = self.input_neu.text().strip()
if text and text not in self.daten.kostenarten:
self.daten.kostenarten.append(text)
self.daten.speichere_stammdaten()
self.lade_daten()
self.input_neu.clear()
def loeschen(self):
row = self.tabelle.currentRow()
if row >= 0:
art = self.daten.kostenarten[row]
self.daten.kostenarten.remove(art)
self.daten.speichere_stammdaten()
self.lade_daten()
class KostenZeile(QWidget):
"""Widget für eine Kostenart mit Betrag und Belegen"""
def __init__(self, kostenart, vorjahr_wert=0, parent=None):
super().__init__(parent)
self.kostenart = kostenart
self.belege = [] # Liste der Beleg-Pfade
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 2, 0, 2)
# Label
label = kostenart
if vorjahr_wert > 0:
label = f"{kostenart} (VJ: {vorjahr_wert:.2f} €)"
self.lbl = QLabel(label)
self.lbl.setMinimumWidth(200)
layout.addWidget(self.lbl)
# Betrag
self.betrag = QDoubleSpinBox()
self.betrag.setRange(0, 100000)
self.betrag.setDecimals(2)
self.betrag.setSuffix("")
self.betrag.setMinimumWidth(120)
layout.addWidget(self.betrag)
# Beleg-Button
self.btn_beleg = QPushButton("📎 Beleg...")
self.btn_beleg.setMinimumWidth(100)
self.btn_beleg.clicked.connect(self.waehle_beleg)
layout.addWidget(self.btn_beleg)
# Beleg-Anzeige
self.lbl_beleg = QLabel("")
self.lbl_beleg.setStyleSheet("color: gray; font-size: 9px;")
layout.addWidget(self.lbl_beleg, 1)
def waehle_beleg(self):
dateien, _ = QFileDialog.getOpenFileNames(
self, f"Belege für {self.kostenart}",
"", "PDF/Bilder (*.pdf *.jpg *.jpeg *.png);;Alle (*.*)"
)
if dateien:
self.belege = dateien
self.aktualisiere_beleg_anzeige()
def aktualisiere_beleg_anzeige(self):
if self.belege:
namen = [Path(b).name for b in self.belege]
text = ", ".join(namen)
if len(text) > 40:
text = f"{len(self.belege)} Belege"
self.lbl_beleg.setText(f"{text}")
self.lbl_beleg.setStyleSheet("color: green; font-size: 9px;")
else:
self.lbl_beleg.setText("")
def setze_belege(self, belege_liste):
"""Setzt Belege aus gespeichertem Entwurf"""
self.belege = [b for b in belege_liste if Path(b).exists()]
self.aktualisiere_beleg_anzeige()
class AbrechnungSeite(QWidget):
"""Seite für neue Abrechnung mit Beleg-Upload"""
def __init__(self, daten: DatenManager):
super().__init__()
self.daten = daten
self.kosten_zeilen = {} # kostenart -> KostenZeile
self.setup_ui()
def setup_ui(self):
layout = QVBoxLayout(self)
layout.setSpacing(15)
# Titel und Status
titel_layout = QHBoxLayout()
titel = QLabel("Abrechnung")
titel.setFont(QFont("", 18, QFont.Bold))
titel_layout.addWidget(titel)
self.lbl_status = QLabel("")
self.lbl_status.setStyleSheet("color: gray;")
titel_layout.addWidget(self.lbl_status)
titel_layout.addStretch()
layout.addLayout(titel_layout)
# Jahr, Monate und Laden
periode_layout = QHBoxLayout()
periode_layout.addWidget(QLabel("Jahr:"))
self.jahr = QSpinBox()
self.jahr.setRange(2000, 2100)
self.jahr.setValue(datetime.now().year - 1)
self.jahr.valueChanged.connect(self.jahr_geaendert)
periode_layout.addWidget(self.jahr)
periode_layout.addWidget(QLabel("Monate:"))
self.monate = QSpinBox()
self.monate.setRange(1, 12)
self.monate.setValue(12)
self.monate.valueChanged.connect(self.berechne)
periode_layout.addWidget(self.monate)
self.btn_laden = QPushButton("Entwurf laden")
self.btn_laden.clicked.connect(self.lade_entwurf)
periode_layout.addWidget(self.btn_laden)
periode_layout.addStretch()
layout.addLayout(periode_layout)
# Scroll-Bereich für Kosten
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.NoFrame)
scroll.setMaximumHeight(300)
self.kosten_widget = QWidget()
self.kosten_layout = QVBoxLayout(self.kosten_widget)
self.kosten_layout.setSpacing(5)
scroll.setWidget(self.kosten_widget)
kosten_group = QGroupBox("Nebenkosten (Jahressummen) - mit Belegen")
kosten_group_layout = QVBoxLayout(kosten_group)
kosten_group_layout.addWidget(scroll)
layout.addWidget(kosten_group)
# Zusammenfassung
summen_group = QGroupBox("Zusammenfassung")
sl = QFormLayout(summen_group)
self.lbl_summe_kosten = QLabel("0,00 €")
self.lbl_summe_kosten.setFont(QFont("", 11))
sl.addRow("Summe Nebenkosten:", self.lbl_summe_kosten)
self.lbl_vorauszahlung = QLabel("0,00 €")
self.lbl_vorauszahlung.setFont(QFont("", 11))
sl.addRow("Vorauszahlungen:", self.lbl_vorauszahlung)
self.lbl_differenz = QLabel("0,00 €")
self.lbl_differenz.setFont(QFont("", 14, QFont.Bold))
sl.addRow("Ergebnis:", self.lbl_differenz)
layout.addWidget(summen_group)
# Bemerkungen
bemerk_group = QGroupBox("Bemerkungen (erscheint im PDF)")
bemerk_layout = QVBoxLayout(bemerk_group)
self.bemerkungen = QTextEdit()
self.bemerkungen.setMaximumHeight(80)
self.bemerkungen.setPlaceholderText("Optionale Anmerkungen zur Abrechnung...")
bemerk_layout.addWidget(self.bemerkungen)
layout.addWidget(bemerk_group)
# Buttons
btn_layout = QHBoxLayout()
self.btn_entwurf = QPushButton("💾 Entwurf speichern")
self.btn_entwurf.clicked.connect(self.speichere_entwurf)
btn_layout.addWidget(self.btn_entwurf)
btn_layout.addStretch()
self.btn_pdf = QPushButton("📄 PDF erstellen")
self.btn_pdf.setMinimumWidth(150)
self.btn_pdf.clicked.connect(self.erstelle_pdf)
btn_layout.addWidget(self.btn_pdf)
layout.addLayout(btn_layout)
layout.addStretch()
# Initial laden (inkl. vorhandener Daten)
self.jahr_geaendert()
def jahr_geaendert(self):
"""Wird aufgerufen wenn das Jahr geändert wird - lädt automatisch vorhandene Daten"""
self.aktualisiere_kosten()
# Prüfen ob Daten existieren und automatisch laden
entwurf = self.daten.lade_entwurf(self.jahr.value())
if entwurf:
# Automatisch laden
self.monate.setValue(entwurf.get("anzahl_monate", 12))
kosten = entwurf.get("kosten", {})
belege = entwurf.get("belege", {})
for art, zeile in self.kosten_zeilen.items():
if art in kosten:
zeile.betrag.setValue(kosten[art])
if art in belege:
zeile.setze_belege(belege[art])
# Bemerkungen laden
self.bemerkungen.setPlainText(entwurf.get("bemerkungen", ""))
status = entwurf.get("status", "entwurf")
status_text = "Abrechnung" if status == "fertig" else "Entwurf"
self.lbl_status.setText(f"[{status_text} vom {entwurf.get('datum', '?')}]")
self.btn_laden.setEnabled(True)
self.berechne()
else:
self.lbl_status.setText("")
self.btn_laden.setEnabled(False)
def aktualisiere_kosten(self):
"""Erstellt die Kosten-Zeilen neu"""
# Alte Widgets entfernen
while self.kosten_layout.count():
item = self.kosten_layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
self.kosten_zeilen = {}
vorjahr = self.daten.get_vorjahr_kosten(self.jahr.value())
for art in self.daten.kostenarten:
vj_wert = vorjahr.get(art, 0)
zeile = KostenZeile(art, vj_wert)
zeile.betrag.valueChanged.connect(self.berechne)
self.kosten_layout.addWidget(zeile)
self.kosten_zeilen[art] = zeile
self.kosten_layout.addStretch()
self.berechne()
def lade_entwurf(self):
"""Lädt einen gespeicherten Entwurf"""
entwurf = self.daten.lade_entwurf(self.jahr.value())
if not entwurf:
QMessageBox.information(self, "Kein Entwurf", "Kein Entwurf für dieses Jahr gefunden.")
return
# Monate setzen
self.monate.setValue(entwurf.get("anzahl_monate", 12))
# Kosten und Belege setzen
kosten = entwurf.get("kosten", {})
belege = entwurf.get("belege", {})
for art, zeile in self.kosten_zeilen.items():
if art in kosten:
zeile.betrag.setValue(kosten[art])
if art in belege:
zeile.setze_belege(belege[art])
self.lbl_status.setText(f"[Geladen: {entwurf.get('datum', '?')}]")
self.berechne()
def speichere_entwurf(self):
"""Speichert den aktuellen Stand als Entwurf"""
kosten = {art: zeile.betrag.value() for art, zeile in self.kosten_zeilen.items()}
belege = {art: zeile.belege for art, zeile in self.kosten_zeilen.items()}
bemerkungen = self.bemerkungen.toPlainText()
self.daten.speichere_entwurf(
self.jahr.value(), kosten, belege, self.monate.value(), bemerkungen
)
self.lbl_status.setText(f"[Entwurf gespeichert: {datetime.now().strftime('%H:%M')}]")
QMessageBox.information(self, "Gespeichert", "Entwurf wurde gespeichert.\nBelege wurden kopiert.")
def berechne(self):
summe = sum(zeile.betrag.value() for zeile in self.kosten_zeilen.values())
vz = self.daten.vorauszahlung_monat * self.monate.value()
diff = summe - vz
self.lbl_summe_kosten.setText(f"{summe:,.2f}".replace(",", "X").replace(".", ",").replace("X", "."))
self.lbl_vorauszahlung.setText(f"{vz:,.2f}".replace(",", "X").replace(".", ",").replace("X", "."))
if diff > 0:
self.lbl_differenz.setText(f"Nachzahlung: {diff:,.2f}".replace(",", "X").replace(".", ",").replace("X", "."))
self.lbl_differenz.setStyleSheet("color: #CC0000;")
else:
self.lbl_differenz.setText(f"Guthaben: {abs(diff):,.2f}".replace(",", "X").replace(".", ",").replace("X", "."))
self.lbl_differenz.setStyleSheet("color: #006600;")
def erstelle_pdf(self):
if not self.daten.vermieter.get("name"):
QMessageBox.warning(self, "Fehler", "Bitte zuerst Stammdaten eingeben!")
return
# Kosten und Belege aus den Zeilen sammeln
kosten = {art: zeile.betrag.value() for art, zeile in self.kosten_zeilen.items()}
belege = {art: zeile.belege for art, zeile in self.kosten_zeilen.items()}
jahr = self.jahr.value()
anzahl_monate = self.monate.value()
bemerkungen = self.bemerkungen.toPlainText()
# Busy-Cursor anzeigen während PDF erstellt wird
QApplication.setOverrideCursor(Qt.WaitCursor)
self.lbl_status.setText("PDF wird erstellt...")
QApplication.processEvents()
try:
# Speichern (kopiert auch Belege)
gespeicherte_belege = self.daten.speichere_abrechnung(jahr, kosten, belege, anzahl_monate, bemerkungen)
# PDF erstellen (mit angehängten Belegen)
pdf_pfad = self._erstelle_pdf(jahr, kosten, anzahl_monate, gespeicherte_belege, bemerkungen)
finally:
QApplication.restoreOverrideCursor()
QMessageBox.information(self, "PDF erstellt", f"PDF gespeichert:\n{pdf_pfad}")
self.lbl_status.setText(f"[PDF erstellt: {datetime.now().strftime('%H:%M')}]")
# PDF öffnen
subprocess.run(["xdg-open", pdf_pfad])
def _erstelle_pdf(self, jahr, kosten, anzahl_monate, belege=None, bemerkungen=""):
# Absoluten Pfad sicherstellen
dok_ordner = Path(self.daten.dokumente_ordner).resolve()
jahr_ordner = dok_ordner / str(jahr)
jahr_ordner.mkdir(parents=True, exist_ok=True)
print(f"PDF-Ordner: {jahr_ordner}")
# Dateiname: Datum - Nebenkostenabrechnung - Objekt.pdf
datum_str = datetime.now().strftime("%Y-%m-%d")
objekt_safe = self.daten.objekt_adresse.replace('/', '-').replace('\\', '-').replace(':', '-')
dateiname = jahr_ordner / f"{datum_str} - Nebenkostenabrechnung - {objekt_safe}.pdf"
summe_kosten = sum(kosten.values())
summe_vz = self.daten.vorauszahlung_monat * anzahl_monate
differenz = summe_kosten - summe_vz
# Hack-Schrift für PDF registrieren
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
try:
pdfmetrics.registerFont(TTFont('Hack', '/usr/share/fonts/TTF/Hack-Regular.ttf'))
pdfmetrics.registerFont(TTFont('Hack-Bold', '/usr/share/fonts/TTF/Hack-Bold.ttf'))
FONT = 'Hack'
FONT_BOLD = 'Hack-Bold'
except:
FONT = 'Helvetica'
FONT_BOLD = 'Helvetica-Bold'
# PDF erstellen
buffer = BytesIO()
c = canvas.Canvas(buffer, pagesize=A4)
breite, hoehe = A4
def format_eur(betrag):
return f"{betrag:,.2f} EUR".replace(",", "X").replace(".", ",").replace("X", ".")
def format_iban(iban):
iban = iban.replace(" ", "")
return " ".join([iban[i:i+4] for i in range(0, len(iban), 4)])
# ============ BRIEFKOPF ============
# Absender kommt aus der Vorlage (Briefkopf)
# Absenderzeile klein (direkt über Empfänger mit 1 Leerzeile)
y = hoehe - 4.5*cm
c.setFont(FONT, 7)
absender_zeile = f"{self.daten.vermieter['name']} · {self.daten.vermieter['adresse']}"
c.drawString(2*cm, y, absender_zeile)
# Leerzeile
y -= 0.4*cm
# Empfänger (Mieter) - "An:"
y -= 0.4*cm
c.setFont(FONT_BOLD, 10)
c.drawString(2*cm, y, "An:")
y -= 0.5*cm
c.setFont(FONT, 11)
c.drawString(2*cm, y, self.daten.mieter["name"])
y -= 0.45*cm
# Adresse aufteilen falls mit Komma
adresse_teile = self.daten.mieter["adresse"].split(",")
for teil in adresse_teile:
c.drawString(2*cm, y, teil.strip())
y -= 0.45*cm
# Datum rechts
c.setFont(FONT, 10)
c.drawRightString(breite - 2*cm, y + 0.9*cm, f"Datum: {datetime.now().strftime('%d.%m.%Y')}")
# ============ BETREFF / TITEL ============
# 6 Zeilen tiefer (ca. 2.5cm)
y -= 3.3*cm
c.setFont(FONT_BOLD, 12)
c.drawString(2*cm, y, f"Nebenkostenabrechnung {jahr}")
y -= 0.5*cm
c.setFont(FONT, 10)
c.drawString(2*cm, y, f"Abrechnungszeitraum: 01.01.{jahr} - 31.12.{jahr}")
# Objekt und Umlageschlüssel
y -= 0.6*cm
c.setFont(FONT, 9)
c.drawString(2*cm, y, f"Mietobjekt: {self.daten.objekt_adresse}")
y -= 0.4*cm
c.drawString(2*cm, y, "Umlageschlüssel: 100%")
# Trennlinie
y -= 0.5*cm
c.line(2*cm, y, breite - 2*cm, y)
# Kosten
y -= 0.8*cm
c.setFont(FONT_BOLD, 11)
c.drawString(2*cm, y, "Kostenaufstellung")
y -= 0.5*cm
c.setFont(FONT_BOLD, 9)
c.drawString(2*cm, y, "Kostenart")
c.drawRightString(breite - 2*cm, y, "Betrag")
y -= 0.15*cm
c.line(2*cm, y, breite - 2*cm, y)
c.setFont(FONT, 9)
for art, betrag in kosten.items():
if betrag > 0:
y -= 0.45*cm
c.drawString(2*cm, y, art)
c.drawRightString(breite - 2*cm, y, format_eur(betrag))
y -= 0.15*cm
c.line(2*cm, y, breite - 2*cm, y)
y -= 0.45*cm
c.setFont(FONT_BOLD, 9)
c.drawString(2*cm, y, "Summe Nebenkosten")
c.drawRightString(breite - 2*cm, y, format_eur(summe_kosten))
# Vorauszahlung
y -= 0.8*cm
c.setFont(FONT_BOLD, 11)
c.drawString(2*cm, y, "Vorauszahlungen")
y -= 0.5*cm
c.setFont(FONT, 9)
c.drawString(2*cm, y, f"{anzahl_monate} Monate x {format_eur(self.daten.vorauszahlung_monat)}")
c.drawRightString(breite - 2*cm, y, format_eur(summe_vz))
# Ergebnis
y -= 0.7*cm
c.setLineWidth(1.5)
c.line(2*cm, y, breite - 2*cm, y)
y -= 0.6*cm
c.setFont(FONT_BOLD, 11)
if differenz > 0:
c.drawString(2*cm, y, "Nachzahlung durch Mieter")
c.setFillColor(colors.HexColor("#CC0000"))
else:
c.drawString(2*cm, y, "Guthaben für Mieter")
c.setFillColor(colors.HexColor("#006600"))
c.drawRightString(breite - 2*cm, y, format_eur(abs(differenz)))
c.setFillColor(colors.black)
# Zahlungshinweis
y -= 1*cm
c.setFont(FONT_BOLD, 9)
c.drawString(2*cm, y, "Zahlungshinweis:")
y -= 0.4*cm
c.setFont(FONT, 8)
if differenz > 0:
c.drawString(2*cm, y, f"Bitte überweisen Sie {format_eur(abs(differenz))} innerhalb von 30 Tagen an:")
y -= 0.4*cm
if self.daten.vermieter["iban"]:
c.drawString(2*cm, y, f"IBAN: {format_iban(self.daten.vermieter['iban'])}")
if self.daten.vermieter["bank"]:
c.drawString(10*cm, y, f"Bank: {self.daten.vermieter['bank']}")
y -= 0.35*cm
c.drawString(2*cm, y, f"Verwendungszweck: Nebenkostennachzahlung {jahr}")
else:
c.drawString(2*cm, y, f"Das Guthaben von {format_eur(abs(differenz))} wird überwiesen an:")
y -= 0.4*cm
c.drawString(2*cm, y, f"Empfänger: {self.daten.mieter['name']}")
if self.daten.mieter["iban"]:
y -= 0.35*cm
c.drawString(2*cm, y, f"IBAN: {format_iban(self.daten.mieter['iban'])}")
# Bemerkungen (falls vorhanden)
if bemerkungen and bemerkungen.strip():
y -= 0.8*cm
c.setFont(FONT_BOLD, 9)
c.drawString(2*cm, y, "Bemerkungen:")
y -= 0.4*cm
c.setFont(FONT, 8)
for zeile in bemerkungen.strip().split('\n'):
c.drawString(2*cm, y, zeile)
y -= 0.3*cm
# Anlagen-Verzeichnis (falls Belege vorhanden)
anlagen_liste = []
if belege:
for kostenart, dateien in belege.items():
for datei in dateien:
if datei and Path(datei).exists():
anlagen_liste.append((kostenart, Path(datei).name))
if anlagen_liste:
y -= 0.8*cm
c.setFont(FONT_BOLD, 9)
c.drawString(2*cm, y, f"Anlagen ({len(anlagen_liste)} Beleg{'e' if len(anlagen_liste) > 1 else ''}):")
y -= 0.4*cm
c.setFont(FONT, 8)
for i, (kostenart, anlage_name) in enumerate(anlagen_liste, 1):
c.drawString(2*cm, y, f" {i}. {kostenart}: {anlage_name}")
y -= 0.3*cm
# Rechtliche Hinweise (Pflichtangaben gem. § 556 BGB)
y -= 0.5*cm
c.setFont(FONT_BOLD, 8)
c.drawString(2*cm, y, "Rechtliche Hinweise:")
y -= 0.35*cm
c.setFont(FONT, 7)
c.drawString(2*cm, y, "• Sie haben das Recht, die dieser Abrechnung zugrunde liegenden Belege einzusehen (§ 556 Abs. 4 BGB).")
y -= 0.3*cm
c.drawString(2*cm, y, "• Einwendungen gegen diese Abrechnung sind innerhalb von 12 Monaten nach Zugang schriftlich geltend zu machen (§ 556 Abs. 3 BGB).")
y -= 0.3*cm
c.drawString(2*cm, y, "• Nach Ablauf dieser Frist können Einwendungen nicht mehr geltend gemacht werden, es sei denn, der Mieter hat die")
y -= 0.3*cm
c.drawString(2*cm, y, " verspätete Geltendmachung nicht zu vertreten.")
# Unterschrift
y -= 0.8*cm
c.setLineWidth(0.5)
c.line(2*cm, y, 6*cm, y)
y -= 0.3*cm
c.setFont(FONT, 8)
c.drawString(2*cm, y, self.daten.vermieter["name"])
c.save()
buffer.seek(0)
# PDF zusammenstellen
writer = PdfWriter()
# Mit Vorlage zusammenführen oder direkt verwenden
vorlage_pfad = self.daten.vorlage_datei
if vorlage_pfad.exists() and PYPDF2_AVAILABLE:
try:
vorlage = PdfReader(vorlage_pfad)
vorlage_seite = vorlage.pages[0]
inhalt = PdfReader(buffer)
vorlage_seite.merge_page(inhalt.pages[0])
writer.add_page(vorlage_seite)
except:
buffer.seek(0)
inhalt = PdfReader(buffer)
writer.add_page(inhalt.pages[0])
elif PYPDF2_AVAILABLE:
inhalt = PdfReader(buffer)
writer.add_page(inhalt.pages[0])
else:
# Ohne PyPDF2 - nur Abrechnung, keine Belege
with open(str(dateiname), "wb") as f:
f.write(buffer.getvalue())
return str(dateiname)
# Hilfsfunktion: Prüft ob Seite leer ist (mit OCR)
def ist_seite_leer(seite):
"""Prüft ob eine PDF-Seite leer/ohne sinnvollen Inhalt ist"""
try:
# Basis-Prüfungen
if '/Contents' not in seite:
return True
contents = seite['/Contents']
if contents is None:
return True
if '/MediaBox' in seite:
box = seite['/MediaBox']
if box[2] < 50 or box[3] < 50:
return True
# OCR-Prüfung: Hat die Seite lesbaren Text?
try:
import pytesseract
from pdf2image import convert_from_bytes
from PyPDF2 import PdfWriter as TempWriter
temp_writer = TempWriter()
temp_writer.add_page(seite)
temp_buffer = BytesIO()
temp_writer.write(temp_buffer)
temp_buffer.seek(0)
images = convert_from_bytes(temp_buffer.read(), dpi=100)
if not images:
return True
# Text extrahieren
text = pytesseract.image_to_string(images[0], lang='deu')
# Nur alphanumerische Zeichen zählen
alnum_chars = sum(1 for c in text if c.isalnum())
# Weniger als 50 Zeichen = wahrscheinlich leer
if alnum_chars < 50:
print(f"Seite gefiltert: nur {alnum_chars} Zeichen erkannt")
return True
except Exception as e:
print(f"OCR-Prüfung fehlgeschlagen: {e}")
return False
except:
return False
# Orientierung erkennen und korrigieren
def korrigiere_seite(seite):
"""Erkennt Textausrichtung mit OCR und dreht Seite falls nötig"""
try:
import pytesseract
from pdf2image import convert_from_bytes
from PyPDF2 import PdfWriter as TempWriter
# Seite temporär als PDF speichern
temp_writer = TempWriter()
temp_writer.add_page(seite)
temp_buffer = BytesIO()
temp_writer.write(temp_buffer)
temp_buffer.seek(0)
# PDF in Bild konvertieren
images = convert_from_bytes(temp_buffer.read(), dpi=150)
if not images:
return seite
# Orientierung mit Tesseract erkennen
osd = pytesseract.image_to_osd(images[0], output_type=pytesseract.Output.DICT)
rotation_needed = osd.get('rotate', 0)
print(f"OCR erkannt: Rotation {rotation_needed}°")
# Seite drehen falls nötig
if rotation_needed != 0:
seite.rotate(rotation_needed)
seite.transfer_rotation_to_content()
return seite
except Exception as e:
print(f"Orientierungserkennung fehlgeschlagen: {e}")
return seite
# Belege anhängen
beleg_seiten = [] # Sammelt alle Beleg-Seiten
if belege and PYPDF2_AVAILABLE:
for kostenart, dateien in belege.items():
for datei in dateien:
if datei and Path(datei).exists():
datei_pfad = Path(datei)
# Leere/sehr kleine Dateien überspringen
if datei_pfad.stat().st_size < 500: # < 500 Bytes
print(f"Überspringe leere Datei: {datei}")
continue
# PDFs direkt anhängen
if datei_pfad.suffix.lower() == '.pdf':
try:
beleg_pdf = PdfReader(str(datei_pfad))
for seite in beleg_pdf.pages:
# Leere Seiten überspringen
if ist_seite_leer(seite):
print(f"Überspringe leere Seite in: {datei}")
continue
# ERST rotieren
seite = korrigiere_seite(seite)
beleg_seiten.append((seite, kostenart, datei_pfad.name))
except Exception as e:
print(f"Fehler beim Anhängen von {datei}: {e}")
# Bilder in PDF konvertieren
elif datei_pfad.suffix.lower() in ['.jpg', '.jpeg', '.png']:
try:
from reportlab.lib.utils import ImageReader
bild_buffer = BytesIO()
bild_c = canvas.Canvas(bild_buffer, pagesize=A4)
bild_breite, bild_hoehe = A4
img = ImageReader(str(datei_pfad))
img_w, img_h = img.getSize()
# Skalierung (max 85% wegen Fußzeile)
max_w = bild_breite * 0.85
max_h = (bild_hoehe - 2*cm) * 0.85
scale = min(max_w / img_w, max_h / img_h)
new_w = img_w * scale
new_h = img_h * scale
# Zentriert zeichnen (etwas höher wegen Fußzeile)
x = (bild_breite - new_w) / 2
y = (bild_hoehe - new_h) / 2 + 0.5*cm
bild_c.drawImage(str(datei_pfad), x, y, new_w, new_h)
bild_c.save()
bild_buffer.seek(0)
bild_pdf = PdfReader(bild_buffer)
beleg_seiten.append((bild_pdf.pages[0], kostenart, datei_pfad.name))
except Exception as e:
print(f"Fehler beim Konvertieren von {datei}: {e}")
# Alle Beleg-Seiten zum Writer hinzufügen
for seite, _, _ in beleg_seiten:
writer.add_page(seite)
# Gesamtseitenzahl ermitteln
gesamt_seiten = len(writer.pages)
# Seitenzahlen als Overlay hinzufügen
final_writer = PdfWriter()
for i, seite in enumerate(writer.pages):
seiten_nr = i + 1
# Text für Fußzeile
seiten_text = f"Seite {seiten_nr} von {gesamt_seiten}"
anlage_text = ""
if seiten_nr > 1 and (seiten_nr - 2) < len(beleg_seiten):
_, kostenart, dateiname_beleg = beleg_seiten[seiten_nr - 2]
anlage_text = f"Anlage: {kostenart} - {dateiname_beleg}"
# Overlay erstellen - Text links am Rand vertikal
overlay_buffer = BytesIO()
overlay_c = canvas.Canvas(overlay_buffer, pagesize=A4)
overlay_c.setFont(FONT, 8)
# Beide Texte links am Rand (vertikal, von unten nach oben)
overlay_c.saveState()
overlay_c.translate(0.8*cm, 2*cm)
overlay_c.rotate(90)
# Anlage-Text und Seitenzahl kombinieren
if anlage_text:
overlay_c.drawString(0, 0, f"{anlage_text} - {seiten_text}")
else:
overlay_c.drawString(0, 0, seiten_text)
overlay_c.restoreState()
overlay_c.save()
overlay_buffer.seek(0)
# Overlay auf Seite legen
overlay_pdf = PdfReader(overlay_buffer)
seite.merge_page(overlay_pdf.pages[0])
final_writer.add_page(seite)
# Finales PDF speichern
with open(str(dateiname), "wb") as f:
final_writer.write(f)
return str(dateiname)
class StatistikSeite(QWidget):
"""Seite für Statistik/Historie"""
def __init__(self, daten: DatenManager):
super().__init__()
self.daten = daten
self.setup_ui()
def setup_ui(self):
layout = QVBoxLayout(self)
layout.setSpacing(20)
titel = QLabel("Statistik / Historie")
titel.setFont(QFont("", 18, QFont.Bold))
layout.addWidget(titel)
# Übersicht
self.tabelle = QTableWidget()
self.tabelle.setColumnCount(4)
self.tabelle.setHorizontalHeaderLabels(["Jahr", "Kosten", "Vorauszahlung", "Differenz"])
self.tabelle.horizontalHeader().setStretchLastSection(True)
self.tabelle.setSelectionBehavior(QTableWidget.SelectRows)
layout.addWidget(self.tabelle)
# Refresh Button
btn = QPushButton("Aktualisieren")
btn.clicked.connect(self.lade_daten)
layout.addWidget(btn)
self.lade_daten()
def lade_daten(self):
self.daten.lade_historie()
jahre = sorted(self.daten.historie.keys(), reverse=True)
self.tabelle.setRowCount(len(jahre))
for i, jahr in enumerate(jahre):
eintrag = self.daten.historie[jahr]
self.tabelle.setItem(i, 0, QTableWidgetItem(str(jahr)))
self.tabelle.setItem(i, 1, QTableWidgetItem(f"{eintrag.get('summe_kosten', 0):.2f}"))
self.tabelle.setItem(i, 2, QTableWidgetItem(f"{eintrag.get('summe_vorauszahlung', 0):.2f}"))
diff = eintrag.get('differenz', 0)
diff_item = QTableWidgetItem(f"{diff:+.2f}")
if diff > 0:
diff_item.setForeground(QColor("#CC0000"))
else:
diff_item.setForeground(QColor("#006600"))
self.tabelle.setItem(i, 3, diff_item)
class EinstellungenSeite(QWidget):
"""Seite für Einstellungen"""
def __init__(self, daten: DatenManager):
super().__init__()
self.daten = daten
self.setup_ui()
def setup_ui(self):
layout = QVBoxLayout(self)
layout.setSpacing(20)
titel = QLabel("Einstellungen")
titel.setFont(QFont("", 18, QFont.Bold))
layout.addWidget(titel)
# Ordner-Einstellungen
ordner_group = QGroupBox("Ordner")
ol = QFormLayout(ordner_group)
# Daten-Ordner
daten_layout = QHBoxLayout()
self.daten_pfad = QLineEdit(self.daten.daten_ordner)
daten_layout.addWidget(self.daten_pfad)
btn_daten = QPushButton("...")
btn_daten.setFixedWidth(40)
btn_daten.clicked.connect(lambda: self.waehle_ordner(self.daten_pfad))
daten_layout.addWidget(btn_daten)
ol.addRow("Daten:", daten_layout)
# Dokumente-Ordner
dok_layout = QHBoxLayout()
self.dok_pfad = QLineEdit(self.daten.dokumente_ordner)
dok_layout.addWidget(self.dok_pfad)
btn_dok = QPushButton("...")
btn_dok.setFixedWidth(40)
btn_dok.clicked.connect(lambda: self.waehle_ordner(self.dok_pfad))
dok_layout.addWidget(btn_dok)
ol.addRow("Dokumente:", dok_layout)
# Vorlagen-Ordner
vorl_layout = QHBoxLayout()
self.vorl_pfad = QLineEdit(self.daten.vorlagen_ordner)
vorl_layout.addWidget(self.vorl_pfad)
btn_vorl = QPushButton("...")
btn_vorl.setFixedWidth(40)
btn_vorl.clicked.connect(lambda: self.waehle_ordner(self.vorl_pfad))
vorl_layout.addWidget(btn_vorl)
ol.addRow("Vorlagen:", vorl_layout)
layout.addWidget(ordner_group)
# PDF-Einstellungen
pdf_group = QGroupBox("PDF-Vorlage")
pl = QFormLayout(pdf_group)
self.briefkopf = QDoubleSpinBox()
self.briefkopf.setRange(0, 15)
self.briefkopf.setDecimals(1)
self.briefkopf.setSuffix(" cm")
self.briefkopf.setValue(self.daten.briefkopf_hoehe)
pl.addRow("Briefkopf-Höhe:", self.briefkopf)
self.vorlage_label = QLabel(self._get_vorlage_status())
pl.addRow("Vorlage-Status:", self.vorlage_label)
layout.addWidget(pdf_group)
# Speichern
btn_layout = QHBoxLayout()
btn_layout.addStretch()
btn = QPushButton("Speichern")
btn.setMinimumWidth(150)
btn.clicked.connect(self.speichern)
btn_layout.addWidget(btn)
layout.addLayout(btn_layout)
layout.addStretch()
def waehle_ordner(self, line_edit):
ordner = QFileDialog.getExistingDirectory(self, "Ordner wählen", line_edit.text())
if ordner:
line_edit.setText(ordner)
def _get_vorlage_status(self):
if self.daten.vorlage_datei.exists():
return f"{self.daten.vorlage_datei}"
return f"Keine Vorlage in {self.daten.vorlagen_ordner}"
def speichern(self):
# Ordner aktualisieren
self.daten.daten_ordner = self.daten_pfad.text()
self.daten.dokumente_ordner = self.dok_pfad.text()
self.daten.vorlagen_ordner = self.vorl_pfad.text()
self.daten.speichere_config()
self.daten.aktualisiere_pfade()
# PDF-Einstellungen
self.daten.briefkopf_hoehe = self.briefkopf.value()
self.daten.speichere_stammdaten()
# Status aktualisieren
self.vorlage_label.setText(self._get_vorlage_status())
QMessageBox.information(self, "Gespeichert", "Einstellungen gespeichert.")
VERSION = "1.0.0"
class HauptFenster(QMainWindow):
"""Hauptfenster der Anwendung"""
def __init__(self):
super().__init__()
self.daten = DatenManager()
self.setup_ui()
self.setup_menu()
def setup_ui(self):
self.setWindowTitle("Nebenkostenabrechnung")
self.setMinimumSize(900, 650)
# Icon setzen
icon_pfad = Path(__file__).parent / "icon.svg"
if icon_pfad.exists():
self.setWindowIcon(QIcon(str(icon_pfad)))
# Zentrales Widget
central = QWidget()
self.setCentralWidget(central)
layout = QHBoxLayout(central)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Navigation (links)
self.nav = QListWidget()
self.nav.setFixedWidth(180)
self.nav.setSpacing(2)
items = [
("📝 Neue Abrechnung", 0),
("👤 Stammdaten", 1),
("📋 Kostenarten", 2),
("📊 Statistik", 3),
("⚙️ Einstellungen", 4),
]
for text, _ in items:
item = QListWidgetItem(text)
item.setSizeHint(QSize(170, 45))
self.nav.addItem(item)
self.nav.currentRowChanged.connect(self.seite_wechseln)
layout.addWidget(self.nav)
# Trennlinie
line = QFrame()
line.setFrameShape(QFrame.VLine)
line.setStyleSheet("color: #ccc;")
layout.addWidget(line)
# Seiten (rechts)
self.stack = QStackedWidget()
self.abrechnung_seite = AbrechnungSeite(self.daten)
self.stammdaten_seite = StammdatenSeite(self.daten)
self.kostenarten_seite = KostenartenSeite(self.daten)
self.statistik_seite = StatistikSeite(self.daten)
self.einstellungen_seite = EinstellungenSeite(self.daten)
self.stack.addWidget(self.abrechnung_seite)
self.stack.addWidget(self.stammdaten_seite)
self.stack.addWidget(self.kostenarten_seite)
self.stack.addWidget(self.statistik_seite)
self.stack.addWidget(self.einstellungen_seite)
content_widget = QWidget()
content_layout = QVBoxLayout(content_widget)
content_layout.setContentsMargins(20, 20, 20, 20)
content_layout.addWidget(self.stack)
layout.addWidget(content_widget, 1)
# Erste Seite auswählen
self.nav.setCurrentRow(0)
def setup_menu(self):
"""Erstellt die Menüleiste"""
menubar = self.menuBar()
# Datei-Menü
datei_menu = menubar.addMenu("&Datei")
action_neu = QAction("&Neue Abrechnung", self)
action_neu.setShortcut(QKeySequence("Ctrl+N"))
action_neu.triggered.connect(lambda: self.nav.setCurrentRow(0))
datei_menu.addAction(action_neu)
action_drucken = QAction("&Drucken...", self)
action_drucken.setShortcut(QKeySequence("Ctrl+P"))
action_drucken.triggered.connect(self.drucken)
datei_menu.addAction(action_drucken)
action_ordner = QAction("Dokumente-&Ordner öffnen", self)
action_ordner.triggered.connect(self.ordner_oeffnen)
datei_menu.addAction(action_ordner)
datei_menu.addSeparator()
action_beenden = QAction("&Beenden", self)
action_beenden.setShortcut(QKeySequence("Ctrl+Q"))
action_beenden.triggered.connect(self.close)
datei_menu.addAction(action_beenden)
# Bearbeiten-Menü
bearbeiten_menu = menubar.addMenu("&Bearbeiten")
action_stamm = QAction("&Stammdaten", self)
action_stamm.triggered.connect(lambda: self.nav.setCurrentRow(1))
bearbeiten_menu.addAction(action_stamm)
action_kosten = QAction("&Kostenarten", self)
action_kosten.triggered.connect(lambda: self.nav.setCurrentRow(2))
bearbeiten_menu.addAction(action_kosten)
action_einst = QAction("&Einstellungen", self)
action_einst.triggered.connect(lambda: self.nav.setCurrentRow(4))
bearbeiten_menu.addAction(action_einst)
# Ansicht-Menü
ansicht_menu = menubar.addMenu("&Ansicht")
action_statistik = QAction("&Statistik / Historie", self)
action_statistik.triggered.connect(lambda: self.nav.setCurrentRow(3))
ansicht_menu.addAction(action_statistik)
# Hilfe-Menü
hilfe_menu = menubar.addMenu("&?")
action_info = QAction("&Info", self)
action_info.setShortcut(QKeySequence("F1"))
action_info.triggered.connect(self.zeige_info)
hilfe_menu.addAction(action_info)
def drucken(self):
"""Öffnet den Druckdialog für das letzte PDF"""
# Prüfen ob ein PDF existiert
jahr = self.abrechnung_seite.jahr.value()
mieter_name = self.daten.mieter.get("name", "").replace(" ", "_")
pdf_pfad = Path(self.daten.dokumente_ordner) / str(jahr) / f"Nebenkostenabrechnung_{jahr}_{mieter_name}.pdf"
if not pdf_pfad.exists():
QMessageBox.warning(self, "Kein PDF", "Bitte erst eine Abrechnung erstellen.")
return
# Druckdialog
printer = QPrinter(QPrinter.HighResolution)
dialog = QPrintDialog(printer, self)
if dialog.exec() == QPrintDialog.Accepted:
# PDF mit Systembefehl drucken
import subprocess
subprocess.run(["lpr", str(pdf_pfad)])
QMessageBox.information(self, "Gedruckt", f"Druckauftrag gesendet:\n{pdf_pfad.name}")
def ordner_oeffnen(self):
"""Öffnet den Dokumente-Ordner"""
subprocess.run(["xdg-open", self.daten.dokumente_ordner])
def zeige_info(self):
"""Zeigt den Info-Dialog"""
info_text = f"""<h2>Nebenkostenabrechnung</h2>
<p><b>Version:</b> {VERSION}</p>
<p><b>Beschreibung:</b><br>
Erstellt Nebenkostenabrechnungen für Mietobjekte
gemäß § 556 BGB mit allen Pflichtangaben.</p>
<p><b>Funktionen:</b></p>
<ul>
<li>Automatische Speicherung von Entwürfen</li>
<li>Belege anhängen (PDF, JPG, PNG)</li>
<li>PDF-Vorlage mit Briefkopf</li>
<li>Vorjahresvergleich</li>
<li>Historie aller Abrechnungen</li>
</ul>
<p><b>Ordner:</b></p>
<ul>
<li>Daten: {self.daten.daten_ordner}</li>
<li>Dokumente: {self.daten.dokumente_ordner}</li>
<li>Vorlagen: {self.daten.vorlagen_ordner}</li>
</ul>
<p><b>PDF-Vorlage:</b><br>
Legen Sie eine Datei <code>vorlage.pdf</code> im Vorlagen-Ordner ab,
um einen Briefkopf/Wasserzeichen zu verwenden.</p>
<hr>
<p style="color: gray;">© 2024 - Entwickelt mit Python & PySide6</p>
"""
QMessageBox.about(self, "Info", info_text)
def seite_wechseln(self, index):
self.stack.setCurrentIndex(index)
# Daten aktualisieren
if index == 0:
self.abrechnung_seite.aktualisiere_kosten()
elif index == 2:
self.kostenarten_seite.lade_daten()
elif index == 3:
self.statistik_seite.lade_daten()
def installiere_startmenu():
"""Installiert Startmenü-Eintrag beim ersten Start"""
# Pfade
programm_ordner = Path(__file__).parent.resolve()
desktop_quelle = programm_ordner / "nebenkostenabrechnung.desktop"
desktop_ziel = Path.home() / ".local" / "share" / "applications" / "nebenkostenabrechnung.desktop"
# Nur installieren wenn Quelldatei existiert und Ziel noch nicht
if desktop_quelle.exists() and not desktop_ziel.exists():
try:
desktop_ziel.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(desktop_quelle, desktop_ziel)
print(f"Startmenü-Eintrag installiert: {desktop_ziel}")
except Exception as e:
print(f"Startmenü-Installation fehlgeschlagen: {e}")
def main():
# Startmenü-Eintrag installieren (beim ersten Start)
installiere_startmenu()
app = QApplication(sys.argv)
app.setStyle("Fusion")
fenster = HauptFenster()
fenster.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()