diff --git a/src/ui/main_window.py b/src/ui/main_window.py new file mode 100644 index 0000000..7a553ae --- /dev/null +++ b/src/ui/main_window.py @@ -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]) diff --git a/src/ui/transactions_view.py b/src/ui/transactions_view.py new file mode 100644 index 0000000..c24e49e --- /dev/null +++ b/src/ui/transactions_view.py @@ -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()