feat: add expense editing - view and edit all existing receipts

- Added ✏️ edit button on every expense in the list
- Clicking loads the receipt form pre-filled with the expense data
- Changes are saved via PUT /expenses/{id}
- Receipt form auto-switches between create (POST) and edit (PUT) mode
- Full HTMX multi-target response: form resets + list refreshes
This commit is contained in:
Claus Lohmar 2026-05-30 12:43:33 +00:00
parent e00c3373cb
commit ded4954c72
7 changed files with 211 additions and 1 deletions

View file

@ -336,3 +336,39 @@ func GetExpensesByEvent(db *sql.DB, eventID string) ([]Expense, error) {
} }
return expenses, rows.Err() return expenses, rows.Err()
} }
// GetExpenseByID returns a single expense by its ID, or nil if not found.
func GetExpenseByID(db *sql.DB, id string) (*Expense, error) {
row := db.QueryRow(
`SELECT id, event_id, amount, currency, converted_amount, base_currency,
merchant, category, COALESCE(description, ''), date, image_path, created_at
FROM expenses WHERE id = ?`, id)
e := &Expense{}
if err := row.Scan(
&e.ID, &e.EventID, &e.Amount, &e.Currency, &e.ConvertedAmount, &e.BaseCurrency,
&e.Merchant, &e.Category, &e.Description, &e.Date, &e.ImagePath, &e.CreatedAt,
); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
log.Printf("ERROR [%s] database: GetExpenseByID(%s): %v",
time.Now().Format(time.RFC3339), id, err)
return nil, err
}
return e, nil
}
// UpdateExpense updates all editable fields of an existing expense.
func UpdateExpense(db *sql.DB, expense Expense) error {
_, err := db.Exec(
`UPDATE expenses SET amount=?, currency=?, converted_amount=?, base_currency=?,
merchant=?, category=?, description=?, date=? WHERE id=?`,
expense.Amount, expense.Currency, expense.ConvertedAmount, expense.BaseCurrency,
expense.Merchant, expense.Category, expense.Description, expense.Date, expense.ID,
)
if err != nil {
log.Printf("ERROR [%s] database: UpdateExpense(%s): %v",
time.Now().Format(time.RFC3339), expense.ID, err)
}
return err
}

View file

