201 lines
7.9 KiB
Python
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()
|