chore: add postbox — generate report package, download or email link
This commit is contained in:
parent
cc56d1ac9f
commit
be3aa7a036
4 changed files with 457 additions and 32 deletions
|
|
@ -60,6 +60,16 @@ type Expense struct {
|
|||
CreatedAt string
|
||||
}
|
||||
|
||||
// DownloadToken represents a download token for a generated report package.
|
||||
type DownloadToken struct {
|
||||
Token string
|
||||
EventID string
|
||||
Filename string
|
||||
CreatedAt string
|
||||
ExpiresAt string
|
||||
Accessed bool
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initialization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -127,6 +137,14 @@ func createTables(db *sql.DB) error {
|
|||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(event_id) REFERENCES events(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS download_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
event_id TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME NOT NULL,
|
||||
accessed INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
}
|
||||
|
||||
for _, stmt := range statements {
|
||||
|
|
@ -421,3 +439,97 @@ func DeleteExpense(db *sql.DB, id string) error {
|
|||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Download token queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CreateDownloadToken inserts a new download token row.
|
||||
func CreateDownloadToken(db *sql.DB, token, eventID, filename, expiresAt string) error {
|
||||
_, err := db.Exec(
|
||||
"INSERT INTO download_tokens (token, event_id, filename, expires_at) VALUES (?, ?, ?, ?)",
|
||||
token, eventID, filename, expiresAt,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("ERROR [%s] database: CreateDownloadToken(%s): %v",
|
||||
time.Now().Format(time.RFC3339), token, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// GetDownloadTokenByToken retrieves a download token record by its token string.
|
||||
func GetDownloadTokenByToken(db *sql.DB, token string) (*DownloadToken, error) {
|
||||
dt := &DownloadToken{}
|
||||
err := db.QueryRow(
|
||||
"SELECT token, event_id, filename, created_at, expires_at, accessed FROM download_tokens WHERE token = ?",
|
||||
token,
|
||||
).Scan(&dt.Token, &dt.EventID, &dt.Filename, &dt.CreatedAt, &dt.ExpiresAt, &dt.Accessed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dt, nil
|
||||
}
|
||||
|
||||
// MarkDownloadTokenAccessed sets the accessed flag for a token.
|
||||
func MarkDownloadTokenAccessed(db *sql.DB, token string) error {
|
||||
_, err := db.Exec("UPDATE download_tokens SET accessed = 1 WHERE token = ?", token)
|
||||
if err != nil {
|
||||
log.Printf("ERROR [%s] database: MarkDownloadTokenAccessed(%s): %v",
|
||||
time.Now().Format(time.RFC3339), token, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteExpiredDownloadTokens removes tokens past their expiry and their files.
|
||||
// Returns the filenames of deleted tokens so the caller can clean up disk files.
|
||||
func DeleteExpiredDownloadTokens(db *sql.DB) ([]string, error) {
|
||||
rows, err := db.Query("SELECT filename FROM download_tokens WHERE expires_at < datetime('now')")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var filenames []string
|
||||
for rows.Next() {
|
||||
var fn string
|
||||
if err := rows.Scan(&fn); err != nil {
|
||||
continue
|
||||
}
|
||||
filenames = append(filenames, fn)
|
||||
}
|
||||
|
||||
if len(filenames) > 0 {
|
||||
if _, err := db.Exec("DELETE FROM download_tokens WHERE expires_at < datetime('now')"); err != nil {
|
||||
return filenames, err
|
||||
}
|
||||
}
|
||||
|
||||
return filenames, nil
|
||||
}
|
||||
|
||||
// DeleteDownloadTokensByEvent removes all download tokens for a given event.
|
||||
// Returns the filenames so the caller can clean up disk files.
|
||||
func DeleteDownloadTokensByEvent(db *sql.DB, eventID string) ([]string, error) {
|
||||
rows, err := db.Query("SELECT filename FROM download_tokens WHERE event_id = ?", eventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var filenames []string
|
||||
for rows.Next() {
|
||||
var fn string
|
||||
if err := rows.Scan(&fn); err != nil {
|
||||
continue
|
||||
}
|
||||
filenames = append(filenames, fn)
|
||||
}
|
||||
|
||||
if len(filenames) > 0 {
|
||||
if _, err := db.Exec("DELETE FROM download_tokens WHERE event_id = ?", eventID); err != nil {
|
||||
return filenames, err
|
||||
}
|
||||
}
|
||||
|
||||
return filenames, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ package handlers
|
|||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/csv"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
|
|
@ -184,6 +186,311 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
|
|||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /events/{id}/generate — GenerateReport
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GenerateReport creates a report package (CSV/PDF + receipt images ZIP),
|
||||
// stores it on disk with a crypto-random download token, and returns an
|
||||
// HTMX fragment with download and email-link options. The event is NOT
|
||||
// closed — the user can add more receipts and regenerate.
|
||||
func (h *FileHandler) GenerateReport(w http.ResponseWriter, r *http.Request) {
|
||||
eventID := chi.URLParam(r, "id")
|
||||
if eventID == "" {
|
||||
log.Printf("ERROR [%s] handlers: GenerateReport: missing event ID",
|
||||
time.Now().Format(time.RFC3339))
|
||||
renderFileError(w, "Missing event ID.")
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
log.Printf("ERROR [%s] handlers: GenerateReport: parse form: %v",
|
||||
time.Now().Format(time.RFC3339), err)
|
||||
renderFileError(w, "Cannot parse form data.")
|
||||
return
|
||||
}
|
||||
|
||||
format := strings.ToLower(strings.TrimSpace(r.FormValue("format")))
|
||||
if format != "csv" && format != "pdf" {
|
||||
format = "pdf"
|
||||
}
|
||||
|
||||
userID := getUserID(r)
|
||||
if userID == "" {
|
||||
renderFileError(w, "Session expired. Please log in again.")
|
||||
return
|
||||
}
|
||||
|
||||
event, err := database.GetEventByID(h.DB, eventID)
|
||||
if err != nil || event == nil {
|
||||
renderFileError(w, "Event not found.")
|
||||
return
|
||||
}
|
||||
if event.UserID != userID {
|
||||
renderFileError(w, "You do not have permission to access this event.")
|
||||
return
|
||||
}
|
||||
|
||||
expenses, err := database.GetExpensesByEvent(h.DB, eventID)
|
||||
if err != nil {
|
||||
renderFileError(w, "Failed to retrieve expenses.")
|
||||
return
|
||||
}
|
||||
if len(expenses) == 0 {
|
||||
renderFileError(w, "No expenses to include in the report.")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate the report.
|
||||
var reportAtt *email.Attachment
|
||||
if format == "csv" {
|
||||
reportAtt, err = generateCSV(event.Name, expenses)
|
||||
} else {
|
||||
reportAtt, err = generatePDF(event.Name, expenses)
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("ERROR [%s] handlers: GenerateReport: generate %s: %v",
|
||||
time.Now().Format(time.RFC3339), format, err)
|
||||
renderFileError(w, "Failed to generate report.")
|
||||
return
|
||||
}
|
||||
|
||||
// Create receipt images ZIP.
|
||||
var zipAtt *email.Attachment
|
||||
zipAtt, _ = createReceiptZip(event.Name, expenses)
|
||||
|
||||
// Package everything into a single download ZIP.
|
||||
var pkgBuf bytes.Buffer
|
||||
pkg := zip.NewWriter(&pkgBuf)
|
||||
|
||||
// Add report file.
|
||||
addToZip(pkg, reportAtt.Filename, reportAtt.Content)
|
||||
// Add images ZIP if present.
|
||||
if zipAtt != nil {
|
||||
addToZip(pkg, zipAtt.Filename, zipAtt.Content)
|
||||
}
|
||||
|
||||
if err := pkg.Close(); err != nil {
|
||||
renderFileError(w, "Failed to create package.")
|
||||
return
|
||||
}
|
||||
|
||||
// Save to postbox directory — first clean up any previous packages for this event.
|
||||
os.MkdirAll("storage/postbox", 0755)
|
||||
|
||||
// Delete old download tokens and their files for this event.
|
||||
if oldFiles, err := database.DeleteDownloadTokensByEvent(h.DB, eventID); err == nil {
|
||||
for _, fn := range oldFiles {
|
||||
oldPath := filepath.Join("storage", "postbox", fn)
|
||||
os.Remove(oldPath)
|
||||
}
|
||||
}
|
||||
|
||||
tokenBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
renderFileError(w, "Failed to generate download token.")
|
||||
return
|
||||
}
|
||||
token := hex.EncodeToString(tokenBytes)
|
||||
pkgFilename := token + ".zip"
|
||||
pkgPath := filepath.Join("storage", "postbox", pkgFilename)
|
||||
|
||||
if err := os.WriteFile(pkgPath, pkgBuf.Bytes(), 0644); err != nil {
|
||||
log.Printf("ERROR [%s] handlers: GenerateReport: write %s: %v",
|
||||
time.Now().Format(time.RFC3339), pkgPath, err)
|
||||
renderFileError(w, "Failed to save report package.")
|
||||
return
|
||||
}
|
||||
|
||||
// Store token in DB (24h expiry).
|
||||
expiresAt := time.Now().Add(24 * time.Hour).Format(time.RFC3339)
|
||||
if err := database.CreateDownloadToken(h.DB, token, eventID, pkgFilename, expiresAt); err != nil {
|
||||
os.Remove(pkgPath)
|
||||
renderFileError(w, "Failed to store download token.")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("INFO [%s] handlers: GenerateReport: package %s created for event %s",
|
||||
time.Now().Format(time.RFC3339), pkgFilename, eventID)
|
||||
|
||||
// Render the download/send fragment.
|
||||
ext := format
|
||||
reportName := fmt.Sprintf("%s-report.%s", sanitiseFilename(event.Name), ext)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, `<div id="report-package" style="background: #064e3b; border: 1px solid #065f46; border-radius: 0.5rem; padding: 1rem; margin-top: 1rem;">
|
||||
<div style="font-weight: 600; color: #6ee7b7; margin-bottom: 0.5rem;">Report Ready</div>
|
||||
<p style="font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.75rem;">%s & %d receipt images packaged.</p>
|
||||
<div style="display: flex; gap: 0.5rem; margin-bottom: 0.75rem;">
|
||||
<a href="/dl/%s" class="btn btn-primary" style="flex:1; text-align:center; text-decoration:none; font-size:0.85rem;" download>⬇ Download Now</a>
|
||||
</div>
|
||||
<div style="border-top: 1px solid #065f46; padding-top: 0.75rem;">
|
||||
<p style="font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: 0.5rem;">Or send a download link via email (tiny email, no attachment limits):</p>
|
||||
<form hx-post="/events/%s/send-link" hx-target="#send-link-result" hx-indicator="#send-link-spinner" style="display: flex; gap: 0.5rem;">
|
||||
<input type="hidden" name="token" value="%s">
|
||||
<input type="email" name="email" placeholder="finance@company.com" required style="flex:1; padding:0.5rem; border:1px solid #475569; border-radius:0.375rem; background:#1e293b; color:#f8fafc; font-size:0.85rem;">
|
||||
<button type="submit" class="btn btn-secondary" style="font-size:0.85rem; white-space:nowrap;">Send Link</button>
|
||||
</form>
|
||||
<div id="send-link-spinner" class="htmx-indicator" style="text-align:center; padding:0.5rem;"><div class="spinner"></div></div>
|
||||
<div id="send-link-result"></div>
|
||||
</div>
|
||||
</div>`,
|
||||
template.HTMLEscapeString(reportName), len(expenses),
|
||||
template.HTMLEscapeString(token),
|
||||
template.HTMLEscapeString(eventID), template.HTMLEscapeString(token))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /events/{id}/send-link — SendDownloadLink
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// SendDownloadLink emails a download link for a previously generated report
|
||||
// package to the specified recipient. Returns an HTMX fragment with success
|
||||
// or error feedback.
|
||||
func (h *FileHandler) SendDownloadLink(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Failed to parse form.</div>`)
|
||||
return
|
||||
}
|
||||
|
||||
token := strings.TrimSpace(r.FormValue("token"))
|
||||
to := strings.TrimSpace(r.FormValue("email"))
|
||||
if token == "" || to == "" {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Token and email are required.</div>`)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify token exists and belongs to user's event.
|
||||
dt, err := database.GetDownloadTokenByToken(h.DB, token)
|
||||
if err != nil || dt == nil {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Invalid or expired download token.</div>`)
|
||||
return
|
||||
}
|
||||
|
||||
event, err := database.GetEventByID(h.DB, dt.EventID)
|
||||
if err != nil || event == nil || event.UserID != getUserID(r) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Permission denied.</div>`)
|
||||
return
|
||||
}
|
||||
|
||||
if h.EmailSender == nil {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">SMTP not configured.</div>`)
|
||||
return
|
||||
}
|
||||
|
||||
// Build the download URL using BASE_URL.
|
||||
baseURL := os.Getenv("BASE_URL")
|
||||
if baseURL == "" {
|
||||
baseURL = "http://localhost:8080"
|
||||
}
|
||||
link := strings.TrimRight(baseURL, "/") + "/dl/" + token
|
||||
|
||||
subject := "Expense report: " + event.Name
|
||||
body := fmt.Sprintf("Expense report for %s is ready.\n\nDownload: %s\n\nThis link expires in 24 hours.", event.Name, link)
|
||||
|
||||
if err := h.EmailSender.SendReport(to, subject, body, nil); err != nil {
|
||||
log.Printf("ERROR [%s] handlers: SendDownloadLink: %v",
|
||||
time.Now().Format(time.RFC3339), err)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Failed to send: %s</div>`,
|
||||
template.HTMLEscapeString(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, `<div style="color:#6ee7b7; font-size:0.8rem; margin-top:0.5rem;">Download link sent to %s.</div>`,
|
||||
template.HTMLEscapeString(to))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /dl/{token} — ServeDownload
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ServeDownload streams a previously generated report package to the client.
|
||||
// Access is controlled via the crypto-random token in the URL — no login
|
||||
// required. The token is valid for 24 hours from creation.
|
||||
func (h *FileHandler) ServeDownload(w http.ResponseWriter, r *http.Request) {
|
||||
token := chi.URLParam(r, "token")
|
||||
if token == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
dt, err := database.GetDownloadTokenByToken(h.DB, token)
|
||||
if err != nil || dt == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Check expiry.
|
||||
expiresAt, err := time.Parse(time.RFC3339, dt.ExpiresAt)
|
||||
if err != nil || time.Now().After(expiresAt) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
pkgPath := filepath.Join("storage", "postbox", dt.Filename)
|
||||
if _, err := os.Stat(pkgPath); os.IsNotExist(err) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Mark as accessed.
|
||||
database.MarkDownloadTokenAccessed(h.DB, token)
|
||||
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, dt.Filename))
|
||||
http.ServeFile(w, r, pkgPath)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StartDownloadCleanup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// StartDownloadCleanup runs a background goroutine that periodically deletes
|
||||
// expired download tokens and their associated files from disk.
|
||||
func (h *FileHandler) StartDownloadCleanup() {
|
||||
go func() {
|
||||
for {
|
||||
time.Sleep(1 * time.Hour)
|
||||
filenames, err := database.DeleteExpiredDownloadTokens(h.DB)
|
||||
if err != nil {
|
||||
log.Printf("ERROR [%s] handlers: download cleanup: %v",
|
||||
time.Now().Format(time.RFC3339), err)
|
||||
continue
|
||||
}
|
||||
for _, fn := range filenames {
|
||||
path := filepath.Join("storage", "postbox", fn)
|
||||
if err := os.Remove(path); err != nil {
|
||||
log.Printf("WARN [%s] handlers: download cleanup: remove %s: %v",
|
||||
time.Now().Format(time.RFC3339), path, err)
|
||||
}
|
||||
}
|
||||
if len(filenames) > 0 {
|
||||
log.Printf("INFO [%s] handlers: download cleanup: removed %d expired packages",
|
||||
time.Now().Format(time.RFC3339), len(filenames))
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// addToZip adds a file to a zip.Writer.
|
||||
func addToZip(zw *zip.Writer, name string, data []byte) {
|
||||
f, err := zw.Create(name)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
f.Write(data)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Report generation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
8
main.go
8
main.go
|
|
@ -107,6 +107,9 @@ func main() {
|
|||
EmailSender: emailSender,
|
||||
}
|
||||
|
||||
// Start background cleanup of expired download packages.
|
||||
fileHandler.StartDownloadCleanup()
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Router
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
@ -193,6 +196,9 @@ func main() {
|
|||
r.Post("/request-otp", authHandler.RequestOTP)
|
||||
r.Post("/verify-otp", authHandler.VerifyOTP)
|
||||
|
||||
// Download link (token-based auth, no login required).
|
||||
r.Get("/dl/{token}", fileHandler.ServeDownload)
|
||||
|
||||
// ---- Logout (invalidates server-side session + clears cookie) ----
|
||||
|
||||
r.Post("/logout", authHandler.Logout)
|
||||
|
|
@ -220,6 +226,8 @@ func main() {
|
|||
|
||||
// Filing.
|
||||
r.Post("/events/{id}/file", fileHandler.FileEvent)
|
||||
r.Post("/events/{id}/generate", fileHandler.GenerateReport)
|
||||
r.Post("/events/{id}/send-link", fileHandler.SendDownloadLink)
|
||||
|
||||
// Storage (receipt images) — protected by auth + path traversal check.
|
||||
r.With(authHandler.RequireAuth).Get("/storage/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
|
|||
|
|
@ -124,44 +124,42 @@
|
|||
<p>Analyzing receipt...</p>
|
||||
</div>
|
||||
|
||||
<!-- Submit Event (only if open and has expenses) -->
|
||||
<!-- Generate Report (only if open and has expenses) -->
|
||||
{{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>
|
||||
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 1rem;">Generate Report</h3>
|
||||
<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>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Format</label>
|
||||
<div style="display: flex; gap: 1rem; margin-top: 0.25rem;">
|
||||
<label style="display: flex; align-items: center; gap: 0.25rem;">
|
||||
<input type="radio" name="format" value="csv" checked> CSV
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 0.25rem;">
|
||||
<input type="radio" name="format" value="pdf"> PDF
|
||||
</label>
|
||||
<div id="report-section">
|
||||
<form hx-post="/events/{{.Event.ID}}/generate" hx-target="#report-section" hx-swap="outerHTML"
|
||||
hx-indicator="#generate-spinner">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Format</label>
|
||||
<div style="display: flex; gap: 1rem; margin-top: 0.25rem;">
|
||||
<label style="display: flex; align-items: center; gap: 0.25rem;">
|
||||
<input type="radio" name="format" value="csv"> CSV
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 0.25rem;">
|
||||
<input type="radio" name="format" value="pdf" checked> PDF
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Total claim</label>
|
||||
<div style="font-size: 1.25rem; font-weight: 700; color: #166534; margin-top: 0.25rem;">
|
||||
{{printf "%.2f" .TotalClaim}} {{.Event.BaseCurrency}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Total claim</label>
|
||||
<div style="font-size: 1.25rem; font-weight: 700; color: #166534; margin-top: 0.25rem;">
|
||||
{{printf "%.2f" .TotalClaim}} {{.Event.BaseCurrency}}
|
||||
</div>
|
||||
<div id="generate-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;">Packaging report…</p>
|
||||
</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>
|
||||
</form>
|
||||
<button type="submit" class="btn btn-primary btn-block" style="margin-top: 0.5rem;">
|
||||
Generate Report
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue