From 2b376a4f91f97127ab0ab079b6e4cf14ff62edd7 Mon Sep 17 00:00:00 2001 From: Matt Speer Date: Sat, 9 May 2026 09:34:25 -0500 Subject: [PATCH] v2.0 --- .DS_Store | Bin 0 -> 6148 bytes ClientMain.py | 45 ++++++++++ Config.json | 4 + EnterTime.py | 115 ++++++++++++++++++++++++++ Invoice.py | 217 +++++++++++++++++++++++++++++++++++++++++++++++++ ProjectMain.py | 39 +++++++++ Unbilled.py | 78 ++++++++++++++++++ utils.py | 47 +++++++++++ 8 files changed, 545 insertions(+) create mode 100644 .DS_Store create mode 100755 ClientMain.py create mode 100644 Config.json create mode 100644 EnterTime.py create mode 100644 Invoice.py create mode 100644 ProjectMain.py create mode 100644 Unbilled.py create mode 100644 utils.py diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..99e106f66b7ca55662fe3fa1cd2a1396d63bca11 GIT binary patch literal 6148 zcmeHKOHRWu5Pc363KUS6EG-TYi5o@Fz^z0Iid&V43IL8eN%v;gg@f#J8 zwL8QaO0HSXRr_4O1dn(bm(_GqRue|aCz8>VcW0~teKISuZppqwf_p5b-C(^nSO*xn zOgR>~BJ+WnJF=OR25%o@@}8NYz;p}ewZS>U1+!Fqvz(l7XBWx${B?FQR%8^^X4&LxnsCK1p3Z(7;?jV%M^A@wnfaWM*@PR4aoO2#T{ukYQA<@o71&f@ z$2SME|Btrc|2Ik6QUz3jf2Dxw4KIforxf 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() \ No newline at end of file diff --git a/ProjectMain.py b/ProjectMain.py new file mode 100644 index 0000000..ad4c9ee --- /dev/null +++ b/ProjectMain.py @@ -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() \ No newline at end of file diff --git a/Unbilled.py b/Unbilled.py new file mode 100644 index 0000000..7d22efc --- /dev/null +++ b/Unbilled.py @@ -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() \ No newline at end of file diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..e396123 --- /dev/null +++ b/utils.py @@ -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}" \ No newline at end of file