diff --git a/build/provisionsmanager.spec b/build/provisionsmanager.spec index 40fd297..709b7ec 100644 --- a/build/provisionsmanager.spec +++ b/build/provisionsmanager.spec @@ -12,7 +12,8 @@ a = Analysis( [os.path.join(ROOT, "build", "start.py")], pathex=[ROOT], hiddenimports=["openpyxl", "xlrd"], - datas=[(os.path.join(ROOT, "provtool", "gui", "bilder"), os.path.join("provtool", "gui", "bilder"))], + datas=[(os.path.join(ROOT, "provtool", "gui", "bilder"), os.path.join("provtool", "gui", "bilder")), + (os.path.join(ROOT, "provtool", "gui", "schriften"), os.path.join("provtool", "gui", "schriften"))], excludes=["numpy", "pandas", "PIL", "pytest", "matplotlib", "unittest", "pydoc_data"], noarchive=False, ) diff --git a/provtool/gui/app.py b/provtool/gui/app.py index 10f97df..c909785 100644 --- a/provtool/gui/app.py +++ b/provtool/gui/app.py @@ -82,9 +82,13 @@ class App: self.rahmen = rh.Hauptrahmen(root, w.APP_NAME, self.icons("app"), self.beenden, klassisch=klassisch) self.rahmen.set_titel(w.APP_NAME) self.inhalt = self.rahmen.inhalt + if w.DUNKEL: + from provtool.gui import win32 + win32.dunkle_titelleiste(root) - self._menue() - self._toolbar() + self.neustart = False # Design umgeschaltet: main() baut das Fenster neu auf + self.filter = "alle" + self._kopf() self._body() self.status = w.Statusleiste(self.inhalt, [("info", 0), ("pos", 16), ("orig", 22), ("ausz", 22)]) griff = self.rahmen.griff(self.status) @@ -107,9 +111,52 @@ class App: root.after(500, self.tipp_des_tages) # ================================================================= Aufbau - def _menue(self): - self.menueleiste = mn.Menueleiste(self.inhalt, self.root) - self.menueleiste.pack(side="top", fill="x") + def _kopf(self): + """Kopfzeile wie in den Webtools: Logo, Menüs, rechts Suche (modern auch Hell/Dunkel).""" + kopf = tk.Frame(self.inhalt, bg=w.FACE) + kopf.pack(side="top", fill="x") + if w.MODERN: + w.trenner(self.inhalt, vertikal=False).pack(side="top", fill="x") + innen = tk.Frame(kopf, bg=w.FACE) + innen.pack(fill="x", padx=(18, 14) if w.MODERN else 0, pady=10 if w.MODERN else 0) + if w.MODERN: + w.logo(innen).pack(side="left") + namen = tk.Frame(innen, bg=w.FACE) + namen.pack(side="left", padx=(10, 22)) + tk.Label(namen, text=w.APP_NAME, font=(w.FONT_FAMILY, w.FONT_SIZE + 1, "bold")).pack(anchor="w") + tk.Label(namen, text="Handy-Insel", fg=w.SHADOW, font=(w.MONO_FAMILY, w.FONT_SIZE - 2)).pack(anchor="w") + self._menue(innen) + + if w.MODERN: + self.b_design = w.knopf(innen, "Hell" if w.DUNKEL else "Dunkel", self.design_umschalten) + self.b_design.pack(side="right", padx=(8, 0)) + w.Tooltip(self.b_design, "Zwischen hellem und dunklem Design wechseln") + self.v_suche = tk.StringVar() + if w.MODERN: # Suchfeld mit Rahmen, Hinweis und Tastenkürzel + sf = tk.Frame(innen, bg=w.WHITE, highlightthickness=1, highlightbackground=w.RAND2, + highlightcolor=w.NAVY) + sf.pack(side="right") + tk.Label(sf, text="Suchen", bg=w.WHITE, fg=w.SHADOW).pack(side="left", padx=(10, 2)) + e = tk.Entry(sf, textvariable=self.v_suche, width=28, highlightthickness=0, bd=4) + e.pack(side="left") + tk.Label(sf, text="Strg F", bg=w.WHITE, fg=w.SHADOW, font=(w.MONO_FAMILY, w.FONT_SIZE - 2)).pack( + side="left", padx=(2, 10)) + e.bind("", lambda _e: sf.configure(highlightbackground=w.NAVY), add="+") + e.bind("", lambda _e: sf.configure(highlightbackground=w.RAND2), add="+") + else: + sf = tk.Frame(innen, 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._suche_geplant = None + self.v_suche.trace_add("write", lambda *_: self._suche_planen()) + self._suchfeld = e + self._suchrahmen = sf + + def _menue(self, master): + self.menueleiste = mn.Menueleiste(master, self.root) + self.menueleiste.pack(side="left") d = self.menueleiste.menue("Datei") 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) @@ -144,7 +191,8 @@ class App: h.add_command(label="Provisionsarten (Airtime / Jahresbonus)...", command=self.provisionsarten) h.add_command(label="Standard-Staffel Jahresbonus...", command=self.standard_staffel) - hi = self.menueleiste.menue("?", underline=None) + self.hilfe_menue = "Hilfe" if w.MODERN else "?" + hi = self.menueleiste.menue(self.hilfe_menue, underline=None) hi.add_command(label="Kurzanleitung", accelerator="F1", command=self.hilfe) hi.add_command(label="Rundgang durch das Programm", command=self.rundgang) hi.add_command(label="Tipp des Tages...", command=self.tipp_des_tages) @@ -153,116 +201,169 @@ class App: hi.add_separator() hi.add_command(label=f"Info über {w.APP_NAME}...", command=lambda: dlg.InfoDialog(self.root).zeigen()) - def _toolbar(self): - tb = tk.Frame(self.inhalt, bg=w.FACE, bd=0 if w.MODERN else 1, relief="raised") - self.tb: dict[str, w.ToolbarButton] = {} - 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 - self.tb[text] = w.ToolbarButton(tb, text, self.icons(icon), cmd, tip) - self.tb[text].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._suche_geplant = None - self.v_suche.trace_add("write", lambda *_: self._suche_planen()) - self._suchfeld = e - self._suchrahmen = sf + FILTER = (("alle", "Alle"), ("unzugeordnet", "Nicht zugeordnet"), ("manuell", "Von Hand geändert"), + ("doppelt", "Doppelt"), ("abgerechnet", "Bereits abgerechnet")) def _body(self): - desk = tk.Frame(self.inhalt, bg=w.TEAL) + m = w.MODERN + desk = tk.Frame(self.inhalt, bg=w.BG) desk.pack(side="top", fill="both", expand=True) - # Trennlinie zwischen den Spalten: grau und erhaben wie ein Win95-Splitter, mit der Maus ziehbar. - # Wie im Win95-Explorer wandert beim Ziehen nur eine Linie mit; neu aufgebaut wird erst beim - # Loslassen (flüssig, auch mit vielen Positionen). - pw = self.pw = tk.PanedWindow(desk, orient="horizontal", bg=w.FACE, sashwidth=7, - sashrelief="flat" if w.MODERN else "raised", + # Trennlinie zwischen den Spalten, mit der Maus ziehbar. Beim Ziehen wandert nur eine Linie mit; + # neu aufgebaut wird erst beim Loslassen (flüssig, auch mit vielen Positionen). + pw = self.pw = tk.PanedWindow(desk, orient="horizontal", bg=w.BG, sashwidth=6 if m else 7, + sashrelief="flat" if m else "raised", bd=0, opaqueresize=False, sashcursor="sb_h_double_arrow") - pw.pack(fill="both", expand=True, padx=6, pady=6) + pw.pack(fill="both", expand=True, padx=0 if m else 6, pady=0 if m else 6) - links = tk.Frame(pw, bg=w.FACE) - # --- Abrechnungen (ein Klick öffnet) - self.f_a = w.Fenster(links, "Abrechnungen", self.icons("oeffnen")) - self.f_a.pack(fill="x", pady=(0, 6)) - r, self.atree = w.scroll_tree(self.f_a.inhalt, columns=("status",), show="tree headings", height=5, - selectmode="browse") - self.atree.heading("#0", text="Abrechnung", anchor="w") - self.atree.heading("status", text="Status") - self.atree.column("#0", width=170, minwidth=80, stretch=False) - self.atree.column("status", width=90, anchor="w", stretch=False) + # ---------------- Seitenleiste: Abrechnungen und Händler + links = tk.Frame(pw, bg=w.FACE, bd=0 if m else 2, relief="flat" if m else "raised") + innen = tk.Frame(links, bg=w.FACE) + innen.pack(fill="both", expand=True, padx=12 if m else 4, pady=(16, 10) if m else 4) + seite = dict(style="Seite.Treeview") if m else {} + + self.f_a = tk.Frame(innen, bg=w.FACE) + self.f_a.pack(fill="x", pady=(0, 16 if m else 6)) + zeile = tk.Frame(self.f_a, bg=w.FACE) + zeile.pack(fill="x", pady=(0, 4)) + w.abschnitt(zeile, "Abrechnungen").pack(side="left", padx=(8 if m else 2, 0)) + self.b_neu = w.knopf(zeile, "+ Neu", self.neu, padx=8, pady=0, **self._link()) + self.b_neu.pack(side="right") + w.Tooltip(self.b_neu, "Neue Abrechnung anlegen (Strg+N)") + r, self.atree = w.scroll_tree(self.f_a, columns=("status",), show="tree", height=5, selectmode="browse", + auto=True, **seite) + self.atree.column("#0", width=150, minwidth=80, stretch=False) + self.atree.column("status", width=110, anchor="e", stretch=False) w.auto_breite(self.atree, {"#0": 1}) + self.atree.tag_configure("leise", foreground=w.SHADOW) + self._punkte = {f: w.punkt(self.root, getattr(w, f)) for f in ("BLAU", "GOLD", "GRUEN", "ROT", "SHADOW")} r.pack(fill="both", expand=True) self.atree.bind("<>", self._abrechnung_gewaehlt) - # --- Händler-Fenster - self.f_h = w.Fenster(links, "Händler", self.icons("haendler")) + self.f_h = tk.Frame(innen, bg=w.FACE) self.f_h.pack(fill="both", expand=True) - 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) + zeile = tk.Frame(self.f_h, bg=w.FACE) + zeile.pack(fill="x", pady=(0, 4)) + w.abschnitt(zeile, "Händler").pack(side="left", padx=(8 if m else 2, 0)) + self.b_haendler = w.knopf(zeile, "Verwalten", self.haendler, padx=8, pady=0, **self._link()) + self.b_haendler.pack(side="right") + w.Tooltip(self.b_haendler, "Händler und Kennungen verwalten (Strg+H)") + r, self.htree = w.scroll_tree(self.f_h, columns=("anzahl", "summe"), show="tree", selectmode="browse", + auto=True, **seite) + self.htree.column("#0", width=140, minwidth=80, stretch=False) + self.htree.column("anzahl", width=40, anchor="e", stretch=False) + self.htree.column("summe", width=96, 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("<>", self._haendler_gewaehlt) self.htree.bind("", lambda e: self._haendler_doppelklick()) - pw.add(links, minsize=240, width=310) + pw.add(links, minsize=240, width=320) + + # ---------------- Inhalt: Kopf der Abrechnung, Kennzahlen, Positionen + rechts = tk.Frame(pw, bg=w.BG) + innen = tk.Frame(rechts, bg=w.BG) + innen.pack(fill="both", expand=True, padx=(16, 20) if m else 0, pady=(16, 0) if m else 0) + + kopf = tk.Frame(innen, bg=w.BG) + kopf.pack(fill="x", pady=(0, 14 if m else 6)) + texte = tk.Frame(kopf, bg=w.BG) + texte.pack(side="left", fill="x", expand=True) + self.l_status = w.abschnitt(texte, "", bg=w.BG) + self.l_status.pack(anchor="w") + self.l_titel = tk.Label(texte, text="", bg=w.BG, font=w.FONT_TITEL, anchor="w") + self.l_titel.pack(anchor="w") + self.l_info = tk.Label(texte, text="", bg=w.BG, fg=w.TEXT2, anchor="w") + self.l_info.pack(anchor="w") + self.b_export = w.knopf(kopf, "Exportieren", self.exportieren, primaer=True) + self.b_export.pack(side="right", anchor="s") + w.Tooltip(self.b_export, "Händler-Abrechnungen als Excel speichern (Strg+E)") + self.b_import = w.knopf(kopf, "Importieren", self.importieren) + self.b_import.pack(side="right", anchor="s", padx=8) + w.Tooltip(self.b_import, "CSV-/Excel-Dateien einlesen (Strg+I)") + self.b_erledigt = w.knopf(kopf, "Als erledigt markieren", self.erledigt_umschalten) + self.b_erledigt.pack(side="right", anchor="s") + self.tb = {"Neu": self.b_neu, "Import": self.b_import, "Export": self.b_export, "Händler": self.b_haendler} + + kacheln = tk.Frame(innen, bg=w.BG) + kacheln.pack(fill="x", pady=(0, 14 if m else 6)) + self.kpi: dict[str, tuple[tk.Label, tk.Label]] = {} + for i, (key, titel) in enumerate((("pos", "Positionen"), ("orig", "Provision"), ("ausz", "Auszahlung"), + ("air", "Airtime"))): + k = w.Karte(kacheln) + k.grid(row=0, column=i, sticky="nsew", padx=(0 if i == 0 else 7, 0 if i == 3 else 7)) + kacheln.columnconfigure(i, weight=1, uniform="kpi") + ki = tk.Frame(k, bg=w.FACE) + ki.pack(fill="both", expand=True, padx=16 if m else 8, pady=12 if m else 6) + w.abschnitt(ki, titel).pack(anchor="w") + farbe = {"ausz": w.NAVY, "air": w.GRUEN}.get(key, w.DARK) + wert = tk.Label(ki, text="", font=w.FONT_ZAHL, fg=farbe, anchor="w") + wert.pack(anchor="w", pady=(4, 0)) + info = tk.Label(ki, text="", fg=w.TEXT2, anchor="w") + info.pack(anchor="w") + self.kpi[key] = (wert, info) + + self.f_p = w.Karte(innen) + self.f_p.pack(fill="both", expand=True, pady=(0, 12 if m else 0)) + kopfzeile = tk.Frame(self.f_p, bg=w.FACE) + kopfzeile.pack(fill="x", padx=14 if m else 4, pady=10 if m else 4) + self.l_ptitel = tk.Label(kopfzeile, text="Positionen", font=(w.FONT_FAMILY, w.FONT_SIZE + 1, "bold")) + self.l_ptitel.pack(side="left", padx=(0, 12)) + self.pills: dict[str, w.Pill] = {} + for key, text in self.FILTER: + self.pills[key] = w.Pill(kopfzeile, text, lambda k=key: self._filter_setzen(k), aktiv=key == self.filter) + self.pills[key].pack(side="left", padx=(0, 6)) + self.l_auswahl = tk.Label(kopfzeile, text="", fg=w.TEXT2) + self.l_auswahl.pack(side="right") + if m: + w.trenner(self.f_p, vertikal=False).pack(fill="x") - # --- 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") + r2, self.ptree = w.scroll_tree(self.f_p, columns=cols, show="tree headings", selectmode="extended") + if m: + r2.configure(highlightthickness=0) + kopf_text = (lambda t: t.upper()) if m else (lambda t: t) + self.ptree.heading("#0", text=kopf_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) + for c, t, b, a in (("datum", "Datum", 86, "w"), ("rufnummer", "Rufnummer", 112, "w"), + ("tarif", "Tarif", 120, "w"), ("zeitraum", "Zeitraum", 94, "w"), + ("kennung", "Kennung", 80, "w"), + ("orig", "Provision", 100, "e"), ("proz", "%", 64, "e"), ("ausz", "Auszahlung", 112, "e")): + self.ptree.heading(c, text=kopf_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") + self.ptree.tag_configure("zebra", background=w.ZEBRA) + self.ptree.tag_configure("manuell", foreground=w.MANUELL) + self.ptree.tag_configure("abw", foreground=w.ROT) + self.ptree.tag_configure("negativ", foreground=w.ROT) self.ptree.tag_configure("abgerechnet", foreground=w.SHADOW) r2.pack(fill="both", expand=True) self.ptree.bind("", self._doppelklick) self.ptree.bind("", self._kontextmenue) self.ptree.bind("<>", lambda e: self._status_auswahl()) - leiste = tk.Frame(self.f_p.inhalt, bg=w.FACE) - leiste.pack(fill="x", pady=(4, 0)) - self.b_prozent = w.knopf(leiste, "Prozent...", self.prozent, self.icons("prozent")) + leiste = tk.Frame(self.f_p, bg=w.FACE) + leiste.pack(fill="x", padx=14 if m else 2, pady=10 if m else (4, 2)) + self.b_prozent = w.knopf(leiste, "Prozent...", self.prozent, None if m else self.icons("prozent")) self.b_prozent.pack(side="left") - w.knopf(leiste, "Betrag ändern...", self.betrag_bearbeiten, self.icons("rechner")).pack(side="left", padx=4) + w.knopf(leiste, "Betrag ändern...", self.betrag_bearbeiten, + None if m else self.icons("rechner")).pack(side="left", padx=6) 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 · Grau = abgerechnet", fg=w.SHADOW).pack( - side="left", padx=8) - pw.add(self.f_p, minsize=500) + w.knopf(leiste, "Alle aufklappen", lambda: self._klappen(True)).pack(side="right", padx=6) + pw.add(rechts, minsize=560) + + @staticmethod + def _link() -> dict: + """Kleine Textknöpfe in der Seitenleiste (modern ohne Rahmen, in Akzentfarbe).""" + return dict(fg=w.NAVY, highlightthickness=0, font=w.FONT_BOLD) if w.MODERN else {} + + def _filter_setzen(self, key: str): + self.filter = key + for k, pill in self.pills.items(): + pill.setzen(k == key) + self._tabelle() def _tasten(self): r = self.root @@ -300,15 +401,35 @@ class App: self.rahmen.set_titel(w.APP_NAME) self.status.set("info", " Keine Abrechnung geöffnet – Datei > Neue Abrechnung oder Import") self._abrechnungsliste() + self._seitenkopf() self._haendlerliste(behalte_auswahl) self._tabelle() + def _seitenkopf(self): + """Titel, Status und Knöpfe über den Kennzahlen.""" + a = self.db.abrechnung(self.aid) if self.aid else None + if not a: + self.l_status.configure(text="Keine Abrechnung geöffnet".upper() if w.MODERN else "Keine Abrechnung") + self.l_titel.configure(text="Willkommen") + self.l_info.configure(text="Links „+ Neu“ anlegen oder gleich Dateien importieren.") + self.b_erledigt.configure(text="Als erledigt markieren", state="disabled") + return + erledigt = self.db.erledigt(self.aid) + dateien = self.db.dateien(self.aid) + status = "erledigt" if erledigt else "offen" + self.l_status.configure(text=f"Abrechnung · {status}".upper() if w.MODERN else f"Abrechnung ({status})") + self.l_titel.configure(text=a["titel"]) + self.l_info.configure(text=f"{len(dateien)} {'Datei' if len(dateien) == 1 else 'Dateien'} importiert · " + f"{sum(d['anzahl'] for d in dateien)} Zeilen" if dateien else "Noch nichts importiert") + self.b_erledigt.configure(text="Wieder öffnen" if erledigt else "Als erledigt markieren", state="normal") + def _abrechnungsliste(self): t = self.atree t.delete(*t.get_children()) for a in self.db.abrechnungen(): - status = "erledigt" if a["erledigt"] else (f"{a['abgerechnet']} abgerechnet" if a["abgerechnet"] else "offen") - t.insert("", "end", iid=str(a["id"]), text=a["titel"], values=(status,)) + status, farbe = (("erledigt", "GRUEN") if a["erledigt"] else + (f"{a['abgerechnet']} abgerechnet", "GOLD") if a["abgerechnet"] else ("offen", "BLAU")) + t.insert("", "end", iid=str(a["id"]), text=" " + a["titel"], values=(status,), image=self._punkte[farbe]) if self.aid and t.exists(str(self.aid)): t.selection_set(str(self.aid)) t.see(str(self.aid)) @@ -398,6 +519,10 @@ class App: 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()] + pruef = {"unzugeordnet": lambda p: p["haendler_id"] == UNZUGEORDNET, "manuell": lambda p: p["manuell"], + "doppelt": lambda p: p["doppelt"], "abgerechnet": lambda p: p["abgerechnet"]}.get(self.filter) + if pruef: + pos = [p for p in pos if pruef(p)] gruppen: dict[str, list[dict]] = defaultdict(list) for p in exp.sortiert(pos): gruppen[self._gruppe_von(p)].append(p) @@ -414,8 +539,8 @@ class App: 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 = [] + for i, p in enumerate(plist): + tags = ["zebra"] if i % 2 else [] if p["abgerechnet"] or p["doppelt"]: tags.append("abgerechnet") elif p["manuell"]: @@ -434,17 +559,30 @@ class App: name = {ALLE: "Alle Positionen", UNZUGEORDNET: "Nicht zugeordnet", EIGEN: "Eigene Kennungen (ausgeblendet, werden nicht exportiert)"}.get( self.ansicht, self.haendler_namen.get(self.ansicht, "")) - self.f_p.set_titel(f"Positionen – {name}") + self.l_ptitel.configure(text=f"Positionen – {name}") so = sum(p["betrag_orig"] for p in pos) sa = sum(p["betrag"] for p in pos) + air = [p for p in pos if p.get("typ") == regeln.AIRTIME] self.status.set("pos", f" {len(pos)} Positionen") self.status.set("orig", f" Provision: {fmt(so)}") self.status.set("ausz", f" Auszahlung: {fmt(sa)}") + self._kennzahl("pos", str(len(pos)), name) + self._kennzahl("orig", fmt(so), "laut Anbieter") + self._kennzahl("ausz", fmt(sa), f"{round(sa * 100 / so)} % im Schnitt" if so > 0 else "") + self._kennzahl("air", fmt(sum(p["betrag"] for p in air)), + (f"{len(air)} Posten · eigene Excel-Datei" if any(p["betrag"] for p in air) else f"{len(air)} Posten") + if air else "keine Airtime") + self.l_auswahl.configure(text="") + + def _kennzahl(self, key: str, wert: str, info: str): + self.kpi[key][0].configure(text=wert) + self.kpi[key][1].configure(text=info) 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.l_auswahl.configure(text=f"{len(ps)} ausgewählt · {fmt(sum(p['betrag'] for p in ps))}") 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))}") @@ -1015,15 +1153,22 @@ class App: res = dlg.EinstellungenDialog(self.root, self.settings, str(self.db.path)).zeigen() if not res: return - if res["design"] != w.DESIGN: - self.settings["design"] = res["design"] - config.save(self.settings) - w.info(self.root, "Einstellungen", "Das neue Design wird beim nächsten Programmstart wirksam.") if res["pfad"] and Path(res["pfad"]) != self.db.path: try: self.db_wechseln(res["pfad"]) except Exception as exc: w.fehler(self.root, "Einstellungen", f"Datenbank konnte nicht geöffnet werden:\n{exc}") + if res["design"] != w.DESIGN: + self.design_wechseln(res["design"]) + + def design_umschalten(self): + self.design_wechseln("modern" if w.DUNKEL else "dunkel") + + def design_wechseln(self, design: str): + """Fenster mit dem neuen Design neu aufbauen (main() startet es nach dem Schließen wieder).""" + self.settings["design"] = design + self.neustart = True + self.root.after_idle(self.beenden) def hilfe(self): w.info(self.root, "Kurzanleitung", @@ -1082,7 +1227,7 @@ class App: "(freenet-Unterhändlernummer, Vodafone-VO-Nr. oder Kundenname in GROSSBUCHSTABEN)."), (tb["Neu"], "Neue Abrechnung", "Für jeden Abrechnungszeitraum – meist ein Monat – legen Sie eine neue Abrechnung an. " - "Mit „Öffnen“ holen Sie frühere Abrechnungen wieder hervor."), + "Frühere Abrechnungen öffnen Sie mit einem Klick in der Liste darunter."), (tb["Import"], "Dateien importieren", "Wählen Sie die CSV- und Excel-Dateien von freenet und Vodafone – gerne mehrere auf einmal. " "Das Programm erkennt das Format selbst und warnt bei doppelt importierten Dateien."), @@ -1090,13 +1235,16 @@ class App: "Links sehen Sie jeden Händler mit Anzahl der Positionen und Auszahlungssumme.\n\n" "„(Nicht zugeordnet)“ ist alles, was keinem Unterhändler gehört – meist Ihre eigene Filiale. " "Das wird nicht exportiert."), + (self.pills["alle"], "Filter", + "Mit diesen Knöpfen zeigen Sie nur bestimmte Positionen: nicht zugeordnete, von Hand geänderte, " + "doppelte oder bereits abgerechnete."), (self.f_p, "Positionen prüfen", "Rechts stehen die Positionen des gewählten Händlers, gruppiert nach Provisionsart.\n\n" "- Doppelklick auf eine Position: Betrag frei ändern\n" "- Rechtsklick: Auszahlung 0 €, Händler zuweisen, ...\n" "- Blau = von Hand geändert, Rot = durch Prozent-Regel geändert"), (self.b_prozent, "Auszahlung in Prozent", - "Graue Gruppenzeile (Provisionsart) markieren und hier klicken – z. B. 80 % für den Sonderbonus. " + "Gruppenzeile (Provisionsart) markieren und hier klicken – z. B. 80 % für den Sonderbonus. " "Mit „Als Standard merken“ gilt das auch in den nächsten Monaten automatisch."), (tb["Export"], "Exportieren", "Zum Schluss erstellt „Export“ für jeden Händler eine Excel-Datei im gewohnten Abrechnungsformat " @@ -1106,8 +1254,9 @@ class App: "Provision laut Anbieter und Ihre Auszahlung."), (self._suchrahmen, "Suchen", "Rufnummer, Name oder Tarif eingeben – die Liste filtert sofort. Tastenkürzel: Strg+F."), - (self.menueleiste.knoepfe["?"], "Das war's!", - "Diesen Rundgang, den Einrichtungs-Assistenten und die Tipps finden Sie jederzeit hier im Menü „?“.\n\n" + (self.menueleiste.knoepfe[self.hilfe_menue], "Das war's!", + "Diesen Rundgang, den Einrichtungs-Assistenten und die Tipps finden Sie jederzeit hier im Menü " + f"„{self.hilfe_menue}“.\n\n" f"Viel Erfolg mit dem {w.APP_NAME}!"), ] self._rundgang = ass.Rundgang(self, schritte) @@ -1211,11 +1360,16 @@ def main(): 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() + dateien = [a for a in sys.argv[1:] if Path(a).exists()] + while True: + root = tk.Tk(className="Provisionsmanager95") + app = App(root) + if dateien: + root.after(200, lambda: app.importieren(dateien)) + dateien = [] + root.mainloop() + if not app.neustart: # sonst: Design wurde umgeschaltet – Fenster neu aufbauen + break if __name__ == "__main__": diff --git a/provtool/gui/assistent.py b/provtool/gui/assistent.py index 80d547d..86b2c3b 100644 --- a/provtool/gui/assistent.py +++ b/provtool/gui/assistent.py @@ -74,9 +74,9 @@ class Einrichtungsassistent(w.Dialog): self._img = w.make_icon(self, "app", 7) c.create_rectangle(0, self.HOEHE - 70, 160, self.HOEHE + 10, fill="#006060", outline="") for i, (x, y) in enumerate(((20, 30), (120, 55), (35, 120), (128, 150), (60, 20))): - c.create_rectangle(x, y, x + 2 + i % 2, y + 2 + i % 2, fill=w.WHITE, outline="") + c.create_rectangle(x, y, x + 2 + i % 2, y + 2 + i % 2, fill="#ffffff", outline="") c.create_image(77, self.HOEHE // 2 - 10, image=self._img) - c.create_text(77, self.HOEHE - 38, text=w.APP_NAME.replace("-", "-\n", 1), fill=w.WHITE, font=w.FONT_BOLD, + c.create_text(77, self.HOEHE - 38, text=w.APP_NAME.replace("-", "-\n", 1), fill="#ffffff", font=w.FONT_BOLD, justify="center") def _leeren(self): diff --git a/provtool/gui/dialogs.py b/provtool/gui/dialogs.py index 3f7e48b..7229c7f 100644 --- a/provtool/gui/dialogs.py +++ b/provtool/gui/dialogs.py @@ -868,10 +868,15 @@ class ExportDialog(w.Dialog): """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) + rumpf = tk.Frame(self, bg=w.FACE) + rumpf.pack(fill="both", expand=True, padx=10, pady=10) + # zwei Spalten (links Händler, rechts Zeitraum und Optionen) – passt auch auf kleine Bildschirme + f = tk.Frame(rumpf, bg=w.FACE) + f.pack(side="left", fill="both", expand=True) + rechts = tk.Frame(rumpf, bg=w.FACE) + rechts.pack(side="left", fill="y", padx=(14, 0)) 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, + r, self.tree = w.scroll_tree(f, columns=("name", "anzahl", "summe"), show="tree headings", height=12, selectmode="extended") self.tree.heading("#0", text="") self.tree.column("#0", width=40, stretch=False) @@ -891,7 +896,7 @@ class ExportDialog(w.Dialog): 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 = w.gruppe(rechts, "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) @@ -913,13 +918,14 @@ class ExportDialog(w.Dialog): ("Summenzeile unter der Tabelle", self.v_sum), ("Ordner nach dem Export öffnen", self.v_auf)), start=2): tk.Checkbutton(g, text=text, variable=var).grid(row=zeile, column=0, columnspan=3, sticky="w") - tk.Label(g, text="Airtime steht in der Abrechnung als eine Summenzeile, die Einzelposten kommen in eine\n" - "eigene Datei „... - Airtime - ...xlsx“.", fg=w.SHADOW, justify="left", anchor="w").grid( + tk.Label(g, text="Airtime steht in der Abrechnung als eine Summenzeile, die Einzelposten kommen in eine " + "eigene Datei „... - Airtime - ...xlsx“.", fg=w.SHADOW, justify="left", anchor="w", + wraplength=420).grid( row=7, column=0, columnspan=3, sticky="w", pady=(4, 0)) g.columnconfigure(1, weight=1) - z = w.gruppe(f, "Zeitraum und Abrechnung") - z.pack(fill="x", pady=(8, 0)) + z = w.gruppe(rechts, "Zeitraum und Abrechnung") + z.pack(fill="x", before=g) zf = tk.Frame(z, bg=w.FACE) zf.pack(fill="x") tk.Label(zf, text="Nur von:").pack(side="left") @@ -934,11 +940,12 @@ class ExportDialog(w.Dialog): self.v_basis = tk.StringVar(value=zr.BASEN[settings.get("export_basis", zr.AKTIVIERUNG)]) ttk.Combobox(z, textvariable=self.v_basis, values=list(zr.BASEN.values()), state="readonly", width=40).pack(anchor="w", pady=(4, 0)) - tk.Label(z, text="(leer bzw. „Noch nicht abgerechnet“ = alle offenen Positionen; Datum als TT.MM.JJJJ)", fg=w.SHADOW, anchor="w").pack( - fill="x") + tk.Label(z, text="(leer bzw. „Noch nicht abgerechnet“ = alle offenen Positionen; Datum als TT.MM.JJJJ)", + fg=w.SHADOW, anchor="w", justify="left", wraplength=420).pack(fill="x") self.v_kennz = tk.BooleanVar(value=True) tk.Checkbutton(z, text="Exportierte Positionen als abgerechnet kennzeichnen (werden dann nicht noch " - "einmal abgerechnet)", variable=self.v_kennz, anchor="w").pack(fill="x", pady=(4, 0)) + "einmal abgerechnet)", variable=self.v_kennz, anchor="w", justify="left", + wraplength=400).pack(fill="x", pady=(4, 0)) self.knopfleiste([("Exportieren", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10)) def _vormonat(self): @@ -1014,10 +1021,11 @@ class EinstellungenDialog(w.Dialog): g2 = w.gruppe(self, "Darstellung") g2.pack(fill="x", padx=10, pady=(0, 10)) self.v_design = tk.StringVar(value=settings.get("design", "modern")) - tk.Radiobutton(g2, text="Modern (wie Windows 11)", variable=self.v_design, value="modern").pack(anchor="w") + tk.Radiobutton(g2, text="Modern hell", variable=self.v_design, value="modern").pack(anchor="w") + tk.Radiobutton(g2, text="Modern dunkel", variable=self.v_design, value="dunkel").pack(anchor="w") tk.Radiobutton(g2, text="Klassisch (Windows 95, Legacy)", variable=self.v_design, value="win95").pack( anchor="w") - tk.Label(g2, text="Wirkt beim nächsten Programmstart.", fg=w.SHADOW).pack(anchor="w") + tk.Label(g2, text="Das Fenster wird nach OK mit dem neuen Design neu aufgebaut.", fg=w.SHADOW).pack(anchor="w") self.knopfleiste([("OK", self._ok), ("Abbrechen", self.abbrechen)]).pack(pady=(0, 10)) def _waehlen(self): diff --git a/provtool/gui/menue.py b/provtool/gui/menue.py index 3ebe40b..50234ac 100644 --- a/provtool/gui/menue.py +++ b/provtool/gui/menue.py @@ -226,7 +226,8 @@ class Menueleiste(tk.Frame): self.offener_knopf: tk.Label | None = None def menue(self, label: str, underline: int | None = 0) -> Popup: - knopf = tk.Label(self, text=label, bg=w.FACE, padx=7, pady=2, bd=1, relief="flat", + knopf = tk.Label(self, text=label, bg=w.FACE, padx=10 if w.MODERN else 7, pady=6 if w.MODERN else 2, + bd=1, relief="flat", underline=-1 if underline is None else underline) knopf.pack(side="left") popup = Popup(self) diff --git a/provtool/gui/schriften/IBMPlexMono-Regular.ttf b/provtool/gui/schriften/IBMPlexMono-Regular.ttf new file mode 100644 index 0000000..f730099 Binary files /dev/null and b/provtool/gui/schriften/IBMPlexMono-Regular.ttf differ diff --git a/provtool/gui/schriften/IBMPlexSans-Bold.ttf b/provtool/gui/schriften/IBMPlexSans-Bold.ttf new file mode 100644 index 0000000..1d66b1a Binary files /dev/null and b/provtool/gui/schriften/IBMPlexSans-Bold.ttf differ diff --git a/provtool/gui/schriften/IBMPlexSans-Regular.ttf b/provtool/gui/schriften/IBMPlexSans-Regular.ttf new file mode 100644 index 0000000..bd6817d Binary files /dev/null and b/provtool/gui/schriften/IBMPlexSans-Regular.ttf differ diff --git a/provtool/gui/schriften/OFL.txt b/provtool/gui/schriften/OFL.txt new file mode 100644 index 0000000..c35c4c6 --- /dev/null +++ b/provtool/gui/schriften/OFL.txt @@ -0,0 +1,93 @@ +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/provtool/gui/win32.py b/provtool/gui/win32.py index fb246c6..5f50694 100644 --- a/provtool/gui/win32.py +++ b/provtool/gui/win32.py @@ -80,6 +80,19 @@ def hwnd(fenster: tk.Misc) -> int | None: return None +def dunkle_titelleiste(fenster: tk.Misc) -> None: + """Windows-Titelleiste dunkel (Design „dunkel“, ab Windows 10 20H1).""" + h = hwnd(fenster) + if not h: + return + try: + import ctypes + an = ctypes.c_int(1) + ctypes.windll.dwmapi.DwmSetWindowAttribute(h, 20, ctypes.byref(an), ctypes.sizeof(an)) # IMMERSIVE_DARK_MODE + except (OSError, AttributeError): + pass + + def stapel(*fenster: tk.Misc) -> list[int]: """Reihenfolge der übergebenen Fenster im Windows-Fensterstapel, oberstes zuerst (Indizes).""" ziele = [hwnd(f) for f in fenster] diff --git a/provtool/gui/win95.py b/provtool/gui/win95.py index 0290ec7..d13660e 100644 --- a/provtool/gui/win95.py +++ b/provtool/gui/win95.py @@ -22,17 +22,35 @@ INFO_BG = "#ffffe1" SEL_BG, SEL_FG = NAVY, WHITE # Auswahl/Markierung RAND = SHADOW # dünne Rahmenlinien (nur modern) DESIGN = "win95" -MODERN = False +MODERN = False # „modern“ oder „dunkel“ +DUNKEL = False APP_NAME = "Provisions-Manager 95" # modern: ohne „95“ +# Farbnamen: FACE = Fläche (Fenster, Karten, Dialoge), BG = Grund hinter den Karten, WHITE = Eingabefelder/Listen, +# DARK = Text, TEXT2 = Nebentext, SHADOW = leiser Text/Win95-Schatten, NAVY = Akzent, RAND/RAND2 = Linien, +# PRIMAER/AUF_PRIMAER = Hauptknopf, MANUELL = von Hand geänderte Beträge, *_SOFT = Grund der Status-Pills. _PALETTEN = { "win95": dict(FACE="#c0c0c0", HILITE="#ffffff", LIGHT="#dfdfdf", SHADOW="#808080", DARK="#000000", NAVY="#000080", TEAL="#008080", WHITE="#ffffff", INFO_BG="#ffffe1", SEL_BG="#000080", - SEL_FG="#ffffff", RAND="#808080"), - "modern": dict(FACE="#f3f3f3", HILITE="#ffffff", LIGHT="#ebebeb", SHADOW="#8a8a8a", DARK="#1b1b1b", - NAVY="#005fb8", TEAL="#e6eef6", WHITE="#ffffff", INFO_BG="#ffffff", SEL_BG="#cce4f7", - SEL_FG="#1b1b1b", RAND="#d1d1d1"), + SEL_FG="#ffffff", RAND="#808080", BG="#008080", RAND2="#808080", TEXT2="#000000", + PRIMAER="#c0c0c0", AUF_PRIMAER="#000000", ZEBRA="#ffffff", MANUELL="#0000c0", ROT="#a00000", + ROT_SOFT="#c0c0c0", GRUEN="#008000", GRUEN_SOFT="#c0c0c0", BLAU="#000080", BLAU_SOFT="#c0c0c0", + GOLD="#808000", GOLD_SOFT="#c0c0c0"), + # Hausfarben der Handy-Insel-Webtools (VVL-Tool): Lila, IBM Plex + "modern": dict(FACE="#ffffff", HILITE="#ffffff", LIGHT="#f1edf8", SHADOW="#6b6386", DARK="#211a38", + NAVY="#5e35b1", TEAL="#f1eef8", WHITE="#ffffff", INFO_BG="#ffffff", SEL_BG="#ede7fa", + SEL_FG="#2d1b69", RAND="#e5e0f0", BG="#f1eef8", RAND2="#d6cee8", TEXT2="#5b5372", + PRIMAER="#2d1b69", AUF_PRIMAER="#ffffff", ZEBRA="#faf8fd", MANUELL="#5e35b1", ROT="#b8223f", + ROT_SOFT="#fbe4e9", GRUEN="#11734d", GRUEN_SOFT="#def3ea", BLAU="#2a59c4", BLAU_SOFT="#e4ebfb", + GOLD="#8a6212", GOLD_SOFT="#f7eed8"), + "dunkel": dict(FACE="#1c1436", HILITE="#1c1436", LIGHT="#2a2049", SHADOW="#948daa", DARK="#ece8f6", + NAVY="#a88beb", TEAL="#120c22", WHITE="#221940", INFO_BG="#2a2049", SEL_BG="#3a2d66", + SEL_FG="#ece8f6", RAND="#312551", BG="#120c22", RAND2="#3f3167", TEXT2="#ada5c6", + PRIMAER="#c3adee", AUF_PRIMAER="#1c1436", ZEBRA="#201838", MANUELL="#c3adee", ROT="#f26982", + ROT_SOFT="#3a1e28", GRUEN="#4fc796", GRUEN_SOFT="#163025", BLAU="#7da0f0", BLAU_SOFT="#1e2743", + GOLD="#d8b058", GOLD_SOFT="#332a17"), } +globals().update(_PALETTEN["win95"]) IS_WIN = sys.platform.startswith("win") FONT_FAMILY = "MS Sans Serif" if IS_WIN else "DejaVu Sans" @@ -40,26 +58,57 @@ 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) +MONO_FAMILY = "Courier New" if IS_WIN else "DejaVu Sans Mono" +FONT_MONO = (MONO_FAMILY, FONT_SIZE + 1) +FONT_KLEIN_FETT = FONT_TITEL = FONT_ZAHL = FONT_BOLD def design_setzen(name: str) -> None: - """„modern“ oder „win95“ – setzt Farben und Schriften (Feinschliff der Schrift in apply()).""" - global DESIGN, MODERN, FONT_FAMILY, FONT_SIZE, APP_NAME + """„modern“ (hell), „dunkel“ oder „win95“ – setzt Farben und Schriften (Feinschliff der Schrift in apply()).""" + global DESIGN, MODERN, DUNKEL, FONT_FAMILY, FONT_SIZE, APP_NAME, MONO_FAMILY DESIGN = name if name in _PALETTEN else "modern" - MODERN = DESIGN == "modern" + MODERN = DESIGN in ("modern", "dunkel") + DUNKEL = DESIGN == "dunkel" APP_NAME = "Provisions-Manager" if MODERN else "Provisions-Manager 95" globals().update(_PALETTEN[DESIGN]) FONT_FAMILY = ("Segoe UI" if MODERN else "MS Sans Serif") if IS_WIN else "DejaVu Sans" - FONT_SIZE = 9 if (MODERN or not IS_WIN) else 8 + MONO_FAMILY = ("Consolas" if MODERN else "Courier New") if IS_WIN else "DejaVu Sans Mono" + FONT_SIZE = 10 if MODERN else (8 if IS_WIN else 9) _schriften() def _schriften(): - global FONT, FONT_BOLD, FONT_BIG + global FONT, FONT_BOLD, FONT_BIG, FONT_MONO, FONT_KLEIN_FETT, FONT_TITEL, FONT_ZAHL FONT = (FONT_FAMILY, FONT_SIZE) FONT_BOLD = (FONT_FAMILY, FONT_SIZE, "bold") FONT_BIG = (FONT_FAMILY, FONT_SIZE + 4, "bold") + FONT_MONO = (MONO_FAMILY, FONT_SIZE + (0 if MODERN else 1)) + FONT_KLEIN_FETT = (FONT_FAMILY, FONT_SIZE - 2, "bold") # Abschnitts-Überschriften (ABRECHNUNGEN …) + FONT_TITEL = (FONT_FAMILY, FONT_SIZE + 8, "bold") # Name der geöffneten Abrechnung + FONT_ZAHL = (MONO_FAMILY, FONT_SIZE + 6) if MODERN else (FONT_FAMILY, FONT_SIZE + 4, "bold") # Kennzahlen + + +SCHRIFTEN = Path(__file__).resolve().parent / "schriften" + + +def _schriften_laden() -> None: + """IBM Plex (Hausschrift der Handy-Insel-Webtools) nur für dieses Programm laden – ohne Installation.""" + import ctypes + import ctypes.util + dateien = sorted(SCHRIFTEN.glob("*.ttf")) + try: + if IS_WIN: + for f in dateien: + ctypes.windll.gdi32.AddFontResourceExW(str(f), 0x10, 0) # FR_PRIVATE + return + lib = ctypes.util.find_library("fontconfig") + if lib: + fc = ctypes.CDLL(lib) + fc.FcConfigAppFontAddFile.argtypes = [ctypes.c_void_p, ctypes.c_char_p] + for f in dateien: + fc.FcConfigAppFontAddFile(None, str(f).encode()) + except Exception: + pass # dann eben Segoe UI / Systemschrift # --------------------------------------------------------------------------- Pixel-Icons (16x16) PALETTE = { @@ -332,12 +381,15 @@ class Icons: # --------------------------------------------------------------------------- Theme def apply(root: tk.Tk) -> None: - global FONT_FAMILY - if MODERN and not IS_WIN: # Linux: die erste vorhandene moderne Schrift + global FONT_FAMILY, MONO_FAMILY + if MODERN: # die erste vorhandene moderne Schrift, am liebsten IBM Plex from tkinter import font as tkfont + _schriften_laden() vorhanden = set(tkfont.families(root)) - FONT_FAMILY = next((f for f in ("Segoe UI", "Noto Sans", "Cantarell", "Ubuntu", "DejaVu Sans") - if f in vorhanden), FONT_FAMILY) + FONT_FAMILY = next((f for f in ("IBM Plex Sans", "Segoe UI", "Noto Sans", "Cantarell", "Ubuntu", + "DejaVu Sans") if f in vorhanden), FONT_FAMILY) + MONO_FAMILY = next((f for f in ("IBM Plex Mono", "Consolas", "DejaVu Sans Mono") if f in vorhanden), + MONO_FAMILY) _schriften() root.configure(bg=FACE) o = root.option_add @@ -399,40 +451,61 @@ def apply(root: tk.Tk) -> None: def _modern(root: tk.Tk, st: ttk.Style, zeile: int) -> None: - """Flach und hell: dünne graue Rahmen statt 3D-Kanten, Akzentfarbe beim Fokus.""" + """Flach wie die Handy-Insel-Webtools: weiße Flächen, dünne Linien, Lila als Akzent.""" o = root.option_add for klasse in ("Entry", "Text", "Listbox", "Spinbox"): o(f"*{klasse}.relief", "flat") - o(f"*{klasse}.borderWidth", 3) + o(f"*{klasse}.borderWidth", 4) o(f"*{klasse}.highlightThickness", 1) - o(f"*{klasse}.highlightBackground", RAND) + o(f"*{klasse}.highlightBackground", RAND2) o(f"*{klasse}.highlightColor", NAVY) + o(f"*{klasse}.background", WHITE) + o(f"*{klasse}.foreground", DARK) + o(f"*{klasse}.insertBackground", DARK) + o("*Entry.disabledBackground", LIGHT) o("*Button.relief", "flat") o("*Button.borderWidth", 0) o("*Button.highlightThickness", 1) - o("*Button.highlightBackground", RAND) - o("*Button.background", WHITE) - o("*Button.activeBackground", SEL_BG) - o("*Button.padY", 3) - o("*Checkbutton.activeBackground", FACE) - o("*Radiobutton.activeBackground", FACE) + o("*Button.highlightBackground", RAND2) + o("*Button.background", FACE) + o("*Button.activeBackground", LIGHT) + o("*Button.padX", 12) + o("*Button.padY", 4) + o("*Button.cursor", "hand2") + for klasse in ("Checkbutton", "Radiobutton"): + o(f"*{klasse}.activeBackground", FACE) + o(f"*{klasse}.selectColor", WHITE) + o("*selectBackground", SEL_BG) + o("*selectForeground", SEL_FG) o("*Menu.relief", "flat") + o("*TCombobox*Listbox.background", WHITE) + o("*TCombobox*Listbox.foreground", DARK) + o("*TCombobox*Listbox.selectBackground", SEL_BG) + o("*TCombobox*Listbox.selectForeground", SEL_FG) st.theme_use("clam") st.configure(".", background=FACE, foreground=DARK, font=FONT, troughcolor=FACE, selectbackground=SEL_BG, - selectforeground=SEL_FG, fieldbackground=WHITE, bordercolor=RAND, lightcolor=WHITE, - darkcolor=RAND, focuscolor=NAVY) - st.configure("Treeview", background=WHITE, fieldbackground=WHITE, rowheight=zeile + 4, borderwidth=0, font=FONT) + selectforeground=SEL_FG, fieldbackground=WHITE, bordercolor=RAND, lightcolor=FACE, + darkcolor=RAND, focuscolor=NAVY, insertcolor=DARK) + st.configure("Treeview", background=WHITE, fieldbackground=WHITE, foreground=DARK, rowheight=zeile + 10, + borderwidth=0, font=FONT) st.map("Treeview", background=[("selected", SEL_BG)], foreground=[("selected", SEL_FG)]) - st.configure("Treeview.Heading", background=WHITE, relief="flat", borderwidth=1, font=FONT, padding=(6, 3), - bordercolor=RAND, lightcolor=WHITE, darkcolor=RAND) + st.configure("Treeview.Heading", background=FACE, foreground=SHADOW, relief="flat", borderwidth=1, + font=FONT_KLEIN_FETT, padding=(10, 7), bordercolor=RAND, lightcolor=FACE, darkcolor=RAND) st.map("Treeview.Heading", background=[("active", LIGHT)], relief=[("pressed", "flat")]) - st.configure("TScrollbar", background=LIGHT, troughcolor=FACE, arrowcolor=SHADOW, bordercolor=FACE, - lightcolor=LIGHT, darkcolor=LIGHT, relief="flat", arrowsize=12, width=12) - st.map("TScrollbar", background=[("active", RAND)]) - st.configure("TCombobox", fieldbackground=WHITE, background=WHITE, arrowcolor=DARK, padding=3, - bordercolor=RAND, lightcolor=WHITE, darkcolor=WHITE) + # Seitenleiste: ohne Kopf, hohe Zeilen, Auswahl lila wie im VVL-Tool + st.configure("Seite.Treeview", background=FACE, fieldbackground=FACE, rowheight=zeile + 16, font=FONT, + borderwidth=0) + st.layout("Seite.Treeview", [("Treeview.treearea", {"sticky": "nswe"})]) # ohne Rahmen + st.map("Seite.Treeview", background=[("selected", SEL_BG)], foreground=[("selected", NAVY)]) + st.configure("TScrollbar", background=RAND2, troughcolor=FACE, arrowcolor=SHADOW, bordercolor=FACE, + lightcolor=RAND2, darkcolor=RAND2, relief="flat", arrowsize=10, width=10, gripcount=0) + st.map("TScrollbar", background=[("active", SHADOW)]) + st.configure("TCombobox", fieldbackground=WHITE, background=FACE, foreground=DARK, arrowcolor=DARK, padding=4, + bordercolor=RAND2, lightcolor=WHITE, darkcolor=WHITE) st.map("TCombobox", fieldbackground=[("readonly", WHITE)], selectbackground=[("readonly", WHITE)], selectforeground=[("readonly", DARK)], bordercolor=[("focus", NAVY)]) + st.configure("TPanedwindow", background=BG) + st.configure("Sash", sashthickness=6, background=BG) # --------------------------------------------------------------------------- Wort löschen @@ -566,12 +639,19 @@ class Statusleiste(tk.Frame): self.vars[key].set(text) -def knopf(master, text: str, command=None, icon: tk.PhotoImage | None = None, breite: int | None = None, **kw): +def knopf(master, text: str, command=None, icon: tk.PhotoImage | None = None, breite: int | None = None, + primaer: bool = False, **kw): + """primaer: der eine Hauptknopf einer Fläche (modern dunkel-lila gefüllt, Win95 mit dickem Rahmen).""" if MODERN: + if primaer: + kw = dict(bg=PRIMAER, fg=AUF_PRIMAER, activebackground=NAVY, activeforeground=AUF_PRIMAER, + highlightbackground=PRIMAER, font=FONT_BOLD) | kw b = tk.Button(master, text=text, command=command, **kw) # Aussehen aus apply()/_modern() else: b = tk.Button(master, text=text, command=command, bd=2, relief="raised", bg=FACE, activebackground=FACE, **kw) + if primaer: + b.configure(default="active") if icon is not None: b.configure(image=icon, compound="left", padx=6) if breite: @@ -579,6 +659,104 @@ def knopf(master, text: str, command=None, icon: tk.PhotoImage | None = None, br return b +class Karte(tk.Frame): + """Fläche, die Inhalte zusammenfasst (Kennzahl, Tabelle): modern weiß mit dünner Linie.""" + + def __init__(self, master, **kw): + if MODERN: + kw = dict(bg=FACE, bd=0, highlightthickness=1, highlightbackground=RAND, highlightcolor=RAND) | kw + else: + kw = dict(bg=FACE, bd=2, relief="raised") | kw + super().__init__(master, **kw) + + +def abschnitt(master, text: str, **kw) -> tk.Label: + """Kleine Überschrift (modern in Großbuchstaben und grau, wie die Tabellenköpfe der Webtools).""" + if MODERN: + return tk.Label(master, text=text.upper(), fg=SHADOW, font=FONT_KLEIN_FETT, anchor="w", **kw) + return tk.Label(master, text=text, font=FONT_BOLD, anchor="w", **kw) + + +def _rund(c: tk.Canvas, x0, y0, x1, y1, r, **kw): + """Abgerundetes Rechteck auf einem Canvas.""" + r = min(r, (x1 - x0) / 2, (y1 - y0) / 2) + pkt = [x0 + r, y0, x1 - r, y0, x1, y0, x1, y0 + r, x1, y1 - r, x1, y1, x1 - r, y1, x0 + r, y1, x0, y1, + x0, y1 - r, x0, y0 + r, x0, y0] + return c.create_polygon(pkt, smooth=True, **kw) + + +class Pill(tk.Canvas): + """Umschalter für Filter („Alle“, „Von Hand geändert“ …): modern abgerundet, Win95 als Knopf, + der eingedrückt bleibt. command bekommt nichts übergeben; aktiv wird von außen gesetzt.""" + + def __init__(self, master, text: str, command=None, aktiv: bool = False, **kw): + from tkinter import font as tkfont + f = tkfont.Font(root=master, font=FONT) + breite, hoehe = f.measure(text) + (24 if MODERN else 16), f.metrics("linespace") + (10 if MODERN else 8) + bg = kw.pop("bg", master.cget("bg")) + super().__init__(master, width=breite, height=hoehe, bg=bg, highlightthickness=0, bd=0, takefocus=1, + cursor="hand2" if MODERN else "", **kw) + self.text, self.command, self.aktiv = text, command, aktiv + self._zeichnen() + self.bind("", lambda e: self._ausloesen()) + self.bind("", lambda e: self._ausloesen()) + self.bind("", lambda e: self._ausloesen()) + + def _ausloesen(self): + self.focus_set() + if self.command: + self.command() + + def setzen(self, aktiv: bool): + if aktiv != self.aktiv: + self.aktiv = aktiv + self._zeichnen() + + def _zeichnen(self): + self.delete("all") + b, h = int(self["width"]), int(self["height"]) + if MODERN: + fuell, linie, fg = (PRIMAER, PRIMAER, AUF_PRIMAER) if self.aktiv else (FACE, RAND2, TEXT2) + _rund(self, 1, 1, b - 2, h - 2, h, fill=fuell, outline=linie) + else: + oben, unten = (SHADOW, HILITE) if self.aktiv else (HILITE, SHADOW) + self.create_rectangle(0, 0, b - 1, h - 1, fill=LIGHT if self.aktiv else FACE, outline=DARK) + self.create_line(1, h - 2, 1, 1, b - 2, 1, fill=oben) + self.create_line(1, h - 2, b - 2, h - 2, b - 2, 1, fill=unten) + fg = DARK + self.create_text(b // 2 + (1 if self.aktiv and not MODERN else 0), h // 2, text=self.text, fill=fg, font=FONT) + + +def punkt(master, farbe: str, d: int = 9) -> tk.PhotoImage: + """Farbiger Punkt (Status in Listen); Ecken bleiben durchsichtig.""" + img = tk.PhotoImage(master=master, width=d + 4, height=d) + r = d / 2 + for y in range(d): + xs = [x for x in range(d) if (x + .5 - r) ** 2 + (y + .5 - r) ** 2 <= r * r] + if xs: + img.put(farbe, to=(xs[0], y, xs[-1] + 1, y + 1)) + return img + + +def logo(master, groesse: int = 34) -> tk.Canvas: + """Logo-Kachel wie in den Webtools: Lila-Verlauf, abgerundet, weißes %.""" + c = tk.Canvas(master, width=groesse, height=groesse, bg=master.cget("bg"), highlightthickness=0, bd=0) + img = tk.PhotoImage(master=master, width=groesse, height=groesse) + a, e = (0x2d, 0x1b, 0x69), (0x5e, 0x35, 0xb1) + r = groesse * 0.28 + for y in range(groesse): + for x in range(groesse): + dx = max(r - x - .5, x + .5 - (groesse - r), 0) + dy = max(r - y - .5, y + .5 - (groesse - r), 0) + if dx * dx + dy * dy <= r * r: + t = (x + y) / (2 * groesse - 2) + img.put("#%02x%02x%02x" % tuple(round(a[i] + (e[i] - a[i]) * t) for i in range(3)), (x, y)) + c.create_image(0, 0, image=img, anchor="nw") + c.bild = img # type: ignore[attr-defined] # Referenz halten + c.create_text(groesse / 2, groesse / 2, text="%", fill="#ffffff", font=(FONT_FAMILY, int(groesse * .4), "bold")) + return c + + class ToolbarButton(tk.Label): """Flacher Werkzeugleisten-Knopf (Office-95-Stil): hebt sich beim Überfahren, senkt sich beim Klick.""" @@ -660,15 +838,28 @@ 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]: +def _bei_bedarf(leiste: ttk.Scrollbar): + """Bildlaufleiste nur zeigen, wenn nicht alles hineinpasst.""" + def setzen(lo, hi): + leiste.grid() if float(lo) > 0 or float(hi) < 1 else leiste.grid_remove() + leiste.set(lo, hi) + return setzen + + +def scroll_tree(master, auto: bool = False, **kw) -> tuple[tk.Frame, ttk.Treeview]: + """Treeview mit Bildlaufleisten (modern nur bei Bedarf). auto: keine waagrechte Leiste (Seitenleiste).""" 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") + if auto and MODERN: + rahmen.configure(highlightthickness=0, bg=FACE) + if not auto: + hs = ttk.Scrollbar(rahmen, orient="horizontal", command=tree.xview) + hs.grid(row=1, column=0, sticky="ew") + tree.configure(xscrollcommand=_bei_bedarf(hs) if MODERN else hs.set) + tree.configure(yscrollcommand=_bei_bedarf(vs) if auto or MODERN else vs.set) rahmen.rowconfigure(0, weight=1) rahmen.columnconfigure(0, weight=1) return rahmen, tree @@ -747,6 +938,8 @@ class Dialog(tk.Toplevel): self.geometry(f"{w}x{h}+{max(0, x)}+{max(0, y)}") # Größe + Position zusammen (siehe Tooltip) self.deiconify() self.update_idletasks() + if DUNKEL: + win32.dunkle_titelleiste(self) if KLASSISCH and win32.IS_WIN: # eigener Taskleisten-Eintrag; wird erst nach kurzem Aus-/Einblenden wirksam if win32.taskleisteneintrag(self): @@ -796,7 +989,7 @@ class Dialog(tk.Toplevel): 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) + b = knopf(f, text, cmd, breite=10, primaer=MODERN and i == default) if i == default: b.configure(default="active") b.pack(side="left", padx=4) diff --git a/tests/test_gui.py b/tests/test_gui.py index 08c4619..b70e22c 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -1161,3 +1161,34 @@ def test_abrechnungen_links_und_import_fragt_nach_ziel(lauf_modern, home, monkey assert app.aid == neu and len(app.positionen) == 3, "Import nicht in der gewählten Abrechnung" assert app.atree.selection() == (str(neu),) lauf.pruefen() + + +def test_filter_kennzahlen_und_hell_dunkel(lauf_modern): + """Filter-Knöpfe über der Tabelle, Kennzahl-Kacheln und der Hell/Dunkel-Schalter in der Kopfzeile.""" + from provtool import config + from provtool.db import EIGEN + lauf = lauf_modern + app = lauf.app + alle = [p for p in app.positionen if p["haendler_id"] != EIGEN] + assert app.kpi["pos"][0].cget("text") == str(len(alle)) + p = alle[0] + app.db.betrag_setzen(p["id"], 123) + app._aktualisieren() + app.pills["manuell"].event_generate("") + lauf.root.update() + manuell = [x for x in app.positionen if x["manuell"] and x["haendler_id"] != EIGEN] + assert len(manuell) == 1 + zeilen = [i for g in app.ptree.get_children() for i in app.ptree.get_children(g)] + assert len(zeilen) == len(manuell), "Filter „Von Hand geändert“ wirkt nicht" + assert app.kpi["pos"][0].cget("text") == str(len(manuell)) + assert app.pills["manuell"].aktiv and not app.pills["alle"].aktiv + app._filter_setzen("alle") + assert len([i for g in app.ptree.get_children() for i in app.ptree.get_children(g)]) == len(alle) + + app.design_umschalten() # schließt das Fenster; main() baut es danach dunkel neu auf + for _ in range(50): + if app._beendet: + break + lauf.root.update() + assert app.neustart and app._beendet + assert config.load()["design"] == "dunkel"