Files
time-tracker/time_tracker.py

1373 lines
58 KiB
Python

#!/usr/bin/env python3
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
from datetime import time, datetime, date
import csv
import os
import json
import calendar
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, start_hour=9):
super().__init__(parent, relief="solid", borderwidth=1, width=width, height=height)
self.row_col_key = row_col_key
self.callback = callback
self.checked = False
self.start_hour = start_hour
# Calculate which hour this cell represents based on column index
col_idx = row_col_key[1]
hour_offset = col_idx // 4 # 4 cells per hour (15-minute intervals)
current_hour = start_hour + hour_offset
# Determine background color based on hour (alternating pattern)
if current_hour % 2 == 0:
self.default_bg = "#e8e8e8" # Medium gray for even hours
else:
self.default_bg = "#f5f5f5" # Light gray for odd hours
# Create label with hour-based background
self.label = tk.Label(self, text=" ", bg=self.default_bg, width=width, height=height)
self.label.pack(fill="both", expand=True)
# Bind only click events for now
self.label.bind("<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=self.default_bg, 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=self.default_bg, text=" ")
self.callback(self.row_col_key, False)
return True
return False
def set_state(self, checked):
self.checked = checked
if checked:
self.label.config(bg="lightblue", text="")
else:
self.label.config(bg=self.default_bg, text=" ")
class TimeTracker:
def __init__(self, root):
self.root = root
self.root.title("Time Tracker")
self.root.geometry("1400x500")
# Settings - use UNIX-compliant config directory
self.settings_file = os.path.expanduser("~/.config/time-tracker.json")
settings = self.load_settings()
self.jobs = settings['jobs']
self.customers = settings['customers']
self.start_hour = settings['start_hour']
self.work_hours = settings['work_hours']
self.archive_path = settings['archive_path']
# 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, start_hour=self.start_hour)
cell.grid(row=row_num, column=4 + i, sticky="nsew", padx=1, pady=1)
self.time_cells[row_num][i] = cell
# Total hours label
total_label = tk.Label(self.scrollable_frame, text="0.00", relief="solid",
borderwidth=1, width=10, height=2)
total_label.grid(row=row_num, column=4 + time_slots, sticky="nsew", padx=1, pady=1)
# Store row data
self.data_rows[row_num] = {
'total_label': total_label,
'time_cells': self.time_cells[row_num]
}
def update_day_total(self):
"""Update the daily total hours display"""
if self.day_total_label:
total_slots = len(self.time_assignments)
total_hours = total_slots * 0.25
self.day_total_label.config(text=f"{total_hours:.2f} hours")
# Change color if over 8 hours
if total_hours > 8.0:
self.day_total_label.config(bg="salmon", fg="black")
elif total_hours == 8.0:
self.day_total_label.config(bg="lightgreen", fg="black")
else:
self.day_total_label.config(bg="lightyellow", fg="black")
def on_time_cell_clicked(self, row_col_key, checked):
row_num, col_idx = row_col_key
cell = self.time_cells[row_num][col_idx]
if checked:
# Check if this time slot is already assigned to another row
if col_idx in self.time_assignments and self.time_assignments[col_idx] != row_num:
existing_row = self.time_assignments[col_idx]
# Find the job name for the existing assignment
job_name = f"Task on row {existing_row}"
widgets = self.scrollable_frame.grid_slaves(row=existing_row)
for widget in widgets:
if isinstance(widget, ttk.Combobox): # Job dropdown column
job_name = widget.get()
break
start_minute = (col_idx * 15)
hour = self.start_hour + (self.start_hour * 60 + start_minute) // 60
minute = (self.start_hour * 60 + start_minute) % 60
# Highlight conflicting cells in red
cell = self.time_cells[row_num][col_idx]
cell.label.config(bg="red", text="")
# Also highlight the existing conflicting cell
if existing_row in self.time_cells and col_idx in self.time_cells[existing_row]:
existing_cell = self.time_cells[existing_row][col_idx]
existing_cell.label.config(bg="red", text="")
messagebox.showwarning("Time Conflict",
f"Time slot at {hour:02d}:{minute:02d} is already assigned to:\n{job_name}\n\nConflicting slots marked in red.")
# Don't return - let the cell remain checked so user can see the conflict
# Assign this time slot to this row
self.time_assignments[col_idx] = row_num
else:
# Remove assignment from this row
if self.time_assignments.get(col_idx) == row_num:
del self.time_assignments[col_idx]
# Clear any red conflict highlighting for this column across all rows
for other_row_num in self.time_cells:
if col_idx in self.time_cells[other_row_num]:
cell = self.time_cells[other_row_num][col_idx]
if cell.label['bg'] == "red":
# Reset to normal state based on checked status
if cell.checked:
cell.label.config(bg="lightblue", text="")
else:
cell.label.config(bg=cell.default_bg, text=" ")
self.update_total_hours(row_num)
self.update_day_total()
def update_total_hours(self, row_num):
if row_num not in self.data_rows:
return
checked_count = sum(1 for cell in self.data_rows[row_num]['time_cells'].values() if cell.checked)
total_hours = checked_count * 0.25
self.data_rows[row_num]['total_label'].config(text=f"{total_hours:.2f}")
def get_row_data(self, row_num):
"""Extract data from a specific row"""
if row_num not in self.data_rows:
return None
# Get text from entry widgets
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()