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"
|
"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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue