Compare commits
3 Commits
6c1b55158d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 602ecffa23 | |||
| 77d25739ac | |||
| 2b376a4f91 |
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()
|
||||||
@@ -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