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:
parent
e22bd56169
commit
dcb43b08a0
14 changed files with 224 additions and 65 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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;">
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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
11
main.go
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
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 |
|
|
@ -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
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}}">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue