From a29135304ddfb35de20e5fa2a910732211df073a Mon Sep 17 00:00:00 2001 From: NguyenND Date: Wed, 18 Feb 2026 12:42:36 -0500 Subject: [PATCH] Desktop app update --- README.md | 4 + desktop_app.py | 663 +++++++++++++++++++++++++++++++++++-------------- 2 files changed, 480 insertions(+), 187 deletions(-) diff --git a/README.md b/README.md index abe183e..a2ea9e1 100644 --- a/README.md +++ b/README.md @@ -242,3 +242,7 @@ User actions (create, edit, delete) are automatically logged with: ## License Proprietary - Internal Use Only + +## Build Windows executable app command + +pyinstaller .\build_exe.spec --clean --noconfirm \ No newline at end of file diff --git a/desktop_app.py b/desktop_app.py index d4561c9..2a890de 100644 --- a/desktop_app.py +++ b/desktop_app.py @@ -206,9 +206,32 @@ class QBOExcelSyncApp: def __init__(self): self.root = tk.Tk() self.root.title(APP_NAME) - self.root.geometry(f"{WINDOW_WIDTH}x{WINDOW_HEIGHT}") + + # Detect screen size and set appropriate window size + screen_width = self.root.winfo_screenwidth() + screen_height = self.root.winfo_screenheight() + + # Calculate window size based on screen (80% of screen, but within limits) + window_width = min(max(int(screen_width * 0.8), MIN_WIDTH), WINDOW_WIDTH) + window_height = min(max(int(screen_height * 0.8), MIN_HEIGHT), WINDOW_HEIGHT) + + # For smaller screens (like 1920x1080), use more of the screen + if screen_width <= 1920: + window_width = min(int(screen_width * 0.9), screen_width - 50) + window_height = min(int(screen_height * 0.85), screen_height - 100) + + # Center the window + x = (screen_width - window_width) // 2 + y = (screen_height - window_height) // 2 + + self.root.geometry(f"{window_width}x{window_height}+{x}+{y}") self.root.minsize(MIN_WIDTH, MIN_HEIGHT) + # Store screen info for responsive layouts + self.screen_width = screen_width + self.screen_height = screen_height + self.is_small_screen = screen_width <= 1920 or screen_height <= 1080 + # Maximize window on startup (works on Windows) try: self.root.state('zoomed') # Windows @@ -247,7 +270,7 @@ class QBOExcelSyncApp: # Bind window close event self.root.protocol("WM_DELETE_WINDOW", self._on_close) - logger.info("Application started") + logger.info(f"Application started - Screen: {screen_width}x{screen_height}, Window: {window_width}x{window_height}") def _setup_styles(self): """Configure ttk styles.""" @@ -344,89 +367,68 @@ class QBOExcelSyncApp: def _create_dashboard_tab(self): """Create the dashboard tab.""" - tab = ttk.Frame(self.notebook, padding=20) + tab = ttk.Frame(self.notebook, padding=15) self.notebook.add(tab, text=" Dashboard ") - # Welcome header + # Header with title and connection status header_frame = ttk.Frame(tab) - header_frame.pack(fill=tk.X, pady=(0, 20)) + header_frame.pack(fill=tk.X, pady=(0, 10)) + + # Left side - title + title_frame = ttk.Frame(header_frame) + title_frame.pack(side=tk.LEFT) ttk.Label( - header_frame, + title_frame, text="Welcome to QBO Excel Sync", style='Title.TLabel' ).pack(anchor=tk.W) ttk.Label( - header_frame, + title_frame, text="Import your Excel data to QuickBooks with ease.", - style='Subtitle.TLabel', foreground='gray' - ).pack(anchor=tk.W, pady=(5, 0)) + ).pack(anchor=tk.W) - # Stats cards frame - stats_frame = ttk.Frame(tab) - stats_frame.pack(fill=tk.X, pady=10) + # Right side - connection status (like Lookup tab) + self.dashboard_conn_label = ttk.Label(header_frame, text="Not connected", foreground='gray') + self.dashboard_conn_label.pack(side=tk.RIGHT, padx=10) - # Connection status card - self.conn_card = self._create_stat_card( - stats_frame, - "Connection Status", - "Not Connected", - "Connect to QuickBooks to start" - ) - self.conn_card.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 10)) - - # Supported types card - types_card = self._create_stat_card( - stats_frame, - "Supported Types", - "6 Types", - "Checks, Invoices, Bills, & more" - ) - types_card.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(10, 0)) - - # Quick actions - actions_frame = ttk.LabelFrame(tab, text="Quick Actions", padding=15) - actions_frame.pack(fill=tk.X, pady=20) + # Quick actions - horizontal buttons + actions_frame = ttk.LabelFrame(tab, text="Quick Actions", padding=10) + actions_frame.pack(fill=tk.X, pady=(0, 10)) actions_inner = ttk.Frame(actions_frame) actions_inner.pack(fill=tk.X) - ttk.Button( - actions_inner, - text="🔗 Manage Connection", - command=lambda: self.notebook.select(1), # Connection tab (index 1) - width=25 - ).pack(side=tk.LEFT, padx=5) + btn_width = 18 ttk.Button( - actions_inner, - text="📁 Import Excel File", - command=lambda: self.notebook.select(2), # Import tab (index 2) - width=25 - ).pack(side=tk.LEFT, padx=5) + actions_inner, text="🔗 Connection", + command=lambda: self.notebook.select(1), width=btn_width + ).grid(row=0, column=0, padx=3, pady=3, sticky='ew') ttk.Button( - actions_inner, - text="📋 Manage Templates", - command=lambda: self.notebook.select(3), # Templates tab (index 3) - width=25 - ).pack(side=tk.LEFT, padx=5) + actions_inner, text="📁 Import File", + command=lambda: self.notebook.select(2), width=btn_width + ).grid(row=0, column=1, padx=3, pady=3, sticky='ew') ttk.Button( - actions_inner, - text="🔍 QuickBooks Lookup", - command=lambda: self.notebook.select(4), # Lookup tab (index 4) - width=25 - ).pack(side=tk.LEFT, padx=5) + actions_inner, text="📋 Templates", + command=lambda: self.notebook.select(3), width=btn_width + ).grid(row=0, column=2, padx=3, pady=3, sticky='ew') - # Supported data types - types_frame = ttk.LabelFrame(tab, text="Supported Data Types", padding=15) - types_frame.pack(fill=tk.BOTH, expand=True, pady=10) + ttk.Button( + actions_inner, text="🔍 Lookup", + command=lambda: self.notebook.select(4), width=btn_width + ).grid(row=0, column=3, padx=3, pady=3, sticky='ew') - types_grid = ttk.Frame(types_frame) - types_grid.pack(fill=tk.BOTH, expand=True) + for i in range(4): + actions_inner.columnconfigure(i, weight=1) + + # Supported data types - compact grid layout + types_frame = ttk.LabelFrame(tab, text="Supported Data Types", padding=10) + types_frame.pack(fill=tk.BOTH, expand=True) type_info = [ ("✓ Checks", "Import check payments with payee, amount, and account details."), @@ -437,16 +439,21 @@ class QBOExcelSyncApp: ("✓ Accounts", "Create accounts with types, numbers, and descriptions."), ] + # Always use 3 columns, compact layout + types_grid = ttk.Frame(types_frame) + types_grid.pack(fill=tk.BOTH, expand=True) + for i, (title, desc) in enumerate(type_info): row = i // 3 col = i % 3 - type_card = ttk.Frame(types_grid, padding=10) - type_card.grid(row=row, column=col, sticky='nsew', padx=5, pady=5) + type_card = ttk.Frame(types_grid, padding=5) + type_card.grid(row=row, column=col, sticky='nsew', padx=3, pady=3) ttk.Label(type_card, text=title, font=('Segoe UI', 10, 'bold')).pack(anchor=tk.W) - ttk.Label(type_card, text=desc, wraplength=200, foreground='gray').pack(anchor=tk.W) - + ttk.Label(type_card, text=desc, wraplength=220, foreground='gray', font=('Segoe UI', 9)).pack(anchor=tk.W) + + for col in range(3): types_grid.columnconfigure(col, weight=1) def _create_stat_card(self, parent, title: str, value: str, detail: str) -> ttk.Frame: @@ -467,34 +474,33 @@ class QBOExcelSyncApp: def _create_import_tab(self): """Create the import tab.""" - tab = ttk.Frame(self.notebook, padding=20) + tab = ttk.Frame(self.notebook, padding=15) self.notebook.add(tab, text=" Import ") - # Left panel - File selection and options - left_frame = ttk.Frame(tab) - left_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 10)) + # Top section - File selection and options + top_frame = ttk.Frame(tab) + top_frame.pack(fill=tk.X, pady=(0, 10)) # File selection - file_frame = ttk.LabelFrame(left_frame, text="1. Select Excel File", padding=15) - file_frame.pack(fill=tk.X, pady=(0, 10)) + file_frame = ttk.LabelFrame(top_frame, text="1. Select Excel File", padding=10) + file_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 5)) file_inner = ttk.Frame(file_frame) file_inner.pack(fill=tk.X) - self.file_entry = ttk.Entry(file_inner, width=50) - self.file_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 10)) + self.file_entry = ttk.Entry(file_inner, width=40) + self.file_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 5)) ttk.Button(file_inner, text="Browse...", command=self._open_file).pack(side=tk.LEFT) - ttk.Button(file_inner, text="Refresh", command=self._refresh_file).pack(side=tk.LEFT, padx=(5, 0)) + ttk.Button(file_inner, text="↻", command=self._refresh_file, width=3).pack(side=tk.LEFT, padx=(3, 0)) self.file_info_label = ttk.Label(file_frame, text="No file selected", foreground='gray') - self.file_info_label.pack(anchor=tk.W, pady=(10, 0)) + self.file_info_label.pack(anchor=tk.W, pady=(5, 0)) - # Data type and template selection - type_frame = ttk.LabelFrame(left_frame, text="2. Select Data Type & Template", padding=15) - type_frame.pack(fill=tk.X, pady=10) + # Data type selection + type_frame = ttk.LabelFrame(top_frame, text="2. Data Type", padding=10) + type_frame.pack(side=tk.LEFT, fill=tk.BOTH, padx=(5, 5)) - # Data type radio buttons self.data_type_var = tk.StringVar(value="check") type_grid = ttk.Frame(type_frame) @@ -508,11 +514,15 @@ class QBOExcelSyncApp: value=value, command=self._on_data_type_change ) - rb.grid(row=i // 3, column=i % 3, sticky=tk.W, padx=10, pady=5) + rb.grid(row=i // 2, column=i % 2, sticky=tk.W, padx=5, pady=2) - # Template selection - template_row = ttk.Frame(type_frame) - template_row.pack(fill=tk.X, pady=(10, 0)) + # Template and mapping + mapping_frame = ttk.LabelFrame(top_frame, text="3. Template & Mapping", padding=10) + mapping_frame.pack(side=tk.LEFT, fill=tk.BOTH, padx=(5, 0)) + + # Template selection row + template_row = ttk.Frame(mapping_frame) + template_row.pack(fill=tk.X) ttk.Label(template_row, text="Template:").pack(side=tk.LEFT) @@ -521,66 +531,32 @@ class QBOExcelSyncApp: template_row, textvariable=self.template_var, values=["(Default Template)"], - width=30, + width=20, state='readonly' ) - self.template_combo.pack(side=tk.LEFT, padx=(10, 0)) + self.template_combo.pack(side=tk.LEFT, padx=(5, 0)) self.template_combo.current(0) self.template_combo.bind('<>', self._on_template_change) - ttk.Button(template_row, text="↻", command=self._refresh_template_list, width=3).pack(side=tk.LEFT, padx=(5, 0)) + ttk.Button(template_row, text="↻", command=self._refresh_template_list, width=3).pack(side=tk.LEFT, padx=(3, 0)) - # Field mapping - mapping_frame = ttk.LabelFrame(left_frame, text="3. Field Mapping", padding=15) - mapping_frame.pack(fill=tk.BOTH, expand=True, pady=10) + # Mapping buttons + mapping_btns = ttk.Frame(mapping_frame) + mapping_btns.pack(fill=tk.X, pady=(8, 0)) - # Mapping treeview - mapping_inner = ttk.Frame(mapping_frame) - mapping_inner.pack(fill=tk.BOTH, expand=True) + ttk.Button(mapping_btns, text="📝 Edit Mapping...", command=self._open_mapping_dialog).pack(side=tk.LEFT, padx=(0, 3)) + ttk.Button(mapping_btns, text="Auto-Map", command=self._auto_map_fields).pack(side=tk.LEFT, padx=3) + ttk.Button(mapping_btns, text="Save Template", command=self._save_template).pack(side=tk.LEFT, padx=3) - columns = ('excel_column', 'qbo_field') - self.mapping_tree = ttk.Treeview(mapping_inner, columns=columns, show='headings', height=10) - self.mapping_tree.heading('excel_column', text='Excel Column') - self.mapping_tree.heading('qbo_field', text='QuickBooks Field') - self.mapping_tree.column('excel_column', width=200) - self.mapping_tree.column('qbo_field', width=200) - - mapping_scroll = ttk.Scrollbar(mapping_inner, orient=tk.VERTICAL, command=self.mapping_tree.yview) - self.mapping_tree.configure(yscrollcommand=mapping_scroll.set) - - self.mapping_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - mapping_scroll.pack(side=tk.RIGHT, fill=tk.Y) - - mapping_buttons = ttk.Frame(mapping_frame) - mapping_buttons.pack(fill=tk.X, pady=(10, 0)) - - ttk.Button(mapping_buttons, text="Auto-Map", command=self._auto_map_fields).pack(side=tk.LEFT) - ttk.Button(mapping_buttons, text="Edit Mapping...", command=self._edit_mapping).pack(side=tk.LEFT, padx=5) - ttk.Button(mapping_buttons, text="Save as Template", command=self._save_template).pack(side=tk.LEFT) - - # Right panel - Preview and import - right_frame = ttk.Frame(tab) - right_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True, padx=(10, 0)) - - # Data preview - preview_frame = ttk.LabelFrame(right_frame, text="Data Preview", padding=15) - preview_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) - - self.preview_tree = ttk.Treeview(preview_frame, show='headings', height=10) - preview_scroll_y = ttk.Scrollbar(preview_frame, orient=tk.VERTICAL, command=self.preview_tree.yview) - preview_scroll_x = ttk.Scrollbar(preview_frame, orient=tk.HORIZONTAL, command=self.preview_tree.xview) - self.preview_tree.configure(yscrollcommand=preview_scroll_y.set, xscrollcommand=preview_scroll_x.set) - - self.preview_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) - preview_scroll_y.pack(side=tk.RIGHT, fill=tk.Y) - - # Progress and import - import_frame = ttk.LabelFrame(right_frame, text="Import", padding=15) + # Middle section - Import controls and progress + import_frame = ttk.LabelFrame(tab, text="Import", padding=10) import_frame.pack(fill=tk.X, pady=10) + # Progress bar self.progress_frame = ProgressFrame(import_frame) - self.progress_frame.pack(fill=tk.X, pady=(0, 10)) + self.progress_frame.pack(fill=tk.X, pady=(0, 8)) + # Import buttons import_buttons = ttk.Frame(import_frame) import_buttons.pack(fill=tk.X) @@ -588,7 +564,7 @@ class QBOExcelSyncApp: import_buttons, text="▶ Start Import", command=self._start_import, - width=20 + width=18 ) self.import_btn.pack(side=tk.LEFT) @@ -601,12 +577,26 @@ class QBOExcelSyncApp: ) self.stop_btn.pack(side=tk.LEFT, padx=10) - # Log viewer - log_frame = ttk.LabelFrame(right_frame, text="Import Log", padding=10) + ttk.Button( + import_buttons, + text="Preview Data", + command=self._preview_data, + width=12 + ).pack(side=tk.LEFT) + + # Mapping status label + self.mapping_status_label = ttk.Label(import_buttons, text="No mappings configured", foreground='gray') + self.mapping_status_label.pack(side=tk.RIGHT, padx=10) + + # Bottom section - Import log (takes most space) + log_frame = ttk.LabelFrame(tab, text="Import Log", padding=10) log_frame.pack(fill=tk.BOTH, expand=True) self.log_viewer = LogViewer(log_frame) self.log_viewer.pack(fill=tk.BOTH, expand=True) + + # Hidden preview tree for data preview dialog + self.preview_tree = None def _create_connection_tab(self): """Create the connection tab.""" @@ -777,15 +767,14 @@ class QBOExcelSyncApp: def _create_templates_tab(self): """Create the templates management tab.""" - tab = ttk.Frame(self.notebook, padding=20) + tab = ttk.Frame(self.notebook, padding=15) self.notebook.add(tab, text=" Templates ") # Header header = ttk.Frame(tab) - header.pack(fill=tk.X, pady=(0, 20)) + header.pack(fill=tk.X, pady=(0, 10)) ttk.Label(header, text="Field Mapping Templates", style='Title.TLabel').pack(side=tk.LEFT) - ttk.Button(header, text="+ New Template", command=self._create_template).pack(side=tk.RIGHT) # Templates list list_frame = ttk.Frame(tab) @@ -804,15 +793,25 @@ class QBOExcelSyncApp: self.templates_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) templates_scroll.pack(side=tk.RIGHT, fill=tk.Y) - # Template actions + # Template actions - all buttons in one row actions = ttk.Frame(tab) - actions.pack(fill=tk.X, pady=(15, 0)) + actions.pack(fill=tk.X, pady=(10, 0)) - ttk.Button(actions, text="Edit", command=self._edit_template).pack(side=tk.LEFT) - ttk.Button(actions, text="Duplicate", command=self._duplicate_template).pack(side=tk.LEFT, padx=5) - ttk.Button(actions, text="Delete", command=self._delete_template).pack(side=tk.LEFT) + ttk.Button(actions, text="+ New", command=self._create_template).pack(side=tk.LEFT) + ttk.Button(actions, text="Edit", command=self._edit_template).pack(side=tk.LEFT, padx=5) + ttk.Button(actions, text="Duplicate", command=self._duplicate_template).pack(side=tk.LEFT) + ttk.Button(actions, text="Delete", command=self._delete_template).pack(side=tk.LEFT, padx=5) ttk.Button(actions, text="Refresh", command=self._refresh_templates).pack(side=tk.RIGHT) + # Import/Export actions + io_frame = ttk.Frame(tab) + io_frame.pack(fill=tk.X, pady=(10, 0)) + + ttk.Label(io_frame, text="Import/Export:", foreground='gray').pack(side=tk.LEFT) + ttk.Button(io_frame, text="Import Template", command=self._import_template).pack(side=tk.LEFT, padx=(10, 5)) + ttk.Button(io_frame, text="Export Selected", command=self._export_template).pack(side=tk.LEFT, padx=5) + ttk.Button(io_frame, text="Export All", command=self._export_all_templates).pack(side=tk.LEFT, padx=5) + # Load templates self._refresh_templates() @@ -1885,7 +1884,48 @@ except Exception as e: def _qbd_connect_error(self, error: str): """Handle QBD connection error.""" self.qbd_connect_btn.config(state=tk.NORMAL, text="Connect to QuickBooks Desktop") - messagebox.showerror("Connection Failed", f"Failed to connect: {error}") + + # Provide helpful error messages for common issues + error_lower = str(error).lower() + + if 'no module named' in error_lower and 'pythoncom' in error_lower: + helpful_msg = ( + "QuickBooks Desktop connection requires the 'pywin32' package.\n\n" + "Please open Command Prompt and run:\n" + " pip install pywin32\n\n" + "Then restart the application." + ) + messagebox.showerror("Missing Dependency", helpful_msg) + elif 'no module named' in error_lower and 'win32com' in error_lower: + helpful_msg = ( + "QuickBooks Desktop connection requires the 'pywin32' package.\n\n" + "Please open Command Prompt and run:\n" + " pip install pywin32\n\n" + "Then restart the application." + ) + messagebox.showerror("Missing Dependency", helpful_msg) + elif 'no module named' in error_lower and 'encodings' in error_lower: + helpful_msg = ( + "Python installation not found or corrupted.\n\n" + "Please install Python from https://www.python.org/downloads/\n" + "Make sure to check 'Add Python to PATH' during installation.\n\n" + "Then install pywin32:\n" + " pip install pywin32" + ) + messagebox.showerror("Python Not Found", helpful_msg) + elif 'could not find' in error_lower or 'not found' in error_lower: + helpful_msg = ( + "Could not connect to QuickBooks Desktop.\n\n" + "Please ensure:\n" + "1. QuickBooks Desktop is open\n" + "2. A company file is open\n" + "3. QuickBooks is not showing any dialogs\n\n" + f"Error: {error}" + ) + messagebox.showerror("Connection Failed", helpful_msg) + else: + messagebox.showerror("Connection Failed", f"Failed to connect: {error}") + logger.error(f"QBD connection failed: {error}") def _disconnect(self): @@ -1928,21 +1968,35 @@ except Exception as e: # Status bar self.status_bar.set_connected(self.company_name, self.connection_type) - # Dashboard card - self.conn_card.value_label.config(text="Connected") - self.conn_card.detail_label.config(text=self.company_name) + # Dashboard connection label (top right) + type_text = "Desktop" if self.connection_type == 'qbd' else "Online" + if hasattr(self, 'dashboard_conn_label'): + self.dashboard_conn_label.config( + text=f"● Connected: {self.company_name} ({type_text})", + foreground='green' + ) + + # Lookup tab connection label + if hasattr(self, 'lookup_conn_label'): + self.lookup_conn_label.config( + text=f"● Connected: {self.company_name} ({type_text})", + foreground='green' + ) # Connection tab - type_text = "Desktop" if self.connection_type == 'qbd' else "Online" self.conn_status_label.config(text=f"● Connected ({type_text})", foreground='green') self.conn_company_label.config(text=self.company_name) else: # Status bar self.status_bar.set_disconnected() - # Dashboard card - self.conn_card.value_label.config(text="Not Connected") - self.conn_card.detail_label.config(text="Connect to QuickBooks to start") + # Dashboard connection label (top right) + if hasattr(self, 'dashboard_conn_label'): + self.dashboard_conn_label.config(text="Not connected", foreground='gray') + + # Lookup tab connection label + if hasattr(self, 'lookup_conn_label'): + self.lookup_conn_label.config(text="Not connected", foreground='gray') # Connection tab self.conn_status_label.config(text="● Not Connected", foreground='red') @@ -2410,45 +2464,20 @@ except Exception as e: messagebox.showerror("Parse Error", f"Failed to parse file: {str(e)}") def _update_preview(self): - """Update the data preview tree.""" - # Clear existing - for item in self.preview_tree.get_children(): - self.preview_tree.delete(item) - + """Update the data preview (now just updates internal state, preview shown via dialog).""" + # Preview tree was removed from main UI - data is shown via _preview_data dialog + # Just ensure data is loaded if not hasattr(self, 'excel_data') or not self.excel_data: return - # Get columns - columns = self.excel_columns if hasattr(self, 'excel_columns') else [] - - if not columns and self.excel_data: - # Fallback: get columns from first row - columns = list(self.excel_data[0].keys()) - - # Configure columns - self.preview_tree['columns'] = columns - for col in columns: - self.preview_tree.heading(col, text=col) - self.preview_tree.column(col, width=100) - - # Add rows (limit to 100 for performance) - for row in self.excel_data[:100]: - values = [row.get(col, '') for col in columns] - self.preview_tree.insert('', tk.END, values=values) + # Update mapping status when data changes + self._update_mapping_status() def _update_mapping(self): - """Update the field mapping tree.""" - # Clear existing - for item in self.mapping_tree.get_children(): - self.mapping_tree.delete(item) - - if not hasattr(self, 'excel_columns') or not self.excel_columns: - return - - # Add to tree - for col in self.excel_columns: - qbo_field = self.field_mappings.get(col, '(unmapped)') - self.mapping_tree.insert('', tk.END, values=(col, qbo_field)) + """Update the field mapping status (tree was removed, now uses status label).""" + # Mapping tree was removed from main UI - mapping is done via dialog + # Just update the status label + self._update_mapping_status() def _on_data_type_change(self): """Handle data type selection change.""" @@ -2658,12 +2687,77 @@ except Exception as e: self.field_mappings[col] = qbo_field break - self._update_mapping() + self._update_mapping_status() mapped_count = len(self.field_mappings) total_count = len(excel_columns) messagebox.showinfo("Auto-Map Complete", f"Mapped {mapped_count} of {total_count} fields.") + def _open_mapping_dialog(self): + """Open the field mapping dialog - wrapper for _edit_mapping.""" + self._edit_mapping() + + def _update_mapping_status(self): + """Update the mapping status label in Import tab.""" + if hasattr(self, 'mapping_status_label'): + count = len(self.field_mappings) if self.field_mappings else 0 + if count > 0: + self.mapping_status_label.config( + text=f"✓ {count} field(s) mapped", + foreground='green' + ) + else: + self.mapping_status_label.config( + text="No mappings configured", + foreground='gray' + ) + + def _preview_data(self): + """Show data preview dialog.""" + if not self.excel_data: + messagebox.showwarning("No Data", "Please load a file first.") + return + + dialog = tk.Toplevel(self.root) + dialog.title("Data Preview") + dialog.geometry("900x500") + dialog.transient(self.root) + + # Info label + info_text = f"Showing {min(50, len(self.excel_data))} of {len(self.excel_data)} rows" + ttk.Label(dialog, text=info_text, font=('Segoe UI', 10)).pack(pady=10) + + # Treeview for preview + tree_frame = ttk.Frame(dialog) + tree_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=(0, 10)) + + columns = self.excel_columns if self.excel_columns else [] + preview_tree = ttk.Treeview(tree_frame, columns=columns, show='headings', height=20) + + for col in columns: + preview_tree.heading(col, text=col) + preview_tree.column(col, width=100, minwidth=50) + + # Scrollbars + vsb = ttk.Scrollbar(tree_frame, orient=tk.VERTICAL, command=preview_tree.yview) + hsb = ttk.Scrollbar(tree_frame, orient=tk.HORIZONTAL, command=preview_tree.xview) + preview_tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set) + + preview_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) + + # Load data (first 50 rows) + for row_data in self.excel_data[:50]: + values = [row_data.get(col, '') for col in columns] + preview_tree.insert('', tk.END, values=values) + + # Close button + ttk.Button(dialog, text="Close", command=dialog.destroy).pack(pady=10) + def _edit_mapping(self): """Open mapping editor dialog.""" if not hasattr(self, 'excel_columns') or not self.excel_columns: @@ -2718,7 +2812,7 @@ except Exception as e: def save_mappings(): self.field_mappings = {col: var.get() for col, var in mapping_vars.items() if var.get()} - self._update_mapping() + self._update_mapping_status() dialog.destroy() logger.info(f"[EDIT] Field mappings updated: {len(self.field_mappings)} fields mapped") @@ -3395,7 +3489,7 @@ except Exception as e: def _create_template(self): """Create a new template.""" messagebox.showinfo("Create Template", "Load a file and map fields first, then use 'Save as Template'.") - self.notebook.select(1) # Switch to Import tab + self.notebook.select(2) # Switch to Import tab (index 2) def _edit_template(self): """Edit selected template.""" @@ -3427,8 +3521,8 @@ except Exception as e: self.field_mappings[mapping.get('excel_column', '')] = mapping.get('qbo_field', '') self.data_type_var.set(template.data_type if hasattr(template, 'data_type') else 'check') - self._update_mapping() - self.notebook.select(1) # Switch to Import tab + self._update_mapping_status() + self.notebook.select(2) # Switch to Import tab (index 2) logger.info(f"[EDIT] Loaded template: {template_name}") def _duplicate_template(self): @@ -3480,6 +3574,201 @@ except Exception as e: if messagebox.askyesno("Confirm Delete", f"Delete template '{template_name}'?"): self.settings.delete_template(template_name) self._refresh_templates() + logger.info(f"[TEMPLATE] Deleted template: {template_name}") + + def _import_template(self): + """Import template(s) from a JSON file.""" + file_path = filedialog.askopenfilename( + title="Import Template", + filetypes=[ + ("JSON Files", "*.json"), + ("All Files", "*.*") + ] + ) + + if not file_path: + return + + try: + with open(file_path, 'r') as f: + data = json.load(f) + + # Handle both single template and multiple templates + if isinstance(data, list): + templates_data = data + elif isinstance(data, dict): + templates_data = [data] + else: + raise ValueError("Invalid template format") + + imported_count = 0 + skipped_count = 0 + + for template_data in templates_data: + # Validate required fields + if 'name' not in template_data or 'data_type' not in template_data: + skipped_count += 1 + continue + + template_name = template_data['name'] + + # Check if template already exists + existing_templates = self.settings.get_templates() + exists = any(t.name == template_name for t in existing_templates) + + if exists: + overwrite = messagebox.askyesno( + "Template Exists", + f"Template '{template_name}' already exists. Overwrite?" + ) + if not overwrite: + skipped_count += 1 + continue + + # Import the FieldMapping class + from src.config.settings import MappingTemplate, FieldMapping + + # Create MappingTemplate from data + mappings = [] + for m in template_data.get('mappings', []): + mapping = FieldMapping( + excel_column=m.get('excel_column', ''), + qbo_field=m.get('qbo_field', ''), + transform=m.get('transform'), + default_value=m.get('default_value'), + required=m.get('required', False) + ) + mappings.append(mapping) + + template = MappingTemplate( + name=template_data['name'], + data_type=template_data['data_type'], + mappings=mappings, + created_at=template_data.get('created_at', datetime.now().isoformat()), + updated_at=datetime.now().isoformat() + ) + + self.settings.save_template(template) + imported_count += 1 + logger.info(f"[TEMPLATE] Imported template: {template_name}") + + # Refresh the list + self._refresh_templates() + + # Show result + msg = f"Imported {imported_count} template(s)." + if skipped_count > 0: + msg += f"\nSkipped {skipped_count} template(s)." + messagebox.showinfo("Import Complete", msg) + + except json.JSONDecodeError as e: + messagebox.showerror("Import Error", f"Invalid JSON file: {str(e)}") + logger.error(f"[TEMPLATE] Import failed - invalid JSON: {e}") + except Exception as e: + messagebox.showerror("Import Error", f"Failed to import template: {str(e)}") + logger.error(f"[TEMPLATE] Import failed: {e}") + + def _export_template(self): + """Export the selected template to a JSON file.""" + selection = self.templates_tree.selection() + if not selection: + messagebox.showwarning("No Selection", "Please select a template to export.") + return + + item = self.templates_tree.item(selection[0]) + template_name = item['values'][0] + + # Find the template + templates = self.settings.get_templates() + template = None + for t in templates: + if t.name == template_name: + template = t + break + + if not template: + messagebox.showerror("Error", f"Template '{template_name}' not found.") + return + + # Prepare export data + from dataclasses import asdict + export_data = { + 'name': template.name, + 'data_type': template.data_type, + 'mappings': [asdict(m) for m in template.mappings], + 'created_at': template.created_at, + 'updated_at': template.updated_at + } + + # Get save location + safe_name = "".join(c for c in template_name if c.isalnum() or c in " -_").strip() + file_path = filedialog.asksaveasfilename( + title="Export Template", + defaultextension=".json", + filetypes=[("JSON Files", "*.json"), ("All Files", "*.*")], + initialfile=f"template_{safe_name}.json" + ) + + if not file_path: + return + + try: + with open(file_path, 'w') as f: + json.dump(export_data, f, indent=2) + + messagebox.showinfo("Export Complete", f"Template '{template_name}' exported successfully.") + logger.info(f"[TEMPLATE] Exported template: {template_name} to {file_path}") + + except Exception as e: + messagebox.showerror("Export Error", f"Failed to export template: {str(e)}") + logger.error(f"[TEMPLATE] Export failed: {e}") + + def _export_all_templates(self): + """Export all templates to a single JSON file.""" + templates = self.settings.get_templates() + + if not templates: + messagebox.showwarning("No Templates", "No templates to export.") + return + + # Prepare export data + from dataclasses import asdict + export_data = [] + + for template in templates: + template_dict = { + 'name': template.name, + 'data_type': template.data_type, + 'mappings': [asdict(m) for m in template.mappings], + 'created_at': template.created_at, + 'updated_at': template.updated_at + } + export_data.append(template_dict) + + # Get save location + file_path = filedialog.asksaveasfilename( + title="Export All Templates", + defaultextension=".json", + filetypes=[("JSON Files", "*.json"), ("All Files", "*.*")], + initialfile=f"templates_backup_{datetime.now().strftime('%Y%m%d')}.json" + ) + + if not file_path: + return + + try: + with open(file_path, 'w') as f: + json.dump(export_data, f, indent=2) + + messagebox.showinfo( + "Export Complete", + f"Exported {len(templates)} template(s) successfully." + ) + logger.info(f"[TEMPLATE] Exported {len(templates)} templates to {file_path}") + + except Exception as e: + messagebox.showerror("Export Error", f"Failed to export templates: {str(e)}") + logger.error(f"[TEMPLATE] Export all failed: {e}") # ========================================================================= # Other UI Methods