chore: fix invisible filing errors — add HTMX error feedback and loading indicator
This commit is contained in:
parent
90c9df6cce
commit
422aaa08ab
2 changed files with 32 additions and 14 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue