feat: add month hierarchy, AI categories, and UI polish

- Restructure hierarchy: Month → Event → Expense (new months table, FK)
- Add MonthHandler with CRUD, monthly reports, dropdown create form
- Events now scoped under months with extended ownership chain
- AI extraction: 16 specific expense categories (Airfare, Meals, etc.)
- UI: category dropdown, button-consistent cards, centered mobile shell on desktop
- Dashboard/month views show claim totals per card
- Description field now mandatory, forms simplified
- Months sorted by name chronologically (latest first)
This commit is contained in:
Claus Lohmar 2026-07-14 09:28:33 +00:00
parent 1c9ab4554a
commit d65c0cd5fa
21 changed files with 1475 additions and 404 deletions

View file

@ -88,7 +88,7 @@ func (p geminiProvider) ExtractReceipt(imagePath string) (*ReceiptData, error) {
payload := geminiRequest{
Contents: []geminiContent{{
Parts: []geminiPart{
{Text: "Analyze this receipt. Extract as strict JSON with keys: \"merchant\" (string), \"amount\" (number), \"currency\" (3-letter code), \"category\" (Food/Travel/Lodging/Software/Other), \"date\" (YYYY-MM-DD). Return ONLY valid JSON. No markdown."},
{Text: fmt.Sprintf("Analyze this receipt. Extract as strict JSON with keys: \"merchant\" (string), \"amount\" (number), \"currency\" (3-letter code), \"category\" (string, MUST be exactly one of: %s), \"date\" (YYYY-MM-DD). Return ONLY valid JSON. No markdown.", categoryPrompt())},
{InlineData: &geminiFileData{MimeType: mimeType, Data: b64Data}},
},
}},

View file

@ -86,7 +86,7 @@ func (p openaiProvider) ExtractReceipt(imagePath string) (*ReceiptData, error) {
Messages: []openaiMessage{{
Role: "user",
Content: []openaiContent{
{Type: "text", Text: "Analyze this receipt image. Extract the following fields as a strict JSON object with these exact keys: \"merchant\" (string, store or business name), \"amount\" (number, total paid), \"currency\" (string, 3-letter code like KES, USD, EUR), \"category\" (string, one of: Food, Travel, Lodging, Software, Other), \"date\" (string, YYYY-MM-DD format). Return ONLY valid JSON. No markdown, no explanation, no code fences."},
{Type: "text", Text: fmt.Sprintf("Analyze this receipt image. Extract the following fields as a strict JSON object with these exact keys: \"merchant\" (string, store or business name), \"amount\" (number, total paid), \"currency\" (string, 3-letter code like KES, USD, EUR), \"category\" (string, MUST be exactly one of: %s), \"date\" (string, YYYY-MM-DD format). Return ONLY valid JSON. No markdown, no explanation, no code fences.", categoryPrompt())},
{Type: "image_url", ImageURL: &openaiImage{URL: dataURL}},
},
}},

View file

@ -23,6 +23,32 @@ type ReceiptData struct {
Date string `json:"date"`
}
// ValidCategories is the list of allowed expense categories the AI should
// classify receipts into.
var ValidCategories = []string{
"Airfare",
"Accommodation",
"Meals Self",
"Staff Meal",
"Client Meal",
"Travel - Taxi",
"Travel - Phone",
"Misc Travel",
"Mobile / Office Phone",
"Office Supplies",
"Postage / Couriers",
"Other Expenses",
"Hotel",
"Per Diem",
"Visa Fees",
"Connectivity (internet connections)",
}
// categoryPrompt returns the comma-separated category list for AI prompts.
func categoryPrompt() string {
return `"Airfare", "Accommodation", "Meals Self", "Staff Meal", "Client Meal", "Travel - Taxi", "Travel - Phone", "Misc Travel", "Mobile / Office Phone", "Office Supplies", "Postage / Couriers", "Other Expenses", "Hotel", "Per Diem", "Visa Fees", "Connectivity (internet connections)"`
}
// Provider is the interface that wraps receipt extraction.
// Each provider (Gemini, OpenAI, Ollama) implements this interface.
type Provider interface {

View file

@ -36,10 +36,19 @@ type OTP struct {
ExpiresAt string
}
// Month represents a row in the months table.
type Month struct {
ID string
UserID string
Name string
CreatedAt string
}
// Event represents a row in the events table.
type Event struct {
ID string
UserID string
MonthID string
Name string
Status string
BaseCurrency string
@ -108,7 +117,7 @@ func Init() (*sql.DB, error) {
return DB, nil
}
// createTables executes the DDL statements for all four tables.
// createTables executes the DDL statements for all tables.
func createTables(db *sql.DB) error {
statements := []string{
`CREATE TABLE IF NOT EXISTS users (
@ -124,15 +133,24 @@ func createTables(db *sql.DB) error {
otp_code TEXT NOT NULL,
expires_at DATETIME NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS months (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id)
)`,
`CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
month_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)
FOREIGN KEY(user_id) REFERENCES users(id),
FOREIGN KEY(month_id) REFERENCES months(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS expenses (
id TEXT PRIMARY KEY,
@ -175,6 +193,7 @@ func migrateTables(db *sql.DB) error {
"ALTER TABLE users ADD COLUMN name TEXT NOT NULL DEFAULT ''",
"ALTER TABLE users ADD COLUMN department TEXT NOT NULL DEFAULT ''",
"ALTER TABLE users ADD COLUMN onboarded INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE events ADD COLUMN month_id TEXT NOT NULL DEFAULT ''",
}
for _, stmt := range migrations {
@ -287,12 +306,124 @@ func DeleteOTP(db *sql.DB, email string) error {
return err
}
// ---------------------------------------------------------------------------
// Month queries
// ---------------------------------------------------------------------------
// CreateMonth inserts a new month row.
func CreateMonth(db *sql.DB, id, userID, name string) error {
_, err := db.Exec(
"INSERT INTO months (id, user_id, name) VALUES (?, ?, ?)",
id, userID, name,
)
if err != nil {
log.Printf("ERROR [%s] database: CreateMonth(%s, %s, %s): %v",
time.Now().Format(time.RFC3339), id, userID, name, err)
}
return err
}
// GetMonthsByUser returns all months belonging to a user, ordered by creation date descending.
func GetMonthsByUser(db *sql.DB, userID string) ([]Month, error) {
rows, err := db.Query(
"SELECT id, user_id, name, created_at FROM months WHERE user_id = ? ORDER BY created_at DESC",
userID,
)
if err != nil {
log.Printf("ERROR [%s] database: GetMonthsByUser(%s): %v",
time.Now().Format(time.RFC3339), userID, err)
return nil, err
}
defer rows.Close()
var months []Month
for rows.Next() {
var m Month
if err := rows.Scan(&m.ID, &m.UserID, &m.Name, &m.CreatedAt); err != nil {
log.Printf("ERROR [%s] database: GetMonthsByUser scan: %v",
time.Now().Format(time.RFC3339), err)
return nil, err
}
months = append(months, m)
}
return months, rows.Err()
}
// GetMonthByID returns a single month by ID, or nil if not found.
func GetMonthByID(db *sql.DB, id string) (*Month, error) {
row := db.QueryRow("SELECT id, user_id, name, created_at FROM months WHERE id = ?", id)
m := &Month{}
if err := row.Scan(&m.ID, &m.UserID, &m.Name, &m.CreatedAt); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
log.Printf("ERROR [%s] database: GetMonthByID(%s): %v",
time.Now().Format(time.RFC3339), id, err)
return nil, err
}
return m, nil
}
// UpdateMonth updates the name of an existing month.
func UpdateMonth(db *sql.DB, id, name string) error {
_, err := db.Exec("UPDATE months SET name = ? WHERE id = ?", name, id)
if err != nil {
log.Printf("ERROR [%s] database: UpdateMonth(%s): %v",
time.Now().Format(time.RFC3339), id, err)
}
return err
}
// DeleteMonth removes a month and all its events (cascade deletes expenses via FK).
func DeleteMonth(db *sql.DB, id string) error {
_, err := db.Exec("DELETE FROM months WHERE id = ?", id)
if err != nil {
log.Printf("ERROR [%s] database: DeleteMonth(%s): %v",
time.Now().Format(time.RFC3339), id, err)
}
return err
}
// GetMonthTotalClaim returns the sum of all converted_amounts across all
// events and expenses in a given month. Returns 0 if no expenses exist.
func GetMonthTotalClaim(db *sql.DB, monthID string) (float64, error) {
var total sql.NullFloat64
err := db.QueryRow(
`SELECT COALESCE(SUM(e.converted_amount), 0)
FROM expenses e
JOIN events ev ON e.event_id = ev.id
WHERE ev.month_id = ?`, monthID,
).Scan(&total)
if err != nil {
return 0, err
}
if total.Valid {
return total.Float64, nil
}
return 0, nil
}
// GetEventTotalClaim returns the sum of all converted_amounts for a given event.
func GetEventTotalClaim(db *sql.DB, eventID string) (float64, error) {
var total sql.NullFloat64
err := db.QueryRow(
`SELECT COALESCE(SUM(converted_amount), 0) FROM expenses WHERE event_id = ?`, eventID,
).Scan(&total)
if err != nil {
return 0, err
}
if total.Valid {
return total.Float64, nil
}
return 0, nil
}
// ---------------------------------------------------------------------------
// Event queries
// ---------------------------------------------------------------------------
// 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 {
func CreateEvent(db *sql.DB, id, userID, monthID, name, baseCurrency string, exchangeRate float64) error {
if baseCurrency == "" {
baseCurrency = "EUR"
}
@ -300,12 +431,12 @@ func CreateEvent(db *sql.DB, id, userID, name, baseCurrency string, exchangeRate
exchangeRate = 1.0
}
_, err := db.Exec(
"INSERT INTO events (id, user_id, name, base_currency, exchange_rate) VALUES (?, ?, ?, ?, ?)",
id, userID, name, baseCurrency, exchangeRate,
"INSERT INTO events (id, user_id, month_id, name, base_currency, exchange_rate) VALUES (?, ?, ?, ?, ?, ?)",
id, userID, monthID, name, baseCurrency, exchangeRate,
)
if err != nil {
log.Printf("ERROR [%s] database: CreateEvent(%s, %s, %s, %s, %.4f): %v",
time.Now().Format(time.RFC3339), id, userID, name, baseCurrency, exchangeRate, err)
log.Printf("ERROR [%s] database: CreateEvent(%s, %s, %s, %s, %s, %.4f): %v",
time.Now().Format(time.RFC3339), id, userID, monthID, name, baseCurrency, exchangeRate, err)
}
return err
}
@ -313,7 +444,7 @@ func CreateEvent(db *sql.DB, id, userID, name, baseCurrency string, exchangeRate
// 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, base_currency, exchange_rate, created_at FROM events WHERE user_id = ? ORDER BY created_at DESC",
"SELECT id, user_id, month_id, name, status, base_currency, exchange_rate, created_at FROM events WHERE user_id = ? ORDER BY created_at DESC",
userID,
)
if err != nil {
@ -326,7 +457,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.BaseCurrency, &e.ExchangeRate, &e.CreatedAt); err != nil {
if err := rows.Scan(&e.ID, &e.UserID, &e.MonthID, &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
@ -336,11 +467,37 @@ func GetEventsByUser(db *sql.DB, userID string) ([]Event, error) {
return events, rows.Err()
}
// GetEventsByMonth returns all events under a given month, ordered by creation date descending.
func GetEventsByMonth(db *sql.DB, monthID string) ([]Event, error) {
rows, err := db.Query(
"SELECT id, user_id, month_id, name, status, base_currency, exchange_rate, created_at FROM events WHERE month_id = ? ORDER BY created_at DESC",
monthID,
)
if err != nil {
log.Printf("ERROR [%s] database: GetEventsByMonth(%s): %v",
time.Now().Format(time.RFC3339), monthID, err)
return nil, err
}
defer rows.Close()
var events []Event
for rows.Next() {
var e Event
if err := rows.Scan(&e.ID, &e.UserID, &e.MonthID, &e.Name, &e.Status, &e.BaseCurrency, &e.ExchangeRate, &e.CreatedAt); err != nil {
log.Printf("ERROR [%s] database: GetEventsByMonth scan: %v",
time.Now().Format(time.RFC3339), err)
return nil, err
}
events = append(events, e)
}
return events, rows.Err()
}
// 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, base_currency, exchange_rate, created_at FROM events WHERE id = ?", id)
row := db.QueryRow("SELECT id, user_id, month_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.BaseCurrency, &e.ExchangeRate, &e.CreatedAt); err != nil {
if err := row.Scan(&e.ID, &e.UserID, &e.MonthID, &e.Name, &e.Status, &e.BaseCurrency, &e.ExchangeRate, &e.CreatedAt); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}

View file

@ -1,7 +1,7 @@
// Package handlers provides HTTP request handlers for NextExpense.
//
// This file implements event management endpoints including dashboard
// listing, event creation, reopening, and expense viewing.
// This file implements event management endpoints including event creation,
// reopening, closing, and expense viewing — all scoped under a parent month.
package handlers
import (
@ -36,66 +36,32 @@ func NewEventHandler(db *sql.DB) *EventHandler {
}
// ---------------------------------------------------------------------------
// GET /dashboard — Dashboard
// POST /months/{mid}/events — CreateEvent
// ---------------------------------------------------------------------------
// Dashboard renders the main dashboard page showing all events belonging
// to the authenticated user, along with the new event creation form.
func (h *EventHandler) Dashboard(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r)
if userID == "" {
log.Printf("ERROR [%s] handlers: Dashboard: missing user ID", time.Now().Format(time.RFC3339))
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Redirect to onboarding if the user hasn't completed it yet.
user, _ := database.GetUserByID(h.DB, userID)
if user != nil && !user.Onboarded {
w.Header().Set("HX-Redirect", "/onboarding")
w.WriteHeader(http.StatusOK)
return
}
events, err := database.GetEventsByUser(h.DB, userID)
if err != nil {
log.Printf("ERROR [%s] handlers: Dashboard: GetEventsByUser: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to load events", http.StatusInternalServerError)
return
}
tmpl := getTemplate("dashboard.html")
data := map[string]interface{}{
"Events": events,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
log.Printf("ERROR [%s] handlers: Dashboard: execute template: %v",
time.Now().Format(time.RFC3339), err)
}
}
// ---------------------------------------------------------------------------
// POST /events — CreateEvent
// ---------------------------------------------------------------------------
// CreateEvent handles the creation of a new event for the authenticated user.
// It reads the event name from the form, generates a UUID, persists the
// event, and redirects to the dashboard via HX-Redirect.
// CreateEvent handles the creation of a new event under a given month.
func (h *EventHandler) CreateEvent(w http.ResponseWriter, r *http.Request) {
monthID := chi.URLParam(r, "mid")
if monthID == "" {
http.Error(w, "Missing month ID", http.StatusBadRequest)
return
}
userID := getUserID(r)
if userID == "" {
log.Printf("ERROR [%s] handlers: CreateEvent: missing user ID", time.Now().Format(time.RFC3339))
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Verify month ownership.
month, err := database.GetMonthByID(h.DB, monthID)
if err != nil || month == nil || month.UserID != userID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
name := r.FormValue("name")
if name == "" {
log.Printf("ERROR [%s] handlers: CreateEvent: missing event name", time.Now().Format(time.RFC3339))
http.Error(w, "Event name is required", http.StatusBadRequest)
return
}
@ -105,8 +71,7 @@ func (h *EventHandler) CreateEvent(w http.ResponseWriter, r *http.Request) {
baseCurrency = "USD"
}
// Compute exchange rate from user-provided sample:
// e.g. receipt=1000 KES, claimed=7.73 USD → rate = 7.73 / 1000 = 0.00773
// Compute exchange rate from user-provided sample.
exchangeRate := 1.0
sampleReceipt := r.FormValue("sample_receipt_amount")
sampleClaim := r.FormValue("sample_claim_amount")
@ -115,201 +80,176 @@ func (h *EventHandler) CreateEvent(w http.ResponseWriter, r *http.Request) {
sampleClaimVal, err2 := strconv.ParseFloat(sampleClaim, 64)
if err1 == nil && err2 == nil && sampleReceiptVal > 0 && sampleClaimVal > 0 {
exchangeRate = sampleClaimVal / sampleReceiptVal
log.Printf("INFO [%s] handlers: CreateEvent: computed rate %.6f from sample %s %s → %.2f %s",
time.Now().Format(time.RFC3339), exchangeRate,
r.FormValue("sample_receipt_currency"), sampleReceipt, sampleClaimVal, baseCurrency)
}
}
id := utils.NewUUID()
if err := database.CreateEvent(h.DB, id, userID, name, baseCurrency, exchangeRate); err != nil {
if err := database.CreateEvent(h.DB, id, userID, monthID, 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)
return
}
w.Header().Set("HX-Redirect", "/dashboard")
w.Header().Set("HX-Redirect", "/months/"+monthID)
w.WriteHeader(http.StatusOK)
}
// ---------------------------------------------------------------------------
// PUT /events/{id}/reopen — ReopenEvent
// PUT /months/{mid}/events/{eid}/reopen — ReopenEvent
// ---------------------------------------------------------------------------
// ReopenEvent sets an event's status back to "open" and returns an HTMX
// fragment replacing the event's status badge with a green "open" badge.
// It verifies that the requesting user owns the event.
// ReopenEvent sets an event's status back to "open". Verifies month and event ownership.
func (h *EventHandler) ReopenEvent(w http.ResponseWriter, r *http.Request) {
eventID := chi.URLParam(r, "id")
if eventID == "" {
log.Printf("ERROR [%s] handlers: ReopenEvent: missing event ID",
time.Now().Format(time.RFC3339))
http.Error(w, "Missing event ID", http.StatusBadRequest)
monthID := chi.URLParam(r, "mid")
eventID := chi.URLParam(r, "eid")
if monthID == "" || eventID == "" {
http.Error(w, "Missing ID", http.StatusBadRequest)
return
}
userID := getUserID(r)
if userID == "" {
log.Printf("ERROR [%s] handlers: ReopenEvent: missing user ID",
time.Now().Format(time.RFC3339))
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
event, err := database.GetEventByID(h.DB, eventID)
if err != nil {
log.Printf("ERROR [%s] handlers: ReopenEvent: GetEventByID(%s): %v",
time.Now().Format(time.RFC3339), eventID, err)
http.Error(w, "Failed to retrieve event", http.StatusInternalServerError)
return
}
if event == nil {
log.Printf("ERROR [%s] handlers: ReopenEvent: event %s not found",
time.Now().Format(time.RFC3339), eventID)
http.Error(w, "Event not found", http.StatusNotFound)
return
}
if event.UserID != userID {
log.Printf("ERROR [%s] handlers: ReopenEvent: ownership mismatch for event %s",
time.Now().Format(time.RFC3339), eventID)
// Verify month ownership.
month, err := database.GetMonthByID(h.DB, monthID)
if err != nil || month == nil || month.UserID != userID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if err := database.UpdateEventStatus(h.DB, eventID, "open"); err != nil {
log.Printf("ERROR [%s] handlers: ReopenEvent: UpdateEventStatus(%s): %v",
time.Now().Format(time.RFC3339), eventID, err)
http.Error(w, "Failed to reopen event", http.StatusInternalServerError)
return
}
// Clean up any stale download packages — user must regenerate after reopening.
if oldFiles, err := database.DeleteDownloadTokensByEvent(h.DB, eventID); err == nil {
for _, fn := range oldFiles {
os.Remove(filepath.Join("storage", "postbox", fn))
}
}
// Redirect to dashboard so the full page renders with updated status.
w.Header().Set("HX-Redirect", "/dashboard")
w.WriteHeader(http.StatusOK)
}
// ---------------------------------------------------------------------------
// POST /events/{id}/close — CloseEvent
// ---------------------------------------------------------------------------
// CloseEvent sets an event's status to "closed". Only the event owner may
// close it. On success, redirects to the dashboard.
func (h *EventHandler) CloseEvent(w http.ResponseWriter, r *http.Request) {
eventID := chi.URLParam(r, "id")
if eventID == "" {
http.Error(w, "Missing event ID", http.StatusBadRequest)
return
}
userID := getUserID(r)
if userID == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
event, err := database.GetEventByID(h.DB, eventID)
if err != nil || event == nil {
http.Error(w, "Event not found", http.StatusNotFound)
return
}
if event.UserID != userID {
if event.MonthID != monthID || event.UserID != userID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if err := database.UpdateEventStatus(h.DB, eventID, "closed"); err != nil {
log.Printf("ERROR [%s] handlers: CloseEvent: UpdateEventStatus(%s): %v",
time.Now().Format(time.RFC3339), eventID, err)
http.Error(w, "Failed to close event", http.StatusInternalServerError)
if err := database.UpdateEventStatus(h.DB, eventID, "open"); err != nil {
log.Printf("ERROR [%s] handlers: ReopenEvent: %v", time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to reopen event", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", "/dashboard")
// Clean up stale download packages.
if oldFiles, err := database.DeleteDownloadTokensByEvent(h.DB, eventID); err == nil {
for _, fn := range oldFiles {
os.Remove(filepath.Join("storage", "postbox", fn))
}
}
w.Header().Set("HX-Redirect", "/months/"+monthID)
w.WriteHeader(http.StatusOK)
}
// ---------------------------------------------------------------------------
// GET /events/{id}/expenses — ViewEventExpenses
// POST /months/{mid}/events/{eid}/close — CloseEvent
// ---------------------------------------------------------------------------
// ViewEventExpenses displays the expense collection view for a specific event.
// It verifies event ownership, sets the current_event_id cookie, and renders
// the event_expenses.html template with the event and its expense list.
func (h *EventHandler) ViewEventExpenses(w http.ResponseWriter, r *http.Request) {
eventID := chi.URLParam(r, "id")
if eventID == "" {
log.Printf("ERROR [%s] handlers: ViewEventExpenses: missing event ID",
time.Now().Format(time.RFC3339))
http.Error(w, "Missing event ID", http.StatusBadRequest)
// CloseEvent sets an event's status to "closed".
func (h *EventHandler) CloseEvent(w http.ResponseWriter, r *http.Request) {
monthID := chi.URLParam(r, "mid")
eventID := chi.URLParam(r, "eid")
if monthID == "" || eventID == "" {
http.Error(w, "Missing ID", http.StatusBadRequest)
return
}
userID := getUserID(r)
if userID == "" {
log.Printf("ERROR [%s] handlers: ViewEventExpenses: missing user ID",
time.Now().Format(time.RFC3339))
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
event, err := database.GetEventByID(h.DB, eventID)
if err != nil {
log.Printf("ERROR [%s] handlers: ViewEventExpenses: GetEventByID(%s): %v",
time.Now().Format(time.RFC3339), eventID, err)
http.Error(w, "Failed to retrieve event", http.StatusInternalServerError)
// Verify month ownership.
month, err := database.GetMonthByID(h.DB, monthID)
if err != nil || month == nil || month.UserID != userID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if event == nil {
log.Printf("ERROR [%s] handlers: ViewEventExpenses: event %s not found",
time.Now().Format(time.RFC3339), eventID)
event, err := database.GetEventByID(h.DB, eventID)
if err != nil || event == nil {
http.Error(w, "Event not found", http.StatusNotFound)
return
}
if event.UserID != userID {
log.Printf("ERROR [%s] handlers: ViewEventExpenses: ownership mismatch for event %s",
time.Now().Format(time.RFC3339), eventID)
if event.MonthID != monthID || event.UserID != userID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if err := database.UpdateEventStatus(h.DB, eventID, "closed"); err != nil {
log.Printf("ERROR [%s] handlers: CloseEvent: %v", time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to close event", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", "/months/"+monthID)
w.WriteHeader(http.StatusOK)
}
// ---------------------------------------------------------------------------
// GET /months/{mid}/events/{eid}/expenses — ViewEventExpenses
// ---------------------------------------------------------------------------
// ViewEventExpenses displays the expense collection view for a specific event.
func (h *EventHandler) ViewEventExpenses(w http.ResponseWriter, r *http.Request) {
monthID := chi.URLParam(r, "mid")
eventID := chi.URLParam(r, "eid")
if monthID == "" || eventID == "" {
http.Error(w, "Missing ID", http.StatusBadRequest)
return
}
userID := getUserID(r)
if userID == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Verify month ownership.
month, err := database.GetMonthByID(h.DB, monthID)
if err != nil || month == nil || month.UserID != userID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
event, err := database.GetEventByID(h.DB, eventID)
if err != nil || event == nil {
http.Error(w, "Event not found", http.StatusNotFound)
return
}
if event.MonthID != monthID || event.UserID != userID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
expenses, err := database.GetExpensesByEvent(h.DB, eventID)
if err != nil {
log.Printf("ERROR [%s] handlers: ViewEventExpenses: GetExpensesByEvent(%s): %v",
time.Now().Format(time.RFC3339), eventID, err)
log.Printf("ERROR [%s] handlers: ViewEventExpenses: %v", time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to load expenses", http.StatusInternalServerError)
return
}
// Set the current_event_id cookie so subsequent expense operations
// (SaveExpense, UploadReceipt) know which event to associate with.
// Set current_event_id cookie for expense operations.
setCurrentEventID(w, eventID)
tmpl := getTemplate("event_expenses.html")
totalClaim := 0.0
for _, exp := range expenses {
if exp.ConvertedAmount > 0 {
totalClaim += exp.ConvertedAmount
}
}
// Normalize ImagePath for all expenses (old DB entries may have storage/ prefix).
for i := range expenses {
expenses[i].ImagePath = normalizeImagePath(expenses[i].ImagePath)
}
data := map[string]interface{}{
"Event": event,
"Expenses": expenses,
"TotalClaim": totalClaim,
"Month": month,
"Event": event,
"Expenses": expenses,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@ -320,56 +260,59 @@ func (h *EventHandler) ViewEventExpenses(w http.ResponseWriter, r *http.Request)
}
// ---------------------------------------------------------------------------
// GET /events/{id}/edit — EditEvent
// GET /months/{mid}/events/{eid}/edit — EditEvent
// ---------------------------------------------------------------------------
// EditEvent returns the event edit form fragment pre-filled with current data.
func (h *EventHandler) EditEvent(w http.ResponseWriter, r *http.Request) {
eventID := chi.URLParam(r, "id")
monthID := chi.URLParam(r, "mid")
eventID := chi.URLParam(r, "eid")
event, err := database.GetEventByID(h.DB, eventID)
if err != nil || event == nil || event.UserID != getUserID(r) {
if err != nil || event == nil || event.UserID != getUserID(r) || event.MonthID != monthID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
// Build a slug for the sample receipt currency (use base currency as default).
rcptCur := event.BaseCurrency
if rcptCur == "" {
rcptCur = "KES"
}
// Compute a rough reverse sample from the exchange rate:
// rate = claim / receipt → sample_receipt = 1000, sample_claim = 1000 * rate
sampleClm := event.ExchangeRate * 1000
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div class="card" style="padding: 1rem;">
<h3 style="margin-bottom: 1rem;">Edit Event</h3>
<form hx-put="/events/%s" hx-target="body" hx-push-url="true">
<form hx-put="/months/%s/events/%s" hx-target="body" hx-push-url="true">
<div class="form-group">
<label class="form-label">Event</label>
<input type="text" name="name" value="%s" placeholder="Event name">
</div>
<div class="form-group">
<label class="form-label">Claim Currency</label>
<input type="text" name="base_currency" value="%s" required maxlength="3" style="text-transform: uppercase;">
</div>
<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;">Update the sample to recalculate the rate.</p>
<div class="form-row">
<div class="form-group">
<label class="form-label">Receipt Amount</label>
<div style="background: #064e3b; border: 1px solid #065f46; border-radius: 0.5rem; padding: 1rem; margin-bottom: 0.5rem;">
<div style="font-size: 0.8rem; font-weight: 600; color: #6ee7b7; margin-bottom: 0.75rem;">Conversion Rate</div>
<div class="form-row" style="gap: 1rem;">
<div style="flex: 1;">
<label class="form-label">Claim Amount</label>
<input type="number" name="sample_claim_amount" value="%.2f" step="0.01" min="0.01" required>
</div>
<div style="flex: 0 0 80px;">
<label class="form-label">Currency</label>
<input type="text" name="base_currency" value="%s" required maxlength="3" style="text-transform: uppercase;">
</div>
</div>
<div class="form-row" style="gap: 1rem; margin-top: 0.5rem;">
<div style="flex: 1;">
<label class="form-label">Local Amount</label>
<input type="number" name="sample_receipt_amount" value="1000" step="0.01" min="0.01" required>
</div>
<div class="form-group" style="flex: 0 0 110px;">
<div style="flex: 0 0 80px;">
<label class="form-label">Currency</label>
<input type="text" name="sample_receipt_currency" value="%s" required maxlength="3" style="text-transform: uppercase;">
</div>
</div>
<div class="form-group">
<label class="form-label">Converted amount</label>
<input type="number" name="sample_claim_amount" value="%.2f" step="0.01" min="0.01" required>
<div style="font-size: 0.7rem; color: var(--color-text-muted); margin-top: 0.5rem;">
Rate = Claim / Local
</div>
</div>
<button type="submit" class="btn btn-primary btn-block">Save Changes</button>
@ -378,21 +321,23 @@ func (h *EventHandler) EditEvent(w http.ResponseWriter, r *http.Request) {
onclick="document.getElementById('create-event-form').innerHTML='';document.getElementById('create-event-form').classList.add('hidden')">Cancel</button>
<button type="button" class="btn btn-secondary" style="flex:1; text-align:center; color:#fca5a5; border-color:#7f1d1d;"
onclick="if(confirm('Delete this event and all its receipts?')){htmx.trigger('#delete-event-%s','click')}">Delete</button>
<div hx-delete="/events/%s" hx-target="body" hx-push-url="true" id="delete-event-%s" style="display:none"></div>
<div hx-delete="/months/%s/events/%s" hx-target="body" hx-push-url="true" id="delete-event-%s" style="display:none"></div>
</div>
</form>
</div>`, event.ID, template.HTMLEscapeString(event.Name), template.HTMLEscapeString(event.BaseCurrency), template.HTMLEscapeString(rcptCur), sampleClm, event.ID, event.ID, event.ID)
</div>`, monthID, event.ID, template.HTMLEscapeString(event.Name), sampleClm, template.HTMLEscapeString(event.BaseCurrency), template.HTMLEscapeString(rcptCur), event.ID, monthID, event.ID, event.ID)
}
// ---------------------------------------------------------------------------
// PUT /events/{id} — UpdateEvent
// PUT /months/{mid}/events/{eid} — UpdateEvent
// ---------------------------------------------------------------------------
// UpdateEvent updates the event's base currency and exchange rate.
func (h *EventHandler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
eventID := chi.URLParam(r, "id")
monthID := chi.URLParam(r, "mid")
eventID := chi.URLParam(r, "eid")
event, err := database.GetEventByID(h.DB, eventID)
if err != nil || event == nil || event.UserID != getUserID(r) {
if err != nil || event == nil || event.UserID != getUserID(r) || event.MonthID != monthID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
@ -419,33 +364,34 @@ func (h *EventHandler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
return
}
// Recalculate all existing expense converted amounts with the new rate.
if err := database.RecalculateExpenses(h.DB, eventID, baseCurrency, exchangeRate); err != nil {
log.Printf("ERROR [%s] handlers: UpdateEvent: recalc expenses: %v", time.Now().Format(time.RFC3339), err)
// Non-fatal — the event was updated, but expenses may have stale conversions.
}
w.Header().Set("HX-Redirect", "/dashboard")
w.Header().Set("HX-Redirect", "/months/"+monthID)
w.WriteHeader(http.StatusOK)
}
// ---------------------------------------------------------------------------
// DELETE /events/{id} — DeleteEvent
// DELETE /months/{mid}/events/{eid} — DeleteEvent
// ---------------------------------------------------------------------------
// DeleteEvent removes an event and its expenses after ownership verification.
func (h *EventHandler) DeleteEvent(w http.ResponseWriter, r *http.Request) {
eventID := chi.URLParam(r, "id")
monthID := chi.URLParam(r, "mid")
eventID := chi.URLParam(r, "eid")
event, err := database.GetEventByID(h.DB, eventID)
if err != nil || event == nil || event.UserID != getUserID(r) {
if err != nil || event == nil || event.UserID != getUserID(r) || event.MonthID != monthID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if err := database.DeleteEvent(h.DB, eventID); err != nil {
log.Printf("ERROR [%s] handlers: DeleteEvent(%s): %v", time.Now().Format(time.RFC3339), eventID, err)
http.Error(w, "Failed to delete event", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", "/dashboard")
w.Header().Set("HX-Redirect", "/months/"+monthID)
w.WriteHeader(http.StatusOK)
}

View file

@ -261,6 +261,9 @@ func (h *ExpenseHandler) SaveExpense(w http.ResponseWriter, r *http.Request) {
if date == "" {
missing = append(missing, "date")
}
if description == "" {
missing = append(missing, "description")
}
if len(missing) > 0 {
log.Printf("ERROR [%s] handlers: SaveExpense: missing fields: %s",
time.Now().Format(time.RFC3339), strings.Join(missing, ", "))
@ -373,12 +376,16 @@ func (h *ExpenseHandler) EditExpense(w http.ResponseWriter, r *http.Request) {
return
}
// Verify ownership: the expense's event must belong to the current user.
// Verify ownership: expense → event → month → user.
event, err := database.GetEventByID(h.DB, expense.EventID)
if err != nil || event == nil || event.UserID != getUserID(r) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if !verifyMonthOwnership(h.DB, event.MonthID, getUserID(r)) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
tmpl := getTemplate("receipt_form.html")
@ -442,12 +449,16 @@ func (h *ExpenseHandler) UpdateExpense(w http.ResponseWriter, r *http.Request) {
return
}
// Verify ownership: the expense's event must belong to the current user.
// Verify ownership: expense → event → month → user.
event, err := database.GetEventByID(h.DB, existing.EventID)
if err != nil || event == nil || event.UserID != getUserID(r) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if !verifyMonthOwnership(h.DB, event.MonthID, getUserID(r)) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
expense := database.Expense{
ID: expenseID,
@ -520,12 +531,16 @@ func (h *ExpenseHandler) DeleteExpense(w http.ResponseWriter, r *http.Request) {
return
}
// Verify ownership: the expense's event must belong to the current user.
// Verify ownership: expense → event → month → user.
event, err := database.GetEventByID(h.DB, existing.EventID)
if err != nil || event == nil || event.UserID != getUserID(r) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if !verifyMonthOwnership(h.DB, event.MonthID, getUserID(r)) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
eventID := existing.EventID

View file

@ -47,24 +47,15 @@ type FileHandler struct {
// FileEvent generates an expense report (CSV or PDF) for a given event and
// emails it as an attachment to the specified recipient. On success the
// event status is updated to "closed" and the client is redirected to the
// dashboard via the HX-Redirect header.
//
// Flow:
// 1. Extract event ID from the URL via chi.URLParam
// 2. Parse the form for target email and report format
// 3. Verify the authenticated user owns this event
// 4. Fetch all expenses for the event from the database
// 5. Generate the report in the requested format (CSV or PDF)
// 6. Send the report as an email attachment
// 7. Update the event status to "closed"
// 8. Return an HX-Redirect header pointing to /dashboard
// month view via the HX-Redirect header.
func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
// 1. Get event ID from the URL path parameter.
eventID := chi.URLParam(r, "id")
if eventID == "" {
log.Printf("ERROR [%s] handlers: FileEvent: missing event ID in URL",
// 1. Get month and event IDs from the URL path parameters.
monthID := chi.URLParam(r, "mid")
eventID := chi.URLParam(r, "eid")
if monthID == "" || eventID == "" {
log.Printf("ERROR [%s] handlers: FileEvent: missing ID in URL",
time.Now().Format(time.RFC3339))
renderFileError(w, "Missing event ID.")
renderFileError(w, "Missing ID.")
return
}
@ -85,7 +76,7 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
return
}
// 3. Verify the authenticated user owns this event.
// 3. Verify the authenticated user owns this event (via month).
userID := getUserID(r)
if userID == "" {
log.Printf("ERROR [%s] handlers: FileEvent: unauthenticated request",
@ -94,6 +85,11 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
return
}
if !verifyMonthOwnership(h.DB, monthID, userID) {
renderFileError(w, "You do not have permission to file this event.")
return
}
event, err := database.GetEventByID(h.DB, eventID)
if err != nil {
log.Printf("ERROR [%s] handlers: FileEvent: GetEventByID(%s): %v",
@ -107,7 +103,7 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
renderFileError(w, "Event not found.")
return
}
if event.UserID != userID {
if event.MonthID != monthID || event.UserID != userID {
log.Printf("ERROR [%s] handlers: FileEvent: user %s does not own event %s",
time.Now().Format(time.RFC3339), userID, eventID)
renderFileError(w, "You do not have permission to file this event.")
@ -184,8 +180,8 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
return
}
// 8. Redirect to the dashboard via HTMX.
w.Header().Set("HX-Redirect", "/dashboard")
// 8. Redirect to the month view via HTMX.
w.Header().Set("HX-Redirect", "/months/"+monthID)
w.WriteHeader(http.StatusOK)
}
@ -198,11 +194,12 @@ func (h *FileHandler) FileEvent(w http.ResponseWriter, r *http.Request) {
// HTMX fragment with download and email-link options. The event is NOT
// closed — the user can add more receipts and regenerate.
func (h *FileHandler) GenerateReport(w http.ResponseWriter, r *http.Request) {
eventID := chi.URLParam(r, "id")
if eventID == "" {
log.Printf("ERROR [%s] handlers: GenerateReport: missing event ID",
monthID := chi.URLParam(r, "mid")
eventID := chi.URLParam(r, "eid")
if monthID == "" || eventID == "" {
log.Printf("ERROR [%s] handlers: GenerateReport: missing ID",
time.Now().Format(time.RFC3339))
renderFileError(w, "Missing event ID.")
renderFileError(w, "Missing ID.")
return
}
@ -219,12 +216,17 @@ func (h *FileHandler) GenerateReport(w http.ResponseWriter, r *http.Request) {
return
}
if !verifyMonthOwnership(h.DB, monthID, userID) {
renderFileError(w, "You do not have permission to access this event.")
return
}
event, err := database.GetEventByID(h.DB, eventID)
if err != nil || event == nil {
renderFileError(w, "Event not found.")
return
}
if event.UserID != userID {
if event.MonthID != monthID || event.UserID != userID {
renderFileError(w, "You do not have permission to access this event.")
return
}
@ -345,7 +347,7 @@ func (h *FileHandler) GenerateReport(w http.ResponseWriter, r *http.Request) {
</div>
<div style="border-top: 1px solid #065f46; padding-top: 0.75rem;">
<p style="font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: 0.5rem;">Or send a download link via email (tiny email, no attachment limits):</p>
<form hx-post="/events/%s/send-link" hx-target="#send-link-result" hx-indicator="#send-link-spinner" style="display: flex; gap: 0.5rem;">
<form hx-post="/months/%s/events/%s/send-link" hx-target="#send-link-result" hx-indicator="#send-link-spinner" style="display: flex; gap: 0.5rem;">
<input type="hidden" name="token" value="%s">
<input type="email" name="email" placeholder="finance@company.com" required style="flex:1; padding:0.5rem; border:1px solid #475569; border-radius:0.375rem; background:#1e293b; color:#f8fafc; font-size:0.85rem;">
<button type="submit" class="btn btn-secondary" style="font-size:0.85rem; white-space:nowrap;">Send Link</button>
@ -356,17 +358,18 @@ func (h *FileHandler) GenerateReport(w http.ResponseWriter, r *http.Request) {
</div>`,
len(expenses),
template.HTMLEscapeString(token), template.HTMLEscapeString(dlName),
template.HTMLEscapeString(eventID), template.HTMLEscapeString(token))
template.HTMLEscapeString(monthID), template.HTMLEscapeString(eventID), template.HTMLEscapeString(token))
}
// ---------------------------------------------------------------------------
// POST /events/{id}/send-link — SendDownloadLink
// POST /months/{mid}/events/{eid}/send-link — SendDownloadLink
// ---------------------------------------------------------------------------
// SendDownloadLink emails a download link for a previously generated report
// package to the specified recipient. Returns an HTMX fragment with success
// or error feedback.
// package to the specified recipient.
func (h *FileHandler) SendDownloadLink(w http.ResponseWriter, r *http.Request) {
monthID := chi.URLParam(r, "mid")
if err := r.ParseForm(); err != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Failed to parse form.</div>`)
@ -390,7 +393,7 @@ func (h *FileHandler) SendDownloadLink(w http.ResponseWriter, r *http.Request) {
}
event, err := database.GetEventByID(h.DB, dt.EventID)
if err != nil || event == nil || event.UserID != getUserID(r) {
if err != nil || event == nil || event.UserID != getUserID(r) || event.MonthID != monthID {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Permission denied.</div>`)
return

View file

@ -1,6 +1,11 @@
package handlers
import "strings"
import (
"database/sql"
"strings"
"github.com/cclohmar/NextExpense/internal/database"
)
// normalizeImagePath strips a legacy "storage/" prefix if present, so that
// the template can safely build "/storage/{filename}" URLs regardless of
@ -8,3 +13,16 @@ import "strings"
func normalizeImagePath(path string) string {
return strings.TrimPrefix(path, "storage/")
}
// verifyMonthOwnership checks that a month exists and belongs to the given user.
// Returns true if the month is owned by the user, false otherwise.
func verifyMonthOwnership(db *sql.DB, monthID, userID string) bool {
if monthID == "" {
return false
}
month, err := database.GetMonthByID(db, monthID)
if err != nil || month == nil {
return false
}
return month.UserID == userID
}

758
internal/handlers/months.go Normal file
View file

@ -0,0 +1,758 @@
// Package handlers provides HTTP request handlers for NextExpense.
//
// This file implements month management endpoints including month listing,
// creation, editing, deletion, event viewing within a month, and monthly
// report generation that aggregates all events across a month.
package handlers
import (
"archive/zip"
"bytes"
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"html/template"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/cclohmar/NextExpense/internal/database"
"github.com/cclohmar/NextExpense/internal/email"
"github.com/cclohmar/NextExpense/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/jung-kurt/gofpdf"
)
// ---------------------------------------------------------------------------
// MonthHandler
// ---------------------------------------------------------------------------
// MonthHandler groups HTTP handlers related to month management.
// It depends on a shared *sql.DB handle for database operations and an
// optional *email.Sender for delivering monthly reports via email.
type MonthHandler struct {
DB *sql.DB
EmailSender *email.Sender
}
// NewMonthHandler creates a new MonthHandler with the given database handle.
func NewMonthHandler(db *sql.DB) *MonthHandler {
return &MonthHandler{DB: db}
}
// ---------------------------------------------------------------------------
// GET /dashboard — ListMonths (replaces old EventHandler.Dashboard)
// ---------------------------------------------------------------------------
// ListMonths renders the main dashboard page showing all months belonging
// to the authenticated user, along with the create month form.
func (h *MonthHandler) ListMonths(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r)
if userID == "" {
log.Printf("ERROR [%s] handlers: ListMonths: missing user ID", time.Now().Format(time.RFC3339))
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Redirect to onboarding if the user hasn't completed it yet.
user, _ := database.GetUserByID(h.DB, userID)
if user != nil && !user.Onboarded {
w.Header().Set("HX-Redirect", "/onboarding")
w.WriteHeader(http.StatusOK)
return
}
months, err := database.GetMonthsByUser(h.DB, userID)
if err != nil {
log.Printf("ERROR [%s] handlers: ListMonths: GetMonthsByUser: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to load months", http.StatusInternalServerError)
return
}
// Sort by month name descending (latest first): "December 2026" before "January 2026".
sort.Slice(months, func(i, j int) bool {
yi, mi := parseMonthName(months[i].Name)
yj, mj := parseMonthName(months[j].Name)
if yi != yj {
return yi > yj
}
return mi > mj
})
// Compute total claim per month.
type MonthWithTotal struct {
Month database.Month
Total float64
}
var items []MonthWithTotal
for _, m := range months {
total, _ := database.GetMonthTotalClaim(h.DB, m.ID)
items = append(items, MonthWithTotal{Month: m, Total: total})
}
tmpl := getTemplate("dashboard.html")
data := map[string]interface{}{
"Months": items,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
log.Printf("ERROR [%s] handlers: ListMonths: execute template: %v",
time.Now().Format(time.RFC3339), err)
}
}
// ---------------------------------------------------------------------------
// POST /months — CreateMonth
// ---------------------------------------------------------------------------
// CreateMonth handles the creation of a new month for the authenticated user.
func (h *MonthHandler) CreateMonth(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r)
if userID == "" {
log.Printf("ERROR [%s] handlers: CreateMonth: missing user ID", time.Now().Format(time.RFC3339))
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
month := strings.TrimSpace(r.FormValue("month"))
year := strings.TrimSpace(r.FormValue("year"))
if month == "" || year == "" {
log.Printf("ERROR [%s] handlers: CreateMonth: missing month or year", time.Now().Format(time.RFC3339))
http.Error(w, "Month and year are required", http.StatusBadRequest)
return
}
name := month + " " + year
id := utils.NewUUID()
if err := database.CreateMonth(h.DB, id, userID, name); err != nil {
log.Printf("ERROR [%s] handlers: CreateMonth: %v",
time.Now().Format(time.RFC3339), err)
http.Error(w, "Failed to create month", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", "/dashboard")
w.WriteHeader(http.StatusOK)
}
// ---------------------------------------------------------------------------
// GET /months/{mid} — ViewMonth
// ---------------------------------------------------------------------------
// ViewMonth displays all events under a given month.
func (h *MonthHandler) ViewMonth(w http.ResponseWriter, r *http.Request) {
monthID := chi.URLParam(r, "mid")
if monthID == "" {
http.Error(w, "Missing month ID", http.StatusBadRequest)
return
}
userID := getUserID(r)
if userID == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
month, err := database.GetMonthByID(h.DB, monthID)
if err != nil || month == nil {
http.Error(w, "Month not found", http.StatusNotFound)
return
}
if month.UserID != userID {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
events, err := database.GetEventsByMonth(h.DB, monthID)
if err != nil {
log.Printf("ERROR [%s] handlers: ViewMonth: GetEventsByMonth(%s): %v",
time.Now().Format(time.RFC3339), monthID, err)
http.Error(w, "Failed to load events", http.StatusInternalServerError)
return
}
// Compute total claim per event.
type EventWithTotal struct {
Event database.Event
Total float64
Currency string
}
var items []EventWithTotal
for _, evt := range events {
total, _ := database.GetEventTotalClaim(h.DB, evt.ID)
items = append(items, EventWithTotal{Event: evt, Total: total, Currency: evt.BaseCurrency})
}
tmpl := getTemplate("month_events.html")
data := map[string]interface{}{
"Month": month,
"Events": items,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
log.Printf("ERROR [%s] handlers: ViewMonth: execute template: %v",
time.Now().Format(time.RFC3339), err)
}
}
// ---------------------------------------------------------------------------
// GET /months/{mid}/edit — EditMonth
// ---------------------------------------------------------------------------
// EditMonth returns an inline edit form fragment for a month.
func (h *MonthHandler) EditMonth(w http.ResponseWriter, r *http.Request) {
monthID := chi.URLParam(r, "mid")
month, err := database.GetMonthByID(h.DB, monthID)
if err != nil || month == nil || month.UserID != getUserID(r) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div class="card" style="padding: 1rem;">
<h3 style="margin-bottom: 1rem;">Edit Month</h3>
<form hx-put="/months/%s" hx-target="body" hx-push-url="true">
<div class="form-group">
<label class="form-label">Month Name</label>
<input type="text" name="name" value="%s" placeholder="e.g. July 2026">
</div>
<button type="submit" class="btn btn-primary btn-block">Save Changes</button>
<div style="display:flex; gap:0.5rem; margin-top:0.5rem;">
<button type="button" class="btn btn-secondary" style="flex:1; text-align:center;"
onclick="document.getElementById('create-form').innerHTML='';document.getElementById('create-form').classList.add('hidden')">Cancel</button>
<button type="button" class="btn btn-secondary" style="flex:1; text-align:center; color:#fca5a5; border-color:#7f1d1d;"
onclick="if(confirm('Delete this month and ALL its events and receipts?')){htmx.trigger('#delete-month-%s','click')}">Delete</button>
<div hx-delete="/months/%s" hx-target="body" hx-push-url="true" id="delete-month-%s" style="display:none"></div>
</div>
</form>
</div>`, month.ID, template.HTMLEscapeString(month.Name), month.ID, month.ID, month.ID)
}
// ---------------------------------------------------------------------------
// PUT /months/{mid} — UpdateMonth
// ---------------------------------------------------------------------------
// UpdateMonth updates a month's name after ownership verification.
func (h *MonthHandler) UpdateMonth(w http.ResponseWriter, r *http.Request) {
monthID := chi.URLParam(r, "mid")
month, err := database.GetMonthByID(h.DB, monthID)
if err != nil || month == nil || month.UserID != getUserID(r) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
name := strings.TrimSpace(r.FormValue("name"))
if name == "" {
http.Error(w, "Month name is required", http.StatusBadRequest)
return
}
if err := database.UpdateMonth(h.DB, monthID, name); err != nil {
log.Printf("ERROR [%s] handlers: UpdateMonth(%s): %v", time.Now().Format(time.RFC3339), monthID, err)
http.Error(w, "Failed to update month", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", "/dashboard")
w.WriteHeader(http.StatusOK)
}
// ---------------------------------------------------------------------------
// DELETE /months/{mid} — DeleteMonth
// ---------------------------------------------------------------------------
// DeleteMonth removes a month and all its events (cascade deletes expenses).
func (h *MonthHandler) DeleteMonth(w http.ResponseWriter, r *http.Request) {
monthID := chi.URLParam(r, "mid")
month, err := database.GetMonthByID(h.DB, monthID)
if err != nil || month == nil || month.UserID != getUserID(r) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if err := database.DeleteMonth(h.DB, monthID); err != nil {
log.Printf("ERROR [%s] handlers: DeleteMonth(%s): %v", time.Now().Format(time.RFC3339), monthID, err)
http.Error(w, "Failed to delete month", http.StatusInternalServerError)
return
}
w.Header().Set("HX-Redirect", "/dashboard")
w.WriteHeader(http.StatusOK)
}
// ---------------------------------------------------------------------------
// POST /months/{mid}/generate — GenerateMonthlyReport
// ---------------------------------------------------------------------------
// GenerateMonthlyReport aggregates all expenses across all events in a month
// into a single report package (CSV/PDF + all receipt images ZIP), stores
// it with a download token, and returns an HTMX fragment with download link.
func (h *MonthHandler) GenerateMonthlyReport(w http.ResponseWriter, r *http.Request) {
monthID := chi.URLParam(r, "mid")
if monthID == "" {
renderFileError(w, "Missing month ID.")
return
}
if err := r.ParseForm(); err != nil {
renderFileError(w, "Cannot parse form data.")
return
}
format := strings.ToLower(strings.TrimSpace(r.FormValue("format")))
if format != "csv" && format != "pdf" {
format = "pdf"
}
userID := getUserID(r)
if userID == "" {
renderFileError(w, "Session expired. Please log in again.")
return
}
month, err := database.GetMonthByID(h.DB, monthID)
if err != nil || month == nil {
renderFileError(w, "Month not found.")
return
}
if month.UserID != userID {
renderFileError(w, "You do not have permission to access this month.")
return
}
// Get all events for this month.
events, err := database.GetEventsByMonth(h.DB, monthID)
if err != nil {
renderFileError(w, "Failed to retrieve events.")
return
}
if len(events) == 0 {
renderFileError(w, "No events in this month.")
return
}
// Aggregate all expenses across all events.
var allExpenses []database.Expense
for _, evt := range events {
expenses, err := database.GetExpensesByEvent(h.DB, evt.ID)
if err != nil {
continue
}
allExpenses = append(allExpenses, expenses...)
}
if len(allExpenses) == 0 {
renderFileError(w, "No expenses to include in the report.")
return
}
// Fetch user info for report personalisation.
repUser, _ := database.GetUserByID(h.DB, userID)
uName := ""
uDept := ""
if repUser != nil {
uName = repUser.Name
uDept = repUser.Department
}
// Generate the report.
var reportAtt *email.Attachment
if format == "csv" {
reportAtt, err = generateMonthlyCSV(month.Name, events, allExpenses, uName, uDept)
} else {
reportAtt, err = generateMonthlyPDF(month.Name, events, allExpenses, uName, uDept)
}
if err != nil {
log.Printf("ERROR [%s] handlers: GenerateMonthlyReport: generate %s: %v",
time.Now().Format(time.RFC3339), format, err)
renderFileError(w, "Failed to generate report.")
return
}
// Package everything into a single flat ZIP.
var pkgBuf bytes.Buffer
pkg := zip.NewWriter(&pkgBuf)
addToZip(pkg, reportAtt.Filename, reportAtt.Content)
// Add all receipt images from all events.
imgIdx := 0
for _, evt := range events {
expenses, _ := database.GetExpensesByEvent(h.DB, evt.ID)
for _, exp := range expenses {
if exp.ImagePath == "" {
continue
}
normPath := normalizeImagePath(exp.ImagePath)
safePath := filepath.Join("storage", filepath.Base(normPath))
data, err := os.ReadFile(safePath)
if err != nil {
continue
}
ext := filepath.Ext(exp.ImagePath)
if ext == "" {
ext = ".jpg"
}
imgIdx++
imgName := fmt.Sprintf("receipt-%d%s", imgIdx, ext)
addToZip(pkg, imgName, data)
}
}
if err := pkg.Close(); err != nil {
renderFileError(w, "Failed to create package.")
return
}
// Save to postbox directory.
os.MkdirAll("storage/postbox", 0755)
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
renderFileError(w, "Failed to generate download token.")
return
}
token := hex.EncodeToString(tokenBytes)
safeMonth := sanitiseFilename(month.Name)
if safeMonth == "" {
safeMonth = "monthly-report"
}
dlName := safeMonth + ".zip"
pkgFilename := token + ".zip"
pkgPath := filepath.Join("storage", "postbox", pkgFilename)
if err := os.WriteFile(pkgPath, pkgBuf.Bytes(), 0644); err != nil {
log.Printf("ERROR [%s] handlers: GenerateMonthlyReport: write %s: %v",
time.Now().Format(time.RFC3339), pkgPath, err)
renderFileError(w, "Failed to save report package.")
return
}
// Store token in DB (24h expiry). Use monthID as event_id for token tracking.
expiresAt := time.Now().Add(24 * time.Hour).Format(time.RFC3339)
if err := database.CreateDownloadToken(h.DB, token, monthID, pkgFilename, expiresAt); err != nil {
os.Remove(pkgPath)
renderFileError(w, "Failed to store download token.")
return
}
log.Printf("INFO [%s] handlers: GenerateMonthlyReport: package %s created for month %s",
time.Now().Format(time.RFC3339), pkgFilename, monthID)
ext := format
reportName := fmt.Sprintf("%s-report.%s", sanitiseFilename(month.Name), ext)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div id="report-package" style="background: #064e3b; border: 1px solid #065f46; border-radius: 0.5rem; padding: 1rem; margin-top: 1rem;">
<div style="font-weight: 600; color: #6ee7b7; margin-bottom: 0.5rem;">Monthly Report Ready</div>
<p style="font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.75rem;">%s &amp; %d events, %d receipt images packaged.</p>
<div style="display: flex; gap: 0.5rem; margin-bottom: 0.75rem;">
<a href="/dl/%s/%s" class="btn btn-primary" style="flex:1; text-align:center; text-decoration:none; font-size:0.85rem;" download> Download Now</a>
</div>
<div style="border-top: 1px solid #065f46; padding-top: 0.75rem;">
<p style="font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: 0.5rem;">Or send a download link via email:</p>
<form hx-post="/months/%s/send-link" hx-target="#send-link-result" hx-indicator="#send-link-spinner" style="display: flex; gap: 0.5rem;">
<input type="hidden" name="token" value="%s">
<input type="email" name="email" placeholder="finance@company.com" required style="flex:1; padding:0.5rem; border:1px solid #475569; border-radius:0.375rem; background:#1e293b; color:#f8fafc; font-size:0.85rem;">
<button type="submit" class="btn btn-secondary" style="font-size:0.85rem; white-space:nowrap;">Send Link</button>
</form>
<div id="send-link-spinner" class="htmx-indicator" style="text-align:center; padding:0.5rem;"><div class="spinner"></div></div>
<div id="send-link-result"></div>
</div>
</div>`,
template.HTMLEscapeString(reportName), len(events), imgIdx,
template.HTMLEscapeString(token), template.HTMLEscapeString(dlName),
template.HTMLEscapeString(monthID), template.HTMLEscapeString(token))
}
// ---------------------------------------------------------------------------
// POST /months/{mid}/send-link — SendMonthlyDownloadLink
// ---------------------------------------------------------------------------
// SendMonthlyDownloadLink emails a download link for a previously generated
// monthly report package.
func (h *MonthHandler) SendMonthlyDownloadLink(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Failed to parse form.</div>`)
return
}
token := strings.TrimSpace(r.FormValue("token"))
to := strings.TrimSpace(r.FormValue("email"))
if token == "" || to == "" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Token and email are required.</div>`)
return
}
// Verify token exists.
dt, err := database.GetDownloadTokenByToken(h.DB, token)
if err != nil || dt == nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Invalid or expired download token.</div>`)
return
}
// For monthly reports, the token's event_id stores the month_id.
// Verify the month belongs to the user.
month, err := database.GetMonthByID(h.DB, dt.EventID)
if err != nil || month == nil || month.UserID != getUserID(r) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Permission denied.</div>`)
return
}
if h.EmailSender == nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">SMTP not configured.</div>`)
return
}
scheme := "https"
host := r.Host
baseURL := os.Getenv("BASE_URL")
if host == "" && baseURL != "" {
if strings.HasPrefix(baseURL, "https://") {
host = strings.TrimPrefix(baseURL, "https://")
} else if strings.HasPrefix(baseURL, "http://") {
scheme = "http"
host = strings.TrimPrefix(baseURL, "http://")
}
}
if host == "" {
host = "localhost:8080"
}
safeName := sanitiseFilename(month.Name)
if safeName == "" {
safeName = "monthly-report"
}
link := fmt.Sprintf("%s://%s/dl/%s/%s.zip", scheme, host, token, safeName)
subject := "Monthly Expense Report: " + month.Name
body := fmt.Sprintf("Monthly expense report for %s is ready.\n\nDownload: %s\n\nThis link expires in 24 hours.", month.Name, link)
if err := h.EmailSender.SendReport(to, subject, body, nil); err != nil {
log.Printf("ERROR [%s] handlers: SendMonthlyDownloadLink: %v",
time.Now().Format(time.RFC3339), err)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#fca5a5; font-size:0.8rem;">Failed to send: %s</div>`,
template.HTMLEscapeString(err.Error()))
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<div style="color:#6ee7b7; font-size:0.8rem; margin-top:0.5rem;">Download link sent to %s.</div>`,
template.HTMLEscapeString(to))
}
// ---------------------------------------------------------------------------
// Monthly report generation helpers
// ---------------------------------------------------------------------------
// generateMonthlyCSV creates a CSV attachment aggregating expenses across
// all events in a month. Each event is prefixed with a section header.
func generateMonthlyCSV(monthName string, events []database.Event, expenses []database.Expense, userName, userDept string) (*email.Attachment, error) {
var buf bytes.Buffer
buf.WriteString(fmt.Sprintf("Monthly Expense Report: %s\r\n", monthName))
if userName != "" {
metaLine := fmt.Sprintf("Prepared by: %s", userName)
if userDept != "" && userDept != "-" {
metaLine += fmt.Sprintf(" | Department: %s", userDept)
}
buf.WriteString(metaLine + "\r\n")
}
buf.WriteString("\r\n")
// Group expenses by event.
expensesByEvent := make(map[string][]database.Expense)
eventNames := make(map[string]string)
for _, evt := range events {
eventNames[evt.ID] = evt.Name
}
for _, exp := range expenses {
expensesByEvent[exp.EventID] = append(expensesByEvent[exp.EventID], exp)
}
itemNum := 1
var grandTotalOrig, grandTotalConv float64
for _, evt := range events {
evtExpenses := expensesByEvent[evt.ID]
if len(evtExpenses) == 0 {
continue
}
buf.WriteString(fmt.Sprintf("\r\n--- %s ---\r\n", eventNames[evt.ID]))
buf.WriteString("#,Date,Merchant,Amount,Currency,Category,Description\r\n")
var evtTotal float64
for _, exp := range evtExpenses {
buf.WriteString(fmt.Sprintf("%d,%s,%s,%.2f,%s,%s,%s\r\n",
itemNum, exp.Date, exp.Merchant, exp.Amount, exp.Currency, exp.Category, exp.Description))
evtTotal += exp.Amount
if exp.ConvertedAmount > 0 {
grandTotalConv += exp.ConvertedAmount
} else {
grandTotalConv += exp.Amount
}
itemNum++
}
buf.WriteString(fmt.Sprintf("Event Total,,,,%.2f,,\r\n", evtTotal))
grandTotalOrig += evtTotal
}
buf.WriteString(fmt.Sprintf("\r\nGrand Total,,,,%.2f,,\r\n", grandTotalOrig))
filename := fmt.Sprintf("monthly-%s-report.csv", sanitiseFilename(monthName))
return &email.Attachment{
Filename: filename,
Content: []byte(buf.String()),
}, nil
}
// generateMonthlyPDF creates a PDF attachment aggregating expenses across
// all events in a month, with a section per event.
func generateMonthlyPDF(monthName string, events []database.Event, expenses []database.Expense, userName, userDept string) (*email.Attachment, error) {
pdf := gofpdf.New("P", "mm", "A4", "")
pdf.AddPage()
// Title.
pdf.SetFont("Helvetica", "B", 16)
pdf.Cell(0, 10, "Monthly Expense Report: "+monthName)
pdf.Ln(8)
// User info.
if userName != "" {
pdf.SetFont("Helvetica", "", 9)
infoLine := fmt.Sprintf("Prepared by: %s", userName)
if userDept != "" && userDept != "-" {
infoLine += fmt.Sprintf(" | Department: %s", userDept)
}
pdf.Cell(0, 6, infoLine)
pdf.Ln(10)
}
// Group expenses by event.
expensesByEvent := make(map[string][]database.Expense)
eventNames := make(map[string]string)
for _, evt := range events {
eventNames[evt.ID] = evt.Name
}
for _, exp := range expenses {
expensesByEvent[exp.EventID] = append(expensesByEvent[exp.EventID], exp)
}
itemNum := 1
colWidths := []float64{8, 22, 38, 18, 14, 24, 30}
headers := []string{"#", "Date", "Merchant", "Amount", "Curr.", "Category", "Desc."}
marginBottom := 20.0
var grandTotal float64
for _, evt := range events {
evtExpenses := expensesByEvent[evt.ID]
if len(evtExpenses) == 0 {
continue
}
// Event section header with page break check.
if pdf.GetY() > 260 {
pdf.AddPage()
}
pdf.SetFont("Helvetica", "B", 11)
pdf.Cell(0, 8, eventNames[evt.ID])
pdf.Ln(10)
// Column headers.
pdf.SetFont("Helvetica", "B", 9)
for j, h := range headers {
pdf.Cell(colWidths[j], 8, h)
}
pdf.Ln(8)
var evtTotal float64
pdf.SetFont("Helvetica", "", 9)
for _, exp := range evtExpenses {
if pdf.GetY() > 297-marginBottom {
pdf.AddPage()
pdf.SetFont("Helvetica", "B", 9)
for j, h := range headers {
pdf.Cell(colWidths[j], 8, h)
}
pdf.Ln(8)
pdf.SetFont("Helvetica", "", 9)
}
pdf.Cell(colWidths[0], 7, fmt.Sprintf("%d", itemNum))
pdf.Cell(colWidths[1], 7, exp.Date)
pdf.Cell(colWidths[2], 7, truncateString(exp.Merchant, 18))
pdf.Cell(colWidths[3], 7, fmt.Sprintf("%.2f", exp.Amount))
pdf.Cell(colWidths[4], 7, exp.Currency)
pdf.Cell(colWidths[5], 7, truncateString(exp.Category, 12))
pdf.Cell(colWidths[6], 7, truncateString(exp.Description, 18))
pdf.Ln(7)
evtTotal += exp.Amount
itemNum++
}
// Event subtotal with separator.
pdf.SetDrawColor(71, 85, 105)
pdf.Line(10, pdf.GetY()+1, 200, pdf.GetY()+1)
pdf.Ln(3)
pdf.SetFont("Helvetica", "B", 9)
pdf.Cell(colWidths[0], 8, "")
pdf.Cell(colWidths[1], 8, "")
pdf.Cell(colWidths[2], 8, "Event Total")
pdf.Cell(colWidths[3], 8, fmt.Sprintf("%.2f", evtTotal))
pdf.Ln(10)
grandTotal += evtTotal
}
// Grand total.
pdf.SetFont("Helvetica", "B", 11)
pdf.Cell(0, 8, fmt.Sprintf("Grand Total: %.2f", grandTotal))
pdf.Ln(10)
var buf bytes.Buffer
if err := pdf.Output(&buf); err != nil {
return nil, fmt.Errorf("PDF output: %w", err)
}
filename := fmt.Sprintf("monthly-%s-report.pdf", sanitiseFilename(monthName))
return &email.Attachment{
Filename: filename,
Content: buf.Bytes(),
}, nil
}
// parseMonthName extracts year and month number from a name like "July 2026".
// Returns (0, 0) if parsing fails.
func parseMonthName(name string) (year, month int) {
parts := strings.Fields(name)
if len(parts) < 2 {
return 0, 0
}
months := map[string]int{
"january": 1, "february": 2, "march": 3, "april": 4,
"may": 5, "june": 6, "july": 7, "august": 8,
"september": 9, "october": 10, "november": 11, "december": 12,
}
m, ok := months[strings.ToLower(parts[0])]
if !ok {
return 0, 0
}
y, err := strconv.Atoi(parts[1])
if err != nil {
return 0, 0
}
return y, m
}

40
main.go
View file

@ -100,6 +100,9 @@ func main() {
EmailSender: emailSender,
}
monthHandler := handlers.NewMonthHandler(db)
monthHandler.EmailSender = emailSender
eventHandler := handlers.NewEventHandler(db)
expenseHandler := handlers.NewExpenseHandler(db)
fileHandler := &handlers.FileHandler{
@ -209,19 +212,30 @@ func main() {
r.Group(func(r chi.Router) {
r.Use(authHandler.RequireAuth)
// Events.
r.Get("/dashboard", eventHandler.Dashboard)
// Dashboard (months).
r.Get("/dashboard", monthHandler.ListMonths)
r.Get("/onboarding", authHandler.OnboardingPage)
r.Post("/onboarding", authHandler.SaveOnboarding)
r.Get("/profile", authHandler.ProfilePage)
r.Post("/profile", authHandler.SaveProfile)
r.Post("/events", eventHandler.CreateEvent)
r.Put("/events/{id}", eventHandler.UpdateEvent)
r.Get("/events/{id}/edit", eventHandler.EditEvent)
r.Delete("/events/{id}", eventHandler.DeleteEvent)
r.Put("/events/{id}/reopen", eventHandler.ReopenEvent)
r.Post("/events/{id}/close", eventHandler.CloseEvent)
r.Get("/events/{id}/expenses", eventHandler.ViewEventExpenses)
// Months.
r.Post("/months", monthHandler.CreateMonth)
r.Put("/months/{mid}", monthHandler.UpdateMonth)
r.Delete("/months/{mid}", monthHandler.DeleteMonth)
r.Get("/months/{mid}/edit", monthHandler.EditMonth)
r.Get("/months/{mid}", monthHandler.ViewMonth)
r.Post("/months/{mid}/generate", monthHandler.GenerateMonthlyReport)
r.Post("/months/{mid}/send-link", monthHandler.SendMonthlyDownloadLink)
// Events (scoped under months).
r.Post("/months/{mid}/events", eventHandler.CreateEvent)
r.Put("/months/{mid}/events/{eid}", eventHandler.UpdateEvent)
r.Get("/months/{mid}/events/{eid}/edit", eventHandler.EditEvent)
r.Delete("/months/{mid}/events/{eid}", eventHandler.DeleteEvent)
r.Put("/months/{mid}/events/{eid}/reopen", eventHandler.ReopenEvent)
r.Post("/months/{mid}/events/{eid}/close", eventHandler.CloseEvent)
r.Get("/months/{mid}/events/{eid}/expenses", eventHandler.ViewEventExpenses)
// Expenses.
r.Post("/expenses/upload", expenseHandler.UploadReceipt)
@ -230,10 +244,10 @@ func main() {
r.Put("/expenses/{id}", expenseHandler.UpdateExpense)
r.Delete("/expenses/{id}", expenseHandler.DeleteExpense)
// Filing.
r.Post("/events/{id}/file", fileHandler.FileEvent)
r.Post("/events/{id}/generate", fileHandler.GenerateReport)
r.Post("/events/{id}/send-link", fileHandler.SendDownloadLink)
// Filing (event-level).
r.Post("/months/{mid}/events/{eid}/file", fileHandler.FileEvent)
r.Post("/months/{mid}/events/{eid}/generate", fileHandler.GenerateReport)
r.Post("/months/{mid}/events/{eid}/send-link", fileHandler.SendDownloadLink)
// Storage (receipt images) — protected by auth + path traversal check.
r.With(authHandler.RequireAuth).Get("/storage/*", func(w http.ResponseWriter, r *http.Request) {

View file

@ -129,6 +129,23 @@ body {
-moz-osx-font-smoothing: grayscale;
}
/* Center app in a mobile-width shell on desktop */
.app-shell {
max-width: 480px;
margin: 0 auto;
min-height: 100vh;
min-height: 100dvh;
border-left: 1px solid var(--color-border);
border-right: 1px solid var(--color-border);
box-shadow: 0 0 40px rgba(0, 0, 0, 0.3);
}
@media (min-width: 481px) {
body {
background-color: #070d19;
}
}
img {
max-width: 100%;
height: auto;
@ -1902,3 +1919,13 @@ small, .text-sm {
.animate-stagger > *:nth-child(4) { animation-delay: 180ms; }
.animate-stagger > *:nth-child(5) { animation-delay: 240ms; }
.animate-stagger > *:nth-child(6) { animation-delay: 300ms; }
/* Month card — left border accent distinguishes from event cards */
.month-card {
transition: border-color var(--transition-base), background var(--transition-base);
}
.month-card:hover {
border-color: var(--color-primary);
background: rgba(16, 185, 129, 0.05);
}

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<rect width="64" height="64" rx="12" fill="#10b981"/>
<text x="32" y="42" font-family="Georgia, 'Times New Roman', serif" font-size="36" font-weight="bold" fill="#ffffff" text-anchor="middle" letter-spacing="-2">Rx</text>
<text x="32" y="42" font-family="Georgia, 'Times New Roman', serif" font-size="36" font-weight="bold" fill="#ffffff" text-anchor="middle" letter-spacing="-2">Nx</text>
</svg>

Before

Width:  |  Height:  |  Size: 317 B

After

Width:  |  Height:  |  Size: 317 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 48 KiB

View file

@ -5,7 +5,7 @@
* Strategy: Cache-first for shell assets, network-only for API
* ============================================================ */
const CACHE_NAME = 'nextexpense-v1';
const CACHE_NAME = 'nextexpense-v2';
// Shell assets to pre-cache on install
const SHELL_ASSETS = [

View file

@ -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?v=4">
<link rel="stylesheet" href="/static/css/style.css?v=5">
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
<body>
@ -25,103 +25,90 @@
<main class="main-content">
<div class="dashboard-header">
<h2>My Events</h2>
<h2>My Months</h2>
<button class="btn btn-primary btn-sm"
onclick="document.getElementById('create-event-form').classList.toggle('hidden')">
onclick="document.getElementById('create-form').classList.toggle('hidden')">
+ New
</button>
</div>
<div id="create-event-form" class="hidden" style="margin-bottom: 1rem;">
<div id="create-form" class="hidden" style="margin-bottom: 1rem;">
<div class="card" style="padding: 1rem;">
{{if .Editing}}
<h3 style="margin-bottom: 1rem;">Edit Event</h3>
<form hx-put="/events/{{.Event.ID}}" hx-target="body" hx-push-url="true">
{{else}}
<form hx-post="/events" hx-target="body" hx-push-url="true">
{{end}}
<div class="form-group">
<label class="form-label">{{if .Editing}}Event{{else}}Event Name{{end}}</label>
<input type="text" id="name" name="name" placeholder="Event name" value="{{.Event.Name}}" {{if not .Editing}}required{{end}}>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Claim Currency</label>
<input type="text" id="base_currency" name="base_currency" value="{{if .Editing}}{{.Event.BaseCurrency}}{{else}}USD{{end}}" placeholder="USD, EUR, KES…" required maxlength="3" style="text-transform: uppercase;">
<h3 style="margin-bottom: 1rem;">New Month</h3>
<form hx-post="/months" hx-target="body" hx-push-url="true">
<div class="form-row" style="gap: 1rem;">
<div style="flex: 2;">
<label class="form-label">Month</label>
<select name="month" required style="width: 100%; padding: 0.6rem; border: 1px solid var(--color-border); border-radius: 0.375rem; background: #1e293b; color: #f8fafc; font-size: 0.9rem;">
<option value="">Select</option>
<option value="January">January</option>
<option value="February">February</option>
<option value="March">March</option>
<option value="April">April</option>
<option value="May">May</option>
<option value="June">June</option>
<option value="July">July</option>
<option value="August">August</option>
<option value="September">September</option>
<option value="October">October</option>
<option value="November">November</option>
<option value="December">December</option>
</select>
</div>
<div style="flex: 1;">
<label class="form-label">Year</label>
<select name="year" required style="width: 100%; padding: 0.6rem; border: 1px solid var(--color-border); border-radius: 0.375rem; background: #1e293b; color: #f8fafc; font-size: 0.9rem;">
<option value="">Select</option>
<option value="2024">2024</option>
<option value="2025">2025</option>
<option value="2026" selected>2026</option>
<option value="2027">2027</option>
<option value="2028">2028</option>
<option value="2029">2029</option>
<option value="2030">2030</option>
</select>
</div>
</div>
<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;">
{{if .Editing}}Update the sample to recalculate the rate.{{else}}From a payment notification: receipt amount and what you were charged.{{end}}
</p>
<div class="form-row">
<div class="form-group">
<label class="form-label">Receipt Amount</label>
<input type="number" id="sample_receipt_amount" name="sample_receipt_amount" step="0.01" min="0.01" placeholder="e.g. 1000" required>
</div>
<div class="form-group" style="flex: 0 0 110px;">
<label class="form-label">Currency</label>
<input type="text" id="sample_receipt_currency" name="sample_receipt_currency" value="{{if .Editing}}{{.Event.BaseCurrency}}{{else}}KES{{end}}" placeholder="KES" required maxlength="3" style="text-transform: uppercase;">
</div>
</div>
<div class="form-group">
<label class="form-label">{{if .Editing}}Converted amount{{else}}What you were charged (claim currency){{end}}</label>
<input type="number" id="sample_claim_amount" name="sample_claim_amount" step="0.01" min="0.01" placeholder="e.g. 7.73" required>
</div>
</div>
<button type="submit" class="btn btn-primary btn-block">{{if .Editing}}Save Changes{{else}}Create Event{{end}}</button>
{{if .Editing}}
<a href="/dashboard" class="btn btn-secondary btn-block" style="display: block; text-align: center; margin-top: 0.5rem;"
hx-get="/dashboard" hx-target="body" hx-push-url="true">Cancel</a>
{{end}}
<button type="submit" class="btn btn-primary btn-block">Create Month</button>
</form>
</div>
</div>
<div id="event-list">
{{if .Events}}
<div id="month-list">
{{if .Months}}
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
{{range .Events}}
<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;">
{{range .Months}}
<div class="month-card" style="display: flex; align-items: center; justify-content: space-between; background: var(--color-card); border: 1px solid var(--color-border); border-left: 4px solid var(--color-primary); border-radius: 0.5rem; padding: 0.75rem 1rem;">
<div>
<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 style="font-weight: 500; color: var(--color-text);">{{.Month.Name}}</div>
<div style="font-size: 0.75rem; color: var(--color-text-muted);">{{.Month.CreatedAt}}</div>
</div>
{{if .Total}}
<div style="text-align: right; margin-right: 0.75rem;">
<div style="font-size: 0.7rem; color: var(--color-text-muted);">claim</div>
<div style="font-weight: 600; color: var(--color-primary); font-size: 0.95rem;">{{printf "%.2f" .Total}}</div>
</div>
{{end}}
<div style="display: flex; gap: 0.5rem;">
<a href="/events/{{.ID}}/expenses" class="btn btn-primary btn-sm"
hx-get="/events/{{.ID}}/expenses" hx-target="body" hx-push-url="true"
style="text-decoration: none;">
<button class="btn btn-primary btn-sm"
hx-get="/months/{{.Month.ID}}" hx-target="body" hx-push-url="true">
Open
</a>
{{if eq .Status "open"}}
</button>
<button class="btn btn-secondary btn-sm"
hx-get="/events/{{.ID}}/edit"
hx-target="#create-event-form"
hx-get="/months/{{.Month.ID}}/edit"
hx-target="#create-form"
hx-swap="innerHTML"
onclick="document.getElementById('create-event-form').classList.remove('hidden')"
onclick="document.getElementById('create-form').classList.remove('hidden')"
style="font-size: 0.75rem;">
Edit
</button>
{{end}}
{{if eq .Status "closed"}}
<button class="btn btn-secondary btn-sm"
hx-put="/events/{{.ID}}/reopen"
hx-target="body"
hx-push-url="true"
style="font-size: 0.75rem;">
Reopen
</button>
{{end}}
</div>
</div>
{{end}}
</div>
{{else}}
<div class="empty-state" style="text-align: center; padding: 3rem; color: #94a3b8;">
<p>No events yet. Create one to get started.</p>
<p>No months yet. Create one to get started.</p>
</div>
{{end}}
</div>

View file

@ -8,21 +8,30 @@
<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?v=4">
<link rel="stylesheet" href="/static/css/style.css?v=5">
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
<body>
<div class="app-shell">
<header class="app-header">
<a href="/dashboard" class="btn btn-secondary btn-sm"
hx-get="/dashboard" hx-target="body" hx-push-url="true">&larr; Events</a>
<a href="/months/{{.Month.ID}}" class="btn btn-secondary btn-sm"
hx-get="/months/{{.Month.ID}}" hx-target="body" hx-push-url="true">&larr; {{.Month.Name}}</a>
<h1 class="app-title" style="font-size: 1.1rem;">{{.Event.Name}}</h1>
<button class="btn btn-secondary btn-sm" style="font-size: 0.75rem;"
hx-post="/logout" hx-target="body" hx-push-url="true">Logout</button>
</header>
<main class="main-content">
<!-- Event Metadata (read-only — edit via dashboard) -->
<!-- Breadcrumb -->
<div style="font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: 1rem;">
<a href="/dashboard" style="color: var(--color-primary); text-decoration: none;"
hx-get="/dashboard" hx-target="body" hx-push-url="true">Dashboard</a>
&rsaquo; <a href="/months/{{.Month.ID}}" style="color: var(--color-primary); text-decoration: none;"
hx-get="/months/{{.Month.ID}}" hx-target="body" hx-push-url="true">{{.Month.Name}}</a>
&rsaquo; {{.Event.Name}}
</div>
<!-- Event Metadata -->
<div style="background: var(--color-card); border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 1rem; margin-bottom: 1rem;">
<div style="font-size: 0.75rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem;">Event Details</div>
<div class="form-row" style="margin:0;">
@ -123,65 +132,6 @@
<div class="spinner"></div>
<p>Analyzing receipt...</p>
</div>
<!-- Generate Report (only if open and has expenses) -->
{{if and (eq .Event.Status "open") .Expenses}}
<div style="margin-top: 2rem; border-top: 1px solid var(--color-border); padding-top: 1.5rem;">
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 1rem;">Generate Report</h3>
<div id="submit-error"></div>
<div id="report-section">
<form hx-post="/events/{{.Event.ID}}/generate" hx-target="#report-section" hx-swap="outerHTML"
hx-indicator="#generate-spinner">
<div class="form-row">
<div class="form-group">
<label>Format</label>
<div style="display: flex; gap: 1rem; margin-top: 0.25rem; color: var(--color-text-muted); font-size: 0.85rem;">
CSV + PDF (both included)
</div>
</div>
<div class="form-group">
<label>Total claim</label>
<div style="font-size: 1.25rem; font-weight: 700; color: #166534; margin-top: 0.25rem;">
{{printf "%.2f" .TotalClaim}} {{.Event.BaseCurrency}}
</div>
</div>
</div>
<div id="generate-spinner" class="htmx-indicator" style="text-align: center; padding: 0.5rem;">
<div class="spinner"></div>
<p style="font-size: 0.8rem; color: var(--color-text-muted); margin-top: 0.25rem;">Packaging report…</p>
</div>
<button type="submit" class="btn btn-primary btn-block" style="margin-top: 0.5rem;">
Generate Report
</button>
</form>
</div>
</div>
{{end}}
<!-- Close (only if open) -->
{{if eq .Event.Status "open"}}
<div style="margin-top: 1.5rem; text-align: center;">
<button class="btn btn-secondary" style="color: #fca5a5; border-color: #7f1d1d;"
hx-post="/events/{{.Event.ID}}/close"
hx-target="body"
hx-push-url="true"
onclick="return confirm('Close this event? You can reopen it later.')">
Close Event
</button>
</div>
{{end}}
<!-- Reopen (only if closed) -->
{{if eq .Event.Status "closed"}}
<div style="margin-top: 2rem; text-align: center;">
<button class="btn btn-secondary"
hx-put="/events/{{.Event.ID}}/reopen"
hx-target="body"
hx-push-url="true">
Reopen Event
</button>
</div>
{{end}}
</main>
</div>
</body>

View file

@ -18,7 +18,7 @@
<div class="login-page">
<div class="card login-card">
<div class="login-brand">
<div class="login-icon"><img src="/static/favicon.svg" width="48" height="48" alt="Rx"></div>
<div class="login-icon"><img src="/static/favicon.svg" width="48" height="48" alt="Nx"></div>
<h1>NextExpense</h1>
<p class="text-secondary">AI-Powered Expense Tracking</p>
</div>

159
templates/month_events.html Normal file
View file

@ -0,0 +1,159 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#10b981">
<title>{{.Month.Name}} - NextExpense</title>
<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?v=5">
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
<body>
<div class="app-shell">
<header class="app-header">
<a href="/dashboard" class="btn btn-secondary btn-sm"
hx-get="/dashboard" hx-target="body" hx-push-url="true">&larr; Dashboard</a>
<h1 class="app-title" style="font-size: 1.1rem;">{{.Month.Name}}</h1>
<button class="btn btn-secondary btn-sm" style="font-size: 0.75rem;"
hx-post="/logout" hx-target="body" hx-push-url="true">Logout</button>
</header>
<main class="main-content">
<!-- Breadcrumb -->
<div style="font-size: 0.75rem; color: var(--color-text-muted); margin-bottom: 1rem;">
<a href="/dashboard" style="color: var(--color-primary); text-decoration: none;"
hx-get="/dashboard" hx-target="body" hx-push-url="true">Dashboard</a>
&rsaquo; {{.Month.Name}}
</div>
<div class="dashboard-header">
<h2>Events</h2>
<button class="btn btn-primary btn-sm"
onclick="document.getElementById('create-event-form').classList.toggle('hidden')">
+ New
</button>
</div>
<div id="create-event-form" class="hidden" style="margin-bottom: 1rem;">
<div class="card" style="padding: 1rem;">
<h3 style="margin-bottom: 1rem;">New Event</h3>
<form hx-post="/months/{{.Month.ID}}/events" hx-target="body" hx-push-url="true">
<div class="form-group">
<label class="form-label">Event Name</label>
<input type="text" name="name" placeholder="Event name" required>
</div>
<div style="background: #064e3b; border: 1px solid #065f46; border-radius: 0.5rem; padding: 1rem; margin-bottom: 0.5rem;">
<div style="font-size: 0.8rem; font-weight: 600; color: #6ee7b7; margin-bottom: 0.75rem;">Conversion Rate</div>
<div class="form-row" style="gap: 1rem;">
<div style="flex: 1;">
<label class="form-label">Claim Amount</label>
<input type="number" name="sample_claim_amount" step="0.01" min="0.01" placeholder="e.g. 7.73" required>
</div>
<div style="flex: 0 0 80px;">
<label class="form-label">Currency</label>
<input type="text" name="base_currency" value="USD" required maxlength="3" style="text-transform: uppercase;">
</div>
</div>
<div class="form-row" style="gap: 1rem; margin-top: 0.5rem;">
<div style="flex: 1;">
<label class="form-label">Local Amount</label>
<input type="number" name="sample_receipt_amount" step="0.01" min="0.01" placeholder="e.g. 1000" required>
</div>
<div style="flex: 0 0 80px;">
<label class="form-label">Currency</label>
<input type="text" name="sample_receipt_currency" value="KES" required maxlength="3" style="text-transform: uppercase;">
</div>
</div>
<div style="font-size: 0.7rem; color: var(--color-text-muted); margin-top: 0.5rem;">
Rate = Claim / Local
</div>
</div>
<button type="submit" class="btn btn-primary btn-block">Create Event</button>
</form>
</div>
</div>
<div id="event-list">
{{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: var(--color-card); border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 0.75rem 1rem;">
<div>
<div style="font-weight: 500; color: var(--color-text);">{{.Event.Name}}</div>
<div style="font-size: 0.75rem; color: var(--color-text-muted);">
{{.Event.BaseCurrency}} · {{printf "%.6f" .Event.ExchangeRate}} rate
</div>
</div>
{{if .Total}}
<div style="text-align: right; margin-right: 0.75rem;">
<div style="font-size: 0.7rem; color: var(--color-text-muted);">claim</div>
<div style="font-weight: 600; color: var(--color-primary); font-size: 0.95rem;">{{printf "%.2f" .Total}} {{.Currency}}</div>
</div>
{{end}}
<div style="display: flex; gap: 0.5rem;">
<button class="btn btn-primary btn-sm"
hx-get="/months/{{$.Month.ID}}/events/{{.Event.ID}}/expenses" hx-target="body" hx-push-url="true">
Open
</button>
{{if eq .Event.Status "open"}}
<button class="btn btn-secondary btn-sm"
hx-get="/months/{{$.Month.ID}}/events/{{.Event.ID}}/edit"
hx-target="#create-event-form"
hx-swap="innerHTML"
onclick="document.getElementById('create-event-form').classList.remove('hidden')"
style="font-size: 0.75rem;">
Edit
</button>
{{end}}
{{if eq .Event.Status "closed"}}
<button class="btn btn-secondary btn-sm"
hx-put="/months/{{$.Month.ID}}/events/{{.Event.ID}}/reopen"
hx-target="body"
hx-push-url="true"
style="font-size: 0.75rem;">
Reopen
</button>
{{end}}
</div>
</div>
{{end}}
</div>
{{else}}
<div class="empty-state" style="text-align: center; padding: 3rem; color: #94a3b8;">
<p>No events yet. Create one to get started.</p>
</div>
{{end}}
</div>
<!-- Monthly Report Generation -->
{{if .Events}}
<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;">Monthly Report</h3>
<div id="submit-error"></div>
<div id="report-section">
<form hx-post="/months/{{.Month.ID}}/generate" hx-target="#report-section" hx-swap="outerHTML"
hx-indicator="#generate-spinner">
<div class="form-group">
<label>Generate a complete report with all event expenses and receipt images.</label>
<div style="font-size: 0.85rem; color: var(--color-text-muted); margin-bottom: 0.5rem;">
Includes {{len .Events}} event(s) with all receipts.
</div>
</div>
<div id="generate-spinner" class="htmx-indicator" style="text-align: center; padding: 0.5rem;">
<div class="spinner"></div>
<p style="font-size: 0.8rem; color: var(--color-text-muted); margin-top: 0.25rem;">Packaging monthly report…</p>
</div>
<button type="submit" class="btn btn-primary btn-block" style="margin-top: 0.5rem;">
Generate Monthly Report
</button>
</form>
</div>
</div>
{{end}}
</main>
</div>
</body>
</html>

View file

@ -49,11 +49,22 @@
<label for="category">Category *</label>
<select id="category" name="category" required>
<option value="">Select</option>
<option value="Food" {{if eq .Category "Food"}}selected{{end}}>Food</option>
<option value="Travel" {{if eq .Category "Travel"}}selected{{end}}>Travel</option>
<option value="Lodging" {{if eq .Category "Lodging"}}selected{{end}}>Lodging</option>
<option value="Software" {{if eq .Category "Software"}}selected{{end}}>Software</option>
<option value="Other" {{if eq .Category "Other"}}selected{{end}}>Other</option>
<option value="Airfare" {{if eq .Category "Airfare"}}selected{{end}}>Airfare</option>
<option value="Accommodation" {{if eq .Category "Accommodation"}}selected{{end}}>Accommodation</option>
<option value="Meals Self" {{if eq .Category "Meals Self"}}selected{{end}}>Meals Self</option>
<option value="Staff Meal" {{if eq .Category "Staff Meal"}}selected{{end}}>Staff Meal</option>
<option value="Client Meal" {{if eq .Category "Client Meal"}}selected{{end}}>Client Meal</option>
<option value="Travel - Taxi" {{if eq .Category "Travel - Taxi"}}selected{{end}}>Travel - Taxi</option>
<option value="Travel - Phone" {{if eq .Category "Travel - Phone"}}selected{{end}}>Travel - Phone</option>
<option value="Misc Travel" {{if eq .Category "Misc Travel"}}selected{{end}}>Misc Travel</option>
<option value="Mobile / Office Phone" {{if eq .Category "Mobile / Office Phone"}}selected{{end}}>Mobile / Office Phone</option>
<option value="Office Supplies" {{if eq .Category "Office Supplies"}}selected{{end}}>Office Supplies</option>
<option value="Postage / Couriers" {{if eq .Category "Postage / Couriers"}}selected{{end}}>Postage / Couriers</option>
<option value="Other Expenses" {{if eq .Category "Other Expenses"}}selected{{end}}>Other Expenses</option>
<option value="Hotel" {{if eq .Category "Hotel"}}selected{{end}}>Hotel</option>
<option value="Per Diem" {{if eq .Category "Per Diem"}}selected{{end}}>Per Diem</option>
<option value="Visa Fees" {{if eq .Category "Visa Fees"}}selected{{end}}>Visa Fees</option>
<option value="Connectivity (internet connections)" {{if eq .Category "Connectivity (internet connections)"}}selected{{end}}>Connectivity (internet connections)</option>
</select>
</div>
<div class="form-group">
@ -63,8 +74,8 @@
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description" placeholder="Optional notes...">{{.Description}}</textarea>
<label for="description">Description *</label>
<textarea id="description" name="description" placeholder="Required notes..." required></textarea>
</div>
{{if .BaseCurrency}}