feat: add KES support + base currency + exchange rate for expense claims
- Added KES and 12+ additional currencies to receipt form - Events now have base_currency (claim currency) and exchange_rate fields - Receipts show original amount + auto-computed converted amount - Converted amounts stored per expense in database - CSV and PDF reports include both original and converted amounts - Dashboard shows claim currency per event card
This commit is contained in:
parent
46c78ef507
commit
4895b3d608
9 changed files with 307 additions and 84 deletions
|
|
@ -24,7 +24,7 @@ type ReceiptData struct {
|
|||
}
|
||||
|
||||
const (
|
||||
geminiAPIURL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-lite:generateContent"
|
||||
geminiAPIURL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.1-flash-lite:generateContent"
|
||||
requestTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -35,25 +35,29 @@ type OTP struct {
|
|||
|
||||
// Event represents a row in the events table.
|
||||
type Event struct {
|
||||
ID string
|
||||
UserID string
|
||||
Name string
|
||||
Status string
|
||||
CreatedAt string
|
||||
ID string
|
||||
UserID string
|
||||
Name string
|
||||
Status string
|
||||
BaseCurrency string
|
||||
ExchangeRate float64
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// Expense represents a row in the expenses table.
|
||||
type Expense struct {
|
||||
ID string
|
||||
EventID string
|
||||
Amount float64
|
||||
Currency string
|
||||
Merchant string
|
||||
Category string
|
||||
Description string
|
||||
Date string
|
||||
ImagePath string
|
||||
CreatedAt string
|
||||
ID string
|
||||
EventID string
|
||||
Amount float64
|
||||
Currency string
|
||||
ConvertedAmount float64
|
||||
BaseCurrency string
|
||||
Merchant string
|
||||
Category string
|
||||
Description string
|
||||
Date string
|
||||
ImagePath string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -101,6 +105,8 @@ func createTables(db *sql.DB) error {
|
|||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
status TEXT CHECK(status IN ('open', 'closed')) DEFAULT 'open',
|
||||
base_currency TEXT NOT NULL DEFAULT 'EUR',
|
||||
exchange_rate REAL NOT NULL DEFAULT 1.0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id)
|
||||
)`,
|
||||
|
|
@ -109,6 +115,8 @@ func createTables(db *sql.DB) error {
|
|||
event_id TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
currency TEXT NOT NULL,
|
||||
converted_amount REAL NOT NULL DEFAULT 0,
|
||||
base_currency TEXT NOT NULL DEFAULT 'EUR',
|
||||
merchant TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
description TEXT,
|
||||
|
|
@ -207,15 +215,21 @@ func DeleteOTP(db *sql.DB, email string) error {
|
|||
// Event queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CreateEvent inserts a new event row.
|
||||
func CreateEvent(db *sql.DB, id, userID, name string) error {
|
||||
// CreateEvent inserts a new event row with optional base currency and exchange rate.
|
||||
func CreateEvent(db *sql.DB, id, userID, name, baseCurrency string, exchangeRate float64) error {
|
||||
if baseCurrency == "" {
|
||||
baseCurrency = "EUR"
|
||||
}
|
||||
if exchangeRate <= 0 {
|
||||
exchangeRate = 1.0
|
||||
}
|
||||
_, err := db.Exec(
|
||||
"INSERT INTO events (id, user_id, name) VALUES (?, ?, ?)",
|
||||
id, userID, name,
|
||||
"INSERT INTO events (id, user_id, name, base_currency, exchange_rate) VALUES (?, ?, ?, ?, ?)",
|
||||
id, userID, name, baseCurrency, exchangeRate,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("ERROR [%s] database: CreateEvent(%s, %s, %s): %v",
|
||||
time.Now().Format(time.RFC3339), id, userID, name, err)
|
||||
log.Printf("ERROR [%s] database: CreateEvent(%s, %s, %s, %s, %.4f): %v",
|
||||
time.Now().Format(time.RFC3339), id, userID, name, baseCurrency, exchangeRate, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
|
@ -223,7 +237,7 @@ func CreateEvent(db *sql.DB, id, userID, name string) error {
|
|||
// GetEventsByUser returns all events belonging to a user, ordered by creation date descending.
|
||||
func GetEventsByUser(db *sql.DB, userID string) ([]Event, error) {
|
||||
rows, err := db.Query(
|
||||
"SELECT id, user_id, name, status, created_at FROM events WHERE user_id = ? ORDER BY created_at DESC",
|
||||
"SELECT id, user_id, name, status, base_currency, exchange_rate, created_at FROM events WHERE user_id = ? ORDER BY created_at DESC",
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
|
|
@ -236,7 +250,7 @@ func GetEventsByUser(db *sql.DB, userID string) ([]Event, error) {
|
|||
var events []Event
|
||||
for rows.Next() {
|
||||
var e Event
|
||||
if err := rows.Scan(&e.ID, &e.UserID, &e.Name, &e.Status, &e.CreatedAt); err != nil {
|
||||
if err := rows.Scan(&e.ID, &e.UserID, &e.Name, &e.Status, &e.BaseCurrency, &e.ExchangeRate, &e.CreatedAt); err != nil {
|
||||
log.Printf("ERROR [%s] database: GetEventsByUser scan: %v",
|
||||
time.Now().Format(time.RFC3339), err)
|
||||
return nil, err
|
||||
|
|
@ -248,9 +262,9 @@ func GetEventsByUser(db *sql.DB, userID string) ([]Event, error) {
|
|||
|
||||
// GetEventByID returns a single event by ID, or nil if not found.
|
||||
func GetEventByID(db *sql.DB, id string) (*Event, error) {
|
||||
row := db.QueryRow("SELECT id, user_id, name, status, created_at FROM events WHERE id = ?", id)
|
||||
row := db.QueryRow("SELECT id, user_id, name, status, base_currency, exchange_rate, created_at FROM events WHERE id = ?", id)
|
||||
e := &Event{}
|
||||
if err := row.Scan(&e.ID, &e.UserID, &e.Name, &e.Status, &e.CreatedAt); err != nil {
|
||||
if err := row.Scan(&e.ID, &e.UserID, &e.Name, &e.Status, &e.BaseCurrency, &e.ExchangeRate, &e.CreatedAt); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
|
|
@ -276,12 +290,12 @@ func UpdateEventStatus(db *sql.DB, id, status string) error {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CreateExpense inserts a new expense row from the provided Expense struct.
|
||||
// The expense's ID, EventID, and other fields must be set by the caller.
|
||||
func CreateExpense(db *sql.DB, expense Expense) error {
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO expenses (id, event_id, amount, currency, merchant, category, description, date, image_path)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT INTO expenses (id, event_id, amount, currency, converted_amount, base_currency, merchant, category, description, date, image_path)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
expense.ID, expense.EventID, expense.Amount, expense.Currency,
|
||||
expense.ConvertedAmount, expense.BaseCurrency,
|
||||
expense.Merchant, expense.Category, expense.Description,
|
||||
expense.Date, expense.ImagePath,
|
||||
)
|
||||
|
|
@ -295,8 +309,8 @@ func CreateExpense(db *sql.DB, expense Expense) error {
|
|||
// GetExpensesByEvent returns all expenses for a given event, ordered by creation date descending.
|
||||
func GetExpensesByEvent(db *sql.DB, eventID string) ([]Expense, error) {
|
||||
rows, err := db.Query(
|
||||
`SELECT id, event_id, amount, currency, merchant, category,
|
||||
COALESCE(description, ''), date, image_path, created_at
|
||||
`SELECT id, event_id, amount, currency, converted_amount, base_currency,
|
||||
merchant, category, COALESCE(description, ''), date, image_path, created_at
|
||||
FROM expenses WHERE event_id = ? ORDER BY created_at DESC`,
|
||||
eventID,
|
||||
)
|
||||
|
|
@ -311,8 +325,8 @@ func GetExpensesByEvent(db *sql.DB, eventID string) ([]Expense, error) {
|
|||
for rows.Next() {
|
||||
var e Expense
|
||||
if err := rows.Scan(
|
||||
&e.ID, &e.EventID, &e.Amount, &e.Currency, &e.Merchant,
|
||||
&e.Category, &e.Description, &e.Date, &e.ImagePath, &e.CreatedAt,
|
||||
&e.ID, &e.EventID, &e.Amount, &e.Currency, &e.ConvertedAmount, &e.BaseCurrency,
|
||||
&e.Merchant, &e.Category, &e.Description, &e.Date, &e.ImagePath, &e.CreatedAt,
|
||||
); err != nil {
|
||||
log.Printf("ERROR [%s] database: GetExpensesByEvent scan: %v",
|
||||
time.Now().Format(time.RFC3339), err)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/expenseflow/internal/database"
|
||||
|
|
@ -95,8 +96,20 @@ func (h *EventHandler) CreateEvent(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
baseCurrency := r.FormValue("base_currency")
|
||||
if baseCurrency == "" {
|
||||
baseCurrency = "EUR"
|
||||
}
|
||||
|
||||
exchangeRate := 1.0
|
||||
if v := r.FormValue("exchange_rate"); v != "" {
|
||||
if parsed, err := strconv.ParseFloat(v, 64); err == nil && parsed > 0 {
|
||||
exchangeRate = parsed
|
||||
}
|
||||
}
|
||||
|
||||
id := utils.New()
|
||||
if err := database.CreateEvent(h.DB, id, userID, name); err != nil {
|
||||
if err := database.CreateEvent(h.DB, id, userID, name, baseCurrency, exchangeRate); err != nil {
|
||||
log.Printf("ERROR [%s] handlers: CreateEvent: %v",
|
||||
time.Now().Format(time.RFC3339), err)
|
||||
http.Error(w, "Failed to create event", http.StatusInternalServerError)
|
||||
|
|
|
|||
|
|
@ -115,10 +115,27 @@ func (h *ExpenseHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// 8. Call the DeepSeek Vision API for AI extraction.
|
||||
// 8. Fetch the event's base currency and exchange rate.
|
||||
eventID := getCurrentEventID(r)
|
||||
var baseCurrency string
|
||||
var exchangeRate float64
|
||||
if eventID != "" {
|
||||
if event, err := database.GetEventByID(h.DB, eventID); err == nil && event != nil {
|
||||
baseCurrency = event.BaseCurrency
|
||||
exchangeRate = event.ExchangeRate
|
||||
}
|
||||
}
|
||||
if baseCurrency == "" {
|
||||
baseCurrency = "EUR"
|
||||
}
|
||||
if exchangeRate <= 0 {
|
||||
exchangeRate = 1.0
|
||||
}
|
||||
|
||||
// 9. Call the Gemini Vision API for AI extraction.
|
||||
receipt, aiErr := ai.ExtractReceipt(storagePath)
|
||||
|
||||
// 9. Render the receipt_form.html fragment.
|
||||
// 10. Render the receipt_form.html fragment.
|
||||
tmpl, err := template.ParseFiles("templates/receipt_form.html")
|
||||
if err != nil {
|
||||
log.Printf("ERROR [%s] handlers: UploadReceipt: parse template: %v",
|
||||
|
|
@ -128,14 +145,17 @@ func (h *ExpenseHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"ImagePath": storagePath,
|
||||
"AIError": "",
|
||||
"Amount": "",
|
||||
"Currency": "",
|
||||
"Merchant": "",
|
||||
"Category": "",
|
||||
"Date": "",
|
||||
"Description": "",
|
||||
"ImagePath": storagePath,
|
||||
"AIError": "",
|
||||
"Amount": "",
|
||||
"Currency": "",
|
||||
"Merchant": "",
|
||||
"Category": "",
|
||||
"Date": "",
|
||||
"Description": "",
|
||||
"BaseCurrency": baseCurrency,
|
||||
"ExchangeRate": exchangeRate,
|
||||
"ConvertedAmount": "",
|
||||
}
|
||||
|
||||
if aiErr != nil {
|
||||
|
|
@ -148,6 +168,12 @@ func (h *ExpenseHandler) UploadReceipt(w http.ResponseWriter, r *http.Request) {
|
|||
data["Merchant"] = receipt.Merchant
|
||||
data["Category"] = receipt.Category
|
||||
data["Date"] = receipt.Date
|
||||
|
||||
// Compute converted amount only if currencies differ.
|
||||
if receipt.Currency != "" && receipt.Currency != baseCurrency && receipt.Amount > 0 {
|
||||
converted := receipt.Amount * exchangeRate
|
||||
data["ConvertedAmount"] = strconv.FormatFloat(converted, 'f', 2, 64)
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
|
@ -197,6 +223,10 @@ func (h *ExpenseHandler) SaveExpense(w http.ResponseWriter, r *http.Request) {
|
|||
description := r.FormValue("description")
|
||||
imagePath := r.FormValue("image_path")
|
||||
|
||||
// Read conversion fields (hidden fields from receipt form).
|
||||
baseCurrency := r.FormValue("base_currency")
|
||||
convertedAmountStr := r.FormValue("converted_amount")
|
||||
|
||||
// 3. Validate required fields.
|
||||
var missing []string
|
||||
if amountStr == "" {
|
||||
|
|
@ -231,17 +261,33 @@ func (h *ExpenseHandler) SaveExpense(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Parse converted amount (optional).
|
||||
convertedAmount := 0.0
|
||||
if convertedAmountStr != "" {
|
||||
convertedAmount, _ = strconv.ParseFloat(convertedAmountStr, 64)
|
||||
}
|
||||
if baseCurrency == "" || baseCurrency == currency {
|
||||
baseCurrency = currency
|
||||
convertedAmount = amount
|
||||
}
|
||||
if convertedAmount <= 0 {
|
||||
convertedAmount = amount
|
||||
baseCurrency = currency
|
||||
}
|
||||
|
||||
// 5. Build and save the expense record.
|
||||
expense := database.Expense{
|
||||
ID: utils.New(),
|
||||
EventID: eventID,
|
||||
Amount: amount,
|
||||
Currency: currency,
|
||||
Merchant: merchant,
|
||||
Category: category,
|
||||
Description: description,
|
||||
Date: date,
|
||||
ImagePath: imagePath,
|
||||
ID: utils.New(),
|
||||
EventID: eventID,
|
||||
Amount: amount,
|
||||
Currency: currency,
|
||||
ConvertedAmount: convertedAmount,
|
||||
BaseCurrency: baseCurrency,
|
||||
Merchant: merchant,
|
||||
Category: category,
|
||||
Description: description,
|
||||
Date: date,
|
||||
ImagePath: imagePath,
|
||||
}
|
||||
|
||||
if err := database.CreateExpense(h.DB, expense); err != nil {
|
||||
|
|
|
|||
|
|
@ -173,27 +173,70 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
// generateCSV creates a CSV attachment from the provided expenses.
|
||||
// The CSV includes a header row and one data row per expense.
|
||||
// If the expenses use a different currency than the base currency, both
|
||||
// original and converted amounts are included.
|
||||
func generateCSV(eventName string, expenses []database.Expense) (*email.Attachment, error) {
|
||||
var buf bytes.Buffer
|
||||
writer := csv.NewWriter(&buf)
|
||||
|
||||
// Determine if we need conversion columns.
|
||||
hasConversion := false
|
||||
for _, exp := range expenses {
|
||||
if exp.ConvertedAmount > 0 && exp.BaseCurrency != "" && exp.BaseCurrency != exp.Currency {
|
||||
hasConversion = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Write header row.
|
||||
if err := writer.Write([]string{"Date", "Merchant", "Amount", "Currency", "Category", "Description"}); err != nil {
|
||||
var header []string
|
||||
if hasConversion {
|
||||
header = []string{"Date", "Merchant", "Amount", "Currency", "Converted", "Claim Currency", "Category", "Description"}
|
||||
} else {
|
||||
header = []string{"Date", "Merchant", "Amount", "Currency", "Category", "Description"}
|
||||
}
|
||||
if err := writer.Write(header); err != nil {
|
||||
return nil, fmt.Errorf("write CSV header: %w", err)
|
||||
}
|
||||
|
||||
// Write one data row per expense.
|
||||
totalOrig := 0.0
|
||||
totalConv := 0.0
|
||||
for _, exp := range expenses {
|
||||
if err := writer.Write([]string{
|
||||
exp.Date,
|
||||
exp.Merchant,
|
||||
fmt.Sprintf("%.2f", exp.Amount),
|
||||
exp.Currency,
|
||||
exp.Category,
|
||||
exp.Description,
|
||||
}); err != nil {
|
||||
var row []string
|
||||
if hasConversion {
|
||||
row = []string{
|
||||
exp.Date,
|
||||
exp.Merchant,
|
||||
fmt.Sprintf("%.2f", exp.Amount),
|
||||
exp.Currency,
|
||||
fmt.Sprintf("%.2f", exp.ConvertedAmount),
|
||||
exp.BaseCurrency,
|
||||
exp.Category,
|
||||
exp.Description,
|
||||
}
|
||||
} else {
|
||||
row = []string{
|
||||
exp.Date,
|
||||
exp.Merchant,
|
||||
fmt.Sprintf("%.2f", exp.Amount),
|
||||
exp.Currency,
|
||||
exp.Category,
|
||||
exp.Description,
|
||||
}
|
||||
}
|
||||
if err := writer.Write(row); err != nil {
|
||||
return nil, fmt.Errorf("write CSV row: %w", err)
|
||||
}
|
||||
totalOrig += exp.Amount
|
||||
totalConv += exp.ConvertedAmount
|
||||
}
|
||||
|
||||
// Write totals row.
|
||||
if hasConversion {
|
||||
writer.Write([]string{"TOTAL", "", fmt.Sprintf("%.2f", totalOrig), "", fmt.Sprintf("%.2f", totalConv), "", "", ""})
|
||||
} else {
|
||||
writer.Write([]string{"TOTAL", "", fmt.Sprintf("%.2f", totalOrig), "", "", "", ""})
|
||||
}
|
||||
|
||||
writer.Flush()
|
||||
|
|
@ -209,6 +252,8 @@ func generateCSV(eventName string, expenses []database.Expense) (*email.Attachme
|
|||
|
||||
// generatePDF creates a PDF attachment from the provided expenses using gofpdf.
|
||||
// The PDF contains a title row, a header row, and one data row per expense.
|
||||
// If the expenses use a different currency than the base currency, both
|
||||
// original and converted amounts are included.
|
||||
func generatePDF(eventName string, expenses []database.Expense) (*email.Attachment, error) {
|
||||
pdf := gofpdf.New("P", "mm", "A4", "")
|
||||
pdf.AddPage()
|
||||
|
|
@ -218,22 +263,49 @@ func generatePDF(eventName string, expenses []database.Expense) (*email.Attachme
|
|||
pdf.Cell(0, 10, "Expense Report: "+eventName)
|
||||
pdf.Ln(15)
|
||||
|
||||
// Determine if we need conversion columns.
|
||||
hasConversion := false
|
||||
for _, exp := range expenses {
|
||||
if exp.ConvertedAmount > 0 && exp.BaseCurrency != "" && exp.BaseCurrency != exp.Currency {
|
||||
hasConversion = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Table header row.
|
||||
pdf.SetFont("Helvetica", "B", 10)
|
||||
headers := []string{"Date", "Merchant", "Amount", "Currency", "Category"}
|
||||
for _, h := range headers {
|
||||
pdf.Cell(35, 8, h)
|
||||
var headers []string
|
||||
var colWidths []float64
|
||||
if hasConversion {
|
||||
headers = []string{"Date", "Merchant", "Amount", "Curr.", "Converted", "Claim", "Category"}
|
||||
colWidths = []float64{25, 40, 20, 12, 22, 14, 30}
|
||||
} else {
|
||||
headers = []string{"Date", "Merchant", "Amount", "Currency", "Category"}
|
||||
colWidths = []float64{30, 45, 25, 20, 45}
|
||||
}
|
||||
for i, h := range headers {
|
||||
pdf.Cell(colWidths[i], 8, h)
|
||||
}
|
||||
pdf.Ln(8)
|
||||
|
||||
// Table data rows.
|
||||
pdf.SetFont("Helvetica", "", 10)
|
||||
pdf.SetFont("Helvetica", "", 9)
|
||||
for _, exp := range expenses {
|
||||
pdf.Cell(35, 8, exp.Date)
|
||||
pdf.Cell(35, 8, exp.Merchant)
|
||||
pdf.Cell(20, 8, fmt.Sprintf("%.2f", exp.Amount))
|
||||
pdf.Cell(20, 8, exp.Currency)
|
||||
pdf.Cell(35, 8, exp.Category)
|
||||
if hasConversion {
|
||||
pdf.Cell(colWidths[0], 8, exp.Date)
|
||||
pdf.Cell(colWidths[1], 8, truncateString(exp.Merchant, 18))
|
||||
pdf.Cell(colWidths[2], 8, fmt.Sprintf("%.2f", exp.Amount))
|
||||
pdf.Cell(colWidths[3], 8, exp.Currency)
|
||||
pdf.Cell(colWidths[4], 8, fmt.Sprintf("%.2f", exp.ConvertedAmount))
|
||||
pdf.Cell(colWidths[5], 8, exp.BaseCurrency)
|
||||
pdf.Cell(colWidths[6], 8, truncateString(exp.Category, 12))
|
||||
} else {
|
||||
pdf.Cell(colWidths[0], 8, exp.Date)
|
||||
pdf.Cell(colWidths[1], 8, truncateString(exp.Merchant, 20))
|
||||
pdf.Cell(colWidths[2], 8, fmt.Sprintf("%.2f", exp.Amount))
|
||||
pdf.Cell(colWidths[3], 8, exp.Currency)
|
||||
pdf.Cell(colWidths[4], 8, truncateString(exp.Category, 20))
|
||||
}
|
||||
pdf.Ln(8)
|
||||
}
|
||||
|
||||
|
|
@ -248,3 +320,12 @@ func generatePDF(eventName string, expenses []database.Expense) (*email.Attachme
|
|||
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 {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen-1] + "…"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,25 @@
|
|||
<label for="name">Event Name</label>
|
||||
<input type="text" id="name" name="name" placeholder="e.g., WebSummit 2026" required>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="base_currency">Claim Currency</label>
|
||||
<select id="base_currency" name="base_currency" required>
|
||||
<option value="EUR">EUR - Euro</option>
|
||||
<option value="USD">USD - US Dollar</option>
|
||||
<option value="GBP">GBP - British Pound</option>
|
||||
<option value="KES" selected>KES - Kenyan Shilling</option>
|
||||
<option value="CHF">CHF - Swiss Franc</option>
|
||||
<option value="SEK">SEK - Swedish Krona</option>
|
||||
<option value="NOK">NOK - Norwegian Krone</option>
|
||||
<option value="PLN">PLN - Polish Zloty</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="exchange_rate">Exchange Rate (1 {receipt currency} = ? {claim currency})</label>
|
||||
<input type="number" id="exchange_rate" name="exchange_rate" step="0.000001" min="0.000001" value="1.0" required inputmode="decimal">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-block">Create Event</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -50,6 +69,7 @@
|
|||
</div>
|
||||
<div class="event-card-meta">
|
||||
<span>Created: {{.CreatedAt}}</span>
|
||||
<span>Claim in: {{.BaseCurrency}} @ {{printf "%.6f" .ExchangeRate}}</span>
|
||||
</div>
|
||||
<div class="event-card-actions">
|
||||
{{if eq .Status "open"}}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,14 @@
|
|||
|
||||
<!-- Expense List -->
|
||||
<div id="expense-list">
|
||||
<h3 style="margin-bottom: 1rem;">Expenses</h3>
|
||||
<h3 style="margin-bottom: 1rem;">
|
||||
Expenses
|
||||
{{if and .Event.BaseCurrency (ne .Event.BaseCurrency "")}}
|
||||
<span style="font-size: 0.875rem; font-weight: 400; color: #6b7280;">
|
||||
(claim in {{.Event.BaseCurrency}})
|
||||
</span>
|
||||
{{end}}
|
||||
</h3>
|
||||
{{if .Expenses}}
|
||||
<div class="expense-list">
|
||||
{{range .Expenses}}
|
||||
|
|
@ -70,6 +77,11 @@
|
|||
<div class="expense-item-amount">
|
||||
<span class="amount">{{printf "%.2f" .Amount}}</span>
|
||||
<span class="currency">{{.Currency}}</span>
|
||||
{{if .ConvertedAmount}}
|
||||
<div style="font-size: 0.75rem; color: #166534;">
|
||||
≈ {{printf "%.2f" .ConvertedAmount}} {{.BaseCurrency}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@
|
|||
<div class="expense-item-amount">
|
||||
<span class="amount">{{printf "%.2f" .Amount}}</span>
|
||||
<span class="currency">{{.Currency}}</span>
|
||||
{{if .ConvertedAmount}}
|
||||
<div style="font-size: 0.75rem; color: #166534;">
|
||||
≈ {{printf "%.2f" .ConvertedAmount}} {{.BaseCurrency}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -29,18 +29,29 @@
|
|||
<label for="currency">Currency *</label>
|
||||
<select id="currency" name="currency" required>
|
||||
<option value="">Select</option>
|
||||
<option value="USD" {{if eq .Currency "USD"}}selected{{end}}>USD</option>
|
||||
<option value="EUR" {{if eq .Currency "EUR"}}selected{{end}}>EUR</option>
|
||||
<option value="GBP" {{if eq .Currency "GBP"}}selected{{end}}>GBP</option>
|
||||
<option value="JPY" {{if eq .Currency "JPY"}}selected{{end}}>JPY</option>
|
||||
<option value="CHF" {{if eq .Currency "CHF"}}selected{{end}}>CHF</option>
|
||||
<option value="SEK" {{if eq .Currency "SEK"}}selected{{end}}>SEK</option>
|
||||
<option value="NOK" {{if eq .Currency "NOK"}}selected{{end}}>NOK</option>
|
||||
<option value="DKK" {{if eq .Currency "DKK"}}selected{{end}}>DKK</option>
|
||||
<option value="PLN" {{if eq .Currency "PLN"}}selected{{end}}>PLN</option>
|
||||
<option value="CZK" {{if eq .Currency "CZK"}}selected{{end}}>CZK</option>
|
||||
<option value="HUF" {{if eq .Currency "HUF"}}selected{{end}}>HUF</option>
|
||||
<option value="RON" {{if eq .Currency "RON"}}selected{{end}}>RON</option>
|
||||
<option value="KES" {{if eq .Currency "KES"}}selected{{end}}>KES - Kenyan Shilling</option>
|
||||
<option value="USD" {{if eq .Currency "USD"}}selected{{end}}>USD - US Dollar</option>
|
||||
<option value="EUR" {{if eq .Currency "EUR"}}selected{{end}}>EUR - Euro</option>
|
||||
<option value="GBP" {{if eq .Currency "GBP"}}selected{{end}}>GBP - British Pound</option>
|
||||
<option value="JPY" {{if eq .Currency "JPY"}}selected{{end}}>JPY - Japanese Yen</option>
|
||||
<option value="CHF" {{if eq .Currency "CHF"}}selected{{end}}>CHF - Swiss Franc</option>
|
||||
<option value="SEK" {{if eq .Currency "SEK"}}selected{{end}}>SEK - Swedish Krona</option>
|
||||
<option value="NOK" {{if eq .Currency "NOK"}}selected{{end}}>NOK - Norwegian Krone</option>
|
||||
<option value="DKK" {{if eq .Currency "DKK"}}selected{{end}}>DKK - Danish Krone</option>
|
||||
<option value="PLN" {{if eq .Currency "PLN"}}selected{{end}}>PLN - Polish Zloty</option>
|
||||
<option value="CZK" {{if eq .Currency "CZK"}}selected{{end}}>CZK - Czech Koruna</option>
|
||||
<option value="HUF" {{if eq .Currency "HUF"}}selected{{end}}>HUF - Hungarian Forint</option>
|
||||
<option value="RON" {{if eq .Currency "RON"}}selected{{end}}>RON - Romanian Leu</option>
|
||||
<option value="ZAR" {{if eq .Currency "ZAR"}}selected{{end}}>ZAR - South African Rand</option>
|
||||
<option value="NGN" {{if eq .Currency "NGN"}}selected{{end}}>NGN - Nigerian Naira</option>
|
||||
<option value="EGP" {{if eq .Currency "EGP"}}selected{{end}}>EGP - Egyptian Pound</option>
|
||||
<option value="TZS" {{if eq .Currency "TZS"}}selected{{end}}>TZS - Tanzanian Shilling</option>
|
||||
<option value="UGX" {{if eq .Currency "UGX"}}selected{{end}}>UGX - Ugandan Shilling</option>
|
||||
<option value="RWF" {{if eq .Currency "RWF"}}selected{{end}}>RWF - Rwandan Franc</option>
|
||||
<option value="AED" {{if eq .Currency "AED"}}selected{{end}}>AED - UAE Dirham</option>
|
||||
<option value="CNY" {{if eq .Currency "CNY"}}selected{{end}}>CNY - Chinese Yuan</option>
|
||||
<option value="INR" {{if eq .Currency "INR"}}selected{{end}}>INR - Indian Rupee</option>
|
||||
<option value="BRL" {{if eq .Currency "BRL"}}selected{{end}}>BRL - Brazilian Real</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -74,6 +85,27 @@
|
|||
<textarea id="description" name="description" placeholder="Optional notes...">{{.Description}}</textarea>
|
||||
</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;">
|
||||
Claim Conversion
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="converted_amount">Converted Amount ({{.BaseCurrency}})</label>
|
||||
<input type="number" id="converted_amount" name="converted_amount" step="0.01" min="0" placeholder="0.00"
|
||||
value="{{.ConvertedAmount}}" inputmode="decimal">
|
||||
</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;">
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="base_currency" value="{{.BaseCurrency}}">
|
||||
<input type="hidden" name="exchange_rate" value="{{.ExchangeRate}}">
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button type="submit" class="btn btn-primary" id="save-indicator">
|
||||
<span class="spinner htmx-indicator"></span>
|
||||
|
|
|
|||
Loading…
Reference in a new issue