""" 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. WORKAROUND NOTE: this dialog (along with Return Label) crashed the whole process on close, confirmed via two full crash dumps (identical fault offset both times) to be caused by Bitdefender Endpoint Security's Advanced Threat Control corrupting a stack frame inside Qt6Core.dll - not a bug in this code. Reusing a persistent instance instead of destroying/recreating it per ticket did NOT resolve it - the crash recurred at the same offset regardless, ruling out object destruction timing as the cause. The current mitigation is in main_window.py: this dialog is shown via show() (non-modal) instead of exec() (modal), since exec() runs a nested event loop that disables and re-enables the parent window - a different, more involved Windows API sequence than a plain show/hide. This dialog still supports being reused via set_order() regardless, since avoiding unnecessary construction/destruction is sound practice either way. """ from __future__ import annotations from typing import Optional 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: Optional[Order] = None self._field_rows: list[tuple[QLineEdit, QLineEdit]] = [] # (label_edit, value_edit) layout = QVBoxLayout(self) self.summary_label = QLabel() layout.addWidget(self.summary_label) layout.addWidget(QLabel("Serial numbers (scan or type; Enter moves to the next field):")) self._scroll_area = QScrollArea() self._scroll_area.setWidgetResizable(True) layout.addWidget(self._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") 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.set_order(order) def set_order(self, order: Order) -> None: """ Re-initializes this dialog for a different ticket, in place - this is what lets main_window.py reuse a single persistent instance instead of constructing (and eventually destroying) a new one per ticket. See the module docstring for why that matters here specifically. """ self.order = order self.setWindowTitle(f"Pack Ticket - {order.ticket_number or order.external_id}") self.resize(520, 600) 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)'}", ] self.summary_label.setText("\n".join(summary_lines)) # Swap in a fresh fields widget rather than trying to clear rows # out of the existing QFormLayout - simpler, and the old one is # only deleteLater()'d, not force-destroyed immediately. old_fields_widget = self._scroll_area.takeWidget() if old_fields_widget is not None: old_fields_widget.deleteLater() self._fields_widget = QWidget() self._fields_layout = QFormLayout(self._fields_widget) self._scroll_area.setWidget(self._fields_widget) self._field_rows = [] self.packed_checkbox.setChecked(bool(order.packed)) 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()