feat: send receipt images as ZIP attachment with report

- Email now includes both report (CSV/PDF) + ZIP of all receipt images
- ZIP images named {event-name}-{index}.{ext} matching list order
- Uses Go's archive/zip (stdlib, no external deps)
- Sender.SendReport now accepts []*Attachment for multiple files
- Gracefully skips missing image files with warnings
This commit is contained in:
Claus Lohmar 2026-05-30 13:38:23 +00:00
parent e22bd56169
commit dcb43b08a0
14 changed files with 224 additions and 65 deletions

View file

@ -77,11 +77,10 @@ func (s *Sender) SendOTP(to, code string) error {
return nil return nil
} }
// SendReport sends an email with the given subject and body, attaching a CSV // SendReport sends an email with the given subject and body, attaching one or
// or PDF file. The attachment's Content-Type is inferred from its filename // more files (report CSV/PDF + ZIP of receipt images).
// extension (text/csv for .csv, application/octet-stream otherwise). func (s *Sender) SendReport(to, subject, body string, attachments []*Attachment) error {
func (s *Sender) SendReport(to, subject, body string, attachment *Attachment) error { msg, err := buildMultipartMessage(s.from, to, subject, body, attachments)
msg, err := buildMultipartMessage(s.from, to, subject, body, attachment)
if err != nil { if err != nil {
log.Printf("ERROR [%s] email: SendReport(%s): build failed: %v", log.Printf("ERROR [%s] email: SendReport(%s): build failed: %v",
time.Now().Format(time.RFC3339), to, err) time.Now().Format(time.RFC3339), to, err)
@ -94,8 +93,12 @@ func (s *Sender) SendReport(to, subject, body string, attachment *Attachment) er
return err return err
} }
names := make([]string, len(attachments))
for i, a := range attachments {
names[i] = a.Filename
}
log.Printf("INFO [%s] email: report sent to %s (%s)", log.Printf("INFO [%s] email: report sent to %s (%s)",
time.Now().Format(time.RFC3339), to, attachment.Filename) time.Now().Format(time.RFC3339), to, strings.Join(names, ", "))
return nil return nil
} }
@ -184,13 +187,15 @@ func buildPlainMessage(from, to, subject, body string) []byte {
// buildMultipartMessage constructs an RFC 2046 multipart/mixed email with a // buildMultipartMessage constructs an RFC 2046 multipart/mixed email with a
// text/plain body and a single attachment encoded as base64. // text/plain body and a single attachment encoded as base64.
func buildMultipartMessage(from, to, subject, body string, attachment *Attachment) ([]byte, error) { func buildMultipartMessage(from, to, subject, body string, attachments []*Attachment) ([]byte, error) {
var b strings.Builder var b strings.Builder
// Write the main SMTP headers. // Write the main SMTP headers with deliverability improvements.
writeHeader(&b, "From", from) writeHeader(&b, "From", from)
writeHeader(&b, "To", to) writeHeader(&b, "To", to)
writeHeader(&b, "Subject", subject) writeHeader(&b, "Subject", subject)
writeHeader(&b, "Message-ID", fmt.Sprintf("<%d.receiptnext@post.2-4-h.app>", time.Now().UnixNano()))
writeHeader(&b, "Date", time.Now().Format(time.RFC1123Z))
// Create a multipart writer using a unique boundary string. // Create a multipart writer using a unique boundary string.
mw := multipart.NewWriter(&b) mw := multipart.NewWriter(&b)
@ -209,18 +214,20 @@ func buildMultipartMessage(from, to, subject, body string, attachment *Attachmen
return nil, fmt.Errorf("writing text part: %w", err) return nil, fmt.Errorf("writing text part: %w", err)
} }
// --- Attachment part --- // --- Attachment parts (report + receipt images zip) ---
aw, err := mw.CreatePart(attachmentHeader(attachment.Filename)) for _, att := range attachments {
aw, err := mw.CreatePart(attachmentHeader(att.Filename))
if err != nil { if err != nil {
return nil, fmt.Errorf("creating attachment part: %w", err) return nil, fmt.Errorf("creating attachment part %q: %w", att.Filename, err)
} }
enc := base64.NewEncoder(base64.StdEncoding, aw) enc := base64.NewEncoder(base64.StdEncoding, aw)
if _, err := enc.Write(attachment.Content); err != nil { if _, err := enc.Write(att.Content); err != nil {
enc.Close() enc.Close()
return nil, fmt.Errorf("writing attachment content: %w", err) return nil, fmt.Errorf("writing attachment %q: %w", att.Filename, err)
} }
enc.Close() enc.Close()
}
mw.Close() mw.Close()
@ -268,7 +275,8 @@ func attachmentHeader(filename string) textproto.MIMEHeader {
func attachmentContentType(filename string) string { func attachmentContentType(filename string) string {
switch { switch {
case strings.HasSuffix(strings.ToLower(filename), ".csv"): case strings.HasSuffix(strings.ToLower(filename), ".csv"):
return "text/csv; charset=\"utf-8\"" // Some providers block text/csv; use text/plain as fallback.
return "text/plain; charset=\"utf-8\""
case strings.HasSuffix(strings.ToLower(filename), ".pdf"): case strings.HasSuffix(strings.ToLower(filename), ".pdf"):
return "application/pdf" return "application/pdf"
default: default:

View file

@ -251,7 +251,7 @@ func getUserID(r *http.Request) string {
// renderError writes an HTMX-compatible HTML error fragment to the response. // renderError writes an HTMX-compatible HTML error fragment to the response.
func renderError(w http.ResponseWriter, message string) { func renderError(w http.ResponseWriter, message string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div class="error-message" style="color: #dc2626; margin-bottom: 1rem;">%s</div>`, template.HTMLEscapeString(message)) fmt.Fprintf(w, `<div class="error-message" style="color: #fca5a5; margin-bottom: 1rem;">%s</div>`, template.HTMLEscapeString(message))
} }
// renderOTPForm writes the OTP verification form partial as an HTMX fragment. // renderOTPForm writes the OTP verification form partial as an HTMX fragment.
@ -262,7 +262,7 @@ func renderOTPForm(w http.ResponseWriter, email string, errMsg string) {
tmpl := template.Must(template.New("otp_form").Parse(` tmpl := template.Must(template.New("otp_form").Parse(`
<form hx-post="/verify-otp" hx-target="#otp-form" hx-swap="innerHTML"> <form hx-post="/verify-otp" hx-target="#otp-form" hx-swap="innerHTML">
<input type="hidden" name="email" value="{{.Email}}"> <input type="hidden" name="email" value="{{.Email}}">
{{if .Error}}<div class="error-message" style="color: #dc2626; background: #fef2f2; border: 1px solid #fecaca; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;">{{.Error}}</div>{{end}} {{if .Error}}<div class="error-message" style="color: #fca5a5; background: #450a0a; border: 1px solid #7f1d1d; padding: 0.75rem; border-radius: 0.5rem; margin-bottom: 1rem;">{{.Error}}</div>{{end}}
<div style="display: flex; gap: 0.5rem; justify-content: center; margin: 1rem 0;"> <div style="display: flex; gap: 0.5rem; justify-content: center; margin: 1rem 0;">
<input type="text" name="digit_0" maxlength="1" pattern="[0-9]" inputmode="numeric" autocomplete="one-time-code" required <input type="text" name="digit_0" maxlength="1" pattern="[0-9]" inputmode="numeric" autocomplete="one-time-code" required
style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;"> style="width: 3rem; height: 3rem; text-align: center; font-size: 1.5rem; border: 2px solid #d1d5db; border-radius: 0.5rem;">

View file

@ -6,7 +6,6 @@ package handlers
import ( import (
"database/sql" "database/sql"
"fmt"
"html/template" "html/template"
"log" "log"
"net/http" "net/http"
@ -180,9 +179,9 @@ func (h *EventHandler) ReopenEvent(w http.ResponseWriter, r *http.Request) {
return return
} }
// Return HTMX fragment: green "open" badge targeting #status-badge-{id}. // Redirect to dashboard so the full page renders with updated status.
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("HX-Redirect", "/dashboard")
fmt.Fprintf(w, `<span id="status-badge-%s" class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">open</span>`, eventID) w.WriteHeader(http.StatusOK)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -5,12 +5,16 @@
package handlers package handlers
import ( import (
"archive/zip"
"bytes" "bytes"
"database/sql" "database/sql"
"encoding/csv" "encoding/csv"
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"os"
"path/filepath"
"strings"
"time" "time"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@ -124,12 +128,12 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
} }
// 5. Generate the report in the requested format. // 5. Generate the report in the requested format.
var attachment *email.Attachment var reportAttachment *email.Attachment
switch format { switch format {
case "csv": case "csv":
attachment, err = generateCSV(event.Name, expenses) reportAttachment, err = generateCSV(event.Name, expenses)
case "pdf": case "pdf":
attachment, err = generatePDF(event.Name, expenses) reportAttachment, err = generatePDF(event.Name, expenses)
} }
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",
@ -138,7 +142,19 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
return return
} }
// 6. Send the report as an email attachment. // 6. Create a ZIP of all receipt images.
zipAttachment, zipErr := createReceiptZip(event.Name, expenses)
// 7. Build the list of attachments (report + ZIP if available).
attachments := []*email.Attachment{reportAttachment}
if zipErr == nil && zipAttachment != nil {
attachments = append(attachments, zipAttachment)
} else if zipErr != nil {
log.Printf("WARN [%s] handlers: FileEvent: receipt zip failed: %v",
time.Now().Format(time.RFC3339), zipErr)
}
// 8. Send the email with all attachments.
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))
@ -146,8 +162,8 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
return return
} }
subject := "Expense report for event " + event.Name subject := "Expense report for event " + event.Name
body := "Please find attached the expense report." body := "Please find attached the expense report and receipt images."
if err := h.EmailSender.SendReport(to, subject, body, attachment); 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) http.Error(w, "Failed to send report email", http.StatusInternalServerError)
@ -244,8 +260,9 @@ func generateCSV(eventName string, expenses []database.Expense) (*email.Attachme
return nil, fmt.Errorf("CSV writer flush: %w", err) return nil, fmt.Errorf("CSV writer flush: %w", err)
} }
filename := fmt.Sprintf("receiptnext-%s.csv", sanitiseFilename(eventName))
return &email.Attachment{ return &email.Attachment{
Filename: "report.csv", Filename: filename,
Content: buf.Bytes(), Content: buf.Bytes(),
}, nil }, nil
} }
@ -321,6 +338,70 @@ func generatePDF(eventName string, expenses []database.Expense) (*email.Attachme
}, nil }, nil
} }
// createReceiptZip creates a ZIP archive containing all receipt images from the
// given expenses. Each image is named {event-name}-{index}.{ext} inside the ZIP.
// Returns nil if there are no expenses with images, or if all image files are
// missing from disk.
func createReceiptZip(eventName string, expenses []database.Expense) (*email.Attachment, error) {
safeName := sanitiseFilename(eventName)
if safeName == "" {
safeName = "event"
}
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
added := 0
for i, exp := range expenses {
if exp.ImagePath == "" {
continue
}
// Read the image file from disk.
data, err := os.ReadFile(exp.ImagePath)
if err != nil {
log.Printf("WARN [%s] handlers: createReceiptZip: reading %q: %v",
time.Now().Format(time.RFC3339), exp.ImagePath, err)
continue
}
// Determine file extension from the image path.
ext := filepath.Ext(exp.ImagePath)
if ext == "" {
ext = ".jpg"
}
filename := fmt.Sprintf("%s-%d%s", safeName, i+1, ext)
f, err := zw.Create(filename)
if err != nil {
log.Printf("WARN [%s] handlers: createReceiptZip: creating entry %q: %v",
time.Now().Format(time.RFC3339), filename, err)
continue
}
if _, err := f.Write(data); err != nil {
log.Printf("WARN [%s] handlers: createReceiptZip: writing %q: %v",
time.Now().Format(time.RFC3339), filename, err)
continue
}
added++
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("closing zip: %w", err)
}
if added == 0 {
log.Printf("INFO [%s] handlers: createReceiptZip: no receipt images found for event %q",
time.Now().Format(time.RFC3339), eventName)
return nil, nil
}
return &email.Attachment{
Filename: fmt.Sprintf("%s-images.zip", safeName),
Content: buf.Bytes(),
}, nil
}
// 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 {
@ -329,3 +410,20 @@ func truncateString(s string, maxLen int) string {
} }
return s[:maxLen-1] + "…" return s[:maxLen-1] + "…"
} }
// sanitiseFilename converts a string into a safe filename (alphanumerics,
// hyphens, underscores only — no spaces or special characters).
func sanitiseFilename(s string) string {
var result []rune
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
result = append(result, r)
} else if r == ' ' || r == '.' {
result = append(result, '-')
}
}
if len(result) == 0 {
return "expenses"
}
return strings.Trim(string(result), "-")
}

