""" 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 import creates/updates ticket rows. "Pull Tracking Numbers" (ShipStation) doesn't create anything of its own - it merges tracking numbers onto the matching JIRA row by ticket number, since ShipStation here is purely a label-generation step for JIRA tickets. """ from __future__ import annotations import webbrowser from PyQt6.QtGui import QAction from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QToolBar, QStatusBar, QMessageBox, QLabel, QTabWidget, QFileDialog, QDialog, ) from app import config from app.external_links import jira_ticket_url, google_maps_search_url from app.return_labels import is_emailed_label_order from app.services import SERVICE_REGISTRY from app.services.odoo_export import export_orders_to_csv from app.services.shipstation_send import export_order_to_shipstation_csv from app.status_rules import get_cancelled_statuses, find_new_cancellations 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.ui.widgets.return_label_dialog import ReturnLabelDialog from app.ui.widgets.pack_ticket_dialog import PackTicketDialog from app.workers import ( FetchOrdersWorker, SendToShipStationWorker, CreateDummyShipmentWorker, CreateReturnLabelWorker, load_orders_by_view, get_dashboard_stats, mark_shipstation_sent, save_dummy_outbound_label_id, save_pack_data, reset_local_database, ) 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._send_worker: SendToShipStationWorker | None = None self._return_label_worker: CreateDummyShipmentWorker | CreateReturnLabelWorker | None = None # Reused, never-destroyed dialog instances (see the WORKAROUND NOTE # in each dialog's module docstring) - created lazily via # set_order() rather than a fresh instance per ticket. self._pack_ticket_dialog: PackTicketDialog | None = None self._return_label_dialog: ReturnLabelDialog | None = None 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.cancelled_table = OrdersTableView() self.cancelled_table.order_double_clicked.connect(self._on_order_double_clicked) self.done_table = OrdersTableView() self.done_table.order_double_clicked.connect(self._on_order_double_clicked) self.tabs.addTab(self.dashboard, "Dashboard") self.tabs.addTab(self.orders_table, "Active Orders") self.tabs.addTab(self.cancelled_table, "Cancelled") self.tabs.addTab(self.done_table, "Done") layout.addWidget(self.tabs) self.setCentralWidget(central) menu_bar = self.menuBar() file_menu = menu_bar.addMenu("&File") settings_action = QAction("Settings...", self) settings_action.triggered.connect(self._on_settings_clicked) file_menu.addAction(settings_action) file_menu.addSeparator() exit_action = QAction("Exit", self) exit_action.triggered.connect(self.close) file_menu.addAction(exit_action) data_menu = menu_bar.addMenu("&Data") jira_action = QAction("Import from JIRA", self) jira_action.triggered.connect(lambda: self._on_import_clicked("jira")) data_menu.addAction(jira_action) self._import_actions["jira"] = jira_action shipstation_action = QAction("Pull Tracking Numbers (ShipStation)", self) shipstation_action.triggered.connect(lambda: self._on_import_clicked("shipstation")) data_menu.addAction(shipstation_action) self._import_actions["shipstation"] = shipstation_action data_menu.addSeparator() refresh_action = QAction("Refresh from Local DB", self) refresh_action.triggered.connect(self._refresh_everything) data_menu.addAction(refresh_action) export_action = QAction("Export Visible Orders to Odoo CSV...", self) export_action.setStatusTip("Exports from whichever tab is currently open") export_action.triggered.connect(self._on_export_clicked) data_menu.addAction(export_action) data_menu.addSeparator() reset_action = QAction("Reset Local Database...", self) reset_action.setStatusTip("Wipes the local cache - re-import from JIRA afterward") reset_action.triggered.connect(self._on_reset_database_clicked) data_menu.addAction(reset_action) data_menu.addSeparator() self._test_mode_action = QAction("ShipStation Test Mode", self) self._test_mode_action.setCheckable(True) self._test_mode_action.setChecked(config.is_shipstation_test_mode()) self._test_mode_action.setStatusTip( "Uses your ShipStation test/sandbox API key for every ShipStation action - " "no real charges, nothing appears in your production account" ) self._test_mode_action.toggled.connect(self._on_test_mode_toggled) data_menu.addAction(self._test_mode_action) list_carriers_action = QAction("List ShipStation Carriers...", self) list_carriers_action.setStatusTip( "Shows the carrier IDs actually valid for whichever API key is currently active " "(test or production) - useful for finding the right carrier_id directly" ) list_carriers_action.triggered.connect(self._on_list_carriers_clicked) data_menu.addAction(list_carriers_action) list_stores_action = QAction("About ShipStation Store IDs...", self) list_stores_action.setStatusTip( "ShipStation's V2 API has no way to list stores - explains where to find a " "store_id instead" ) list_stores_action.triggered.connect(self._on_list_stores_clicked) data_menu.addAction(list_stores_action) list_warehouses_action = QAction("List ShipStation Warehouses...", self) list_warehouses_action.setStatusTip( "Shows the warehouse IDs actually valid for whichever API key is currently active " "(test or production)" ) list_warehouses_action.triggered.connect(self._on_list_warehouses_clicked) data_menu.addAction(list_warehouses_action) create_test_warehouses_action = QAction("Create Test Warehouse(s)...", self) create_test_warehouses_action.setStatusTip( "Test Mode only - the sandbox account starts with zero warehouses, so this " "creates one per company (using the same return-address settings) and saves " "the resulting IDs into TEST_SHIPSTATION_*_WAREHOUSE_ID" ) create_test_warehouses_action.triggered.connect(self._on_create_test_warehouses_clicked) data_menu.addAction(create_test_warehouses_action) orders_menu = menu_bar.addMenu("&Orders") self._pack_ticket_action = QAction("Pack Ticket", self) self._pack_ticket_action.setStatusTip( "Enter serial numbers and mark packed - select a ticket first" ) self._pack_ticket_action.triggered.connect(self._on_pack_ticket_clicked) orders_menu.addAction(self._pack_ticket_action) emergency_action = QAction("Send to ShipStation", self) emergency_action.setStatusTip("Select a ticket in Active Orders first") emergency_action.triggered.connect(self._on_emergency_send_clicked) orders_menu.addAction(emergency_action) self._return_label_action = QAction("Create Return Label", self) self._return_label_action.setStatusTip( "For emailed-return-label tickets (SH007/OK012) - select one in Active Orders first" ) self._return_label_action.triggered.connect(self._on_create_return_label_clicked) orders_menu.addAction(self._return_label_action) orders_menu.addSeparator() view_jira_action = QAction("View in JIRA", self) view_jira_action.setStatusTip("Opens the selected ticket in JIRA") view_jira_action.triggered.connect(self._on_view_in_jira_clicked) orders_menu.addAction(view_jira_action) lookup_address_action = QAction("Look Up Address on Google Maps", self) lookup_address_action.setStatusTip( "Opens the selected ticket's shipping address in Google Maps" ) lookup_address_action.triggered.connect(self._on_lookup_address_clicked) orders_menu.addAction(lookup_address_action) # Quick-access toolbar for the highest-frequency actions - everything # here is also in the menus above (same QAction objects, so there's # nothing to keep in sync); this is just a shortcut for the few used # constantly enough to want one click instead of a menu dropdown. quick_toolbar = QToolBar("Quick Actions") quick_toolbar.setMovable(False) self.addToolBar(quick_toolbar) quick_toolbar.addAction(self._import_actions["jira"]) quick_toolbar.addAction(self._pack_ticket_action) quick_toolbar.addAction(self._return_label_action) self.status_bar = QStatusBar() self.setStatusBar(self.status_bar) self.status_label = QLabel("Ready.") self.status_bar.addWidget(self.status_label) # Permanent (right-aligned) so it's always visible regardless of # whatever status_label currently says - the whole point is that # it should be hard to miss whether real charges/labels are in # play right now. self._test_mode_indicator = QLabel() self._test_mode_indicator.setStyleSheet( "background-color: #b35c00; color: white; padding: 2px 8px; font-weight: bold;" ) self.status_bar.addPermanentWidget(self._test_mode_indicator) self._update_test_mode_indicator() # -- actions ----------------------------------------------------------- def _on_settings_clicked(self) -> None: dialog = SettingsDialog(self) dialog.exec() # Settings could have been edited directly (SHIPSTATION_TEST_MODE # as raw text) rather than via the menu checkbox - keep both in sync. self._test_mode_action.blockSignals(True) self._test_mode_action.setChecked(config.is_shipstation_test_mode()) self._test_mode_action.blockSignals(False) self._update_test_mode_indicator() def _on_test_mode_toggled(self, checked: bool) -> None: config.save_settings({"SHIPSTATION_TEST_MODE": "true" if checked else "false"}) self._update_test_mode_indicator() def _update_test_mode_indicator(self) -> None: if config.is_shipstation_test_mode(): self._test_mode_indicator.setText("SHIPSTATION TEST MODE") self._test_mode_indicator.show() else: self._test_mode_indicator.hide() def _on_list_carriers_clicked(self) -> None: from app.services.shipstation_send import list_carriers, ShipStationSendError mode = "TEST" if config.is_shipstation_test_mode() else "PRODUCTION" try: carriers = list_carriers() except ShipStationSendError as exc: QMessageBox.critical(self, "Could not list carriers", str(exc)) return if not carriers: QMessageBox.information( self, f"ShipStation Carriers ({mode})", f"No carriers are connected to this {mode.lower()} ShipStation account.", ) return lines = [f"Carriers visible to your current {mode} API key:", ""] for carrier in carriers: nickname = carrier.get("nickname") or "(no nickname)" lines.append( f" carrier_id: {carrier.get('carrier_id', '?')} " f"{carrier.get('friendly_name', '?')} - {nickname}" ) QMessageBox.information(self, f"ShipStation Carriers ({mode})", "\n".join(lines)) def _on_list_stores_clicked(self) -> None: from app.services.shipstation_send import list_stores, ShipStationSendError mode = "TEST" if config.is_shipstation_test_mode() else "PRODUCTION" try: stores = list_stores() except ShipStationSendError as exc: QMessageBox.critical(self, "Could not list stores", str(exc)) return if not stores: QMessageBox.information( self, f"ShipStation Stores ({mode})", f"No stores are set up in this {mode.lower()} ShipStation account.", ) return lines = [f"Stores visible to your current {mode} API key:", ""] for store in stores: lines.append( f" store_id: {store.get('store_id', '?')} " f"{store.get('store_name', '?')}" ) QMessageBox.information(self, f"ShipStation Stores ({mode})", "\n".join(lines)) def _on_list_warehouses_clicked(self) -> None: from app.services.shipstation_send import list_warehouses, ShipStationSendError mode = "TEST" if config.is_shipstation_test_mode() else "PRODUCTION" try: warehouses = list_warehouses() except ShipStationSendError as exc: QMessageBox.critical(self, "Could not list warehouses", str(exc)) return if not warehouses: QMessageBox.information( self, f"ShipStation Warehouses ({mode})", f"No warehouses are set up in this {mode.lower()} ShipStation account.", ) return lines = [f"Warehouses visible to your current {mode} API key:", ""] for warehouse in warehouses: lines.append( f" warehouse_id: {warehouse.get('warehouse_id', '?')} " f"{warehouse.get('name', '?')}" ) QMessageBox.information(self, f"ShipStation Warehouses ({mode})", "\n".join(lines)) def _on_create_test_warehouses_clicked(self) -> None: if not config.is_shipstation_test_mode(): QMessageBox.information( self, "Test Mode is off", "Turn on ShipStation Test Mode first (Data menu). This creates a " "warehouse in whichever ShipStation account your API key currently " "points to, and it's meant for the sandbox account only.", ) return from app.services.shipstation_send import ( create_test_warehouse_for_company, ShipStationSendError, ) companies = [ ("Signify Health", "TEST_SHIPSTATION_SIGNIFY_WAREHOUSE_ID"), ("Oak Street Health", "TEST_SHIPSTATION_OAKSTREET_WAREHOUSE_ID"), ] lines = [] for company, setting_key in companies: existing = config.load_settings().get(setting_key) if existing: lines.append(f"{company}: already set to {existing} - skipped") continue try: warehouse = create_test_warehouse_for_company(company) except ShipStationSendError as exc: lines.append(f"{company}: FAILED - {exc}") continue warehouse_id = warehouse.get("warehouse_id", "") config.save_settings({setting_key: warehouse_id}) lines.append(f"{company}: created {warehouse_id} and saved it to {setting_key}") QMessageBox.information(self, "Create Test Warehouse(s)", "\n".join(lines)) 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) if service_name == "jira": self.status_label.setText("Importing orders from JIRA...") else: self.status_label.setText("Pulling tracking numbers from ShipStation...") worker = FetchOrdersWorker(service) worker.finished_ok.connect( lambda result: self._on_import_finished(service_name, result) ) 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, result: dict) -> None: self._import_actions[service_name].setEnabled(True) self._refresh_everything() if service_name == "jira": self.status_label.setText( f"JIRA import complete: {result['new_count']} new, " f"{result['updated_count']} updated." ) self._notify_new_cancellations(result["status_changes"]) else: self.status_label.setText( f"Tracking pull complete: {result['enriched_count']} ticket(s) updated with " f"tracking numbers." ) self._notify_unmatched_tracking(result["unmatched_tracking_tickets"]) def _notify_unmatched_tracking(self, unmatched_tickets: list) -> None: if not unmatched_tickets: return lines = unmatched_tickets[:15] if len(unmatched_tickets) > 15: lines.append(f"...and {len(unmatched_tickets) - 15} more") QMessageBox.information( self, "Some tracking numbers didn't match a known ticket", "ShipStation had tracking numbers for these ticket numbers, but no matching " "JIRA ticket was found locally (maybe it hasn't been imported yet):\n\n" + "\n".join(lines), ) def _notify_new_cancellations(self, status_changes: list) -> None: newly_cancelled = find_new_cancellations(status_changes, get_cancelled_statuses()) if not newly_cancelled: return lines = [ f"{c['ticket_number']}: {c['old_status']} -> {c['new_status']}" for c in newly_cancelled[:15] ] if len(newly_cancelled) > 15: lines.append(f"...and {len(newly_cancelled) - 15} more") QMessageBox.warning( self, "Ticket(s) cancelled", f"{len(newly_cancelled)} ticket(s) were just cancelled:\n\n" + "\n".join(lines), ) 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: current_tab = self.tabs.currentWidget() if not isinstance(current_tab, OrdersTableView): QMessageBox.information( self, "Nothing to export", "Switch to Active Orders, Cancelled, or Done first, then export.", ) return orders = current_tab.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 _on_emergency_send_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 Send to ShipStation.", ) return info = order.shipping_info or {} line_items = order.line_items or [] skus_display = ", ".join(item.get("sku", "") for item in line_items) or ( ", ".join(order.skus or []) or "(none)" ) address_line2 = f" {info.get('address2')}" if info.get("address2") else "" summary_lines = [ f"Ticket: {order.ticket_number or order.external_id}", f"Company: {order.company}", f"Name: {info.get('name') or '(missing)'}", f"Address: {info.get('address1') or '(missing)'}{address_line2}", f" {info.get('city', '')}, {info.get('state', '')} {info.get('zip', '')}", f"SKUs: {skus_display}", ] box = QMessageBox(self) box.setIcon(QMessageBox.Icon.Warning) box.setWindowTitle("Send to ShipStation") box.setText("Review before sending - this creates a real order in ShipStation.") box.setInformativeText("\n".join(summary_lines)) api_button = box.addButton("Send via API Now", QMessageBox.ButtonRole.AcceptRole) csv_button = box.addButton("Export CSV Row...", QMessageBox.ButtonRole.ActionRole) box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole) box.exec() clicked = box.clickedButton() if clicked is api_button: self._send_via_api(order) elif clicked is csv_button: self._send_via_csv(order) def _send_via_api(self, order) -> None: ticket_number = order.ticket_number or order.external_id self.status_label.setText(f"Sending {ticket_number} to ShipStation...") worker = SendToShipStationWorker(order) worker.finished_ok.connect(lambda result: self._on_emergency_send_ok(ticket_number, result)) worker.failed.connect(self._on_emergency_send_failed) self._send_worker = worker # keep a reference so it isn't garbage collected worker.start() def _on_emergency_send_ok(self, ticket_number: str, result: dict) -> None: shipment_id = result.get("shipment_id", "?") mark_shipstation_sent(ticket_number) self._refresh_everything() self.status_label.setText(f"Sent to ShipStation - shipment {shipment_id} created.") QMessageBox.information( self, "Sent to ShipStation", f"Order created in ShipStation (shipment {shipment_id}).\n\n" "This is a live order - please double-check it in ShipStation to confirm " "automation packed and priced it correctly, especially the first few times " "you use this.", ) def _on_emergency_send_failed(self, message: str) -> None: self.status_label.setText("Send to ShipStation failed.") QMessageBox.critical(self, "Send to ShipStation failed", message) def _send_via_csv(self, order) -> None: default_name = f"shipstation_{order.ticket_number or order.external_id}.csv" filepath, _ = QFileDialog.getSaveFileName( self, "Export Ticket to ShipStation CSV", default_name, "CSV Files (*.csv)" ) if not filepath: return try: count = export_order_to_shipstation_csv([order], filepath) except OSError as exc: QMessageBox.critical(self, "Export failed", str(exc)) return self.status_label.setText(f"Exported {count} row(s) to {filepath}") QMessageBox.information( self, "Exported", f"Wrote {count} row(s) to:\n{filepath}\n\n" "Upload this into ShipStation's Import Orders wizard.", ) def _on_create_return_label_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 Create Return Label.", ) return if not is_emailed_label_order(order.skus or []): proceed = QMessageBox.question( self, "Not a return-label ticket", f"This ticket's SKU(s) ({', '.join(order.skus or []) or 'none'}) don't match " "the configured emailed-label SKUs. Continue anyway?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if proceed != QMessageBox.StandardButton.Yes: return if self._return_label_dialog is None: self._return_label_dialog = ReturnLabelDialog(order, self) self._return_label_dialog.finished.connect(self._on_return_label_dialog_finished) else: self._return_label_dialog.set_order(order) # Non-modal on purpose - see the module docstring in # return_label_dialog.py. Disable the action while it's open so a # second click can't call set_order() on it mid-edit and silently # overwrite whatever the user is in the middle of entering. self._return_label_action.setEnabled(False) self._return_label_dialog.show() self._return_label_dialog.raise_() self._return_label_dialog.activateWindow() def _on_return_label_dialog_finished(self, result: int) -> None: self._return_label_action.setEnabled(True) if result != QDialog.DialogCode.Accepted: return dialog = self._return_label_dialog order = dialog.order ticket_number = order.ticket_number or order.external_id if order.dummy_outbound_label_id: # Step 2: a dummy already exists for this ticket - create the # real return label from it. packages = dialog.get_packages() charge_event = dialog.get_charge_event() if not packages: QMessageBox.warning(self, "No packages", "Add at least one package first.") return self.status_label.setText(f"Creating return label for {ticket_number}...") worker = CreateReturnLabelWorker(order, order.dummy_outbound_label_id, packages, charge_event) worker.finished_ok.connect(lambda result: self._on_return_label_ok(ticket_number, result)) worker.failed.connect(self._on_return_label_failed) self._return_label_worker = worker # keep a reference so it isn't garbage collected worker.start() else: # Step 1: no dummy yet - create it, then stop and let the user # verify it in ShipStation before running this again for step 2. self.status_label.setText(f"Creating dummy shipment for {ticket_number}...") worker = CreateDummyShipmentWorker(order) worker.finished_ok.connect(lambda result: self._on_dummy_shipment_ok(ticket_number, result)) worker.failed.connect(self._on_return_label_failed) self._return_label_worker = worker worker.start() def _on_dummy_shipment_ok(self, ticket_number: str, result: dict) -> None: label_id = result.get("label_id", "?") save_dummy_outbound_label_id(ticket_number, label_id) self._refresh_everything() # Refreshing resets the table model, which clears whatever row was # selected - re-select the same ticket so clicking Create Return # Label again immediately proceeds to step 2 rather than hitting # "no ticket selected". self.orders_table.select_ticket(ticket_number) self.status_label.setText(f"Dummy shipment created - {label_id}") QMessageBox.information( self, "Step 1 Complete", f"Dummy shipment {label_id} created for {ticket_number}.\n\n" "Go check it in ShipStation now - confirm Order #, Ship From/Store, and everything " "else looks right. Once you've verified it, click Create Return Label again on this " "ticket to run step 2 (the actual return label).", ) def _on_return_label_ok(self, ticket_number: str, result: dict) -> None: mark_shipstation_sent(ticket_number) self._refresh_everything() self.orders_table.select_ticket(ticket_number) label_id = result.get("label_id", "?") tracking = result.get("tracking_number", "?") label_status = result.get("status", "unknown") dummy_id = result.get("_dummy_outbound_label_id", "?") self.status_label.setText(f"Return label created - {label_id} (status: {label_status})") if label_status == "processing": body = ( f"Label {label_id} was accepted but is still processing (tracking {tracking}).\n\n" "ShipStation says this can take a few minutes to fully complete - if you can't " "find it yet, wait a bit and check again before assuming something's wrong.\n\n" f"(Linked to dummy outbound label {dummy_id}, for troubleshooting reference.)" ) else: body = ( f"Label {label_id} created, status: {label_status} (tracking {tracking}).\n\n" "Find it in ShipStation and use Other Actions -> Send Return Label to email " "it to the customer through your branded template.\n\n" f"(Linked to dummy outbound label {dummy_id}, for troubleshooting reference.)" ) box = QMessageBox(self) box.setIcon(QMessageBox.Icon.Information) box.setWindowTitle("Return Label Created") box.setText(body) copy_button = None if tracking and tracking != "?": copy_button = box.addButton("Copy Tracking Number", QMessageBox.ButtonRole.ActionRole) box.addButton(QMessageBox.StandardButton.Ok) box.exec() # Only ever touches the clipboard if explicitly asked - never # overwrites it automatically, since it's used constantly for other # things and clobbering it silently would lose whatever was there. if copy_button is not None and box.clickedButton() is copy_button: QApplication.clipboard().setText(tracking) self.status_label.setText(f"Tracking number {tracking} copied to clipboard.") def _on_return_label_failed(self, message: str) -> None: 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 if self._pack_ticket_dialog is None: self._pack_ticket_dialog = PackTicketDialog(order, self) self._pack_ticket_dialog.finished.connect(self._on_pack_ticket_dialog_finished) else: self._pack_ticket_dialog.set_order(order) # Non-modal on purpose - see the module docstring in # pack_ticket_dialog.py. Disable the action while it's open so a # second click can't call set_order() on it mid-edit and silently # overwrite whatever the user is in the middle of entering/scanning. self._pack_ticket_action.setEnabled(False) self._pack_ticket_dialog.show() self._pack_ticket_dialog.raise_() self._pack_ticket_dialog.activateWindow() def _on_pack_ticket_dialog_finished(self, result: int) -> None: self._pack_ticket_action.setEnabled(True) if result != QDialog.DialogCode.Accepted: return dialog = self._pack_ticket_dialog order = dialog.order 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 _currently_selected_order(self): """Unlike orders_table.selected_order() (Active Orders specifically), this checks whichever tab is actually showing - View in JIRA and Look Up Address are read-only lookups that make just as much sense for a Cancelled or Done ticket as an Active one.""" current_widget = self.tabs.currentWidget() if isinstance(current_widget, OrdersTableView): return current_widget.selected_order() return None def _on_view_in_jira_clicked(self) -> None: order = self._currently_selected_order() if order is None: QMessageBox.information(self, "No ticket selected", "Select a ticket first.") return url = jira_ticket_url(order.ticket_number) if url is None: QMessageBox.warning( self, "JIRA URL not set", "Add your JIRA URL in Settings first (or this ticket has no ticket number).", ) return webbrowser.open(url) def _on_lookup_address_clicked(self) -> None: order = self._currently_selected_order() if order is None: QMessageBox.information(self, "No ticket selected", "Select a ticket first.") return url = google_maps_search_url(order.shipping_info) if url is None: QMessageBox.information(self, "No address", "This ticket has no address on file.") return webbrowser.open(url) def _on_reset_database_clicked(self) -> None: confirm = QMessageBox.warning( self, "Reset Local Database", "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, ) if confirm != QMessageBox.StandardButton.Yes: return count = reset_local_database() self._refresh_everything() self.status_label.setText( f"Local database reset - {count} order(s) cleared. Run Import from JIRA to repopulate." ) def _refresh_everything(self) -> None: active_orders, cancelled_orders, done_orders = load_orders_by_view() self.orders_table.set_orders(active_orders) self.cancelled_table.set_orders(cancelled_orders) self.done_table.set_orders(done_orders) self.dashboard.update_stats(get_dashboard_stats()) if not hasattr(self, "_suppress_ready_status"): self.status_label.setText( f"{len(active_orders)} active, {len(cancelled_orders)} cancelled, " f"{len(done_orders)} done." ) # --------------------------------------------------------------------------- # 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. # - Emailed-label SKU: these tickets won't get tracking numbers pulled via # the normal ShipStation flow - once its handling is defined, it likely # needs its own status/action here so it doesn't get stuck in Active # forever with nothing to trigger its move to Done. # - Pipeline actions ("mark fulfilled manually", "void a 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. # ---------------------------------------------------------------------------