summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorT <t@tjp.lol>2025-08-13 23:09:07 -0600
committerT <t@tjp.lol>2025-08-13 23:09:38 -0600
commit99b4888709b8b9dc435bff476cb73210e91017cc (patch)
tree3045f926be8832d2f1865ffad53319c2b5b39a1a
parent5c076e605185a09b1e570f9aa3c5ddb784ace0f3 (diff)
edit clients and projects
-rw-r--r--internal/actions/actions.go2
-rw-r--r--internal/actions/clients.go31
-rw-r--r--internal/actions/projects.go23
-rw-r--r--internal/tui/app.go18
-rw-r--r--internal/tui/commands.go37
-rw-r--r--internal/tui/form.go12
-rw-r--r--internal/tui/keys.go14
-rw-r--r--internal/tui/modal.go88
8 files changed, 179 insertions, 46 deletions
diff --git a/internal/actions/actions.go b/internal/actions/actions.go
index 61d007b..7f707d3 100644
--- a/internal/actions/actions.go
+++ b/internal/actions/actions.go
@@ -16,10 +16,12 @@ type Actions interface {
// Client operations
CreateClient(ctx context.Context, name, email string, billableRate *float64) (*queries.Client, error)
+ EditClient(ctx context.Context, id int64, name, email string, billableRate *float64) (*queries.Client, error)
FindClient(ctx context.Context, nameOrID string) (*queries.Client, error)
// Project operations
CreateProject(ctx context.Context, name, client string, billableRate *float64) (*queries.Project, error)
+ EditProject(ctx context.Context, id int64, name string, billableRate *float64) (*queries.Project, error)
FindProject(ctx context.Context, nameOrID string) (*queries.Project, error)
}
diff --git a/internal/actions/clients.go b/internal/actions/clients.go
index 1a99d59..10e8e7d 100644
--- a/internal/actions/clients.go
+++ b/internal/actions/clients.go
@@ -22,9 +22,8 @@ func (a *actions) CreateClient(ctx context.Context, name, email string, billable
}
var billableRateParam sql.NullInt64
- if billableRate != nil && *billableRate > 0 {
- rate := int64(*billableRate * 100) // Convert dollars to cents
- billableRateParam = sql.NullInt64{Int64: rate, Valid: true}
+ if billableRate != nil {
+ billableRateParam = sql.NullInt64{Int64: int64(*billableRate * 100), Valid: true}
}
client, err := a.queries.CreateClient(ctx, queries.CreateClientParams{
@@ -39,6 +38,32 @@ func (a *actions) CreateClient(ctx context.Context, name, email string, billable
return &client, nil
}
+func (a *actions) EditClient(ctx context.Context, id int64, name, email string, billableRate *float64) (*queries.Client, error) {
+ finalName, finalEmail := parseNameAndEmail(name, email)
+
+ var emailParam sql.NullString
+ if finalEmail != "" {
+ emailParam = sql.NullString{String: finalEmail, Valid: true}
+ }
+
+ var rateParam sql.NullInt64
+ if billableRate != nil {
+ rateParam = sql.NullInt64{Int64: int64(*billableRate * 100), Valid: true}
+ }
+
+ client, err := a.queries.UpdateClient(ctx, queries.UpdateClientParams{
+ Name: finalName,
+ Email: emailParam,
+ BillableRate: rateParam,
+ ID: id,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to update client: %w", err)
+ }
+
+ return &client, nil
+}
+
// FindClient finds a client by name or ID
func (a *actions) FindClient(ctx context.Context, nameOrID string) (*queries.Client, error) {
// Parse as ID if possible, otherwise use 0
diff --git a/internal/actions/projects.go b/internal/actions/projects.go
index 4cb4638..d36780b 100644
--- a/internal/actions/projects.go
+++ b/internal/actions/projects.go
@@ -18,9 +18,8 @@ func (a *actions) CreateProject(ctx context.Context, name, client string, billab
}
var billableRateParam sql.NullInt64
- if billableRate != nil && *billableRate > 0 {
- rate := int64(*billableRate * 100) // Convert dollars to cents
- billableRateParam = sql.NullInt64{Int64: rate, Valid: true}
+ if billableRate != nil {
+ billableRateParam = sql.NullInt64{Int64: int64(*billableRate * 100), Valid: true}
}
project, err := a.queries.CreateProject(ctx, queries.CreateProjectParams{
@@ -35,6 +34,24 @@ func (a *actions) CreateProject(ctx context.Context, name, client string, billab
return &project, nil
}
+func (a *actions) EditProject(ctx context.Context, id int64, name string, billableRate *float64) (*queries.Project, error) {
+ var rateParam sql.NullInt64
+ if billableRate != nil {
+ rateParam = sql.NullInt64{Int64: int64(*billableRate * 100), Valid: true}
+ }
+
+ project, err := a.queries.UpdateProject(ctx, queries.UpdateProjectParams{
+ Name: name,
+ BillableRate: rateParam,
+ ID: id,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to update project: %w", err)
+ }
+
+ return &project, nil
+}
+
// FindProject finds a project by name or ID
func (a *actions) FindProject(ctx context.Context, nameOrID string) (*queries.Project, error) {
// Parse as ID if possible, otherwise use 0
diff --git a/internal/tui/app.go b/internal/tui/app.go
index 0b67933..d21df5c 100644
--- a/internal/tui/app.go
+++ b/internal/tui/app.go
@@ -188,6 +188,11 @@ func (m AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case openContractorEditor:
m.openContractorEditor()
+ case openClientOrProjectEditor:
+ if m.selectedBox == ProjectsBox {
+ m.openClientOrProjectEditor()
+ }
+
case openModalUnchanged:
m.modalBox.Active = true
@@ -263,6 +268,19 @@ func (m *AppModel) openContractorEditor() {
m.modalBox.form.fields[0].Focus()
}
+func (m *AppModel) openClientOrProjectEditor() {
+ client := m.projectsBox.clients[m.projectsBox.selectedClient]
+ if m.projectsBox.selectedProject == nil {
+ m.modalBox.activate(ModalTypeClient, client.ID, *m)
+ m.modalBox.populateClientFields(client)
+ } else {
+ project := m.projectsBox.projects[client.ID][*m.projectsBox.selectedProject]
+ m.modalBox.activate(ModalTypeProjectEdit, project.ID, *m)
+ m.modalBox.populateProjectFields(project)
+ }
+ m.modalBox.form.fields[0].Focus()
+}
+
func (m *AppModel) openHistoryFilterModal() {
m.modalBox.activate(ModalTypeHistoryFilter, 0, *m)
m.modalBox.form.fields[0].Focus()
diff --git a/internal/tui/commands.go b/internal/tui/commands.go
index 363b570..66a0c4d 100644
--- a/internal/tui/commands.go
+++ b/internal/tui/commands.go
@@ -14,22 +14,23 @@ import (
)
type (
- navigationMsg struct{ Forward bool }
- selectionMsg struct{ Forward bool }
- selectionToEnd struct{ Top bool }
- drillDownMsg struct{}
- drillUpMsg struct{}
- modalClosed struct{}
- openTimeEntryEditor struct{}
- openContractorEditor struct{}
- openModalUnchanged struct{}
- openDeleteConfirmation struct{}
- recheckBounds struct{}
- openCreateClientModal struct{}
- openCreateProjectModal struct{}
- openHistoryFilterModal struct{}
- openReportModal struct{}
- updateHistoryFilter HistoryFilter
+ navigationMsg struct{ Forward bool }
+ selectionMsg struct{ Forward bool }
+ selectionToEnd struct{ Top bool }
+ drillDownMsg struct{}
+ drillUpMsg struct{}
+ modalClosed struct{}
+ openTimeEntryEditor struct{}
+ openContractorEditor struct{}
+ openClientOrProjectEditor struct{}
+ openModalUnchanged struct{}
+ openDeleteConfirmation struct{}
+ recheckBounds struct{}
+ openCreateClientModal struct{}
+ openCreateProjectModal struct{}
+ openHistoryFilterModal struct{}
+ openReportModal struct{}
+ updateHistoryFilter HistoryFilter
)
func navigate(forward bool) tea.Cmd {
@@ -105,6 +106,10 @@ func editContractor() tea.Cmd {
return func() tea.Msg { return openContractorEditor{} }
}
+func editClientOrProject() tea.Cmd {
+ return func() tea.Msg { return openClientOrProjectEditor{} }
+}
+
func reOpenModal() tea.Cmd {
return func() tea.Msg { return openModalUnchanged{} }
}
diff --git a/internal/tui/form.go b/internal/tui/form.go
index d0a2025..b0ac18d 100644
--- a/internal/tui/form.go
+++ b/internal/tui/form.go
@@ -165,7 +165,7 @@ func NewClientForm() Form {
return form
}
-func NewProjectForm() Form {
+func NewProjectCreateForm() Form {
form := NewForm([]FormField{
{Model: textinput.New(), label: "Name"},
{Model: textinput.New(), label: "Client", suggestions: suggestClients},
@@ -176,6 +176,16 @@ func NewProjectForm() Form {
return form
}
+func NewProjectEditForm() Form {
+ form := NewForm([]FormField{
+ {Model: textinput.New(), label: "Name"},
+ newOptionalFloatField("Hourly Rate"),
+ })
+ form.SelectedStyle = &modalFocusedInputStyle
+ form.UnselectedStyle = &modalBlurredInputStyle
+ return form
+}
+
func NewHistoryFilterForm() Form {
form := NewForm([]FormField{
newDateRangeField("Date Range"),
diff --git a/internal/tui/keys.go b/internal/tui/keys.go
index 52e15f6..5cbefa4 100644
--- a/internal/tui/keys.go
+++ b/internal/tui/keys.go
@@ -58,9 +58,9 @@ var Bindings map[KeyBindingScope]map[string]KeyBinding = map[KeyBindingScope]map
Result: func(am *AppModel) tea.Cmd { return am.refreshCmd },
},
"c": KeyBinding{
- Key: "c",
+ Key: "c",
Description: func(am AppModel) string { return "Edit Contractor" },
- Result: func(am *AppModel) tea.Cmd { return editContractor() },
+ Result: func(am *AppModel) tea.Cmd { return editContractor() },
},
"q": KeyBinding{
Key: "q",
@@ -108,6 +108,16 @@ var Bindings map[KeyBindingScope]map[string]KeyBinding = map[KeyBindingScope]map
Description: func(AppModel) string { return "Up" },
Result: func(*AppModel) tea.Cmd { return changeSelection(false) },
},
+ "e": KeyBinding{
+ Key: "e",
+ Description: func(m AppModel) string {
+ if m.projectsBox.selectedProject != nil {
+ return "Edit Project"
+ }
+ return "Edit Client"
+ },
+ Result: func(*AppModel) tea.Cmd { return editClientOrProject() },
+ },
"down": KeyBinding{
Key: "down",
Description: func(AppModel) string { return "Down" },
diff --git a/internal/tui/modal.go b/internal/tui/modal.go
index 51ac384..248654b 100644
--- a/internal/tui/modal.go
+++ b/internal/tui/modal.go
@@ -19,7 +19,8 @@ type ModalType int
const (
ModalTypeClient ModalType = iota
- ModalTypeProject
+ ModalTypeProjectCreate
+ ModalTypeProjectEdit
ModalTypeDeleteConfirmation
ModalTypeEntry
ModalTypeHistoryFilter
@@ -33,8 +34,10 @@ func (mt ModalType) newForm() Form {
return NewEntryEditorForm()
case ModalTypeClient:
return NewClientForm()
- case ModalTypeProject:
- return NewProjectForm()
+ case ModalTypeProjectCreate:
+ return NewProjectCreateForm()
+ case ModalTypeProjectEdit:
+ return NewProjectEditForm()
case ModalTypeHistoryFilter:
return NewHistoryFilterForm()
case ModalTypeGenerateReport:
@@ -93,7 +96,7 @@ func (m ModalBoxModel) Render() string {
return m.RenderDeleteConfirmation()
case ModalTypeClient:
return m.RenderFormModal("👤 Client")
- case ModalTypeProject:
+ case ModalTypeProjectCreate, ModalTypeProjectEdit:
return m.RenderFormModal("📂 Project")
case ModalTypeHistoryFilter:
return m.RenderFormModal("🔍 History Filter")
@@ -126,7 +129,7 @@ func (m ModalBoxModel) RenderDeleteConfirmation() string {
}
func (m *ModalBoxModel) activateCreateProjectModal(am AppModel) {
- m.activate(ModalTypeProject, 0, am)
+ m.activate(ModalTypeProjectCreate, 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)
@@ -152,6 +155,23 @@ func (m *ModalBoxModel) populateContractorFields(contractor ContractorInfo) {
m.form.fields[2].SetValue(contractor.email)
}
+func (m *ModalBoxModel) populateClientFields(client queries.Client) {
+ m.form.fields[0].SetValue(client.Name)
+ if client.Email.Valid {
+ m.form.fields[1].SetValue(client.Email.String)
+ }
+ if client.BillableRate.Valid {
+ m.form.fields[2].SetValue(fmt.Sprintf("%.2f", float64(client.BillableRate.Int64)/100))
+ }
+}
+
+func (m *ModalBoxModel) populateProjectFields(project queries.Project) {
+ m.form.fields[0].SetValue(project.Name)
+ if project.BillableRate.Valid {
+ m.form.fields[2].SetValue(fmt.Sprintf("%.2f", float64(project.BillableRate.Int64)/100))
+ }
+}
+
var (
boldStyle = lipgloss.NewStyle().Bold(true)
modalTitleStyle = boldStyle
@@ -182,8 +202,8 @@ func (m *ModalBoxModel) SubmitForm(am AppModel) tea.Cmd {
return reOpenModal()
}
- msg := am.refreshCmd()
- return func() tea.Msg { return msg }
+ return am.refreshCmd
+
case ModalTypeClient:
if err := m.form.Error(); err != nil {
return reOpenModal()
@@ -196,10 +216,42 @@ func (m *ModalBoxModel) SubmitForm(am AppModel) tea.Cmd {
}
if m.editedID != 0 {
- panic("editing a client not yet implemented")
+ if _, err := actions.New(am.queries).EditClient(
+ context.Background(),
+ m.editedID,
+ m.form.fields[0].Value(),
+ m.form.fields[1].Value(),
+ rate,
+ ); err != nil {
+ m.form.err = err
+ return reOpenModal()
+ }
+ } else {
+ 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()
+ }
}
- if _, err := actions.New(am.queries).CreateClient(
+ return am.refreshCmd
+
+ case ModalTypeProjectCreate:
+ 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 _, err := actions.New(am.queries).CreateProject(
context.Background(),
m.form.fields[0].Value(),
m.form.fields[1].Value(),
@@ -209,36 +261,30 @@ func (m *ModalBoxModel) SubmitForm(am AppModel) tea.Cmd {
return reOpenModal()
}
- msg := am.refreshCmd()
- return func() tea.Msg { return msg }
+ return am.refreshCmd
- case ModalTypeProject:
+ case ModalTypeProjectEdit:
if err := m.form.Error(); err != nil {
return reOpenModal()
}
var rate *float64
- if value := m.form.fields[2].Value(); value != "" {
+ if value := m.form.fields[1].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(
+ if _, err := actions.New(am.queries).EditProject(
context.Background(),
+ m.editedID,
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 }
+ return am.refreshCmd
case ModalTypeHistoryFilter:
if err := m.form.Error(); err != nil {