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:
parent
e00c3373cb
commit
ded4954c72
7 changed files with 211 additions and 1 deletions
|
|
@ -336,3 +336,39 @@ func GetExpensesByEvent(db *sql.DB, eventID string) ([]Expense, error) {
|
|||
}
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import (
|
|||
"github.com/expenseflow/internal/ai"
|
||||
"github.com/expenseflow/internal/database"
|
||||
"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())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
2
main.go
2
main.go
|
|
@ -174,6 +174,8 @@ func main() {
|
|||
// Expenses.
|
||||
r.Post("/expenses/upload", expenseHandler.UploadReceipt)
|
||||
r.Post("/expenses", expenseHandler.SaveExpense)
|
||||
r.Get("/expenses/{id}/edit", expenseHandler.EditExpense)
|
||||
r.Put("/expenses/{id}", expenseHandler.UpdateExpense)
|
||||
|
||||
// Filing.
|
||||
r.Post("/events/{id}/file", fileHandler.FileEvent)
|
||||
|
|
|
|||
|
|
@ -113,6 +113,11 @@
|
|||
hx-get="/events/{{.ID}}/expenses" hx-target="body" hx-push-url="true">
|
||||
Add Expenses
|
||||
</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}}
|
||||
<button class="btn btn-secondary btn-sm"
|
||||
hx-put="/events/{{.ID}}/reopen"
|
||||
|
|
|
|||
|
|
@ -83,6 +83,15 @@
|
|||
</div>
|
||||
{{end}}
|
||||
</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>
|
||||
{{end}}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,15 @@
|
|||
</div>
|
||||
{{end}}
|
||||
</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>
|
||||
{{end}}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -15,9 +15,15 @@
|
|||
<span class="badge badge-open" style="margin-left: auto;">AI Extracted</span>
|
||||
</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"
|
||||
hx-indicator="#save-indicator">
|
||||
{{end}}
|
||||
<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-group">
|
||||
|
|
@ -109,7 +115,7 @@
|
|||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button type="submit" class="btn btn-primary" id="save-indicator">
|
||||
<span class="spinner htmx-indicator"></span>
|
||||
Save Expense
|
||||
{{if .EditID}}Update Expense{{else}}Save Expense{{end}}
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary"
|
||||
onclick="document.getElementById('receipt-form').innerHTML = ''">
|
||||
|
|
|
|||
Loading…
Reference in a new issue