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()