@ -20,6 +20,7 @@ import (
"github.com/expenseflow/internal/ai" "github.com/expenseflow/internal/ai"
"github.com/expenseflow/internal/database" "github.com/expenseflow/internal/database"
"github.com/expenseflow/internal/utils" "github.com/expenseflow/internal/utils"
"github.com/go-chi/chi/v5"
) )
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -333,6 +334,148 @@ func (h *ExpenseHandler) SaveExpense(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `<div id="expense-list" hx-swap-oob="true">%s</div>`, listBuf.String()) fmt.Fprintf(w, `<div id="expense-list" hx-swap-oob="true">%s</div>`, listBuf.String())
} }
// ---------------------------------------------------------------------------
// GET /expenses/{id}/edit — EditExpense
// ---------------------------------------------------------------------------
// EditExpense returns the receipt form pre-filled with an existing expense's
// data, allowing the user to edit and re-save it.
func (h *ExpenseHandler) EditExpense(w http.ResponseWriter, r *http.Request) {
expenseID := chi.URLParam(r, "id")
if expenseID == "" {
http.Error(w, "Missing expense ID", http.StatusBadRequest)
return
}
expense, err := database.GetExpenseByID(h.DB, expenseID)
if err != nil {
log.Printf("ERROR [%s] handlers: EditExpense: GetExpenseByID(%s): %v",
time.Now().Format(time.RFC3339), expenseID, err)
http.Error(w, "Failed to retrieve expense", http.StatusInternalServerError)
return
}
if expense == nil {
http.Error(w, "Expense not found", http.StatusNotFound)
return
}
tmpl, err := template.ParseFiles("templates/receipt_form.html")
if err != nil {
log.Printf("ERROR [%s] handlers: EditExpense: parse template: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Template error", http.StatusInternalServerError)
return
}
data := map[string]interface{}{
"ImagePath": expense.ImagePath,
"AIError": "",
"Amount": strconv.FormatFloat(expense.Amount, 'f', 2, 64),
"Currency": expense.Currency,
"Merchant": expense.Merchant,
"Category": expense.Category,
"Date": expense.Date,
"Description": expense.Description,
"BaseCurrency": expense.BaseCurrency,
"ExchangeRate": 0.0,
"ConvertedAmount": strconv.FormatFloat(expense.ConvertedAmount, 'f', 2, 64),
"EditID": expense.ID,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
log.Printf("ERROR [%s] handlers: EditExpense: execute template: %v",
time.Now().Format(time.RFC3339), err)
}
}
// ---------------------------------------------------------------------------
// PUT /expenses/{id} — UpdateExpense
// ---------------------------------------------------------------------------
// UpdateExpense updates an existing expense record with form data and returns
// the updated expense list via HTMX multi-target response.
func (h *ExpenseHandler) UpdateExpense(w http.ResponseWriter, r *http.Request) {
expenseID := chi.URLParam(r, "id")
if expenseID == "" {
http.Error(w, "Missing expense ID", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
log.Printf("ERROR [%s] handlers: UpdateExpense: parse form: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Cannot parse form data", http.StatusBadRequest)
return
}
amount, _ := strconv.ParseFloat(r.FormValue("amount"), 64)
convertedAmount, _ := strconv.ParseFloat(r.FormValue("converted_amount"), 64)
baseCurrency := r.FormValue("base_currency")
if baseCurrency == "" {
baseCurrency = r.FormValue("currency")
convertedAmount = amount
}
// Fetch the existing expense to preserve the event_id and image_path.
existing, err := database.GetExpenseByID(h.DB, expenseID)
if err != nil || existing == nil {
log.Printf("ERROR [%s] handlers: UpdateExpense: get existing: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Expense not found", http.StatusNotFound)
return
}
expense := database.Expense{
ID: expenseID,
EventID: existing.EventID,
Amount: amount,
Currency: r.FormValue("currency"),
ConvertedAmount: convertedAmount,
BaseCurrency: baseCurrency,
Merchant: r.FormValue("merchant"),
Category: r.FormValue("category"),
Description: r.FormValue("description"),
Date: r.FormValue("date"),
ImagePath: existing.ImagePath,
}
if err := database.UpdateExpense(h.DB, expense); err != nil {
log.Printf("ERROR [%s] handlers: UpdateExpense: %v", time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to update expense", http.StatusInternalServerError)
return
}
// Return updated expense list via HTMX.
expenses, err := database.GetExpensesByEvent(h.DB, existing.EventID)
if err != nil {
log.Printf("ERROR [%s] handlers: UpdateExpense: fetch expenses: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to fetch expenses", http.StatusInternalServerError)
return
}
listTmpl, err := template.ParseFiles("templates/expense_list.html")
if err != nil {
log.Printf("ERROR [%s] handlers: UpdateExpense: parse template: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Template error", http.StatusInternalServerError)
return
}
var listBuf strings.Builder
if err := listTmpl.Execute(&listBuf, map[string]interface{}{"Expenses": expenses}); err != nil {
log.Printf("ERROR [%s] handlers: UpdateExpense: execute template: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Template error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div id="receipt-form" hx-swap-oob="true"><div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">Expense updated successfully!</div></div>`)
fmt.Fprintf(w, `<div id="expense-list" hx-swap-oob="true">%s</div>`, listBuf.String())
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Cookie helpers (shared with events.go via package-level access) // Cookie helpers (shared with events.go via package-level access)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -174,6 +174,8 @@ func main() {
// Expenses. // Expenses.
r.Post("/expenses/upload", expenseHandler.UploadReceipt) r.Post("/expenses/upload", expenseHandler.UploadReceipt)
r.Post("/expenses", expenseHandler.SaveExpense) r.Post("/expenses", expenseHandler.SaveExpense)
r.Get("/expenses/{id}/edit", expenseHandler.EditExpense)
r.Put("/expenses/{id}", expenseHandler.UpdateExpense)
// Filing. // Filing.
r.Post("/events/{id}/file", fileHandler.FileEvent) r.Post("/events/{id}/file", fileHandler.FileEvent)

View file

@ -113,6 +113,11 @@
hx-get="/events/{{.ID}}/expenses" hx-target="body" hx-push-url="true"> hx-get="/events/{{.ID}}/expenses" hx-target="body" hx-push-url="true">
Add Expenses Add Expenses
</a> </a>
<a href="/events/{{.ID}}/expenses" class="btn btn-secondary btn-sm"
hx-get="/events/{{.ID}}/expenses" hx-target="body" hx-push-url="true"
style="border-color: #10b981; color: #10b981;">
File Report
</a>
{{else}} {{else}}
<button class="btn btn-secondary btn-sm" <button class="btn btn-secondary btn-sm"
hx-put="/events/{{.ID}}/reopen" hx-put="/events/{{.ID}}/reopen"

View file

@ -83,6 +83,15 @@
</div> </div>
{{end}} {{end}}
</div> </div>
<div style="display: flex; align-items: center; margin-left: 0.5rem;">
<button class="btn btn-sm" style="background: none; border: 1px solid #d1d5db; border-radius: 0.375rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer;"
hx-get="/expenses/{{.ID}}/edit"
hx-target="#receipt-form"
hx-swap="innerHTML"
title="Edit expense">
✏️
</button>
</div>
</div> </div>
{{end}} {{end}}
</div> </div>

View file

@ -28,6 +28,15 @@
</div> </div>
{{end}} {{end}}
</div> </div>
<div style="display: flex; align-items: center; margin-left: 0.5rem;">
<button class="btn btn-sm" style="background: none; border: 1px solid #d1d5db; border-radius: 0.375rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer;"
hx-get="/expenses/{{.ID}}/edit"
hx-target="#receipt-form"
hx-swap="innerHTML"
title="Edit expense">
✏️
</button>
</div>
</div> </div>
{{end}} {{end}}
</div> </div>

View file

@ -15,9 +15,15 @@
<span class="badge badge-open" style="margin-left: auto;">AI Extracted</span> <span class="badge badge-open" style="margin-left: auto;">AI Extracted</span>
</div> </div>
{{if .EditID}}
<form hx-put="/expenses/{{.EditID}}" hx-target="#receipt-form" hx-swap="outerHTML"
hx-indicator="#save-indicator">
{{else}}
<form hx-post="/expenses" hx-target="#receipt-form" hx-swap="outerHTML" <form hx-post="/expenses" hx-target="#receipt-form" hx-swap="outerHTML"
hx-indicator="#save-indicator"> hx-indicator="#save-indicator">
{{end}}
<input type="hidden" name="image_path" value="{{.ImagePath}}"> <input type="hidden" name="image_path" value="{{.ImagePath}}">
{{if .EditID}}<input type="hidden" name="edit_id" value="{{.EditID}}">{{end}}
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
@ -109,7 +115,7 @@
<div style="display: flex; gap: 0.5rem;"> <div style="display: flex; gap: 0.5rem;">
<button type="submit" class="btn btn-primary" id="save-indicator"> <button type="submit" class="btn btn-primary" id="save-indicator">
<span class="spinner htmx-indicator"></span> <span class="spinner htmx-indicator"></span>
Save Expense {{if .EditID}}Update Expense{{else}}Save Expense{{end}}
</button> </button>
<button type="button" class="btn btn-secondary" <button type="button" class="btn btn-secondary"
onclick="document.getElementById('receipt-form').innerHTML = ''"> onclick="document.getElementById('receipt-form').innerHTML = ''">