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" "database/sql"
"encoding/csv" "encoding/csv"
"fmt" "fmt"
"html/template"
"log" "log"
"net/http" "net/http"
"os" "os"
@ -61,7 +62,7 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
if eventID == "" { if eventID == "" {
log.Printf("ERROR [%s] handlers: FileEvent: missing event ID in URL", log.Printf("ERROR [%s] handlers: FileEvent: missing event ID in URL",
time.Now().Format(time.RFC3339)) time.Now().Format(time.RFC3339))
http.Error(w, "Missing event ID", http.StatusBadRequest) renderFileError(w, "Missing event ID.")
return return
} }
@ -69,7 +70,7 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: parse form: %v", log.Printf("ERROR [%s] handlers: FileEvent: parse form: %v",
time.Now().Format(time.RFC3339), err) time.Now().Format(time.RFC3339), err)
http.Error(w, "Cannot parse form data", http.StatusBadRequest) renderFileError(w, "Cannot parse form data.")
return return
} }
@ -79,13 +80,13 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
if to == "" { if to == "" {
log.Printf("ERROR [%s] handlers: FileEvent: missing email field", log.Printf("ERROR [%s] handlers: FileEvent: missing email field",
time.Now().Format(time.RFC3339)) time.Now().Format(time.RFC3339))
http.Error(w, "Email address is required", http.StatusBadRequest) renderFileError(w, "Email address is required.")
return return
} }
if format != "csv" && format != "pdf" { if format != "csv" && format != "pdf" {
log.Printf("ERROR [%s] handlers: FileEvent: invalid format %q", log.Printf("ERROR [%s] handlers: FileEvent: invalid format %q",
time.Now().Format(time.RFC3339), format) 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 return
} }
@ -94,7 +95,7 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
if userID == "" { if userID == "" {
log.Printf("ERROR [%s] handlers: FileEvent: unauthenticated request", log.Printf("ERROR [%s] handlers: FileEvent: unauthenticated request",
time.Now().Format(time.RFC3339)) time.Now().Format(time.RFC3339))
http.Error(w, "Unauthorized", http.StatusUnauthorized) renderFileError(w, "Session expired. Please log in again.")
return return
} }
@ -102,19 +103,19 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: GetEventByID(%s): %v", log.Printf("ERROR [%s] handlers: FileEvent: GetEventByID(%s): %v",
time.Now().Format(time.RFC3339), eventID, err) 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 return
} }
if event == nil { if event == nil {
log.Printf("ERROR [%s] handlers: FileEvent: event not found: %s", log.Printf("ERROR [%s] handlers: FileEvent: event not found: %s",
time.Now().Format(time.RFC3339), eventID) time.Now().Format(time.RFC3339), eventID)
http.Error(w, "Event not found", http.StatusNotFound) renderFileError(w, "Event not found.")
return return
} }
if event.UserID != userID { if event.UserID != userID {
log.Printf("ERROR [%s] handlers: FileEvent: user %s does not own event %s", log.Printf("ERROR [%s] handlers: FileEvent: user %s does not own event %s",
time.Now().Format(time.RFC3339), userID, eventID) 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 return
} }
@ -123,7 +124,7 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: GetExpensesByEvent(%s): %v", log.Printf("ERROR [%s] handlers: FileEvent: GetExpensesByEvent(%s): %v",
time.Now().Format(time.RFC3339), eventID, err) 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 return
} }
@ -138,7 +139,7 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: generate %s report: %v", log.Printf("ERROR [%s] handlers: FileEvent: generate %s report: %v",
time.Now().Format(time.RFC3339), format, err) 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 return
} }
@ -158,7 +159,7 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
if h.EmailSender == nil { if h.EmailSender == nil {
log.Printf("ERROR [%s] handlers: FileEvent: SMTP not configured, cannot send email", log.Printf("ERROR [%s] handlers: FileEvent: SMTP not configured, cannot send email",
time.Now().Format(time.RFC3339)) 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 return
} }
subject := "Expense report for event " + event.Name 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 { if err := h.EmailSender.SendReport(to, subject, body, attachments); err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: SendReport(%s): %v", log.Printf("ERROR [%s] handlers: FileEvent: SendReport(%s): %v",
time.Now().Format(time.RFC3339), to, err) 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 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 { if err := database.UpdateEventStatus(h.DB, eventID, "closed"); err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: UpdateEventStatus(%s): %v", log.Printf("ERROR [%s] handlers: FileEvent: UpdateEventStatus(%s): %v",
time.Now().Format(time.RFC3339), eventID, err) 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 return
} }
@ -427,6 +428,17 @@ func createReceiptZip(eventName string, expenses []database.Expense) (*email.Att
}, nil }, 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 "…" // truncateString truncates a string to the given maximum length, appending "…"
// if the string was shortened. // if the string was shortened.
func truncateString(s string, maxLen int) string { func truncateString(s string, maxLen int) string {

View file

@ -128,7 +128,9 @@
{{if and (eq .Event.Status "open") .Expenses}} {{if and (eq .Event.Status "open") .Expenses}}
<div style="margin-top: 2rem; border-top: 1px solid var(--color-border); padding-top: 1.5rem;"> <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> <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"> <div class="form-group">
<label for="file-email">Send claim to</label> <label for="file-email">Send claim to</label>
<input type="email" id="file-email" name="email" placeholder="finance@company.com" required> <input type="email" id="file-email" name="email" placeholder="finance@company.com" required>
@ -152,6 +154,10 @@
</div> </div>
</div> </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;"> <button type="submit" class="btn btn-primary btn-block" style="margin-top: 0.5rem;">
Submit & Close Event Submit & Close Event
</button> </button>