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
|
||||
}
|
||||
|
||||
// SendReport sends an email with the given subject and body, attaching a CSV
|
||||
// or PDF file. The attachment's Content-Type is inferred from its filename
|
||||
// extension (text/csv for .csv, application/octet-stream otherwise).
|
||||
func (s *Sender) SendReport(to, subject, body string, attachment *Attachment) error {
|
||||
msg, err := buildMultipartMessage(s.from, to, subject, body, attachment)
|
||||
// SendReport sends an email with the given subject and body, attaching one or
|
||||
// more files (report CSV/PDF + ZIP of receipt images).
|
||||
func (s *Sender) SendReport(to, subject, body string, attachments []*Attachment) error {
|
||||
msg, err := buildMultipartMessage(s.from, to, subject, body, attachments)
|
||||
if err != nil {
|
||||
log.Printf("ERROR [%s] email: SendReport(%s): build failed: %v",
|
||||
time.Now().Format(time.RFC3339), to, err)
|
||||
|
|
@ -94,8 +93,12 @@ func (s *Sender) SendReport(to, subject, body string, attachment *Attachment) er
|
|||
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)",
|
||||
time.Now().Format(time.RFC3339), to, attachment.Filename)
|
||||
time.Now().Format(time.RFC3339), to, strings.Join(names, ", "))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -184,13 +187,15 @@ func buildPlainMessage(from, to, subject, body string) []byte {
|
|||
|
||||
// buildMultipartMessage constructs an RFC 2046 multipart/mixed email with a
|
||||
// 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
|
||||
|
||||
// Write the main SMTP headers.
|
||||
// Write the main SMTP headers with deliverability improvements.
|
||||
writeHeader(&b, "From", from)
|
||||
writeHeader(&b, "To", to)
|
||||
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.
|
||||
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)
|
||||
}
|
||||
|
||||
// --- Attachment part ---
|
||||
aw, err := mw.CreatePart(attachmentHeader(attachment.Filename))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating attachment part: %w", err)
|
||||
}
|
||||
// --- Attachment parts (report + receipt images zip) ---
|
||||
for _, att := range attachments {
|
||||
aw, err := mw.CreatePart(attachmentHeader(att.Filename))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating attachment part %q: %w", att.Filename, err)
|
||||
}
|
||||
|
||||
enc := base64.NewEncoder(base64.StdEncoding, aw)
|
||||
if _, err := enc.Write(attachment.Content); err != nil {
|
||||
enc := base64.NewEncoder(base64.StdEncoding, aw)
|
||||
if _, err := enc.Write(att.Content); err != nil {
|
||||
enc.Close()
|
||||
return nil, fmt.Errorf("writing attachment %q: %w", att.Filename, err)
|
||||
}
|
||||
enc.Close()
|
||||
return nil, fmt.Errorf("writing attachment content: %w", err)
|
||||
}
|
||||
enc.Close()
|
||||
|
||||
mw.Close()
|
||||
|
||||
|
|
@ -268,7 +275,8 @@ func attachmentHeader(filename string) textproto.MIMEHeader {
|
|||
func attachmentContentType(filename string) string {
|
||||
switch {
|
||||
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"):
|
||||
return "application/pdf"
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -251,7 +251,7 @@ func getUserID(r *http.Request) string {
|
|||
// renderError writes an HTMX-compatible HTML error fragment to the response.
|
||||
func renderError(w http.ResponseWriter, message string) {
|
||||
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.
|
||||
|
|
@ -262,7 +262,7 @@ func renderOTPForm(w http.ResponseWriter, email string, errMsg string) {
|
|||
tmpl := template.Must(template.New("otp_form").Parse(`
|
||||
<form hx-post="/verify-otp" hx-target="#otp-form" hx-swap="innerHTML">
|
||||
<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;">
|
||||
<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;">
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ package handlers
|
|||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
|
|
@ -180,9 +179,9 @@ func (h *EventHandler) ReopenEvent(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Return HTMX fragment: green "open" badge targeting #status-badge-{id}.
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
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)
|
||||
// Redirect to dashboard so the full page renders with updated status.
|
||||
w.Header().Set("HX-Redirect", "/dashboard")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -5,12 +5,16 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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.
|
||||
var attachment *email.Attachment
|
||||
var reportAttachment *email.Attachment
|
||||
switch format {
|
||||
case "csv":
|
||||
attachment, err = generateCSV(event.Name, expenses)
|
||||
reportAttachment, err = generateCSV(event.Name, expenses)
|
||||
case "pdf":
|
||||
attachment, err = generatePDF(event.Name, expenses)
|
||||
reportAttachment, err = generatePDF(event.Name, expenses)
|
||||
}
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
log.Printf("ERROR [%s] handlers: FileEvent: SMTP not configured, cannot send email",
|
||||
time.Now().Format(time.RFC3339))
|
||||
|
|
@ -146,8 +162,8 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
subject := "Expense report for event " + event.Name
|
||||
body := "Please find attached the expense report."
|
||||
if err := h.EmailSender.SendReport(to, subject, body, attachment); err != nil {
|
||||
body := "Please find attached the expense report and receipt images."
|
||||
if err := h.EmailSender.SendReport(to, subject, body, attachments); err != nil {
|
||||
log.Printf("ERROR [%s] handlers: FileEvent: SendReport(%s): %v",
|
||||
time.Now().Format(time.RFC3339), to, err)
|
||||
http.Error(w, "Failed to send report email", http.StatusInternalServerError)
|
||||
|
|
@ -244,8 +260,9 @@ func generateCSV(eventName string, expenses []database.Expense) (*email.Attachme
|
|||
return nil, fmt.Errorf("CSV writer flush: %w", err)
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("receiptnext-%s.csv", sanitiseFilename(eventName))
|
||||
return &email.Attachment{
|
||||
Filename: "report.csv",
|
||||
Filename: filename,
|
||||
Content: buf.Bytes(),
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -321,6 +338,70 @@ func generatePDF(eventName string, expenses []database.Expense) (*email.Attachme
|
|||
}, 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 "…"
|
||||
// if the string was shortened.
|
||||
func truncateString(s string, maxLen int) string {
|
||||
|
|
@ -329,3 +410,20 @@ func truncateString(s string, maxLen int) string {
|
|||
}
|
||||
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")
|
||||
}))
|
||||
|
||||
// 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.
|
||||
r.Get("/storage/*", http.StripPrefix("/storage/", http.FileServer(http.Dir("storage"))).ServeHTTP)
|
||||
|
||||
|
|
|
|||
|
|
@ -670,10 +670,12 @@ small, .text-sm {
|
|||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--color-text);
|
||||
margin-bottom: var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: var(--space-1);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.form-label--required::after {
|
||||
|
|
@ -681,14 +683,15 @@ small, .text-sm {
|
|||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-select,
|
||||
.form-textarea {
|
||||
/* Bare input/select/textarea inside form-group get the same styling */
|
||||
.form-group input:not([type="radio"]):not([type="checkbox"]):not([type="file"]):not([type="hidden"]),
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: var(--space-4) var(--space-4);
|
||||
font-family: inherit;
|
||||
font-size: var(--text-base);
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-relaxed);
|
||||
color: var(--color-text);
|
||||
background-color: var(--color-card);
|
||||
|
|
@ -699,19 +702,55 @@ small, .text-sm {
|
|||
box-shadow var(--transition-fast);
|
||||
-webkit-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-textarea::placeholder {
|
||||
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-select:hover,
|
||||
.form-textarea:hover {
|
||||
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-select:focus,
|
||||
.form-textarea:focus {
|
||||
|
|
@ -754,6 +793,7 @@ small, .text-sm {
|
|||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-group 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-repeat: no-repeat;
|
||||
|
|
@ -764,6 +804,9 @@ small, .text-sm {
|
|||
|
||||
/* Prevent zoom on mobile for inputs */
|
||||
@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-select,
|
||||
.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
|
||||
* Version: 1.0.0
|
||||
* Cache name: receiptnext-v1
|
||||
* Version: 2.0.0
|
||||
* Cache name: receiptnext-v2
|
||||
* 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="alternate icon" href="/static/icons/icon-192.png">
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -44,9 +44,9 @@
|
|||
<option value="PLN">PLN - Polish Zloty</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="background: #f0fdf4; border: 1px solid #bbf7d0; 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>
|
||||
<p style="font-size: 0.75rem; color: #4b5563; 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: #6ee7b7; margin-bottom: 0.5rem;">Conversion Sample</div>
|
||||
<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.
|
||||
</p>
|
||||
<div class="form-row">
|
||||
|
|
@ -75,10 +75,10 @@
|
|||
{{if .Events}}
|
||||
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
|
||||
{{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 style="font-weight: 500;">{{.Name}}</div>
|
||||
<div style="font-size: 0.75rem; color: #6b7280;">
|
||||
<div style="font-weight: 500; color: var(--color-text);">{{.Name}}</div>
|
||||
<div style="font-size: 0.75rem; color: var(--color-text-muted);">
|
||||
{{.BaseCurrency}} · {{printf "%.6f" .ExchangeRate}} rate
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -91,8 +91,8 @@
|
|||
{{if eq .Status "closed"}}
|
||||
<button class="btn btn-secondary btn-sm"
|
||||
hx-put="/events/{{.ID}}/reopen"
|
||||
hx-target="#event-list"
|
||||
hx-swap="outerHTML"
|
||||
hx-target="body"
|
||||
hx-push-url="true"
|
||||
style="font-size: 0.75rem;">
|
||||
Reopen
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="alternate icon" href="/static/icons/icon-192.png">
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -25,25 +25,25 @@
|
|||
<h3 style="margin-bottom: 1rem; font-size: 1rem; font-weight: 600;">
|
||||
Receipts
|
||||
{{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}}
|
||||
</h3>
|
||||
|
||||
{{if .Expenses}}
|
||||
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
|
||||
{{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="font-weight: 500;">{{.Merchant}}</div>
|
||||
<div style="font-size: 0.75rem; color: #6b7280;">{{.Date}} · {{.Category}} {{if .Description}}· {{.Description}}{{end}}</div>
|
||||
<div style="font-weight: 500; color: var(--color-text);">{{.Merchant}}</div>
|
||||
<div style="font-size: 0.75rem; color: var(--color-text-muted);">{{.Date}} · {{.Category}} {{if .Description}}· {{.Description}}{{end}}</div>
|
||||
</div>
|
||||
<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}}
|
||||
<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}}
|
||||
</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-target="#receipt-form"
|
||||
hx-swap="innerHTML"
|
||||
|
|
@ -52,7 +52,7 @@
|
|||
{{end}}
|
||||
</div>
|
||||
{{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>
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
@ -81,7 +81,7 @@
|
|||
|
||||
<!-- Submit Event (only if open and has 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>
|
||||
<form hx-post="/events/{{.Event.ID}}/file" hx-target="body" hx-push-url="true">
|
||||
<div class="form-group">
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@
|
|||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="alternate icon" href="/static/icons/icon-192.png">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/icons/icon-180.png">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=4">
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -92,8 +92,8 @@
|
|||
</div>
|
||||
|
||||
{{if .BaseCurrency}}
|
||||
<div class="card" style="padding: 0.75rem; background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 0.5rem; margin-bottom: 1rem;">
|
||||
<div style="font-size: 0.875rem; font-weight: 600; color: #166534; margin-bottom: 0.5rem;">
|
||||
<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: #6ee7b7; margin-bottom: 0.5rem;">
|
||||
Claim Conversion
|
||||
</div>
|
||||
<div class="form-row">
|
||||
|
|
@ -104,7 +104,7 @@
|
|||
</div>
|
||||
<div class="form-group">
|
||||
<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>
|
||||
<input type="hidden" name="base_currency" value="{{.BaseCurrency}}">
|
||||
|
|
|
|||
Loading…
Reference in a new issue