feat: single-click inline editing for category, tag, and person columns
Make all three columns editable via single-click dropdowns with auto-popup. Add bulk rule application to existing uncategorized transactions after creating a new rule. Fix column widths for better readability. Add explicit font-size to QSS child-widget selectors to fix QFont point size warning. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
+166
-16
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user