Files
time-tracker/time_tracker.py
Eric Taylor fbdf450c14 Move drag_info from global to class attribute for better encapsulation
## Code Quality Improvements

### Global State Removal
- Eliminated global drag_info dictionary
- Moved drag_state management into TimeTracker class
- Removed all global drag_info dependencies

### Updated Components
- **ClickableCell constructor**: Added time_tracker parameter for proper reference
- **ClickableCell methods**: Updated to use self.time_tracker.drag_info
- **TimeTracker methods**: Updated on_global_drag() and on_global_up()
- **Instance creation**: Updated ClickableCell instantiation calls

### Benefits Achieved
- **Better Encapsulation**: State properly contained within class boundaries
- **Thread Safety**: Reduced race conditions from shared global state
- **Testability**: Individual instance testing now possible
- **Instance Isolation**: Multiple TimeTracker instances work independently
- **Maintainability**: Clearer code structure with explicit dependencies

### Verification
-  All drag functionality preserved (paint/erase operations)
-  Drag state management works correctly
-  Multiple instances properly isolated
-  All 6 existing test suites pass (no regressions)
-  New comprehensive test suite created and passing
-  Application starts and runs correctly

## Files Modified
- **time_tracker.py**: Global state removal and class attribute implementation
- **AGENTS.md**: Updated coding guidelines for class preferences
- **TODO.md**: Marked drag_info task as completed, updated progress
- **tests/test_drag_info_class_attribute.py**: New comprehensive test suite

## Testing
- Added complete test suite for drag_info functionality
- Tests verify global state removal and class attribute access
- Confirms multiple instance isolation
- Validates drag state management

Code quality significantly improved with zero functional regressions.
2025-10-29 17:38:00 -04:00

