From 8c681282efe6be583b909960ee6450d8cb99c43d Mon Sep 17 00:00:00 2001 From: juancwu Date: Wed, 22 Apr 2026 15:49:00 +0000 Subject: [PATCH] feat: pay bills --- internal/app/app.go | 37 ++-- internal/ctxkeys/ctx.go | 13 +- ..._add_transaction_title_and_occurred_at.sql | 11 ++ ...lace_related_transaction_id_with_table.sql | 22 +++ internal/handler/space.go | 173 +++++++++++++++++- internal/model/financial_management.go | 17 +- internal/repository/category.go | 27 +++ internal/repository/transaction.go | 53 ++++++ internal/routes/routes.go | 4 +- internal/service/transaction.go | 98 ++++++++++ internal/ui/components/icon/icon_data.go | 20 +- internal/ui/components/icon/icon_defs.go | 1 + internal/ui/forms/create_bill.templ | 155 ++++++++++++++++ internal/ui/pages/space_account.templ | 18 +- internal/ui/pages/space_create_bill.templ | 23 ++- 15 files changed, 607 insertions(+), 65 deletions(-) create mode 100644 internal/db/migrations/00007_add_transaction_title_and_occurred_at.sql create mode 100644 internal/db/migrations/00008_replace_related_transaction_id_with_table.sql create mode 100644 internal/repository/category.go create mode 100644 internal/repository/transaction.go create mode 100644 internal/service/transaction.go create mode 100644 internal/ui/forms/create_bill.templ diff --git a/internal/app/app.go b/internal/app/app.go index 6c823bd..db346ca 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -11,14 +11,15 @@ import ( ) type App struct { - Cfg *config.Config - DB *sqlx.DB - UserService *service.UserService - AuthService *service.AuthService - EmailService *service.EmailService - SpaceService *service.SpaceService - AccountService *service.AccountService - InviteService *service.InviteService + Cfg *config.Config + DB *sqlx.DB + UserService *service.UserService + AuthService *service.AuthService + EmailService *service.EmailService + SpaceService *service.SpaceService + AccountService *service.AccountService + TransactionService *service.TransactionService + InviteService *service.InviteService } func New(cfg *config.Config) (*App, error) { @@ -39,12 +40,15 @@ func New(cfg *config.Config) (*App, error) { tokenRepository := repository.NewTokenRepository(database) spaceRepository := repository.NewSpaceRepository(database) accountRepository := repository.NewAccountRepository(database) + transactionRepository := repository.NewTransactionRepository(database) + categoryRepository := repository.NewCategoryRepository(database) invitationRepository := repository.NewInvitationRepository(database) // Services userService := service.NewUserService(userRepository) spaceService := service.NewSpaceService(spaceRepository) accountService := service.NewAccountService(accountRepository) + transactionService := service.NewTransactionService(transactionRepository, categoryRepository, accountService) emailService := service.NewEmailService( emailClient, cfg.MailerEmailFrom, @@ -66,14 +70,15 @@ func New(cfg *config.Config) (*App, error) { inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService) return &App{ - Cfg: cfg, - DB: database, - UserService: userService, - AuthService: authService, - EmailService: emailService, - SpaceService: spaceService, - AccountService: accountService, - InviteService: inviteService, + Cfg: cfg, + DB: database, + UserService: userService, + AuthService: authService, + EmailService: emailService, + SpaceService: spaceService, + AccountService: accountService, + TransactionService: transactionService, + InviteService: inviteService, }, nil } diff --git a/internal/ctxkeys/ctx.go b/internal/ctxkeys/ctx.go index ee98d82..653d0bc 100644 --- a/internal/ctxkeys/ctx.go +++ b/internal/ctxkeys/ctx.go @@ -8,13 +8,13 @@ import ( ) const ( - UserKey string = "user" + UserKey string = "user" - URLPathKey string = "url_path" - ConfigKey string = "config" - CSRFTokenKey string = "csrf_token" - AppVersionKey string = "app_version" - SidebarCollapsedKey string = "sidebar_collapsed" + URLPathKey string = "url_path" + ConfigKey string = "config" + CSRFTokenKey string = "csrf_token" + AppVersionKey string = "app_version" + SidebarCollapsedKey string = "sidebar_collapsed" ) func User(ctx context.Context) *model.User { @@ -26,7 +26,6 @@ func WithUser(ctx context.Context, user *model.User) context.Context { return context.WithValue(ctx, UserKey, user) } - func URLPath(ctx context.Context) string { path, _ := ctx.Value(URLPathKey).(string) return path diff --git a/internal/db/migrations/00007_add_transaction_title_and_occurred_at.sql b/internal/db/migrations/00007_add_transaction_title_and_occurred_at.sql new file mode 100644 index 0000000..a4c2270 --- /dev/null +++ b/internal/db/migrations/00007_add_transaction_title_and_occurred_at.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE transactions ADD COLUMN title TEXT NOT NULL DEFAULT ''; +ALTER TABLE transactions ADD COLUMN occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE transactions DROP COLUMN occurred_at; +ALTER TABLE transactions DROP COLUMN title; +-- +goose StatementEnd diff --git a/internal/db/migrations/00008_replace_related_transaction_id_with_table.sql b/internal/db/migrations/00008_replace_related_transaction_id_with_table.sql new file mode 100644 index 0000000..d2484d2 --- /dev/null +++ b/internal/db/migrations/00008_replace_related_transaction_id_with_table.sql @@ -0,0 +1,22 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE related_transactions ( + transaction_one_id TEXT NOT NULL REFERENCES transactions(id) ON DELETE CASCADE, + transaction_two_id TEXT NOT NULL REFERENCES transactions(id) ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (transaction_one_id, transaction_two_id), + UNIQUE (transaction_one_id), + UNIQUE (transaction_two_id), + CHECK (transaction_one_id < transaction_two_id) +); + +ALTER TABLE transactions DROP COLUMN related_transaction_id; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE transactions ADD COLUMN related_transaction_id TEXT REFERENCES transactions(id) ON DELETE SET NULL; + +DROP TABLE related_transactions; +-- +goose StatementEnd diff --git a/internal/handler/space.go b/internal/handler/space.go index b0b519a..c314e85 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -4,9 +4,11 @@ import ( "log/slog" "net/http" "strings" + "time" "git.juancwu.dev/juancwu/budgit/internal/ctxkeys" "git.juancwu.dev/juancwu/budgit/internal/model" + "git.juancwu.dev/juancwu/budgit/internal/routeurl" "git.juancwu.dev/juancwu/budgit/internal/service" "git.juancwu.dev/juancwu/budgit/internal/ui" "git.juancwu.dev/juancwu/budgit/internal/ui/blocks" @@ -16,12 +18,21 @@ import ( ) type spaceHandler struct { - spaceService *service.SpaceService - accountService *service.AccountService + spaceService *service.SpaceService + accountService *service.AccountService + transactionService *service.TransactionService } -func NewSpaceHandler(spaceService *service.SpaceService, accountService *service.AccountService) *spaceHandler { - return &spaceHandler{spaceService: spaceService, accountService: accountService} +func NewSpaceHandler( + spaceService *service.SpaceService, + accountService *service.AccountService, + transactionService *service.TransactionService, +) *spaceHandler { + return &spaceHandler{ + spaceService: spaceService, + accountService: accountService, + transactionService: transactionService, + } } func (h *spaceHandler) SpacesPage(w http.ResponseWriter, r *http.Request) { @@ -164,12 +175,154 @@ func (h *spaceHandler) SpaceAccountPage(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.SpaceAccountPage(pages.SpaceAccountPageProps{ - SpaceID: spaceID, - AccountID: accountID, - AccountName: "Money Account", - AccountDescription: "Vault Infinite Priority", - AccountNumber: "4492", - AccountBalance: decimal.NewFromFloat(32093.11), + SpaceID: spaceID, + AccountID: accountID, + AccountName: account.Name, + AccountBalance: account.Balance, })) } + +func (h *spaceHandler) SpaceCreateBillPage(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 + } + + 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 + } + + ui.Render(w, r, pages.SpaceCreateBillPage(pages.SpaceCreateBillPageProps{ + SpaceID: spaceID, + AccountID: accountID, + AccountName: account.Name, + Form: forms.CreateBillProps{ + SpaceID: spaceID, + AccountID: accountID, + Categories: categories, + Date: time.Now().Format("2006-01-02"), + }, + })) +} + +func (h *spaceHandler) HandleCreateBill(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + accountID := r.PathValue("accountID") + + titleInput := strings.TrimSpace(r.FormValue("title")) + amountInput := strings.TrimSpace(r.FormValue("amount")) + dateInput := strings.TrimSpace(r.FormValue("date")) + descriptionInput := strings.TrimSpace(r.FormValue("description")) + 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.CreateBillProps{ + SpaceID: spaceID, + AccountID: accountID, + Categories: categories, + Title: titleInput, + Amount: amountInput, + Date: dateInput, + Description: descriptionInput, + CategoryID: categoryInput, + } + + hasErr := false + if titleInput == "" { + formProps.TitleErr = "Title is required." + hasErr = true + } + + var amount decimal.Decimal + if amountInput == "" { + formProps.AmountErr = "Amount is required." + hasErr = true + } else { + amt, err := decimal.NewFromString(amountInput) + if err != nil { + formProps.AmountErr = "Enter a valid amount (e.g. 12.34)." + hasErr = true + } else if !amt.IsPositive() { + formProps.AmountErr = "Amount must be greater than zero." + hasErr = true + } else if amt.Exponent() < -2 { + formProps.AmountErr = "Amount can have at most 2 decimal places." + hasErr = true + } else { + amount = amt + } + } + + var occurredAt time.Time + if dateInput == "" { + formProps.DateErr = "Date is required." + hasErr = true + } else { + parsed, err := time.Parse("2006-01-02", dateInput) + if err != nil { + formProps.DateErr = "Enter a valid date." + hasErr = true + } else { + occurredAt = parsed + } + } + + if hasErr { + ui.Render(w, r, forms.CreateBill(formProps)) + return + } + + _, err = h.transactionService.PayBill(service.PayBillInput{ + AccountID: accountID, + Title: titleInput, + Amount: amount, + OccurredAt: occurredAt, + Description: descriptionInput, + CategoryID: categoryInput, + }) + if err != nil { + slog.Error("failed to create bill", "error", err, "account_id", accountID) + formProps.GeneralErr = "Something went wrong. Please try again." + ui.Render(w, r, forms.CreateBill(formProps)) + return + } + + redirectTo := routeurl.URL( + "page.app.spaces.space.accounts.account.overview", + "spaceID", spaceID, + "accountID", accountID, + ) + w.Header().Set("HX-Redirect", redirectTo) + w.WriteHeader(http.StatusOK) +} diff --git a/internal/model/financial_management.go b/internal/model/financial_management.go index b48bf7c..c8b534b 100644 --- a/internal/model/financial_management.go +++ b/internal/model/financial_management.go @@ -23,14 +23,15 @@ const ( ) type Transaction struct { - ID string `db:"id"` - Value decimal.Decimal `db:"value"` - Type TransactionType `db:"type"` - AccountID string `db:"account_id"` - Description *string `db:"description"` - RelatedTransactionID *string `db:"related_transaction_id"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + ID string `db:"id"` + Value decimal.Decimal `db:"value"` + Type TransactionType `db:"type"` + AccountID string `db:"account_id"` + Title string `db:"title"` + Description *string `db:"description"` + OccurredAt time.Time `db:"occurred_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` } type Tag struct { diff --git a/internal/repository/category.go b/internal/repository/category.go new file mode 100644 index 0000000..8eb92d3 --- /dev/null +++ b/internal/repository/category.go @@ -0,0 +1,27 @@ +package repository + +import ( + "git.juancwu.dev/juancwu/budgit/internal/model" + "github.com/jmoiron/sqlx" +) + +type CategoryRepository interface { + All() ([]*model.Category, error) +} + +type categoryRepository struct { + db *sqlx.DB +} + +func NewCategoryRepository(db *sqlx.DB) CategoryRepository { + return &categoryRepository{db: db} +} + +func (r *categoryRepository) All() ([]*model.Category, error) { + var categories []*model.Category + query := `SELECT * FROM categories ORDER BY name ASC;` + if err := r.db.Select(&categories, query); err != nil { + return nil, err + } + return categories, nil +} diff --git a/internal/repository/transaction.go b/internal/repository/transaction.go new file mode 100644 index 0000000..11c8ab4 --- /dev/null +++ b/internal/repository/transaction.go @@ -0,0 +1,53 @@ +package repository + +import ( + "time" + + "git.juancwu.dev/juancwu/budgit/internal/model" + "github.com/jmoiron/sqlx" + "github.com/shopspring/decimal" +) + +type TransactionRepository interface { + CreateBillAtomic(t *model.Transaction, newBalance decimal.Decimal, categoryID *string) error +} + +type transactionRepository struct { + db *sqlx.DB +} + +func NewTransactionRepository(db *sqlx.DB) TransactionRepository { + return &transactionRepository{db: db} +} + +func (r *transactionRepository) CreateBillAtomic(t *model.Transaction, newBalance decimal.Decimal, categoryID *string) error { + return WithTx(r.db, func(tx *sqlx.Tx) error { + insertTxn := ` + INSERT INTO transactions + (id, value, type, account_id, title, description, occurred_at, created_at, updated_at) + VALUES + ($1, $2, $3, $4, $5, $6, $7, $8, $9); + ` + if _, err := tx.Exec( + insertTxn, + t.ID, t.Value, t.Type, t.AccountID, t.Title, t.Description, + t.OccurredAt, t.CreatedAt, t.UpdatedAt, + ); 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 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 + }) +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 4855051..dc7b4c3 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -19,7 +19,7 @@ func SetupRoutes(a *app.App) http.Handler { authH := handler.NewAuthHandler(a.AuthService, a.InviteService, a.SpaceService) homeH := handler.NewHomeHandler() settingsH := handler.NewSettingsHandler(a.AuthService, a.UserService) - spaceH := handler.NewSpaceHandler(a.SpaceService, a.AccountService) + spaceH := handler.NewSpaceHandler(a.SpaceService, a.AccountService, a.TransactionService) redirectH := handler.NewRedirectHandler() r := router.New() @@ -95,6 +95,8 @@ 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("/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") }) }) }) diff --git a/internal/service/transaction.go b/internal/service/transaction.go new file mode 100644 index 0000000..f69572b --- /dev/null +++ b/internal/service/transaction.go @@ -0,0 +1,98 @@ +package service + +import ( + "fmt" + "strings" + "time" + + "git.juancwu.dev/juancwu/budgit/internal/model" + "git.juancwu.dev/juancwu/budgit/internal/repository" + "github.com/google/uuid" + "github.com/shopspring/decimal" +) + +type TransactionService struct { + transactionRepo repository.TransactionRepository + categoryRepo repository.CategoryRepository + accountService *AccountService +} + +func NewTransactionService( + transactionRepo repository.TransactionRepository, + categoryRepo repository.CategoryRepository, + accountService *AccountService, +) *TransactionService { + return &TransactionService{ + transactionRepo: transactionRepo, + categoryRepo: categoryRepo, + accountService: accountService, + } +} + +type PayBillInput struct { + AccountID string + Title string + Amount decimal.Decimal + OccurredAt time.Time + Description string + CategoryID string +} + +func (s *TransactionService) PayBill(input PayBillInput) (*model.Transaction, error) { + title := strings.TrimSpace(input.Title) + if title == "" { + return nil, fmt.Errorf("title is required") + } + if input.AccountID == "" { + return nil, fmt.Errorf("account 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") + } + + account, err := s.accountService.GetAccount(input.AccountID) + if err != nil { + return nil, fmt.Errorf("failed to load account: %w", err) + } + + newBalance := account.Balance.Sub(input.Amount) + + now := time.Now() + var description *string + if d := strings.TrimSpace(input.Description); d != "" { + description = &d + } + var categoryID *string + if c := strings.TrimSpace(input.CategoryID); c != "" { + categoryID = &c + } + + txn := &model.Transaction{ + ID: uuid.NewString(), + Value: input.Amount, + Type: model.TransactionTypeWithdrawal, + AccountID: input.AccountID, + Title: title, + Description: description, + OccurredAt: input.OccurredAt, + CreatedAt: now, + UpdatedAt: now, + } + + if err := s.transactionRepo.CreateBillAtomic(txn, newBalance, categoryID); err != nil { + return nil, fmt.Errorf("failed to create bill transaction: %w", err) + } + + return txn, nil +} + +func (s *TransactionService) ListCategories() ([]*model.Category, error) { + categories, err := s.categoryRepo.All() + if err != nil { + return nil, fmt.Errorf("failed to list categories: %w", err) + } + return categories, nil +} diff --git a/internal/ui/components/icon/icon_data.go b/internal/ui/components/icon/icon_data.go index 833e6fa..a49dc74 100644 --- a/internal/ui/components/icon/icon_data.go +++ b/internal/ui/components/icon/icon_data.go @@ -1415,9 +1415,9 @@ var internalSvgData = map[string]string{ `, "chevron-last": ` `, - "chevron-left": ``, + "chevron-left": ``, "chevron-right": ``, - "chevron-up": ``, + "chevron-up": ``, "chevrons-down": ` `, "chevrons-down-up": ` @@ -2619,9 +2619,9 @@ var internalSvgData = map[string]string{ `, - "flag-triangle-left": ``, + "flag-triangle-left": ``, "flag-triangle-right": ``, - "flame": ``, + "flame": ``, "flame-kindling": ` `, @@ -4189,7 +4189,7 @@ var internalSvgData = map[string]string{ `, - "navigation": ``, + "navigation": ``, "navigation-2": ``, "navigation-2-off": ` @@ -4768,9 +4768,9 @@ var internalSvgData = map[string]string{ `, - "rectangle-goggles": ``, + "rectangle-goggles": ``, "rectangle-horizontal": ``, - "rectangle-vertical": ``, + "rectangle-vertical": ``, "recycle": ` @@ -5723,7 +5723,7 @@ var internalSvgData = map[string]string{ `, "squares-unite": ``, - "squircle": ``, + "squircle": ``, "squircle-dashed": ` @@ -5739,7 +5739,7 @@ var internalSvgData = map[string]string{ "stamp": ` `, - "star": ``, + "star": ``, "star-half": ``, "star-off": ` @@ -6280,7 +6280,7 @@ var internalSvgData = map[string]string{ "tv-minimal-play": ` `, - "twitch": ``, + "twitch": ``, "twitter": ``, "type": ` diff --git a/internal/ui/components/icon/icon_defs.go b/internal/ui/components/icon/icon_defs.go index d41cca6..2bdafe4 100644 --- a/internal/ui/components/icon/icon_defs.go +++ b/internal/ui/components/icon/icon_defs.go @@ -1,6 +1,7 @@ // templui component icon - version: v1.9.5 installed by templui v1.9.5 // 📚 Documentation: https://templui.io/docs/components/icon package icon + // This file is auto generated // Using Lucide icons version 0.576.0 var AArrowDown = Icon("a-arrow-down") diff --git a/internal/ui/forms/create_bill.templ b/internal/ui/forms/create_bill.templ new file mode 100644 index 0000000..23ec9cc --- /dev/null +++ b/internal/ui/forms/create_bill.templ @@ -0,0 +1,155 @@ +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 CreateBillProps struct { + SpaceID string + AccountID string + Categories []*model.Category + + Title string + Amount string + Date string + Description string + CategoryID string + + TitleErr string + AmountErr string + DateErr string + GeneralErr string +} + +templ CreateBill(props CreateBillProps) { +
+ @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 } + } + } + @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.overview", "spaceID", props.SpaceID, "accountID", props.AccountID), + }) { + Cancel + } + @button.Button(button.Props{Type: button.TypeSubmit}) { + Pay Bill + } + } + } +
+} diff --git a/internal/ui/pages/space_account.templ b/internal/ui/pages/space_account.templ index 7dc9d65..05189ac 100644 --- a/internal/ui/pages/space_account.templ +++ b/internal/ui/pages/space_account.templ @@ -1,6 +1,7 @@ package pages import "github.com/shopspring/decimal" +import "git.juancwu.dev/juancwu/budgit/internal/routeurl" import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card" import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" @@ -8,12 +9,10 @@ import "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon" import "git.juancwu.dev/juancwu/budgit/internal/ui/utils" type SpaceAccountPageProps struct { - SpaceID string - AccountID string - AccountName string - AccountDescription string - AccountNumber string - AccountBalance decimal.Decimal + SpaceID string + AccountID string + AccountName string + AccountBalance decimal.Decimal } templ SpaceAccountPage(props SpaceAccountPageProps) { @@ -35,16 +34,10 @@ templ SpaceAccountPage(props SpaceAccountPageProps) { @card.Title() { { props.AccountName } } - @card.Description(card.DescriptionProps{Class: "text-sm"}) { - { props.AccountDescription } - } } @card.Content() {

${ utils.FormatDecimalWithThousands(props.AccountBalance.StringFixedBank(2)) }

Available Balance

-

- Account •••• { props.AccountNumber } -

} } @card.Card(card.Props{Class: "rounded-sm col-span-full md:col-span-4"}) { @@ -57,6 +50,7 @@ templ SpaceAccountPage(props SpaceAccountPageProps) { @button.Button(button.Props{ Class: "w-full flex gap-2 md:gap-4 items-center", Variant: button.VariantDefault, + Href: routeurl.URL("page.app.spaces.space.accounts.account.bills.create", "spaceID", props.SpaceID, "accountID", props.AccountID), }) { Pay Bills @icon.HandCoins() diff --git a/internal/ui/pages/space_create_bill.templ b/internal/ui/pages/space_create_bill.templ index ef342fc..d412ea2 100644 --- a/internal/ui/pages/space_create_bill.templ +++ b/internal/ui/pages/space_create_bill.templ @@ -1,4 +1,25 @@ package pages -templ SpaceCreateBill() { +import "git.juancwu.dev/juancwu/budgit/internal/ui/forms" +import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" + +type SpaceCreateBillPageProps struct { + SpaceID string + AccountID string + AccountName string + Form forms.CreateBillProps +} + +templ SpaceCreateBillPage(props SpaceCreateBillPageProps) { + @layouts.App("Pay Bills", spaceOverviewSidebarContent(), spaceSpecificSidebarContent(props.SpaceID)) { +
+
+

Pay Bills

+

+ Record a bill paid from { props.AccountName }. +

+
+ @forms.CreateBill(props.Form) +
+ } }