diff options
Diffstat (limited to 'internal/tui')
-rw-r--r-- | internal/tui/app.go | 44 | ||||
-rw-r--r-- | internal/tui/commands.go | 59 | ||||
-rw-r--r-- | internal/tui/form.go | 77 | ||||
-rw-r--r-- | internal/tui/keys.go | 5 | ||||
-rw-r--r-- | internal/tui/modal.go | 13 |
5 files changed, 175 insertions, 23 deletions
diff --git a/internal/tui/app.go b/internal/tui/app.go index 433a5ad..4b31c38 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -205,6 +205,11 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.historyBox.filter = HistoryFilter(msg) cmds = append(cmds, m.refreshCmd) + case openReportModal: + if m.selectedBox == HistoryBox && m.historyBox.viewLevel == HistoryLevelSummary { + m.openReportModal() + } + } return m, tea.Batch(cmds...) @@ -252,10 +257,10 @@ func (m *AppModel) openHistoryFilterModal() { var dateRangeStr string if filter.EndDate == nil { // Use "since <date>" format for open-ended ranges - dateRangeStr = fmt.Sprintf("since %s", filter.StartDate.Format("2006-01-02")) + dateRangeStr = fmt.Sprintf("since %s", filter.StartDate.Format(time.DateOnly)) } else { // Use "YYYY-MM-DD to YYYY-MM-DD" format for bounded ranges - dateRangeStr = fmt.Sprintf("%s to %s", filter.StartDate.Format("2006-01-02"), filter.EndDate.Format("2006-01-02")) + dateRangeStr = fmt.Sprintf("%s to %s", filter.StartDate.Format(time.DateOnly), filter.EndDate.Format(time.DateOnly)) } m.modalBox.form.fields[0].SetValue(dateRangeStr) @@ -282,6 +287,41 @@ func (m *AppModel) openHistoryFilterModal() { } } +func (m *AppModel) openReportModal() { + m.modalBox.activate(ModalTypeGenerateReport, 0, *m) + m.modalBox.form.fields[0].Focus() + + filter := m.historyBox.filter + + var dateRangeStr string + if filter.EndDate == nil { + dateRangeStr = fmt.Sprintf("since %s", filter.StartDate.Format(time.DateOnly)) + } else { + dateRangeStr = fmt.Sprintf("%s to %s", filter.StartDate.Format(time.DateOnly), filter.EndDate.Format(time.DateOnly)) + } + m.modalBox.form.fields[1].SetValue(dateRangeStr) + + if filter.ClientID != nil { + for _, client := range m.projectsBox.clients { + if client.ID == *filter.ClientID { + m.modalBox.form.fields[2].SetValue(client.Name) + break + } + } + } + + if filter.ProjectID != nil { + for _, clientProjects := range m.projectsBox.projects { + for _, project := range clientProjects { + if project.ID == *filter.ProjectID { + m.modalBox.form.fields[3].SetValue(project.Name) + break + } + } + } + } +} + // View renders the app func (m AppModel) View() string { if m.width == 0 || m.height == 0 { diff --git a/internal/tui/commands.go b/internal/tui/commands.go index aa5cc79..9a836c5 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -2,8 +2,13 @@ package tui import ( "context" + "fmt" + "strings" + "time" "punchcard/internal/actions" + "punchcard/internal/queries" + "punchcard/internal/reports" tea "github.com/charmbracelet/bubbletea" ) @@ -22,6 +27,7 @@ type ( openCreateClientModal struct{} openCreateProjectModal struct{} openHistoryFilterModal struct{} + openReportModal struct{} updateHistoryFilter HistoryFilter ) @@ -113,3 +119,56 @@ func createProjectModal() tea.Cmd { func createHistoryFilterModal() tea.Cmd { return func() tea.Msg { return openHistoryFilterModal{} } } + +func createReportModal() tea.Cmd { + return func() tea.Msg { return openReportModal{} } +} + +func generateReport(m *ModalBoxModel, am AppModel) tea.Cmd { + return func() tea.Msg { + form := &m.form + + dateRange, err := reports.ParseDateRange(form.fields[1].Value()) + if err != nil { + form.fields[1].Err = fmt.Errorf("invalid date range: %v", err) + return reOpenModal() + } + + var tz *time.Location + tzstr := form.fields[5].Value() + if tzstr == "" { + tz = time.Local + } else { + zone, err := time.LoadLocation(tzstr) + if err != nil { + form.fields[5].Err = err + return reOpenModal() + } + tz = zone + } + + var genFunc func(context.Context, *queries.Queries, reports.ReportParams) (*reports.ReportResult, error) + switch strings.ToLower(form.fields[0].Value()) { + case "invoice": + genFunc = reports.GenerateInvoice + case "timesheet": + genFunc = reports.GenerateTimesheet + case "unified": + genFunc = reports.GenerateUnifiedReport + } + + params := reports.ReportParams{ + ClientName: form.fields[2].Value(), + ProjectName: form.fields[3].Value(), + DateRange: dateRange, + OutputPath: form.fields[4].Value(), + Timezone: tz, + } + if _, err := genFunc(context.Background(), am.queries, params); err != nil { + form.err = err + return reOpenModal() + } + + return nil + } +} diff --git a/internal/tui/form.go b/internal/tui/form.go index 3a1e5f6..d70fb8b 100644 --- a/internal/tui/form.go +++ b/internal/tui/form.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "strconv" + "strings" "time" "punchcard/internal/reports" @@ -19,6 +20,7 @@ const ( noSuggestions suggestionType = iota suggestClients suggestProjects + suggestReportType ) type FormField struct { @@ -100,6 +102,22 @@ func newDateRangeField(label string) FormField { return f } +func newReportTypeField(label string) FormField { + f := FormField{ + Model: textinput.New(), + label: label, + suggestions: suggestReportType, + } + f.Validate = func(s string) error { + switch strings.ToLower(s) { + case "invoice", "timesheet", "unified": + return nil + } + return errors.New("pick one of invoice, timesheet, or unified") + } + return f +} + type Form struct { fields []FormField selIdx int @@ -113,8 +131,8 @@ func NewForm(fields []FormField) Form { return Form{fields: fields} } -func (ff Form) Error() error { - for _, field := range ff.fields { +func (f Form) Error() error { + for _, field := range f.fields { if field.Err != nil { return field.Err } @@ -169,44 +187,58 @@ func NewHistoryFilterForm() Form { return form } -func (ff Form) Update(msg tea.Msg) (Form, tea.Cmd) { +func NewGenerateReportForm() Form { + form := NewForm([]FormField{ + newReportTypeField("Report Type"), + newDateRangeField("Date Range"), + {Model: textinput.New(), label: "Client", suggestions: suggestClients}, + {Model: textinput.New(), label: "Project (optional)", suggestions: suggestProjects}, + {Model: textinput.New(), label: "Output Path (optional)"}, + {Model: textinput.New(), label: "Timezone (optional)"}, + }) + form.SelectedStyle = &modalFocusedInputStyle + form.UnselectedStyle = &modalBlurredInputStyle + return form +} + +func (f Form) Update(msg tea.Msg) (Form, tea.Cmd) { if msg, ok := msg.(tea.KeyMsg); ok { switch msg.String() { case "tab": - ff.fields[ff.selIdx].Blur() - ff.selIdx = (ff.selIdx + 1) % len(ff.fields) - return ff, ff.fields[ff.selIdx].Focus() + f.fields[f.selIdx].Blur() + f.selIdx = (f.selIdx + 1) % len(f.fields) + return f, f.fields[f.selIdx].Focus() case "shift+tab": - ff.fields[ff.selIdx].Blur() - ff.selIdx-- - if ff.selIdx < 0 { - ff.selIdx += len(ff.fields) + f.fields[f.selIdx].Blur() + f.selIdx-- + if f.selIdx < 0 { + f.selIdx += len(f.fields) } - return ff, ff.fields[ff.selIdx].Focus() + return f, f.fields[f.selIdx].Focus() } } - field, cmd := ff.fields[ff.selIdx].Update(msg) - ff.fields[ff.selIdx] = field - return ff, cmd + field, cmd := f.fields[f.selIdx].Update(msg) + f.fields[f.selIdx] = field + return f, cmd } -func (ff Form) View() string { +func (f Form) View() string { content := "" - if ff.err != nil { - content += errorStyle.Render(ff.err.Error()) + "\n\n" + if f.err != nil { + content += errorStyle.Render(f.err.Error()) + "\n\n" } - for i, field := range ff.fields { + for i, field := range f.fields { if i > 0 { content += "\n\n" } content += field.label + ":\n" - style := ff.UnselectedStyle - if i == ff.selIdx { - style = ff.SelectedStyle + style := f.UnselectedStyle + if i == f.selIdx { + style = f.SelectedStyle } if style != nil { content += style.Render(field.View()) @@ -241,6 +273,9 @@ func (f *Form) SetSuggestions(m AppModel) { } ff.SetSuggestions(projNames) ff.ShowSuggestions = true + case suggestReportType: + ff.SetSuggestions([]string{"Invoice", "Timesheet", "Unified"}) + ff.ShowSuggestions = true } } } diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 3a7242d..69ce223 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -174,6 +174,11 @@ var Bindings map[KeyBindingScope]map[string]KeyBinding = map[KeyBindingScope]map Description: func(AppModel) string { return "Bottom" }, Result: func(*AppModel) tea.Cmd { return changeSelectionToBottom() }, }, + "R": KeyBinding{ + Key: "R", + Description: func(AppModel) string { return "Report" }, + Result: func(*AppModel) tea.Cmd { return createReportModal() }, + }, }, ScopeHistoryBoxDetails: { "j": KeyBinding{ diff --git a/internal/tui/modal.go b/internal/tui/modal.go index badc658..7660243 100644 --- a/internal/tui/modal.go +++ b/internal/tui/modal.go @@ -23,6 +23,7 @@ const ( ModalTypeDeleteConfirmation ModalTypeEntry ModalTypeHistoryFilter + ModalTypeGenerateReport ) func (mt ModalType) newForm() Form { @@ -35,6 +36,8 @@ func (mt ModalType) newForm() Form { return NewProjectForm() case ModalTypeHistoryFilter: return NewHistoryFilterForm() + case ModalTypeGenerateReport: + return NewGenerateReportForm() } return Form{} @@ -91,6 +94,8 @@ func (m ModalBoxModel) Render() string { return m.RenderFormModal("📂 Project") case ModalTypeHistoryFilter: return m.RenderFormModal("🔍 History Filter") + case ModalTypeGenerateReport: + return m.RenderFormModal("📄 Generate Report") default: // REMOVE ME return "DEFAULT CONTENT" } @@ -274,6 +279,14 @@ func (m *ModalBoxModel) SubmitForm(am AppModel) tea.Cmd { // 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 |