""" Table view + model for displaying orders. Using QAbstractTableModel instead of QTableWidget on purpose: it scales to thousands of rows, and features like sorting and the company/source filters below are straightforward extensions of this model rather than rewrites. """ from __future__ import annotations from typing import List, Any, Optional from PyQt6.QtCore import Qt, QAbstractTableModel, QModelIndex, QSortFilterProxyModel, pyqtSignal from PyQt6.QtWidgets import ( QTableView, QAbstractItemView, QHeaderView, QWidget, QVBoxLayout, QHBoxLayout, QComboBox, QLineEdit, QLabel, ) from app.models import Order COLUMNS = [ ("company", "Company"), ("ticket_number", "Ticket #"), ("skus_display", "SKUs"), ("source", "Source"), ("external_id", "Source ID"), ("summary", "Summary"), ("status", "Status"), ("source_created_at", "Created"), ("imported_at", "Imported"), ("fulfilled", "Fulfilled"), ] ALL_COMPANIES = "All Companies" ALL_SOURCES = "All Sources" class OrdersTableModel(QAbstractTableModel): def __init__(self, orders: List[Order] | None = None, parent=None): super().__init__(parent) self._orders: List[Order] = orders or [] def set_orders(self, orders: List[Order]) -> None: self.beginResetModel() self._orders = orders self.endResetModel() def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: return len(self._orders) def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: return len(COLUMNS) def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole): if role != Qt.ItemDataRole.DisplayRole: return None if orientation == Qt.Orientation.Horizontal: return COLUMNS[section][1] return str(section + 1) def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: if not index.isValid() or role != Qt.ItemDataRole.DisplayRole: return None order = self._orders[index.row()] field_name, _ = COLUMNS[index.column()] if field_name == "skus_display": return ", ".join(order.skus or []) value = getattr(order, field_name) if value is None: return "" if field_name == "fulfilled": return "Yes" if value else "No" return str(value) def order_at(self, row: int) -> Order: return self._orders[row] class OrdersFilterProxyModel(QSortFilterProxyModel): """Filters by company, source, and a free-text search across ticket/SKU/summary.""" def __init__(self, parent=None): super().__init__(parent) self.company_filter: str = ALL_COMPANIES self.source_filter: str = ALL_SOURCES self.search_text: str = "" def set_company_filter(self, company: str) -> None: self.company_filter = company self.invalidateFilter() def set_source_filter(self, source: str) -> None: self.source_filter = source self.invalidateFilter() def set_search_text(self, text: str) -> None: self.search_text = text.strip().lower() self.invalidateFilter() def filterAcceptsRow(self, source_row: int, source_parent: QModelIndex) -> bool: model: OrdersTableModel = self.sourceModel() order = model.order_at(source_row) if self.company_filter != ALL_COMPANIES and order.company != self.company_filter: return False if self.source_filter != ALL_SOURCES and order.source != self.source_filter: return False if self.search_text: haystack = " ".join( [ order.ticket_number or "", order.external_id or "", order.summary or "", " ".join(order.skus or []), ] ).lower() if self.search_text not in haystack: return False return True class OrdersTableView(QWidget): """Filter bar + sortable, read-only, full-row-selection table.""" order_double_clicked = pyqtSignal(object) # emits the Order that was double-clicked def __init__(self, parent=None): super().__init__(parent) self._source_model = OrdersTableModel() self._proxy_model = OrdersFilterProxyModel() self._proxy_model.setSourceModel(self._source_model) layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) filter_bar = QHBoxLayout() filter_bar.addWidget(QLabel("Company:")) self.company_combo = QComboBox() self.company_combo.addItem(ALL_COMPANIES) self.company_combo.currentTextChanged.connect(self._proxy_model.set_company_filter) filter_bar.addWidget(self.company_combo) filter_bar.addWidget(QLabel("Source:")) self.source_combo = QComboBox() self.source_combo.addItem(ALL_SOURCES) self.source_combo.currentTextChanged.connect(self._proxy_model.set_source_filter) filter_bar.addWidget(self.source_combo) filter_bar.addWidget(QLabel("Search:")) self.search_box = QLineEdit() self.search_box.setPlaceholderText("Ticket #, SKU, or summary...") self.search_box.textChanged.connect(self._proxy_model.set_search_text) filter_bar.addWidget(self.search_box, stretch=1) layout.addLayout(filter_bar) self.table = QTableView() self.table.setModel(self._proxy_model) self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self.table.setSortingEnabled(True) self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) self.table.horizontalHeader().setStretchLastSection(False) self.table.doubleClicked.connect(self._on_row_double_clicked) layout.addWidget(self.table) def _on_row_double_clicked(self, index) -> None: source_index = self._proxy_model.mapToSource(index) order = self._source_model.order_at(source_index.row()) self.order_double_clicked.emit(order) def set_orders(self, orders: List[Order]) -> None: self._source_model.set_orders(orders) self.table.resizeColumnsToContents() self._refresh_filter_options(orders) def _refresh_filter_options(self, orders: List[Order]) -> None: for combo, attr, all_label in ( (self.company_combo, "company", ALL_COMPANIES), (self.source_combo, "source", ALL_SOURCES), ): current = combo.currentText() values = sorted({getattr(o, attr) for o in orders if getattr(o, attr)}) combo.blockSignals(True) combo.clear() combo.addItem(all_label) combo.addItems(values) restore_index = combo.findText(current) combo.setCurrentIndex(restore_index if restore_index >= 0 else 0) combo.blockSignals(False) def selected_order(self) -> Optional[Order]: indexes = self.table.selectionModel().selectedRows() if not indexes: return None source_index = self._proxy_model.mapToSource(indexes[0]) return self._source_model.order_at(source_index.row()) def visible_orders(self) -> List[Order]: """Orders currently passing the active filters - used for export.""" result = [] for row in range(self._proxy_model.rowCount()): source_index = self._proxy_model.mapToSource(self._proxy_model.index(row, 0)) result.append(self._source_model.order_at(source_index.row())) return result