commit eafa1a5567eba0d80cf8195866eae0f9862d6de8 Author: Eric Taylor Date: Wed Oct 29 11:29:20 2025 -0400 Inital Commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0967698 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Ignore all files by default +* + +# Whitelist specific files to track +!time_tracker.py + +# Keep this gitignore file +!.gitignore + +# Ignore common Python artifacts +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +*.so + +# Ignore virtual environments +venv/ +env/ +ENV/ + +# Ignore IDE files +.vscode/ +*.swp +*.swo +*~ + +# Ignore temp files +*.tmp +*.bak + +# Ignore log files +*.log + +# Ignore configuration and data files +*.json +*.csv +!time_tracker.py \ No newline at end of file diff --git a/time_tracker.py b/time_tracker.py new file mode 100644 index 0000000..7ff0afb --- /dev/null +++ b/time_tracker.py @@ -0,0 +1,1364 @@ +#!/usr/bin/env python3 + +#TODO: +# - alternating colors for each hour +# - more compact user interface +# - button to open the archive csv in text editor (vim) +# - make sure it runs on windows + +import tkinter as tk +from tkinter import ttk, messagebox, filedialog +from datetime import time, datetime, date +import csv +import os +import json +import calendar +from collections import defaultdict + +# Global drag state +drag_info = { + 'active': False, + 'mode': None, # 'paint' or 'erase' + 'start_row': None, + 'last_cell': None +} + +class ClickableCell(tk.Frame): + def __init__(self, parent, row_col_key, callback, width=5, height=2): + super().__init__(parent, relief="solid", borderwidth=1, width=width, height=height) + self.row_col_key = row_col_key + self.callback = callback + self.checked = False + + # Create label + self.label = tk.Label(self, text=" ", bg="white", 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): + global drag_info + + # Start drag mode + drag_info['active'] = True + drag_info['mode'] = 'paint' if not self.checked else 'erase' + 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="white", text=" ") + self.callback(self.row_col_key, self.checked) + drag_info['last_cell'] = self.row_col_key + + def apply_drag_state(self, force_mode=None): + """Apply drag state to this cell""" + global drag_info + + mode = force_mode or 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="white", 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="white", 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'] + + # 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""" + 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) + + with open(self.settings_file, 'w') as f: + json.dump({ + 'jobs': self.jobs, + 'customers': self.customers, + 'start_hour': self.start_hour, + 'work_hours': self.work_hours, + 'archive_path': self.archive_path + }, f, indent=2) + 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""" + global drag_info + + if not 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 != drag_info['last_cell']: + applied = cell.apply_drag_state() + if applied: + drag_info['last_cell'] = cell.row_col_key + + def on_global_up(self, event): + """Global mouse up handler""" + global drag_info + + drag_info['active'] = False + drag_info['mode'] = None + drag_info['start_row'] = None + 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, 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, 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, width=5, height=1) + 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="white", 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 + 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 = widget.get() if isinstance(widget, ttk.Combobox) else "" + elif col == 1: # Task Name column + task = widget.get() if isinstance(widget, tk.Entry) else "" + elif col == 2: # Notes column + notes = widget.get() if isinstance(widget, tk.Entry) else "" + elif col == 3: # Customer column (now dropdown) + customer = 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) + + # 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': row_data['job'], + 'TaskName': row_data['task'], + 'Note': row_data['notes'], + 'Customer': row_data['customer'], + 'Hours': row_data['hours'], + 'Date': self.get_selected_date().strftime('%Y-%m-%d'), + 'username': os.getenv('USER', os.getenv('USERNAME', 'unknown')), + 'Billable': 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 = invoice_entry.get().strip() + if not invoice_num: + messagebox.showwarning("Invalid Input", "Please enter an invoice number.") + return + + customer = 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 + # Headers + headers = ["Job"] + 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) + writer.writeheader() + writer.writerows(all_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)) + 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 + headers = ["Job"] + 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 adjusted sizing for better fit + num_dates = len(dates) + + # Calculate appropriate font size based on number of dates + if num_dates >= 25: # Very wide reports + font_size = 5 + elif num_dates >= 15: # Medium reports + font_size = 6 + else: # Small reports + font_size = 7 + + table = Table(table_data, repeatRows=1) + + # 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) + ])) + + 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 = 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(): + name = name_entry.get().strip() + if not name: + messagebox.showwarning("Invalid Input", "Job name cannot be empty.") + return + + tree.insert('', tk.END, values=(name, 'Yes' if billable_var.get() else 'No', 'Yes' if active_var.get() else 'No')) + 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(): + name = name_entry.get().strip() + if not name: + messagebox.showwarning("Invalid Input", "Job name cannot be empty.") + return + + # Find and update the tree item + for item in tree.get_children(): + if tree.item(item)['values'][0] == job_name: + tree.item(item, values=(name, 'Yes' if billable_var.get() else 'No', 'Yes' if active_var.get() else 'No')) + 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(): + name = name_entry.get().strip() + if not name: + messagebox.showwarning("Invalid Input", "Customer name cannot be empty.") + return + + tree.insert('', tk.END, values=(name, 'Yes' if active_var.get() else 'No')) + 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(): + name = name_entry.get().strip() + if not name: + messagebox.showwarning("Invalid Input", "Customer name cannot be empty.") + return + + # Find and update tree item + for item in tree.get_children(): + if tree.item(item)['values'][0] == customer_name: + tree.item(item, values=(name, 'Yes' if active_var.get() else 'No')) + 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()