summaryrefslogtreecommitdiff
path: root/internal/tui
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tui')
-rw-r--r--internal/tui/app.go44
-rw-r--r--internal/tui/commands.go59
-rw-r--r--internal/tui/form.go77
-rw-r--r--internal/tui/keys.go5
-rw-r--r--internal/tui/modal.go13
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