""" 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()