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 }