#!/usr/bin/env python3 import tkinter as tk from tkinter import ttk, messagebox, filedialog from datetime import time, datetime, date import csv import os import json import calendar import re from collections import defaultdict def sanitize_csv_text(text): """Sanitize text for safe CSV writing""" if not text: return "" text = str(text) # Remove dangerous characters that could cause CSV injection dangerous_chars = ["=", "+", "-", "@", "\t", "\r", "\n"] for char in dangerous_chars: text = text.replace(char, "") # Remove Excel formula triggers text = re.sub(r"^[+\-=@]", "", text) # Truncate to reasonable length text = text[:500] # Strip whitespace return text.strip() def sanitize_date_text(date_text): """Sanitize date text while preserving YYYY-MM-DD format""" if not date_text: return "" text = str(date_text).strip() # Remove dangerous characters except hyphens (needed for date format) dangerous_chars = ["=", "+", "@", "\t", "\r", "\n"] for char in dangerous_chars: text = text.replace(char, "") # Remove Excel formula triggers (except hyphens for date format) text = re.sub(r"^[+\-=@]", "", text) # Validate and fix date format if possible # Remove extra hyphens but keep the YYYY-MM-DD structure if text and "-" in text: parts = text.split("-") # Only keep first 3 parts (year, month, day) parts = parts[:3] # Ensure each part contains only digits clean_parts = [] for part in parts: clean_part = re.sub(r"[^0-9]", "", part) if clean_part: # Only add if not empty clean_parts.append(clean_part) # Rebuild date if we have all parts if len(clean_parts) == 3: try: # Validate the date year = int(clean_parts[0]) month = int(clean_parts[1]) day = int(clean_parts[2]) # Basic validation if 2000 <= year <= 2100 and 1 <= month <= 12 and 1 <= day <= 31: text = f"{year:04d}-{month:02d}-{day:02d}" else: # Fallback to today if invalid from datetime import date text = date.today().strftime("%Y-%m-%d") except (ValueError, TypeError): # Fallback to today if anything goes wrong from datetime import date text = date.today().strftime("%Y-%m-%d") else: # Fallback to today if format is broken from datetime import date text = date.today().strftime("%Y-%m-%d") return text.strip() def sanitize_filename(filename): """Sanitize filename for safe file operations""" if not filename: return "default.csv" text = str(filename) # Remove path separators and dangerous characters text = re.sub(r'[<>:"/\\|?*]', "", text) text = re.sub(r"\.\.", "", text) # Remove directory traversal # Remove leading/trailing dots and spaces text = text.strip(". ") # Ensure filename is not empty if not text or text.startswith("."): return "default.csv" # Ensure .csv extension if not text.lower().endswith(".csv"): text += ".csv" return text def sanitize_config_text(text, max_length=100): """Sanitize text for configuration files""" if not text: return "" text = str(text) # Remove characters that could break JSON/config files text = re.sub(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]", "", text) text = re.sub(r'[{}[\]"]', "", text) # Escape forward slashes and backslashes text = text.replace("\\", "\\\\").replace("/", "\\/") # Truncate to reasonable length text = text[:max_length] return text.strip() def validate_input(input_type, value, **kwargs): """Validate and sanitize user input""" if value is None: value = "" if input_type == "task_name": return sanitize_csv_text(value) elif input_type == "notes": return sanitize_csv_text(value) elif input_type == "invoice_number": # Invoice numbers - allow alphanumeric, hyphens, underscores value = str(value) value = re.sub(r"[^\w\-]", "", value) return value.strip()[:50] or "INV001" elif input_type == "customer_name": return sanitize_config_text(value) elif input_type == "job_name": return sanitize_config_text(value) elif input_type == "file_path": return sanitize_filename(value) else: # Default sanitization return sanitize_csv_text(value) class ClickableCell(tk.Frame): def __init__( self, parent, row_col_key, callback, time_tracker, width=5, height=2, start_hour=9, ): super().__init__( parent, relief="solid", borderwidth=1, width=width, height=height ) self.row_col_key = row_col_key self.callback = callback self.time_tracker = time_tracker self.checked = False self.start_hour = start_hour # Calculate which hour this cell represents based on column index col_idx = row_col_key[1] hour_offset = col_idx // 4 # 4 cells per hour (15-minute intervals) current_hour = start_hour + hour_offset # Determine background color based on hour (alternating pattern) if current_hour % 2 == 0: self.default_bg = "#e8e8e8" # Medium gray for even hours else: self.default_bg = "#f5f5f5" # Light gray for odd hours # Create label with hour-based background self.label = tk.Label( self, text=" ", bg=self.default_bg, width=width, height=height ) self.label.pack(fill="both", expand=True) # Bind only click events for now self.label.bind("", self.on_mouse_down) self.bind("", self.on_mouse_down) def on_mouse_down(self, event): # Start drag mode self.time_tracker.drag_info["active"] = True self.time_tracker.drag_info["mode"] = "paint" if not self.checked else "erase" self.time_tracker.drag_info["start_row"] = self.row_col_key[0] # Toggle this cell self.checked = not self.checked if self.checked: self.label.config(bg="lightblue", text="✓") else: self.label.config(bg=self.default_bg, text=" ") self.callback(self.row_col_key, self.checked) self.time_tracker.drag_info["last_cell"] = self.row_col_key def apply_drag_state(self, force_mode=None): mode = force_mode or self.time_tracker.drag_info["mode"] if mode == "paint" and not self.checked: self.checked = True self.label.config(bg="lightblue", text="✓") self.callback(self.row_col_key, True) return True elif mode == "erase" and self.checked: self.checked = False self.label.config(bg=self.default_bg, text=" ") self.callback(self.row_col_key, False) return True return False def set_state(self, checked): self.checked = checked if checked: self.label.config(bg="lightblue", text="✓") else: self.label.config(bg=self.default_bg, text=" ") class TimeTracker: def __init__(self, root): self.root = root self.root.title("Time Tracker") self.root.geometry("1400x500") # Settings - use UNIX-compliant config directory self.settings_file = os.path.expanduser("~/.config/time-tracker.json") settings = self.load_settings() self.jobs = settings["jobs"] self.customers = settings["customers"] self.start_hour = settings["start_hour"] self.work_hours = settings["work_hours"] self.archive_path = settings["archive_path"] # Drag state - moved from global to class attribute self.drag_info = { "active": False, "mode": None, # 'paint' or 'erase' "start_row": None, "last_cell": None, } # Main container with scrollbars main_container = tk.Frame(root) main_container.pack(fill=tk.BOTH, expand=True) # Create canvas and scrollbars canvas = tk.Canvas(main_container) h_scrollbar = tk.Scrollbar( main_container, orient="horizontal", command=canvas.xview ) v_scrollbar = tk.Scrollbar( main_container, orient="vertical", command=canvas.yview ) self.scrollable_frame = tk.Frame(canvas) self.scrollable_frame.bind( "", lambda e: canvas.configure(scrollregion=canvas.bbox("all")) ) canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw") canvas.configure(xscrollcommand=h_scrollbar.set, yscrollcommand=v_scrollbar.set) canvas.grid(row=0, column=0, sticky="nsew") h_scrollbar.grid(row=1, column=0, sticky="ew") v_scrollbar.grid(row=0, column=1, sticky="ns") main_container.grid_rowconfigure(0, weight=1) main_container.grid_columnconfigure(0, weight=1) # Data storage self.data_rows = {} self.row_count = 0 self.time_cells = {} self.time_assignments = {} # Track which time slots are assigned: time_idx -> row_num # Current working date (defaults to today) self.current_date = date.today() # Daily total label reference self.day_total_label = None # Global mouse event bindings self.root.bind("", self.on_global_drag) self.root.bind("", self.on_global_up) # Create headers self.create_headers() # Start with one empty row for data entry self.add_empty_row() # Controls controls_frame = tk.Frame(root) controls_frame.pack(fill=tk.X, side=tk.BOTTOM, pady=5) # Date selector on the left tk.Label(controls_frame, text="Working Date:").pack(side=tk.LEFT, padx=5) date_frame = tk.Frame(controls_frame) date_frame.pack(side=tk.LEFT, padx=5) self.date_month_var = tk.StringVar(value=str(self.current_date.month)) self.date_day_var = tk.StringVar(value=str(self.current_date.day)) self.date_year_var = tk.StringVar(value=str(self.current_date.year)) ttk.Combobox( date_frame, textvariable=self.date_month_var, width=3, values=[str(i) for i in range(1, 13)], state="readonly", ).pack(side=tk.LEFT) ttk.Combobox( date_frame, textvariable=self.date_day_var, width=3, values=[str(i) for i in range(1, 32)], state="readonly", ).pack(side=tk.LEFT, padx=(2, 0)) ttk.Combobox( date_frame, textvariable=self.date_year_var, width=6, values=[str(i) for i in range(2020, 2031)], state="readonly", ).pack(side=tk.LEFT, padx=(2, 0)) today_btn = tk.Button( controls_frame, text="Today", command=self.set_date_to_today, width=6 ) today_btn.pack(side=tk.LEFT, padx=(0, 10)) # Separator tk.Frame(controls_frame, width=20).pack(side=tk.LEFT, padx=5) # Daily Total Hours Display tk.Label(controls_frame, text="Day Total:").pack(side=tk.LEFT, padx=5) self.day_total_label = tk.Label( controls_frame, text="0.00 hours", relief="solid", fg="black", bg="lightyellow", width=12, font=("Arial", 10, "bold"), ) self.day_total_label.pack(side=tk.LEFT, padx=5) # Initialize day total display self.update_day_total() # Separator tk.Frame(controls_frame, width=20).pack(side=tk.LEFT, padx=5) add_btn = tk.Button( controls_frame, text="Add New Row", command=self.add_empty_row ) add_btn.pack(side=tk.LEFT, padx=5) archive_btn = tk.Button( controls_frame, text="Archive Day", command=self.archive_day ) archive_btn.pack(side=tk.LEFT, padx=5) report_btn = tk.Button( controls_frame, text="Build Our Details Report", command=self.open_report_dialog, ) report_btn.pack(side=tk.LEFT, padx=5) settings_btn = tk.Button( controls_frame, text="Settings", command=self.open_settings ) settings_btn.pack(side=tk.LEFT, padx=5) def load_settings(self): """Load settings from JSON file""" # Ensure config directory exists config_dir = os.path.dirname(self.settings_file) if config_dir and not os.path.exists(config_dir): try: os.makedirs(config_dir, exist_ok=True) except OSError: pass # Will fall back to defaults if directory can't be created if os.path.exists(self.settings_file): try: with open(self.settings_file, "r") as f: data = json.load(f) return { "jobs": data.get( "jobs", [ { "name": "Troubleshooting", "billable": True, "active": True, }, { "name": "Development", "billable": True, "active": True, }, {"name": "Meeting", "billable": False, "active": True}, {"name": "Admin", "billable": False, "active": True}, ], ), "customers": data.get( "customers", [ {"name": "Internal", "active": True}, {"name": "Client Corp", "active": True}, {"name": "Customer Inc", "active": True}, ], ), "start_hour": data.get("start_hour", 9), "work_hours": data.get("work_hours", 8), "archive_path": data.get( "archive_path", "time_tracker_archive.csv" ), } except Exception as e: messagebox.showwarning( "Settings Error", f"Failed to load settings: {e}" ) # Default settings if no file exists return { "jobs": [ {"name": "Troubleshooting", "billable": True, "active": True}, {"name": "Development", "billable": True, "active": True}, {"name": "Meeting", "billable": False, "active": True}, {"name": "Admin", "billable": False, "active": True}, ], "customers": [ {"name": "Internal", "active": True}, {"name": "Client Corp", "active": True}, {"name": "Customer Inc", "active": True}, ], "start_hour": 9, "work_hours": 8, "archive_path": "time_tracker_archive.csv", } def save_settings(self): """Save settings to JSON file using atomic write operation""" try: # Ensure config directory exists config_dir = os.path.dirname(self.settings_file) if config_dir and not os.path.exists(config_dir): os.makedirs(config_dir, exist_ok=True) # Create temporary file in same directory to ensure atomic operation temp_file = self.settings_file + ".tmp" try: with open(temp_file, "w") as f: # Sanitize configuration data before saving sanitized_jobs = [] for job in self.jobs: sanitized_jobs.append( { "name": sanitize_config_text(job.get("name", ""), 100), "billable": bool(job.get("billable", True)), "active": bool(job.get("active", True)), } ) sanitized_customers = [] for customer in self.customers: sanitized_customers.append( { "name": sanitize_config_text( customer.get("name", ""), 100 ), "active": bool(customer.get("active", True)), } ) json.dump( { "jobs": sanitized_jobs, "customers": sanitized_customers, "start_hour": int(self.start_hour), "work_hours": int(self.work_hours), "archive_path": sanitize_filename(self.archive_path), }, f, indent=2, ) # Atomic replace operation - this is the critical fix os.replace(temp_file, self.settings_file) except Exception: # Clean up temp file if something went wrong if os.path.exists(temp_file): try: os.remove(temp_file) except OSError: pass raise return True except Exception as e: messagebox.showerror("Save Error", f"Failed to save settings: {e}") return False def get_selected_date(self): """Get the currently selected date from the form""" try: return date( int(self.date_year_var.get()), int(self.date_month_var.get()), int(self.date_day_var.get()), ) except ValueError: # If invalid date, return today's date return date.today() def set_date_to_today(self): """Set the date fields to today's date""" today = date.today() self.date_month_var.set(str(today.month)) self.date_day_var.set(str(today.day)) self.date_year_var.set(str(today.year)) def get_active_jobs(self): """Get list of active job names for dropdown""" return [job["name"] for job in self.jobs if job.get("active", True)] def get_active_customers(self): """Get list of active customer names for dropdown""" return [ customer["name"] for customer in self.customers if customer.get("active", True) ] def get_job_billable_status(self, job_name): """Get billable status for a specific job""" for job in self.jobs: if job["name"] == job_name: return job.get("billable", True) return True # Default to True if job not found def on_global_drag(self, event): """Global drag handler that finds which cell we're over""" if not self.drag_info["active"]: return # Find which widget we're currently over widget = event.widget.winfo_containing(event.x_root, event.y_root) # Check if it's a ClickableCell cell = None current_widget = widget while current_widget: if isinstance(current_widget, ClickableCell): cell = current_widget break current_widget = current_widget.master if cell and cell.row_col_key != self.drag_info["last_cell"]: applied = cell.apply_drag_state() if applied: self.drag_info["last_cell"] = cell.row_col_key def on_global_up(self, event): """Global mouse up handler""" self.drag_info["active"] = False self.drag_info["mode"] = None self.drag_info["start_row"] = None self.drag_info["last_cell"] = None def create_headers(self): headers = ["Job", "Task Name", "Notes", "Customer"] # Calculate time slots based on settings time_slots = self.work_hours * 4 # 4 slots per hour (15-minute intervals) for i in range(time_slots): total_minutes = self.start_hour * 60 + i * 15 hour = total_minutes // 60 minute = total_minutes % 60 time_str = f"{hour:02d}:{minute:02d}" headers.append(time_str) headers.append("Total Hours") # Create header labels for i, header in enumerate(headers): if i < 4: width = 15 elif i == len(headers) - 1: width = 10 else: width = 6 label = tk.Label( self.scrollable_frame, text=header, relief="solid", borderwidth=1, width=width, height=2, ) label.grid(row=0, column=i, sticky="nsew") def add_empty_row(self): self.add_row("", "", "", "") def add_row(self, job, task_name, notes, customer): self.row_count += 1 row_num = self.row_count # Job dropdown job_dropdown = ttk.Combobox( self.scrollable_frame, width=15, values=self.get_active_jobs(), state="readonly", ) job_dropdown.grid(row=row_num, column=0, sticky="nsew", padx=1, pady=1) if job and job in job_dropdown["values"]: job_dropdown.set(job) # Don't auto-select first job for empty rows # Task Name field task_entry = tk.Entry(self.scrollable_frame, width=15) task_entry.insert(0, validate_input("task_name", task_name)) task_entry.grid(row=row_num, column=1, sticky="nsew", padx=1, pady=1) # Notes field notes_entry = tk.Entry(self.scrollable_frame, width=15) notes_entry.insert(0, validate_input("notes", notes)) notes_entry.grid(row=row_num, column=2, sticky="nsew", padx=1, pady=1) # Customer field customer_dropdown = ttk.Combobox( self.scrollable_frame, width=15, values=self.get_active_customers(), state="readonly", ) customer_dropdown.grid(row=row_num, column=3, sticky="nsew", padx=1, pady=1) if customer and customer in customer_dropdown["values"]: customer_dropdown.set(customer) # Don't auto-select first customer for empty rows # Time cells self.time_cells[row_num] = {} time_slots = self.work_hours * 4 # Calculate based on current settings for i in range(time_slots): cell = ClickableCell( self.scrollable_frame, (row_num, i), self.on_time_cell_clicked, self, width=5, height=1, start_hour=self.start_hour, ) cell.grid(row=row_num, column=4 + i, sticky="nsew", padx=1, pady=1) self.time_cells[row_num][i] = cell # Total hours label total_label = tk.Label( self.scrollable_frame, text="0.00", relief="solid", borderwidth=1, width=10, height=2, ) total_label.grid( row=row_num, column=4 + time_slots, sticky="nsew", padx=1, pady=1 ) # Store row data self.data_rows[row_num] = { "total_label": total_label, "time_cells": self.time_cells[row_num], } def update_day_total(self): """Update the daily total hours display""" if self.day_total_label: total_slots = len(self.time_assignments) total_hours = total_slots * 0.25 self.day_total_label.config(text=f"{total_hours:.2f} hours") # Change color if over 8 hours if total_hours > 8.0: self.day_total_label.config(bg="salmon", fg="black") elif total_hours == 8.0: self.day_total_label.config(bg="lightgreen", fg="black") else: self.day_total_label.config(bg="lightyellow", fg="black") def on_time_cell_clicked(self, row_col_key, checked): row_num, col_idx = row_col_key cell = self.time_cells[row_num][col_idx] if checked: # Check if this time slot is already assigned to another row if ( col_idx in self.time_assignments and self.time_assignments[col_idx] != row_num ): existing_row = self.time_assignments[col_idx] # Find the job name for the existing assignment job_name = f"Task on row {existing_row}" widgets = self.scrollable_frame.grid_slaves(row=existing_row) for widget in widgets: if isinstance(widget, ttk.Combobox): # Job dropdown column job_name = widget.get() break start_minute = col_idx * 15 hour = self.start_hour + (self.start_hour * 60 + start_minute) // 60 minute = (self.start_hour * 60 + start_minute) % 60 # Highlight conflicting cells in red cell = self.time_cells[row_num][col_idx] cell.label.config(bg="red", text="✗") # Also highlight the existing conflicting cell if ( existing_row in self.time_cells and col_idx in self.time_cells[existing_row] ): existing_cell = self.time_cells[existing_row][col_idx] existing_cell.label.config(bg="red", text="✗") messagebox.showwarning( "Time Conflict", f"Time slot at {hour:02d}:{minute:02d} is already assigned to:\n{job_name}\n\nConflicting slots marked in red.", ) # Don't return - let the cell remain checked so user can see the conflict # Assign this time slot to this row self.time_assignments[col_idx] = row_num else: # Remove assignment from this row if self.time_assignments.get(col_idx) == row_num: del self.time_assignments[col_idx] # Clear any red conflict highlighting for this column across all rows for other_row_num in self.time_cells: if col_idx in self.time_cells[other_row_num]: cell = self.time_cells[other_row_num][col_idx] if cell.label["bg"] == "red": # Reset to normal state based on checked status if cell.checked: cell.label.config(bg="lightblue", text="✓") else: cell.label.config(bg=cell.default_bg, text=" ") self.update_total_hours(row_num) self.update_day_total() def update_total_hours(self, row_num): if row_num not in self.data_rows: return checked_count = sum( 1 for cell in self.data_rows[row_num]["time_cells"].values() if cell.checked ) total_hours = checked_count * 0.25 self.data_rows[row_num]["total_label"].config(text=f"{total_hours:.2f}") def get_row_data(self, row_num): """Extract data from a specific row""" if row_num not in self.data_rows: return None # Get text from entry widgets with sanitization widgets = self.scrollable_frame.grid_slaves(row=row_num) job = task = notes = customer = "" for widget in widgets: col = widget.grid_info()["column"] if col == 0: # Job dropdown job = validate_input( "job_name", widget.get() if isinstance(widget, ttk.Combobox) else "" ) elif col == 1: # Task Name column task = validate_input( "task_name", widget.get() if isinstance(widget, tk.Entry) else "" ) elif col == 2: # Notes column notes = validate_input( "notes", widget.get() if isinstance(widget, tk.Entry) else "" ) elif col == 3: # Customer column (now dropdown) customer = validate_input( "customer_name", widget.get() if isinstance(widget, ttk.Combobox) else "", ) # Calculate hours checked_count = sum( 1 for cell in self.data_rows[row_num]["time_cells"].values() if cell.checked ) total_hours = checked_count * 0.25 return { "job": job, "task": task, "notes": notes, "customer": customer, "hours": total_hours, } def archive_day(self): """Archive current day's data to CSV""" # Check if there's any data to archive data_to_archive = [] for row_num in self.data_rows: row_data = self.get_row_data(row_num) if row_data and row_data["hours"] > 0: data_to_archive.append(row_data) if not data_to_archive: messagebox.showinfo("No Data", "No time entries to archive.") return # Check for missing customer data rows_with_no_customer = [] for row_data in data_to_archive: if not row_data["customer"] or row_data["customer"].strip() == "": rows_with_no_customer.append(row_data) if rows_with_no_customer: error_msg = f"Found {len(rows_with_no_customer)} time entry(s) with no customer specified:\n\n" for i, row in enumerate(rows_with_no_customer[:3]): # Show first 3 examples error_msg += f"• {row['job']}: {row['task'] or '(no task)'} ({row['hours']:.2f} hours)\n" if len(rows_with_no_customer) > 3: error_msg += f"... and {len(rows_with_no_customer) - 3} more\n" error_msg += "\nPlease select a customer for all entries before archiving." messagebox.showerror("Missing Customer Data", error_msg) return # Get archive file path from settings archive_path = self.archive_path # Check if file exists to determine if we need headers file_exists = os.path.exists(archive_path) try: with open(archive_path, "a", newline="", encoding="utf-8") as csvfile: fieldnames = [ "Job", "TaskName", "Note", "Customer", "Hours", "Date", "username", "Billable", "Billed", ] writer = csv.DictWriter( csvfile, fieldnames=fieldnames, quoting=csv.QUOTE_MINIMAL ) # Write header if file is new if not file_exists: writer.writeheader() # Write each row with archive metadata for row_data in data_to_archive: writer.writerow( { "Job": sanitize_csv_text(row_data["job"]), "TaskName": sanitize_csv_text(row_data["task"]), "Note": sanitize_csv_text(row_data["notes"]), "Customer": sanitize_csv_text(row_data["customer"]), "Hours": float(row_data["hours"]), # Ensure numeric "Date": sanitize_date_text( self.get_selected_date().strftime("%Y-%m-%d") ), "username": sanitize_csv_text( os.getenv("USER", os.getenv("USERNAME", "unknown")) ), "Billable": bool( self.get_job_billable_status(row_data["job"]) ), "Billed": False, # Default to False for now } ) messagebox.showinfo( "Archive Complete", f"Archived {len(data_to_archive)} entries to {archive_path}", ) # Clear the interface after successful archive self.clear_all_rows() except Exception as e: messagebox.showerror("Archive Failed", f"Failed to archive data: {str(e)}") def clear_all_rows(self): """Clear all data rows from the interface""" # Remove all widgets in scrollable frame except headers for widget in list(self.scrollable_frame.winfo_children()): widget.destroy() # Reset data structures self.data_rows = {} self.time_cells = {} self.time_assignments = {} self.row_count = 0 # Recreate headers self.create_headers() def open_report_dialog(self): """Open dialog for generating Billable Hours Details report""" dialog = tk.Toplevel(self.root) dialog.title("Billable Hours Details Report") dialog.geometry("400x350") # Invoice Number tk.Label(dialog, text="Invoice Number:").grid( row=0, column=0, padx=10, pady=5, sticky="w" ) invoice_entry = tk.Entry(dialog, width=30) invoice_entry.grid(row=0, column=1, padx=10, pady=5) # Customer Selection tk.Label(dialog, text="Customer:").grid( row=1, column=0, padx=10, pady=5, sticky="w" ) customer_combo = ttk.Combobox( dialog, width=27, values=self.get_active_customers(), state="readonly" ) customer_combo.grid(row=1, column=1, padx=10, pady=5) if customer_combo["values"]: customer_combo.set(customer_combo["values"][0]) # Date Range tk.Label(dialog, text="Start Date:").grid( row=2, column=0, padx=10, pady=5, sticky="w" ) start_date_frame = tk.Frame(dialog) start_date_frame.grid(row=2, column=1, padx=10, pady=5, sticky="w") start_month_var = tk.StringVar(value=str(datetime.now().month)) start_day_var = tk.StringVar(value="1") start_year_var = tk.StringVar(value=str(datetime.now().year)) ttk.Combobox( start_date_frame, textvariable=start_month_var, width=5, values=[str(i) for i in range(1, 13)], ).pack(side=tk.LEFT) ttk.Combobox( start_date_frame, textvariable=start_day_var, width=5, values=[str(i) for i in range(1, 32)], ).pack(side=tk.LEFT, padx=(5, 0)) ttk.Combobox( start_date_frame, textvariable=start_year_var, width=8, values=[str(i) for i in range(2020, 2031)], ).pack(side=tk.LEFT, padx=(5, 0)) tk.Label(dialog, text="End Date:").grid( row=3, column=0, padx=10, pady=5, sticky="w" ) end_date_frame = tk.Frame(dialog) end_date_frame.grid(row=3, column=1, padx=10, pady=5, sticky="w") end_month_var = tk.StringVar(value=str(datetime.now().month)) end_day_var = tk.StringVar( value=str(calendar.monthrange(datetime.now().year, datetime.now().month)[1]) ) end_year_var = tk.StringVar(value=str(datetime.now().year)) ttk.Combobox( end_date_frame, textvariable=end_month_var, width=5, values=[str(i) for i in range(1, 13)], ).pack(side=tk.LEFT) ttk.Combobox( end_date_frame, textvariable=end_day_var, width=5, values=[str(i) for i in range(1, 32)], ).pack(side=tk.LEFT, padx=(5, 0)) ttk.Combobox( end_date_frame, textvariable=end_year_var, width=8, values=[str(i) for i in range(2020, 2031)], ).pack(side=tk.LEFT, padx=(5, 0)) # Include only billable checkbox include_billable_var = tk.BooleanVar(value=True) tk.Checkbutton( dialog, text="Include only billable hours", variable=include_billable_var ).grid(row=4, column=0, columnspan=2, padx=10, pady=10, sticky="w") def generate_report(): try: invoice_num = validate_input( "invoice_number", invoice_entry.get().strip() ) if not invoice_num: messagebox.showwarning( "Invalid Input", "Please enter a valid invoice number." ) return customer = validate_input("customer_name", customer_combo.get()) start_date_obj = date( int(start_year_var.get()), int(start_month_var.get()), int(start_day_var.get()), ) end_date_obj = date( int(end_year_var.get()), int(end_month_var.get()), int(end_day_var.get()), ) if start_date_obj > end_date_obj: messagebox.showwarning( "Invalid Date Range", "Start date must be before or equal to end date.", ) return self.generate_report_data( invoice_num, customer, start_date_obj, end_date_obj, include_billable_var.get(), ) dialog.destroy() except ValueError as e: messagebox.showerror("Invalid Date", "Please enter valid dates.") except Exception as e: messagebox.showerror("Error", f"Failed to generate report: {str(e)}") def cancel(): dialog.destroy() # Buttons btn_frame = tk.Frame(dialog) btn_frame.grid(row=5, column=0, columnspan=2, pady=20) tk.Button( btn_frame, text="Generate Report", command=generate_report, width=15 ).pack(side=tk.LEFT, padx=5) tk.Button(btn_frame, text="Cancel", command=cancel, width=15).pack( side=tk.LEFT, padx=5 ) def generate_report_data( self, invoice_num, customer, start_date, end_date, billable_only ): """Generate report data and show preview dialog""" archive_file = self.archive_path if not os.path.exists(archive_file): messagebox.showerror( "No Data", "Archive file not found. No data to generate report." ) return # Read and filter CSV data filtered_data = [] try: with open(archive_file, "r", encoding="utf-8") as csvfile: reader = csv.DictReader(csvfile) for row in reader: row_date = datetime.strptime(row["Date"], "%Y-%m-%d").date() # Apply filters if ( row["Customer"] == customer and start_date <= row_date <= end_date and ( not billable_only or row.get("Billable", "True").lower() == "true" ) ): filtered_data.append(row) except Exception as e: messagebox.showerror("Read Error", f"Failed to read archive file: {str(e)}") return if not filtered_data: messagebox.showinfo( "No Data", f"No data found for {customer} in the selected date range." ) return # Create pivot table data structure pivot_data = defaultdict(lambda: defaultdict(float)) # job -> date -> hours all_dates = set() all_jobs = set() for row in filtered_data: job = row["Job"] date_str = row["Date"] hours = float(row["Hours"]) pivot_data[job][date_str] += hours all_dates.add(date_str) all_jobs.add(job) # Sort dates and jobs sorted_dates = sorted(all_dates) sorted_jobs = sorted(all_jobs) # Show report preview dialog self.show_report_preview( invoice_num, customer, start_date, end_date, pivot_data, sorted_jobs, sorted_dates, filtered_data, billable_only, ) def show_report_preview( self, invoice_num, customer, start_date, end_date, pivot_data, jobs, dates, filtered_data, billable_only=True, ): """Show report preview dialog with pivot table and export options""" preview_window = tk.Toplevel(self.root) preview_window.title(f"Invoice #{invoice_num} Billable Hours Details") preview_window.geometry("1200x600") # Title frame title_frame = tk.Frame(preview_window) title_frame.pack(fill=tk.X, padx=10, pady=10) title_label = tk.Label( title_frame, text=f"Invoice #{invoice_num} Billable Hours Details", font=("Arial", 16, "bold"), ) title_label.pack(side=tk.LEFT) subtitle_label = tk.Label( title_frame, text=f"Customer: {customer} | Period: {start_date} to {end_date}", ) subtitle_label.pack(side=tk.LEFT, padx=(20, 0)) # Create scrollable frame for table main_frame = tk.Frame(preview_window) main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) canvas = tk.Canvas(main_frame) h_scrollbar = tk.Scrollbar( main_frame, orient="horizontal", command=canvas.xview ) v_scrollbar = tk.Scrollbar(main_frame, orient="vertical", command=canvas.yview) table_frame = tk.Frame(canvas) table_frame.bind( "", lambda e: canvas.configure(scrollregion=canvas.bbox("all")) ) canvas.create_window((0, 0), window=table_frame, anchor="nw") canvas.configure(xscrollcommand=h_scrollbar.set, yscrollcommand=v_scrollbar.set) canvas.grid(row=0, column=0, sticky="nsew") h_scrollbar.grid(row=1, column=0, sticky="ew") v_scrollbar.grid(row=0, column=1, sticky="ns") main_frame.grid_rowconfigure(0, weight=1) main_frame.grid_columnconfigure(0, weight=1) # Build pivot table # Format dates as MM-DD for better preview display formatted_dates = [] for date_str in dates: try: # Convert from YYYY-MM-DD to MM-DD date_obj = datetime.strptime(date_str, "%Y-%m-%d").date() formatted_dates.append(date_obj.strftime("%m-%d")) except ValueError: # Fallback to original if parsing fails formatted_dates.append(date_str) headers = ["Job"] + formatted_dates + ["Total"] for col, header in enumerate(headers): width = 15 if col == 0 else 10 label = tk.Label( table_frame, text=header, relief="solid", borderwidth=1, width=width, height=2, font=("Arial", 10, "bold"), ) label.grid(row=0, column=col, sticky="nsew") # Data rows with totals grand_total = 0.0 day_totals = [0.0] * len(dates) for row, job in enumerate(jobs, 1): # Job name job_label = tk.Label( table_frame, text=job, relief="solid", borderwidth=1, width=15, height=2 ) job_label.grid(row=row, column=0, sticky="nsew") # Daily hours row_total = 0.0 for col, date_str in enumerate(dates, 1): hours = pivot_data.get(job, {}).get(date_str, 0.0) if hours > 0: cell_label = tk.Label( table_frame, text=f"{hours:.2f}", relief="solid", borderwidth=1, width=10, height=2, ) row_total += hours day_totals[col - 1] += hours else: cell_label = tk.Label( table_frame, text="", relief="solid", borderwidth=1, width=10, height=2, ) cell_label.grid(row=row, column=col, sticky="nsew") # Row total row_total_label = tk.Label( table_frame, text=f"{row_total:.2f}", relief="solid", borderwidth=1, width=10, height=2, font=("Arial", 10, "bold"), ) row_total_label.grid(row=row, column=len(dates) + 1, sticky="nsew") grand_total += row_total # Bottom row - Day totals total_row = len(jobs) + 1 totals_label = tk.Label( table_frame, text="Daily Total", relief="solid", borderwidth=1, width=15, height=2, font=("Arial", 10, "bold"), ) totals_label.grid(row=total_row, column=0, sticky="nsew") for col, day_total in enumerate(day_totals, 1): day_total_label = tk.Label( table_frame, text=f"{day_total:.2f}", relief="solid", borderwidth=1, width=10, height=2, font=("Arial", 10, "bold"), ) day_total_label.grid(row=total_row, column=col, sticky="nsew") # Grand total grand_total_label = tk.Label( table_frame, text=f"{grand_total:.2f}", relief="solid", borderwidth=1, width=10, height=2, font=("Arial", 10, "bold"), ) grand_total_label.grid(row=total_row, column=len(dates) + 1, sticky="nsew") # Export buttons button_frame = tk.Frame(preview_window) button_frame.pack(fill=tk.X, padx=10, pady=10) def export_to_pdf(): try: filename = filedialog.asksaveasfilename( defaultextension=".pdf", filetypes=[("PDF files", "*.pdf"), ("All files", "*.*")], initialfile=f"Invoice_{invoice_num}_Hours_Report.pdf", ) if filename: self.export_to_pdf( filename, invoice_num, customer, start_date, end_date, pivot_data, jobs, dates, ) messagebox.showinfo("Success", f"Report exported to {filename}") except Exception as e: messagebox.showerror("Export Error", f"Failed to export PDF: {str(e)}") def mark_as_billed(): """Mark all rows in this report as billed""" try: archive_file = self.archive_path # Read all archive data all_data = [] fieldnames = None with open(archive_file, "r", encoding="utf-8") as csvfile: reader = csv.DictReader(csvfile) fieldnames = reader.fieldnames if fieldnames: # Ensure we have fieldnames for row in reader: row_date = datetime.strptime(row["Date"], "%Y-%m-%d").date() # Check if this row matches filtered data is_in_report = False for report_row in filtered_data: if ( row["Customer"] == report_row["Customer"] and row["Date"] == report_row["Date"] and row["Job"] == report_row["Job"] and row["Hours"] == report_row["Hours"] ): is_in_report = True break if is_in_report: # This row is included in report, mark as billed row["Billed"] = "True" all_data.append(row) # Write back to archive with updated Billed status if fieldnames: # Ensure fieldnames is not None with open( archive_file, "w", newline="", encoding="utf-8" ) as csvfile: writer = csv.DictWriter( csvfile, fieldnames=fieldnames, quoting=csv.QUOTE_MINIMAL ) writer.writeheader() # Sanitize all data before writing sanitized_data = [] for row in all_data: sanitized_row = {} for field_name, field_value in row.items(): if field_name in [ "Job", "TaskName", "Note", "Customer", "username", ]: if isinstance(field_value, str): sanitized_row[field_name] = sanitize_csv_text( field_value ) else: sanitized_row[field_name] = field_value elif field_name in ["Date"]: if isinstance(field_value, str): sanitized_row[field_name] = sanitize_date_text( field_value ) else: sanitized_row[field_name] = field_value else: # For Hours, Billable, Billed - keep as-is but ensure proper types sanitized_row[field_name] = field_value sanitized_data.append(sanitized_row) writer.writerows(sanitized_data) messagebox.showinfo( "Success", f"Marked {len(filtered_data)} entries as billed for invoice #{invoice_num}", ) # Close preview window after marking as billed preview_window.destroy() except Exception as e: messagebox.showerror( "Error Marking Billed", f"Failed to mark entries as billed: {str(e)}", ) def close_preview(): preview_window.destroy() tk.Button( button_frame, text="Mark as Billed", command=mark_as_billed, width=15, bg="lightgreen", ).pack(side=tk.LEFT, padx=5) tk.Button( button_frame, text="Export to PDF", command=export_to_pdf, width=15 ).pack(side=tk.LEFT, padx=5) tk.Button(button_frame, text="Close", command=close_preview, width=15).pack( side=tk.LEFT, padx=5 ) def export_to_pdf( self, filename, invoice_num, customer, start_date, end_date, pivot_data, jobs, dates, ): """Export report data to PDF format""" try: from reportlab.lib.pagesizes import letter, landscape from reportlab.platypus import ( SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, ) from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib import colors from reportlab.lib.units import inch except ImportError: messagebox.showerror( "Missing Library", "reportlab library is required for PDF export. Please install it with:\n\n" "pip install reportlab", ) return doc = SimpleDocTemplate( filename, pagesize=landscape(letter), leftMargin=0.5 * inch, rightMargin=0.5 * inch, topMargin=0.75 * inch, bottomMargin=0.75 * inch, ) styles = getSampleStyleSheet() story = [] # Title title_style = ParagraphStyle( "CustomTitle", parent=styles["Heading1"], fontSize=16, spaceAfter=20, alignment=1, # Center ) title = Paragraph(f"Invoice #{invoice_num} Billable Hours Details", title_style) story.append(title) # Subtitle subtitle = Paragraph( f"Customer: {customer} | Period: {start_date} to {end_date}", styles["Normal"], ) story.append(subtitle) story.append(Spacer(1, 12)) # Build table data # Format dates as MM-DD for better column fit formatted_dates = [] for date_str in dates: try: # Convert from YYYY-MM-DD to MM-DD date_obj = datetime.strptime(date_str, "%Y-%m-%d").date() formatted_dates.append(date_obj.strftime("%m-%d")) except ValueError: # Fallback to original if parsing fails formatted_dates.append(date_str) headers = ["Job"] + formatted_dates + ["Total"] table_data = [headers] grand_total = 0.0 day_totals = [0.0] * len(dates) # Add job rows for job in jobs: row = [job] row_total = 0.0 for date_str in dates: hours = pivot_data.get(job, {}).get(date_str, 0.0) row.append(f"{hours:.2f}" if hours > 0 else "") row_total += hours day_totals[len(row) - 2] += hours row.append(f"{row_total:.2f}") table_data.append(row) grand_total += row_total # Add totals row totals_row = ["Daily Total"] for day_total in day_totals: totals_row.append(f"{day_total:.2f}") totals_row.append(f"{grand_total:.2f}") table_data.append(totals_row) # Create table with explicit width control to prevent truncation num_dates = len(dates) total_cols = num_dates + 2 # Job column + date columns + Total column # Available width: landscape letter (11") minus 1" margins available_width = 10.0 # inches # Calculate appropriate font size based on number of columns if num_dates >= 31: # Very wide reports (like 31-day months) font_size = 5 # Job column gets 1.5", remaining space divided among date columns job_col = 1.5 * inch date_col = (available_width - 1.5) / (num_dates + 1) * inch elif num_dates >= 25: # Wide reports font_size = 6 job_col = 1.6 * inch date_col = (available_width - 1.6) / (num_dates + 1) * inch elif num_dates >= 15: # Medium reports font_size = 7 job_col = 1.8 * inch date_col = (available_width - 1.8) / (num_dates + 1) * inch else: # Small reports font_size = 8 job_col = 2.0 * inch date_col = (available_width - 2.0) / (num_dates + 1) * inch # Create column widths list: Job + date columns + Total col_widths = [job_col] + [date_col] * num_dates + [date_col] # Create table with explicit column widths table = Table(table_data, repeatRows=1, colWidths=col_widths) # Style the table with appropriate font size # Minimize padding for wide reports to fit on page if num_dates >= 31: left_padding = 0.5 right_padding = 0.5 bottom_padding = 1 top_padding = 1 elif num_dates >= 25: left_padding = 1 right_padding = 1 bottom_padding = 2 top_padding = 2 else: left_padding = 2 right_padding = 2 bottom_padding = 4 top_padding = 3 table.setStyle( TableStyle( [ ("BACKGROUND", (0, 0), (-1, 0), colors.grey), ("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke), ("ALIGN", (0, 0), (-1, -1), "CENTER"), ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), ("FONTNAME", (0, -1), (-1, -1), "Helvetica-Bold"), ("FONTSIZE", (0, 0), (-1, -1), font_size), ("LEADING", (0, 0), (-1, -1), font_size + 1), ("BOTTOMPADDING", (0, 0), (-1, 0), bottom_padding), ("TOPPADDING", (0, 0), (-1, -1), top_padding), ("LEFTPADDING", (0, 0), (-1, -1), left_padding), ("RIGHTPADDING", (0, 0), (-1, -1), right_padding), ("BACKGROUND", (0, -1), (-1, -1), colors.grey), ("TEXTCOLOR", (0, -1), (-1, -1), colors.whitesmoke), ("GRID", (0, 0), (-1, -1), 1, colors.black), ] ) ) story.append(table) # Add a note about report scaling if we compressed it a lot if num_dates >= 25: note_style = ParagraphStyle( "Note", parent=styles["Normal"], fontSize=8, textColor=colors.gray, alignment=1, # Center ) note = Paragraph("*Report scaled to fit large date range*", note_style) story.append(Spacer(1, 6)) story.append(note) # Build PDF doc.build(story) # Settings dialog methods (keeping from original) def open_settings(self): """Open settings dialog to manage jobs and time settings""" settings_window = tk.Toplevel(self.root) settings_window.title("Settings") settings_window.geometry("700x500") # Create notebook for tabbed interface notebook = ttk.Notebook(settings_window) notebook.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) # Jobs tab jobs_frame = tk.Frame(notebook) notebook.add(jobs_frame, text="Jobs") # Customers tab customers_frame = tk.Frame(notebook) notebook.add(customers_frame, text="Customers") # Time Settings tab time_frame = tk.Frame(notebook) notebook.add(time_frame, text="Time Settings") # Storage tab storage_frame = tk.Frame(notebook) notebook.add(storage_frame, text="Storage") # Jobs tab content list_frame = tk.Frame(jobs_frame) list_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) # Treeview for job management columns = ("Job Name", "Billable", "Active") tree = ttk.Treeview(list_frame, columns=columns, show="headings", height=10) for col in columns: tree.heading(col, text=col) tree.column(col, width=150) # Populate tree with current jobs for job in self.jobs: job_name = job["name"] billable = "Yes" if job.get("billable", True) else "No" active = "Yes" if job.get("active", True) else "No" tree.insert("", tk.END, values=(job_name, billable, active)) tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) # Scrollbar scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=tree.yview) scrollbar.pack(side=tk.RIGHT, fill=tk.Y) tree.configure(yscrollcommand=scrollbar.set) # Job control buttons jobs_btn_frame = tk.Frame(jobs_frame) jobs_btn_frame.pack(fill=tk.X, padx=10, pady=5) def add_job(): self.add_job_dialog(tree) def edit_job(): selected = tree.selection() if not selected: messagebox.showwarning("No Selection", "Please select a job to edit.") return item = tree.item(selected[0]) values = item["values"] self.edit_job_dialog( tree, values[0], values[1] == "Yes", values[2] == "Yes" ) tk.Button(jobs_btn_frame, text="Add Job", command=add_job).pack( side=tk.LEFT, padx=2 ) tk.Button(jobs_btn_frame, text="Edit Job", command=edit_job).pack( side=tk.LEFT, padx=2 ) # Customers tab content (using same pattern as jobs) customers_list_frame = tk.Frame(customers_frame) customers_list_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) # Treeview for customer management customer_columns = ("Customer Name", "Active") customer_tree = ttk.Treeview( customers_list_frame, columns=customer_columns, show="headings", height=10 ) for col in customer_columns: customer_tree.heading(col, text=col) customer_tree.column(col, width=150) # Populate tree with current customers for customer in self.customers: customer_name = customer["name"] active = "Yes" if customer.get("active", True) else "No" customer_tree.insert("", tk.END, values=(customer_name, active)) customer_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) # Customer scrollbar customer_scrollbar = ttk.Scrollbar( customers_list_frame, orient="vertical", command=customer_tree.yview ) customer_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) customer_tree.configure(yscrollcommand=customer_scrollbar.set) # Customer control buttons customers_btn_frame = tk.Frame(customers_frame) customers_btn_frame.pack(fill=tk.X, padx=10, pady=5) def add_customer(): self.add_customer_dialog(customer_tree) def edit_customer(): selected = customer_tree.selection() if not selected: messagebox.showwarning( "No Selection", "Please select a customer to edit." ) return item = customer_tree.item(selected[0]) values = item["values"] self.edit_customer_dialog(customer_tree, values[0], values[1] == "Yes") tk.Button(customers_btn_frame, text="Add Customer", command=add_customer).pack( side=tk.LEFT, padx=2 ) tk.Button( customers_btn_frame, text="Edit Customer", command=edit_customer ).pack(side=tk.LEFT, padx=2) # Storage tab content storage_settings_frame = tk.Frame(storage_frame) storage_settings_frame.pack(fill=tk.X, padx=20, pady=20) tk.Label(storage_settings_frame, text="Archive File Location:").grid( row=0, column=0, sticky="w", padx=5, pady=5 ) archive_path_frame = tk.Frame(storage_settings_frame) archive_path_frame.grid(row=0, column=1, sticky="w", padx=5, pady=5) archive_path_var = tk.StringVar(value=self.archive_path) archive_path_entry = tk.Entry( archive_path_frame, textvariable=archive_path_var, width=50 ) archive_path_entry.pack(side=tk.LEFT) def browse_archive_path(): filename = filedialog.asksaveasfilename( title="Select Archive File Location", initialfile=os.path.basename(self.archive_path) if self.archive_path else "time_tracker_archive.csv", filetypes=[("CSV files", "*.csv"), ("All files", "*.*")], ) if filename: archive_path_var.set(filename) browse_btn = tk.Button( archive_path_frame, text="Browse...", command=browse_archive_path ) browse_btn.pack(side=tk.LEFT, padx=(5, 0)) # Time Settings tab content time_settings_frame = tk.Frame(time_frame) time_settings_frame.pack(fill=tk.X, padx=20, pady=20) tk.Label(time_settings_frame, text="Start Hour (0-23):").grid( row=0, column=0, sticky="w", padx=5, pady=5 ) start_hour_var = tk.IntVar(value=self.start_hour) start_hour_spinbox = tk.Spinbox( time_settings_frame, from_=0, to=23, textvariable=start_hour_var, width=10 ) start_hour_spinbox.grid(row=0, column=1, sticky="w", padx=5, pady=5) tk.Label(time_settings_frame, text="Work Hours (1-24):").grid( row=1, column=0, sticky="w", padx=5, pady=5 ) work_hours_var = tk.IntVar(value=self.work_hours) work_hours_spinbox = tk.Spinbox( time_settings_frame, from_=1, to=24, textvariable=work_hours_var, width=10 ) work_hours_spinbox.grid(row=1, column=1, sticky="w", padx=5, pady=5) # Bottom buttons bottom_btn_frame = tk.Frame(settings_window) bottom_btn_frame.pack(fill=tk.X, padx=10, pady=5) def save_changes(): # Update jobs from tree data self.jobs = [] for item in tree.get_children(): values = tree.item(item)["values"] self.jobs.append( { "name": values[0], "billable": values[1] == "Yes", "active": values[2] == "Yes", } ) # Update customers from tree data self.customers = [] for item in customer_tree.get_children(): values = customer_tree.item(item)["values"] self.customers.append({"name": values[0], "active": values[1] == "Yes"}) # Update time settings self.start_hour = start_hour_var.get() self.work_hours = work_hours_var.get() # Update archive path new_archive_path = validate_input( "file_path", archive_path_var.get().strip() ) if new_archive_path: # If directory doesn't exist, try to create it import os.path dir_name = os.path.dirname(new_archive_path) if dir_name and not os.path.exists(dir_name): try: os.makedirs(dir_name) except OSError: messagebox.showerror( "Invalid Path", f"Cannot create directory: {dir_name}" ) return self.archive_path = new_archive_path if self.save_settings(): messagebox.showinfo( "Success", "Settings saved successfully. Please restart application for time changes to take effect.", ) settings_window.destroy() # Refresh job and customer dropdowns immediately self.refresh_job_dropdowns() self.refresh_customer_dropdowns() tk.Button(bottom_btn_frame, text="Save Settings", command=save_changes).pack( side=tk.RIGHT, padx=2 ) tk.Button( bottom_btn_frame, text="Cancel", command=settings_window.destroy ).pack(side=tk.RIGHT, padx=2) # Simplified customer and job management methods (using original pattern) def add_job_dialog(self, tree): """Dialog to add new job""" dialog = tk.Toplevel(self.root) dialog.title("Add New Job") dialog.geometry("300x150") tk.Label(dialog, text="Job Name:").grid( row=0, column=0, padx=10, pady=5, sticky="w" ) name_entry = tk.Entry(dialog) name_entry.grid(row=0, column=1, padx=10, pady=5) billable_var = tk.BooleanVar(value=True) tk.Checkbutton(dialog, text="Billable", variable=billable_var).grid( row=1, column=0, columnspan=2, padx=10, pady=5, sticky="w" ) active_var = tk.BooleanVar(value=True) tk.Checkbutton(dialog, text="Active", variable=active_var).grid( row=2, column=0, columnspan=2, padx=10, pady=5, sticky="w" ) def save_job(): job_name = validate_input("job_name", name_entry.get().strip()) if not job_name: messagebox.showwarning("Invalid Input", "Job name cannot be empty.") return # Sanitize billable and active status billable_text = "Yes" if billable_var.get() else "No" active_text = "Yes" if active_var.get() else "No" tree.insert("", tk.END, values=(job_name, billable_text, active_text)) dialog.destroy() tk.Button(dialog, text="Save", command=save_job).grid( row=3, column=0, padx=10, pady=10 ) tk.Button(dialog, text="Cancel", command=dialog.destroy).grid( row=3, column=1, padx=10, pady=10 ) def edit_job_dialog(self, tree, job_name, is_billable, is_active): """Dialog to edit existing job""" dialog = tk.Toplevel(self.root) dialog.title("Edit Job") dialog.geometry("300x150") tk.Label(dialog, text="Job Name:").grid( row=0, column=0, padx=10, pady=5, sticky="w" ) name_entry = tk.Entry(dialog) name_entry.insert(0, job_name) name_entry.grid(row=0, column=1, padx=10, pady=5) billable_var = tk.BooleanVar(value=is_billable) tk.Checkbutton(dialog, text="Billable", variable=billable_var).grid( row=1, column=0, columnspan=2, padx=10, pady=5, sticky="w" ) active_var = tk.BooleanVar(value=is_active) tk.Checkbutton(dialog, text="Active", variable=active_var).grid( row=2, column=0, columnspan=2, padx=10, pady=5, sticky="w" ) def save_job(): new_job_name = validate_input("job_name", name_entry.get().strip()) if not new_job_name: messagebox.showwarning("Invalid Input", "Job name cannot be empty.") return # Sanitize billable and active status billable_text = "Yes" if billable_var.get() else "No" active_text = "Yes" if active_var.get() else "No" # Find and update the tree item for item in tree.get_children(): if tree.item(item)["values"][0] == job_name: tree.item(item, values=(new_job_name, billable_text, active_text)) break dialog.destroy() tk.Button(dialog, text="Save", command=save_job).grid( row=3, column=0, padx=10, pady=10 ) tk.Button(dialog, text="Cancel", command=dialog.destroy).grid( row=3, column=1, padx=10, pady=10 ) def add_customer_dialog(self, tree): """Dialog to add new customer""" dialog = tk.Toplevel(self.root) dialog.title("Add New Customer") dialog.geometry("300x150") tk.Label(dialog, text="Customer Name:").grid( row=0, column=0, padx=10, pady=5, sticky="w" ) name_entry = tk.Entry(dialog) name_entry.grid(row=0, column=1, padx=10, pady=5) active_var = tk.BooleanVar(value=True) tk.Checkbutton(dialog, text="Active", variable=active_var).grid( row=1, column=0, columnspan=2, padx=10, pady=5, sticky="w" ) def save_customer(): customer_name = validate_input("customer_name", name_entry.get().strip()) if not customer_name: messagebox.showwarning( "Invalid Input", "Customer name cannot be empty." ) return active_text = "Yes" if active_var.get() else "No" tree.insert("", tk.END, values=(customer_name, active_text)) dialog.destroy() tk.Button(dialog, text="Save", command=save_customer).grid( row=2, column=0, padx=10, pady=10 ) tk.Button(dialog, text="Cancel", command=dialog.destroy).grid( row=2, column=1, padx=10, pady=10 ) def edit_customer_dialog(self, tree, customer_name, is_active): """Dialog to edit existing customer""" dialog = tk.Toplevel(self.root) dialog.title("Edit Customer") dialog.geometry("300x120") tk.Label(dialog, text="Customer Name:").grid( row=0, column=0, padx=10, pady=5, sticky="w" ) name_entry = tk.Entry(dialog) name_entry.insert(0, customer_name) name_entry.grid(row=0, column=1, padx=10, pady=5) active_var = tk.BooleanVar(value=is_active) tk.Checkbutton(dialog, text="Active", variable=active_var).grid( row=1, column=0, columnspan=2, padx=10, pady=5, sticky="w" ) def save_customer(): new_customer_name = validate_input( "customer_name", name_entry.get().strip() ) if not new_customer_name: messagebox.showwarning( "Invalid Input", "Customer name cannot be empty." ) return active_text = "Yes" if active_var.get() else "No" # Find and update tree item for item in tree.get_children(): if tree.item(item)["values"][0] == customer_name: tree.item(item, values=(new_customer_name, active_text)) break dialog.destroy() tk.Button(dialog, text="Save", command=save_customer).grid( row=2, column=0, padx=10, pady=10 ) tk.Button(dialog, text="Cancel", command=dialog.destroy).grid( row=2, column=1, padx=10, pady=10 ) def refresh_job_dropdowns(self): """Refresh all job dropdowns in the interface""" for row_num in self.data_rows: widgets = self.scrollable_frame.grid_slaves(row=row_num) for widget in widgets: if ( isinstance(widget, ttk.Combobox) and widget.grid_info()["column"] == 0 ): # This is a job dropdown current_selection = widget.get() widget["values"] = self.get_active_jobs() if current_selection in widget["values"]: widget.set(current_selection) def refresh_customer_dropdowns(self): """Refresh all customer dropdowns in the interface""" for row_num in self.data_rows: widgets = self.scrollable_frame.grid_slaves(row=row_num) for widget in widgets: if ( isinstance(widget, ttk.Combobox) and widget.grid_info()["column"] == 3 ): # This is a customer dropdown current_selection = widget.get() widget["values"] = self.get_active_customers() if current_selection in widget["values"]: widget.set(current_selection) if __name__ == "__main__": root = tk.Tk() app = TimeTracker(root) root.mainloop()