diff options
author | T <t@tjp.lol> | 2025-08-05 11:37:02 -0600 |
---|---|---|
committer | T <t@tjp.lol> | 2025-08-05 11:37:08 -0600 |
commit | 665bd389a0a1c8adadcaa1122e846cc81f5ead31 (patch) | |
tree | f34f9ec77891308c600c680683f60951599429c3 /internal/tui/clients_projects_box.go | |
parent | dc895cec9d8a84af89ce2501db234dff33c757e2 (diff) |
WIP TUI
Diffstat (limited to 'internal/tui/clients_projects_box.go')
-rw-r--r-- | internal/tui/clients_projects_box.go | 247 |
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 |