package tui import ( "context" "database/sql" "fmt" "strconv" "time" "punchcard/internal/actions" "punchcard/internal/queries" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss/v2" ) type ModalType int const ( ModalTypeSearch ModalType = iota ModalTypeClient ModalTypeProject ModalTypeDeleteConfirmation ModalTypeEntry ) func (mt ModalType) newForm() Form { return NewEntryEditorForm() } 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 ModalTypeSearch: return modalTitleStyle.Render("SEARCH BOX") case ModalTypeEntry: return m.RenderEntryEditor() case ModalTypeDeleteConfirmation: return m.RenderDeleteConfirmation() default: // REMOVE ME return "DEFAULT CONTENT" } } func (m ModalBoxModel) RenderEntryEditor() string { return fmt.Sprintf("%s\n\n%s", modalTitleStyle.Render("✏️ Edit Time Entry"), m.form.View()) } func (m ModalBoxModel) RenderDeleteConfirmation() string { title := modalTitleStyle.Render("🗑️ Delete Time Entry") content := "Are you sure you want to delete this time entry?\nThis action cannot be undone.\n\n[Enter] Delete [Esc] Cancel" return fmt.Sprintf("%s\n\n%s", title, content) } func (m *ModalBoxModel) activate(t ModalType) { m.Active = true m.Type = t m.form = t.newForm() } func (m *ModalBoxModel) deactivate() { m.Active = false } var modalTitleStyle = lipgloss.NewStyle(). Bold(true) 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() } // Extract and validate form data params, hasErrors := m.validateAndParseForm(am) if hasErrors { return reOpenModal() } // Perform the edit err := am.queries.EditTimeEntry(context.Background(), params) if err != nil { // TODO: Handle edit error (could set form error) return reOpenModal() } // Success - close modal and refresh data return func() tea.Msg { return am.refreshCmd() } } return nil } func (m *ModalBoxModel) validateAndParseForm(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.Parse(time.DateTime, startTimeStr) if err != nil { m.form.fields[0].Err = fmt.Errorf("invalid start time format") hasErrors = true } else { params.StartTime = startTime } endTimeStr := m.form.fields[1].Value() if endTimeStr != "" { endTime, err := time.Parse(time.DateTime, endTimeStr) if err != nil { m.form.fields[1].Err = fmt.Errorf("invalid end time format") hasErrors = true } else { params.EndTime = sql.NullTime{Time: endTime, Valid: true} } } 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 }