Files
pancake-tui/src/invoices.py
2025-05-14 17:51:21 -06:00

201 lines
7.9 KiB
Python

from typing import List
from textual.app import App, ComposeResult
from textual.widgets import Footer, Header, Tabs, TabbedContent, TabPane, Select
from textual.containers import Horizontal, Vertical, ScrollableContainer
from textual.widgets import ListView, DataTable, ListItem, Label, Static
from textual.reactive import Reactive
from textual import work
from services.pancake import ListResponse, PancakeAPI, ClientResponse, ProjectResponse
from datetime import datetime, UTC, timedelta
class InvoicesApp(App):
"""A Textual app to manage stopwatches."""
CSS_PATH = "styles/invoices.tcss"
clients: List[ClientResponse]
projects: List[ProjectResponse]
client_project_id: str
BINDINGS = [
("d", "toggle_dark", "Toggle dark mode"),
("q", "quit", "Quit the App"),
]
def compose(self) -> ComposeResult:
"""Create child widgets for the app."""
yield Header()
with Horizontal():
with Vertical(id="sidebar"):
yield ListView(
id="client_list",
)
with ScrollableContainer(id="main"):
with TabbedContent():
with TabPane("Projects"):
yield DataTable(id="project_list")
with TabPane("Invoices"):
yield DataTable(id="invoice_list")
with TabPane("Timesheet"):
with ScrollableContainer(id="timesheets"):
yield Select([("loading", 0)],id="timesheet_select")
yield DataTable(id="timesheet_list")
yield Footer()
def on_mount(self) -> None:
#load data from pancake api
self.pancake = PancakeAPI()
self.load_sidebar()
self.query_one("#timesheet_list").add_columns(
"ID",
"Is Billed",
"Date",
"Minutes",
"Notes"
)
self.query_one("#invoice_list", DataTable).add_columns(
"#",
"Payment Date",
"Amount",
"Due Date",
"Is Paid",
"Is Viewable",
"Last Viewed",
"Paid",
"Overdue")
self.query_one("#project_list", DataTable).add_columns(
"ID",
"Name",
"Is Archived",
"Is Completed",
"Created At",
"Updated At"
)
@work
async def load_sidebar(self) -> None:
client_resp = await self.pancake.get_all_clients({"limit": 100, "sort_by": "id", "sort_dir": "asc"})
self.clients = sorted(client_resp.get("clients", []), key=lambda x: x['total'], reverse=True)
project_resp = await self.pancake.get_all_projects({"limit": 100})
self.projects = project_resp.get("projects", [])
# self.query_one("#invoice_list", ListView).extend(ListItem(f"{invoice['id']} {invoice['client']}") for invoice in self.invoices)
client_list = self.query_one("#client_list", ListView)
client_list.clear()
client_list.border_title = "Clients"
for client in self.clients:
client_list.append(ListItem(Label(f"{client.get('company', '-')} {client.get('email', '@')}"), id="client_" + str(client["id"]))) if client.get("archived") != "1" else None
# if self.clients:
# self.fetch_invoices(self.clients[0]["id"])
def on_list_view_selected(self, event: ListView.Selected) -> None:
"""Handle the selection of an invoice."""
if event.list_view.id == "client_list":
selected_client_id = event.item.id.split("_")[1]
if selected_client_id:
self.fetch_invoices(selected_client_id)
self.show_projects(selected_client_id)
@work
async def fetch_invoices(self, client_id: str) -> None:
invoices = await self.pancake.get_all_invoices(params={"client_id": client_id, "limit": 100, "sort_by": "id", "sort_dir": "desc"})
invoice_details = invoices.get("invoices", [])
table = self.query_one("#invoice_list", DataTable)
table.clear()
table.border_title = "Invoices"
for detail in invoice_details:
table.add_row(
detail["invoice_number"],
detail["payment_date"],
str(detail["amount"]),
detail["due_date"],
detail["is_paid"],
detail["is_viewable"],
detail.get("last_viewed", "N/A"),
detail.get("paid", "No"),
detail.get("overdue", "No"),
key=detail["id"]
)
def show_projects(self, client_id: str) -> None:
"""Show projects for the selected client."""
table = self.query_one("#project_list", DataTable)
table.clear()
table.border_title = "Projects"
# create a new list that is sorted by id and only contains the projects for the selected client
client_projects = sorted(
self.projects,
key=lambda x: x["id"],
reverse=True
)
# filter the projects by client_id
client_projects = [project for project in client_projects if project["client_id"] == client_id]
select = self.query_one("#timesheet_select", Select)
select.clear()
select.set_options((project["name"], project["id"],) for project in client_projects)
self.selected_project_id = client_projects[0]["id"] if client_projects else None
if (self.selected_project_id):
select.value = self.selected_project_id
self.fetch_timesheet(self.selected_project_id)
for project in client_projects:
if project["client_id"] == client_id:
table.add_row(
str(project["id"]),
project["name"],
project["is_archived"],
project.get("completed", "No"),
project["date_entered"],
project["date_updated"],
)
@work
async def fetch_timesheet(self, project_id: str) -> None:
"""Fetch timesheet for the selected project."""
#is project_id a string of digits
if not project_id or not str(project_id).isdigit():
return
table = self.query_one("#timesheet_list", DataTable)
table.clear()
table.border_title = "Timesheets"
# fetch timesheet data from pancake api
timesheet_resp = await self.pancake.get_one_project(project_id)
timesheet_details = timesheet_resp.get("times", [])
timesheet_details = sorted(timesheet_details, key=lambda x: x["date"], reverse=True)
for detail in timesheet_details:
table.add_row(
str(detail["id"]),
"Yes" if detail.get("invoice_item_id") else "No",
datetime.fromtimestamp(int(detail["date"])).strftime("%Y-%m-%d %H:%M:%S"),
round(float(detail.get("minutes", 0))), #round the minutes to int
detail.get("note", "N/A")
)
def on_select_changed(self, event: Select.Changed) -> None:
"""Handle the selection of a project."""
if event.select.id == "timesheet_select":
selected_project_id = event.select.value
if selected_project_id:
self.fetch_timesheet(selected_project_id)
def action_toggle_dark(self) -> None:
"""An action to toggle dark mode."""
self.theme = (
"textual-dark" if self.theme == "textual-light" else "textual-light"
)
if __name__ == "__main__":
app = InvoicesApp()
app.run()