diff --git a/internal/handler/space.go b/internal/handler/space.go index 0373a28..7737ce0 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -972,6 +972,287 @@ func (h *spaceHandler) HandleCreateDeposit(w http.ResponseWriter, r *http.Reques w.WriteHeader(http.StatusOK) } +func (h *spaceHandler) SpaceTransactionPage(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + accountID := r.PathValue("accountID") + transactionID := r.PathValue("transactionID") + + account, err := h.accountService.GetAccount(accountID) + if err != nil || account.SpaceID != spaceID { + ui.Render(w, r, pages.NotFound()) + return + } + + txn, err := h.transactionService.GetTransaction(transactionID) + if err != nil || txn.AccountID != accountID { + ui.Render(w, r, pages.NotFound()) + return + } + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + slog.Error("failed to load space", "error", err, "space_id", spaceID) + ui.RenderError(w, r, "Failed to load page", http.StatusInternalServerError) + return + } + + categoryName := "" + if txn.Type == model.TransactionTypeWithdrawal { + categoryID, err := h.transactionService.GetTransactionCategoryID(transactionID) + if err != nil { + slog.Error("failed to load transaction category", "error", err, "transaction_id", transactionID) + } else if categoryID != "" { + categories, err := h.transactionService.ListCategories() + if err != nil { + slog.Error("failed to load categories", "error", err) + } else { + for _, c := range categories { + if c.ID == categoryID { + categoryName = c.Name + break + } + } + } + } + } + + ui.Render(w, r, pages.SpaceTransactionPage(pages.SpaceTransactionPageProps{ + SpaceID: spaceID, + SpaceName: space.Name, + AccountID: accountID, + AccountName: account.Name, + Transaction: txn, + CategoryName: categoryName, + })) +} + +func (h *spaceHandler) SpaceEditTransactionPage(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + accountID := r.PathValue("accountID") + transactionID := r.PathValue("transactionID") + + account, err := h.accountService.GetAccount(accountID) + if err != nil || account.SpaceID != spaceID { + ui.Render(w, r, pages.NotFound()) + return + } + + txn, err := h.transactionService.GetTransaction(transactionID) + if err != nil || txn.AccountID != accountID { + ui.Render(w, r, pages.NotFound()) + return + } + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + slog.Error("failed to load space", "error", err, "space_id", spaceID) + ui.RenderError(w, r, "Failed to load page", http.StatusInternalServerError) + return + } + + description := "" + if txn.Description != nil { + description = *txn.Description + } + + pageProps := pages.SpaceEditTransactionPageProps{ + SpaceID: spaceID, + SpaceName: space.Name, + AccountID: accountID, + AccountName: account.Name, + TransactionType: txn.Type, + } + + if txn.Type == model.TransactionTypeDeposit { + pageProps.DepositForm = forms.EditDepositProps{ + SpaceID: spaceID, + AccountID: accountID, + TransactionID: transactionID, + Title: txn.Title, + Amount: txn.Value.StringFixedBank(2), + Date: txn.OccurredAt.Format("2006-01-02"), + Description: description, + } + } else { + categories, err := h.transactionService.ListCategories() + if err != nil { + slog.Error("failed to load categories", "error", err) + ui.RenderError(w, r, "Failed to load categories", http.StatusInternalServerError) + return + } + categoryID, err := h.transactionService.GetTransactionCategoryID(transactionID) + if err != nil { + slog.Error("failed to load transaction category", "error", err, "transaction_id", transactionID) + categoryID = "" + } + pageProps.BillForm = forms.EditBillProps{ + SpaceID: spaceID, + AccountID: accountID, + TransactionID: transactionID, + Categories: categories, + Title: txn.Title, + Amount: txn.Value.StringFixedBank(2), + Date: txn.OccurredAt.Format("2006-01-02"), + Description: description, + CategoryID: categoryID, + } + } + + ui.Render(w, r, pages.SpaceEditTransactionPage(pageProps)) +} + +func (h *spaceHandler) HandleEditTransaction(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + accountID := r.PathValue("accountID") + transactionID := r.PathValue("transactionID") + + account, err := h.accountService.GetAccount(accountID) + if err != nil || account.SpaceID != spaceID { + ui.RenderError(w, r, "Account not found", http.StatusNotFound) + return + } + + txn, err := h.transactionService.GetTransaction(transactionID) + if err != nil || txn.AccountID != accountID { + ui.RenderError(w, r, "Transaction not found", http.StatusNotFound) + return + } + + titleInput := strings.TrimSpace(r.FormValue("title")) + amountInput := strings.TrimSpace(r.FormValue("amount")) + dateInput := strings.TrimSpace(r.FormValue("date")) + descriptionInput := strings.TrimSpace(r.FormValue("description")) + + titleErr, amountErr, dateErr := "", "", "" + hasErr := false + + if titleInput == "" { + titleErr = "Title is required." + hasErr = true + } + + var amount decimal.Decimal + if amountInput == "" { + amountErr = "Amount is required." + hasErr = true + } else { + amt, err := decimal.NewFromString(amountInput) + if err != nil { + amountErr = "Enter a valid amount (e.g. 12.34)." + hasErr = true + } else if !amt.IsPositive() { + amountErr = "Amount must be greater than zero." + hasErr = true + } else if amt.Exponent() < -2 { + amountErr = "Amount can have at most 2 decimal places." + hasErr = true + } else { + amount = amt + } + } + + var occurredAt time.Time + if dateInput == "" { + dateErr = "Date is required." + hasErr = true + } else { + parsed, err := time.Parse("2006-01-02", dateInput) + if err != nil { + dateErr = "Enter a valid date." + hasErr = true + } else { + occurredAt = parsed + } + } + + if txn.Type == model.TransactionTypeDeposit { + formProps := forms.EditDepositProps{ + SpaceID: spaceID, + AccountID: accountID, + TransactionID: transactionID, + Title: titleInput, + Amount: amountInput, + Date: dateInput, + Description: descriptionInput, + TitleErr: titleErr, + AmountErr: amountErr, + DateErr: dateErr, + } + if hasErr { + ui.Render(w, r, forms.EditDeposit(formProps)) + return + } + if _, err := h.transactionService.UpdateDeposit(service.UpdateDepositInput{ + TransactionID: transactionID, + Title: titleInput, + Amount: amount, + OccurredAt: occurredAt, + Description: descriptionInput, + }); err != nil { + slog.Error("failed to update deposit", "error", err, "transaction_id", transactionID) + formProps.GeneralErr = "Something went wrong. Please try again." + ui.Render(w, r, forms.EditDeposit(formProps)) + return + } + redirectTo := routeurl.URL( + "page.app.spaces.space.accounts.account.transactions.transaction", + "spaceID", spaceID, + "accountID", accountID, + "transactionID", transactionID, + ) + w.Header().Set("HX-Redirect", redirectTo) + w.WriteHeader(http.StatusOK) + return + } + + categoryInput := strings.TrimSpace(r.FormValue("category")) + categories, err := h.transactionService.ListCategories() + if err != nil { + slog.Error("failed to load categories", "error", err) + ui.RenderError(w, r, "Failed to load categories", http.StatusInternalServerError) + return + } + formProps := forms.EditBillProps{ + SpaceID: spaceID, + AccountID: accountID, + TransactionID: transactionID, + Categories: categories, + Title: titleInput, + Amount: amountInput, + Date: dateInput, + Description: descriptionInput, + CategoryID: categoryInput, + TitleErr: titleErr, + AmountErr: amountErr, + DateErr: dateErr, + } + if hasErr { + ui.Render(w, r, forms.EditBill(formProps)) + return + } + if _, err := h.transactionService.UpdateBill(service.UpdateBillInput{ + TransactionID: transactionID, + Title: titleInput, + Amount: amount, + OccurredAt: occurredAt, + Description: descriptionInput, + CategoryID: categoryInput, + }); err != nil { + slog.Error("failed to update bill", "error", err, "transaction_id", transactionID) + formProps.GeneralErr = "Something went wrong. Please try again." + ui.Render(w, r, forms.EditBill(formProps)) + return + } + redirectTo := routeurl.URL( + "page.app.spaces.space.accounts.account.transactions.transaction", + "spaceID", spaceID, + "accountID", accountID, + "transactionID", transactionID, + ) + w.Header().Set("HX-Redirect", redirectTo) + w.WriteHeader(http.StatusOK) +} + func (h *spaceHandler) HandleCreateBill(w http.ResponseWriter, r *http.Request) { spaceID := r.PathValue("spaceID") accountID := r.PathValue("accountID") diff --git a/internal/repository/transaction.go b/internal/repository/transaction.go index 6d50f1e..f3aaee6 100644 --- a/internal/repository/transaction.go +++ b/internal/repository/transaction.go @@ -1,6 +1,7 @@ package repository import ( + "database/sql" "time" "git.juancwu.dev/juancwu/budgit/internal/model" @@ -11,6 +12,10 @@ import ( type TransactionRepository interface { CreateBillAtomic(t *model.Transaction, newBalance decimal.Decimal, categoryID *string) error CreateDepositAtomic(t *model.Transaction, newBalance decimal.Decimal) error + UpdateBillAtomic(t *model.Transaction, newBalance decimal.Decimal, categoryID *string) error + UpdateDepositAtomic(t *model.Transaction, newBalance decimal.Decimal) error + GetByID(id string) (*model.Transaction, error) + GetCategoryID(transactionID string) (*string, error) ListByAccount(accountID string, limit, offset int) ([]*model.Transaction, error) CountByAccount(accountID string) (int, error) } @@ -79,6 +84,85 @@ func (r *transactionRepository) CreateDepositAtomic(t *model.Transaction, newBal }) } +func (r *transactionRepository) UpdateBillAtomic(t *model.Transaction, newBalance decimal.Decimal, categoryID *string) error { + return WithTx(r.db, func(tx *sqlx.Tx) error { + updateTxn := ` + UPDATE transactions + SET value = $1, title = $2, description = $3, occurred_at = $4, updated_at = $5 + WHERE id = $6; + ` + if _, err := tx.Exec( + updateTxn, + t.Value, t.Title, t.Description, t.OccurredAt, t.UpdatedAt, t.ID, + ); err != nil { + return err + } + + updateBalance := `UPDATE accounts SET balance = $1, updated_at = $2 WHERE id = $3;` + if _, err := tx.Exec(updateBalance, newBalance, time.Now(), t.AccountID); err != nil { + return err + } + + if _, err := tx.Exec(`DELETE FROM transaction_categories WHERE transaction_id = $1;`, t.ID); err != nil { + return err + } + if categoryID != nil && *categoryID != "" { + linkCategory := `INSERT INTO transaction_categories (category_id, transaction_id) VALUES ($1, $2);` + if _, err := tx.Exec(linkCategory, *categoryID, t.ID); err != nil { + return err + } + } + return nil + }) +} + +func (r *transactionRepository) UpdateDepositAtomic(t *model.Transaction, newBalance decimal.Decimal) error { + return WithTx(r.db, func(tx *sqlx.Tx) error { + updateTxn := ` + UPDATE transactions + SET value = $1, title = $2, description = $3, occurred_at = $4, updated_at = $5 + WHERE id = $6; + ` + if _, err := tx.Exec( + updateTxn, + t.Value, t.Title, t.Description, t.OccurredAt, t.UpdatedAt, t.ID, + ); err != nil { + return err + } + + updateBalance := `UPDATE accounts SET balance = $1, updated_at = $2 WHERE id = $3;` + if _, err := tx.Exec(updateBalance, newBalance, time.Now(), t.AccountID); err != nil { + return err + } + return nil + }) +} + +func (r *transactionRepository) GetByID(id string) (*model.Transaction, error) { + query := ` + SELECT id, value, type, account_id, title, description, occurred_at, created_at, updated_at + FROM transactions + WHERE id = $1; + ` + t := &model.Transaction{} + if err := r.db.Get(t, query, id); err != nil { + return nil, err + } + return t, nil +} + +func (r *transactionRepository) GetCategoryID(transactionID string) (*string, error) { + var id string + err := r.db.Get(&id, `SELECT category_id FROM transaction_categories WHERE transaction_id = $1 LIMIT 1;`, transactionID) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, err + } + return &id, nil +} + func (r *transactionRepository) ListByAccount(accountID string, limit, offset int) ([]*model.Transaction, error) { query := ` SELECT id, value, type, account_id, title, description, occurred_at, created_at, updated_at diff --git a/internal/routes/routes.go b/internal/routes/routes.go index b4e9874..e2a2955 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -109,6 +109,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("/transactions/{transactionID}", spaceH.SpaceTransactionPage).Name("page.app.spaces.space.accounts.account.transactions.transaction") + g.Get("/transactions/{transactionID}/edit", spaceH.SpaceEditTransactionPage).Name("page.app.spaces.space.accounts.account.transactions.transaction.edit") + g.Post("/transactions/{transactionID}/edit", spaceH.HandleEditTransaction).Name("action.app.spaces.space.accounts.account.transactions.transaction.edit") 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") diff --git a/internal/service/transaction.go b/internal/service/transaction.go index 0083f58..3a7dc76 100644 --- a/internal/service/transaction.go +++ b/internal/service/transaction.go @@ -144,6 +144,140 @@ func (s *TransactionService) Deposit(input DepositInput) (*model.Transaction, er return txn, nil } +type UpdateBillInput struct { + TransactionID string + Title string + Amount decimal.Decimal + OccurredAt time.Time + Description string + CategoryID string +} + +func (s *TransactionService) UpdateBill(input UpdateBillInput) (*model.Transaction, error) { + title := strings.TrimSpace(input.Title) + if title == "" { + return nil, fmt.Errorf("title is required") + } + if input.TransactionID == "" { + return nil, fmt.Errorf("transaction id is required") + } + if !input.Amount.IsPositive() { + return nil, fmt.Errorf("amount must be greater than zero") + } + if input.OccurredAt.IsZero() { + return nil, fmt.Errorf("date is required") + } + + existing, err := s.transactionRepo.GetByID(input.TransactionID) + if err != nil { + return nil, fmt.Errorf("failed to load transaction: %w", err) + } + if existing.Type != model.TransactionTypeWithdrawal { + return nil, fmt.Errorf("transaction is not a bill") + } + + account, err := s.accountService.GetAccount(existing.AccountID) + if err != nil { + return nil, fmt.Errorf("failed to load account: %w", err) + } + + newBalance := account.Balance.Add(existing.Value).Sub(input.Amount) + + var description *string + if d := strings.TrimSpace(input.Description); d != "" { + description = &d + } + var categoryID *string + if c := strings.TrimSpace(input.CategoryID); c != "" { + categoryID = &c + } + + existing.Value = input.Amount + existing.Title = title + existing.Description = description + existing.OccurredAt = input.OccurredAt + existing.UpdatedAt = time.Now() + + if err := s.transactionRepo.UpdateBillAtomic(existing, newBalance, categoryID); err != nil { + return nil, fmt.Errorf("failed to update bill transaction: %w", err) + } + return existing, nil +} + +type UpdateDepositInput struct { + TransactionID string + Title string + Amount decimal.Decimal + OccurredAt time.Time + Description string +} + +func (s *TransactionService) UpdateDeposit(input UpdateDepositInput) (*model.Transaction, error) { + title := strings.TrimSpace(input.Title) + if title == "" { + return nil, fmt.Errorf("title is required") + } + if input.TransactionID == "" { + return nil, fmt.Errorf("transaction id is required") + } + if !input.Amount.IsPositive() { + return nil, fmt.Errorf("amount must be greater than zero") + } + if input.OccurredAt.IsZero() { + return nil, fmt.Errorf("date is required") + } + + existing, err := s.transactionRepo.GetByID(input.TransactionID) + if err != nil { + return nil, fmt.Errorf("failed to load transaction: %w", err) + } + if existing.Type != model.TransactionTypeDeposit { + return nil, fmt.Errorf("transaction is not a deposit") + } + + account, err := s.accountService.GetAccount(existing.AccountID) + if err != nil { + return nil, fmt.Errorf("failed to load account: %w", err) + } + + newBalance := account.Balance.Sub(existing.Value).Add(input.Amount) + + var description *string + if d := strings.TrimSpace(input.Description); d != "" { + description = &d + } + + existing.Value = input.Amount + existing.Title = title + existing.Description = description + existing.OccurredAt = input.OccurredAt + existing.UpdatedAt = time.Now() + + if err := s.transactionRepo.UpdateDepositAtomic(existing, newBalance); err != nil { + return nil, fmt.Errorf("failed to update deposit transaction: %w", err) + } + return existing, nil +} + +func (s *TransactionService) GetTransaction(id string) (*model.Transaction, error) { + txn, err := s.transactionRepo.GetByID(id) + if err != nil { + return nil, fmt.Errorf("failed to load transaction: %w", err) + } + return txn, nil +} + +func (s *TransactionService) GetTransactionCategoryID(transactionID string) (string, error) { + id, err := s.transactionRepo.GetCategoryID(transactionID) + if err != nil { + return "", fmt.Errorf("failed to load transaction category: %w", err) + } + if id == nil { + return "", nil + } + return *id, nil +} + func (s *TransactionService) ListByAccount(accountID string, limit, offset int) ([]*model.Transaction, error) { if limit <= 0 { limit = 25 diff --git a/internal/ui/blocks/transaction_list.templ b/internal/ui/blocks/transaction_list.templ index ef61397..5df15bf 100644 --- a/internal/ui/blocks/transaction_list.templ +++ b/internal/ui/blocks/transaction_list.templ @@ -1,24 +1,32 @@ package blocks import "git.juancwu.dev/juancwu/budgit/internal/model" +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/icon" import "git.juancwu.dev/juancwu/budgit/internal/ui/utils" -templ TransactionList(txns []*model.Transaction) { - if len(txns) == 0 { +type TransactionListProps struct { + SpaceID string + AccountID string + Transactions []*model.Transaction +} + +templ TransactionList(props TransactionListProps) { + if len(props.Transactions) == 0 {
No transactions yet.
} else { } } -templ transactionRow(t *model.Transaction) { +templ transactionRow(spaceID, accountID string, t *model.Transaction) { {{ isDeposit := t.Type == model.TransactionTypeDeposit amountClasses := []string{"text-sm font-semibold tabular-nums"} @@ -40,16 +48,41 @@ templ transactionRow(t *model.Transaction) { }
-

{ t.Title }

+ if spaceID != "" && accountID != "" { + + { t.Title } + + } else { +

{ t.Title }

+ }

{ t.OccurredAt.Format("Jan 2, 2006") }

-
-

- { sign }${ utils.FormatDecimalWithThousands(t.Value.StringFixedBank(2)) } -

- if t.Description != nil && *t.Description != "" { -

{ *t.Description }

+
+
+

+ { sign }${ utils.FormatDecimalWithThousands(t.Value.StringFixedBank(2)) } +

+ if t.Description != nil && *t.Description != "" { +

{ *t.Description }

+ } +
+ if spaceID != "" && accountID != "" { + @button.Button(button.Props{ + Variant: button.VariantGhost, + Size: button.SizeIcon, + Href: routeurl.URL("page.app.spaces.space.accounts.account.transactions.transaction.edit", "spaceID", spaceID, "accountID", accountID, "transactionID", t.ID), + Class: "h-8 w-8", + Attributes: templ.Attributes{ + "aria-label": "Edit transaction", + "title": "Edit transaction", + }, + }) { + @icon.Pencil(icon.Props{Class: "size-4"}) + } }
diff --git a/internal/ui/forms/edit_bill.templ b/internal/ui/forms/edit_bill.templ new file mode 100644 index 0000000..8074695 --- /dev/null +++ b/internal/ui/forms/edit_bill.templ @@ -0,0 +1,162 @@ +package forms + +import "git.juancwu.dev/juancwu/budgit/internal/model" +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" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/textarea" + +type EditBillProps struct { + SpaceID string + AccountID string + TransactionID string + Categories []*model.Category + + Title string + Amount string + Date string + Description string + CategoryID string + + TitleErr string + AmountErr string + DateErr string + GeneralErr string + SuccessMsg string +} + +templ EditBill(props EditBillProps) { +
+ @card.Card(card.Props{Class: "rounded-sm"}) { + @card.Content(card.ContentProps{Class: "p-4 space-y-4"}) { + if props.GeneralErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.GeneralErr } + } + } + if props.SuccessMsg != "" { + @form.Message() { + { props.SuccessMsg } + } + } + @form.Item() { + @form.Label(form.LabelProps{For: "title"}) { + Title + } + @input.Input(input.Props{ + ID: "title", + Name: "title", + Type: input.TypeText, + Placeholder: "e.g. Hydro bill", + Class: "rounded-sm", + Value: props.Title, + HasError: props.TitleErr != "", + Required: true, + Attributes: templ.Attributes{ + "autocomplete": "off", + "autofocus": "", + }, + }) + if props.TitleErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.TitleErr } + } + } + } +
+ @form.Item() { + @form.Label(form.LabelProps{For: "amount"}) { + Amount + } + @input.Input(input.Props{ + ID: "amount", + Name: "amount", + Type: input.TypeNumber, + Placeholder: "0.00", + Class: "rounded-sm", + Value: props.Amount, + HasError: props.AmountErr != "", + Required: true, + Attributes: templ.Attributes{ + "step": "0.01", + "min": "0", + "inputmode": "decimal", + "autocomplete": "off", + }, + }) + if props.AmountErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.AmountErr } + } + } + } + @form.Item() { + @form.Label(form.LabelProps{For: "date"}) { + Date + } + @input.Input(input.Props{ + ID: "date", + Name: "date", + Type: input.TypeDate, + Class: "rounded-sm", + Value: props.Date, + HasError: props.DateErr != "", + Required: true, + }) + if props.DateErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.DateErr } + } + } + } +
+ @form.Item() { + @form.Label(form.LabelProps{For: "category"}) { + Category + } + + @form.Description() { + Optional. Helps with budget reporting. + } + } + @form.Item() { + @form.Label(form.LabelProps{For: "description"}) { + Description + } + @textarea.Textarea(textarea.Props{ + ID: "description", + Name: "description", + Placeholder: "Anything extra worth remembering", + Rows: 3, + Value: props.Description, + }) + @form.Description() { + Optional. + } + } + } + @card.Footer(card.FooterProps{Class: "flex justify-end gap-2"}) { + @button.Button(button.Props{ + Variant: button.VariantGhost, + Href: routeurl.URL("page.app.spaces.space.accounts.account.transactions.transaction", "spaceID", props.SpaceID, "accountID", props.AccountID, "transactionID", props.TransactionID), + }) { + Cancel + } + @button.Button(button.Props{Type: button.TypeSubmit}) { + Save Changes + } + } + } +
+} diff --git a/internal/ui/forms/edit_deposit.templ b/internal/ui/forms/edit_deposit.templ new file mode 100644 index 0000000..ca8cf1d --- /dev/null +++ b/internal/ui/forms/edit_deposit.templ @@ -0,0 +1,141 @@ +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" +import "git.juancwu.dev/juancwu/budgit/internal/ui/components/textarea" + +type EditDepositProps struct { + SpaceID string + AccountID string + TransactionID string + + Title string + Amount string + Date string + Description string + + TitleErr string + AmountErr string + DateErr string + GeneralErr string + SuccessMsg string +} + +templ EditDeposit(props EditDepositProps) { +
+ @card.Card(card.Props{Class: "rounded-sm"}) { + @card.Content(card.ContentProps{Class: "p-4 space-y-4"}) { + if props.GeneralErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.GeneralErr } + } + } + if props.SuccessMsg != "" { + @form.Message() { + { props.SuccessMsg } + } + } + @form.Item() { + @form.Label(form.LabelProps{For: "title"}) { + Title + } + @input.Input(input.Props{ + ID: "title", + Name: "title", + Type: input.TypeText, + Placeholder: "e.g. Paycheck", + Class: "rounded-sm", + Value: props.Title, + HasError: props.TitleErr != "", + Required: true, + Attributes: templ.Attributes{ + "autocomplete": "off", + "autofocus": "", + }, + }) + if props.TitleErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.TitleErr } + } + } + } +
+ @form.Item() { + @form.Label(form.LabelProps{For: "amount"}) { + Amount + } + @input.Input(input.Props{ + ID: "amount", + Name: "amount", + Type: input.TypeNumber, + Placeholder: "0.00", + Class: "rounded-sm", + Value: props.Amount, + HasError: props.AmountErr != "", + Required: true, + Attributes: templ.Attributes{ + "step": "0.01", + "min": "0", + "inputmode": "decimal", + "autocomplete": "off", + }, + }) + if props.AmountErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.AmountErr } + } + } + } + @form.Item() { + @form.Label(form.LabelProps{For: "date"}) { + Date + } + @input.Input(input.Props{ + ID: "date", + Name: "date", + Type: input.TypeDate, + Class: "rounded-sm", + Value: props.Date, + HasError: props.DateErr != "", + Required: true, + }) + if props.DateErr != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { props.DateErr } + } + } + } +
+ @form.Item() { + @form.Label(form.LabelProps{For: "description"}) { + Description + } + @textarea.Textarea(textarea.Props{ + ID: "description", + Name: "description", + Placeholder: "Anything extra worth remembering", + Rows: 3, + Value: props.Description, + }) + @form.Description() { + Optional. + } + } + } + @card.Footer(card.FooterProps{Class: "flex justify-end gap-2"}) { + @button.Button(button.Props{ + Variant: button.VariantGhost, + Href: routeurl.URL("page.app.spaces.space.accounts.account.transactions.transaction", "spaceID", props.SpaceID, "accountID", props.AccountID, "transactionID", props.TransactionID), + }) { + Cancel + } + @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 1e5b6e9..37ce842 100644 --- a/internal/ui/pages/space_account.templ +++ b/internal/ui/pages/space_account.templ @@ -91,7 +91,11 @@ templ SpaceAccountPage(props SpaceAccountPageProps) {
} @card.Content() { - @blocks.TransactionList(props.RecentTransactions) + @blocks.TransactionList(blocks.TransactionListProps{ + SpaceID: props.SpaceID, + AccountID: props.AccountID, + Transactions: props.RecentTransactions, + }) } @card.Footer(card.FooterProps{Class: "justify-end"}) { @button.Button(button.Props{ diff --git a/internal/ui/pages/space_account_transactions.templ b/internal/ui/pages/space_account_transactions.templ index 7235407..6431d00 100644 --- a/internal/ui/pages/space_account_transactions.templ +++ b/internal/ui/pages/space_account_transactions.templ @@ -61,7 +61,11 @@ templ SpaceAccountTransactionsPage(props SpaceAccountTransactionsPageProps) { } } @card.Content() { - @blocks.TransactionList(props.Transactions) + @blocks.TransactionList(blocks.TransactionListProps{ + SpaceID: props.SpaceID, + AccountID: props.AccountID, + Transactions: props.Transactions, + }) } if props.TotalPages > 1 { @card.Footer() { diff --git a/internal/ui/pages/space_edit_transaction.templ b/internal/ui/pages/space_edit_transaction.templ new file mode 100644 index 0000000..8ca99f9 --- /dev/null +++ b/internal/ui/pages/space_edit_transaction.templ @@ -0,0 +1,33 @@ +package pages + +import "git.juancwu.dev/juancwu/budgit/internal/model" +import "git.juancwu.dev/juancwu/budgit/internal/ui/forms" +import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" + +type SpaceEditTransactionPageProps struct { + SpaceID string + SpaceName string + AccountID string + AccountName string + TransactionType model.TransactionType + BillForm forms.EditBillProps + DepositForm forms.EditDepositProps +} + +templ SpaceEditTransactionPage(props SpaceEditTransactionPageProps) { + @layouts.AppWithBreadcrumb("Edit Transaction", accountChildBreadcrumb(props.SpaceID, props.SpaceName, props.AccountID, props.AccountName, "Edit Transaction"), spaceOverviewSidebarContent(), spaceSpecificSidebarContent(props.SpaceID), spaceAccountSidebarContent(props.SpaceID, props.AccountID)) { +
+
+

Edit Transaction

+

+ Update the details of this transaction in { props.AccountName }. +

+
+ if props.TransactionType == model.TransactionTypeDeposit { + @forms.EditDeposit(props.DepositForm) + } else { + @forms.EditBill(props.BillForm) + } +
+ } +} diff --git a/internal/ui/pages/space_transaction.templ b/internal/ui/pages/space_transaction.templ new file mode 100644 index 0000000..71516e1 --- /dev/null +++ b/internal/ui/pages/space_transaction.templ @@ -0,0 +1,105 @@ +package pages + +import "git.juancwu.dev/juancwu/budgit/internal/model" +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/icon" +import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" +import "git.juancwu.dev/juancwu/budgit/internal/ui/utils" + +type SpaceTransactionPageProps struct { + SpaceID string + SpaceName string + AccountID string + AccountName string + Transaction *model.Transaction + CategoryName string +} + +templ SpaceTransactionPage(props SpaceTransactionPageProps) { + @layouts.AppWithBreadcrumb("Transaction", accountChildBreadcrumb(props.SpaceID, props.SpaceName, props.AccountID, props.AccountName, props.Transaction.Title), spaceOverviewSidebarContent(), spaceSpecificSidebarContent(props.SpaceID), spaceAccountSidebarContent(props.SpaceID, props.AccountID)) { + {{ + isDeposit := props.Transaction.Type == model.TransactionTypeDeposit + amountClasses := []string{"text-3xl font-semibold tabular-nums"} + sign := "-" + label := "Bill" + if isDeposit { + amountClasses = append(amountClasses, "text-green-600 dark:text-green-400") + sign = "+" + label = "Deposit" + } else { + amountClasses = append(amountClasses, "text-red-600 dark:text-red-400") + } + }} +
+
+ @button.Button(button.Props{ + Variant: button.VariantGhost, + Size: button.SizeSm, + Href: routeurl.URL("page.app.spaces.space.accounts.account.transactions", "spaceID", props.SpaceID, "accountID", props.AccountID), + Class: "flex items-center gap-1 -ml-2", + }) { + @icon.ChevronLeft(icon.Props{Class: "size-4"}) + Back to all transactions + } +
+
+
+

{ props.Transaction.Title }

+

+ { label } in { props.AccountName } +

+
+ @button.Button(button.Props{ + Variant: button.VariantDefault, + Href: routeurl.URL("page.app.spaces.space.accounts.account.transactions.transaction.edit", "spaceID", props.SpaceID, "accountID", props.AccountID, "transactionID", props.Transaction.ID), + Class: "flex items-center gap-2", + }) { + @icon.Pencil(icon.Props{Class: "size-4"}) + Edit + } +
+ @card.Card() { + @card.Content(card.ContentProps{Class: "p-6 space-y-6"}) { +
+

Amount

+

+ { sign }${ utils.FormatDecimalWithThousands(props.Transaction.Value.StringFixedBank(2)) } +

+
+
+
+

Date

+

{ props.Transaction.OccurredAt.Format("January 2, 2006") }

+
+
+

Type

+

{ label }

+
+ if !isDeposit { +
+

Category

+ if props.CategoryName != "" { +

{ props.CategoryName }

+ } else { +

Uncategorized

+ } +
+ } +
+

Last updated

+

{ props.Transaction.UpdatedAt.Format("Jan 2, 2006 3:04 PM") }

+
+
+ if props.Transaction.Description != nil && *props.Transaction.Description != "" { +
+

Description

+

{ *props.Transaction.Description }

+
+ } + } + } +
+ } +}