Initial commit

This commit is contained in:
2026-08-26 09:15:35 -05:00
commit ffaaa0c8c8
23 changed files with 1775 additions and 0 deletions
View File
+124
View File
@@ -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)
+46
View File
@@ -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)
+217
View File
@@ -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