package tui import ( "context" "database/sql" "fmt" "strconv" "time" "git.tjp.lol/punchcard/internal/actions" "git.tjp.lol/punchcard/internal/queries" "git.tjp.lol/punchcard/internal/reports" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss/v2" ) type ModalType int const ( ModalTypeClient ModalType = iota ModalTypeProject ModalTypeDeleteConfirmation ModalTypeEntry ModalTypeHistoryFilter ModalTypeGenerateReport ) func (mt ModalType) newForm() Form { switch mt { case ModalTypeEntry: return NewEntryEditorForm() case ModalTypeClient: return NewClientForm() case ModalTypeProject: return NewProjectForm() case ModalTypeHistoryFilter: return NewHistoryFilterForm() case ModalTypeGenerateReport: return NewGenerateReportForm() } return Form{} } type ModalBoxModel struct { Active bool Type ModalType form Form editedID int64 } func (m *ModalBoxModel) HandleKeyPress(msg tea.KeyMsg) tea.Cmd { form, cmd := m.form.Update(msg) m.form = form return cmd } func (m ModalBoxModel) RenderCenteredOver(mainContent string, app AppModel) string { if !m.Active { return mainContent } modalContent := m.Render() base := lipgloss.NewLayer(mainContent) overlayStyle := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("62")).Padding(2, 4) overlay := lipgloss.NewLayer(overlayStyle.Render(modalContent)) overlayWidth := overlay.GetWidth() overlayHeight := overlay.GetHeight() overlayLeft := (app.width - overlayWidth) / 2 overlayTop := (app.height - overlayHeight) / 2 canvas := lipgloss.NewCanvas( base.Z(0), overlay.X(overlayLeft).Y(overlayTop).Z(1), ) return canvas.Render() } func (m ModalBoxModel) Render() string { switch m.Type { case ModalTypeEntry: return m.RenderFormModal("⏰ Time Entry") case ModalTypeDeleteConfirmation: return m.RenderDeleteConfirmation() case ModalTypeClient: return m.RenderFormModal("👤 Client") case ModalTypeProject: return m.RenderFormModal("📂 Project") case ModalTypeHistoryFilter: return m.RenderFormModal("🔍 History Filter") case ModalTypeGenerateReport: return m.RenderFormModal("📄 Generate Report") default: // REMOVE ME return "DEFAULT CONTENT" } } func (m ModalBoxModel) RenderFormModal(title string) string { return fmt.Sprintf( "%s\n\n%s\n\n%s Submit %s Cancel", modalTitleStyle.Render(title), m.form.View(), boldStyle.Render("[Enter]"), boldStyle.Render("[Esc]"), ) } func (m ModalBoxModel) RenderDeleteConfirmation() string { return fmt.Sprintf( "%s\n\nAre you sure you want to delete this time entry?\nThis action cannot be undone.\n\n%s Delete %s Cancel", modalTitleStyle.Render("🗑️ Delete Time Entry"), boldStyle.Render("[Enter]"), boldStyle.Render("[Esc]"), ) } func (m *ModalBoxModel) activateCreateProjectModal(am AppModel) { m.activate(ModalTypeProject, 0, am) if am.selectedBox == ProjectsBox && len(am.projectsBox.clients) > 0 { client := am.projectsBox.clients[am.projectsBox.selectedClient] m.form.fields[1].SetValue(client.Name) } m.form.fields[0].Focus() } func (m *ModalBoxModel) activate(t ModalType, editedID int64, am AppModel) { m.Active = true m.Type = t m.form = t.newForm() m.editedID = editedID m.form.SetSuggestions(am) } func (m *ModalBoxModel) deactivate() { m.Active = false } var ( boldStyle = lipgloss.NewStyle().Bold(true) modalTitleStyle = boldStyle ) func (m *ModalBoxModel) SubmitForm(am AppModel) tea.Cmd { switch m.Type { case ModalTypeDeleteConfirmation: err := am.queries.RemoveTimeEntry(context.Background(), m.editedID) if err != nil { return reOpenModal() } return tea.Sequence(am.refreshCmd, func() tea.Msg { return recheckBounds{} }) case ModalTypeEntry: if err := m.form.Error(); err != nil { return reOpenModal() } params, hasErrors := m.validateAndParseEntryForm(am) if hasErrors { return reOpenModal() } err := am.queries.EditTimeEntry(context.Background(), params) if err != nil { m.form.err = err return reOpenModal() } msg := am.refreshCmd() return func() tea.Msg { return msg } case ModalTypeClient: if err := m.form.Error(); err != nil { return reOpenModal() } var rate *float64 if value := m.form.fields[2].Value(); value != "" { r, _ := strconv.ParseFloat(value, 64) rate = &r } if m.editedID != 0 { panic("editing a client not yet implemented") } if _, err := actions.New(am.queries).CreateClient( context.Background(), m.form.fields[0].Value(), m.form.fields[1].Value(), rate, ); err != nil { m.form.err = err return reOpenModal() } msg := am.refreshCmd() return func() tea.Msg { return msg } case ModalTypeProject: if err := m.form.Error(); err != nil { return reOpenModal() } var rate *float64 if value := m.form.fields[2].Value(); value != "" { r, _ := strconv.ParseFloat(value, 64) rate = &r } if m.editedID != 0 { panic("editing a project not yet implemented") } if _, err := actions.New(am.queries).CreateProject( context.Background(), m.form.fields[0].Value(), m.form.fields[1].Value(), rate, ); err != nil { m.form.err = err return reOpenModal() } msg := am.refreshCmd() return func() tea.Msg { return msg } case ModalTypeHistoryFilter: if err := m.form.Error(); err != nil { return reOpenModal() } // Parse date range dateRangeStr := m.form.fields[0].Value() dateRange, err := reports.ParseDateRange(dateRangeStr) if err != nil { m.form.fields[0].Err = fmt.Errorf("invalid date range: %v", err) return reOpenModal() } // Create new filter newFilter := HistoryFilter{ StartDate: dateRange.Start, EndDate: nil, ClientID: nil, ProjectID: nil, } // Set end date if the parsed range has one if !dateRange.End.IsZero() { newFilter.EndDate = &dateRange.End } // Parse client filter if provided clientStr := m.form.fields[1].Value() if clientStr != "" { client, err := actions.New(am.queries).FindClient(context.Background(), clientStr) if err != nil { m.form.fields[1].Err = fmt.Errorf("client not found: %s", clientStr) return reOpenModal() } newFilter.ClientID = &client.ID } // Parse project filter if provided projectStr := m.form.fields[2].Value() if projectStr != "" { project, err := actions.New(am.queries).FindProject(context.Background(), projectStr) if err != nil { m.form.fields[2].Err = fmt.Errorf("project not found: %s", projectStr) return reOpenModal() } newFilter.ProjectID = &project.ID } // Return filter update message return func() tea.Msg { return updateHistoryFilter(newFilter) } case ModalTypeGenerateReport: if err := m.form.Error(); err != nil { return reOpenModal() } return generateReport(m, am) } return nil } func (m *ModalBoxModel) validateAndParseEntryForm(am AppModel) (queries.EditTimeEntryParams, bool) { var params queries.EditTimeEntryParams var hasErrors bool // Set the entry ID params.EntryID = m.editedID entry, _ := am.queries.GetTimeEntryById(context.Background(), params.EntryID) startTimeStr := m.form.fields[0].Value() startTime, err := time.ParseInLocation(time.DateTime, startTimeStr, time.Local) if err != nil { m.form.fields[0].Err = fmt.Errorf("invalid start time format") hasErrors = true } else { params.StartTime = startTime.UTC().Format(time.DateTime) } endTimeStr := m.form.fields[1].Value() if endTimeStr != "" { endTime, err := time.ParseInLocation(time.DateTime, endTimeStr, time.Local) if err != nil { m.form.fields[1].Err = fmt.Errorf("invalid end time format") hasErrors = true } else { params.EndTime = endTime.UTC().Format(time.DateTime) } } else if entry.EndTime.Valid { m.form.fields[1].Err = fmt.Errorf("can not re-open an entry") hasErrors = true } clientStr := m.form.fields[2].Value() if clientStr == "" { m.form.fields[2].Err = fmt.Errorf("client is required") hasErrors = true } else { client, err := actions.New(am.queries).FindClient(context.Background(), clientStr) if err != nil { m.form.fields[2].Err = fmt.Errorf("client not found: %s", clientStr) hasErrors = true } else { params.ClientID = client.ID } } projectStr := m.form.fields[3].Value() if projectStr != "" { project, err := actions.New(am.queries).FindProject(context.Background(), projectStr) if err != nil { m.form.fields[3].Err = fmt.Errorf("project not found: %s", projectStr) hasErrors = true } else { params.ProjectID = sql.NullInt64{Int64: project.ID, Valid: true} } } descriptionStr := m.form.fields[4].Value() if descriptionStr != "" { params.Description = sql.NullString{String: descriptionStr, Valid: true} } rateStr := m.form.fields[5].Value() if rateStr != "" { rate, err := strconv.ParseFloat(rateStr, 64) if err != nil { m.form.fields[5].Err = fmt.Errorf("invalid hourly rate") hasErrors = true } else { params.HourlyRate = sql.NullInt64{Int64: int64(rate * 100), Valid: true} } } return params, hasErrors }