Initial commit
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
Main window.
|
||||
|
||||
Deliberately thin: it wires the toolbar, tabs, and dialogs together,
|
||||
and delegates real work to app.services (fetching), app.workers
|
||||
(saving/loading/stats), and app.services.odoo_export (file export).
|
||||
|
||||
JIRA and ShipStation are pulled independently (separate buttons,
|
||||
separate workers) per how the business actually operates - they are
|
||||
correlated afterwards for the dashboard via ticket_number, not forced
|
||||
into one pipeline.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtGui import QAction
|
||||
from PyQt6.QtWidgets import (
|
||||
QMainWindow,
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
QToolBar,
|
||||
QStatusBar,
|
||||
QMessageBox,
|
||||
QLabel,
|
||||
QTabWidget,
|
||||
QFileDialog,
|
||||
)
|
||||
|
||||
from app.services import SERVICE_REGISTRY
|
||||
from app.services.odoo_export import export_orders_to_csv
|
||||
from app.ui.settings_dialog import SettingsDialog
|
||||
from app.ui.widgets.orders_table import OrdersTableView
|
||||
from app.ui.widgets.dashboard import DashboardWidget
|
||||
from app.ui.widgets.order_detail_dialog import OrderDetailDialog
|
||||
from app.workers import FetchOrdersWorker, load_all_orders, get_dashboard_stats
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Order Manager")
|
||||
self.resize(1150, 700)
|
||||
|
||||
self._workers: dict[str, FetchOrdersWorker] = {}
|
||||
self._import_actions: dict[str, QAction] = {}
|
||||
|
||||
self._build_ui()
|
||||
self._refresh_everything()
|
||||
|
||||
# -- UI construction -------------------------------------------------
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
central = QWidget()
|
||||
layout = QVBoxLayout(central)
|
||||
|
||||
self.tabs = QTabWidget()
|
||||
self.dashboard = DashboardWidget()
|
||||
self.orders_table = OrdersTableView()
|
||||
self.orders_table.order_double_clicked.connect(self._on_order_double_clicked)
|
||||
self.tabs.addTab(self.dashboard, "Dashboard")
|
||||
self.tabs.addTab(self.orders_table, "All Orders")
|
||||
layout.addWidget(self.tabs)
|
||||
|
||||
self.setCentralWidget(central)
|
||||
|
||||
toolbar = QToolBar("Main")
|
||||
toolbar.setMovable(False)
|
||||
self.addToolBar(toolbar)
|
||||
|
||||
jira_action = QAction("Import from JIRA", self)
|
||||
jira_action.triggered.connect(lambda: self._on_import_clicked("jira"))
|
||||
toolbar.addAction(jira_action)
|
||||
self._import_actions["jira"] = jira_action
|
||||
|
||||
shipstation_action = QAction("Import from ShipStation", self)
|
||||
shipstation_action.triggered.connect(lambda: self._on_import_clicked("shipstation"))
|
||||
toolbar.addAction(shipstation_action)
|
||||
self._import_actions["shipstation"] = shipstation_action
|
||||
|
||||
toolbar.addSeparator()
|
||||
|
||||
export_action = QAction("Export Visible Orders to Odoo CSV", self)
|
||||
export_action.triggered.connect(self._on_export_clicked)
|
||||
toolbar.addAction(export_action)
|
||||
|
||||
toolbar.addSeparator()
|
||||
|
||||
settings_action = QAction("Settings", self)
|
||||
settings_action.triggered.connect(self._on_settings_clicked)
|
||||
toolbar.addAction(settings_action)
|
||||
|
||||
refresh_action = QAction("Refresh from Local DB", self)
|
||||
refresh_action.triggered.connect(self._refresh_everything)
|
||||
toolbar.addAction(refresh_action)
|
||||
|
||||
self.status_bar = QStatusBar()
|
||||
self.setStatusBar(self.status_bar)
|
||||
self.status_label = QLabel("Ready.")
|
||||
self.status_bar.addWidget(self.status_label)
|
||||
|
||||
# -- actions -----------------------------------------------------------
|
||||
|
||||
def _on_settings_clicked(self) -> None:
|
||||
dialog = SettingsDialog(self)
|
||||
dialog.exec()
|
||||
|
||||
def _on_order_double_clicked(self, order) -> None:
|
||||
dialog = OrderDetailDialog(order, self)
|
||||
dialog.exec()
|
||||
|
||||
def _on_import_clicked(self, service_name: str) -> None:
|
||||
service_cls = SERVICE_REGISTRY[service_name]
|
||||
service = service_cls()
|
||||
|
||||
if not service.is_configured():
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
f"{service_name.title()} not configured",
|
||||
f"Fill in the {service_name.title()} settings first, then try again.",
|
||||
)
|
||||
return
|
||||
|
||||
action = self._import_actions[service_name]
|
||||
action.setEnabled(False)
|
||||
self.status_label.setText(f"Importing orders from {service_name.title()}...")
|
||||
|
||||
worker = FetchOrdersWorker(service)
|
||||
worker.finished_ok.connect(
|
||||
lambda new_count, updated_count: self._on_import_finished(
|
||||
service_name, new_count, updated_count
|
||||
)
|
||||
)
|
||||
worker.failed.connect(lambda message: self._on_import_failed(service_name, message))
|
||||
self._workers[service_name] = worker # keep a reference so it isn't garbage collected
|
||||
worker.start()
|
||||
|
||||
def _on_import_finished(self, service_name: str, new_count: int, updated_count: int) -> None:
|
||||
self._import_actions[service_name].setEnabled(True)
|
||||
self.status_label.setText(
|
||||
f"{service_name.title()} import complete: {new_count} new, {updated_count} updated."
|
||||
)
|
||||
self._refresh_everything()
|
||||
|
||||
def _on_import_failed(self, service_name: str, message: str) -> None:
|
||||
self._import_actions[service_name].setEnabled(True)
|
||||
self.status_label.setText(f"{service_name.title()} import failed.")
|
||||
QMessageBox.critical(self, f"{service_name.title()} import failed", message)
|
||||
|
||||
def _on_export_clicked(self) -> None:
|
||||
orders = self.orders_table.visible_orders()
|
||||
if not orders:
|
||||
QMessageBox.information(
|
||||
self, "Nothing to export", "No orders match the current filters."
|
||||
)
|
||||
return
|
||||
|
||||
filepath, _ = QFileDialog.getSaveFileName(
|
||||
self, "Export to Odoo CSV", "odoo_orders_export.csv", "CSV Files (*.csv)"
|
||||
)
|
||||
if not filepath:
|
||||
return
|
||||
|
||||
try:
|
||||
count = export_orders_to_csv(orders, filepath)
|
||||
except OSError as exc:
|
||||
QMessageBox.critical(self, "Export failed", str(exc))
|
||||
return
|
||||
|
||||
self.status_label.setText(f"Exported {count} order(s) to {filepath}")
|
||||
|
||||
def _refresh_everything(self) -> None:
|
||||
orders = load_all_orders()
|
||||
self.orders_table.set_orders(orders)
|
||||
self.dashboard.update_stats(get_dashboard_stats())
|
||||
if not hasattr(self, "_suppress_ready_status"):
|
||||
self.status_label.setText(f"{len(orders)} order(s) in local database.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Future growth notes:
|
||||
#
|
||||
# - Odoo push API (once ready): add app/services/odoo_service.py with a
|
||||
# push_orders(orders) method, wire a new toolbar action similarly to
|
||||
# _on_import_clicked, and it can eventually replace/augment odoo_export.py.
|
||||
# - Order detail view: connect a table double-click to a dialog showing
|
||||
# selected_order().raw_data (full JIRA/ShipStation payload) for debugging
|
||||
# ticket-number/SKU extraction as real data comes in.
|
||||
# - Pipeline actions ("mark fulfilled", "create ShipStation label"): add
|
||||
# toolbar actions gated on table selection.
|
||||
# - If the window grows too much, split each tab's toolbar into its own
|
||||
# QToolBar shown only while that tab is active.
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
Settings dialog.
|
||||
|
||||
Built dynamically from app.config.SETTINGS_SCHEMA, grouped by section
|
||||
(Database, JIRA, ...). When a new service adds settings to that
|
||||
schema, they show up here automatically - no UI changes needed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog,
|
||||
QVBoxLayout,
|
||||
QFormLayout,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QHBoxLayout,
|
||||
QGroupBox,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
)
|
||||
|
||||
from app import config
|
||||
|
||||
|
||||
class SettingsDialog(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Settings")
|
||||
self.setMinimumWidth(480)
|
||||
|
||||
self._fields: dict[str, QLineEdit] = {}
|
||||
current_values = config.load_settings()
|
||||
|
||||
# Group schema entries by their group label, preserving order
|
||||
groups: dict[str, list[tuple[str, str, bool]]] = defaultdict(list)
|
||||
for key, (label, group, is_secret) in config.SETTINGS_SCHEMA.items():
|
||||
groups[group].append((key, label, is_secret))
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(
|
||||
QLabel("Changes are saved to your .env file and applied immediately.")
|
||||
)
|
||||
|
||||
for group_name, entries in groups.items():
|
||||
box = QGroupBox(group_name)
|
||||
form = QFormLayout(box)
|
||||
for key, label, is_secret in entries:
|
||||
field = QLineEdit(current_values.get(key, ""))
|
||||
if is_secret:
|
||||
field.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
form.addRow(label, field)
|
||||
self._fields[key] = field
|
||||
layout.addWidget(box)
|
||||
|
||||
button_row = QHBoxLayout()
|
||||
save_button = QPushButton("Save")
|
||||
cancel_button = QPushButton("Cancel")
|
||||
save_button.clicked.connect(self._on_save)
|
||||
cancel_button.clicked.connect(self.reject)
|
||||
button_row.addStretch()
|
||||
button_row.addWidget(cancel_button)
|
||||
button_row.addWidget(save_button)
|
||||
layout.addLayout(button_row)
|
||||
|
||||
def _on_save(self) -> None:
|
||||
values = {key: field.text().strip() for key, field in self._fields.items()}
|
||||
try:
|
||||
config.save_settings(values)
|
||||
except OSError as exc:
|
||||
QMessageBox.critical(self, "Couldn't save settings", str(exc))
|
||||
return
|
||||
self.accept()
|
||||
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
Dashboard tab: at-a-glance counts of what's come in.
|
||||
|
||||
Kept intentionally simple for now (stat cards, no charts) per the
|
||||
"just a dashboard for now" scope. The stats themselves come from
|
||||
app.workers.get_dashboard_stats(), so adding a new stat later is a
|
||||
matter of adding a key there and a card here - this widget doesn't
|
||||
know anything about how orders are fetched or stored.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtCore import Qt
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget,
|
||||
QGridLayout,
|
||||
QVBoxLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QFrame,
|
||||
QSizePolicy,
|
||||
)
|
||||
|
||||
|
||||
class StatCard(QFrame):
|
||||
def __init__(self, title: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setFrameShape(QFrame.Shape.StyledPanel)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
self.value_label = QLabel("0")
|
||||
value_font = self.value_label.font()
|
||||
value_font.setPointSize(28)
|
||||
value_font.setBold(True)
|
||||
self.value_label.setFont(value_font)
|
||||
self.value_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
title_label = QLabel(title)
|
||||
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
title_label.setWordWrap(True)
|
||||
|
||||
layout.addWidget(self.value_label)
|
||||
layout.addWidget(title_label)
|
||||
|
||||
def set_value(self, value) -> None:
|
||||
self.value_label.setText(str(value))
|
||||
|
||||
|
||||
class DashboardWidget(QWidget):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
|
||||
heading = QLabel("Today's Orders at a Glance")
|
||||
heading_font = heading.font()
|
||||
heading_font.setPointSize(16)
|
||||
heading_font.setBold(True)
|
||||
heading.setFont(heading_font)
|
||||
outer.addWidget(heading)
|
||||
|
||||
# Top row: overall + per-source totals
|
||||
top_row = QHBoxLayout()
|
||||
self.total_card = StatCard("Total Orders")
|
||||
self.jira_card = StatCard("From JIRA")
|
||||
self.shipstation_card = StatCard("From ShipStation")
|
||||
for card in (self.total_card, self.jira_card, self.shipstation_card):
|
||||
top_row.addWidget(card)
|
||||
outer.addLayout(top_row)
|
||||
|
||||
# Second row: per-company totals (grid so adding a 3rd company later just works)
|
||||
outer.addWidget(self._section_label("By Company"))
|
||||
self.company_grid = QGridLayout()
|
||||
self.company_cards: dict[str, StatCard] = {}
|
||||
outer.addLayout(self.company_grid)
|
||||
|
||||
# Third row: cross-source matching, since JIRA and ShipStation are pulled independently
|
||||
outer.addWidget(self._section_label("JIRA <-> ShipStation Matching (by ticket #)"))
|
||||
match_row = QHBoxLayout()
|
||||
self.matched_card = StatCard("Matched in Both")
|
||||
self.jira_only_card = StatCard("JIRA Only (not yet in ShipStation)")
|
||||
self.shipstation_only_card = StatCard("ShipStation Only (no matching ticket)")
|
||||
for card in (self.matched_card, self.jira_only_card, self.shipstation_only_card):
|
||||
match_row.addWidget(card)
|
||||
outer.addLayout(match_row)
|
||||
|
||||
outer.addStretch()
|
||||
|
||||
@staticmethod
|
||||
def _section_label(text: str) -> QLabel:
|
||||
label = QLabel(text)
|
||||
font = label.font()
|
||||
font.setBold(True)
|
||||
label.setFont(font)
|
||||
return label
|
||||
|
||||
def update_stats(self, stats: dict) -> None:
|
||||
self.total_card.set_value(stats.get("total", 0))
|
||||
self.jira_card.set_value(stats.get("by_source", {}).get("jira", 0))
|
||||
self.shipstation_card.set_value(stats.get("by_source", {}).get("shipstation", 0))
|
||||
|
||||
self.matched_card.set_value(stats.get("matched_count", 0))
|
||||
self.jira_only_card.set_value(stats.get("jira_only_count", 0))
|
||||
self.shipstation_only_card.set_value(stats.get("shipstation_only_count", 0))
|
||||
|
||||
by_company = stats.get("by_company", {})
|
||||
# Rebuild company cards if the set of companies changed (e.g. a 3rd company added)
|
||||
if set(by_company.keys()) != set(self.company_cards.keys()):
|
||||
self._rebuild_company_cards(by_company.keys())
|
||||
for company, count in by_company.items():
|
||||
self.company_cards[company].set_value(count)
|
||||
|
||||
def _rebuild_company_cards(self, companies) -> None:
|
||||
while self.company_grid.count():
|
||||
item = self.company_grid.takeAt(0)
|
||||
widget = item.widget()
|
||||
if widget:
|
||||
widget.deleteLater()
|
||||
self.company_cards.clear()
|
||||
|
||||
for i, company in enumerate(sorted(companies)):
|
||||
card = StatCard(company)
|
||||
self.company_cards[company] = card
|
||||
self.company_grid.addWidget(card, i // 3, i % 3)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Order detail dialog.
|
||||
|
||||
Mainly a debugging aid: shows exactly what the source system (JIRA or
|
||||
ShipStation) sent back for this order, so field-shape mismatches - like
|
||||
the "Hardware Needed" single-select vs. "Deliverables" multi-select
|
||||
issue - are easy to spot by just double-clicking a row instead of
|
||||
reading logs or re-running a script.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QTextEdit, QLabel, QPushButton
|
||||
|
||||
from app.models import Order
|
||||
|
||||
|
||||
class OrderDetailDialog(QDialog):
|
||||
def __init__(self, order: Order, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(f"Order Detail - {order.ticket_number or order.external_id}")
|
||||
self.resize(700, 600)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
summary_lines = [
|
||||
f"Source: {order.source}",
|
||||
f"Ticket #: {order.ticket_number or '(none found)'}",
|
||||
f"Company: {order.company}",
|
||||
f"SKUs: {', '.join(order.skus or []) or '(none extracted)'}",
|
||||
f"Status: {order.status}",
|
||||
]
|
||||
summary_label = QLabel("\n".join(summary_lines))
|
||||
layout.addWidget(summary_label)
|
||||
|
||||
layout.addWidget(QLabel("Raw payload from source system:"))
|
||||
text = QTextEdit()
|
||||
text.setReadOnly(True)
|
||||
text.setFontFamily("Courier New")
|
||||
text.setPlainText(json.dumps(order.raw_data, indent=2, default=str))
|
||||
layout.addWidget(text)
|
||||
|
||||
close_button = QPushButton("Close")
|
||||
close_button.clicked.connect(self.accept)
|
||||
layout.addWidget(close_button)
|
||||
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user