#!/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("", 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=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( "", 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, 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( "", 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()