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)
|
|
@ -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}},
|
||||
},
|
||||
}},
|
||||
|
|
|
|||
|
|
@ -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}},
|
||||
},
|
||||
}},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 & %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
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 48 KiB |
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">← 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">← {{.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>
|
||||
› <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>
|
||||
› {{.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>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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">← 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>
|
||||
› {{.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>
|
||||
|
|
@ -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}}
|
||||
|
|
|
|||