diff --git a/src/ui/themes/dark.qss b/src/ui/themes/dark.qss index 20fd65e..33c2865 100644 --- a/src/ui/themes/dark.qss +++ b/src/ui/themes/dark.qss @@ -126,6 +126,7 @@ QHeaderView::section { border: none; border-bottom: 2px solid #00ff41; border-right: 1px solid #1a2332; + font-size: 13px; font-weight: bold; } @@ -164,6 +165,7 @@ QComboBox QAbstractItemView { background-color: #0d1117; color: #b8c4d4; border: 1px solid #00ff41; + font-size: 13px; selection-background-color: rgba(0,255,65,0.15); selection-color: #e8edf5; } @@ -231,6 +233,7 @@ QCalendarWidget QToolButton:hover { QCalendarWidget QAbstractItemView { background-color: #0d1117; color: #b8c4d4; + font-size: 13px; selection-background-color: rgba(0,255,65,0.15); selection-color: #e8edf5; } diff --git a/src/ui/transactions_view.py b/src/ui/transactions_view.py index 6f210d3..d949120 100644 --- a/src/ui/transactions_view.py +++ b/src/ui/transactions_view.py @@ -10,6 +10,8 @@ from PySide6.QtCore import ( QModelIndex, QSortFilterProxyModel, Qt, + QTimer, + Signal, ) from PySide6.QtGui import QColor from PySide6.QtWidgets import ( @@ -51,7 +53,9 @@ class TransactionTableModel(QAbstractTableModel): self._session = session self._transactions: list[Transaction] = [] self._categories: list[Category] = [] + self._members: list[HouseholdMember] = [] self._refresh_categories() + self._refresh_members() # -- public helpers ----------------------------------------------------- @@ -60,6 +64,7 @@ class TransactionTableModel(QAbstractTableModel): self.beginResetModel() self._transactions = self._query(filters or {}) self._refresh_categories() + self._refresh_members() self.endResetModel() @property @@ -98,29 +103,44 @@ class TransactionTableModel(QAbstractTableModel): amount = float(txn.amount) if txn.amount is not None else 0.0 return QColor(EXPENSE) if amount < 0 else QColor(INCOME) - # Store the category id for the delegate - if role == Qt.ItemDataRole.UserRole and col == 4: - return txn.category_id + # Store editable values for delegates + if role == Qt.ItemDataRole.UserRole: + if col == 4: + return txn.category_id + if col == 5: + return txn.tag + if col == 6: + return txn.attributed_to_id return None def flags(self, index: QModelIndex) -> Qt.ItemFlag: base = super().flags(index) - if index.column() == 4: # Category column is editable + if index.column() in (4, 5, 6): # Category, Tag, Person are editable return base | Qt.ItemFlag.ItemIsEditable return base def setData(self, index: QModelIndex, value: Any, role: int = Qt.ItemDataRole.EditRole) -> bool: # noqa: N802 - if role != Qt.ItemDataRole.EditRole or index.column() != 4: + if role != Qt.ItemDataRole.EditRole or index.column() not in (4, 5, 6): return False txn = self._transactions[index.row()] - new_category_id: int | None = value + col = index.column() - if new_category_id == txn.category_id: - return False + if col == 4: + if value == txn.category_id: + return False + txn.category_id = value + elif col == 5: + new_tag = value or None + if new_tag == txn.tag: + return False + txn.tag = new_tag + elif col == 6: + if value == txn.attributed_to_id: + return False + txn.attributed_to_id = value - txn.category_id = new_category_id self._session.commit() self.dataChanged.emit(index, index, [Qt.ItemDataRole.DisplayRole]) return True @@ -130,6 +150,13 @@ class TransactionTableModel(QAbstractTableModel): def _refresh_categories(self) -> None: self._categories = list(self._session.query(Category).order_by(Category.name).all()) + def _refresh_members(self) -> None: + self._members = list(self._session.query(HouseholdMember).order_by(HouseholdMember.name).all()) + + @property + def members(self) -> list[HouseholdMember]: + return list(self._members) + def _display_value(self, txn: Transaction, col: int) -> str: if col == 0: return str(txn.date) @@ -186,6 +213,8 @@ class TransactionTableModel(QAbstractTableModel): class CategoryDelegate(QStyledItemDelegate): """Provides a QComboBox editor for the Category column.""" + rules_applied = Signal() + def __init__(self, model: TransactionTableModel, session: Session, parent: QWidget | None = None): super().__init__(parent) self._table_model = model @@ -196,6 +225,7 @@ class CategoryDelegate(QStyledItemDelegate): combo.addItem("", None) for cat in self._table_model.categories: combo.addItem(cat.name, cat.id) + QTimer.singleShot(0, combo.showPopup) return combo def setEditorData(self, editor: QComboBox, index: QModelIndex) -> None: # noqa: N802 @@ -227,7 +257,6 @@ class CategoryDelegate(QStyledItemDelegate): source_model.setData(source_index, new_cat_id) # Ask whether to create a categorization rule - cat_name = editor.currentText() or "Uncategorized" pattern = _extract_merchant_pattern(txn.description) answer = QMessageBox.question( editor.parent(), @@ -245,6 +274,109 @@ class CategoryDelegate(QStyledItemDelegate): self._session.add(rule) self._session.commit() + # Offer to apply the new rule to existing uncategorized transactions + self._apply_rule_to_existing(rule, pattern, txn.id) + + def _apply_rule_to_existing(self, rule: CategorizationRule, pattern: str, exclude_id: int) -> None: + """Offer to apply a newly created rule to existing uncategorized transactions.""" + uncategorized = ( + self._session.query(Transaction) + .filter(Transaction.category_id.is_(None)) + .filter(Transaction.id != exclude_id) + .all() + ) + + matching = [ + t for t in uncategorized + if re.search(re.escape(pattern.upper()), (t.description or "").upper()) + ] + + if not matching: + return + + count = len(matching) + answer = QMessageBox.question( + self.parent(), + "Apply Rule", + f'Found {count} other uncategorized transaction{"s" if count != 1 else ""} ' + f'matching "{pattern}".\n\nApply this category to all of them?', + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + + if answer == QMessageBox.StandardButton.Yes: + tag = rule.tag_override + if tag is None and rule.category: + tag = rule.category.default_tag + for t in matching: + t.category_id = rule.category_id + t.tag = tag + t.attributed_to_id = rule.attributed_to_id + self._session.commit() + self.rules_applied.emit() + + +class TagDelegate(QStyledItemDelegate): + """Provides a QComboBox editor for the Tag column.""" + + TAG_OPTIONS = [("(none)", None), ("needs", "needs"), ("wants", "wants"), ("savings", "savings")] + + def createEditor(self, parent: QWidget, option, index: QModelIndex) -> QWidget: # noqa: N802 + combo = QComboBox(parent) + for label, value in self.TAG_OPTIONS: + combo.addItem(label, value) + QTimer.singleShot(0, combo.showPopup) + return combo + + def setEditorData(self, editor: QComboBox, index: QModelIndex) -> None: # noqa: N802 + current_tag = index.data(Qt.ItemDataRole.UserRole) + if current_tag: + idx = editor.findData(current_tag) + if idx >= 0: + editor.setCurrentIndex(idx) + + def setModelData(self, editor: QComboBox, model, index: QModelIndex) -> None: # noqa: N802 + new_tag = editor.currentData() + source_index = index + if isinstance(model, QSortFilterProxyModel): + source_index = model.mapToSource(index) + source_model = model.sourceModel() + else: + source_model = model + source_model.setData(source_index, new_tag) + + +class PersonDelegate(QStyledItemDelegate): + """Provides a QComboBox editor for the Person column.""" + + def __init__(self, model: TransactionTableModel, parent: QWidget | None = None): + super().__init__(parent) + self._table_model = model + + def createEditor(self, parent: QWidget, option, index: QModelIndex) -> QWidget: # noqa: N802 + combo = QComboBox(parent) + combo.addItem("(none)", None) + for member in self._table_model.members: + combo.addItem(member.name, member.id) + QTimer.singleShot(0, combo.showPopup) + return combo + + def setEditorData(self, editor: QComboBox, index: QModelIndex) -> None: # noqa: N802 + person_id = index.data(Qt.ItemDataRole.UserRole) + if person_id is not None: + idx = editor.findData(person_id) + if idx >= 0: + editor.setCurrentIndex(idx) + + def setModelData(self, editor: QComboBox, model, index: QModelIndex) -> None: # noqa: N802 + new_person_id = editor.currentData() + source_index = index + if isinstance(model, QSortFilterProxyModel): + source_index = model.mapToSource(index) + source_model = model.sourceModel() + else: + source_model = model + source_model.setData(source_index, new_person_id) + # --------------------------------------------------------------------------- # Transactions view widget @@ -323,25 +455,32 @@ class TransactionsView(QWidget): self._table.setAlternatingRowColors(True) self._table.verticalHeader().setVisible(False) - # Column sizing: Date=Fixed, Description=Stretch, Amount=Fixed, - # Account/Category=ResizeToContents, Tag=Fixed, Person=ResizeToContents + # Column sizing header = self._table.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) # Date header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) # Description header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed) # Amount header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) # Account - header.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents) # Category + header.setSectionResizeMode(4, QHeaderView.ResizeMode.Fixed) # Category header.setSectionResizeMode(5, QHeaderView.ResizeMode.Fixed) # Tag - header.setSectionResizeMode(6, QHeaderView.ResizeMode.ResizeToContents) # Person + header.setSectionResizeMode(6, QHeaderView.ResizeMode.Fixed) # Person self._table.setColumnWidth(0, 100) # Date self._table.setColumnWidth(2, 110) # Amount - self._table.setColumnWidth(5, 80) # Tag + self._table.setColumnWidth(4, 150) # Category + self._table.setColumnWidth(5, 100) # Tag + self._table.setColumnWidth(6, 130) # Person header.setMinimumSectionSize(80) - # Delegate for inline category editing + # Delegates for inline editing of Category, Tag, Person self._cat_delegate = CategoryDelegate(self._table_model, session, self._table) self._table.setItemDelegateForColumn(4, self._cat_delegate) + self._tag_delegate = TagDelegate(self._table) + self._table.setItemDelegateForColumn(5, self._tag_delegate) + + self._person_delegate = PersonDelegate(self._table_model, self._table) + self._table.setItemDelegateForColumn(6, self._person_delegate) + root_layout.addWidget(self._table) # -- signals -------------------------------------------------------- @@ -353,6 +492,12 @@ class TransactionsView(QWidget): self._uncategorized_cb.stateChanged.connect(self._apply_filters) self._search_edit.textChanged.connect(self._apply_filters) + # Single-click editing for Category, Tag, Person columns + self._table.clicked.connect(self._on_cell_clicked) + + # Refresh table when rules are bulk-applied to uncategorized transactions + self._cat_delegate.rules_applied.connect(self._apply_filters) + # Initial load self._apply_filters() @@ -365,6 +510,11 @@ class TransactionsView(QWidget): # -- internals ---------------------------------------------------------- + def _on_cell_clicked(self, index: QModelIndex) -> None: + """Open editor on single click for Category, Tag, Person columns.""" + if index.column() in (4, 5, 6): + self._table.edit(index) + def _build_filters(self) -> dict[str, Any]: filters: dict[str, Any] = {} filters["date_from"] = self._date_from.date().toPython()