diff --git a/internal/app/app.go b/internal/app/app.go index 55f6d2d..847dd2d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -11,15 +11,16 @@ import ( ) type App struct { - Cfg *config.Config - DB *sqlx.DB - UserService *service.UserService + Cfg *config.Config + DB *sqlx.DB + UserService *service.UserService AuthService *service.AuthService EmailService *service.EmailService ProfileService *service.ProfileService SpaceService *service.SpaceService TagService *service.TagService ShoppingListService *service.ShoppingListService + ExpenseService *service.ExpenseService } func New(cfg *config.Config) (*App, error) { @@ -43,6 +44,7 @@ func New(cfg *config.Config) (*App, error) { tagRepository := repository.NewTagRepository(database) shoppingListRepository := repository.NewShoppingListRepository(database) listItemRepository := repository.NewListItemRepository(database) + expenseRepository := repository.NewExpenseRepository(database) // Services userService := service.NewUserService(userRepository) @@ -68,6 +70,7 @@ func New(cfg *config.Config) (*App, error) { profileService := service.NewProfileService(profileRepository) tagService := service.NewTagService(tagRepository) shoppingListService := service.NewShoppingListService(shoppingListRepository, listItemRepository) + expenseService := service.NewExpenseService(expenseRepository) return &App{ Cfg: cfg, @@ -79,9 +82,9 @@ func New(cfg *config.Config) (*App, error) { SpaceService: spaceService, TagService: tagService, ShoppingListService: shoppingListService, + ExpenseService: expenseService, }, nil } - func (a *App) Close() error { if a.DB != nil { return a.DB.Close() diff --git a/internal/db/migrations/00007_create_expenses_tables.sql b/internal/db/migrations/00007_create_expenses_tables.sql new file mode 100644 index 0000000..fc321dc --- /dev/null +++ b/internal/db/migrations/00007_create_expenses_tables.sql @@ -0,0 +1,42 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS expenses ( + id TEXT PRIMARY KEY NOT NULL, + space_id TEXT NOT NULL, + created_by TEXT NOT NULL, + description TEXT NOT NULL, + amount_cents INTEGER NOT NULL, + type TEXT NOT NULL CHECK (type IN ('expense', 'topup')), + date TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS expense_tags ( + expense_id TEXT NOT NULL, + tag_id TEXT NOT NULL, + PRIMARY KEY (expense_id, tag_id), + FOREIGN KEY (expense_id) REFERENCES expenses(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS expense_items ( + expense_id TEXT NOT NULL, + item_id TEXT NOT NULL, + PRIMARY KEY (expense_id, item_id), + FOREIGN KEY (expense_id) REFERENCES expenses(id) ON DELETE CASCADE, + FOREIGN KEY (item_id) REFERENCES list_items(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_expenses_space_id ON expenses(space_id); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP INDEX IF EXISTS idx_expenses_space_id; +DROP TABLE IF EXISTS expense_items; +DROP TABLE IF EXISTS expense_tags; +DROP TABLE IF EXISTS expenses; +-- +goose StatementEnd diff --git a/internal/handler/space.go b/internal/handler/space.go index e9ba5ab..5d6a4ec 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -3,8 +3,11 @@ package handler import ( "log/slog" "net/http" + "strconv" + "time" "git.juancwu.dev/juancwu/budgit/internal/ctxkeys" + "git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/service" "git.juancwu.dev/juancwu/budgit/internal/ui" "git.juancwu.dev/juancwu/budgit/internal/ui/components/shoppinglist" @@ -13,16 +16,18 @@ import ( ) type SpaceHandler struct { - spaceService *service.SpaceService - tagService *service.TagService - listService *service.ShoppingListService + spaceService *service.SpaceService + tagService *service.TagService + listService *service.ShoppingListService + expenseService *service.ExpenseService } -func NewSpaceHandler(ss *service.SpaceService, ts *service.TagService, sls *service.ShoppingListService) *SpaceHandler { +func NewSpaceHandler(ss *service.SpaceService, ts *service.TagService, sls *service.ShoppingListService, es *service.ExpenseService) *SpaceHandler { return &SpaceHandler{ - spaceService: ss, - tagService: ts, - listService: sls, + spaceService: ss, + tagService: ts, + listService: sls, + expenseService: es, } } @@ -73,7 +78,7 @@ func (h *SpaceHandler) ListsPage(w http.ResponseWriter, r *http.Request) { func (h *SpaceHandler) CreateList(w http.ResponseWriter, r *http.Request) { spaceID := r.PathValue("spaceID") - + err := r.ParseForm() if err != nil { http.Error(w, "Bad Request", http.StatusBadRequest) @@ -240,3 +245,111 @@ func (h *SpaceHandler) DeleteTag(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } +func (h *SpaceHandler) ExpensesPage(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + http.Error(w, "Space not found", http.StatusNotFound) + return + } + + expenses, err := h.expenseService.GetExpensesForSpace(spaceID) + if err != nil { + slog.Error("failed to get expenses for space", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + balance, err := h.expenseService.GetBalanceForSpace(spaceID) + if err != nil { + slog.Error("failed to get balance for space", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + tags, err := h.tagService.GetTagsForSpace(spaceID) + if err != nil { + slog.Error("failed to get tags for space", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + lists, err := h.listService.GetListsForSpace(spaceID) + if err != nil { + slog.Error("failed to get lists for space", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + ui.Render(w, r, pages.SpaceExpensesPage(space, expenses, balance, tags, lists)) +} + +func (h *SpaceHandler) CreateExpense(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + user := ctxkeys.User(r.Context()) + + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + + // --- Form Parsing --- + description := r.FormValue("description") + amountStr := r.FormValue("amount") + typeStr := r.FormValue("type") + dateStr := r.FormValue("date") + tagIDs := r.Form["tags"] // For multi-select + + // --- Validation & Conversion --- + if description == "" || amountStr == "" || typeStr == "" || dateStr == "" { + http.Error(w, "All fields are required.", http.StatusBadRequest) + return + } + + amountFloat, err := strconv.ParseFloat(amountStr, 64) + if err != nil { + http.Error(w, "Invalid amount format.", http.StatusBadRequest) + return + } + amountCents := int(amountFloat * 100) + + date, err := time.Parse("2006-01-02", dateStr) + if err != nil { + http.Error(w, "Invalid date format.", http.StatusBadRequest) + return + } + + expenseType := model.ExpenseType(typeStr) + if expenseType != model.ExpenseTypeExpense && expenseType != model.ExpenseTypeTopup { + http.Error(w, "Invalid transaction type.", http.StatusBadRequest) + return + } + + // --- DTO Creation & Service Call --- + dto := service.CreateExpenseDTO{ + SpaceID: spaceID, + UserID: user.ID, + Description: description, + Amount: amountCents, + Type: expenseType, + Date: date, + TagIDs: tagIDs, + ItemIDs: []string{}, // TODO: Add item IDs from form + } + + newExpense, err := h.expenseService.CreateExpense(dto) + if err != nil { + slog.Error("failed to create expense", "error", err) + http.Error(w, "Failed to create expense.", http.StatusInternalServerError) + return + } + + balance, err := h.expenseService.GetBalanceForSpace(spaceID) + if err != nil { + slog.Error("failed to get balance", "error", err, "space_id", spaceID) + // Fallback: return just the item if balance fails, but ideally we want both. + // For now we will just log and continue, potentially showing stale balance. + } + + ui.Render(w, r, pages.ExpenseCreatedResponse(newExpense, balance)) +} diff --git a/internal/model/expense.go b/internal/model/expense.go new file mode 100644 index 0000000..48713e3 --- /dev/null +++ b/internal/model/expense.go @@ -0,0 +1,39 @@ +package model + +import "time" + +type ExpenseType string + +const ( + ExpenseTypeExpense ExpenseType = "expense" + ExpenseTypeTopup ExpenseType = "topup" +) + +type Expense struct { + ID string `db:"id"` + SpaceID string `db:"space_id"` + CreatedBy string `db:"created_by"` + Description string `db:"description"` + AmountCents int `db:"amount_cents"` + Type ExpenseType `db:"type"` + Date time.Time `db:"date"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +type ExpenseTag struct { + ExpenseID string `db:"expense_id"` + TagID string `db:"tag_id"` +} + +type ExpenseItem struct { + ExpenseID string `db:"expense_id"` + ItemID string `db:"item_id"` +} + +type TagExpenseSummary struct { + TagID string `db:"tag_id"` + TagName string `db:"tag_name"` + TagColor string `db:"tag_color"` + TotalAmount int `db:"total_amount"` +} diff --git a/internal/model/file.go b/internal/model/file.go index 55641ad..9087082 100644 --- a/internal/model/file.go +++ b/internal/model/file.go @@ -9,8 +9,8 @@ const ( ) type File struct { - ID string `db:"id"` - UserID string `db:"user_id"` // Who owns/created this file + ID string `db:"id"` + UserID string `db:"user_id"` // Who owns/created this file OwnerType string `db:"owner_type"` // "user", "profile", etc. - the entity that owns the file OwnerID string `db:"owner_id"` // Polymorphic FK Type string `db:"type"` diff --git a/internal/model/profile.go b/internal/model/profile.go index 02ea260..32ef07a 100644 --- a/internal/model/profile.go +++ b/internal/model/profile.go @@ -3,8 +3,8 @@ package model import "time" type Profile struct { - ID string `db:"id"` - UserID string `db:"user_id"` + ID string `db:"id"` + UserID string `db:"user_id"` Name string `db:"name"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` diff --git a/internal/model/shopping_list.go b/internal/model/shopping_list.go index 57a9484..ff0f8d8 100644 --- a/internal/model/shopping_list.go +++ b/internal/model/shopping_list.go @@ -11,11 +11,11 @@ type ShoppingList struct { } type ListItem struct { - ID string `db:"id"` - ListID string `db:"list_id"` - Name string `db:"name"` - IsChecked bool `db:"is_checked"` - CreatedBy string `db:"created_by"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + ID string `db:"id"` + ListID string `db:"list_id"` + Name string `db:"name"` + IsChecked bool `db:"is_checked"` + CreatedBy string `db:"created_by"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` } diff --git a/internal/model/token.go b/internal/model/token.go index d76d10f..0746c7d 100644 --- a/internal/model/token.go +++ b/internal/model/token.go @@ -5,8 +5,8 @@ import ( ) type Token struct { - ID string `db:"id"` - UserID string `db:"user_id"` + ID string `db:"id"` + UserID string `db:"user_id"` Type string `db:"type"` // "email_verify" or "password_reset" Token string `db:"token"` ExpiresAt time.Time `db:"expires_at"` diff --git a/internal/model/user.go b/internal/model/user.go index a9f39d5..a823efd 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -3,7 +3,7 @@ package model import "time" type User struct { - ID string `db:"id"` + ID string `db:"id"` Email string `db:"email"` // Allow null for passwordless users PasswordHash *string `db:"password_hash"` diff --git a/internal/repository/expense.go b/internal/repository/expense.go new file mode 100644 index 0000000..60d89b9 --- /dev/null +++ b/internal/repository/expense.go @@ -0,0 +1,111 @@ +package repository + +import ( + "database/sql" + "errors" + "time" + + "git.juancwu.dev/juancwu/budgit/internal/model" + "github.com/jmoiron/sqlx" +) + +var ( + ErrExpenseNotFound = errors.New("expense not found") +) + +type ExpenseRepository interface { + Create(expense *model.Expense, tagIDs []string, itemIDs []string) error + GetByID(id string) (*model.Expense, error) + GetBySpaceID(spaceID string) ([]*model.Expense, error) + GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error) +} + +type expenseRepository struct { + db *sqlx.DB +} + +func NewExpenseRepository(db *sqlx.DB) ExpenseRepository { + return &expenseRepository{db: db} +} + +func (r *expenseRepository) Create(expense *model.Expense, tagIDs []string, itemIDs []string) error { + tx, err := r.db.Beginx() + if err != nil { + return err + } + defer tx.Rollback() + + // Insert Expense + queryExpense := `INSERT INTO expenses (id, space_id, created_by, description, amount_cents, type, date, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9);` + _, err = tx.Exec(queryExpense, expense.ID, expense.SpaceID, expense.CreatedBy, expense.Description, expense.AmountCents, expense.Type, expense.Date, expense.CreatedAt, expense.UpdatedAt) + if err != nil { + return err + } + + // Insert Tags + if len(tagIDs) > 0 { + queryTags := `INSERT INTO expense_tags (expense_id, tag_id) VALUES ($1, $2);` + for _, tagID := range tagIDs { + _, err := tx.Exec(queryTags, expense.ID, tagID) + if err != nil { + return err + } + } + } + + // Insert Items + if len(itemIDs) > 0 { + queryItems := `INSERT INTO expense_items (expense_id, item_id) VALUES ($1, $2);` + for _, itemID := range itemIDs { + _, err := tx.Exec(queryItems, expense.ID, itemID) + if err != nil { + return err + } + } + } + + return tx.Commit() +} + +func (r *expenseRepository) GetByID(id string) (*model.Expense, error) { + expense := &model.Expense{} + query := `SELECT * FROM expenses WHERE id = $1;` + err := r.db.Get(expense, query, id) + if err == sql.ErrNoRows { + return nil, ErrExpenseNotFound + } + return expense, err +} + +func (r *expenseRepository) GetBySpaceID(spaceID string) ([]*model.Expense, error) { + var expenses []*model.Expense + query := `SELECT * FROM expenses WHERE space_id = $1 ORDER BY date DESC, created_at DESC;` + err := r.db.Select(&expenses, query, spaceID) + if err != nil { + return nil, err + } + return expenses, nil +} + +func (r *expenseRepository) GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error) { + var summaries []*model.TagExpenseSummary + query := ` + SELECT + t.id as tag_id, + t.name as tag_name, + t.color as tag_color, + SUM(e.amount_cents) as total_amount + FROM expenses e + JOIN expense_tags et ON e.id = et.expense_id + JOIN tags t ON et.tag_id = t.id + WHERE e.space_id = $1 AND e.type = 'expense' AND e.date >= $2 AND e.date <= $3 + GROUP BY t.id, t.name, t.color + ORDER BY total_amount DESC; + ` + err := r.db.Select(&summaries, query, spaceID, fromDate, toDate) + if err != nil { + return nil, err + } + return summaries, nil +} diff --git a/internal/repository/tag.go b/internal/repository/tag.go index 947dc01..09446ee 100644 --- a/internal/repository/tag.go +++ b/internal/repository/tag.go @@ -11,8 +11,8 @@ import ( ) var ( - ErrTagNotFound = errors.New("tag not found") - ErrDuplicateTagName = errors.New("tag with that name already exists in this space") + ErrTagNotFound = errors.New("tag not found") + ErrDuplicateTagName = errors.New("tag with that name already exists in this space") ) type TagRepository interface { diff --git a/internal/routes/routes.go b/internal/routes/routes.go index a5a52c9..9f99ab6 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -14,7 +14,7 @@ func SetupRoutes(a *app.App) http.Handler { auth := handler.NewAuthHandler(a.AuthService) home := handler.NewHomeHandler() dashboard := handler.NewDashboardHandler() - space := handler.NewSpaceHandler(a.SpaceService, a.TagService, a.ShoppingListService) + space := handler.NewSpaceHandler(a.SpaceService, a.TagService, a.ShoppingListService, a.ExpenseService) mux := http.NewServeMux() @@ -94,6 +94,15 @@ func SetupRoutes(a *app.App) http.Handler { deleteTagWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(deleteTagHandler) mux.Handle("DELETE /app/spaces/{spaceID}/tags/{tagID}", deleteTagWithAccess) + // Expense routes + expensesPageHandler := middleware.RequireAuth(space.ExpensesPage) + expensesPageWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(expensesPageHandler) + mux.Handle("GET /app/spaces/{spaceID}/expenses", expensesPageWithAccess) + + createExpenseHandler := middleware.RequireAuth(space.CreateExpense) + createExpenseWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createExpenseHandler) + mux.Handle("POST /app/spaces/{spaceID}/expenses", createExpenseWithAccess) + // 404 mux.HandleFunc("/{path...}", home.NotFoundPage) diff --git a/internal/service/expense.go b/internal/service/expense.go new file mode 100644 index 0000000..7b9adab --- /dev/null +++ b/internal/service/expense.go @@ -0,0 +1,84 @@ +package service + +import ( + "fmt" + "time" + + "git.juancwu.dev/juancwu/budgit/internal/model" + "git.juancwu.dev/juancwu/budgit/internal/repository" + "github.com/google/uuid" +) + +type CreateExpenseDTO struct { + SpaceID string + UserID string + Description string + Amount int + Type model.ExpenseType + Date time.Time + TagIDs []string + ItemIDs []string +} + +type ExpenseService struct { + expenseRepo repository.ExpenseRepository +} + +func NewExpenseService(expenseRepo repository.ExpenseRepository) *ExpenseService { + return &ExpenseService{expenseRepo: expenseRepo} +} + +func (s *ExpenseService) CreateExpense(dto CreateExpenseDTO) (*model.Expense, error) { + if dto.Description == "" { + return nil, fmt.Errorf("expense description cannot be empty") + } + if dto.Amount <= 0 { + return nil, fmt.Errorf("amount must be positive") + } + + now := time.Now() + expense := &model.Expense{ + ID: uuid.NewString(), + SpaceID: dto.SpaceID, + CreatedBy: dto.UserID, + Description: dto.Description, + AmountCents: dto.Amount, + Type: dto.Type, + Date: dto.Date, + CreatedAt: now, + UpdatedAt: now, + } + + err := s.expenseRepo.Create(expense, dto.TagIDs, dto.ItemIDs) + if err != nil { + return nil, err + } + + return expense, nil +} + +func (s *ExpenseService) GetExpensesForSpace(spaceID string) ([]*model.Expense, error) { + return s.expenseRepo.GetBySpaceID(spaceID) +} + +func (s *ExpenseService) GetBalanceForSpace(spaceID string) (int, error) { + expenses, err := s.expenseRepo.GetBySpaceID(spaceID) + if err != nil { + return 0, err + } + + var balance int + for _, expense := range expenses { + if expense.Type == model.ExpenseTypeExpense { + balance -= expense.AmountCents + } else if expense.Type == model.ExpenseTypeTopup { + balance += expense.AmountCents + } + } + + return balance, nil +} + +func (s *ExpenseService) GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error) { + return s.expenseRepo.GetExpensesByTag(spaceID, fromDate, toDate) +} diff --git a/internal/ui/components/expense/expense.templ b/internal/ui/components/expense/expense.templ new file mode 100644 index 0000000..2eac8cb --- /dev/null +++ b/internal/ui/components/expense/expense.templ @@ -0,0 +1,94 @@ +package expense + +import ( + "fmt" + "git.juancwu.dev/juancwu/budgit/internal/model" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/input" + "time" +) + +templ AddExpenseForm(space *model.Space, tags []*model.Tag, lists []*model.ShoppingList) { +
+ @csrf.Token() + // Type +
+ + +
+ + // Description +
+ + @input.Input(input.Props{ + Name: "description", + ID: "description", + Attributes: templ.Attributes{"required": "true"}, + }) +
+ + // Amount +
+ + @input.Input(input.Props{ + Name: "amount", + ID: "amount", + Type: "number", + Attributes: templ.Attributes{"step": "0.01", "required": "true"}, + }) +
+ + // Date +
+ + @input.Input(input.Props{ + Name: "date", + ID: "date", + Type: "date", + Value: time.Now().Format("2006-01-02"), + Attributes: templ.Attributes{"required": "true"}, + }) +
+ + // Tags + if len(tags) > 0 { +
+ + +
+ } + + // TODO: Shopping list items selector + +
+ @button.Button(button.Props{ Type: button.TypeSubmit }) { + Save Transaction + } +
+
+} + +templ BalanceCard(balance int, oob bool) { +
+

Current Balance

+

+ { fmt.Sprintf("$%.2f", float64(balance)/100.0) } +

+
+} diff --git a/internal/ui/layouts/space.templ b/internal/ui/layouts/space.templ index ca888f5..ed0bf38 100644 --- a/internal/ui/layouts/space.templ +++ b/internal/ui/layouts/space.templ @@ -70,6 +70,16 @@ templ Space(title string, space *model.Space) { Tags } } + @sidebar.MenuItem() { + @sidebar.MenuButton(sidebar.MenuButtonProps{ + Href: "/app/spaces/" + space.ID + "/expenses", + IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/expenses", + Tooltip: "Expenses", + }) { + @icon.Landmark(icon.Props{Class: "size-4"}) + Expenses + } + } } } } diff --git a/internal/ui/pages/app_space_expenses.templ b/internal/ui/pages/app_space_expenses.templ new file mode 100644 index 0000000..2838ea5 --- /dev/null +++ b/internal/ui/pages/app_space_expenses.templ @@ -0,0 +1,77 @@ +package pages + +import ( + "fmt" + "git.juancwu.dev/juancwu/budgit/internal/model" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/expense" + "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" +) + +templ SpaceExpensesPage(space *model.Space, expenses []*model.Expense, balance int, tags []*model.Tag, lists []*model.ShoppingList) { + @layouts.Space("Expenses", space) { +
+
+

Expenses

+ @dialog.Dialog(dialog.Props{ ID: "add-expense-dialog" }) { + @dialog.Trigger() { + @button.Button() { + Add Expense + } + } + @dialog.Content() { + @dialog.Header() { + @dialog.Title() { Add Transaction } + @dialog.Description() { + Add a new expense or top-up to your space. + } + } + @expense.AddExpenseForm(space, tags, lists) + } + } +
+ + // Balance Card + @expense.BalanceCard(balance, false) + + // List of expenses +
+

History

+
+ if len(expenses) == 0 { +

No expenses recorded yet.

+ } + for _, expense := range expenses { + @ExpenseListItem(expense) + } +
+
+
+ } +} + +templ ExpenseListItem(expense *model.Expense) { +
+
+

{ expense.Description }

+

{ expense.Date.Format("Jan 02, 2006") }

+
+
+ if expense.Type == model.ExpenseTypeExpense { +

+ - { fmt.Sprintf("$%.2f", float64(expense.AmountCents)/100.0) } +

+ } else { +

+ + { fmt.Sprintf("$%.2f", float64(expense.AmountCents)/100.0) } +

+ } +
+
+} + +templ ExpenseCreatedResponse(newExpense *model.Expense, balance int) { + @ExpenseListItem(newExpense) + @expense.BalanceCard(balance, true) +} \ No newline at end of file