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