1373 lines
58 KiB
Python
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()
|