- Implement precise column width calculation to prevent truncation of Job and Total columns - Add responsive page margins (0.5" left/right, 0.75" top/bottom) on landscape orientation - Scale font sizes and padding dynamically based on report width (5pt for 31+ days, 6pt for 25+ days, etc.) - Format date headers as MM-DD instead of YYYY-MM-DD to reduce column width requirements - Apply date formatting to both PDF export and preview dialog for consistency - Ensure reports with full month data (like October 31 days) display all columns without cutoff
2127 lines
76 KiB
Python
2127 lines
76 KiB
Python
#!/usr/bin/env python3
|
|
|
|
|
|
import tkinter as tk
|
|
from tkinter import ttk, messagebox, filedialog
|
|
from datetime import time, datetime, date
|
|
import csv
|
|
import os
|
|
import json
|
|
import calendar
|
|
import re
|
|
from collections import defaultdict
|
|
|
|
|
|
def sanitize_csv_text(text):
|
|
"""Sanitize text for safe CSV writing"""
|
|
if not text:
|
|
return ""
|
|
|
|
text = str(text)
|
|
|
|
# Remove dangerous characters that could cause CSV injection
|
|
dangerous_chars = ["=", "+", "-", "@", "\t", "\r", "\n"]
|
|
for char in dangerous_chars:
|
|
text = text.replace(char, "")
|
|
|
|
# Remove Excel formula triggers
|
|
text = re.sub(r"^[+\-=@]", "", text)
|
|
|
|
# Truncate to reasonable length
|
|
text = text[:500]
|
|
|
|
# Strip whitespace
|
|
return text.strip()
|
|
|
|
|
|
def sanitize_date_text(date_text):
|
|
"""Sanitize date text while preserving YYYY-MM-DD format"""
|
|
if not date_text:
|
|
return ""
|
|
|
|
text = str(date_text).strip()
|
|
|
|
# Remove dangerous characters except hyphens (needed for date format)
|
|
dangerous_chars = ["=", "+", "@", "\t", "\r", "\n"]
|
|
for char in dangerous_chars:
|
|
text = text.replace(char, "")
|
|
|
|
# Remove Excel formula triggers (except hyphens for date format)
|
|
text = re.sub(r"^[+\-=@]", "", text)
|
|
|
|
# Validate and fix date format if possible
|
|
# Remove extra hyphens but keep the YYYY-MM-DD structure
|
|
if text and "-" in text:
|
|
parts = text.split("-")
|
|
# Only keep first 3 parts (year, month, day)
|
|
parts = parts[:3]
|
|
# Ensure each part contains only digits
|
|
clean_parts = []
|
|
for part in parts:
|
|
clean_part = re.sub(r"[^0-9]", "", part)
|
|
if clean_part: # Only add if not empty
|
|
clean_parts.append(clean_part)
|
|
|
|
# Rebuild date if we have all parts
|
|
if len(clean_parts) == 3:
|
|
try:
|
|
# Validate the date
|
|
year = int(clean_parts[0])
|
|
month = int(clean_parts[1])
|
|
day = int(clean_parts[2])
|
|
|
|
# Basic validation
|
|
if 2000 <= year <= 2100 and 1 <= month <= 12 and 1 <= day <= 31:
|
|
text = f"{year:04d}-{month:02d}-{day:02d}"
|
|
else:
|
|
# Fallback to today if invalid
|
|
from datetime import date
|
|
|
|
text = date.today().strftime("%Y-%m-%d")
|
|
except (ValueError, TypeError):
|
|
# Fallback to today if anything goes wrong
|
|
from datetime import date
|
|
|
|
text = date.today().strftime("%Y-%m-%d")
|
|
else:
|
|
# Fallback to today if format is broken
|
|
from datetime import date
|
|
|
|
text = date.today().strftime("%Y-%m-%d")
|
|
|
|
return text.strip()
|
|
|
|
|
|
def sanitize_filename(filename):
|
|
"""Sanitize filename for safe file operations"""
|
|
if not filename:
|
|
return "default.csv"
|
|
|
|
text = str(filename)
|
|
|
|
# Remove path separators and dangerous characters
|
|
text = re.sub(r'[<>:"/\\|?*]', "", text)
|
|
text = re.sub(r"\.\.", "", text) # Remove directory traversal
|
|
|
|
# Remove leading/trailing dots and spaces
|
|
text = text.strip(". ")
|
|
|
|
# Ensure filename is not empty
|
|
if not text or text.startswith("."):
|
|
return "default.csv"
|
|
|
|
# Ensure .csv extension
|
|
if not text.lower().endswith(".csv"):
|
|
text += ".csv"
|
|
|
|
return text
|
|
|
|
|
|
def sanitize_config_text(text, max_length=100):
|
|
"""Sanitize text for configuration files"""
|
|
if not text:
|
|
return ""
|
|
|
|
text = str(text)
|
|
|
|
# Remove characters that could break JSON/config files
|
|
text = re.sub(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]", "", text)
|
|
text = re.sub(r'[{}[\]"]', "", text)
|
|
|
|
# Escape forward slashes and backslashes
|
|
text = text.replace("\\", "\\\\").replace("/", "\\/")
|
|
|
|
# Truncate to reasonable length
|
|
text = text[:max_length]
|
|
|
|
return text.strip()
|
|
|
|
|
|
def validate_input(input_type, value, **kwargs):
|
|
"""Validate and sanitize user input"""
|
|
if value is None:
|
|
value = ""
|
|
|
|
if input_type == "task_name":
|
|
return sanitize_csv_text(value)
|
|
|
|
elif input_type == "notes":
|
|
return sanitize_csv_text(value)
|
|
|
|
elif input_type == "invoice_number":
|
|
# Invoice numbers - allow alphanumeric, hyphens, underscores
|
|
value = str(value)
|
|
value = re.sub(r"[^\w\-]", "", value)
|
|
return value.strip()[:50] or "INV001"
|
|
|
|
elif input_type == "customer_name":
|
|
return sanitize_config_text(value)
|
|
|
|
elif input_type == "job_name":
|
|
return sanitize_config_text(value)
|
|
|
|
elif input_type == "file_path":
|
|
return sanitize_filename(value)
|
|
|
|
else:
|
|
# Default sanitization
|
|
return sanitize_csv_text(value)
|
|
|
|
|
|
class ClickableCell(tk.Frame):
|
|
def __init__(
|
|
self,
|
|
parent,
|
|
row_col_key,
|
|
callback,
|
|
time_tracker,
|
|
width=5,
|
|
height=2,
|
|
start_hour=9,
|
|
):
|
|
super().__init__(
|
|
parent, relief="solid", borderwidth=1, width=width, height=height
|
|
)
|
|
self.row_col_key = row_col_key
|
|
self.callback = callback
|
|
self.time_tracker = time_tracker
|
|
self.checked = False
|
|
self.start_hour = start_hour
|
|
|
|
# Calculate which hour this cell represents based on column index
|
|
col_idx = row_col_key[1]
|
|
hour_offset = col_idx // 4 # 4 cells per hour (15-minute intervals)
|
|
current_hour = start_hour + hour_offset
|
|
|
|
# Determine background color based on hour (alternating pattern)
|
|
if current_hour % 2 == 0:
|
|
self.default_bg = "#e8e8e8" # Medium gray for even hours
|
|
else:
|
|
self.default_bg = "#f5f5f5" # Light gray for odd hours
|
|
|
|
# Create label with hour-based background
|
|
self.label = tk.Label(
|
|
self, text=" ", bg=self.default_bg, width=width, height=height
|
|
)
|
|
self.label.pack(fill="both", expand=True)
|
|
|
|
# Bind only click events for now
|
|
self.label.bind("<Button-1>", self.on_mouse_down)
|
|
self.bind("<Button-1>", self.on_mouse_down)
|
|
|
|
def on_mouse_down(self, event):
|
|
# Start drag mode
|
|
self.time_tracker.drag_info["active"] = True
|
|
self.time_tracker.drag_info["mode"] = "paint" if not self.checked else "erase"
|
|
self.time_tracker.drag_info["start_row"] = self.row_col_key[0]
|
|
|
|
# Toggle this cell
|
|
self.checked = not self.checked
|
|
if self.checked:
|
|
self.label.config(bg="lightblue", text="✓")
|
|
else:
|
|
self.label.config(bg=self.default_bg, text=" ")
|
|
self.callback(self.row_col_key, self.checked)
|
|
self.time_tracker.drag_info["last_cell"] = self.row_col_key
|
|
|
|
def apply_drag_state(self, force_mode=None):
|
|
mode = force_mode or self.time_tracker.drag_info["mode"]
|
|
|
|
if mode == "paint" and not self.checked:
|
|
self.checked = True
|
|
self.label.config(bg="lightblue", text="✓")
|
|
self.callback(self.row_col_key, True)
|
|
return True
|
|
elif mode == "erase" and self.checked:
|
|
self.checked = False
|
|
self.label.config(bg=self.default_bg, text=" ")
|
|
self.callback(self.row_col_key, False)
|
|
return True
|
|
return False
|
|
|
|
def set_state(self, checked):
|
|
self.checked = checked
|
|
if checked:
|
|
self.label.config(bg="lightblue", text="✓")
|
|
else:
|
|
self.label.config(bg=self.default_bg, text=" ")
|
|
|
|
|
|
class TimeTracker:
|
|
def __init__(self, root):
|
|
self.root = root
|
|
self.root.title("Time Tracker")
|
|
self.root.geometry("1400x500")
|
|
|
|
# Settings - use UNIX-compliant config directory
|
|
self.settings_file = os.path.expanduser("~/.config/time-tracker.json")
|
|
settings = self.load_settings()
|
|
self.jobs = settings["jobs"]
|
|
self.customers = settings["customers"]
|
|
self.start_hour = settings["start_hour"]
|
|
self.work_hours = settings["work_hours"]
|
|
self.archive_path = settings["archive_path"]
|
|
|
|
# Drag state - moved from global to class attribute
|
|
self.drag_info = {
|
|
"active": False,
|
|
"mode": None, # 'paint' or 'erase'
|
|
"start_row": None,
|
|
"last_cell": None,
|
|
}
|
|
|
|
# Main container with scrollbars
|
|
main_container = tk.Frame(root)
|
|
main_container.pack(fill=tk.BOTH, expand=True)
|
|
|
|
# Create canvas and scrollbars
|
|
canvas = tk.Canvas(main_container)
|
|
h_scrollbar = tk.Scrollbar(
|
|
main_container, orient="horizontal", command=canvas.xview
|
|
)
|
|
v_scrollbar = tk.Scrollbar(
|
|
main_container, orient="vertical", command=canvas.yview
|
|
)
|
|
|
|
self.scrollable_frame = tk.Frame(canvas)
|
|
|
|
self.scrollable_frame.bind(
|
|
"<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))
|
|
)
|
|
|
|
canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw")
|
|
canvas.configure(xscrollcommand=h_scrollbar.set, yscrollcommand=v_scrollbar.set)
|
|
|
|
canvas.grid(row=0, column=0, sticky="nsew")
|
|
h_scrollbar.grid(row=1, column=0, sticky="ew")
|
|
v_scrollbar.grid(row=0, column=1, sticky="ns")
|
|
|
|
main_container.grid_rowconfigure(0, weight=1)
|
|
main_container.grid_columnconfigure(0, weight=1)
|
|
|
|
# Data storage
|
|
self.data_rows = {}
|
|
self.row_count = 0
|
|
self.time_cells = {}
|
|
self.time_assignments = {} # Track which time slots are assigned: time_idx -> row_num
|
|
|
|
# Current working date (defaults to today)
|
|
self.current_date = date.today()
|
|
|
|
# Daily total label reference
|
|
self.day_total_label = None
|
|
|
|
# Global mouse event bindings
|
|
self.root.bind("<B1-Motion>", self.on_global_drag)
|
|
self.root.bind("<ButtonRelease-1>", self.on_global_up)
|
|
|
|
# Create headers
|
|
self.create_headers()
|
|
|
|
# Start with one empty row for data entry
|
|
self.add_empty_row()
|
|
|
|
# Controls
|
|
controls_frame = tk.Frame(root)
|
|
controls_frame.pack(fill=tk.X, side=tk.BOTTOM, pady=5)
|
|
|
|
# Date selector on the left
|
|
tk.Label(controls_frame, text="Working Date:").pack(side=tk.LEFT, padx=5)
|
|
|
|
date_frame = tk.Frame(controls_frame)
|
|
date_frame.pack(side=tk.LEFT, padx=5)
|
|
|
|
self.date_month_var = tk.StringVar(value=str(self.current_date.month))
|
|
self.date_day_var = tk.StringVar(value=str(self.current_date.day))
|
|
self.date_year_var = tk.StringVar(value=str(self.current_date.year))
|
|
|
|
ttk.Combobox(
|
|
date_frame,
|
|
textvariable=self.date_month_var,
|
|
width=3,
|
|
values=[str(i) for i in range(1, 13)],
|
|
state="readonly",
|
|
).pack(side=tk.LEFT)
|
|
ttk.Combobox(
|
|
date_frame,
|
|
textvariable=self.date_day_var,
|
|
width=3,
|
|
values=[str(i) for i in range(1, 32)],
|
|
state="readonly",
|
|
).pack(side=tk.LEFT, padx=(2, 0))
|
|
ttk.Combobox(
|
|
date_frame,
|
|
textvariable=self.date_year_var,
|
|
width=6,
|
|
values=[str(i) for i in range(2020, 2031)],
|
|
state="readonly",
|
|
).pack(side=tk.LEFT, padx=(2, 0))
|
|
|
|
today_btn = tk.Button(
|
|
controls_frame, text="Today", command=self.set_date_to_today, width=6
|
|
)
|
|
today_btn.pack(side=tk.LEFT, padx=(0, 10))
|
|
|
|
# Separator
|
|
tk.Frame(controls_frame, width=20).pack(side=tk.LEFT, padx=5)
|
|
|
|
# Daily Total Hours Display
|
|
tk.Label(controls_frame, text="Day Total:").pack(side=tk.LEFT, padx=5)
|
|
self.day_total_label = tk.Label(
|
|
controls_frame,
|
|
text="0.00 hours",
|
|
relief="solid",
|
|
fg="black",
|
|
bg="lightyellow",
|
|
width=12,
|
|
font=("Arial", 10, "bold"),
|
|
)
|
|
self.day_total_label.pack(side=tk.LEFT, padx=5)
|
|
|
|
# Initialize day total display
|
|
self.update_day_total()
|
|
|
|
# Separator
|
|
tk.Frame(controls_frame, width=20).pack(side=tk.LEFT, padx=5)
|
|
|
|
add_btn = tk.Button(
|
|
controls_frame, text="Add New Row", command=self.add_empty_row
|
|
)
|
|
add_btn.pack(side=tk.LEFT, padx=5)
|
|
|
|
archive_btn = tk.Button(
|
|
controls_frame, text="Archive Day", command=self.archive_day
|
|
)
|
|
archive_btn.pack(side=tk.LEFT, padx=5)
|
|
|
|
report_btn = tk.Button(
|
|
controls_frame,
|
|
text="Build Our Details Report",
|
|
command=self.open_report_dialog,
|
|
)
|
|
report_btn.pack(side=tk.LEFT, padx=5)
|
|
|
|
settings_btn = tk.Button(
|
|
controls_frame, text="Settings", command=self.open_settings
|
|
)
|
|
settings_btn.pack(side=tk.LEFT, padx=5)
|
|
|
|
def load_settings(self):
|
|
"""Load settings from JSON file"""
|
|
# Ensure config directory exists
|
|
config_dir = os.path.dirname(self.settings_file)
|
|
if config_dir and not os.path.exists(config_dir):
|
|
try:
|
|
os.makedirs(config_dir, exist_ok=True)
|
|
except OSError:
|
|
pass # Will fall back to defaults if directory can't be created
|
|
|
|
if os.path.exists(self.settings_file):
|
|
try:
|
|
with open(self.settings_file, "r") as f:
|
|
data = json.load(f)
|
|
return {
|
|
"jobs": data.get(
|
|
"jobs",
|
|
[
|
|
{
|
|
"name": "Troubleshooting",
|
|
"billable": True,
|
|
"active": True,
|
|
},
|
|
{
|
|
"name": "Development",
|
|
"billable": True,
|
|
"active": True,
|
|
},
|
|
{"name": "Meeting", "billable": False, "active": True},
|
|
{"name": "Admin", "billable": False, "active": True},
|
|
],
|
|
),
|
|
"customers": data.get(
|
|
"customers",
|
|
[
|
|
{"name": "Internal", "active": True},
|
|
{"name": "Client Corp", "active": True},
|
|
{"name": "Customer Inc", "active": True},
|
|
],
|
|
),
|
|
"start_hour": data.get("start_hour", 9),
|
|
"work_hours": data.get("work_hours", 8),
|
|
"archive_path": data.get(
|
|
"archive_path", "time_tracker_archive.csv"
|
|
),
|
|
}
|
|
except Exception as e:
|
|
messagebox.showwarning(
|
|
"Settings Error", f"Failed to load settings: {e}"
|
|
)
|
|
|
|
# Default settings if no file exists
|
|
return {
|
|
"jobs": [
|
|
{"name": "Troubleshooting", "billable": True, "active": True},
|
|
{"name": "Development", "billable": True, "active": True},
|
|
{"name": "Meeting", "billable": False, "active": True},
|
|
{"name": "Admin", "billable": False, "active": True},
|
|
],
|
|
"customers": [
|
|
{"name": "Internal", "active": True},
|
|
{"name": "Client Corp", "active": True},
|
|
{"name": "Customer Inc", "active": True},
|
|
],
|
|
"start_hour": 9,
|
|
"work_hours": 8,
|
|
"archive_path": "time_tracker_archive.csv",
|
|
}
|
|
|
|
def save_settings(self):
|
|
"""Save settings to JSON file using atomic write operation"""
|
|
try:
|
|
# Ensure config directory exists
|
|
config_dir = os.path.dirname(self.settings_file)
|
|
if config_dir and not os.path.exists(config_dir):
|
|
os.makedirs(config_dir, exist_ok=True)
|
|
|
|
# Create temporary file in same directory to ensure atomic operation
|
|
temp_file = self.settings_file + ".tmp"
|
|
|
|
try:
|
|
with open(temp_file, "w") as f:
|
|
# Sanitize configuration data before saving
|
|
sanitized_jobs = []
|
|
for job in self.jobs:
|
|
sanitized_jobs.append(
|
|
{
|
|
"name": sanitize_config_text(job.get("name", ""), 100),
|
|
"billable": bool(job.get("billable", True)),
|
|
"active": bool(job.get("active", True)),
|
|
}
|
|
)
|
|
|
|
sanitized_customers = []
|
|
for customer in self.customers:
|
|
sanitized_customers.append(
|
|
{
|
|
"name": sanitize_config_text(
|
|
customer.get("name", ""), 100
|
|
),
|
|
"active": bool(customer.get("active", True)),
|
|
}
|
|
)
|
|
|
|
json.dump(
|
|
{
|
|
"jobs": sanitized_jobs,
|
|
"customers": sanitized_customers,
|
|
"start_hour": int(self.start_hour),
|
|
"work_hours": int(self.work_hours),
|
|
"archive_path": sanitize_filename(self.archive_path),
|
|
},
|
|
f,
|
|
indent=2,
|
|
)
|
|
|
|
# Atomic replace operation - this is the critical fix
|
|
os.replace(temp_file, self.settings_file)
|
|
|
|
except Exception:
|
|
# Clean up temp file if something went wrong
|
|
if os.path.exists(temp_file):
|
|
try:
|
|
os.remove(temp_file)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
|
|
return True
|
|
except Exception as e:
|
|
messagebox.showerror("Save Error", f"Failed to save settings: {e}")
|
|
return False
|
|
|
|
def get_selected_date(self):
|
|
"""Get the currently selected date from the form"""
|
|
try:
|
|
return date(
|
|
int(self.date_year_var.get()),
|
|
int(self.date_month_var.get()),
|
|
int(self.date_day_var.get()),
|
|
)
|
|
except ValueError:
|
|
# If invalid date, return today's date
|
|
return date.today()
|
|
|
|
def set_date_to_today(self):
|
|
"""Set the date fields to today's date"""
|
|
today = date.today()
|
|
self.date_month_var.set(str(today.month))
|
|
self.date_day_var.set(str(today.day))
|
|
self.date_year_var.set(str(today.year))
|
|
|
|
def get_active_jobs(self):
|
|
"""Get list of active job names for dropdown"""
|
|
return [job["name"] for job in self.jobs if job.get("active", True)]
|
|
|
|
def get_active_customers(self):
|
|
"""Get list of active customer names for dropdown"""
|
|
return [
|
|
customer["name"]
|
|
for customer in self.customers
|
|
if customer.get("active", True)
|
|
]
|
|
|
|
def get_job_billable_status(self, job_name):
|
|
"""Get billable status for a specific job"""
|
|
for job in self.jobs:
|
|
if job["name"] == job_name:
|
|
return job.get("billable", True)
|
|
return True # Default to True if job not found
|
|
|
|
def on_global_drag(self, event):
|
|
"""Global drag handler that finds which cell we're over"""
|
|
|
|
if not self.drag_info["active"]:
|
|
return
|
|
|
|
# Find which widget we're currently over
|
|
widget = event.widget.winfo_containing(event.x_root, event.y_root)
|
|
|
|
# Check if it's a ClickableCell
|
|
cell = None
|
|
current_widget = widget
|
|
while current_widget:
|
|
if isinstance(current_widget, ClickableCell):
|
|
cell = current_widget
|
|
break
|
|
current_widget = current_widget.master
|
|
|
|
if cell and cell.row_col_key != self.drag_info["last_cell"]:
|
|
applied = cell.apply_drag_state()
|
|
if applied:
|
|
self.drag_info["last_cell"] = cell.row_col_key
|
|
|
|
def on_global_up(self, event):
|
|
"""Global mouse up handler"""
|
|
|
|
self.drag_info["active"] = False
|
|
self.drag_info["mode"] = None
|
|
self.drag_info["start_row"] = None
|
|
self.drag_info["last_cell"] = None
|
|
|
|
def create_headers(self):
|
|
headers = ["Job", "Task Name", "Notes", "Customer"]
|
|
|
|
# Calculate time slots based on settings
|
|
time_slots = self.work_hours * 4 # 4 slots per hour (15-minute intervals)
|
|
|
|
for i in range(time_slots):
|
|
total_minutes = self.start_hour * 60 + i * 15
|
|
hour = total_minutes // 60
|
|
minute = total_minutes % 60
|
|
time_str = f"{hour:02d}:{minute:02d}"
|
|
headers.append(time_str)
|
|
|
|
headers.append("Total Hours")
|
|
|
|
# Create header labels
|
|
for i, header in enumerate(headers):
|
|
if i < 4:
|
|
width = 15
|
|
elif i == len(headers) - 1:
|
|
width = 10
|
|
else:
|
|
width = 6
|
|
|
|
label = tk.Label(
|
|
self.scrollable_frame,
|
|
text=header,
|
|
relief="solid",
|
|
borderwidth=1,
|
|
width=width,
|
|
height=2,
|
|
)
|
|
label.grid(row=0, column=i, sticky="nsew")
|
|
|
|
def add_empty_row(self):
|
|
self.add_row("", "", "", "")
|
|
|
|
def add_row(self, job, task_name, notes, customer):
|
|
self.row_count += 1
|
|
row_num = self.row_count
|
|
|
|
# Job dropdown
|
|
job_dropdown = ttk.Combobox(
|
|
self.scrollable_frame,
|
|
width=15,
|
|
values=self.get_active_jobs(),
|
|
state="readonly",
|
|
)
|
|
job_dropdown.grid(row=row_num, column=0, sticky="nsew", padx=1, pady=1)
|
|
if job and job in job_dropdown["values"]:
|
|
job_dropdown.set(job)
|
|
# Don't auto-select first job for empty rows
|
|
|
|
# Task Name field
|
|
task_entry = tk.Entry(self.scrollable_frame, width=15)
|
|
task_entry.insert(0, validate_input("task_name", task_name))
|
|
task_entry.grid(row=row_num, column=1, sticky="nsew", padx=1, pady=1)
|
|
|
|
# Notes field
|
|
notes_entry = tk.Entry(self.scrollable_frame, width=15)
|
|
notes_entry.insert(0, validate_input("notes", notes))
|
|
notes_entry.grid(row=row_num, column=2, sticky="nsew", padx=1, pady=1)
|
|
|
|
# Customer field
|
|
customer_dropdown = ttk.Combobox(
|
|
self.scrollable_frame,
|
|
width=15,
|
|
values=self.get_active_customers(),
|
|
state="readonly",
|
|
)
|
|
customer_dropdown.grid(row=row_num, column=3, sticky="nsew", padx=1, pady=1)
|
|
if customer and customer in customer_dropdown["values"]:
|
|
customer_dropdown.set(customer)
|
|
# Don't auto-select first customer for empty rows
|
|
|
|
# Time cells
|
|
self.time_cells[row_num] = {}
|
|
time_slots = self.work_hours * 4 # Calculate based on current settings
|
|
for i in range(time_slots):
|
|
cell = ClickableCell(
|
|
self.scrollable_frame,
|
|
(row_num, i),
|
|
self.on_time_cell_clicked,
|
|
self,
|
|
width=5,
|
|
height=1,
|
|
start_hour=self.start_hour,
|
|
)
|
|
cell.grid(row=row_num, column=4 + i, sticky="nsew", padx=1, pady=1)
|
|
self.time_cells[row_num][i] = cell
|
|
|
|
# Total hours label
|
|
total_label = tk.Label(
|
|
self.scrollable_frame,
|
|
text="0.00",
|
|
relief="solid",
|
|
borderwidth=1,
|
|
width=10,
|
|
height=2,
|
|
)
|
|
total_label.grid(
|
|
row=row_num, column=4 + time_slots, sticky="nsew", padx=1, pady=1
|
|
)
|
|
|
|
# Store row data
|
|
self.data_rows[row_num] = {
|
|
"total_label": total_label,
|
|
"time_cells": self.time_cells[row_num],
|
|
}
|
|
|
|
def update_day_total(self):
|
|
"""Update the daily total hours display"""
|
|
if self.day_total_label:
|
|
total_slots = len(self.time_assignments)
|
|
total_hours = total_slots * 0.25
|
|
self.day_total_label.config(text=f"{total_hours:.2f} hours")
|
|
|
|
# Change color if over 8 hours
|
|
if total_hours > 8.0:
|
|
self.day_total_label.config(bg="salmon", fg="black")
|
|
elif total_hours == 8.0:
|
|
self.day_total_label.config(bg="lightgreen", fg="black")
|
|
else:
|
|
self.day_total_label.config(bg="lightyellow", fg="black")
|
|
|
|
def on_time_cell_clicked(self, row_col_key, checked):
|
|
row_num, col_idx = row_col_key
|
|
cell = self.time_cells[row_num][col_idx]
|
|
|
|
if checked:
|
|
# Check if this time slot is already assigned to another row
|
|
if (
|
|
col_idx in self.time_assignments
|
|
and self.time_assignments[col_idx] != row_num
|
|
):
|
|
existing_row = self.time_assignments[col_idx]
|
|
|
|
# Find the job name for the existing assignment
|
|
job_name = f"Task on row {existing_row}"
|
|
widgets = self.scrollable_frame.grid_slaves(row=existing_row)
|
|
for widget in widgets:
|
|
if isinstance(widget, ttk.Combobox): # Job dropdown column
|
|
job_name = widget.get()
|
|
break
|
|
|
|
start_minute = col_idx * 15
|
|
hour = self.start_hour + (self.start_hour * 60 + start_minute) // 60
|
|
minute = (self.start_hour * 60 + start_minute) % 60
|
|
|
|
# Highlight conflicting cells in red
|
|
cell = self.time_cells[row_num][col_idx]
|
|
cell.label.config(bg="red", text="✗")
|
|
|
|
# Also highlight the existing conflicting cell
|
|
if (
|
|
existing_row in self.time_cells
|
|
and col_idx in self.time_cells[existing_row]
|
|
):
|
|
existing_cell = self.time_cells[existing_row][col_idx]
|
|
existing_cell.label.config(bg="red", text="✗")
|
|
|
|
messagebox.showwarning(
|
|
"Time Conflict",
|
|
f"Time slot at {hour:02d}:{minute:02d} is already assigned to:\n{job_name}\n\nConflicting slots marked in red.",
|
|
)
|
|
|
|
# Don't return - let the cell remain checked so user can see the conflict
|
|
|
|
# Assign this time slot to this row
|
|
self.time_assignments[col_idx] = row_num
|
|
else:
|
|
# Remove assignment from this row
|
|
if self.time_assignments.get(col_idx) == row_num:
|
|
del self.time_assignments[col_idx]
|
|
|
|
# Clear any red conflict highlighting for this column across all rows
|
|
for other_row_num in self.time_cells:
|
|
if col_idx in self.time_cells[other_row_num]:
|
|
cell = self.time_cells[other_row_num][col_idx]
|
|
if cell.label["bg"] == "red":
|
|
# Reset to normal state based on checked status
|
|
if cell.checked:
|
|
cell.label.config(bg="lightblue", text="✓")
|
|
else:
|
|
cell.label.config(bg=cell.default_bg, text=" ")
|
|
|
|
self.update_total_hours(row_num)
|
|
self.update_day_total()
|
|
|
|
def update_total_hours(self, row_num):
|
|
if row_num not in self.data_rows:
|
|
return
|
|
|
|
checked_count = sum(
|
|
1 for cell in self.data_rows[row_num]["time_cells"].values() if cell.checked
|
|
)
|
|
total_hours = checked_count * 0.25
|
|
self.data_rows[row_num]["total_label"].config(text=f"{total_hours:.2f}")
|
|
|
|
def get_row_data(self, row_num):
|
|
"""Extract data from a specific row"""
|
|
if row_num not in self.data_rows:
|
|
return None
|
|
|
|
# Get text from entry widgets with sanitization
|
|
widgets = self.scrollable_frame.grid_slaves(row=row_num)
|
|
job = task = notes = customer = ""
|
|
|
|
for widget in widgets:
|
|
col = widget.grid_info()["column"]
|
|
if col == 0: # Job dropdown
|
|
job = validate_input(
|
|
"job_name", widget.get() if isinstance(widget, ttk.Combobox) else ""
|
|
)
|
|
elif col == 1: # Task Name column
|
|
task = validate_input(
|
|
"task_name", widget.get() if isinstance(widget, tk.Entry) else ""
|
|
)
|
|
elif col == 2: # Notes column
|
|
notes = validate_input(
|
|
"notes", widget.get() if isinstance(widget, tk.Entry) else ""
|
|
)
|
|
elif col == 3: # Customer column (now dropdown)
|
|
customer = validate_input(
|
|
"customer_name",
|
|
widget.get() if isinstance(widget, ttk.Combobox) else "",
|
|
)
|
|
|
|
# Calculate hours
|
|
checked_count = sum(
|
|
1 for cell in self.data_rows[row_num]["time_cells"].values() if cell.checked
|
|
)
|
|
total_hours = checked_count * 0.25
|
|
|
|
return {
|
|
"job": job,
|
|
"task": task,
|
|
"notes": notes,
|
|
"customer": customer,
|
|
"hours": total_hours,
|
|
}
|
|
|
|
def archive_day(self):
|
|
"""Archive current day's data to CSV"""
|
|
# Check if there's any data to archive
|
|
data_to_archive = []
|
|
for row_num in self.data_rows:
|
|
row_data = self.get_row_data(row_num)
|
|
if row_data and row_data["hours"] > 0:
|
|
data_to_archive.append(row_data)
|
|
|
|
if not data_to_archive:
|
|
messagebox.showinfo("No Data", "No time entries to archive.")
|
|
return
|
|
|
|
# Check for missing customer data
|
|
rows_with_no_customer = []
|
|
for row_data in data_to_archive:
|
|
if not row_data["customer"] or row_data["customer"].strip() == "":
|
|
rows_with_no_customer.append(row_data)
|
|
|
|
if rows_with_no_customer:
|
|
error_msg = f"Found {len(rows_with_no_customer)} time entry(s) with no customer specified:\n\n"
|
|
for i, row in enumerate(rows_with_no_customer[:3]): # Show first 3 examples
|
|
error_msg += f"• {row['job']}: {row['task'] or '(no task)'} ({row['hours']:.2f} hours)\n"
|
|
if len(rows_with_no_customer) > 3:
|
|
error_msg += f"... and {len(rows_with_no_customer) - 3} more\n"
|
|
error_msg += "\nPlease select a customer for all entries before archiving."
|
|
messagebox.showerror("Missing Customer Data", error_msg)
|
|
return
|
|
|
|
# Get archive file path from settings
|
|
archive_path = self.archive_path
|
|
|
|
# Check if file exists to determine if we need headers
|
|
file_exists = os.path.exists(archive_path)
|
|
|
|
try:
|
|
with open(archive_path, "a", newline="", encoding="utf-8") as csvfile:
|
|
fieldnames = [
|
|
"Job",
|
|
"TaskName",
|
|
"Note",
|
|
"Customer",
|
|
"Hours",
|
|
"Date",
|
|
"username",
|
|
"Billable",
|
|
"Billed",
|
|
]
|
|
writer = csv.DictWriter(
|
|
csvfile, fieldnames=fieldnames, quoting=csv.QUOTE_MINIMAL
|
|
)
|
|
|
|
# Write header if file is new
|
|
if not file_exists:
|
|
writer.writeheader()
|
|
|
|
# Write each row with archive metadata
|
|
for row_data in data_to_archive:
|
|
writer.writerow(
|
|
{
|
|
"Job": sanitize_csv_text(row_data["job"]),
|
|
"TaskName": sanitize_csv_text(row_data["task"]),
|
|
"Note": sanitize_csv_text(row_data["notes"]),
|
|
"Customer": sanitize_csv_text(row_data["customer"]),
|
|
"Hours": float(row_data["hours"]), # Ensure numeric
|
|
"Date": sanitize_date_text(
|
|
self.get_selected_date().strftime("%Y-%m-%d")
|
|
),
|
|
"username": sanitize_csv_text(
|
|
os.getenv("USER", os.getenv("USERNAME", "unknown"))
|
|
),
|
|
"Billable": bool(
|
|
self.get_job_billable_status(row_data["job"])
|
|
),
|
|
"Billed": False, # Default to False for now
|
|
}
|
|
)
|
|
|
|
messagebox.showinfo(
|
|
"Archive Complete",
|
|
f"Archived {len(data_to_archive)} entries to {archive_path}",
|
|
)
|
|
|
|
# Clear the interface after successful archive
|
|
self.clear_all_rows()
|
|
|
|
except Exception as e:
|
|
messagebox.showerror("Archive Failed", f"Failed to archive data: {str(e)}")
|
|
|
|
def clear_all_rows(self):
|
|
"""Clear all data rows from the interface"""
|
|
# Remove all widgets in scrollable frame except headers
|
|
for widget in list(self.scrollable_frame.winfo_children()):
|
|
widget.destroy()
|
|
|
|
# Reset data structures
|
|
self.data_rows = {}
|
|
self.time_cells = {}
|
|
self.time_assignments = {}
|
|
self.row_count = 0
|
|
|
|
# Recreate headers
|
|
self.create_headers()
|
|
|
|
def open_report_dialog(self):
|
|
"""Open dialog for generating Billable Hours Details report"""
|
|
dialog = tk.Toplevel(self.root)
|
|
dialog.title("Billable Hours Details Report")
|
|
dialog.geometry("400x350")
|
|
|
|
# Invoice Number
|
|
tk.Label(dialog, text="Invoice Number:").grid(
|
|
row=0, column=0, padx=10, pady=5, sticky="w"
|
|
)
|
|
invoice_entry = tk.Entry(dialog, width=30)
|
|
invoice_entry.grid(row=0, column=1, padx=10, pady=5)
|
|
|
|
# Customer Selection
|
|
tk.Label(dialog, text="Customer:").grid(
|
|
row=1, column=0, padx=10, pady=5, sticky="w"
|
|
)
|
|
customer_combo = ttk.Combobox(
|
|
dialog, width=27, values=self.get_active_customers(), state="readonly"
|
|
)
|
|
customer_combo.grid(row=1, column=1, padx=10, pady=5)
|
|
if customer_combo["values"]:
|
|
customer_combo.set(customer_combo["values"][0])
|
|
|
|
# Date Range
|
|
tk.Label(dialog, text="Start Date:").grid(
|
|
row=2, column=0, padx=10, pady=5, sticky="w"
|
|
)
|
|
start_date_frame = tk.Frame(dialog)
|
|
start_date_frame.grid(row=2, column=1, padx=10, pady=5, sticky="w")
|
|
|
|
start_month_var = tk.StringVar(value=str(datetime.now().month))
|
|
start_day_var = tk.StringVar(value="1")
|
|
start_year_var = tk.StringVar(value=str(datetime.now().year))
|
|
|
|
ttk.Combobox(
|
|
start_date_frame,
|
|
textvariable=start_month_var,
|
|
width=5,
|
|
values=[str(i) for i in range(1, 13)],
|
|
).pack(side=tk.LEFT)
|
|
ttk.Combobox(
|
|
start_date_frame,
|
|
textvariable=start_day_var,
|
|
width=5,
|
|
values=[str(i) for i in range(1, 32)],
|
|
).pack(side=tk.LEFT, padx=(5, 0))
|
|
ttk.Combobox(
|
|
start_date_frame,
|
|
textvariable=start_year_var,
|
|
width=8,
|
|
values=[str(i) for i in range(2020, 2031)],
|
|
).pack(side=tk.LEFT, padx=(5, 0))
|
|
|
|
tk.Label(dialog, text="End Date:").grid(
|
|
row=3, column=0, padx=10, pady=5, sticky="w"
|
|
)
|
|
end_date_frame = tk.Frame(dialog)
|
|
end_date_frame.grid(row=3, column=1, padx=10, pady=5, sticky="w")
|
|
|
|
end_month_var = tk.StringVar(value=str(datetime.now().month))
|
|
end_day_var = tk.StringVar(
|
|
value=str(calendar.monthrange(datetime.now().year, datetime.now().month)[1])
|
|
)
|
|
end_year_var = tk.StringVar(value=str(datetime.now().year))
|
|
|
|
ttk.Combobox(
|
|
end_date_frame,
|
|
textvariable=end_month_var,
|
|
width=5,
|
|
values=[str(i) for i in range(1, 13)],
|
|
).pack(side=tk.LEFT)
|
|
ttk.Combobox(
|
|
end_date_frame,
|
|
textvariable=end_day_var,
|
|
width=5,
|
|
values=[str(i) for i in range(1, 32)],
|
|
).pack(side=tk.LEFT, padx=(5, 0))
|
|
ttk.Combobox(
|
|
end_date_frame,
|
|
textvariable=end_year_var,
|
|
width=8,
|
|
values=[str(i) for i in range(2020, 2031)],
|
|
).pack(side=tk.LEFT, padx=(5, 0))
|
|
|
|
# Include only billable checkbox
|
|
include_billable_var = tk.BooleanVar(value=True)
|
|
tk.Checkbutton(
|
|
dialog, text="Include only billable hours", variable=include_billable_var
|
|
).grid(row=4, column=0, columnspan=2, padx=10, pady=10, sticky="w")
|
|
|
|
def generate_report():
|
|
try:
|
|
invoice_num = validate_input(
|
|
"invoice_number", invoice_entry.get().strip()
|
|
)
|
|
if not invoice_num:
|
|
messagebox.showwarning(
|
|
"Invalid Input", "Please enter a valid invoice number."
|
|
)
|
|
return
|
|
|
|
customer = validate_input("customer_name", customer_combo.get())
|
|
start_date_obj = date(
|
|
int(start_year_var.get()),
|
|
int(start_month_var.get()),
|
|
int(start_day_var.get()),
|
|
)
|
|
end_date_obj = date(
|
|
int(end_year_var.get()),
|
|
int(end_month_var.get()),
|
|
int(end_day_var.get()),
|
|
)
|
|
|
|
if start_date_obj > end_date_obj:
|
|
messagebox.showwarning(
|
|
"Invalid Date Range",
|
|
"Start date must be before or equal to end date.",
|
|
)
|
|
return
|
|
|
|
self.generate_report_data(
|
|
invoice_num,
|
|
customer,
|
|
start_date_obj,
|
|
end_date_obj,
|
|
include_billable_var.get(),
|
|
)
|
|
dialog.destroy()
|
|
|
|
except ValueError as e:
|
|
messagebox.showerror("Invalid Date", "Please enter valid dates.")
|
|
except Exception as e:
|
|
messagebox.showerror("Error", f"Failed to generate report: {str(e)}")
|
|
|
|
def cancel():
|
|
dialog.destroy()
|
|
|
|
# Buttons
|
|
btn_frame = tk.Frame(dialog)
|
|
btn_frame.grid(row=5, column=0, columnspan=2, pady=20)
|
|
|
|
tk.Button(
|
|
btn_frame, text="Generate Report", command=generate_report, width=15
|
|
).pack(side=tk.LEFT, padx=5)
|
|
tk.Button(btn_frame, text="Cancel", command=cancel, width=15).pack(
|
|
side=tk.LEFT, padx=5
|
|
)
|
|
|
|
def generate_report_data(
|
|
self, invoice_num, customer, start_date, end_date, billable_only
|
|
):
|
|
"""Generate report data and show preview dialog"""
|
|
archive_file = self.archive_path
|
|
|
|
if not os.path.exists(archive_file):
|
|
messagebox.showerror(
|
|
"No Data", "Archive file not found. No data to generate report."
|
|
)
|
|
return
|
|
|
|
# Read and filter CSV data
|
|
filtered_data = []
|
|
try:
|
|
with open(archive_file, "r", encoding="utf-8") as csvfile:
|
|
reader = csv.DictReader(csvfile)
|
|
for row in reader:
|
|
row_date = datetime.strptime(row["Date"], "%Y-%m-%d").date()
|
|
|
|
# Apply filters
|
|
if (
|
|
row["Customer"] == customer
|
|
and start_date <= row_date <= end_date
|
|
and (
|
|
not billable_only
|
|
or row.get("Billable", "True").lower() == "true"
|
|
)
|
|
):
|
|
filtered_data.append(row)
|
|
except Exception as e:
|
|
messagebox.showerror("Read Error", f"Failed to read archive file: {str(e)}")
|
|
return
|
|
|
|
if not filtered_data:
|
|
messagebox.showinfo(
|
|
"No Data", f"No data found for {customer} in the selected date range."
|
|
)
|
|
return
|
|
|
|
# Create pivot table data structure
|
|
pivot_data = defaultdict(lambda: defaultdict(float)) # job -> date -> hours
|
|
all_dates = set()
|
|
all_jobs = set()
|
|
|
|
for row in filtered_data:
|
|
job = row["Job"]
|
|
date_str = row["Date"]
|
|
hours = float(row["Hours"])
|
|
|
|
pivot_data[job][date_str] += hours
|
|
all_dates.add(date_str)
|
|
all_jobs.add(job)
|
|
|
|
# Sort dates and jobs
|
|
sorted_dates = sorted(all_dates)
|
|
sorted_jobs = sorted(all_jobs)
|
|
|
|
# Show report preview dialog
|
|
self.show_report_preview(
|
|
invoice_num,
|
|
customer,
|
|
start_date,
|
|
end_date,
|
|
pivot_data,
|
|
sorted_jobs,
|
|
sorted_dates,
|
|
filtered_data,
|
|
billable_only,
|
|
)
|
|
|
|
def show_report_preview(
|
|
self,
|
|
invoice_num,
|
|
customer,
|
|
start_date,
|
|
end_date,
|
|
pivot_data,
|
|
jobs,
|
|
dates,
|
|
filtered_data,
|
|
billable_only=True,
|
|
):
|
|
"""Show report preview dialog with pivot table and export options"""
|
|
preview_window = tk.Toplevel(self.root)
|
|
preview_window.title(f"Invoice #{invoice_num} Billable Hours Details")
|
|
preview_window.geometry("1200x600")
|
|
|
|
# Title frame
|
|
title_frame = tk.Frame(preview_window)
|
|
title_frame.pack(fill=tk.X, padx=10, pady=10)
|
|
|
|
title_label = tk.Label(
|
|
title_frame,
|
|
text=f"Invoice #{invoice_num} Billable Hours Details",
|
|
font=("Arial", 16, "bold"),
|
|
)
|
|
title_label.pack(side=tk.LEFT)
|
|
|
|
subtitle_label = tk.Label(
|
|
title_frame,
|
|
text=f"Customer: {customer} | Period: {start_date} to {end_date}",
|
|
)
|
|
subtitle_label.pack(side=tk.LEFT, padx=(20, 0))
|
|
|
|
# Create scrollable frame for table
|
|
main_frame = tk.Frame(preview_window)
|
|
main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
|
|
|
|
canvas = tk.Canvas(main_frame)
|
|
h_scrollbar = tk.Scrollbar(
|
|
main_frame, orient="horizontal", command=canvas.xview
|
|
)
|
|
v_scrollbar = tk.Scrollbar(main_frame, orient="vertical", command=canvas.yview)
|
|
|
|
table_frame = tk.Frame(canvas)
|
|
|
|
table_frame.bind(
|
|
"<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all"))
|
|
)
|
|
|
|
canvas.create_window((0, 0), window=table_frame, anchor="nw")
|
|
canvas.configure(xscrollcommand=h_scrollbar.set, yscrollcommand=v_scrollbar.set)
|
|
|
|
canvas.grid(row=0, column=0, sticky="nsew")
|
|
h_scrollbar.grid(row=1, column=0, sticky="ew")
|
|
v_scrollbar.grid(row=0, column=1, sticky="ns")
|
|
|
|
main_frame.grid_rowconfigure(0, weight=1)
|
|
main_frame.grid_columnconfigure(0, weight=1)
|
|
|
|
# Build pivot table
|
|
# Format dates as MM-DD for better preview display
|
|
formatted_dates = []
|
|
for date_str in dates:
|
|
try:
|
|
# Convert from YYYY-MM-DD to MM-DD
|
|
date_obj = datetime.strptime(date_str, "%Y-%m-%d").date()
|
|
formatted_dates.append(date_obj.strftime("%m-%d"))
|
|
except ValueError:
|
|
# Fallback to original if parsing fails
|
|
formatted_dates.append(date_str)
|
|
|
|
headers = ["Job"] + formatted_dates + ["Total"]
|
|
for col, header in enumerate(headers):
|
|
width = 15 if col == 0 else 10
|
|
label = tk.Label(
|
|
table_frame,
|
|
text=header,
|
|
relief="solid",
|
|
borderwidth=1,
|
|
width=width,
|
|
height=2,
|
|
font=("Arial", 10, "bold"),
|
|
)
|
|
label.grid(row=0, column=col, sticky="nsew")
|
|
|
|
# Data rows with totals
|
|
grand_total = 0.0
|
|
day_totals = [0.0] * len(dates)
|
|
|
|
for row, job in enumerate(jobs, 1):
|
|
# Job name
|
|
job_label = tk.Label(
|
|
table_frame, text=job, relief="solid", borderwidth=1, width=15, height=2
|
|
)
|
|
job_label.grid(row=row, column=0, sticky="nsew")
|
|
|
|
# Daily hours
|
|
row_total = 0.0
|
|
for col, date_str in enumerate(dates, 1):
|
|
hours = pivot_data.get(job, {}).get(date_str, 0.0)
|
|
if hours > 0:
|
|
cell_label = tk.Label(
|
|
table_frame,
|
|
text=f"{hours:.2f}",
|
|
relief="solid",
|
|
borderwidth=1,
|
|
width=10,
|
|
height=2,
|
|
)
|
|
row_total += hours
|
|
day_totals[col - 1] += hours
|
|
else:
|
|
cell_label = tk.Label(
|
|
table_frame,
|
|
text="",
|
|
relief="solid",
|
|
borderwidth=1,
|
|
width=10,
|
|
height=2,
|
|
)
|
|
cell_label.grid(row=row, column=col, sticky="nsew")
|
|
|
|
# Row total
|
|
row_total_label = tk.Label(
|
|
table_frame,
|
|
text=f"{row_total:.2f}",
|
|
relief="solid",
|
|
borderwidth=1,
|
|
width=10,
|
|
height=2,
|
|
font=("Arial", 10, "bold"),
|
|
)
|
|
row_total_label.grid(row=row, column=len(dates) + 1, sticky="nsew")
|
|
grand_total += row_total
|
|
|
|
# Bottom row - Day totals
|
|
total_row = len(jobs) + 1
|
|
totals_label = tk.Label(
|
|
table_frame,
|
|
text="Daily Total",
|
|
relief="solid",
|
|
borderwidth=1,
|
|
width=15,
|
|
height=2,
|
|
font=("Arial", 10, "bold"),
|
|
)
|
|
totals_label.grid(row=total_row, column=0, sticky="nsew")
|
|
|
|
for col, day_total in enumerate(day_totals, 1):
|
|
day_total_label = tk.Label(
|
|
table_frame,
|
|
text=f"{day_total:.2f}",
|
|
relief="solid",
|
|
borderwidth=1,
|
|
width=10,
|
|
height=2,
|
|
font=("Arial", 10, "bold"),
|
|
)
|
|
day_total_label.grid(row=total_row, column=col, sticky="nsew")
|
|
|
|
# Grand total
|
|
grand_total_label = tk.Label(
|
|
table_frame,
|
|
text=f"{grand_total:.2f}",
|
|
relief="solid",
|
|
borderwidth=1,
|
|
width=10,
|
|
height=2,
|
|
font=("Arial", 10, "bold"),
|
|
)
|
|
grand_total_label.grid(row=total_row, column=len(dates) + 1, sticky="nsew")
|
|
|
|
# Export buttons
|
|
button_frame = tk.Frame(preview_window)
|
|
button_frame.pack(fill=tk.X, padx=10, pady=10)
|
|
|
|
def export_to_pdf():
|
|
try:
|
|
filename = filedialog.asksaveasfilename(
|
|
defaultextension=".pdf",
|
|
filetypes=[("PDF files", "*.pdf"), ("All files", "*.*")],
|
|
initialfile=f"Invoice_{invoice_num}_Hours_Report.pdf",
|
|
)
|
|
if filename:
|
|
self.export_to_pdf(
|
|
filename,
|
|
invoice_num,
|
|
customer,
|
|
start_date,
|
|
end_date,
|
|
pivot_data,
|
|
jobs,
|
|
dates,
|
|
)
|
|
messagebox.showinfo("Success", f"Report exported to {filename}")
|
|
except Exception as e:
|
|
messagebox.showerror("Export Error", f"Failed to export PDF: {str(e)}")
|
|
|
|
def mark_as_billed():
|
|
"""Mark all rows in this report as billed"""
|
|
try:
|
|
archive_file = self.archive_path
|
|
|
|
# Read all archive data
|
|
all_data = []
|
|
fieldnames = None
|
|
with open(archive_file, "r", encoding="utf-8") as csvfile:
|
|
reader = csv.DictReader(csvfile)
|
|
fieldnames = reader.fieldnames
|
|
if fieldnames: # Ensure we have fieldnames
|
|
for row in reader:
|
|
row_date = datetime.strptime(row["Date"], "%Y-%m-%d").date()
|
|
|
|
# Check if this row matches filtered data
|
|
is_in_report = False
|
|
for report_row in filtered_data:
|
|
if (
|
|
row["Customer"] == report_row["Customer"]
|
|
and row["Date"] == report_row["Date"]
|
|
and row["Job"] == report_row["Job"]
|
|
and row["Hours"] == report_row["Hours"]
|
|
):
|
|
is_in_report = True
|
|
break
|
|
|
|
if is_in_report:
|
|
# This row is included in report, mark as billed
|
|
row["Billed"] = "True"
|
|
|
|
all_data.append(row)
|
|
|
|
# Write back to archive with updated Billed status
|
|
if fieldnames: # Ensure fieldnames is not None
|
|
with open(
|
|
archive_file, "w", newline="", encoding="utf-8"
|
|
) as csvfile:
|
|
writer = csv.DictWriter(
|
|
csvfile, fieldnames=fieldnames, quoting=csv.QUOTE_MINIMAL
|
|
)
|
|
writer.writeheader()
|
|
|
|
# Sanitize all data before writing
|
|
sanitized_data = []
|
|
for row in all_data:
|
|
sanitized_row = {}
|
|
for field_name, field_value in row.items():
|
|
if field_name in [
|
|
"Job",
|
|
"TaskName",
|
|
"Note",
|
|
"Customer",
|
|
"username",
|
|
]:
|
|
if isinstance(field_value, str):
|
|
sanitized_row[field_name] = sanitize_csv_text(
|
|
field_value
|
|
)
|
|
else:
|
|
sanitized_row[field_name] = field_value
|
|
elif field_name in ["Date"]:
|
|
if isinstance(field_value, str):
|
|
sanitized_row[field_name] = sanitize_date_text(
|
|
field_value
|
|
)
|
|
else:
|
|
sanitized_row[field_name] = field_value
|
|
else:
|
|
# For Hours, Billable, Billed - keep as-is but ensure proper types
|
|
sanitized_row[field_name] = field_value
|
|
sanitized_data.append(sanitized_row)
|
|
|
|
writer.writerows(sanitized_data)
|
|
|
|
messagebox.showinfo(
|
|
"Success",
|
|
f"Marked {len(filtered_data)} entries as billed for invoice #{invoice_num}",
|
|
)
|
|
|
|
# Close preview window after marking as billed
|
|
preview_window.destroy()
|
|
|
|
except Exception as e:
|
|
messagebox.showerror(
|
|
"Error Marking Billed",
|
|
f"Failed to mark entries as billed: {str(e)}",
|
|
)
|
|
|
|
def close_preview():
|
|
preview_window.destroy()
|
|
|
|
tk.Button(
|
|
button_frame,
|
|
text="Mark as Billed",
|
|
command=mark_as_billed,
|
|
width=15,
|
|
bg="lightgreen",
|
|
).pack(side=tk.LEFT, padx=5)
|
|
tk.Button(
|
|
button_frame, text="Export to PDF", command=export_to_pdf, width=15
|
|
).pack(side=tk.LEFT, padx=5)
|
|
tk.Button(button_frame, text="Close", command=close_preview, width=15).pack(
|
|
side=tk.LEFT, padx=5
|
|
)
|
|
|
|
def export_to_pdf(
|
|
self,
|
|
filename,
|
|
invoice_num,
|
|
customer,
|
|
start_date,
|
|
end_date,
|
|
pivot_data,
|
|
jobs,
|
|
dates,
|
|
):
|
|
"""Export report data to PDF format"""
|
|
try:
|
|
from reportlab.lib.pagesizes import letter, landscape
|
|
from reportlab.platypus import (
|
|
SimpleDocTemplate,
|
|
Table,
|
|
TableStyle,
|
|
Paragraph,
|
|
Spacer,
|
|
)
|
|
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
|
from reportlab.lib import colors
|
|
from reportlab.lib.units import inch
|
|
except ImportError:
|
|
messagebox.showerror(
|
|
"Missing Library",
|
|
"reportlab library is required for PDF export. Please install it with:\n\n"
|
|
"pip install reportlab",
|
|
)
|
|
return
|
|
|
|
doc = SimpleDocTemplate(
|
|
filename,
|
|
pagesize=landscape(letter),
|
|
leftMargin=0.5 * inch,
|
|
rightMargin=0.5 * inch,
|
|
topMargin=0.75 * inch,
|
|
bottomMargin=0.75 * inch,
|
|
)
|
|
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
|
|
# Format dates as MM-DD for better column fit
|
|
formatted_dates = []
|
|
for date_str in dates:
|
|
try:
|
|
# Convert from YYYY-MM-DD to MM-DD
|
|
date_obj = datetime.strptime(date_str, "%Y-%m-%d").date()
|
|
formatted_dates.append(date_obj.strftime("%m-%d"))
|
|
except ValueError:
|
|
# Fallback to original if parsing fails
|
|
formatted_dates.append(date_str)
|
|
|
|
headers = ["Job"] + formatted_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 explicit width control to prevent truncation
|
|
num_dates = len(dates)
|
|
total_cols = num_dates + 2 # Job column + date columns + Total column
|
|
|
|
# Available width: landscape letter (11") minus 1" margins
|
|
available_width = 10.0 # inches
|
|
|
|
# Calculate appropriate font size based on number of columns
|
|
if num_dates >= 31: # Very wide reports (like 31-day months)
|
|
font_size = 5
|
|
# Job column gets 1.5", remaining space divided among date columns
|
|
job_col = 1.5 * inch
|
|
date_col = (available_width - 1.5) / (num_dates + 1) * inch
|
|
elif num_dates >= 25: # Wide reports
|
|
font_size = 6
|
|
job_col = 1.6 * inch
|
|
date_col = (available_width - 1.6) / (num_dates + 1) * inch
|
|
elif num_dates >= 15: # Medium reports
|
|
font_size = 7
|
|
job_col = 1.8 * inch
|
|
date_col = (available_width - 1.8) / (num_dates + 1) * inch
|
|
else: # Small reports
|
|
font_size = 8
|
|
job_col = 2.0 * inch
|
|
date_col = (available_width - 2.0) / (num_dates + 1) * inch
|
|
|
|
# Create column widths list: Job + date columns + Total
|
|
col_widths = [job_col] + [date_col] * num_dates + [date_col]
|
|
|
|
# Create table with explicit column widths
|
|
table = Table(table_data, repeatRows=1, colWidths=col_widths)
|
|
|
|
# Style the table with appropriate font size
|
|
# Minimize padding for wide reports to fit on page
|
|
if num_dates >= 31:
|
|
left_padding = 0.5
|
|
right_padding = 0.5
|
|
bottom_padding = 1
|
|
top_padding = 1
|
|
elif num_dates >= 25:
|
|
left_padding = 1
|
|
right_padding = 1
|
|
bottom_padding = 2
|
|
top_padding = 2
|
|
else:
|
|
left_padding = 2
|
|
right_padding = 2
|
|
bottom_padding = 4
|
|
top_padding = 3
|
|
|
|
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), bottom_padding),
|
|
("TOPPADDING", (0, 0), (-1, -1), top_padding),
|
|
("LEFTPADDING", (0, 0), (-1, -1), left_padding),
|
|
("RIGHTPADDING", (0, 0), (-1, -1), right_padding),
|
|
("BACKGROUND", (0, -1), (-1, -1), colors.grey),
|
|
("TEXTCOLOR", (0, -1), (-1, -1), colors.whitesmoke),
|
|
("GRID", (0, 0), (-1, -1), 1, colors.black),
|
|
]
|
|
)
|
|
)
|
|
|
|
story.append(table)
|
|
|
|
# Add a note about report scaling if we compressed it a lot
|
|
if num_dates >= 25:
|
|
note_style = ParagraphStyle(
|
|
"Note",
|
|
parent=styles["Normal"],
|
|
fontSize=8,
|
|
textColor=colors.gray,
|
|
alignment=1, # Center
|
|
)
|
|
note = Paragraph("*Report scaled to fit large date range*", note_style)
|
|
story.append(Spacer(1, 6))
|
|
story.append(note)
|
|
|
|
# Build PDF
|
|
doc.build(story)
|
|
|
|
# Settings dialog methods (keeping from original)
|
|
def open_settings(self):
|
|
"""Open settings dialog to manage jobs and time settings"""
|
|
settings_window = tk.Toplevel(self.root)
|
|
settings_window.title("Settings")
|
|
settings_window.geometry("700x500")
|
|
|
|
# Create notebook for tabbed interface
|
|
notebook = ttk.Notebook(settings_window)
|
|
notebook.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
|
|
|
|
# Jobs tab
|
|
jobs_frame = tk.Frame(notebook)
|
|
notebook.add(jobs_frame, text="Jobs")
|
|
|
|
# Customers tab
|
|
customers_frame = tk.Frame(notebook)
|
|
notebook.add(customers_frame, text="Customers")
|
|
|
|
# Time Settings tab
|
|
time_frame = tk.Frame(notebook)
|
|
notebook.add(time_frame, text="Time Settings")
|
|
|
|
# Storage tab
|
|
storage_frame = tk.Frame(notebook)
|
|
notebook.add(storage_frame, text="Storage")
|
|
|
|
# Jobs tab content
|
|
list_frame = tk.Frame(jobs_frame)
|
|
list_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
|
|
|
|
# Treeview for job management
|
|
columns = ("Job Name", "Billable", "Active")
|
|
tree = ttk.Treeview(list_frame, columns=columns, show="headings", height=10)
|
|
|
|
for col in columns:
|
|
tree.heading(col, text=col)
|
|
tree.column(col, width=150)
|
|
|
|
# Populate tree with current jobs
|
|
for job in self.jobs:
|
|
job_name = job["name"]
|
|
billable = "Yes" if job.get("billable", True) else "No"
|
|
active = "Yes" if job.get("active", True) else "No"
|
|
tree.insert("", tk.END, values=(job_name, billable, active))
|
|
|
|
tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
|
|
|
# Scrollbar
|
|
scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=tree.yview)
|
|
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
|
tree.configure(yscrollcommand=scrollbar.set)
|
|
|
|
# Job control buttons
|
|
jobs_btn_frame = tk.Frame(jobs_frame)
|
|
jobs_btn_frame.pack(fill=tk.X, padx=10, pady=5)
|
|
|
|
def add_job():
|
|
self.add_job_dialog(tree)
|
|
|
|
def edit_job():
|
|
selected = tree.selection()
|
|
if not selected:
|
|
messagebox.showwarning("No Selection", "Please select a job to edit.")
|
|
return
|
|
item = tree.item(selected[0])
|
|
values = item["values"]
|
|
self.edit_job_dialog(
|
|
tree, values[0], values[1] == "Yes", values[2] == "Yes"
|
|
)
|
|
|
|
tk.Button(jobs_btn_frame, text="Add Job", command=add_job).pack(
|
|
side=tk.LEFT, padx=2
|
|
)
|
|
tk.Button(jobs_btn_frame, text="Edit Job", command=edit_job).pack(
|
|
side=tk.LEFT, padx=2
|
|
)
|
|
|
|
# Customers tab content (using same pattern as jobs)
|
|
customers_list_frame = tk.Frame(customers_frame)
|
|
customers_list_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
|
|
|
|
# Treeview for customer management
|
|
customer_columns = ("Customer Name", "Active")
|
|
customer_tree = ttk.Treeview(
|
|
customers_list_frame, columns=customer_columns, show="headings", height=10
|
|
)
|
|
|
|
for col in customer_columns:
|
|
customer_tree.heading(col, text=col)
|
|
customer_tree.column(col, width=150)
|
|
|
|
# Populate tree with current customers
|
|
for customer in self.customers:
|
|
customer_name = customer["name"]
|
|
active = "Yes" if customer.get("active", True) else "No"
|
|
customer_tree.insert("", tk.END, values=(customer_name, active))
|
|
|
|
customer_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
|
|
|
# Customer scrollbar
|
|
customer_scrollbar = ttk.Scrollbar(
|
|
customers_list_frame, orient="vertical", command=customer_tree.yview
|
|
)
|
|
customer_scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
|
customer_tree.configure(yscrollcommand=customer_scrollbar.set)
|
|
|
|
# Customer control buttons
|
|
customers_btn_frame = tk.Frame(customers_frame)
|
|
customers_btn_frame.pack(fill=tk.X, padx=10, pady=5)
|
|
|
|
def add_customer():
|
|
self.add_customer_dialog(customer_tree)
|
|
|
|
def edit_customer():
|
|
selected = customer_tree.selection()
|
|
if not selected:
|
|
messagebox.showwarning(
|
|
"No Selection", "Please select a customer to edit."
|
|
)
|
|
return
|
|
item = customer_tree.item(selected[0])
|
|
values = item["values"]
|
|
self.edit_customer_dialog(customer_tree, values[0], values[1] == "Yes")
|
|
|
|
tk.Button(customers_btn_frame, text="Add Customer", command=add_customer).pack(
|
|
side=tk.LEFT, padx=2
|
|
)
|
|
tk.Button(
|
|
customers_btn_frame, text="Edit Customer", command=edit_customer
|
|
).pack(side=tk.LEFT, padx=2)
|
|
|
|
# Storage tab content
|
|
storage_settings_frame = tk.Frame(storage_frame)
|
|
storage_settings_frame.pack(fill=tk.X, padx=20, pady=20)
|
|
|
|
tk.Label(storage_settings_frame, text="Archive File Location:").grid(
|
|
row=0, column=0, sticky="w", padx=5, pady=5
|
|
)
|
|
archive_path_frame = tk.Frame(storage_settings_frame)
|
|
archive_path_frame.grid(row=0, column=1, sticky="w", padx=5, pady=5)
|
|
|
|
archive_path_var = tk.StringVar(value=self.archive_path)
|
|
archive_path_entry = tk.Entry(
|
|
archive_path_frame, textvariable=archive_path_var, width=50
|
|
)
|
|
archive_path_entry.pack(side=tk.LEFT)
|
|
|
|
def browse_archive_path():
|
|
filename = filedialog.asksaveasfilename(
|
|
title="Select Archive File Location",
|
|
initialfile=os.path.basename(self.archive_path)
|
|
if self.archive_path
|
|
else "time_tracker_archive.csv",
|
|
filetypes=[("CSV files", "*.csv"), ("All files", "*.*")],
|
|
)
|
|
if filename:
|
|
archive_path_var.set(filename)
|
|
|
|
browse_btn = tk.Button(
|
|
archive_path_frame, text="Browse...", command=browse_archive_path
|
|
)
|
|
browse_btn.pack(side=tk.LEFT, padx=(5, 0))
|
|
|
|
# Time Settings tab content
|
|
time_settings_frame = tk.Frame(time_frame)
|
|
time_settings_frame.pack(fill=tk.X, padx=20, pady=20)
|
|
|
|
tk.Label(time_settings_frame, text="Start Hour (0-23):").grid(
|
|
row=0, column=0, sticky="w", padx=5, pady=5
|
|
)
|
|
start_hour_var = tk.IntVar(value=self.start_hour)
|
|
start_hour_spinbox = tk.Spinbox(
|
|
time_settings_frame, from_=0, to=23, textvariable=start_hour_var, width=10
|
|
)
|
|
start_hour_spinbox.grid(row=0, column=1, sticky="w", padx=5, pady=5)
|
|
|
|
tk.Label(time_settings_frame, text="Work Hours (1-24):").grid(
|
|
row=1, column=0, sticky="w", padx=5, pady=5
|
|
)
|
|
work_hours_var = tk.IntVar(value=self.work_hours)
|
|
work_hours_spinbox = tk.Spinbox(
|
|
time_settings_frame, from_=1, to=24, textvariable=work_hours_var, width=10
|
|
)
|
|
work_hours_spinbox.grid(row=1, column=1, sticky="w", padx=5, pady=5)
|
|
|
|
# Bottom buttons
|
|
bottom_btn_frame = tk.Frame(settings_window)
|
|
bottom_btn_frame.pack(fill=tk.X, padx=10, pady=5)
|
|
|
|
def save_changes():
|
|
# Update jobs from tree data
|
|
self.jobs = []
|
|
for item in tree.get_children():
|
|
values = tree.item(item)["values"]
|
|
self.jobs.append(
|
|
{
|
|
"name": values[0],
|
|
"billable": values[1] == "Yes",
|
|
"active": values[2] == "Yes",
|
|
}
|
|
)
|
|
|
|
# Update customers from tree data
|
|
self.customers = []
|
|
for item in customer_tree.get_children():
|
|
values = customer_tree.item(item)["values"]
|
|
self.customers.append({"name": values[0], "active": values[1] == "Yes"})
|
|
|
|
# Update time settings
|
|
self.start_hour = start_hour_var.get()
|
|
self.work_hours = work_hours_var.get()
|
|
|
|
# Update archive path
|
|
new_archive_path = validate_input(
|
|
"file_path", archive_path_var.get().strip()
|
|
)
|
|
if new_archive_path:
|
|
# If directory doesn't exist, try to create it
|
|
import os.path
|
|
|
|
dir_name = os.path.dirname(new_archive_path)
|
|
if dir_name and not os.path.exists(dir_name):
|
|
try:
|
|
os.makedirs(dir_name)
|
|
except OSError:
|
|
messagebox.showerror(
|
|
"Invalid Path", f"Cannot create directory: {dir_name}"
|
|
)
|
|
return
|
|
self.archive_path = new_archive_path
|
|
|
|
if self.save_settings():
|
|
messagebox.showinfo(
|
|
"Success",
|
|
"Settings saved successfully. Please restart application for time changes to take effect.",
|
|
)
|
|
settings_window.destroy()
|
|
# Refresh job and customer dropdowns immediately
|
|
self.refresh_job_dropdowns()
|
|
self.refresh_customer_dropdowns()
|
|
|
|
tk.Button(bottom_btn_frame, text="Save Settings", command=save_changes).pack(
|
|
side=tk.RIGHT, padx=2
|
|
)
|
|
tk.Button(
|
|
bottom_btn_frame, text="Cancel", command=settings_window.destroy
|
|
).pack(side=tk.RIGHT, padx=2)
|
|
|
|
# Simplified customer and job management methods (using original pattern)
|
|
def add_job_dialog(self, tree):
|
|
"""Dialog to add new job"""
|
|
dialog = tk.Toplevel(self.root)
|
|
dialog.title("Add New Job")
|
|
dialog.geometry("300x150")
|
|
|
|
tk.Label(dialog, text="Job Name:").grid(
|
|
row=0, column=0, padx=10, pady=5, sticky="w"
|
|
)
|
|
name_entry = tk.Entry(dialog)
|
|
name_entry.grid(row=0, column=1, padx=10, pady=5)
|
|
|
|
billable_var = tk.BooleanVar(value=True)
|
|
tk.Checkbutton(dialog, text="Billable", variable=billable_var).grid(
|
|
row=1, column=0, columnspan=2, padx=10, pady=5, sticky="w"
|
|
)
|
|
|
|
active_var = tk.BooleanVar(value=True)
|
|
tk.Checkbutton(dialog, text="Active", variable=active_var).grid(
|
|
row=2, column=0, columnspan=2, padx=10, pady=5, sticky="w"
|
|
)
|
|
|
|
def save_job():
|
|
job_name = validate_input("job_name", name_entry.get().strip())
|
|
if not job_name:
|
|
messagebox.showwarning("Invalid Input", "Job name cannot be empty.")
|
|
return
|
|
|
|
# Sanitize billable and active status
|
|
billable_text = "Yes" if billable_var.get() else "No"
|
|
active_text = "Yes" if active_var.get() else "No"
|
|
|
|
tree.insert("", tk.END, values=(job_name, billable_text, active_text))
|
|
dialog.destroy()
|
|
|
|
tk.Button(dialog, text="Save", command=save_job).grid(
|
|
row=3, column=0, padx=10, pady=10
|
|
)
|
|
tk.Button(dialog, text="Cancel", command=dialog.destroy).grid(
|
|
row=3, column=1, padx=10, pady=10
|
|
)
|
|
|
|
def edit_job_dialog(self, tree, job_name, is_billable, is_active):
|
|
"""Dialog to edit existing job"""
|
|
dialog = tk.Toplevel(self.root)
|
|
dialog.title("Edit Job")
|
|
dialog.geometry("300x150")
|
|
|
|
tk.Label(dialog, text="Job Name:").grid(
|
|
row=0, column=0, padx=10, pady=5, sticky="w"
|
|
)
|
|
name_entry = tk.Entry(dialog)
|
|
name_entry.insert(0, job_name)
|
|
name_entry.grid(row=0, column=1, padx=10, pady=5)
|
|
|
|
billable_var = tk.BooleanVar(value=is_billable)
|
|
tk.Checkbutton(dialog, text="Billable", variable=billable_var).grid(
|
|
row=1, column=0, columnspan=2, padx=10, pady=5, sticky="w"
|
|
)
|
|
|
|
active_var = tk.BooleanVar(value=is_active)
|
|
tk.Checkbutton(dialog, text="Active", variable=active_var).grid(
|
|
row=2, column=0, columnspan=2, padx=10, pady=5, sticky="w"
|
|
)
|
|
|
|
def save_job():
|
|
new_job_name = validate_input("job_name", name_entry.get().strip())
|
|
if not new_job_name:
|
|
messagebox.showwarning("Invalid Input", "Job name cannot be empty.")
|
|
return
|
|
|
|
# Sanitize billable and active status
|
|
billable_text = "Yes" if billable_var.get() else "No"
|
|
active_text = "Yes" if active_var.get() else "No"
|
|
|
|
# Find and update the tree item
|
|
for item in tree.get_children():
|
|
if tree.item(item)["values"][0] == job_name:
|
|
tree.item(item, values=(new_job_name, billable_text, active_text))
|
|
break
|
|
dialog.destroy()
|
|
|
|
tk.Button(dialog, text="Save", command=save_job).grid(
|
|
row=3, column=0, padx=10, pady=10
|
|
)
|
|
tk.Button(dialog, text="Cancel", command=dialog.destroy).grid(
|
|
row=3, column=1, padx=10, pady=10
|
|
)
|
|
|
|
def add_customer_dialog(self, tree):
|
|
"""Dialog to add new customer"""
|
|
dialog = tk.Toplevel(self.root)
|
|
dialog.title("Add New Customer")
|
|
dialog.geometry("300x150")
|
|
|
|
tk.Label(dialog, text="Customer Name:").grid(
|
|
row=0, column=0, padx=10, pady=5, sticky="w"
|
|
)
|
|
name_entry = tk.Entry(dialog)
|
|
name_entry.grid(row=0, column=1, padx=10, pady=5)
|
|
|
|
active_var = tk.BooleanVar(value=True)
|
|
tk.Checkbutton(dialog, text="Active", variable=active_var).grid(
|
|
row=1, column=0, columnspan=2, padx=10, pady=5, sticky="w"
|
|
)
|
|
|
|
def save_customer():
|
|
customer_name = validate_input("customer_name", name_entry.get().strip())
|
|
if not customer_name:
|
|
messagebox.showwarning(
|
|
"Invalid Input", "Customer name cannot be empty."
|
|
)
|
|
return
|
|
|
|
active_text = "Yes" if active_var.get() else "No"
|
|
tree.insert("", tk.END, values=(customer_name, active_text))
|
|
dialog.destroy()
|
|
|
|
tk.Button(dialog, text="Save", command=save_customer).grid(
|
|
row=2, column=0, padx=10, pady=10
|
|
)
|
|
tk.Button(dialog, text="Cancel", command=dialog.destroy).grid(
|
|
row=2, column=1, padx=10, pady=10
|
|
)
|
|
|
|
def edit_customer_dialog(self, tree, customer_name, is_active):
|
|
"""Dialog to edit existing customer"""
|
|
dialog = tk.Toplevel(self.root)
|
|
dialog.title("Edit Customer")
|
|
dialog.geometry("300x120")
|
|
|
|
tk.Label(dialog, text="Customer Name:").grid(
|
|
row=0, column=0, padx=10, pady=5, sticky="w"
|
|
)
|
|
name_entry = tk.Entry(dialog)
|
|
name_entry.insert(0, customer_name)
|
|
name_entry.grid(row=0, column=1, padx=10, pady=5)
|
|
|
|
active_var = tk.BooleanVar(value=is_active)
|
|
tk.Checkbutton(dialog, text="Active", variable=active_var).grid(
|
|
row=1, column=0, columnspan=2, padx=10, pady=5, sticky="w"
|
|
)
|
|
|
|
def save_customer():
|
|
new_customer_name = validate_input(
|
|
"customer_name", name_entry.get().strip()
|
|
)
|
|
if not new_customer_name:
|
|
messagebox.showwarning(
|
|
"Invalid Input", "Customer name cannot be empty."
|
|
)
|
|
return
|
|
|
|
active_text = "Yes" if active_var.get() else "No"
|
|
|
|
# Find and update tree item
|
|
for item in tree.get_children():
|
|
if tree.item(item)["values"][0] == customer_name:
|
|
tree.item(item, values=(new_customer_name, active_text))
|
|
break
|
|
dialog.destroy()
|
|
|
|
tk.Button(dialog, text="Save", command=save_customer).grid(
|
|
row=2, column=0, padx=10, pady=10
|
|
)
|
|
tk.Button(dialog, text="Cancel", command=dialog.destroy).grid(
|
|
row=2, column=1, padx=10, pady=10
|
|
)
|
|
|
|
def refresh_job_dropdowns(self):
|
|
"""Refresh all job dropdowns in the interface"""
|
|
for row_num in self.data_rows:
|
|
widgets = self.scrollable_frame.grid_slaves(row=row_num)
|
|
for widget in widgets:
|
|
if (
|
|
isinstance(widget, ttk.Combobox)
|
|
and widget.grid_info()["column"] == 0
|
|
):
|
|
# This is a job dropdown
|
|
current_selection = widget.get()
|
|
widget["values"] = self.get_active_jobs()
|
|
if current_selection in widget["values"]:
|
|
widget.set(current_selection)
|
|
|
|
def refresh_customer_dropdowns(self):
|
|
"""Refresh all customer dropdowns in the interface"""
|
|
for row_num in self.data_rows:
|
|
widgets = self.scrollable_frame.grid_slaves(row=row_num)
|
|
for widget in widgets:
|
|
if (
|
|
isinstance(widget, ttk.Combobox)
|
|
and widget.grid_info()["column"] == 3
|
|
):
|
|
# This is a customer dropdown
|
|
current_selection = widget.get()
|
|
widget["values"] = self.get_active_customers()
|
|
if current_selection in widget["values"]:
|
|
widget.set(current_selection)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
root = tk.Tk()
|
|
app = TimeTracker(root)
|
|
root.mainloop()
|