#!/usr/bin/env python3 #TODO: # - alternating colors for each hour # - more compact user interface # - button to open the archive csv in text editor (vim) # - make sure it runs on windows import tkinter as tk from tkinter import ttk, messagebox, filedialog from datetime import time, datetime, date import csv import os import json import calendar from collections import defaultdict # Global drag state drag_info = { 'active': False, 'mode': None, # 'paint' or 'erase' 'start_row': None, 'last_cell': None } class ClickableCell(tk.Frame): def __init__(self, parent, row_col_key, callback, width=5, height=2): super().__init__(parent, relief="solid", borderwidth=1, width=width, height=height) self.row_col_key = row_col_key self.callback = callback self.checked = False # Create label self.label = tk.Label(self, text=" ", bg="white", width=width, height=height) self.label.pack(fill="both", expand=True) # Bind only click events for now self.label.bind("", self.on_mouse_down) self.bind("", self.on_mouse_down) def on_mouse_down(self, event): global drag_info # Start drag mode drag_info['active'] = True drag_info['mode'] = 'paint' if not self.checked else 'erase' drag_info['start_row'] = self.row_col_key[0] # Toggle this cell self.checked = not self.checked if self.checked: self.label.config(bg="lightblue", text="✓") else: self.label.config(bg="white", text=" ") self.callback(self.row_col_key, self.checked) drag_info['last_cell'] = self.row_col_key def apply_drag_state(self, force_mode=None): """Apply drag state to this cell""" global drag_info mode = force_mode or drag_info['mode'] if mode == 'paint' and not self.checked: self.checked = True self.label.config(bg="lightblue", text="✓") self.callback(self.row_col_key, True) return True elif mode == 'erase' and self.checked: self.checked = False self.label.config(bg="white", text=" ") self.callback(self.row_col_key, False) return True return False def set_state(self, checked): self.checked = checked if checked: self.label.config(bg="lightblue", text="✓") else: self.label.config(bg="white", text=" ") class TimeTracker: def __init__(self, root): self.root = root self.root.title("Time Tracker") self.root.geometry("1400x500") # Settings - use UNIX-compliant config directory self.settings_file = os.path.expanduser("~/.config/time-tracker.json") settings = self.load_settings() self.jobs = settings['jobs'] self.customers = settings['customers'] self.start_hour = settings['start_hour'] self.work_hours = settings['work_hours'] self.archive_path = settings['archive_path'] # Main container with scrollbars main_container = tk.Frame(root) main_container.pack(fill=tk.BOTH, expand=True) # Create canvas and scrollbars canvas = tk.Canvas(main_container) h_scrollbar = tk.Scrollbar(main_container, orient="horizontal", command=canvas.xview) v_scrollbar = tk.Scrollbar(main_container, orient="vertical", command=canvas.yview) self.scrollable_frame = tk.Frame(canvas) self.scrollable_frame.bind( "", lambda e: canvas.configure(scrollregion=canvas.bbox("all")) ) canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw") canvas.configure(xscrollcommand=h_scrollbar.set, yscrollcommand=v_scrollbar.set) canvas.grid(row=0, column=0, sticky="nsew") h_scrollbar.grid(row=1, column=0, sticky="ew") v_scrollbar.grid(row=0, column=1, sticky="ns") main_container.grid_rowconfigure(0, weight=1) main_container.grid_columnconfigure(0, weight=1) # Data storage self.data_rows = {} self.row_count = 0 self.time_cells = {} self.time_assignments = {} # Track which time slots are assigned: time_idx -> row_num # Current working date (defaults to today) self.current_date = date.today() # Daily total label reference self.day_total_label = None # Global mouse event bindings self.root.bind("", self.on_global_drag) self.root.bind("", self.on_global_up) # Create headers self.create_headers() # Start with one empty row for data entry self.add_empty_row() # Controls controls_frame = tk.Frame(root) controls_frame.pack(fill=tk.X, side=tk.BOTTOM, pady=5) # Date selector on the left tk.Label(controls_frame, text="Working Date:").pack(side=tk.LEFT, padx=5) date_frame = tk.Frame(controls_frame) date_frame.pack(side=tk.LEFT, padx=5) self.date_month_var = tk.StringVar(value=str(self.current_date.month)) self.date_day_var = tk.StringVar(value=str(self.current_date.day)) self.date_year_var = tk.StringVar(value=str(self.current_date.year)) ttk.Combobox(date_frame, textvariable=self.date_month_var, width=3, values=[str(i) for i in range(1,13)], state="readonly").pack(side=tk.LEFT) ttk.Combobox(date_frame, textvariable=self.date_day_var, width=3, values=[str(i) for i in range(1,32)], state="readonly").pack(side=tk.LEFT, padx=(2,0)) ttk.Combobox(date_frame, textvariable=self.date_year_var, width=6, values=[str(i) for i in range(2020,2031)], state="readonly").pack(side=tk.LEFT, padx=(2,0)) today_btn = tk.Button(controls_frame, text="Today", command=self.set_date_to_today, width=6) today_btn.pack(side=tk.LEFT, padx=(0,10)) # Separator tk.Frame(controls_frame, width=20).pack(side=tk.LEFT, padx=5) # Daily Total Hours Display tk.Label(controls_frame, text="Day Total:").pack(side=tk.LEFT, padx=5) self.day_total_label = tk.Label(controls_frame, text="0.00 hours", relief="solid", fg="black", bg="lightyellow", width=12, font=("Arial", 10, "bold")) self.day_total_label.pack(side=tk.LEFT, padx=5) # Initialize day total display self.update_day_total() # Separator tk.Frame(controls_frame, width=20).pack(side=tk.LEFT, padx=5) add_btn = tk.Button(controls_frame, text="Add New Row", command=self.add_empty_row) add_btn.pack(side=tk.LEFT, padx=5) archive_btn = tk.Button(controls_frame, text="Archive Day", command=self.archive_day) archive_btn.pack(side=tk.LEFT, padx=5) report_btn = tk.Button(controls_frame, text="Build Our Details Report", command=self.open_report_dialog) report_btn.pack(side=tk.LEFT, padx=5) settings_btn = tk.Button(controls_frame, text="Settings", command=self.open_settings) settings_btn.pack(side=tk.LEFT, padx=5) def load_settings(self): """Load settings from JSON file""" # Ensure config directory exists config_dir = os.path.dirname(self.settings_file) if config_dir and not os.path.exists(config_dir): try: os.makedirs(config_dir, exist_ok=True) except OSError: pass # Will fall back to defaults if directory can't be created if os.path.exists(self.settings_file): try: with open(self.settings_file, 'r') as f: data = json.load(f) return { 'jobs': data.get('jobs', [ {'name': 'Troubleshooting', 'billable': True, 'active': True}, {'name': 'Development', 'billable': True, 'active': True}, {'name': 'Meeting', 'billable': False, 'active': True}, {'name': 'Admin', 'billable': False, 'active': True} ]), 'customers': data.get('customers', [ {'name': 'Internal', 'active': True}, {'name': 'Client Corp', 'active': True}, {'name': 'Customer Inc', 'active': True} ]), 'start_hour': data.get('start_hour', 9), 'work_hours': data.get('work_hours', 8), 'archive_path': data.get('archive_path', 'time_tracker_archive.csv') } except Exception as e: messagebox.showwarning("Settings Error", f"Failed to load settings: {e}") # Default settings if no file exists return { 'jobs': [ {'name': 'Troubleshooting', 'billable': True, 'active': True}, {'name': 'Development', 'billable': True, 'active': True}, {'name': 'Meeting', 'billable': False, 'active': True}, {'name': 'Admin', 'billable': False, 'active': True} ], 'customers': [ {'name': 'Internal', 'active': True}, {'name': 'Client Corp', 'active': True}, {'name': 'Customer Inc', 'active': True} ], 'start_hour': 9, 'work_hours': 8, 'archive_path': 'time_tracker_archive.csv' } def save_settings(self): """Save settings to JSON file""" try: # Ensure config directory exists config_dir = os.path.dirname(self.settings_file) if config_dir and not os.path.exists(config_dir): os.makedirs(config_dir, exist_ok=True) with open(self.settings_file, 'w') as f: json.dump({ 'jobs': self.jobs, 'customers': self.customers, 'start_hour': self.start_hour, 'work_hours': self.work_hours, 'archive_path': self.archive_path }, f, indent=2) return True except Exception as e: messagebox.showerror("Save Error", f"Failed to save settings: {e}") return False def get_selected_date(self): """Get the currently selected date from the form""" try: return date(int(self.date_year_var.get()), int(self.date_month_var.get()), int(self.date_day_var.get())) except ValueError: # If invalid date, return today's date return date.today() def set_date_to_today(self): """Set the date fields to today's date""" today = date.today() self.date_month_var.set(str(today.month)) self.date_day_var.set(str(today.day)) self.date_year_var.set(str(today.year)) def get_active_jobs(self): """Get list of active job names for dropdown""" return [job['name'] for job in self.jobs if job.get('active', True)] def get_active_customers(self): """Get list of active customer names for dropdown""" return [customer['name'] for customer in self.customers if customer.get('active', True)] def get_job_billable_status(self, job_name): """Get billable status for a specific job""" for job in self.jobs: if job['name'] == job_name: return job.get('billable', True) return True # Default to True if job not found def on_global_drag(self, event): """Global drag handler that finds which cell we're over""" global drag_info if not drag_info['active']: return # Find which widget we're currently over widget = event.widget.winfo_containing(event.x_root, event.y_root) # Check if it's a ClickableCell cell = None current_widget = widget while current_widget: if isinstance(current_widget, ClickableCell): cell = current_widget break current_widget = current_widget.master if cell and cell.row_col_key != drag_info['last_cell']: applied = cell.apply_drag_state() if applied: drag_info['last_cell'] = cell.row_col_key def on_global_up(self, event): """Global mouse up handler""" global drag_info drag_info['active'] = False drag_info['mode'] = None drag_info['start_row'] = None drag_info['last_cell'] = None def create_headers(self): headers = ["Job", "Task Name", "Notes", "Customer"] # Calculate time slots based on settings time_slots = self.work_hours * 4 # 4 slots per hour (15-minute intervals) for i in range(time_slots): total_minutes = self.start_hour * 60 + i * 15 hour = total_minutes // 60 minute = total_minutes % 60 time_str = f"{hour:02d}:{minute:02d}" headers.append(time_str) headers.append("Total Hours") # Create header labels for i, header in enumerate(headers): if i < 4: width = 15 elif i == len(headers) - 1: width = 10 else: width = 6 label = tk.Label(self.scrollable_frame, text=header, relief="solid", borderwidth=1, width=width, height=2) label.grid(row=0, column=i, sticky="nsew") def add_empty_row(self): self.add_row("", "", "", "") def add_row(self, job, task_name, notes, customer): self.row_count += 1 row_num = self.row_count # Job dropdown job_dropdown = ttk.Combobox(self.scrollable_frame, width=15, values=self.get_active_jobs(), state="readonly") job_dropdown.grid(row=row_num, column=0, sticky="nsew", padx=1, pady=1) if job and job in job_dropdown['values']: job_dropdown.set(job) # Don't auto-select first job for empty rows # Task Name field task_entry = tk.Entry(self.scrollable_frame, width=15) task_entry.insert(0, task_name) task_entry.grid(row=row_num, column=1, sticky="nsew", padx=1, pady=1) # Notes field notes_entry = tk.Entry(self.scrollable_frame, width=15) notes_entry.insert(0, notes) notes_entry.grid(row=row_num, column=2, sticky="nsew", padx=1, pady=1) # Customer field customer_dropdown = ttk.Combobox(self.scrollable_frame, width=15, values=self.get_active_customers(), state="readonly") customer_dropdown.grid(row=row_num, column=3, sticky="nsew", padx=1, pady=1) if customer and customer in customer_dropdown['values']: customer_dropdown.set(customer) # Don't auto-select first customer for empty rows # Time cells self.time_cells[row_num] = {} time_slots = self.work_hours * 4 # Calculate based on current settings for i in range(time_slots): cell = ClickableCell(self.scrollable_frame, (row_num, i), self.on_time_cell_clicked, width=5, height=1) cell.grid(row=row_num, column=4 + i, sticky="nsew", padx=1, pady=1) self.time_cells[row_num][i] = cell # Total hours label total_label = tk.Label(self.scrollable_frame, text="0.00", relief="solid", borderwidth=1, width=10, height=2) total_label.grid(row=row_num, column=4 + time_slots, sticky="nsew", padx=1, pady=1) # Store row data self.data_rows[row_num] = { 'total_label': total_label, 'time_cells': self.time_cells[row_num] } def update_day_total(self): """Update the daily total hours display""" if self.day_total_label: total_slots = len(self.time_assignments) total_hours = total_slots * 0.25 self.day_total_label.config(text=f"{total_hours:.2f} hours") # Change color if over 8 hours if total_hours > 8.0: self.day_total_label.config(bg="salmon", fg="black") elif total_hours == 8.0: self.day_total_label.config(bg="lightgreen", fg="black") else: self.day_total_label.config(bg="lightyellow", fg="black") def on_time_cell_clicked(self, row_col_key, checked): row_num, col_idx = row_col_key cell = self.time_cells[row_num][col_idx] if checked: # Check if this time slot is already assigned to another row if col_idx in self.time_assignments and self.time_assignments[col_idx] != row_num: existing_row = self.time_assignments[col_idx] # Find the job name for the existing assignment job_name = f"Task on row {existing_row}" widgets = self.scrollable_frame.grid_slaves(row=existing_row) for widget in widgets: if isinstance(widget, ttk.Combobox): # Job dropdown column job_name = widget.get() break start_minute = (col_idx * 15) hour = self.start_hour + (self.start_hour * 60 + start_minute) // 60 minute = (self.start_hour * 60 + start_minute) % 60 # Highlight conflicting cells in red cell = self.time_cells[row_num][col_idx] cell.label.config(bg="red", text="✗") # Also highlight the existing conflicting cell if existing_row in self.time_cells and col_idx in self.time_cells[existing_row]: existing_cell = self.time_cells[existing_row][col_idx] existing_cell.label.config(bg="red", text="✗") messagebox.showwarning("Time Conflict", f"Time slot at {hour:02d}:{minute:02d} is already assigned to:\n{job_name}\n\nConflicting slots marked in red.") # Don't return - let the cell remain checked so user can see the conflict # Assign this time slot to this row self.time_assignments[col_idx] = row_num else: # Remove assignment from this row if self.time_assignments.get(col_idx) == row_num: del self.time_assignments[col_idx] # Clear any red conflict highlighting for this column across all rows for other_row_num in self.time_cells: if col_idx in self.time_cells[other_row_num]: cell = self.time_cells[other_row_num][col_idx] if cell.label['bg'] == "red": # Reset to normal state based on checked status if cell.checked: cell.label.config(bg="lightblue", text="✓") else: cell.label.config(bg="white", text=" ") self.update_total_hours(row_num) self.update_day_total() def update_total_hours(self, row_num): if row_num not in self.data_rows: return checked_count = sum(1 for cell in self.data_rows[row_num]['time_cells'].values() if cell.checked) total_hours = checked_count * 0.25 self.data_rows[row_num]['total_label'].config(text=f"{total_hours:.2f}") def get_row_data(self, row_num): """Extract data from a specific row""" if row_num not in self.data_rows: return None # Get text from entry widgets widgets = self.scrollable_frame.grid_slaves(row=row_num) job = task = notes = customer = "" for widget in widgets: col = widget.grid_info()["column"] if col == 0: # Job dropdown job = widget.get() if isinstance(widget, ttk.Combobox) else "" elif col == 1: # Task Name column task = widget.get() if isinstance(widget, tk.Entry) else "" elif col == 2: # Notes column notes = widget.get() if isinstance(widget, tk.Entry) else "" elif col == 3: # Customer column (now dropdown) customer = widget.get() if isinstance(widget, ttk.Combobox) else "" # Calculate hours checked_count = sum(1 for cell in self.data_rows[row_num]['time_cells'].values() if cell.checked) total_hours = checked_count * 0.25 return { 'job': job, 'task': task, 'notes': notes, 'customer': customer, 'hours': total_hours } def archive_day(self): """Archive current day's data to CSV""" # Check if there's any data to archive data_to_archive = [] for row_num in self.data_rows: row_data = self.get_row_data(row_num) if row_data and row_data['hours'] > 0: data_to_archive.append(row_data) if not data_to_archive: messagebox.showinfo("No Data", "No time entries to archive.") return # Check for missing customer data rows_with_no_customer = [] for row_data in data_to_archive: if not row_data['customer'] or row_data['customer'].strip() == '': rows_with_no_customer.append(row_data) if rows_with_no_customer: error_msg = f"Found {len(rows_with_no_customer)} time entry(s) with no customer specified:\n\n" for i, row in enumerate(rows_with_no_customer[:3]): # Show first 3 examples error_msg += f"• {row['job']}: {row['task'] or '(no task)'} ({row['hours']:.2f} hours)\n" if len(rows_with_no_customer) > 3: error_msg += f"... and {len(rows_with_no_customer) - 3} more\n" error_msg += "\nPlease select a customer for all entries before archiving." messagebox.showerror("Missing Customer Data", error_msg) return # Get archive file path from settings archive_path = self.archive_path # Check if file exists to determine if we need headers file_exists = os.path.exists(archive_path) try: with open(archive_path, 'a', newline='', encoding='utf-8') as csvfile: fieldnames = ['Job', 'TaskName', 'Note', 'Customer', 'Hours', 'Date', 'username', 'Billable', 'Billed'] writer = csv.DictWriter(csvfile, fieldnames=fieldnames) # Write header if file is new if not file_exists: writer.writeheader() # Write each row with archive metadata for row_data in data_to_archive: writer.writerow({ 'Job': row_data['job'], 'TaskName': row_data['task'], 'Note': row_data['notes'], 'Customer': row_data['customer'], 'Hours': row_data['hours'], 'Date': self.get_selected_date().strftime('%Y-%m-%d'), 'username': os.getenv('USER', os.getenv('USERNAME', 'unknown')), 'Billable': self.get_job_billable_status(row_data['job']), 'Billed': False # Default to False for now }) messagebox.showinfo("Archive Complete", f"Archived {len(data_to_archive)} entries to {archive_path}") # Clear the interface after successful archive self.clear_all_rows() except Exception as e: messagebox.showerror("Archive Failed", f"Failed to archive data: {str(e)}") def clear_all_rows(self): """Clear all data rows from the interface""" # Remove all widgets in scrollable frame except headers for widget in list(self.scrollable_frame.winfo_children()): widget.destroy() # Reset data structures self.data_rows = {} self.time_cells = {} self.time_assignments = {} self.row_count = 0 # Recreate headers self.create_headers() def open_report_dialog(self): """Open dialog for generating Billable Hours Details report""" dialog = tk.Toplevel(self.root) dialog.title("Billable Hours Details Report") dialog.geometry("400x350") # Invoice Number tk.Label(dialog, text="Invoice Number:").grid(row=0, column=0, padx=10, pady=5, sticky='w') invoice_entry = tk.Entry(dialog, width=30) invoice_entry.grid(row=0, column=1, padx=10, pady=5) # Customer Selection tk.Label(dialog, text="Customer:").grid(row=1, column=0, padx=10, pady=5, sticky='w') customer_combo = ttk.Combobox(dialog, width=27, values=self.get_active_customers(), state="readonly") customer_combo.grid(row=1, column=1, padx=10, pady=5) if customer_combo['values']: customer_combo.set(customer_combo['values'][0]) # Date Range tk.Label(dialog, text="Start Date:").grid(row=2, column=0, padx=10, pady=5, sticky='w') start_date_frame = tk.Frame(dialog) start_date_frame.grid(row=2, column=1, padx=10, pady=5, sticky='w') start_month_var = tk.StringVar(value=str(datetime.now().month)) start_day_var = tk.StringVar(value="1") start_year_var = tk.StringVar(value=str(datetime.now().year)) ttk.Combobox(start_date_frame, textvariable=start_month_var, width=5, values=[str(i) for i in range(1,13)]).pack(side=tk.LEFT) ttk.Combobox(start_date_frame, textvariable=start_day_var, width=5, values=[str(i) for i in range(1,32)]).pack(side=tk.LEFT, padx=(5,0)) ttk.Combobox(start_date_frame, textvariable=start_year_var, width=8, values=[str(i) for i in range(2020,2031)]).pack(side=tk.LEFT, padx=(5,0)) tk.Label(dialog, text="End Date:").grid(row=3, column=0, padx=10, pady=5, sticky='w') end_date_frame = tk.Frame(dialog) end_date_frame.grid(row=3, column=1, padx=10, pady=5, sticky='w') end_month_var = tk.StringVar(value=str(datetime.now().month)) end_day_var = tk.StringVar(value=str(calendar.monthrange(datetime.now().year, datetime.now().month)[1])) end_year_var = tk.StringVar(value=str(datetime.now().year)) ttk.Combobox(end_date_frame, textvariable=end_month_var, width=5, values=[str(i) for i in range(1,13)]).pack(side=tk.LEFT) ttk.Combobox(end_date_frame, textvariable=end_day_var, width=5, values=[str(i) for i in range(1,32)]).pack(side=tk.LEFT, padx=(5,0)) ttk.Combobox(end_date_frame, textvariable=end_year_var, width=8, values=[str(i) for i in range(2020,2031)]).pack(side=tk.LEFT, padx=(5,0)) # Include only billable checkbox include_billable_var = tk.BooleanVar(value=True) tk.Checkbutton(dialog, text="Include only billable hours", variable=include_billable_var).grid(row=4, column=0, columnspan=2, padx=10, pady=10, sticky='w') def generate_report(): try: invoice_num = invoice_entry.get().strip() if not invoice_num: messagebox.showwarning("Invalid Input", "Please enter an invoice number.") return customer = customer_combo.get() start_date_obj = date(int(start_year_var.get()), int(start_month_var.get()), int(start_day_var.get())) end_date_obj = date(int(end_year_var.get()), int(end_month_var.get()), int(end_day_var.get())) if start_date_obj > end_date_obj: messagebox.showwarning("Invalid Date Range", "Start date must be before or equal to end date.") return self.generate_report_data(invoice_num, customer, start_date_obj, end_date_obj, include_billable_var.get()) dialog.destroy() except ValueError as e: messagebox.showerror("Invalid Date", "Please enter valid dates.") except Exception as e: messagebox.showerror("Error", f"Failed to generate report: {str(e)}") def cancel(): dialog.destroy() # Buttons btn_frame = tk.Frame(dialog) btn_frame.grid(row=5, column=0, columnspan=2, pady=20) tk.Button(btn_frame, text="Generate Report", command=generate_report, width=15).pack(side=tk.LEFT, padx=5) tk.Button(btn_frame, text="Cancel", command=cancel, width=15).pack(side=tk.LEFT, padx=5) def generate_report_data(self, invoice_num, customer, start_date, end_date, billable_only): """Generate report data and show preview dialog""" archive_file = self.archive_path if not os.path.exists(archive_file): messagebox.showerror("No Data", "Archive file not found. No data to generate report.") return # Read and filter CSV data filtered_data = [] try: with open(archive_file, 'r', encoding='utf-8') as csvfile: reader = csv.DictReader(csvfile) for row in reader: row_date = datetime.strptime(row['Date'], '%Y-%m-%d').date() # Apply filters if (row['Customer'] == customer and start_date <= row_date <= end_date and (not billable_only or row.get('Billable', 'True').lower() == 'true')): filtered_data.append(row) except Exception as e: messagebox.showerror("Read Error", f"Failed to read archive file: {str(e)}") return if not filtered_data: messagebox.showinfo("No Data", f"No data found for {customer} in the selected date range.") return # Create pivot table data structure pivot_data = defaultdict(lambda: defaultdict(float)) # job -> date -> hours all_dates = set() all_jobs = set() for row in filtered_data: job = row['Job'] date_str = row['Date'] hours = float(row['Hours']) pivot_data[job][date_str] += hours all_dates.add(date_str) all_jobs.add(job) # Sort dates and jobs sorted_dates = sorted(all_dates) sorted_jobs = sorted(all_jobs) # Show report preview dialog self.show_report_preview(invoice_num, customer, start_date, end_date, pivot_data, sorted_jobs, sorted_dates, filtered_data, billable_only) def show_report_preview(self, invoice_num, customer, start_date, end_date, pivot_data, jobs, dates, filtered_data, billable_only=True): """Show report preview dialog with pivot table and export options""" preview_window = tk.Toplevel(self.root) preview_window.title(f"Invoice #{invoice_num} Billable Hours Details") preview_window.geometry("1200x600") # Title frame title_frame = tk.Frame(preview_window) title_frame.pack(fill=tk.X, padx=10, pady=10) title_label = tk.Label(title_frame, text=f"Invoice #{invoice_num} Billable Hours Details", font=("Arial", 16, "bold")) title_label.pack(side=tk.LEFT) subtitle_label = tk.Label(title_frame, text=f"Customer: {customer} | Period: {start_date} to {end_date}") subtitle_label.pack(side=tk.LEFT, padx=(20, 0)) # Create scrollable frame for table main_frame = tk.Frame(preview_window) main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) canvas = tk.Canvas(main_frame) h_scrollbar = tk.Scrollbar(main_frame, orient="horizontal", command=canvas.xview) v_scrollbar = tk.Scrollbar(main_frame, orient="vertical", command=canvas.yview) table_frame = tk.Frame(canvas) table_frame.bind( "", lambda e: canvas.configure(scrollregion=canvas.bbox("all")) ) canvas.create_window((0, 0), window=table_frame, anchor="nw") canvas.configure(xscrollcommand=h_scrollbar.set, yscrollcommand=v_scrollbar.set) canvas.grid(row=0, column=0, sticky="nsew") h_scrollbar.grid(row=1, column=0, sticky="ew") v_scrollbar.grid(row=0, column=1, sticky="ns") main_frame.grid_rowconfigure(0, weight=1) main_frame.grid_columnconfigure(0, weight=1) # Build pivot table # Headers headers = ["Job"] + dates + ["Total"] for col, header in enumerate(headers): width = 15 if col == 0 else 10 label = tk.Label(table_frame, text=header, relief="solid", borderwidth=1, width=width, height=2, font=("Arial", 10, "bold")) label.grid(row=0, column=col, sticky="nsew") # Data rows with totals grand_total = 0.0 day_totals = [0.0] * len(dates) for row, job in enumerate(jobs, 1): # Job name job_label = tk.Label(table_frame, text=job, relief="solid", borderwidth=1, width=15, height=2) job_label.grid(row=row, column=0, sticky="nsew") # Daily hours row_total = 0.0 for col, date_str in enumerate(dates, 1): hours = pivot_data.get(job, {}).get(date_str, 0.0) if hours > 0: cell_label = tk.Label(table_frame, text=f"{hours:.2f}", relief="solid", borderwidth=1, width=10, height=2) row_total += hours day_totals[col-1] += hours else: cell_label = tk.Label(table_frame, text="", relief="solid", borderwidth=1, width=10, height=2) cell_label.grid(row=row, column=col, sticky="nsew") # Row total row_total_label = tk.Label(table_frame, text=f"{row_total:.2f}", relief="solid", borderwidth=1, width=10, height=2, font=("Arial", 10, "bold")) row_total_label.grid(row=row, column=len(dates) + 1, sticky="nsew") grand_total += row_total # Bottom row - Day totals total_row = len(jobs) + 1 totals_label = tk.Label(table_frame, text="Daily Total", relief="solid", borderwidth=1, width=15, height=2, font=("Arial", 10, "bold")) totals_label.grid(row=total_row, column=0, sticky="nsew") for col, day_total in enumerate(day_totals, 1): day_total_label = tk.Label(table_frame, text=f"{day_total:.2f}", relief="solid", borderwidth=1, width=10, height=2, font=("Arial", 10, "bold")) day_total_label.grid(row=total_row, column=col, sticky="nsew") # Grand total grand_total_label = tk.Label(table_frame, text=f"{grand_total:.2f}", relief="solid", borderwidth=1, width=10, height=2, font=("Arial", 10, "bold")) grand_total_label.grid(row=total_row, column=len(dates) + 1, sticky="nsew") # Export buttons button_frame = tk.Frame(preview_window) button_frame.pack(fill=tk.X, padx=10, pady=10) def export_to_pdf(): try: filename = filedialog.asksaveasfilename( defaultextension=".pdf", filetypes=[("PDF files", "*.pdf"), ("All files", "*.*")], initialfile=f"Invoice_{invoice_num}_Hours_Report.pdf" ) if filename: self.export_to_pdf(filename, invoice_num, customer, start_date, end_date, pivot_data, jobs, dates) messagebox.showinfo("Success", f"Report exported to {filename}") except Exception as e: messagebox.showerror("Export Error", f"Failed to export PDF: {str(e)}") def mark_as_billed(): """Mark all rows in this report as billed""" try: archive_file = self.archive_path # Read all archive data all_data = [] fieldnames = None with open(archive_file, 'r', encoding='utf-8') as csvfile: reader = csv.DictReader(csvfile) fieldnames = reader.fieldnames if fieldnames: # Ensure we have fieldnames for row in reader: row_date = datetime.strptime(row['Date'], '%Y-%m-%d').date() # Check if this row matches filtered data is_in_report = False for report_row in filtered_data: if (row['Customer'] == report_row['Customer'] and row['Date'] == report_row['Date'] and row['Job'] == report_row['Job'] and row['Hours'] == report_row['Hours']): is_in_report = True break if is_in_report: # This row is included in report, mark as billed row['Billed'] = 'True' all_data.append(row) # Write back to archive with updated Billed status if fieldnames: # Ensure fieldnames is not None with open(archive_file, 'w', newline='', encoding='utf-8') as csvfile: writer = csv.DictWriter(csvfile, fieldnames=fieldnames) writer.writeheader() writer.writerows(all_data) messagebox.showinfo("Success", f"Marked {len(filtered_data)} entries as billed for invoice #{invoice_num}") # Close preview window after marking as billed preview_window.destroy() except Exception as e: messagebox.showerror("Error Marking Billed", f"Failed to mark entries as billed: {str(e)}") def close_preview(): preview_window.destroy() tk.Button(button_frame, text="Mark as Billed", command=mark_as_billed, width=15, bg="lightgreen").pack(side=tk.LEFT, padx=5) tk.Button(button_frame, text="Export to PDF", command=export_to_pdf, width=15).pack(side=tk.LEFT, padx=5) tk.Button(button_frame, text="Close", command=close_preview, width=15).pack(side=tk.LEFT, padx=5) def export_to_pdf(self, filename, invoice_num, customer, start_date, end_date, pivot_data, jobs, dates): """Export report data to PDF format""" try: from reportlab.lib.pagesizes import letter, landscape from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib import colors from reportlab.lib.units import inch except ImportError: messagebox.showerror("Missing Library", "reportlab library is required for PDF export. Please install it with:\n\n" "pip install reportlab") return doc = SimpleDocTemplate(filename, pagesize=landscape(letter)) styles = getSampleStyleSheet() story = [] # Title title_style = ParagraphStyle( 'CustomTitle', parent=styles['Heading1'], fontSize=16, spaceAfter=20, alignment=1 # Center ) title = Paragraph(f"Invoice #{invoice_num} Billable Hours Details", title_style) story.append(title) # Subtitle subtitle = Paragraph(f"Customer: {customer} | Period: {start_date} to {end_date}", styles['Normal']) story.append(subtitle) story.append(Spacer(1, 12)) # Build table data headers = ["Job"] + dates + ["Total"] table_data = [headers] grand_total = 0.0 day_totals = [0.0] * len(dates) # Add job rows for job in jobs: row = [job] row_total = 0.0 for date_str in dates: hours = pivot_data.get(job, {}).get(date_str, 0.0) row.append(f"{hours:.2f}" if hours > 0 else "") row_total += hours day_totals[len(row) - 2] += hours row.append(f"{row_total:.2f}") table_data.append(row) grand_total += row_total # Add totals row totals_row = ["Daily Total"] for day_total in day_totals: totals_row.append(f"{day_total:.2f}") totals_row.append(f"{grand_total:.2f}") table_data.append(totals_row) # Create table with adjusted sizing for better fit num_dates = len(dates) # Calculate appropriate font size based on number of dates if num_dates >= 25: # Very wide reports font_size = 5 elif num_dates >= 15: # Medium reports font_size = 6 else: # Small reports font_size = 7 table = Table(table_data, repeatRows=1) # Style the table with appropriate font size table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), colors.grey), ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), ('ALIGN', (0, 0), (-1, -1), 'CENTER'), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTNAME', (0, -1), (-1, -1), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), font_size), ('LEADING', (0, 0), (-1, -1), font_size + 1), ('BOTTOMPADDING', (0, 0), (-1, 0), 6), ('TOPPADDING', (0, 0), (-1, -1), 3), ('LEFTPADDING', (0, 0), (-1, -1), 2), ('RIGHTPADDING', (0, 0), (-1, -1), 2), ('BACKGROUND', (0, -1), (-1, -1), colors.grey), ('TEXTCOLOR', (0, -1), (-1, -1), colors.whitesmoke), ('GRID', (0, 0), (-1, -1), 1, colors.black) ])) story.append(table) # Add a note about report scaling if we compressed it a lot if num_dates >= 25: note_style = ParagraphStyle( 'Note', parent=styles['Normal'], fontSize=8, textColor=colors.gray, alignment=1 # Center ) note = Paragraph("*Report scaled to fit large date range*", note_style) story.append(Spacer(1, 6)) story.append(note) # Build PDF doc.build(story) # Settings dialog methods (keeping from original) def open_settings(self): """Open settings dialog to manage jobs and time settings""" settings_window = tk.Toplevel(self.root) settings_window.title("Settings") settings_window.geometry("700x500") # Create notebook for tabbed interface notebook = ttk.Notebook(settings_window) notebook.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) # Jobs tab jobs_frame = tk.Frame(notebook) notebook.add(jobs_frame, text="Jobs") # Customers tab customers_frame = tk.Frame(notebook) notebook.add(customers_frame, text="Customers") # Time Settings tab time_frame = tk.Frame(notebook) notebook.add(time_frame, text="Time Settings") # Storage tab storage_frame = tk.Frame(notebook) notebook.add(storage_frame, text="Storage") # Jobs tab content list_frame = tk.Frame(jobs_frame) list_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) # Treeview for job management columns = ('Job Name', 'Billable', 'Active') tree = ttk.Treeview(list_frame, columns=columns, show='headings', height=10) for col in columns: tree.heading(col, text=col) tree.column(col, width=150) # Populate tree with current jobs for job in self.jobs: job_name = job['name'] billable = 'Yes' if job.get('billable', True) else 'No' active = 'Yes' if job.get('active', True) else 'No' tree.insert('', tk.END, values=(job_name, billable, active)) tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) # Scrollbar scrollbar = ttk.Scrollbar(list_frame, orient='vertical', command=tree.yview) scrollbar.pack(side=tk.RIGHT, fill=tk.Y) tree.configure(yscrollcommand=scrollbar.set) # Job control buttons jobs_btn_frame = tk.Frame(jobs_frame) jobs_btn_frame.pack(fill=tk.X, padx=10, pady=5) def add_job(): self.add_job_dialog(tree) def edit_job(): selected = tree.selection() if not selected: messagebox.showwarning("No Selection", "Please select a job to edit.") return item = tree.item(selected[0]) values = item['values'] self.edit_job_dialog(tree, values[0], values[1] == 'Yes', values[2] == 'Yes') tk.Button(jobs_btn_frame, text="Add Job", command=add_job).pack(side=tk.LEFT, padx=2) tk.Button(jobs_btn_frame, text="Edit Job", command=edit_job).pack(side=tk.LEFT, padx=2) # Customers tab content (using same pattern as jobs) customers_list_frame = tk.Frame(customers_frame) customers_list_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) # Treeview for customer management customer_columns = ('Customer Name', 'Active') customer_tree = ttk.Treeview(customers_list_frame, columns=customer_columns, show='headings', height=10) for col in customer_columns: customer_tree.heading(col, text=col) customer_tree.column(col, width=150) # Populate tree with current customers for customer in self.customers: customer_name = customer['name'] active = 'Yes' if customer.get('active', True) else 'No' customer_tree.insert('', tk.END, values=(customer_name, active)) customer_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) # Customer scrollbar customer_scrollbar = ttk.Scrollbar(customers_list_frame, orient='vertical', command=customer_tree.yview) customer_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) customer_tree.configure(yscrollcommand=customer_scrollbar.set) # Customer control buttons customers_btn_frame = tk.Frame(customers_frame) customers_btn_frame.pack(fill=tk.X, padx=10, pady=5) def add_customer(): self.add_customer_dialog(customer_tree) def edit_customer(): selected = customer_tree.selection() if not selected: messagebox.showwarning("No Selection", "Please select a customer to edit.") return item = customer_tree.item(selected[0]) values = item['values'] self.edit_customer_dialog(customer_tree, values[0], values[1] == 'Yes') tk.Button(customers_btn_frame, text="Add Customer", command=add_customer).pack(side=tk.LEFT, padx=2) tk.Button(customers_btn_frame, text="Edit Customer", command=edit_customer).pack(side=tk.LEFT, padx=2) # Storage tab content storage_settings_frame = tk.Frame(storage_frame) storage_settings_frame.pack(fill=tk.X, padx=20, pady=20) tk.Label(storage_settings_frame, text="Archive File Location:").grid(row=0, column=0, sticky='w', padx=5, pady=5) archive_path_frame = tk.Frame(storage_settings_frame) archive_path_frame.grid(row=0, column=1, sticky='w', padx=5, pady=5) archive_path_var = tk.StringVar(value=self.archive_path) archive_path_entry = tk.Entry(archive_path_frame, textvariable=archive_path_var, width=50) archive_path_entry.pack(side=tk.LEFT) def browse_archive_path(): filename = filedialog.asksaveasfilename( title="Select Archive File Location", initialfile=os.path.basename(self.archive_path) if self.archive_path else "time_tracker_archive.csv", filetypes=[("CSV files", "*.csv"), ("All files", "*.*")] ) if filename: archive_path_var.set(filename) browse_btn = tk.Button(archive_path_frame, text="Browse...", command=browse_archive_path) browse_btn.pack(side=tk.LEFT, padx=(5,0)) # Time Settings tab content time_settings_frame = tk.Frame(time_frame) time_settings_frame.pack(fill=tk.X, padx=20, pady=20) tk.Label(time_settings_frame, text="Start Hour (0-23):").grid(row=0, column=0, sticky='w', padx=5, pady=5) start_hour_var = tk.IntVar(value=self.start_hour) start_hour_spinbox = tk.Spinbox(time_settings_frame, from_=0, to=23, textvariable=start_hour_var, width=10) start_hour_spinbox.grid(row=0, column=1, sticky='w', padx=5, pady=5) tk.Label(time_settings_frame, text="Work Hours (1-24):").grid(row=1, column=0, sticky='w', padx=5, pady=5) work_hours_var = tk.IntVar(value=self.work_hours) work_hours_spinbox = tk.Spinbox(time_settings_frame, from_=1, to=24, textvariable=work_hours_var, width=10) work_hours_spinbox.grid(row=1, column=1, sticky='w', padx=5, pady=5) # Bottom buttons bottom_btn_frame = tk.Frame(settings_window) bottom_btn_frame.pack(fill=tk.X, padx=10, pady=5) def save_changes(): # Update jobs from tree data self.jobs = [] for item in tree.get_children(): values = tree.item(item)['values'] self.jobs.append({ 'name': values[0], 'billable': values[1] == 'Yes', 'active': values[2] == 'Yes' }) # Update customers from tree data self.customers = [] for item in customer_tree.get_children(): values = customer_tree.item(item)['values'] self.customers.append({ 'name': values[0], 'active': values[1] == 'Yes' }) # Update time settings self.start_hour = start_hour_var.get() self.work_hours = work_hours_var.get() # Update archive path new_archive_path = archive_path_var.get().strip() if new_archive_path: # If directory doesn't exist, try to create it import os.path dir_name = os.path.dirname(new_archive_path) if dir_name and not os.path.exists(dir_name): try: os.makedirs(dir_name) except OSError: messagebox.showerror("Invalid Path", f"Cannot create directory: {dir_name}") return self.archive_path = new_archive_path if self.save_settings(): messagebox.showinfo("Success", "Settings saved successfully. Please restart application for time changes to take effect.") settings_window.destroy() # Refresh job and customer dropdowns immediately self.refresh_job_dropdowns() self.refresh_customer_dropdowns() tk.Button(bottom_btn_frame, text="Save Settings", command=save_changes).pack(side=tk.RIGHT, padx=2) tk.Button(bottom_btn_frame, text="Cancel", command=settings_window.destroy).pack(side=tk.RIGHT, padx=2) # Simplified customer and job management methods (using original pattern) def add_job_dialog(self, tree): """Dialog to add new job""" dialog = tk.Toplevel(self.root) dialog.title("Add New Job") dialog.geometry("300x150") tk.Label(dialog, text="Job Name:").grid(row=0, column=0, padx=10, pady=5, sticky='w') name_entry = tk.Entry(dialog) name_entry.grid(row=0, column=1, padx=10, pady=5) billable_var = tk.BooleanVar(value=True) tk.Checkbutton(dialog, text="Billable", variable=billable_var).grid(row=1, column=0, columnspan=2, padx=10, pady=5, sticky='w') active_var = tk.BooleanVar(value=True) tk.Checkbutton(dialog, text="Active", variable=active_var).grid(row=2, column=0, columnspan=2, padx=10, pady=5, sticky='w') def save_job(): name = name_entry.get().strip() if not name: messagebox.showwarning("Invalid Input", "Job name cannot be empty.") return tree.insert('', tk.END, values=(name, 'Yes' if billable_var.get() else 'No', 'Yes' if active_var.get() else 'No')) dialog.destroy() tk.Button(dialog, text="Save", command=save_job).grid(row=3, column=0, padx=10, pady=10) tk.Button(dialog, text="Cancel", command=dialog.destroy).grid(row=3, column=1, padx=10, pady=10) def edit_job_dialog(self, tree, job_name, is_billable, is_active): """Dialog to edit existing job""" dialog = tk.Toplevel(self.root) dialog.title("Edit Job") dialog.geometry("300x150") tk.Label(dialog, text="Job Name:").grid(row=0, column=0, padx=10, pady=5, sticky='w') name_entry = tk.Entry(dialog) name_entry.insert(0, job_name) name_entry.grid(row=0, column=1, padx=10, pady=5) billable_var = tk.BooleanVar(value=is_billable) tk.Checkbutton(dialog, text="Billable", variable=billable_var).grid(row=1, column=0, columnspan=2, padx=10, pady=5, sticky='w') active_var = tk.BooleanVar(value=is_active) tk.Checkbutton(dialog, text="Active", variable=active_var).grid(row=2, column=0, columnspan=2, padx=10, pady=5, sticky='w') def save_job(): name = name_entry.get().strip() if not name: messagebox.showwarning("Invalid Input", "Job name cannot be empty.") return # Find and update the tree item for item in tree.get_children(): if tree.item(item)['values'][0] == job_name: tree.item(item, values=(name, 'Yes' if billable_var.get() else 'No', 'Yes' if active_var.get() else 'No')) break dialog.destroy() tk.Button(dialog, text="Save", command=save_job).grid(row=3, column=0, padx=10, pady=10) tk.Button(dialog, text="Cancel", command=dialog.destroy).grid(row=3, column=1, padx=10, pady=10) def add_customer_dialog(self, tree): """Dialog to add new customer""" dialog = tk.Toplevel(self.root) dialog.title("Add New Customer") dialog.geometry("300x150") tk.Label(dialog, text="Customer Name:").grid(row=0, column=0, padx=10, pady=5, sticky='w') name_entry = tk.Entry(dialog) name_entry.grid(row=0, column=1, padx=10, pady=5) active_var = tk.BooleanVar(value=True) tk.Checkbutton(dialog, text="Active", variable=active_var).grid(row=1, column=0, columnspan=2, padx=10, pady=5, sticky='w') def save_customer(): name = name_entry.get().strip() if not name: messagebox.showwarning("Invalid Input", "Customer name cannot be empty.") return tree.insert('', tk.END, values=(name, 'Yes' if active_var.get() else 'No')) dialog.destroy() tk.Button(dialog, text="Save", command=save_customer).grid(row=2, column=0, padx=10, pady=10) tk.Button(dialog, text="Cancel", command=dialog.destroy).grid(row=2, column=1, padx=10, pady=10) def edit_customer_dialog(self, tree, customer_name, is_active): """Dialog to edit existing customer""" dialog = tk.Toplevel(self.root) dialog.title("Edit Customer") dialog.geometry("300x120") tk.Label(dialog, text="Customer Name:").grid(row=0, column=0, padx=10, pady=5, sticky='w') name_entry = tk.Entry(dialog) name_entry.insert(0, customer_name) name_entry.grid(row=0, column=1, padx=10, pady=5) active_var = tk.BooleanVar(value=is_active) tk.Checkbutton(dialog, text="Active", variable=active_var).grid(row=1, column=0, columnspan=2, padx=10, pady=5, sticky='w') def save_customer(): name = name_entry.get().strip() if not name: messagebox.showwarning("Invalid Input", "Customer name cannot be empty.") return # Find and update tree item for item in tree.get_children(): if tree.item(item)['values'][0] == customer_name: tree.item(item, values=(name, 'Yes' if active_var.get() else 'No')) break dialog.destroy() tk.Button(dialog, text="Save", command=save_customer).grid(row=2, column=0, padx=10, pady=10) tk.Button(dialog, text="Cancel", command=dialog.destroy).grid(row=2, column=1, padx=10, pady=10) def refresh_job_dropdowns(self): """Refresh all job dropdowns in the interface""" for row_num in self.data_rows: widgets = self.scrollable_frame.grid_slaves(row=row_num) for widget in widgets: if isinstance(widget, ttk.Combobox) and widget.grid_info()["column"] == 0: # This is a job dropdown current_selection = widget.get() widget['values'] = self.get_active_jobs() if current_selection in widget['values']: widget.set(current_selection) def refresh_customer_dropdowns(self): """Refresh all customer dropdowns in the interface""" for row_num in self.data_rows: widgets = self.scrollable_frame.grid_slaves(row=row_num) for widget in widgets: if isinstance(widget, ttk.Combobox) and widget.grid_info()["column"] == 3: # This is a customer dropdown current_selection = widget.get() widget['values'] = self.get_active_customers() if current_selection in widget['values']: widget.set(current_selection) if __name__ == "__main__": root = tk.Tk() app = TimeTracker(root) root.mainloop()