diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..99e106f Binary files /dev/null and b/.DS_Store differ diff --git a/ClientMain.py b/ClientMain.py new file mode 100755 index 0000000..9d4679d --- /dev/null +++ b/ClientMain.py @@ -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() \ No newline at end of file diff --git a/Config.json b/Config.json new file mode 100644 index 0000000..e10d730 --- /dev/null +++ b/Config.json @@ -0,0 +1,4 @@ +{ + "DataDirectory": "~/HourStack", + "PDFOutputDirectory": "~/Invoices" +} \ No newline at end of file diff --git a/EnterTime.py b/EnterTime.py new file mode 100644 index 0000000..994f8fa --- /dev/null +++ b/EnterTime.py @@ -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() \ No newline at end of file diff --git a/Invoice.py b/Invoice.py new file mode 100644 index 0000000..5437f62 --- /dev/null +++ b/Invoice.py @@ -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() \ 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