feat: transactions view with filtering and inline categorization

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 14:51:40 -05:00
parent acb2702d3e
commit 112d3aa99d
2 changed files with 464 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
from PySide6.QtWidgets import QMainWindow, QHBoxLayout, QWidget, QStackedWidget, QLabel
from PySide6.QtCore import Qt
from src.ui.sidebar import Sidebar
from src.ui.transactions_view import TransactionsView
class MainWindow(QMainWindow):
def __init__(self, session):
super().__init__()
self.session = session
self.setWindowTitle("SpendingAnalysis")
self.setMinimumSize(1200, 800)
central = QWidget()
self.setCentralWidget(central)
layout = QHBoxLayout(central)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self.sidebar = Sidebar()
layout.addWidget(self.sidebar)
self.stack = QStackedWidget()
layout.addWidget(self.stack)
# Build views — real implementations where available, placeholders otherwise
self._views = {}
self._transactions_view = TransactionsView(session)
for label, key in Sidebar.VIEWS:
if key == "transactions":
self._views[key] = self.stack.addWidget(self._transactions_view)
else:
placeholder = QLabel(f"{label} View")
placeholder.setAlignment(Qt.AlignmentFlag.AlignCenter)
placeholder.setStyleSheet("font-size: 24px; color: #888;")
self._views[key] = self.stack.addWidget(placeholder)
self.sidebar.view_changed.connect(self._switch_view)
def _switch_view(self, key: str):
self.stack.setCurrentIndex(self._views[key])
+423
View File
@@ -0,0 +1,423 @@
from __future__ import annotations
import re
from decimal import Decimal
from typing import Any
from PySide6.QtCore import (
QAbstractTableModel,
QDate,
QModelIndex,
QSortFilterProxyModel,
Qt,
)
from PySide6.QtGui import QColor
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QDateEdit,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMessageBox,
QStyledItemDelegate,
QTableView,
QVBoxLayout,
QWidget,
)
from sqlalchemy.orm import Session, joinedload
from src.models.account import Account
from src.models.category import Category
from src.models.household import HouseholdMember
from src.models.rule import CategorizationRule
from src.models.transaction import Transaction
# ---------------------------------------------------------------------------
# Table model
# ---------------------------------------------------------------------------
COLUMNS = ["Date", "Description", "Amount", "Account", "Category", "Tag", "Person"]
class TransactionTableModel(QAbstractTableModel):
"""Wraps a list of Transaction ORM objects for display in a QTableView."""
def __init__(self, session: Session, parent: QWidget | None = None):
super().__init__(parent)
self._session = session
self._transactions: list[Transaction] = []
self._categories: list[Category] = []
self._refresh_categories()
# -- public helpers -----------------------------------------------------
def refresh(self, filters: dict[str, Any] | None = None) -> None:
"""Re-query the database with the given *filters* dict and reset the model."""
self.beginResetModel()
self._transactions = self._query(filters or {})
self._refresh_categories()
self.endResetModel()
@property
def categories(self) -> list[Category]:
return list(self._categories)
def transaction_at(self, row: int) -> Transaction | None:
if 0 <= row < len(self._transactions):
return self._transactions[row]
return None
# -- Qt overrides -------------------------------------------------------
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: # noqa: N802
return len(self._transactions)
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: # noqa: N802
return len(COLUMNS)
def headerData(self, section: int, orientation: Qt.Orientation, role: int = Qt.ItemDataRole.DisplayRole): # noqa: N802
if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal:
return COLUMNS[section]
return None
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
if not index.isValid():
return None
txn = self._transactions[index.row()]
col = index.column()
if role == Qt.ItemDataRole.DisplayRole:
return self._display_value(txn, col)
if role == Qt.ItemDataRole.ForegroundRole and col == 2:
amount = float(txn.amount) if txn.amount is not None else 0.0
return QColor("red") if amount < 0 else QColor("green")
# Store the category id for the delegate
if role == Qt.ItemDataRole.UserRole and col == 4:
return txn.category_id
return None
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
base = super().flags(index)
if index.column() == 4: # Category column is 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:
return False
txn = self._transactions[index.row()]
new_category_id: int | None = value
if new_category_id == txn.category_id:
return False
txn.category_id = new_category_id
self._session.commit()
self.dataChanged.emit(index, index, [Qt.ItemDataRole.DisplayRole])
return True
# -- internal -----------------------------------------------------------
def _refresh_categories(self) -> None:
self._categories = list(self._session.query(Category).order_by(Category.name).all())
def _display_value(self, txn: Transaction, col: int) -> str:
if col == 0:
return str(txn.date)
if col == 1:
return txn.description or ""
if col == 2:
amount = txn.amount
if amount is None:
return ""
return f"{float(amount):,.2f}"
if col == 3:
return txn.account.name if txn.account else ""
if col == 4:
return txn.category.name if txn.category else ""
if col == 5:
return txn.tag or ""
if col == 6:
return txn.attributed_to.name if txn.attributed_to else ""
return ""
def _query(self, filters: dict[str, Any]) -> list[Transaction]:
q = (
self._session.query(Transaction)
.options(
joinedload(Transaction.account),
joinedload(Transaction.category),
joinedload(Transaction.attributed_to),
)
)
if filters.get("date_from"):
q = q.filter(Transaction.date >= filters["date_from"])
if filters.get("date_to"):
q = q.filter(Transaction.date <= filters["date_to"])
if filters.get("account_id"):
q = q.filter(Transaction.account_id == filters["account_id"])
if filters.get("person_id"):
q = q.filter(Transaction.attributed_to_id == filters["person_id"])
if filters.get("category_id"):
q = q.filter(Transaction.category_id == filters["category_id"])
if filters.get("uncategorized_only"):
q = q.filter(Transaction.category_id.is_(None))
if filters.get("search"):
pattern = f"%{filters['search']}%"
q = q.filter(Transaction.description.ilike(pattern))
return q.order_by(Transaction.date.desc()).all()
# ---------------------------------------------------------------------------
# Category combo-box delegate (inline editing)
# ---------------------------------------------------------------------------
class CategoryDelegate(QStyledItemDelegate):
"""Provides a QComboBox editor for the Category column."""
def __init__(self, model: TransactionTableModel, session: Session, parent: QWidget | None = None):
super().__init__(parent)
self._table_model = model
self._session = session
def createEditor(self, parent: QWidget, option, index: QModelIndex) -> QWidget: # noqa: N802
combo = QComboBox(parent)
combo.addItem("", None)
for cat in self._table_model.categories:
combo.addItem(cat.name, cat.id)
return combo
def setEditorData(self, editor: QComboBox, index: QModelIndex) -> None: # noqa: N802
cat_id = index.data(Qt.ItemDataRole.UserRole)
if cat_id is not None:
idx = editor.findData(cat_id)
if idx >= 0:
editor.setCurrentIndex(idx)
def setModelData(self, editor: QComboBox, model, index: QModelIndex) -> None: # noqa: N802
new_cat_id = editor.currentData()
# Resolve the *source* model index when a proxy is in use
source_index = index
if isinstance(model, QSortFilterProxyModel):
source_index = model.mapToSource(index)
source_model = model.sourceModel()
else:
source_model = model
txn = source_model.transaction_at(source_index.row())
if txn is None:
return
old_cat_id = txn.category_id
if new_cat_id == old_cat_id:
return
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(),
"Create Rule",
f'Create rule for "{pattern}"?',
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if answer == QMessageBox.StandardButton.Yes and new_cat_id is not None:
rule = CategorizationRule(
pattern=pattern,
category_id=new_cat_id,
attributed_to_id=txn.attributed_to_id,
priority=0,
)
self._session.add(rule)
self._session.commit()
# ---------------------------------------------------------------------------
# Transactions view widget
# ---------------------------------------------------------------------------
class TransactionsView(QWidget):
"""Full transactions page: filter bar + table."""
def __init__(self, session: Session, parent: QWidget | None = None):
super().__init__(parent)
self._session = session
root_layout = QVBoxLayout(self)
root_layout.setContentsMargins(8, 8, 8, 8)
# -- filter bar -----------------------------------------------------
filter_bar = QHBoxLayout()
root_layout.addLayout(filter_bar)
# Date range
filter_bar.addWidget(QLabel("From:"))
self._date_from = QDateEdit()
self._date_from.setCalendarPopup(True)
self._date_from.setDate(QDate.currentDate().addMonths(-3))
filter_bar.addWidget(self._date_from)
filter_bar.addWidget(QLabel("To:"))
self._date_to = QDateEdit()
self._date_to.setCalendarPopup(True)
self._date_to.setDate(QDate.currentDate())
filter_bar.addWidget(self._date_to)
# Account dropdown
filter_bar.addWidget(QLabel("Account:"))
self._account_combo = QComboBox()
self._account_combo.addItem("All", None)
for acct in session.query(Account).order_by(Account.name).all():
self._account_combo.addItem(acct.name, acct.id)
filter_bar.addWidget(self._account_combo)
# Person dropdown
filter_bar.addWidget(QLabel("Person:"))
self._person_combo = QComboBox()
self._person_combo.addItem("All", None)
for member in session.query(HouseholdMember).order_by(HouseholdMember.name).all():
self._person_combo.addItem(member.name, member.id)
filter_bar.addWidget(self._person_combo)
# Category dropdown
filter_bar.addWidget(QLabel("Category:"))
self._category_combo = QComboBox()
self._category_combo.addItem("All", None)
for cat in session.query(Category).order_by(Category.name).all():
self._category_combo.addItem(cat.name, cat.id)
filter_bar.addWidget(self._category_combo)
# Uncategorized checkbox
self._uncategorized_cb = QCheckBox("Uncategorized only")
filter_bar.addWidget(self._uncategorized_cb)
# Search
filter_bar.addWidget(QLabel("Search:"))
self._search_edit = QLineEdit()
self._search_edit.setPlaceholderText("Filter by description...")
filter_bar.addWidget(self._search_edit)
# -- table ----------------------------------------------------------
self._table_model = TransactionTableModel(session, self)
self._proxy_model = QSortFilterProxyModel(self)
self._proxy_model.setSourceModel(self._table_model)
self._table = QTableView()
self._table.setModel(self._proxy_model)
self._table.setSortingEnabled(True)
self._table.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
self._table.setAlternatingRowColors(True)
self._table.horizontalHeader().setStretchLastSection(True)
self._table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
self._table.verticalHeader().setVisible(False)
# Delegate for inline category editing
self._cat_delegate = CategoryDelegate(self._table_model, session, self._table)
self._table.setItemDelegateForColumn(4, self._cat_delegate)
root_layout.addWidget(self._table)
# -- signals --------------------------------------------------------
self._date_from.dateChanged.connect(self._apply_filters)
self._date_to.dateChanged.connect(self._apply_filters)
self._account_combo.currentIndexChanged.connect(self._apply_filters)
self._person_combo.currentIndexChanged.connect(self._apply_filters)
self._category_combo.currentIndexChanged.connect(self._apply_filters)
self._uncategorized_cb.stateChanged.connect(self._apply_filters)
self._search_edit.textChanged.connect(self._apply_filters)
# Initial load
self._apply_filters()
# -- public API ---------------------------------------------------------
def refresh(self) -> None:
"""Reload dropdown options and re-query transactions."""
self._reload_combos()
self._apply_filters()
# -- internals ----------------------------------------------------------
def _build_filters(self) -> dict[str, Any]:
filters: dict[str, Any] = {}
filters["date_from"] = self._date_from.date().toPython()
filters["date_to"] = self._date_to.date().toPython()
acct_id = self._account_combo.currentData()
if acct_id is not None:
filters["account_id"] = acct_id
person_id = self._person_combo.currentData()
if person_id is not None:
filters["person_id"] = person_id
cat_id = self._category_combo.currentData()
if cat_id is not None:
filters["category_id"] = cat_id
if self._uncategorized_cb.isChecked():
filters["uncategorized_only"] = True
text = self._search_edit.text().strip()
if text:
filters["search"] = text
return filters
def _apply_filters(self) -> None:
self._table_model.refresh(self._build_filters())
def _reload_combos(self) -> None:
"""Re-populate the filter dropdowns from the database."""
self._reload_combo(self._account_combo, self._session.query(Account).order_by(Account.name).all(), "name")
self._reload_combo(self._person_combo, self._session.query(HouseholdMember).order_by(HouseholdMember.name).all(), "name")
self._reload_combo(self._category_combo, self._session.query(Category).order_by(Category.name).all(), "name")
@staticmethod
def _reload_combo(combo: QComboBox, items: list, label_attr: str) -> None:
current = combo.currentData()
combo.blockSignals(True)
combo.clear()
combo.addItem("All", None)
for item in items:
combo.addItem(getattr(item, label_attr), item.id)
# Restore previous selection if still present
idx = combo.findData(current)
combo.setCurrentIndex(idx if idx >= 0 else 0)
combo.blockSignals(False)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _extract_merchant_pattern(description: str) -> str:
"""Derive a simple merchant matching pattern from a transaction description.
Strategy: take the first two significant tokens (ignoring common noise
words and trailing numbers/dates) and lower-case them so they can serve
as a case-insensitive substring match.
"""
if not description:
return ""
# Remove digits that look like dates, reference numbers, etc.
cleaned = re.sub(r"\b\d{2,}\b", "", description)
# Remove common noise tokens
noise = {"the", "of", "and", "for", "in", "at", "to", "on", "a", "an"}
tokens = [t for t in cleaned.lower().split() if t not in noise and len(t) > 1]
# Take up to the first two meaningful tokens
pattern_tokens = tokens[:2]
return " ".join(pattern_tokens) if pattern_tokens else description.strip().lower()