diff --git a/src/ccma/assets/CHANGELOG.json b/src/ccma/assets/CHANGELOG.json index c96f639..93e6a37 100644 --- a/src/ccma/assets/CHANGELOG.json +++ b/src/ccma/assets/CHANGELOG.json @@ -23,7 +23,8 @@ "Ein Akten-Preflight sperrt bei beschädigten Mitglieder-, Beitrags- oder Eventdateien alle Regeln für die betroffene Akte.", "Der Hausmeister-Tab zeigt den vollständigen Inhalt eines markierten Vorgangs in einem mehrzeiligen Detailbereich.", "Hausmeister-Tasks lassen sich manuell löschen; Einträge entfernter Mitgliederakten werden beim nächsten Lauf bereinigt.", - "Forderungen besitzen eigene Tabs mit Positionen, Teilzahlungen, GnuCash-Referenzen, Zahlungszuordnungen, Mahnungen und Gebühren.", + "Forderungen besitzen eigene Arbeitsansichten mit Teilzahlungen, GnuCash-Referenzen, Zahlungszuordnungen, Mahnungen und Gebühren.", + "Positionen, Zahlungen und Mahnungen einer Forderung werden gemeinsam in einer farblich gruppierten Übersicht dargestellt.", "Dropdowns zeigen deutsche Begriffe bei weiterhin englischen Speicher-Keys; der Hausmeisterstatus liegt einheitlich in housekeeper.json.", "Mehrstufiger Mahnworkflow mit Hausmeister-Regel, Entwurf, Versandbestätigung, Zahlungsfrist, optionaler Gebühr und Mahnsperre ergänzt.", "Splash-Screen auf das eingebettete CCMA-Hintergrundmotiv umgestellt und redundante Titeltexte entfernt.", diff --git a/src/ccma/ui/claim_tab.py b/src/ccma/ui/claim_tab.py index ec8f439..92b7b97 100644 --- a/src/ccma/ui/claim_tab.py +++ b/src/ccma/ui/claim_tab.py @@ -77,17 +77,7 @@ class ClaimTab(ttk.Frame): self.summary_vars[key] = variable ttk.Label(card, textvariable=variable, style="CardValue.TLabel").pack(anchor="w", pady=(5, 0)) - notebook = ttk.Notebook(self) - notebook.grid(row=2, column=0, sticky="nsew") - positions = ttk.Frame(notebook, padding=12) - payments = ttk.Frame(notebook, padding=12) - reminders = ttk.Frame(notebook, padding=12) - notebook.add(positions, text="Positionen") - notebook.add(payments, text="Zahlungen") - notebook.add(reminders, text="Mahnungen") - self._build_positions(positions) - self._build_payments(payments) - self._build_reminders(reminders) + self._build_ledger() footer = ttk.Frame(self) footer.grid(row=3, column=0, sticky="ew", pady=(10, 0)) @@ -97,80 +87,64 @@ class ClaimTab(ttk.Frame): self.cancel_button = ttk.Button(footer, text="Forderung stornieren", command=self._cancel_claim) self.cancel_button.grid(row=0, column=1, sticky="e") - def _build_positions(self, parent: ttk.Frame) -> None: - parent.columnconfigure(0, weight=1) - parent.rowconfigure(0, weight=1) - self.positions = ttk.Treeview( - parent, columns=("type", "description", "quantity", "unit", "amount"), show="headings" + def _build_ledger(self) -> None: + ledger = ttk.Frame(self, padding=12) + ledger.grid(row=2, column=0, sticky="nsew") + ledger.columnconfigure(0, weight=1) + ledger.rowconfigure(0, weight=1) + self.ledger = ttk.Treeview( + ledger, + columns=("date", "description", "quantity", "unit", "amount", "status", "reference"), + show="tree headings", ) + self.ledger.heading("#0", text="Bereich / Element") + self.ledger.column("#0", width=190, minwidth=140, anchor="w") for key, title, width in ( - ("type", "Typ", 100), - ("description", "Beschreibung", 360), - ("quantity", "Menge", 80), + ("date", "Datum", 120), + ("description", "Beschreibung", 300), + ("quantity", "Menge", 75), ("unit", "Einzelpreis", 100), - ("amount", "Betrag", 100), + ("amount", "Betrag", 105), + ("status", "Status / Details", 180), + ("reference", "Referenz", 210), ): - self.positions.heading(key, text=title) - self.positions.column(key, width=width, anchor="w") - self.positions.grid(row=0, column=0, sticky="nsew") - ttk.Button(parent, text="Position hinzufügen", command=self._add_item).grid( - row=1, column=0, sticky="e", pady=(10, 0) + self.ledger.heading(key, text=title) + self.ledger.column(key, width=width, minwidth=60, anchor="w") + self.ledger.grid(row=0, column=0, sticky="nsew") + vertical_scroll = ttk.Scrollbar(ledger, orient="vertical", command=self.ledger.yview) + vertical_scroll.grid(row=0, column=1, sticky="ns") + horizontal_scroll = ttk.Scrollbar(ledger, orient="horizontal", command=self.ledger.xview) + horizontal_scroll.grid(row=1, column=0, sticky="ew") + self.ledger.configure( + yscrollcommand=vertical_scroll.set, + xscrollcommand=horizontal_scroll.set, ) + self.ledger.tag_configure("position-group", background="#1261a0", foreground="#ffffff") + self.ledger.tag_configure("position", background="#234d70", foreground="#ffffff") + self.ledger.tag_configure("payment-group", background="#237a3b", foreground="#ffffff") + self.ledger.tag_configure("payment", background="#285b3b", foreground="#ffffff") + self.ledger.tag_configure("reminder-group", background="#b85f00", foreground="#ffffff") + self.ledger.tag_configure("reminder", background="#70451f", foreground="#ffffff") + self.ledger.bind("<>", lambda _event: self._update_reminder_buttons()) - def _build_payments(self, parent: ttk.Frame) -> None: - parent.columnconfigure(0, weight=1) - parent.rowconfigure(0, weight=1) - self.payments = ttk.Treeview( - parent, columns=("date", "allocated", "payment", "gnucash", "reference"), show="headings" - ) - for key, title, width in ( - ("date", "Datum", 110), - ("allocated", "Zugeordnet", 100), - ("payment", "Zahlung gesamt", 110), - ("gnucash", "GnuCash-ID", 180), - ("reference", "Referenz", 280), - ): - self.payments.heading(key, text=title) - self.payments.column(key, width=width, anchor="w") - self.payments.grid(row=0, column=0, sticky="nsew") - buttons = ttk.Frame(parent) - buttons.grid(row=1, column=0, sticky="e", pady=(10, 0)) + buttons = ttk.Frame(ledger) + buttons.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(10, 0)) + ttk.Button(buttons, text="Position hinzufügen", command=self._add_item).pack(side="left") + ttk.Separator(buttons, orient="vertical").pack(side="left", fill="y", padx=10) ttk.Button(buttons, text="Vorhandene Zahlung zuordnen", command=self._allocate_payment).pack( side="left", padx=(0, 8) ) ttk.Button(buttons, text="Zahlung erfassen", command=self._record_payment).pack(side="left") - - def _build_reminders(self, parent: ttk.Frame) -> None: - parent.columnconfigure(0, weight=1) - parent.rowconfigure(0, weight=1) - self.reminders = ttk.Treeview( - parent, - columns=("created", "level", "name", "status", "deadline", "fee"), - show="headings", - ) - for key, title, width in ( - ("created", "Erstellt", 150), - ("level", "Stufe", 60), - ("name", "Mahnung", 230), - ("status", "Status", 130), - ("deadline", "Zahlungsfrist", 120), - ("fee", "Gebühr", 90), - ): - self.reminders.heading(key, text=title) - self.reminders.column(key, width=width, anchor="w") - self.reminders.grid(row=0, column=0, sticky="nsew") - self.reminders.bind("<>", lambda _event: self._update_reminder_buttons()) - buttons = ttk.Frame(parent) - buttons.grid(row=1, column=0, sticky="e", pady=(10, 0)) + ttk.Separator(buttons, orient="vertical").pack(side="left", fill="y", padx=10) self.discard_reminder_button = ttk.Button( buttons, text="Entwurf verwerfen", command=self._discard_reminder, state="disabled" ) - self.discard_reminder_button.pack(side="left", padx=(0, 8)) + self.discard_reminder_button.pack(side="right", padx=(8, 0)) self.send_reminder_button = ttk.Button( buttons, text="Als versandt markieren", command=self._send_reminder, state="disabled" ) - self.send_reminder_button.pack(side="left", padx=(0, 8)) - ttk.Button(buttons, text="Mahnung vorbereiten", command=self._add_reminder).pack(side="left") + self.send_reminder_button.pack(side="right", padx=(8, 0)) + ttk.Button(buttons, text="Mahnung vorbereiten", command=self._add_reminder).pack(side="right") def refresh(self) -> None: try: @@ -199,66 +173,111 @@ class ClaimTab(ttk.Frame): self.summary_vars["status"].set(CLAIM_STATUS_LABELS.get(status, status.upper())) self.cancel_button.configure(state="disabled" if status == "cancelled" else "normal") self.hold_button.configure(text="Mahnsperre aufheben" if hold.get("active") else "Mahnsperre setzen") - self._render_positions() - self._render_payments() - self._render_reminders() + self._render_ledger() - def _render_positions(self) -> None: - self.positions.delete(*self.positions.get_children()) - for item in claim_items(self.claim): - self.positions.insert( - "", + def _render_ledger(self) -> None: + self.ledger.delete(*self.ledger.get_children()) + items = claim_items(self.claim) + position_group = self.ledger.insert( + "", + "end", + iid="group:positions", + text=f"Positionen ({len(items)})", + values=("", "", "", "", f"{money_text(claim_total(self.claim))} EUR", "", ""), + tags=("position-group",), + open=True, + ) + for item in items: + self.ledger.insert( + position_group, "end", - iid=str(item.get("item_id", "")), + iid=f"item:{item.get('item_id', '')}", + text=display_label(CLAIM_ITEM_TYPE_LABELS, str(item.get("type", ""))), values=( - display_label(CLAIM_ITEM_TYPE_LABELS, str(item.get("type", ""))), + str(item.get("created_at", ""))[:10], item.get("description", ""), item.get("quantity", "1"), item.get("unit_price", item.get("amount", "")), - item.get("amount", ""), + f"{item.get('amount', '')} EUR", + "", + "", ), + tags=("position",), ) - def _render_payments(self) -> None: - self.payments.delete(*self.payments.get_children()) payment_by_id = {str(item.get("payment_id")): item for item in self.data.payments} - for allocation in self.data.allocations: - if str(allocation.get("claim_id", "")) != self.claim_id: - continue + allocations = [ + allocation + for allocation in self.data.allocations + if str(allocation.get("claim_id", "")) == self.claim_id + ] + payment_group = self.ledger.insert( + "", + "end", + iid="group:payments", + text=f"Zahlungen ({len(allocations)})", + values=("", "", "", "", f"-{money_text(allocated_total(self.data, self.claim_id))} EUR", "", ""), + tags=("payment-group",), + open=True, + ) + for allocation in allocations: payment = payment_by_id.get(str(allocation.get("payment_id", "")), {}) - self.payments.insert( - "", + payment_total = str(payment.get("amount", "")) + gnucash_id = str(payment.get("gnucash_transaction_id", "")) + self.ledger.insert( + payment_group, "end", - iid=str(allocation.get("allocation_id", "")), + iid=f"allocation:{allocation.get('allocation_id', '')}", + text="Zahlung", values=( format_date_for_display(str(payment.get("date", ""))), - allocation.get("amount", ""), - payment.get("amount", ""), - payment.get("gnucash_transaction_id", ""), payment.get("reference", ""), + "", + "", + f"-{allocation.get('amount', '')} EUR", + f"Zahlung gesamt: {payment_total} EUR", + f"GnuCash: {gnucash_id}" if gnucash_id else "", ), + tags=("payment",), ) - def _render_reminders(self) -> None: - self.reminders.delete(*self.reminders.get_children()) - items = {str(item.get("item_id")): item for item in claim_items(self.claim)} - for reminder in self.data.reminders: - if str(reminder.get("claim_id", "")) != self.claim_id: - continue - fee_item = items.get(str(reminder.get("fee_item_id", "")), {}) + reminders = [ + reminder + for reminder in self.data.reminders + if str(reminder.get("claim_id", "")) == self.claim_id + ] + reminder_group = self.ledger.insert( + "", + "end", + iid="group:reminders", + text=f"Mahnungen ({len(reminders)})", + tags=("reminder-group",), + open=True, + ) + for reminder in reminders: status = str(reminder.get("status", "draft")) - self.reminders.insert( - "", + deadline = format_date_for_display(str(reminder.get("payment_deadline") or "")) + channel = display_label(REMINDER_CHANNEL_LABELS, str(reminder.get("channel", ""))) + detail = str(reminder.get("detail", "")) + self.ledger.insert( + reminder_group, "end", - iid=str(reminder.get("reminder_id", "")), + iid=f"reminder:{reminder.get('reminder_id', '')}", + text=f"Mahnstufe {reminder.get('level', '')}", values=( str(reminder.get("created_at", ""))[:16], - reminder.get("level", ""), reminder.get("name", f"Mahnung Stufe {reminder.get('level', '')}"), + "", + "", + f"{reminder.get('fee', '0.00')} EUR", display_label(REMINDER_STATUS_LABELS, status), - format_date_for_display(str(reminder.get("payment_deadline") or "")), - fee_item.get("amount", reminder.get("fee", "0.00")), + " · ".join( + part + for part in (channel, f"Frist: {deadline}" if deadline else "", detail) + if part + ), ), + tags=("reminder",), ) self._update_reminder_buttons() @@ -289,11 +308,12 @@ class ClaimTab(ttk.Frame): ReminderDialog(self, self.repository, self.member_id, self.claim_id, self._changed) def _selected_reminder(self) -> dict | None: - selected = self.reminders.selection() - if not selected: + selected = self.ledger.selection() + if not selected or not selected[0].startswith("reminder:"): return None + reminder_id = selected[0].removeprefix("reminder:") return next( - (item for item in self.data.reminders if str(item.get("reminder_id", "")) == selected[0]), + (item for item in self.data.reminders if str(item.get("reminder_id", "")) == reminder_id), None, )