Email procedure (beta), WorkFlow update

This commit is contained in:
2026-09-02 11:44:54 -05:00
parent 826a70bdf6
commit edd5f2a886
12 changed files with 450 additions and 8 deletions
+33 -4
View File
@@ -35,6 +35,7 @@ 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.ui.widgets.return_label_dialog import ReturnLabelDialog
from app.ui.widgets.pack_ticket_dialog import PackTicketDialog
from app.workers import (
FetchOrdersWorker,
SendToShipStationWorker,
@@ -42,6 +43,7 @@ from app.workers import (
load_orders_by_view,
get_dashboard_stats,
mark_shipstation_sent,
save_pack_data,
reset_local_database,
)
@@ -103,6 +105,11 @@ class MainWindow(QMainWindow):
export_action.triggered.connect(self._on_export_clicked)
toolbar.addAction(export_action)
pack_action = QAction("Pack Ticket", self)
pack_action.setToolTip("Enter serial numbers and mark packed - select a ticket first")
pack_action.triggered.connect(self._on_pack_ticket_clicked)
toolbar.addAction(pack_action)
toolbar.addSeparator()
emergency_action = QAction("Send to ShipStation", self)
@@ -410,14 +417,36 @@ class MainWindow(QMainWindow):
self.status_label.setText("Return label creation failed.")
QMessageBox.critical(self, "Return label creation failed", message)
def _on_pack_ticket_clicked(self) -> None:
order = self.orders_table.selected_order()
if order is None:
QMessageBox.information(
self,
"No ticket selected",
"Select a ticket in Active Orders first, then click Pack Ticket.",
)
return
dialog = PackTicketDialog(order, self)
if dialog.exec() != QDialog.DialogCode.Accepted:
return
ticket_number = order.ticket_number or order.external_id
save_pack_data(ticket_number, dialog.get_serial_numbers(), dialog.get_packed())
self._refresh_everything()
self.status_label.setText(f"Saved pack data for {ticket_number}.")
def _on_reset_database_clicked(self) -> None:
confirm = QMessageBox.warning(
self,
"Reset Local Database",
"This clears every locally cached order (JIRA is still the real source of "
"truth, so nothing there is affected). You'll need to click Import from JIRA "
"afterward to repopulate, and Pull Tracking Numbers again if you rely on "
"today's already-pulled tracking data.\n\n"
"This clears every locally cached order, INCLUDING any serial numbers "
"and packed status your team has already entered on the Pack Ticket "
"dialog - that data doesn't come from JIRA, so a reset does not bring "
"it back. JIRA itself is unaffected either way.\n\n"
"You'll need to click Import from JIRA afterward to repopulate, and "
"Pull Tracking Numbers again if you rely on today's already-pulled "
"tracking data.\n\n"
"Continue?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
+10
View File
@@ -41,11 +41,15 @@ COLUMNS = [
("company", "Company"),
("ticket_number", "Ticket #"),
("status", "Status"),
("packed_display", "Done"),
("skus_display", "SKUs"),
("summary", "Summary"),
("assignee", "Assignee"),
("serials_display", "Serial #s"),
("return_label_display", "Return Label"),
("outgoing_tracking_display", "Outgoing Tracking"),
("return_tracking_display", "Return Tracking"),
("shipping_method", "Shipping Method"),
("uploaded_display", "Uploaded"),
("past_cutoff_display", "Past Cutoff"),
("created_display", "Created"),
@@ -124,6 +128,12 @@ class OrdersTableModel(QAbstractTableModel):
if field_name == "skus_display":
return ", ".join(order.skus or [])
if field_name == "packed_display":
return CHECK_MARK if order.packed else ""
if field_name == "serials_display":
serials = order.serial_numbers or {}
filled = sum(1 for v in serials.values() if (v or "").strip())
return f"{filled} entered" if filled else ""
if field_name == "return_label_display":
return CHECK_MARK if is_emailed_label_order(order.skus or []) else ""
if field_name == "outgoing_tracking_display":
+149
View File
@@ -0,0 +1,149 @@
"""
Pack Ticket dialog - where staff enter serial numbers (mostly via
barcode scanner) and mark a ticket packed/ready to ship.
Barcode scanners act as a keyboard: they type the scanned value and
then send an Enter keystroke automatically. So every field here
connects its Enter/returnPressed signal to jump focus to the next
field - staff scan device after device without touching the mouse or
keyboard in between. This is the actual point of this dialog; get this
wrong and it defeats the "minimal interactions" requirement entirely.
Suggested fields come from app.serial_suggestions, based on keywords in
the ticket's line items - a starting point, not a fixed schema. Staff
can add any custom field the suggestions miss.
"""
from __future__ import annotations
from PyQt6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QFormLayout,
QLabel,
QLineEdit,
QPushButton,
QCheckBox,
QScrollArea,
QWidget,
QDialogButtonBox,
)
from app.models import Order
from app.serial_suggestions import suggest_serial_fields
class PackTicketDialog(QDialog):
def __init__(self, order: Order, parent=None):
super().__init__(parent)
self.order = order
self.setWindowTitle(f"Pack Ticket - {order.ticket_number or order.external_id}")
self.resize(520, 600)
self._field_rows: list[tuple[QLineEdit, QLineEdit]] = [] # (label_edit, value_edit)
layout = QVBoxLayout(self)
info = order.shipping_info or {}
kit_text = ", ".join(
f"{item.get('sku', '')}: {item.get('item_name', '')}" for item in (order.line_items or [])
) or ", ".join(order.skus or [])
summary_lines = [
f"Ticket: {order.ticket_number or order.external_id} Company: {order.company}",
f"Customer: {info.get('name') or '(missing)'}",
f"Kit: {kit_text or '(none)'}",
]
layout.addWidget(QLabel("\n".join(summary_lines)))
layout.addWidget(QLabel("Serial numbers (scan or type; Enter moves to the next field):"))
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
self._fields_widget = QWidget()
self._fields_layout = QFormLayout(self._fields_widget)
scroll_area.setWidget(self._fields_widget)
layout.addWidget(scroll_area, stretch=1)
add_field_row = QHBoxLayout()
self.new_field_label_input = QLineEdit()
self.new_field_label_input.setPlaceholderText("Custom field name...")
add_button = QPushButton("Add Field")
add_button.clicked.connect(self._on_add_custom_field_clicked)
self.new_field_label_input.returnPressed.connect(self._on_add_custom_field_clicked)
add_field_row.addWidget(self.new_field_label_input, stretch=1)
add_field_row.addWidget(add_button)
layout.addLayout(add_field_row)
self.packed_checkbox = QCheckBox("Packed / ready to ship")
self.packed_checkbox.setChecked(bool(order.packed))
layout.addWidget(self.packed_checkbox)
button_box = QDialogButtonBox()
button_box.addButton("Save", QDialogButtonBox.ButtonRole.AcceptRole)
button_box.addButton(QDialogButtonBox.StandardButton.Cancel)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
layout.addWidget(button_box)
self._populate_initial_fields()
def _populate_initial_fields(self) -> None:
existing = dict(self.order.serial_numbers or {})
suggested = suggest_serial_fields(self.order.line_items or [])
# Suggested fields first (in suggestion order), pre-filled with any
# already-saved value so re-opening a partially-packed ticket
# doesn't lose earlier scans. Then any existing fields that aren't
# part of the current suggestion set (e.g. a custom field added
# last time, or a suggestion rule that's since changed).
added_labels: set[str] = set()
for label in suggested:
self._add_field_row(label, existing.get(label, ""))
added_labels.add(label)
for label, value in existing.items():
if label not in added_labels:
self._add_field_row(label, value)
if self._field_rows:
self._field_rows[0][1].setFocus()
def _add_field_row(self, label: str, value: str = "") -> None:
label_edit = QLineEdit(label)
label_edit.setReadOnly(True)
label_edit.setStyleSheet("border: none; background: transparent;")
value_edit = QLineEdit(value)
row_index = len(self._field_rows)
value_edit.returnPressed.connect(lambda: self._focus_next(row_index))
self._fields_layout.addRow(label_edit, value_edit)
self._field_rows.append((label_edit, value_edit))
def _focus_next(self, current_index: int) -> None:
next_index = current_index + 1
if next_index < len(self._field_rows):
self._field_rows[next_index][1].setFocus()
self._field_rows[next_index][1].selectAll()
else:
# Last known field - hand off to the checkbox rather than
# silently submitting, so finishing still takes one deliberate
# action instead of an accidental extra scan closing the dialog.
self.packed_checkbox.setFocus()
def _on_add_custom_field_clicked(self) -> None:
label = self.new_field_label_input.text().strip()
if not label:
return
self._add_field_row(label, "")
self.new_field_label_input.clear()
self._field_rows[-1][1].setFocus()
def get_serial_numbers(self) -> dict[str, str]:
return {
label_edit.text(): value_edit.text()
for label_edit, value_edit in self._field_rows
if value_edit.text().strip()
}
def get_packed(self) -> bool:
return self.packed_checkbox.isChecked()