chore: fix invisible filing errors — add HTMX error feedback and loading indicator

This commit is contained in:
Claus Lohmar 2026-06-17 11:08:01 +00:00
parent 90c9df6cce
commit 422aaa08ab
2 changed files with 32 additions and 14 deletions

View file

@ -10,6 +10,7 @@ import (
"database/sql"
"encoding/csv"
"fmt"
"html/template"
"log"
"net/http"
"os"
@ -61,7 +62,7 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
if eventID == "" {
log.Printf("ERROR [%s] handlers: FileEvent: missing event ID in URL",
time.Now().Format(time.RFC3339))
http.Error(w, "Missing event ID", http.StatusBadRequest)
renderFileError(w, "Missing event ID.")
return
}
@ -69,7 +70,7 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: parse form: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Cannot parse form data", http.StatusBadRequest)
renderFileError(w, "Cannot parse form data.")
return
}
@ -79,13 +80,13 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
if to == "" {
log.Printf("ERROR [%s] handlers: FileEvent: missing email field",
time.Now().Format(time.RFC3339))
http.Error(w, "Email address is required", http.StatusBadRequest)
renderFileError(w, "Email address is required.")
return
}
if format != "csv" && format != "pdf" {
log.Printf("ERROR [%s] handlers: FileEvent: invalid format %q",
time.Now().Format(time.RFC3339), format)
http.Error(w, "Format must be 'csv' or 'pdf'", http.StatusBadRequest)
renderFileError(w, "Format must be 'csv' or 'pdf'.")
return
}
@ -94,7 +95,7 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
if userID == "" {
log.Printf("ERROR [%s] handlers: FileEvent: unauthenticated request",
time.Now().Format(time.RFC3339))
http.Error(w, "Unauthorized", http.StatusUnauthorized)
renderFileError(w, "Session expired. Please log in again.")
return
}
@ -102,19 +103,19 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
if err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: GetEventByID(%s): %v",
time.Now().Format(time.RFC3339), eventID, err)
http.Error(w, "Failed to retrieve event", http.StatusInternalServerError)
renderFileError(w, "Failed to retrieve event. Please try again.")
return
}
if event == nil {
log.Printf("ERROR [%s] handlers: FileEvent: event not found: %s",
time.Now().Format(time.RFC3339), eventID)
http.Error(w, "Event not found", http.StatusNotFound)
renderFileError(w, "Event not found.")
return
}
if event.UserID != userID {
log.Printf("ERROR [%s] handlers: FileEvent: user %s does not own event %s",
time.Now().Format(time.RFC3339), userID, eventID)
http.Error(w, "Forbidden", http.StatusForbidden)
renderFileError(w, "You do not have permission to file this event.")
return
}
@ -123,7 +124,7 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
if err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: GetExpensesByEvent(%s): %v",
time.Now().Format(time.RFC3339), eventID, err)
http.Error(w, "Failed to retrieve expenses", http.StatusInternalServerError)
renderFileError(w, "Failed to retrieve expenses. Please try again.")
return
}
@ -138,7 +139,7 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
if err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: generate %s report: %v",
time.Now().Format(time.RFC3339), format, err)
http.Error(w, "Failed to generate report", http.StatusInternalServerError)
renderFileError(w, "Failed to generate report. Please try again.")
return
}
@ -158,7 +159,7 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
if h.EmailSender == nil {
log.Printf("ERROR [%s] handlers: FileEvent: SMTP not configured, cannot send email",
time.Now().Format(time.RFC3339))
http.Error(w, "SMTP not configured. Please set SMTP environment variables.", http.StatusInternalServerError)
renderFileError(w, "SMTP not configured. Please contact the administrator.")
return
}
subject := "Expense report for event " + event.Name
@ -166,7 +167,7 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
if err := h.EmailSender.SendReport(to, subject, body, attachments); err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: SendReport(%s): %v",
time.Now().Format(time.RFC3339), to, err)
http.Error(w, "Failed to send report email", http.StatusInternalServerError)
renderFileError(w, "Failed to send report email. Please check the recipient address and try again.")
return
}
@ -174,7 +175,7 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
if err := database.UpdateEventStatus(h.DB, eventID, "closed"); err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: UpdateEventStatus(%s): %v",
time.Now().Format(time.RFC3339), eventID, err)
http.Error(w, "Failed to close event", http.StatusInternalServerError)
renderFileError(w, "Report sent but failed to close the event. Please try again.")
return
}
@ -427,6 +428,17 @@ func createReceiptZip(eventName string, expenses []database.Expense) (*email.Att
}, nil
}
// renderFileError writes an HTMX-compatible error fragment targeted at the
// #submit-error container on the event expenses page. Using a 200 status
// ensures HTMX always swaps the content (HTMX skips 4xx/5xx by default).
func renderFileError(w http.ResponseWriter, message string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("HX-Retarget", "#submit-error")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, `<div id="submit-error" style="background: #450a0a; border: 1px solid #7f1d1d; color: #fca5a5; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;">%s</div>`,
template.HTMLEscapeString(message))
}
// truncateString truncates a string to the given maximum length, appending "…"
// if the string was shortened.
func truncateString(s string, maxLen int) string {

View file

@ -128,7 +128,9 @@
{{if and (eq .Event.Status "open") .Expenses}}
<div style="margin-top: 2rem; border-top: 1px solid var(--color-border); padding-top: 1.5rem;">
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 1rem;">Submit Event</h3>
<form hx-post="/events/{{.Event.ID}}/file" hx-target="body" hx-push-url="true">
<div id="submit-error"></div>
<form hx-post="/events/{{.Event.ID}}/file" hx-target="body" hx-push-url="true"
hx-indicator="#submit-spinner">
<div class="form-group">
<label for="file-email">Send claim to</label>
<input type="email" id="file-email" name="email" placeholder="finance@company.com" required>
@ -152,6 +154,10 @@
</div>
</div>
</div>
<div id="submit-spinner" class="htmx-indicator" style="text-align: center; padding: 0.5rem;">
<div class="spinner"></div>
<p style="font-size: 0.8rem; color: var(--color-text-muted); margin-top: 0.25rem;">Sending report via email…</p>
</div>
<button type="submit" class="btn btn-primary btn-block" style="margin-top: 0.5rem;">
Submit & Close Event
</button>