summaryrefslogtreecommitdiff
path: root/internal/tui/clients_projects_box.go
diff options
context:
space:
mode:
authorT <t@tjp.lol>2025-08-05 11:37:02 -0600
committerT <t@tjp.lol>2025-08-05 11:37:08 -0600
commit665bd389a0a1c8adadcaa1122e846cc81f5ead31 (patch)
treef34f9ec77891308c600c680683f60951599429c3 /internal/tui/clients_projects_box.go
parentdc895cec9d8a84af89ce2501db234dff33c757e2 (diff)
WIP TUI
Diffstat (limited to 'internal/tui/clients_projects_box.go')
-rw-r--r--internal/tui/clients_projects_box.go247
1 files changed, 247 insertions, 0 deletions
diff --git a/internal/tui/clients_projects_box.go b/internal/tui/clients_projects_box.go
new file mode 100644
index 0000000..c52e964
--- /dev/null
+++ b/internal/tui/clients_projects_box.go
@@ -0,0 +1,247 @@
+package tui
+
+import (
+ "fmt"
+
+ "punchcard/internal/queries"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss/v2"
+)
+
+// NewClientsProjectsModel creates a new clients/projects model
+func NewClientsProjectsModel() ClientsProjectsModel {
+ return ClientsProjectsModel{
+ selectedIndex: 0,
+ selectedIsClient: true,
+ }
+}
+
+// Update handles messages for the clients/projects box
+func (m ClientsProjectsModel) Update(msg tea.Msg) (ClientsProjectsModel, tea.Cmd) {
+ return m, nil
+}
+
+// View renders the clients/projects box
+func (m ClientsProjectsModel) View(width, height int, isSelected bool) string {
+ var content string
+
+ if len(m.clients) == 0 {
+ content = "No clients found\n\nUse 'punch add client' to\nadd your first client."
+ } else {
+ content = m.renderClientsAndProjects()
+ }
+
+ // Apply box styling
+ style := unselectedBoxStyle
+ if isSelected {
+ style = selectedBoxStyle
+ }
+
+ title := "👥 Clients & Projects"
+
+ return style.Width(width).Height(height).Render(
+ fmt.Sprintf("%s\n\n%s", title, content),
+ )
+}
+
+// renderClientsAndProjects renders the clients and their projects
+func (m ClientsProjectsModel) renderClientsAndProjects() string {
+ var content string
+
+ // Group projects by client
+ projectsByClient := make(map[int64][]queries.ListAllProjectsRow)
+ for _, project := range m.projects {
+ projectsByClient[project.ClientID] = append(projectsByClient[project.ClientID], project)
+ }
+
+ // Track the absolute row index for selection highlighting
+ absoluteRowIndex := 0
+
+ for i, client := range m.clients {
+ if i > 0 {
+ content += "\n"
+ }
+
+ // Client name with rate if available
+ clientLine := fmt.Sprintf("• %s", client.Name)
+ if client.BillableRate.Valid {
+ rateInDollars := float64(client.BillableRate.Int64) / 100.0
+ clientLine += fmt.Sprintf(" ($%.2f/hr)", rateInDollars)
+ }
+
+ // Highlight if this client is selected
+ clientStyle := lipgloss.NewStyle().Bold(true)
+ if m.selectedIsClient && m.selectedIndex == i {
+ clientStyle = clientStyle.Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230"))
+ }
+ content += clientStyle.Render(clientLine) + "\n"
+ absoluteRowIndex++
+
+ // Projects for this client
+ clientProjects := projectsByClient[client.ID]
+ if len(clientProjects) == 0 {
+ content += " └── (no projects)\n"
+ } else {
+ for j, project := range clientProjects {
+ prefix := "├──"
+ if j == len(clientProjects)-1 {
+ prefix = "└──"
+ }
+
+ projectLine := fmt.Sprintf(" %s %s", prefix, project.Name)
+ if project.BillableRate.Valid {
+ rateInDollars := float64(project.BillableRate.Int64) / 100.0
+ projectLine += fmt.Sprintf(" ($%.2f/hr)", rateInDollars)
+ }
+
+ // Highlight if this project is selected
+ // We need to check against the absolute project index in m.projects
+ projectStyle := lipgloss.NewStyle()
+ if !m.selectedIsClient {
+ // Find this project's index in the m.projects slice
+ for k, p := range m.projects {
+ if p.ID == project.ID && m.selectedIndex == k {
+ projectStyle = projectStyle.Background(lipgloss.Color("62")).Foreground(lipgloss.Color("230"))
+ break
+ }
+ }
+ }
+ content += projectStyle.Render(projectLine) + "\n"
+ }
+ }
+ }
+
+ return content
+}
+
+// UpdateData updates the clients and projects data
+func (m ClientsProjectsModel) UpdateData(clients []queries.Client, projects []queries.ListAllProjectsRow) ClientsProjectsModel {
+ m.clients = clients
+ m.projects = projects
+ // Reset selection if we have new data
+ if len(clients) > 0 {
+ m.selectedIndex = 0
+ m.selectedIsClient = true
+ }
+ return m
+}
+
+// NextSelection moves to the next selectable row
+func (m ClientsProjectsModel) NextSelection() ClientsProjectsModel {
+ totalRows := m.getTotalSelectableRows()
+ if totalRows == 0 {
+ return m
+ }
+
+ currentIndex := m.getCurrentRowIndex()
+ if currentIndex < totalRows-1 {
+ m.setRowIndex(currentIndex + 1)
+ }
+ return m
+}
+
+// PrevSelection moves to the previous selectable row
+func (m ClientsProjectsModel) PrevSelection() ClientsProjectsModel {
+ totalRows := m.getTotalSelectableRows()
+ if totalRows == 0 {
+ return m
+ }
+
+ currentIndex := m.getCurrentRowIndex()
+ if currentIndex > 0 {
+ m.setRowIndex(currentIndex - 1)
+ }
+ return m
+}
+
+// getDisplayOrder returns items in the order they are displayed (tree structure)
+func (m ClientsProjectsModel) getDisplayOrder() []ProjectsDisplayItem {
+ var items []ProjectsDisplayItem
+
+ // Group projects by client
+ projectsByClient := make(map[int64][]queries.ListAllProjectsRow)
+ projectIndexByID := make(map[int64]int)
+ for i, project := range m.projects {
+ projectsByClient[project.ClientID] = append(projectsByClient[project.ClientID], project)
+ projectIndexByID[project.ID] = i
+ }
+
+ // Build display order: client followed by its projects
+ for i, client := range m.clients {
+ // Add client
+ items = append(items, ProjectsDisplayItem{
+ IsClient: true,
+ ClientIndex: i,
+ Client: &client,
+ })
+
+ // Add projects for this client
+ clientProjects := projectsByClient[client.ID]
+ for _, project := range clientProjects {
+ projectCopy := project // Copy to avoid reference issues
+ items = append(items, ProjectsDisplayItem{
+ IsClient: false,
+ ClientIndex: i,
+ ProjectIndex: projectIndexByID[project.ID],
+ Project: &projectCopy,
+ })
+ }
+ }
+
+ return items
+}
+
+// getTotalSelectableRows counts total items in display order
+func (m ClientsProjectsModel) getTotalSelectableRows() int {
+ return len(m.getDisplayOrder())
+}
+
+// getCurrentRowIndex gets the current absolute row index in display order
+func (m ClientsProjectsModel) getCurrentRowIndex() int {
+ displayOrder := m.getDisplayOrder()
+
+ for i, item := range displayOrder {
+ if item.IsClient && m.selectedIsClient && item.ClientIndex == m.selectedIndex {
+ return i
+ }
+ if !item.IsClient && !m.selectedIsClient && item.ProjectIndex == m.selectedIndex {
+ return i
+ }
+ }
+
+ return 0 // Default to first item if not found
+}
+
+// setRowIndex sets the selection to the given absolute row index in display order
+func (m *ClientsProjectsModel) setRowIndex(index int) {
+ displayOrder := m.getDisplayOrder()
+ if index < 0 || index >= len(displayOrder) {
+ return
+ }
+
+ item := displayOrder[index]
+ if item.IsClient {
+ m.selectedIndex = item.ClientIndex
+ m.selectedIsClient = true
+ } else {
+ m.selectedIndex = item.ProjectIndex
+ m.selectedIsClient = false
+ }
+}
+
+// GetSelectedClient returns the selected client if one is selected
+func (m ClientsProjectsModel) GetSelectedClient() *queries.Client {
+ if m.selectedIsClient && m.selectedIndex < len(m.clients) {
+ return &m.clients[m.selectedIndex]
+ }
+ return nil
+}
+
+// GetSelectedProject returns the selected project if one is selected
+func (m ClientsProjectsModel) GetSelectedProject() *queries.ListAllProjectsRow {
+ if !m.selectedIsClient && m.selectedIndex < len(m.projects) {
+ return &m.projects[m.selectedIndex]
+ }
+ return nil
+} \ No newline at end of file