Compare commits
9 Commits
v1.0
..
77d25739ac
| Author | SHA1 | Date | |
|---|---|---|---|
| 77d25739ac | |||
| 2b376a4f91 | |||
| 6c1b55158d | |||
| ab046271cf | |||
| 975b54b749 | |||
| 52239d00af | |||
| ffef627a4a | |||
| 910a3bc600 | |||
| 3cd30645fd |
Executable
+45
@@ -0,0 +1,45 @@
|
||||
import sys
|
||||
import os
|
||||
from utils import load_config, load_json, save_json, get_next_id
|
||||
|
||||
config = load_config()
|
||||
CLIENTS_FILE = os.path.join(config["DataDirectory"], "Clients.json")
|
||||
|
||||
def main():
|
||||
data = load_json(CLIENTS_FILE, {"Clients": []})
|
||||
|
||||
print("\n--- Client Maintenance ---")
|
||||
print("1. Add new client")
|
||||
print("2. Edit existing client")
|
||||
print("3. Deactivate / reactivate a client")
|
||||
print("4. View all clients")
|
||||
|
||||
choice = input("Select an option: ")
|
||||
|
||||
if choice == "1":
|
||||
new_client = {
|
||||
"ClientID": get_next_id("CLT", data["Clients"], "ClientID"),
|
||||
"Name": input("Name: "),
|
||||
"Active": True,
|
||||
"BillingAddress": {
|
||||
"Street1": input("Street1: "),
|
||||
"Street2": input("Street2: "),
|
||||
"City": input("City: "),
|
||||
"State": input("State: "),
|
||||
"PostalCode": input("Postal Code: "),
|
||||
"Country": input("Country: ")
|
||||
},
|
||||
"BillingEmail": input("Billing Email: "),
|
||||
"DefaultRate": float(input("Default Rate: "))
|
||||
}
|
||||
data["Clients"].append(new_client)
|
||||
save_json(CLIENTS_FILE, data)
|
||||
print(f"Added client {new_client['Name']} ({new_client['ClientID']})")
|
||||
|
||||
elif choice == "4":
|
||||
for c in data["Clients"]:
|
||||
status = "Active" if c["Active"] else "Inactive"
|
||||
print(f"[{c['ClientID']}] {c['Name']} - Rate: ${c['DefaultRate']} - {status}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"DataDirectory": "~/HourStack",
|
||||
"PDFOutputDirectory": "~/Invoices"
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime
|
||||
from utils import load_config, load_json, save_json
|
||||
|
||||
def get_next_id(data_list, id_prefix, id_field):
|
||||
"""Generates the next incremental ID for clients or projects."""
|
||||
if not data_list:
|
||||
return f"{id_prefix}-001"
|
||||
ids = [int(item[id_field].split('-')[1]) for item in data_list]
|
||||
return f"{id_prefix}-{max(ids) + 1:03d}"
|
||||
|
||||
def select_or_create_client(data_dir, clients_data):
|
||||
"""Allows selecting an active client or creating a new one."""
|
||||
active_clients = [c for c in clients_data["Clients"] if c.get("Active", True)]
|
||||
|
||||
print("\n--- Select Client ---")
|
||||
for i, client in enumerate(active_clients, 1):
|
||||
print(f"{i}. {client['Name']}")
|
||||
print(f"{len(active_clients) + 1}. [ADD NEW CLIENT]")
|
||||
|
||||
choice = input("\nSelect a number: ")
|
||||
if choice == str(len(active_clients) + 1):
|
||||
# Create New Client Flow[cite: 2]
|
||||
new_name = input("Client Name: ")
|
||||
new_rate = float(input("Default Hourly Rate: ") or 0)
|
||||
new_id = get_next_id(clients_data["Clients"], "CLT", "ClientID")
|
||||
|
||||
new_client = {
|
||||
"ClientID": new_id,
|
||||
"Name": new_name,
|
||||
"Active": True,
|
||||
"BillingAddress": {"Street1": "", "Street2": "", "City": "", "State": "", "PostalCode": "", "Country": ""},
|
||||
"BillingEmail": "",
|
||||
"DefaultRate": new_rate
|
||||
}
|
||||
clients_data["Clients"].append(new_client)
|
||||
save_json(os.path.join(data_dir, "Clients.json"), clients_data)
|
||||
return new_id
|
||||
else:
|
||||
return active_clients[int(choice) - 1]["ClientID"]
|
||||
|
||||
def select_or_create_project(data_dir, projects_data, client_id):
|
||||
"""Allows selecting an active project for the client or creating a new one."""
|
||||
client_projects = [p for p in projects_data["Projects"] if p["ClientID"] == client_id and p.get("Active", True)]
|
||||
|
||||
print("\n--- Select Project ---")
|
||||
print("0. No Project")
|
||||
for i, project in enumerate(client_projects, 1):
|
||||
print(f"{i}. {project['Name']}")
|
||||
print(f"{len(client_projects) + 1}. [ADD NEW PROJECT]")
|
||||
|
||||
choice = input("\nSelect a number: ")
|
||||
if choice == "0":
|
||||
return ""
|
||||
elif choice == str(len(client_projects) + 1):
|
||||
# Create New Project Flow[cite: 2]
|
||||
new_name = input("Project Name: ")
|
||||
new_rate = float(input("Project Billing Rate (0 for client default): ") or 0)
|
||||
new_id = get_next_id(projects_data["Projects"], "PRJ", "ProjectID")
|
||||
|
||||
new_project = {
|
||||
"ProjectID": new_id,
|
||||
"ClientID": client_id,
|
||||
"Name": new_name,
|
||||
"BillingRate": new_rate,
|
||||
"Active": True
|
||||
}
|
||||
projects_data["Projects"].append(new_project)
|
||||
save_json(os.path.join(data_dir, "Projects.json"), projects_data)
|
||||
return new_id
|
||||
else:
|
||||
return client_projects[int(choice) - 1]["ProjectID"]
|
||||
|
||||
def main():
|
||||
config = load_config()
|
||||
data_dir = config["DataDirectory"]
|
||||
|
||||
# Load existing data[cite: 2]
|
||||
clients_data = load_json(os.path.join(data_dir, "Clients.json"), {"Clients": []})
|
||||
projects_data = load_json(os.path.join(data_dir, "Projects.json"), {"Projects": []})
|
||||
|
||||
# Collect Entry Details[cite: 2]
|
||||
date_str = input(f"Date (YYYY-MM-DD) [Default: {datetime.now().strftime('%Y-%m-%d')}]: ") or datetime.now().strftime("%Y-%m-%d")
|
||||
duration = float(input("Duration (e.g., 1.5): "))
|
||||
|
||||
client_id = select_or_create_client(data_dir, clients_data)
|
||||
project_id = select_or_create_project(data_dir, projects_data, client_id)
|
||||
|
||||
description = input("Description: ")
|
||||
|
||||
# Generate Entry[cite: 2]
|
||||
entry_id = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
entry = {
|
||||
"ID": entry_id,
|
||||
"Date": date_str,
|
||||
"Duration": duration,
|
||||
"ClientID": client_id,
|
||||
"ProjectID": project_id,
|
||||
"Description": description,
|
||||
"Invoiced": False,
|
||||
"InvoiceNumber": ""
|
||||
}
|
||||
|
||||
# Save to Annual Log[cite: 2]
|
||||
year = date_str.split('-')[0]
|
||||
log_path = os.path.join(data_dir, f"{year}_Time_Log.json")
|
||||
log_data = load_json(log_path, {"Year": int(year), "Entries": []})
|
||||
log_data["Entries"].append(entry)
|
||||
save_json(log_path, log_data)
|
||||
|
||||
print(f"\nSuccess: Entry {entry_id} saved to {year}_Time_Log.json")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
import sys
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from fpdf import FPDF
|
||||
from utils import load_config, load_json, save_json
|
||||
|
||||
class InvoicePDF(FPDF):
|
||||
def __init__(self, is_recreated=False):
|
||||
super().__init__(orientation='P', unit='mm', format='A4')
|
||||
self.is_recreated = is_recreated
|
||||
# Table column widths[cite: 1]
|
||||
self.w_date, self.w_desc, self.w_proj, self.w_hrs, self.w_rate, self.w_total = 25, 80, 25, 15, 20, 25
|
||||
|
||||
def header(self):
|
||||
# Main Header[cite: 1]
|
||||
self.set_font("helvetica", "B", 28)
|
||||
self.cell(100, 20, "INVOICE", 0, 0, "L")
|
||||
self.ln(10)
|
||||
|
||||
# Repeat table headers on every page if we are currently in the table section[cite: 1]
|
||||
if self.page_no() > 1:
|
||||
self.draw_table_header()
|
||||
|
||||
def draw_table_header(self):
|
||||
"""Standardized table header that can be called on new pages[cite: 1]."""
|
||||
self.ln(5)
|
||||
self.set_font("helvetica", "B", 10)
|
||||
self.cell(self.w_date, 8, "Date", 0)
|
||||
self.cell(self.w_desc, 8, "Description", 0)
|
||||
self.cell(self.w_proj, 8, "Project", 0)
|
||||
self.cell(self.w_hrs, 8, "Hours", 0, 0, "R")
|
||||
self.cell(self.w_rate, 8, "Rate", 0, 0, "R")
|
||||
self.cell(self.w_total, 8, "Amount", 0, 1, "R")
|
||||
self.line(10, self.get_y(), 200, self.get_y())
|
||||
self.ln(2)
|
||||
self.set_font("helvetica", "", 9)
|
||||
|
||||
def footer(self):
|
||||
self.set_y(-15)
|
||||
self.set_font("helvetica", "I", 8)
|
||||
self.cell(0, 10, f"Page {self.page_no()}", 0, 0, "R")
|
||||
|
||||
def select_item(items, label):
|
||||
print(f"\n--- Select {label} ---")
|
||||
for i, item in enumerate(items, 1):
|
||||
print(f"{i}. {item['Name']}")
|
||||
while True:
|
||||
try:
|
||||
choice = input(f"Enter # for {label} (or Enter for all): ")
|
||||
if not choice: return None
|
||||
idx = int(choice)
|
||||
if 1 <= idx <= len(items):
|
||||
return items[idx - 1]
|
||||
except ValueError:
|
||||
pass
|
||||
print("Invalid selection.")
|
||||
|
||||
def main():
|
||||
config = load_config()
|
||||
data_dir = config["DataDirectory"]
|
||||
|
||||
clients_data = load_json(os.path.join(data_dir, "Clients.json"), {"Clients": []})
|
||||
projects_data = load_json(os.path.join(data_dir, "Projects.json"), {"Projects": []})
|
||||
active_clients = [c for c in clients_data["Clients"] if c.get("Active", True)]
|
||||
|
||||
client = select_item(active_clients, "Client")
|
||||
if not client: return
|
||||
client_id = client["ClientID"]
|
||||
|
||||
print("\n--- Invoice Mode ---")
|
||||
print("1. Standard (Last Month)")
|
||||
print("2. Custom (Range/Filter)")
|
||||
print("3. Re-create")
|
||||
mode = input("Select mode: ")
|
||||
|
||||
start_date, end_date, filter_pid = None, None, None
|
||||
if mode == "2":
|
||||
start_date = input("Start Date (YYYY-MM-DD): ")
|
||||
end_date = input("End Date (YYYY-MM-DD): ")
|
||||
client_projs = [p for p in projects_data["Projects"] if p["ClientID"] == client_id]
|
||||
if client_projs:
|
||||
p_choice = select_item(client_projs, "Project Filter")
|
||||
if p_choice: filter_pid = p_choice["ProjectID"]
|
||||
elif mode == "1":
|
||||
today = datetime.now()
|
||||
last_month = today.replace(day=1) - timedelta(days=1)
|
||||
start_date = last_month.replace(day=1).strftime("%Y-%m-%d")
|
||||
end_date = last_month.strftime("%Y-%m-%d")
|
||||
|
||||
aggregated = {}
|
||||
log_files = [f for f in os.listdir(data_dir) if f.endswith("_Time_Log.json")]
|
||||
invoice_id = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
files_to_update = {}
|
||||
|
||||
for file_name in log_files:
|
||||
full_path = os.path.join(data_dir, file_name)
|
||||
logs = load_json(full_path, {"Entries": []})
|
||||
modified = False
|
||||
for entry in logs.get("Entries", []):
|
||||
if entry["ClientID"] != client_id: continue
|
||||
if mode != "3" and entry.get("Invoiced", False): continue
|
||||
if start_date and entry["Date"] < start_date: continue
|
||||
if end_date and entry["Date"] > end_date: continue
|
||||
if filter_pid and entry["ProjectID"] != filter_pid: continue
|
||||
|
||||
if mode != "3":
|
||||
entry["Invoiced"] = True
|
||||
modified = True
|
||||
|
||||
key = (entry["Date"], entry["ProjectID"])
|
||||
rate = client["DefaultRate"]
|
||||
p_name = ""
|
||||
if entry["ProjectID"]:
|
||||
p = next((x for x in projects_data["Projects"] if x["ProjectID"] == entry["ProjectID"]), None)
|
||||
if p:
|
||||
p_name = p.get("Code") or p["Name"]
|
||||
if p.get("BillingRate", 0) > 0: rate = p["BillingRate"]
|
||||
|
||||
if key not in aggregated:
|
||||
aggregated[key] = {"Date": entry["Date"], "Project": p_name, "Hours": 0.0, "Desc": [], "Rate": rate}
|
||||
aggregated[key]["Hours"] += float(entry.get("Duration", 0))
|
||||
aggregated[key]["Desc"].append(entry["Description"])
|
||||
if modified: files_to_update[full_path] = logs
|
||||
|
||||
if not aggregated:
|
||||
print("No billable entries found.")
|
||||
return
|
||||
|
||||
pdf = InvoicePDF(is_recreated=(mode == "3"))
|
||||
pdf.add_page()
|
||||
|
||||
# 1. Contact Information and Metadata[cite: 1]
|
||||
align_x = 130
|
||||
|
||||
pdf.set_font("helvetica", "B", 11)
|
||||
pdf.set_xy(align_x, 10)
|
||||
pdf.cell(0, 5, "From:", 0, 1)
|
||||
pdf.set_font("helvetica", "", 10)
|
||||
pdf.set_x(align_x)
|
||||
pdf.multi_cell(70, 5, "Matt Speer\n2313 Hunters Cove\nVestavia Hills, AL 35216")
|
||||
|
||||
pdf.ln(10)
|
||||
pdf.set_font("helvetica", "", 10)
|
||||
meta_y = pdf.get_y()
|
||||
pdf.text(10, meta_y, f"Invoice ID: {invoice_id}")
|
||||
pdf.text(10, meta_y + 8, f"Invoice Date: {datetime.now().strftime('%m/%d/%Y')}")
|
||||
|
||||
pdf.set_xy(align_x, meta_y - 4)
|
||||
pdf.set_font("helvetica", "B", 11)
|
||||
pdf.cell(0, 5, "Invoice For:", 0, 1)
|
||||
pdf.set_font("helvetica", "", 10)
|
||||
pdf.set_x(align_x)
|
||||
addr = client['BillingAddress']
|
||||
pdf.multi_cell(70, 5, f"{client['Name']}\n{addr['Street1']}\n{addr['City']}, {addr['State']} {addr['PostalCode']}")
|
||||
|
||||
# 2. Initial Table Header[cite: 1]
|
||||
pdf.ln(15)
|
||||
pdf.draw_table_header()
|
||||
|
||||
# 3. Table Rows with TOP ALIGNMENT[cite: 1]
|
||||
pdf.set_font("helvetica", "", 9)
|
||||
grand_total = 0
|
||||
for key in sorted(aggregated.keys()):
|
||||
row = aggregated[key]
|
||||
line_total = row["Hours"] * row["Rate"]
|
||||
grand_total += line_total
|
||||
full_desc = "\n".join(row["Desc"])
|
||||
|
||||
# Calculate height required for the description block[cite: 1]
|
||||
line_h = 5
|
||||
lines = pdf.multi_cell(pdf.w_desc, line_h, full_desc, split_only=True)
|
||||
row_h = max(8, len(lines) * line_h)
|
||||
|
||||
# Check for page break[cite: 1]
|
||||
if pdf.get_y() + row_h > 270:
|
||||
pdf.add_page()
|
||||
# Header is automatically drawn by pdf.header() logic[cite: 1]
|
||||
|
||||
start_y = pdf.get_y()
|
||||
|
||||
# Date (Top Aligned)[cite: 1]
|
||||
pdf.set_y(start_y)
|
||||
pdf.cell(pdf.w_date, line_h, row["Date"], 0)
|
||||
|
||||
# Description (Multi-line, Top Aligned)[cite: 1]
|
||||
pdf.set_xy(10 + pdf.w_date, start_y)
|
||||
pdf.multi_cell(pdf.w_desc, line_h, full_desc, 0)
|
||||
|
||||
# Project, Hours, Rate, Amount (Top Aligned relative to the description start)[cite: 1]
|
||||
pdf.set_xy(10 + pdf.w_date + pdf.w_desc, start_y)
|
||||
pdf.cell(pdf.w_proj, line_h, row["Project"], 0)
|
||||
pdf.cell(pdf.w_hrs, line_h, f"{row['Hours']:.2f}", 0, 0, "R")
|
||||
pdf.cell(pdf.w_rate, line_h, f"${row['Rate']:.2f}", 0, 0, "R")
|
||||
pdf.cell(pdf.w_total, line_h, f"${line_total:.2f}", 0, 1, "R")
|
||||
|
||||
# Reset Y to the bottom of the current multi-line row before next entry[cite: 1]
|
||||
pdf.set_y(start_y + row_h + 2)
|
||||
|
||||
# 4. Grand Total[cite: 1]
|
||||
pdf.ln(5)
|
||||
if pdf.get_y() > 270: pdf.add_page()
|
||||
pdf.line(150, pdf.get_y(), 200, pdf.get_y())
|
||||
pdf.set_font("helvetica", "B", 11)
|
||||
pdf.cell(pdf.w_date + pdf.w_desc + pdf.w_proj + pdf.w_hrs + pdf.w_rate, 10, "Total Amount Due:", 0, 0, "R")
|
||||
pdf.cell(pdf.w_total, 10, f"${grand_total:.2f}", 0, 1, "R")
|
||||
|
||||
out_path = os.path.join(config["PDFOutputDirectory"], f"Invoice_{invoice_id}.pdf")
|
||||
pdf.output(out_path)
|
||||
|
||||
if mode != "3":
|
||||
for path, data in files_to_update.items():
|
||||
save_json(path, data)
|
||||
|
||||
print(f"Generated: {out_path}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,39 @@
|
||||
import sys
|
||||
import os
|
||||
from utils import load_config, load_json, save_json, get_next_id
|
||||
|
||||
config = load_config()
|
||||
PROJECTS_FILE = os.path.join(config["DataDirectory"], "Projects.json")
|
||||
CLIENTS_FILE = os.path.join(config["DataDirectory"], "Clients.json")
|
||||
|
||||
def main():
|
||||
proj_data = load_json(PROJECTS_FILE, {"Projects": []})
|
||||
client_data = load_json(CLIENTS_FILE, {"Clients": []})
|
||||
|
||||
print("\n--- Project Maintenance ---")
|
||||
print("1. Add new project")
|
||||
print("4. View all projects")
|
||||
|
||||
choice = input("Select an option: ")
|
||||
|
||||
if choice == "1":
|
||||
client_id = input("Enter Client ID: ")
|
||||
rate = float(input("Billing Rate (0 to use client default): "))
|
||||
new_project = {
|
||||
"ProjectID": get_next_id("PRJ", proj_data["Projects"], "ProjectID"),
|
||||
"ClientID": client_id,
|
||||
"Name": input("Project Name: "),
|
||||
"BillingRate": rate,
|
||||
"Active": True
|
||||
}
|
||||
proj_data["Projects"].append(new_project)
|
||||
save_json(PROJECTS_FILE, proj_data)
|
||||
print(f"Added project {new_project['Name']} ({new_project['ProjectID']})")
|
||||
|
||||
elif choice == "4":
|
||||
for p in proj_data["Projects"]:
|
||||
status = "Active" if p["Active"] else "Inactive"
|
||||
print(f"[{p['ProjectID']}] {p['Name']} (Client: {p['ClientID']}) - Rate: ${p['BillingRate']} - {status}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
import os
|
||||
from utils import load_config, load_json
|
||||
|
||||
def main():
|
||||
config = load_config()
|
||||
data_dir = config["DataDirectory"]
|
||||
|
||||
# Load supporting data
|
||||
clients_data = load_json(os.path.join(data_dir, "Clients.json"), {"Clients": []})
|
||||
projects_data = load_json(os.path.join(data_dir, "Projects.json"), {"Projects": []})
|
||||
|
||||
# Map IDs for quick lookup
|
||||
client_map = {c["ClientID"]: c["Name"] for c in clients_data["Clients"]}
|
||||
project_map = {p["ProjectID"]: p["Name"] for p in projects_data["Projects"]}
|
||||
project_rates = {p["ProjectID"]: p.get("BillingRate", 0) for p in projects_data["Projects"]}
|
||||
client_rates = {c["ClientID"]: c["DefaultRate"] for c in clients_data["Clients"]}
|
||||
|
||||
log_files = [f for f in os.listdir(data_dir) if f.endswith("_Time_Log.json")]
|
||||
|
||||
# Structure: { ClientName: { ProjectName: { hours: 0, amount: 0 } } }
|
||||
report = {}
|
||||
|
||||
for file_name in log_files:
|
||||
logs = load_json(os.path.join(data_dir, file_name), {"Entries": []})
|
||||
for entry in logs.get("Entries", []):
|
||||
if not entry.get("Invoiced", False):
|
||||
c_id = entry["ClientID"]
|
||||
p_id = entry["ProjectID"]
|
||||
|
||||
c_name = client_map.get(c_id, f"Unknown ({c_id})")
|
||||
p_name = project_map.get(p_id, "No Project")
|
||||
|
||||
# Determine rate
|
||||
rate = project_rates.get(p_id, 0)
|
||||
if rate == 0:
|
||||
rate = client_rates.get(c_id, 0)
|
||||
|
||||
hours = float(entry.get("Duration", 0))
|
||||
amount = hours * rate
|
||||
|
||||
if c_name not in report:
|
||||
report[c_name] = {}
|
||||
if p_name not in report[c_name]:
|
||||
report[c_name][p_name] = {"hours": 0.0, "amount": 0.0}
|
||||
|
||||
report[c_name][p_name]["hours"] += hours
|
||||
report[c_name][p_name]["amount"] += amount
|
||||
|
||||
# Print Report
|
||||
print("\n" + "="*50)
|
||||
print("UNBILLED TIME REPORT")
|
||||
print("="*50)
|
||||
|
||||
if not report:
|
||||
print("No unbilled entries found.")
|
||||
return
|
||||
|
||||
grand_total_amount = 0
|
||||
for client, projects in sorted(report.items()):
|
||||
print(f"\nCLIENT: {client}")
|
||||
print("-" * 30)
|
||||
client_total_h = 0
|
||||
client_total_a = 0
|
||||
|
||||
for project, stats in projects.items():
|
||||
print(f" {project:<20} {stats['hours']:>6.2f} hrs ${stats['amount']:>8.2f}")
|
||||
client_total_h += stats['hours']
|
||||
client_total_a += stats['amount']
|
||||
|
||||
print(f" {'Total:':<20} {client_total_h:>6.2f} hrs ${client_total_a:>8.2f}")
|
||||
grand_total_amount += client_total_a
|
||||
|
||||
print("\n" + "="*50)
|
||||
print(f"GRAND TOTAL UNBILLED: ${grand_total_amount:,.2f}")
|
||||
print("="*50 + "\n")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,274 +0,0 @@
|
||||
import sqlite3
|
||||
import os
|
||||
from datetime import date, datetime
|
||||
from reportlab.lib.pagesizes import letter
|
||||
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
|
||||
def connect_db():
|
||||
"""
|
||||
Connects to the SQLite database file and returns the connection object.
|
||||
If the file does not exist, it prints an an error message.
|
||||
"""
|
||||
db_file = 'time_tracker.db'
|
||||
if not os.path.exists(db_file):
|
||||
print(f"Error: Database file '{db_file}' not found. Please run the database creation script first.")
|
||||
return None
|
||||
return sqlite3.connect(db_file)
|
||||
|
||||
def generate_invoice():
|
||||
"""
|
||||
Guides the user through selecting a client and month to generate a PDF invoice.
|
||||
Calculates costs, creates the PDF, and updates the database.
|
||||
"""
|
||||
conn = connect_db()
|
||||
if not conn:
|
||||
return
|
||||
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Display active clients for selection
|
||||
cursor.execute('SELECT client_id, client_name FROM clients WHERE active = 1 ORDER BY client_name')
|
||||
active_clients = cursor.fetchall()
|
||||
|
||||
if not active_clients:
|
||||
print("No active clients found to generate an invoice for.")
|
||||
return
|
||||
|
||||
print("\n--- Select a Client to Invoice ---")
|
||||
for client_id, client_name in active_clients:
|
||||
print(f"{client_id}: {client_name}")
|
||||
print("0: Exit")
|
||||
print("----------------------------------\n")
|
||||
|
||||
while True:
|
||||
try:
|
||||
choice = input("Enter the ID of the client (or 0 to exit): ")
|
||||
if choice == '0':
|
||||
print("Exiting invoice generation.")
|
||||
return
|
||||
|
||||
client_id = int(choice)
|
||||
# Check if the entered ID is in the list of active clients
|
||||
if any(c[0] == client_id for c in active_clients):
|
||||
break
|
||||
else:
|
||||
print("Invalid client ID. Please enter a valid ID from the list.")
|
||||
except ValueError:
|
||||
print("Invalid input. Please enter a number.")
|
||||
|
||||
# Get the month from the user
|
||||
month_year_str = input("Enter the month to invoice (YYYY-MM): ")
|
||||
if len(month_year_str) != 7 or month_year_str[4] != '-':
|
||||
print("Invalid date format. Please use YYYY-MM.")
|
||||
return
|
||||
|
||||
# Fetch client details and billing rate
|
||||
cursor.execute('SELECT * FROM clients WHERE client_id = ?', (client_id,))
|
||||
client_data = cursor.fetchone()
|
||||
client_name, billing_rate = client_data[1], client_data[7]
|
||||
|
||||
# Fetch time entries for the selected client and month
|
||||
cursor.execute('''
|
||||
SELECT date, hours, description, project, entry_id
|
||||
FROM time_tracking
|
||||
WHERE client_id = ? AND date LIKE ? AND invoiced = 0
|
||||
ORDER BY date
|
||||
''', (client_id, f"{month_year_str}%"))
|
||||
|
||||
time_entries = cursor.fetchall()
|
||||
|
||||
if not time_entries:
|
||||
print("No new time entries found for this client and month.")
|
||||
return
|
||||
|
||||
# Group entries by date and calculate daily totals
|
||||
daily_summary = {}
|
||||
for entry in time_entries:
|
||||
entry_date = entry[0]
|
||||
if entry_date not in daily_summary:
|
||||
daily_summary[entry_date] = {'hours': 0.0, 'descriptions': [], 'projects': [], 'entry_ids': []}
|
||||
|
||||
daily_summary[entry_date]['hours'] += entry[1]
|
||||
daily_summary[entry_date]['descriptions'].append(f"{entry[2]}")
|
||||
daily_summary[entry_date]['projects'].append(f"{entry[3]}")
|
||||
daily_summary[entry_date]['entry_ids'].append(entry[4])
|
||||
|
||||
# Prepare data for PDF and calculate invoice total
|
||||
invoice_total = 0.0
|
||||
# Reorder table headers as requested
|
||||
data_for_pdf = [['Date', 'Description', 'Project', 'Hours', 'Rate', 'Amount']]
|
||||
|
||||
for daily_date, summary in daily_summary.items():
|
||||
daily_hours = summary['hours']
|
||||
daily_total = daily_hours * billing_rate
|
||||
invoice_total += daily_total
|
||||
# Join descriptions and projects with <br/> for ReportLab to create new lines
|
||||
descriptions_str = "<br/>".join(summary['descriptions'])
|
||||
projects_str = "<br/>".join(summary['projects'])
|
||||
|
||||
# Use Paragraph to handle multi-line descriptions and projects in the PDF
|
||||
data_for_pdf.append([
|
||||
daily_date,
|
||||
Paragraph(descriptions_str, getSampleStyleSheet()['Normal']),
|
||||
Paragraph(projects_str, getSampleStyleSheet()['Normal']),
|
||||
f"{daily_hours:.2f}",
|
||||
f"${billing_rate:.2f}",
|
||||
f"${daily_total:.2f}"
|
||||
])
|
||||
|
||||
# Display the invoice summary before creating the PDF
|
||||
print("\n--- Invoice Summary ---")
|
||||
for row in data_for_pdf[1:]: # Skip header row for print
|
||||
print(f"Date: {row[0]}, Hours: {row[3]}, Total: {row[5]}")
|
||||
print(f"\nTotal Invoice Amount: ${invoice_total:.2f}")
|
||||
|
||||
# Confirmation to create the PDF
|
||||
confirm = input("Generate PDF and update database? (yes/no): ").lower()
|
||||
if confirm not in ['yes', 'y']:
|
||||
print("Invoice generation cancelled.")
|
||||
return
|
||||
|
||||
# Get the desired save directory from the user with a default value
|
||||
default_dir = os.path.expanduser("~/Documents/0 - Inbox")
|
||||
save_dir = input(f"Enter directory to save PDF (default: {default_dir}): ")
|
||||
if not save_dir:
|
||||
save_dir = default_dir
|
||||
|
||||
# Ensure the directory exists
|
||||
if not os.path.exists(save_dir):
|
||||
os.makedirs(save_dir)
|
||||
print(f"Created directory: {save_dir}")
|
||||
|
||||
# Generate unique invoice ID and full file path
|
||||
invoice_id = datetime.now().strftime('%Y%m%d%H%M%S')
|
||||
file_name = f"Invoice_{invoice_id}_{client_name.replace(' ', '_')}.pdf"
|
||||
full_file_path = os.path.join(save_dir, file_name)
|
||||
|
||||
# Create PDF and update database
|
||||
create_pdf(full_file_path, client_data, invoice_total, data_for_pdf, invoice_id)
|
||||
|
||||
# Update database
|
||||
update_database_after_invoice(conn, cursor, time_entries, client_id, invoice_total)
|
||||
|
||||
print(f"\nInvoice successfully created at '{full_file_path}' and database has been updated.")
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"An error occurred: {e}")
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def create_pdf(file_name, client_data, invoice_total, data_for_pdf, invoice_id):
|
||||
"""
|
||||
Creates the PDF document with the invoice details.
|
||||
"""
|
||||
doc = SimpleDocTemplate(file_name, pagesize=letter)
|
||||
elements = []
|
||||
|
||||
# Define styles for the document
|
||||
styles = getSampleStyleSheet()
|
||||
styles.add(ParagraphStyle(name='InvoiceTitle', fontSize=24, fontName='Helvetica-Bold'))
|
||||
styles.add(ParagraphStyle(name='ClientInfo', fontSize=12))
|
||||
styles.add(ParagraphStyle(name='AmountDue', fontSize=12, alignment=2, fontName='Helvetica-Bold'))
|
||||
|
||||
today_date_str = date.today().strftime('%m/%d/%Y')
|
||||
|
||||
# Top header table (INVOICE and From)
|
||||
header_top_table = Table([
|
||||
[
|
||||
Paragraph("INVOICE", styles['InvoiceTitle']),
|
||||
Paragraph("<b>From:</b><br/>Matt Speer<br/>2313 Hunters Cove<br/>Vestavia Hills, AL 35216", styles['ClientInfo'])
|
||||
]
|
||||
], colWidths=[250, 250])
|
||||
|
||||
header_top_table.setStyle(TableStyle([
|
||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||
('LEFTPADDING', (0, 0), (-1, -1), 0),
|
||||
('RIGHTPADDING', (0, 0), (-1, -1), 0),
|
||||
('ALIGN', (1, 0), (1, 0), 'RIGHT')
|
||||
]))
|
||||
|
||||
elements.append(header_top_table)
|
||||
elements.append(Spacer(1, 20))
|
||||
|
||||
# Construct the client address string conditionally
|
||||
client_address_str = f"<b>Invoice For:</b><br/>{client_data[1]}<br/>{client_data[2]}"
|
||||
if client_data[3]: # Check if street_address_2 is not empty
|
||||
client_address_str += f"<br/>{client_data[3]}"
|
||||
client_address_str += f"<br/>{client_data[4]}, {client_data[5]} {client_data[6]}"
|
||||
|
||||
# Bottom header table (Invoice ID/Date and Invoice For)
|
||||
header_bottom_table = Table([
|
||||
[
|
||||
Paragraph(f"Invoice ID: {invoice_id}<br/><br/>Invoice Date: {today_date_str}", styles['ClientInfo']),
|
||||
Paragraph(client_address_str, styles['ClientInfo'])
|
||||
]
|
||||
], colWidths=[250, 250])
|
||||
|
||||
header_bottom_table.setStyle(TableStyle([
|
||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||
('LEFTPADDING', (0, 0), (-1, -1), 0),
|
||||
('RIGHTPADDING', (0, 0), (-1, -1), 0),
|
||||
('ALIGN', (1, 0), (1, 0), 'RIGHT')
|
||||
]))
|
||||
|
||||
elements.append(header_bottom_table)
|
||||
elements.append(Spacer(1, 20))
|
||||
|
||||
# Create the table for time entries
|
||||
table = Table(data_for_pdf, colWidths=[80, 200, 80, 40, 50, 50])
|
||||
table.setStyle(TableStyle([
|
||||
('TEXTCOLOR', (0, 0), (-1, 0), colors.black),
|
||||
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
|
||||
('ALIGN', (0, 0), (-1, -0), 'LEFT'),
|
||||
('VALIGN', (0, 0), (-1, 0), 'MIDDLE'), # Vertical align the header to the middle
|
||||
('VALIGN', (0, 1), (-1, -1), 'TOP'),
|
||||
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
||||
('FONTSIZE', (0, 0), (-1, 0), 10),
|
||||
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
|
||||
('LINEABOVE', (0, 0), (-1, 0), 1, colors.black),
|
||||
('LINEBELOW', (0, 0), (-1, 0), 1, colors.black),
|
||||
]))
|
||||
|
||||
elements.append(table)
|
||||
elements.append(Spacer(1, 12))
|
||||
|
||||
# Line before the total amount due
|
||||
line_table = Table([['']], colWidths=[500]) # Set width to match the time entries table
|
||||
line_table.setStyle(TableStyle([
|
||||
('LINEBELOW', (0, 0), (-1, -1), 1, colors.black),
|
||||
('BOTTOMPADDING', (0, 0), (-1, -1), 0)
|
||||
]))
|
||||
elements.append(line_table)
|
||||
|
||||
elements.append(Spacer(1, 12))
|
||||
|
||||
# Add the total amount due section after the table
|
||||
elements.append(Paragraph(f"AMOUNT DUE: ${invoice_total:.2f}", styles['AmountDue']))
|
||||
|
||||
# Build the document
|
||||
doc.build(elements)
|
||||
|
||||
def update_database_after_invoice(conn, cursor, time_entries, client_id, invoice_total):
|
||||
"""
|
||||
Updates the 'invoiced' field for time entries and the client's balance.
|
||||
"""
|
||||
entry_ids = [entry[4] for entry in time_entries]
|
||||
|
||||
# Update time entries to be invoiced
|
||||
cursor.executemany('UPDATE time_tracking SET invoiced = 1 WHERE entry_id = ?', [(entry_id,) for entry_id in entry_ids])
|
||||
|
||||
# Update the client's balance
|
||||
cursor.execute('SELECT balance FROM clients WHERE client_id = ?', (client_id,))
|
||||
current_balance = cursor.fetchone()[0]
|
||||
|
||||
new_balance = current_balance - invoice_total
|
||||
cursor.execute('UPDATE clients SET balance = ? WHERE client_id = ?', (new_balance, client_id))
|
||||
|
||||
conn.commit()
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate_invoice()
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
import sqlite3
|
||||
|
||||
def create_database():
|
||||
"""
|
||||
Connects to the SQLite database and creates the 'clients' and 'time_tracking' tables
|
||||
if they do not already exist.
|
||||
"""
|
||||
db_file = 'time_tracker.db' # Define the database file name
|
||||
conn = None # Initialize the connection variable to None
|
||||
|
||||
try:
|
||||
# Connect to the database. This will create the file if it doesn't exist.
|
||||
conn = sqlite3.connect(db_file)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Create the 'clients' table. The 'address' field has been split into
|
||||
# separate fields for better data organization.
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
client_id INTEGER PRIMARY KEY,
|
||||
client_name TEXT NOT NULL UNIQUE,
|
||||
street_address_1 TEXT,
|
||||
street_address_2 TEXT,
|
||||
city TEXT,
|
||||
state TEXT,
|
||||
zip_code TEXT,
|
||||
billing_rate REAL,
|
||||
balance REAL,
|
||||
active INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
''')
|
||||
|
||||
# Create the 'time_tracking' table with new fields for date and hours.
|
||||
# The 'invoiced' field is an INTEGER where 0 is false and 1 is true.
|
||||
# The 'client_id' is a foreign key that references the 'clients' table.
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS time_tracking (
|
||||
entry_id INTEGER PRIMARY KEY,
|
||||
project TEXT NOT NULL,
|
||||
description TEXT,
|
||||
invoiced INTEGER NOT NULL DEFAULT 0 CHECK(invoiced IN (0, 1)),
|
||||
date TEXT NOT NULL,
|
||||
hours REAL NOT NULL,
|
||||
client_id INTEGER,
|
||||
FOREIGN KEY (client_id) REFERENCES clients (client_id)
|
||||
);
|
||||
''')
|
||||
|
||||
# Commit the changes to the database.
|
||||
conn.commit()
|
||||
print(f"Successfully created tables 'clients' and 'time_tracking' in '{db_file}'")
|
||||
|
||||
except sqlite3.Error as e:
|
||||
# Print an error message if something goes wrong.
|
||||
print(f"An error occurred: {e}")
|
||||
|
||||
finally:
|
||||
# Ensure the database connection is always closed, even if an error occurs.
|
||||
if conn:
|
||||
conn.close()
|
||||
print("Database connection closed.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Call the function to create the database when the script is run directly.
|
||||
create_database()
|
||||
@@ -1,90 +0,0 @@
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
def connect_db():
|
||||
db_file = 'time_tracker.db'
|
||||
if not os.path.exists(db_file):
|
||||
print(f"Error: Database file '{db_file}' not found.")
|
||||
return None
|
||||
return sqlite3.connect(db_file)
|
||||
|
||||
def display_entry_details(entry):
|
||||
print("\n--- Current Entry Details ---")
|
||||
print(f"ID: {entry[0]} | Project: {entry[1]} | Date: {entry[4]}")
|
||||
print(f"Description: {entry[2]}")
|
||||
print(f"Hours: {entry[5]}")
|
||||
print("-" * 30)
|
||||
|
||||
def edit_time_entry():
|
||||
conn = connect_db()
|
||||
if not conn: return
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
# 1. Select Client
|
||||
cursor.execute('SELECT client_id, client_name FROM clients ORDER BY client_name')
|
||||
clients = cursor.fetchall()
|
||||
|
||||
print("\n--- Select Client to Edit Time ---")
|
||||
for cid, name in clients:
|
||||
print(f"{cid}: {name}")
|
||||
|
||||
client_id = input("\nEnter Client ID (or 0 to go back): ")
|
||||
if client_id == '0' or not client_id: return
|
||||
|
||||
while True:
|
||||
# 2. List Uninvoiced Entries for Client
|
||||
cursor.execute('''SELECT entry_id, project, description, invoiced, date, hours
|
||||
FROM time_tracking WHERE client_id = ? AND invoiced = 0
|
||||
ORDER BY date DESC''', (client_id,))
|
||||
entries = cursor.fetchall()
|
||||
|
||||
if not entries:
|
||||
print("No uninvoiced entries found for this client.")
|
||||
break
|
||||
|
||||
print(f"\n--- Time Entries for Client {client_id} ---")
|
||||
for e in entries:
|
||||
print(f"ID {e[0]}: [{e[4]}] {e[1]} - {e[5]} hrs")
|
||||
print("0: Back to Main Menu")
|
||||
|
||||
entry_choice = input("\nSelect Entry ID to edit: ")
|
||||
if entry_choice == '0': break
|
||||
|
||||
# Find the specific entry
|
||||
selected_entry = next((e for e in entries if str(e[0]) == entry_choice), None)
|
||||
if not selected_entry:
|
||||
print("Invalid Entry ID.")
|
||||
continue
|
||||
|
||||
# 3. Edit Fields one by one
|
||||
display_entry_details(selected_entry)
|
||||
|
||||
# Project
|
||||
new_project = input(f"Project [{selected_entry[1]}]: ") or selected_entry[1]
|
||||
# Description
|
||||
new_desc = input(f"Description [{selected_entry[2]}]: ") or selected_entry[2]
|
||||
# Date
|
||||
new_date = input(f"Date [{selected_entry[4]}]: ") or selected_entry[4]
|
||||
# Hours
|
||||
new_hours_raw = input(f"Hours [{selected_entry[5]}]: ")
|
||||
new_hours = float(new_hours_raw) if new_hours_raw else selected_entry[5]
|
||||
|
||||
# 4. Update Database
|
||||
cursor.execute('''UPDATE time_tracking
|
||||
SET project = ?, description = ?, date = ?, hours = ?
|
||||
WHERE entry_id = ?''',
|
||||
(new_project, new_desc, new_date, new_hours, entry_choice))
|
||||
conn.commit()
|
||||
|
||||
print("\nUpdate Successful! New Values:")
|
||||
print(f"Project: {new_project}\nDescription: {new_desc}\nDate: {new_date}\nHours: {new_hours}")
|
||||
print("="*30)
|
||||
|
||||
except Exception as e:
|
||||
print(f"An error occurred: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
edit_time_entry()
|
||||
@@ -1,73 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
|
||||
# It is assumed that the following scripts exist in the same directory:
|
||||
# - manage_clients.py
|
||||
# - track_time.py
|
||||
# - create_invoice.py
|
||||
# These modules will be run as separate processes.
|
||||
|
||||
def run_script_from_file(script_name):
|
||||
"""
|
||||
Runs a Python script as a subprocess.
|
||||
|
||||
Args:
|
||||
script_name (str): The name of the Python file to run.
|
||||
"""
|
||||
# Check if the file exists
|
||||
if not os.path.exists(script_name):
|
||||
print(f"Error: The script '{script_name}' was not found.")
|
||||
print("Please ensure all required script files are in the same directory.")
|
||||
return
|
||||
|
||||
try:
|
||||
# Run the script using subprocess.run
|
||||
# This will execute the script's top-level code (including any code in
|
||||
# its 'if __name__ == "__main__":' block).
|
||||
print(f"\n--- Running {script_name} ---")
|
||||
subprocess.run(['python3', script_name], check=True)
|
||||
print(f"--- {script_name} completed ---")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"An error occurred while trying to run '{script_name}': {e}")
|
||||
except Exception as e:
|
||||
print(f"An unexpected error occurred: {e}")
|
||||
|
||||
def main_menu():
|
||||
"""
|
||||
Displays the main menu and handles user input to run different scripts.
|
||||
"""
|
||||
while True:
|
||||
print("\n" + "="*40)
|
||||
print(" TIME TRACKER MAIN MENU")
|
||||
print("="*40)
|
||||
print("1. Manage Clients")
|
||||
print("2. Track Time")
|
||||
print("3. Edit Time") # New Option
|
||||
print("4. Create Invoice")
|
||||
print("5. Reports")
|
||||
print("0. Exit")
|
||||
print("="*40)
|
||||
|
||||
choice = input("Enter your choice (1-5, or 0): ")
|
||||
|
||||
if choice == '1':
|
||||
run_script_from_file('manage_clients.py')
|
||||
elif choice == '2':
|
||||
run_script_from_file('track_time.py')
|
||||
elif choice == '3':
|
||||
run_script_from_file('edit_time.py') # Call new script
|
||||
elif choice == '4':
|
||||
run_script_from_file('create_invoice.py')
|
||||
elif choice == '5':
|
||||
run_script_from_file('reports.py')
|
||||
elif choice == '0':
|
||||
print("Exiting the Time Tracker. Goodbye!")
|
||||
break
|
||||
else:
|
||||
print("Invalid choice. Please enter a number between 1 and 5, or 0 to exit.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main_menu()
|
||||
@@ -1,289 +0,0 @@
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
def connect_db():
|
||||
"""
|
||||
Connects to the SQLite database file and returns the connection object.
|
||||
If the file does not exist, it prints an error message.
|
||||
"""
|
||||
db_file = 'time_tracker.db'
|
||||
if not os.path.exists(db_file):
|
||||
print(f"Error: Database file '{db_file}' not found. Please run the database creation script first.")
|
||||
return None
|
||||
|
||||
return sqlite3.connect(db_file)
|
||||
|
||||
def display_client_details(client_data):
|
||||
"""
|
||||
Displays the details of a single client in a readable format.
|
||||
"""
|
||||
print("\n--- Client Details ---")
|
||||
print(f"ID: {client_data[0]}")
|
||||
print(f"Name: {client_data[1]}")
|
||||
print(f"Street Address 1: {client_data[2]}")
|
||||
print(f"Street Address 2: {client_data[3]}")
|
||||
print(f"City: {client_data[4]}")
|
||||
print(f"State: {client_data[5]}")
|
||||
print(f"Zip Code: {client_data[6]}")
|
||||
print(f"Billing Rate: {client_data[7]:.2f}")
|
||||
print(f"Balance: {client_data[8]:.2f}")
|
||||
print(f"Active: {'Yes' if client_data[9] else 'No'}")
|
||||
print("----------------------\n")
|
||||
|
||||
def add_new_client():
|
||||
"""
|
||||
Prompts the user for information to create a new client and adds it to the database.
|
||||
"""
|
||||
conn = connect_db()
|
||||
if not conn:
|
||||
return
|
||||
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
print("\n--- Add New Client ---")
|
||||
|
||||
# Prompt for each field
|
||||
client_name = input("Enter client name: ")
|
||||
street_address_1 = input("Enter street address 1: ")
|
||||
street_address_2 = input("Enter street address 2 (optional): ")
|
||||
city = input("Enter city: ")
|
||||
state = input("Enter state: ")
|
||||
zip_code = input("Enter zip code: ")
|
||||
|
||||
# Validate billing rate and balance
|
||||
while True:
|
||||
try:
|
||||
billing_rate = float(input("Enter billing rate: "))
|
||||
break
|
||||
except ValueError:
|
||||
print("Invalid input. Please enter a number for billing rate.")
|
||||
|
||||
while True:
|
||||
try:
|
||||
balance = float(input("Enter starting balance: "))
|
||||
break
|
||||
except ValueError:
|
||||
print("Invalid input. Please enter a number for balance.")
|
||||
|
||||
# Active status is boolean, use 1 or 0
|
||||
active_input = input("Is the client active? (yes/no): ").lower()
|
||||
active = 1 if active_input in ['yes', 'y'] else 0
|
||||
|
||||
# Insert the new client into the database
|
||||
cursor.execute('''
|
||||
INSERT INTO clients (client_name, street_address_1, street_address_2, city, state, zip_code, billing_rate, balance, active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''', (client_name, street_address_1, street_address_2, city, state, zip_code, billing_rate, balance, active))
|
||||
|
||||
conn.commit()
|
||||
print("\nClient added successfully!")
|
||||
|
||||
# Fetch the newly created client to display it
|
||||
last_id = cursor.lastrowid
|
||||
cursor.execute('SELECT * FROM clients WHERE client_id = ?', (last_id,))
|
||||
new_client = cursor.fetchone()
|
||||
display_client_details(new_client)
|
||||
|
||||
except sqlite3.IntegrityError:
|
||||
print("\nError: A client with that name already exists. Please choose a unique name.")
|
||||
except sqlite3.Error as e:
|
||||
print(f"\nAn error occurred: {e}")
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def edit_existing_client():
|
||||
"""
|
||||
Displays a list of clients and allows the user to select and edit one.
|
||||
"""
|
||||
conn = connect_db()
|
||||
if not conn:
|
||||
return
|
||||
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT client_id, client_name FROM clients')
|
||||
clients = cursor.fetchall()
|
||||
|
||||
if not clients:
|
||||
print("No clients found to edit.")
|
||||
return
|
||||
|
||||
print("\n--- Existing Clients ---")
|
||||
for client_id, client_name in clients:
|
||||
print(f"{client_id}: {client_name}")
|
||||
print("----------------------\n")
|
||||
|
||||
while True:
|
||||
try:
|
||||
choice = input("Enter the ID of the client you want to edit (or 'back' to return to menu): ").strip().lower()
|
||||
if choice == 'back':
|
||||
return
|
||||
|
||||
client_id = int(choice)
|
||||
cursor.execute('SELECT * FROM clients WHERE client_id = ?', (client_id,))
|
||||
client_data = cursor.fetchone()
|
||||
|
||||
if client_data:
|
||||
break
|
||||
else:
|
||||
print("Invalid client ID. Please try again.")
|
||||
except ValueError:
|
||||
print("Invalid input. Please enter a number or 'back'.")
|
||||
|
||||
display_client_details(client_data)
|
||||
|
||||
# The fields that can be edited, mapped to their database column names
|
||||
field_map = {
|
||||
'1': 'client_name',
|
||||
'2': 'street_address_1',
|
||||
'3': 'street_address_2',
|
||||
'4': 'city',
|
||||
'5': 'state',
|
||||
'6': 'zip_code',
|
||||
'7': 'billing_rate',
|
||||
'8': 'balance',
|
||||
'9': 'active'
|
||||
}
|
||||
|
||||
print("\n--- Select a Field to Edit ---")
|
||||
print("1: Client Name")
|
||||
print("2: Street Address 1")
|
||||
print("3: Street Address 2")
|
||||
print("4: City")
|
||||
print("5: State")
|
||||
print("6: Zip Code")
|
||||
print("7: Billing Rate")
|
||||
print("8: Balance")
|
||||
print("9: Active Status")
|
||||
print("0: Cancel and Return to Main Menu")
|
||||
|
||||
while True:
|
||||
edit_choice = input("Enter the number of the field you want to edit: ")
|
||||
if edit_choice == '0':
|
||||
print("Edit cancelled.")
|
||||
return
|
||||
|
||||
column_name = field_map.get(edit_choice)
|
||||
if column_name:
|
||||
break
|
||||
else:
|
||||
print("Invalid choice. Please enter a valid number.")
|
||||
|
||||
# Get the new value from the user, prepopulating with old value
|
||||
current_value_index = list(field_map.keys()).index(edit_choice) + 1
|
||||
|
||||
# Handle special cases for data types
|
||||
if column_name in ['billing_rate', 'balance']:
|
||||
new_value_str = input(f"Enter new value for {column_name.replace('_', ' ')} (current: {client_data[current_value_index]}): ")
|
||||
if not new_value_str:
|
||||
new_value = client_data[current_value_index]
|
||||
else:
|
||||
try:
|
||||
new_value = float(new_value_str)
|
||||
except ValueError:
|
||||
print("Invalid input. Please enter a number.")
|
||||
return
|
||||
elif column_name == 'active':
|
||||
new_value_input = input(f"Is the client active? (yes/no, current: {'Yes' if client_data[current_value_index] else 'No'}): ").lower()
|
||||
if not new_value_input:
|
||||
new_value = client_data[current_value_index]
|
||||
else:
|
||||
new_value = 1 if new_value_input in ['yes', 'y'] else 0
|
||||
else:
|
||||
new_value_input = input(f"Enter new value for {column_name.replace('_', ' ')} (current: {client_data[current_value_index]}): ")
|
||||
if not new_value_input:
|
||||
new_value = client_data[current_value_index]
|
||||
else:
|
||||
new_value = new_value_input
|
||||
|
||||
# Update the database
|
||||
cursor.execute(f"UPDATE clients SET {column_name} = ? WHERE client_id = ?", (new_value, client_id))
|
||||
conn.commit()
|
||||
|
||||
print("\nClient information updated successfully!")
|
||||
|
||||
# Fetch and display the updated client details
|
||||
cursor.execute('SELECT * FROM clients WHERE client_id = ?', (client_id,))
|
||||
updated_client = cursor.fetchone()
|
||||
display_client_details(updated_client)
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"\nAn error occurred: {e}")
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def display_client_info():
|
||||
"""
|
||||
Displays a list of clients and allows the user to select one to view its details.
|
||||
"""
|
||||
conn = connect_db()
|
||||
if not conn:
|
||||
return
|
||||
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT client_id, client_name FROM clients ORDER BY client_name')
|
||||
clients = cursor.fetchall()
|
||||
|
||||
if not clients:
|
||||
print("No clients found.")
|
||||
return
|
||||
|
||||
print("\n--- Existing Clients ---")
|
||||
for client_id, client_name in clients:
|
||||
print(f"{client_id}: {client_name}")
|
||||
print("----------------------\n")
|
||||
|
||||
while True:
|
||||
try:
|
||||
choice = input("Enter the ID of the client you want to view (or 'back' to return to menu): ").strip().lower()
|
||||
if choice == 'back':
|
||||
return
|
||||
|
||||
client_id = int(choice)
|
||||
cursor.execute('SELECT * FROM clients WHERE client_id = ?', (client_id,))
|
||||
client_data = cursor.fetchone()
|
||||
|
||||
if client_data:
|
||||
display_client_details(client_data)
|
||||
break
|
||||
else:
|
||||
print("Invalid client ID. Please try again.")
|
||||
except ValueError:
|
||||
print("Invalid input. Please enter a number or 'back'.")
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"An error occurred: {e}")
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def main_menu():
|
||||
"""
|
||||
Displays the main menu and handles user choices.
|
||||
"""
|
||||
while True:
|
||||
print("\n--- Time Tracker Client Manager ---")
|
||||
print("1. Add a new client")
|
||||
print("2. Display client information")
|
||||
print("3. Edit an existing client")
|
||||
print("0. Exit")
|
||||
|
||||
choice = input("Enter your choice: ").strip()
|
||||
|
||||
if choice == '1':
|
||||
add_new_client()
|
||||
elif choice == '2':
|
||||
display_client_info()
|
||||
elif choice == '3':
|
||||
edit_existing_client()
|
||||
elif choice == '0':
|
||||
print("Exiting. Goodbye!")
|
||||
break
|
||||
else:
|
||||
print("Invalid choice. Please try again.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main_menu()
|
||||
-125
@@ -1,125 +0,0 @@
|
||||
import sqlite3
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
def connect_db():
|
||||
"""
|
||||
Connects to the SQLite database file and returns the connection object.
|
||||
If the file does not exist, it prints an an error message.
|
||||
"""
|
||||
db_file = 'time_tracker.db'
|
||||
if not os.path.exists(db_file):
|
||||
print(f"Error: Database file '{db_file}' not found. Please run the database creation script first.")
|
||||
return None
|
||||
return sqlite3.connect(db_file)
|
||||
|
||||
def unbilled_time_report():
|
||||
"""
|
||||
Generates a report of unbilled time entries for a selected client.
|
||||
"""
|
||||
conn = connect_db()
|
||||
if not conn:
|
||||
return
|
||||
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Display active clients for selection
|
||||
cursor.execute('SELECT client_id, client_name FROM clients WHERE active = 1 ORDER BY client_name')
|
||||
active_clients = cursor.fetchall()
|
||||
|
||||
if not active_clients:
|
||||
print("No active clients found to generate a report for.")
|
||||
return
|
||||
|
||||
print("\n--- Select a Client for Unbilled Time Report ---")
|
||||
for client_id, client_name in active_clients:
|
||||
print(f"{client_id}: {client_name}")
|
||||
print("0: Exit")
|
||||
print("--------------------------------------------------\n")
|
||||
|
||||
while True:
|
||||
try:
|
||||
choice = input("Enter the ID of the client (or 0 to exit): ")
|
||||
if choice == '0':
|
||||
print("Exiting report generation.")
|
||||
return
|
||||
|
||||
client_id = int(choice)
|
||||
if any(c[0] == client_id for c in active_clients):
|
||||
break
|
||||
else:
|
||||
print("Invalid client ID. Please enter a valid ID from the list.")
|
||||
except ValueError:
|
||||
print("Invalid input. Please enter a number.")
|
||||
|
||||
# Fetch client details and billing rate
|
||||
cursor.execute('SELECT client_name, billing_rate FROM clients WHERE client_id = ?', (client_id,))
|
||||
client_name, billing_rate = cursor.fetchone()
|
||||
|
||||
# Fetch unbilled time entries for the selected client, including description
|
||||
cursor.execute('''
|
||||
SELECT date, hours, description, project
|
||||
FROM time_tracking
|
||||
WHERE client_id = ? AND invoiced = 0
|
||||
ORDER BY date
|
||||
''', (client_id,))
|
||||
|
||||
time_entries = cursor.fetchall()
|
||||
|
||||
if not time_entries:
|
||||
print(f"\nNo unbilled time entries found for {client_name}.")
|
||||
return
|
||||
|
||||
# Calculate daily summary, descriptions, and total cost
|
||||
daily_summary = {}
|
||||
grand_total_cost = 0.0
|
||||
|
||||
for entry_date, hours, description, project in time_entries:
|
||||
if entry_date not in daily_summary:
|
||||
daily_summary[entry_date] = {'hours': 0.0, 'cost': 0.0, 'entries': []}
|
||||
|
||||
daily_summary[entry_date]['hours'] += hours
|
||||
daily_summary[entry_date]['cost'] += hours * billing_rate
|
||||
daily_summary[entry_date]['entries'].append({'description': description, 'project': project})
|
||||
grand_total_cost += hours * billing_rate
|
||||
|
||||
# Print the formatted report
|
||||
print(f"\n--- Unbilled Time Report for {client_name} ---")
|
||||
print(f"Total Unbilled Cost: ${grand_total_cost:.2f}\n")
|
||||
|
||||
for daily_date, summary in daily_summary.items():
|
||||
print(f"Date: {daily_date} | Hours: {summary['hours']:.2f} | Cost: ${summary['cost']:.2f}")
|
||||
for entry in summary['entries']:
|
||||
print(f" - Project: {entry['project']}, Description: {entry['description']}")
|
||||
print("-" * 32)
|
||||
|
||||
print(f"\nReport generated on {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"An error occurred: {e}")
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def main_menu():
|
||||
"""
|
||||
Displays the main menu and handles user choices.
|
||||
"""
|
||||
while True:
|
||||
print("\n--- Reports Menu ---")
|
||||
print("1: Unbilled Time")
|
||||
print("0: Exit")
|
||||
print("--------------------\n")
|
||||
choice = input("Enter your choice: ")
|
||||
|
||||
if choice == '1':
|
||||
unbilled_time_report()
|
||||
elif choice == '0':
|
||||
print("Exiting reports script. Goodbye!")
|
||||
break
|
||||
else:
|
||||
print("Invalid choice. Please try again.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main_menu()
|
||||
-114
@@ -1,114 +0,0 @@
|
||||
import sqlite3
|
||||
import os
|
||||
from datetime import date
|
||||
|
||||
def connect_db():
|
||||
"""
|
||||
Connects to the SQLite database file and returns the connection object.
|
||||
If the file does not exist, it prints an error message.
|
||||
"""
|
||||
db_file = 'time_tracker.db'
|
||||
if not os.path.exists(db_file):
|
||||
print(f"Error: Database file '{db_file}' not found. Please run the database creation script first.")
|
||||
return None
|
||||
|
||||
return sqlite3.connect(db_file)
|
||||
|
||||
def add_time_entry():
|
||||
"""
|
||||
Prompts the user for details and adds a new time entry to the database.
|
||||
"""
|
||||
conn = connect_db()
|
||||
if not conn:
|
||||
return
|
||||
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Display all active clients for selection
|
||||
cursor.execute('SELECT client_id, client_name FROM clients WHERE active = 1 ORDER BY client_name')
|
||||
active_clients = cursor.fetchall()
|
||||
|
||||
if not active_clients:
|
||||
print("No active clients found. Please add an active client first.")
|
||||
return
|
||||
|
||||
print("\n--- Select a Client ---")
|
||||
for client_id, client_name in active_clients:
|
||||
print(f"{client_id}: {client_name}")
|
||||
print("0: Exit") # Added exit option
|
||||
print("----------------------")
|
||||
|
||||
while True:
|
||||
try:
|
||||
choice = int(input("Enter the ID of the client (or 0 to exit): "))
|
||||
except ValueError:
|
||||
print("Invalid input. Please enter a number.")
|
||||
continue
|
||||
|
||||
if choice == 0:
|
||||
print("Exiting time entry.")
|
||||
return # Exit the function if the user chooses 0
|
||||
|
||||
# Check if the entered ID is in the list of active clients
|
||||
if any(c[0] == choice for c in active_clients):
|
||||
client_id = choice
|
||||
break
|
||||
else:
|
||||
print("Invalid client ID. Please enter a valid ID from the list.")
|
||||
|
||||
print(f"\n--- Add New Time Entry for Client ID {client_id} ---")
|
||||
project = input("Enter project name: ")
|
||||
description = input("Enter a brief description: ")
|
||||
|
||||
# Get the hours and validate it's a number
|
||||
while True:
|
||||
try:
|
||||
hours = float(input("Enter number of hours (e.g., 1.5): "))
|
||||
if hours <= 0:
|
||||
print("Hours must be a positive number.")
|
||||
else:
|
||||
break
|
||||
except ValueError:
|
||||
print("Invalid input. Please enter a number for hours.")
|
||||
|
||||
# Get the date, defaulting to today's date
|
||||
entry_date = input(f"Enter the date (YYYY-MM-DD, default is today: {date.today()}): ")
|
||||
if not entry_date:
|
||||
entry_date = str(date.today())
|
||||
|
||||
# The 'invoiced' field defaults to 0 (false)
|
||||
invoiced = 0
|
||||
|
||||
# Insert the new time entry into the database
|
||||
cursor.execute('''
|
||||
INSERT INTO time_tracking (project, description, invoiced, date, hours, client_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
''', (project, description, invoiced, entry_date, hours, client_id))
|
||||
|
||||
conn.commit()
|
||||
print("\nTime entry added successfully!")
|
||||
|
||||
# Fetch and display the newly created entry
|
||||
last_id = cursor.lastrowid
|
||||
cursor.execute('SELECT * FROM time_tracking WHERE entry_id = ?', (last_id,))
|
||||
new_entry = cursor.fetchone()
|
||||
|
||||
print("\n--- New Time Entry Details ---")
|
||||
print(f"ID: {new_entry[0]}")
|
||||
print(f"Project: {new_entry[1]}")
|
||||
print(f"Description: {new_entry[2]}")
|
||||
print(f"Invoiced: {'Yes' if new_entry[3] else 'No'}")
|
||||
print(f"Date: {new_entry[4]}")
|
||||
print(f"Hours: {new_entry[5]:.2f}")
|
||||
print(f"Client ID: {new_entry[6]}")
|
||||
print("------------------------------\n")
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"An error occurred: {e}")
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
add_time_entry()
|
||||
@@ -0,0 +1,47 @@
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
def load_config():
|
||||
"""Loads config.json and ensures directories exist."""
|
||||
try:
|
||||
with open("config.json", "r") as f:
|
||||
config = json.load(f)
|
||||
|
||||
os.makedirs(config.get("DataDirectory", "./"), exist_ok=True)
|
||||
os.makedirs(config.get("PDFOutputDirectory", "./"), exist_ok=True)
|
||||
return config
|
||||
except FileNotFoundError:
|
||||
print("Error: config.json is missing. Please create it.")
|
||||
exit(1)
|
||||
except Exception as e:
|
||||
print(f"Error reading config: {e}")
|
||||
exit(1)
|
||||
|
||||
def load_json(filepath, default_structure):
|
||||
"""Loads a JSON file or creates it with a default structure if missing."""
|
||||
if not os.path.exists(filepath):
|
||||
save_json(filepath, default_structure)
|
||||
return default_structure
|
||||
try:
|
||||
with open(filepath, "r") as f:
|
||||
return json.load(f)
|
||||
except json.JSONDecodeError:
|
||||
print(f"Warning: {filepath} is corrupted. Returning default structure.")
|
||||
return default_structure
|
||||
|
||||
def save_json(filepath, data):
|
||||
"""Saves data to a JSON file."""
|
||||
try:
|
||||
with open(filepath, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
except Exception as e:
|
||||
print(f"File write failure for {filepath}: {e}")
|
||||
|
||||
def get_next_id(prefix, current_records, id_field):
|
||||
"""Generates the next sequential ID (e.g., CLT-001)."""
|
||||
if not current_records:
|
||||
return f"{prefix}-001"
|
||||
ids = [int(r[id_field].split("-")[1]) for r in current_records if r[id_field].startswith(prefix)]
|
||||
next_num = max(ids) + 1 if ids else 1
|
||||
return f"{prefix}-{next_num:03d}"
|
||||
Reference in New Issue
Block a user