Files
time-tracker/time_tracker.py
2025-10-29 11:29:20 -04:00

1365 lines
58 KiB
Python

#!/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("<Button-1>", self.on_mouse_down)
self.bind("<Button-1>", 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(
"<Configure>",
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("<B1-Motion>", self.on_global_drag)
self.root.bind("<ButtonRelease-1>", 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(
"<Configure>",
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()