1585 lines
66 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
import re
from collections import defaultdict
def sanitize_csv_text(text):
"""Sanitize text for safe CSV writing"""
if not text:
return ""
text = str(text)
# Remove dangerous characters that could cause CSV injection
dangerous_chars = ['=', '+', '-', '@', '\t', '\r', '\n']
for char in dangerous_chars:
text = text.replace(char, '')
# Remove Excel formula triggers
text = re.sub(r'^[+\-=@]', '', text)
# Truncate to reasonable length
text = text[:500]
# Strip whitespace
return text.strip()
def sanitize_date_text(date_text):
"""Sanitize date text while preserving YYYY-MM-DD format"""
if not date_text:
return ""
text = str(date_text).strip()
# Remove dangerous characters except hyphens (needed for date format)
dangerous_chars = ['=', '+', '@', '\t', '\r', '\n']
for char in dangerous_chars:
text = text.replace(char, '')
# Remove Excel formula triggers (except hyphens for date format)
text = re.sub(r'^[+\-=@]', '', text)
# Validate and fix date format if possible
# Remove extra hyphens but keep the YYYY-MM-DD structure
if text and '-' in text:
parts = text.split('-')
# Only keep first 3 parts (year, month, day)
parts = parts[:3]
# Ensure each part contains only digits
clean_parts = []
for part in parts:
clean_part = re.sub(r'[^0-9]', '', part)
if clean_part: # Only add if not empty
clean_parts.append(clean_part)
# Rebuild date if we have all parts
if len(clean_parts) == 3:
try:
# Validate the date
year = int(clean_parts[0])
month = int(clean_parts[1])
day = int(clean_parts[2])
# Basic validation
if 2000 <= year <= 2100 and 1 <= month <= 12 and 1 <= day <= 31:
text = f"{year:04d}-{month:02d}-{day:02d}"
else:
# Fallback to today if invalid
from datetime import date
text = date.today().strftime('%Y-%m-%d')
except (ValueError, TypeError):
# Fallback to today if anything goes wrong
from datetime import date
text = date.today().strftime('%Y-%m-%d')
else:
# Fallback to today if format is broken
from datetime import date
text = date.today().strftime('%Y-%m-%d')
return text.strip()
def sanitize_filename(filename):
"""Sanitize filename for safe file operations"""
if not filename:
return "default.csv"
text = str(filename)
# Remove path separators and dangerous characters
text = re.sub(r'[<>:"/\\|?*]', '', text)
text = re.sub(r'\.\.', '', text) # Remove directory traversal
# Remove leading/trailing dots and spaces
text = text.strip('. ')
# Ensure filename is not empty
if not text or text.startswith('.'):
return "default.csv"
# Ensure .csv extension
if not text.lower().endswith('.csv'):
text += '.csv'
return text
def sanitize_config_text(text, max_length=100):
"""Sanitize text for configuration files"""
if not text:
return ""
text = str(text)
# Remove characters that could break JSON/config files
text = re.sub(r'[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]', '', text)
text = re.sub(r'[{}[\]"]', '', text)
# Escape forward slashes and backslashes
text = text.replace('\\', '\\\\').replace('/', '\\/')
# Truncate to reasonable length
text = text[:max_length]
return text.strip()
def validate_input(input_type, value, **kwargs):
"""Validate and sanitize user input"""
if value is None:
value = ""
if input_type == "task_name":
return sanitize_csv_text(value)
elif input_type == "notes":
return sanitize_csv_text(value)
elif input_type == "invoice_number":
# Invoice numbers - allow alphanumeric, hyphens, underscores
value = str(value)
value = re.sub(r'[^\w\-]', '', value)
return value.strip()[:50] or "INV001"
elif input_type == "customer_name":
return sanitize_config_text(value)
elif input_type == "job_name":
return sanitize_config_text(value)
elif input_type == "file_path":
return sanitize_filename(value)
else:
# Default sanitization
return sanitize_csv_text(value)
class ClickableCell(tk.Frame):
def __init__(self, parent, row_col_key, callback, time_tracker, width=5, height=2, start_hour=9):
super().__init__(parent, relief="solid", borderwidth=1, width=width, height=height)
self.row_col_key = row_col_key
self.callback = callback
self.time_tracker = time_tracker
self.checked = False
self.start_hour = start_hour
# Calculate which hour this cell represents based on column index
col_idx = row_col_key[1]
hour_offset = col_idx // 4 # 4 cells per hour (15-minute intervals)
current_hour = start_hour + hour_offset
# Determine background color based on hour (alternating pattern)
if current_hour % 2 == 0:
self.default_bg = "#e8e8e8" # Medium gray for even hours
else:
self.default_bg = "#f5f5f5" # Light gray for odd hours
# Create label with hour-based background
self.label = tk.Label(self, text=" ", bg=self.default_bg, width=width, height=height)
self.label.pack(fill="both", expand=True)
# Bind only click events for now
self.label.bind("<Button-1>", self.on_mouse_down)
self.bind("<Button-1>", self.on_mouse_down)
def on_mouse_down(self, event):
# Start drag mode
self.time_tracker.drag_info['active'] = True
self.time_tracker.drag_info['mode'] = 'paint' if not self.checked else 'erase'
self.time_tracker.drag_info['start_row'] = self.row_col_key[0]
# Toggle this cell
self.checked = not self.checked
if self.checked:
self.label.config(bg="lightblue", text="")
else:
self.label.config(bg=self.default_bg, text=" ")
self.callback(self.row_col_key, self.checked)
self.time_tracker.drag_info['last_cell'] = self.row_col_key
def apply_drag_state(self, force_mode=None):
mode = force_mode or self.time_tracker.drag_info['mode']
if mode == 'paint' and not self.checked:
self.checked = True
self.label.config(bg="lightblue", text="")
self.callback(self.row_col_key, True)
return True
elif mode == 'erase' and self.checked:
self.checked = False
self.label.config(bg=self.default_bg, text=" ")
self.callback(self.row_col_key, False)
return True
return False
def set_state(self, checked):
self.checked = checked
if checked:
self.label.config(bg="lightblue", text="")
else:
self.label.config(bg=self.default_bg, text=" ")
class TimeTracker:
def __init__(self, root):
self.root = root
self.root.title("Time Tracker")
self.root.geometry("1400x500")
# Settings - use UNIX-compliant config directory
self.settings_file = os.path.expanduser("~/.config/time-tracker.json")
settings = self.load_settings()
self.jobs = settings['jobs']
self.customers = settings['customers']
self.start_hour = settings['start_hour']
self.work_hours = settings['work_hours']
self.archive_path = settings['archive_path']
# Drag state - moved from global to class attribute
self.drag_info = {
'active': False,
'mode': None, # 'paint' or 'erase'
'start_row': None,
'last_cell': None
}
# Main container with scrollbars
main_container = tk.Frame(root)
main_container.pack(fill=tk.BOTH, expand=True)
# Create canvas and scrollbars
canvas = tk.Canvas(main_container)
h_scrollbar = tk.Scrollbar(main_container, orient="horizontal", command=canvas.xview)
v_scrollbar = tk.Scrollbar(main_container, orient="vertical", command=canvas.yview)
self.scrollable_frame = tk.Frame(canvas)
self.scrollable_frame.bind(
"<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 using atomic write operation"""
try:
# Ensure config directory exists
config_dir = os.path.dirname(self.settings_file)
if config_dir and not os.path.exists(config_dir):
os.makedirs(config_dir, exist_ok=True)
# Create temporary file in same directory to ensure atomic operation
temp_file = self.settings_file + '.tmp'
try:
with open(temp_file, 'w') as f:
# Sanitize configuration data before saving
sanitized_jobs = []
for job in self.jobs:
sanitized_jobs.append({
'name': sanitize_config_text(job.get('name', ''), 100),
'billable': bool(job.get('billable', True)),
'active': bool(job.get('active', True))
})
sanitized_customers = []
for customer in self.customers:
sanitized_customers.append({
'name': sanitize_config_text(customer.get('name', ''), 100),
'active': bool(customer.get('active', True))
})
json.dump({
'jobs': sanitized_jobs,
'customers': sanitized_customers,
'start_hour': int(self.start_hour),
'work_hours': int(self.work_hours),
'archive_path': sanitize_filename(self.archive_path)
}, f, indent=2)
# Atomic replace operation - this is the critical fix
os.replace(temp_file, self.settings_file)
except Exception:
# Clean up temp file if something went wrong
if os.path.exists(temp_file):
try:
os.remove(temp_file)
except OSError:
pass
raise
return True
except Exception as e:
messagebox.showerror("Save Error", f"Failed to save settings: {e}")
return False
def get_selected_date(self):
"""Get the currently selected date from the form"""
try:
return date(int(self.date_year_var.get()), int(self.date_month_var.get()), int(self.date_day_var.get()))
except ValueError:
# If invalid date, return today's date
return date.today()
def set_date_to_today(self):
"""Set the date fields to today's date"""
today = date.today()
self.date_month_var.set(str(today.month))
self.date_day_var.set(str(today.day))
self.date_year_var.set(str(today.year))
def get_active_jobs(self):
"""Get list of active job names for dropdown"""
return [job['name'] for job in self.jobs if job.get('active', True)]
def get_active_customers(self):
"""Get list of active customer names for dropdown"""
return [customer['name'] for customer in self.customers if customer.get('active', True)]
def get_job_billable_status(self, job_name):
"""Get billable status for a specific job"""
for job in self.jobs:
if job['name'] == job_name:
return job.get('billable', True)
return True # Default to True if job not found
def on_global_drag(self, event):
"""Global drag handler that finds which cell we're over"""
if not self.drag_info['active']:
return
# Find which widget we're currently over
widget = event.widget.winfo_containing(event.x_root, event.y_root)
# Check if it's a ClickableCell
cell = None
current_widget = widget
while current_widget:
if isinstance(current_widget, ClickableCell):
cell = current_widget
break
current_widget = current_widget.master
if cell and cell.row_col_key != self.drag_info['last_cell']:
applied = cell.apply_drag_state()
if applied:
self.drag_info['last_cell'] = cell.row_col_key
def on_global_up(self, event):
"""Global mouse up handler"""
self.drag_info['active'] = False
self.drag_info['mode'] = None
self.drag_info['start_row'] = None
self.drag_info['last_cell'] = None
def create_headers(self):
headers = ["Job", "Task Name", "Notes", "Customer"]
# Calculate time slots based on settings
time_slots = self.work_hours * 4 # 4 slots per hour (15-minute intervals)
for i in range(time_slots):
total_minutes = self.start_hour * 60 + i * 15
hour = total_minutes // 60
minute = total_minutes % 60
time_str = f"{hour:02d}:{minute:02d}"
headers.append(time_str)
headers.append("Total Hours")
# Create header labels
for i, header in enumerate(headers):
if i < 4:
width = 15
elif i == len(headers) - 1:
width = 10
else:
width = 6
label = tk.Label(self.scrollable_frame, text=header, relief="solid",
borderwidth=1, width=width, height=2)
label.grid(row=0, column=i, sticky="nsew")
def add_empty_row(self):
self.add_row("", "", "", "")
def add_row(self, job, task_name, notes, customer):
self.row_count += 1
row_num = self.row_count
# Job dropdown
job_dropdown = ttk.Combobox(self.scrollable_frame, width=15, values=self.get_active_jobs(), state="readonly")
job_dropdown.grid(row=row_num, column=0, sticky="nsew", padx=1, pady=1)
if job and job in job_dropdown['values']:
job_dropdown.set(job)
# Don't auto-select first job for empty rows
# Task Name field
task_entry = tk.Entry(self.scrollable_frame, width=15)
task_entry.insert(0, validate_input("task_name", task_name))
task_entry.grid(row=row_num, column=1, sticky="nsew", padx=1, pady=1)
# Notes field
notes_entry = tk.Entry(self.scrollable_frame, width=15)
notes_entry.insert(0, validate_input("notes", notes))
notes_entry.grid(row=row_num, column=2, sticky="nsew", padx=1, pady=1)
# Customer field
customer_dropdown = ttk.Combobox(self.scrollable_frame, width=15, values=self.get_active_customers(), state="readonly")
customer_dropdown.grid(row=row_num, column=3, sticky="nsew", padx=1, pady=1)
if customer and customer in customer_dropdown['values']:
customer_dropdown.set(customer)
# Don't auto-select first customer for empty rows
# Time cells
self.time_cells[row_num] = {}
time_slots = self.work_hours * 4 # Calculate based on current settings
for i in range(time_slots):
cell = ClickableCell(self.scrollable_frame, (row_num, i), self.on_time_cell_clicked, self, width=5, height=1, start_hour=self.start_hour)
cell.grid(row=row_num, column=4 + i, sticky="nsew", padx=1, pady=1)
self.time_cells[row_num][i] = cell
# Total hours label
total_label = tk.Label(self.scrollable_frame, text="0.00", relief="solid",
borderwidth=1, width=10, height=2)
total_label.grid(row=row_num, column=4 + time_slots, sticky="nsew", padx=1, pady=1)
# Store row data
self.data_rows[row_num] = {
'total_label': total_label,
'time_cells': self.time_cells[row_num]
}
def update_day_total(self):
"""Update the daily total hours display"""
if self.day_total_label:
total_slots = len(self.time_assignments)
total_hours = total_slots * 0.25
self.day_total_label.config(text=f"{total_hours:.2f} hours")
# Change color if over 8 hours
if total_hours > 8.0:
self.day_total_label.config(bg="salmon", fg="black")
elif total_hours == 8.0:
self.day_total_label.config(bg="lightgreen", fg="black")
else:
self.day_total_label.config(bg="lightyellow", fg="black")
def on_time_cell_clicked(self, row_col_key, checked):
row_num, col_idx = row_col_key
cell = self.time_cells[row_num][col_idx]
if checked:
# Check if this time slot is already assigned to another row
if col_idx in self.time_assignments and self.time_assignments[col_idx] != row_num:
existing_row = self.time_assignments[col_idx]
# Find the job name for the existing assignment
job_name = f"Task on row {existing_row}"
widgets = self.scrollable_frame.grid_slaves(row=existing_row)
for widget in widgets:
if isinstance(widget, ttk.Combobox): # Job dropdown column
job_name = widget.get()
break
start_minute = (col_idx * 15)
hour = self.start_hour + (self.start_hour * 60 + start_minute) // 60
minute = (self.start_hour * 60 + start_minute) % 60
# Highlight conflicting cells in red
cell = self.time_cells[row_num][col_idx]
cell.label.config(bg="red", text="")
# Also highlight the existing conflicting cell
if existing_row in self.time_cells and col_idx in self.time_cells[existing_row]:
existing_cell = self.time_cells[existing_row][col_idx]
existing_cell.label.config(bg="red", text="")
messagebox.showwarning("Time Conflict",
f"Time slot at {hour:02d}:{minute:02d} is already assigned to:\n{job_name}\n\nConflicting slots marked in red.")
# Don't return - let the cell remain checked so user can see the conflict
# Assign this time slot to this row
self.time_assignments[col_idx] = row_num
else:
# Remove assignment from this row
if self.time_assignments.get(col_idx) == row_num:
del self.time_assignments[col_idx]
# Clear any red conflict highlighting for this column across all rows
for other_row_num in self.time_cells:
if col_idx in self.time_cells[other_row_num]:
cell = self.time_cells[other_row_num][col_idx]
if cell.label['bg'] == "red":
# Reset to normal state based on checked status
if cell.checked:
cell.label.config(bg="lightblue", text="")
else:
cell.label.config(bg=cell.default_bg, text=" ")
self.update_total_hours(row_num)
self.update_day_total()
def update_total_hours(self, row_num):
if row_num not in self.data_rows:
return
checked_count = sum(1 for cell in self.data_rows[row_num]['time_cells'].values() if cell.checked)
total_hours = checked_count * 0.25
self.data_rows[row_num]['total_label'].config(text=f"{total_hours:.2f}")
def get_row_data(self, row_num):
"""Extract data from a specific row"""
if row_num not in self.data_rows:
return None
# Get text from entry widgets with sanitization
widgets = self.scrollable_frame.grid_slaves(row=row_num)
job = task = notes = customer = ""
for widget in widgets:
col = widget.grid_info()["column"]
if col == 0: # Job dropdown
job = validate_input("job_name", widget.get() if isinstance(widget, ttk.Combobox) else "")
elif col == 1: # Task Name column
task = validate_input("task_name", widget.get() if isinstance(widget, tk.Entry) else "")
elif col == 2: # Notes column
notes = validate_input("notes", widget.get() if isinstance(widget, tk.Entry) else "")
elif col == 3: # Customer column (now dropdown)
customer = validate_input("customer_name", widget.get() if isinstance(widget, ttk.Combobox) else "")
# Calculate hours
checked_count = sum(1 for cell in self.data_rows[row_num]['time_cells'].values() if cell.checked)
total_hours = checked_count * 0.25
return {
'job': job,
'task': task,
'notes': notes,
'customer': customer,
'hours': total_hours
}
def archive_day(self):
"""Archive current day's data to CSV"""
# Check if there's any data to archive
data_to_archive = []
for row_num in self.data_rows:
row_data = self.get_row_data(row_num)
if row_data and row_data['hours'] > 0:
data_to_archive.append(row_data)
if not data_to_archive:
messagebox.showinfo("No Data", "No time entries to archive.")
return
# Check for missing customer data
rows_with_no_customer = []
for row_data in data_to_archive:
if not row_data['customer'] or row_data['customer'].strip() == '':
rows_with_no_customer.append(row_data)
if rows_with_no_customer:
error_msg = f"Found {len(rows_with_no_customer)} time entry(s) with no customer specified:\n\n"
for i, row in enumerate(rows_with_no_customer[:3]): # Show first 3 examples
error_msg += f"{row['job']}: {row['task'] or '(no task)'} ({row['hours']:.2f} hours)\n"
if len(rows_with_no_customer) > 3:
error_msg += f"... and {len(rows_with_no_customer) - 3} more\n"
error_msg += "\nPlease select a customer for all entries before archiving."
messagebox.showerror("Missing Customer Data", error_msg)
return
# Get archive file path from settings
archive_path = self.archive_path
# Check if file exists to determine if we need headers
file_exists = os.path.exists(archive_path)
try:
with open(archive_path, 'a', newline='', encoding='utf-8') as csvfile:
fieldnames = ['Job', 'TaskName', 'Note', 'Customer', 'Hours', 'Date', 'username', 'Billable', 'Billed']
writer = csv.DictWriter(csvfile, fieldnames=fieldnames, quoting=csv.QUOTE_MINIMAL)
# Write header if file is new
if not file_exists:
writer.writeheader()
# Write each row with archive metadata
for row_data in data_to_archive:
writer.writerow({
'Job': sanitize_csv_text(row_data['job']),
'TaskName': sanitize_csv_text(row_data['task']),
'Note': sanitize_csv_text(row_data['notes']),
'Customer': sanitize_csv_text(row_data['customer']),
'Hours': float(row_data['hours']), # Ensure numeric
'Date': sanitize_date_text(self.get_selected_date().strftime('%Y-%m-%d')),
'username': sanitize_csv_text(os.getenv('USER', os.getenv('USERNAME', 'unknown'))),
'Billable': bool(self.get_job_billable_status(row_data['job'])),
'Billed': False # Default to False for now
})
messagebox.showinfo("Archive Complete", f"Archived {len(data_to_archive)} entries to {archive_path}")
# Clear the interface after successful archive
self.clear_all_rows()
except Exception as e:
messagebox.showerror("Archive Failed", f"Failed to archive data: {str(e)}")
def clear_all_rows(self):
"""Clear all data rows from the interface"""
# Remove all widgets in scrollable frame except headers
for widget in list(self.scrollable_frame.winfo_children()):
widget.destroy()
# Reset data structures
self.data_rows = {}
self.time_cells = {}
self.time_assignments = {}
self.row_count = 0
# Recreate headers
self.create_headers()
def open_report_dialog(self):
"""Open dialog for generating Billable Hours Details report"""
dialog = tk.Toplevel(self.root)
dialog.title("Billable Hours Details Report")
dialog.geometry("400x350")
# Invoice Number
tk.Label(dialog, text="Invoice Number:").grid(row=0, column=0, padx=10, pady=5, sticky='w')
invoice_entry = tk.Entry(dialog, width=30)
invoice_entry.grid(row=0, column=1, padx=10, pady=5)
# Customer Selection
tk.Label(dialog, text="Customer:").grid(row=1, column=0, padx=10, pady=5, sticky='w')
customer_combo = ttk.Combobox(dialog, width=27, values=self.get_active_customers(), state="readonly")
customer_combo.grid(row=1, column=1, padx=10, pady=5)
if customer_combo['values']:
customer_combo.set(customer_combo['values'][0])
# Date Range
tk.Label(dialog, text="Start Date:").grid(row=2, column=0, padx=10, pady=5, sticky='w')
start_date_frame = tk.Frame(dialog)
start_date_frame.grid(row=2, column=1, padx=10, pady=5, sticky='w')
start_month_var = tk.StringVar(value=str(datetime.now().month))
start_day_var = tk.StringVar(value="1")
start_year_var = tk.StringVar(value=str(datetime.now().year))
ttk.Combobox(start_date_frame, textvariable=start_month_var, width=5, values=[str(i) for i in range(1,13)]).pack(side=tk.LEFT)
ttk.Combobox(start_date_frame, textvariable=start_day_var, width=5, values=[str(i) for i in range(1,32)]).pack(side=tk.LEFT, padx=(5,0))
ttk.Combobox(start_date_frame, textvariable=start_year_var, width=8, values=[str(i) for i in range(2020,2031)]).pack(side=tk.LEFT, padx=(5,0))
tk.Label(dialog, text="End Date:").grid(row=3, column=0, padx=10, pady=5, sticky='w')
end_date_frame = tk.Frame(dialog)
end_date_frame.grid(row=3, column=1, padx=10, pady=5, sticky='w')
end_month_var = tk.StringVar(value=str(datetime.now().month))
end_day_var = tk.StringVar(value=str(calendar.monthrange(datetime.now().year, datetime.now().month)[1]))
end_year_var = tk.StringVar(value=str(datetime.now().year))
ttk.Combobox(end_date_frame, textvariable=end_month_var, width=5, values=[str(i) for i in range(1,13)]).pack(side=tk.LEFT)
ttk.Combobox(end_date_frame, textvariable=end_day_var, width=5, values=[str(i) for i in range(1,32)]).pack(side=tk.LEFT, padx=(5,0))
ttk.Combobox(end_date_frame, textvariable=end_year_var, width=8, values=[str(i) for i in range(2020,2031)]).pack(side=tk.LEFT, padx=(5,0))
# Include only billable checkbox
include_billable_var = tk.BooleanVar(value=True)
tk.Checkbutton(dialog, text="Include only billable hours", variable=include_billable_var).grid(row=4, column=0, columnspan=2, padx=10, pady=10, sticky='w')
def generate_report():
try:
invoice_num = validate_input("invoice_number", invoice_entry.get().strip())
if not invoice_num:
messagebox.showwarning("Invalid Input", "Please enter a valid invoice number.")
return
customer = validate_input("customer_name", customer_combo.get())
start_date_obj = date(int(start_year_var.get()), int(start_month_var.get()), int(start_day_var.get()))
end_date_obj = date(int(end_year_var.get()), int(end_month_var.get()), int(end_day_var.get()))
if start_date_obj > end_date_obj:
messagebox.showwarning("Invalid Date Range", "Start date must be before or equal to end date.")
return
self.generate_report_data(invoice_num, customer, start_date_obj, end_date_obj, include_billable_var.get())
dialog.destroy()
except ValueError as e:
messagebox.showerror("Invalid Date", "Please enter valid dates.")
except Exception as e:
messagebox.showerror("Error", f"Failed to generate report: {str(e)}")
def cancel():
dialog.destroy()
# Buttons
btn_frame = tk.Frame(dialog)
btn_frame.grid(row=5, column=0, columnspan=2, pady=20)
tk.Button(btn_frame, text="Generate Report", command=generate_report, width=15).pack(side=tk.LEFT, padx=5)
tk.Button(btn_frame, text="Cancel", command=cancel, width=15).pack(side=tk.LEFT, padx=5)
def generate_report_data(self, invoice_num, customer, start_date, end_date, billable_only):
"""Generate report data and show preview dialog"""
archive_file = self.archive_path
if not os.path.exists(archive_file):
messagebox.showerror("No Data", "Archive file not found. No data to generate report.")
return
# Read and filter CSV data
filtered_data = []
try:
with open(archive_file, 'r', encoding='utf-8') as csvfile:
reader = csv.DictReader(csvfile)
for row in reader:
row_date = datetime.strptime(row['Date'], '%Y-%m-%d').date()
# Apply filters
if (row['Customer'] == customer and
start_date <= row_date <= end_date and
(not billable_only or row.get('Billable', 'True').lower() == 'true')):
filtered_data.append(row)
except Exception as e:
messagebox.showerror("Read Error", f"Failed to read archive file: {str(e)}")
return
if not filtered_data:
messagebox.showinfo("No Data", f"No data found for {customer} in the selected date range.")
return
# Create pivot table data structure
pivot_data = defaultdict(lambda: defaultdict(float)) # job -> date -> hours
all_dates = set()
all_jobs = set()
for row in filtered_data:
job = row['Job']
date_str = row['Date']
hours = float(row['Hours'])
pivot_data[job][date_str] += hours
all_dates.add(date_str)
all_jobs.add(job)
# Sort dates and jobs
sorted_dates = sorted(all_dates)
sorted_jobs = sorted(all_jobs)
# Show report preview dialog
self.show_report_preview(invoice_num, customer, start_date, end_date,
pivot_data, sorted_jobs, sorted_dates, filtered_data, billable_only)
def show_report_preview(self, invoice_num, customer, start_date, end_date,
pivot_data, jobs, dates, filtered_data, billable_only=True):
"""Show report preview dialog with pivot table and export options"""
preview_window = tk.Toplevel(self.root)
preview_window.title(f"Invoice #{invoice_num} Billable Hours Details")
preview_window.geometry("1200x600")
# Title frame
title_frame = tk.Frame(preview_window)
title_frame.pack(fill=tk.X, padx=10, pady=10)
title_label = tk.Label(title_frame, text=f"Invoice #{invoice_num} Billable Hours Details",
font=("Arial", 16, "bold"))
title_label.pack(side=tk.LEFT)
subtitle_label = tk.Label(title_frame,
text=f"Customer: {customer} | Period: {start_date} to {end_date}")
subtitle_label.pack(side=tk.LEFT, padx=(20, 0))
# Create scrollable frame for table
main_frame = tk.Frame(preview_window)
main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
canvas = tk.Canvas(main_frame)
h_scrollbar = tk.Scrollbar(main_frame, orient="horizontal", command=canvas.xview)
v_scrollbar = tk.Scrollbar(main_frame, orient="vertical", command=canvas.yview)
table_frame = tk.Frame(canvas)
table_frame.bind(
"<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, quoting=csv.QUOTE_MINIMAL)
writer.writeheader()
# Sanitize all data before writing
sanitized_data = []
for row in all_data:
sanitized_row = {}
for field_name, field_value in row.items():
if field_name in ['Job', 'TaskName', 'Note', 'Customer', 'username']:
if isinstance(field_value, str):
sanitized_row[field_name] = sanitize_csv_text(field_value)
else:
sanitized_row[field_name] = field_value
elif field_name in ['Date']:
if isinstance(field_value, str):
sanitized_row[field_name] = sanitize_date_text(field_value)
else:
sanitized_row[field_name] = field_value
else:
# For Hours, Billable, Billed - keep as-is but ensure proper types
sanitized_row[field_name] = field_value
sanitized_data.append(sanitized_row)
writer.writerows(sanitized_data)
messagebox.showinfo("Success", f"Marked {len(filtered_data)} entries as billed for invoice #{invoice_num}")
# Close preview window after marking as billed
preview_window.destroy()
except Exception as e:
messagebox.showerror("Error Marking Billed", f"Failed to mark entries as billed: {str(e)}")
def close_preview():
preview_window.destroy()
tk.Button(button_frame, text="Mark as Billed", command=mark_as_billed, width=15, bg="lightgreen").pack(side=tk.LEFT, padx=5)
tk.Button(button_frame, text="Export to PDF", command=export_to_pdf, width=15).pack(side=tk.LEFT, padx=5)
tk.Button(button_frame, text="Close", command=close_preview, width=15).pack(side=tk.LEFT, padx=5)
def export_to_pdf(self, filename, invoice_num, customer, start_date, end_date,
pivot_data, jobs, dates):
"""Export report data to PDF format"""
try:
from reportlab.lib.pagesizes import letter, landscape
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib import colors
from reportlab.lib.units import inch
except ImportError:
messagebox.showerror("Missing Library",
"reportlab library is required for PDF export. Please install it with:\n\n"
"pip install reportlab")
return
doc = SimpleDocTemplate(filename, pagesize=landscape(letter))
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 = validate_input("file_path", archive_path_var.get().strip())
if new_archive_path:
# If directory doesn't exist, try to create it
import os.path
dir_name = os.path.dirname(new_archive_path)
if dir_name and not os.path.exists(dir_name):
try:
os.makedirs(dir_name)
except OSError:
messagebox.showerror("Invalid Path", f"Cannot create directory: {dir_name}")
return
self.archive_path = new_archive_path
if self.save_settings():
messagebox.showinfo("Success", "Settings saved successfully. Please restart application for time changes to take effect.")
settings_window.destroy()
# Refresh job and customer dropdowns immediately
self.refresh_job_dropdowns()
self.refresh_customer_dropdowns()
tk.Button(bottom_btn_frame, text="Save Settings", command=save_changes).pack(side=tk.RIGHT, padx=2)
tk.Button(bottom_btn_frame, text="Cancel", command=settings_window.destroy).pack(side=tk.RIGHT, padx=2)
# Simplified customer and job management methods (using original pattern)
def add_job_dialog(self, tree):
"""Dialog to add new job"""
dialog = tk.Toplevel(self.root)
dialog.title("Add New Job")
dialog.geometry("300x150")
tk.Label(dialog, text="Job Name:").grid(row=0, column=0, padx=10, pady=5, sticky='w')
name_entry = tk.Entry(dialog)
name_entry.grid(row=0, column=1, padx=10, pady=5)
billable_var = tk.BooleanVar(value=True)
tk.Checkbutton(dialog, text="Billable", variable=billable_var).grid(row=1, column=0, columnspan=2, padx=10, pady=5, sticky='w')
active_var = tk.BooleanVar(value=True)
tk.Checkbutton(dialog, text="Active", variable=active_var).grid(row=2, column=0, columnspan=2, padx=10, pady=5, sticky='w')
def save_job():
job_name = validate_input("job_name", name_entry.get().strip())
if not job_name:
messagebox.showwarning("Invalid Input", "Job name cannot be empty.")
return
# Sanitize billable and active status
billable_text = 'Yes' if billable_var.get() else 'No'
active_text = 'Yes' if active_var.get() else 'No'
tree.insert('', tk.END, values=(job_name, billable_text, active_text))
dialog.destroy()
tk.Button(dialog, text="Save", command=save_job).grid(row=3, column=0, padx=10, pady=10)
tk.Button(dialog, text="Cancel", command=dialog.destroy).grid(row=3, column=1, padx=10, pady=10)
def edit_job_dialog(self, tree, job_name, is_billable, is_active):
"""Dialog to edit existing job"""
dialog = tk.Toplevel(self.root)
dialog.title("Edit Job")
dialog.geometry("300x150")
tk.Label(dialog, text="Job Name:").grid(row=0, column=0, padx=10, pady=5, sticky='w')
name_entry = tk.Entry(dialog)
name_entry.insert(0, job_name)
name_entry.grid(row=0, column=1, padx=10, pady=5)
billable_var = tk.BooleanVar(value=is_billable)
tk.Checkbutton(dialog, text="Billable", variable=billable_var).grid(row=1, column=0, columnspan=2, padx=10, pady=5, sticky='w')
active_var = tk.BooleanVar(value=is_active)
tk.Checkbutton(dialog, text="Active", variable=active_var).grid(row=2, column=0, columnspan=2, padx=10, pady=5, sticky='w')
def save_job():
new_job_name = validate_input("job_name", name_entry.get().strip())
if not new_job_name:
messagebox.showwarning("Invalid Input", "Job name cannot be empty.")
return
# Sanitize billable and active status
billable_text = 'Yes' if billable_var.get() else 'No'
active_text = 'Yes' if active_var.get() else 'No'
# Find and update the tree item
for item in tree.get_children():
if tree.item(item)['values'][0] == job_name:
tree.item(item, values=(new_job_name, billable_text, active_text))
break
dialog.destroy()
tk.Button(dialog, text="Save", command=save_job).grid(row=3, column=0, padx=10, pady=10)
tk.Button(dialog, text="Cancel", command=dialog.destroy).grid(row=3, column=1, padx=10, pady=10)
def add_customer_dialog(self, tree):
"""Dialog to add new customer"""
dialog = tk.Toplevel(self.root)
dialog.title("Add New Customer")
dialog.geometry("300x150")
tk.Label(dialog, text="Customer Name:").grid(row=0, column=0, padx=10, pady=5, sticky='w')
name_entry = tk.Entry(dialog)
name_entry.grid(row=0, column=1, padx=10, pady=5)
active_var = tk.BooleanVar(value=True)
tk.Checkbutton(dialog, text="Active", variable=active_var).grid(row=1, column=0, columnspan=2, padx=10, pady=5, sticky='w')
def save_customer():
customer_name = validate_input("customer_name", name_entry.get().strip())
if not customer_name:
messagebox.showwarning("Invalid Input", "Customer name cannot be empty.")
return
active_text = 'Yes' if active_var.get() else 'No'
tree.insert('', tk.END, values=(customer_name, active_text))
dialog.destroy()
tk.Button(dialog, text="Save", command=save_customer).grid(row=2, column=0, padx=10, pady=10)
tk.Button(dialog, text="Cancel", command=dialog.destroy).grid(row=2, column=1, padx=10, pady=10)
def edit_customer_dialog(self, tree, customer_name, is_active):
"""Dialog to edit existing customer"""
dialog = tk.Toplevel(self.root)
dialog.title("Edit Customer")
dialog.geometry("300x120")
tk.Label(dialog, text="Customer Name:").grid(row=0, column=0, padx=10, pady=5, sticky='w')
name_entry = tk.Entry(dialog)
name_entry.insert(0, customer_name)
name_entry.grid(row=0, column=1, padx=10, pady=5)
active_var = tk.BooleanVar(value=is_active)
tk.Checkbutton(dialog, text="Active", variable=active_var).grid(row=1, column=0, columnspan=2, padx=10, pady=5, sticky='w')
def save_customer():
new_customer_name = validate_input("customer_name", name_entry.get().strip())
if not new_customer_name:
messagebox.showwarning("Invalid Input", "Customer name cannot be empty.")
return
active_text = 'Yes' if active_var.get() else 'No'
# Find and update tree item
for item in tree.get_children():
if tree.item(item)['values'][0] == customer_name:
tree.item(item, values=(new_customer_name, active_text))
break
dialog.destroy()
tk.Button(dialog, text="Save", command=save_customer).grid(row=2, column=0, padx=10, pady=10)
tk.Button(dialog, text="Cancel", command=dialog.destroy).grid(row=2, column=1, padx=10, pady=10)
def refresh_job_dropdowns(self):
"""Refresh all job dropdowns in the interface"""
for row_num in self.data_rows:
widgets = self.scrollable_frame.grid_slaves(row=row_num)
for widget in widgets:
if isinstance(widget, ttk.Combobox) and widget.grid_info()["column"] == 0:
# This is a job dropdown
current_selection = widget.get()
widget['values'] = self.get_active_jobs()
if current_selection in widget['values']:
widget.set(current_selection)
def refresh_customer_dropdowns(self):
"""Refresh all customer dropdowns in the interface"""
for row_num in self.data_rows:
widgets = self.scrollable_frame.grid_slaves(row=row_num)
for widget in widgets:
if isinstance(widget, ttk.Combobox) and widget.grid_info()["column"] == 3:
# This is a customer dropdown
current_selection = widget.get()
widget['values'] = self.get_active_customers()
if current_selection in widget['values']:
widget.set(current_selection)
if __name__ == "__main__":
root = tk.Tk()
app = TimeTracker(root)
root.mainloop()