11
main.go
View file

@ -136,6 +136,17 @@ func main() {
http.ServeFile(w, r, "static/manifest.json") http.ServeFile(w, r, "static/manifest.json")
})) }))
// iOS PWA / Safari root-level icon requests.
r.Get("/apple-touch-icon.png", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "static/icons/icon-180.png")
}))
r.Get("/apple-touch-icon-120x120.png", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "static/icons/icon-180.png")
}))
r.Get("/favicon.ico", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "static/favicon.svg")
}))
// Serve uploaded receipt images. // Serve uploaded receipt images.
r.Get("/storage/*", http.StripPrefix("/storage/", http.FileServer(http.Dir("storage"))).ServeHTTP) r.Get("/storage/*", http.StripPrefix("/storage/", http.FileServer(http.Dir("storage"))).ServeHTTP)

View file

@ -670,10 +670,12 @@ small, .text-sm {
.form-label { .form-label {
display: block; display: block;
font-size: var(--text-sm); font-size: var(--text-xs);
font-weight: var(--font-medium); font-weight: var(--font-semibold);
color: var(--color-text); color: var(--color-text-muted);
margin-bottom: var(--space-2); margin-bottom: var(--space-1);
text-transform: uppercase;
letter-spacing: 0.05em;
} }
.form-label--required::after { .form-label--required::after {
@ -681,14 +683,15 @@ small, .text-sm {
color: var(--color-danger); color: var(--color-danger);
} }
.form-input, /* Bare input/select/textarea inside form-group get the same styling */
.form-select, .form-group input:not([type="radio"]):not([type="checkbox"]):not([type="file"]):not([type="hidden"]),
.form-textarea { .form-group select,
.form-group textarea {
display: block; display: block;
width: 100%; width: 100%;
padding: var(--space-4) var(--space-4); padding: var(--space-4) var(--space-4);
font-family: inherit; font-family: inherit;
font-size: var(--text-base); font-size: var(--text-lg);
line-height: var(--leading-relaxed); line-height: var(--leading-relaxed);
color: var(--color-text); color: var(--color-text);
background-color: var(--color-card); background-color: var(--color-card);
@ -699,19 +702,55 @@ small, .text-sm {
box-shadow var(--transition-fast); box-shadow var(--transition-fast);
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
box-sizing: border-box;
}
.form-group input:not([type="radio"]):not([type="checkbox"]):not([type="file"]):not([type="hidden"])::placeholder,
.form-group textarea::placeholder {
color: var(--color-text-light);
font-size: var(--text-base);
}
/* Also keep the class-based selectors for explicit usage */
.form-input,
.form-select,
.form-textarea {
display: block;
width: 100%;
padding: var(--space-4) var(--space-4);
font-family: inherit;
font-size: var(--text-lg);
line-height: var(--leading-relaxed);
color: var(--color-text);
background-color: var(--color-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast);
-webkit-appearance: none;
appearance: none;
box-sizing: border-box;
} }
.form-input::placeholder, .form-input::placeholder,
.form-textarea::placeholder { .form-textarea::placeholder {
color: var(--color-text-light); color: var(--color-text-light);
font-size: var(--text-base);
} }
.form-group input:not([type="radio"]):not([type="checkbox"]):not([type="file"]):not([type="hidden"]):hover,
.form-group select:hover,
.form-group textarea:hover,
.form-input:hover, .form-input:hover,
.form-select:hover, .form-select:hover,
.form-textarea:hover { .form-textarea:hover {
border-color: var(--color-text-light); border-color: var(--color-text-light);
} }
.form-group input:not([type="radio"]):not([type="checkbox"]):not([type="file"]):not([type="hidden"]):focus,
.form-group select:focus,
.form-group textarea:focus,
.form-input:focus, .form-input:focus,
.form-select:focus, .form-select:focus,
.form-textarea:focus { .form-textarea:focus {
@ -754,6 +793,7 @@ small, .text-sm {
resize: vertical; resize: vertical;
} }
.form-group select,
.form-select { .form-select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
background-repeat: no-repeat; background-repeat: no-repeat;
@ -764,6 +804,9 @@ small, .text-sm {
/* Prevent zoom on mobile for inputs */ /* Prevent zoom on mobile for inputs */
@media screen and (max-width: 768px) { @media screen and (max-width: 768px) {
.form-group input:not([type="radio"]):not([type="checkbox"]):not([type="file"]):not([type="hidden"]),
.form-group select,
.form-group textarea,
.form-input, .form-input,
.form-select, .form-select,
.form-textarea { .form-textarea {

BIN
static/icons/icon-180.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 593 B

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -1,7 +1,7 @@
/* ============================================================ /* ============================================================
* ReceiptNext Service Worker * ReceiptNext Service Worker
* Version: 1.0.0 * Version: 2.0.0
* Cache name: receiptnext-v1 * Cache name: receiptnext-v2
* Strategy: Cache-first for shell assets, network-only for API * Strategy: Cache-first for shell assets, network-only for API
* ============================================================ */ * ============================================================ */

View file

@ -8,7 +8,7 @@
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="alternate icon" href="/static/icons/icon-192.png"> <link rel="alternate icon" href="/static/icons/icon-192.png">
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.css?v=4">
<script src="https://unpkg.com/htmx.org@1.9.10"></script> <script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head> </head>
<body> <body>
@ -44,9 +44,9 @@
<option value="PLN">PLN - Polish Zloty</option> <option value="PLN">PLN - Polish Zloty</option>
</select> </select>
</div> </div>
<div style="background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 0.5rem; padding: 0.75rem; margin-bottom: 0.5rem;"> <div style="background: #064e3b; border: 1px solid #065f46; border-radius: 0.5rem; padding: 0.75rem; margin-bottom: 0.5rem;">
<div style="font-size: 0.8rem; font-weight: 600; color: #166534; margin-bottom: 0.5rem;">Conversion Sample</div> <div style="font-size: 0.8rem; font-weight: 600; color: #6ee7b7; margin-bottom: 0.5rem;">Conversion Sample</div>
<p style="font-size: 0.75rem; color: #4b5563; margin-bottom: 0.5rem;"> <p style="font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: 0.5rem;">
From a payment notification: receipt amount and what you were charged. From a payment notification: receipt amount and what you were charged.
</p> </p>
<div class="form-row"> <div class="form-row">
@ -75,10 +75,10 @@
{{if .Events}} {{if .Events}}
<div style="display: flex; flex-direction: column; gap: 0.5rem;"> <div style="display: flex; flex-direction: column; gap: 0.5rem;">
{{range .Events}} {{range .Events}}
<div style="display: flex; align-items: center; justify-content: space-between; background: white; border: 1px solid #e2e8f0; border-radius: 0.5rem; padding: 0.75rem 1rem;"> <div style="display: flex; align-items: center; justify-content: space-between; background: var(--color-card); border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 0.75rem 1rem;">
<div> <div>
<div style="font-weight: 500;">{{.Name}}</div> <div style="font-weight: 500; color: var(--color-text);">{{.Name}}</div>
<div style="font-size: 0.75rem; color: #6b7280;"> <div style="font-size: 0.75rem; color: var(--color-text-muted);">
{{.BaseCurrency}} · {{printf "%.6f" .ExchangeRate}} rate {{.BaseCurrency}} · {{printf "%.6f" .ExchangeRate}} rate
</div> </div>
</div> </div>
@ -91,8 +91,8 @@
{{if eq .Status "closed"}} {{if eq .Status "closed"}}
<button class="btn btn-secondary btn-sm" <button class="btn btn-secondary btn-sm"
hx-put="/events/{{.ID}}/reopen" hx-put="/events/{{.ID}}/reopen"
hx-target="#event-list" hx-target="body"
hx-swap="outerHTML" hx-push-url="true"
style="font-size: 0.75rem;"> style="font-size: 0.75rem;">
Reopen Reopen
</button> </button>

View file

@ -8,7 +8,7 @@
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="alternate icon" href="/static/icons/icon-192.png"> <link rel="alternate icon" href="/static/icons/icon-192.png">
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.css?v=4">
<script src="https://unpkg.com/htmx.org@1.9.10"></script> <script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head> </head>
<body> <body>
@ -25,25 +25,25 @@
<h3 style="margin-bottom: 1rem; font-size: 1rem; font-weight: 600;"> <h3 style="margin-bottom: 1rem; font-size: 1rem; font-weight: 600;">
Receipts Receipts
{{if .Event.BaseCurrency}} {{if .Event.BaseCurrency}}
<span style="font-weight: 400; color: #6b7280;">(claim in {{.Event.BaseCurrency}})</span> <span style="font-weight: 400; color: var(--color-text-muted);">(claim in {{.Event.BaseCurrency}})</span>
{{end}} {{end}}
</h3> </h3>
{{if .Expenses}} {{if .Expenses}}
<div style="display: flex; flex-direction: column; gap: 0.5rem;"> <div style="display: flex; flex-direction: column; gap: 0.5rem;">
{{range .Expenses}} {{range .Expenses}}
<div style="display: flex; align-items: center; background: white; border: 1px solid #e2e8f0; border-radius: 0.5rem; padding: 0.75rem;"> <div style="display: flex; align-items: center; background: var(--color-card); border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 0.75rem;">
<div style="flex: 1; min-width: 0;"> <div style="flex: 1; min-width: 0;">
<div style="font-weight: 500;">{{.Merchant}}</div> <div style="font-weight: 500; color: var(--color-text);">{{.Merchant}}</div>
<div style="font-size: 0.75rem; color: #6b7280;">{{.Date}} · {{.Category}} {{if .Description}}· {{.Description}}{{end}}</div> <div style="font-size: 0.75rem; color: var(--color-text-muted);">{{.Date}} · {{.Category}} {{if .Description}}· {{.Description}}{{end}}</div>
</div> </div>
<div style="text-align: right; margin-right: 0.75rem;"> <div style="text-align: right; margin-right: 0.75rem;">
<div style="font-weight: 600;">{{printf "%.2f" .Amount}} {{.Currency}}</div> <div style="font-weight: 600; color: var(--color-text);">{{printf "%.2f" .Amount}} {{.Currency}}</div>
{{if .ConvertedAmount}} {{if .ConvertedAmount}}
<div style="font-size: 0.75rem; color: #166534;">{{printf "%.2f" .ConvertedAmount}} {{.BaseCurrency}}</div> <div style="font-size: 0.75rem; color: var(--color-primary);">{{printf "%.2f" .ConvertedAmount}} {{.BaseCurrency}}</div>
{{end}} {{end}}
</div> </div>
<button class="btn btn-sm" style="background: none; border: 1px solid #d1d5db; border-radius: 0.375rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer; flex-shrink: 0;" <button class="btn btn-sm" style="background: none; border: 1px solid var(--color-border); border-radius: 0.375rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; cursor: pointer; flex-shrink: 0; color: var(--color-text);"
hx-get="/expenses/{{.ID}}/edit" hx-get="/expenses/{{.ID}}/edit"
hx-target="#receipt-form" hx-target="#receipt-form"
hx-swap="innerHTML" hx-swap="innerHTML"
@ -52,7 +52,7 @@
{{end}} {{end}}
</div> </div>
{{else}} {{else}}
<div style="text-align: center; padding: 2rem; color: #94a3b8; font-size: 0.875rem;"> <div style="text-align: center; padding: 2rem; color: var(--color-text-muted); font-size: 0.875rem;">
<p>No receipts yet.</p> <p>No receipts yet.</p>
</div> </div>
{{end}} {{end}}
@ -81,7 +81,7 @@
<!-- Submit Event (only if open and has expenses) --> <!-- Submit Event (only if open and has expenses) -->
{{if and (eq .Event.Status "open") .Expenses}} {{if and (eq .Event.Status "open") .Expenses}}
<div style="margin-top: 2rem; border-top: 1px solid #e2e8f0; 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"> <form hx-post="/events/{{.Event.ID}}/file" hx-target="body" hx-push-url="true">
<div class="form-group"> <div class="form-group">

View file

@ -10,8 +10,8 @@
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="alternate icon" href="/static/icons/icon-192.png"> <link rel="alternate icon" href="/static/icons/icon-192.png">
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
<link rel="apple-touch-icon" href="/static/icons/icon-192.png"> <link rel="apple-touch-icon" sizes="180x180" href="/static/icons/icon-180.png">
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.css?v=4">
<script src="https://unpkg.com/htmx.org@1.9.10"></script> <script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head> </head>
<body> <body>

View file

@ -92,8 +92,8 @@
</div> </div>
{{if .BaseCurrency}} {{if .BaseCurrency}}
<div class="card" style="padding: 0.75rem; background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 0.5rem; margin-bottom: 1rem;"> <div class="card" style="padding: 0.75rem; background: #064e3b; border: 1px solid #065f46; border-radius: 0.5rem; margin-bottom: 1rem;">
<div style="font-size: 0.875rem; font-weight: 600; color: #166534; margin-bottom: 0.5rem;"> <div style="font-size: 0.875rem; font-weight: 600; color: #6ee7b7; margin-bottom: 0.5rem;">
Claim Conversion Claim Conversion
</div> </div>
<div class="form-row"> <div class="form-row">
@ -104,7 +104,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Rate</label> <label>Rate</label>
<input type="text" class="form-control" value="1 {{.Currency}} = {{printf "%.6f" .ExchangeRate}} {{.BaseCurrency}}" readonly style="background: #f9fafb; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.375rem; width: 100%; box-sizing: border-box;"> <input type="text" class="form-control" value="1 {{.Currency}} = {{printf "%.6f" .ExchangeRate}} {{.BaseCurrency}}" readonly style="background: var(--color-card); padding: 0.5rem; border: 1px solid var(--color-border); border-radius: 0.375rem; width: 100%; box-sizing: border-box; color: var(--color-text-muted);">
</div> </div>
</div> </div>
<input type="hidden" name="base_currency" value="{{.BaseCurrency}}"> <input type="hidden" name="base_currency" value="{{.BaseCurrency}}">