feat: extend expense card info and allow edit/delete of expense
This commit is contained in:
parent
1c210bde67
commit
99a002c607
7 changed files with 516 additions and 24 deletions
|
|
@ -38,6 +38,20 @@ func NewSpaceHandler(ss *service.SpaceService, ts *service.TagService, sls *serv
|
|||
}
|
||||
}
|
||||
|
||||
// getExpenseForSpace fetches an expense and verifies it belongs to the given space.
|
||||
func (h *SpaceHandler) getExpenseForSpace(w http.ResponseWriter, spaceID, expenseID string) *model.Expense {
|
||||
exp, err := h.expenseService.GetExpense(expenseID)
|
||||
if err != nil {
|
||||
http.Error(w, "Expense not found", http.StatusNotFound)
|
||||
return nil
|
||||
}
|
||||
if exp.SpaceID != spaceID {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return nil
|
||||
}
|
||||
return exp
|
||||
}
|
||||
|
||||
// getListForSpace fetches a shopping list and verifies it belongs to the given space.
|
||||
// Returns the list on success, or writes an error response and returns nil.
|
||||
func (h *SpaceHandler) getListForSpace(w http.ResponseWriter, spaceID, listID string) *model.ShoppingList {
|
||||
|
|
@ -401,7 +415,7 @@ func (h *SpaceHandler) ExpensesPage(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
expenses, err := h.expenseService.GetExpensesForSpace(spaceID)
|
||||
expenses, err := h.expenseService.GetExpensesWithTagsForSpace(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)
|
||||
|
|
@ -558,7 +572,136 @@ func (h *SpaceHandler) CreateExpense(w http.ResponseWriter, r *http.Request) {
|
|||
slog.Error("failed to get balance", "error", err, "space_id", spaceID)
|
||||
}
|
||||
|
||||
ui.Render(w, r, pages.ExpenseCreatedResponse(newExpense, balance))
|
||||
// Build tags for the newly created expense
|
||||
tagsMap, _ := h.expenseService.GetTagsByExpenseIDs([]string{newExpense.ID})
|
||||
newExpenseWithTags := &model.ExpenseWithTags{
|
||||
Expense: *newExpense,
|
||||
Tags: tagsMap[newExpense.ID],
|
||||
}
|
||||
|
||||
ui.Render(w, r, pages.ExpenseCreatedResponse(spaceID, newExpenseWithTags, balance))
|
||||
}
|
||||
|
||||
func (h *SpaceHandler) UpdateExpense(w http.ResponseWriter, r *http.Request) {
|
||||
spaceID := r.PathValue("spaceID")
|
||||
expenseID := r.PathValue("expenseID")
|
||||
|
||||
if h.getExpenseForSpace(w, spaceID, expenseID) == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
description := r.FormValue("description")
|
||||
amountStr := r.FormValue("amount")
|
||||
typeStr := r.FormValue("type")
|
||||
dateStr := r.FormValue("date")
|
||||
tagNames := r.Form["tags"]
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Tag processing (same as CreateExpense)
|
||||
existingTags, 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
|
||||
}
|
||||
|
||||
existingTagsMap := make(map[string]string)
|
||||
for _, t := range existingTags {
|
||||
existingTagsMap[t.Name] = t.ID
|
||||
}
|
||||
|
||||
var finalTagIDs []string
|
||||
processedTags := make(map[string]bool)
|
||||
|
||||
for _, rawTagName := range tagNames {
|
||||
tagName := service.NormalizeTagName(rawTagName)
|
||||
if tagName == "" || processedTags[tagName] {
|
||||
continue
|
||||
}
|
||||
|
||||
if id, exists := existingTagsMap[tagName]; exists {
|
||||
finalTagIDs = append(finalTagIDs, id)
|
||||
} else {
|
||||
newTag, err := h.tagService.CreateTag(spaceID, tagName, nil)
|
||||
if err != nil {
|
||||
slog.Error("failed to create new tag from expense form", "error", err, "tag_name", tagName)
|
||||
continue
|
||||
}
|
||||
finalTagIDs = append(finalTagIDs, newTag.ID)
|
||||
existingTagsMap[tagName] = newTag.ID
|
||||
}
|
||||
processedTags[tagName] = true
|
||||
}
|
||||
|
||||
dto := service.UpdateExpenseDTO{
|
||||
ID: expenseID,
|
||||
SpaceID: spaceID,
|
||||
Description: description,
|
||||
Amount: amountCents,
|
||||
Type: expenseType,
|
||||
Date: date,
|
||||
TagIDs: finalTagIDs,
|
||||
}
|
||||
|
||||
updatedExpense, err := h.expenseService.UpdateExpense(dto)
|
||||
if err != nil {
|
||||
slog.Error("failed to update expense", "error", err)
|
||||
http.Error(w, "Failed to update expense.", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
tagsMap, _ := h.expenseService.GetTagsByExpenseIDs([]string{updatedExpense.ID})
|
||||
expWithTags := &model.ExpenseWithTags{
|
||||
Expense: *updatedExpense,
|
||||
Tags: tagsMap[updatedExpense.ID],
|
||||
}
|
||||
|
||||
ui.Render(w, r, pages.ExpenseListItem(spaceID, expWithTags))
|
||||
}
|
||||
|
||||
func (h *SpaceHandler) DeleteExpense(w http.ResponseWriter, r *http.Request) {
|
||||
spaceID := r.PathValue("spaceID")
|
||||
expenseID := r.PathValue("expenseID")
|
||||
|
||||
if h.getExpenseForSpace(w, spaceID, expenseID) == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.expenseService.DeleteExpense(expenseID, spaceID); err != nil {
|
||||
slog.Error("failed to delete expense", "error", err, "expense_id", expenseID)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *SpaceHandler) CreateInvite(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -637,14 +780,14 @@ func (h *SpaceHandler) GetBalanceCard(w http.ResponseWriter, r *http.Request) {
|
|||
func (h *SpaceHandler) GetExpensesList(w http.ResponseWriter, r *http.Request) {
|
||||
spaceID := r.PathValue("spaceID")
|
||||
|
||||
expenses, err := h.expenseService.GetExpensesForSpace(spaceID)
|
||||
expenses, err := h.expenseService.GetExpensesWithTagsForSpace(spaceID)
|
||||
if err != nil {
|
||||
slog.Error("failed to get expenses", "error", err, "space_id", spaceID)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
ui.Render(w, r, pages.ExpensesListContent(expenses))
|
||||
ui.Render(w, r, pages.ExpensesListContent(spaceID, expenses))
|
||||
}
|
||||
|
||||
func (h *SpaceHandler) GetShoppingListItems(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,11 @@ type Expense struct {
|
|||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
type ExpenseWithTags struct {
|
||||
Expense
|
||||
Tags []*Tag
|
||||
}
|
||||
|
||||
type ExpenseTag struct {
|
||||
ExpenseID string `db:"expense_id"`
|
||||
TagID string `db:"tag_id"`
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ type ExpenseRepository interface {
|
|||
GetByID(id string) (*model.Expense, error)
|
||||
GetBySpaceID(spaceID string) ([]*model.Expense, error)
|
||||
GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error)
|
||||
GetTagsByExpenseIDs(expenseIDs []string) (map[string][]*model.Tag, error)
|
||||
Update(expense *model.Expense, tagIDs []string) error
|
||||
Delete(id string) error
|
||||
}
|
||||
|
||||
type expenseRepository struct {
|
||||
|
|
@ -91,10 +94,10 @@ func (r *expenseRepository) GetBySpaceID(spaceID string) ([]*model.Expense, erro
|
|||
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,
|
||||
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
|
||||
|
|
@ -109,3 +112,81 @@ func (r *expenseRepository) GetExpensesByTag(spaceID string, fromDate, toDate ti
|
|||
}
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
func (r *expenseRepository) GetTagsByExpenseIDs(expenseIDs []string) (map[string][]*model.Tag, error) {
|
||||
if len(expenseIDs) == 0 {
|
||||
return make(map[string][]*model.Tag), nil
|
||||
}
|
||||
|
||||
type row struct {
|
||||
ExpenseID string `db:"expense_id"`
|
||||
ID string `db:"id"`
|
||||
SpaceID string `db:"space_id"`
|
||||
Name string `db:"name"`
|
||||
Color *string `db:"color"`
|
||||
}
|
||||
|
||||
query, args, err := sqlx.In(`
|
||||
SELECT et.expense_id, t.id, t.space_id, t.name, t.color
|
||||
FROM expense_tags et
|
||||
JOIN tags t ON et.tag_id = t.id
|
||||
WHERE et.expense_id IN (?)
|
||||
ORDER BY t.name;
|
||||
`, expenseIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query = r.db.Rebind(query)
|
||||
|
||||
var rows []row
|
||||
if err := r.db.Select(&rows, query, args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[string][]*model.Tag)
|
||||
for _, rw := range rows {
|
||||
result[rw.ExpenseID] = append(result[rw.ExpenseID], &model.Tag{
|
||||
ID: rw.ID,
|
||||
SpaceID: rw.SpaceID,
|
||||
Name: rw.Name,
|
||||
Color: rw.Color,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *expenseRepository) Update(expense *model.Expense, tagIDs []string) error {
|
||||
tx, err := r.db.Beginx()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
query := `UPDATE expenses SET description = $1, amount_cents = $2, type = $3, date = $4, updated_at = $5 WHERE id = $6;`
|
||||
_, err = tx.Exec(query, expense.Description, expense.AmountCents, expense.Type, expense.Date, expense.UpdatedAt, expense.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Replace tags: delete all existing, re-insert
|
||||
_, err = tx.Exec(`DELETE FROM expense_tags WHERE expense_id = $1;`, expense.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(tagIDs) > 0 {
|
||||
insertTag := `INSERT INTO expense_tags (expense_id, tag_id) VALUES ($1, $2);`
|
||||
for _, tagID := range tagIDs {
|
||||
if _, err := tx.Exec(insertTag, expense.ID, tagID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (r *expenseRepository) Delete(id string) error {
|
||||
_, err := r.db.Exec(`DELETE FROM expenses WHERE id = $1;`, id)
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,6 +122,14 @@ func SetupRoutes(a *app.App) http.Handler {
|
|||
createExpenseWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createExpenseHandler)
|
||||
mux.Handle("POST /app/spaces/{spaceID}/expenses", createExpenseWithAccess)
|
||||
|
||||
updateExpenseHandler := middleware.RequireAuth(space.UpdateExpense)
|
||||
updateExpenseWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(updateExpenseHandler)
|
||||
mux.Handle("PATCH /app/spaces/{spaceID}/expenses/{expenseID}", updateExpenseWithAccess)
|
||||
|
||||
deleteExpenseHandler := middleware.RequireAuth(space.DeleteExpense)
|
||||
deleteExpenseWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(deleteExpenseHandler)
|
||||
mux.Handle("DELETE /app/spaces/{spaceID}/expenses/{expenseID}", deleteExpenseWithAccess)
|
||||
|
||||
// Component routes (HTMX updates)
|
||||
balanceCardHandler := middleware.RequireAuth(space.GetBalanceCard)
|
||||
balanceCardWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(balanceCardHandler)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,16 @@ type CreateExpenseDTO struct {
|
|||
ItemIDs []string
|
||||
}
|
||||
|
||||
type UpdateExpenseDTO struct {
|
||||
ID string
|
||||
SpaceID string
|
||||
Description string
|
||||
Amount int
|
||||
Type model.ExpenseType
|
||||
Date time.Time
|
||||
TagIDs []string
|
||||
}
|
||||
|
||||
type ExpenseService struct {
|
||||
expenseRepo repository.ExpenseRepository
|
||||
eventBus *event.Broker
|
||||
|
|
@ -95,3 +105,83 @@ func (s *ExpenseService) GetBalanceForSpace(spaceID string) (int, error) {
|
|||
func (s *ExpenseService) GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error) {
|
||||
return s.expenseRepo.GetExpensesByTag(spaceID, fromDate, toDate)
|
||||
}
|
||||
|
||||
func (s *ExpenseService) GetExpensesWithTagsForSpace(spaceID string) ([]*model.ExpenseWithTags, error) {
|
||||
expenses, err := s.expenseRepo.GetBySpaceID(spaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ids := make([]string, len(expenses))
|
||||
for i, e := range expenses {
|
||||
ids[i] = e.ID
|
||||
}
|
||||
|
||||
tagsMap, err := s.expenseRepo.GetTagsByExpenseIDs(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make([]*model.ExpenseWithTags, len(expenses))
|
||||
for i, e := range expenses {
|
||||
result[i] = &model.ExpenseWithTags{
|
||||
Expense: *e,
|
||||
Tags: tagsMap[e.ID],
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *ExpenseService) GetExpense(id string) (*model.Expense, error) {
|
||||
return s.expenseRepo.GetByID(id)
|
||||
}
|
||||
|
||||
func (s *ExpenseService) GetTagsByExpenseIDs(expenseIDs []string) (map[string][]*model.Tag, error) {
|
||||
return s.expenseRepo.GetTagsByExpenseIDs(expenseIDs)
|
||||
}
|
||||
|
||||
func (s *ExpenseService) UpdateExpense(dto UpdateExpenseDTO) (*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")
|
||||
}
|
||||
|
||||
existing, err := s.expenseRepo.GetByID(dto.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existing.Description = dto.Description
|
||||
existing.AmountCents = dto.Amount
|
||||
existing.Type = dto.Type
|
||||
existing.Date = dto.Date
|
||||
existing.UpdatedAt = time.Now()
|
||||
|
||||
if err := s.expenseRepo.Update(existing, dto.TagIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
balance, _ := s.GetBalanceForSpace(dto.SpaceID)
|
||||
s.eventBus.Publish(dto.SpaceID, "balance_changed", map[string]interface{}{
|
||||
"balance": balance,
|
||||
})
|
||||
s.eventBus.Publish(dto.SpaceID, "expenses_updated", nil)
|
||||
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func (s *ExpenseService) DeleteExpense(id string, spaceID string) error {
|
||||
if err := s.expenseRepo.Delete(id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
balance, _ := s.GetBalanceForSpace(spaceID)
|
||||
s.eventBus.Publish(spaceID, "balance_changed", map[string]interface{}{
|
||||
"balance": balance,
|
||||
})
|
||||
s.eventBus.Publish(spaceID, "expenses_updated", nil)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -209,6 +209,106 @@ templ AddExpenseForm(space *model.Space, tags []*model.Tag, listsWithItems []mod
|
|||
</form>
|
||||
}
|
||||
|
||||
templ EditExpenseForm(spaceID string, exp *model.ExpenseWithTags) {
|
||||
{{ editDialogID := "edit-expense-" + exp.ID }}
|
||||
{{ tagValues := make([]string, len(exp.Tags)) }}
|
||||
for i, t := range exp.Tags {
|
||||
{{ tagValues[i] = t.Name }}
|
||||
}
|
||||
<form
|
||||
hx-patch={ fmt.Sprintf("/app/spaces/%s/expenses/%s", spaceID, exp.ID) }
|
||||
hx-target={ "#expense-" + exp.ID }
|
||||
hx-swap="outerHTML"
|
||||
_={ "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + editDialogID + "') end" }
|
||||
class="space-y-4"
|
||||
>
|
||||
@csrf.Token()
|
||||
// Type
|
||||
<div class="flex gap-4">
|
||||
<div class="flex items-start gap-3">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "edit-type-expense-" + exp.ID,
|
||||
Name: "type",
|
||||
Value: "expense",
|
||||
Checked: exp.Type == model.ExpenseTypeExpense,
|
||||
})
|
||||
<div class="grid gap-2">
|
||||
@label.Label(label.Props{For: "edit-type-expense-" + exp.ID}) {
|
||||
Expense
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "edit-type-topup-" + exp.ID,
|
||||
Name: "type",
|
||||
Value: "topup",
|
||||
Checked: exp.Type == model.ExpenseTypeTopup,
|
||||
})
|
||||
<div class="grid gap-2">
|
||||
@label.Label(label.Props{For: "edit-type-topup-" + exp.ID}) {
|
||||
Top-up
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
// Description
|
||||
<div>
|
||||
@label.Label(label.Props{For: "edit-description-" + exp.ID}) {
|
||||
Description
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "description",
|
||||
ID: "edit-description-" + exp.ID,
|
||||
Value: exp.Description,
|
||||
Attributes: templ.Attributes{"required": "true"},
|
||||
})
|
||||
</div>
|
||||
// Amount
|
||||
<div>
|
||||
@label.Label(label.Props{For: "edit-amount-" + exp.ID}) {
|
||||
Amount
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "amount",
|
||||
ID: "edit-amount-" + exp.ID,
|
||||
Type: "number",
|
||||
Value: fmt.Sprintf("%.2f", float64(exp.AmountCents)/100.0),
|
||||
Attributes: templ.Attributes{"step": "0.01", "required": "true"},
|
||||
})
|
||||
</div>
|
||||
// Date
|
||||
<div>
|
||||
@label.Label(label.Props{For: "edit-date-" + exp.ID}) {
|
||||
Date
|
||||
}
|
||||
@datepicker.DatePicker(datepicker.Props{
|
||||
ID: "edit-date-" + exp.ID,
|
||||
Name: "date",
|
||||
Value: exp.Date,
|
||||
Attributes: templ.Attributes{"required": "true"},
|
||||
})
|
||||
</div>
|
||||
// Tags
|
||||
<div>
|
||||
@label.Label(label.Props{For: "edit-tags-" + exp.ID}) {
|
||||
Tags
|
||||
}
|
||||
@tagsinput.TagsInput(tagsinput.Props{
|
||||
ID: "edit-tags-" + exp.ID,
|
||||
Name: "tags",
|
||||
Value: tagValues,
|
||||
Placeholder: "Add tags (press enter)",
|
||||
})
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
@button.Button(button.Props{Type: button.TypeSubmit}) {
|
||||
Save
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
templ BalanceCard(spaceID string, balance int, oob bool) {
|
||||
<div
|
||||
id="balance-card"
|
||||
|
|
|
|||
|
|
@ -3,13 +3,15 @@ package pages
|
|||
import (
|
||||
"fmt"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
|
||||
"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/components/icon"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
||||
)
|
||||
|
||||
templ SpaceExpensesPage(space *model.Space, expenses []*model.Expense, balance int, tags []*model.Tag, listsWithItems []model.ListWithUncheckedItems) {
|
||||
templ SpaceExpensesPage(space *model.Space, expenses []*model.ExpenseWithTags, balance int, tags []*model.Tag, listsWithItems []model.ListWithUncheckedItems) {
|
||||
@layouts.Space("Expenses", space) {
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
|
|
@ -42,45 +44,108 @@ templ SpaceExpensesPage(space *model.Space, expenses []*model.Expense, balance i
|
|||
hx-trigger="sse:expenses_updated"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
@ExpensesListContent(expenses)
|
||||
@ExpensesListContent(space.ID, expenses)
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ ExpensesListContent(expenses []*model.Expense) {
|
||||
templ ExpensesListContent(spaceID string, expenses []*model.ExpenseWithTags) {
|
||||
<h2 class="text-lg font-semibold p-4">History</h2>
|
||||
<div id="expenses-list" class="divide-y">
|
||||
if len(expenses) == 0 {
|
||||
<p class="p-4 text-sm text-muted-foreground">No expenses recorded yet.</p>
|
||||
}
|
||||
for _, expense := range expenses {
|
||||
@ExpenseListItem(expense)
|
||||
for _, exp := range expenses {
|
||||
@ExpenseListItem(spaceID, exp)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ ExpenseListItem(expense *model.Expense) {
|
||||
<div class="p-4 flex justify-between items-center">
|
||||
<div>
|
||||
<p class="font-medium">{ expense.Description }</p>
|
||||
<p class="text-sm text-muted-foreground">{ expense.Date.Format("Jan 02, 2006") }</p>
|
||||
templ ExpenseListItem(spaceID string, exp *model.ExpenseWithTags) {
|
||||
<div id={ "expense-" + exp.ID } class="p-4 flex justify-between items-start gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-medium">{ exp.Description }</p>
|
||||
<p class="text-sm text-muted-foreground">{ exp.Date.Format("Jan 02, 2006") }</p>
|
||||
if len(exp.Tags) > 0 {
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
for _, t := range exp.Tags {
|
||||
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
|
||||
{ t.Name }
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
if expense.Type == model.ExpenseTypeExpense {
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
if exp.Type == model.ExpenseTypeExpense {
|
||||
<p class="font-bold text-destructive">
|
||||
- { fmt.Sprintf("$%.2f", float64(expense.AmountCents)/100.0) }
|
||||
- { fmt.Sprintf("$%.2f", float64(exp.AmountCents)/100.0) }
|
||||
</p>
|
||||
} else {
|
||||
<p class="font-bold text-green-500">
|
||||
+ { fmt.Sprintf("$%.2f", float64(expense.AmountCents)/100.0) }
|
||||
+ { fmt.Sprintf("$%.2f", float64(exp.AmountCents)/100.0) }
|
||||
</p>
|
||||
}
|
||||
// Edit button
|
||||
@dialog.Dialog(dialog.Props{ID: "edit-expense-" + exp.ID}) {
|
||||
@dialog.Trigger() {
|
||||
@button.Button(button.Props{Variant: button.VariantGhost, Size: button.SizeIcon, Class: "size-7"}) {
|
||||
@icon.Pencil(icon.Props{Size: 14})
|
||||
}
|
||||
}
|
||||
@dialog.Content() {
|
||||
@dialog.Header() {
|
||||
@dialog.Title() {
|
||||
Edit Transaction
|
||||
}
|
||||
@dialog.Description() {
|
||||
Update the details of this transaction.
|
||||
}
|
||||
}
|
||||
@expense.EditExpenseForm(spaceID, exp)
|
||||
}
|
||||
}
|
||||
// Delete button
|
||||
@dialog.Dialog(dialog.Props{ID: "del-expense-" + exp.ID}) {
|
||||
@dialog.Trigger() {
|
||||
@button.Button(button.Props{Variant: button.VariantGhost, Size: button.SizeIcon, Class: "size-7"}) {
|
||||
@icon.Trash2(icon.Props{Size: 14})
|
||||
}
|
||||
}
|
||||
@dialog.Content() {
|
||||
@dialog.Header() {
|
||||
@dialog.Title() {
|
||||
Delete Transaction
|
||||
}
|
||||
@dialog.Description() {
|
||||
Are you sure you want to delete "{ exp.Description }"? This action cannot be undone.
|
||||
}
|
||||
}
|
||||
@dialog.Footer() {
|
||||
@dialog.Close() {
|
||||
@button.Button(button.Props{Variant: button.VariantOutline}) {
|
||||
Cancel
|
||||
}
|
||||
}
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantDestructive,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-delete": fmt.Sprintf("/app/spaces/%s/expenses/%s", spaceID, exp.ID),
|
||||
"hx-target": "#expense-" + exp.ID,
|
||||
"hx-swap": "outerHTML",
|
||||
},
|
||||
}) {
|
||||
Delete
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ ExpenseCreatedResponse(newExpense *model.Expense, balance int) {
|
||||
@ExpenseListItem(newExpense)
|
||||
templ ExpenseCreatedResponse(spaceID string, newExpense *model.ExpenseWithTags, balance int) {
|
||||
@ExpenseListItem(spaceID, newExpense)
|
||||
@expense.BalanceCard(newExpense.SpaceID, balance, true)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue