From 2b4a485819cec97afbaa3b00f68ae39fa0e400fb Mon Sep 17 00:00:00 2001 From: NguyenND Date: Tue, 17 Feb 2026 14:28:19 -0500 Subject: [PATCH] Update for desktop executable file --- desktop_app.py | 1079 +++++++++++++++++++++++++++++++++++++++- src/config/settings.py | 57 ++- 2 files changed, 1128 insertions(+), 8 deletions(-) diff --git a/desktop_app.py b/desktop_app.py index 2ddb0b0..54325fe 100644 --- a/desktop_app.py +++ b/desktop_app.py @@ -241,8 +241,8 @@ class QBOExcelSyncApp: self._create_main_layout() self._create_status_bar() - # Try to restore connection - self._try_restore_connection() + # Don't auto-connect - let user select connection type manually + # self._try_restore_connection() # Bind window close event self.root.protocol("WM_DELETE_WINDOW", self._on_close) @@ -297,6 +297,22 @@ class QBOExcelSyncApp: menubar.add_cascade(label="Tools", menu=tools_menu) tools_menu.add_command(label="Manage Templates...", command=self._show_templates) tools_menu.add_command(label="Settings...", command=self._show_settings) + tools_menu.add_separator() + + # QuickBooks Lookup submenu + lookup_menu = tk.Menu(tools_menu, tearoff=0) + tools_menu.add_cascade(label="QuickBooks Lookup", menu=lookup_menu) + lookup_menu.add_command(label="Accounts...", command=lambda: self._show_qb_lookup('account')) + lookup_menu.add_command(label="Customers...", command=lambda: self._show_qb_lookup('customer')) + lookup_menu.add_command(label="Vendors...", command=lambda: self._show_qb_lookup('vendor')) + lookup_menu.add_command(label="Items/Products...", command=lambda: self._show_qb_lookup('item')) + lookup_menu.add_separator() + lookup_menu.add_command(label="Classes...", command=lambda: self._show_qb_lookup('class')) + lookup_menu.add_command(label="Departments...", command=lambda: self._show_qb_lookup('department')) + lookup_menu.add_command(label="Payment Terms...", command=lambda: self._show_qb_lookup('term')) + lookup_menu.add_command(label="Payment Methods...", command=lambda: self._show_qb_lookup('paymentmethod')) + lookup_menu.add_command(label="Employees...", command=lambda: self._show_qb_lookup('employee')) + tools_menu.add_separator() tools_menu.add_command(label="View Logs...", command=self._show_logs) @@ -324,6 +340,7 @@ class QBOExcelSyncApp: self._create_import_tab() self._create_connection_tab() self._create_templates_tab() + self._create_lookup_tab() def _create_dashboard_tab(self): """Create the dashboard tab.""" @@ -641,7 +658,7 @@ class QBOExcelSyncApp: ttk.Label(creds_frame, text="Redirect URI:").grid(row=2, column=0, sticky=tk.W, pady=5) self.redirect_uri_entry = ttk.Entry(creds_frame, width=50) - self.redirect_uri_entry.insert(0, "http://localhost:5000/oauth/callback") + self.redirect_uri_entry.insert(0, "http://localhost:9000/oauth/callback") self.redirect_uri_entry.grid(row=2, column=1, sticky=tk.W, padx=10, pady=5) ttk.Label(creds_frame, text="Environment:").grid(row=3, column=0, sticky=tk.W, pady=5) @@ -767,6 +784,436 @@ class QBOExcelSyncApp: # Load templates self._refresh_templates() + def _create_lookup_tab(self): + """Create the QuickBooks Lookup tab.""" + tab = ttk.Frame(self.notebook, padding=20) + self.notebook.add(tab, text=" Lookup ") + + # Header + header_frame = ttk.Frame(tab) + header_frame.pack(fill=tk.X, pady=(0, 15)) + + ttk.Label(header_frame, text="QuickBooks Data Lookup", style='Title.TLabel').pack(side=tk.LEFT) + + # Connection status + self.lookup_conn_label = ttk.Label(header_frame, text="Not connected", foreground='gray') + self.lookup_conn_label.pack(side=tk.RIGHT) + + # Entity type selection + select_frame = ttk.LabelFrame(tab, text="Select Entity Type", padding=10) + select_frame.pack(fill=tk.X, pady=(0, 10)) + + self.lookup_entity_var = tk.StringVar(value="account") + + entity_types = [ + ("Accounts", "account"), + ("Customers", "customer"), + ("Vendors", "vendor"), + ("Items/Products", "item"), + ("Classes", "class"), + ("Departments", "department"), + ("Payment Terms", "term"), + ("Payment Methods", "paymentmethod"), + ("Employees", "employee"), + ] + + entity_grid = ttk.Frame(select_frame) + entity_grid.pack(fill=tk.X) + + for i, (label, value) in enumerate(entity_types): + rb = ttk.Radiobutton( + entity_grid, + text=label, + variable=self.lookup_entity_var, + value=value, + command=self._on_lookup_entity_change + ) + rb.grid(row=i // 5, column=i % 5, sticky=tk.W, padx=15, pady=3) + + # Search frame + search_frame = ttk.Frame(tab) + search_frame.pack(fill=tk.X, pady=10) + + ttk.Label(search_frame, text="Search:").pack(side=tk.LEFT) + self.lookup_search_var = tk.StringVar() + self.lookup_search_entry = ttk.Entry(search_frame, textvariable=self.lookup_search_var, width=30) + self.lookup_search_entry.pack(side=tk.LEFT, padx=(5, 15)) + self.lookup_search_entry.bind('', lambda e: self._perform_lookup()) + + # Type filter for accounts + ttk.Label(search_frame, text="Account Type:").pack(side=tk.LEFT) + self.lookup_type_var = tk.StringVar(value="All Types") + self.lookup_type_combo = ttk.Combobox( + search_frame, + textvariable=self.lookup_type_var, + values=["All Types", "Bank", "Accounts Receivable", "Other Current Asset", "Fixed Asset", + "Accounts Payable", "Credit Card", "Other Current Liability", + "Long Term Liability", "Equity", "Income", "Cost of Goods Sold", + "Expense", "Other Income", "Other Expense"], + width=20, + state='readonly' + ) + self.lookup_type_combo.pack(side=tk.LEFT, padx=(5, 15)) + + ttk.Button(search_frame, text="Search", command=self._perform_lookup, width=12).pack(side=tk.LEFT) + + # Results treeview + results_frame = ttk.LabelFrame(tab, text="Results", padding=10) + results_frame.pack(fill=tk.BOTH, expand=True, pady=10) + + # Create treeview with scrollbars + tree_container = ttk.Frame(results_frame) + tree_container.pack(fill=tk.BOTH, expand=True) + + self.lookup_columns = ['Id', 'Name', 'Type', 'SubType', 'Balance', 'Active'] + self.lookup_tree = ttk.Treeview(tree_container, columns=self.lookup_columns, show='headings', height=15) + + # Default column configuration + self.lookup_tree.heading('Id', text='ID') + self.lookup_tree.heading('Name', text='Name') + self.lookup_tree.heading('Type', text='Type') + self.lookup_tree.heading('SubType', text='SubType') + self.lookup_tree.heading('Balance', text='Balance') + self.lookup_tree.heading('Active', text='Active') + + self.lookup_tree.column('Id', width=60, minwidth=50) + self.lookup_tree.column('Name', width=200, minwidth=100) + self.lookup_tree.column('Type', width=120, minwidth=80) + self.lookup_tree.column('SubType', width=120, minwidth=80) + self.lookup_tree.column('Balance', width=100, minwidth=60) + self.lookup_tree.column('Active', width=60, minwidth=50) + + # Scrollbars + vsb = ttk.Scrollbar(tree_container, orient=tk.VERTICAL, command=self.lookup_tree.yview) + hsb = ttk.Scrollbar(tree_container, orient=tk.HORIZONTAL, command=self.lookup_tree.xview) + self.lookup_tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set) + + self.lookup_tree.grid(row=0, column=0, sticky='nsew') + vsb.grid(row=0, column=1, sticky='ns') + hsb.grid(row=1, column=0, sticky='ew') + + tree_container.grid_rowconfigure(0, weight=1) + tree_container.grid_columnconfigure(0, weight=1) + + # Double-click to copy + self.lookup_tree.bind('', lambda e: self._copy_lookup_selection()) + + # Status and action frame + bottom_frame = ttk.Frame(tab) + bottom_frame.pack(fill=tk.X, pady=(10, 0)) + + self.lookup_status_var = tk.StringVar(value="Select an entity type and click Search") + ttk.Label(bottom_frame, textvariable=self.lookup_status_var, foreground='gray').pack(side=tk.LEFT) + + # Action buttons + btn_frame = ttk.Frame(bottom_frame) + btn_frame.pack(side=tk.RIGHT) + + ttk.Button(btn_frame, text="Copy Selected", command=self._copy_lookup_selection, width=14).pack(side=tk.LEFT, padx=5) + ttk.Button(btn_frame, text="Export to CSV", command=self._export_lookup_csv, width=14).pack(side=tk.LEFT, padx=5) + ttk.Button(btn_frame, text="Refresh", command=self._perform_lookup, width=10).pack(side=tk.LEFT) + + def _on_lookup_entity_change(self): + """Handle lookup entity type change.""" + entity_type = self.lookup_entity_var.get() + + # Show/hide account type filter + if entity_type == 'account': + self.lookup_type_combo.config(state='readonly') + else: + self.lookup_type_combo.config(state='disabled') + + # Update columns based on entity type + self._update_lookup_columns(entity_type) + + # Clear results + for item in self.lookup_tree.get_children(): + self.lookup_tree.delete(item) + + self.lookup_status_var.set(f"Click Search to load {entity_type}s") + + def _update_lookup_columns(self, entity_type: str): + """Update treeview columns based on entity type.""" + # Entity column configurations + column_configs = { + 'account': [ + ('Id', 'ID', 60), + ('Name', 'Name', 200), + ('AccountType', 'Type', 120), + ('AccountSubType', 'SubType', 120), + ('CurrentBalance', 'Balance', 100), + ('Active', 'Active', 60), + ], + 'customer': [ + ('Id', 'ID', 60), + ('DisplayName', 'Name', 200), + ('CompanyName', 'Company', 150), + ('Email', 'Email', 180), + ('Phone', 'Phone', 120), + ('Balance', 'Balance', 100), + ('Active', 'Active', 60), + ], + 'vendor': [ + ('Id', 'ID', 60), + ('DisplayName', 'Name', 200), + ('CompanyName', 'Company', 150), + ('Email', 'Email', 180), + ('Phone', 'Phone', 120), + ('Balance', 'Balance', 100), + ('Active', 'Active', 60), + ], + 'item': [ + ('Id', 'ID', 60), + ('Name', 'Name', 200), + ('Type', 'Type', 100), + ('Description', 'Description', 250), + ('UnitPrice', 'Price', 80), + ('Active', 'Active', 60), + ], + 'class': [ + ('Id', 'ID', 60), + ('Name', 'Name', 200), + ('FullyQualifiedName', 'Full Name', 300), + ('Active', 'Active', 60), + ], + 'department': [ + ('Id', 'ID', 60), + ('Name', 'Name', 200), + ('FullyQualifiedName', 'Full Name', 300), + ('Active', 'Active', 60), + ], + 'term': [ + ('Id', 'ID', 60), + ('Name', 'Name', 200), + ('DueDays', 'Due Days', 100), + ('Active', 'Active', 60), + ], + 'paymentmethod': [ + ('Id', 'ID', 60), + ('Name', 'Name', 200), + ('Type', 'Type', 150), + ('Active', 'Active', 60), + ], + 'employee': [ + ('Id', 'ID', 60), + ('DisplayName', 'Name', 200), + ('GivenName', 'First Name', 120), + ('FamilyName', 'Last Name', 120), + ('Active', 'Active', 60), + ], + } + + config = column_configs.get(entity_type, column_configs['account']) + + # Update columns + columns = [col[0] for col in config] + self.lookup_tree['columns'] = columns + + for col_key, col_name, col_width in config: + self.lookup_tree.heading(col_key, text=col_name) + self.lookup_tree.column(col_key, width=col_width, minwidth=50) + + def _perform_lookup(self): + """Perform the lookup search.""" + if not self.is_connected: + messagebox.showwarning("Not Connected", "Please connect to QuickBooks first.") + return + + # Update connection label + conn_type = "Online" if self.connection_type == 'qbo' else "Desktop" + self.lookup_conn_label.config(text=f"Connected: {self.company_name} ({conn_type})", foreground='green') + + entity_type = self.lookup_entity_var.get() + search_term = self.lookup_search_var.get().strip() + type_filter = self.lookup_type_var.get() if self.lookup_type_var.get() != "All Types" else "" + + # Clear existing results + for item in self.lookup_tree.get_children(): + self.lookup_tree.delete(item) + + self.lookup_status_var.set("Searching...") + self.root.update() + + try: + entities = [] + + # Entity query configurations + query_config = { + 'account': ('Account', 'Name'), + 'customer': ('Customer', 'DisplayName'), + 'vendor': ('Vendor', 'DisplayName'), + 'item': ('Item', 'Name'), + 'class': ('Class', 'Name'), + 'department': ('Department', 'Name'), + 'term': ('Term', 'Name'), + 'paymentmethod': ('PaymentMethod', 'Name'), + 'employee': ('Employee', 'DisplayName'), + } + + query_name, search_field = query_config.get(entity_type, ('Account', 'Name')) + + if self.connection_type == 'qbo' and self.qbo_client: + entities = self._fetch_qbo_entities( + query_name, + search_field, + search_term, + type_filter if entity_type == 'account' else None + ) + elif self.connection_type == 'qbd' and self.is_connected: + entities = self._fetch_qbd_entities( + entity_type, + search_term, + type_filter if entity_type == 'account' else None + ) + + # Populate tree + for entity in entities: + values = self._extract_entity_values(entity, entity_type) + self.lookup_tree.insert('', tk.END, values=values) + + self.lookup_status_var.set(f"Found {len(entities)} record(s)") + logger.info(f"[LOOKUP] {entity_type}: Found {len(entities)} records") + + except Exception as e: + self.lookup_status_var.set(f"Error: {str(e)}") + logger.error(f"Lookup error: {e}") + messagebox.showerror("Lookup Error", f"Failed to fetch data: {str(e)}") + + def _extract_entity_values(self, entity: Dict, entity_type: str) -> tuple: + """Extract values from entity based on type.""" + + def get_nested(obj, path): + """Get nested value using dot notation.""" + keys = path.split('.') + value = obj + for key in keys: + if isinstance(value, dict): + value = value.get(key) + else: + return None + return value + + def format_val(value, is_currency=False): + """Format value for display.""" + if value is None: + return '-' + elif isinstance(value, bool): + return 'Yes' if value else 'No' + elif is_currency: + try: + return f"${float(value):,.2f}" + except: + return str(value) + return str(value) + + if entity_type == 'account': + return ( + entity.get('Id', ''), + entity.get('Name', ''), + entity.get('AccountType', ''), + entity.get('AccountSubType', ''), + format_val(entity.get('CurrentBalance'), True), + format_val(entity.get('Active')), + ) + elif entity_type in ['customer', 'vendor']: + return ( + entity.get('Id', ''), + entity.get('DisplayName', ''), + entity.get('CompanyName', ''), + get_nested(entity, 'PrimaryEmailAddr.Address') or '', + get_nested(entity, 'PrimaryPhone.FreeFormNumber') or '', + format_val(entity.get('Balance'), True), + format_val(entity.get('Active')), + ) + elif entity_type == 'item': + return ( + entity.get('Id', ''), + entity.get('Name', ''), + entity.get('Type', ''), + entity.get('Description', '')[:50] if entity.get('Description') else '', + format_val(entity.get('UnitPrice'), True), + format_val(entity.get('Active')), + ) + elif entity_type in ['class', 'department']: + return ( + entity.get('Id', ''), + entity.get('Name', ''), + entity.get('FullyQualifiedName', ''), + format_val(entity.get('Active')), + ) + elif entity_type == 'term': + return ( + entity.get('Id', ''), + entity.get('Name', ''), + entity.get('DueDays', ''), + format_val(entity.get('Active')), + ) + elif entity_type == 'paymentmethod': + return ( + entity.get('Id', ''), + entity.get('Name', ''), + entity.get('Type', ''), + format_val(entity.get('Active')), + ) + elif entity_type == 'employee': + return ( + entity.get('Id', ''), + entity.get('DisplayName', ''), + entity.get('GivenName', ''), + entity.get('FamilyName', ''), + format_val(entity.get('Active')), + ) + else: + return (entity.get('Id', ''), entity.get('Name', '')) + + def _copy_lookup_selection(self): + """Copy selected lookup row to clipboard.""" + selection = self.lookup_tree.selection() + if not selection: + messagebox.showinfo("No Selection", "Please select a row to copy.") + return + + values = self.lookup_tree.item(selection[0])['values'] + # Format as ID: Name + text = f"{values[0]}: {values[1]}" + + self.root.clipboard_clear() + self.root.clipboard_append(text) + self.lookup_status_var.set(f"Copied to clipboard: {text}") + + def _export_lookup_csv(self): + """Export lookup results to CSV.""" + items = self.lookup_tree.get_children() + if not items: + messagebox.showwarning("No Data", "No data to export. Please perform a search first.") + return + + from tkinter import filedialog + entity_type = self.lookup_entity_var.get() + filepath = filedialog.asksaveasfilename( + defaultextension=".csv", + filetypes=[("CSV files", "*.csv"), ("All files", "*.*")], + initialfile=f"{entity_type}_export.csv" + ) + + if not filepath: + return + + import csv + columns = self.lookup_tree['columns'] + + with open(filepath, 'w', newline='', encoding='utf-8') as f: + writer = csv.writer(f) + # Header - get display names from headings + headers = [self.lookup_tree.heading(col)['text'] for col in columns] + writer.writerow(headers) + # Data + for item in items: + writer.writerow(self.lookup_tree.item(item)['values']) + + messagebox.showinfo("Export Complete", f"Exported {len(items)} records to:\n{filepath}") + logger.info(f"[EXPORT] Exported {len(items)} {entity_type} to {filepath}") + def _create_status_bar(self): """Create the status bar.""" self.status_bar = StatusBar(self.root) @@ -843,7 +1290,7 @@ class QBOExcelSyncApp: # Parse redirect URI to get port parsed = urlparse(redirect_uri) - port = parsed.port or 5000 + port = parsed.port or 9000 # Variables to store callback data callback_data = {'code': None, 'realm_id': None, 'error': None} @@ -2503,6 +2950,630 @@ except Exception as e: """Show templates tab.""" self.notebook.select(3) + def _show_qb_lookup(self, entity_type: str): + """Show QuickBooks entity lookup dialog.""" + if not self.is_connected: + messagebox.showwarning("Not Connected", "Please connect to QuickBooks first.") + return + + # Entity configurations + entity_config = { + 'account': { + 'query_name': 'Account', + 'display_name': 'Accounts', + 'columns': [ + ('Id', 'ID', 60), + ('Name', 'Name', 200), + ('AccountType', 'Type', 120), + ('AccountSubType', 'SubType', 120), + ('AcctNum', 'Number', 80), + ('CurrentBalance', 'Balance', 100), + ('Active', 'Active', 60), + ], + 'search_field': 'Name', + 'type_filter': True, + 'type_options': ['Bank', 'Accounts Receivable', 'Other Current Asset', 'Fixed Asset', + 'Accounts Payable', 'Credit Card', 'Other Current Liability', + 'Long Term Liability', 'Equity', 'Income', 'Cost of Goods Sold', + 'Expense', 'Other Income', 'Other Expense'], + }, + 'customer': { + 'query_name': 'Customer', + 'display_name': 'Customers', + 'columns': [ + ('Id', 'ID', 60), + ('DisplayName', 'Name', 200), + ('CompanyName', 'Company', 150), + ('PrimaryEmailAddr.Address', 'Email', 180), + ('PrimaryPhone.FreeFormNumber', 'Phone', 120), + ('Balance', 'Balance', 100), + ('Active', 'Active', 60), + ], + 'search_field': 'DisplayName', + }, + 'vendor': { + 'query_name': 'Vendor', + 'display_name': 'Vendors', + 'columns': [ + ('Id', 'ID', 60), + ('DisplayName', 'Name', 200), + ('CompanyName', 'Company', 150), + ('PrimaryEmailAddr.Address', 'Email', 180), + ('PrimaryPhone.FreeFormNumber', 'Phone', 120), + ('Balance', 'Balance', 100), + ('Active', 'Active', 60), + ], + 'search_field': 'DisplayName', + }, + 'item': { + 'query_name': 'Item', + 'display_name': 'Items/Products', + 'columns': [ + ('Id', 'ID', 60), + ('Name', 'Name', 200), + ('Type', 'Type', 100), + ('Description', 'Description', 250), + ('UnitPrice', 'Price', 80), + ('Active', 'Active', 60), + ], + 'search_field': 'Name', + }, + 'class': { + 'query_name': 'Class', + 'display_name': 'Classes', + 'columns': [ + ('Id', 'ID', 60), + ('Name', 'Name', 200), + ('FullyQualifiedName', 'Full Name', 250), + ('Active', 'Active', 60), + ], + 'search_field': 'Name', + }, + 'department': { + 'query_name': 'Department', + 'display_name': 'Departments', + 'columns': [ + ('Id', 'ID', 60), + ('Name', 'Name', 200), + ('FullyQualifiedName', 'Full Name', 250), + ('Active', 'Active', 60), + ], + 'search_field': 'Name', + }, + 'term': { + 'query_name': 'Term', + 'display_name': 'Payment Terms', + 'columns': [ + ('Id', 'ID', 60), + ('Name', 'Name', 200), + ('DueDays', 'Due Days', 80), + ('Active', 'Active', 60), + ], + 'search_field': 'Name', + }, + 'paymentmethod': { + 'query_name': 'PaymentMethod', + 'display_name': 'Payment Methods', + 'columns': [ + ('Id', 'ID', 60), + ('Name', 'Name', 200), + ('Type', 'Type', 100), + ('Active', 'Active', 60), + ], + 'search_field': 'Name', + }, + 'employee': { + 'query_name': 'Employee', + 'display_name': 'Employees', + 'columns': [ + ('Id', 'ID', 60), + ('DisplayName', 'Name', 200), + ('GivenName', 'First Name', 100), + ('FamilyName', 'Last Name', 100), + ('PrimaryEmailAddr.Address', 'Email', 180), + ('Active', 'Active', 60), + ], + 'search_field': 'DisplayName', + }, + } + + config = entity_config.get(entity_type) + if not config: + messagebox.showerror("Error", f"Unknown entity type: {entity_type}") + return + + # Create dialog + dialog = tk.Toplevel(self.root) + dialog.title(f"QuickBooks {config['display_name']} Lookup") + dialog.geometry("1000x600") + dialog.transient(self.root) + + # Connection info + conn_type = "Online" if self.connection_type == 'qbo' else "Desktop" + info_frame = ttk.Frame(dialog) + info_frame.pack(fill=tk.X, padx=10, pady=5) + + ttk.Label( + info_frame, + text=f"Connected to: {self.company_name} ({conn_type})", + font=('Segoe UI', 9, 'italic'), + foreground='gray' + ).pack(side=tk.LEFT) + + # Search frame + search_frame = ttk.Frame(dialog) + search_frame.pack(fill=tk.X, padx=10, pady=10) + + ttk.Label(search_frame, text="Search:").pack(side=tk.LEFT) + search_var = tk.StringVar() + search_entry = ttk.Entry(search_frame, textvariable=search_var, width=30) + search_entry.pack(side=tk.LEFT, padx=(5, 10)) + + # Type filter for accounts + type_var = tk.StringVar(value="") + if config.get('type_filter'): + ttk.Label(search_frame, text="Type:").pack(side=tk.LEFT, padx=(10, 5)) + type_combo = ttk.Combobox( + search_frame, + textvariable=type_var, + values=["All Types"] + config.get('type_options', []), + width=20, + state='readonly' + ) + type_combo.pack(side=tk.LEFT) + type_combo.current(0) + + # Results treeview + tree_frame = ttk.Frame(dialog) + tree_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) + + columns = [col[0] for col in config['columns']] + tree = ttk.Treeview(tree_frame, columns=columns, show='headings') + + for col_key, col_name, col_width in config['columns']: + tree.heading(col_key, text=col_name) + tree.column(col_key, width=col_width, minwidth=50) + + # Scrollbars + vsb = ttk.Scrollbar(tree_frame, orient=tk.VERTICAL, command=tree.yview) + hsb = ttk.Scrollbar(tree_frame, orient=tk.HORIZONTAL, command=tree.xview) + tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set) + + tree.grid(row=0, column=0, sticky='nsew') + vsb.grid(row=0, column=1, sticky='ns') + hsb.grid(row=1, column=0, sticky='ew') + + tree_frame.grid_rowconfigure(0, weight=1) + tree_frame.grid_columnconfigure(0, weight=1) + + # Status bar + status_var = tk.StringVar(value="Click 'Search' to load data") + status_label = ttk.Label(dialog, textvariable=status_var, foreground='gray') + status_label.pack(fill=tk.X, padx=10, pady=5) + + # Button frame + btn_frame = ttk.Frame(dialog) + btn_frame.pack(fill=tk.X, padx=10, pady=10) + + def get_nested_value(obj, key_path): + """Get nested value from dictionary using dot notation.""" + keys = key_path.split('.') + value = obj + for key in keys: + if isinstance(value, dict): + value = value.get(key) + else: + return None + return value + + def format_value(value, col_key): + """Format value for display.""" + if value is None: + return '-' + elif isinstance(value, bool): + return 'Yes' if value else 'No' + elif 'Balance' in col_key or 'Price' in col_key: + try: + return f"${float(value):,.2f}" + except: + return str(value) + else: + return str(value) + + def search_entities(): + """Search for entities.""" + # Clear existing + for item in tree.get_children(): + tree.delete(item) + + search_term = search_var.get().strip() + type_filter = type_var.get() if type_var.get() != "All Types" else "" + + status_var.set("Searching...") + dialog.update() + + try: + entities = [] + + if self.connection_type == 'qbo' and self.qbo_client: + # QuickBooks Online query + entities = self._fetch_qbo_entities( + config['query_name'], + config['search_field'], + search_term, + type_filter if entity_type == 'account' else None + ) + elif self.connection_type == 'qbd' and self.is_connected: + # QuickBooks Desktop query + entities = self._fetch_qbd_entities( + entity_type, + search_term, + type_filter if entity_type == 'account' else None + ) + + # Populate tree + for entity in entities: + values = [] + for col_key, _, _ in config['columns']: + value = get_nested_value(entity, col_key) + values.append(format_value(value, col_key)) + tree.insert('', tk.END, values=values) + + status_var.set(f"Found {len(entities)} record(s)") + logger.info(f"[LOOKUP] {config['display_name']}: Found {len(entities)} records") + + except Exception as e: + status_var.set(f"Error: {str(e)}") + logger.error(f"Lookup error: {e}") + messagebox.showerror("Error", f"Failed to fetch data: {str(e)}") + + def export_csv(): + """Export results to CSV.""" + items = tree.get_children() + if not items: + messagebox.showwarning("No Data", "No data to export.") + return + + from tkinter import filedialog + filepath = filedialog.asksaveasfilename( + defaultextension=".csv", + filetypes=[("CSV files", "*.csv"), ("All files", "*.*")], + initialfile=f"{config['query_name'].lower()}_export.csv" + ) + + if not filepath: + return + + import csv + with open(filepath, 'w', newline='', encoding='utf-8') as f: + writer = csv.writer(f) + # Header + writer.writerow([col[1] for col in config['columns']]) + # Data + for item in items: + writer.writerow(tree.item(item)['values']) + + messagebox.showinfo("Export Complete", f"Exported {len(items)} records to:\n{filepath}") + logger.info(f"[EXPORT] Exported {len(items)} {config['display_name']} to {filepath}") + + def copy_selected(): + """Copy selected row to clipboard.""" + selection = tree.selection() + if not selection: + messagebox.showinfo("No Selection", "Please select a row to copy.") + return + + values = tree.item(selection[0])['values'] + # Format as ID: Name + text = f"{values[0]}: {values[1]}" + + dialog.clipboard_clear() + dialog.clipboard_append(text) + status_var.set(f"Copied to clipboard: {text}") + + # Buttons + ttk.Button(btn_frame, text="Search", command=search_entities, width=12).pack(side=tk.LEFT) + ttk.Button(btn_frame, text="Export CSV", command=export_csv, width=12).pack(side=tk.LEFT, padx=5) + ttk.Button(btn_frame, text="Copy Selected", command=copy_selected, width=14).pack(side=tk.LEFT, padx=5) + ttk.Button(btn_frame, text="Close", command=dialog.destroy, width=12).pack(side=tk.RIGHT) + + # Bind Enter key to search + search_entry.bind('', lambda e: search_entities()) + + # Double-click to copy + tree.bind('', lambda e: copy_selected()) + + # Load data immediately + dialog.after(100, search_entities) + + def _fetch_qbo_entities(self, query_name: str, search_field: str, search_term: str = None, type_filter: str = None) -> List[Dict]: + """Fetch entities from QuickBooks Online.""" + query = f"SELECT * FROM {query_name}" + conditions = [] + + if search_term: + conditions.append(f"{search_field} LIKE '%{search_term}%'") + + if type_filter: + conditions.append(f"AccountType = '{type_filter}'") + + if conditions: + query += " WHERE " + " AND ".join(conditions) + + query += f" ORDERBY {search_field} MAXRESULTS 1000" + + response = self.qbo_client._make_request('GET', 'query', params={'query': query}) + + if response and 'QueryResponse' in response: + return response['QueryResponse'].get(query_name, []) + + return [] + + def _fetch_qbd_entities(self, entity_type: str, search_term: str = None, type_filter: str = None) -> List[Dict]: + """Fetch entities from QuickBooks Desktop using subprocess for COM compatibility.""" + if not HAS_QBD: + raise Exception("QuickBooks Desktop integration not available") + + if self.connection_type != 'qbd' or not self.is_connected: + raise Exception("Not connected to QuickBooks Desktop") + + # Map entity types to QBD query request names + query_map = { + 'account': 'AccountQueryRq', + 'customer': 'CustomerQueryRq', + 'vendor': 'VendorQueryRq', + 'item': 'ItemQueryRq', + 'employee': 'EmployeeQueryRq', + 'class': 'ClassQueryRq', + 'paymentmethod': 'PaymentMethodQueryRq', + 'term': 'TermsQueryRq', + } + + if entity_type not in query_map: + raise Exception(f"Unsupported entity type: {entity_type}") + + query_rq = query_map[entity_type] + + # Build the subprocess script - use regular string to avoid f-string issues + query_script = ''' +import sys +import json +import xml.etree.ElementTree as ET + +QUERY_RQ = "''' + query_rq + '''" + +try: + import pythoncom + from win32com.client import Dispatch + + pythoncom.CoInitialize() + + rp = Dispatch("QBXMLRP2.RequestProcessor") + rp.OpenConnection2("", "QBO Excel Sync Lookup", 1) + ticket = rp.BeginSession("", 0) + + # Build query XML + xml_request = """ + + + + <""" + QUERY_RQ + """> + 1000 + All + + +""" + + response = rp.ProcessRequest(ticket, xml_request) + + rp.EndSession(ticket) + rp.CloseConnection() + pythoncom.CoUninitialize() + + # Parse XML response + root = ET.fromstring(response) + + results = [] + + # Find all return elements + for elem in root.iter(): + # Check if this is a return element with ListID + if "Ret" in elem.tag: + list_id = elem.find("ListID") + if list_id is not None: + entity = {"Id": list_id.text} + + # Extract common fields + for field in ["Name", "FullName", "IsActive", "AccountType", "AccountSubType", + "CompanyName", "FirstName", "LastName", "Balance", "TotalBalance", + "Description", "SalesDesc", "PaymentMethodType", "DueDays", "StdDueDays"]: + el = elem.find(field) + if el is not None and el.text: + entity[field] = el.text + + # Handle nested phone + for phone_path in [".//Phone", ".//PrimaryPhone", ".//MainPhone"]: + phone_elem = elem.find(phone_path) + if phone_elem is not None: + phone_text = phone_elem.text or phone_elem.find("FreeFormNumber") + if phone_text is not None: + if hasattr(phone_text, "text"): + entity["Phone"] = phone_text.text + else: + entity["Phone"] = str(phone_text) + break + + # Handle email + email = elem.find(".//Email") + if email is not None and email.text: + entity["Email"] = email.text + + # Price fields for items + for price_path in [".//SalesOrPurchase/Price", ".//SalesAndPurchase/SalesPrice", + ".//SalesPrice", ".//Price"]: + price = elem.find(price_path) + if price is not None and price.text: + entity["UnitPrice"] = price.text + break + + results.append(entity) + + print(json.dumps({"success": True, "data": results})) + +except Exception as e: + import traceback + print(json.dumps({"success": False, "error": str(e), "traceback": traceback.format_exc()})) + sys.exit(1) +''' + + try: + result = subprocess.run( + [sys.executable, '-c', query_script], + capture_output=True, + text=True, + timeout=120 + ) + + # Check for output + stdout = result.stdout.strip() if result.stdout else '' + stderr = result.stderr.strip() if result.stderr else '' + + if not stdout: + error_detail = stderr if stderr else 'No output from QuickBooks query' + raise Exception(f"Query failed: {error_detail}") + + try: + output = json.loads(stdout) + except json.JSONDecodeError: + raise Exception(f"Invalid response from QuickBooks: {stdout[:500]}") + + if output.get('success'): + raw_entities = output.get('data', []) + else: + error_msg = output.get('error', 'Unknown error') + tb = output.get('traceback', '') + logger.error(f"QBD query error: {error_msg}\n{tb}") + raise Exception(error_msg) + + def clean_qbd_id(list_id: str) -> str: + """Clean up QBD ListID for display. + QBD ListIDs are like '80000026-1335494949'. + Extract just the hex part and convert to a cleaner number.""" + if not list_id: + return '' + # If it contains a dash, take just the first part (hex ID) + if '-' in list_id: + hex_part = list_id.split('-')[0] + # Convert hex to decimal for cleaner display + try: + # Remove leading '8' which is a prefix, then convert + if hex_part.startswith('8'): + return str(int(hex_part[1:], 16)) + return str(int(hex_part, 16)) + except: + return hex_part + return list_id + + # Transform to standard format + entities = [] + for e in raw_entities: + try: + raw_id = e.get('Id', '') + clean_id = clean_qbd_id(raw_id) + + if entity_type == 'account': + balance = e.get('Balance') or e.get('TotalBalance') or '0' + entity = { + 'Id': clean_id, + 'Name': e.get('Name') or e.get('FullName', ''), + 'AccountType': e.get('AccountType', ''), + 'AccountSubType': e.get('AccountSubType', ''), + 'CurrentBalance': float(balance) if balance else 0, + 'Active': str(e.get('IsActive', 'true')).lower() == 'true', + } + elif entity_type in ['customer', 'vendor']: + balance = e.get('Balance') or e.get('TotalBalance') or '0' + entity = { + 'Id': clean_id, + 'DisplayName': e.get('Name') or e.get('FullName', ''), + 'CompanyName': e.get('CompanyName', ''), + 'PrimaryEmailAddr': {'Address': e.get('Email', '')}, + 'PrimaryPhone': {'FreeFormNumber': e.get('Phone', '')}, + 'Balance': float(balance) if balance else 0, + 'Active': str(e.get('IsActive', 'true')).lower() == 'true', + } + elif entity_type == 'item': + price = e.get('UnitPrice') or e.get('SalesPrice') or '0' + entity = { + 'Id': clean_id, + 'Name': e.get('Name') or e.get('FullName', ''), + 'Type': 'Service', + 'Description': e.get('Description') or e.get('SalesDesc', ''), + 'UnitPrice': float(price) if price else 0, + 'Active': str(e.get('IsActive', 'true')).lower() == 'true', + } + elif entity_type == 'employee': + entity = { + 'Id': clean_id, + 'DisplayName': e.get('Name') or e.get('FullName', ''), + 'GivenName': e.get('FirstName', ''), + 'FamilyName': e.get('LastName', ''), + 'Active': str(e.get('IsActive', 'true')).lower() == 'true', + } + elif entity_type == 'class': + entity = { + 'Id': clean_id, + 'Name': e.get('Name', ''), + 'FullyQualifiedName': e.get('FullName', ''), + 'Active': str(e.get('IsActive', 'true')).lower() == 'true', + } + elif entity_type == 'paymentmethod': + entity = { + 'Id': clean_id, + 'Name': e.get('Name', ''), + 'Type': e.get('PaymentMethodType', ''), + 'Active': str(e.get('IsActive', 'true')).lower() == 'true', + } + elif entity_type == 'term': + entity = { + 'Id': clean_id, + 'Name': e.get('Name', ''), + 'DueDays': e.get('DueDays') or e.get('StdDueDays', ''), + 'Active': str(e.get('IsActive', 'true')).lower() == 'true', + } + else: + entity = { + 'Id': clean_id, + 'Name': e.get('Name') or e.get('FullName', ''), + 'Active': str(e.get('IsActive', 'true')).lower() == 'true', + } + + entities.append(entity) + except Exception as transform_err: + logger.warning(f"Error transforming entity: {transform_err}") + continue + + # Apply search filter + if search_term: + search_lower = search_term.lower() + entities = [ent for ent in entities + if search_lower in str(ent.get('Name', '')).lower() + or search_lower in str(ent.get('DisplayName', '')).lower() + or search_lower in str(ent.get('CompanyName', '')).lower()] + + # Apply type filter for accounts + if type_filter and entity_type == 'account': + entities = [ent for ent in entities if ent.get('AccountType') == type_filter] + + logger.info(f"[QBD LOOKUP] Fetched {len(entities)} {entity_type}(s)") + return entities + + except subprocess.TimeoutExpired: + raise Exception("Query timed out. QuickBooks Desktop may not be responding.") + except Exception as e: + logger.error(f"QBD lookup error: {e}") + raise + def _show_settings(self): """Show settings dialog.""" dialog = tk.Toplevel(self.root) diff --git a/src/config/settings.py b/src/config/settings.py index 078ad5d..f39e342 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -60,6 +60,8 @@ class ImportSettings: date_format: str = "%Y-%m-%d" decimal_separator: str = "." thousand_separator: str = "," + skip_empty_rows: bool = True + stop_on_error: bool = False class Settings: @@ -87,8 +89,36 @@ class Settings: def _get_config_dir(self) -> Path: """Get platform-specific config directory.""" - # For web app, use a local directory - base = Path(__file__).parent.parent.parent + import sys + + # Use user's AppData folder for persistent storage + # This works correctly for both normal Python and PyInstaller executables + if sys.platform == 'win32': + # Windows: Use AppData/Local + app_data = os.environ.get('LOCALAPPDATA') + if app_data: + return Path(app_data) / self.APP_NAME + # Fallback to APPDATA if LOCALAPPDATA not available + app_data = os.environ.get('APPDATA') + if app_data: + return Path(app_data) / self.APP_NAME + elif sys.platform == 'darwin': + # macOS: Use ~/Library/Application Support + home = Path.home() + return home / "Library" / "Application Support" / self.APP_NAME + else: + # Linux/Unix: Use ~/.config + home = Path.home() + return home / ".config" / self.APP_NAME.lower() + + # Final fallback: Use a 'data' folder relative to executable/script + if getattr(sys, 'frozen', False): + # Running as PyInstaller executable + base = Path(sys.executable).parent + else: + # Running as script + base = Path(__file__).parent.parent.parent + return base / "data" def _load_config(self) -> Dict[str, Any]: @@ -136,6 +166,21 @@ class Settings: self.save() logger.info("Import settings updated") + def get_import_settings(self) -> ImportSettings: + """Get import settings (method version for compatibility).""" + try: + settings_data = self._config.get("import_settings", {}) + return ImportSettings(**settings_data) + except Exception as e: + logger.warning(f"Error loading import settings: {e}") + return ImportSettings() + + def save_import_settings(self, settings: ImportSettings): + """Save import settings (method version for compatibility).""" + self._config["import_settings"] = asdict(settings) + self.save() + logger.info("Import settings saved") + @property def recent_files(self) -> List[str]: """Get list of recently opened files.""" @@ -186,7 +231,11 @@ class Settings: try: with open(self.credentials_file, 'w') as f: json.dump(asdict(credentials), f) - os.chmod(self.credentials_file, 0o600) # Restrict permissions + # Restrict file permissions (Unix only, skip on Windows) + try: + os.chmod(self.credentials_file, 0o600) + except (OSError, AttributeError): + pass # chmod not supported on Windows logger.info("Credentials saved successfully") except Exception as e: logger.error(f"Failed to save credentials: {e}") @@ -348,4 +397,4 @@ class Settings: FieldMapping("CurrentBalance", "CurrentBalance", transform="currency"), ] ), - } + } \ No newline at end of file