diff --git a/time_tracker.py b/time_tracker.py index 0e91324..98dd927 100644 --- a/time_tracker.py +++ b/time_tracker.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 - import tkinter as tk from tkinter import ttk, messagebox, filedialog from datetime import time, datetime, date @@ -13,56 +12,56 @@ 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'] + dangerous_chars = ["=", "+", "-", "@", "\t", "\r", "\n"] for char in dangerous_chars: - text = text.replace(char, '') - + text = text.replace(char, "") + # Remove Excel formula triggers - text = re.sub(r'^[+\-=@]', '', text) - + 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'] + dangerous_chars = ["=", "+", "@", "\t", "\r", "\n"] for char in dangerous_chars: - text = text.replace(char, '') - + text = text.replace(char, "") + # Remove Excel formula triggers (except hyphens for date format) - text = re.sub(r'^[+\-=@]', '', text) - + 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('-') + 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) + 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: @@ -70,120 +69,140 @@ def sanitize_date_text(date_text): 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') + + 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') + + 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') - + + 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 - + text = re.sub(r'[<>:"/\\|?*]', "", text) + text = re.sub(r"\.\.", "", text) # Remove directory traversal + # Remove leading/trailing dots and spaces - text = text.strip('. ') - + text = text.strip(". ") + # Ensure filename is not empty - if not text or text.startswith('.'): + if not text or text.startswith("."): return "default.csv" - + # Ensure .csv extension - if not text.lower().endswith('.csv'): - text += '.csv' - + 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) - + 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('/', '\\/') - + 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) + 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) + 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 - + 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 = 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 @@ -191,11 +210,10 @@ class ClickableCell(tk.Frame): 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] + 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 @@ -204,18 +222,17 @@ class ClickableCell(tk.Frame): 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 + 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"] - mode = force_mode or self.time_tracker.drag_info['mode'] - - if mode == 'paint' and not self.checked: + 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: + elif mode == "erase" and self.checked: self.checked = False self.label.config(bg=self.default_bg, text=" ") self.callback(self.row_col_key, False) @@ -229,6 +246,7 @@ class ClickableCell(tk.Frame): else: self.label.config(bg=self.default_bg, text=" ") + class TimeTracker: def __init__(self, root): self.root = root @@ -238,18 +256,18 @@ class TimeTracker: # 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'] + 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 + "active": False, + "mode": None, # 'paint' or 'erase' + "start_row": None, + "last_cell": None, } # Main container with scrollbars @@ -258,14 +276,17 @@ class TimeTracker: # 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) + 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")) + "", lambda e: canvas.configure(scrollregion=canvas.bbox("all")) ) canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw") @@ -314,19 +335,47 @@ class TimeTracker: 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)) + 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)) + 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 = 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 @@ -335,16 +384,26 @@ class TimeTracker: # 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 = 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 = 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 = 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 = tk.Button( + controls_frame, text="Settings", command=self.open_settings + ) settings_btn.pack(side=tk.LEFT, padx=5) def load_settings(self): @@ -359,43 +418,61 @@ class TimeTracker: if os.path.exists(self.settings_file): try: - with open(self.settings_file, 'r') as f: + 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') + "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}") + 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} + "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} + "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' + "start_hour": 9, + "work_hours": 8, + "archive_path": "time_tracker_archive.csv", } def save_settings(self): @@ -407,37 +484,47 @@ class TimeTracker: os.makedirs(config_dir, exist_ok=True) # Create temporary file in same directory to ensure atomic operation - temp_file = self.settings_file + '.tmp' - + temp_file = self.settings_file + ".tmp" + try: - with open(temp_file, 'w') as f: + 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_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) - + 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): @@ -446,7 +533,7 @@ class TimeTracker: except OSError: pass raise - + return True except Exception as e: messagebox.showerror("Save Error", f"Failed to save settings: {e}") @@ -455,7 +542,11 @@ class TimeTracker: 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())) + 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() @@ -469,23 +560,27 @@ class TimeTracker: 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)] + 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)] + 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) + 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']: + if not self.drag_info["active"]: return # Find which widget we're currently over @@ -500,18 +595,18 @@ class TimeTracker: break current_widget = current_widget.master - if cell and cell.row_col_key != self.drag_info['last_cell']: + 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 + 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 + 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"] @@ -537,8 +632,14 @@ class TimeTracker: else: width = 6 - label = tk.Label(self.scrollable_frame, text=header, relief="solid", - borderwidth=1, width=width, height=2) + 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): @@ -549,9 +650,14 @@ class TimeTracker: 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 = 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']: + if job and job in job_dropdown["values"]: job_dropdown.set(job) # Don't auto-select first job for empty rows @@ -566,9 +672,14 @@ class TimeTracker: 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 = 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']: + if customer and customer in customer_dropdown["values"]: customer_dropdown.set(customer) # Don't auto-select first customer for empty rows @@ -576,19 +687,35 @@ class TimeTracker: 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 = 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) + 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] + "total_label": total_label, + "time_cells": self.time_cells[row_num], } def update_day_total(self): @@ -612,7 +739,10 @@ class TimeTracker: 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: + 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 @@ -623,7 +753,7 @@ class TimeTracker: job_name = widget.get() break - start_minute = (col_idx * 15) + start_minute = col_idx * 15 hour = self.start_hour + (self.start_hour * 60 + start_minute) // 60 minute = (self.start_hour * 60 + start_minute) % 60 @@ -632,12 +762,17 @@ class TimeTracker: 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]: + 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.") + 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 @@ -652,7 +787,7 @@ class TimeTracker: 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": + if cell.label["bg"] == "red": # Reset to normal state based on checked status if cell.checked: cell.label.config(bg="lightblue", text="✓") @@ -666,9 +801,11 @@ class TimeTracker: 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) + 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}") + 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""" @@ -682,24 +819,35 @@ class TimeTracker: 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 "") + 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 "") + 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 "") + 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 "") + 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) + 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 + "job": job, + "task": task, + "notes": notes, + "customer": customer, + "hours": total_hours, } def archive_day(self): @@ -708,7 +856,7 @@ class TimeTracker: 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: + if row_data and row_data["hours"] > 0: data_to_archive.append(row_data) if not data_to_archive: @@ -718,7 +866,7 @@ class TimeTracker: # 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() == '': + if not row_data["customer"] or row_data["customer"].strip() == "": rows_with_no_customer.append(row_data) if rows_with_no_customer: @@ -738,9 +886,21 @@ class TimeTracker: 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) + 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: @@ -748,19 +908,30 @@ class TimeTracker: # 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 - }) + 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}") + messagebox.showinfo( + "Archive Complete", + f"Archived {len(data_to_archive)} entries to {archive_path}", + ) # Clear the interface after successful archive self.clear_all_rows() @@ -790,62 +961,127 @@ class TimeTracker: dialog.geometry("400x350") # Invoice Number - tk.Label(dialog, text="Invoice Number:").grid(row=0, column=0, padx=10, pady=5, sticky='w') + 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") + 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]) + 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') + 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_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)) + 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') + 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_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_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)) + 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') + 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()) + invoice_num = validate_input( + "invoice_number", invoice_entry.get().strip() + ) if not invoice_num: - messagebox.showwarning("Invalid Input", "Please enter a valid invoice number.") + 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())) + 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.") + 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()) + self.generate_report_data( + invoice_num, + customer, + start_date_obj, + end_date_obj, + include_billable_var.get(), + ) dialog.destroy() except ValueError as e: @@ -860,36 +1096,51 @@ class TimeTracker: 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) + 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): + 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.") + 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: + 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() + 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')): + 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.") + messagebox.showinfo( + "No Data", f"No data found for {customer} in the selected date range." + ) return # Create pivot table data structure @@ -898,9 +1149,9 @@ class TimeTracker: all_jobs = set() for row in filtered_data: - job = row['Job'] - date_str = row['Date'] - hours = float(row['Hours']) + job = row["Job"] + date_str = row["Date"] + hours = float(row["Hours"]) pivot_data[job][date_str] += hours all_dates.add(date_str) @@ -911,11 +1162,30 @@ class TimeTracker: 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) + 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): + 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") @@ -925,12 +1195,17 @@ class TimeTracker: 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 = 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 = 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 @@ -938,14 +1213,15 @@ class TimeTracker: 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) + 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")) + "", lambda e: canvas.configure(scrollregion=canvas.bbox("all")) ) canvas.create_window((0, 0), window=table_frame, anchor="nw") @@ -959,12 +1235,29 @@ class TimeTracker: main_frame.grid_columnconfigure(0, weight=1) # Build pivot table - # Headers - headers = ["Job"] + dates + ["Total"] + # 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 = 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 @@ -973,8 +1266,9 @@ class TimeTracker: 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 = 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 @@ -982,35 +1276,75 @@ class TimeTracker: 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) + 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 + day_totals[col - 1] += hours else: - cell_label = tk.Label(table_frame, text="", relief="solid", - borderwidth=1, width=10, height=2) + 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 = 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 = 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 = 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 = 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 @@ -1022,11 +1356,19 @@ class TimeTracker: filename = filedialog.asksaveasfilename( defaultextension=".pdf", filetypes=[("PDF files", "*.pdf"), ("All files", "*.*")], - initialfile=f"Invoice_{invoice_num}_Hours_Report.pdf" + 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) + 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)}") @@ -1039,112 +1381,180 @@ class TimeTracker: # Read all archive data all_data = [] fieldnames = None - with open(archive_file, 'r', encoding='utf-8') as csvfile: + 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() + 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']): + 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' + 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) + 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 field_name in [ + "Job", + "TaskName", + "Note", + "Customer", + "username", + ]: if isinstance(field_value, str): - sanitized_row[field_name] = sanitize_csv_text(field_value) + sanitized_row[field_name] = sanitize_csv_text( + field_value + ) else: sanitized_row[field_name] = field_value - elif field_name in ['Date']: + elif field_name in ["Date"]: if isinstance(field_value, str): - sanitized_row[field_name] = sanitize_date_text(field_value) + 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}") + 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)}") + 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) + 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): + 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.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") + 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)) + 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'], + "CustomTitle", + parent=styles["Heading1"], fontSize=16, spaceAfter=20, - alignment=1 # Center + 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']) + subtitle = Paragraph( + f"Customer: {customer} | Period: {start_date} to {end_date}", + styles["Normal"], + ) story.append(subtitle) story.append(Spacer(1, 12)) # Build table data - headers = ["Job"] + dates + ["Total"] + # 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 @@ -1171,47 +1581,87 @@ class TimeTracker: totals_row.append(f"{grand_total:.2f}") table_data.append(totals_row) - # Create table with adjusted sizing for better fit + # Create table with explicit width control to prevent truncation num_dates = len(dates) + total_cols = num_dates + 2 # Job column + date columns + Total column - # Calculate appropriate font size based on number of dates - if num_dates >= 25: # Very wide reports + # 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 - elif num_dates >= 15: # Medium reports + # 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 - else: # Small reports + 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 - table = Table(table_data, repeatRows=1) + # 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 - 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), 6), - ('TOPPADDING', (0, 0), (-1, -1), 3), - ('LEFTPADDING', (0, 0), (-1, -1), 2), - ('RIGHTPADDING', (0, 0), (-1, -1), 2), - ('BACKGROUND', (0, -1), (-1, -1), colors.grey), - ('TEXTCOLOR', (0, -1), (-1, -1), colors.whitesmoke), - ('GRID', (0, 0), (-1, -1), 1, colors.black) - ])) + # 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'], + "Note", + parent=styles["Normal"], fontSize=8, textColor=colors.gray, - alignment=1 # Center + alignment=1, # Center ) note = Paragraph("*Report scaled to fit large date range*", note_style) story.append(Spacer(1, 6)) @@ -1252,8 +1702,8 @@ class TimeTracker: 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) + 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) @@ -1261,15 +1711,15 @@ class TimeTracker: # 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)) + 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 = ttk.Scrollbar(list_frame, orient="vertical", command=tree.yview) scrollbar.pack(side=tk.RIGHT, fill=tk.Y) tree.configure(yscrollcommand=scrollbar.set) @@ -1286,19 +1736,27 @@ class TimeTracker: 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') + 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) + 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) + 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) @@ -1306,14 +1764,16 @@ class TimeTracker: # 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_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 = 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) @@ -1327,52 +1787,74 @@ class TimeTracker: def edit_customer(): selected = customer_tree.selection() if not selected: - messagebox.showwarning("No Selection", "Please select a customer to edit.") + 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') + 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) + 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) + 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_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 = 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", "*.*")] + 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)) + 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) + 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) + 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) + 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) + 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) @@ -1382,49 +1864,60 @@ class TimeTracker: # 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' - }) + 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' - }) + 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()) + 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}") + 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.") + 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) + 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): @@ -1433,15 +1926,21 @@ class TimeTracker: 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') + 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') + 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') + 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()) @@ -1450,14 +1949,18 @@ class TimeTracker: 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)) + 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) + 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""" @@ -1465,16 +1968,22 @@ class TimeTracker: dialog.title("Edit Job") dialog.geometry("300x150") - tk.Label(dialog, text="Job Name:").grid(row=0, column=0, padx=10, pady=5, sticky='w') + 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') + 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') + 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()) @@ -1483,18 +1992,22 @@ class TimeTracker: return # Sanitize billable and active status - billable_text = 'Yes' if billable_var.get() else 'No' - active_text = 'Yes' if active_var.get() else 'No' - + 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: + 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) + 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""" @@ -1502,25 +2015,35 @@ class TimeTracker: 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') + 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') + 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.") + 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)) + 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) + 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""" @@ -1528,42 +2051,57 @@ class TimeTracker: dialog.title("Edit Customer") dialog.geometry("300x120") - tk.Label(dialog, text="Customer Name:").grid(row=0, column=0, padx=10, pady=5, sticky='w') + 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') + 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()) + 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.") + messagebox.showwarning( + "Invalid Input", "Customer name cannot be empty." + ) return - active_text = 'Yes' if active_var.get() else 'No' - + 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: + 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) + 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: + 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["values"] = self.get_active_jobs() + if current_selection in widget["values"]: widget.set(current_selection) def refresh_customer_dropdowns(self): @@ -1571,13 +2109,17 @@ class TimeTracker: 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: + 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["values"] = self.get_active_customers() + if current_selection in widget["values"]: widget.set(current_selection) + if __name__ == "__main__": root = tk.Tk() app = TimeTracker(root)