diff --git a/internal/handler/space.go b/internal/handler/space.go index f6b47f5..91ddfd1 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -333,6 +333,108 @@ func (h *spaceHandler) SpaceAccountTransactionsPage(w http.ResponseWriter, r *ht })) } +func (h *spaceHandler) SpaceAccountSettingsPage(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + accountID := r.PathValue("accountID") + + account, err := h.accountService.GetAccount(accountID) + if err != nil { + slog.Error("failed to load account", "error", err, "account_id", accountID) + ui.Render(w, r, pages.NotFound()) + return + } + if account.SpaceID != spaceID { + ui.Render(w, r, pages.NotFound()) + return + } + + ui.Render(w, r, pages.SpaceAccountSettingsPage(pages.SpaceAccountSettingsPageProps{ + SpaceID: spaceID, + AccountID: accountID, + AccountName: account.Name, + UpdateForm: forms.UpdateAccountProps{ + SpaceID: spaceID, + AccountID: accountID, + Name: account.Name, + }, + })) +} + +func (h *spaceHandler) HandleRenameAccount(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + accountID := r.PathValue("accountID") + + account, err := h.accountService.GetAccount(accountID) + if err != nil || account.SpaceID != spaceID { + ui.RenderError(w, r, "Account not found", http.StatusNotFound) + return + } + + nameInput := strings.TrimSpace(r.FormValue("name")) + formProps := forms.UpdateAccountProps{ + SpaceID: spaceID, + AccountID: accountID, + Name: nameInput, + } + + if nameInput == "" { + formProps.NameErr = "Account name is required." + ui.Render(w, r, forms.UpdateAccount(formProps)) + return + } + + if !strings.EqualFold(nameInput, account.Name) { + existing, err := h.accountService.GetAccountsForSpace(spaceID) + if err != nil { + slog.Error("failed to load accounts", "error", err, "space_id", spaceID) + formProps.GeneralErr = "Something went wrong. Please try again." + ui.Render(w, r, forms.UpdateAccount(formProps)) + return + } + for _, a := range existing { + if a.ID == accountID { + continue + } + if strings.EqualFold(strings.TrimSpace(a.Name), nameInput) { + formProps.NameErr = "An account with this name already exists in this space." + ui.Render(w, r, forms.UpdateAccount(formProps)) + return + } + } + } + + if err := h.accountService.RenameAccount(accountID, nameInput); err != nil { + slog.Error("failed to rename account", "error", err, "account_id", accountID) + formProps.GeneralErr = "Something went wrong. Please try again." + ui.Render(w, r, forms.UpdateAccount(formProps)) + return + } + + formProps.SuccessMsg = "Account name updated." + ui.Render(w, r, forms.UpdateAccount(formProps)) +} + +func (h *spaceHandler) HandleDeleteAccount(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + accountID := r.PathValue("accountID") + + account, err := h.accountService.GetAccount(accountID) + if err != nil || account.SpaceID != spaceID { + ui.RenderError(w, r, "Account not found", http.StatusNotFound) + return + } + + if err := h.accountService.DeleteAccount(accountID); err != nil { + slog.Error("failed to delete account", "error", err, "account_id", accountID) + ui.RenderError(w, r, "Failed to delete account", http.StatusInternalServerError) + return + } + + redirectTo := routeurl.URL("page.app.spaces.space.overview", "spaceID", spaceID) + w.Header().Set("HX-Redirect", redirectTo) + w.WriteHeader(http.StatusOK) +} + func (h *spaceHandler) SpaceCreateBillPage(w http.ResponseWriter, r *http.Request) { spaceID := r.PathValue("spaceID") accountID := r.PathValue("accountID") diff --git a/internal/repository/account.go b/internal/repository/account.go index 70354bf..a84b41c 100644 --- a/internal/repository/account.go +++ b/internal/repository/account.go @@ -14,6 +14,7 @@ type AccountRepository interface { Create(account *model.Account) error ByID(id string) (*model.Account, error) BySpaceID(spaceID string) ([]*model.Account, error) + Rename(id, name string) error Delete(id string) error } @@ -52,6 +53,12 @@ func (r *accountRepository) BySpaceID(spaceID string) ([]*model.Account, error) return accounts, nil } +func (r *accountRepository) Rename(id, name string) error { + query := `UPDATE accounts SET name = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2;` + _, err := r.db.Exec(query, name, id) + return err +} + func (r *accountRepository) Delete(id string) error { query := `DELETE FROM accounts WHERE id = $1;` _, err := r.db.Exec(query, id) diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 8f70af4..5dbbf27 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -98,6 +98,9 @@ func SetupRoutes(a *app.App) http.Handler { g.SubGroup("/accounts/{accountID}", func(g *router.Group) { g.Get("/overview", spaceH.SpaceAccountPage).Name("page.app.spaces.space.accounts.account.overview") g.Get("/transactions", spaceH.SpaceAccountTransactionsPage).Name("page.app.spaces.space.accounts.account.transactions") + g.Get("/settings", spaceH.SpaceAccountSettingsPage).Name("page.app.spaces.space.accounts.account.settings") + g.Post("/settings/rename", spaceH.HandleRenameAccount).Name("action.app.spaces.space.accounts.account.settings.rename") + g.Post("/settings/delete", spaceH.HandleDeleteAccount).Name("action.app.spaces.space.accounts.account.settings.delete") g.Get("/bills/create", spaceH.SpaceCreateBillPage).Name("page.app.spaces.space.accounts.account.bills.create") g.Post("/bills/create", spaceH.HandleCreateBill).Name("action.app.spaces.space.accounts.account.bills.create") g.Get("/deposits/create", spaceH.SpaceCreateDepositPage).Name("page.app.spaces.space.accounts.account.deposits.create") diff --git a/internal/service/account.go b/internal/service/account.go index 0331bf1..39e88a5 100644 --- a/internal/service/account.go +++ b/internal/service/account.go @@ -49,6 +49,29 @@ func (s *AccountService) GetAccount(id string) (*model.Account, error) { return account, nil } +func (s *AccountService) RenameAccount(id, name string) error { + if id == "" { + return fmt.Errorf("account id is required") + } + if name == "" { + return fmt.Errorf("account name cannot be empty") + } + if err := s.accountRepo.Rename(id, name); err != nil { + return fmt.Errorf("failed to rename account: %w", err) + } + return nil +} + +func (s *AccountService) DeleteAccount(id string) error { + if id == "" { + return fmt.Errorf("account id is required") + } + if err := s.accountRepo.Delete(id); err != nil { + return fmt.Errorf("failed to delete account: %w", err) + } + return nil +} + func (s *AccountService) GetAccountsForSpace(spaceID string) ([]*model.Account, error) { accounts, err := s.accountRepo.BySpaceID(spaceID) if err != nil { diff --git a/internal/ui/forms/update_account.templ b/internal/ui/forms/update_account.templ new file mode 100644 index 0000000..889147c --- /dev/null +++ b/internal/ui/forms/update_account.templ @@ -0,0 +1,76 @@ +package forms + +import "git.juancwu.dev/juancwu/budgit/internal/routeurl" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/form" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/input" + +type UpdateAccountProps struct { + SpaceID string + AccountID string + + Name string + + NameErr string + GeneralErr string + SuccessMsg string +} + +templ UpdateAccount(props UpdateAccountProps) { +
+ @card.Card(card.Props{Class: "rounded-sm"}) { + @card.Header() { + @card.Title() { + Account Details + } + @card.Description() { + Update the account name. + } + } + @card.Content(card.ContentProps{Class: "space-y-4"}) { + if props.GeneralErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.GeneralErr } + } + } + if props.SuccessMsg != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantInfo}) { + { props.SuccessMsg } + } + } + @form.Item() { + @form.Label(form.LabelProps{For: "name"}) { + Account Name + } + @input.Input(input.Props{ + ID: "name", + Name: "name", + Type: input.TypeText, + Class: "rounded-sm", + Value: props.Name, + HasError: props.NameErr != "", + Required: true, + Attributes: templ.Attributes{ + "autocomplete": "off", + }, + }) + if props.NameErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.NameErr } + } + } + } + } + @card.Footer(card.FooterProps{Class: "flex justify-end gap-2"}) { + @button.Button(button.Props{Type: button.TypeSubmit}) { + Save changes + } + } + } +
+} diff --git a/internal/ui/pages/space_account.templ b/internal/ui/pages/space_account.templ index 91e3ffb..d66b5d9 100644 --- a/internal/ui/pages/space_account.templ +++ b/internal/ui/pages/space_account.templ @@ -69,6 +69,7 @@ templ SpaceAccountPage(props SpaceAccountPageProps) { @button.Button(button.Props{ Class: "w-full flex gap-2 md:gap-4 items-center", Variant: button.VariantLink, + Href: routeurl.URL("page.app.spaces.space.accounts.account.settings", "spaceID", props.SpaceID, "accountID", props.AccountID), }) { Account Settings @icon.Settings() diff --git a/internal/ui/pages/space_account_settings.templ b/internal/ui/pages/space_account_settings.templ index 50f5fd3..06e0d27 100644 --- a/internal/ui/pages/space_account_settings.templ +++ b/internal/ui/pages/space_account_settings.templ @@ -1,4 +1,59 @@ package pages -templ SpaceAccountSettings() { +import "git.juancwu.dev/juancwu/budgit/internal/routeurl" +import "git.juancwu.dev/juancwu/budgit/internal/ui/forms" +import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon" + +type SpaceAccountSettingsPageProps struct { + SpaceID string + AccountID string + AccountName string + UpdateForm forms.UpdateAccountProps +} + +templ SpaceAccountSettingsPage(props SpaceAccountSettingsPageProps) { + @layouts.App( + "Account Settings", + spaceOverviewSidebarContent(), + spaceSpecificSidebarContent(props.SpaceID), + spaceAccountSidebarContent(props.SpaceID, props.AccountID, props.AccountName), + ) { +
+
+

Account Settings

+

+ Manage settings for { props.AccountName }. +

+
+ @forms.UpdateAccount(props.UpdateForm) + @card.Card(card.Props{Class: "rounded-sm border-destructive"}) { + @card.Header() { + @card.Title(card.TitleProps{Class: "text-destructive"}) { + Danger Zone + } + @card.Description() { + Deleting an account permanently removes it and all associated transactions. This cannot be undone. + } + } + @card.Footer(card.FooterProps{Class: "flex justify-end pt-8"}) { +
+ @button.Button(button.Props{ + Type: button.TypeSubmit, + Variant: button.VariantDestructive, + Class: "flex gap-2 items-center", + }) { + @icon.Trash2() + Delete Account + } +
+ } + } +
+ } } diff --git a/internal/ui/pages/space_overview.templ b/internal/ui/pages/space_overview.templ index 5c3e761..f83a224 100644 --- a/internal/ui/pages/space_overview.templ +++ b/internal/ui/pages/space_overview.templ @@ -114,6 +114,16 @@ templ spaceAccountSidebarContent(spaceID, accountID, accountName string) { Deposit Funds } } + @sidebar.MenuItem() { + @sidebar.MenuButton(sidebar.MenuButtonProps{ + Href: routeurl.URL("page.app.spaces.space.accounts.account.settings", "spaceID", spaceID, "accountID", accountID), + IsActive: ctxkeys.URLPath(ctx) == routeurl.URL("page.app.spaces.space.accounts.account.settings", "spaceID", spaceID, "accountID", accountID), + Tooltip: "Account Settings", + }) { + @icon.Settings() + Settings + } + } } } }