This commit is contained in:
2026-05-09 09:34:25 -05:00
parent 6c1b55158d
commit 2b376a4f91
8 changed files with 545 additions and 0 deletions
Vendored
BIN
View File
Binary file not shown.
Executable
+45
View File
@@ -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()
+4
View File
@@ -0,0 +1,4 @@
{
"DataDirectory": "~/HourStack",
"PDFOutputDirectory": "~/Invoices"
}
+115
View File
@@ -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
View File
@@ -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()
+39
View File
@@ -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
View File
@@ -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()
+47
View File
@@ -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}"