Provisions-Manager 95: Import, Händler-DB, Auszahlungsregeln, Excel-Export, Win95-Oberfläche, Wine-Build
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
322d2ed1c3
25 files changed
+3366
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,803 @@
|
||||
"""Hauptfenster des Provisions-Managers 95."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tkinter as tk
|
||||
import traceback
|
||||
from collections import defaultdict
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from tkinter import filedialog
|
||||
|
||||
from provtool import APP_NAME, config
|
||||
from provtool import export as exp
|
||||
from provtool.db import UNZUGEORDNET, Datenbank
|
||||
from provtool.gui import dialogs as dlg
|
||||
from provtool.gui import win95 as w
|
||||
from provtool.importer import ANBIETER, ImportFehler, lies_datei
|
||||
from provtool.money import fmt, fmt_percent, parse_cent
|
||||
|
||||
ALLE = -1 # Ansicht „Alle Positionen“
|
||||
MONATE = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober",
|
||||
"November", "Dezember"]
|
||||
|
||||
|
||||
def _datum_de(iso: str | None) -> str:
|
||||
if not iso:
|
||||
return ""
|
||||
y, m, d = iso.split("-")
|
||||
return f"{d}.{m}.{y}"
|
||||
|
||||
|
||||
def _zeitraum_kurz(z: str | None) -> str:
|
||||
"""'2026.10.01-2026.10.07' → '01.10.–07.10.'"""
|
||||
m = re.fullmatch(r"(\d{4})\.(\d{2})\.(\d{2})\s*-\s*(\d{4})\.(\d{2})\.(\d{2})", (z or "").strip())
|
||||
if m:
|
||||
return f"{m[3]}.{m[2]}.–{m[6]}.{m[5]}."
|
||||
return z or ""
|
||||
|
||||
|
||||
def _oeffne_ordner(pfad: str):
|
||||
try:
|
||||
if sys.platform.startswith("win"):
|
||||
os.startfile(pfad) # type: ignore[attr-defined]
|
||||
elif sys.platform == "darwin":
|
||||
subprocess.Popen(["open", pfad])
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", pfad])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class App:
|
||||
def __init__(self, root: tk.Tk):
|
||||
self.root = root
|
||||
self.settings = config.load()
|
||||
self.db = Datenbank(config.db_path(self.settings))
|
||||
self.aid: int | None = None
|
||||
self.positionen: list[dict] = []
|
||||
self.haendler_namen: dict[int, str] = {}
|
||||
self.ansicht: int = ALLE
|
||||
self._edit: tk.Entry | None = None
|
||||
|
||||
w.apply(root)
|
||||
self.icons = w.Icons(root)
|
||||
root._app_icon = self.icons("app", 2) # type: ignore[attr-defined]
|
||||
root.iconphoto(True, self.icons("app", 2), self.icons("app", 1))
|
||||
root.title(APP_NAME)
|
||||
root.geometry(self.settings.get("fenster", "1200x720"))
|
||||
root.minsize(900, 520)
|
||||
root.protocol("WM_DELETE_WINDOW", self.beenden)
|
||||
root.report_callback_exception = self._fehler_handler
|
||||
|
||||
self._menue()
|
||||
self._toolbar()
|
||||
self._body()
|
||||
self.status = w.Statusleiste(root, [("info", 0), ("pos", 16), ("orig", 22), ("ausz", 22)])
|
||||
self.status.pack(side="bottom", fill="x")
|
||||
self._tasten()
|
||||
|
||||
abr = self.db.abrechnungen()
|
||||
if abr:
|
||||
self.oeffnen(abr[0]["id"])
|
||||
else:
|
||||
self._aktualisieren()
|
||||
root.after(300, self._willkommen)
|
||||
|
||||
# ================================================================= Aufbau
|
||||
def _menue(self):
|
||||
m = tk.Menu(self.root, tearoff=0)
|
||||
d = tk.Menu(m, tearoff=0)
|
||||
d.add_command(label="Neue Abrechnung…", accelerator="Strg+N", command=self.neu)
|
||||
d.add_command(label="Abrechnung öffnen…", accelerator="Strg+O", command=self.abrechnung_waehlen)
|
||||
d.add_separator()
|
||||
d.add_command(label="Dateien importieren…", accelerator="Strg+I", command=self.importieren)
|
||||
d.add_command(label="Importierte Dateien…", command=self.dateien)
|
||||
d.add_command(label="Exportieren…", accelerator="Strg+E", command=self.exportieren)
|
||||
d.add_separator()
|
||||
d.add_command(label="Datensicherung erstellen…", command=self.sicherung)
|
||||
d.add_command(label="Datensicherung einspielen…", command=self.wiederherstellen)
|
||||
d.add_command(label="Einstellungen…", command=self.einstellungen)
|
||||
d.add_separator()
|
||||
d.add_command(label="Beenden", accelerator="Alt+F4", command=self.beenden)
|
||||
m.add_cascade(label="Datei", menu=d, underline=0)
|
||||
|
||||
b = tk.Menu(m, tearoff=0)
|
||||
b.add_command(label="Betrag ändern…", accelerator="F2", command=self.betrag_bearbeiten)
|
||||
b.add_command(label="Auszahlung in Prozent…", accelerator="F4", command=self.prozent)
|
||||
b.add_command(label="Auf Regel zurücksetzen", command=self.zuruecksetzen)
|
||||
b.add_separator()
|
||||
b.add_command(label="Alle Beträge neu berechnen", command=self.neu_berechnen)
|
||||
m.add_cascade(label="Bearbeiten", menu=b, underline=0)
|
||||
|
||||
h = tk.Menu(m, tearoff=0)
|
||||
h.add_command(label="Händler verwalten…", accelerator="Strg+H", command=self.haendler)
|
||||
h.add_command(label="Kennung des Eintrags zuordnen…", command=self.kennung_zuordnen_auswahl)
|
||||
m.add_cascade(label="Händler", menu=h, underline=0)
|
||||
|
||||
hi = tk.Menu(m, tearoff=0)
|
||||
hi.add_command(label="Kurzanleitung", accelerator="F1", command=self.hilfe)
|
||||
hi.add_separator()
|
||||
hi.add_command(label=f"Info über {APP_NAME}…", command=lambda: dlg.InfoDialog(self.root).zeigen())
|
||||
m.add_cascade(label="?", menu=hi)
|
||||
self.root.configure(menu=m)
|
||||
|
||||
def _toolbar(self):
|
||||
tb = tk.Frame(self.root, bg=w.FACE, bd=1, relief="raised")
|
||||
tb.pack(side="top", fill="x")
|
||||
for text, icon, cmd, tip in (
|
||||
("Neu", "neu", self.neu, "Neue Abrechnung anlegen (Strg+N)"),
|
||||
("Öffnen", "oeffnen", self.abrechnung_waehlen, "Gespeicherte Abrechnung öffnen (Strg+O)"),
|
||||
(None, None, None, None),
|
||||
("Import", "import", self.importieren, "CSV-/Excel-Dateien einlesen (Strg+I)"),
|
||||
("Export", "export", self.exportieren, "Händler-Abrechnungen als Excel speichern (Strg+E)"),
|
||||
(None, None, None, None),
|
||||
("Händler", "haendler", self.haendler, "Händler und Kennungen verwalten (Strg+H)"),
|
||||
("Prozent", "prozent", self.prozent, "Auszahlung für Auswahl in Prozent festlegen (F4)"),
|
||||
("Betrag", "rechner", self.betrag_bearbeiten, "Betrag der Position ändern (F2)"),
|
||||
):
|
||||
if text is None:
|
||||
w.trenner(tb).pack(side="left", fill="y", padx=4, pady=3)
|
||||
continue
|
||||
w.ToolbarButton(tb, text, self.icons(icon), cmd, tip).pack(side="left", padx=1, pady=1)
|
||||
self.v_suche = tk.StringVar()
|
||||
sf = tk.Frame(tb, bg=w.FACE)
|
||||
sf.pack(side="right", padx=8)
|
||||
tk.Label(sf, text="Suchen:").pack(side="left")
|
||||
e = tk.Entry(sf, textvariable=self.v_suche, width=22)
|
||||
e.pack(side="left", padx=4)
|
||||
self.v_suche.trace_add("write", lambda *_: self._tabelle())
|
||||
self._suchfeld = e
|
||||
|
||||
def _body(self):
|
||||
desk = tk.Frame(self.root, bg=w.TEAL)
|
||||
desk.pack(side="top", fill="both", expand=True)
|
||||
pw = tk.PanedWindow(desk, orient="horizontal", bg=w.TEAL, sashwidth=6, bd=0)
|
||||
pw.pack(fill="both", expand=True, padx=6, pady=6)
|
||||
|
||||
# --- Händler-Fenster
|
||||
self.f_h = w.Fenster(pw, "Händler", self.icons("haendler"))
|
||||
r, self.htree = w.scroll_tree(self.f_h.inhalt, columns=("anzahl", "summe"), show="tree headings",
|
||||
selectmode="browse")
|
||||
self.htree.heading("#0", text="Händler", anchor="w")
|
||||
self.htree.heading("anzahl", text="Pos.")
|
||||
self.htree.heading("summe", text="Auszahlung")
|
||||
self.htree.column("#0", width=150, minwidth=80, stretch=False)
|
||||
self.htree.column("anzahl", width=38, anchor="e", stretch=False)
|
||||
self.htree.column("summe", width=84, anchor="e", stretch=False)
|
||||
w.auto_breite(self.htree, {"#0": 1})
|
||||
self.htree.tag_configure("grau", foreground=w.SHADOW)
|
||||
self.htree.tag_configure("fett", font=w.FONT_BOLD)
|
||||
r.pack(fill="both", expand=True)
|
||||
self.htree.bind("<<TreeviewSelect>>", self._haendler_gewaehlt)
|
||||
self.htree.bind("<Double-1>", lambda e: self._haendler_doppelklick())
|
||||
pw.add(self.f_h, minsize=240, width=310)
|
||||
|
||||
# --- Positionen-Fenster
|
||||
self.f_p = w.Fenster(pw, "Positionen", self.icons("neu"))
|
||||
cols = ("datum", "rufnummer", "tarif", "zeitraum", "kennung", "orig", "proz", "ausz")
|
||||
r2, self.ptree = w.scroll_tree(self.f_p.inhalt, columns=cols, show="tree headings", selectmode="extended")
|
||||
self.ptree.heading("#0", text="Provisionsart / Kunde", anchor="w")
|
||||
self.ptree.column("#0", width=200, minwidth=140, stretch=False)
|
||||
for c, t, b, a in (("datum", "Datum", 76, "w"), ("rufnummer", "Rufnummer", 100, "w"),
|
||||
("tarif", "Tarif", 120, "w"), ("zeitraum", "Zeitraum", 84, "w"),
|
||||
("kennung", "Kennung", 70, "w"),
|
||||
("orig", "Provision", 78, "e"), ("proz", "%", 52, "e"), ("ausz", "Auszahlung", 84, "e")):
|
||||
self.ptree.heading(c, text=t, anchor=a)
|
||||
self.ptree.column(c, width=b, minwidth=80 if c == "tarif" else 30, anchor=a, stretch=False)
|
||||
w.auto_breite(self.ptree, {"#0": 0.6, "tarif": 0.4})
|
||||
self.ptree.tag_configure("gruppe", background=w.LIGHT, font=w.FONT_BOLD)
|
||||
self.ptree.tag_configure("manuell", foreground="#0000c0")
|
||||
self.ptree.tag_configure("abw", foreground="#a00000")
|
||||
self.ptree.tag_configure("negativ", foreground="#a00000")
|
||||
r2.pack(fill="both", expand=True)
|
||||
self.ptree.bind("<Double-1>", self._doppelklick)
|
||||
self.ptree.bind("<Button-3>", self._kontextmenue)
|
||||
self.ptree.bind("<<TreeviewSelect>>", lambda e: self._status_auswahl())
|
||||
|
||||
leiste = tk.Frame(self.f_p.inhalt, bg=w.FACE)
|
||||
leiste.pack(fill="x", pady=(4, 0))
|
||||
w.knopf(leiste, "Prozent…", self.prozent, self.icons("prozent")).pack(side="left")
|
||||
w.knopf(leiste, "Betrag ändern…", self.betrag_bearbeiten, self.icons("rechner")).pack(side="left", padx=4)
|
||||
w.knopf(leiste, "Zurücksetzen", self.zuruecksetzen).pack(side="left")
|
||||
self.v_alle_auf = tk.BooleanVar(value=True)
|
||||
w.knopf(leiste, "Alle zuklappen", lambda: self._klappen(False)).pack(side="right")
|
||||
w.knopf(leiste, "Alle aufklappen", lambda: self._klappen(True)).pack(side="right", padx=4)
|
||||
tk.Label(leiste, text="Blau = von Hand · Rot = abweichend", fg=w.SHADOW).pack(
|
||||
side="left", padx=8)
|
||||
pw.add(self.f_p, minsize=500)
|
||||
|
||||
def _tasten(self):
|
||||
r = self.root
|
||||
r.bind_all("<Control-n>", lambda e: self.neu())
|
||||
r.bind_all("<Control-o>", lambda e: self.abrechnung_waehlen())
|
||||
r.bind_all("<Control-i>", lambda e: self.importieren())
|
||||
r.bind_all("<Control-e>", lambda e: self.exportieren())
|
||||
r.bind_all("<Control-h>", lambda e: self.haendler())
|
||||
r.bind_all("<Control-f>", lambda e: self._suchfeld.focus_set())
|
||||
r.bind_all("<F1>", lambda e: self.hilfe())
|
||||
self.ptree.bind("<F2>", lambda e: self.betrag_bearbeiten())
|
||||
self.ptree.bind("<Return>", lambda e: self.betrag_bearbeiten())
|
||||
self.ptree.bind("<F4>", lambda e: self.prozent())
|
||||
|
||||
# ================================================================= Daten & Anzeige
|
||||
def oeffnen(self, aid: int):
|
||||
self.aid = aid
|
||||
self._aktualisieren()
|
||||
|
||||
def _aktualisieren(self, behalte_auswahl: bool = True):
|
||||
self.haendler_namen = {h.id: h.name for h in self.db.haendler_liste()}
|
||||
if self.aid and self.db.abrechnung(self.aid):
|
||||
self.positionen = self.db.positionen(self.aid)
|
||||
titel = self.db.abrechnung(self.aid)["titel"]
|
||||
self.root.title(f"{APP_NAME} - [{titel}]")
|
||||
self.status.set("info", f" Abrechnung: {titel}")
|
||||
else:
|
||||
self.aid = None
|
||||
self.positionen = []
|
||||
self.root.title(APP_NAME)
|
||||
self.status.set("info", " Keine Abrechnung geöffnet – Datei ▸ Neue Abrechnung oder Import")
|
||||
self._haendlerliste(behalte_auswahl)
|
||||
self._tabelle()
|
||||
|
||||
def _gefiltert(self) -> list[dict]:
|
||||
if self.ansicht == ALLE:
|
||||
return self.positionen
|
||||
return [p for p in self.positionen if p["haendler_id"] == self.ansicht]
|
||||
|
||||
def _haendlerliste(self, behalte_auswahl=True):
|
||||
t = self.htree
|
||||
t.delete(*t.get_children())
|
||||
stat: dict[int, list[int]] = defaultdict(lambda: [0, 0, 0])
|
||||
for p in self.positionen:
|
||||
s = stat[p["haendler_id"]]
|
||||
s[0] += 1
|
||||
s[1] += p["betrag"]
|
||||
s[2] += p["betrag_orig"]
|
||||
ges = [sum(s[0] for s in stat.values()), sum(s[1] for s in stat.values())]
|
||||
t.insert("", "end", iid=str(ALLE), text="(Alle Positionen)", values=(ges[0], fmt(ges[1])), tags=("fett",))
|
||||
for h in self.db.haendler_liste():
|
||||
s = stat.get(h.id, [0, 0, 0])
|
||||
tags = () if s[0] else ("grau",)
|
||||
t.insert("", "end", iid=str(h.id), text=h.name, values=(s[0] or "", fmt(s[1]) if s[0] else ""), tags=tags)
|
||||
s = stat.get(UNZUGEORDNET)
|
||||
if s:
|
||||
t.insert("", "end", iid=str(UNZUGEORDNET), text="(Nicht zugeordnet)", values=(s[0], fmt(s[1])),
|
||||
tags=("grau",))
|
||||
ziel = str(self.ansicht) if behalte_auswahl and t.exists(str(self.ansicht)) else str(ALLE)
|
||||
self.ansicht = int(ziel)
|
||||
t.selection_set(ziel)
|
||||
t.see(ziel)
|
||||
|
||||
def _haendler_gewaehlt(self, _e=None):
|
||||
sel = self.htree.selection()
|
||||
if sel and int(sel[0]) != self.ansicht:
|
||||
self.ansicht = int(sel[0])
|
||||
self._tabelle()
|
||||
|
||||
def _haendler_doppelklick(self):
|
||||
if self.ansicht > 0:
|
||||
self.haendler(self.ansicht)
|
||||
elif self.ansicht == UNZUGEORDNET:
|
||||
self.kennung_zuordnen_auswahl()
|
||||
|
||||
def _gruppe_von(self, p: dict) -> str:
|
||||
if self.ansicht == UNZUGEORDNET:
|
||||
return f"{ANBIETER.get(p['anbieter'], p['anbieter'])} · Kennung {p['kennung'] or '(leer)'}"
|
||||
if self.ansicht == ALLE:
|
||||
name = self.haendler_namen.get(p["haendler_id"], "(Nicht zugeordnet)")
|
||||
return f"{name} · {p['provisionsart'] or '(ohne)'}"
|
||||
return f"{ANBIETER.get(p['anbieter'], p['anbieter'])} · {p['provisionsart'] or '(ohne Provisionsart)'}"
|
||||
|
||||
def _tabelle(self):
|
||||
if self._edit:
|
||||
self._edit.destroy()
|
||||
self._edit = None
|
||||
t = self.ptree
|
||||
offen = {iid for iid in t.get_children() if t.item(iid, "open")}
|
||||
war_leer = not t.get_children()
|
||||
t.delete(*t.get_children())
|
||||
such = self.v_suche.get().strip().lower()
|
||||
pos = self._gefiltert()
|
||||
if such:
|
||||
pos = [p for p in pos if such in " ".join(str(p.get(k) or "") for k in (
|
||||
"rufnummer", "vorname", "name", "tarif", "provisionsart", "kennung", "zeitraum", "aktionscode",
|
||||
"imei")).lower()]
|
||||
gruppen: dict[str, list[dict]] = defaultdict(list)
|
||||
for p in exp.sortiert(pos):
|
||||
gruppen[self._gruppe_von(p)].append(p)
|
||||
self._gruppen = {}
|
||||
for gi, (gname, plist) in enumerate(sorted(gruppen.items())):
|
||||
giid = f"g{gi}"
|
||||
self._gruppen[giid] = (gname, plist)
|
||||
so = sum(p["betrag_orig"] for p in plist)
|
||||
sa = sum(p["betrag"] for p in plist)
|
||||
proz = {p["prozent"] for p in plist}
|
||||
pz = fmt_percent(next(iter(proz))) if len(proz) == 1 and None not in proz else ("gemischt" if so else "")
|
||||
t.insert("", "end", iid=giid, text=f"{gname} ({len(plist)})", open=(war_leer or giid in offen or bool(such)),
|
||||
values=("", "", "", "", "", fmt(so), pz, fmt(sa)), tags=("gruppe",))
|
||||
for p in plist:
|
||||
tags = []
|
||||
if p["manuell"]:
|
||||
tags.append("manuell")
|
||||
elif p["betrag"] != p["betrag_orig"]:
|
||||
tags.append("abw")
|
||||
kunde = " ".join(x for x in (p["vorname"], p["name"]) if x) or "(ohne Name)"
|
||||
t.insert(giid, "end", iid=f"p{p['id']}", text=kunde,
|
||||
values=(_datum_de(p["datum"]), p["rufnummer"], p["tarif"], _zeitraum_kurz(p["zeitraum"]),
|
||||
p["kennung"], fmt(p["betrag_orig"]),
|
||||
"manuell" if p["manuell"] else fmt_percent(p["prozent"]), fmt(p["betrag"])),
|
||||
tags=tuple(tags))
|
||||
name = {ALLE: "Alle Positionen", UNZUGEORDNET: "Nicht zugeordnet"}.get(
|
||||
self.ansicht, self.haendler_namen.get(self.ansicht, ""))
|
||||
self.f_p.set_titel(f"Positionen – {name}")
|
||||
so = sum(p["betrag_orig"] for p in pos)
|
||||
sa = sum(p["betrag"] for p in pos)
|
||||
self.status.set("pos", f" {len(pos)} Positionen")
|
||||
self.status.set("orig", f" Provision: {fmt(so)}")
|
||||
self.status.set("ausz", f" Auszahlung: {fmt(sa)}")
|
||||
|
||||
def _status_auswahl(self):
|
||||
pids = self._ausgewaehlte_pids()
|
||||
if len(pids) > 1 or (pids and any(i.startswith("g") for i in self.ptree.selection())):
|
||||
ps = [p for p in self.positionen if p["id"] in set(pids)]
|
||||
self.status.set("pos", f" {len(ps)} ausgewählt")
|
||||
self.status.set("orig", f" Provision: {fmt(sum(p['betrag_orig'] for p in ps))}")
|
||||
self.status.set("ausz", f" Auszahlung: {fmt(sum(p['betrag'] for p in ps))}")
|
||||
|
||||
def _klappen(self, auf: bool):
|
||||
for iid in self.ptree.get_children():
|
||||
self.ptree.item(iid, open=auf)
|
||||
|
||||
def _ausgewaehlte_pids(self) -> list[int]:
|
||||
pids: list[int] = []
|
||||
for iid in self.ptree.selection():
|
||||
if iid.startswith("g"):
|
||||
pids.extend(p["id"] for p in self._gruppen[iid][1])
|
||||
elif iid.startswith("p"):
|
||||
pids.append(int(iid[1:]))
|
||||
return list(dict.fromkeys(pids))
|
||||
|
||||
# ================================================================= Aktionen
|
||||
def _brauche_abrechnung(self, frage_neu=True) -> bool:
|
||||
if self.aid:
|
||||
return True
|
||||
if frage_neu:
|
||||
return self.neu()
|
||||
return False
|
||||
|
||||
def _vorschlag_titel(self) -> str:
|
||||
heute = date.today()
|
||||
m = heute.month - 1 or 12
|
||||
y = heute.year if heute.month > 1 else heute.year - 1
|
||||
return f"{MONATE[m - 1]} {y}"
|
||||
|
||||
def neu(self) -> bool:
|
||||
t = w.eingabe(self.root, "Neue Abrechnung", "Bezeichnung der Abrechnung (z. B. Abrechnungsmonat):",
|
||||
self._vorschlag_titel())
|
||||
if not t or not t.strip():
|
||||
return False
|
||||
self.aid = self.db.abrechnung_neu(t.strip())
|
||||
self.ansicht = ALLE
|
||||
self._aktualisieren()
|
||||
return True
|
||||
|
||||
def abrechnung_waehlen(self):
|
||||
if not self.db.abrechnungen():
|
||||
w.info(self.root, "Abrechnung öffnen", "Es gibt noch keine gespeicherten Abrechnungen.")
|
||||
return
|
||||
d = dlg.AbrechnungenDialog(self.root, self.db, self.aid)
|
||||
res = d.zeigen()
|
||||
if res and res[0] == "oeffnen":
|
||||
self.ansicht = ALLE
|
||||
self.oeffnen(res[1])
|
||||
else:
|
||||
self._aktualisieren()
|
||||
|
||||
def importieren(self, dateien: list[str] | None = None):
|
||||
if not dateien:
|
||||
dateien = list(filedialog.askopenfilenames(
|
||||
parent=self.root, title="Provisionsabrechnungen importieren",
|
||||
initialdir=self.settings.get("import_ordner") or None,
|
||||
filetypes=[("Abrechnungen (CSV, Excel)", "*.csv *.xlsx *.xlsm *.xls *.txt"),
|
||||
("CSV-Dateien", "*.csv"), ("Excel-Dateien", "*.xlsx *.xlsm *.xls"),
|
||||
("Alle Dateien", "*.*")]))
|
||||
if not dateien:
|
||||
return
|
||||
self.settings["import_ordner"] = str(Path(dateien[0]).parent)
|
||||
if not self._brauche_abrechnung():
|
||||
return
|
||||
bericht = []
|
||||
self.root.configure(cursor="watch")
|
||||
self.root.update()
|
||||
try:
|
||||
for f in dateien:
|
||||
name = Path(f).name
|
||||
try:
|
||||
erg = lies_datei(f)
|
||||
except ImportFehler as exc:
|
||||
bericht.append((name, "–", 0, 0, str(exc).split("\n")[0]))
|
||||
continue
|
||||
except Exception as exc:
|
||||
bericht.append((name, "–", 0, 0, f"Fehler: {exc}"))
|
||||
continue
|
||||
schon = self.db.datei_schon_importiert(erg.sha256)
|
||||
if schon and not w.frage(self.root, "Bereits importiert",
|
||||
f"„{name}“ wurde bereits importiert (in: {', '.join(schon)}).\n\n"
|
||||
"Trotzdem noch einmal importieren? Die Positionen wären dann doppelt.",
|
||||
default_ja=False):
|
||||
bericht.append((name, erg.format, 0, 0, "übersprungen (bereits importiert)"))
|
||||
continue
|
||||
n = self.db.importieren(self.aid, erg)
|
||||
bericht.append((name, erg.format, n, sum(p.betrag_cent for p in erg.positionen),
|
||||
"; ".join(erg.warnungen) or "OK"))
|
||||
finally:
|
||||
self.root.configure(cursor="")
|
||||
self._aktualisieren()
|
||||
unbekannt: dict[tuple[str, str], list[int]] = defaultdict(lambda: [0, 0])
|
||||
for p in self.positionen:
|
||||
if p["haendler_id"] == UNZUGEORDNET:
|
||||
u = unbekannt[(p["anbieter"], p["kennung"])]
|
||||
u[0] += 1
|
||||
u[1] += p["betrag_orig"]
|
||||
res = dlg.ImportBericht(self.root, bericht, [(a, k, n, s) for (a, k), (n, s) in sorted(unbekannt.items())]).zeigen()
|
||||
if res:
|
||||
self.kennung_zuordnen(*res)
|
||||
|
||||
def dateien(self):
|
||||
if not self.aid:
|
||||
return
|
||||
if dlg.DateienDialog(self.root, self.db, self.aid).zeigen():
|
||||
self._aktualisieren()
|
||||
|
||||
def haendler(self, hid: int | None = None, neue_kennung: tuple[str, str] | None = None):
|
||||
geaendert = dlg.HaendlerDialog(self.root, self.db, hid, neue_kennung).zeigen()
|
||||
if geaendert:
|
||||
self._nach_haendler_aenderung()
|
||||
|
||||
def _nach_haendler_aenderung(self):
|
||||
if self.aid:
|
||||
n = self.db.neu_berechnen(self.aid)
|
||||
if n:
|
||||
self.status.set("info", f" {n} Position(en) nach neuen Händler-Regeln berechnet")
|
||||
self._aktualisieren()
|
||||
|
||||
def kennung_zuordnen(self, anbieter: str, kennung: str):
|
||||
res = dlg.KennungZuordnen(self.root, self.db, anbieter, kennung).zeigen()
|
||||
if not res:
|
||||
return
|
||||
if res[0] == "neu":
|
||||
self.haendler(neue_kennung=(anbieter, kennung))
|
||||
else:
|
||||
self._nach_haendler_aenderung()
|
||||
|
||||
def kennung_zuordnen_auswahl(self):
|
||||
pids = self._ausgewaehlte_pids()
|
||||
p = next((x for x in self.positionen if pids and x["id"] == pids[0]), None)
|
||||
if not p:
|
||||
w.info(self.root, "Kennung zuordnen", "Bitte zuerst eine Position oder Gruppe auswählen.")
|
||||
return
|
||||
if p["haendler_id"] != UNZUGEORDNET:
|
||||
w.info(self.root, "Kennung zuordnen",
|
||||
f"Die Kennung {p['kennung']} gehört bereits zu „{self.haendler_namen.get(p['haendler_id'])}“.")
|
||||
return
|
||||
self.kennung_zuordnen(p["anbieter"], p["kennung"])
|
||||
|
||||
def _auswahl_info(self):
|
||||
sel = self.ptree.selection()
|
||||
pids = self._ausgewaehlte_pids()
|
||||
ps = [p for p in self.positionen if p["id"] in set(pids)]
|
||||
return sel, pids, ps
|
||||
|
||||
def prozent(self):
|
||||
sel, pids, ps = self._auswahl_info()
|
||||
if not ps:
|
||||
w.info(self.root, "Auszahlung", "Bitte zuerst eine Provisionsart (Gruppe) oder einzelne Positionen "
|
||||
"in der Liste auswählen.")
|
||||
return
|
||||
hids = {p["haendler_id"] for p in ps}
|
||||
arten = {p["provisionsart"] for p in ps}
|
||||
hid = next(iter(hids)) if len(hids) == 1 else None
|
||||
art = next(iter(arten)) if len(arten) == 1 else None
|
||||
regel_moeglich = bool(hid and hid > 0 and art is not None)
|
||||
hname = self.haendler_namen.get(hid, "(Nicht zugeordnet)") if hid is not None else "(mehrere)"
|
||||
akt = {p["prozent"] for p in ps if p["prozent"] is not None}
|
||||
vorschlag = next(iter(akt)) if len(akt) == 1 else 100.0
|
||||
nur_gruppe = len(sel) == 1 and sel[0].startswith("g")
|
||||
res = dlg.ProzentDialog(self.root, hname, art if nur_gruppe or len(ps) > 1 else None, len(ps),
|
||||
sum(p["betrag_orig"] for p in ps), vorschlag, regel_moeglich).zeigen()
|
||||
if not res:
|
||||
return
|
||||
proz, als_regel, manuelle = res
|
||||
if als_regel and regel_moeglich:
|
||||
self.db.regel_setzen(hid, art, proz)
|
||||
self.db.prozent_anwenden(pids, proz, auch_manuelle=manuelle)
|
||||
self.db.touch(self.aid)
|
||||
self._aktualisieren()
|
||||
self._auswahl_wiederherstellen(sel)
|
||||
|
||||
def _auswahl_wiederherstellen(self, sel):
|
||||
vorhanden = [i for i in sel if self.ptree.exists(i)]
|
||||
if vorhanden:
|
||||
self.ptree.selection_set(vorhanden)
|
||||
self.ptree.see(vorhanden[0])
|
||||
|
||||
def zuruecksetzen(self):
|
||||
sel, pids, ps = self._auswahl_info()
|
||||
if not ps:
|
||||
return
|
||||
for p in ps:
|
||||
self.db.prozent_anwenden([p["id"]], self.db.prozent_fuer(p["haendler_id"], p["provisionsart"]))
|
||||
self.db.touch(self.aid)
|
||||
self._aktualisieren()
|
||||
self._auswahl_wiederherstellen(sel)
|
||||
|
||||
def neu_berechnen(self):
|
||||
if not self.aid:
|
||||
return
|
||||
manuell = sum(1 for p in self.positionen if p["manuell"])
|
||||
auch = False
|
||||
if manuell:
|
||||
auch = w.frage(self.root, "Neu berechnen",
|
||||
f"{manuell} Position(en) wurden von Hand geändert.\n\nSollen diese ebenfalls nach den "
|
||||
"Händler-Regeln neu berechnet werden?", ja="Ja, alle", nein="Nein, behalten",
|
||||
default_ja=False)
|
||||
n = self.db.neu_berechnen(self.aid, auch_manuelle=auch)
|
||||
self._aktualisieren()
|
||||
w.info(self.root, "Neu berechnen", f"{n} Position(en) wurden neu berechnet.")
|
||||
|
||||
# ----- Inline-Bearbeitung des Auszahlungsbetrags
|
||||
def _doppelklick(self, e):
|
||||
iid = self.ptree.identify_row(e.y)
|
||||
if not iid:
|
||||
return
|
||||
if iid.startswith("g"):
|
||||
self.ptree.selection_set(iid)
|
||||
if self.ptree.identify_column(e.x) in ("#7", "#8"):
|
||||
self.prozent()
|
||||
return "break"
|
||||
return
|
||||
self.ptree.selection_set(iid)
|
||||
self.betrag_bearbeiten(iid)
|
||||
|
||||
def betrag_bearbeiten(self, iid: str | None = None):
|
||||
if iid is None:
|
||||
sel = [i for i in self.ptree.selection() if i.startswith("p")]
|
||||
if len(sel) != 1:
|
||||
if any(i.startswith("g") for i in self.ptree.selection()) or len(sel) > 1:
|
||||
self.prozent()
|
||||
else:
|
||||
w.info(self.root, "Betrag ändern", "Bitte eine einzelne Position auswählen.")
|
||||
return
|
||||
iid = sel[0]
|
||||
self.ptree.see(iid)
|
||||
self.ptree.update_idletasks()
|
||||
bbox = self.ptree.bbox(iid, "ausz")
|
||||
if not bbox:
|
||||
return
|
||||
pid = int(iid[1:])
|
||||
p = next(x for x in self.positionen if x["id"] == pid)
|
||||
x, y, bw, bh = bbox
|
||||
var = tk.StringVar(value=fmt(p["betrag"], symbol=False))
|
||||
e = tk.Entry(self.ptree, textvariable=var, justify="right", bd=1, relief="solid", font=w.FONT)
|
||||
e.place(x=x, y=y, width=bw, height=bh)
|
||||
e.select_range(0, "end")
|
||||
e.focus_set()
|
||||
self._edit = e
|
||||
|
||||
def speichern(_ev=None, weiter: int = 0):
|
||||
if self._edit is not e:
|
||||
return
|
||||
try:
|
||||
cent = parse_cent(var.get())
|
||||
except ValueError:
|
||||
e.configure(bg="#ffc0c0")
|
||||
return "break"
|
||||
self._edit = None
|
||||
e.destroy()
|
||||
if cent != p["betrag"]:
|
||||
self.db.betrag_setzen(pid, cent)
|
||||
self.db.touch(self.aid)
|
||||
self._aktualisieren()
|
||||
nxt = self.ptree.next(iid) if weiter > 0 else (self.ptree.prev(iid) if weiter < 0 else None)
|
||||
if self.ptree.exists(iid):
|
||||
self.ptree.selection_set(iid)
|
||||
self.ptree.focus(iid)
|
||||
if nxt and self.ptree.exists(nxt):
|
||||
self.ptree.selection_set(nxt)
|
||||
self.ptree.focus(nxt)
|
||||
self.betrag_bearbeiten(nxt)
|
||||
else:
|
||||
self.ptree.focus_set()
|
||||
return "break"
|
||||
|
||||
def abbruch(_ev=None):
|
||||
self._edit = None
|
||||
e.destroy()
|
||||
self.ptree.focus_set()
|
||||
return "break"
|
||||
|
||||
e.bind("<Return>", speichern)
|
||||
e.bind("<KP_Enter>", speichern)
|
||||
e.bind("<Tab>", lambda ev: speichern(ev, 1))
|
||||
e.bind("<Down>", lambda ev: speichern(ev, 1))
|
||||
e.bind("<Up>", lambda ev: speichern(ev, -1))
|
||||
e.bind("<Escape>", abbruch)
|
||||
e.bind("<FocusOut>", speichern)
|
||||
|
||||
def _kontextmenue(self, e):
|
||||
iid = self.ptree.identify_row(e.y)
|
||||
if not iid:
|
||||
return
|
||||
if iid not in self.ptree.selection():
|
||||
self.ptree.selection_set(iid)
|
||||
m = tk.Menu(self.root, tearoff=0)
|
||||
if iid.startswith("p"):
|
||||
m.add_command(label="Betrag ändern…", command=lambda: self.betrag_bearbeiten(iid))
|
||||
m.add_command(label="Auszahlung in Prozent…", command=self.prozent)
|
||||
m.add_command(label="Auf Regel zurücksetzen", command=self.zuruecksetzen)
|
||||
m.add_separator()
|
||||
m.add_command(label="Auszahlung 0 € (nicht auszahlen)", command=lambda: self._null())
|
||||
if self.ansicht == UNZUGEORDNET or self.ansicht == ALLE:
|
||||
m.add_separator()
|
||||
m.add_command(label="Kennung einem Händler zuordnen…", command=self.kennung_zuordnen_auswahl)
|
||||
m.tk_popup(e.x_root, e.y_root)
|
||||
|
||||
def _null(self):
|
||||
sel, pids, _ = self._auswahl_info()
|
||||
self.db.prozent_anwenden(pids, 0.0)
|
||||
self.db.touch(self.aid)
|
||||
self._aktualisieren()
|
||||
self._auswahl_wiederherstellen(sel)
|
||||
|
||||
# ----- Export
|
||||
def exportieren(self):
|
||||
if not self.aid or not self.positionen:
|
||||
w.info(self.root, "Export", "Diese Abrechnung enthält noch keine Positionen.\n"
|
||||
"Bitte zuerst Dateien importieren.")
|
||||
return
|
||||
stat: dict[int, list[int]] = defaultdict(lambda: [0, 0])
|
||||
for p in self.positionen:
|
||||
if p["haendler_id"] != UNZUGEORDNET:
|
||||
stat[p["haendler_id"]][0] += 1
|
||||
stat[p["haendler_id"]][1] += p["betrag"]
|
||||
if not stat:
|
||||
w.warnung(self.root, "Export", "Keine Position ist einem Händler zugeordnet.\n\n"
|
||||
"Lege unter „Händler“ die Unterhändler mit ihren Kennungen an.")
|
||||
return
|
||||
liste = [(hid, self.haendler_namen[hid], n, s) for hid, (n, s) in
|
||||
sorted(stat.items(), key=lambda x: self.haendler_namen[x[0]].lower())]
|
||||
vor = {self.ansicht} if self.ansicht in stat else set(stat)
|
||||
titel = self.db.abrechnung(self.aid)["titel"]
|
||||
res = dlg.ExportDialog(self.root, liste, vor, self.settings, titel).zeigen()
|
||||
if not res:
|
||||
return
|
||||
config.save(self.settings)
|
||||
erzeugt = []
|
||||
try:
|
||||
for hid in res["ids"]:
|
||||
pos = [p for p in self.positionen if p["haendler_id"] == hid]
|
||||
erzeugt += exp.exportiere_haendler(res["ordner"], self.haendler_namen[hid], pos, res["titel"],
|
||||
pro_anbieter=res["pro_anbieter"], kopfzeile=res["kopfzeile"],
|
||||
summe=res["summe"])
|
||||
except PermissionError as exc:
|
||||
w.fehler(self.root, "Export", f"Die Datei konnte nicht geschrieben werden:\n{exc.filename}\n\n"
|
||||
"Ist sie vielleicht noch in Excel geöffnet?")
|
||||
return
|
||||
w.info(self.root, "Export", f"{len(erzeugt)} Datei(en) gespeichert in:\n{res['ordner']}\n\n"
|
||||
+ "\n".join(p.name for p in erzeugt[:15]) + ("\n…" if len(erzeugt) > 15 else ""))
|
||||
if res["oeffnen"]:
|
||||
_oeffne_ordner(res["ordner"])
|
||||
|
||||
# ----- Datei-Menü
|
||||
def sicherung(self):
|
||||
p = filedialog.asksaveasfilename(parent=self.root, title="Datensicherung speichern", defaultextension=".db",
|
||||
initialfile=f"Provisionen-Sicherung-{date.today().isoformat()}.db",
|
||||
filetypes=[("Datenbank", "*.db")])
|
||||
if p:
|
||||
self.db.backup(p)
|
||||
w.info(self.root, "Datensicherung", f"Sicherung gespeichert:\n{p}")
|
||||
|
||||
def wiederherstellen(self):
|
||||
p = filedialog.askopenfilename(parent=self.root, title="Datensicherung einspielen",
|
||||
filetypes=[("Datenbank", "*.db"), ("Alle Dateien", "*.*")])
|
||||
if not p:
|
||||
return
|
||||
if not w.frage(self.root, "Datensicherung einspielen",
|
||||
"Die aktuelle Datenbank wird durch die Sicherung ERSETZT.\n"
|
||||
"Vorher wird automatisch eine Kopie (*.vor-wiederherstellung.db) angelegt.\n\nFortfahren?",
|
||||
default_ja=False):
|
||||
return
|
||||
ziel = self.db.path
|
||||
self.db.backup(ziel.with_suffix(".vor-wiederherstellung.db"))
|
||||
self.db.close()
|
||||
try:
|
||||
Datenbank.restore(p, ziel)
|
||||
except Exception as exc:
|
||||
w.fehler(self.root, "Datensicherung", f"Die Datei ist keine gültige Sicherung:\n{exc}")
|
||||
self.db = Datenbank(ziel)
|
||||
self.aid = None
|
||||
abr = self.db.abrechnungen()
|
||||
if abr:
|
||||
self.aid = abr[0]["id"]
|
||||
self.ansicht = ALLE
|
||||
self._aktualisieren()
|
||||
|
||||
def einstellungen(self):
|
||||
neu = dlg.EinstellungenDialog(self.root, self.settings, str(self.db.path)).zeigen()
|
||||
if neu and Path(neu) != self.db.path:
|
||||
try:
|
||||
db = Datenbank(neu)
|
||||
except Exception as exc:
|
||||
w.fehler(self.root, "Einstellungen", f"Datenbank konnte nicht geöffnet werden:\n{exc}")
|
||||
return
|
||||
self.db.close()
|
||||
self.db = db
|
||||
self.settings["db_pfad"] = neu
|
||||
config.save(self.settings)
|
||||
abr = self.db.abrechnungen()
|
||||
self.aid = abr[0]["id"] if abr else None
|
||||
self.ansicht = ALLE
|
||||
self._aktualisieren()
|
||||
|
||||
def hilfe(self):
|
||||
w.info(self.root, "Kurzanleitung",
|
||||
"1. Händler anlegen (Strg+H): Name, Standard-Auszahlung in % und die Kennungen –\n"
|
||||
" bei freenet die Unterhändlernummer, bei Vodafone die VO-Nr.\n\n"
|
||||
"2. Neue Abrechnung (Strg+N), z. B. „Oktober 2026“.\n\n"
|
||||
"3. Dateien importieren (Strg+I) – mehrere CSV/Excel auf einmal möglich,\n"
|
||||
" auch alle Wochenlisten eines Monats.\n\n"
|
||||
"4. Links den Händler wählen. Positionen sind nach Provisionsart gruppiert.\n"
|
||||
" • Gruppe wählen + „Prozent…“ (F4): Auszahlung für die ganze Provisionsart,\n"
|
||||
" optional als Standard für diesen Händler merken.\n"
|
||||
" • Doppelklick / F2 auf eine Position: Betrag frei ändern\n"
|
||||
" (Enter = speichern, ↓/Tab = nächste Zeile, Esc = abbrechen).\n\n"
|
||||
"5. Exportieren (Strg+E): je Händler eine Excel-Datei im Abrechnungsformat.\n\n"
|
||||
"Alles wird automatisch in der Datenbank gespeichert.")
|
||||
|
||||
def _willkommen(self):
|
||||
if not self.db.haendler_liste():
|
||||
if w.frage(self.root, "Willkommen",
|
||||
f"Willkommen beim {APP_NAME}!\n\nEs sind noch keine Händler angelegt.\n"
|
||||
"Jetzt die Unterhändler mit ihren Kennungen anlegen?"):
|
||||
self.haendler()
|
||||
|
||||
def _fehler_handler(self, exc, val, tb):
|
||||
text = "".join(traceback.format_exception(exc, val, tb))
|
||||
try:
|
||||
with (config.app_dir() / "fehler.log").open("a", encoding="utf-8") as f:
|
||||
f.write(text + "\n")
|
||||
except OSError:
|
||||
pass
|
||||
w.fehler(self.root, "Allgemeine Schutzverletzung",
|
||||
f"Im Programm ist ein Fehler aufgetreten:\n\n{val}\n\n"
|
||||
f"Details stehen in {config.app_dir() / 'fehler.log'}")
|
||||
|
||||
def beenden(self):
|
||||
try:
|
||||
self.settings["fenster"] = self.root.geometry()
|
||||
config.save(self.settings)
|
||||
self.db.close()
|
||||
finally:
|
||||
self.root.destroy()
|
||||
|
||||
|
||||
def main():
|
||||
if sys.platform.startswith("win"):
|
||||
try: # scharfe Darstellung auf HiDPI-Bildschirmen
|
||||
import ctypes
|
||||
ctypes.windll.shcore.SetProcessDpiAwareness(1)
|
||||
except Exception:
|
||||
pass
|
||||
root = tk.Tk(className="Provisionsmanager95")
|
||||
app = App(root)
|
||||
if len(sys.argv) > 1:
|
||||
root.after(200, lambda: app.importieren([a for a in sys.argv[1:] if Path(a).exists()]))
|
||||
root.mainloop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,633 @@
|
||||
"""Dialogfenster des Provisions-Managers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog, ttk
|
||||
|
||||
from provtool import APP_NAME, APP_ORG, __version__
|
||||
from provtool.db import Datenbank, Haendler
|
||||
from provtool.gui import win95 as w
|
||||
from provtool.importer import ANBIETER
|
||||
from provtool.money import fmt, fmt_percent, parse_percent
|
||||
|
||||
|
||||
# =========================================================================== Händlerverwaltung
|
||||
|
||||
class HaendlerDialog(w.Dialog):
|
||||
def __init__(self, master, db: Datenbank, auswahl_id: int | None = None,
|
||||
neue_kennung: tuple[str, str] | None = None):
|
||||
super().__init__(master, "Händler verwalten", resizable=True)
|
||||
self.db = db
|
||||
self.aktuell: Haendler | None = None
|
||||
self.geaendert = False
|
||||
|
||||
haupt = tk.Frame(self, bg=w.FACE)
|
||||
haupt.pack(fill="both", expand=True, padx=8, pady=8)
|
||||
|
||||
# --- links: Liste
|
||||
links = tk.Frame(haupt, bg=w.FACE)
|
||||
links.pack(side="left", fill="y")
|
||||
tk.Label(links, text="Händler:", anchor="w").pack(fill="x")
|
||||
rahmen, self.liste = w.scroll_tree(links, columns=("name",), show="tree", selectmode="browse", height=16)
|
||||
self.liste.column("#0", width=0, stretch=False)
|
||||
self.liste.column("name", width=190)
|
||||
rahmen.pack(fill="y", expand=True)
|
||||
self.liste.bind("<<TreeviewSelect>>", self._auswahl)
|
||||
lk = tk.Frame(links, bg=w.FACE)
|
||||
lk.pack(fill="x", pady=(6, 0))
|
||||
w.knopf(lk, "Neu", self._neu, breite=8).pack(side="left")
|
||||
w.knopf(lk, "Löschen", self._loeschen, breite=8).pack(side="left", padx=4)
|
||||
|
||||
# --- rechts: Formular
|
||||
rechts = tk.Frame(haupt, bg=w.FACE)
|
||||
rechts.pack(side="left", fill="both", expand=True, padx=(10, 0))
|
||||
|
||||
g = w.gruppe(rechts, "Stammdaten")
|
||||
g.pack(fill="x")
|
||||
self.v_name, self.v_ap, self.v_mail = tk.StringVar(), tk.StringVar(), tk.StringVar()
|
||||
self.v_proz, self.v_aktiv = tk.StringVar(value="100"), tk.BooleanVar(value=True)
|
||||
for r, (lbl, var) in enumerate((("Name / Firma:", self.v_name), ("Ansprechpartner:", self.v_ap),
|
||||
("E-Mail:", self.v_mail))):
|
||||
tk.Label(g, text=lbl, anchor="w").grid(row=r, column=0, sticky="w", pady=2)
|
||||
tk.Entry(g, textvariable=var, width=36).grid(row=r, column=1, columnspan=2, sticky="we", pady=2)
|
||||
tk.Label(g, text="Standard-Auszahlung:", anchor="w").grid(row=3, column=0, sticky="w", pady=2)
|
||||
pf = tk.Frame(g, bg=w.FACE)
|
||||
pf.grid(row=3, column=1, sticky="w")
|
||||
tk.Entry(pf, textvariable=self.v_proz, width=7, justify="right").pack(side="left")
|
||||
tk.Label(pf, text="% der Provision").pack(side="left", padx=4)
|
||||
tk.Checkbutton(g, text="Aktiv", variable=self.v_aktiv).grid(row=3, column=2, sticky="e")
|
||||
tk.Label(g, text="Notiz:", anchor="nw").grid(row=4, column=0, sticky="nw", pady=2)
|
||||
self.t_notiz = tk.Text(g, width=36, height=3, font=w.FONT)
|
||||
self.t_notiz.grid(row=4, column=1, columnspan=2, sticky="we", pady=2)
|
||||
g.columnconfigure(1, weight=1)
|
||||
|
||||
g2 = w.gruppe(rechts, "Kennungen (ordnet Positionen diesem Händler zu)")
|
||||
g2.pack(fill="both", expand=True, pady=(8, 0))
|
||||
r2, self.kliste = w.scroll_tree(g2, columns=("anbieter", "wert"), show="headings", height=4,
|
||||
selectmode="browse")
|
||||
self.kliste.heading("anbieter", text="Anbieter")
|
||||
self.kliste.heading("wert", text="Unterhändler-Nr. / VO-Nr.")
|
||||
self.kliste.column("anbieter", width=90)
|
||||
self.kliste.column("wert", width=200)
|
||||
r2.pack(fill="both", expand=True)
|
||||
kf = tk.Frame(g2, bg=w.FACE)
|
||||
kf.pack(fill="x", pady=(4, 0))
|
||||
self.v_kanb = tk.StringVar(value="freenet")
|
||||
cb = ttk.Combobox(kf, textvariable=self.v_kanb, values=list(ANBIETER.values()), width=10, state="readonly")
|
||||
cb.pack(side="left")
|
||||
self.v_kwert = tk.StringVar()
|
||||
ke = tk.Entry(kf, textvariable=self.v_kwert, width=18)
|
||||
ke.pack(side="left", padx=4)
|
||||
ke.bind("<Return>", lambda e: self._kennung_dazu())
|
||||
w.knopf(kf, "Hinzufügen", self._kennung_dazu).pack(side="left")
|
||||
w.knopf(kf, "Entfernen", self._kennung_weg).pack(side="left", padx=4)
|
||||
|
||||
g3 = w.gruppe(rechts, "Abweichende Auszahlung je Provisionsart")
|
||||
g3.pack(fill="both", expand=True, pady=(8, 0))
|
||||
r3, self.rliste = w.scroll_tree(g3, columns=("art", "proz"), show="headings", height=4, selectmode="browse")
|
||||
self.rliste.heading("art", text="Provisionsart")
|
||||
self.rliste.heading("proz", text="Auszahlung")
|
||||
self.rliste.column("art", width=240)
|
||||
self.rliste.column("proz", width=70, anchor="e")
|
||||
r3.pack(fill="both", expand=True)
|
||||
rf = tk.Frame(g3, bg=w.FACE)
|
||||
rf.pack(fill="x", pady=(4, 0))
|
||||
tk.Label(rf, text="Regeln entstehen im Hauptfenster über „Prozent…“.", fg=w.SHADOW).pack(side="left")
|
||||
w.knopf(rf, "Entfernen", self._regel_weg).pack(side="right")
|
||||
|
||||
unten = tk.Frame(self, bg=w.FACE)
|
||||
unten.pack(fill="x", padx=8, pady=(0, 8))
|
||||
w.knopf(unten, "Schließen", self.abbrechen, breite=10).pack(side="right")
|
||||
w.knopf(unten, "Speichern", self._speichern, breite=10, default="active").pack(side="right", padx=6)
|
||||
|
||||
self._kennungen: list[tuple[str, str]] = []
|
||||
self._liste_laden(auswahl_id)
|
||||
if neue_kennung:
|
||||
self._neu()
|
||||
self._kennungen = [neue_kennung]
|
||||
self._kennungen_zeigen()
|
||||
self.minsize(640, 460)
|
||||
|
||||
# ---- intern
|
||||
@staticmethod
|
||||
def _anbieter_key(anzeige: str) -> str:
|
||||
return next((k for k, v in ANBIETER.items() if v == anzeige), anzeige)
|
||||
|
||||
def _liste_laden(self, auswahl_id=None):
|
||||
self.liste.delete(*self.liste.get_children())
|
||||
for h in self.db.haendler_liste():
|
||||
self.liste.insert("", "end", iid=str(h.id), values=(h.name + ("" if h.aktiv else " (inaktiv)"),))
|
||||
if auswahl_id and self.liste.exists(str(auswahl_id)):
|
||||
self.liste.selection_set(str(auswahl_id))
|
||||
self.liste.see(str(auswahl_id))
|
||||
elif self.liste.get_children():
|
||||
self.liste.selection_set(self.liste.get_children()[0])
|
||||
else:
|
||||
self._neu()
|
||||
|
||||
def _auswahl(self, _e=None):
|
||||
sel = self.liste.selection()
|
||||
if not sel:
|
||||
return
|
||||
h = self.db.haendler(int(sel[0]))
|
||||
if not h:
|
||||
return
|
||||
self.aktuell = h
|
||||
self.v_name.set(h.name)
|
||||
self.v_ap.set(h.ansprechpartner)
|
||||
self.v_mail.set(h.email)
|
||||
self.v_proz.set(fmt_percent(h.standard_prozent).replace(" %", ""))
|
||||
self.v_aktiv.set(h.aktiv)
|
||||
self.t_notiz.delete("1.0", "end")
|
||||
self.t_notiz.insert("1.0", h.notiz or "")
|
||||
self._kennungen = self.db.kennungen(h.id)
|
||||
self._kennungen_zeigen()
|
||||
self._regeln_zeigen()
|
||||
|
||||
def _kennungen_zeigen(self):
|
||||
self.kliste.delete(*self.kliste.get_children())
|
||||
for i, (a, v) in enumerate(self._kennungen):
|
||||
self.kliste.insert("", "end", iid=str(i), values=(ANBIETER.get(a, a), v))
|
||||
|
||||
def _regeln_zeigen(self):
|
||||
self.rliste.delete(*self.rliste.get_children())
|
||||
if self.aktuell and self.aktuell.id:
|
||||
for art, p in sorted(self.db.regeln(self.aktuell.id).items()):
|
||||
self.rliste.insert("", "end", iid=art, values=(art, fmt_percent(p)))
|
||||
|
||||
def _neu(self):
|
||||
self.liste.selection_remove(self.liste.selection())
|
||||
self.aktuell = Haendler(0, "")
|
||||
for v in (self.v_name, self.v_ap, self.v_mail):
|
||||
v.set("")
|
||||
self.v_proz.set("100")
|
||||
self.v_aktiv.set(True)
|
||||
self.t_notiz.delete("1.0", "end")
|
||||
self._kennungen = []
|
||||
self._kennungen_zeigen()
|
||||
self._regeln_zeigen()
|
||||
|
||||
def _kennung_dazu(self):
|
||||
wert = self.v_kwert.get().strip()
|
||||
if not wert:
|
||||
return
|
||||
anb = self._anbieter_key(self.v_kanb.get())
|
||||
if (anb, wert) not in self._kennungen:
|
||||
self._kennungen.append((anb, wert))
|
||||
self.v_kwert.set("")
|
||||
self._kennungen_zeigen()
|
||||
|
||||
def _kennung_weg(self):
|
||||
sel = self.kliste.selection()
|
||||
if sel:
|
||||
del self._kennungen[int(sel[0])]
|
||||
self._kennungen_zeigen()
|
||||
|
||||
def _regel_weg(self):
|
||||
sel = self.rliste.selection()
|
||||
if sel and self.aktuell and self.aktuell.id:
|
||||
self.db.regel_setzen(self.aktuell.id, sel[0], None)
|
||||
self.geaendert = True
|
||||
self._regeln_zeigen()
|
||||
|
||||
def _speichern(self):
|
||||
name = self.v_name.get().strip()
|
||||
if not name:
|
||||
w.warnung(self, "Händler", "Bitte einen Namen eingeben.")
|
||||
return
|
||||
try:
|
||||
proz = parse_percent(self.v_proz.get())
|
||||
except ValueError:
|
||||
w.warnung(self, "Händler", "Die Standard-Auszahlung muss eine Zahl sein (z. B. 100 oder 85,5).")
|
||||
return
|
||||
if self.v_kwert.get().strip():
|
||||
self._kennung_dazu()
|
||||
h = Haendler(self.aktuell.id if self.aktuell else 0, name, self.v_ap.get().strip(), self.v_mail.get().strip(),
|
||||
self.t_notiz.get("1.0", "end").strip(), proz, self.v_aktiv.get())
|
||||
try:
|
||||
hid = self.db.haendler_speichern(h, self._kennungen)
|
||||
except Exception as exc: # sqlite3.IntegrityError / ValueError
|
||||
msg = str(exc)
|
||||
if "UNIQUE" in msg and "haendler.name" in msg:
|
||||
msg = f"Es gibt bereits einen Händler „{name}“."
|
||||
w.fehler(self, "Händler speichern", msg)
|
||||
return
|
||||
self.geaendert = True
|
||||
self._liste_laden(hid)
|
||||
|
||||
def _loeschen(self):
|
||||
if not self.aktuell or not self.aktuell.id:
|
||||
return
|
||||
if w.frage(self, "Händler löschen",
|
||||
f"Händler „{self.aktuell.name}“ mit allen Kennungen und Regeln löschen?\n\n"
|
||||
"Positionen in Abrechnungen bleiben erhalten, gelten dann aber als „nicht zugeordnet“.",
|
||||
default_ja=False):
|
||||
self.db.haendler_loeschen(self.aktuell.id)
|
||||
self.geaendert = True
|
||||
self.aktuell = None
|
||||
self._liste_laden()
|
||||
|
||||
def abbrechen(self):
|
||||
self.result = self.geaendert
|
||||
self.destroy()
|
||||
|
||||
|
||||
# =========================================================================== Prozent anwenden
|
||||
|
||||
class ProzentDialog(w.Dialog):
|
||||
def __init__(self, master, haendler_name: str, provisionsart: str | None, anzahl: int, summe_orig: int,
|
||||
vorschlag: float, regel_moeglich: bool):
|
||||
super().__init__(master, "Auszahlung festlegen")
|
||||
self.summe_orig = summe_orig
|
||||
f = tk.Frame(self, bg=w.FACE)
|
||||
f.pack(fill="both", padx=12, pady=12)
|
||||
ic = w.make_icon(self, "prozent", 2)
|
||||
self._ic = ic
|
||||
tk.Label(f, image=ic).grid(row=0, column=0, rowspan=4, sticky="n", padx=(0, 12))
|
||||
was = f"Provisionsart „{provisionsart}“" if provisionsart else f"{anzahl} ausgewählte Position(en)"
|
||||
tk.Label(f, text=f"Händler: {haendler_name}\n{was}\nProvision laut Abrechnung: {fmt(summe_orig)}",
|
||||
justify="left", anchor="w").grid(row=0, column=1, columnspan=3, sticky="w")
|
||||
tk.Label(f, text="Auszahlung:").grid(row=1, column=1, sticky="w", pady=(10, 2))
|
||||
self.v = tk.StringVar(value=fmt_percent(vorschlag).replace(" %", ""))
|
||||
ef = tk.Frame(f, bg=w.FACE)
|
||||
ef.grid(row=1, column=2, columnspan=2, sticky="w", pady=(10, 2))
|
||||
e = tk.Entry(ef, textvariable=self.v, width=8, justify="right")
|
||||
e.pack(side="left")
|
||||
tk.Label(ef, text="% der Provision").pack(side="left", padx=4)
|
||||
self.vorschau = tk.Label(f, text="", fg=w.NAVY, anchor="w")
|
||||
self.vorschau.grid(row=2, column=1, columnspan=3, sticky="w")
|
||||
self.v_regel = tk.BooleanVar(value=bool(provisionsart and regel_moeglich))
|
||||
self.v_manuell = tk.BooleanVar(value=False)
|
||||
cbf = tk.Frame(f, bg=w.FACE)
|
||||
cbf.grid(row=3, column=1, columnspan=3, sticky="w", pady=(8, 0))
|
||||
if provisionsart and regel_moeglich:
|
||||
tk.Checkbutton(cbf, text="Als Standard für diesen Händler und diese Provisionsart merken",
|
||||
variable=self.v_regel).pack(anchor="w")
|
||||
tk.Checkbutton(cbf, text="Auch von Hand geänderte Beträge überschreiben",
|
||||
variable=self.v_manuell).pack(anchor="w")
|
||||
self.v.trace_add("write", lambda *_: self._vorschau())
|
||||
self._vorschau()
|
||||
self.bind("<Return>", lambda _e: self._ok())
|
||||
self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 12))
|
||||
e.select_range(0, "end")
|
||||
self._e = e
|
||||
|
||||
def _vorschau(self):
|
||||
from provtool.money import apply_percent
|
||||
try:
|
||||
p = parse_percent(self.v.get())
|
||||
self.vorschau.configure(text=f"→ Auszahlung ca. {fmt(apply_percent(self.summe_orig, p))}")
|
||||
except ValueError:
|
||||
self.vorschau.configure(text="(ungültiger Wert)")
|
||||
|
||||
def _ok(self):
|
||||
try:
|
||||
p = parse_percent(self.v.get())
|
||||
except ValueError:
|
||||
w.warnung(self, "Auszahlung", "Bitte einen Prozentwert eingeben, z. B. 100, 80 oder 72,5.")
|
||||
return
|
||||
self.result = (p, self.v_regel.get(), self.v_manuell.get())
|
||||
self.destroy()
|
||||
|
||||
def zeigen(self, fokus=None):
|
||||
return super().zeigen(self._e)
|
||||
|
||||
|
||||
# =========================================================================== Abrechnungen
|
||||
|
||||
class AbrechnungenDialog(w.Dialog):
|
||||
def __init__(self, master, db: Datenbank, aktuelle_id: int | None):
|
||||
super().__init__(master, "Abrechnung öffnen", resizable=True)
|
||||
self.db = db
|
||||
tk.Label(self, text="Gespeicherte Abrechnungen:", anchor="w").pack(fill="x", padx=10, pady=(10, 2))
|
||||
r, self.tree = w.scroll_tree(self, columns=("titel", "geaendert", "anzahl", "dateien"), show="headings",
|
||||
height=12, selectmode="browse")
|
||||
for c, t, b in (("titel", "Bezeichnung", 180), ("geaendert", "Zuletzt geändert", 120),
|
||||
("anzahl", "Positionen", 70), ("dateien", "Dateien", 260)):
|
||||
self.tree.heading(c, text=t)
|
||||
self.tree.column(c, width=b, anchor="e" if c == "anzahl" else "w")
|
||||
r.pack(fill="both", expand=True, padx=10)
|
||||
self.tree.bind("<Double-1>", lambda e: self._oeffnen())
|
||||
k = tk.Frame(self, bg=w.FACE)
|
||||
k.pack(fill="x", padx=10, pady=10)
|
||||
w.knopf(k, "Öffnen", self._oeffnen, breite=10, default="active").pack(side="left")
|
||||
w.knopf(k, "Umbenennen…", self._umbenennen, breite=12).pack(side="left", padx=6)
|
||||
w.knopf(k, "Löschen", self._loeschen, breite=10).pack(side="left")
|
||||
w.knopf(k, "Abbrechen", self.abbrechen, breite=10).pack(side="right")
|
||||
self.bind("<Return>", lambda e: self._oeffnen())
|
||||
self._laden(aktuelle_id)
|
||||
|
||||
def _laden(self, sel=None):
|
||||
self.tree.delete(*self.tree.get_children())
|
||||
for a in self.db.abrechnungen():
|
||||
self.tree.insert("", "end", iid=str(a["id"]),
|
||||
values=(a["titel"], a["geaendert"][:16], a["anzahl"], a["dateien"] or ""))
|
||||
kids = self.tree.get_children()
|
||||
if sel and self.tree.exists(str(sel)):
|
||||
self.tree.selection_set(str(sel))
|
||||
elif kids:
|
||||
self.tree.selection_set(kids[0])
|
||||
|
||||
def _sel(self) -> int | None:
|
||||
s = self.tree.selection()
|
||||
return int(s[0]) if s else None
|
||||
|
||||
def _oeffnen(self):
|
||||
if self._sel():
|
||||
self.result = ("oeffnen", self._sel())
|
||||
self.destroy()
|
||||
|
||||
def _umbenennen(self):
|
||||
aid = self._sel()
|
||||
if not aid:
|
||||
return
|
||||
t = w.eingabe(self, "Umbenennen", "Neue Bezeichnung:", self.db.abrechnung(aid)["titel"])
|
||||
if t and t.strip():
|
||||
self.db.abrechnung_umbenennen(aid, t.strip())
|
||||
self._laden(aid)
|
||||
self.result = ("geaendert", aid)
|
||||
|
||||
def _loeschen(self):
|
||||
aid = self._sel()
|
||||
if not aid:
|
||||
return
|
||||
titel = self.db.abrechnung(aid)["titel"]
|
||||
if w.frage(self, "Löschen", f"Abrechnung „{titel}“ mit allen Positionen und Änderungen endgültig löschen?",
|
||||
default_ja=False):
|
||||
self.db.abrechnung_loeschen(aid)
|
||||
self._laden()
|
||||
self.result = ("geloescht", aid)
|
||||
|
||||
def abbrechen(self):
|
||||
self.destroy()
|
||||
|
||||
|
||||
class DateienDialog(w.Dialog):
|
||||
"""Zeigt die importierten Dateien einer Abrechnung; einzelne Dateien können wieder entfernt werden."""
|
||||
|
||||
def __init__(self, master, db: Datenbank, aid: int):
|
||||
super().__init__(master, "Importierte Dateien", resizable=True)
|
||||
self.db, self.aid, self.geaendert = db, aid, False
|
||||
r, self.tree = w.scroll_tree(self, columns=("datei", "format", "anzahl", "zeit"), show="headings", height=8,
|
||||
selectmode="browse")
|
||||
for c, t, b in (("datei", "Datei", 260), ("format", "Format", 80), ("anzahl", "Positionen", 70),
|
||||
("zeit", "Importiert", 120)):
|
||||
self.tree.heading(c, text=t)
|
||||
self.tree.column(c, width=b)
|
||||
r.pack(fill="both", expand=True, padx=10, pady=(10, 0))
|
||||
k = tk.Frame(self, bg=w.FACE)
|
||||
k.pack(fill="x", padx=10, pady=10)
|
||||
w.knopf(k, "Datei entfernen", self._weg).pack(side="left")
|
||||
w.knopf(k, "Schließen", self.abbrechen, breite=10).pack(side="right")
|
||||
self._laden()
|
||||
|
||||
def _laden(self):
|
||||
self.tree.delete(*self.tree.get_children())
|
||||
for d in self.db.dateien(self.aid):
|
||||
self.tree.insert("", "end", iid=str(d["id"]),
|
||||
values=(d["dateiname"], d["format"], d["anzahl"], d["importiert"][:16]))
|
||||
|
||||
def _weg(self):
|
||||
s = self.tree.selection()
|
||||
if not s:
|
||||
return
|
||||
name = self.tree.item(s[0], "values")[0]
|
||||
if w.frage(self, "Datei entfernen", f"Alle Positionen aus „{name}“ aus dieser Abrechnung entfernen?\n"
|
||||
"Von Hand geänderte Beträge dieser Datei gehen verloren.", default_ja=False):
|
||||
self.db.datei_entfernen(int(s[0]))
|
||||
self.geaendert = True
|
||||
self._laden()
|
||||
|
||||
def abbrechen(self):
|
||||
self.result = self.geaendert
|
||||
self.destroy()
|
||||
|
||||
|
||||
# =========================================================================== Import-Bericht
|
||||
|
||||
class ImportBericht(w.Dialog):
|
||||
def __init__(self, master, zeilen: list[tuple[str, str, int, int, str]], unbekannt: list[tuple[str, str, int, int]]):
|
||||
"""zeilen: (datei, format, anzahl, summe_cent, hinweis); unbekannt: (anbieter, kennung, anzahl, summe)."""
|
||||
super().__init__(master, "Import abgeschlossen", resizable=True)
|
||||
tk.Label(self, text="Folgende Dateien wurden eingelesen:", anchor="w").pack(fill="x", padx=10, pady=(10, 2))
|
||||
r, t = w.scroll_tree(self, columns=("datei", "format", "anzahl", "summe", "hinweis"), show="headings",
|
||||
height=min(6, max(2, len(zeilen))))
|
||||
for c, h, b, a in (("datei", "Datei", 220, "w"), ("format", "Format", 70, "w"), ("anzahl", "Pos.", 50, "e"),
|
||||
("summe", "Summe", 90, "e"), ("hinweis", "Hinweis", 260, "w")):
|
||||
t.heading(c, text=h)
|
||||
t.column(c, width=b, anchor=a)
|
||||
for d, f, n, s, hw in zeilen:
|
||||
t.insert("", "end", values=(d, f, n, fmt(s) if n else "", hw))
|
||||
r.pack(fill="both", expand=True, padx=10)
|
||||
self.zuordnen = None
|
||||
if unbekannt:
|
||||
tk.Label(self, text="Nicht zugeordnete Kennungen (kein Händler hinterlegt – z. B. eigene Filiale):",
|
||||
anchor="w").pack(fill="x", padx=10, pady=(10, 2))
|
||||
r2, self.ut = w.scroll_tree(self, columns=("anbieter", "kennung", "anzahl", "summe"), show="headings",
|
||||
height=min(6, len(unbekannt)), selectmode="browse")
|
||||
for c, h, b, a in (("anbieter", "Anbieter", 90, "w"), ("kennung", "Kennung", 140, "w"),
|
||||
("anzahl", "Pos.", 50, "e"), ("summe", "Summe", 90, "e")):
|
||||
self.ut.heading(c, text=h)
|
||||
self.ut.column(c, width=b, anchor=a)
|
||||
for a, k, n, s in unbekannt:
|
||||
self.ut.insert("", "end", iid=f"{a}\t{k}", values=(ANBIETER.get(a, a), k or "(leer)", n, fmt(s)))
|
||||
r2.pack(fill="both", expand=True, padx=10)
|
||||
k = tk.Frame(self, bg=w.FACE)
|
||||
k.pack(fill="x", padx=10, pady=10)
|
||||
if unbekannt:
|
||||
w.knopf(k, "Kennung einem Händler zuordnen…", self._zuordnen).pack(side="left")
|
||||
w.knopf(k, "OK", self.abbrechen, breite=10, default="active").pack(side="right")
|
||||
self.bind("<Return>", lambda e: self.abbrechen())
|
||||
|
||||
def _zuordnen(self):
|
||||
s = self.ut.selection()
|
||||
if s:
|
||||
self.result = tuple(s[0].split("\t", 1))
|
||||
self.destroy()
|
||||
|
||||
|
||||
class KennungZuordnen(w.Dialog):
|
||||
"""Fragt, ob eine Kennung einem bestehenden oder neuen Händler zugeordnet werden soll."""
|
||||
|
||||
def __init__(self, master, db: Datenbank, anbieter: str, kennung: str):
|
||||
super().__init__(master, "Kennung zuordnen")
|
||||
self.db, self.anbieter, self.kennung = db, anbieter, kennung
|
||||
tk.Label(self, text=f"{ANBIETER.get(anbieter, anbieter)}-Kennung {kennung} zuordnen zu:",
|
||||
anchor="w").pack(fill="x", padx=12, pady=(12, 6))
|
||||
self.v = tk.StringVar(value="__neu__")
|
||||
f = tk.Frame(self, bg=w.FACE)
|
||||
f.pack(fill="x", padx=20)
|
||||
tk.Radiobutton(f, text="Neuem Händler …", variable=self.v, value="__neu__").pack(anchor="w")
|
||||
haendler = db.haendler_liste()
|
||||
if haendler:
|
||||
tk.Radiobutton(f, text="Bestehendem Händler:", variable=self.v, value="__alt__").pack(anchor="w")
|
||||
self.cb = ttk.Combobox(f, values=[h.name for h in haendler], state="readonly", width=34)
|
||||
self.cb.pack(anchor="w", padx=22, pady=(0, 4))
|
||||
self.cb.bind("<<ComboboxSelected>>", lambda e: self.v.set("__alt__"))
|
||||
self._hmap = {h.name: h.id for h in haendler}
|
||||
self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=12)
|
||||
|
||||
def _ok(self):
|
||||
if self.v.get() == "__neu__":
|
||||
self.result = ("neu", None)
|
||||
else:
|
||||
name = self.cb.get()
|
||||
if not name:
|
||||
w.warnung(self, "Kennung zuordnen", "Bitte einen Händler auswählen.")
|
||||
return
|
||||
hid = self._hmap[name]
|
||||
kenn = self.db.kennungen(hid) + [(self.anbieter, self.kennung)]
|
||||
try:
|
||||
self.db.haendler_speichern(self.db.haendler(hid), kenn)
|
||||
except ValueError as exc:
|
||||
w.fehler(self, "Kennung zuordnen", str(exc))
|
||||
return
|
||||
self.result = ("alt", hid)
|
||||
self.destroy()
|
||||
|
||||
|
||||
# =========================================================================== Export
|
||||
|
||||
class ExportDialog(w.Dialog):
|
||||
def __init__(self, master, haendler: list[tuple[int, str, int, int]], vorauswahl: set[int], settings: dict,
|
||||
titel: str):
|
||||
"""haendler: (id, name, anzahl, summe_cent)"""
|
||||
super().__init__(master, "Abrechnungen exportieren")
|
||||
self.settings = settings
|
||||
f = tk.Frame(self, bg=w.FACE)
|
||||
f.pack(fill="both", expand=True, padx=10, pady=10)
|
||||
tk.Label(f, text="Für welche Händler soll eine Excel-Datei erstellt werden?", anchor="w").pack(fill="x")
|
||||
r, self.tree = w.scroll_tree(f, columns=("name", "anzahl", "summe"), show="tree headings", height=8,
|
||||
selectmode="extended")
|
||||
self.tree.heading("#0", text="")
|
||||
self.tree.column("#0", width=30, stretch=False)
|
||||
for c, t, b, a in (("name", "Händler", 220, "w"), ("anzahl", "Pos.", 50, "e"), ("summe", "Auszahlung", 100, "e")):
|
||||
self.tree.heading(c, text=t)
|
||||
self.tree.column(c, width=b, anchor=a)
|
||||
self.haken = {}
|
||||
for hid, name, n, s in haendler:
|
||||
self.haken[hid] = hid in vorauswahl
|
||||
self.tree.insert("", "end", iid=str(hid), text="[x]" if self.haken[hid] else "[ ]",
|
||||
values=(name, n, fmt(s)))
|
||||
self.tree.bind("<Button-1>", self._klick)
|
||||
self.tree.bind("<space>", lambda e: [self._toggle(int(i)) for i in self.tree.selection()])
|
||||
r.pack(fill="both", expand=True, pady=(2, 4))
|
||||
kk = tk.Frame(f, bg=w.FACE)
|
||||
kk.pack(fill="x")
|
||||
w.knopf(kk, "Alle", lambda: self._alle(True)).pack(side="left")
|
||||
w.knopf(kk, "Keine", lambda: self._alle(False)).pack(side="left", padx=4)
|
||||
|
||||
g = w.gruppe(f, "Optionen")
|
||||
g.pack(fill="x", pady=(8, 0))
|
||||
tk.Label(g, text="Bezeichnung im Dateinamen:").grid(row=0, column=0, sticky="w")
|
||||
self.v_titel = tk.StringVar(value=titel)
|
||||
tk.Entry(g, textvariable=self.v_titel, width=30).grid(row=0, column=1, columnspan=2, sticky="we", pady=2)
|
||||
tk.Label(g, text="Zielordner:").grid(row=1, column=0, sticky="w")
|
||||
self.v_ordner = tk.StringVar(value=settings.get("export_ordner") or "")
|
||||
tk.Entry(g, textvariable=self.v_ordner, width=40).grid(row=1, column=1, sticky="we", pady=2)
|
||||
w.knopf(g, "Durchsuchen…", self._ordner).grid(row=1, column=2, padx=(4, 0))
|
||||
self.v_anb = tk.BooleanVar(value=settings.get("export_pro_anbieter", True))
|
||||
self.v_kopf = tk.BooleanVar(value=settings.get("export_kopfzeile", False))
|
||||
self.v_sum = tk.BooleanVar(value=settings.get("export_summe", True))
|
||||
self.v_auf = tk.BooleanVar(value=True)
|
||||
tk.Checkbutton(g, text="Pro Anbieter (freenet / Vodafone) eine eigene Datei",
|
||||
variable=self.v_anb).grid(row=2, column=0, columnspan=3, sticky="w")
|
||||
tk.Checkbutton(g, text="Kopfzeile mit Spaltennamen", variable=self.v_kopf).grid(row=3, column=0, columnspan=3,
|
||||
sticky="w")
|
||||
tk.Checkbutton(g, text="Summenzeile unter der Tabelle", variable=self.v_sum).grid(row=4, column=0,
|
||||
columnspan=3, sticky="w")
|
||||
tk.Checkbutton(g, text="Ordner nach dem Export öffnen", variable=self.v_auf).grid(row=5, column=0,
|
||||
columnspan=3, sticky="w")
|
||||
g.columnconfigure(1, weight=1)
|
||||
self.knopfleiste([("Exportieren", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10))
|
||||
|
||||
def _klick(self, e):
|
||||
if self.tree.identify_column(e.x) == "#0":
|
||||
iid = self.tree.identify_row(e.y)
|
||||
if iid:
|
||||
self._toggle(int(iid))
|
||||
|
||||
def _toggle(self, hid):
|
||||
self.haken[hid] = not self.haken[hid]
|
||||
self.tree.item(str(hid), text="[x]" if self.haken[hid] else "[ ]")
|
||||
|
||||
def _alle(self, an):
|
||||
for hid in self.haken:
|
||||
self.haken[hid] = an
|
||||
self.tree.item(str(hid), text="[x]" if an else "[ ]")
|
||||
|
||||
def _ordner(self):
|
||||
d = filedialog.askdirectory(parent=self, title="Zielordner wählen",
|
||||
initialdir=self.v_ordner.get() or None)
|
||||
if d:
|
||||
self.v_ordner.set(d)
|
||||
|
||||
def _ok(self):
|
||||
ids = [h for h, an in self.haken.items() if an]
|
||||
if not ids:
|
||||
w.warnung(self, "Export", "Bitte mindestens einen Händler anhaken.")
|
||||
return
|
||||
if not self.v_ordner.get().strip():
|
||||
self._ordner()
|
||||
if not self.v_ordner.get().strip():
|
||||
return
|
||||
self.settings.update(export_ordner=self.v_ordner.get().strip(), export_pro_anbieter=self.v_anb.get(),
|
||||
export_kopfzeile=self.v_kopf.get(), export_summe=self.v_sum.get())
|
||||
self.result = dict(ids=ids, ordner=self.v_ordner.get().strip(), titel=self.v_titel.get().strip(),
|
||||
pro_anbieter=self.v_anb.get(), kopfzeile=self.v_kopf.get(), summe=self.v_sum.get(),
|
||||
oeffnen=self.v_auf.get())
|
||||
self.destroy()
|
||||
|
||||
|
||||
# =========================================================================== Einstellungen
|
||||
|
||||
class EinstellungenDialog(w.Dialog):
|
||||
def __init__(self, master, settings: dict, db_pfad: str):
|
||||
super().__init__(master, "Einstellungen")
|
||||
self.settings = settings
|
||||
g = w.gruppe(self, "Datenbank")
|
||||
g.pack(fill="x", padx=10, pady=10)
|
||||
tk.Label(g, text="Speicherort der Datenbank (Händler, Regeln, Abrechnungen):", anchor="w").grid(
|
||||
row=0, column=0, columnspan=2, sticky="w")
|
||||
self.v = tk.StringVar(value=db_pfad)
|
||||
tk.Entry(g, textvariable=self.v, width=56).grid(row=1, column=0, sticky="we", pady=4)
|
||||
w.knopf(g, "Ändern…", self._waehlen).grid(row=1, column=1, padx=(4, 0))
|
||||
tk.Label(g, text="Tipp: Liegt die Datei auf einem Netzlaufwerk, können mehrere PCs dieselben\n"
|
||||
"Händler nutzen – aber bitte nicht gleichzeitig damit arbeiten.",
|
||||
justify="left", fg=w.SHADOW).grid(row=2, column=0, columnspan=2, sticky="w")
|
||||
self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10))
|
||||
|
||||
def _waehlen(self):
|
||||
p = filedialog.asksaveasfilename(parent=self, title="Datenbank wählen oder neu anlegen",
|
||||
defaultextension=".db", confirmoverwrite=False,
|
||||
filetypes=[("Datenbank", "*.db"), ("Alle Dateien", "*.*")])
|
||||
if p:
|
||||
self.v.set(p)
|
||||
|
||||
def _ok(self):
|
||||
self.result = self.v.get().strip()
|
||||
self.destroy()
|
||||
|
||||
|
||||
# =========================================================================== Info
|
||||
|
||||
class InfoDialog(w.Dialog):
|
||||
def __init__(self, master):
|
||||
super().__init__(master, f"Info über {APP_NAME}")
|
||||
top = tk.Frame(self, bg=w.FACE)
|
||||
top.pack(padx=14, pady=14)
|
||||
c = tk.Canvas(top, width=110, height=110, bg=w.TEAL, bd=2, relief="sunken", highlightthickness=0)
|
||||
c.pack(side="left", anchor="n")
|
||||
self._img = w.make_icon(self, "app", 6)
|
||||
c.create_image(57, 57, image=self._img)
|
||||
rechts = tk.Frame(top, bg=w.FACE)
|
||||
rechts.pack(side="left", padx=(14, 0), anchor="n")
|
||||
tk.Label(rechts, text=APP_NAME, font=w.FONT_BIG).pack(anchor="w")
|
||||
tk.Label(rechts, text=f"Version {__version__}", anchor="w").pack(anchor="w")
|
||||
w.trenner(rechts, vertikal=False).pack(fill="x", pady=8)
|
||||
tk.Label(rechts, text=f"Lizenziert für:\n{APP_ORG}\n\nProvisionsabrechnung für Unterhändler\n"
|
||||
"freenet · Vodafone", justify="left").pack(anchor="w")
|
||||
w.trenner(rechts, vertikal=False).pack(fill="x", pady=8)
|
||||
tk.Label(rechts, text="Arbeitsspeicher für Provisionen: reichlich", fg=w.SHADOW).pack(anchor="w")
|
||||
self.knopfleiste([("OK", self.abbrechen)]).pack(pady=(0, 12))
|
||||
self.bind("<Return>", lambda e: self.abbrechen())
|
||||
@@ -0,0 +1,582 @@
|
||||
"""Windows-95-Optik für Tkinter: Farben, Schriften, Pixel-Icons und ein paar Bausteine."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
FACE = "#c0c0c0"
|
||||
HILITE = "#ffffff"
|
||||
LIGHT = "#dfdfdf"
|
||||
SHADOW = "#808080"
|
||||
DARK = "#000000"
|
||||
NAVY = "#000080"
|
||||
TEAL = "#008080"
|
||||
WHITE = "#ffffff"
|
||||
INFO_BG = "#ffffe1"
|
||||
|
||||
IS_WIN = sys.platform.startswith("win")
|
||||
FONT_FAMILY = "MS Sans Serif" if IS_WIN else "DejaVu Sans"
|
||||
FONT_SIZE = 8 if IS_WIN else 9
|
||||
FONT = (FONT_FAMILY, FONT_SIZE)
|
||||
FONT_BOLD = (FONT_FAMILY, FONT_SIZE, "bold")
|
||||
FONT_BIG = (FONT_FAMILY, FONT_SIZE + 4, "bold")
|
||||
FONT_MONO = ("Courier New" if IS_WIN else "DejaVu Sans Mono", FONT_SIZE + 1)
|
||||
|
||||
# --------------------------------------------------------------------------- Pixel-Icons (16x16)
|
||||
PALETTE = {
|
||||
"k": "#000000", "w": "#ffffff", "g": "#c0c0c0", "d": "#808080", "y": "#ffff00", "Y": "#808000",
|
||||
"b": "#000080", "B": "#0000ff", "r": "#ff0000", "R": "#800000", "G": "#008000", "L": "#00ff00",
|
||||
"t": "#008080", "c": "#00ffff", "s": "#ffcc99", "o": "#ff8000",
|
||||
}
|
||||
|
||||
ICONS = {
|
||||
"neu": """
|
||||
..kkkkkkkkk.....
|
||||
..kwwwwwwwkk....
|
||||
..kwwwwwwwkwk...
|
||||
..kwwwwwwwkkkk..
|
||||
..kwwwwwwwwwwk..
|
||||
..kwkkkkkkkwwk..
|
||||
..kwwwwwwwwwwk..
|
||||
..kwkkkkkkkkwk..
|
||||
..kwwwwwwwwwwk..
|
||||
..kwkkkkkkkkwk..
|
||||
..kwwwwwwwwwwk..
|
||||
..kwkkkkkwwwwk..
|
||||
..kwwwwwwwwwwk..
|
||||
..kwwwwwwwwwwk..
|
||||
..kkkkkkkkkkkk..
|
||||
................""",
|
||||
"oeffnen": """
|
||||
................
|
||||
.........kkk....
|
||||
........k...k.k.
|
||||
.............kk.
|
||||
.kkk........kkk.
|
||||
kyYyk...........
|
||||
kYyYykkkkkkk....
|
||||
kyYyYyYyYyYk....
|
||||
kYyYykkkkkkkkkk.
|
||||
kyYykyyyyyyyyyk.
|
||||
kYykyyyyyyyyyk..
|
||||
kykyyyyyyyyyk...
|
||||
kkyyyyyyyyyk....
|
||||
kkkkkkkkkkk.....
|
||||
................
|
||||
................""",
|
||||
"import": """
|
||||
......kkkk......
|
||||
......kGGk......
|
||||
......kGGk......
|
||||
......kGGk......
|
||||
...kkkkGGkkkk...
|
||||
....kGGGGGGk....
|
||||
.....kGGGGk.....
|
||||
......kGGk......
|
||||
.......kk.......
|
||||
.kkkkkkkkkkkkkk.
|
||||
.kwwwwwwwwwwwdk.
|
||||
.kwkkwkkwkkwwdk.
|
||||
.kwwwwwwwwwwwdk.
|
||||
.kddddddddddddk.
|
||||
.kkkkkkkkkkkkkk.
|
||||
................""",
|
||||
"export": """
|
||||
kkkkkkkkkkkkkkk.
|
||||
kbbkwwwwwwwkbbk.
|
||||
kbbkwwwwwwwkbbk.
|
||||
kbbkwwwwwwwkbbk.
|
||||
kbbkwwwwwwwkbbk.
|
||||
kbbkkkkkkkkkbbk.
|
||||
kbbbbbbbbbbbbbk.
|
||||
kbbbbbbbbbbbbbk.
|
||||
kbbkkkkkkkkkkbk.
|
||||
kbbkgggkkgggkbk.
|
||||
kbbkgggkkgggkbk.
|
||||
kbbkgggkkgggkbk.
|
||||
kbbkgggggggggbk.
|
||||
.kbkkkkkkkkkkbk.
|
||||
..kkkkkkkkkkkkk.
|
||||
................""",
|
||||
"haendler": """
|
||||
................
|
||||
....kkk.........
|
||||
...kssskk.kkk...
|
||||
...ksksk.kssk...
|
||||
...kssskksksk...
|
||||
....kskkkssk....
|
||||
...kbbbkkksk....
|
||||
..kbbbbbkRRRk...
|
||||
..kbbbbbkRRRRk..
|
||||
..kbkbkbRRRRRk..
|
||||
..kbkbkbkRkRRk..
|
||||
..kbkbkbkRkRRk..
|
||||
..kbbbbbkRRRRk..
|
||||
...kbkbkkRkRk...
|
||||
...kkkkk.kkkk...
|
||||
................""",
|
||||
"prozent": """
|
||||
................
|
||||
..kkk......kk...
|
||||
.kwwwk....kk....
|
||||
.kwkwk...kk.....
|
||||
.kwwwk..kk......
|
||||
..kkk..kk.......
|
||||
......kk........
|
||||
.....kk.........
|
||||
....kk..........
|
||||
...kk...kkk.....
|
||||
..kk...kwwwk....
|
||||
.kk....kwkwk....
|
||||
kk.....kwwwk....
|
||||
........kkk.....
|
||||
................
|
||||
................""",
|
||||
"rechner": """
|
||||
.kkkkkkkkkkkkk..
|
||||
.kddddddddddddk.
|
||||
.kdkkkkkkkkkkdk.
|
||||
.kdkLLLLLLLLkdk.
|
||||
.kdkkkkkkkkkkdk.
|
||||
.kddddddddddddk.
|
||||
.kdwwdwwdwwdrrk.
|
||||
.kdkkdkkdkkdkkk.
|
||||
.kddddddddddddk.
|
||||
.kdwwdwwdwwdwwk.
|
||||
.kdkkdkkdkkdkkk.
|
||||
.kddddddddddddk.
|
||||
.kdwwdwwdwwdwwk.
|
||||
.kdkkdkkdkkdkkk.
|
||||
.kkkkkkkkkkkkkk.
|
||||
................""",
|
||||
"info": """
|
||||
.....kkkkkk.....
|
||||
...kkBBBBBBkk...
|
||||
..kBBBBBBBBBBk..
|
||||
.kBBBBBwwBBBBBk.
|
||||
.kBBBBBwwBBBBBk.
|
||||
kBBBBBBBBBBBBBBk
|
||||
kBBBBBwwwBBBBBBk
|
||||
kBBBBBBwwBBBBBBk
|
||||
kBBBBBBwwBBBBBBk
|
||||
kBBBBBBwwBBBBBBk
|
||||
.kBBBBBwwBBBBBk.
|
||||
.kBBBBwwwwBBBBk.
|
||||
..kBBBBBBBBBBk..
|
||||
...kkBBBBBBkk...
|
||||
.....kkkkkk.....
|
||||
................""",
|
||||
"warnung": """
|
||||
.......kk.......
|
||||
......kyyk......
|
||||
......kyyk......
|
||||
.....kyyyyk.....
|
||||
.....kykkyk.....
|
||||
....kyykkyyk....
|
||||
....kyykkyyk....
|
||||
...kyyykkyyyk...
|
||||
...kyyykkyyyk...
|
||||
..kyyyyyyyyyyk..
|
||||
..kyyyykkyyyyk..
|
||||
.kyyyyykkyyyyyk.
|
||||
.kyyyyyyyyyyyyk.
|
||||
kkkkkkkkkkkkkkkk
|
||||
................
|
||||
................""",
|
||||
"fehler": """
|
||||
.....kkkkkk.....
|
||||
...kkrrrrrrkk...
|
||||
..krrrrrrrrrrk..
|
||||
.krrwrrrrrrwrrk.
|
||||
.krwwwrrrrwwwrk.
|
||||
krrrwwwrrwwwrrrk
|
||||
krrrrwwwwwwrrrrk
|
||||
krrrrrwwwwrrrrrk
|
||||
krrrrrwwwwrrrrrk
|
||||
krrrrwwwwwwrrrrk
|
||||
.krrwwwrrwwwrrk.
|
||||
.krwwwrrrrwwwrk.
|
||||
..krrrrrrrrrrk..
|
||||
...kkrrrrrrkk...
|
||||
.....kkkkkk.....
|
||||
................""",
|
||||
"frage": """
|
||||
.....kkkkkk.....
|
||||
...kkwwwwwwkk...
|
||||
..kwwwwwwwwwwk..
|
||||
.kwwwwBBBBwwwwk.
|
||||
.kwwwBBwwBBwwwk.
|
||||
kwwwwwwwwBBwwwwk
|
||||
kwwwwwwwBBwwwwwk
|
||||
kwwwwwwBBwwwwwwk
|
||||
kwwwwwwBBwwwwwwk
|
||||
kwwwwwwwwwwwwwwk
|
||||
.kwwwwwBBwwwwwk.
|
||||
.kwwwwwBBwwwwwk.
|
||||
..kwwwwwwwwwwk..
|
||||
...kkwwwwwwkk...
|
||||
.....kkkkkkk....
|
||||
................""",
|
||||
"app": """
|
||||
................
|
||||
.....kkkkkkk....
|
||||
....kdddddddk...
|
||||
....kdkkkkkdk...
|
||||
....kdkLLLkdk...
|
||||
....kdkLLLkdk...
|
||||
....kdkkkkkdk...
|
||||
....kdwdwdwdk...
|
||||
....kdddddddk...
|
||||
....kdwdwdwdk...
|
||||
....kdddddddk...
|
||||
..ttkdwdwdwdktt.
|
||||
.tcccckkkkkkccct
|
||||
tcccccyyyyyyccct
|
||||
.ttttttttttttttt
|
||||
................""",
|
||||
}
|
||||
|
||||
|
||||
def _icon_rows(name: str) -> list[str]:
|
||||
rows = [r.strip() for r in ICONS[name].strip("\n").splitlines() if r.strip()]
|
||||
return [r.ljust(16, ".")[:16] for r in rows[:16]] + ["." * 16] * (16 - len(rows))
|
||||
|
||||
|
||||
def make_icon(master, name: str, scale: int = 1) -> tk.PhotoImage:
|
||||
img = tk.PhotoImage(master=master, width=16 * scale, height=16 * scale)
|
||||
for y, row in enumerate(_icon_rows(name)):
|
||||
for x, ch in enumerate(row):
|
||||
if ch == ".":
|
||||
continue
|
||||
color = PALETTE[ch]
|
||||
img.put(color, to=(x * scale, y * scale, (x + 1) * scale, (y + 1) * scale))
|
||||
return img
|
||||
|
||||
|
||||
class Icons:
|
||||
"""Lazy-Cache, damit PhotoImages nicht vom Garbage Collector entsorgt werden."""
|
||||
|
||||
def __init__(self, master):
|
||||
self.master = master
|
||||
self._cache: dict[tuple[str, int], tk.PhotoImage] = {}
|
||||
|
||||
def __call__(self, name: str, scale: int = 1) -> tk.PhotoImage:
|
||||
key = (name, scale)
|
||||
if key not in self._cache:
|
||||
self._cache[key] = make_icon(self.master, name, scale)
|
||||
return self._cache[key]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- Theme
|
||||
|
||||
def apply(root: tk.Tk) -> None:
|
||||
root.configure(bg=FACE)
|
||||
o = root.option_add
|
||||
o("*Font", FONT)
|
||||
o("*Background", FACE)
|
||||
o("*Foreground", DARK)
|
||||
o("*activeBackground", FACE)
|
||||
o("*highlightThickness", 0)
|
||||
o("*selectBackground", NAVY)
|
||||
o("*selectForeground", WHITE)
|
||||
o("*Button.relief", "raised")
|
||||
o("*Button.borderWidth", 2)
|
||||
o("*Button.padX", 8)
|
||||
o("*Button.padY", 1)
|
||||
o("*Entry.background", WHITE)
|
||||
o("*Entry.relief", "sunken")
|
||||
o("*Entry.borderWidth", 2)
|
||||
o("*Entry.disabledBackground", FACE)
|
||||
o("*Text.background", WHITE)
|
||||
o("*Text.relief", "sunken")
|
||||
o("*Text.borderWidth", 2)
|
||||
o("*Listbox.background", WHITE)
|
||||
o("*Listbox.relief", "sunken")
|
||||
o("*Listbox.borderWidth", 2)
|
||||
o("*Checkbutton.selectColor", WHITE)
|
||||
o("*Radiobutton.selectColor", WHITE)
|
||||
o("*Menu.relief", "raised")
|
||||
o("*Menu.activeBackground", NAVY)
|
||||
o("*Menu.activeForeground", WHITE)
|
||||
o("*Menu.activeBorderWidth", 0)
|
||||
o("*TCombobox*Listbox.background", WHITE)
|
||||
o("*TCombobox*Listbox.selectBackground", NAVY)
|
||||
|
||||
from tkinter import font as tkfont
|
||||
zeile = max(18, tkfont.Font(root=root, font=FONT).metrics("linespace") + 5)
|
||||
st = ttk.Style(root)
|
||||
st.theme_use("classic" if "classic" in st.theme_names() else "default")
|
||||
st.configure(".", background=FACE, foreground=DARK, font=FONT, troughcolor=LIGHT,
|
||||
selectbackground=NAVY, selectforeground=WHITE, fieldbackground=WHITE,
|
||||
bordercolor=FACE, lightcolor=HILITE, darkcolor=SHADOW, highlightthickness=0)
|
||||
st.configure("Treeview", background=WHITE, fieldbackground=WHITE, rowheight=zeile, borderwidth=0, font=FONT)
|
||||
st.map("Treeview", background=[("selected", NAVY)], foreground=[("selected", WHITE)])
|
||||
st.configure("Treeview.Heading", background=FACE, relief="raised", borderwidth=2, font=FONT, padding=(3, 1))
|
||||
st.map("Treeview.Heading", relief=[("pressed", "sunken")], background=[("active", FACE)])
|
||||
st.configure("TScrollbar", background=FACE, troughcolor=LIGHT, arrowcolor=DARK, relief="raised", borderwidth=2,
|
||||
arrowsize=16, width=16)
|
||||
st.map("TScrollbar", background=[("active", FACE)])
|
||||
st.configure("TCombobox", fieldbackground=WHITE, background=FACE, arrowsize=14, padding=1)
|
||||
st.map("TCombobox", fieldbackground=[("readonly", WHITE)], selectbackground=[("readonly", WHITE)],
|
||||
selectforeground=[("readonly", DARK)])
|
||||
st.configure("TPanedwindow", background=FACE)
|
||||
st.configure("Sash", sashthickness=6, background=FACE)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- Bausteine
|
||||
|
||||
class Sunken(tk.Frame):
|
||||
"""Eingelassener Rahmen (Doppellinie wie bei Win95-Listen)."""
|
||||
|
||||
def __init__(self, master, **kw):
|
||||
kw.setdefault("bd", 2)
|
||||
kw.setdefault("relief", "sunken")
|
||||
super().__init__(master, **kw)
|
||||
|
||||
|
||||
class Fenster(tk.Frame):
|
||||
"""Ein „Fenster im Fenster“ mit blauer Titelleiste – reine Optik, gliedert die Oberfläche."""
|
||||
|
||||
def __init__(self, master, titel: str, icon: tk.PhotoImage | None = None, **kw):
|
||||
super().__init__(master, bd=2, relief="raised", bg=FACE, **kw)
|
||||
self.titelleiste = tk.Frame(self, bg=NAVY, height=18)
|
||||
self.titelleiste.pack(fill="x", padx=1, pady=1)
|
||||
if icon is not None:
|
||||
tk.Label(self.titelleiste, image=icon, bg=NAVY).pack(side="left", padx=(2, 0))
|
||||
self.titel_var = tk.StringVar(value=titel)
|
||||
tk.Label(self.titelleiste, textvariable=self.titel_var, bg=NAVY, fg=WHITE, font=FONT_BOLD,
|
||||
anchor="w").pack(side="left", fill="x", padx=3)
|
||||
self.inhalt = tk.Frame(self, bg=FACE)
|
||||
self.inhalt.pack(fill="both", expand=True, padx=2, pady=2)
|
||||
|
||||
def set_titel(self, t: str):
|
||||
self.titel_var.set(t)
|
||||
|
||||
|
||||
class Statusleiste(tk.Frame):
|
||||
def __init__(self, master, felder: list[tuple[str, int]]):
|
||||
super().__init__(master, bg=FACE)
|
||||
self.vars: dict[str, tk.StringVar] = {}
|
||||
for i, (key, breite) in enumerate(felder):
|
||||
v = tk.StringVar()
|
||||
lbl = tk.Label(self, textvariable=v, bd=1, relief="sunken", anchor="w", padx=4)
|
||||
if breite:
|
||||
lbl.configure(width=breite)
|
||||
lbl.pack(side="left", padx=(0, 2), pady=2)
|
||||
else:
|
||||
lbl.pack(side="left", fill="x", expand=True, padx=(0, 2), pady=2)
|
||||
self.vars[key] = v
|
||||
|
||||
def set(self, key: str, text: str):
|
||||
self.vars[key].set(text)
|
||||
|
||||
|
||||
def knopf(master, text: str, command=None, icon: tk.PhotoImage | None = None, breite: int | None = None, **kw):
|
||||
b = tk.Button(master, text=text, command=command, bd=2, relief="raised", bg=FACE,
|
||||
activebackground=FACE, **kw)
|
||||
if icon is not None:
|
||||
b.configure(image=icon, compound="left", padx=6)
|
||||
if breite:
|
||||
b.configure(width=breite)
|
||||
return b
|
||||
|
||||
|
||||
class ToolbarButton(tk.Label):
|
||||
"""Flacher Werkzeugleisten-Knopf (Office-95-Stil): hebt sich beim Überfahren, senkt sich beim Klick."""
|
||||
|
||||
def __init__(self, master, text: str, icon: tk.PhotoImage, command, tooltip: str = ""):
|
||||
super().__init__(master, text=text, image=icon, compound="top", bd=1, relief="flat", bg=FACE,
|
||||
padx=6, pady=2, font=FONT)
|
||||
self.command = command
|
||||
self.bind("<Enter>", lambda e: self.configure(relief="raised"))
|
||||
self.bind("<Leave>", lambda e: self.configure(relief="flat"))
|
||||
self.bind("<ButtonPress-1>", lambda e: self.configure(relief="sunken"))
|
||||
self.bind("<ButtonRelease-1>", self._release)
|
||||
if tooltip:
|
||||
Tooltip(self, tooltip)
|
||||
|
||||
def _release(self, e):
|
||||
self.configure(relief="raised")
|
||||
if 0 <= e.x < self.winfo_width() and 0 <= e.y < self.winfo_height() and self.command:
|
||||
self.command()
|
||||
|
||||
|
||||
class Tooltip:
|
||||
def __init__(self, widget, text: str):
|
||||
self.widget, self.text, self.tip, self._after = widget, text, None, None
|
||||
widget.bind("<Enter>", self._plan, add="+")
|
||||
widget.bind("<Leave>", self._weg, add="+")
|
||||
widget.bind("<ButtonPress>", self._weg, add="+")
|
||||
|
||||
def _plan(self, _e):
|
||||
self._after = self.widget.after(600, self._zeigen)
|
||||
|
||||
def _zeigen(self):
|
||||
x = self.widget.winfo_rootx() + 10
|
||||
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4
|
||||
self.tip = tw = tk.Toplevel(self.widget)
|
||||
tw.wm_overrideredirect(True)
|
||||
tw.wm_geometry(f"+{x}+{y}")
|
||||
tk.Label(tw, text=self.text, bg=INFO_BG, bd=1, relief="solid", padx=4, pady=1).pack()
|
||||
|
||||
def _weg(self, _e=None):
|
||||
if self._after:
|
||||
self.widget.after_cancel(self._after)
|
||||
self._after = None
|
||||
if self.tip:
|
||||
self.tip.destroy()
|
||||
self.tip = None
|
||||
|
||||
|
||||
def trenner(master, vertikal: bool = True):
|
||||
f = tk.Frame(master, bg=FACE)
|
||||
if vertikal:
|
||||
tk.Frame(f, width=1, bg=SHADOW).pack(side="left", fill="y")
|
||||
tk.Frame(f, width=1, bg=HILITE).pack(side="left", fill="y")
|
||||
else:
|
||||
tk.Frame(f, height=1, bg=SHADOW).pack(fill="x")
|
||||
tk.Frame(f, height=1, bg=HILITE).pack(fill="x")
|
||||
return f
|
||||
|
||||
|
||||
def gruppe(master, titel: str, **kw) -> tk.LabelFrame:
|
||||
return tk.LabelFrame(master, text=titel, bd=2, relief="groove", bg=FACE, padx=6, pady=4, **kw)
|
||||
|
||||
|
||||
def scroll_tree(master, **kw) -> tuple[tk.Frame, ttk.Treeview]:
|
||||
rahmen = Sunken(master, bg=WHITE)
|
||||
tree = ttk.Treeview(rahmen, **kw)
|
||||
vs = ttk.Scrollbar(rahmen, orient="vertical", command=tree.yview)
|
||||
hs = ttk.Scrollbar(rahmen, orient="horizontal", command=tree.xview)
|
||||
tree.configure(yscrollcommand=vs.set, xscrollcommand=hs.set)
|
||||
tree.grid(row=0, column=0, sticky="nsew")
|
||||
vs.grid(row=0, column=1, sticky="ns")
|
||||
hs.grid(row=1, column=0, sticky="ew")
|
||||
rahmen.rowconfigure(0, weight=1)
|
||||
rahmen.columnconfigure(0, weight=1)
|
||||
return rahmen, tree
|
||||
|
||||
|
||||
def auto_breite(tree: ttk.Treeview, flex: dict[str, float], reserve: int = 4):
|
||||
"""Feste Spalten behalten ihre Breite, die „flex“-Spalten teilen sich den Rest anteilig.
|
||||
So bleiben z. B. die Betragsspalten immer sichtbar, egal wie breit das Fenster ist."""
|
||||
|
||||
def anpassen(_e=None):
|
||||
verfuegbar = tree.winfo_width() - reserve
|
||||
if verfuegbar < 50:
|
||||
return
|
||||
alle = ["#0"] + list(tree["columns"]) if "tree" in str(tree["show"]) else list(tree["columns"])
|
||||
fest = sum(int(tree.column(c, "width")) for c in alle if c not in flex)
|
||||
rest = max(0, verfuegbar - fest)
|
||||
summe = sum(flex.values())
|
||||
for c, anteil in flex.items():
|
||||
minimum = int(tree.column(c, "minwidth"))
|
||||
tree.column(c, width=max(minimum, int(rest * anteil / summe)))
|
||||
|
||||
tree.bind("<Configure>", anpassen, add="+")
|
||||
return anpassen
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- Dialoge
|
||||
|
||||
class Dialog(tk.Toplevel):
|
||||
"""Modaler Dialog im Win95-Stil."""
|
||||
|
||||
def __init__(self, master, titel: str, resizable: bool = False):
|
||||
super().__init__(master, bg=FACE)
|
||||
self.withdraw()
|
||||
self.title(titel)
|
||||
self.transient(master.winfo_toplevel())
|
||||
self.resizable(resizable, resizable)
|
||||
self.result = None
|
||||
self.protocol("WM_DELETE_WINDOW", self.abbrechen)
|
||||
self.bind("<Escape>", lambda e: self.abbrechen())
|
||||
try:
|
||||
self.iconphoto(False, master.winfo_toplevel()._app_icon) # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def zeigen(self, fokus=None):
|
||||
self.update_idletasks()
|
||||
m = self.master.winfo_toplevel()
|
||||
w, h = self.winfo_reqwidth(), self.winfo_reqheight()
|
||||
x = m.winfo_rootx() + max(0, (m.winfo_width() - w) // 2)
|
||||
y = m.winfo_rooty() + max(0, (m.winfo_height() - h) // 3)
|
||||
self.geometry(f"+{x}+{y}")
|
||||
self.deiconify()
|
||||
self.lift()
|
||||
try:
|
||||
self.grab_set()
|
||||
except tk.TclError:
|
||||
pass
|
||||
(fokus or self).focus_set()
|
||||
self.wait_window()
|
||||
return self.result
|
||||
|
||||
def abbrechen(self):
|
||||
self.result = None
|
||||
self.destroy()
|
||||
|
||||
def knopfleiste(self, knoepfe: list[tuple[str, object]], default: int = 0) -> tk.Frame:
|
||||
f = tk.Frame(self, bg=FACE)
|
||||
for i, (text, cmd) in enumerate(knoepfe):
|
||||
b = knopf(f, text, cmd, breite=10)
|
||||
if i == default:
|
||||
b.configure(default="active")
|
||||
b.pack(side="left", padx=4)
|
||||
return f
|
||||
|
||||
|
||||
def _meldung(master, titel: str, text: str, icon: str, knoepfe: list[tuple[str, object]], default=0):
|
||||
d = Dialog(master, titel)
|
||||
body = tk.Frame(d, bg=FACE)
|
||||
body.pack(fill="both", expand=True, padx=12, pady=(14, 8))
|
||||
ic = make_icon(d, icon, 2)
|
||||
d._ic = ic
|
||||
tk.Label(body, image=ic, bg=FACE).pack(side="left", anchor="n", padx=(0, 14))
|
||||
tk.Label(body, text=text, bg=FACE, justify="left", wraplength=420, anchor="w").pack(side="left", fill="x")
|
||||
|
||||
def mk(val):
|
||||
def f():
|
||||
d.result = val
|
||||
d.destroy()
|
||||
return f
|
||||
|
||||
leiste = d.knopfleiste([(t, mk(v)) for t, v in knoepfe], default)
|
||||
leiste.pack(pady=(4, 12))
|
||||
d.bind("<Return>", lambda e: mk(knoepfe[default][1])())
|
||||
return d.zeigen()
|
||||
|
||||
|
||||
def info(master, titel: str, text: str):
|
||||
_meldung(master, titel, text, "info", [("OK", True)])
|
||||
|
||||
|
||||
def warnung(master, titel: str, text: str):
|
||||
_meldung(master, titel, text, "warnung", [("OK", True)])
|
||||
|
||||
|
||||
def fehler(master, titel: str, text: str):
|
||||
_meldung(master, titel, text, "fehler", [("OK", True)])
|
||||
|
||||
|
||||
def frage(master, titel: str, text: str, ja: str = "Ja", nein: str = "Nein", default_ja: bool = True) -> bool:
|
||||
return bool(_meldung(master, titel, text, "frage", [(ja, True), (nein, False)], 0 if default_ja else 1))
|
||||
|
||||
|
||||
def eingabe(master, titel: str, text: str, wert: str = "") -> str | None:
|
||||
d = Dialog(master, titel)
|
||||
tk.Label(d, text=text, bg=FACE, justify="left", anchor="w").pack(fill="x", padx=12, pady=(12, 4))
|
||||
var = tk.StringVar(value=wert)
|
||||
e = tk.Entry(d, textvariable=var, width=46)
|
||||
e.pack(padx=12, pady=4)
|
||||
e.select_range(0, "end")
|
||||
|
||||
def ok():
|
||||
d.result = var.get()
|
||||
d.destroy()
|
||||
|
||||
d.bind("<Return>", lambda _e: ok())
|
||||
d.knopfleiste([("OK", ok), ("Abbrechen", d.abbrechen)]).pack(pady=(6, 12))
|
||||
return d.zeigen(e)
|
||||
Reference in new issue
Block a user